carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package historyreadback
2
3import (
4 "context"
5 "crypto/ed25519"
6 "errors"
7 "os"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/hostactors"
13 "github.com/carlosframework/platform/internal/release"
14)
15
16type fixture struct {
17 directory hostactors.Directory
18 signer release.Signer
19 key ed25519.PrivateKey
20 scope Scope
21 now time.Time
22 status release.HistoryMigrationStatus
23}
24
25func newFixture(t *testing.T) fixture {
26 t.Helper()
27 pub, key, err := ed25519.GenerateKey(nil)
28 if err != nil {
29 t.Fatal(err)
30 }
31 _, boxKey, err := ed25519.GenerateKey(nil)
32 if err != nil {
33 t.Fatal(err)
34 }
35 f := fixture{directory: hostactors.Directory{Store: release.NewDirStore(t.TempDir()), PublicKey: pub, Bucket: "bucket"}, signer: release.RootSigner(key), key: boxKey, now: time.Now().UTC()}
36 f.scope = Scope{Bucket: "bucket", Box: "box", Enrollment: strings.Repeat("a", 32), Binding: release.HistoryBinding{Account: "abc", App: "web", Host: "web.example"}}
37 roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Boxes: map[string]hostactors.Box{"box": {Enrollment: f.scope.Enrollment, Namespace: "platform", BoxID: "box", Label: "box", Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-box"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", CensusPublicKey: boxKey.Public().(ed25519.PublicKey)}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
38 wire, err := release.SignRootEnvelope(f.signer, roster)
39 if err != nil {
40 t.Fatal(err)
41 }
42 if err := publish(t.Context(), f.directory.Store, hostactors.Key, wire); err != nil {
43 t.Fatal(err)
44 }
45 f.status = release.HistoryMigrationStatus{Schema: 1, Account: "abc", App: "web", HistoryReconstructionStatus: release.HistoryReconstructionStatus{Identity: strings.Repeat("c", 64), Revision: 1}, Hosts: []release.HistoryHostStatus{{HistoryReconstructionHost: release.HistoryReconstructionHost{Host: "web.example", Source: "canonical"}}}}
46 return f
47}
48
49func (f fixture) snapshot(context.Context) (release.HistoryMigrationStatus, error) {
50 return f.status, nil
51}
52
53func TestPlan14HistoryReadback(t *testing.T) {
54 f := newFixture(t)
55 q, err := RequestStatus(t.Context(), f.directory, f.signer, f.scope, f.now)
56 if err != nil {
57 t.Fatal(err)
58 }
59 if _, err := ReadStatus(t.Context(), f.directory, q, f.now); !errors.Is(err, os.ErrNotExist) {
60 t.Fatalf("delivery treated as completion: %v", err)
61 }
62 if err := Respond(t.Context(), f.directory, f.scope, f.key, f.now, f.snapshot); err != nil {
63 t.Fatal(err)
64 }
65 got, err := ReadStatus(t.Context(), f.directory, q, f.now)
66 if err != nil {
67 t.Fatal(err)
68 }
69 if got.Status.Identity != f.status.Identity || got.Status.AppComplete || got.Status.Hosts[0].Programs.Complete || got.Status.RecoverySources != nil {
70 t.Fatalf("readback invented completeness: %+v", got)
71 }
72 next, err := RequestStatus(t.Context(), f.directory, f.signer, f.scope, f.now)
73 if err != nil {
74 t.Fatal(err)
75 }
76 if next.ID == q.ID {
77 t.Fatal("challenge reused")
78 }
79 old, _, err := f.directory.Store.Get(t.Context(), ResponseKey(q))
80 if err != nil {
81 t.Fatal(err)
82 }
83 if err := publish(t.Context(), f.directory.Store, ResponseKey(next), old); err != nil {
84 t.Fatal(err)
85 }
86 if _, err := ReadStatus(t.Context(), f.directory, next, f.now); err == nil {
87 t.Fatal("old signed status answered new challenge")
88 }
89 if err := Respond(t.Context(), f.directory, f.scope, f.key, f.now, f.snapshot); err != nil {
90 t.Fatal(err)
91 }
92 if _, err := ReadStatus(t.Context(), f.directory, next, f.now); err != nil {
93 t.Fatal(err)
94 }
95}
96
97func TestPlan14HistoryReadbackAuthority(t *testing.T) {
98 for _, fault := range []string{"request signature", "request kind", "request bucket", "request box", "request enrollment", "request host", "request account", "request app", "request expired", "request future", "response signature", "response kind", "response digest", "response future", "response predates", "response account", "response host", "key", "snapshot", "source", "namespace", "roster changed", "fence", "oversize"} {
99 t.Run(fault, func(t *testing.T) {
100 f := newFixture(t)
101 q, err := RequestStatus(t.Context(), f.directory, f.signer, f.scope, f.now)
102 if err != nil {
103 t.Fatal(err)
104 }
105 if strings.HasPrefix(fault, "request ") {
106 bad := q
107 signer := f.signer
108 switch fault {
109 case "request signature":
110 _, key, _ := ed25519.GenerateKey(nil)
111 signer = release.RootSigner(key)
112 case "request kind":
113 bad.Kind = "other"
114 case "request bucket":
115 bad.Scope.Bucket = "other"
116 case "request box":
117 bad.Scope.Box = "other"
118 case "request enrollment":
119 bad.Scope.Enrollment = strings.Repeat("d", 32)
120 case "request host":
121 bad.Scope.Binding.Host = "other.example"
122 case "request account":
123 bad.Scope.Binding.Account = "xyz"
124 case "request app":
125 bad.Scope.Binding.App = "other"
126 case "request expired":
127 bad.IssuedAt = f.now.Add(-25 * time.Hour)
128 case "request future":
129 bad.IssuedAt = f.now.Add(2 * time.Minute)
130 }
131 wire, err := release.SignRootEnvelope(signer, bad)
132 if err != nil {
133 t.Fatal(err)
134 }
135 if err := publish(t.Context(), f.directory.Store, RequestKey(f.scope), wire); err != nil {
136 t.Fatal(err)
137 }
138 }
139 calls := 0
140 snapshot := func(ctx context.Context) (release.HistoryMigrationStatus, error) {
141 calls++
142 switch fault {
143 case "snapshot":
144 return release.HistoryMigrationStatus{}, os.ErrPermission
145 case "source":
146 f.status.Hosts[0].Source = ""
147 case "namespace":
148 f.status.Identity = ""
149 case "oversize":
150 f.status.Hosts[0].Source = strings.Repeat("x", release.MaxRootEnvelopeBytes)
151 case "roster changed":
152 observed, err := f.directory.Read(ctx)
153 if err != nil {
154 t.Fatal(err)
155 }
156 observed.Roster.Revision++
157 wire, err := release.SignRootEnvelope(f.signer, observed.Roster)
158 if err != nil {
159 t.Fatal(err)
160 }
161 if err := publish(ctx, f.directory.Store, hostactors.Key, wire); err != nil {
162 t.Fatal(err)
163 }
164 case "fence":
165 if err := publish(ctx, f.directory.Store, "control/box-fences/"+f.scope.Enrollment+".json", []byte("{}")); err != nil {
166 t.Fatal(err)
167 }
168 }
169 return f.status, nil
170 }
171 key := f.key
172 if fault == "key" {
173 _, key, _ = ed25519.GenerateKey(nil)
174 }
175 err = Respond(t.Context(), f.directory, f.scope, key, f.now, snapshot)
176 if strings.HasPrefix(fault, "response ") {
177 if err != nil {
178 t.Fatal(err)
179 }
180 wire, _, err := f.directory.Store.Get(t.Context(), ResponseKey(q))
181 if err != nil {
182 t.Fatal(err)
183 }
184 var receipt Receipt
185 if err := release.VerifyRootEnvelope(f.key.Public().(ed25519.PublicKey), wire, &receipt); err != nil {
186 t.Fatal(err)
187 }
188 key := f.key
189 switch fault {
190 case "response signature":
191 _, key, _ = ed25519.GenerateKey(nil)
192 case "response kind":
193 receipt.Kind = "other"
194 case "response digest":
195 receipt.RequestDigest = strings.Repeat("e", 64)
196 case "response future":
197 receipt.ObservedAt = f.now.Add(2 * time.Minute)
198 case "response predates":
199 receipt.ObservedAt = q.IssuedAt.Add(-2 * time.Minute)
200 case "response account":
201 receipt.Status.Account = "xyz"
202 case "response host":
203 receipt.Status.Hosts[0].Host = "other.example"
204 }
205 wire, err = release.SignRootEnvelope(release.RootSigner(key), receipt)
206 if err != nil {
207 t.Fatal(err)
208 }
209 if err := publish(t.Context(), f.directory.Store, ResponseKey(q), wire); err != nil {
210 t.Fatal(err)
211 }
212 if _, err := ReadStatus(t.Context(), f.directory, q, f.now); err == nil {
213 t.Fatal("forged response accepted")
214 }
215 } else {
216 if err == nil {
217 t.Fatal("invalid readback published")
218 }
219 if _, _, err := f.directory.Store.Get(t.Context(), ResponseKey(q)); !errors.Is(err, os.ErrNotExist) {
220 t.Fatalf("refused readback left response: %v", err)
221 }
222 if strings.HasPrefix(fault, "request ") && calls != 0 {
223 t.Fatal("invalid request reached private snapshot")
224 }
225 }
226 })
227 }
228}
229
230func TestPlan14HistoryReadbackReenrollment(t *testing.T) {
231 f := newFixture(t)
232 q, err := RequestStatus(t.Context(), f.directory, f.signer, f.scope, f.now)
233 if err != nil {
234 t.Fatal(err)
235 }
236 if err := Respond(t.Context(), f.directory, f.scope, f.key, f.now, f.snapshot); err != nil {
237 t.Fatal(err)
238 }
239 observed, err := f.directory.Read(t.Context())
240 if err != nil {
241 t.Fatal(err)
242 }
243 row := observed.Roster.Boxes[f.scope.Box]
244 row.Enrollment = strings.Repeat("d", 32)
245 observed.Roster.Boxes[f.scope.Box] = row
246 wire, err := release.SignRootEnvelope(f.signer, observed.Roster)
247 if err != nil {
248 t.Fatal(err)
249 }
250 if err := publish(t.Context(), f.directory.Store, hostactors.Key, wire); err != nil {
251 t.Fatal(err)
252 }
253 if _, err := ReadStatus(t.Context(), f.directory, q, f.now); err == nil {
254 t.Fatal("retired enrollment accepted")
255 }
256}
257
258func TestPlan14HistoryReadbackRequestRefusal(t *testing.T) {
259 for _, fault := range []string{"wrong signer", "missing key", "quarantined", "no store", "bad scope", "clock"} {
260 t.Run(fault, func(t *testing.T) {
261 f := newFixture(t)
262 switch fault {
263 case "wrong signer":
264 _, key, _ := ed25519.GenerateKey(nil)
265 f.signer = release.RootSigner(key)
266 case "missing key", "quarantined":
267 observed, err := f.directory.Read(t.Context())
268 if err != nil {
269 t.Fatal(err)
270 }
271 row := observed.Roster.Boxes[f.scope.Box]
272 if fault == "missing key" {
273 row.CensusPublicKey = nil
274 } else {
275 row.State = "quarantined"
276 row.QuarantineRevision = 1
277 row.LossIDs = []string{"loss"}
278 }
279 observed.Roster.Boxes[f.scope.Box] = row
280 raw, err := release.SignRootEnvelope(f.signer, observed.Roster)
281 if err != nil {
282 t.Fatal(err)
283 }
284 if err := publish(t.Context(), f.directory.Store, hostactors.Key, raw); err != nil {
285 t.Fatal(err)
286 }
287 case "no store":
288 f.directory.Store = nil
289 case "bad scope":
290 f.scope.Binding.Host = "../host"
291 case "clock":
292 f.now = time.Time{}
293 }
294 if _, err := RequestStatus(t.Context(), f.directory, f.signer, f.scope, f.now); err == nil {
295 t.Fatal("invalid request published")
296 }
297 })
298 }
299}
300
301type readbackStore struct {
302 release.Store
303 get func(string)
304 fail string
305}
306
307func (s readbackStore) Get(ctx context.Context, key string) ([]byte, string, error) {
308 raw, tag, err := s.Store.Get(ctx, key)
309 if s.get != nil {
310 s.get(key)
311 }
312 if key == s.fail {
313 return nil, "", os.ErrPermission
314 }
315 return raw, tag, err
316}
317
318func TestPlan14HistoryReadbackReadRace(t *testing.T) {
319 for _, fault := range []string{"roster", "fence", "response read", "roster read"} {
320 t.Run(fault, func(t *testing.T) {
321 f := newFixture(t)
322 q, err := RequestStatus(t.Context(), f.directory, f.signer, f.scope, f.now)
323 if err != nil {
324 t.Fatal(err)
325 }
326 if err := Respond(t.Context(), f.directory, f.scope, f.key, f.now, f.snapshot); err != nil {
327 t.Fatal(err)
328 }
329 base := f.directory.Store
330 wrapped := readbackStore{Store: base}
331 if fault == "response read" {
332 wrapped.fail = ResponseKey(q)
333 }
334 if fault == "roster read" {
335 wrapped.fail = hostactors.Key
336 }
337 wrapped.get = func(key string) {
338 if key != ResponseKey(q) {
339 return
340 }
341 switch fault {
342 case "roster":
343 o, err := f.directory.Read(t.Context())
344 if err != nil {
345 t.Fatal(err)
346 }
347 o.Roster.Revision++
348 wire, err := release.SignRootEnvelope(f.signer, o.Roster)
349 if err != nil {
350 t.Fatal(err)
351 }
352 if err := publish(t.Context(), base, hostactors.Key, wire); err != nil {
353 t.Fatal(err)
354 }
355 case "fence":
356 if err := publish(t.Context(), base, "control/box-fences/"+f.scope.Enrollment+".json", []byte("{}")); err != nil {
357 t.Fatal(err)
358 }
359 }
360 }
361 f.directory.Store = wrapped
362 if _, err := ReadStatus(t.Context(), f.directory, q, f.now); err == nil {
363 t.Fatal("changed/unreadable readback accepted")
364 }
365 })
366 }
367}
368