| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/hostbox" |
| 16 | "github.com/carlosframework/platform/internal/hostguard" |
| 17 | "github.com/carlosframework/platform/internal/provision" |
| 18 | "github.com/carlosframework/platform/internal/release" |
| 19 | "github.com/carlosframework/platform/internal/rootparticipation" |
| 20 | "github.com/carlosframework/platform/internal/rootstate" |
| 21 | "golang.org/x/sys/unix" |
| 22 | ) |
| 23 | |
| 24 | func TestPlan14GenerationRequiredArtifacts(t *testing.T) { |
| 25 | plan14GenerationRequiredArtifacts(t, false) |
| 26 | } |
| 27 | |
| 28 | func TestPlan14GenerationLocalRecoveryObligations(t *testing.T) { |
| 29 | plan14GenerationRequiredArtifacts(t, true) |
| 30 | } |
| 31 | |
| 32 | func plan14GenerationRequiredArtifacts(t *testing.T, local bool) { |
| 33 | for _, attack := range []string{"none", "service", "record-next-pass", "route-omitted", "state-omitted", "historical-state-omitted", "requirements-missing", "requirements-schema", "requirements-malformed", "state-child-only", "state-wrong-host", "state-wrong-root", "state-file", "duplicate-route", "duplicate-state", "false-empty", "predecessor-omitted", "record-schema", "record-kind", "record-missing", "record-malformed", "record-account", "record-app", "record-host", "record-incarnation", "changed-record-during-kernel"} { |
| 34 | if local && (strings.HasPrefix(attack, "record-") || attack == "changed-record-during-kernel") { |
| 35 | continue |
| 36 | } |
| 37 | t.Run(attack, func(t *testing.T) { |
| 38 | f, disk, rec := generationLaunchFixture(t) |
| 39 | if _, err := f.root.Serve(t.Context(), f.peer, f.req); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | r := readGenerationLaunch(t, disk) |
| 43 | views, err := f.root.Guard.Root.GenerationInstallations(t.Context()) |
| 44 | if err != nil { |
| 45 | t.Fatal(err) |
| 46 | } |
| 47 | launch, err := f.journal.Read(t.Context(), f.target) |
| 48 | if err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | t.Setenv("CARLOS_DATA_DIR", t.TempDir()) |
| 52 | path := provision.InstanceDir(dataDir(), rec.Host) |
| 53 | if err := os.MkdirAll(path, 0700); err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | var st unix.Stat_t |
| 57 | if err := unix.Stat(path, &st); err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | rel, err := filepath.Rel(dataDir(), path) |
| 61 | if err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | snapshot := generationInventory{Census: rootparticipation.Census{Schema: 1, Box: f.target.Box, Enrollment: f.target.Enrollment, Migration: 1, Revision: 1, LaunchRevision: 1, Complete: true}, Installations: views, Launches: []hostbox.LaunchInventory{launch}, Payloads: generationPayloadFixture(views)} |
| 65 | entry := hostbox.StopInventoryEntry{Identity: inventoryIdentityFixture(t, dataDir(), rel), Kind: "path", ArtifactID: "state", ManagedRoot: "data", RelativePath: rel, Ownership: "owner", Materialization: "materialized", Device: uint64(st.Dev), Inode: st.Ino, Type: unix.S_IFDIR} |
| 66 | snapshot.Payloads[0].Inventory.Entries = append(snapshot.Payloads[0].Inventory.Entries, entry) |
| 67 | store := f.root.generations.store |
| 68 | rec.Schema = 1 |
| 69 | plan14PutRecord(t, store, rec) |
| 70 | key := provision.RecordKey(rec.Account, rec.App, rec.Host) |
| 71 | original, _, err := store.Get(t.Context(), key) |
| 72 | if err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | put := func(raw []byte) { |
| 76 | t.Helper() |
| 77 | _, etag, err := store.Get(t.Context(), key) |
| 78 | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | if _, err := store.Put(t.Context(), key, raw, etag); err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | } |
| 85 | changeRecord := func() { |
| 86 | t.Helper() |
| 87 | var changed provision.Record |
| 88 | if err := json.Unmarshal(original, &changed); err != nil { |
| 89 | t.Fatal(err) |
| 90 | } |
| 91 | switch attack { |
| 92 | case "record-schema": |
| 93 | changed.Schema = 2 |
| 94 | case "record-kind": |
| 95 | changed.Kind = "unknown" |
| 96 | case "record-account": |
| 97 | changed.Account = "bap" |
| 98 | case "record-app": |
| 99 | changed.App = "other" |
| 100 | case "record-host": |
| 101 | changed.Host = "other.example" |
| 102 | case "record-incarnation": |
| 103 | changed.CreatedAt = changed.CreatedAt.Add(time.Hour) |
| 104 | default: |
| 105 | changed.RecordSeq++ |
| 106 | } |
| 107 | raw, err := json.Marshal(changed) |
| 108 | if err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | put(raw) |
| 112 | } |
| 113 | attackActive := false |
| 114 | v := generationReconciler{read: func(ctx context.Context) (generationInventory, error) { |
| 115 | if local { |
| 116 | return generationLocalObligations(ctx, snapshot, nil) |
| 117 | } |
| 118 | return generationRequiredInventory(ctx, store, snapshot, nil) |
| 119 | }, kernel: func(context.Context) (string, string, error) { |
| 120 | if attackActive && attack == "changed-record-during-kernel" { |
| 121 | changeRecord() |
| 122 | } |
| 123 | return "boot", r.Binding.Digest, nil |
| 124 | }} |
| 125 | source := generationPinSource{open: func() (rootstate.Store, error) { return disk, nil }, reconcile: v.reconcile} |
| 126 | ledger := &release.RootGenerations{Store: disk} |
| 127 | accept := func() { |
| 128 | t.Helper() |
| 129 | pins, err := source.StagedPins(t.Context(), f.root.generations.optDir) |
| 130 | if err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | if pinned, err := pins.Refers(r.Binding.Path); err != nil || !pinned { |
| 134 | t.Fatalf("reservation not pinned: %v", err) |
| 135 | } |
| 136 | if c, err := ledger.Completeness(); err != nil || !c.Complete { |
| 137 | t.Fatalf("valid required artifacts refused: %+v %v", c, err) |
| 138 | } |
| 139 | } |
| 140 | if attack == "service" { |
| 141 | snapshot.Installations[0].Installation.Artifacts.StateDirectory = false |
| 142 | rec.Kind, rec.Addr = "service", "127.0.0.1:8080" |
| 143 | plan14PutRecord(t, store, rec) |
| 144 | snapshot.Payloads[0].Inventory.Entries = snapshot.Payloads[0].Inventory.Entries[:1] |
| 145 | } |
| 146 | accept() |
| 147 | if attack == "none" || attack == "service" { |
| 148 | return |
| 149 | } |
| 150 | initial, _ := ledger.Completeness() |
| 151 | if attack == "record-next-pass" { |
| 152 | changeRecord() |
| 153 | accept() |
| 154 | next, err := ledger.Completeness() |
| 155 | if err != nil || next.Evidence.Installations == initial.Evidence.Installations { |
| 156 | t.Fatalf("record revision absent from certificate: %+v %v", next, err) |
| 157 | } |
| 158 | return |
| 159 | } |
| 160 | saved, err := json.Marshal(snapshot) |
| 161 | if err != nil { |
| 162 | t.Fatal(err) |
| 163 | } |
| 164 | entries := &snapshot.Payloads[0].Inventory.Entries |
| 165 | switch attack { |
| 166 | case "requirements-missing": |
| 167 | snapshot.Installations[0].Installation.Artifacts = hostguard.InstallationArtifacts{} |
| 168 | case "requirements-schema": |
| 169 | snapshot.Installations[0].Installation.Artifacts.Schema++ |
| 170 | case "requirements-malformed": |
| 171 | snapshot.Installations[0].Installation.Artifacts.Timers = "bad/path" |
| 172 | case "route-omitted": |
| 173 | *entries = (*entries)[1:] |
| 174 | case "historical-state-omitted": |
| 175 | rec.Kind, rec.Addr = "service", "127.0.0.1:8080" |
| 176 | plan14PutRecord(t, store, rec) |
| 177 | *entries = (*entries)[:1] |
| 178 | case "state-omitted": |
| 179 | *entries = (*entries)[:1] |
| 180 | case "state-child-only", "state-wrong-host": |
| 181 | name := filepath.Join(path, "child") |
| 182 | if attack == "state-wrong-host" { |
| 183 | name = provision.InstanceDir(dataDir(), "other.example") |
| 184 | } |
| 185 | if err := os.MkdirAll(name, 0700); err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | if err := unix.Stat(name, &st); err != nil { |
| 189 | t.Fatal(err) |
| 190 | } |
| 191 | (*entries)[1].RelativePath, err = filepath.Rel(dataDir(), name) |
| 192 | if err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | (*entries)[1].Device, (*entries)[1].Inode = uint64(st.Dev), st.Ino |
| 196 | case "state-wrong-root": |
| 197 | (*entries)[1].ManagedRoot = "opt" |
| 198 | case "state-file": |
| 199 | (*entries)[1].Type = unix.S_IFREG |
| 200 | case "duplicate-route": |
| 201 | *entries = append(*entries, (*entries)[0]) |
| 202 | case "duplicate-state": |
| 203 | *entries = append(*entries, (*entries)[1]) |
| 204 | case "false-empty": |
| 205 | *entries = nil |
| 206 | snapshot.Installations[0].Installation.Empty = true |
| 207 | case "predecessor-omitted": |
| 208 | snapshot.Installations[0].Retired = append(snapshot.Installations[0].Retired, hostguard.Captured{Incarnation: f.target.Incarnation, Stamp: f.target.Stamp - 1}) |
| 209 | old := snapshot.Payloads[0] |
| 210 | old.Inventory.InstallationStamp-- |
| 211 | old.Inventory.Entries = append([]hostbox.StopInventoryEntry(nil), (*entries)[:1]...) |
| 212 | snapshot.Payloads = append(snapshot.Payloads, old) |
| 213 | case "record-missing": |
| 214 | if err := store.Delete(t.Context(), key); err != nil { |
| 215 | t.Fatal(err) |
| 216 | } |
| 217 | case "record-malformed": |
| 218 | put([]byte("{")) |
| 219 | case "record-schema", "record-kind", "record-account", "record-app", "record-host", "record-incarnation": |
| 220 | changeRecord() |
| 221 | } |
| 222 | attackActive = true |
| 223 | if pins, err := source.StagedPins(t.Context(), f.root.generations.optDir); err == nil || pins != nil { |
| 224 | t.Fatalf("required-artifact omission or record change certified: %v %v", pins, err) |
| 225 | } |
| 226 | if c, err := ledger.Completeness(); err != nil || c.Complete || c.Revision <= initial.Revision { |
| 227 | t.Fatalf("invalid source retained completeness: %+v %v", c, err) |
| 228 | } |
| 229 | if got, err := ledger.Read(r.Binding); err != nil || got.Binding != r.Binding || got.Closed { |
| 230 | t.Fatal("required-artifact refusal altered the reservation") |
| 231 | } |
| 232 | attackActive = false |
| 233 | put(original) |
| 234 | if err := json.Unmarshal(saved, &snapshot); err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | accept() |
| 238 | }) |
| 239 | } |
| 240 | } |
| 241 | |