Files
deathwatch/pin.go
T
emmatherock e9fe10f0f7 Add TLS + certificate pinning for the peer link
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).
2026-09-17 21:51:22 -03:00

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,
}
}