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)") // ErrCodeOpensRetained — the code did NOT open the package the hub currently holds, and DID open a // RETAINED (earlier) one. R-311. // // ⚠ THIS IS NOT A FAILURE OF THE CUSTOMER'S. It is the single most important distinction on this // path, because until 2026-08-12 it was indistinguishable from a mistype and was reported as one. // The screen could only say "it may be a typo, or it may be an older code, and we cannot tell them // apart from here" — and it could not tell them apart because NOTHING EVER LOOKED. Now something // looks, so the sentence can stop hedging. // // It carries no material and no code: only WHICH earlier package opened, by its supersession date, // which is the one fact the customer needs to recognise it. ErrCodeOpensRetained = errors.New("escrow: the recovery code did not open the CURRENT sealed package, but it DID open a retained earlier one") ) // RetainedMatch says which retained package a code opened. Returned inside RetainedOpenedError; it // carries no secret — not the code, not the bundle, not the repository password. type RetainedMatch struct { // SupersededAt is when this package stopped being the current one (hub-supplied, RFC3339-ish). // It is what the recovery screen shows so the customer can recognise which code they are holding. SupersededAt string // KeyFingerprint is the escrow key fingerprint of that package — operator-log material only. KeyFingerprint string // Index is the hub's position label within ONE response. Not durable; do not persist it. Index int // HasResticPassword is false when the retained package opened but carries no repository password // (a pre-fork-4 seal). The code is still CORRECT; the history behind it still cannot be reopened. // Collapsing this into "recoverable" would repeat R-202's mistake on a new surface. HasResticPassword bool } // RetainedOpenedError wraps ErrCodeOpensRetained with the match. Callers classify with errors.Is on // the sentinel and read the detail with errors.As. type RetainedOpenedError struct { Match RetainedMatch } func (e *RetainedOpenedError) Error() string { return ErrCodeOpensRetained.Error() + " (superseded_at=" + e.Match.SupersededAt + ")" } func (e *RetainedOpenedError) Unwrap() error { return ErrCodeOpensRetained } // 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) // RetainedBlob is one retained sealed package as the recoverer sees it: opaque bytes plus the labels // needed to name it. No secret. type RetainedBlob struct { Blob []byte SupersededAt string KeyFingerprint string Index int } // RetainedFetcher yields this host's RETAINED sealed packages, newest-superseded first. An empty // slice is a clean "none". R-311. type RetainedFetcher func(ctx context.Context) (blobs []RetainedBlob, unopenable int, err error) // OffsiteKeyRecoverer is the assembled links 6→8. Construct it with a fetcher; call it with R. type OffsiteKeyRecoverer struct { Fetch BlobFetcher // FetchRetained is OPTIONAL and consulted ONLY after the current package has refused the code. // nil keeps the pre-R-311 behaviour exactly: a refusal stays a refusal. That is deliberate — an // agent wired without it must not behave differently from one that has no retained packages. FetchRetained RetainedFetcher // MaxRetainedTried bounds the scrypt work a single wrong code can cost. Each attempt is ~1 s of // KDF by design, so an unbounded loop over a long supersession history would turn one wrong code // into a minutes-long hang on the customer's screen. 0 means the built-in default. MaxRetainedTried int } // defaultMaxRetainedTried — six attempts is ~6 s worst case, which is a slow screen and not a hang. const defaultMaxRetainedTried = 6 // 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 { // R-311 — BEFORE calling this a wrong code, ask whether it is the RIGHT code for an EARLIER // package. The engine fails closed identically either way, so the two are indistinguishable // from the unwrap alone; the only way to tell is to try. Until this existed nobody tried, and // the screen said so out loud ("innen nem tudjuk megkülönböztetni őket") — a true sentence // about our own incuriosity, read by the customer as a statement about their code. if m, ok := r.tryRetained(ctx, recoveryCode); ok { return "", &RetainedOpenedError{Match: m} } return "", err // the fail-closed "the recovery code did not unwrap…" message; no secret in it } if bundle.ResticRepoPassword == "" { return "", ErrNoResticPassword } return bundle.ResticRepoPassword, nil } // tryRetained reports whether the code opens one of this host's RETAINED packages, and which. // // FAILURE HERE IS SILENT AND MEANS "NO", NEVER "YES" and never a different verdict for the caller. A // hub that cannot answer, a route an older hub does not have, a malformed blob — each leaves the // original refusal standing, unchanged. That is the fail-safe direction: the worst outcome of this // function breaking is the behaviour we had before it existed. // // NOTHING IS LOGGED HERE and no return value carries the code, a bundle or a password. func (r OffsiteKeyRecoverer) tryRetained(ctx context.Context, recoveryCode string) (RetainedMatch, bool) { if r.FetchRetained == nil { return RetainedMatch{}, false } blobs, _, err := r.FetchRetained(ctx) if err != nil || len(blobs) == 0 { return RetainedMatch{}, false } limit := r.MaxRetainedTried if limit <= 0 { limit = defaultMaxRetainedTried } for i, rb := range blobs { if i >= limit { break } if len(rb.Blob) == 0 { continue } bundle, uerr := UnwrapIdentityBundle(ctx, rb.Blob, recoveryCode) if uerr != nil { continue // this one is not the customer's; try the next } return RetainedMatch{ SupersededAt: rb.SupersededAt, KeyFingerprint: rb.KeyFingerprint, Index: rb.Index, // A retained package can itself predate the repository-password field. The code is still // correct and must be told so — but the history behind it still cannot be reopened, and // saying otherwise would be a promise this path cannot keep. HasResticPassword: bundle.ResticRepoPassword != "", }, true } return RetainedMatch{}, false }