| 1 | package historyreadback |
| 2 | |
| 3 | import ( |
| 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 | |
| 16 | type 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 | |
| 25 | func 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 | |
| 49 | func (f fixture) snapshot(context.Context) (release.HistoryMigrationStatus, error) { |
| 50 | return f.status, nil |
| 51 | } |
| 52 | |
| 53 | func 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 | |
| 97 | func 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 | |
| 230 | func 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 | |
| 258 | func 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 | |
| 301 | type readbackStore struct { |
| 302 | release.Store |
| 303 | get func(string) |
| 304 | fail string |
| 305 | } |
| 306 | |
| 307 | func (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 | |
| 318 | func 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 | |