v0.129.0 — a correct code for an earlier package stops being called wrong (R-311)
gates / gates (push) Successful in 14s
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.
This commit is contained in:
+116
-1
@@ -47,17 +47,80 @@ var (
|
||||
// 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
|
||||
@@ -86,10 +149,62 @@ func (r OffsiteKeyRecoverer) RecoverOffsiteRepoPassword(ctx context.Context, rec
|
||||
}
|
||||
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
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user