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
This commit is contained in:
@@ -88,6 +88,35 @@ 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)
|
||||
|
||||
@@ -8,9 +8,54 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestInstallRecoveredKey_SameIdentityCreateOnly (S5): a recovered WG key installs into a fresh
|
||||
// state dir, EnsureKey LOADS it (no keygen) yielding the SAME pubkey (→ same /32); a second install
|
||||
// REFUSES (create-only, never overwrite); an invalid key errors and writes nothing.
|
||||
func TestInstallRecoveredKey_SameIdentityCreateOnly(t *testing.T) {
|
||||
src := t.TempDir()
|
||||
srcPub, created, err := EnsureKey(src)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("seed EnsureKey: created=%v err=%v", created, err)
|
||||
}
|
||||
recovered, err := readPrivateKeyB64(src) // the base64 the escrow bundle carries
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dst := t.TempDir()
|
||||
if err := InstallRecoveredKey(dst, recovered); err != nil {
|
||||
t.Fatalf("InstallRecoveredKey: %v", err)
|
||||
}
|
||||
pub, created, err := EnsureKey(dst) // must LOAD, not generate
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created {
|
||||
t.Error("EnsureKey generated a fresh key instead of loading the installed one (no-keygen negative)")
|
||||
}
|
||||
if pub != srcPub {
|
||||
t.Errorf("recovered pubkey = %q, want same as source %q (same /32)", pub, srcPub)
|
||||
}
|
||||
|
||||
// Create-only: a second install REFUSES (a present key may be a live identity).
|
||||
if err := InstallRecoveredKey(dst, recovered); err == nil || !strings.Contains(err.Error(), "refusing to overwrite") {
|
||||
t.Errorf("second install must refuse (create-only), got %v", err)
|
||||
}
|
||||
|
||||
// Invalid recovered key → error, nothing written.
|
||||
bad := t.TempDir()
|
||||
if err := InstallRecoveredKey(bad, "not-base64!!"); err == nil {
|
||||
t.Error("invalid recovered key accepted")
|
||||
}
|
||||
if _, serr := os.Stat(KeyFilePath(bad)); !os.IsNotExist(serr) {
|
||||
t.Error("a key file was written despite an invalid recovered key")
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed test vector. PROVENANCE: public key generated ONCE with the real `wg pubkey` (
|
||||
// wireguard-tools on felhom-hetzner, 2026-07-04) from the spec's published test private key —
|
||||
// this private key is a PUBLISHED test constant, not a secret.
|
||||
|
||||
Reference in New Issue
Block a user