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:
@@ -3,6 +3,38 @@
|
||||
All notable changes to **felhom-agent** are recorded here. Update on every code
|
||||
change that gets pushed.
|
||||
|
||||
## v0.17.0 — slice 10C: escrow consumption (productionize the spike) (2026-06-10)
|
||||
|
||||
Turns the throwaway 10C spike harness into a real, tested **`Consume`** path: recover the PBS key
|
||||
`K` from an R-wrapped escrow blob, **gate it on the expected fingerprint**, and install it for the
|
||||
restore. The spike already proved the crypto + real-data restore; this bakes its findings into
|
||||
production code. **Agent-only** — 10C *reads* the four inputs as parameters (so it stays
|
||||
standalone-testable); 10D sources blob/fingerprint/PBS-connection from the hub and prompts for R.
|
||||
**Zero-knowledge holds**: the hub serves everything except **R** (by hand from the customer), so a
|
||||
hub compromise alone still can't decrypt.
|
||||
|
||||
### Added
|
||||
- **`escrow.Consume(ctx, blob, R, expectedFingerprint, keyDest)`** — the consumption contract:
|
||||
1. **Unwrap** the blob (a copy — F-C6: the input blob is read-only → a failed Consume is
|
||||
**retryable**) with `R`; a **wrong R fails closed** at the scrypt KDF (F-C3) → a clear,
|
||||
R-free error, **nothing written**.
|
||||
2. **Fingerprint gate (F-C4)** — `KeyFingerprint(recovered)` must equal the expected (the hub
|
||||
knows it); a mismatch **fails fast + loud, no install, no restore attempted**.
|
||||
3. **Atomic install (F-C2)** at `keyDest` (`0600`, write-temp-sibling→rename); any failure leaves
|
||||
**no partial install**. The recovered key lives only in a `0700` tempdir that is always removed.
|
||||
**Secret discipline:** `R` and key bytes are never logged/persisted (only fingerprint prefixes);
|
||||
`K` is never mutated.
|
||||
- **`--selftest=escrow-consume`** (`-blob -fingerprint -keydest`, R via env `FELHOM_RECOVERY_CODE`
|
||||
to keep it off the command line) — invokes the real `Consume` live (the spike's S3 via the
|
||||
production path, not a harness).
|
||||
|
||||
### Tests (non-hollow)
|
||||
- valid → key installed + `KeyFingerprint(dest) == expected` + `0600` + blob byte-unchanged;
|
||||
**wrong R** → error, **no file at dest**, blob unchanged; **fingerprint mismatch** → fail fast,
|
||||
**no install** (the gate runs before any restore); input validation; format-tolerant fingerprint
|
||||
compare (no empty-fingerprint gate-bypass); atomic-install permissions (integration tests gated to
|
||||
a host with `proxmox-backup-client`).
|
||||
|
||||
## v0.16.0 — slice 10B: operator-signed destructive completion (offline key + signing CLI) (2026-06-10)
|
||||
|
||||
The security centerpiece: a destructive op runs ONLY on a verified, operator-signed authorization
|
||||
|
||||
@@ -1,63 +1,50 @@
|
||||
# REPORT — slice 10B (agent half): operator-signed destructive completion (v0.16.0) (2026-06-10)
|
||||
# REPORT — slice 10C: escrow consumption — productionize the spike (v0.17.0) (2026-06-10)
|
||||
|
||||
> Overwrite-latest report. Cumulative history: [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## What was implemented
|
||||
|
||||
The security centerpiece: a destructive op runs ONLY on a verified, operator-signed authorization.
|
||||
Decision (a): **offline operator key + signing CLI**, hardware-key-ready. The signing key is NOT in
|
||||
the hub and NOT in the agent. Concrete consumer: this **closes the 8C data-bearing-wipe
|
||||
`pending_signature` gap**. Pairs with hub v0.10.0.
|
||||
The 10C spike proved the crypto + real-data restore on a key-less box (recover `K` from `(blob, R)`,
|
||||
restore real data, GO). 10C turns that throwaway harness into a real, tested **`Consume`** path.
|
||||
**Agent-only — no hub change** (10C reads its four inputs as parameters; 10D sources blob/fingerprint/
|
||||
PBS-connection from the hub and prompts for R).
|
||||
|
||||
### The flow (end-to-end)
|
||||
8C format of a data-bearing device → agent refuses `pending_signature` + **surfaces the bound op**
|
||||
(durable id + host) → operator **signs offline** (`felhom-opsign`) → uploads to the hub jobs queue →
|
||||
agent's signed-jobs runner **verifies** + **executes** the wipe (re-resolve durable id + re-inspect
|
||||
8C → `mkfs`) → clears the job.
|
||||
### `escrow.Consume(ctx, blob, R, expectedFingerprint, keyDest)`
|
||||
The consumption contract, baking in the spike findings:
|
||||
1. **Unwrap** the blob (operating on a copy — **F-C6**: the input blob is read-only, so a failed
|
||||
Consume is **retryable**) with `R`; a **wrong R fails closed** at the scrypt KDF (**F-C3**) → a
|
||||
clear, R-free error, **nothing written**.
|
||||
2. **Fingerprint gate (F-C4)** — `KeyFingerprint(recovered)` must equal the expected (the hub knows
|
||||
it); a mismatch **fails fast + loud, no install, no restore attempted** (the cheap correctness
|
||||
check *before* any multi-GB restore).
|
||||
3. **Atomic install (F-C2)** at `keyDest` (`0600`, write-temp-sibling → rename); any failure leaves
|
||||
**no partial install**. The recovered key lives only in a `0700` tempdir that is always removed.
|
||||
|
||||
### `cmd/felhom-opsign` (new) — the offline signing CLI
|
||||
- Builds the canonical `OpBlob` by **reusing `authz.CanonicalBlob`** (the exact bytes the verifier
|
||||
authenticates over — signer/verifier can't drift) and signs with **`ssh-keygen -Y sign -n
|
||||
felhom-op-v1`** (hardware-ready: `sk-`/YubiKey work unchanged). Output: `{op_blob_b64,
|
||||
sig_armored}`; optional `--upload`. Touches only the operator's key.
|
||||
**Secret discipline:** `R` and key bytes are never logged/persisted (only fingerprint prefixes); the
|
||||
input blob is read-only; `K` is never mutated. **Zero-knowledge holds**: the hub serves the blob +
|
||||
expected fingerprint + PBS connection; **R is by-hand from the customer, never the hub** — a hub
|
||||
compromise alone still cannot decrypt.
|
||||
|
||||
### The verify-and-execute machinery
|
||||
- **`internal/signedjobs.Runner`** — fetches each opaque job → runs the **gate** (the LOCKED authz
|
||||
pipeline: pinned-key SSHSIG → namespace → allow-list by key MATERIAL → crypto over raw bytes →
|
||||
host target → time window → **durable nonce-burn LAST**) → on all-pass hands the verified op to an
|
||||
`Executor`. Order: **verify → burn nonce (durable) → execute → clear**. Rejects (forged/replayed/
|
||||
expired/retargeted/non-pinned) never reach the executor.
|
||||
- **`WipeExecutor`** (the 8C consumer) — resolve the signed **durable** id → re-derive + **match**
|
||||
(anti-retarget) → **re-inspect (8C classifier)** still-data-bearing → `mkfs`. A path-only binding,
|
||||
a vanished/changed device, or a non-data-bearing target is refused **even with a valid signature**.
|
||||
- **`internal/storage` durable identity** — `DeviceDurableID` / `ResolveDurableDevice` over the
|
||||
world-readable udev symlinks (`byid:` wwn/serial, `byuuid:` fallback) — no privilege, no subprocess.
|
||||
- **`authz.CanonicalBlob`** promoted to production. `hub.Client.Jobs`/`CompleteJob` + `MultiObserver`.
|
||||
The 8C 403 now carries a `pending_op` (op + durable id + host) + a `felhom-opsign` hint.
|
||||
### `--selftest=escrow-consume`
|
||||
`-blob -fingerprint -keydest`, R via env `FELHOM_RECOVERY_CODE` (off the command line / `ps`). Invokes
|
||||
the **real** `Consume` live — the spike's S3 via the production path, not a harness.
|
||||
|
||||
### Pinning / rotation
|
||||
Operator pubkeys pinned via `authz.signers` (config, trusted path — NEVER hub-alone), **multi-key**
|
||||
(KeyID selects, role-scoped) for backup/rotation without a flag-day. Unchanged verifier wiring; 10B
|
||||
activates the execute path (the runner is the second `EnvelopeObserver`, runs on `HasSignedOps`).
|
||||
|
||||
## Tests (real crypto, non-hollow — assert the op did/did NOT run)
|
||||
- `signedjobs` over the **real** gate+verifier (in-Go minted SSHSIGs): valid → executor runs once +
|
||||
job cleared; **replay / non-pinned / expired / retarget / forged / no-signer** → rejected, executor
|
||||
never called; malformed cleared.
|
||||
- `WipeExecutor`: valid → `mkfs`; path-only / durable-mismatch / device-gone / re-inspect-non-data-
|
||||
bearing / not-probed → refused, `Format` not called.
|
||||
- `storage` durable: wwn-preference, uuid-fallback, path-only+traversal refusal, round-trip, missing
|
||||
(symlink tests gated to Linux). `go test ./...` green.
|
||||
## Tests (non-hollow)
|
||||
- valid → key installed + `KeyFingerprint(dest) == expected` + `0600` + **blob byte-unchanged**;
|
||||
**wrong R** → error + **no file at dest** + blob unchanged; **fingerprint mismatch** → fail fast +
|
||||
**no install** (the gate runs before any restore); input validation; format-tolerant fingerprint
|
||||
compare (no empty-fingerprint gate-bypass); atomic-install. `go test ./...` green (integration
|
||||
round-trip gated to a host with `proxmox-backup-client`).
|
||||
|
||||
## Versioning / docs
|
||||
- Version `0.15.0 → 0.16.0`; `CHANGELOG.md`. Doc 03 §4 (signed path live) + §6 (8C wipe completes) +
|
||||
§9 (10B done) updated.
|
||||
- Version `0.16.0 → 0.17.0`; `CHANGELOG.md`. Doc 03 §8a (consumption implemented; zero-knowledge) +
|
||||
§9 (10C done) updated. No cross-repo golden (no wire change).
|
||||
|
||||
## Out of scope (per the task)
|
||||
- Other destructive executors (guest_destroy, decommission, **restore-overwrite → 10D**) reuse the
|
||||
same gate+runner; their executors plug in per-slice. 10B ships the machinery + the storage-wipe.
|
||||
- DR orchestration (re-enroll in restore mode, *which* guests, identity restore) → **10D**;
|
||||
populating/serving the restore directive from the hub → **10D**.
|
||||
|
||||
## Pending
|
||||
- **Live validation** on the demo: data-bearing wipe → `pending_signature` → sign offline with a real
|
||||
operator key → hub queue → agent verifies + wipes; confirm replay + a non-pinned-key signature are
|
||||
rejected. (Also validates the Linux-only durable-device tests + ssh-keygen interop.)
|
||||
- **Live validation** on the demo: create a fresh escrow from the live `K` → `Consume` on a key-less
|
||||
scratch → restore a real encrypted backup with the consumed key; **wrong R → clean failure, nothing
|
||||
installed**; the live `K` stays byte-unchanged.
|
||||
|
||||
@@ -42,7 +42,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.16.0"
|
||||
var version = "0.17.0"
|
||||
|
||||
func main() {
|
||||
var (
|
||||
@@ -62,6 +62,9 @@ func main() {
|
||||
custDomain string
|
||||
custName string
|
||||
custEmail string
|
||||
blobPath string
|
||||
expectedFP string
|
||||
keyDest string
|
||||
showVersion bool
|
||||
)
|
||||
flag.StringVar(&cfgPath, "config", envOr("FELHOM_AGENT_CONFIG", "/etc/felhom-agent/agent.json"), "path to the agent config file (JSON)")
|
||||
@@ -76,6 +79,9 @@ func main() {
|
||||
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.StringVar(&blobPath, "blob", "", "for --selftest=escrow-consume: path to the R-wrapped escrow blob file")
|
||||
flag.StringVar(&expectedFP, "fingerprint", "", "for --selftest=escrow-consume: the EXPECTED key fingerprint (the gate target)")
|
||||
flag.StringVar(&keyDest, "keydest", "", "for --selftest=escrow-consume: where to install the recovered key (0600)")
|
||||
flag.StringVar(&custID, "customer-id", "", "for --selftest=provision: the customer id to seed into the guest's bootstrap")
|
||||
flag.StringVar(&custDomain, "customer-domain", "", "for --selftest=provision: the customer domain to seed")
|
||||
flag.StringVar(&custName, "customer-name", "", "for --selftest=provision: the customer display name to seed (optional)")
|
||||
@@ -126,6 +132,8 @@ func main() {
|
||||
}))
|
||||
case "escrow-create":
|
||||
os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload))
|
||||
case "escrow-consume":
|
||||
os.Exit(runSelftestEscrowConsume(context.Background(), logger, blobPath, expectedFP, keyDest))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1123,6 +1131,40 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo
|
||||
return 0
|
||||
}
|
||||
|
||||
// runSelftestEscrowConsume exercises the slice-10C production Consume path live: recover K from an
|
||||
// R-wrapped blob, gate it on the expected fingerprint, install it at -keydest. R is taken BY HAND
|
||||
// from the env var FELHOM_RECOVERY_CODE (kept off the command line / ps) — never a flag, never
|
||||
// logged. The other three inputs (blob/fingerprint/keydest) are flags (10D sources blob+fingerprint
|
||||
// from the hub directive). This is the real Consume code, not a throwaway harness.
|
||||
func runSelftestEscrowConsume(ctx context.Context, logger *slog.Logger, blobPath, expectedFP, keyDest string) int {
|
||||
if blobPath == "" || expectedFP == "" || keyDest == "" {
|
||||
fmt.Fprintln(os.Stderr, "selftest=escrow-consume requires -blob, -fingerprint and -keydest (R via env FELHOM_RECOVERY_CODE)")
|
||||
return 2
|
||||
}
|
||||
R := os.Getenv("FELHOM_RECOVERY_CODE")
|
||||
if R == "" {
|
||||
fmt.Fprintln(os.Stderr, "selftest=escrow-consume: set the recovery code in env FELHOM_RECOVERY_CODE (by-hand input; never a flag/arg)")
|
||||
return 2
|
||||
}
|
||||
blob, err := os.ReadFile(blobPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "selftest=escrow-consume: reading blob %s: %v\n", blobPath, err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("=== felhom-agent %s selftest=escrow-consume (blob=%s → %s) ===\n", version, blobPath, keyDest)
|
||||
logger.Info("escrow: consuming R-wrapped escrow (Unwrap → fingerprint-gate → install)",
|
||||
"blob_bytes", len(blob), "key_dest", keyDest) // R is NOT logged
|
||||
if err := escrow.Consume(ctx, blob, R, expectedFP, keyDest); err != nil {
|
||||
R = "" // drop the reference
|
||||
fmt.Fprintln(os.Stderr, " [FAIL] consume:", err) // the error never contains R or key bytes
|
||||
return 1
|
||||
}
|
||||
R = "" // drop the reference promptly
|
||||
fmt.Printf(" [OK] recovered key installed at %s (fingerprint-gated, 0600) — ready for the PBS restore\n", keyDest)
|
||||
fmt.Println("=== selftest=escrow-consume 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.
|
||||
@@ -1519,8 +1561,10 @@ func (f *selftestFlag) Set(v string) error {
|
||||
f.mode = "provision"
|
||||
case "escrow-create":
|
||||
f.mode = "escrow-create"
|
||||
case "escrow-consume":
|
||||
f.mode = "escrow-consume"
|
||||
default:
|
||||
return fmt.Errorf("invalid --selftest value %q (want read|task|hub|storage|backup|restore-test|pbs-verify|bring-up|provision|escrow-create)", v)
|
||||
return fmt.Errorf("invalid --selftest value %q (want read|task|hub|storage|backup|restore-test|pbs-verify|bring-up|provision|escrow-create|escrow-consume)", v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user