8561dc8a5c
The root ceremony uploaded the new package before the new code was claimed, and the hub overwrites the current package when the sealed secrets are unchanged: a customer who started the ceremony again and walked away kept a written-down code that opened nothing. Json mode (the FELHOM_ESCROW argv, unchanged) now returns the package (CeremonyOutput v2, PendingUpload); the daemon uploads it at POST /escrow/ceremony/claim before handing out the code. A failed upload is a 502 with the code still claimable; TTL and a new start wait for an in-flight claim upload; no uploader is a 503. Text mode still uploads at once. No sudoers change. Tests TestEscrowCeremony_R978_* (localapi) and the cmd-side defer/delivery/wiring tests, red-proved.
220 lines
8.8 KiB
Go
220 lines
8.8 KiB
Go
package localapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// R-978 — starting the escrow ceremony again must not make the customer's written-down recovery code
|
|
// useless. The hub overwrites the current package when the sealed secrets are unchanged, so the NEW
|
|
// package may reach the hub only when its code is claimed. These tests ask the customer's question of
|
|
// the hub's current package — „does my code still open it?" — not whether some function ran.
|
|
|
|
const (
|
|
firstR = "first-ceremony-code-alpha"
|
|
secondR = "second-ceremony-code-bravo"
|
|
)
|
|
|
|
// sequenceRunner mints the given codes in order, one per ceremony.
|
|
func sequenceRunner(codes ...string) ceremonyRunner {
|
|
i := 0
|
|
return func(context.Context) ([]byte, []byte, int, error) {
|
|
r := codes[i]
|
|
if i < len(codes)-1 {
|
|
i++
|
|
}
|
|
return []byte(cannedCeremonyJSON(r)), nil, 0, nil
|
|
}
|
|
}
|
|
|
|
func claimCode(t *testing.T, h http.Handler) (int, string) {
|
|
t.Helper()
|
|
w := do(t, h, "POST", "/escrow/ceremony/claim", "A", "")
|
|
return w.Code, w.Body.String()
|
|
}
|
|
|
|
// THE R-978 CASE. Ceremony 1 is claimed (the customer writes code 1 down). Ceremony 2 is started and
|
|
// abandoned — three ways: its code expires unclaimed, a third start supersedes it, the agent restarts.
|
|
// In every case the hub's current package must still open with code 1, and nothing was uploaded for 2.
|
|
//
|
|
// COMPANION RED-PROOF (observed 2026-10-18): restore the pre-R-978 shape — the package uploaded when the
|
|
// ceremony finishes (s.escrowCeremony.Upload(base, out.PendingUpload) in the job goroutine, as the root
|
|
// subprocess did with `--upload`) and nothing left for the claim (finishEscrowCeremony(job, R, nil)) →
|
|
// all three subtests FAIL with "the abandoned second ceremony replaced the package: code 1 no longer
|
|
// opens it (uploads=2)" (uploads=3 for the superseding start). Restored.
|
|
func TestEscrowCeremony_R978_AbandonedSecondCeremonyKeepsFirstCode(t *testing.T) {
|
|
setup := func(t *testing.T) (*Server, *fakeEscrowHub, http.Handler, *time.Time) {
|
|
srv, hub := newEscrowTestServerHub(t, sequenceRunner(firstR, secondR, "third-code-charlie"), &fakeEscrowHub{})
|
|
cur := testNow
|
|
srv.now = func() time.Time { return cur }
|
|
h := srv.Handler()
|
|
startAndWait(t, srv, h)
|
|
if code, body := claimCode(t, h); code != http.StatusOK || !strings.Contains(body, firstR) {
|
|
t.Fatalf("claim of ceremony 1: %d %s", code, body)
|
|
}
|
|
if !hub.opensWith(firstR) || hub.count() != 1 {
|
|
t.Fatalf("after the first claim the hub must hold code 1's package (uploads=%d)", hub.count())
|
|
}
|
|
startAndWait(t, srv, h) // ceremony 2 — about to be abandoned
|
|
return srv, hub, h, &cur
|
|
}
|
|
check := func(t *testing.T, hub *fakeEscrowHub) {
|
|
t.Helper()
|
|
if !hub.opensWith(firstR) {
|
|
t.Fatalf("the abandoned second ceremony replaced the package: code 1 no longer opens it (uploads=%d)", hub.count())
|
|
}
|
|
if hub.opensWith(secondR) || hub.count() != 1 {
|
|
t.Fatalf("a package for the never-claimed code 2 reached the hub (uploads=%d)", hub.count())
|
|
}
|
|
}
|
|
|
|
t.Run("code 2 expires unclaimed", func(t *testing.T) {
|
|
_, hub, h, cur := setup(t)
|
|
*cur = cur.Add(escrowClaimTTL + time.Minute)
|
|
if code, _ := claimCode(t, h); code != http.StatusGone {
|
|
t.Fatalf("claim after TTL: got %d, want 410", code)
|
|
}
|
|
check(t, hub)
|
|
})
|
|
t.Run("a third start supersedes code 2", func(t *testing.T) {
|
|
srv, hub, h, _ := setup(t)
|
|
startAndWait(t, srv, h) // ceremony 3, also left unclaimed
|
|
check(t, hub)
|
|
})
|
|
t.Run("the agent restarts before code 2 is claimed", func(t *testing.T) {
|
|
_, hub, _, _ := setup(t)
|
|
// A restart is a new process with an empty slot; only the hub's record survives it.
|
|
srv2, _ := newEscrowTestServerHub(t, sequenceRunner("unused"), hub)
|
|
if code, _ := claimCode(t, srv2.Handler()); code != http.StatusNotFound {
|
|
t.Fatalf("claim after a restart: got %d, want 404", code)
|
|
}
|
|
check(t, hub)
|
|
})
|
|
}
|
|
|
|
// The positive control: CLAIMING code 2 is what replaces the package — the code handed out opens the
|
|
// package the hub now holds, and the claim answers only after the hub took it.
|
|
func TestEscrowCeremony_R978_ClaimStoresThePackageBeforeHandingOutTheCode(t *testing.T) {
|
|
srv, hub := newEscrowTestServerHub(t, sequenceRunner(firstR, secondR), &fakeEscrowHub{})
|
|
h := srv.Handler()
|
|
startAndWait(t, srv, h)
|
|
if hub.count() != 0 {
|
|
t.Fatalf("the package was uploaded before any claim (uploads=%d)", hub.count())
|
|
}
|
|
claimCode(t, h)
|
|
startAndWait(t, srv, h)
|
|
code, body := claimCode(t, h)
|
|
if code != http.StatusOK || !strings.Contains(body, secondR) {
|
|
t.Fatalf("claim of ceremony 2: %d %s", code, body)
|
|
}
|
|
if !hub.opensWith(secondR) || hub.count() != 2 {
|
|
t.Fatalf("after claiming code 2 the hub must hold its package (uploads=%d, opens with 2: %v)", hub.count(), hub.opensWith(secondR))
|
|
}
|
|
}
|
|
|
|
// A failed upload hands out NO code, keeps the code claimable and the previous package current; a retry
|
|
// that reaches the hub succeeds.
|
|
func TestEscrowCeremony_R978_UploadFailureKeepsTheCodeClaimable(t *testing.T) {
|
|
hub := &fakeEscrowHub{err: errors.New("hub returned HTTP 502")}
|
|
srv, _ := newEscrowTestServerHub(t, sequenceRunner(firstR), hub)
|
|
h := srv.Handler()
|
|
startAndWait(t, srv, h)
|
|
|
|
code, body := claimCode(t, h)
|
|
if code != http.StatusBadGateway {
|
|
t.Fatalf("claim with the hub failing: got %d, want 502 (%s)", code, body)
|
|
}
|
|
assertNoRIn(t, "failed-upload claim payload", body, firstR)
|
|
if st := do(t, h, "GET", "/escrow/ceremony/status", "A", "").Body.String(); !strings.Contains(st, `"claimable":true`) || !strings.Contains(st, `"uploaded":false`) {
|
|
t.Fatalf("after a failed upload the code must stay claimable and un-uploaded: %s", st)
|
|
}
|
|
|
|
hub.mu.Lock()
|
|
hub.err = nil
|
|
hub.mu.Unlock()
|
|
code, body = claimCode(t, h)
|
|
if code != http.StatusOK || !strings.Contains(body, firstR) || !hub.opensWith(firstR) {
|
|
t.Fatalf("retry after the hub recovered: %d %s (opens: %v)", code, body, hub.opensWith(firstR))
|
|
}
|
|
}
|
|
|
|
// While the claim's upload is in flight, neither the TTL nor a new start may take the code away — else
|
|
// the hub would hold a package whose code nobody was given (the R-978 failure, reached the other way).
|
|
func TestEscrowCeremony_R978_TTLAndRestartWaitForAnInFlightClaim(t *testing.T) {
|
|
hub := &fakeEscrowHub{gate: make(chan struct{}), entered: make(chan struct{})}
|
|
entered := hub.entered
|
|
srv, _ := newEscrowTestServerHub(t, sequenceRunner(firstR, secondR), hub)
|
|
var mu = make(chan struct{}, 1)
|
|
mu <- struct{}{}
|
|
cur := testNow
|
|
srv.now = func() time.Time { <-mu; defer func() { mu <- struct{}{} }(); return cur }
|
|
h := srv.Handler()
|
|
startAndWait(t, srv, h)
|
|
|
|
type res struct {
|
|
code int
|
|
body string
|
|
}
|
|
done := make(chan res, 1)
|
|
go func() { c, b := claimCode(t, h); done <- res{c, b} }()
|
|
<-entered
|
|
|
|
<-mu
|
|
cur = cur.Add(escrowClaimTTL + time.Minute) // the TTL passes mid-upload
|
|
mu <- struct{}{}
|
|
if st := do(t, h, "GET", "/escrow/ceremony/status", "A", "").Body.String(); strings.Contains(st, `"phase":"unclaimed_void"`) {
|
|
t.Fatalf("the TTL voided the code while its package was uploading: %s", st)
|
|
}
|
|
if w := do(t, h, "POST", "/escrow/ceremony", "A", ""); w.Code != http.StatusConflict {
|
|
t.Fatalf("a new start during the claim's upload: got %d, want 409", w.Code)
|
|
}
|
|
if code, _ := claimCode(t, h); code != http.StatusConflict {
|
|
t.Fatalf("a second claim during the upload: got %d, want 409", code)
|
|
}
|
|
close(hub.gate)
|
|
r := <-done
|
|
if r.code != http.StatusOK || !strings.Contains(r.body, firstR) || !hub.opensWith(firstR) {
|
|
t.Fatalf("the in-flight claim must deliver the code whose package it stored: %d %s", r.code, r.body)
|
|
}
|
|
}
|
|
|
|
// No upload configured → the claim refuses rather than hand out a code for a package the hub lacks.
|
|
func TestEscrowCeremony_R978_NoUploaderRefusesTheClaim(t *testing.T) {
|
|
srv, _ := newEscrowTestServerHub(t, sequenceRunner(firstR), &fakeEscrowHub{})
|
|
srv.escrowCeremony.Upload = nil
|
|
h := srv.Handler()
|
|
startAndWait(t, srv, h)
|
|
code, body := claimCode(t, h)
|
|
if code != http.StatusServiceUnavailable {
|
|
t.Fatalf("claim with no uploader: got %d, want 503 (%s)", code, body)
|
|
}
|
|
assertNoRIn(t, "no-uploader claim payload", body, firstR)
|
|
}
|
|
|
|
// An output that uploaded by itself (the pre-R-978 shape) or carries no package fails the ceremony.
|
|
func TestEscrowCeremony_R978_OutputWithoutPendingPackageFails(t *testing.T) {
|
|
srv, _ := newEscrowTestServerHub(t, func(context.Context) ([]byte, []byte, int, error) {
|
|
return []byte(`{"version":2,"recovery_code":"` + firstR + `","uploaded":true}`), nil, 0, nil
|
|
}, &fakeEscrowHub{})
|
|
h := srv.Handler()
|
|
startAndWait(t, srv, h)
|
|
st := do(t, h, "GET", "/escrow/ceremony/status", "A", "").Body.String()
|
|
if !strings.Contains(st, `"phase":"failed"`) || !strings.Contains(st, "no pending package") {
|
|
t.Fatalf("want failed: %s", st)
|
|
}
|
|
if code, _ := claimCode(t, h); code != http.StatusConflict {
|
|
t.Fatalf("claim on a failed ceremony: got %d, want 409", code)
|
|
}
|
|
}
|
|
|
|
func assertNoRIn(t *testing.T, where, body, r string) {
|
|
t.Helper()
|
|
if strings.Contains(body, r) {
|
|
t.Fatalf("%s carries the recovery code", where)
|
|
}
|
|
}
|