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
|
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
|
||||||
|
|||||||
@@ -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).
|
> 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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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