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:
emmatherock committed 2026-09-17 21:51:22 -03:00
1 parent e1f7e6f529
commit e9fe10f0f7
12 files changed
+1198 -465

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
// tlscert.go: the hub's self-signed TLS certificate for the peer link.
//
// There's no certificate authority that can issue for a Tailscale/LAN IP,
// so this isn't meant to be CA-trusted — it's meant to be PINNED (see
// pin.go). The hub generates one ed25519 keypair + cert on first run and
// reuses it after that; the peer verifies the connection by comparing the
// certificate's fingerprint against the one from the invite code, not by
// checking who signed it.
package main
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"fmt"
"math/big"
"os"
"path/filepath"
"time"
)
func hubCertPath() string {
dir, ok := exeDir()
if !ok {
return "hub-cert.pem"
}
return filepath.Join(dir, "hub-cert.pem")
}
func hubKeyPath() string {
dir, ok := exeDir()
if !ok {
return "hub-key.pem"
}
return filepath.Join(dir, "hub-key.pem")
}
// certFingerprint is the hex SHA-256 of the certificate's raw DER bytes —
// what gets pinned on the peer side.
func certFingerprint(der []byte) string {
sum := sha256.Sum256(der)
return hex.EncodeToString(sum[:])
}
// generateHubCert creates a fresh ed25519 keypair and a self-signed
// certificate around it. No SANs: nothing ever validates a hostname
// against this cert, pinning replaces that entirely.
func generateHubCert() (certDER, keyDER []byte, err error) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("couldn't generate a keypair: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, fmt.Errorf("couldn't generate a serial number: %w", err)
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "deathwatch-hub"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
certDER, err = x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, priv)
if err != nil {
return nil, nil, fmt.Errorf("couldn't create the certificate: %w", err)
}
keyDER, err = x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return nil, nil, fmt.Errorf("couldn't encode the private key: %w", err)
}
return certDER, keyDER, nil
}
func writePEM(path, blockType string, der []byte) error {
return os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: blockType, Bytes: der}), 0600)
}
func readPEM(path, wantType string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil || block.Type != wantType {
return nil, fmt.Errorf("%s doesn't look like a valid %s", path, wantType)
}
return block.Bytes, nil
}
// loadOrCreateHubCert loads the hub's certificate and key if both are
// already on disk, or generates and persists a new pair if neither is.
// A partial pair (one file present, one missing, or one that doesn't
// parse) is treated as a broken installation, not something to silently
// regenerate around: delete both and restart to get a fresh pair.
func loadOrCreateHubCert() (cert tls.Certificate, fingerprint string, generated bool, err error) {
certPath, keyPath := hubCertPath(), hubKeyPath()
certDER, certErr := readPEM(certPath, "CERTIFICATE")
keyDER, keyErr := readPEM(keyPath, "PRIVATE KEY")
switch {
case certErr == nil && keyErr == nil:
// both present, fall through to build the tls.Certificate below
case os.IsNotExist(certErr) && os.IsNotExist(keyErr):
certDER, keyDER, err = generateHubCert()
if err != nil {
return tls.Certificate{}, "", false, err
}
if err := writePEM(certPath, "CERTIFICATE", certDER); err != nil {
return tls.Certificate{}, "", false, fmt.Errorf("couldn't save %s: %w", certPath, err)
}
if err := writePEM(keyPath, "PRIVATE KEY", keyDER); err != nil {
return tls.Certificate{}, "", false, fmt.Errorf("couldn't save %s: %w", keyPath, err)
}
generated = true
default:
return tls.Certificate{}, "", false, fmt.Errorf(
"%s and %s should both exist or both be missing (got cert: %v, key: %v) — delete both to generate a fresh pair",
certPath, keyPath, certErr, keyErr)
}
priv, err := x509.ParsePKCS8PrivateKey(keyDER)
if err != nil {
return tls.Certificate{}, "", false, fmt.Errorf("%s doesn't parse as a private key: %w", keyPath, err)
}
leaf, err := x509.ParseCertificate(certDER)
if err != nil {
return tls.Certificate{}, "", false, fmt.Errorf("%s doesn't parse as a certificate: %w", certPath, err)
}
cert = tls.Certificate{
Certificate: [][]byte{certDER},
PrivateKey: priv,
Leaf: leaf,
}
return cert, certFingerprint(certDER), generated, nil
}