| 1 | package hostclaim |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/boxid" |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | "github.com/carlosframework/platform/internal/termfence" |
| 15 | ) |
| 16 | |
| 17 | // storeFence writes a terminated-box fence for box to the store and returns |
| 18 | // the journal entry that names it and an authority that binds it. |
| 19 | func storeFence(t *testing.T, s release.Store, box, enrollment string) (string, c7Authority) { |
| 20 | t.Helper() |
| 21 | 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)}} |
| 22 | raw, err := json.Marshal(f) |
| 23 | if err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | digest := sha256.Sum256(raw) |
| 27 | ref := FenceRef{Enrollment: enrollment, Key: "control/box-fences/" + enrollment + ".json", SHA256: hex.EncodeToString(digest[:])} |
| 28 | if _, err := s.Put(context.Background(), ref.Key, raw, ""); err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | entry, err := json.Marshal(ref) |
| 32 | if err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | return string(entry), c7Authority{want: f} |
| 36 | } |
| 37 | |
| 38 | // Review fix round 1 (Minor): the public fence path end to end, through a |
| 39 | // stored claim and a stored fence. VerifyReturnFence re-reads the fence the |
| 40 | // claim journals for the target and the authority binds it; ReturnToSource |
| 41 | // then claims for the source. A token verified for one journal reference does |
| 42 | // not authorise a claim whose reference has since changed, and a reference |
| 43 | // whose digest is not the stored fence's is never verified. |
| 44 | func TestVerifyReturnFenceThenReturnToSource(t *testing.T) { |
| 45 | ctx := context.Background() |
| 46 | s := release.NewDirStore(t.TempDir()) |
| 47 | entry, authority := storeFence(t, s, "uk-box", strings.Repeat("c", 32)) |
| 48 | otherEntry, _ := storeFence(t, s, "uk-box", strings.Repeat("d", 32)) |
| 49 | |
| 50 | fixture := rolledBack(t) |
| 51 | fixture.Op.Phase, fixture.Op.Exec = PhaseReleased, "none" |
| 52 | fixture.Op.Journal["fence.uk-box"] = entry |
| 53 | obs, err := Update(ctx, s, Observed{Host: fixture.Host, Absent: true}, func(c *Claim) error { |
| 54 | *c = *fixture |
| 55 | c.Gen = 0 |
| 56 | return nil |
| 57 | }) |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | |
| 62 | // The successful path. |
| 63 | fence, err := VerifyReturnFence(ctx, s, authority, obs.Claim.Op, "uk-box") |
| 64 | if err != nil { |
| 65 | t.Fatalf("verify: %v", err) |
| 66 | } |
| 67 | claimed, err := Update(ctx, s, obs, ReturnToSource(moveA, "ie-box", true, fence)) |
| 68 | if err != nil { |
| 69 | t.Fatalf("return: %v", err) |
| 70 | } |
| 71 | got, err := Read(ctx, s, fixture.Host) |
| 72 | if err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | 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" { |
| 76 | t.Fatalf("stored claim after the return: op %+v holder %+v", got.Claim.Op, got.Claim.Holder) |
| 77 | } |
| 78 | |
| 79 | // The target box's fence is not the source's: verifying it for another box fails. |
| 80 | if _, err := VerifyReturnFence(ctx, s, authority, obs.Claim.Op, "ie-box"); !errors.Is(err, ErrFence) { |
| 81 | t.Fatalf("verify for the source: %v", err) |
| 82 | } |
| 83 | |
| 84 | // A changed journal reference: the token verified against the old entry |
| 85 | // does not authorise the claim that now names another fence. |
| 86 | changed, err := Update(ctx, s, got, func(c *Claim) error { |
| 87 | c.Op.Phase, c.Op.Exec = PhaseReleased, "none" |
| 88 | c.Op.Journal["fence.uk-box"] = otherEntry |
| 89 | return nil |
| 90 | }) |
| 91 | if err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | if _, err := Update(ctx, s, changed, ReturnToSource(moveA, "ie-box", true, fence)); !errors.Is(err, ErrWrongState) { |
| 95 | t.Fatalf("a token for the old reference: %v", err) |
| 96 | } |
| 97 | if after, err := Read(ctx, s, fixture.Host); err != nil || after.ETag != changed.ETag { |
| 98 | t.Fatalf("a refused return wrote the claim: %v", err) |
| 99 | } |
| 100 | |
| 101 | // A reference whose digest is not the stored fence's is never verified. |
| 102 | var ref FenceRef |
| 103 | if err := json.Unmarshal([]byte(entry), &ref); err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | ref.SHA256 = strings.Repeat("0", 64) |
| 107 | forged, err := json.Marshal(ref) |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | op := changed.Claim.Op |
| 112 | op.Journal = map[string]string{"fence.uk-box": string(forged)} |
| 113 | if _, err := VerifyReturnFence(ctx, s, authority, op, "uk-box"); !errors.Is(err, ErrFence) { |
| 114 | t.Fatalf("a changed digest: %v", err) |
| 115 | } |
| 116 | // An authority that does not bind the record refuses too. |
| 117 | if _, err := VerifyReturnFence(ctx, s, c7Authority{}, changed.Claim.Op, "uk-box"); !errors.Is(err, ErrFence) { |
| 118 | t.Fatalf("an unbound authority: %v", err) |
| 119 | } |
| 120 | } |
| 121 | |