e4dfe5ccc7
Identity escrow wraps {tunnel_token,pbs_token} under the SAME R via age
(scrypt+ChaCha20-Poly1305), reusing the K-escrow pty; wrong R fails closed.
escrow.Create optionally emits the identity blob; escrow-create uploads it +
the non-secret directive; identity-consume recovers it (R by hand, never
logged). K-escrow + 10C Consume untouched. Closes slice 10 with hub v0.11.0;
operator-side rotation model (hub holds no Cloudflare write-power).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
201 lines
8.3 KiB
Go
201 lines
8.3 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
|
|
// 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)
|
|
}
|