| 1 | //go:build linux |
| 2 | |
| 3 | package provisioner |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "database/sql" |
| 9 | "io" |
| 10 | "os" |
| 11 | "os/exec" |
| 12 | "strconv" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "amadan.net/oficina/home/internal/operator" |
| 17 | "github.com/go-json-experiment/json" |
| 18 | "github.com/go-json-experiment/json/jsontext" |
| 19 | "golang.org/x/sys/unix" |
| 20 | ) |
| 21 | |
| 22 | func TestPrivateRequestDescriptorsSubprocess(t *testing.T) { |
| 23 | if mode := os.Getenv("OFICINA_DESCRIPTOR_CHILD"); mode != "" { |
| 24 | var output *requestOutput |
| 25 | var e error |
| 26 | if mode == "input-alias" { |
| 27 | output, e = privateRequestOutput(3, 4) |
| 28 | } else { |
| 29 | output, e = privateRequestOutput(3) |
| 30 | } |
| 31 | if mode != "valid" { |
| 32 | if output != nil { |
| 33 | output.Close() |
| 34 | } |
| 35 | if e != ErrRefused { |
| 36 | os.Exit(2) |
| 37 | } |
| 38 | os.Exit(0) |
| 39 | } |
| 40 | if e != nil { |
| 41 | os.Exit(3) |
| 42 | } |
| 43 | if e = writePrivateRequest(output.file, []byte("synthetic-private-output")); e != nil { |
| 44 | os.Exit(4) |
| 45 | } |
| 46 | output.Close() |
| 47 | os.Exit(0) |
| 48 | } |
| 49 | for _, mode := range []string{"valid", "stdout-alias", "stderr-alias", "stdin-alias", "input-alias", "read-only", "regular", "socket", "named-pipe"} { |
| 50 | t.Run(mode, func(t *testing.T) { |
| 51 | reader, writer, e := os.Pipe() |
| 52 | if e != nil { |
| 53 | t.Fatal(e) |
| 54 | } |
| 55 | defer reader.Close() |
| 56 | defer writer.Close() |
| 57 | inherited := writer |
| 58 | var extra *os.File |
| 59 | switch mode { |
| 60 | case "read-only": |
| 61 | inherited = reader |
| 62 | case "regular": |
| 63 | extra, e = os.CreateTemp(t.TempDir(), "private") |
| 64 | if e != nil { |
| 65 | t.Fatal(e) |
| 66 | } |
| 67 | defer extra.Close() |
| 68 | inherited = extra |
| 69 | case "socket": |
| 70 | pair, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) |
| 71 | if err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | extra = os.NewFile(uintptr(pair[0]), "socket") |
| 75 | defer extra.Close() |
| 76 | defer unix.Close(pair[1]) |
| 77 | inherited = extra |
| 78 | case "named-pipe": |
| 79 | path := t.TempDir() + "/pipe" |
| 80 | if e = unix.Mkfifo(path, 0600); e != nil { |
| 81 | t.Fatal(e) |
| 82 | } |
| 83 | holder, err := unix.Open(path, unix.O_RDWR|unix.O_NONBLOCK, 0) |
| 84 | if err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | defer unix.Close(holder) |
| 88 | fd, err := unix.Open(path, unix.O_WRONLY|unix.O_NONBLOCK, 0) |
| 89 | if err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | extra = os.NewFile(uintptr(fd), path) |
| 93 | defer extra.Close() |
| 94 | inherited = extra |
| 95 | } |
| 96 | cmd := exec.Command(os.Args[0], "-test.run=^TestPrivateRequestDescriptorsSubprocess$") |
| 97 | cmd.Env = append(os.Environ(), "OFICINA_DESCRIPTOR_CHILD="+mode) |
| 98 | cmd.ExtraFiles = []*os.File{inherited} |
| 99 | if mode == "input-alias" { |
| 100 | cmd.ExtraFiles = append(cmd.ExtraFiles, reader) |
| 101 | } |
| 102 | var public bytes.Buffer |
| 103 | cmd.Stdout = &public |
| 104 | cmd.Stderr = &public |
| 105 | if mode == "stdout-alias" { |
| 106 | cmd.Stdout = writer |
| 107 | } |
| 108 | if mode == "stderr-alias" { |
| 109 | cmd.Stderr = writer |
| 110 | } |
| 111 | if mode == "stdin-alias" { |
| 112 | cmd.Stdin = reader |
| 113 | } |
| 114 | if e = cmd.Start(); e != nil { |
| 115 | t.Fatal(e) |
| 116 | } |
| 117 | writer.Close() |
| 118 | private, e := io.ReadAll(reader) |
| 119 | if e != nil { |
| 120 | t.Fatal(e) |
| 121 | } |
| 122 | if e = cmd.Wait(); e != nil { |
| 123 | t.Fatalf("descriptor %s: %v %s", mode, e, public.Bytes()) |
| 124 | } |
| 125 | if public.Len() != 0 { |
| 126 | t.Fatal("private output reached standard streams") |
| 127 | } |
| 128 | if mode == "valid" && !bytes.Equal(private, []byte("synthetic-private-output")) { |
| 129 | t.Fatal("private pipe output") |
| 130 | } |
| 131 | if mode != "valid" && len(private) != 0 { |
| 132 | t.Fatal("refused output wrote bytes") |
| 133 | } |
| 134 | }) |
| 135 | } |
| 136 | } |
| 137 | func ownerOperationPlan() OperationPlan { |
| 138 | input := operator.OwnerInput{InputBase: operator.InputBase{Version: 1, Operation: "credential.owner-seed", OperationID: testToken(23)}, Directory: planFixture().Directory.Origin, TeamID: 1, TeamSQID: "Uk", Address: "native-prepare-private-83@example.invalid", ExistingOnly: true} |
| 139 | raw, _ := json.Marshal(input, json.Deterministic(true)) |
| 140 | return OperationPlan{1, testToken(20), "credential", testToken(23), "credential.owner-seed", raw} |
| 141 | } |
| 142 | func TestNativePreparationPrivateReplayAndFailedOutput(t *testing.T) { |
| 143 | if os.Getenv("OFICINA_PREPARE_CHILD") == "1" { |
| 144 | args := []string{"provision", "prepare", "--state", os.Getenv("OFICINA_STATE_PATH"), "--floor-dir", os.Getenv("OFICINA_FLOOR_PATH"), "--password-fd", "3", "--plan-fd", "4", "--request-fd", "5"} |
| 145 | if e := Run(args, os.Stdout); e != nil { |
| 146 | os.Exit(3) |
| 147 | } |
| 148 | os.Exit(0) |
| 149 | } |
| 150 | state, floor := persistentDirectory(t), persistentDirectory(t) |
| 151 | password := []byte("synthetic-prepare-private-password") |
| 152 | if _, e := Initialize(state, floor, password); e != nil { |
| 153 | t.Fatal(e) |
| 154 | } |
| 155 | enrollment, _ := canonicalJSON(planFixture()) |
| 156 | if _, e := EnrollPlan(state, floor, password, enrollment); e != nil { |
| 157 | t.Fatal(e) |
| 158 | } |
| 159 | p := ownerOperationPlan() |
| 160 | plan, _ := json.Marshal(p, json.Deterministic(true)) |
| 161 | run := func(raw []byte, broken bool) ([]byte, []byte, error) { |
| 162 | pr, pw, e := os.Pipe() |
| 163 | if e != nil { |
| 164 | t.Fatal(e) |
| 165 | } |
| 166 | defer pr.Close() |
| 167 | ir, iw, e := os.Pipe() |
| 168 | if e != nil { |
| 169 | t.Fatal(e) |
| 170 | } |
| 171 | defer ir.Close() |
| 172 | rr, rw, e := os.Pipe() |
| 173 | if e != nil { |
| 174 | t.Fatal(e) |
| 175 | } |
| 176 | defer rr.Close() |
| 177 | if broken { |
| 178 | rr.Close() |
| 179 | } |
| 180 | cmd := exec.Command(os.Args[0], "-test.run=^TestNativePreparationPrivateReplayAndFailedOutput$") |
| 181 | cmd.Env = append(os.Environ(), "OFICINA_PREPARE_CHILD=1", "OFICINA_STATE_PATH="+state, "OFICINA_FLOOR_PATH="+floor) |
| 182 | cmd.ExtraFiles = []*os.File{pr, ir, rw} |
| 183 | var public, stderr bytes.Buffer |
| 184 | cmd.Stdout = &public |
| 185 | cmd.Stderr = &stderr |
| 186 | if e = cmd.Start(); e != nil { |
| 187 | t.Fatal(e) |
| 188 | } |
| 189 | pr.Close() |
| 190 | ir.Close() |
| 191 | rw.Close() |
| 192 | pw.Write(password) |
| 193 | pw.Close() |
| 194 | iw.Write(raw) |
| 195 | iw.Close() |
| 196 | var request []byte |
| 197 | if !broken { |
| 198 | request, e = io.ReadAll(rr) |
| 199 | if e != nil { |
| 200 | t.Fatal(e) |
| 201 | } |
| 202 | } |
| 203 | e = cmd.Wait() |
| 204 | for _, stream := range [][]byte{public.Bytes(), stderr.Bytes()} { |
| 205 | if bytes.Contains(stream, []byte("native-prepare-private-83@example.invalid")) || bytes.Contains(stream, password) || bytes.Contains(stream, []byte("signed_authorization")) { |
| 206 | t.Fatal("private request leaked") |
| 207 | } |
| 208 | } |
| 209 | return request, public.Bytes(), e |
| 210 | } |
| 211 | if _, public, e := run(plan, true); e == nil || len(public) != 0 { |
| 212 | t.Fatal("failed private output reported success", e) |
| 213 | } |
| 214 | j, e := existingJournal(state, floor, password) |
| 215 | if e != nil { |
| 216 | t.Fatal(e) |
| 217 | } |
| 218 | before := snapshot(t, j).installations[testToken(20)] |
| 219 | saved := append([]byte(nil), before.Operations[testToken(23)].Raw...) |
| 220 | if before.Operations[testToken(23)].Status != "prepared" || before.Heads["credential"].Sequence != 0 { |
| 221 | t.Fatal("output failure advanced state") |
| 222 | } |
| 223 | closeExisting(j) |
| 224 | request, public, e := run(plan, false) |
| 225 | if e != nil || !bytes.Equal(request, saved) { |
| 226 | t.Fatal("exact failed-output resume", e) |
| 227 | } |
| 228 | var info PreparationInfo |
| 229 | if json.Unmarshal(public, &info, json.RejectUnknownMembers(true)) != nil || info.State != "local-request" || info.Sequence != 1 { |
| 230 | t.Fatal("public preparation status") |
| 231 | } |
| 232 | again, _, e := run(plan, false) |
| 233 | if e != nil || !bytes.Equal(again, request) { |
| 234 | t.Fatal("cross-process replay changed bytes", e) |
| 235 | } |
| 236 | padded := append(append([]byte(nil), plan...), bytes.Repeat([]byte(" "), operator.BodyLimit-len(plan))...) |
| 237 | if len(padded) != operator.BodyLimit { |
| 238 | t.Fatal("test boundary") |
| 239 | } |
| 240 | exact, _, e := run(padded, false) |
| 241 | if e != nil || !bytes.Equal(exact, request) { |
| 242 | t.Fatal("64KiB local plan boundary", e) |
| 243 | } |
| 244 | if _, public, e = run(append(padded, ' '), false); e == nil || len(public) != 0 { |
| 245 | t.Fatal("oversized plan accepted", e) |
| 246 | } |
| 247 | wrong := p |
| 248 | wrong.Role = "directory" |
| 249 | raw, _ := json.Marshal(wrong) |
| 250 | if _, public, e = run(raw, false); e == nil || len(public) != 0 { |
| 251 | t.Fatal("foreign role accepted") |
| 252 | } |
| 253 | wrong = p |
| 254 | wrong.Installation = testToken(99) |
| 255 | raw, _ = json.Marshal(wrong) |
| 256 | if _, public, e = run(raw, false); e == nil || len(public) != 0 { |
| 257 | t.Fatal("foreign installation accepted") |
| 258 | } |
| 259 | j, e = existingJournal(state, floor, password) |
| 260 | if e != nil { |
| 261 | t.Fatal(e) |
| 262 | } |
| 263 | defer closeExisting(j) |
| 264 | after := snapshot(t, j).installations[testToken(20)] |
| 265 | if len(after.Operations) != 1 || after.Heads["credential"].Sequence != 0 || after.Operations[testToken(23)].Status != "prepared" { |
| 266 | t.Fatal("local request implied execution") |
| 267 | } |
| 268 | } |
| 269 | |
| 270 | type shortPrivateWriter struct{} |
| 271 | |
| 272 | func (shortPrivateWriter) Write(p []byte) (int, error) { return len(p) - 1, nil } |
| 273 | func TestPrivatePreparationBoundsAndCollectorRefusal(t *testing.T) { |
| 274 | for _, size := range []int{0, 1, operator.BodyLimit, operator.BodyLimit + 1} { |
| 275 | raw := bytes.Repeat([]byte("x"), size) |
| 276 | var out bytes.Buffer |
| 277 | e := writePrivateRequest(&out, raw) |
| 278 | if size == 0 || size > operator.BodyLimit { |
| 279 | if e != ErrRefused || out.Len() != 0 { |
| 280 | t.Fatal(size, e) |
| 281 | } |
| 282 | } else if e != nil || !bytes.Equal(raw, out.Bytes()) { |
| 283 | t.Fatal(size, e) |
| 284 | } |
| 285 | } |
| 286 | if e := writePrivateRequest(shortPrivateWriter{}, []byte("private")); e != ErrUnavailable { |
| 287 | t.Fatal("short output") |
| 288 | } |
| 289 | for _, action := range []string{"directory.verify-consumer", "directory.activate-consumer"} { |
| 290 | p := ownerOperationPlan() |
| 291 | p.Role = "directory" |
| 292 | p.Action = action |
| 293 | p.Input = jsontext.Value(`{}`) |
| 294 | raw, _ := json.Marshal(p) |
| 295 | if _, _, e := decodeOperationPlan(raw); e != ErrRefused { |
| 296 | t.Fatal("missing collector signed", action) |
| 297 | } |
| 298 | } |
| 299 | for _, fd := range []int{-1, 0, 1, 2, 99999} { |
| 300 | if out, e := privateRequestOutput(fd); out != nil || e != ErrRefused { |
| 301 | t.Fatal("unsafe descriptor", strconv.Itoa(fd), e) |
| 302 | } |
| 303 | } |
| 304 | p := ownerOperationPlan() |
| 305 | input := new(operator.OwnerInput) |
| 306 | if json.Unmarshal(p.Input, input) != nil { |
| 307 | t.Fatal("fixture") |
| 308 | } |
| 309 | input.ExistingOnly = false |
| 310 | if preparationScope(&installationState{Plan: planFixture()}, input) { |
| 311 | t.Fatal("unbacked person creation preparation") |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | func TestPreparationClosedPlanRefusesUnknownAndDuplicateFields(t *testing.T) { |
| 316 | p := ownerOperationPlan() |
| 317 | raw, _ := json.Marshal(p, json.Deterministic(true)) |
| 318 | for _, bad := range [][]byte{ |
| 319 | append([]byte{}, append(raw, raw...)...), |
| 320 | bytes.Replace(raw, []byte(`"version":1`), []byte(`"version":1,"version":1`), 1), |
| 321 | bytes.Replace(raw, []byte(`"version":1`), []byte(`"version":1,"unknown":"private@example.invalid"`), 1), |
| 322 | } { |
| 323 | if _, _, e := decodeOperationPlan(bad); e != ErrRefused { |
| 324 | t.Fatal("non-closed plan", e) |
| 325 | } |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | func TestFullPrivatePipeCancellationKeepsExactRequest(t *testing.T) { |
| 330 | j, p := stateFixture(t) |
| 331 | op := testToken(23) |
| 332 | raw, e := j.prepare(context.Background(), p.Installation, op, "directory.initialize", initialization(op)) |
| 333 | if e != nil { |
| 334 | t.Fatal(e) |
| 335 | } |
| 336 | fds := make([]int, 2) |
| 337 | if e = unix.Pipe2(fds, unix.O_CLOEXEC|unix.O_NONBLOCK); e != nil { |
| 338 | t.Fatal(e) |
| 339 | } |
| 340 | defer unix.Close(fds[0]) |
| 341 | output, e := privateRequestOutput(fds[1]) |
| 342 | if e != nil { |
| 343 | unix.Close(fds[1]) |
| 344 | t.Fatal(e) |
| 345 | } |
| 346 | defer output.Close() |
| 347 | fill := bytes.Repeat([]byte("x"), 4096) |
| 348 | filled := 0 |
| 349 | for { |
| 350 | n, err := unix.Write(fds[1], fill) |
| 351 | filled += n |
| 352 | if err == unix.EAGAIN { |
| 353 | break |
| 354 | } |
| 355 | if err != nil { |
| 356 | t.Fatal(err) |
| 357 | } |
| 358 | } |
| 359 | if filled <= 0 { |
| 360 | t.Fatal("pipe was not filled") |
| 361 | } |
| 362 | ctx, cancel := context.WithCancel(context.Background()) |
| 363 | defer cancel() |
| 364 | done := make(chan error, 1) |
| 365 | go func() { done <- output.write(ctx, raw) }() |
| 366 | select { |
| 367 | case e := <-done: |
| 368 | t.Fatal("full pipe did not block", e) |
| 369 | case <-time.After(50 * time.Millisecond): |
| 370 | } |
| 371 | cancel() |
| 372 | select { |
| 373 | case e := <-done: |
| 374 | if e != ErrUnavailable { |
| 375 | t.Fatal("cancellation error", e) |
| 376 | } |
| 377 | case <-time.After(2 * time.Second): |
| 378 | t.Fatal("cancellation did not interrupt pipe") |
| 379 | } |
| 380 | j.close() |
| 381 | reopened, e := openJournal(j.dir, j.floor, j.key, false) |
| 382 | if e != nil { |
| 383 | t.Fatal("process lock retained", e) |
| 384 | } |
| 385 | defer reopened.close() |
| 386 | repeated, e := reopened.prepare(context.Background(), p.Installation, op, "directory.initialize", initialization(op)) |
| 387 | if e != nil || !bytes.Equal(raw, repeated) { |
| 388 | t.Fatal("interrupted output lost exact request", e) |
| 389 | } |
| 390 | s := snapshot(t, reopened).installations[p.Installation] |
| 391 | if s.Operations[op].Status != "prepared" || s.Heads["directory"].Sequence != 0 { |
| 392 | t.Fatal("cancellation implied execution") |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | func TestPrepareRequestCancelsFullPipeAndReleasesRegistry(t *testing.T) { |
| 397 | state, floor := persistentDirectory(t), persistentDirectory(t) |
| 398 | password := []byte("synthetic-full-pipe-password") |
| 399 | if _, e := Initialize(state, floor, password); e != nil { |
| 400 | t.Fatal(e) |
| 401 | } |
| 402 | enrollment, _ := canonicalJSON(planFixture()) |
| 403 | if _, e := EnrollPlan(state, floor, password, enrollment); e != nil { |
| 404 | t.Fatal(e) |
| 405 | } |
| 406 | p := ownerOperationPlan() |
| 407 | plan, _ := json.Marshal(p, json.Deterministic(true)) |
| 408 | fds := make([]int, 2) |
| 409 | if e := unix.Pipe2(fds, unix.O_CLOEXEC|unix.O_NONBLOCK); e != nil { |
| 410 | t.Fatal(e) |
| 411 | } |
| 412 | defer unix.Close(fds[0]) |
| 413 | output, e := privateRequestOutput(fds[1]) |
| 414 | if e != nil { |
| 415 | unix.Close(fds[1]) |
| 416 | t.Fatal(e) |
| 417 | } |
| 418 | defer output.Close() |
| 419 | for { |
| 420 | _, e = unix.Write(fds[1], bytes.Repeat([]byte("x"), 4096)) |
| 421 | if e == unix.EAGAIN { |
| 422 | break |
| 423 | } |
| 424 | if e != nil { |
| 425 | t.Fatal(e) |
| 426 | } |
| 427 | } |
| 428 | ctx, cancel := context.WithCancel(context.Background()) |
| 429 | defer cancel() |
| 430 | done := make(chan error, 1) |
| 431 | go func() { _, e := prepareRequest(ctx, state, floor, password, plan, output); done <- e }() |
| 432 | // Observe the immutable fixture record without joining its writer lock. |
| 433 | observer, e := sql.Open("sqlite", "file:"+state+"/registry.db?mode=ro") |
| 434 | if e != nil { |
| 435 | t.Fatal(e) |
| 436 | } |
| 437 | defer observer.Close() |
| 438 | deadline := time.Now().Add(30 * time.Second) |
| 439 | for { |
| 440 | var n int |
| 441 | e = observer.QueryRow("SELECT count(*) FROM records WHERE operation=? AND state='finalized'", p.Operation).Scan(&n) |
| 442 | if e == nil && n == 1 { |
| 443 | break |
| 444 | } |
| 445 | select { |
| 446 | case e := <-done: |
| 447 | t.Fatal("preparation stopped before output", e) |
| 448 | default: |
| 449 | } |
| 450 | if time.Now().After(deadline) { |
| 451 | t.Fatal("fixture request was not persisted") |
| 452 | } |
| 453 | time.Sleep(10 * time.Millisecond) |
| 454 | } |
| 455 | select { |
| 456 | case e := <-done: |
| 457 | t.Fatal("full output did not wait", e) |
| 458 | case <-time.After(50 * time.Millisecond): |
| 459 | } |
| 460 | cancel() |
| 461 | select { |
| 462 | case e := <-done: |
| 463 | if e != ErrUnavailable { |
| 464 | t.Fatal("canceled preparation", e) |
| 465 | } |
| 466 | case <-time.After(2 * time.Second): |
| 467 | t.Fatal("prepareRequest ignored cancellation") |
| 468 | } |
| 469 | observer.Close() |
| 470 | j, e := existingJournal(state, floor, password) |
| 471 | if e != nil { |
| 472 | t.Fatal("registry lock not released", e) |
| 473 | } |
| 474 | defer closeExisting(j) |
| 475 | s := snapshot(t, j).installations[p.Installation] |
| 476 | saved := s.Operations[p.Operation] |
| 477 | if saved == nil || saved.Status != "prepared" || s.Heads[p.Role].Sequence != 0 { |
| 478 | t.Fatal("canceled output changed authority") |
| 479 | } |
| 480 | _, input, e := decodeOperationPlan(plan) |
| 481 | if e != nil { |
| 482 | t.Fatal(e) |
| 483 | } |
| 484 | replay, e := j.prepare(context.Background(), p.Installation, p.Operation, p.Action, input) |
| 485 | if e != nil || !bytes.Equal(replay, saved.Raw) { |
| 486 | t.Fatal("canceled prepare lost exact retry", e) |
| 487 | } |
| 488 | } |
| 489 | |