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.
+100 -16
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 (
@@ -62,10 +62,12 @@ func main() {
custDomain string custDomain string
custName string custName string
custEmail string custEmail string
blobPath string blobPath string
expectedFP string expectedFP string
keyDest string keyDest string
showVersion bool idBundlePath string
directivePath string
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)")
flag.Var(&selftest, "selftest", "run a self-test and exit: bare/`read` = read-only queries; `task` = reversible mutating exercise (needs -vmid); `hub` = one collect+report; `storage` = observe storage (+ -watch); `backup` = one-shot backup of -vmid; `restore-test` = restore→boot→verify→teardown of -archive (or newest backup); `pbs-verify` = trigger a PBS verify + print snapshot records; `bring-up` = restore→reset identity→size→start link-up of -archive into -vmid (needs -mode/-archive/-vmid; tears down unless -keep); `provision` = full slice-8A chain: bring-up provision + mint token + populate bootstrap config mount (needs -archive/-vmid/-customer-id/-customer-domain; keeps the guest)") flag.Var(&selftest, "selftest", "run a self-test and exit: bare/`read` = read-only queries; `task` = reversible mutating exercise (needs -vmid); `hub` = one collect+report; `storage` = observe storage (+ -watch); `backup` = one-shot backup of -vmid; `restore-test` = restore→boot→verify→teardown of -archive (or newest backup); `pbs-verify` = trigger a PBS verify + print snapshot records; `bring-up` = restore→reset identity→size→start link-up of -archive into -vmid (needs -mode/-archive/-vmid; tears down unless -keep); `provision` = full slice-8A chain: bring-up provision + mint token + populate bootstrap config mount (needs -archive/-vmid/-customer-id/-customer-domain; keeps the guest)")
@@ -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)
}
}