slice 10C: escrow consumption — productionize the spike (v0.17.0)

Add escrow.Consume(blob, R, expectedFingerprint, keyDest): Unwrap -> fingerprint
gate -> atomic 0600 install. Bakes in the spike findings — wrong R fails closed
(no write), the fingerprint gate runs BEFORE any restore (no install on
mismatch), the input blob is read-only (retryable), K is never mutated, R/key
bytes never logged. Zero-knowledge holds: the hub serves all but R (by hand).
--selftest=escrow-consume invokes the real path live. Agent-only; no hub change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 22:17:56 +02:00
parent 588fed2aa9
commit 89e9f98a95
5 changed files with 445 additions and 50 deletions
+146
View File
@@ -0,0 +1,146 @@
package escrow
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
)
// Consume is the slice-10C production escrow-consumption path — the throwaway 10C spike harness
// turned into a real, tested function. It recovers the PBS client encryption key `K` from an
// R-wrapped escrow `blob`, GATES it on the expected key fingerprint, and installs it at `keyDest`
// for the PBS restore path. It is the inverse of Create: Create wraps `K` under `R`; Consume
// unwraps it back. The DR orchestration around it (re-enroll in restore mode, source the directive
// from the hub, decide which guests to restore) is slice 10D — Consume takes its four inputs as
// PARAMETERS so it stays standalone-testable (exactly as the spike harness was).
//
// The four inputs (the consumption contract — spike findings):
// - blob: the opaque R-wrapped escrow blob (hub-served restore directive in 10D).
// - recoveryCode (R): BY HAND from the customer — NEVER hub-sourced (zero-knowledge holds: a
// hub compromise alone cannot decrypt). A secret: never logged/persisted.
// - expectedFingerprint: hub-served — the gate target (F-C4).
// - keyDest: where the PBS restore reads the key (`--keyfile` or the default
// `$XDG_CONFIG_HOME/proxmox-backup/encryption-key.json`).
//
// Order (spike F-C2/F-C3/F-C4/F-C6): Unwrap → fingerprint-gate → install. On ANY failure there is
// NO partial install (nothing at keyDest) and no key material leaks: the recovered key lives only
// in a 0600 tempdir that is always removed; `R` and key bytes are never logged (only fingerprint
// prefixes); the input `blob` is read-only, so a failed Consume is RETRYABLE.
func Consume(ctx context.Context, blob []byte, recoveryCode, expectedFingerprint, keyDest string) error {
if len(blob) == 0 {
return fmt.Errorf("escrow: Consume needs a non-empty blob")
}
if recoveryCode == "" {
return fmt.Errorf("escrow: Consume needs the recovery code (R)")
}
if expectedFingerprint == "" {
// The fingerprint gate is mandatory — without it we'd install whatever a (wrong-but-valid)
// unwrap produced. There is no "skip the gate" path.
return fmt.Errorf("escrow: Consume needs the expected key fingerprint (the gate target)")
}
if keyDest == "" {
return fmt.Errorf("escrow: Consume needs a key destination path")
}
// Work in a private 0700 tempdir; RemoveAll always runs, so the recovered key never lingers.
work, err := os.MkdirTemp("", "felhom-consume-")
if err != nil {
return fmt.Errorf("escrow: tempdir: %w", err)
}
defer os.RemoveAll(work)
recovered := filepath.Join(work, "recovered.key")
// Operate on a COPY of the blob (F-C6: the input blob is read-only → a failed Consume is
// retryable). Unwrap re-keys this copy in place.
if err := os.WriteFile(recovered, blob, 0o600); err != nil {
return fmt.Errorf("escrow: staging blob: %w", err)
}
// 1. Unwrap with R. A WRONG R fails closed at the scrypt KDF (F-C3): nonzero exit, no key
// emitted. Surface a clear, R-free error; nothing is written to keyDest.
if err := Unwrap(ctx, recovered, recoveryCode); err != nil {
return fmt.Errorf("escrow: the recovery code did not unwrap the escrow (wrong recovery code, or a corrupt blob): %w", err)
}
// 2. Fingerprint gate (F-C4) — the cheap correctness check, BEFORE any install or multi-GB
// restore. A mismatch means a wrong escrow/datastore (or a recovery code that, against the odds,
// produced a different key): fail fast + loud, no install.
recFP, err := KeyFingerprint(ctx, recovered)
if err != nil {
return fmt.Errorf("escrow: reading recovered key fingerprint: %w", err)
}
if !fingerprintsEqual(recFP, expectedFingerprint) {
return fmt.Errorf("escrow: recovered key fingerprint %s != expected %s — wrong escrow/datastore (not installing)",
shortFP(recFP), shortFP(expectedFingerprint))
}
// 3. Install atomically at keyDest (0600) — the only thing Consume writes outside its tempdir.
// A failure here leaves nothing partial at keyDest (the temp sibling is removed).
if err := installKey(recovered, keyDest); err != nil {
return fmt.Errorf("escrow: installing recovered key: %w", err)
}
return nil
}
// installKey atomically places the recovered key at dest with 0600: write a sibling temp on the
// SAME filesystem, fsync, then rename (atomic). The parent dir is created 0700 if missing. On any
// error the temp is removed so dest is never left partial.
func installKey(recovered, dest string) error {
if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil {
return fmt.Errorf("key dir: %w", err)
}
b, err := os.ReadFile(recovered)
if err != nil {
return err
}
var rnd [8]byte
_, _ = rand.Read(rnd[:])
tmp := dest + ".tmp-" + hex.EncodeToString(rnd[:])
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600)
if err != nil {
return err
}
if _, err := f.Write(b); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, dest); err != nil {
os.Remove(tmp)
return err
}
return nil
}
// fingerprintsEqual compares two PBS key fingerprints tolerant of formatting (case + ':'/' '
// separators) — the hub-stored value and `key show`'s output may differ only cosmetically.
func fingerprintsEqual(a, b string) bool {
return normFP(a) == normFP(b) && normFP(a) != ""
}
func normFP(s string) string {
return strings.ToLower(strings.NewReplacer(":", "", " ", "", "\t", "", "\n", "").Replace(strings.TrimSpace(s)))
}
// shortFP returns a log-safe truncated fingerprint prefix (a key fingerprint is an identifier, not
// the key — but we still show only a prefix in logs/errors).
func shortFP(s string) string {
s = strings.TrimSpace(s)
if len(s) > 23 { // ~ first 8 colon-separated octets
return s[:23] + "…"
}
return s
}
+186
View File
@@ -0,0 +1,186 @@
package escrow
import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
// ---- pure-unit tests (no proxmox-backup-client) ----------------------------------------------
func TestConsume_InputValidation(t *testing.T) {
ctx := context.Background()
dest := filepath.Join(t.TempDir(), "k.json")
cases := []struct {
name string
blob []byte
r, fp, dst string
}{
{"empty blob", nil, "R", "fp", dest},
{"empty R", []byte("x"), "", "fp", dest},
{"empty fingerprint", []byte("x"), "R", "", dest},
{"empty dest", []byte("x"), "R", "fp", ""},
}
for _, c := range cases {
if err := Consume(ctx, c.blob, c.r, c.fp, c.dst); err == nil {
t.Errorf("%s: expected an error", c.name)
}
}
// no key written on a validation failure
if _, err := os.Stat(dest); !os.IsNotExist(err) {
t.Error("a validation failure left a key behind")
}
}
func TestFingerprintsEqual_NormalizesFormatting(t *testing.T) {
if !fingerprintsEqual("01:36:E9:FE", "0136e9fe") {
t.Error("case/colon-insensitive compare failed")
}
if fingerprintsEqual("", "") {
t.Error("two empty fingerprints must NOT compare equal (no gate-bypass on empty)")
}
if fingerprintsEqual("01:36", "ff:ee") {
t.Error("distinct fingerprints compared equal")
}
}
func TestInstallKey_AtomicAnd0600(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src")
if err := os.WriteFile(src, []byte("keymaterial"), 0o600); err != nil {
t.Fatal(err)
}
dest := filepath.Join(dir, "sub", "installed.json") // parent dir must be created
if err := installKey(src, dest); err != nil {
t.Fatalf("installKey: %v", err)
}
b, _ := os.ReadFile(dest)
if string(b) != "keymaterial" {
t.Errorf("installed content = %q", b)
}
if runtime.GOOS != "windows" { // Windows does not enforce Unix file modes
if info, _ := os.Stat(dest); info.Mode().Perm() != 0o600 {
t.Errorf("installed key mode = %v, want 0600", info.Mode().Perm())
}
}
// no .tmp- siblings left behind
entries, _ := os.ReadDir(filepath.Dir(dest))
for _, e := range entries {
if filepath.Ext(e.Name()) != ".json" && len(e.Name()) > 4 && e.Name()[:4] == "inst" {
t.Errorf("temp sibling left behind: %s", e.Name())
}
}
}
// ---- integration tests (real proxmox-backup-client wrap/unwrap round-trip) --------------------
func ensurePbc(t *testing.T) {
t.Helper()
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
}
}
}
// makeBlob creates a throwaway kdf=none key Kt, wraps it under R, and returns (blobBytes, Kt-fp).
func makeBlob(t *testing.T, dir, R string) ([]byte, string) {
t.Helper()
ctx := context.Background()
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)
}
fp, err := KeyFingerprint(ctx, Kt)
if err != nil {
t.Fatalf("fingerprint: %v", err)
}
blobPath := filepath.Join(dir, "escrow.blob")
if err := Wrap(ctx, Kt, blobPath, R); err != nil {
t.Fatalf("Wrap: %v", err)
}
blob, err := os.ReadFile(blobPath)
if err != nil {
t.Fatal(err)
}
return blob, fp
}
// VALID: Consume installs a key whose fingerprint matches the expected; blob is byte-unchanged.
func TestConsume_ValidInstallsGatedKey(t *testing.T) {
ensurePbc(t)
ctx := context.Background()
dir := t.TempDir()
const R = "throwaway-correct-horse-battery-staple"
blob, wantFP := makeBlob(t, dir, R)
blobBefore := append([]byte(nil), blob...)
dest := filepath.Join(dir, "freshbox", "encryption-key.json")
if err := Consume(ctx, blob, R, wantFP, dest); err != nil {
t.Fatalf("Consume(valid): %v", err)
}
// key installed + fingerprint matches the expected gate target.
gotFP, err := KeyFingerprint(ctx, dest)
if err != nil {
t.Fatalf("fingerprint(dest): %v", err)
}
if !fingerprintsEqual(gotFP, wantFP) {
t.Errorf("installed key fingerprint %q != expected %q", gotFP, wantFP)
}
// 0600.
if info, _ := os.Stat(dest); info.Mode().Perm() != 0o600 {
t.Errorf("installed key mode = %v, want 0600", info.Mode().Perm())
}
// blob read-only / unchanged (retryable).
if !bytes.Equal(blob, blobBefore) {
t.Error("Consume mutated the input blob — must be read-only/retryable")
}
}
// WRONG R: Unwrap fails closed → clear error, NO file at dest, blob unchanged.
func TestConsume_WrongRNoInstall(t *testing.T) {
ensurePbc(t)
ctx := context.Background()
dir := t.TempDir()
blob, wantFP := makeBlob(t, dir, "the-correct-code")
blobBefore := append([]byte(nil), blob...)
dest := filepath.Join(dir, "freshbox", "encryption-key.json")
err := Consume(ctx, blob, "DEFINITELY-the-wrong-code", wantFP, dest)
if err == nil {
t.Fatal("a wrong recovery code must fail")
}
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
t.Errorf("a wrong-R Consume left a key at dest (%v) — must install nothing", statErr)
}
if !bytes.Equal(blob, blobBefore) {
t.Error("wrong-R Consume mutated the blob — must be retryable")
}
}
// FINGERPRINT MISMATCH: right unwrap, wrong expected fingerprint → fail fast, NO install (proves
// the gate runs BEFORE any restore).
func TestConsume_FingerprintMismatchNoInstall(t *testing.T) {
ensurePbc(t)
ctx := context.Background()
dir := t.TempDir()
const R = "the-correct-code"
blob, _ := makeBlob(t, dir, R)
dest := filepath.Join(dir, "freshbox", "encryption-key.json")
// Right R, but an expected fingerprint for a DIFFERENT datastore/key.
err := Consume(ctx, blob, R, "ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff:ff", dest)
if err == nil {
t.Fatal("a fingerprint mismatch must fail")
}
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
t.Errorf("a fingerprint-mismatch Consume left a key at dest — the gate must run before install")
}
}