| 1 | //go:build linux |
| 2 | |
| 3 | package home |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "crypto/sha256" |
| 9 | "database/sql" |
| 10 | "encoding/base64" |
| 11 | "encoding/hex" |
| 12 | stdjson "encoding/json" |
| 13 | "errors" |
| 14 | "io" |
| 15 | "os" |
| 16 | "os/exec" |
| 17 | "path/filepath" |
| 18 | "strings" |
| 19 | "testing" |
| 20 | "time" |
| 21 | |
| 22 | "amadan.net/carlos/platform.git/launchctx" |
| 23 | "amadan.net/oficina/home/internal/operator" |
| 24 | family "amadan.net/rastrillo/rastrillo/crypto" |
| 25 | rdb "amadan.net/rastrillo/rastrillo/db" |
| 26 | "amadan.net/rastrillo/rastrillo/migrate" |
| 27 | "golang.org/x/sys/unix" |
| 28 | ) |
| 29 | |
| 30 | func TestOperatorCommandChild(t *testing.T) { |
| 31 | if os.Getenv("HOME_OPERATOR_CHILD") != "1" { |
| 32 | return |
| 33 | } |
| 34 | os.Exit(RunOperatorCommand(context.Background(), os.Getenv("HOME_OPERATOR_DATABASE"))) |
| 35 | } |
| 36 | |
| 37 | type commandFixture struct { |
| 38 | path string |
| 39 | env []string |
| 40 | pin operator.Pins |
| 41 | signer, owner *family.Keypair |
| 42 | imageHash string |
| 43 | outputMode string |
| 44 | executable string |
| 45 | args []string |
| 46 | } |
| 47 | |
| 48 | func newCommandFixture(t *testing.T, role string) commandFixture { |
| 49 | t.Helper() |
| 50 | makeKey := func() *family.Keypair { |
| 51 | key, err := family.Generate() |
| 52 | if err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | return key |
| 56 | } |
| 57 | encodeKey := func(key *family.Keypair) string { |
| 58 | raw, err := family.MarshalKeypair(key) |
| 59 | if err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | return base64.StdEncoding.EncodeToString(raw) |
| 63 | } |
| 64 | signer, owner, browser, provisioning, page := makeKey(), makeKey(), makeKey(), makeKey(), makeKey() |
| 65 | path := filepath.Join(t.TempDir(), "home.db") |
| 66 | db, err := rdb.Open(path, nil) |
| 67 | if err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | if _, err = migrate.Apply(t.Context(), db, compatibilitySchema(role)); err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | if _, err = db.Writer().Exec(replicationCompatibilityDDL); err != nil { |
| 74 | t.Fatal(err) |
| 75 | } |
| 76 | if err = db.Close(); err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | credential, directory := "https://id.example.test", "https://home.example.test" |
| 80 | origin := credential |
| 81 | actions := []string{"credential.owner-seed"} |
| 82 | if role == "directory" { |
| 83 | origin = directory |
| 84 | actions = []string{"directory.inspect", "directory.initialize", "directory.seed-team", "directory.provision-consumer", "directory.verify-consumer", "directory.activate-consumer"} |
| 85 | } |
| 86 | pins := operator.Pins{Role: role, Origin: origin, Installation: opToken(1), Registry: opToken(2), Device: opToken(3), PublicKey: base64.RawURLEncoding.EncodeToString(signer.SignPub()), Actions: actions} |
| 87 | config := map[string]any{"version": 1, "account": "bds", "app": "home", "host": "home.example.test", "role": role, "origin": origin, "installation": pins.Installation, "registry": pins.Registry, "device": pins.Device, "public_key": pins.PublicKey, "key_id": rosterKeyID(signer), "actions": actions} |
| 88 | raw, err := stdjson.Marshal(config) |
| 89 | if err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | env := []string{"HOME_OPERATOR_CHILD=1", "HOME_OPERATOR_DATABASE=" + path, "HOME_ROLE=" + role, "HOME_CREDENTIAL_ORIGIN=" + credential, "HOME_DIRECTORY_ORIGIN=" + directory, "HOME_OPERATOR_COMMAND=" + string(raw), "HOME_DEK=" + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)), "GODEBUG=containermaxprocs=0,updatemaxprocs=0"} |
| 93 | material := map[string]any{"version": 1, "role": role, "mac_key": base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{8}, 32))} |
| 94 | if role == "credential" { |
| 95 | material["owner_seed_key"] = encodeKey(owner) |
| 96 | env = append(env, "HOME_PEPPER="+base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{9}, 32)), "HOME_ASSERTION_KEY="+encodeKey(browser)) |
| 97 | } else { |
| 98 | material["owner_seed_public"] = base64.RawURLEncoding.EncodeToString(owner.SignPub()) |
| 99 | material["provisioning_key"] = encodeKey(provisioning) |
| 100 | material["roster_keys"] = []map[string]string{{"id": rosterKeyID(page), "public": base64.RawURLEncoding.EncodeToString(page.SignPub())}} |
| 101 | keys, _ := stdjson.Marshal(map[string]string{rosterKeyID(browser): base64.StdEncoding.EncodeToString(browser.SignPub())}) |
| 102 | env = append(env, "HOME_ASSERTION_KEYS="+string(keys)) |
| 103 | } |
| 104 | raw, err = stdjson.Marshal(material) |
| 105 | if err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | env = append(env, "HOME_OPERATOR_MATERIAL="+string(raw)) |
| 109 | image, err := os.ReadFile("/proc/self/exe") |
| 110 | if err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | hash := sha256.Sum256(image) |
| 114 | return commandFixture{path: path, env: env, pin: pins, signer: signer, owner: owner, imageHash: hex.EncodeToString(hash[:]), executable: "/proc/self/exe", args: []string{"-test.run=^TestOperatorCommandChild$"}} |
| 115 | } |
| 116 | func (f commandFixture) request(t *testing.T, action string, input any, sequence int64, previous string, op byte) []byte { |
| 117 | t.Helper() |
| 118 | now := time.Now().Unix() |
| 119 | auth := operator.Authorization{Version: 1, Purpose: "operator-input", Role: f.pin.Role, Origin: f.pin.Origin, Installation: f.pin.Installation, Registry: f.pin.Registry, Device: f.pin.Device, Sequence: sequence, Initial: sequence == 1, Previous: previous, JournalSequence: sequence, OperationID: opToken(op), Action: action, Issued: now, Expires: now + 300} |
| 120 | raw, err := operator.SignRequest(f.signer, auth, input) |
| 121 | if err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | return raw |
| 125 | } |
| 126 | func (f commandFixture) run(t *testing.T, input []byte) ([]byte, int, bool) { |
| 127 | t.Helper() |
| 128 | var st unix.Stat_t |
| 129 | if err := unix.Stat(f.path, &st); err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | digest := strings.Repeat("a", 64) |
| 133 | sum := sha256.Sum256(input) |
| 134 | launch := launchctx.LaunchContext{Version: 1, Account: "bds", App: "home", Host: "home.example.test", RequestID: opToken(30), AttemptID: opToken(31), RequestDigest: digest, RecordIdentity: digest, ArtifactDigest: f.imageHash, EnvironmentEnvelopeDigest: digest, GuardGeneration: 1, InputSHA256: hex.EncodeToString(sum[:]), DatabasePath: f.path, DatabaseDevice: uint64(st.Dev), DatabaseInode: st.Ino, ExecutionDeadline: time.Now().Add(20 * time.Second).UTC().Format(time.RFC3339Nano)} |
| 135 | raw, err := launchctx.EncodeLaunch(launch) |
| 136 | if err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | pin, err := os.Open(f.executable) |
| 140 | if err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | defer pin.Close() |
| 144 | read, write, err := os.Pipe() |
| 145 | if err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | defer read.Close() |
| 149 | defer write.Close() |
| 150 | watch, err := unix.InotifyInit1(unix.IN_NONBLOCK | unix.IN_CLOEXEC) |
| 151 | if err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | defer unix.Close(watch) |
| 155 | if _, err = unix.InotifyAddWatch(watch, f.path, unix.IN_OPEN); err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | ctx, cancel := context.WithTimeout(t.Context(), 25*time.Second) |
| 159 | defer cancel() |
| 160 | cmd := exec.CommandContext(ctx, f.executable, f.args...) |
| 161 | cmd.Dir = "/" |
| 162 | cmd.Env = f.env |
| 163 | cmd.Stdin = bytes.NewReader(input) |
| 164 | cmd.ExtraFiles = []*os.File{read, pin} |
| 165 | var out, stderr bytes.Buffer |
| 166 | cmd.Stdout = &out |
| 167 | if f.outputMode == "closed-pipe" { |
| 168 | reader, writer, err := os.Pipe() |
| 169 | if err != nil { |
| 170 | t.Fatal(err) |
| 171 | } |
| 172 | reader.Close() |
| 173 | defer writer.Close() |
| 174 | cmd.Stdout = writer |
| 175 | } |
| 176 | if f.outputMode == "regular-file" { |
| 177 | file, err := os.CreateTemp(t.TempDir(), "output") |
| 178 | if err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | defer file.Close() |
| 182 | cmd.Stdout = file |
| 183 | } |
| 184 | cmd.Stderr = &stderr |
| 185 | if err = cmd.Start(); err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | read.Close() |
| 189 | done := make(chan error, 1) |
| 190 | go func() { _, err := io.Copy(write, bytes.NewReader(raw)); write.Close(); done <- err }() |
| 191 | err = cmd.Wait() |
| 192 | writeErr := <-done |
| 193 | code := 0 |
| 194 | if err != nil { |
| 195 | var exit *exec.ExitError |
| 196 | if !errors.As(err, &exit) { |
| 197 | t.Fatal(err) |
| 198 | } |
| 199 | code = exit.ExitCode() |
| 200 | } |
| 201 | if code == 0 && writeErr != nil { |
| 202 | t.Fatal(writeErr) |
| 203 | } |
| 204 | if stderr.Len() != 0 { |
| 205 | t.Fatal("command wrote stderr") |
| 206 | } |
| 207 | buf := make([]byte, 4096) |
| 208 | n, err := unix.Read(watch, buf) |
| 209 | if err != nil && !errors.Is(err, unix.EAGAIN) { |
| 210 | t.Fatal(err) |
| 211 | } |
| 212 | return out.Bytes(), code, n > 0 |
| 213 | } |
| 214 | func commandRowCount(t *testing.T, path, table string) int { |
| 215 | t.Helper() |
| 216 | db, err := sql.Open("sqlite", "file:"+path+"?mode=ro") |
| 217 | if err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | defer db.Close() |
| 221 | var n int |
| 222 | if err = db.QueryRow("SELECT count(*) FROM " + table).Scan(&n); err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | return n |
| 226 | } |
| 227 | func TestOperatorCommandCredentialPrivateAtomicReplay(t *testing.T) { |
| 228 | f := newCommandFixture(t, "credential") |
| 229 | address := "operator-command-private@example.invalid" |
| 230 | raw := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: address}, 1, "", 10) |
| 231 | out, code, opened := f.run(t, raw) |
| 232 | if code != 0 || !opened { |
| 233 | t.Fatal("credential command failed", code, opened) |
| 234 | } |
| 235 | var result operator.Result |
| 236 | if stdjson.Unmarshal(out, &result) != nil { |
| 237 | t.Fatal("invalid result") |
| 238 | } |
| 239 | owner, err := operator.NewOwnerVerifier(base64.RawURLEncoding.EncodeToString(f.owner.SignPub()), f.pin.Origin, "https://home.example.test") |
| 240 | if err != nil { |
| 241 | t.Fatal(err) |
| 242 | } |
| 243 | receipt, err := owner.Verify(result.Receipt, time.Now()) |
| 244 | if err != nil || !ValidPersonRef(receipt.PersonRef) { |
| 245 | t.Fatal("owner receipt failed", err) |
| 246 | } |
| 247 | again, code, _ := f.run(t, raw) |
| 248 | if code != 0 || !bytes.Equal(out, again) { |
| 249 | t.Fatal("replay changed result", code) |
| 250 | } |
| 251 | if commandRowCount(t, f.path, "people") != 1 || commandRowCount(t, f.path, "credential_operator_results") != 1 { |
| 252 | t.Fatal("replay duplicated data") |
| 253 | } |
| 254 | for _, suffix := range []string{"", "-wal"} { |
| 255 | disk, err := os.ReadFile(f.path + suffix) |
| 256 | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 257 | t.Fatal(err) |
| 258 | } |
| 259 | if bytes.Contains(disk, []byte(address)) || bytes.Contains(disk, result.Receipt) { |
| 260 | t.Fatal("private values stored in plaintext") |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 | func TestOperatorCommandRefusesBeforeDatabaseOpen(t *testing.T) { |
| 265 | for _, mode := range []string{"signer", "missing-material", "unknown-material", "wrong-role"} { |
| 266 | t.Run(mode, func(t *testing.T) { |
| 267 | f := newCommandFixture(t, "credential") |
| 268 | if mode == "signer" { |
| 269 | var err error |
| 270 | f.signer, err = family.Generate() |
| 271 | if err != nil { |
| 272 | t.Fatal(err) |
| 273 | } |
| 274 | } |
| 275 | for i, value := range f.env { |
| 276 | if strings.HasPrefix(value, "HOME_OPERATOR_MATERIAL=") { |
| 277 | switch mode { |
| 278 | case "missing-material": |
| 279 | f.env[i] = "HOME_OPERATOR_MATERIAL=" |
| 280 | case "unknown-material": |
| 281 | f.env[i] = strings.Replace(value, `"version":1`, `"version":1,"unknown":true`, 1) |
| 282 | } |
| 283 | } |
| 284 | if mode == "wrong-role" && strings.HasPrefix(value, "HOME_ROLE=") { |
| 285 | f.env[i] = "HOME_ROLE=directory" |
| 286 | } |
| 287 | } |
| 288 | raw := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: "synthetic@example.invalid"}, 1, "", 10) |
| 289 | out, code, opened := f.run(t, raw) |
| 290 | if code != 2 || len(out) != 0 || opened { |
| 291 | t.Fatal("refusal boundary failed", code, opened) |
| 292 | } |
| 293 | }) |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | func TestOperatorCommandDirectoryInspectAndSeed(t *testing.T) { |
| 298 | f := newCommandFixture(t, "directory") |
| 299 | now := time.Now().Unix() |
| 300 | challenge := operator.InspectChallenge{Version: 1, Purpose: "operator-inspect", Role: "directory", Origin: f.pin.Origin, Installation: f.pin.Installation, Registry: f.pin.Registry, Device: f.pin.Device, Challenge: opToken(45), Issued: now, Expires: now + 300} |
| 301 | inspect, err := operator.SignInspect(f.signer, challenge) |
| 302 | if err != nil { |
| 303 | t.Fatal(err) |
| 304 | } |
| 305 | out, code, opened := f.run(t, inspect) |
| 306 | if code != 0 || !opened { |
| 307 | t.Fatal("inspect failed", code) |
| 308 | } |
| 309 | var observation operator.Inspection |
| 310 | if stdjson.Unmarshal(out, &observation) != nil || observation.Generation != nil || observation.TeamID != 1 || observation.TeamSQID != "Uk" { |
| 311 | t.Fatal("wrong initial inspection") |
| 312 | } |
| 313 | if commandRowCount(t, f.path, "directory_operator_state") != 0 || commandRowCount(t, f.path, "directory_operator_results") != 0 { |
| 314 | t.Fatal("inspect changed operator state") |
| 315 | } |
| 316 | generation := opToken(40) |
| 317 | initialize := f.request(t, "directory.initialize", operator.InitializeInput{InputBase: operator.InputBase{Version: 1, Operation: "directory.initialize", OperationID: opToken(10)}, Generation: generation, Baseline: "operator-attests-never-provisioned"}, 1, "", 10) |
| 318 | out, code, _ = f.run(t, initialize) |
| 319 | if code != 0 { |
| 320 | t.Fatal("initialize failed", code) |
| 321 | } |
| 322 | v, err := operator.NewVerifier(f.pin) |
| 323 | if err != nil { |
| 324 | t.Fatal(err) |
| 325 | } |
| 326 | request, err := v.Verify(initialize) |
| 327 | if err != nil { |
| 328 | t.Fatal(err) |
| 329 | } |
| 330 | receipt, err := operator.SignOwner(f.owner, operator.OwnerReceipt{Version: 1, Purpose: "owner-seed", OperationID: opToken(20), Issuer: "https://id.example.test", Directory: f.pin.Origin, TeamID: 1, TeamSQID: "Uk", PersonRef: "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa", Role: "owner", Issued: now, Expires: now + 300}) |
| 331 | if err != nil { |
| 332 | t.Fatal(err) |
| 333 | } |
| 334 | seed := f.request(t, "directory.seed-team", operator.SeedInput{InputBase: operator.InputBase{Version: 1, Operation: "directory.seed-team", OperationID: opToken(11)}, Generation: generation, TeamID: 1, TeamSQID: "Uk", Label: "test-command", Name: "private-command-team", Receipt: receipt}, 2, request.Head(), 11) |
| 335 | out, code, _ = f.run(t, seed) |
| 336 | if code != 0 { |
| 337 | t.Fatal("team seed failed", code) |
| 338 | } |
| 339 | again, code, _ := f.run(t, seed) |
| 340 | if code != 0 || !bytes.Equal(out, again) { |
| 341 | t.Fatal("team replay failed", code) |
| 342 | } |
| 343 | if commandRowCount(t, f.path, "teams") != 1 || commandRowCount(t, f.path, "memberships") != 1 || commandRowCount(t, f.path, "directory_operator_results") != 2 { |
| 344 | t.Fatal("wrong seed state") |
| 345 | } |
| 346 | out, code, _ = f.run(t, inspect) |
| 347 | if code != 0 || stdjson.Unmarshal(out, &observation) != nil || observation.Generation == nil || *observation.Generation != generation || observation.TeamID != 2 || observation.TeamSQID != "gb" { |
| 348 | t.Fatal("inspect after seed failed", code) |
| 349 | } |
| 350 | if commandRowCount(t, f.path, "directory_operator_results") != 2 { |
| 351 | t.Fatal("inspect persisted result") |
| 352 | } |
| 353 | for _, suffix := range []string{"", "-wal"} { |
| 354 | disk, err := os.ReadFile(f.path + suffix) |
| 355 | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 356 | t.Fatal(err) |
| 357 | } |
| 358 | if bytes.Contains(disk, []byte("private-command-team")) || bytes.Contains(disk, receipt) { |
| 359 | t.Fatal("private seed data stored in plaintext") |
| 360 | } |
| 361 | } |
| 362 | } |
| 363 | |
| 364 | func TestOperatorCommandOutputFailureRecoversCommittedResult(t *testing.T) { |
| 365 | for _, mode := range []string{"closed-pipe", "regular-file"} { |
| 366 | t.Run(mode, func(t *testing.T) { |
| 367 | f := newCommandFixture(t, "credential") |
| 368 | f.outputMode = mode |
| 369 | raw := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: "output-private@example.invalid"}, 1, "", 10) |
| 370 | out, code, opened := f.run(t, raw) |
| 371 | want := 3 |
| 372 | if mode == "regular-file" { |
| 373 | want = 2 |
| 374 | } |
| 375 | if code != want || len(out) != 0 || opened != (mode == "closed-pipe") { |
| 376 | t.Fatal("output refusal failed", code, opened) |
| 377 | } |
| 378 | count := commandRowCount(t, f.path, "credential_operator_results") |
| 379 | if (mode == "closed-pipe" && count != 1) || (mode == "regular-file" && count != 0) { |
| 380 | t.Fatal("wrong commit state", count) |
| 381 | } |
| 382 | f.outputMode = "" |
| 383 | out, code, _ = f.run(t, raw) |
| 384 | if code != 0 || len(out) == 0 { |
| 385 | t.Fatal("recovery failed", code) |
| 386 | } |
| 387 | again, code, _ := f.run(t, raw) |
| 388 | if code != 0 || !bytes.Equal(out, again) || commandRowCount(t, f.path, "people") != 1 { |
| 389 | t.Fatal("recovery changed identity or result", code) |
| 390 | } |
| 391 | }) |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | func TestOperatorCommandSchemaRefusalMakesNoIdentity(t *testing.T) { |
| 396 | for _, statement := range []string{"DELETE FROM rastrillo_migrations WHERE id=(SELECT id FROM rastrillo_migrations LIMIT 1)", "CREATE TRIGGER unexpected AFTER INSERT ON people BEGIN SELECT 1; END", "CREATE TABLE unexpected(value TEXT)"} { |
| 397 | t.Run(statement, func(t *testing.T) { |
| 398 | f := newCommandFixture(t, "credential") |
| 399 | db, err := sql.Open("sqlite", f.path) |
| 400 | if err != nil { |
| 401 | t.Fatal(err) |
| 402 | } |
| 403 | if _, err = db.Exec(statement); err != nil { |
| 404 | t.Fatal(err) |
| 405 | } |
| 406 | if err = db.Close(); err != nil { |
| 407 | t.Fatal(err) |
| 408 | } |
| 409 | raw := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: "schema-private@example.invalid"}, 1, "", 10) |
| 410 | out, code, opened := f.run(t, raw) |
| 411 | if code != 2 || !opened || len(out) != 0 { |
| 412 | t.Fatal("schema refusal failed", code, opened) |
| 413 | } |
| 414 | if commandRowCount(t, f.path, "people") != 0 || commandRowCount(t, f.path, "credential_operator_results") != 0 { |
| 415 | t.Fatal("schema refusal wrote identity") |
| 416 | } |
| 417 | }) |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | func TestOperatorCommandActualBinaryDispatch(t *testing.T) { |
| 422 | binary := filepath.Join(t.TempDir(), "home") |
| 423 | build := exec.CommandContext(t.Context(), "go", "build", "-o", binary, "../../cmd/home") |
| 424 | if out, err := build.CombinedOutput(); err != nil { |
| 425 | t.Fatalf("build failed: %v %s", err, out) |
| 426 | } |
| 427 | for _, args := range [][]string{{"operator-v1"}, {"operator-v1", "--db"}, {"operator-v1", "--db", "relative.db"}, {"operator-v1", "--db", "/tmp/example", "--extra"}} { |
| 428 | cmd := exec.CommandContext(t.Context(), binary, args...) |
| 429 | cmd.Env = []string{} |
| 430 | out, err := cmd.CombinedOutput() |
| 431 | var exited *exec.ExitError |
| 432 | if !errors.As(err, &exited) || exited.ExitCode() != 2 || len(out) != 0 { |
| 433 | t.Fatal("bad command argv was not silently refused", args, err) |
| 434 | } |
| 435 | } |
| 436 | f := newCommandFixture(t, "credential") |
| 437 | f.executable = binary |
| 438 | f.args = []string{"operator-v1", "--db", f.path} |
| 439 | image, err := os.ReadFile(binary) |
| 440 | if err != nil { |
| 441 | t.Fatal(err) |
| 442 | } |
| 443 | sum := sha256.Sum256(image) |
| 444 | f.imageHash = hex.EncodeToString(sum[:]) |
| 445 | raw := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: "actual-binary-private@example.invalid"}, 1, "", 10) |
| 446 | out, code, opened := f.run(t, raw) |
| 447 | if code != 0 || !opened || len(out) == 0 || commandRowCount(t, f.path, "people") != 1 { |
| 448 | t.Fatal("real binary dispatch failed", code, opened) |
| 449 | } |
| 450 | } |
| 451 | |
| 452 | func TestOperatorCommandKeyAndScopeCollisionsRefuseBeforeOpen(t *testing.T) { |
| 453 | for _, mode := range []string{"mac-dek", "zero-mac", "owner-browser", "owner-authorizer", "pinned-origin", "pinned-role"} { |
| 454 | t.Run(mode, func(t *testing.T) { |
| 455 | f := newCommandFixture(t, "credential") |
| 456 | vars := map[string]string{} |
| 457 | for _, value := range f.env { |
| 458 | name, value, _ := strings.Cut(value, "=") |
| 459 | vars[name] = value |
| 460 | } |
| 461 | var material, config map[string]any |
| 462 | if stdjson.Unmarshal([]byte(vars["HOME_OPERATOR_MATERIAL"]), &material) != nil || stdjson.Unmarshal([]byte(vars["HOME_OPERATOR_COMMAND"]), &config) != nil { |
| 463 | t.Fatal("fixture JSON") |
| 464 | } |
| 465 | switch mode { |
| 466 | case "mac-dek": |
| 467 | material["mac_key"] = vars["HOME_DEK"] |
| 468 | case "zero-mac": |
| 469 | material["mac_key"] = base64.StdEncoding.EncodeToString(make([]byte, 32)) |
| 470 | case "owner-browser": |
| 471 | material["owner_seed_key"] = vars["HOME_ASSERTION_KEY"] |
| 472 | case "owner-authorizer": |
| 473 | config["public_key"] = base64.RawURLEncoding.EncodeToString(f.owner.SignPub()) |
| 474 | config["key_id"] = rosterKeyID(f.owner) |
| 475 | f.signer = f.owner |
| 476 | case "pinned-origin": |
| 477 | config["origin"] = "https://other.example.test" |
| 478 | case "pinned-role": |
| 479 | config["role"] = "directory" |
| 480 | config["actions"] = []string{"directory.initialize"} |
| 481 | } |
| 482 | raw, _ := stdjson.Marshal(material) |
| 483 | vars["HOME_OPERATOR_MATERIAL"] = string(raw) |
| 484 | raw, _ = stdjson.Marshal(config) |
| 485 | vars["HOME_OPERATOR_COMMAND"] = string(raw) |
| 486 | f.env = nil |
| 487 | for name, value := range vars { |
| 488 | f.env = append(f.env, name+"="+value) |
| 489 | } |
| 490 | input := f.request(t, "credential.owner-seed", operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: opToken(10)}, Directory: "https://home.example.test", TeamID: 1, TeamSQID: "Uk", Address: "collision-private@example.invalid"}, 1, "", 10) |
| 491 | out, code, opened := f.run(t, input) |
| 492 | if code != 2 || opened || len(out) != 0 { |
| 493 | t.Fatal("key/scope collision accepted", code, opened) |
| 494 | } |
| 495 | }) |
| 496 | } |
| 497 | } |
| 498 | |