The new escrow package reaches the hub only when its code is claimed (R-978, unreleased)
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.
This commit is contained in:
1 parent
f7b7308c4d
commit
8561dc8a5c
9 files changed
+592
-36
No files matched your search
@@ -2,12 +2,14 @@ package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -19,25 +21,91 @@ import (
|
||||
|
||||
const testR = "canary-alpha-bravo-charlie-delta-echo-foxtrot-golf-hotel-india"
|
||||
|
||||
// cannedCeremonyJSON is shaped exactly like the agent's --output=json object (spike §2.6 values).
|
||||
// cannedCeremonyJSON is shaped exactly like the agent's --output=json object (spike §2.6 values;
|
||||
// version 2, R-978: the package comes back un-uploaded in pending_upload). The package is a MODEL of
|
||||
// the R-wrapped blob: it "opens" with exactly the code it was sealed under (fakeEscrowHub.opensWith).
|
||||
func cannedCeremonyJSON(r string) string {
|
||||
return fmt.Sprintf(`{"version":1,"recovery_code":%q,"key_fingerprint":"f2:87:68:2a:88:50:16:01","entropy_bits":129,"blob_bytes":383,"identity_blob_bytes":450,"restic_pw_sealed":true,"uploaded":true}`, r)
|
||||
return fmt.Sprintf(`{"version":2,"recovery_code":%q,"key_fingerprint":"f2:87:68:2a:88:50:16:01","entropy_bits":129,"blob_bytes":383,"identity_blob_bytes":450,"restic_pw_sealed":true,"uploaded":false,"pending_upload":%s}`,
|
||||
r, sealedPackage(r))
|
||||
}
|
||||
|
||||
// sealedPackage is the model hub upload body for a ceremony that minted code r.
|
||||
func sealedPackage(r string) string {
|
||||
return fmt.Sprintf(`{"blob_b64":%q,"key_fingerprint":"f2:87:68:2a:88:50:16:01","posture":"zero_knowledge","created_at":"2026-06-10T12:00:00Z"}`,
|
||||
base64.StdEncoding.EncodeToString([]byte("sealed-under:"+r)))
|
||||
}
|
||||
|
||||
// fakeEscrowHub is the hub's current escrow package for this host (the PUT target). Upload replaces it,
|
||||
// as the hub does when the sealed secrets are unchanged (the R-978 case: no retained copy).
|
||||
type fakeEscrowHub struct {
|
||||
mu sync.Mutex
|
||||
current []byte
|
||||
uploads int
|
||||
err error // non-nil → the upload fails, nothing stored
|
||||
gate chan struct{} // non-nil → the upload blocks until closed
|
||||
entered chan struct{} // non-nil → closed when an upload starts
|
||||
}
|
||||
|
||||
func (f *fakeEscrowHub) upload(ctx context.Context, pkg []byte) error {
|
||||
if f.entered != nil {
|
||||
close(f.entered)
|
||||
f.entered = nil
|
||||
}
|
||||
if f.gate != nil {
|
||||
<-f.gate
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.current = append([]byte(nil), pkg...)
|
||||
f.uploads++
|
||||
return nil
|
||||
}
|
||||
|
||||
// opensWith reports whether the hub's CURRENT package opens with code r — the customer's question.
|
||||
func (f *fakeEscrowHub) opensWith(r string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
var pkg struct {
|
||||
BlobB64 string `json:"blob_b64"`
|
||||
}
|
||||
if json.Unmarshal(f.current, &pkg) != nil {
|
||||
return false
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(pkg.BlobB64)
|
||||
return err == nil && string(raw) == "sealed-under:"+r
|
||||
}
|
||||
|
||||
func (f *fakeEscrowHub) count() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.uploads
|
||||
}
|
||||
|
||||
// newEscrowTestServer builds a server with the ceremony configured and every seam faked.
|
||||
func newEscrowTestServer(t *testing.T, run ceremonyRunner) *Server {
|
||||
t.Helper()
|
||||
srv, _ := newEscrowTestServerHub(t, run, &fakeEscrowHub{})
|
||||
return srv
|
||||
}
|
||||
|
||||
// newEscrowTestServerHub is newEscrowTestServer with the hub's escrow record exposed (R-978).
|
||||
func newEscrowTestServerHub(t *testing.T, run ceremonyRunner, hub *fakeEscrowHub) (*Server, *fakeEscrowHub) {
|
||||
t.Helper()
|
||||
srv := newTestServerS(t, &fakeGuests{}, &fakeBackups{}, &fakeStore{}, nil)
|
||||
srv.escrowCeremony = &EscrowCeremonyConfig{
|
||||
PBSStorageID: "felhom-pbs",
|
||||
HubConfigured: true,
|
||||
DRConfigured: func() bool { return true },
|
||||
Upload: hub.upload,
|
||||
}
|
||||
srv.ceremonyRun = run
|
||||
srv.escrowSudoCheck = func(context.Context) error { return nil }
|
||||
srv.escrowLookPath = func(string) (string, error) { return "/usr/bin/age", nil }
|
||||
srv.statFile = func(string) bool { return true }
|
||||
return srv
|
||||
return srv, hub
|
||||
}
|
||||
|
||||
// okRunner returns the canned success output and counts invocations.
|
||||
@@ -160,8 +228,8 @@ func TestEscrowCeremony_OneShotClaim(t *testing.T) {
|
||||
if !strings.Contains(body, `"phase":"done"`) || !strings.Contains(body, `"claimable":true`) {
|
||||
t.Fatalf("status not done/claimable: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"restic_pw_sealed":true`) || !strings.Contains(body, `"uploaded":true`) {
|
||||
t.Fatalf("summary fields missing: %s", body)
|
||||
if !strings.Contains(body, `"restic_pw_sealed":true`) || !strings.Contains(body, `"uploaded":false`) {
|
||||
t.Fatalf("summary fields missing (R-978: not uploaded before the claim): %s", body)
|
||||
}
|
||||
assertNoR(t, "status payload", body)
|
||||
|
||||
@@ -199,7 +267,8 @@ func TestEscrowCeremony_OneShotClaim(t *testing.T) {
|
||||
|
||||
// Post-claim status: claimed, not claimable, still phase done.
|
||||
st2 := do(t, h, "GET", "/escrow/ceremony/status", "A", "")
|
||||
if !strings.Contains(st2.Body.String(), `"claimed":true`) || strings.Contains(st2.Body.String(), `"claimable":true`) {
|
||||
if !strings.Contains(st2.Body.String(), `"claimed":true`) || strings.Contains(st2.Body.String(), `"claimable":true`) ||
|
||||
!strings.Contains(st2.Body.String(), `"uploaded":true`) {
|
||||
t.Fatalf("post-claim status wrong: %s", st2.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -344,7 +413,7 @@ func TestEscrowCeremony_FailurePaths(t *testing.T) {
|
||||
})
|
||||
t.Run("wrong version", func(t *testing.T) {
|
||||
srv := newEscrowTestServer(t, func(context.Context) ([]byte, []byte, int, error) {
|
||||
return []byte(`{"version":2,"recovery_code":"` + testR + `"}`), nil, 0, nil
|
||||
return []byte(`{"version":1,"recovery_code":"` + testR + `","uploaded":true}`), nil, 0, nil
|
||||
})
|
||||
h := srv.Handler()
|
||||
startAndWait(t, srv, h)
|
||||
|
||||
Reference in new issue
Block a user