mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user