carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package hostfence
2
3import (
4 "context"
5 "crypto/ed25519"
6 "encoding/base64"
7 "encoding/json"
8 "errors"
9 "github.com/carlosframework/platform/internal/hostclaim"
10 "reflect"
11 "testing"
12 "time"
13
14 "github.com/aws/aws-sdk-go-v2/aws"
15 awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware"
16 "github.com/aws/aws-sdk-go-v2/service/ec2"
17 "github.com/aws/aws-sdk-go-v2/service/ec2/types"
18 "github.com/aws/smithy-go/middleware"
19 "github.com/carlosframework/platform/internal/release"
20)
21
22type ec2Answer struct {
23 out *ec2.DescribeInstancesOutput
24 err error
25}
26
27func (f ec2Answer) DescribeInstances(_ context.Context, _ *ec2.DescribeInstancesInput, _ ...func(*ec2.Options)) (*ec2.DescribeInstancesOutput, error) {
28 return f.out, f.err
29}
30
31func challengeFixture(t *testing.T, now time.Time) Challenge {
32 t.Helper()
33 c, err := PrepareChallenge(now, Challenge{
34 ID: "loss-1", Actor: "operator@example.com", Host: "app.example.com",
35 HolderIncarnation: "inc-1", HolderStamp: 7, HolderSession: 8,
36 ClaimGen: 9, ClaimETag: "etag-claim",
37 Op: Operation{ID: "op-1", Kind: "moving", Phase: "copying"},
38 Record: RecordObservation{Key: "apps/bab/app/instances/app.example.com.json", ETag: "etag-record", CommitState: "committed"},
39 Box: "box-1", Enrollment: "0123456789abcdef0123456789abcdef",
40 Cloud: CloudBinding{Provider: "aws", Account: "123456789012", Region: "eu-west-1", InstanceID: "i-123"},
41 Consequences: Consequences{Transition: "declare-lost", Chores: []string{"park-local/1"}, PossibleWriteLoss: true},
42 })
43 if err != nil {
44 t.Fatal(err)
45 }
46 return c
47}
48
49func TestChallengeRootEnvelopeAndLifetime(t *testing.T) {
50 pub, priv, err := ed25519.GenerateKey(nil)
51 if err != nil {
52 t.Fatal(err)
53 }
54 now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
55 c := challengeFixture(t, now)
56 wire, err := SignChallenge(release.RootSigner(priv), c)
57 if err != nil {
58 t.Fatal(err)
59 }
60 got, err := VerifyChallenge(pub, wire, now.Add(119*time.Second))
61 if err != nil || got.ID != c.ID || got.Cloud != c.Cloud {
62 t.Fatalf("verify = %+v, %v", got, err)
63 }
64 if _, err := VerifyChallenge(pub, wire, c.ExpiresAt); !errors.Is(err, ErrChallenge) {
65 t.Fatalf("expiry error = %v", err)
66 }
67 wire[len(wire)/2] ^= 1
68 if _, err := VerifyChallenge(pub, wire, now); !errors.Is(err, ErrChallenge) {
69 t.Fatalf("tamper error = %v", err)
70 }
71}
72
73func TestChallengeModelsPresentAndAbsentRecordObservations(t *testing.T) {
74 now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
75 _, priv, err := ed25519.GenerateKey(nil)
76 if err != nil {
77 t.Fatal(err)
78 }
79 c := challengeFixture(t, now)
80 c.HolderStamp, c.HolderSession = 0, 0
81 if _, err := SignChallenge(release.RootSigner(priv), c); err != nil {
82 t.Fatalf("explicit zero holder binding refused: %v", err)
83 }
84 c.Record = RecordObservation{Key: c.Record.Key, CommitState: "absent"}
85 if _, err := SignChallenge(release.RootSigner(priv), c); err != nil {
86 t.Fatalf("explicit absent record refused: %v", err)
87 }
88 for name, breakIt := range map[string]func(*Challenge){
89 "present-without-etag": func(c *Challenge) { c.Record.ETag = "" },
90 "absent-with-etag": func(c *Challenge) { c.Record.CommitState = "absent" },
91 } {
92 t.Run(name, func(t *testing.T) {
93 c := challengeFixture(t, now)
94 breakIt(&c)
95 if _, err := SignChallenge(release.RootSigner(priv), c); !errors.Is(err, ErrChallenge) {
96 t.Fatalf("error = %v", err)
97 }
98 })
99 }
100}
101
102func TestObserveForChallengeBindsExactStoppedInstance(t *testing.T) {
103 issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
104 c := challengeFixture(t, issued)
105 api := ec2Answer{out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{
106 OwnerId: aws.String(c.Cloud.Account), Instances: []types.Instance{{InstanceId: aws.String(c.Cloud.InstanceID), State: &types.InstanceState{Name: types.InstanceStateNameStopped}}},
107 }}}}
108 awsmiddleware.SetRequestIDMetadata(&api.out.ResultMetadata, "aws-request-123")
109 times := []time.Time{issued.Add(time.Second), issued.Add(2 * time.Second)}
110 i := 0
111 o, err := ObserveForChallenge(t.Context(), api, c, func() time.Time { v := times[i]; i++; return v })
112 if err != nil {
113 t.Fatal(err)
114 }
115 if o.State != "stopped" || o.ObservedAt != times[0] || o.RequestID != "aws-request-123" {
116 t.Fatalf("observation = %+v", o)
117 }
118
119}
120
121func TestObserveForChallengeRefusesMissingRequestID(t *testing.T) {
122 issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
123 c := challengeFixture(t, issued)
124 for _, id := range []string{"missing", "", " "} {
125 t.Run(id, func(t *testing.T) {
126 api := ec2Answer{out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{
127 OwnerId: aws.String(c.Cloud.Account), Instances: []types.Instance{{InstanceId: aws.String(c.Cloud.InstanceID), State: &types.InstanceState{Name: types.InstanceStateNameStopped}}},
128 }}}}
129 api.out.ResultMetadata = middleware.Metadata{}
130 if id != "missing" {
131 awsmiddleware.SetRequestIDMetadata(&api.out.ResultMetadata, id)
132 }
133 if _, err := ObserveForChallenge(t.Context(), api, c, func() time.Time { return issued.Add(time.Second) }); !errors.Is(err, ErrObservation) {
134 t.Fatalf("missing request ID accepted: %v", err)
135 }
136 })
137 }
138}
139
140func TestObserveForChallengeRefusesAReadThatBlockedPastFreshness(t *testing.T) {
141 issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
142 c := challengeFixture(t, issued)
143 api := ec2Answer{out: &ec2.DescribeInstancesOutput{Reservations: []types.Reservation{{
144 OwnerId: aws.String(c.Cloud.Account), Instances: []types.Instance{{InstanceId: aws.String(c.Cloud.InstanceID), State: &types.InstanceState{Name: types.InstanceStateNameStopped}}},
145 }}}}
146 awsmiddleware.SetRequestIDMetadata(&api.out.ResultMetadata, "aws-request-123")
147 times := []time.Time{issued.Add(time.Second), issued.Add(62 * time.Second)}
148 i := 0
149 if _, err := ObserveForChallenge(t.Context(), api, c, func() time.Time { v := times[i]; i++; return v }); !errors.Is(err, ErrObservation) {
150 t.Fatalf("error = %v", err)
151 }
152}
153
154func TestValidateObservationFreshnessAndBinding(t *testing.T) {
155 issued := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
156 c := challengeFixture(t, issued)
157 valid := CloudObservation{ObservedAt: issued.Add(10 * time.Second), Account: c.Cloud.Account, Region: c.Cloud.Region, InstanceID: c.Cloud.InstanceID, State: "terminated", RequestID: "aws-request-123"}
158 if err := ValidateObservation(c, valid, issued.Add(60*time.Second)); err != nil {
159 t.Fatal(err)
160 }
161 checks := []CloudObservation{
162 func() CloudObservation { o := valid; o.RequestID = ""; return o }(),
163 func() CloudObservation { o := valid; o.RequestID = " "; return o }(),
164 func() CloudObservation { o := valid; o.ObservedAt = issued; return o }(),
165 func() CloudObservation { o := valid; o.ObservedAt = issued.Add(11 * time.Second); return o }(),
166 func() CloudObservation { o := valid; o.Account = "999999999999"; return o }(),
167 func() CloudObservation { o := valid; o.State = "running"; return o }(),
168 }
169 for i, o := range checks {
170 now := issued.Add(70 * time.Second)
171 if i < 2 {
172 now = issued.Add(60 * time.Second)
173 }
174 if i == 3 {
175 now = issued.Add(5 * time.Second)
176 }
177 if err := ValidateObservation(c, o, now); !errors.Is(err, ErrObservation) {
178 t.Errorf("case %d error = %v", i, err)
179 }
180 }
181}
182
183func TestServingDestroyedLocalOnlyChallengeBindsOriginalChore(t *testing.T) {
184 now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
185 pub, priv, err := ed25519.GenerateKey(nil)
186 if err != nil {
187 t.Fatal(err)
188 }
189 c := challengeFixture(t, now)
190 c.Op = Operation{Kind: hostclaim.KindServing}
191 c.Consequences = Consequences{
192 Transition: "destroyed-local-only",
193 DischargeLocal: []hostclaim.ObligationRef{{ID: "local", Op: "discovery", Retirement: "retired-installation"}},
194 DestroyedStorageAssertion: []string{"discovery/local/retired-installation"},
195 }
196 wire, err := SignChallenge(release.RootSigner(priv), c)
197 if err != nil {
198 t.Fatal(err)
199 }
200 got, err := VerifyChallenge(pub, wire, now.Add(time.Second))
201 if err != nil || !reflect.DeepEqual(got, c) {
202 t.Fatalf("serving binding round trip = %+v %v", got, err)
203 }
204 for name, mutate := range map[string]func(*Challenge){
205 "generic": func(c *Challenge) { c.Consequences.Transition = "declare-lost" },
206 "non-serving": func(c *Challenge) { c.Op.Kind = "moving" },
207 "current-op": func(c *Challenge) { c.Op.ID = "discovery" },
208 "phase": func(c *Challenge) { c.Op.Phase = "recover" },
209 "claim-gen": func(c *Challenge) { c.ClaimGen = 0 },
210 "claim-etag": func(c *Challenge) { c.ClaimETag = "" },
211 "write-loss": func(c *Challenge) { c.Consequences.PossibleWriteLoss = true },
212 "timer-loss": func(c *Challenge) { c.Consequences.PossibleTimerLoss = true },
213 "unbound-chore": func(c *Challenge) { c.Consequences.Chores = []string{"extra"} },
214 "empty": func(c *Challenge) { c.Consequences.DischargeLocal = nil },
215 "original-op": func(c *Challenge) { c.Consequences.DischargeLocal[0].Op = "" },
216 "retirement": func(c *Challenge) { c.Consequences.DischargeLocal[0].Retirement = "" },
217 "proof": func(c *Challenge) {
218 c.Consequences.DestroyedStorageAssertion = []string{"other/local/retired-installation"}
219 },
220 } {
221 t.Run(name, func(t *testing.T) {
222 bad := c
223 bad.Consequences.DischargeLocal = append([]hostclaim.ObligationRef(nil), c.Consequences.DischargeLocal...)
224 mutate(&bad)
225 if _, err := SignChallenge(release.RootSigner(priv), bad); !errors.Is(err, ErrChallenge) {
226 t.Fatalf("widened challenge = %v", err)
227 }
228 })
229 }
230 // The signature covers the exact revision and each original chore identity.
231 for _, field := range []string{"claim_gen", "claim_etag", "op", "retirement"} {
232 var envelope struct {
233 Payload string `json:"payload"`
234 Sig string `json:"sig"`
235 }
236 if err := json.Unmarshal(wire, &envelope); err != nil {
237 t.Fatal(err)
238 }
239 var payload map[string]any
240 raw, err := base64.StdEncoding.DecodeString(envelope.Payload)
241 if err != nil {
242 t.Fatal(err)
243 }
244 if err := json.Unmarshal(raw, &payload); err != nil {
245 t.Fatal(err)
246 }
247 switch field {
248 case "claim_gen":
249 payload[field] = float64(c.ClaimGen + 1)
250 case "claim_etag":
251 payload[field] = "other-etag"
252 default:
253 refs := payload["consequences"].(map[string]any)["discharge_local"].([]any)
254 refs[0].(map[string]any)[field] = "other-identity"
255 }
256 raw, _ = json.Marshal(payload)
257 envelope.Payload = base64.StdEncoding.EncodeToString(raw)
258 tampered, _ := json.Marshal(envelope)
259 if _, err := VerifyChallenge(pub, tampered, now); !errors.Is(err, ErrChallenge) {
260 t.Fatalf("changed %s retained signature: %v", field, err)
261 }
262 }
263}
264