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 ` 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 ` 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 }