slice 7: PBS recovery-code escrow creation (agent, Phase B) (v0.9.0 wip)

internal/escrow: zero-knowledge escrow creation. R = 10 EFF-wordlist words
(crypto/rand, ~129 bits); wrap K under R via PBS-native key change-passphrase
driven over a stdlib pty (x/sys/unix; output discarded so R can't leak, F-A2);
self-verify the blob recovers K (fingerprint match) before shipping. Opt-in (b)
R-wrapped offline copy + (a) raw paperkey. Live K is byte-unchanged (operates on
a copy). --selftest=escrow-create (-storage/-paperkey/-offline/-upload). Posture
config field (zero_knowledge default). PBSEncKeyPath helper. Grounded by the
escrow spike findings.

Tests: R entropy>=128/format/uniqueness; integration round-trip (wrap->unwrap
fingerprint match, wrong-R fails, K byte-unchanged) guarded to linux+pbc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 07:37:03 +02:00
parent 6e036e4c75
commit 47dd0bd244
8 changed files with 8449 additions and 6 deletions
+24 -4
View File
@@ -28,9 +28,20 @@ type Config struct {
Hub HubConfig `json:"hub"`
Storage StorageConfig `json:"storage"`
Backup BackupConfig `json:"backup"`
Escrow EscrowConfig `json:"escrow"`
LogLevel string `json:"log_level"` // debug|info|warn|error (default info)
}
// EscrowConfig configures PBS recovery-code escrow creation (slice 7, doc 03 §8a). Enrollment-time
// only (not the steady-state daemon). The default posture is zero-knowledge (Felhom holds the
// opaque blob, the customer holds the recovery code).
type EscrowConfig struct {
// Posture is the key-custody posture; "" → zero_knowledge (the only one implemented this slice).
Posture string `json:"posture"`
// PBSStorageID is the pbs storage whose client encryption key is escrowed (e.g. "felhom-pbs").
PBSStorageID string `json:"pbs_storage_id"`
}
// BackupConfig tunes the slice-6 backup + self-restore-test layer. The restore-test runs on
// an agent-internal cadence (no hub policy needed — it's self-validation); the backup
// schedule/retention/target-selection policy is hub-manifest-owned and unfed until slice 10.
@@ -91,11 +102,20 @@ func (b BackupConfig) PBSVerifyCadence() time.Duration {
// PBSSecretPath returns the path to a pbs storage's token-secret file.
func (b BackupConfig) PBSSecretPath(storageID string) string {
dir := b.PBSSecretDir
if dir == "" {
dir = "/etc/pve/priv/storage"
return b.pbsSecretDir() + "/" + storageID + ".pw"
}
// PBSEncKeyPath returns the path to a pbs storage's CLIENT ENCRYPTION KEY (K) file — the key the
// escrow wraps (slice 7). PVE stores it alongside the token secret as <id>.enc, 0600 root.
func (b BackupConfig) PBSEncKeyPath(storageID string) string {
return b.pbsSecretDir() + "/" + storageID + ".enc"
}
func (b BackupConfig) pbsSecretDir() string {
if b.PBSSecretDir == "" {
return "/etc/pve/priv/storage"
}
return dir + "/" + storageID + ".pw"
return b.PBSSecretDir
}
// ScratchBand returns the effective [min,max] scratch VMID band (defaults applied).
File diff suppressed because it is too large Load Diff
+184
View File
@@ -0,0 +1,184 @@
package escrow
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
)
// pbcBinary is the PBS client CLI the escrow shells out to (the PBS-native key+passphrase KDF).
var pbcBinary = "/usr/bin/proxmox-backup-client"
// Posture is the customer's key-custody posture (doc 03 §8a). Only the zero-knowledge DEFAULT is
// implemented this slice; the other postures are documented (doc 03 §8a) and implemented only when
// a customer chooses them.
type Posture string
const (
// PostureZeroKnowledge: Felhom storage + customer-only key. The default. Felhom holds the
// opaque R-wrapped blob (cannot open it) and the customer holds R.
PostureZeroKnowledge Posture = "zero_knowledge"
)
// DefaultPosture is the posture when none is configured.
const DefaultPosture = PostureZeroKnowledge
// CreateOptions parameterizes one escrow creation.
type CreateOptions struct {
// KeyPath is the live PBS client encryption key K (e.g. /etc/pve/priv/storage/felhom-pbs.enc).
// It is read (copied) but NEVER modified.
KeyPath string
// Posture is recorded for display/audit; this slice implements only zero-knowledge.
Posture Posture
// WantOfflineCopy: opt-in (b) — also return the wrapped blob for the customer to print
// (still two-factor: useless without R). No extra trust.
WantOfflineCopy bool
// WantPaperkey: opt-in (a) — also return the RAW-key paperkey. Single-factor + unrevocable;
// the caller must surface the loud caveat. Off by default.
WantPaperkey bool
}
// CreateResult is the non-secret output of escrow creation. NOTE: the recovery code R is returned
// SEPARATELY (not in this struct) so it can never be accidentally logged via the result.
type CreateResult struct {
Blob []byte // the opaque R-wrapped escrow blob → hub (Phase C). Ciphertext, not K.
KeyFingerprint string // the PBS key fingerprint (identifies the key; safe to display)
Posture Posture // the posture used
EntropyBits float64 // R's approximate entropy (for display; never R itself)
OfflineCopy []byte // (b) the same wrapped blob, if WantOfflineCopy (for the customer to print)
Paperkey string // (a) raw paperkey text, if WantPaperkey — SECRET-adjacent (single factor)
}
// Create generates a recovery code R, produces the R-wrapped escrow blob from the live key, and
// SELF-VERIFIES it is recoverable (unwraps a copy with R and checks the key fingerprint matches —
// "an escrow you haven't recovered isn't an escrow"). It returns R SEPARATELY: the caller surfaces
// R to the customer exactly once and must not log it. The live key file is byte-unchanged.
func Create(ctx context.Context, opts CreateOptions) (recoveryCode string, res CreateResult, err error) {
if opts.KeyPath == "" {
return "", CreateResult{}, fmt.Errorf("escrow: KeyPath (the live PBS key) is required")
}
posture := opts.Posture
if posture == "" {
posture = DefaultPosture
}
if posture != PostureZeroKnowledge {
return "", CreateResult{}, fmt.Errorf("escrow: posture %q not implemented this slice (only %q)", posture, PostureZeroKnowledge)
}
keyFP, err := KeyFingerprint(ctx, opts.KeyPath)
if err != nil {
return "", CreateResult{}, fmt.Errorf("escrow: read key fingerprint: %w", err)
}
R, err := GenerateRecoveryCode()
if err != nil {
return "", CreateResult{}, err
}
work, err := os.MkdirTemp("", "felhom-escrow-")
if err != nil {
return "", CreateResult{}, fmt.Errorf("escrow: tempdir: %w", err)
}
defer os.RemoveAll(work) // the blob bytes are returned in memory; no plaintext-K temp lingers
blobPath := filepath.Join(work, "escrow.blob")
if err := Wrap(ctx, opts.KeyPath, blobPath, R); err != nil {
return "", CreateResult{}, err
}
// Self-verify recoverability: unwrap a COPY with R and confirm the recovered key's fingerprint
// matches the original. Never ship a blob we can't recover.
verifyPath := filepath.Join(work, "verify.blob")
if err := copyFile(blobPath, verifyPath, 0o600); err != nil {
return "", CreateResult{}, fmt.Errorf("escrow: verify copy: %w", err)
}
if err := Unwrap(ctx, verifyPath, R); err != nil {
return "", CreateResult{}, fmt.Errorf("escrow: self-verify unwrap: %w", err)
}
recFP, err := KeyFingerprint(ctx, verifyPath)
if err != nil {
return "", CreateResult{}, fmt.Errorf("escrow: self-verify fingerprint: %w", err)
}
if recFP != keyFP {
return "", CreateResult{}, fmt.Errorf("escrow: self-verify FAILED — recovered key fingerprint mismatch (blob is not recoverable)")
}
blob, err := os.ReadFile(blobPath)
if err != nil {
return "", CreateResult{}, fmt.Errorf("escrow: read blob: %w", err)
}
res = CreateResult{Blob: blob, KeyFingerprint: keyFP, Posture: posture, EntropyBits: RecoveryCodeEntropyBits()}
if opts.WantOfflineCopy {
res.OfflineCopy = blob // same two-factor blob; customer prints it
}
if opts.WantPaperkey {
pk, err := Paperkey(ctx, opts.KeyPath)
if err != nil {
return "", CreateResult{}, fmt.Errorf("escrow: paperkey: %w", err)
}
res.Paperkey = pk
}
return R, res, nil
}
// Wrap produces the R-wrapped escrow blob from the live PBS key file: copy K → blob, then re-key
// the copy to kdf=scrypt under R via a pty (spike F-A1/F-A2). The live key file is NOT modified.
func Wrap(ctx context.Context, keyPath, blobPath, recoveryCode string) error {
if err := copyFile(keyPath, blobPath, 0o600); err != nil {
return fmt.Errorf("escrow: copy key: %w", err)
}
// `key change-passphrase --kdf scrypt` prompts New + Verify → feed R twice.
if err := runWithPassphrase(ctx, recoveryCode, 2, pbcBinary, "key", "change-passphrase", blobPath, "--kdf", "scrypt"); err != nil {
_ = os.Remove(blobPath)
return fmt.Errorf("escrow: wrap: %w", err)
}
return nil
}
// Unwrap recovers the unencrypted key from an R-wrapped blob, in place: re-key to kdf=none under R
// via a pty (one prompt). Used by Create's self-verify; the real recovery is slice 10.
func Unwrap(ctx context.Context, blobPath, recoveryCode string) error {
// `key change-passphrase --kdf none` prompts the Encryption Key Password → feed R once.
if err := runWithPassphrase(ctx, recoveryCode, 1, pbcBinary, "key", "change-passphrase", blobPath, "--kdf", "none"); err != nil {
return fmt.Errorf("escrow: unwrap: %w", err)
}
return nil
}
// Paperkey emits the RAW-key paperkey (opt-in (a)) as text. Single-factor + unrevocable — the
// caller surfaces the caveat. SECRET-adjacent: do not log it.
func Paperkey(ctx context.Context, keyPath string) (string, error) {
out, err := exec.CommandContext(ctx, pbcBinary, "key", "paperkey", keyPath, "--output-format", "text").Output()
if err != nil {
return "", fmt.Errorf("escrow: paperkey: %w", err)
}
return string(out), nil
}
// KeyFingerprint returns a PBS key file's fingerprint (identifies the key; safe to display).
func KeyFingerprint(ctx context.Context, keyPath string) (string, error) {
out, err := exec.CommandContext(ctx, pbcBinary, "key", "show", keyPath, "--output-format", "json").Output()
if err != nil {
return "", fmt.Errorf("escrow: key show: %w", err)
}
var meta struct {
Fingerprint string `json:"fingerprint"`
}
if err := json.Unmarshal(out, &meta); err != nil {
return "", fmt.Errorf("escrow: parse key show: %w", err)
}
return meta.Fingerprint, nil
}
// copyFile copies src→dst with the given mode (0600 for key material). Reads the whole file
// (PBS key files are tiny).
func copyFile(src, dst string, mode os.FileMode) error {
b, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, b, mode)
}
+188
View File
@@ -0,0 +1,188 @@
package escrow
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestWordlistLoaded(t *testing.T) {
if WordlistSize() != 7776 {
t.Fatalf("EFF large wordlist should be 7776 words, got %d", WordlistSize())
}
}
func TestGenerateRecoveryCode_EntropyAndFormat(t *testing.T) {
if RecoveryCodeEntropyBits() < 128 {
t.Fatalf("recovery code entropy must be ≥128 bits, got %.1f", RecoveryCodeEntropyBits())
}
inList := make(map[string]bool, WordlistSize())
for _, w := range wordlist {
inList[w] = true
}
for i := 0; i < 50; i++ {
r, err := GenerateRecoveryCode()
if err != nil {
t.Fatalf("GenerateRecoveryCode: %v", err)
}
words := strings.Split(r, "-")
if len(words) != RecoveryCodeWords {
t.Fatalf("recovery code must be %d words, got %d (%q)", RecoveryCodeWords, len(words), r)
}
for _, w := range words {
if !inList[w] {
t.Errorf("recovery-code word %q is not from the EFF wordlist", w)
}
}
}
}
func TestGenerateRecoveryCode_Unique(t *testing.T) {
seen := make(map[string]bool)
for i := 0; i < 200; i++ {
r, err := GenerateRecoveryCode()
if err != nil {
t.Fatal(err)
}
if seen[r] {
t.Fatalf("recovery code collision within 200 draws — entropy too low")
}
seen[r] = true
}
}
// --- integration: real PBS key wrap/unwrap round-trip (linux + proxmox-backup-client only) ---
func pbcAvailable() bool {
if runtime.GOOS != "linux" {
return false
}
_, err := exec.LookPath(strings.TrimPrefix(pbcBinary, ""))
if err != nil {
// fall back to PATH lookup of the basename
_, err = exec.LookPath("proxmox-backup-client")
}
return err == nil
}
func TestWrapUnwrapRoundTrip(t *testing.T) {
if !pbcAvailable() {
t.Skip("skipping: proxmox-backup-client + linux required (runs on the demo/build host)")
}
if _, err := os.Stat(pbcBinary); err != nil {
if p, e := exec.LookPath("proxmox-backup-client"); e == nil {
pbcBinary = p
}
}
ctx := context.Background()
dir := t.TempDir()
Kt := filepath.Join(dir, "Kt.json")
// throwaway unencrypted key (mimics the live K posture). NOT a real K.
if out, err := exec.Command(pbcBinary, "key", "create", Kt, "--kdf", "none").CombinedOutput(); err != nil {
t.Fatalf("key create: %v: %s", err, out)
}
ktBefore, _ := os.ReadFile(Kt)
fp0, err := KeyFingerprint(ctx, Kt)
if err != nil {
t.Fatalf("fingerprint: %v", err)
}
const Rt = "throwaway-test-passphrase-correct-horse"
blob := filepath.Join(dir, "escrow.blob")
if err := Wrap(ctx, Kt, blob, Rt); err != nil {
t.Fatalf("Wrap: %v", err)
}
// the live key file must be byte-unchanged after wrap.
if ktAfter, _ := os.ReadFile(Kt); string(ktAfter) != string(ktBefore) {
t.Fatal("the live key file was modified by Wrap — must operate on a copy")
}
// blob is the passphrase-protected (scrypt) form.
if kdf := keyKDF(t, blob); kdf != "scrypt" {
t.Fatalf("wrapped blob kdf = %q, want scrypt", kdf)
}
// unwrap with the RIGHT passphrase → recovered key fingerprint matches the original.
rec := filepath.Join(dir, "rec.blob")
if err := copyFile(blob, rec, 0o600); err != nil {
t.Fatal(err)
}
if err := Unwrap(ctx, rec, Rt); err != nil {
t.Fatalf("Unwrap (correct R): %v", err)
}
fp2, err := KeyFingerprint(ctx, rec)
if err != nil {
t.Fatal(err)
}
if fp2 != fp0 {
t.Fatalf("recovered key fingerprint %q != original %q", fp2, fp0)
}
// unwrap with the WRONG passphrase → must fail (and not produce the key).
wrong := filepath.Join(dir, "wrong.blob")
if err := copyFile(blob, wrong, 0o600); err != nil {
t.Fatal(err)
}
if err := Unwrap(ctx, wrong, "definitely-the-wrong-code"); err == nil {
t.Fatal("Unwrap with the WRONG recovery code must FAIL")
}
}
func TestCreate_SelfVerifiesAndKeepsKey(t *testing.T) {
if !pbcAvailable() {
t.Skip("skipping: proxmox-backup-client + linux required")
}
if _, err := os.Stat(pbcBinary); err != nil {
if p, e := exec.LookPath("proxmox-backup-client"); e == nil {
pbcBinary = p
}
}
ctx := context.Background()
dir := t.TempDir()
Kt := filepath.Join(dir, "Kt.json")
if out, err := exec.Command(pbcBinary, "key", "create", Kt, "--kdf", "none").CombinedOutput(); err != nil {
t.Fatalf("key create: %v: %s", err, out)
}
ktBefore, _ := os.ReadFile(Kt)
R, res, err := Create(ctx, CreateOptions{KeyPath: Kt, Posture: PostureZeroKnowledge})
if err != nil {
t.Fatalf("Create: %v", err)
}
if len(R) == 0 || len(strings.Split(R, "-")) != RecoveryCodeWords {
t.Errorf("Create returned a malformed recovery code")
}
if len(res.Blob) == 0 {
t.Error("Create returned an empty blob")
}
if res.KeyFingerprint == "" || res.Posture != PostureZeroKnowledge {
t.Errorf("result meta wrong: %+v", res)
}
// the blob is ciphertext, not the key: it must NOT equal the live key bytes.
if string(res.Blob) == string(ktBefore) {
t.Fatal("blob equals the plaintext key — wrap did not encrypt")
}
// the live key file is byte-unchanged.
if ktAfter, _ := os.ReadFile(Kt); string(ktAfter) != string(ktBefore) {
t.Fatal("Create modified the live key file")
}
}
func keyKDF(t *testing.T, path string) string {
t.Helper()
out, err := exec.Command(pbcBinary, "key", "show", path, "--output-format", "json").Output()
if err != nil {
t.Fatalf("key show: %v", err)
}
// minimal parse to avoid importing json twice; the field is `"kdf":"scrypt"` or `"none"`.
s := string(out)
for _, k := range []string{"scrypt", "none", "pbkdf2"} {
if strings.Contains(s, `"kdf":"`+k+`"`) || strings.Contains(s, `"kdf": "`+k+`"`) {
return k
}
}
return "?(" + s + ")"
}
+56
View File
@@ -0,0 +1,56 @@
//go:build linux
package escrow
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
"syscall"
"golang.org/x/sys/unix"
)
// runWithPassphrase runs a TTY-requiring command (PBS `key change-passphrase`, F-A1 in the spike
// findings) on a pty, feeding `passphrase` `reps` times (once per prompt) and DISCARDING all pty
// output so the echoed passphrase can never leak (F-A2). Linux-only — the agent runs on Proxmox
// hosts; a non-linux stub returns an error.
func runWithPassphrase(ctx context.Context, passphrase string, reps int, name string, args ...string) error {
master, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("escrow: open ptmx: %w", err)
}
defer master.Close()
if err := unix.IoctlSetPointerInt(int(master.Fd()), unix.TIOCSPTLCK, 0); err != nil { // unlock
return fmt.Errorf("escrow: unlock pty: %w", err)
}
ptn, err := unix.IoctlGetInt(int(master.Fd()), unix.TIOCGPTN)
if err != nil {
return fmt.Errorf("escrow: pty number: %w", err)
}
slave, err := os.OpenFile(fmt.Sprintf("/dev/pts/%d", ptn), os.O_RDWR|syscall.O_NOCTTY, 0)
if err != nil {
return fmt.Errorf("escrow: open pts: %w", err)
}
defer slave.Close()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = slave, slave, slave
// New session + the slave (fd 0) becomes the controlling terminal, so the child's tty prompts
// read from / write to the pty rather than failing "no tty".
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true}
if err := cmd.Start(); err != nil {
return fmt.Errorf("escrow: start %s: %w", name, err)
}
// Feed the passphrase once (the tty line discipline buffers both lines for the sequential
// New/Verify prompts), then DISCARD everything the pty emits — the passphrase is echoed back
// on the master fd and must never reach a log.
go func() { _, _ = master.WriteString(strings.Repeat(passphrase+"\n", reps)) }()
go func() { _, _ = io.Copy(io.Discard, master) }()
return cmd.Wait()
}
+16
View File
@@ -0,0 +1,16 @@
//go:build !linux
package escrow
import (
"context"
"fmt"
"runtime"
)
// runWithPassphrase is unsupported off Linux — the agent runs only on Proxmox (Linux) hosts. This
// stub lets the package compile (and its pure-Go parts, e.g. recovery-code generation, be tested)
// on other platforms.
func runWithPassphrase(_ context.Context, _ string, _ int, _ string, _ ...string) error {
return fmt.Errorf("escrow: pty-driven key wrap is Linux-only (GOOS=%s)", runtime.GOOS)
}
+81
View File
@@ -0,0 +1,81 @@
// Package escrow creates the PBS recovery-code escrow (doc 03 §8a, slice 7): an R-wrapped,
// zero-knowledge copy of the live PBS client encryption key K. It is the FIRST code that touches
// K and introduces the customer recovery code R.
//
// Secret discipline (overriding):
// - R (recovery code) is generated with crypto/rand, ≥128 bits, word-list form; surfaced to the
// customer EXACTLY ONCE (by the caller); NEVER logged, persisted, or returned in a struct that
// gets logged. GenerateRecoveryCode returns it as a bare string the caller must handle with care.
// - K (PBS key) is read by location; the live unencrypted K is NEVER modified — Wrap operates on
// a COPY. K is never logged/printed.
// - The wrapped blob is opaque ciphertext (a PBS scrypt key file); the hub stores it and never
// decrypts it (it has no R).
//
// It shells out to `proxmox-backup-client key` (the PBS-native passphrase KDF — no bespoke crypto;
// see documentation/tests/slice7-escrow-spike-findings.md). That binary is a runtime dependency.
package escrow
import (
"bufio"
"bytes"
"crypto/rand"
_ "embed"
"fmt"
"math"
"math/big"
"strings"
)
//go:embed eff_large_wordlist.txt
var wordlistRaw []byte
// wordlist is the EFF large wordlist (7776 words, 12.92 bits/word) — the diceware standard for
// human-transcribed passphrases. Parsed once at init.
var wordlist = parseWordlist(wordlistRaw)
func parseWordlist(raw []byte) []string {
var w []string
sc := bufio.NewScanner(bytes.NewReader(raw))
for sc.Scan() {
if t := strings.TrimSpace(sc.Text()); t != "" {
w = append(w, t)
}
}
return w
}
// RecoveryCodeWords is the number of words in a recovery code R. 10 words from the 7776-word EFF
// list ≈ 129.2 bits (≥128) — the chosen entropy floor (slice7-escrow-spike-findings.md §4).
const RecoveryCodeWords = 10
// GenerateRecoveryCode returns a fresh recovery code R: RecoveryCodeWords words chosen uniformly
// (crypto/rand via big.Int — no modulo bias) from the EFF large wordlist, hyphen-joined.
//
// SECRET: the returned string is R. Surface it to the customer exactly once; never log or persist it.
func GenerateRecoveryCode() (string, error) {
if len(wordlist) < 2 {
return "", fmt.Errorf("escrow: wordlist not loaded (%d words)", len(wordlist))
}
n := big.NewInt(int64(len(wordlist)))
words := make([]string, RecoveryCodeWords)
for i := range words {
idx, err := rand.Int(rand.Reader, n)
if err != nil {
return "", fmt.Errorf("escrow: recovery-code rng: %w", err)
}
words[i] = wordlist[idx.Int64()]
}
return strings.Join(words, "-"), nil
}
// RecoveryCodeEntropyBits is the approximate entropy of a generated code, for display/audit only
// (never the code itself).
func RecoveryCodeEntropyBits() float64 {
if len(wordlist) < 2 {
return 0
}
return float64(RecoveryCodeWords) * math.Log2(float64(len(wordlist)))
}
// WordlistSize is the loaded wordlist length (for audit/tests).
func WordlistSize() int { return len(wordlist) }