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 // IdentityBundle (slice 10D.1), when set, is ALSO wrapped under the SAME R (via age) → an // IdentityBlob in the result. Additive: the K-escrow path is unchanged when nil. IdentityBundle *IdentityBundle } // 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) IdentityBlob []byte // (10D.1) the age-wrapped identity bundle under the same R, if IdentityBundle set } // 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 } // Slice 10D.1: ALSO wrap the identity bundle under the SAME R (via age), so DR can recover the // box's identity with the one recovery code. Self-verify it round-trips before shipping. if opts.IdentityBundle != nil { idBlob, err := WrapIdentityBundle(ctx, *opts.IdentityBundle, R) if err != nil { return "", CreateResult{}, fmt.Errorf("escrow: identity wrap: %w", err) } if _, err := UnwrapIdentityBundle(ctx, idBlob, R); err != nil { return "", CreateResult{}, fmt.Errorf("escrow: identity self-verify (not recoverable): %w", err) } res.IdentityBlob = idBlob } 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) }