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.
190 lines
8.4 KiB
Go
190 lines
8.4 KiB
Go
package escrow
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// R-199 links 6→8, with REAL crypto (age is present on the build/demo host; ensureAge skips
|
|
// elsewhere). These are the unit half of the session's question — "is the repository password
|
|
// actually recoverable from the sealed bundle" — and the live half is the same equality on hardware.
|
|
|
|
const testR = "correct horse battery staple sedative anaconda wobbly kingdom placard yodel"
|
|
|
|
func sealBundle(t *testing.T, b IdentityBundle, r string) []byte {
|
|
t.Helper()
|
|
blob, err := WrapIdentityBundle(context.Background(), b, r)
|
|
if err != nil {
|
|
t.Fatalf("WrapIdentityBundle: %v", err)
|
|
}
|
|
return blob
|
|
}
|
|
|
|
func fetcherFor(blob []byte) BlobFetcher {
|
|
return func(context.Context) ([]byte, bool, error) { return blob, true, nil }
|
|
}
|
|
|
|
// Scenario A (unit) — the recovered repository password is BYTE-IDENTICAL to the sealed one, and it
|
|
// is the REPOSITORY password rather than some other field of a bundle that also parses.
|
|
//
|
|
// RED-PROOF: return bundle.PBSToken (or TunnelToken, or WGPrivateKey) instead of
|
|
// bundle.ResticRepoPassword → a plausible-looking bundle yields a non-matching key → this FAILS.
|
|
// That mutation is the shape of the bug that would otherwise ship silently, because every one of
|
|
// those fields is a non-empty string that looks like a secret.
|
|
func TestRecoverOffsiteRepoPassword_ReturnsTheRepositoryPassword(t *testing.T) {
|
|
ensureAge(t)
|
|
const repoPW = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
blob := sealBundle(t, IdentityBundle{
|
|
TunnelToken: "TUNNEL-TOKEN-NOT-THE-ANSWER",
|
|
PBSToken: "PBS-TOKEN-NOT-THE-ANSWER",
|
|
WGPrivateKey: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
|
ResticRepoPassword: repoPW,
|
|
}, testR)
|
|
|
|
got, err := (OffsiteKeyRecoverer{Fetch: fetcherFor(blob)}).RecoverOffsiteRepoPassword(context.Background(), testR)
|
|
if err != nil {
|
|
t.Fatalf("recover: %v", err)
|
|
}
|
|
if got != repoPW {
|
|
t.Fatalf("the recovered key is not the sealed repository password (len %d vs %d) — a different "+
|
|
"field of the bundle was returned", len(got), len(repoPW))
|
|
}
|
|
// Belt: it must not be any of the OTHER fields, so a future refactor cannot satisfy the check
|
|
// above by coincidence.
|
|
for _, other := range []string{"TUNNEL-TOKEN-NOT-THE-ANSWER", "PBS-TOKEN-NOT-THE-ANSWER", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="} {
|
|
if got == other {
|
|
t.Fatalf("the recoverer returned the wrong bundle field")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scenario B — a WRONG recovery code fails closed, the failure names no secret, and nothing is
|
|
// written. The fail-closed property is the crypto's (age's scrypt KDF), which is why there is no
|
|
// validation step here to get wrong — the test pins that it stays that way.
|
|
func TestRecoverOffsiteRepoPassword_WrongCodeFailsClosed(t *testing.T) {
|
|
ensureAge(t)
|
|
const repoPW = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
|
blob := sealBundle(t, IdentityBundle{TunnelToken: "t", PBSToken: "p", ResticRepoPassword: repoPW}, testR)
|
|
|
|
got, err := (OffsiteKeyRecoverer{Fetch: fetcherFor(blob)}).RecoverOffsiteRepoPassword(context.Background(), "not the recovery code at all")
|
|
if err == nil {
|
|
t.Fatal("a wrong recovery code MUST fail — a plausible-but-wrong bundle is the one outcome the design forbids")
|
|
}
|
|
if got != "" {
|
|
t.Fatalf("a failed unseal returned %d bytes — there must be no partial result", len(got))
|
|
}
|
|
// The error may name the step; it may never name a secret.
|
|
for _, secret := range []string{repoPW, testR, "not the recovery code at all"} {
|
|
if strings.Contains(err.Error(), secret) {
|
|
t.Fatalf("the failure message leaked a secret: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A bundle with no repository password is its OWN answer, not a wrong-code error. Sealed before
|
|
// fork-4 (agent < v0.77.0) the field did not exist; sending the operator to re-check a correctly
|
|
// typed recovery code would be the wrong instruction.
|
|
func TestRecoverOffsiteRepoPassword_PreForkFourBundle(t *testing.T) {
|
|
ensureAge(t)
|
|
blob := sealBundle(t, IdentityBundle{TunnelToken: "t", PBSToken: "p"}, testR)
|
|
|
|
_, err := (OffsiteKeyRecoverer{Fetch: fetcherFor(blob)}).RecoverOffsiteRepoPassword(context.Background(), testR)
|
|
if !errors.Is(err, ErrNoResticPassword) {
|
|
t.Fatalf("a pre-fork-4 bundle must report its own error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// Scenario D at this layer — no blob is a clean, distinguishable answer.
|
|
func TestRecoverOffsiteRepoPassword_NoBlob(t *testing.T) {
|
|
rec := OffsiteKeyRecoverer{Fetch: func(context.Context) ([]byte, bool, error) { return nil, false, nil }}
|
|
_, err := rec.RecoverOffsiteRepoPassword(context.Background(), testR)
|
|
if !errors.Is(err, ErrNoEscrowBlob) {
|
|
t.Fatalf("absent blob must yield ErrNoEscrowBlob, got %v", err)
|
|
}
|
|
}
|
|
|
|
// Scenario F — R persists NOWHERE. TMPDIR is redirected into the test's own directory, the unseal is
|
|
// run for real, and the whole tree is then walked: no file may contain R (or the recovered password),
|
|
// and the staging directory the unseal creates must be gone.
|
|
//
|
|
// RED-PROOF: write R to a temp file anywhere in the flow (e.g. add
|
|
// `os.WriteFile(filepath.Join(work,"r"), []byte(recoveryCode), 0o600)` inside UnwrapIdentity before
|
|
// its defer removes the dir — or simply drop that defer and let the plaintext staging survive) → the
|
|
// walk finds it → this FAILS.
|
|
func TestRecoverOffsiteRepoPassword_RLeavesNoTrace(t *testing.T) {
|
|
ensureAge(t)
|
|
const repoPW = "1111111111111111111111111111111111111111111111111111111111111111"
|
|
tmp := t.TempDir()
|
|
t.Setenv("TMPDIR", tmp) // os.MkdirTemp honours this — every staging dir lands under the walk
|
|
|
|
const wrongR = "wrong code entirely"
|
|
blob := sealBundle(t, IdentityBundle{TunnelToken: "t", PBSToken: "p", ResticRepoPassword: repoPW}, testR)
|
|
if _, err := (OffsiteKeyRecoverer{Fetch: fetcherFor(blob)}).RecoverOffsiteRepoPassword(context.Background(), testR); err != nil {
|
|
t.Fatalf("recover: %v", err)
|
|
}
|
|
// A failed unseal must leave nothing either — exercise both paths before walking.
|
|
_, _ = (OffsiteKeyRecoverer{Fetch: fetcherFor(blob)}).RecoverOffsiteRepoPassword(context.Background(), wrongR)
|
|
|
|
// THE PRIMARY ASSERTION IS EMPTINESS, not content. A content scan alone is defeatable by a later
|
|
// call OVERWRITING the leaked file with a different secret — which is exactly how the first
|
|
// version of this test passed its own red-proof while R sat on disk. Nothing in this test writes
|
|
// under TMPDIR, so after both calls the tree must contain no files at all.
|
|
var survivors []string
|
|
err := filepath.Walk(tmp, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info == nil || info.IsDir() || path == tmp {
|
|
return nil
|
|
}
|
|
survivors = append(survivors, strings.TrimPrefix(path, tmp))
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(survivors) > 0 {
|
|
t.Fatalf("the unseal left %d file(s) behind under TMPDIR: %v — R, the sealed blob and the "+
|
|
"recovered plaintext all pass through there and none of them may outlive the call", len(survivors), survivors)
|
|
}
|
|
// Defence in depth: any secret that DOES appear anywhere is named, for every code used.
|
|
_ = filepath.Walk(tmp, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info == nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
body, rerr := os.ReadFile(path)
|
|
if rerr != nil {
|
|
return nil
|
|
}
|
|
for label, secret := range map[string]string{"R": testR, "a wrong R": wrongR, "the repository password": repoPW} {
|
|
if strings.Contains(string(body), secret) {
|
|
t.Errorf("%s survived on disk at %s", label, path)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
// And the staging directories are gone, not merely free of secrets.
|
|
entries, _ := os.ReadDir(tmp)
|
|
for _, e := range entries {
|
|
if e.IsDir() && strings.HasPrefix(e.Name(), "felhom-idesc-") {
|
|
t.Fatalf("an unseal staging directory survived: %s", e.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
// A fetch failure surfaces as a fetch failure, not as a wrong-code error — the operator must not be
|
|
// sent to re-read their recovery code because the hub was unreachable.
|
|
func TestRecoverOffsiteRepoPassword_FetchErrorIsDistinct(t *testing.T) {
|
|
rec := OffsiteKeyRecoverer{Fetch: func(context.Context) ([]byte, bool, error) {
|
|
return nil, false, errors.New("hub: connection refused")
|
|
}}
|
|
_, err := rec.RecoverOffsiteRepoPassword(context.Background(), testR)
|
|
if err == nil || !strings.Contains(err.Error(), "fetching the sealed bundle") {
|
|
t.Fatalf("a fetch failure must say so, got %v", err)
|
|
}
|
|
if errors.Is(err, ErrNoEscrowBlob) || errors.Is(err, ErrNoResticPassword) {
|
|
t.Fatal("a transport failure must not masquerade as a content verdict")
|
|
}
|
|
}
|