// Package wgtunnel manages the host's WireGuard tunnel to the offsite endpoint (S3, doc 06 §3.3 // steps 1+5): pure-Go keygen, one-shot pubkey registration with the hub, consumption of the // hub-served `wireguard` desired-state block, and the `wg-quick@wg-felhom` host service through // the narrow-sudoers runner — the lanresolver/dnsmasq shape. // // SECRETS DISCIPLINE (elevated — the S1 session-log incident): the WG private key lives in // exactly two places on the box (the StateDir key file and the installed conf, both 0600) plus // the R-wrapped escrow blob. It is NEVER put on an argv, in a log/error, or read back via // `wg show dump` (whose interface line carries the private key — the ONLY wg read this // package performs is `wg show wg-felhom latest-handshakes`). package wgtunnel import ( "crypto/rand" "encoding/base64" "fmt" "os" "path/filepath" "golang.org/x/crypto/curve25519" ) // keyFileName is the private-key file under /wg/. 0600, agent-owned. Never overwritten: // a key file that exists may be the identity a customer's escrow blob carries. const keyFileName = "private.key" // clampPrivateKey applies the curve25519 clamping WireGuard requires. NOTE: x/crypto's X25519 // clamps the scalar internally (RFC 7748), so DERIVATION is clamp-invariant — what the explicit // clamp guarantees is the STORED key file: the persisted bytes must be in canonical clamped form // so an external `wg pubkey < private.key` agrees with the pubkey the agent registered (covered // by TestEnsureKey_StoredKeyIsClamped). func clampPrivateKey(b []byte) { b[0] &= 248 b[31] &= 127 b[31] |= 64 } // derivePublic derives the WG public key (base64) from a 32-byte private key. func derivePublic(priv []byte) (string, error) { pub, err := curve25519.X25519(priv, curve25519.Basepoint) if err != nil { return "", fmt.Errorf("wgtunnel: derive public key: %w", err) } return base64.StdEncoding.EncodeToString(pub), nil } // EnsureKey creates the keypair once (0600 file in a 0700 dir) or loads the existing one, and // returns the PUBLIC key only — the private key never leaves the package except via KeyFilePath // (rendering) and the escrow join, which read the file themselves. A corrupt key file is an // ERROR, never an overwrite (it may be an escrowed identity). func EnsureKey(stateDir string) (pub string, created bool, err error) { dir := filepath.Join(stateDir, "wg") path := filepath.Join(dir, keyFileName) if raw, rerr := os.ReadFile(path); rerr == nil { priv, derr := decodeKey(raw) if derr != nil { return "", false, fmt.Errorf("wgtunnel: existing key file %s is corrupt (%v) — refusing to overwrite; operator must resolve", path, derr) } pub, err = derivePublic(priv) return pub, false, err } else if !os.IsNotExist(rerr) { return "", false, fmt.Errorf("wgtunnel: reading key file: %w", rerr) } if err := os.MkdirAll(dir, 0o700); err != nil { return "", false, fmt.Errorf("wgtunnel: creating %s: %w", dir, err) } priv := make([]byte, 32) if _, err := rand.Read(priv); err != nil { return "", false, fmt.Errorf("wgtunnel: entropy: %w", err) } clampPrivateKey(priv) enc := base64.StdEncoding.EncodeToString(priv) + "\n" if err := os.WriteFile(path, []byte(enc), 0o600); err != nil { return "", false, fmt.Errorf("wgtunnel: writing key file: %w", err) } pub, err = derivePublic(priv) if err != nil { return "", false, err } return pub, true, nil } // KeyFilePath returns the private-key file location for a state dir (escrow join + conf render // read it directly; the value of the file is never returned by this package's API). func KeyFilePath(stateDir string) string { return filepath.Join(stateDir, "wg", keyFileName) } // InstallRecoveredKey writes an escrow-recovered WG private key (base64 of 32 bytes) to the key // file — the S5 host-loss DR step so the tunnel re-establishes with the SAME identity/pubkey (→ the // same hub `/32`), no fresh keygen. **CREATE-ONLY:** it REFUSES if a key file already exists (a // present key may be a live identity — the never-overwrite rule EnsureKey enforces too). The value // is validated (32-byte base64) and stored canonical (re-encoded), matching EnsureKey's on-disk // form; it is NEVER logged (caller logs the field NAME only). No-op guard: an empty privB64 is a // caller error (the bundle lacked a WG key → the fresh-keygen fallback path, not this one). func InstallRecoveredKey(stateDir, privB64 string) error { priv, err := decodeKey([]byte(privB64)) if err != nil { return fmt.Errorf("wgtunnel: recovered wg key is not 32-byte base64: %w", err) } dir := filepath.Join(stateDir, "wg") path := filepath.Join(dir, keyFileName) if _, serr := os.Stat(path); serr == nil { return fmt.Errorf("wgtunnel: key file %s already exists — refusing to overwrite (a present key may be a live identity)", path) } else if !os.IsNotExist(serr) { return fmt.Errorf("wgtunnel: stat key file: %w", serr) } if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("wgtunnel: creating %s: %w", dir, err) } enc := base64.StdEncoding.EncodeToString(priv) + "\n" if err := os.WriteFile(path, []byte(enc), 0o600); err != nil { return fmt.Errorf("wgtunnel: writing recovered key file: %w", err) } return nil } // decodeKey parses a key-file payload: base64 of exactly 32 bytes (trailing whitespace ok). func decodeKey(raw []byte) ([]byte, error) { s := string(raw) for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r' || s[len(s)-1] == ' ') { s = s[:len(s)-1] } priv, err := base64.StdEncoding.DecodeString(s) if err != nil { return nil, fmt.Errorf("not valid base64: %v", err) } if len(priv) != 32 { return nil, fmt.Errorf("decodes to %d bytes, want 32", len(priv)) } return priv, nil } // readPrivateKeyB64 loads + validates the private key file, returning the base64 string for // conf rendering. Internal only; callers must never log the value. func readPrivateKeyB64(stateDir string) (string, error) { raw, err := os.ReadFile(KeyFilePath(stateDir)) if err != nil { return "", fmt.Errorf("wgtunnel: reading key file: %w", err) } priv, err := decodeKey(raw) if err != nil { return "", fmt.Errorf("wgtunnel: key file corrupt: %w", err) } return base64.StdEncoding.EncodeToString(priv), nil }