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).
This commit is contained in:
1 parent
e1f7e6f529
commit
e9fe10f0f7
12 files changed
+1198
-465
No files matched your search
+125
@@ -0,0 +1,125 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user