Files
felhom-agent/internal/wgtunnel/key.go
T
admin bd4bced771 dr: recovered WG-key install + host_loss directive→restore-PLAN (S5 safe halves)
wgtunnel.InstallRecoveredKey: write an escrow-recovered WG private key (create-
only, refuse-overwrite) so the tunnel re-establishes with the same identity/pubkey
(same /32), no keygen. Wired into identity-consume -install-wg-key (opt-in;
pre-S3 blob → logged fresh-keygen fallback). Value never logged.

internal/dr (new): consume the host_loss restore_directive (was logged-ignored)
into an inspectable RestorePlan via the AddConsumer raw seam — per guest
{vmid,archive,target,sizing} + per drive {durable_id→mount} + offsite PBS coord.
DERIVE-AND-SURFACE only; the Consumer has no restore/destroy dependency (execute-
nothing is structural). guest_loss/absent → no plan.

Tests + red-proofs (WG create-only overwrite; plan mode-gate). No secrets on
argv/stdout/logs. The destructive in-place restore is a separate operator-present
STOP-gated drill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-04 21:06:31 +02:00

149 lines
6.2 KiB
Go

// 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 <if> 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 <StateDir>/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
}