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).
147 lines
4.7 KiB
Go
147 lines
4.7 KiB
Go
// 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
|
|
}
|