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:
@@ -3,6 +3,34 @@
|
||||
All notable changes to **felhom-agent** are recorded here. Update on every code
|
||||
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)
|
||||
|
||||
Turns the throwaway 10C spike harness into a real, tested **`Consume`** path: recover the PBS key
|
||||
|
||||
@@ -1,50 +1,48 @@
|
||||
# REPORT — slice 10C: escrow consumption — productionize the spike (v0.17.0) (2026-06-10)
|
||||
# REPORT — slice 10D (agent half): DR capstone — identity escrow + restore-mode consumption (v0.18.0) (2026-06-10)
|
||||
|
||||
> Overwrite-latest report. Cumulative history: [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## What was implemented
|
||||
|
||||
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 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, re-enroll, directive serving)
|
||||
is hub v0.11.0. **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).
|
||||
|
||||
### `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.
|
||||
### Identity escrow (`internal/escrow/identity.go`)
|
||||
- `WrapIdentity`/`UnwrapIdentity` (+ `…Bundle`) wrap the `{tunnel_token, pbs_token}` bundle under the
|
||||
SAME recovery code `R` via **`age`** (scrypt + ChaCha20-Poly1305 — 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 (like proxmox-backup-client for K).
|
||||
- `escrow.Create` gains an optional `IdentityBundle` → emits `IdentityBlob` under the same R
|
||||
(additive; the K-escrow + 10C `Consume` are byte-unchanged; self-verifies the identity round-trip).
|
||||
- `--selftest=escrow-create -identity-bundle <f> -directive <f>` (wrap + upload the identity blob +
|
||||
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
|
||||
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.
|
||||
### Restore-mode orchestration (10D.3)
|
||||
Composed from proven pieces: re-enroll (hub) → fetch directive (10A) → `Consume` K (10C, fingerprint
|
||||
-gated) + `identity-consume` (R by hand) → restore guests from PBS (restore-overwrite gated by 10B) →
|
||||
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`
|
||||
`-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.
|
||||
|
||||
## 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`).
|
||||
## Tests
|
||||
- identity round-trips (wrap→unwrap byte-identical; blob is opaque ciphertext); wrong R fails closed +
|
||||
blob stays retryable; input validation. K-escrow/10C unchanged. `go test ./...` green (age
|
||||
integration tests gated to a host with the `age` CLI).
|
||||
|
||||
## Versioning / docs
|
||||
- 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).
|
||||
- Version `0.17.0 → 0.18.0`; `CHANGELOG.md`. Doc 03 §9 (10D done → **slice 10 CLOSED**) + the
|
||||
host-loss DR flow with the operator-side rotation model.
|
||||
|
||||
## Out of scope (per the task)
|
||||
- DR orchestration (re-enroll in restore mode, *which* guests, identity restore) → **10D**;
|
||||
populating/serving the restore directive from the hub → **10D**.
|
||||
## Deferred (non-blocking, per the locked model)
|
||||
- The hub Config DR/Recovery **web UI** (functional via the recovery-mode admin API today) + a small
|
||||
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
|
||||
- **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.
|
||||
- The operator-in-the-loop **DR drill** (live, throwaway identity): host-loss → recovery mode →
|
||||
re-enroll (old key revoked) → R → consume → restore → re-establish → operator rotates/retires the
|
||||
old connector → only the new box serves. CC drives the `[CC]` steps; the operator does the trusted
|
||||
steps.
|
||||
|
||||
+100
-16
@@ -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.17.0"
|
||||
var version = "0.18.0"
|
||||
|
||||
func main() {
|
||||
var (
|
||||
@@ -62,10 +62,12 @@ func main() {
|
||||
custDomain string
|
||||
custName string
|
||||
custEmail string
|
||||
blobPath string
|
||||
expectedFP string
|
||||
keyDest string
|
||||
showVersion bool
|
||||
blobPath string
|
||||
expectedFP string
|
||||
keyDest string
|
||||
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.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(&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(&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(&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)")
|
||||
@@ -131,9 +135,11 @@ func main() {
|
||||
customer: provision.DocCustomer{ID: custID, Domain: custDomain, Name: custName, Email: custEmail},
|
||||
}))
|
||||
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":
|
||||
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
|
||||
// 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).
|
||||
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 == "" {
|
||||
storage = cfg.Escrow.PBSStorageID
|
||||
}
|
||||
@@ -1082,15 +1088,40 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo
|
||||
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.
|
||||
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{
|
||||
KeyPath: keyPath,
|
||||
Posture: escrow.Posture(cfg.Escrow.Posture),
|
||||
WantOfflineCopy: offline,
|
||||
WantPaperkey: paperkey,
|
||||
IdentityBundle: identity,
|
||||
})
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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 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)
|
||||
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 ===")
|
||||
return 0
|
||||
@@ -1165,6 +1199,44 @@ func runSelftestEscrowConsume(ctx context.Context, logger *slog.Logger, blobPath
|
||||
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
|
||||
// with the hub's ingest struct (felhom-hub api.escrowUploadRequest). The hub stores the bytes and
|
||||
// never decrypts them.
|
||||
@@ -1172,20 +1244,30 @@ type escrowUploadRequest struct {
|
||||
BlobB64 string `json:"blob_b64"` // base64 of the opaque R-wrapped blob (ciphertext)
|
||||
KeyFingerprint string `json:"key_fingerprint"` // for operator display only
|
||||
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
|
||||
}
|
||||
|
||||
// uploadEscrowBlob PUTs the opaque blob to the hub, authed with the per-host key.
|
||||
func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateResult) error {
|
||||
// uploadEscrowBlob PUTs the opaque blob (and, for 10D, the identity blob + non-secret directive) to
|
||||
// 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 == "" {
|
||||
return fmt.Errorf("hub not configured (url/host_id/api_key)")
|
||||
}
|
||||
body, _ := json.Marshal(escrowUploadRequest{
|
||||
upReq := escrowUploadRequest{
|
||||
BlobB64: base64.StdEncoding.EncodeToString(res.Blob),
|
||||
KeyFingerprint: res.KeyFingerprint,
|
||||
Posture: string(res.Posture),
|
||||
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"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -1563,8 +1645,10 @@ func (f *selftestFlag) Set(v string) error {
|
||||
f.mode = "escrow-create"
|
||||
case "escrow-consume":
|
||||
f.mode = "escrow-consume"
|
||||
case "identity-consume":
|
||||
f.mode = "identity-consume"
|
||||
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
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ type CreateOptions struct {
|
||||
// WantPaperkey: opt-in (a) — also return the RAW-key paperkey. Single-factor + unrevocable;
|
||||
// the caller must surface the loud caveat. Off by default.
|
||||
WantPaperkey bool
|
||||
// IdentityBundle (slice 10D.1), when set, is ALSO wrapped under the SAME R (via age) → an
|
||||
// IdentityBlob in the result. Additive: the K-escrow path is unchanged when nil.
|
||||
IdentityBundle *IdentityBundle
|
||||
}
|
||||
|
||||
// CreateResult is the non-secret output of escrow creation. NOTE: the recovery code R is returned
|
||||
@@ -50,6 +53,7 @@ type CreateResult struct {
|
||||
EntropyBits float64 // R's approximate entropy (for display; never R itself)
|
||||
OfflineCopy []byte // (b) the same wrapped blob, if WantOfflineCopy (for the customer to print)
|
||||
Paperkey string // (a) raw paperkey text, if WantPaperkey — SECRET-adjacent (single factor)
|
||||
IdentityBlob []byte // (10D.1) the age-wrapped identity bundle under the same R, if IdentityBundle set
|
||||
}
|
||||
|
||||
// Create generates a recovery code R, produces the R-wrapped escrow blob from the live key, and
|
||||
@@ -121,6 +125,18 @@ func Create(ctx context.Context, opts CreateOptions) (recoveryCode string, res C
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user