Files
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

126 lines
3.2 KiB
Go

package main
import (
"crypto/tls"
"crypto/x509"
"io"
"net"
"testing"
)
func TestCertMatchesFingerprint(t *testing.T) {
certDER, _, err := generateHubCert()
if err != nil {
t.Fatal(err)
}
fp := certFingerprint(certDER)
if !certMatchesFingerprint(certDER, fp) {
t.Fatal("should match its own fingerprint")
}
if !certMatchesFingerprint(certDER, " "+fp+" ") {
t.Fatal("should tolerate surrounding whitespace")
}
if certMatchesFingerprint(certDER, "00"+fp[2:]) {
t.Fatal("should not match a different fingerprint")
}
}
func TestValidFingerprint(t *testing.T) {
certDER, _, err := generateHubCert()
if err != nil {
t.Fatal(err)
}
fp := certFingerprint(certDER)
cases := []struct {
s string
ok bool
}{
{fp, true},
{"", false},
{"not-hex-at-all-not-hex-at-all-not-hex-at-all-not-hex-at-all-00", false},
{fp[:len(fp)-1], false}, // one char short
{fp + "0", false}, // one char long
}
for _, c := range cases {
if got := validFingerprint(c.s); got != c.ok {
t.Errorf("validFingerprint(%q) = %v, wanted %v", c.s, got, c.ok)
}
}
}
func TestPinnedClientTLSConfig_RejectsBadFingerprint(t *testing.T) {
if _, err := pinnedClientTLSConfig("too short"); err == nil {
t.Fatal("expected an error for a malformed fingerprint")
}
}
func testCert(t *testing.T) (cert tls.Certificate, fingerprint string) {
t.Helper()
certDER, keyDER, err := generateHubCert()
if err != nil {
t.Fatal(err)
}
priv, err := x509.ParsePKCS8PrivateKey(keyDER)
if err != nil {
t.Fatal(err)
}
return tls.Certificate{Certificate: [][]byte{certDER}, PrivateKey: priv}, certFingerprint(certDER)
}
// listenTLS starts a TLS server on a random localhost port with the given
// certificate and returns its address. Connections are drained (not
// closed outright) so the TLS handshake — which crypto/tls only performs
// lazily, on first Read/Write — actually gets a chance to complete.
func listenTLS(t *testing.T, cert tls.Certificate) string {
t.Helper()
ln, err := tls.Listen("tcp", "127.0.0.1:0", hubServerTLSConfig(cert))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
io.Copy(io.Discard, c)
}(c)
}
}()
return ln.Addr().String()
}
func TestPinnedClientTLSConfig_HandshakeEndToEnd(t *testing.T) {
certA, fpA := testCert(t)
_, fpB := testCert(t) // a different cert, never presented by the server
addr := listenTLS(t, certA)
// Correct fingerprint: handshake succeeds.
cfg, err := pinnedClientTLSConfig(fpA)
if err != nil {
t.Fatal(err)
}
conn, err := tls.Dial("tcp", addr, cfg)
if err != nil {
t.Fatalf("expected the handshake to succeed with the right fingerprint: %v", err)
}
conn.Close()
// Pinned to a fingerprint the server never presents: handshake must
// fail, even though certB (fpB) is a perfectly valid certificate on
// its own — it's just not the one at this address.
cfgWrong, err := pinnedClientTLSConfig(fpB)
if err != nil {
t.Fatal(err)
}
if _, err := tls.Dial("tcp", addr, cfgWrong); err == nil {
t.Fatal("expected the handshake to fail: server presented a cert that doesn't match the pinned fingerprint")
}
}