1db56bf837
gates / gates (push) Successful in 14s
Yesterday's drill proved a retained escrow package opens a set-aside store and restores planted files byte-identical, while this agent answered the customer's correct code with "the recovery code did not open the sealed bundle". Nothing had ever tried the retained packages, so a correct-but-earlier code and a mistype were genuinely indistinguishable. OffsiteKeyRecoverer gains an optional FetchRetained, consulted ONLY after the current package refuses, so the ordinary recovery pays nothing for it and cannot fail because of it. A match returns ErrCodeOpensRetained wrapped in a RetainedOpenedError carrying the supersession date - no material, no code, no password. The local API answers 422: a FIFTH status added to the R-224 switch, never a restructuring of it. Fail-safe in every direction. Nil fetcher, a hub too old for the route (404 is a clean "none"), a transport failure, a malformed package: each leaves the original refusal standing. Attempts bounded at 6 because each unwrap is ~1s of scrypt. Seven tests with REAL age crypto - the two situations are indistinguishable AT THE UNWRAP, so a faked unwrap would prove nothing. Red-proof asserted applied: remove the retained lookup and the fail-closed wrong-code error returns, which is the lie in those exact words.
211 lines
11 KiB
Go
211 lines
11 KiB
Go
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
|
|
}
|