| 1 | //go:build linux && rootrunner |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "crypto/ed25519" |
| 8 | "crypto/sha256" |
| 9 | "encoding/base64" |
| 10 | "encoding/hex" |
| 11 | "fmt" |
| 12 | "os" |
| 13 | "os/exec" |
| 14 | "path/filepath" |
| 15 | "runtime" |
| 16 | "strings" |
| 17 | "testing" |
| 18 | "time" |
| 19 | |
| 20 | "github.com/carlosframework/platform/internal/moves" |
| 21 | "github.com/carlosframework/platform/internal/release" |
| 22 | "github.com/carlosframework/platform/internal/rootstate" |
| 23 | "github.com/carlosframework/platform/internal/seeds" |
| 24 | ) |
| 25 | |
| 26 | func TestPlan14RootFloorBootSelectors(t *testing.T) { |
| 27 | dir := generationRootRunnerDir(t) |
| 28 | self, err := os.Executable() |
| 29 | if err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | binary, err := os.ReadFile(self) |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | runner := filepath.Join(dir, "runner.test") |
| 37 | if err := os.WriteFile(runner, binary, 0755); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | pid1, err := os.ReadFile("/proc/1/comm") |
| 41 | if err != nil || strings.TrimSpace(string(pid1)) != "systemd" { |
| 42 | t.Fatal("root runner requires systemd", err) |
| 43 | } |
| 44 | stage := filepath.Join(dir, "stage") |
| 45 | if err := bootstrap(stage, nil); err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | for _, unit := range []string{"carlos-system-update.service", "carlos-system-recover.service"} { |
| 49 | for _, attack := range []string{"none", "seeds accepted", "seeds below", "seeds legacy", "below", "stale verdict", "missing floor", "corrupt floor", "old reader", "no guard", "missing accepted", "crash-1", "crash-2", "crash-3", "crash-4", "crash-5", "agent high", "signed high", "seeds unpublished", "unknown crashing current"} { |
| 50 | t.Run(unit+"/"+attack, func(t *testing.T) { |
| 51 | opt, err := os.MkdirTemp(dir, "box-") |
| 52 | if err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | if err := os.Chmod(opt, 0755); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | pub, key, err := ed25519.GenerateKey(nil) |
| 59 | if err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub)) |
| 63 | id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"} |
| 64 | f, err := openRootMovesFloors(opt, id, true) |
| 65 | if err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | defer f.close() |
| 69 | b := moves.Floor{Min: 1, MinRelease: "v2.0.0"} |
| 70 | if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &b, Source: "history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, f)}); err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | mark := filepath.Join(opt, "executed") |
| 74 | makeBinary := func(path, version string, reader bool) { |
| 75 | body := "#!/bin/sh\nif [ \"$2\" = floor-allows-prev ]; then\n" |
| 76 | if reader { |
| 77 | body += " PLAN14_P3_GATE=1 exec " + runner + " -test.run=^TestPlan14RootFloorBootPayload$\n" |
| 78 | } else { |
| 79 | body += " exit 2\n" |
| 80 | } |
| 81 | body += "fi\nprintf '" + version + "' > " + mark + "\n" |
| 82 | rootFloorSignRunnerBytes(t, opt, path, version, []byte(body), key) |
| 83 | } |
| 84 | makeBinary(systemBinaryPath(opt), "v2.0.0", true) |
| 85 | makeBinary(systemPrevPath(opt), "v2.0.0", true) |
| 86 | j := rootFloorJudge{floors: f, current: systemBinaryPath(opt), prev: systemPrevPath(opt), binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(opt, path) }, idle: func(context.Context) error { return nil }} |
| 87 | if err := j.judge(t.Context()); err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | if strings.HasPrefix(attack, "seeds ") { |
| 91 | sf, err := openRootProtocolFloors(rootSeedsFloorDir(opt), id, true) |
| 92 | if err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | defer sf.close() |
| 96 | floor := moves.Floor{Min: 1, MinRelease: "v2.0.0"} |
| 97 | if attack != "seeds accepted" { |
| 98 | floor.MinRelease = "v3.0.0" |
| 99 | } |
| 100 | if attack == "seeds legacy" { |
| 101 | dd := filepath.Join(opt, "data") |
| 102 | if err := os.MkdirAll(dd, 0755); err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | armLocalSeedsFloor(t, dd, seeds.Floor{Min: floor.Min, MinRelease: floor.MinRelease}) |
| 106 | if err := refreshRootSeedsFloor(t.Context(), release.NewDirStore(t.TempDir()), opt, dd, id); err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | if err := os.RemoveAll(dd); err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | if _, err := sf.projection(); err == nil { |
| 113 | t.Fatal("legacy seeds accepted") |
| 114 | } |
| 115 | } else if err := sf.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &floor, Source: "root history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, sf)}); err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | } |
| 119 | switch attack { |
| 120 | case "agent high": |
| 121 | dd := filepath.Join(opt, "data") |
| 122 | if err := os.MkdirAll(filepath.Join(dd, ".moves"), 0755); err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | if err := os.WriteFile(filepath.Join(dd, ".moves", "floor.min-release"), []byte("v99.0.0\n"), 0644); err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | if err := os.Chown(filepath.Join(dd, ".moves", "floor.min-release"), 65534, 65534); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | legacy, err := rootFloorLegacy(dd) |
| 132 | if err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | if err := f.refresh(t.Context(), &b, legacy, nil); err != nil { |
| 136 | t.Fatal(err) |
| 137 | } |
| 138 | case "signed high": |
| 139 | if err := f.refresh(t.Context(), &moves.Floor{Min: 1, MinRelease: "v99.0.0"}, nil, nil); err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | case "seeds unpublished": |
| 143 | dd := filepath.Join(opt, "data") |
| 144 | if err := os.MkdirAll(dd, 0755); err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v99.0.0"}) |
| 148 | if err := refreshRootSeedsFloor(t.Context(), release.NewDirStore(t.TempDir()), opt, dd, id); err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | if bound, err := readRootSeedsBound(opt, id); err != nil || bound != nil { |
| 152 | t.Fatal("unpublished seeds armed", bound, err) |
| 153 | } |
| 154 | case "unknown crashing current": |
| 155 | for _, name := range []string{rootFloorAccepted, rootFloorDeny} { |
| 156 | if err := f.private.Remove(name); err != nil { |
| 157 | t.Fatal(err) |
| 158 | } |
| 159 | } |
| 160 | for _, name := range []string{rootFloorProjectionFile, rootFloorSelector} { |
| 161 | if err := f.public.Remove(name); err != nil { |
| 162 | t.Fatal(err) |
| 163 | } |
| 164 | } |
| 165 | rootFloorSignRunnerBytes(t, opt, systemBinaryPath(opt), "v3.0.0", []byte("#!/bin/sh\nexit 99\n"), key) |
| 166 | case "below": |
| 167 | makeBinary(systemBinaryPath(opt), "v1.0.0", true) |
| 168 | makeBinary(systemPrevPath(opt), "v1.0.0", true) |
| 169 | case "stale verdict": |
| 170 | if err := f.refresh(t.Context(), &b, nil, nil); err != nil { |
| 171 | t.Fatal(err) |
| 172 | } |
| 173 | makeBinary(systemBinaryPath(opt), "v1.0.0", true) |
| 174 | case "missing accepted": |
| 175 | if err := f.private.Remove(rootFloorAccepted); err != nil { |
| 176 | t.Fatal(err) |
| 177 | } |
| 178 | case "missing floor": |
| 179 | if err := f.public.Remove(rootFloorSelector); err != nil { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | case "corrupt floor": |
| 183 | if err := f.public.Write(rootFloorSelector, []byte("broken")); err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | case "old reader": |
| 187 | makeBinary(systemPrevPath(opt), "v2.0.0", false) |
| 188 | if err := j.judge(t.Context()); err != nil { |
| 189 | t.Fatal(err) |
| 190 | } |
| 191 | makeBinary(systemBinaryPath(opt), "v1.0.0", true) |
| 192 | } |
| 193 | if strings.HasPrefix(attack, "crash-") { |
| 194 | fail := int(attack[len(attack)-1] - '0') |
| 195 | n := 0 |
| 196 | broken := f |
| 197 | broken.private = &floorFaultStore{Store: f.private, n: &n, fail: fail, after: true} |
| 198 | broken.public = &floorFaultStore{Store: f.public, n: &n, fail: fail, after: true} |
| 199 | if err := broken.refresh(t.Context(), &moves.Floor{Min: 1, MinRelease: "v3.0.0"}, nil, nil); err == nil { |
| 200 | t.Fatal("crash injection succeeded") |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | guard := filepath.Join(opt, "system", "guard", "carlos") |
| 205 | if attack != "no guard" { |
| 206 | if err := os.WriteFile(guard, []byte("#!/bin/sh\nexec "+runner+" -test.run=^TestPlan14RootFloorBootPayload$\n"), 0755); err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | } |
| 210 | if err := os.MkdirAll(filepath.Join(opt, "system", "router"), 0755); err != nil { |
| 211 | t.Fatal(err) |
| 212 | } |
| 213 | if err := os.WriteFile(filepath.Join(opt, "system", "router", "journal.json"), []byte("{}"), 0600); err != nil { |
| 214 | t.Fatal(err) |
| 215 | } |
| 216 | raw, err := os.ReadFile(filepath.Join(stage, "etc", "systemd", "system", unit)) |
| 217 | if err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | body := strings.ReplaceAll(string(raw), "/opt/carlos", opt) |
| 221 | body = strings.ReplaceAll(body, "/data", filepath.Join(opt, "data")) |
| 222 | // Preserve the service's hardening and actual selector. Remove only its |
| 223 | // deployment dependencies; all paths and identity are fixture-owned. |
| 224 | var lines []string |
| 225 | for _, line := range strings.Split(body, "\n") { |
| 226 | if strings.HasPrefix(line, "OnFailure=") || strings.HasPrefix(line, "Before=") || strings.HasPrefix(line, "After=") || strings.HasPrefix(line, "Wants=") || strings.HasPrefix(line, "EnvironmentFile=") { |
| 227 | continue |
| 228 | } |
| 229 | lines = append(lines, line) |
| 230 | if line == "[Service]" { |
| 231 | lines = append(lines, "Environment=PLAN14_P3_OPT="+opt, "Environment=CARLOS_RELEASE_PUBKEY="+base64.StdEncoding.EncodeToString(pub)) |
| 232 | } |
| 233 | } |
| 234 | name := "plan14-p3-" + filepath.Base(opt) + ".service" |
| 235 | path := filepath.Join("/run/systemd/system", name) |
| 236 | if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644); err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | t.Cleanup(func() { |
| 240 | exec.Command("systemctl", "stop", name).Run() |
| 241 | os.Remove(path) |
| 242 | exec.Command("systemctl", "daemon-reload").Run() |
| 243 | exec.Command("systemctl", "reset-failed", name).Run() |
| 244 | }) |
| 245 | if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { |
| 246 | t.Fatal(string(out), err) |
| 247 | } |
| 248 | out, runErr := exec.CommandContext(t.Context(), "systemctl", "start", name).CombinedOutput() |
| 249 | got, readErr := os.ReadFile(mark) |
| 250 | want := "v2.0.0" |
| 251 | if attack == "below" || attack == "stale verdict" || attack == "old reader" { |
| 252 | want = "v1.0.0" |
| 253 | } |
| 254 | if runErr != nil || readErr != nil || string(got) != want { |
| 255 | journal, _ := exec.Command("journalctl", "-u", name, "--no-pager").CombinedOutput() |
| 256 | t.Fatalf("executor unavailable: %v %s marker=%s err=%v want=%s\n%s", runErr, out, got, readErr, want, journal) |
| 257 | } |
| 258 | edge := strings.TrimSuffix(name, ".service") + "-edge.service" |
| 259 | edgePath := filepath.Join("/run/systemd/system", edge) |
| 260 | edgeMark := filepath.Join(opt, "edge-started") |
| 261 | body = "[Unit]\nRequires=" + name + "\nAfter=" + name + "\n[Service]\nType=oneshot\nExecStart=/usr/bin/touch " + edgeMark + "\n" |
| 262 | if err := os.WriteFile(edgePath, []byte(body), 0644); err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | t.Cleanup(func() { |
| 266 | exec.Command("systemctl", "stop", edge).Run() |
| 267 | os.Remove(edgePath) |
| 268 | exec.Command("systemctl", "daemon-reload").Run() |
| 269 | }) |
| 270 | if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { |
| 271 | t.Fatal(err, string(out)) |
| 272 | } |
| 273 | if out, err := exec.Command("systemctl", "start", edge).CombinedOutput(); err != nil { |
| 274 | t.Fatal("edge dependency", err, string(out)) |
| 275 | } |
| 276 | if _, err := os.Stat(edgeMark); err != nil { |
| 277 | t.Fatal("edge did not start", err) |
| 278 | } |
| 279 | |
| 280 | }) |
| 281 | } |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | func TestPlan14RootFloorBootPayload(t *testing.T) { |
| 286 | opt := os.Getenv("PLAN14_P3_OPT") |
| 287 | if opt == "" { |
| 288 | t.Fatal("unit payload only") |
| 289 | } |
| 290 | if os.Getenv("PLAN14_P3_GATE") == "1" { |
| 291 | if err := rootFloorAllowsPrevIdentity(opt, rootFloorIdentity{"deploy", "box", "en"}); err != nil { |
| 292 | t.Fatal(err) |
| 293 | } |
| 294 | return |
| 295 | } |
| 296 | id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"} |
| 297 | f, err := openRootMovesFloors(opt, id, false) |
| 298 | if err != nil { |
| 299 | t.Fatal(err) |
| 300 | } |
| 301 | defer f.close() |
| 302 | j := rootFloorJudge{floors: f, current: systemBinaryPath(opt), prev: systemPrevPath(opt), binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(opt, path) }} |
| 303 | // Installation/participation is a fixture here; F4 exercises installed shell |
| 304 | // selection, the real signed-binary reader, floor records and prev reader. |
| 305 | path, err := rootFloorBootCandidate(t.Context(), j, true, func(ctx context.Context, path string) error { |
| 306 | cmd := exec.CommandContext(ctx, path, "system", "floor-allows-prev", "--root", opt) |
| 307 | cmd.Env = os.Environ() |
| 308 | out, err := cmd.CombinedOutput() |
| 309 | if err != nil { |
| 310 | return fmt.Errorf("%w: %s", err, out) |
| 311 | } |
| 312 | return nil |
| 313 | }, func(context.Context) error { return nil }) |
| 314 | if err != nil { |
| 315 | t.Fatal(err) |
| 316 | } |
| 317 | if path != j.current { |
| 318 | if err := rootSeedsAllowsBinary(opt, id, path); err != nil { |
| 319 | path = j.current |
| 320 | } |
| 321 | } |
| 322 | if out, err := exec.CommandContext(t.Context(), path, "system", "apply").CombinedOutput(); err != nil { |
| 323 | t.Fatal(err, string(out)) |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | func rootFloorSignRunnerBytes(t *testing.T, opt, path, version string, raw []byte, key ed25519.PrivateKey) { |
| 328 | t.Helper() |
| 329 | if err := os.WriteFile(path, raw, 0755); err != nil { |
| 330 | t.Fatal(err) |
| 331 | } |
| 332 | s, err := rootstate.Open(filepath.Join(opt, "system", "guard", "identities"), true, true) |
| 333 | if err != nil { |
| 334 | t.Fatal(err) |
| 335 | } |
| 336 | defer s.Close() |
| 337 | sum := sha256.Sum256(raw) |
| 338 | platform := runtime.GOOS + "-" + runtime.GOARCH |
| 339 | p := release.SystemPointer{Schema: 1, Component: "router", Channel: "stable", Spread: "0s", PromotedAt: time.Now(), Current: release.SystemRelease{Version: version, ReleaseTag: version, MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{platform: {Key: release.SystemArtifactKey(version, platform), SHA256: hex.EncodeToString(sum[:])}}}} |
| 340 | wire, err := release.SignRootEnvelope(release.RootSigner(key), p) |
| 341 | if err != nil { |
| 342 | t.Fatal(err) |
| 343 | } |
| 344 | // Multiple fixture binaries may share a release tag, but never a digest. |
| 345 | if err := s.Write(hex.EncodeToString(sum[:])+".json", wire); err != nil { |
| 346 | t.Fatal(err) |
| 347 | } |
| 348 | } |
| 349 | |