package escrow import ( "context" "errors" "fmt" ) // R-199 links 6→8 — fetch this host's own sealed identity blob, open it with the customer's recovery // code R, and hand back EXACTLY ONE field: the offsite restic repository password. // // WHY ONLY ONE FIELD. The bundle also carries the Cloudflare tunnel token, the PBS access token and // the WG private key (see IdentityBundle). The caller in this flow — the in-guest controller, one // trust tier down — needs none of them, and returning them would widen the blast radius of a // controller compromise for no gain. Narrowing costs nothing here and is not recoverable later. // // WHY R NEVER TOUCHES DISK. `UnwrapIdentity` stages the BLOB and the recovered plaintext in a // `MkdirTemp` that it removes, and feeds R through the pty; R itself is never written. This wrapper // keeps that property: it takes R as an argument, passes it straight through, and holds no copy. // Callers must clear their own reference (the `R = ""` discipline in cmd/felhom-agent). // // The errors below are DISTINCT on purpose. "no blob", "wrong code" and "the blob predates the field" // are three different situations for the operator and only one of them is a fault. var ( // ErrNoEscrowBlob — the hub holds no sealed bundle for this host. Not a fault: no ceremony has run. ErrNoEscrowBlob = errors.New("escrow: the hub holds no sealed identity bundle for this host (no ceremony has run)") // ErrNoResticPassword — the bundle opened, but carries no repository password. Real and expected // for a pre-fork-4 blob (agent < v0.77.0, 2026-07-09): the field did not exist and CANNOT be // retro-fitted, because R is never retained. Distinguished from a wrong code so the operator is // not sent hunting for a mistyped recovery code that was typed correctly. ErrNoResticPassword = errors.New("escrow: the recovered bundle carries NO offsite repository password (a pre-fork-4 blob — the field did not exist when it was sealed and cannot be retro-fitted)") ) // BlobFetcher yields this host's own opaque identity-escrow blob. present=false is a clean "none". // An interface-free func field keeps this package free of any dependency on the hub client. type BlobFetcher func(ctx context.Context) (blob []byte, present bool, err error) // OffsiteKeyRecoverer is the assembled links 6→8. Construct it with a fetcher; call it with R. type OffsiteKeyRecoverer struct { Fetch BlobFetcher } // RecoverOffsiteRepoPassword fetches, unseals and extracts. It returns ONLY the repository password. // // A WRONG RECOVERY CODE FAILS CLOSED at the scrypt KDF inside UnwrapIdentity — `age -d` exits // non-zero and emits no plaintext, so there is no partial result and nothing is written anywhere. // That property is the crypto's, not a check here, which is why this function has no "validate R" // step to get wrong. // // NOTHING IS LOGGED BY THIS FUNCTION and no error it returns contains R, the password, or blob bytes. func (r OffsiteKeyRecoverer) RecoverOffsiteRepoPassword(ctx context.Context, recoveryCode string) (string, error) { if r.Fetch == nil { return "", fmt.Errorf("escrow: recoverer has no blob fetcher configured") } if recoveryCode == "" { return "", fmt.Errorf("escrow: the recovery code is required") } blob, present, err := r.Fetch(ctx) if err != nil { return "", fmt.Errorf("escrow: fetching the sealed bundle: %w", err) // carries no secret } if !present || len(blob) == 0 { return "", ErrNoEscrowBlob } bundle, err := UnwrapIdentityBundle(ctx, blob, recoveryCode) if err != nil { return "", err // already the fail-closed "the recovery code did not unwrap…" message; no secret in it } if bundle.ResticRepoPassword == "" { return "", ErrNoResticPassword } return bundle.ResticRepoPassword, nil }