The co-op link was authenticated (HMAC token, never sent over the wire) but not encrypted. The hub now generates a self-signed cert on first run; the peer pins its exact fingerprint (no CA involved — there isn't one for a Tailscale/LAN address), delivered via a single invite-code paste that also carries the token, replacing today's separate IP+token copy. The peer link moves to its own TLS-only port (peer_listen, 47823) so the plain overlay/panel port (47822, OBS-facing) never needs to be exposed alongside it — today, opening the overlay port to a remote partner also exposes /deaths and the panel to anyone. Mandatory pinning, no insecure fallback: a half-configured peer (some but not all of hub/token/fingerprint, or a broken invite) fails loudly at startup rather than connecting unpinned. An unconfigured peer still runs fine as a local-only overlay, same as before. New: tlscert.go (cert generation/persistence), pin.go (fingerprint pinning), invite.go (invite-code encode/decode, host auto-detection), each with tests. main.go/config.go/duo.go/ws.go carry the wiring for this — the dual listener, new config keys, and the TLS-aware WebSocket dial — and were rewritten in English in the process, per the project's new English-only code convention (see CLAUDE.md).
66 lines
2.3 KiB
Go
66 lines
2.3 KiB
Go
// pin.go: certificate PINNING for the peer link, as opposed to CA trust.
|
|
//
|
|
// There's no certificate authority that can issue for a Tailscale/LAN IP,
|
|
// so the peer doesn't ask "was this signed by someone I trust?" — it asks
|
|
// "is this the exact certificate my invite code told me to expect?". That
|
|
// makes InsecureSkipVerify safe here: it turns off the check that doesn't
|
|
// apply (hostname/CA validation) and VerifyPeerCertificate replaces it
|
|
// with the one that does.
|
|
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
func certMatchesFingerprint(rawCert []byte, wantHex string) bool {
|
|
sum := sha256.Sum256(rawCert)
|
|
return hex.EncodeToString(sum[:]) == strings.ToLower(strings.TrimSpace(wantHex))
|
|
}
|
|
|
|
func validFingerprint(s string) bool {
|
|
s = strings.TrimSpace(s)
|
|
if len(s) != sha256.Size*2 {
|
|
return false
|
|
}
|
|
_, err := hex.DecodeString(s)
|
|
return err == nil
|
|
}
|
|
|
|
// pinnedClientTLSConfig builds a client TLS config that accepts exactly
|
|
// one certificate: the one whose SHA-256 fingerprint matches. Nothing
|
|
// else about the certificate (hostname, expiry chain, issuer) is checked.
|
|
func pinnedClientTLSConfig(fingerprint string) (*tls.Config, error) {
|
|
if !validFingerprint(fingerprint) {
|
|
return nil, fmt.Errorf("fingerprint %q doesn't look like a SHA-256 hex digest (want %d hex chars)", fingerprint, sha256.Size*2)
|
|
}
|
|
want := strings.ToLower(strings.TrimSpace(fingerprint))
|
|
return &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
MinVersion: tls.VersionTLS13,
|
|
VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
|
|
if len(rawCerts) == 0 {
|
|
return fmt.Errorf("the server didn't present a certificate")
|
|
}
|
|
if !certMatchesFingerprint(rawCerts[0], want) {
|
|
return fmt.Errorf("the server's certificate doesn't match the pinned fingerprint — wrong hub, or its certificate was regenerated (you'd need a fresh invite code)")
|
|
}
|
|
return nil
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// hubServerTLSConfig is the hub side: just present the certificate, no
|
|
// client-certificate verification (the peer proves itself at the
|
|
// application layer with the HMAC challenge/response, see auth.go).
|
|
func hubServerTLSConfig(cert tls.Certificate) *tls.Config {
|
|
return &tls.Config{
|
|
Certificates: []tls.Certificate{cert},
|
|
MinVersion: tls.VersionTLS13,
|
|
}
|
|
}
|