Capture certificates from the actual connection authority

Keep explicit URL defaults aligned with the API client and join IPv6 socket addresses correctly. Preserve the legacy bare-host port and credential-free capture without weakening any established pin or verification choice. Cover endpoint parsing, actual IPv4/IPv6 leaves and invalid-authority rejection.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-11 06:09:15 +01:00
parent 4ed767c539
commit 1d7a0023c1
4 changed files with 229 additions and 14 deletions
@@ -18,6 +18,31 @@
## Purpose
### Certificate capture uses the connection's socket authority
`tlsutil.FetchFingerprint` selects an explicit HTTP(S) URL's host and port,
including 443 for a portless HTTPS URL and 80 for a portless HTTP URL. Bare
hostnames and IP addresses retain the existing Proxmox default, 8006. Explicit
ports and IPv6 zones are preserved; IPv6 socket addresses are bracketed rather
than formed by appending a port to a bare literal. Proxy base paths never
change the socket authority. Invalid authorities, userinfo, query/fragment and
invalid ports fail before dialing, using the existing base-URL validation.
Capture remains only a bounded, credential-free TLS handshake with a parseable
peer certificate; it sends no HTTP request, grants no trust and changes no
saved pin, verification choice, CA policy or authentication. A plaintext HTTP
service cannot become trusted through this TLS-only capture. Declared-member
first-use and established-pin recovery rules remain as below.
`TestFetchFingerprintEndpointSelection` discriminates the previous wrong-port
and bare-IPv6 failures through the public helper. `TestFingerprintTargetAuthority`
and `TestFetchFingerprintRejectsInvalidAuthorityBeforeDial` check exact parsing;
`TestFetchFingerprintConnectedAuthority` and
`TestFetchFingerprintLegacyDefaultConnected` check actual distinct loopback
leaves over IPv4/IPv6 and unchanged bare-host defaults, without HTTP. Existing
cluster trust and provisioning controls remain required. This is source proof,
not installed connection recovery or completion of #2140.
### TLS pin failures remain local typed trust evidence
`tlsutil.FingerprintVerifier` preserves its existing certificate digest checks,
+63 -14
View File
@@ -9,9 +9,13 @@ import (
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strconv"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/securityutil"
)
const minimumTLSVersion = tls.VersionTLS12
@@ -53,21 +57,13 @@ func UnverifiedPeerCertificateCaptureTLSConfig() *tls.Config {
// FetchFingerprint connects to a host and returns the SHA256 fingerprint of its TLS certificate.
// This is used for TOFU (Trust On First Use) when discovering cluster peers.
// The host should be in the format "hostname:port" or "https://hostname:port".
// Explicit URLs retain their HTTP(S) authority, including the scheme's default
// port. A bare hostname or IP retains the legacy Proxmox default port, 8006.
// Capture is always a TLS handshake, not an HTTP request or a grant of trust.
func FetchFingerprint(host string) (string, error) {
// Normalize the host to just host:port format
targetHost := host
if strings.HasPrefix(host, "https://") || strings.HasPrefix(host, "http://") {
parsed, err := url.Parse(host)
if err != nil {
return "", fmt.Errorf("failed to parse host URL: %w", err)
}
targetHost = parsed.Host
}
// Ensure port is present (default to 8006 for Proxmox)
if _, _, err := net.SplitHostPort(targetHost); err != nil {
targetHost = targetHost + ":8006"
targetHost, err := fingerprintTarget(host)
if err != nil {
return "", fmt.Errorf("failed to parse host URL: %w", err)
}
// Create a TLS connection that explicitly accepts parseable peer certificates
@@ -100,6 +96,59 @@ func FetchFingerprint(host string) (string, error) {
return hex.EncodeToString(fingerprint[:]), nil
}
func fingerprintTarget(raw string) (string, error) {
raw = strings.TrimSpace(raw)
explicitURL := strings.Contains(raw, "://")
if !explicitURL {
// A port on an IPv6 literal must be bracketed. An entire bare IPv6
// address (including a zone) is a host, never a guessed host:port pair.
host, port, splitErr := net.SplitHostPort(raw)
if splitErr != nil {
host = strings.TrimSuffix(strings.TrimPrefix(raw, "["), "]")
}
if address, err := netip.ParseAddr(host); err == nil && address.Is6() {
authority := "[" + address.String() + "]"
if splitErr == nil {
authority = net.JoinHostPort(address.String(), port)
}
// URL.String escapes a zone's percent separator exactly once.
raw = (&url.URL{Scheme: "https", Host: authority}).String()
}
}
u, err := securityutil.NormalizeHTTPBaseURL(raw, "https")
if err != nil {
return "", err
}
if !explicitURL && u.Path != "" {
return "", fmt.Errorf("a base path requires an explicit HTTP(S) URL")
}
if strings.HasPrefix(u.Host, "[") {
if address, err := netip.ParseAddr(u.Hostname()); err != nil || !address.Is6() {
return "", fmt.Errorf("brackets require a valid IPv6 address")
}
} else if strings.Count(u.Host, ":") > 1 {
return "", fmt.Errorf("an IPv6 URL authority must be bracketed")
}
port := u.Port()
if port == "" {
if strings.HasSuffix(u.Host, ":") {
return "", fmt.Errorf("port is empty")
}
port = "8006"
if explicitURL {
port = "443"
if u.Scheme == "http" {
port = "80"
}
}
}
number, err := strconv.ParseUint(port, 10, 16)
if err != nil || number == 0 {
return "", fmt.Errorf("port must be between 1 and 65535")
}
return net.JoinHostPort(u.Hostname(), strconv.FormatUint(number, 10)), nil
}
// FingerprintVerifier creates a custom TLS config that verifies server certificate fingerprint
func FingerprintVerifier(fingerprint string) *tls.Config {
// Normalize fingerprint (remove colons, convert to lowercase)
+92
View File
@@ -0,0 +1,92 @@
package tlsutil
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"math/big"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// Each fixture owns a distinct parseable leaf, local socket and generated key.
// It serves no provider credentials and records whether capture sent any HTTP.
func fingerprintAuthorityFixture(t *testing.T, network, address string) (*httptest.Server, string, *atomic.Int32) {
t.Helper()
listener, err := net.Listen(network, address)
if err != nil {
t.Fatal(err) // IPv6/default-port support is a requirement, not a skipped proof.
}
t.Cleanup(func() { listener.Close() })
public, key, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
leaf, err := x509.CreateCertificate(rand.Reader, template, template, public, key)
if err != nil {
t.Fatal(err)
}
requests := &atomic.Int32{}
server := httptest.NewUnstartedServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests.Add(1) }))
server.Listener.Close()
server.Listener = listener
server.TLS = &tls.Config{MinVersion: minimumTLSVersion, Certificates: []tls.Certificate{{Certificate: [][]byte{leaf}, PrivateKey: key}}}
server.StartTLS()
t.Cleanup(server.Close)
digest := sha256.Sum256(leaf)
return server, hex.EncodeToString(digest[:]), requests
}
func TestFetchFingerprintConnectedAuthority(t *testing.T) {
for _, tc := range []struct {
name, network, address string
}{
{"ipv4", "tcp4", "127.0.0.1:0"},
{"ipv6", "tcp6", "[::1]:0"},
} {
t.Run(tc.name, func(t *testing.T) {
server, expected, requests := fingerprintAuthorityFixture(t, tc.network, tc.address)
for _, input := range []string{server.URL, server.URL + "/proxy/base/", strings.TrimPrefix(server.URL, "https://"), " " + strings.Replace(server.URL, "https://", "HTTPS://", 1) + " "} {
fingerprint, err := FetchFingerprint(input)
if err != nil || fingerprint != expected {
t.Fatalf("capture at %q = %q, %v; want the owned leaf", input, fingerprint, err)
}
}
if requests.Load() != 0 {
t.Fatal("fingerprint capture sent HTTP rather than just a TLS handshake")
}
})
}
}
func TestFetchFingerprintLegacyDefaultConnected(t *testing.T) {
for _, tc := range []struct {
name, network, address string
inputs []string
}{
{"ipv4", "tcp4", "127.0.0.1:8006", []string{"127.0.0.1", " 127.0.0.1 "}},
{"ipv6", "tcp6", "[::1]:8006", []string{"::1", "[::1]"}},
} {
t.Run(tc.name, func(t *testing.T) {
_, expected, requests := fingerprintAuthorityFixture(t, tc.network, tc.address)
for _, input := range tc.inputs {
fingerprint, err := FetchFingerprint(input)
if err != nil || fingerprint != expected {
t.Fatalf("bare-host capture at %q = %q, %v; want the owned legacy-port leaf", input, fingerprint, err)
}
}
if requests.Load() != 0 {
t.Fatal("bare-host capture sent HTTP")
}
})
}
}
+49
View File
@@ -401,6 +401,7 @@ func TestFetchFingerprintEndpointSelection(t *testing.T) {
name, input, address string
}{
{"explicit-https-default", "https://127.0.0.1", "127.0.0.1:443"},
{"explicit-http-default", "http://127.0.0.1", "127.0.0.1:80"},
{"bare-ipv6-default", "::1", "[::1]:8006"},
} {
t.Run(tc.name, func(t *testing.T) {
@@ -411,3 +412,51 @@ func TestFetchFingerprintEndpointSelection(t *testing.T) {
})
}
}
func TestFingerprintTargetAuthority(t *testing.T) {
for _, tc := range []struct {
input, address string
}{
{"pve.example", "pve.example:8006"},
{"pve.example:8007", "pve.example:8007"},
{"127.0.0.1", "127.0.0.1:8006"},
{"[::1]", "[::1]:8006"},
{"2001:db8::1", "[2001:db8::1]:8006"},
{"fe80::1%eth0", "[fe80::1%eth0]:8006"},
{"[::1]:8443", "[::1]:8443"},
{"https://pve.example", "pve.example:443"},
{"HTTPS://pve.example/proxy/", "pve.example:443"},
{"http://pve.example", "pve.example:80"},
{"https://pve.example:08006/proxy/", "pve.example:8006"},
{"https://[2001:db8::1]/proxy/", "[2001:db8::1]:443"},
{"https://[fe80::1%25eth0]:8443/proxy/", "[fe80::1%eth0]:8443"},
{" https://pve.example:65535/ ", "pve.example:65535"},
} {
t.Run(tc.input, func(t *testing.T) {
address, err := fingerprintTarget(tc.input)
if err != nil || address != tc.address {
t.Fatalf("capture authority = %q, %v; want %q", address, err, tc.address)
}
})
}
}
func TestFetchFingerprintRejectsInvalidAuthorityBeforeDial(t *testing.T) {
for _, input := range []string{
"", "https://", "ftp://127.0.0.1:8006", "https://[::1", "https://::1",
"https://127.0.0.1:", "https://127.0.0.1:0", "https://127.0.0.1:65536",
"https://127.0.0.1:abc", "https://[not-an-ip]:8006", "127.0.0.1/proxy",
"https://fixture-user:fixture-password@127.0.0.1:8006",
"https://127.0.0.1:8006/?query=fixture", "https://127.0.0.1:8006/#fragment",
} {
t.Run(input, func(t *testing.T) {
fingerprint, err := FetchFingerprint(input)
if fingerprint != "" || err == nil || !strings.HasPrefix(err.Error(), "failed to parse host URL:") {
t.Fatalf("invalid authority reached dial or capture: fingerprint=%q error=%v", fingerprint, err)
}
if strings.Contains(err.Error(), "fixture-password") {
t.Fatal("invalid authority exposed userinfo in its error")
}
})
}
}