//go:build linux && rootrunner package host import ( "context" "crypto/ed25519" "crypto/sha256" "encoding/base64" "encoding/hex" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rootstate" "github.com/carlosframework/platform/internal/seeds" ) func TestPlan14RootFloorBootSelectors(t *testing.T) { dir := generationRootRunnerDir(t) self, err := os.Executable() if err != nil { t.Fatal(err) } binary, err := os.ReadFile(self) if err != nil { t.Fatal(err) } runner := filepath.Join(dir, "runner.test") if err := os.WriteFile(runner, binary, 0755); err != nil { t.Fatal(err) } pid1, err := os.ReadFile("/proc/1/comm") if err != nil || strings.TrimSpace(string(pid1)) != "systemd" { t.Fatal("root runner requires systemd", err) } stage := filepath.Join(dir, "stage") if err := bootstrap(stage, nil); err != nil { t.Fatal(err) } for _, unit := range []string{"carlos-system-update.service", "carlos-system-recover.service"} { 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"} { t.Run(unit+"/"+attack, func(t *testing.T) { opt, err := os.MkdirTemp(dir, "box-") if err != nil { t.Fatal(err) } if err := os.Chmod(opt, 0755); err != nil { t.Fatal(err) } pub, key, err := ed25519.GenerateKey(nil) if err != nil { t.Fatal(err) } t.Setenv("CARLOS_RELEASE_PUBKEY", base64.StdEncoding.EncodeToString(pub)) id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"} f, err := openRootMovesFloors(opt, id, true) if err != nil { t.Fatal(err) } defer f.close() b := moves.Floor{Min: 1, MinRelease: "v2.0.0"} if err := f.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &b, Source: "history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, f)}); err != nil { t.Fatal(err) } mark := filepath.Join(opt, "executed") makeBinary := func(path, version string, reader bool) { body := "#!/bin/sh\nif [ \"$2\" = floor-allows-prev ]; then\n" if reader { body += " PLAN14_P3_GATE=1 exec " + runner + " -test.run=^TestPlan14RootFloorBootPayload$\n" } else { body += " exit 2\n" } body += "fi\nprintf '" + version + "' > " + mark + "\n" rootFloorSignRunnerBytes(t, opt, path, version, []byte(body), key) } makeBinary(systemBinaryPath(opt), "v2.0.0", true) makeBinary(systemPrevPath(opt), "v2.0.0", true) 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 }} if err := j.judge(t.Context()); err != nil { t.Fatal(err) } if strings.HasPrefix(attack, "seeds ") { sf, err := openRootProtocolFloors(rootSeedsFloorDir(opt), id, true) if err != nil { t.Fatal(err) } defer sf.close() floor := moves.Floor{Min: 1, MinRelease: "v2.0.0"} if attack != "seeds accepted" { floor.MinRelease = "v3.0.0" } if attack == "seeds legacy" { dd := filepath.Join(opt, "data") if err := os.MkdirAll(dd, 0755); err != nil { t.Fatal(err) } armLocalSeedsFloor(t, dd, seeds.Floor{Min: floor.Min, MinRelease: floor.MinRelease}) if err := refreshRootSeedsFloor(t.Context(), release.NewDirStore(t.TempDir()), opt, dd, id); err != nil { t.Fatal(err) } if err := os.RemoveAll(dd); err != nil { t.Fatal(err) } if _, err := sf.projection(); err == nil { t.Fatal("legacy seeds accepted") } } else if err := sf.reconstruct(t.Context(), rootFloorReconstruction{Identity: id, Floor: &floor, Source: "root history", Digest: floorTestDigest, Authorization: floorTestAuthorization(t, sf)}); err != nil { t.Fatal(err) } } switch attack { case "agent high": dd := filepath.Join(opt, "data") if err := os.MkdirAll(filepath.Join(dd, ".moves"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dd, ".moves", "floor.min-release"), []byte("v99.0.0\n"), 0644); err != nil { t.Fatal(err) } if err := os.Chown(filepath.Join(dd, ".moves", "floor.min-release"), 65534, 65534); err != nil { t.Fatal(err) } legacy, err := rootFloorLegacy(dd) if err != nil { t.Fatal(err) } if err := f.refresh(t.Context(), &b, legacy, nil); err != nil { t.Fatal(err) } case "signed high": if err := f.refresh(t.Context(), &moves.Floor{Min: 1, MinRelease: "v99.0.0"}, nil, nil); err != nil { t.Fatal(err) } case "seeds unpublished": dd := filepath.Join(opt, "data") if err := os.MkdirAll(dd, 0755); err != nil { t.Fatal(err) } armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v99.0.0"}) if err := refreshRootSeedsFloor(t.Context(), release.NewDirStore(t.TempDir()), opt, dd, id); err != nil { t.Fatal(err) } if bound, err := readRootSeedsBound(opt, id); err != nil || bound != nil { t.Fatal("unpublished seeds armed", bound, err) } case "unknown crashing current": for _, name := range []string{rootFloorAccepted, rootFloorDeny} { if err := f.private.Remove(name); err != nil { t.Fatal(err) } } for _, name := range []string{rootFloorProjectionFile, rootFloorSelector} { if err := f.public.Remove(name); err != nil { t.Fatal(err) } } rootFloorSignRunnerBytes(t, opt, systemBinaryPath(opt), "v3.0.0", []byte("#!/bin/sh\nexit 99\n"), key) case "below": makeBinary(systemBinaryPath(opt), "v1.0.0", true) makeBinary(systemPrevPath(opt), "v1.0.0", true) case "stale verdict": if err := f.refresh(t.Context(), &b, nil, nil); err != nil { t.Fatal(err) } makeBinary(systemBinaryPath(opt), "v1.0.0", true) case "missing accepted": if err := f.private.Remove(rootFloorAccepted); err != nil { t.Fatal(err) } case "missing floor": if err := f.public.Remove(rootFloorSelector); err != nil { t.Fatal(err) } case "corrupt floor": if err := f.public.Write(rootFloorSelector, []byte("broken")); err != nil { t.Fatal(err) } case "old reader": makeBinary(systemPrevPath(opt), "v2.0.0", false) if err := j.judge(t.Context()); err != nil { t.Fatal(err) } makeBinary(systemBinaryPath(opt), "v1.0.0", true) } if strings.HasPrefix(attack, "crash-") { fail := int(attack[len(attack)-1] - '0') n := 0 broken := f broken.private = &floorFaultStore{Store: f.private, n: &n, fail: fail, after: true} broken.public = &floorFaultStore{Store: f.public, n: &n, fail: fail, after: true} if err := broken.refresh(t.Context(), &moves.Floor{Min: 1, MinRelease: "v3.0.0"}, nil, nil); err == nil { t.Fatal("crash injection succeeded") } } guard := filepath.Join(opt, "system", "guard", "carlos") if attack != "no guard" { if err := os.WriteFile(guard, []byte("#!/bin/sh\nexec "+runner+" -test.run=^TestPlan14RootFloorBootPayload$\n"), 0755); err != nil { t.Fatal(err) } } if err := os.MkdirAll(filepath.Join(opt, "system", "router"), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(opt, "system", "router", "journal.json"), []byte("{}"), 0600); err != nil { t.Fatal(err) } raw, err := os.ReadFile(filepath.Join(stage, "etc", "systemd", "system", unit)) if err != nil { t.Fatal(err) } body := strings.ReplaceAll(string(raw), "/opt/carlos", opt) body = strings.ReplaceAll(body, "/data", filepath.Join(opt, "data")) // Preserve the service's hardening and actual selector. Remove only its // deployment dependencies; all paths and identity are fixture-owned. var lines []string for _, line := range strings.Split(body, "\n") { if strings.HasPrefix(line, "OnFailure=") || strings.HasPrefix(line, "Before=") || strings.HasPrefix(line, "After=") || strings.HasPrefix(line, "Wants=") || strings.HasPrefix(line, "EnvironmentFile=") { continue } lines = append(lines, line) if line == "[Service]" { lines = append(lines, "Environment=PLAN14_P3_OPT="+opt, "Environment=CARLOS_RELEASE_PUBKEY="+base64.StdEncoding.EncodeToString(pub)) } } name := "plan14-p3-" + filepath.Base(opt) + ".service" path := filepath.Join("/run/systemd/system", name) if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644); err != nil { t.Fatal(err) } t.Cleanup(func() { exec.Command("systemctl", "stop", name).Run() os.Remove(path) exec.Command("systemctl", "daemon-reload").Run() exec.Command("systemctl", "reset-failed", name).Run() }) if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { t.Fatal(string(out), err) } out, runErr := exec.CommandContext(t.Context(), "systemctl", "start", name).CombinedOutput() got, readErr := os.ReadFile(mark) want := "v2.0.0" if attack == "below" || attack == "stale verdict" || attack == "old reader" { want = "v1.0.0" } if runErr != nil || readErr != nil || string(got) != want { journal, _ := exec.Command("journalctl", "-u", name, "--no-pager").CombinedOutput() t.Fatalf("executor unavailable: %v %s marker=%s err=%v want=%s\n%s", runErr, out, got, readErr, want, journal) } edge := strings.TrimSuffix(name, ".service") + "-edge.service" edgePath := filepath.Join("/run/systemd/system", edge) edgeMark := filepath.Join(opt, "edge-started") body = "[Unit]\nRequires=" + name + "\nAfter=" + name + "\n[Service]\nType=oneshot\nExecStart=/usr/bin/touch " + edgeMark + "\n" if err := os.WriteFile(edgePath, []byte(body), 0644); err != nil { t.Fatal(err) } t.Cleanup(func() { exec.Command("systemctl", "stop", edge).Run() os.Remove(edgePath) exec.Command("systemctl", "daemon-reload").Run() }) if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { t.Fatal(err, string(out)) } if out, err := exec.Command("systemctl", "start", edge).CombinedOutput(); err != nil { t.Fatal("edge dependency", err, string(out)) } if _, err := os.Stat(edgeMark); err != nil { t.Fatal("edge did not start", err) } }) } } } func TestPlan14RootFloorBootPayload(t *testing.T) { opt := os.Getenv("PLAN14_P3_OPT") if opt == "" { t.Fatal("unit payload only") } if os.Getenv("PLAN14_P3_GATE") == "1" { if err := rootFloorAllowsPrevIdentity(opt, rootFloorIdentity{"deploy", "box", "en"}); err != nil { t.Fatal(err) } return } id := rootFloorIdentity{Deployment: "deploy", Box: "box", Enrollment: "en"} f, err := openRootMovesFloors(opt, id, false) if err != nil { t.Fatal(err) } defer f.close() j := rootFloorJudge{floors: f, current: systemBinaryPath(opt), prev: systemPrevPath(opt), binary: func(path string) (rootFloorBinary, error) { return readRootFloorBinary(opt, path) }} // Installation/participation is a fixture here; F4 exercises installed shell // selection, the real signed-binary reader, floor records and prev reader. path, err := rootFloorBootCandidate(t.Context(), j, true, func(ctx context.Context, path string) error { cmd := exec.CommandContext(ctx, path, "system", "floor-allows-prev", "--root", opt) cmd.Env = os.Environ() out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%w: %s", err, out) } return nil }, func(context.Context) error { return nil }) if err != nil { t.Fatal(err) } if path != j.current { if err := rootSeedsAllowsBinary(opt, id, path); err != nil { path = j.current } } if out, err := exec.CommandContext(t.Context(), path, "system", "apply").CombinedOutput(); err != nil { t.Fatal(err, string(out)) } } func rootFloorSignRunnerBytes(t *testing.T, opt, path, version string, raw []byte, key ed25519.PrivateKey) { t.Helper() if err := os.WriteFile(path, raw, 0755); err != nil { t.Fatal(err) } s, err := rootstate.Open(filepath.Join(opt, "system", "guard", "identities"), true, true) if err != nil { t.Fatal(err) } defer s.Close() sum := sha256.Sum256(raw) platform := runtime.GOOS + "-" + runtime.GOARCH 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[:])}}}} wire, err := release.SignRootEnvelope(release.RootSigner(key), p) if err != nil { t.Fatal(err) } // Multiple fixture binaries may share a release tag, but never a digest. if err := s.Write(hex.EncodeToString(sum[:])+".json", wire); err != nil { t.Fatal(err) } }