6d7904786c
gates / gates (push) Successful in 7s
Link 7's only production caller was a --selftest reading R from an env var. Link 8 did not exist: that selftest writes the whole bundle JSON and its success message named "tunnel_token + pbs_token" -- accurate when written, a misstatement since v0.77.0 sealed the offsite repository password into the same bundle. It now names what THIS bundle carried and what it did not. POST /escrow/recover-offsite-password: the controller supplies R, the agent fetches this host's own blob from the hub (self-scoped by the per-host key), unseals it, and returns ONLY the offsite restic repository password plus its sha256. Not the tunnel token, not the PBS token, not the WG key -- the controller is a trust tier down and needs none of them. R: in memory for one call, cleared on every path, never on disk, never in argv, never logged, never echoed. A test redirects TMPDIR and asserts the tree is EMPTY afterwards -- emptiness rather than a content scan, because a content scan is defeated by a later call overwriting the leaked file, which is how the first version of that test passed its own red-proof while R sat on disk. Three distinct outcomes: no blob (404), a bundle that opens but predates the field (409), a code that does not open it (400, fail-closed at the KDF, nothing written). The wiring is asserted by an AST walk from func main() to the Options field, not by grep.
75 lines
3.7 KiB
Go
75 lines
3.7 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. "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
|
|
}
|