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. "could not fetch", "no blob", "wrong code" and "the blob // predates the field" are FOUR different situations for the operator and only one of them is a fault. // // ⚠ THERE WERE THREE, AND THE FOURTH WAS THE DEFECT (R-224, 2026-08-06). This comment said "three" // and named "no blob", "wrong code" and "predates the field" — while a FAILED FETCH was wrapped as an // anonymous error and fell through the caller's `default` branch into the wrong-code message. So a // hub that could not be reached was reported to the customer as a bad recovery code. // // Measured live on 2026-08-05 (CAMPAIGN-11 F3): with the hub REJECTed at the appliance's firewall and // a CORRECT current recovery code, the customer was told the code did not open their package — in // 0.0556 s, when a real unseal costs ~1 s of scrypt. The agent's own log carried the truth the whole // time (`escrow: fetching the sealed bundle: hub: transport error: … no route to host`) and the HTTP // boundary threw it away. // // The discriminator therefore has to be a VALUE, not a log line — that is what ErrBundleFetch is. var ( // ErrBundleFetch — the sealed bundle could not be FETCHED (the hub refused, was unreachable, or // the transport failed). **The recovery code was never used**, so nothing about it is known and // nothing may be said about it. Wraps the underlying cause for the operator log; carries no secret. ErrBundleFetch = errors.New("escrow: the sealed bundle could not be fetched") // 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 { // R-224: joined with ErrBundleFetch so the caller can classify by VALUE. The cause stays // wrapped for the operator log; neither carries a secret. Before this, the fetch failure was // an anonymous error and the local-api handler's `default` branch reported it to the customer // as a wrong recovery code. return "", fmt.Errorf("%w: %w", ErrBundleFetch, err) } 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 }