carlos / platform Public

host: the pin's release verifier re-verifies root's signed receipt through main's Complete in its replay form

StopReceiptReleases trusted the journaled key and digest alone, which an
agent writes in its own CAS (Plan 11 Task E forbids it). It now carries
main's RelocationReleaseReceipts and, once the journal binds the release
to its discharged receipt, calls Complete, whose replay branch re-reads
the bytes and checks the digest, the canonical form, the bound fields,
the roster entry and root's signature, and writes nothing. The deleted
signature tests are restored against main's receipt: unsigned, another
box's key, another enrollment, a box missing from the roster, no roster,
a wrong digest, and field mismatches. A verify-only export from main
(RelocationReleaseReceipts.Verify) would replace the replay call.

Merge review fix round 1, finding 2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 8b90c6be526f59544007be604ed863f2a89a4e63 parent bcaf791
2 files changed, +178 −19
  • internal/host/relocationreleases.go +37 −11
  • internal/host/relocationreleases_test.go +141 −8
diff --git a/internal/host/relocationreleases.go b/internal/host/relocationreleases.go
index a4a1c9ce9..20886ed40 100644
--- a/internal/host/relocationreleases.go
+++ b/internal/host/relocationreleases.go
@@ -17,30 +17,46 @@ const RelocationReleaseObligation = "relocation-release"
// StopReceiptReleases is hostclaim.ReleaseAuthority over main's
// relocation-release discharge (claim Plan 11): a source's journaled
// release counts only once RelocationReleaseReceipts.Complete has verified
-// root's exact receipt (its bytes, digest, root's signature under the
-// releasing box's census key from the roster, and the session, operation,
-// phase, box, enrollment and installation it names), journaled its key and
-// digest, and discharged the obligation. The release must cite exactly that
-// key and digest, and the claim must owe this operation no relocation
-// release any more. Main's Complete is the one receipt check; this only
-// binds the release to it.
+// root's exact receipt, journaled its key and digest, and discharged the
+// obligation. The release must cite exactly that key and digest, the claim
+// must owe this operation no relocation release any more, and the receipt
+// is verified again here, at the pin, by the region worker (Plan 11 Task
+// E: the adapter consumes the same signed receipt, never an agent-written
+// digest alone). That check is main's own: Complete's replay form, which
+// re-reads the bytes and checks the digest, the canonical form, every
+// bound field, the roster entry and root's signature under the releasing
+// box's census key, and writes nothing once the journal holds the key and
+// digest and no obligation is owed. A verify-only export from main
+// (RelocationReleaseReceipts.Verify) would replace the replay call.
//
// Main discharges a relocation-release only for the source in releasing.
// A failed target's release in probe-failed has no obligation root will
// stop for, so it is refused here: the nested relocation waits, and its
// release deadline makes it stuck.
-type StopReceiptReleases struct{}
+type StopReceiptReleases struct {
+ Receipts RelocationReleaseReceipts
+}
var _ hostclaim.ReleaseAuthority = StopReceiptReleases{}
-func (StopReceiptReleases) VerifyRelease(_ context.Context, c hostclaim.Claim, r hostclaim.Release) error {
- if c.Holder == nil {
+func (a StopReceiptReleases) VerifyRelease(ctx context.Context, c hostclaim.Claim, r hostclaim.Release) error {
+ if c.Holder == nil || a.Receipts.Store == nil {
return ErrClaimStopUnavailable
}
+ if err := releaseDischarged(&c, r.Receipt, r.ReceiptSHA256); err != nil {
+ return err
+ }
+ return verifyDischargedReceipt(ctx, a.Receipts, &c, r.Receipt, r.ReceiptSHA256)
+}
+
+// releaseDischarged checks c journals key and digest as its relocating
+// operation's discharged release receipt, and owes that operation no
+// relocation release.
+func releaseDischarged(c *hostclaim.Claim, key, digest string) error {
if c.Op.Kind != hostclaim.KindRelocating || c.Op.Phase != hostclaim.PhaseReleasing || c.Op.ID == "" {
return fmt.Errorf("%w: only a source's release in releasing is discharged by root's receipt", ErrClaimStopReceipt)
}
- if r.Receipt == "" || c.Op.Journal["release_stop_receipt"] != r.Receipt || c.Op.Journal["release_stop_sha256"] != r.ReceiptSHA256 {
+ if key == "" || c.Op.Journal["release_stop_receipt"] != key || c.Op.Journal["release_stop_sha256"] != digest {
return fmt.Errorf("%w: the release does not cite the receipt that discharged it", ErrClaimStopReceipt)
}
for _, ob := range c.Obligations {
@@ -51,6 +67,16 @@ func (StopReceiptReleases) VerifyRelease(_ context.Context, c hostclaim.Claim, r
return nil
}
+// verifyDischargedReceipt re-verifies a discharged receipt with main's
+// RelocationReleaseReceipts.Complete in its replay form. The caller has
+// checked releaseDischarged, so Complete takes its replay branch and
+// writes nothing; the observation's ETag is never compared there, and an
+// unexpected write could only fail its CAS against it.
+func verifyDischargedReceipt(ctx context.Context, receipts RelocationReleaseReceipts, c *hostclaim.Claim, key, digest string) error {
+ _, err := receipts.Complete(ctx, hostclaim.Observed{Host: c.Host, Claim: c, ETag: "verify-only"}, key, digest)
+ return err
+}
+
// RelocationReleaseDischarge is the relocation-release obligation's two
// claim CASes around root's stop (claim Plan 11): Queue before the stop is
// requested, Complete with root's exact receipt after it.
diff --git a/internal/host/relocationreleases_test.go b/internal/host/relocationreleases_test.go
index f1b9695a8..d9fd0122c 100644
--- a/internal/host/relocationreleases_test.go
+++ b/internal/host/relocationreleases_test.go
@@ -30,16 +30,30 @@ type releaseWorld struct {
}
func newReleaseWorld(t *testing.T) releaseWorld {
+ t.Helper()
+ return newReleaseWorldWith(t, nil)
+}
+
+// newReleaseWorldWith is newReleaseWorld with the roster's boxes changed
+// by roster before it is signed (nil leaves them).
+func newReleaseWorldWith(t *testing.T, roster func(map[string]hostactors.Box)) releaseWorld {
t.Helper()
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
root := ed25519.NewKeyFromSeed(make([]byte, 32))
boxKey := ed25519.NewKeyFromSeed([]byte(strings.Repeat("b", 32)))
en := strings.Repeat("a", 32)
- roster := hostactors.Roster{V: 1, Revision: 1, DeploymentBucket: "bucket", Boxes: map[string]hostactors.Box{"box": {Enrollment: en, BoxID: "box", Label: "box", Namespace: "platform", Region: "ie", Pool: "default", State: "active",
- Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-box"}, EvidenceSHA256: strings.Repeat("d", 64), CensusPublicKey: boxKey.Public().(ed25519.PublicKey)}},
+ box := func(name, enrollment string, key ed25519.PrivateKey) hostactors.Box {
+ return hostactors.Box{Enrollment: enrollment, BoxID: name, Label: name, Namespace: "platform", Region: "ie", Pool: "default", State: "active",
+ Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-" + name}, EvidenceSHA256: strings.Repeat("d", 64), CensusPublicKey: key.Public().(ed25519.PublicKey)}
+ }
+ boxes := map[string]hostactors.Box{"box": box("box", en, boxKey), "other": box("other", strings.Repeat("c", 32), otherBoxKey)}
+ if roster != nil {
+ roster(boxes)
+ }
+ signed := hostactors.Roster{V: 1, Revision: 1, DeploymentBucket: "bucket", Boxes: boxes,
Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
- wire, err := release.SignRootEnvelope(release.RootSigner(root), roster)
+ wire, err := release.SignRootEnvelope(release.RootSigner(root), signed)
if err != nil {
t.Fatal(err)
}
@@ -62,16 +76,33 @@ func newReleaseWorld(t *testing.T) releaseWorld {
obs: hostclaim.Observed{Host: host, Claim: &claim, ETag: tag}}
}
+// otherBoxKey is another roster box's census key.
+var otherBoxKey = ed25519.NewKeyFromSeed([]byte(strings.Repeat("o", 32)))
+
// receipt publishes root's signed full stop of the claim's session.
func (w releaseWorld) receipt(t *testing.T) (string, string) {
+ t.Helper()
+ return w.receiptWith(t, nil, w.boxKey)
+}
+
+// receiptWith publishes the receipt changed by mutate, signed by signer
+// (nil: the body alone, unsigned).
+func (w releaseWorld) receiptWith(t *testing.T, mutate func(*StopReceipt), signer ed25519.PrivateKey) (string, string) {
t.Helper()
c := w.obs.Claim
r := StopReceipt{V: 1, ID: "stop-1", Kind: "stopped", Host: c.Host, Incarnation: c.Holder.Incarnation, InstallationStamp: c.Holder.Stamp, WriterSession: c.Holder.Session, OperationID: c.Op.ID,
Phase: hostclaim.PhaseReleasing, ObligationID: RelocationReleaseObligation, Box: "box", Enrollment: w.en, Region: "ie", EvidenceAt: time.Now(), RequestLimitNS: int64(hostbox.RequestLimit), ClaimGen: 1,
FenceID: "fence", InventorySHA256: strings.Repeat("e", 64), LocalJournalSHA256: strings.Repeat("f", 64), ProducerDrainDigest: strings.Repeat("a", 64), CgroupIdentities: []string{"root-role"}}
+ if mutate != nil {
+ mutate(&r)
+ }
body, _ := json.Marshal(r)
key := stopEvidenceKey(c.Host, c.Op.ID, "box", r.ID)
- if err := hostevidence.Publish(context.Background(), w.s, w.boxKey, key, body); err != nil {
+ if signer == nil {
+ if _, err := w.s.Put(context.Background(), key, body, ""); err != nil {
+ t.Fatal(err)
+ }
+ } else if err := hostevidence.Publish(context.Background(), w.s, signer, key, body); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(body)
@@ -98,7 +129,7 @@ func TestReleaseObligationQueuesOnceAndDischargesWithRootsReceipt(t *testing.T)
}
key, digest := w.receipt(t)
rel := hostclaim.Release{ReleasedBy: "box", Enrollment: w.en, Generation: "", Session: 7, Receipt: key, ReceiptSHA256: digest}
- if err := (StopReceiptReleases{}).VerifyRelease(ctx, *queued.Claim, rel); !errors.Is(err, ErrClaimStopReceipt) {
+ if err := (StopReceiptReleases{Receipts: o.Receipts}).VerifyRelease(ctx, *queued.Claim, rel); !errors.Is(err, ErrClaimStopReceipt) {
t.Fatalf("a release counted before its receipt discharged the obligation: %v", err)
}
done, err := o.Complete(ctx, queued, key, digest)
@@ -108,7 +139,7 @@ func TestReleaseObligationQueuesOnceAndDischargesWithRootsReceipt(t *testing.T)
if len(done.Claim.Obligations) != 0 || done.Claim.Op.Journal["release_stop_receipt"] != key {
t.Fatalf("not discharged: %+v %v", done.Claim.Obligations, done.Claim.Op.Journal)
}
- if err := (StopReceiptReleases{}).VerifyRelease(ctx, *done.Claim, rel); err != nil {
+ if err := (StopReceiptReleases{Receipts: o.Receipts}).VerifyRelease(ctx, *done.Claim, rel); err != nil {
t.Fatalf("the discharged release: %v", err)
}
// A replay: the discharged release queues nothing and completes again.
@@ -125,7 +156,7 @@ func TestReleaseObligationQueuesOnceAndDischargesWithRootsReceipt(t *testing.T)
} {
other := rel
change(&other)
- if err := (StopReceiptReleases{}).VerifyRelease(ctx, *done.Claim, other); !errors.Is(err, ErrClaimStopReceipt) {
+ if err := (StopReceiptReleases{Receipts: o.Receipts}).VerifyRelease(ctx, *done.Claim, other); !errors.Is(err, ErrClaimStopReceipt) {
t.Fatalf("%s counted: %v", name, err)
}
}
@@ -133,7 +164,7 @@ func TestReleaseObligationQueuesOnceAndDischargesWithRootsReceipt(t *testing.T)
// releasing: a failed target's release in probe-failed never counts.
failed := *done.Claim
failed.Op.Phase = hostclaim.PhaseProbeFailed
- if err := (StopReceiptReleases{}).VerifyRelease(ctx, failed, rel); !errors.Is(err, ErrClaimStopReceipt) {
+ if err := (StopReceiptReleases{Receipts: o.Receipts}).VerifyRelease(ctx, failed, rel); !errors.Is(err, ErrClaimStopReceipt) {
t.Fatalf("a probe-failed release counted: %v", err)
}
}
@@ -165,3 +196,105 @@ func TestReleaseObligationRefusesAnotherRelease(t *testing.T) {
t.Fatalf("no store: %v", err)
}
}
+
+// discharged is the world's claim as Complete leaves it: the receipt's key
+// and digest journaled and no obligation owed.
+func (w releaseWorld) discharged(key, digest string) hostclaim.Claim {
+ c := *w.obs.Claim
+ c.Op.Journal = map[string]string{"release_stop_receipt": key, "release_stop_sha256": digest}
+ c.Obligations = nil
+ return c
+}
+
+// The pin's verifier consumes root's signed receipt itself (Plan 11 Task
+// E), never the journaled digest alone: every receipt main's Complete
+// would refuse is refused at the pin, though the claim journals it as
+// discharged. (The deleted branch verifier's signature tests, against
+// main's receipt.)
+func TestStopReceiptReleasesVerifiesRootsSignedReceiptAtThePin(t *testing.T) {
+ ctx := context.Background()
+ verify := func(t *testing.T, w releaseWorld, key, digest string, dir hostactors.Directory) error {
+ t.Helper()
+ rel := hostclaim.Release{ReleasedBy: "box", Enrollment: w.en, Session: 7, Receipt: key, ReceiptSHA256: digest}
+ return (StopReceiptReleases{Receipts: RelocationReleaseReceipts{Store: w.s, Directory: dir}}).VerifyRelease(ctx, w.discharged(key, digest), rel)
+ }
+ w := newReleaseWorld(t)
+ key, digest := w.receipt(t)
+ if err := verify(t, w, key, digest, w.dir); err != nil {
+ t.Fatalf("root's signed receipt: %v", err)
+ }
+ if now, err := hostclaim.Read(ctx, w.s, w.obs.Host); err != nil || now.ETag != w.obs.ETag {
+ t.Fatalf("verifying wrote the claim: %v", err)
+ }
+ t.Run("an unsigned receipt", func(t *testing.T) {
+ w := newReleaseWorld(t)
+ key, digest := w.receiptWith(t, nil, nil)
+ if err := verify(t, w, key, digest, w.dir); err == nil {
+ t.Fatal("an unsigned receipt counted")
+ }
+ })
+ t.Run("signed by another box's key", func(t *testing.T) {
+ w := newReleaseWorld(t)
+ key, digest := w.receiptWith(t, nil, otherBoxKey)
+ if err := verify(t, w, key, digest, w.dir); err == nil {
+ t.Fatal("another box's signature counted")
+ }
+ })
+ t.Run("a roster entry of another enrollment", func(t *testing.T) {
+ w := newReleaseWorldWith(t, func(b map[string]hostactors.Box) {
+ box := b["box"]
+ box.Enrollment = strings.Repeat("d", 32)
+ b["box"] = box
+ })
+ key, digest := w.receipt(t)
+ if err := verify(t, w, key, digest, w.dir); !errors.Is(err, ErrClaimStopIdentity) {
+ t.Fatalf("another enrollment's key counted: %v", err)
+ }
+ })
+ t.Run("a box the roster does not name", func(t *testing.T) {
+ w := newReleaseWorldWith(t, func(b map[string]hostactors.Box) { delete(b, "box") })
+ key, digest := w.receipt(t)
+ if err := verify(t, w, key, digest, w.dir); !errors.Is(err, ErrClaimStopIdentity) {
+ t.Fatalf("a box missing from the roster counted: %v", err)
+ }
+ })
+ t.Run("no roster", func(t *testing.T) {
+ w := newReleaseWorld(t)
+ key, digest := w.receipt(t)
+ if err := verify(t, w, key, digest, hostactors.Directory{Store: w.s, Bucket: "bucket"}); err == nil {
+ t.Fatal("a release counted without a roster to name its key")
+ }
+ })
+ t.Run("a digest that is not the bytes", func(t *testing.T) {
+ w := newReleaseWorld(t)
+ key, _ := w.receipt(t)
+ if err := verify(t, w, key, strings.Repeat("0", 64), w.dir); !errors.Is(err, ErrClaimStopReceipt) {
+ t.Fatalf("another digest counted: %v", err)
+ }
+ })
+ for name, mutate := range map[string]func(*StopReceipt){
+ "another session": func(r *StopReceipt) { r.WriterSession++ },
+ "another installation stamp": func(r *StopReceipt) { r.InstallationStamp++ },
+ "another operation's phase": func(r *StopReceipt) { r.Phase = hostclaim.PhaseProbeFailed },
+ "a retirement's stop": func(r *StopReceipt) { r.ObligationID = "park-local" },
+ "another enrollment": func(r *StopReceipt) { r.Enrollment = strings.Repeat("f", 32) },
+ "no stopped cgroups": func(r *StopReceipt) { r.CgroupIdentities = nil },
+ "a malformed producer digest": func(r *StopReceipt) { r.ProducerDrainDigest = "drain" },
+ } {
+ t.Run(name, func(t *testing.T) {
+ w := newReleaseWorld(t)
+ key, digest := w.receiptWith(t, mutate, w.boxKey)
+ if err := verify(t, w, key, digest, w.dir); !errors.Is(err, ErrClaimStopReceipt) {
+ t.Fatalf("accepted: %v", err)
+ }
+ })
+ }
+ t.Run("no receipts store", func(t *testing.T) {
+ w := newReleaseWorld(t)
+ key, digest := w.receipt(t)
+ rel := hostclaim.Release{ReleasedBy: "box", Enrollment: w.en, Session: 7, Receipt: key, ReceiptSHA256: digest}
+ if err := (StopReceiptReleases{}).VerifyRelease(ctx, w.discharged(key, digest), rel); !errors.Is(err, ErrClaimStopUnavailable) {
+ t.Fatalf("verified with no store: %v", err)
+ }
+ })
+}