diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 268ff47963..fc31225841 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -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, diff --git a/pkg/tlsutil/fingerprint.go b/pkg/tlsutil/fingerprint.go index 2667b89156..d587d73b99 100644 --- a/pkg/tlsutil/fingerprint.go +++ b/pkg/tlsutil/fingerprint.go @@ -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) diff --git a/pkg/tlsutil/fingerprint_endpoint_test.go b/pkg/tlsutil/fingerprint_endpoint_test.go new file mode 100644 index 0000000000..79b0ab9d61 --- /dev/null +++ b/pkg/tlsutil/fingerprint_endpoint_test.go @@ -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") + } + }) + } +} diff --git a/pkg/tlsutil/tlsutil_test.go b/pkg/tlsutil/tlsutil_test.go index b773f3d408..da4dbf4641 100644 --- a/pkg/tlsutil/tlsutil_test.go +++ b/pkg/tlsutil/tlsutil_test.go @@ -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") + } + }) + } +}