hostclaim: the public fence path, VerifyReturnFence then ReturnToSource, end to end (Task 2 fix round 1)
A stored claim and a stored terminated-box fence with a binding authority: the verified token claims for the source; a token for the old journal reference is refused once the claim names another fence; a changed digest, the wrong box and an unbound authority are never verified.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+120
−0
internal/hostclaim/move_return_fence_test.go+120 −0
diff --git a/internal/hostclaim/move_return_fence_test.go b/internal/hostclaim/move_return_fence_test.go| new file mode 100644 |
| index 000000000..0be7320a0 |
| --- /dev/null |
| +++ b/internal/hostclaim/move_return_fence_test.go |
| @@ -0,0 +1,120 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/termfence" |
| +) |
| + |
| +// storeFence writes a terminated-box fence for box to the store and returns |
| +// the journal entry that names it and an authority that binds it. |
| +func storeFence(t *testing.T, s release.Store, box, enrollment string) (string, c7Authority) { |
| + t.Helper() |
| + f := termfence.Record{V: 1, Box: box, Enrollment: enrollment, Cloud: boxid.Cloud{Provider: "aws", Account: "123", Region: "eu-west-2", InstanceID: "i-" + box}, State: "terminated", ObservedAt: "2026-10-06T00:00:00Z", Via: "describe", RequestID: "request", WrittenBy: termfence.WrittenBy{Box: "worker", Enrollment: strings.Repeat("b", 32)}} |
| + raw, err := json.Marshal(f) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + digest := sha256.Sum256(raw) |
| + ref := FenceRef{Enrollment: enrollment, Key: "control/box-fences/" + enrollment + ".json", SHA256: hex.EncodeToString(digest[:])} |
| + if _, err := s.Put(context.Background(), ref.Key, raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + entry, err := json.Marshal(ref) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return string(entry), c7Authority{want: f} |
| +} |
| + |
| +// Review fix round 1 (Minor): the public fence path end to end, through a |
| +// stored claim and a stored fence. VerifyReturnFence re-reads the fence the |
| +// claim journals for the target and the authority binds it; ReturnToSource |
| +// then claims for the source. A token verified for one journal reference does |
| +// not authorise a claim whose reference has since changed, and a reference |
| +// whose digest is not the stored fence's is never verified. |
| +func TestVerifyReturnFenceThenReturnToSource(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + entry, authority := storeFence(t, s, "uk-box", strings.Repeat("c", 32)) |
| + otherEntry, _ := storeFence(t, s, "uk-box", strings.Repeat("d", 32)) |
| + |
| + fixture := rolledBack(t) |
| + fixture.Op.Phase, fixture.Op.Exec = PhaseReleased, "none" |
| + fixture.Op.Journal["fence.uk-box"] = entry |
| + obs, err := Update(ctx, s, Observed{Host: fixture.Host, Absent: true}, func(c *Claim) error { |
| + *c = *fixture |
| + c.Gen = 0 |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The successful path. |
| + fence, err := VerifyReturnFence(ctx, s, authority, obs.Claim.Op, "uk-box") |
| + if err != nil { |
| + t.Fatalf("verify: %v", err) |
| + } |
| + claimed, err := Update(ctx, s, obs, ReturnToSource(moveA, "ie-box", true, fence)) |
| + if err != nil { |
| + t.Fatalf("return: %v", err) |
| + } |
| + got, err := Read(ctx, s, fixture.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.ETag != claimed.ETag || got.Claim.Op.Exec != "ie-box" || got.Claim.Op.Phase != PhaseClaimed || got.Claim.Holder.Stamp != 3 || got.Claim.Holder.Generation != "gen-7" { |
| + t.Fatalf("stored claim after the return: op %+v holder %+v", got.Claim.Op, got.Claim.Holder) |
| + } |
| + |
| + // The target box's fence is not the source's: verifying it for another box fails. |
| + if _, err := VerifyReturnFence(ctx, s, authority, obs.Claim.Op, "ie-box"); !errors.Is(err, ErrFence) { |
| + t.Fatalf("verify for the source: %v", err) |
| + } |
| + |
| + // A changed journal reference: the token verified against the old entry |
| + // does not authorise the claim that now names another fence. |
| + changed, err := Update(ctx, s, got, func(c *Claim) error { |
| + c.Op.Phase, c.Op.Exec = PhaseReleased, "none" |
| + c.Op.Journal["fence.uk-box"] = otherEntry |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := Update(ctx, s, changed, ReturnToSource(moveA, "ie-box", true, fence)); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("a token for the old reference: %v", err) |
| + } |
| + if after, err := Read(ctx, s, fixture.Host); err != nil || after.ETag != changed.ETag { |
| + t.Fatalf("a refused return wrote the claim: %v", err) |
| + } |
| + |
| + // A reference whose digest is not the stored fence's is never verified. |
| + var ref FenceRef |
| + if err := json.Unmarshal([]byte(entry), &ref); err != nil { |
| + t.Fatal(err) |
| + } |
| + ref.SHA256 = strings.Repeat("0", 64) |
| + forged, err := json.Marshal(ref) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + op := changed.Claim.Op |
| + op.Journal = map[string]string{"fence.uk-box": string(forged)} |
| + if _, err := VerifyReturnFence(ctx, s, authority, op, "uk-box"); !errors.Is(err, ErrFence) { |
| + t.Fatalf("a changed digest: %v", err) |
| + } |
| + // An authority that does not bind the record refuses too. |
| + if _, err := VerifyReturnFence(ctx, s, c7Authority{}, changed.Claim.Op, "uk-box"); !errors.Is(err, ErrFence) { |
| + t.Fatalf("an unbound authority: %v", err) |
| + } |
| +} |