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
+146
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user