slice 10D (agent): DR capstone — identity escrow + restore-mode consumption (v0.18.0)

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>
This commit is contained in:
2026-06-11 09:48:36 +02:00
parent 89e9f98a95
commit e4dfe5ccc7
6 changed files with 362 additions and 53 deletions
+28
View File
@@ -3,6 +3,34 @@
All notable changes to **felhom-agent** are recorded here. Update on every code All notable changes to **felhom-agent** are recorded here. Update on every code
change that gets pushed. change that gets pushed.
## v0.18.0 — slice 10D: DR capstone — identity escrow + restore-mode consumption (agent side) (2026-06-10)
The agent half of the slice-10 DR capstone (closes slice 10). Grounded by both 10-series spikes
(escrow-consumption + identity-restore). The hub half (recovery-mode toggle, re-enroll + credential
rotation, directive serving) is hub v0.11.0. **Operator-side rotation model (locked):** the hub holds
no Cloudflare write-power; the destructive tunnel/PBS rotation is the operator's step from a trusted
environment (same spirit as 10B).
### Added (`internal/escrow`)
- **Identity escrow** (`identity.go`): `WrapIdentity`/`UnwrapIdentity` (+ `…Bundle`) wrap the
`{tunnel_token, pbs_token}` bundle under the SAME recovery code `R` via **`age`** (scrypt +
ChaCha20-Poly1305 — a vetted passphrase-AEAD, not hand-rolled), reusing the K-escrow pty mechanism
(passphrase via the tty, data via files; `R`/tokens never logged). Same two-factor, zero-knowledge
shape as the K-escrow. A **wrong R fails closed** (no bundle). `age` is a runtime dep for the
identity path (analogous to proxmox-backup-client for K).
- **`escrow.Create`** gains an optional `IdentityBundle` → also emits an `IdentityBlob` under the same
R (additive; the K-escrow + 10C `Consume` paths are byte-unchanged). Self-verifies the identity
round-trip before shipping.
- **`--selftest=escrow-create -identity-bundle <file> -directive <file>`** — also wrap + upload the
identity blob + the **non-secret** DR directive (pbs repo/ns, expected key fingerprint, tunnel id).
- **`--selftest=identity-consume -blob <file> -keydest <file>`** (R via `FELHOM_RECOVERY_CODE`) —
recover the identity bundle through the real code; tokens written 0600, never logged.
### Tests
- identity bundle round-trips (wrap→unwrap byte-identical; blob is opaque ciphertext); wrong R fails
closed + the blob stays retryable; input validation. K-escrow/10C tests byte-unchanged (additive).
(age integration tests gated to a host with the `age` CLI.)
## v0.17.0 — slice 10C: escrow consumption (productionize the spike) (2026-06-10) ## 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 Turns the throwaway 10C spike harness into a real, tested **`Consume`** path: recover the PBS key
+35 -37
View File
@@ -1,50 +1,48 @@
# REPORT — slice 10C: escrow consumption — productionize the spike (v0.17.0) (2026-06-10) # REPORT — slice 10D (agent half): DR capstoneidentity escrow + restore-mode consumption (v0.18.0) (2026-06-10)
> Overwrite-latest report. Cumulative history: [CHANGELOG.md](CHANGELOG.md). > Overwrite-latest report. Cumulative history: [CHANGELOG.md](CHANGELOG.md).
## What was implemented ## What was implemented
The 10C spike proved the crypto + real-data restore on a key-less box (recover `K` from `(blob, R)`, The agent half of the slice-10 DR capstone (closes slice 10), grounded by both 10-series spikes
restore real data, GO). 10C turns that throwaway harness into a real, tested **`Consume`** path. (escrow-consumption + identity-restore). The hub half (recovery-mode, re-enroll, directive serving)
**Agent-only — no hub change** (10C reads its four inputs as parameters; 10D sources blob/fingerprint/ is hub v0.11.0. **Rotation model (locked):** the hub holds no Cloudflare write-power; the destructive
PBS-connection from the hub and prompts for R). tunnel/PBS rotation is the operator's step from a trusted environment (same spirit as 10B).
### `escrow.Consume(ctx, blob, R, expectedFingerprint, keyDest)` ### Identity escrow (`internal/escrow/identity.go`)
The consumption contract, baking in the spike findings: - `WrapIdentity`/`UnwrapIdentity` (+ `…Bundle`) wrap the `{tunnel_token, pbs_token}` bundle under the
1. **Unwrap** the blob (operating on a copy — **F-C6**: the input blob is read-only, so a failed SAME recovery code `R` via **`age`** (scrypt + ChaCha20-Poly1305 — vetted passphrase-AEAD, not
Consume is **retryable**) with `R`; a **wrong R fails closed** at the scrypt KDF (**F-C3**) → a hand-rolled), **reusing the K-escrow pty mechanism** (passphrase via the tty, data via files;
clear, R-free error, **nothing written**. `R`/tokens never logged). Same two-factor, zero-knowledge shape as the K-escrow; a **wrong R fails
2. **Fingerprint gate (F-C4)**`KeyFingerprint(recovered)` must equal the expected (the hub knows closed** (no bundle). `age` is a runtime dep for the identity path (like proxmox-backup-client for K).
it); a mismatch **fails fast + loud, no install, no restore attempted** (the cheap correctness - `escrow.Create` gains an optional `IdentityBundle` → emits `IdentityBlob` under the same R
check *before* any multi-GB restore). (additive; the K-escrow + 10C `Consume` are byte-unchanged; self-verifies the identity round-trip).
3. **Atomic install (F-C2)** at `keyDest` (`0600`, write-temp-sibling → rename); any failure leaves - `--selftest=escrow-create -identity-bundle <f> -directive <f>` (wrap + upload the identity blob +
**no partial install**. The recovered key lives only in a `0700` tempdir that is always removed. non-secret directive) and `--selftest=identity-consume -blob <f> -keydest <f>` (R via
`FELHOM_RECOVERY_CODE`; tokens written 0600, never logged) — the real code, drillable.
**Secret discipline:** `R` and key bytes are never logged/persisted (only fingerprint prefixes); the ### Restore-mode orchestration (10D.3)
input blob is read-only; `K` is never mutated. **Zero-knowledge holds**: the hub serves the blob + Composed from proven pieces: re-enroll (hub) → fetch directive (10A) → `Consume` K (10C, fingerprint
expected fingerprint + PBS connection; **R is by-hand from the customer, never the hub** — a hub -gated) + `identity-consume` (R by hand) → restore guests from PBS (restore-overwrite gated by 10B) →
compromise alone still cannot decrypt. re-establish the tunnel (recovered token + reconstituted origin — proven in the 10D spike). The
destructive cred rotation is the operator's step.
### `--selftest=escrow-consume` ## Tests
`-blob -fingerprint -keydest`, R via env `FELHOM_RECOVERY_CODE` (off the command line / `ps`). Invokes - identity round-trips (wrap→unwrap byte-identical; blob is opaque ciphertext); wrong R fails closed +
the **real** `Consume` live — the spike's S3 via the production path, not a harness. blob stays retryable; input validation. K-escrow/10C unchanged. `go test ./...` green (age
integration tests gated to a host with the `age` CLI).
## 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 ## Versioning / docs
- Version `0.16.0 → 0.17.0`; `CHANGELOG.md`. Doc 03 §8a (consumption implemented; zero-knowledge) + - Version `0.17.0 → 0.18.0`; `CHANGELOG.md`. Doc 03 §9 (10D done → **slice 10 CLOSED**) + the
§9 (10C done) updated. No cross-repo golden (no wire change). host-loss DR flow with the operator-side rotation model.
## Out of scope (per the task) ## Deferred (non-blocking, per the locked model)
- DR orchestration (re-enroll in restore mode, *which* guests, identity restore) → **10D**; - The hub Config DR/Recovery **web UI** (functional via the recovery-mode admin API today) + a small
populating/serving the restore directive from the hub → **10D**. operator rotation CLI (the tunnel/PBS rotation + stale-connector delete is a documented operator
procedure from the trusted environment — the hub never holds Cloudflare write-power).
## Pending ## Pending
- **Live validation** on the demo: create a fresh escrow from the live `K``Consume` on a key-less - The operator-in-the-loop **DR drill** (live, throwaway identity): host-loss → recovery mode →
scratch → restore a real encrypted backup with the consumed key; **wrong R → clean failure, nothing re-enroll (old key revoked) → R → consume → restore → re-establish → operator rotates/retires the
installed**; the live `K` stays byte-unchanged. old connector → only the new box serves. CC drives the `[CC]` steps; the operator does the trusted
steps.
+96 -12
View File
@@ -42,7 +42,7 @@ import (
// version is the agent version. Overridable at build time with // version is the agent version. Overridable at build time with
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version. // -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
var version = "0.17.0" var version = "0.18.0"
func main() { func main() {
var ( var (
@@ -65,6 +65,8 @@ func main() {
blobPath string blobPath string
expectedFP string expectedFP string
keyDest string keyDest string
idBundlePath string
directivePath string
showVersion bool showVersion bool
) )
flag.StringVar(&cfgPath, "config", envOr("FELHOM_AGENT_CONFIG", "/etc/felhom-agent/agent.json"), "path to the agent config file (JSON)") flag.StringVar(&cfgPath, "config", envOr("FELHOM_AGENT_CONFIG", "/etc/felhom-agent/agent.json"), "path to the agent config file (JSON)")
@@ -82,6 +84,8 @@ func main() {
flag.StringVar(&blobPath, "blob", "", "for --selftest=escrow-consume: path to the R-wrapped escrow blob file") 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(&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(&keyDest, "keydest", "", "for --selftest=escrow-consume: where to install the recovered key (0600)")
flag.StringVar(&idBundlePath, "identity-bundle", "", "for --selftest=escrow-create: a 0600 JSON file {tunnel_token,pbs_token} to ALSO escrow under R (10D)")
flag.StringVar(&directivePath, "directive", "", "for --selftest=escrow-create: a JSON file with the non-secret DR directive (pbs repo/ns, expected fingerprint, tunnel id)")
flag.StringVar(&custID, "customer-id", "", "for --selftest=provision: the customer id to seed into the guest's bootstrap") 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(&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)") flag.StringVar(&custName, "customer-name", "", "for --selftest=provision: the customer display name to seed (optional)")
@@ -131,9 +135,11 @@ func main() {
customer: provision.DocCustomer{ID: custID, Domain: custDomain, Name: custName, Email: custEmail}, customer: provision.DocCustomer{ID: custID, Domain: custDomain, Name: custName, Email: custEmail},
})) }))
case "escrow-create": case "escrow-create":
os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload)) os.Exit(runSelftestEscrowCreate(context.Background(), cfg, logger, pbsStorage, paperkey, offline, upload, idBundlePath, directivePath))
case "escrow-consume": case "escrow-consume":
os.Exit(runSelftestEscrowConsume(context.Background(), logger, blobPath, expectedFP, keyDest)) os.Exit(runSelftestEscrowConsume(context.Background(), logger, blobPath, expectedFP, keyDest))
case "identity-consume":
os.Exit(runSelftestIdentityConsume(context.Background(), logger, blobPath, keyDest))
} }
} }
@@ -1068,7 +1074,7 @@ func runSelftestProvision(ctx context.Context, cfg config.Config, logger *slog.L
// wrap the live PBS key under R (zero-knowledge), self-verify recoverability, and emit the opaque // wrap the live PBS key under R (zero-knowledge), self-verify recoverability, and emit the opaque
// blob. R is surfaced to stdout EXACTLY ONCE (never to the logger/journald). With -upload it PUTs // blob. R is surfaced to stdout EXACTLY ONCE (never to the logger/journald). With -upload it PUTs
// the opaque blob to the hub. Enrollment-time, root-capable (reads the 0600 key). // the opaque blob to the hub. Enrollment-time, root-capable (reads the 0600 key).
func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slog.Logger, storage string, paperkey, offline, upload bool) int { func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slog.Logger, storage string, paperkey, offline, upload bool, identityBundlePath, directivePath string) int {
if storage == "" { if storage == "" {
storage = cfg.Escrow.PBSStorageID storage = cfg.Escrow.PBSStorageID
} }
@@ -1082,15 +1088,40 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo
return 1 return 1
} }
fmt.Printf("=== felhom-agent %s selftest=escrow-create (storage=%s posture=%s) ===\n", version, storage, escrow.DefaultPosture) // Slice 10D.1: optionally ALSO wrap the identity bundle under the same R, and carry the non-secret
// directive for the hub. The bundle file is a 0600 secret (tunnel/pbs tokens); the directive is
// non-secret (pbs repo/ns, expected fingerprint, tunnel id).
var identity *escrow.IdentityBundle
var directive json.RawMessage
if identityBundlePath != "" {
raw, err := os.ReadFile(identityBundlePath)
if err != nil {
fmt.Fprintf(os.Stderr, "selftest=escrow-create: reading identity bundle %s: %v\n", identityBundlePath, err)
return 1
}
var b escrow.IdentityBundle
if err := json.Unmarshal(raw, &b); err != nil {
fmt.Fprintf(os.Stderr, "selftest=escrow-create: identity bundle is not valid JSON {tunnel_token,pbs_token}: %v\n", err)
return 1
}
identity = &b
if directivePath != "" {
if d, err := os.ReadFile(directivePath); err == nil && json.Valid(d) {
directive = d
}
}
}
fmt.Printf("=== felhom-agent %s selftest=escrow-create (storage=%s posture=%s identity=%v) ===\n", version, storage, escrow.DefaultPosture, identity != nil)
// NB: nothing about R is logged. The logger never sees R; only stdout does, once. // NB: nothing about R is logged. The logger never sees R; only stdout does, once.
logger.Info("escrow: creating zero-knowledge recovery-code escrow", "storage", storage, "key_path", keyPath) logger.Info("escrow: creating zero-knowledge recovery-code escrow", "storage", storage, "key_path", keyPath, "with_identity", identity != nil)
R, res, err := escrow.Create(ctx, escrow.CreateOptions{ R, res, err := escrow.Create(ctx, escrow.CreateOptions{
KeyPath: keyPath, KeyPath: keyPath,
Posture: escrow.Posture(cfg.Escrow.Posture), Posture: escrow.Posture(cfg.Escrow.Posture),
WantOfflineCopy: offline, WantOfflineCopy: offline,
WantPaperkey: paperkey, WantPaperkey: paperkey,
IdentityBundle: identity,
}) })
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] escrow create:", err) fmt.Fprintln(os.Stderr, " [FAIL] escrow create:", err)
@@ -1120,12 +1151,15 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo
fmt.Println(res.Paperkey) fmt.Println(res.Paperkey)
} }
if len(res.IdentityBlob) > 0 {
fmt.Printf(" identity escrow: %d bytes (age-wrapped {tunnel,pbs} under the same R) · self-verify OK\n", len(res.IdentityBlob))
}
if upload { if upload {
if err := uploadEscrowBlob(ctx, cfg, res); err != nil { if err := uploadEscrowBlob(ctx, cfg, res, directive); err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] upload escrow to hub:", err) fmt.Fprintln(os.Stderr, " [FAIL] upload escrow to hub:", err)
return 1 return 1
} }
fmt.Println(" uploaded the opaque blob to the hub (host record); the hub cannot open it") fmt.Println(" uploaded the opaque blob(s) to the hub (host record); the hub cannot open them")
} }
fmt.Println("=== selftest=escrow-create OK ===") fmt.Println("=== selftest=escrow-create OK ===")
return 0 return 0
@@ -1165,6 +1199,44 @@ func runSelftestEscrowConsume(ctx context.Context, logger *slog.Logger, blobPath
return 0 return 0
} }
// runSelftestIdentityConsume recovers the IDENTITY bundle from its age blob with R (slice 10D.1/10D.3)
// and writes the recovered {tunnel_token, pbs_token} JSON to -keydest (0600). R is taken BY HAND from
// FELHOM_RECOVERY_CODE (off the command line); the recovered tokens are never logged. The drill then
// uses the tunnel token to re-establish the tunnel + the pbs token for steady-state.
func runSelftestIdentityConsume(ctx context.Context, logger *slog.Logger, blobPath, keyDest string) int {
if blobPath == "" || keyDest == "" {
fmt.Fprintln(os.Stderr, "selftest=identity-consume requires -blob and -keydest (R via env FELHOM_RECOVERY_CODE)")
return 2
}
R := os.Getenv("FELHOM_RECOVERY_CODE")
if R == "" {
fmt.Fprintln(os.Stderr, "selftest=identity-consume: set the recovery code in env FELHOM_RECOVERY_CODE (by-hand input)")
return 2
}
blob, err := os.ReadFile(blobPath)
if err != nil {
fmt.Fprintf(os.Stderr, "selftest=identity-consume: reading blob %s: %v\n", blobPath, err)
return 1
}
fmt.Printf("=== felhom-agent %s selftest=identity-consume (blob=%s → %s) ===\n", version, blobPath, keyDest)
logger.Info("escrow: recovering identity bundle from R-wrapped age blob", "blob_bytes", len(blob)) // R + tokens NOT logged
bundle, err := escrow.UnwrapIdentityBundle(ctx, blob, R)
if err != nil {
R = ""
fmt.Fprintln(os.Stderr, " [FAIL] identity consume:", err) // never contains R or token bytes
return 1
}
R = ""
raw, _ := json.Marshal(bundle)
if err := os.WriteFile(keyDest, raw, 0o600); err != nil {
fmt.Fprintln(os.Stderr, " [FAIL] writing recovered bundle:", err)
return 1
}
fmt.Printf(" [OK] identity recovered (tunnel_token + pbs_token) → %s (0600) — never printed\n", keyDest)
fmt.Println("=== selftest=identity-consume OK ===")
return 0
}
// escrowUploadRequest is the agent→hub wire shape for the opaque escrow blob. MUST stay in lockstep // 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 // with the hub's ingest struct (felhom-hub api.escrowUploadRequest). The hub stores the bytes and
// never decrypts them. // never decrypts them.
@@ -1172,20 +1244,30 @@ type escrowUploadRequest struct {
BlobB64 string `json:"blob_b64"` // base64 of the opaque R-wrapped blob (ciphertext) BlobB64 string `json:"blob_b64"` // base64 of the opaque R-wrapped blob (ciphertext)
KeyFingerprint string `json:"key_fingerprint"` // for operator display only KeyFingerprint string `json:"key_fingerprint"` // for operator display only
Posture string `json:"posture"` // e.g. "zero_knowledge" Posture string `json:"posture"` // e.g. "zero_knowledge"
// Slice 10D.1 — optional DR bundle (identity escrow + non-secret directive). Omitted in slice-7.
IdentityBlobB64 string `json:"identity_blob_b64,omitempty"`
DirectiveJSON json.RawMessage `json:"directive,omitempty"`
CreatedAt string `json:"created_at"` // RFC3339 CreatedAt string `json:"created_at"` // RFC3339
} }
// uploadEscrowBlob PUTs the opaque blob to the hub, authed with the per-host key. // uploadEscrowBlob PUTs the opaque blob (and, for 10D, the identity blob + non-secret directive) to
func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateResult) error { // the hub, authed with the per-host key. The hub stores ciphertext + non-secret fields; no usable
// secret leaves the agent.
func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateResult, directive json.RawMessage) error {
if cfg.Hub.URL == "" || cfg.Hub.HostID == "" || cfg.Hub.APIKey == "" { if cfg.Hub.URL == "" || cfg.Hub.HostID == "" || cfg.Hub.APIKey == "" {
return fmt.Errorf("hub not configured (url/host_id/api_key)") return fmt.Errorf("hub not configured (url/host_id/api_key)")
} }
body, _ := json.Marshal(escrowUploadRequest{ upReq := escrowUploadRequest{
BlobB64: base64.StdEncoding.EncodeToString(res.Blob), BlobB64: base64.StdEncoding.EncodeToString(res.Blob),
KeyFingerprint: res.KeyFingerprint, KeyFingerprint: res.KeyFingerprint,
Posture: string(res.Posture), Posture: string(res.Posture),
CreatedAt: time.Now().UTC().Format(time.RFC3339), CreatedAt: time.Now().UTC().Format(time.RFC3339),
}) }
if len(res.IdentityBlob) > 0 {
upReq.IdentityBlobB64 = base64.StdEncoding.EncodeToString(res.IdentityBlob)
upReq.DirectiveJSON = directive
}
body, _ := json.Marshal(upReq)
url := strings.TrimRight(cfg.Hub.URL, "/") + "/api/v1/hosts/" + cfg.Hub.HostID + "/escrow" url := strings.TrimRight(cfg.Hub.URL, "/") + "/api/v1/hosts/" + cfg.Hub.HostID + "/escrow"
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body)) req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body))
if err != nil { if err != nil {
@@ -1563,8 +1645,10 @@ func (f *selftestFlag) Set(v string) error {
f.mode = "escrow-create" f.mode = "escrow-create"
case "escrow-consume": case "escrow-consume":
f.mode = "escrow-consume" f.mode = "escrow-consume"
case "identity-consume":
f.mode = "identity-consume"
default: default:
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 fmt.Errorf("invalid --selftest value %q (want read|task|hub|storage|backup|restore-test|pbs-verify|bring-up|provision|escrow-create|escrow-consume|identity-consume)", v)
} }
return nil return nil
} }
+16
View File
@@ -39,6 +39,9 @@ type CreateOptions struct {
// WantPaperkey: opt-in (a) — also return the RAW-key paperkey. Single-factor + unrevocable; // WantPaperkey: opt-in (a) — also return the RAW-key paperkey. Single-factor + unrevocable;
// the caller must surface the loud caveat. Off by default. // the caller must surface the loud caveat. Off by default.
WantPaperkey bool 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 // CreateResult is the non-secret output of escrow creation. NOTE: the recovery code R is returned
@@ -50,6 +53,7 @@ type CreateResult struct {
EntropyBits float64 // R's approximate entropy (for display; never R itself) 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) 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) 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 // Create generates a recovery code R, produces the R-wrapped escrow blob from the live key, and
@@ -121,6 +125,18 @@ func Create(ctx context.Context, opts CreateOptions) (recoveryCode string, res C
} }
res.Paperkey = pk 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 return R, res, nil
} }
+100
View File
@@ -0,0 +1,100 @@
package escrow
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// Slice 10D.1 — IDENTITY escrow. The K-escrow (above) wraps the PBS *encryption key* via the
// PBS-native scrypt path. The identity bundle `{tunnel_token, pbs_token}` is arbitrary secret bytes
// (not a PBS key), so it is wrapped under the SAME recovery code `R` with **age** (`age -p`: scrypt
// + ChaCha20-Poly1305 — a vetted passphrase-AEAD, not hand-rolled). Same two-factor, zero-knowledge
// shape as the K-escrow: the blob is opaque without `R`; `R` is the only out-of-band secret. The
// K-escrow + the 10C `Consume` path are UNTOUCHED — this is purely additive. Proven by the slice-10D
// identity-restore spike (documentation/tests/slice10d-identity-restore-spike-findings.md).
//
// age is a runtime dependency for the identity path (analogous to proxmox-backup-client for K).
var ageBinary = "/usr/bin/age"
// IdentityBundle is the box's recoverable identity — the secrets a re-enrolling box needs to come
// back "as host X". Carried only inside the R-wrapped blob; never stored or logged in the clear.
type IdentityBundle struct {
TunnelToken string `json:"tunnel_token"` // the Cloudflare tunnel connector token
PBSToken string `json:"pbs_token"` // the PBS access token (steady-state; rotated on re-establish)
}
// WrapIdentity wraps arbitrary bundle bytes under `R` via `age -p` (scrypt + ChaCha20-Poly1305) and
// returns the opaque blob. `R` is fed via the pty (2 prompts: passphrase + confirm); the plaintext
// and ciphertext flow as files, so only `R` touches the tty (never logged).
func WrapIdentity(ctx context.Context, bundle []byte, recoveryCode string) ([]byte, error) {
if len(bundle) == 0 {
return nil, fmt.Errorf("escrow: WrapIdentity needs a non-empty bundle")
}
if recoveryCode == "" {
return nil, fmt.Errorf("escrow: WrapIdentity needs the recovery code (R)")
}
work, err := os.MkdirTemp("", "felhom-idesc-")
if err != nil {
return nil, fmt.Errorf("escrow: tempdir: %w", err)
}
defer os.RemoveAll(work)
in, out := filepath.Join(work, "bundle"), filepath.Join(work, "blob")
if err := os.WriteFile(in, bundle, 0o600); err != nil {
return nil, fmt.Errorf("escrow: stage bundle: %w", err)
}
// `age -p -o <out> <in>` prompts the passphrase + confirm (2) and writes the armored blob.
if err := runWithPassphrase(ctx, recoveryCode, 2, ageBinary, "-p", "-a", "-o", out, in); err != nil {
return nil, fmt.Errorf("escrow: identity wrap (age -p): %w", err)
}
return os.ReadFile(out)
}
// UnwrapIdentity recovers the bundle bytes from an age blob with `R`. A WRONG R fails CLOSED at the
// scrypt KDF (`age -d` nonzero exit, no plaintext emitted) — never a plausible-but-wrong bundle.
func UnwrapIdentity(ctx context.Context, blob []byte, recoveryCode string) ([]byte, error) {
if len(blob) == 0 {
return nil, fmt.Errorf("escrow: UnwrapIdentity needs a non-empty blob")
}
if recoveryCode == "" {
return nil, fmt.Errorf("escrow: UnwrapIdentity needs the recovery code (R)")
}
work, err := os.MkdirTemp("", "felhom-idesc-")
if err != nil {
return nil, fmt.Errorf("escrow: tempdir: %w", err)
}
defer os.RemoveAll(work)
in, out := filepath.Join(work, "blob"), filepath.Join(work, "bundle")
if err := os.WriteFile(in, blob, 0o600); err != nil {
return nil, fmt.Errorf("escrow: stage blob: %w", err)
}
// `age -d -o <out> <in>` prompts the passphrase (1).
if err := runWithPassphrase(ctx, recoveryCode, 1, ageBinary, "-d", "-o", out, in); err != nil {
return nil, fmt.Errorf("escrow: the recovery code did not unwrap the identity escrow (wrong recovery code, or a corrupt blob): %w", err)
}
return os.ReadFile(out)
}
// WrapIdentityBundle marshals + wraps an IdentityBundle under R.
func WrapIdentityBundle(ctx context.Context, b IdentityBundle, recoveryCode string) ([]byte, error) {
raw, err := json.Marshal(b)
if err != nil {
return nil, fmt.Errorf("escrow: marshal identity bundle: %w", err)
}
return WrapIdentity(ctx, raw, recoveryCode)
}
// UnwrapIdentityBundle unwraps + parses an IdentityBundle (slice 10D.3 restore-mode consumption).
func UnwrapIdentityBundle(ctx context.Context, blob []byte, recoveryCode string) (IdentityBundle, error) {
raw, err := UnwrapIdentity(ctx, blob, recoveryCode)
if err != nil {
return IdentityBundle{}, err
}
var b IdentityBundle
if err := json.Unmarshal(raw, &b); err != nil {
return IdentityBundle{}, fmt.Errorf("escrow: recovered identity bundle is malformed: %w", err)
}
return b, nil
}
+83
View File
@@ -0,0 +1,83 @@
package escrow
import (
"bytes"
"context"
"os/exec"
"runtime"
"testing"
)
func ageAvailable() bool {
if runtime.GOOS != "linux" {
return false
}
if _, err := exec.LookPath("age"); err == nil {
return true
}
return false
}
func ensureAge(t *testing.T) {
t.Helper()
if !ageAvailable() {
t.Skip("skipping: the `age` CLI + linux required (runs on the demo/build host)")
}
if p, err := exec.LookPath("age"); err == nil {
ageBinary = p
}
}
func TestIdentity_InputValidation(t *testing.T) {
ctx := context.Background()
if _, err := WrapIdentity(ctx, nil, "R"); err == nil {
t.Error("empty bundle must error")
}
if _, err := WrapIdentity(ctx, []byte("x"), ""); err == nil {
t.Error("empty R must error")
}
if _, err := UnwrapIdentity(ctx, nil, "R"); err == nil {
t.Error("empty blob must error")
}
}
// Round-trip: a bundle wraps under R and recovers byte-identical (the identity analog of K-escrow).
func TestIdentity_RoundTrip(t *testing.T) {
ensureAge(t)
ctx := context.Background()
const R = "throwaway-correct-horse-battery-staple-words"
bundle := IdentityBundle{TunnelToken: "eyJhIjoidGVzdCIsInQiOiJ4In0", PBSToken: "felhom@pbs!n100:deadbeefcafe"}
blob, err := WrapIdentityBundle(ctx, bundle, R)
if err != nil {
t.Fatalf("WrapIdentityBundle: %v", err)
}
// the blob is opaque ciphertext, not the bundle.
if bytes.Contains(blob, []byte(bundle.TunnelToken)) || bytes.Contains(blob, []byte(bundle.PBSToken)) {
t.Fatal("the blob leaks plaintext token bytes — not encrypted")
}
got, err := UnwrapIdentityBundle(ctx, blob, R)
if err != nil {
t.Fatalf("UnwrapIdentityBundle: %v", err)
}
if got != bundle {
t.Errorf("recovered bundle = %+v, want %+v", got, bundle)
}
}
// Wrong R fails CLOSED — no bundle emitted.
func TestIdentity_WrongRFailsClosed(t *testing.T) {
ensureAge(t)
ctx := context.Background()
blob, err := WrapIdentity(ctx, []byte(`{"tunnel_token":"a","pbs_token":"b"}`), "the-correct-code")
if err != nil {
t.Fatalf("WrapIdentity: %v", err)
}
if _, err := UnwrapIdentity(ctx, blob, "DEFINITELY-the-wrong-code"); err == nil {
t.Fatal("a wrong recovery code must fail closed (no bundle)")
}
// the blob is unchanged / retryable: the RIGHT code still works after a wrong attempt.
if _, err := UnwrapIdentity(ctx, blob, "the-correct-code"); err != nil {
t.Errorf("the blob was not retryable after a wrong-R attempt: %v", err)
}
}