carlos / platform Public

host: a release receipt counts only under root's signature with the releasing box's census key from the roster, and binds the installation, claim generation and root's stop fields as main's receipt check does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 6cff8a6523090283029a88219354d23764dcdac9 parent 1ee322d
2 files changed, +163 −14
  • internal/host/relocationreleases.go +52 −3
  • internal/host/relocationreleases_test.go +111 −11
diff --git a/internal/host/relocationreleases.go b/internal/host/relocationreleases.go
index 038f7c708..c4717300d 100644
--- a/internal/host/relocationreleases.go
+++ b/internal/host/relocationreleases.go
@@ -6,8 +6,15 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostevidence"
"github.com/carlosframework/platform/internal/release"
)
@@ -21,11 +28,24 @@ const RelocationReleaseObligation = "relocation-release"
// StopReceiptReleases is hostclaim.ReleaseAuthority over this package's
// stop receipts (ClaimStopCoordinator.Publish): a relocation's journaled
// release counts only when the immutable receipt it names hashes to the
-// journaled digest and is the full stop of exactly that box, enrollment,
-// writer session, host, incarnation, operation and phase (region HA spec 1
-// §5.8 release step 4, claim §6.1).
+// journaled digest, carries root's signature under the releasing box's
+// census key, and is the full stop of exactly that box, enrollment,
+// installation, writer session, host, incarnation, operation and phase
+// (region HA spec 1 §5.8 release step 4, claim §6.1).
+//
+// The signature is what makes the object root's attestation rather than
+// the agent's word (P6-D1): the agent journals the digest in its own CAS,
+// so without it anything that can write the evidence key could have a
+// release counted. The key comes from the roster as main's console-side
+// receipt check reads it (hostops verifyEvidence), and the signature is
+// checked with the same hostevidence.Verify. A box missing from the roster,
+// an enrollment that differs, or a missing or bad signature refuses: the
+// worker's pin waits, and the release deadline makes the operation stuck.
type StopReceiptReleases struct {
Evidence release.Store
+ // Directory is main's roster reader (hostactors.Directory.Read): the
+ // root-signed roster names each box's census public key.
+ Directory hostactors.Directory
}
var _ hostclaim.ReleaseAuthority = StopReceiptReleases{}
@@ -34,6 +54,14 @@ func (a StopReceiptReleases) VerifyRelease(ctx context.Context, c hostclaim.Clai
if a.Evidence == nil || c.Holder == nil {
return ErrClaimStopUnavailable
}
+ roster, err := a.Directory.Read(ctx)
+ if err != nil {
+ return err
+ }
+ actor, ok := roster.Roster.Boxes[r.ReleasedBy]
+ if !ok || r.Enrollment == "" || actor.Enrollment != r.Enrollment {
+ return ErrClaimStopIdentity
+ }
raw, _, err := a.Evidence.Get(ctx, r.Receipt)
if err != nil {
return err
@@ -43,11 +71,32 @@ func (a StopReceiptReleases) VerifyRelease(ctx context.Context, c hostclaim.Clai
if hex.EncodeToString(sum[:]) != r.ReceiptSHA256 || json.Unmarshal(raw, &receipt) != nil || !bytes.Equal(raw, mustMarshalStopReceipt(receipt)) {
return ErrClaimStopReceipt
}
+ signature, _, err := a.Evidence.Get(ctx, r.Receipt+".sig")
+ if errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("%w: the receipt is not signed", ErrClaimStopReceipt) // absence is never evidence
+ }
+ if err != nil {
+ return err
+ }
+ if hostevidence.Verify(actor.CensusPublicKey, r.Receipt, raw, signature) != nil {
+ return fmt.Errorf("%w: the receipt is not signed by %s's census key", ErrClaimStopReceipt, r.ReleasedBy)
+ }
key := stopEvidenceKey(receipt.Host, receipt.OperationID, receipt.Box, receipt.ID)
if receipt.V != 1 || receipt.Kind != "stopped" || receipt.OperationID == "" || r.Receipt != key || receipt.Host != c.Host || receipt.Incarnation != c.Holder.Incarnation ||
receipt.OperationID != c.Op.ID || receipt.Phase != c.Op.Phase || receipt.ObligationID != RelocationReleaseObligation || receipt.Box != r.ReleasedBy || receipt.Enrollment != r.Enrollment ||
receipt.WriterSession != r.Session || receipt.EvidenceAt.IsZero() {
return ErrClaimStopReceipt
}
+ // The fields main's console-side check binds (hostops verifyEvidence,
+ // for a writer's own stop): the installation, the claim generation the
+ // fence was taken at, the roster revision, root's identities and
+ // digests, and a non-empty set of stopped cgroups.
+ revision, err := strconv.ParseUint(receipt.DirectoryRevision, 10, 64)
+ if err != nil || revision == 0 || revision > roster.Roster.Revision || receipt.InstallationStamp != c.Holder.Stamp ||
+ receipt.ClaimGen == 0 || receipt.ClaimGen > c.Gen || receipt.BootID == "" || receipt.ControllerID == "" || receipt.FenceID == "" ||
+ receipt.RequestLimitNS != int64(hostbox.RequestLimit) || !stopDigest(receipt.InventorySHA256) || !stopDigest(receipt.LocalJournalSHA256) ||
+ !stopDigest(receipt.ProducerDrainDigest) || len(receipt.CgroupIdentities) == 0 {
+ return ErrClaimStopReceipt
+ }
return nil
}
diff --git a/internal/host/relocationreleases_test.go b/internal/host/relocationreleases_test.go
index df07964c0..19d9d28f8 100644
--- a/internal/host/relocationreleases_test.go
+++ b/internal/host/relocationreleases_test.go
@@ -1,7 +1,9 @@
package host
import (
+ "bytes"
"context"
+ "crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"errors"
@@ -9,28 +11,67 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostevidence"
"github.com/carlosframework/platform/internal/release"
)
+// releaseRoster publishes a root-signed roster naming src and dst with
+// their census keys, and returns the directory that reads it.
+func releaseRoster(t *testing.T, s release.Store, boxes map[string]hostactors.Box) hostactors.Directory {
+ t.Helper()
+ root := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize))
+ roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 5, RequestLimitNS: int64(hostbox.RequestLimit), Regions: map[string]hostactors.Region{"ie": {}},
+ Boxes: boxes, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
+ body, err := release.SignRootEnvelope(release.RootSigner(root), roster)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(context.Background(), hostactors.Key, body, ""); err != nil {
+ t.Fatal(err)
+ }
+ return hostactors.Directory{Store: s, PublicKey: root.Public().(ed25519.PublicKey), Bucket: "bucket"}
+}
+
+func rosterBox(name, enrollment string, key ed25519.PrivateKey) hostactors.Box {
+ return hostactors.Box{CensusPublicKey: key.Public().(ed25519.PublicKey), Enrollment: enrollment, Namespace: "platform", BoxID: name, Label: name, Region: "ie", Pool: "default",
+ Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "ie", InstanceID: "i-" + name}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"}
+}
+
func TestStopReceiptReleasesBindsTheReceiptToTheRelease(t *testing.T) {
ctx := context.Background()
inc := strings.Repeat("a", 64)
- c := hostclaim.Claim{Host: "shop.example", Holder: &hostclaim.Holder{Incarnation: inc}, Op: hostclaim.Op{ID: "rel-1", Kind: hostclaim.KindRelocating, Phase: hostclaim.PhaseReleasing}}
- receipt := StopReceipt{V: 1, ID: "r1", Host: "shop.example", Incarnation: inc, WriterSession: 12, OperationID: "rel-1", Phase: hostclaim.PhaseReleasing, ObligationID: RelocationReleaseObligation,
- Box: "src", Enrollment: "enr", Kind: "stopped", EvidenceAt: time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)}
- put := func(s release.Store, r StopReceipt) hostclaim.Release {
+ enr, other := strings.Repeat("e", 32), strings.Repeat("f", 32)
+ srcKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, ed25519.SeedSize))
+ dstKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{3}, ed25519.SeedSize))
+ boxes := func() map[string]hostactors.Box {
+ return map[string]hostactors.Box{"src": rosterBox("src", enr, srcKey), "dst": rosterBox("dst", other, dstKey)}
+ }
+ c := hostclaim.Claim{Host: "shop.example", Gen: 9, Holder: &hostclaim.Holder{Incarnation: inc, Stamp: 3, Session: 12}, Op: hostclaim.Op{ID: "rel-1", Kind: hostclaim.KindRelocating, Phase: hostclaim.PhaseReleasing}}
+ receipt := StopReceipt{RequestLimitNS: int64(hostbox.RequestLimit), V: 1, ID: "r1", Host: "shop.example", Incarnation: inc, InstallationStamp: 3, WriterSession: 12, OperationID: "rel-1",
+ Phase: hostclaim.PhaseReleasing, ObligationID: RelocationReleaseObligation, Box: "src", Enrollment: enr, BootID: "boot", ControllerID: "ctl", ClaimGen: 8, DirectoryRevision: "4",
+ Kind: "stopped", EvidenceAt: time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC), FenceID: "fence", InventorySHA256: strings.Repeat("1", 64),
+ LocalJournalSHA256: strings.Repeat("2", 64), ProducerDrainDigest: strings.Repeat("3", 64), CgroupIdentities: []string{"cg-1"}}
+ // put publishes r as root does (hostevidence.Publish: the body and its
+ // signature); a nil signer writes the body alone.
+ put := func(s release.Store, r StopReceipt, signer ed25519.PrivateKey) hostclaim.Release {
body := mustMarshalStopReceipt(r)
- key := "control/hosts/shop.example/evidence/" + r.OperationID + "/" + r.Box + "/" + r.ID + ".json"
- if _, err := s.Put(ctx, key, body, ""); err != nil {
+ key := stopEvidenceKey(r.Host, r.OperationID, r.Box, r.ID)
+ if signer != nil {
+ if err := hostevidence.Publish(ctx, s, signer, key, body); err != nil {
+ t.Fatal(err)
+ }
+ } else if _, err := s.Put(ctx, key, body, ""); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(body)
- return hostclaim.Release{ReleasedBy: "src", Enrollment: "enr", Session: 12, Receipt: key, ReceiptSHA256: hex.EncodeToString(sum[:])}
+ return hostclaim.Release{ReleasedBy: "src", Enrollment: enr, Session: 12, Receipt: key, ReceiptSHA256: hex.EncodeToString(sum[:])}
}
s := release.NewDirStore(t.TempDir())
- a := StopReceiptReleases{Evidence: s}
- if err := a.VerifyRelease(ctx, c, put(s, receipt)); err != nil {
+ a := StopReceiptReleases{Evidence: s, Directory: releaseRoster(t, s, boxes())}
+ if err := a.VerifyRelease(ctx, c, put(s, receipt, srcKey)); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
@@ -44,19 +85,78 @@ func TestStopReceiptReleasesBindsTheReceiptToTheRelease(t *testing.T) {
{"another phase", func(r *StopReceipt, _ *hostclaim.Release) { r.Phase = hostclaim.PhaseProbeFailed }},
{"another incarnation", func(r *StopReceipt, _ *hostclaim.Release) { r.Incarnation = strings.Repeat("b", 64) }},
{"a digest that is not the bytes", func(_ *StopReceipt, rel *hostclaim.Release) { rel.ReceiptSHA256 = strings.Repeat("0", 64) }},
+ // The fields main's console-side receipt check binds (fix round 1).
+ {"another installation stamp", func(r *StopReceipt, _ *hostclaim.Release) { r.InstallationStamp = 2 }},
+ {"no claim generation", func(r *StopReceipt, _ *hostclaim.Release) { r.ClaimGen = 0 }},
+ {"a claim generation past the claim's", func(r *StopReceipt, _ *hostclaim.Release) { r.ClaimGen = 10 }},
+ {"no directory revision", func(r *StopReceipt, _ *hostclaim.Release) { r.DirectoryRevision = "0" }},
+ {"a directory revision past the roster's", func(r *StopReceipt, _ *hostclaim.Release) { r.DirectoryRevision = "6" }},
+ {"a directory revision that is not a number", func(r *StopReceipt, _ *hostclaim.Release) { r.DirectoryRevision = "four" }},
+ {"no stopped cgroups", func(r *StopReceipt, _ *hostclaim.Release) { r.CgroupIdentities = nil }},
+ {"another request limit", func(r *StopReceipt, _ *hostclaim.Release) { r.RequestLimitNS = 1 }},
+ {"a malformed inventory digest", func(r *StopReceipt, _ *hostclaim.Release) { r.InventorySHA256 = "inv" }},
+ {"a malformed local journal digest", func(r *StopReceipt, _ *hostclaim.Release) { r.LocalJournalSHA256 = "" }},
+ {"a malformed producer drain digest", func(r *StopReceipt, _ *hostclaim.Release) { r.ProducerDrainDigest = strings.Repeat("z", 64) }},
+ {"no boot", func(r *StopReceipt, _ *hostclaim.Release) { r.BootID = "" }},
+ {"no controller", func(r *StopReceipt, _ *hostclaim.Release) { r.ControllerID = "" }},
+ {"no fence", func(r *StopReceipt, _ *hostclaim.Release) { r.FenceID = "" }},
} {
t.Run(tc.name, func(t *testing.T) {
s := release.NewDirStore(t.TempDir())
r := receipt
+ r.CgroupIdentities = append([]string(nil), receipt.CgroupIdentities...)
var mutated hostclaim.Release
tc.mutate(&r, &mutated)
- rel := put(s, r)
+ rel := put(s, r, srcKey)
if mutated.ReceiptSHA256 != "" {
rel.ReceiptSHA256 = mutated.ReceiptSHA256
}
- if err := (StopReceiptReleases{Evidence: s}).VerifyRelease(ctx, c, rel); !errors.Is(err, ErrClaimStopReceipt) {
+ if err := (StopReceiptReleases{Evidence: s, Directory: releaseRoster(t, s, boxes())}).VerifyRelease(ctx, c, rel); !errors.Is(err, ErrClaimStopReceipt) {
t.Fatalf("accepted: %v", err)
}
})
}
+ // Root's signature (fix round 1, I-1): the receipt counts only under
+ // the releasing box's census key, from the roster.
+ t.Run("an unsigned receipt", func(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ rel := put(s, receipt, nil)
+ if err := (StopReceiptReleases{Evidence: s, Directory: releaseRoster(t, s, boxes())}).VerifyRelease(ctx, c, rel); !errors.Is(err, ErrClaimStopReceipt) {
+ t.Fatalf("an unsigned receipt counted: %v", err)
+ }
+ })
+ t.Run("signed by another box's key", func(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ rel := put(s, receipt, dstKey)
+ if err := (StopReceiptReleases{Evidence: s, Directory: releaseRoster(t, s, boxes())}).VerifyRelease(ctx, c, rel); !errors.Is(err, ErrClaimStopReceipt) {
+ t.Fatalf("another box's signature counted: %v", err)
+ }
+ })
+ t.Run("a roster entry of another enrollment", func(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ rel := put(s, receipt, srcKey)
+ b := boxes()
+ src := b["src"]
+ src.Enrollment = strings.Repeat("d", 32)
+ b["src"] = src
+ if err := (StopReceiptReleases{Evidence: s, Directory: releaseRoster(t, s, b)}).VerifyRelease(ctx, c, rel); !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) {
+ s := release.NewDirStore(t.TempDir())
+ rel := put(s, receipt, srcKey)
+ b := boxes()
+ delete(b, "src")
+ if err := (StopReceiptReleases{Evidence: s, Directory: releaseRoster(t, s, b)}).VerifyRelease(ctx, c, rel); !errors.Is(err, ErrClaimStopIdentity) {
+ t.Fatalf("a box missing from the roster counted: %v", err)
+ }
+ })
+ t.Run("no roster", func(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ rel := put(s, receipt, srcKey)
+ if err := (StopReceiptReleases{Evidence: s, Directory: hostactors.Directory{Store: s, Bucket: "bucket"}}).VerifyRelease(ctx, c, rel); err == nil {
+ t.Fatal("a release counted without a roster to name its key")
+ }
+ })
}