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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user