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
+124 -2
View File
@@ -7,12 +7,15 @@
package main
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
@@ -23,6 +26,7 @@ import (
"gitea.dooplex.hu/admin/felhom-agent/internal/authz"
"gitea.dooplex.hu/admin/felhom-agent/internal/backup"
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
applog "gitea.dooplex.hu/admin/felhom-agent/internal/log"
"gitea.dooplex.hu/admin/felhom-agent/internal/pbs"
@@ -33,7 +37,7 @@ import (
// version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
var version = "0.8.0"
var version = "0.9.0"
func main() {
var (
@@ -45,6 +49,10 @@ func main() {
mode string
hostname string
keep bool
pbsStorage string
paperkey bool
offline bool
upload bool
showVersion bool
)
flag.StringVar(&cfgPath, "config", envOr("FELHOM_AGENT_CONFIG", "/etc/felhom-agent/agent.json"), "path to the agent config file (JSON)")
@@ -55,6 +63,10 @@ func main() {
flag.StringVar(&mode, "mode", "provision", "for --selftest=bring-up: `provision` (golden, fresh identity) | `dr` (customer backup, preserve continuity)")
flag.StringVar(&hostname, "hostname", "", "for --selftest=bring-up provision: the hostname to set on the new guest")
flag.BoolVar(&keep, "keep", false, "for --selftest=bring-up: KEEP the guest instead of tearing it down at the end")
flag.StringVar(&pbsStorage, "storage", "", "for --selftest=escrow-create: the pbs storage whose key to escrow (default: escrow.pbs_storage_id)")
flag.BoolVar(&paperkey, "paperkey", false, "for --selftest=escrow-create: ALSO emit the raw-key paperkey (opt-in (a); single-factor, unrevocable)")
flag.BoolVar(&offline, "offline", false, "for --selftest=escrow-create: ALSO emit the R-wrapped offline copy to print (opt-in (b))")
flag.BoolVar(&upload, "upload", false, "for --selftest=escrow-create: upload the opaque blob to the hub")
flag.BoolVar(&showVersion, "version", false, "print version and exit")
flag.Parse()
@@ -94,6 +106,8 @@ func main() {
os.Exit(runSelftestPBSVerify(context.Background(), cfg, logger))
case "bring-up":
os.Exit(runSelftestBringUp(context.Background(), cfg, logger, mode, archive, vmid, hostname, keep))
case "escrow-create":
os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload))
}
}
@@ -800,6 +814,112 @@ func runSelftestBringUp(ctx context.Context, cfg config.Config, logger *slog.Log
return 0
}
// runSelftestEscrowCreate creates the PBS recovery-code escrow (slice 7, doc 03 §8a): generate R,
// wrap the live PBS key under R (zero-knowledge), self-verify recoverability, and emit the opaque
// blob. R is surfaced to stdout EXACTLY ONCE (never to the logger/journald). With -upload it PUTs
// the opaque blob to the hub. Enrollment-time, root-capable (reads the 0600 key).
func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slog.Logger, storage string, paperkey, offline, upload bool) int {
if storage == "" {
storage = cfg.Escrow.PBSStorageID
}
if storage == "" {
fmt.Fprintln(os.Stderr, "selftest=escrow-create requires -storage <pbs-storage-id> (or escrow.pbs_storage_id)")
return 2
}
keyPath := cfg.Backup.PBSEncKeyPath(storage)
if _, err := os.Stat(keyPath); err != nil {
fmt.Fprintf(os.Stderr, "selftest=escrow-create: PBS key for %q not found (%s): %v\n", storage, keyPath, err)
return 1
}
fmt.Printf("=== felhom-agent %s selftest=escrow-create (storage=%s posture=%s) ===\n", version, storage, escrow.DefaultPosture)
// NB: nothing about R is logged. The logger never sees R; only stdout does, once.
logger.Info("escrow: creating zero-knowledge recovery-code escrow", "storage", storage, "key_path", keyPath)
R, res, err := escrow.Create(ctx, escrow.CreateOptions{
KeyPath: keyPath,
Posture: escrow.Posture(cfg.Escrow.Posture),
WantOfflineCopy: offline,
WantPaperkey: paperkey,
})
if err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] escrow create:", err)
return 1
}
// Surface R EXACTLY ONCE — to stdout, with a write-it-down banner. Never logged/persisted.
fmt.Println()
fmt.Println(" ┌──────────────────────────────────────────────────────────────────────┐")
fmt.Println(" │ RECOVERY CODE — write it down now. It is shown ONCE and never stored. │")
fmt.Println(" │ Without it your offsite backups are unrecoverable, by anyone. │")
fmt.Println(" └──────────────────────────────────────────────────────────────────────┘")
fmt.Println(" " + R)
fmt.Println()
R = "" // drop our reference promptly
fmt.Printf(" blob: %d bytes (opaque, R-wrapped) · key fingerprint %s · posture %s · ~%.0f bits R\n",
len(res.Blob), res.KeyFingerprint, res.Posture, res.EntropyBits)
fmt.Println(" self-verify: the blob unwraps back to the key with R (recoverability confirmed)")
if offline && len(res.OfflineCopy) > 0 {
fmt.Println(" --- (b) R-wrapped OFFLINE COPY (print + store; still needs R) ---")
fmt.Println(base64.StdEncoding.EncodeToString(res.OfflineCopy))
}
if paperkey && res.Paperkey != "" {
fmt.Println(" --- (a) RAW PAPERKEY — single-factor, UNREVOCABLE. Store in a safe only. ---")
fmt.Println(res.Paperkey)
}
if upload {
if err := uploadEscrowBlob(ctx, cfg, res); err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] upload escrow to hub:", err)
return 1
}
fmt.Println(" uploaded the opaque blob to the hub (host record); the hub cannot open it")
}
fmt.Println("=== selftest=escrow-create OK ===")
return 0
}
// escrowUploadRequest is the agent→hub wire shape for the opaque escrow blob. MUST stay in lockstep
// with the hub's ingest struct (felhom-hub api.escrowUploadRequest). The hub stores the bytes and
// never decrypts them.
type escrowUploadRequest struct {
BlobB64 string `json:"blob_b64"` // base64 of the opaque R-wrapped blob (ciphertext)
KeyFingerprint string `json:"key_fingerprint"` // for operator display only
Posture string `json:"posture"` // e.g. "zero_knowledge"
CreatedAt string `json:"created_at"` // RFC3339
}
// uploadEscrowBlob PUTs the opaque blob to the hub, authed with the per-host key.
func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateResult) error {
if cfg.Hub.URL == "" || cfg.Hub.HostID == "" || cfg.Hub.APIKey == "" {
return fmt.Errorf("hub not configured (url/host_id/api_key)")
}
body, _ := json.Marshal(escrowUploadRequest{
BlobB64: base64.StdEncoding.EncodeToString(res.Blob),
KeyFingerprint: res.KeyFingerprint,
Posture: string(res.Posture),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
})
url := strings.TrimRight(cfg.Hub.URL, "/") + "/api/v1/hosts/" + cfg.Hub.HostID + "/escrow"
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+cfg.Hub.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return fmt.Errorf("hub returned HTTP %d", resp.StatusCode)
}
return nil
}
// runSelftestPBSVerify discovers the pbs storages, triggers a verify on each (the new §2
// path), then lists + prints the resulting PBSSnapshot records (verify-state included).
// Standalone on the host. Covers the runbook's (c) verify and (d) list.
@@ -1153,8 +1273,10 @@ func (f *selftestFlag) Set(v string) error {
f.mode = "pbs-verify"
case "bring-up":
f.mode = "bring-up"
case "escrow-create":
f.mode = "escrow-create"
default:
return fmt.Errorf("invalid --selftest value %q (want read|task|hub|storage|backup|restore-test|pbs-verify|bring-up)", v)
return fmt.Errorf("invalid --selftest value %q (want read|task|hub|storage|backup|restore-test|pbs-verify|bring-up|escrow-create)", v)
}
return nil
}
+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"
}
return dir + "/" + 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 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) }