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
+16
View File
@@ -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
}
+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)
}
}