| 1 | //go:build linux && rootrunner |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "crypto/ed25519" |
| 9 | "encoding/json" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/hostactors" |
| 16 | "github.com/carlosframework/platform/internal/hostfence" |
| 17 | ) |
| 18 | |
| 19 | func activationRootFixture(t *testing.T) string { |
| 20 | t.Helper() |
| 21 | if os.Geteuid() != 0 { |
| 22 | t.Fatal("real root required") |
| 23 | } |
| 24 | d, err := os.MkdirTemp("/var/lib", "carlos-activation-test-") |
| 25 | if err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | t.Cleanup(func() { os.RemoveAll(d) }) |
| 29 | return d |
| 30 | } |
| 31 | func TestRootActivationSecrets(t *testing.T) { |
| 32 | d := activationRootFixture(t) |
| 33 | binding := hostactors.CensusKeyBinding{Bucket: "bucket", Box: "leo", Enrollment: strings.Repeat("a", 32)} |
| 34 | p := filepath.Join(d, "host-evidence.key") |
| 35 | result, err := installActivationEvidenceKey(p, binding, false) |
| 36 | if err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | key, err := readClaimEvidenceKey(p) |
| 40 | if err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | wire, _ := json.Marshal(result.Binding) |
| 44 | if !ed25519.Verify(key.Public().(ed25519.PublicKey), wire, result.Proof) { |
| 45 | t.Fatal("proof does not bind installed key") |
| 46 | } |
| 47 | again, err := installActivationEvidenceKey(p, binding, false) |
| 48 | if err != nil || !bytes.Equal(again.Binding.PublicKey, result.Binding.PublicKey) { |
| 49 | t.Fatal("replay rotated key", err) |
| 50 | } |
| 51 | st, _ := os.Stat(p) |
| 52 | if st.Mode().Perm() != 0600 { |
| 53 | t.Fatal(st.Mode()) |
| 54 | } |
| 55 | before, _ := os.ReadFile(p) |
| 56 | _, err = installActivationEvidenceKey(p, binding, true) |
| 57 | if err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | after, _ := os.ReadFile(p) |
| 61 | if !bytes.Equal(before, after) { |
| 62 | t.Fatal("dry run changed key") |
| 63 | } |
| 64 | dry := filepath.Join(d, "absent.key") |
| 65 | if _, err = installActivationEvidenceKey(dry, binding, true); err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | if _, err = os.Lstat(dry); !os.IsNotExist(err) { |
| 69 | t.Fatal("dry run created key") |
| 70 | } |
| 71 | for _, kind := range []string{"symlink", "hardlink", "writable-parent", "mode"} { |
| 72 | t.Run(kind, func(t *testing.T) { |
| 73 | parent := filepath.Join(d, kind) |
| 74 | if err := os.Mkdir(parent, 0700); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | target := filepath.Join(parent, "key") |
| 78 | switch kind { |
| 79 | case "symlink": |
| 80 | os.Symlink(p, target) |
| 81 | case "hardlink": |
| 82 | os.Link(p, target) |
| 83 | case "writable-parent": |
| 84 | os.Chmod(parent, 0777) |
| 85 | case "mode": |
| 86 | os.WriteFile(target, before, 0644) |
| 87 | } |
| 88 | if _, err := installActivationEvidenceKey(target, binding, false); err == nil { |
| 89 | t.Fatal("unsafe path accepted") |
| 90 | } |
| 91 | os.Remove(target) |
| 92 | os.Chmod(parent, 0700) |
| 93 | }) |
| 94 | } |
| 95 | } |
| 96 | func TestRootActivationFenceCredentials(t *testing.T) { |
| 97 | d := activationRootFixture(t) |
| 98 | source := filepath.Join(d, "source.json") |
| 99 | target := filepath.Join(d, "credentials.json") |
| 100 | raw := []byte(`{"role":"arn:aws:iam::123456789012:role/fence","region":"eu-west-1","bucket":"bucket","access_key":"fixture","secret_key":"fixture-secret"}`) |
| 101 | if err := os.WriteFile(source, raw, 0600); err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | scope := activationFenceScope{Bucket: "bucket", Region: "eu-west-1", Role: "arn:aws:iam::123456789012:role/fence"} |
| 105 | if err := installActivationFenceCredentials(source, target, scope, true); err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | if _, err := os.Stat(target); !os.IsNotExist(err) { |
| 109 | t.Fatal("dry run wrote credentials") |
| 110 | } |
| 111 | if err := installActivationFenceCredentials(source, target, scope, false); err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | st, _ := os.Stat(target) |
| 115 | if st.Mode().Perm() != 0600 { |
| 116 | t.Fatal(st.Mode()) |
| 117 | } |
| 118 | if err := installActivationFenceCredentials(source, target, scope, false); err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | scope.Bucket = "wrong" |
| 122 | if err := installActivationFenceCredentials(source, target, scope, false); err == nil { |
| 123 | t.Fatal("scope mismatch accepted") |
| 124 | } |
| 125 | scope.Bucket = "bucket" |
| 126 | os.WriteFile(source, bytes.ReplaceAll(raw, []byte("fixture-secret"), []byte("replacement")), 0600) |
| 127 | if err := installActivationFenceCredentials(source, target, scope, false); err == nil { |
| 128 | t.Fatal("rotation accepted") |
| 129 | } |
| 130 | got, _ := os.ReadFile(target) |
| 131 | if !bytes.Equal(got, raw) { |
| 132 | t.Fatal("existing credentials replaced") |
| 133 | } |
| 134 | } |
| 135 | func TestRootActivationControlInstaller(t *testing.T) { |
| 136 | d := activationRootFixture(t) |
| 137 | env := filepath.Join(d, "host.env") |
| 138 | unitDir := filepath.Join(d, "units") |
| 139 | os.Mkdir(unitDir, 0755) |
| 140 | os.WriteFile(env, []byte("CARLOS_HOST_CLAIM=1\nCARLOS_HOST_CLAIM_ORIGINS=1\n"), 0600) |
| 141 | for _, name := range rootMigrationOfflineUnits { |
| 142 | if err := os.Symlink("/dev/null", filepath.Join(unitDir, name)); err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | } |
| 146 | calls := []string{} |
| 147 | ctl := func(args ...string) (string, error) { calls = append(calls, strings.Join(args, " ")); return "", nil } |
| 148 | if err := installActivationControl(env, unitDir, true, ctl); err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | if len(calls) != 0 { |
| 152 | t.Fatal("dry run called systemd") |
| 153 | } |
| 154 | if _, err := os.Stat(filepath.Join(unitDir, claimControlUnit)); !os.IsNotExist(err) { |
| 155 | t.Fatal("dry run installed") |
| 156 | } |
| 157 | if err := installActivationControl(env, unitDir, false, ctl); err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | got, err := os.ReadFile(filepath.Join(unitDir, claimControlUnit)) |
| 161 | if err != nil { |
| 162 | t.Fatal(err) |
| 163 | } |
| 164 | for _, want := range []string{"StateDirectory=carlos-claims\n", "MemoryMax=256M\n", claimControlEnvironment} { |
| 165 | if !bytes.Contains(got, []byte(want)) { |
| 166 | t.Fatal("missing", want) |
| 167 | } |
| 168 | } |
| 169 | if strings.Join(calls, ",") != "daemon-reload" { |
| 170 | t.Fatal("unexpected startup", calls) |
| 171 | } |
| 172 | for _, name := range rootMigrationOfflineUnits { |
| 173 | target, err := os.Readlink(filepath.Join(unitDir, name)) |
| 174 | if err != nil || target != "/dev/null" { |
| 175 | t.Fatal("mask changed", name, err) |
| 176 | } |
| 177 | } |
| 178 | os.WriteFile(env, []byte("CARLOS_HOST_CLAIM=1\nmalformed\n"), 0600) |
| 179 | if err := installActivationControl(env, unitDir, false, ctl); err == nil { |
| 180 | t.Fatal("malformed config accepted") |
| 181 | } |
| 182 | after, _ := os.ReadFile(filepath.Join(unitDir, claimControlUnit)) |
| 183 | if !bytes.Equal(got, after) { |
| 184 | t.Fatal("unit changed after refusal") |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | func TestRootActivationConsoleInstaller(t *testing.T) { |
| 189 | d := activationRootFixture(t) |
| 190 | env := filepath.Join(d, "claim-console.env") |
| 191 | drop := filepath.Join(d, "console.service.d") |
| 192 | calls := []string{} |
| 193 | ctl := func(args ...string) (string, error) { calls = append(calls, strings.Join(args, " ")); return "", nil } |
| 194 | raw := []byte("CARLOS_CONSOLE_ID=dev-console\nCONSOLE_HOME_REGION=ie\nCARLOS_HOST_CLAIM=\n") |
| 195 | if err := installActivationConsole(env, drop, raw, true, ctl); err != nil { |
| 196 | t.Fatal(err) |
| 197 | } |
| 198 | if _, err := os.Stat(env); !os.IsNotExist(err) { |
| 199 | t.Fatal("dry run changed config") |
| 200 | } |
| 201 | if err := installActivationConsole(env, drop, raw, false, ctl); err != nil { |
| 202 | t.Fatal(err) |
| 203 | } |
| 204 | got, _ := os.ReadFile(env) |
| 205 | if !bytes.Equal(got, raw) { |
| 206 | t.Fatal("config mismatch") |
| 207 | } |
| 208 | if strings.Join(calls, ",") != "daemon-reload" { |
| 209 | t.Fatal("started console", calls) |
| 210 | } |
| 211 | os.WriteFile(env, []byte("operator-change\n"), 0600) |
| 212 | if err := installActivationConsole(env, drop, raw, false, ctl); err == nil { |
| 213 | t.Fatal("overwrote operator config") |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | func TestRootActivationControlSystemd(t *testing.T) { |
| 218 | activationRootFixture(t) |
| 219 | paths := []string{"/etc/carlos", "/etc/systemd/system/" + claimControlUnit, "/etc/systemd/system/.claims-activation.lock"} |
| 220 | for _, name := range rootMigrationOfflineUnits { |
| 221 | paths = append(paths, "/etc/systemd/system/"+name) |
| 222 | } |
| 223 | for _, path := range paths { |
| 224 | if _, err := os.Lstat(path); !os.IsNotExist(err) { |
| 225 | t.Fatal("pre-existing fixture path", path, err) |
| 226 | } |
| 227 | } |
| 228 | t.Cleanup(func() { |
| 229 | for _, path := range paths { |
| 230 | if err := os.RemoveAll(path); err != nil { |
| 231 | t.Error(err) |
| 232 | } |
| 233 | } |
| 234 | if raw, err := claimSystemctl(context.Background(), "daemon-reload"); err != nil { |
| 235 | t.Error(string(raw), err) |
| 236 | } |
| 237 | }) |
| 238 | if err := os.Mkdir("/etc/carlos", 0700); err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | for _, name := range rootMigrationOfflineUnits { |
| 242 | if err := os.Symlink("/dev/null", "/etc/systemd/system/"+name); err != nil { |
| 243 | t.Fatal(err) |
| 244 | } |
| 245 | } |
| 246 | if raw, err := claimSystemctl(t.Context(), "daemon-reload"); err != nil { |
| 247 | t.Fatal(string(raw), err) |
| 248 | } |
| 249 | if err := os.WriteFile("/etc/carlos/host.env", []byte("CARLOS_HOST_CLAIM=1\nCARLOS_HOST_CLAIM_ORIGINS=1\n"), 0600); err != nil { |
| 250 | t.Fatal(err) |
| 251 | } |
| 252 | var result bytes.Buffer |
| 253 | if err := runSystemClaims([]string{"install-control"}, &result); err != nil { |
| 254 | t.Fatal(err) |
| 255 | } |
| 256 | raw, err := claimSystemctl(t.Context(), "cat", claimControlUnit) |
| 257 | if err != nil || !bytes.Contains(raw, []byte("MemoryMax=256M")) { |
| 258 | t.Fatal(string(raw), err) |
| 259 | } |
| 260 | if err = verifyOfflineRootMigration(t.Context()); err != nil { |
| 261 | t.Fatal("migration masks changed", err) |
| 262 | } |
| 263 | raw, err = claimSystemctl(t.Context(), "show", claimControlUnit, "--property=ActiveState", "--value") |
| 264 | if err != nil || strings.TrimSpace(string(raw)) != "inactive" { |
| 265 | t.Fatal("control started", string(raw), err) |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | func TestRootActivationFenceStoreReceipt(t *testing.T) { |
| 270 | if _, err := os.Lstat("/etc/carlos"); !os.IsNotExist(err) { |
| 271 | t.Fatal("pre-existing config directory", err) |
| 272 | } |
| 273 | if err := os.Mkdir("/etc/carlos", 0700); err != nil { |
| 274 | t.Fatal(err) |
| 275 | } |
| 276 | defer os.RemoveAll("/etc/carlos") |
| 277 | source := "/etc/carlos/fence-source.json" |
| 278 | target := "/etc/carlos/fence-writer/credentials.json" |
| 279 | raw := []byte(`{"role":"arn:aws:iam::123456789012:role/fence","region":"eu-west-1","bucket":"bucket","access_key":"fixture","secret_key":"fixture-secret"}`) |
| 280 | if err := os.WriteFile(source, raw, 0600); err != nil { |
| 281 | t.Fatal(err) |
| 282 | } |
| 283 | scope := activationFenceScope{Bucket: "bucket", Region: "eu-west-1", Role: "arn:aws:iam::123456789012:role/fence"} |
| 284 | if err := installActivationFenceCredentials(source, target, scope, false); err != nil { |
| 285 | t.Fatal(err) |
| 286 | } |
| 287 | if _, err := hostfence.OpenRootFenceStore(t.Context(), "bucket"); err != nil { |
| 288 | t.Fatal("installed credentials refused", err) |
| 289 | } |
| 290 | marker := "/etc/carlos/fence-writer/credentials.installation.json" |
| 291 | if err := os.Remove(marker); err != nil { |
| 292 | t.Fatal(err) |
| 293 | } |
| 294 | if _, err := hostfence.OpenRootFenceStore(t.Context(), "bucket"); err == nil { |
| 295 | t.Fatal("uninstalled 0600 credentials accepted") |
| 296 | } |
| 297 | if err := installActivationFenceCredentials(source, target, scope, false); err != nil { |
| 298 | t.Fatal("interrupted receipt not resumed", err) |
| 299 | } |
| 300 | os.Remove(marker) |
| 301 | os.Chmod(target, 0400) |
| 302 | if _, err := hostfence.OpenRootFenceStore(t.Context(), "bucket"); err != nil { |
| 303 | t.Fatal("legacy 0400 credentials changed", err) |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | func TestRootActivationEvidenceBoundKey(t *testing.T) { |
| 308 | d := activationRootFixture(t) |
| 309 | path := filepath.Join(d, "evidence.key") |
| 310 | binding := hostactors.CensusKeyBinding{Bucket: "bucket", Box: "leo", Enrollment: strings.Repeat("a", 32)} |
| 311 | proof, err := installActivationEvidenceKey(path, binding, false) |
| 312 | if err != nil { |
| 313 | t.Fatal(err) |
| 314 | } |
| 315 | bound := proof.Binding |
| 316 | if _, err = installActivationEvidenceKey(path, bound, false); err != nil { |
| 317 | t.Fatal("bound replay", err) |
| 318 | } |
| 319 | if _, err = installActivationEvidenceKey(filepath.Join(d, "missing.key"), bound, false); err == nil { |
| 320 | t.Fatal("replaced a lost bound key") |
| 321 | } |
| 322 | if _, err = os.Lstat(filepath.Join(d, "missing.key")); !os.IsNotExist(err) { |
| 323 | t.Fatal("created replacement key") |
| 324 | } |
| 325 | bound.PublicKey[0] ^= 1 |
| 326 | if _, err = installActivationEvidenceKey(path, bound, false); err == nil { |
| 327 | t.Fatal("accepted another bound key") |
| 328 | } |
| 329 | } |
| 330 | |