| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/hostactors" |
| 15 | "github.com/carlosframework/platform/internal/hostbox" |
| 16 | "github.com/carlosframework/platform/internal/hostevidence" |
| 17 | "github.com/carlosframework/platform/internal/hostguard" |
| 18 | "github.com/carlosframework/platform/internal/release" |
| 19 | ) |
| 20 | |
| 21 | func TestPlan9LegacyJournalNeedsExactRootSignedStop(t *testing.T) { |
| 22 | root := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)) |
| 23 | signer := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, ed25519.SeedSize)) |
| 24 | store := release.NewDirStore(t.TempDir()) |
| 25 | enrollment := strings.Repeat("a", 32) |
| 26 | roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 3, RequestLimitNS: int64(hostbox.RequestLimit), Regions: map[string]hostactors.Region{"ie": {}}, Boxes: map[string]hostactors.Box{ |
| 27 | "box": {CensusPublicKey: signer.Public().(ed25519.PublicKey), Enrollment: enrollment, Namespace: "platform", BoxID: "box", Label: "box", Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "ie", InstanceID: "i-box"}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"}, |
| 28 | }, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| 29 | raw, err := release.SignRootEnvelope(release.RootSigner(root), roster) |
| 30 | if err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | if _, err = store.Put(t.Context(), hostactors.Key, raw, ""); err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | newBatch := func() func(context.Context, hostbox.LaunchInventory, hostbox.StopInventory, hostbox.StopKernelJournal) error { |
| 37 | return legacyJournalProof(store, hostactors.Directory{Store: store, PublicKey: root.Public().(ed25519.PublicKey), Bucket: "bucket"}) |
| 38 | } |
| 39 | verify := newBatch() |
| 40 | j, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "legacy.db")) |
| 41 | if err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | defer j.Close() |
| 45 | in := hostbox.LaunchInventory{Target: hostguard.FenceTarget{Host: "app.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: enrollment, BootID: "boot", ControllerID: "controller", Op: "operation"}, ControllerPID: 1, ProcessStart: "1", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "session", Device: 1, Inode: 2}}} |
| 46 | if err = j.Create(t.Context(), in); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | journal, err := j.ReadStopKernelJournal(t.Context(), in.Target) |
| 50 | if err != nil { |
| 51 | t.Fatal(err) |
| 52 | } |
| 53 | inventory := hostbox.StopInventory{Complete: true, Host: in.Target.Host, Incarnation: "inc", InstallationStamp: 1, Box: "box", Enrollment: enrollment, DirectoryRevision: "1"} |
| 54 | if err = verify(t.Context(), in, inventory, journal); err == nil { |
| 55 | t.Fatal("unproven legacy accepted") |
| 56 | } |
| 57 | target := in.Target |
| 58 | receipt := StopReceipt{FenceID: "fence", ClaimGen: 1, ObservedBootID: "boot", DirectoryRevision: "1", Binding: "1", CgroupIdentities: []string{"session"}, V: 1, ID: "stop1", Kind: "stopped", Host: target.Host, Incarnation: target.Incarnation, InstallationStamp: target.Stamp, WriterSession: target.Session, Box: target.Box, Enrollment: target.Enrollment, BootID: target.BootID, ControllerID: target.ControllerID, OperationID: target.Op, RequestLimitNS: int64(hostbox.RequestLimit), EvidenceAt: time.Now().UTC(), InventorySHA256: digestStop(inventory), LocalJournalSHA256: digestStop(journal)} |
| 59 | raw, _ = json.Marshal(receipt) |
| 60 | key := "control/hosts/app.example/evidence/operation/box/stop1.json" |
| 61 | if _, err = store.Put(t.Context(), key, raw, ""); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | verify = newBatch() |
| 65 | if err = verify(t.Context(), in, inventory, journal); err == nil { |
| 66 | t.Fatal("unsigned stop accepted") |
| 67 | } |
| 68 | if err = hostevidence.Publish(t.Context(), store, signer, key, raw); err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | if err = verify(t.Context(), in, inventory, journal); err == nil { |
| 72 | t.Fatal("batch silently refreshed unsigned evidence") |
| 73 | } |
| 74 | // Two distinct writer histories under one evidence namespace are fetched |
| 75 | // and indexed once, rather than rescanning every receipt for each row. |
| 76 | second := in |
| 77 | second.Target.Session = 2 |
| 78 | secondJournal := journal |
| 79 | secondJournal.Target = second.Target |
| 80 | secondReceipt := receipt |
| 81 | secondReceipt.ID = "stop2" |
| 82 | secondReceipt.WriterSession = 2 |
| 83 | secondReceipt.LocalJournalSHA256 = digestStop(secondJournal) |
| 84 | secondRaw, _ := json.Marshal(secondReceipt) |
| 85 | secondKey := "control/hosts/app.example/evidence/operation/box/stop2.json" |
| 86 | if err = hostevidence.Publish(t.Context(), store, signer, secondKey, secondRaw); err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | counted := &legacyProofCountStore{Store: store, t: t, gets: map[string]int{}, lists: map[string]int{}} |
| 90 | directory := hostactors.Directory{Store: counted, PublicKey: root.Public().(ed25519.PublicKey), Bucket: "bucket"} |
| 91 | verify = legacyJournalProof(counted, directory) |
| 92 | if err = verify(t.Context(), second, inventory, secondJournal); err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | if err = verify(t.Context(), in, inventory, journal); err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | if counted.gets[hostactors.Key] != 1 || counted.lists["control/hosts/app.example/evidence/operation/box/"] != 1 { |
| 99 | t.Fatalf("batch repeated roster/list: %v %v", counted.gets, counted.lists) |
| 100 | } |
| 101 | for _, key := range []string{key, key + ".sig", secondKey, secondKey + ".sig"} { |
| 102 | if counted.gets[key] != 1 { |
| 103 | t.Fatalf("receipt fetched %d times: %s", counted.gets[key], key) |
| 104 | } |
| 105 | } |
| 106 | cancelled, cancel := context.WithCancel(t.Context()) |
| 107 | cancel() |
| 108 | if err = verify(cancelled, in, inventory, journal); !errors.Is(err, context.Canceled) { |
| 109 | t.Fatalf("cached proof ignored cancellation: %v", err) |
| 110 | } |
| 111 | if counted.gets[hostactors.Key] != 1 { |
| 112 | t.Fatal("canceled cache lookup reread roster") |
| 113 | } |
| 114 | // In-flight cancellation is sticky in this immutable batch, while a new |
| 115 | // startup gets a fresh closure and can retry the failed read. |
| 116 | during, stopRead := context.WithCancel(t.Context()) |
| 117 | counted.onGet = func(ctx context.Context, key string) error { |
| 118 | if key == hostactors.Key { |
| 119 | stopRead() |
| 120 | return ctx.Err() |
| 121 | } |
| 122 | return nil |
| 123 | } |
| 124 | interrupted := legacyJournalProof(counted, directory) |
| 125 | if err = interrupted(during, in, inventory, journal); !errors.Is(err, context.Canceled) { |
| 126 | t.Fatalf("in-flight cancellation: %v", err) |
| 127 | } |
| 128 | before := counted.gets[hostactors.Key] |
| 129 | counted.onGet = nil |
| 130 | if err = interrupted(t.Context(), in, inventory, journal); !errors.Is(err, context.Canceled) || counted.gets[hostactors.Key] != before { |
| 131 | t.Fatal("failed batch reread roster", err) |
| 132 | } |
| 133 | if err = legacyJournalProof(counted, directory)(t.Context(), in, inventory, journal); err != nil { |
| 134 | t.Fatal("fresh batch cannot retry", err) |
| 135 | } |
| 136 | changed := journal |
| 137 | changed.Completed = hostbox.ProducerCompletion{Count: 1, Digest: strings.Repeat("d", 64)} |
| 138 | if err = verify(t.Context(), in, inventory, changed); err == nil { |
| 139 | t.Fatal("forged completed counter accepted") |
| 140 | } |
| 141 | inventory.DirectoryRevision = "2" |
| 142 | if err = verify(t.Context(), in, inventory, journal); err == nil { |
| 143 | t.Fatal("changed inventory accepted") |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | type legacyProofCountStore struct { |
| 148 | release.Store |
| 149 | t *testing.T |
| 150 | gets, lists map[string]int |
| 151 | onGet func(context.Context, string) error |
| 152 | } |
| 153 | |
| 154 | func (s *legacyProofCountStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 155 | s.t.Helper() |
| 156 | if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > 30*time.Second { |
| 157 | s.t.Fatal("unbounded migration object read") |
| 158 | } |
| 159 | s.gets[key]++ |
| 160 | if s.onGet != nil { |
| 161 | if err := s.onGet(ctx, key); err != nil { |
| 162 | return nil, "", err |
| 163 | } |
| 164 | } |
| 165 | return s.Store.Get(ctx, key) |
| 166 | } |
| 167 | func (s *legacyProofCountStore) List(ctx context.Context, prefix string) ([]string, error) { |
| 168 | s.t.Helper() |
| 169 | if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > 30*time.Second { |
| 170 | s.t.Fatal("unbounded migration namespace read") |
| 171 | } |
| 172 | s.lists[prefix]++ |
| 173 | return s.Store.List(ctx, prefix) |
| 174 | } |
| 175 | |