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:
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"go/ast"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
|
||||
)
|
||||
|
||||
// R-978, the root half: the controller-driven ceremony (json mode, the FELHOM_ESCROW argv with
|
||||
// `--upload`) must hand its package back instead of sending it — the daemon sends it at the claim.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed 2026-10-18): set `deferUpload: false` in escrowOptsFromCLI → this fails
|
||||
// with "json mode must defer the upload (R-978): got deferUpload=false", and the delivery test's json leg
|
||||
// then sends the package at create time ("put called 1 times"). Restored.
|
||||
func TestEscrowOptsFromCLI_JSONModeDefersUpload(t *testing.T) {
|
||||
// The FELHOM_ESCROW argv: --selftest=escrow-create --upload --output=json.
|
||||
if o := escrowOptsFromCLI("", false, false, true, "", true); !o.deferUpload || !o.upload {
|
||||
t.Fatalf("json mode must defer the upload (R-978): got deferUpload=%v upload=%v", o.deferUpload, o.upload)
|
||||
}
|
||||
// Text mode (an operator at a terminal) keeps the immediate upload; no --upload means no upload at all.
|
||||
if o := escrowOptsFromCLI("", false, false, true, "", false); o.deferUpload {
|
||||
t.Fatal("text mode must upload at once")
|
||||
}
|
||||
if o := escrowOptsFromCLI("", false, false, false, "", true); o.deferUpload {
|
||||
t.Fatal("without --upload there is nothing to defer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverEscrowPackage_JSONModeSendsNothing(t *testing.T) {
|
||||
cfg := config.Config{}
|
||||
cfg.Hub.URL, cfg.Hub.HostID, cfg.Hub.APIKey = "https://hub.invalid", "host-1", "k"
|
||||
res := escrow.CreateResult{Blob: []byte("ciphertext"), KeyFingerprint: "f2:87", Posture: escrow.PostureZeroKnowledge, IdentityBlob: []byte("id")}
|
||||
|
||||
for _, jsonMode := range []bool{true, false} {
|
||||
calls := 0
|
||||
var sent []byte
|
||||
put := func(_ context.Context, _ config.Config, body []byte) error { calls++; sent = body; return nil }
|
||||
o := escrowOptsFromCLI("", false, false, true, "", jsonMode)
|
||||
pending, uploaded, err := deliverEscrowPackage(context.Background(), cfg, res, "abc", o.deferUpload, put)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if jsonMode {
|
||||
if calls != 0 || uploaded {
|
||||
t.Fatalf("json mode: put called %d times (uploaded=%v), want 0 — the package must wait for the claim", calls, uploaded)
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(pending, &m) != nil || m["blob_b64"] == "" || m["identity_blob_b64"] == "" || m["restic_pw_sha256"] != "abc" {
|
||||
t.Fatalf("json mode: the pending package is not the hub upload body: %s", pending)
|
||||
}
|
||||
} else if calls != 1 || !uploaded || pending != nil || len(sent) == 0 {
|
||||
t.Fatalf("text mode: put called %d times, uploaded=%v, pending=%q — want one immediate upload", calls, uploaded, pending)
|
||||
}
|
||||
}
|
||||
|
||||
// A hub that is not configured fails in both modes (as the pre-R-978 upload did).
|
||||
if _, _, err := deliverEscrowPackage(context.Background(), config.Config{}, res, "", true, nil); err == nil {
|
||||
t.Fatal("an unconfigured hub must fail the ceremony even when the upload is deferred")
|
||||
}
|
||||
}
|
||||
|
||||
// R-978 wiring (the built-but-never-wired class): runDaemon's localapi.EscrowCeremonyConfig carries an
|
||||
// Upload that calls putEscrowBody. Without it every claim of a new package answers 503 and no customer
|
||||
// can finish a ceremony.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed 2026-10-18): delete the `Upload:` line in runDaemon → this fails with
|
||||
// "runDaemon's EscrowCeremonyConfig has no Upload calling putEscrowBody". Restored.
|
||||
func TestR978_CeremonyUploadIsWiredIntoTheDaemon(t *testing.T) {
|
||||
_, f := parseMain(t)
|
||||
if !callsWithin(f, "main")["runDaemon"] {
|
||||
t.Fatal("main → runDaemon is broken — the path this test asserts is not the live one")
|
||||
}
|
||||
wired := false
|
||||
for _, d := range f.Decls {
|
||||
fd, ok := d.(*ast.FuncDecl)
|
||||
if !ok || fd.Name == nil || fd.Name.Name != "runDaemon" || fd.Body == nil {
|
||||
continue
|
||||
}
|
||||
ast.Inspect(fd.Body, func(n ast.Node) bool {
|
||||
cl, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := cl.Type.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "EscrowCeremonyConfig" {
|
||||
return true
|
||||
}
|
||||
for _, el := range cl.Elts {
|
||||
if kv, ok := el.(*ast.KeyValueExpr); ok {
|
||||
if k, ok := kv.Key.(*ast.Ident); ok && k.Name == "Upload" {
|
||||
ast.Inspect(kv.Value, func(n ast.Node) bool {
|
||||
if ce, ok := n.(*ast.CallExpr); ok {
|
||||
if id, ok := ce.Fun.(*ast.Ident); ok && id.Name == "putEscrowBody" {
|
||||
wired = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if !wired {
|
||||
t.Fatal("runDaemon's EscrowCeremonyConfig has no Upload calling putEscrowBody")
|
||||
}
|
||||
}
|
||||
+57
-14
@@ -194,7 +194,7 @@ func main() {
|
||||
flag.StringVar(&pbsStorage, "storage", "", "for --selftest=escrow-create: the pbs storage whose key to escrow (default: escrow.pbs_storage_id)")
|
||||
flag.BoolVar(&paperkey, "paperkey", false, "for --selftest=escrow-create: ALSO emit the raw-key paperkey (opt-in (a); single-factor, unrevocable)")
|
||||
flag.BoolVar(&offline, "offline", false, "for --selftest=escrow-create: ALSO emit the R-wrapped offline copy to print (opt-in (b))")
|
||||
flag.BoolVar(&upload, "upload", false, "for --selftest=escrow-create: upload the opaque blob to the hub")
|
||||
flag.BoolVar(&upload, "upload", false, "for --selftest=escrow-create: upload the opaque blob to the hub (with --output=json: hand the package back in pending_upload — the agent daemon uploads it when the code is claimed, R-978)")
|
||||
flag.StringVar(&blobPath, "blob", "", "for --selftest=escrow-consume: path to the R-wrapped escrow blob file")
|
||||
flag.StringVar(&expectedFP, "fingerprint", "", "for --selftest=escrow-consume: the EXPECTED key fingerprint (the gate target)")
|
||||
flag.StringVar(&keyDest, "keydest", "", "for --selftest=escrow-consume: where to install the recovered key (0600)")
|
||||
@@ -1197,6 +1197,9 @@ func runDaemon(cfg config.Config, logger *slog.Logger, logRing *applog.Ring) int
|
||||
return cfg.Escrow.PBSStorageID
|
||||
},
|
||||
HubConfigured: cfg.Hub.URL != "" && cfg.Hub.HostID != "" && cfg.Hub.APIKey != "",
|
||||
// R-978: the ceremony hands its package back; the claim uploads it with the same PUT the root
|
||||
// subprocess used, under the same per-host key, before the code is handed out.
|
||||
Upload: func(ctx context.Context, pkg []byte) error { return putEscrowBody(ctx, cfg, pkg) },
|
||||
DRConfigured: func() bool {
|
||||
if drConfigured != nil {
|
||||
return drConfigured()
|
||||
@@ -2826,6 +2829,20 @@ type escrowCeremonyOpts struct {
|
||||
offline bool
|
||||
upload bool
|
||||
identityBundlePath string
|
||||
// deferUpload (R-978): with upload, build the hub upload body and hand it back in
|
||||
// CeremonyOutput.PendingUpload instead of sending it. Set for json mode — the controller-driven
|
||||
// ceremony, whose daemon uploads the package only when the customer claims the new code.
|
||||
deferUpload bool
|
||||
}
|
||||
|
||||
// escrowOptsFromCLI maps the escrow-create flags onto the ceremony options. json mode defers the upload
|
||||
// (R-978, pinned by TestEscrowOptsFromCLI_JSONModeDefersUpload); text mode — an operator at a terminal,
|
||||
// who sees R at once — keeps the immediate upload.
|
||||
func escrowOptsFromCLI(storage string, paperkey, offline, upload bool, identityBundlePath string, jsonMode bool) escrowCeremonyOpts {
|
||||
return escrowCeremonyOpts{
|
||||
storage: storage, paperkey: paperkey, offline: offline, upload: upload,
|
||||
identityBundlePath: identityBundlePath, deferUpload: upload && jsonMode,
|
||||
}
|
||||
}
|
||||
|
||||
// escrowCeremonyOutcome is the shared core's result. R is the ONLY secret; Sum mirrors the
|
||||
@@ -2976,16 +2993,36 @@ func escrowCeremony(ctx context.Context, cfg config.Config, logger *slog.Logger,
|
||||
ResticPwSealed: resticStaged,
|
||||
}
|
||||
if opts.upload {
|
||||
if err := uploadEscrowBlob(ctx, cfg, res, resticPwSHA256); err != nil {
|
||||
pending, uploaded, err := deliverEscrowPackage(ctx, cfg, res, resticPwSHA256, opts.deferUpload, putEscrowBody)
|
||||
if err != nil {
|
||||
// R is minted and the blob self-verified — only the hub leg failed. kind "upload" lets
|
||||
// the text shell keep the pre-extraction order (R surfaced, THEN the failure).
|
||||
return out, &escrowCeremonyErr{kind: "upload", err: err}
|
||||
}
|
||||
out.Sum.Uploaded = true
|
||||
out.Sum.PendingUpload, out.Sum.Uploaded = pending, uploaded
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// deliverEscrowPackage builds the hub upload body and either sends it now (text mode) or returns it for
|
||||
// the caller to send later (deferUpload — json mode, R-978: the daemon sends it when the customer claims
|
||||
// the code). A hub that is not configured fails in both modes, as before: a package that can never be
|
||||
// delivered is not a ceremony. put is the sender seam (putEscrowBody in production).
|
||||
func deliverEscrowPackage(ctx context.Context, cfg config.Config, res escrow.CreateResult, resticPwSHA256 string, deferUpload bool,
|
||||
put func(ctx context.Context, cfg config.Config, body []byte) error) (pending []byte, uploaded bool, err error) {
|
||||
if cfg.Hub.URL == "" || cfg.Hub.HostID == "" || cfg.Hub.APIKey == "" {
|
||||
return nil, false, fmt.Errorf("hub not configured (url/host_id/api_key)")
|
||||
}
|
||||
body := escrowUploadBody(res, resticPwSHA256)
|
||||
if deferUpload {
|
||||
return body, false, nil
|
||||
}
|
||||
if err := put(ctx, cfg, body); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return nil, true, nil
|
||||
}
|
||||
|
||||
// printEscrowTextBanner prints the text-mode header line (shared by the success and the
|
||||
// post-banner failure paths so the pre-extraction output stays byte-identical — including the
|
||||
// posture: the historical banner always printed the DEFAULT posture, not the result's).
|
||||
@@ -3049,10 +3086,7 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo
|
||||
return 2
|
||||
}
|
||||
|
||||
out, cerr := escrowCeremony(ctx, cfg, logger, escrowCeremonyOpts{
|
||||
storage: storage, paperkey: paperkey, offline: offline, upload: upload,
|
||||
identityBundlePath: identityBundlePath,
|
||||
})
|
||||
out, cerr := escrowCeremony(ctx, cfg, logger, escrowOptsFromCLI(storage, paperkey, offline, upload, identityBundlePath, jsonMode))
|
||||
if cerr != nil {
|
||||
switch cerr.kind {
|
||||
case "usage":
|
||||
@@ -3090,6 +3124,9 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo
|
||||
if out.Sum.Uploaded {
|
||||
fmt.Fprintln(os.Stderr, " uploaded the opaque blob(s) to the hub (host record); the hub cannot open them")
|
||||
}
|
||||
if len(out.Sum.PendingUpload) > 0 {
|
||||
fmt.Fprintln(os.Stderr, " package prepared, NOT uploaded: the agent uploads it when the new code is claimed (R-978)")
|
||||
}
|
||||
wire := out.Sum
|
||||
wire.RecoveryCode = out.R
|
||||
if err := json.NewEncoder(os.Stdout).Encode(wire); err != nil {
|
||||
@@ -3243,13 +3280,10 @@ type escrowUploadRequest struct {
|
||||
ResticPwSHA256 string `json:"restic_pw_sha256,omitempty"`
|
||||
}
|
||||
|
||||
// uploadEscrowBlob PUTs the opaque blob (and, for 10D, the identity blob) to
|
||||
// the hub, authed with the per-host key. The hub stores ciphertext + non-secret fields; no usable
|
||||
// secret leaves the agent.
|
||||
func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateResult, resticPwSHA256 string) error {
|
||||
if cfg.Hub.URL == "" || cfg.Hub.HostID == "" || cfg.Hub.APIKey == "" {
|
||||
return fmt.Errorf("hub not configured (url/host_id/api_key)")
|
||||
}
|
||||
// escrowUploadBody is the hub upload body for one ceremony's result: the opaque blob (and, for 10D, the
|
||||
// identity blob) plus non-secret fields. Ciphertext only — useless without R; no usable secret leaves
|
||||
// the agent. putEscrowBody sends it, authed with the per-host key.
|
||||
func escrowUploadBody(res escrow.CreateResult, resticPwSHA256 string) []byte {
|
||||
upReq := escrowUploadRequest{
|
||||
BlobB64: base64.StdEncoding.EncodeToString(res.Blob),
|
||||
KeyFingerprint: res.KeyFingerprint,
|
||||
@@ -3261,6 +3295,15 @@ func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateR
|
||||
upReq.IdentityBlobB64 = base64.StdEncoding.EncodeToString(res.IdentityBlob)
|
||||
}
|
||||
body, _ := json.Marshal(upReq)
|
||||
return body
|
||||
}
|
||||
|
||||
// putEscrowBody PUTs an escrow upload body to this host's hub record (the per-host key). Used by the
|
||||
// text-mode ceremony at once, and by the daemon when the customer claims the code (R-978).
|
||||
func putEscrowBody(ctx context.Context, cfg config.Config, body []byte) error {
|
||||
if cfg.Hub.URL == "" || cfg.Hub.HostID == "" || cfg.Hub.APIKey == "" {
|
||||
return fmt.Errorf("hub not configured (url/host_id/api_key)")
|
||||
}
|
||||
url := strings.TrimRight(cfg.Hub.URL, "/") + "/api/v1/hosts/" + cfg.Hub.HostID + "/escrow"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user