Files
felhom-agent/internal/escrow/escrow.go
T
admin 47dd0bd244 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>
2026-06-10 07:37:03 +02:00

185 lines
7.4 KiB
Go

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)
}