From 8ebb7a6519f469cf8b76409fa74abbedb1083150 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Tue, 27 Oct 2026 04:14:31 +0800 Subject: [PATCH] [authentication/2.6.2] Activate enrollment and durably promote client credentials Connect public pairing to the existing transactional activation path. Authenticate the exact committed receipt before publishing the complete private enrolled bundle, and retain same-selector recovery after ambiguous replies or interrupted publication. Add process-kill and real Python/Go regressions for durable recovery and original-deadline reply loss or delay. Update operator guidance and release notes. Validation: 1,610 Python unit tests passed (43 skipped), 49 Python/Go integration tests passed, Go test/vet, Pyrefly, Black and website build passed. Local DBB and fresh history-aware general reviews are clean for this candidate; native platform and installed-CLI acceptance remain separate delivery gates. --- .github/workflows/ci.yml | 2 +- client/go-cli/internal/cli/enrollment.go | 74 +++++++- client/go-cli/internal/cli/enrollment_test.go | 72 +++++++- client/go-cli/internal/identity/enrollment.go | 2 +- .../identity/enrollment_promotion_test.go | 147 ++++++++++++++++ .../internal/identity/enrollment_receipt.go | 81 ++++++++- .../identity/enrollment_receipt_test.go | 3 + client/go-cli/internal/identity/prepared.go | 9 +- .../internal/identity/provisional_session.go | 14 ++ server/news/73.feature.md | 5 + .../src/arbiter_server/enrollment_receipt.py | 4 +- server/src/arbiter_server/main.py | 16 +- server/src/arbiter_server/pairing_carrier.py | 13 +- server/src/arbiter_server/prepared.py | 1 - .../test_activation_restart_peer.py | 158 ++++++++++++++++++ .../integration/test_enrollment_cli_peer.py | 118 ++++++++++++- .../unit/test_artifact_http_admission.py | 4 +- website/docs/operate/credentials.md | 58 ++++--- 18 files changed, 709 insertions(+), 72 deletions(-) create mode 100644 client/go-cli/internal/identity/enrollment_promotion_test.go create mode 100644 server/news/73.feature.md create mode 100644 server/tests/integration/test_activation_restart_peer.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index daf66da..f35b52c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,7 +160,7 @@ jobs: if: ${{ matrix.go_tests == true }} env: GOCACHE: ${{ runner.temp }}/go-build - run: nox -s unit -- server/tests/integration/test_credential_peer.py server/tests/integration/test_provisional_peer.py server/tests/integration/test_pairing_peer.py server/tests/integration/test_enrollment_cli_peer.py server/tests/integration/test_control_tls_peer.py server/tests/integration/test_enrollment_receipt_peer.py + run: nox -s unit -- server/tests/integration/test_credential_peer.py server/tests/integration/test_provisional_peer.py server/tests/integration/test_pairing_peer.py server/tests/integration/test_enrollment_cli_peer.py server/tests/integration/test_control_tls_peer.py server/tests/integration/test_enrollment_receipt_peer.py server/tests/integration/test_activation_restart_peer.py - name: Verify native local-control rejection across OS users if: ${{ startsWith(matrix.platform_id, 'linux-') || startsWith(matrix.platform_id, 'macos-') }} diff --git a/client/go-cli/internal/cli/enrollment.go b/client/go-cli/internal/cli/enrollment.go index 9979315..f5d426c 100644 --- a/client/go-cli/internal/cli/enrollment.go +++ b/client/go-cli/internal/cli/enrollment.go @@ -2,6 +2,7 @@ package cli import ( "context" + "encoding/base64" "errors" "fmt" "io" @@ -17,20 +18,28 @@ func runEnrol(args []string, options globalOptions, stdout, stderr io.Writer, lookupEnv EnvLookup, homeDir HomeDirFunc, ) int { if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") { - fmt.Fprintln(stdout, "usage: arbiter enrol url=https://HOST name=NAME") - fmt.Fprintln(stdout, "Requires an interactive terminal. Saves prepared enrollment; activation is not available yet.") + fmt.Fprintln(stdout, "usage: arbiter enrol url=https://HOST name=NAME | arbiter enrol resume=SELECTOR") + fmt.Fprintln(stdout, "New enrollment requires an interactive terminal. Resume uses saved keys and remembered server trust, without a pairing secret.") return 0 } values := map[string]string{} for _, arg := range args { key, value, ok := strings.Cut(arg, "=") - if !ok || (key != "url" && key != "name") || value == "" || values[key] != "" { + if !ok || (key != "url" && key != "name" && key != "resume") || value == "" || values[key] != "" { printClientError(stderr, safeClientError("expected: arbiter enrol url=https://HOST name=NAME"), "usage") return 2 } values[key] = value } - if len(values) != 2 { + resume := len(values) == 1 && values["resume"] != "" + if resume { + decoded, err := base64.RawURLEncoding.DecodeString(values["resume"]) + if err != nil || len(decoded) != 16 || base64.RawURLEncoding.EncodeToString(decoded) != values["resume"] { + printClientError(stderr, safeClientError("expected a valid saved enrollment selector"), "usage") + return 2 + } + } + if !resume && (len(values) != 2 || values["url"] == "" || values["name"] == "") { printClientError(stderr, safeClientError("expected: arbiter enrol url=https://HOST name=NAME"), "usage") return 2 } @@ -44,6 +53,44 @@ func runEnrol(args []string, options globalOptions, stdout, stderr io.Writer, printClientError(stderr, err, "enrollment") return 1 } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + preparedDirectory := filepath.Join(filepath.Dir(configPath), "prepared") + receiptTimeout := config.ConnectTimeout + if receiptTimeout > 10*time.Second { + receiptTimeout = 10 * time.Second + } + finish := func(selector string) (bool, error) { + receipt, err := identity.ReconcileEnrollment(ctx, preparedDirectory, selector, receiptTimeout) + fromEnrolled := errors.Is(err, os.ErrNotExist) + saved := !fromEnrolled + if fromEnrolled { + receipt, err = identity.ReconcileEnrollment(ctx, filepath.Join(filepath.Dir(configPath), "enrolled"), selector, receiptTimeout) + saved = !errors.Is(err, os.ErrNotExist) + } + if err == nil { + err = receipt.Promote(ctx, filepath.Join(filepath.Dir(configPath), "enrolled")) + } + if err != nil { + return saved, safeClientError("Enrollment is not confirmed. Saved private state is retained; retry arbiter enrol resume=" + selector + ". If the server did not commit, ask the operator and start a new enrollment.") + } + if !fromEnrolled { + return true, identity.RemoveProvisional(preparedDirectory, selector) + } + return true, nil + } + if resume { + err := identity.WithEnrollmentOwner(ctx, filepath.Join(filepath.Dir(configPath), "provisional"), func() error { + _, err := finish(values["resume"]) + return err + }) + if err != nil { + printClientError(stderr, err, "enrollment") + return 1 + } + fmt.Fprintln(stdout, "Enrollment activated and saved. Ordinary authenticated API access is not available in this milestone.") + return 0 + } in, out, err := openEnrollmentTerminal() if err != nil { printClientError(stderr, safeClientError("enrollment requires an interactive terminal for deliberate secret handoff"), "enrollment") @@ -51,13 +98,10 @@ func runEnrol(args []string, options globalOptions, stdout, stderr io.Writer, } defer in.Close() defer out.Close() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) - defer cancel() if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil { printClientError(stderr, err, "enrollment") return 1 } - preparedDirectory := filepath.Join(filepath.Dir(configPath), "prepared") err = identity.RunProvisional(ctx, filepath.Join(filepath.Dir(configPath), "provisional"), values["url"], values["name"], func(ctx context.Context, p *identity.Provisional, secret identity.PairingSecret) error { // RunProvisional holds the one live enrollment owner. A second @@ -65,9 +109,21 @@ func runEnrol(args []string, options globalOptions, stdout, stderr io.Writer, if err := identity.CleanupPrepared(ctx, preparedDirectory); err != nil { return err } - return identity.PairEnrollment(ctx, p, secret, config.ConnectTimeout, preparedDirectory, func(ctx context.Context, selector, psk string) error { + pairErr := identity.PairEnrollment(ctx, p, secret, config.ConnectTimeout, preparedDirectory, func(ctx context.Context, selector, psk string) error { return confirmEnrollment(ctx, in, out, selector, psk) }) + public, _ := p.Public() + saved, finishErr := finish(public.Selector) + if finishErr != nil { + if !saved { + if pairErr == nil { + return safeClientError("Prepared enrollment is unavailable; enrollment was not locally promoted.") + } + return pairErr + } + return finishErr + } + return nil }) if err != nil { if errors.Is(err, identity.ErrEnrollmentClosed) { @@ -78,6 +134,6 @@ func runEnrol(args []string, options globalOptions, stdout, stderr io.Writer, printClientError(stderr, err, "enrollment") return 1 } - fmt.Fprintln(stdout, "Prepared enrollment saved and ready proof verified. Enrollment is not active; activation is not available yet.") + fmt.Fprintln(stdout, "Enrollment activated and saved. Ordinary authenticated API access is not available in this milestone.") return 0 } diff --git a/client/go-cli/internal/cli/enrollment_test.go b/client/go-cli/internal/cli/enrollment_test.go index 0c2de05..7670169 100644 --- a/client/go-cli/internal/cli/enrollment_test.go +++ b/client/go-cli/internal/cli/enrollment_test.go @@ -3,6 +3,7 @@ package cli import ( "bufio" "bytes" + "context" "encoding/json" "fmt" "net" @@ -35,6 +36,28 @@ func TestPythonEnrollmentCLI(t *testing.T) { if err := decoder.Decode(&start); err != nil { t.Fatal(err) } + if start.Mode == "resume" { + entries, err := os.ReadDir(filepath.Join(start.Directory, "prepared")) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if !strings.HasSuffix(entry.Name(), ".json") { + continue + } + selector := strings.TrimSuffix(entry.Name(), ".json") + var out, diagnostics bytes.Buffer + if Main([]string{"--config-dir", start.Directory, "arbiter.connect_timeout=15s", "enrol", "resume=" + selector}, &out, &diagnostics, + func(string) (string, bool) { return "", false }, func() (string, error) { return start.Directory, nil }) != 0 { + t.Fatal(diagnostics.String()) + } + if _, err := os.Stat(filepath.Join(start.Directory, "enrolled", entry.Name())); err != nil { + t.Fatal(err) + } + return + } + t.Fatal("missing recoverable prepared state") + } in, feed, _ := os.Pipe() output, out, _ := os.Pipe() defer in.Close() @@ -70,7 +93,15 @@ func TestPythonEnrollmentCLI(t *testing.T) { finished <- secret }() var stdout, stderr bytes.Buffer - code := Main([]string{"--config-dir", start.Directory, "enrol", "url=" + start.URL, "name=Fresh CLI"}, &stdout, &stderr, + args := []string{"--config-dir", start.Directory} + if start.Mode == "ok" { + args = append(args, "arbiter.connect_timeout=15s") + } + if start.Mode == "lost-reply" || start.Mode == "delayed-reply" { + args = append(args, "arbiter.connect_timeout=10s") + } + args = append(args, "enrol", "url="+start.URL, "name=Fresh CLI") + code := Main(args, &stdout, &stderr, func(string) (string, bool) { return "", false }, func() (string, error) { return start.Directory, nil }) // Closing the command's terminal output also releases the synthetic reader // when closed enrollment fails before a handoff. @@ -78,11 +109,11 @@ func TestPythonEnrollmentCLI(t *testing.T) { if secret != "" && (strings.Contains(stdout.String(), secret) || strings.Contains(stderr.String(), secret)) { t.Fatal("secret reached output") } - if start.Mode == "ok" { - if code != 0 || !strings.Contains(stdout.String(), "Enrollment is not active") { + if start.Mode == "ok" || start.Mode == "lost-reply" || start.Mode == "delayed-reply" { + if code != 0 || !strings.Contains(stdout.String(), "Enrollment activated and saved") { t.Fatalf("code=%d stderr=%s", code, stderr.String()) } - entries, err := os.ReadDir(filepath.Join(start.Directory, "prepared")) + entries, err := os.ReadDir(filepath.Join(start.Directory, "enrolled")) if err != nil { t.Fatal(err) } @@ -92,11 +123,21 @@ func TestPythonEnrollmentCLI(t *testing.T) { continue } selector := strings.TrimSuffix(entry.Name(), ".json") - material, prepared, err := identity.LoadPrepared(filepath.Join(start.Directory, "prepared"), selector, time.Now().UTC()) - if err != nil || material == nil || prepared == nil || !strings.Contains(stdout.String(), "Prepared enrollment saved") { - t.Fatal("prepared private result did not survive reload", err) + if start.Mode == "lost-reply" || start.Mode == "delayed-reply" { + receipt, reconcileErr := identity.ReconcileEnrollment(context.Background(), filepath.Join(start.Directory, "enrolled"), selector, 10*time.Second) + if reconcileErr == nil { + reconcileErr = receipt.Promote(context.Background(), filepath.Join(start.Directory, "enrolled")) + } + if reconcileErr != nil { + t.Fatal("expired enrolled receipt did not survive reload", reconcileErr) + } + } else { + material, prepared, err := identity.LoadPrepared(filepath.Join(start.Directory, "enrolled"), selector, time.Now().UTC()) + if err != nil || material == nil || prepared == nil { + t.Fatal("enrolled private result did not survive reload", err) + } } - path := filepath.Join(start.Directory, "prepared", entry.Name()) + path := filepath.Join(start.Directory, "enrolled", entry.Name()) original, err := os.ReadFile(path) if err != nil { t.Fatal(err) @@ -110,7 +151,13 @@ func TestPythonEnrollmentCLI(t *testing.T) { if err := os.WriteFile(path, changed, 0600); err != nil { t.Fatal(err) } - if _, _, err := identity.LoadPrepared(filepath.Join(start.Directory, "prepared"), selector, time.Now().UTC()); err == nil { + var loadErr error + if start.Mode == "lost-reply" || start.Mode == "delayed-reply" { + _, loadErr = identity.ReconcileEnrollment(context.Background(), filepath.Join(start.Directory, "enrolled"), selector, 10*time.Second) + } else { + _, _, loadErr = identity.LoadPrepared(filepath.Join(start.Directory, "enrolled"), selector, time.Now().UTC()) + } + if loadErr == nil { t.Fatal("corrupted prepared record accepted") } } @@ -118,6 +165,13 @@ func TestPythonEnrollmentCLI(t *testing.T) { t.Fatal(err) } count++ + // Explicit recovery after cleanup authenticates against enrolled + // custody and never creates another activation or handoff. + var resumed, resumeErr bytes.Buffer + if Main([]string{"--config-dir", start.Directory, "arbiter.connect_timeout=15s", "enrol", "resume=" + selector}, &resumed, &resumeErr, + func(string) (string, bool) { return "", false }, func() (string, error) { return start.Directory, nil }) != 0 { + t.Fatal("resume after promotion", resumeErr.String()) + } } if count != 1 { t.Fatal("expected one prepared result", count) diff --git a/client/go-cli/internal/identity/enrollment.go b/client/go-cli/internal/identity/enrollment.go index 44e5d0b..5f809bc 100644 --- a/client/go-cli/internal/identity/enrollment.go +++ b/client/go-cli/internal/identity/enrollment.go @@ -275,7 +275,7 @@ func pairingSocket(ctx context.Context, target string, initiator *PairingInitiat return err } plain, err := initiator.Decrypt(ack) - if err != nil || string(plain) != preparedComplete { + if err != nil || !bytes.Equal(plain, prepared) { return ErrEnrollmentUnavailable } return ctx.Err() diff --git a/client/go-cli/internal/identity/enrollment_promotion_test.go b/client/go-cli/internal/identity/enrollment_promotion_test.go new file mode 100644 index 0000000..eb54069 --- /dev/null +++ b/client/go-cli/internal/identity/enrollment_promotion_test.go @@ -0,0 +1,147 @@ +package identity + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "testing" + "time" +) + +func TestPromotionCrashHelper(t *testing.T) { + phase := os.Getenv("ARBITER_TEST_PROMOTION_PHASE") + if phase == "" { + return + } + material, prepared, err := loadPrepared(os.Getenv("ARBITER_TEST_PROMOTION_SOURCE"), os.Getenv("ARBITER_TEST_PROMOTION_SELECTOR"), time.Now(), true) + if err != nil { + t.Fatal(err) + } + private, _ := material.EncodePrivate() + bundle, _ := json.Marshal(preparedPrivateRecord{privateWarning, 1, private, prepared.raw}) + err = savePrivateRecord(context.Background(), os.Getenv("ARBITER_TEST_PROMOTION_DEST"), material.record.Selector, bundle, func(at string) error { + if at == phase { + fmt.Println("READY") + time.Sleep(time.Minute) // Parent kills this process without cleanup. + } + return nil + }) + t.Fatal("crash checkpoint not reached", err) +} + +// Called only after the real Python TLS peer supplied an authenticated receipt. +func testPromotionRecovery(t *testing.T, receipt *EnrollmentReceipt, source string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + base, selector := filepath.Dir(source), receipt.material.record.Selector + owner := filepath.Join(base, "provisional") + for _, phase := range []string{"written", "flushed", "published"} { + destination := filepath.Join(base, phase) + err := WithEnrollmentOwner(ctx, owner, func() error { + cmd := exec.CommandContext(ctx, executable, "-test.run=^TestPromotionCrashHelper$") + cmd.Env = append(os.Environ(), "ARBITER_TEST_PROMOTION_PHASE="+phase, + "ARBITER_TEST_PROMOTION_SOURCE="+source, "ARBITER_TEST_PROMOTION_SELECTOR="+selector, + "ARBITER_TEST_PROMOTION_DEST="+destination) + output, err := cmd.StdoutPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + line, readErr := bufio.NewReader(output).ReadString('\n') + killErr := cmd.Process.Kill() + waitErr := cmd.Wait() + if readErr != nil || line != "READY\n" || killErr != nil || waitErr == nil { + t.Fatalf("%s crash did not execute: %q %v %v %v", phase, line, readErr, killErr, waitErr) + } + final, pending, _ := custodyNames(destination, selector) + _, finalErr := os.Lstat(final) + if (finalErr == nil) != (phase == "published") { + t.Fatal("unexpected publication boundary", phase, finalErr) + } + if phase != "published" { + if _, err := os.Lstat(pending); err != nil { + t.Fatal("missing killed writer staging", err) + } + } else if err := os.Link(final, pending); err != nil { + t.Fatal("interrupted two-link publication fixture", err) + } + for range 2 { + if err := receipt.Promote(ctx, destination); err != nil { + return err + } + } + _, saved, err := loadPrepared(destination, selector, time.Now(), true) + if err != nil || !bytes.Equal(saved.raw, receipt.prepared.raw) { + t.Fatal("exact committed result not recovered", err) + } + if _, err := os.Lstat(pending); !os.IsNotExist(err) { + t.Fatal("staging remains after recovery", err) + } + return nil + }) + if err != nil { + t.Fatal("recover killed writer", phase, err) + } + } + for _, mode := range []string{"different-final", "directory", "hardlink", "symlink", "permissions"} { + if runtime.GOOS == "windows" && (mode == "symlink" || mode == "permissions") { + continue // Native ACL/reparse guards have separate Windows tests. + } + t.Run(mode, func(t *testing.T) { + destination := filepath.Join(base, mode) + root, dir, err := custodyRoot(destination, true) + if err != nil { + t.Fatal(err) + } + dir.Close() + final, pending, _ := custodyNames(root, selector) + marker := []byte("preserve existing private state") + path := pending + if mode == "different-final" { + path = final + } + if mode == "directory" { + err = os.Mkdir(pending, 0700) + } else if mode == "hardlink" || mode == "symlink" { + target := filepath.Join(root, "other") + err = os.WriteFile(target, marker, 0600) + if err == nil && mode == "hardlink" { + err = os.Link(target, pending) + } else if err == nil { + err = os.Symlink(target, pending) + } + } else { + err = os.WriteFile(path, marker, 0600) + if mode == "permissions" && err == nil { + err = os.Chmod(path, 0644) + } + } + if err != nil { + t.Fatal(err) + } + if err := WithEnrollmentOwner(ctx, owner, func() error { return receipt.Promote(ctx, destination) }); err == nil { + t.Fatal("unsafe/different existing state accepted") + } + if mode == "directory" { + if info, err := os.Lstat(pending); err != nil || !info.IsDir() { + t.Fatal("existing directory changed", err) + } + } else if saved, err := os.ReadFile(path); err != nil || !bytes.Equal(saved, marker) { + t.Fatal("existing state changed", err) + } + }) + } +} diff --git a/client/go-cli/internal/identity/enrollment_receipt.go b/client/go-cli/internal/identity/enrollment_receipt.go index c790993..ccee516 100644 --- a/client/go-cli/internal/identity/enrollment_receipt.go +++ b/client/go-cli/internal/identity/enrollment_receipt.go @@ -5,16 +5,21 @@ import ( "context" "crypto/rand" "crypto/sha256" + "encoding/json" + "errors" + "os" "time" ) const receiptRequestTag = "arbiter-enrollment-receipt-request-v1\x00" const receiptProofTag = "arbiter-enrollment-receipt-possession-v1\x00" -// EnrollmentReceipt proves retrieval of the exact committed result over current -// remembered server trust. It cannot by itself activate ordinary API access. -// Durable promotion is a separate operation, added with the activation writer. -type EnrollmentReceipt struct{ prepared *PreparedEnrollment } +// EnrollmentReceipt proves the exact committed result over current remembered +// server trust. Only this authenticated result can be durably promoted. +type EnrollmentReceipt struct { + prepared *PreparedEnrollment + material *Provisional +} // ReconcileEnrollment needs no pairing secret. Keep prepared keys on transport // failures: a lost reply does not prove that the original commit failed. @@ -43,7 +48,73 @@ func ReconcileEnrollment(ctx context.Context, directory, selector string, timeou if err != nil || !bytes.Equal(reply, prepared.raw) { return nil, errControl } - return &EnrollmentReceipt{prepared: prepared}, nil + return &EnrollmentReceipt{prepared: prepared, material: material}, nil +} + +// Promote publishes complete enrolled custody before the caller removes any +// prepared state. An ambiguous filesystem failure retains the recovery source. +// Repeated publication is successful only for the same exact private result. +// The caller must hold the same enrollment owner used by RunProvisional. +func (r *EnrollmentReceipt) Promote(ctx context.Context, directory string) error { + if r == nil || r.prepared == nil || r.material == nil { + return errCustody + } + private, err := r.material.EncodePrivate() + if err != nil { + return err + } + bundle, err := json.Marshal(preparedPrivateRecord{privateWarning, 1, private, r.prepared.raw}) + if err != nil { + return err + } + if err := savePrivateRecord(ctx, directory, r.material.record.Selector, bundle, func(string) error { return nil }); err != nil { + if !errors.Is(err, os.ErrExist) { + return err + } + root, dir, openErr := custodyRoot(directory, false) + if openErr != nil { + return openErr + } + dir.Close() + final, pending, _ := custodyNames(root, r.material.record.Selector) + links := uint32(1) + a, ae := os.Lstat(final) + b, be := os.Lstat(pending) + if errors.Is(ae, os.ErrNotExist) { + // A killed writer can leave only staging. The authenticated source + // is intact; discard only this selector's private single-link file. + if err := ctx.Err(); err != nil { + return err + } + if err := removeCustodyFile(pending, 1); err != nil { + return err + } + return savePrivateRecord(ctx, root, r.material.record.Selector, bundle, func(string) error { return nil }) + } + if ae != nil { + return ae + } + if ae == nil && be == nil && os.SameFile(a, b) { + links = 2 + } + material, prepared, loadErr := loadPreparedLinked(root, r.material.record.Selector, time.Now().UTC(), true, links) + if loadErr != nil || !bytes.Equal(prepared.raw, r.prepared.raw) { + return errCustody + } + existing, loadErr := material.EncodePrivate() + if loadErr != nil || !bytes.Equal(existing, private) { + return errCustody + } + if links == 2 { + if err := removeCustodyFile(pending, 2); err != nil { + return err + } + } + // savePrivateRecord includes the durable-directory sync in ErrExist; + // repeat it explicitly so joined sync failures cannot become success. + return nativeSyncCustody(directory) + } + return nil } func receiptRequest(material *Provisional, prepared *PreparedEnrollment, exporter []byte) ([]byte, error) { diff --git a/client/go-cli/internal/identity/enrollment_receipt_test.go b/client/go-cli/internal/identity/enrollment_receipt_test.go index e4d7a7c..da649df 100644 --- a/client/go-cli/internal/identity/enrollment_receipt_test.go +++ b/client/go-cli/internal/identity/enrollment_receipt_test.go @@ -136,6 +136,9 @@ func TestPythonReceiptPeer(t *testing.T) { if (err == nil) != want || want && (receipt == nil || !bytes.Equal(receipt.prepared.raw, fixture.Result)) { t.Fatal("receipt result", fixture.Mode, err) } + if fixture.Mode == "ok" { + testPromotionRecovery(t, receipt, directory) + } after, err := os.ReadFile(filepath.Join(directory, material.record.Selector+".json")) if err != nil || !bytes.Equal(after, bundle) { t.Fatal("prepared material changed") diff --git a/client/go-cli/internal/identity/prepared.go b/client/go-cli/internal/identity/prepared.go index 0f89f1f..2177c27 100644 --- a/client/go-cli/internal/identity/prepared.go +++ b/client/go-cli/internal/identity/prepared.go @@ -19,7 +19,6 @@ import ( const preparedTag = "arbiter-enrollment-prepared-v1\x00" const readyTag = "arbiter-enrollment-ready-v1\x00" -const preparedComplete = "arbiter-enrollment-prepared-not-active-v1\x00" const maxPreparedPrivateBytes = MaxProvisionalBytes + 2*MaxPairingFrame type preparedPrivateRecord struct { @@ -226,6 +225,10 @@ func LoadPrepared(directory, selector string, now time.Time) (*Provisional, *Pre } func loadPrepared(directory, selector string, now time.Time, receipt bool) (*Provisional, *PreparedEnrollment, error) { + return loadPreparedLinked(directory, selector, now, receipt, 1) +} + +func loadPreparedLinked(directory, selector string, now time.Time, receipt bool, links uint32) (*Provisional, *PreparedEnrollment, error) { root, dir, err := custodyRoot(directory, false) if err != nil { return nil, nil, err @@ -240,14 +243,14 @@ func loadPrepared(directory, selector string, now time.Time, receipt bool) (*Pro return nil, nil, err } defer f.Close() - if err := checkBoundCustody(f, false, false, 1); err != nil { + if err := checkBoundCustody(f, false, false, links); err != nil { return nil, nil, err } bundle, err := io.ReadAll(io.LimitReader(f, int64(maxPreparedPrivateBytes+1))) if err != nil || len(bundle) > maxPreparedPrivateBytes || !utf8.Valid(bundle) { return nil, nil, errCustody } - if err := checkBoundCustody(f, false, false, 1); err != nil { + if err := checkBoundCustody(f, false, false, links); err != nil { return nil, nil, err } var record preparedPrivateRecord diff --git a/client/go-cli/internal/identity/provisional_session.go b/client/go-cli/internal/identity/provisional_session.go index afacdde..9e0448a 100644 --- a/client/go-cli/internal/identity/provisional_session.go +++ b/client/go-cli/internal/identity/provisional_session.go @@ -17,6 +17,20 @@ var ErrProvisionalUnexpectedContents = errors.New("provisional cleanup made no p const provisionalLockName = ".enrollment.lock" const provisionalCleanupBatch = 64 +// WithEnrollmentOwner serializes receipt recovery with new enrollment cleanup. +// It uses the same native lock as RunProvisional without generating another key. +func WithEnrollmentOwner(ctx context.Context, directory string, action func() error) (result error) { + if action == nil { + return errCustody + } + _, lock, err := lockProvisional(ctx, directory) + if err != nil { + return err + } + defer func() { result = errors.Join(result, lock.Close()) }() + return action() +} + // lockProvisional holds native ownership until the actual consumer exits. The // empty lock file persists: unlinking it would allow two independent lock owners. func lockProvisional(ctx context.Context, directory string) (string, *os.File, error) { diff --git a/server/news/73.feature.md b/server/news/73.feature.md new file mode 100644 index 0000000..0b3d7b4 --- /dev/null +++ b/server/news/73.feature.md @@ -0,0 +1,5 @@ +Commit owner-approved enrollment once after durable client readiness. The native +client saves activated credentials only after authenticated receipt confirmation +and supports `arbiter enrol resume=SELECTOR` after an interrupted final exchange. +Committed results survive server restart without a pairing secret. Ordinary +authenticated API operations remain part of a later milestone. diff --git a/server/src/arbiter_server/enrollment_receipt.py b/server/src/arbiter_server/enrollment_receipt.py index 926944f..b7ca6fe 100644 --- a/server/src/arbiter_server/enrollment_receipt.py +++ b/server/src/arbiter_server/enrollment_receipt.py @@ -1,7 +1,7 @@ """Read one committed result using its registered access key, never a pairing PSK. -No table or activation is created here. Until the activation slice installs its -writer, only explicit test fixtures can supply committed enrollment rows. +No table or activation is created here. Only the separate ready/activation +transaction may register product enrollments. """ from contextlib import closing diff --git a/server/src/arbiter_server/main.py b/server/src/arbiter_server/main.py index 2f6c1a7..34711e4 100644 --- a/server/src/arbiter_server/main.py +++ b/server/src/arbiter_server/main.py @@ -2293,7 +2293,9 @@ def _create_native_http_app( pairing_responder = PairingResponder(enrollment_requests, native_limits) async def continue_pairing(channel: PairingChannel) -> None: - await channel.prove(_server_data_root(_storage_config(cfg)) / "authority") + await channel.prove( + _server_data_root(_storage_config(cfg)) / "authority", enrollment_accounts + ) async def continue_control(carrier: Any) -> None: from .control_tls import ControlTLS @@ -2305,15 +2307,17 @@ async def continue_control(carrier: Any) -> None: await dispatch(channel, _server_data_root(_storage_config(cfg)) / "authority") - def enrollment_control( - command: str, arguments: dict[str, object] - ) -> dict[str, object]: - accounts = { + def enrollment_accounts() -> set[tuple[str, str]]: + return { (plugin, str(account)) for plugin in configured_service_names(cfg.arbiter.account) for account in (service_accounts_for(cfg, plugin) or {}) } - return enrollment_requests.control(command, arguments, accounts) + + def enrollment_control( + command: str, arguments: dict[str, object] + ) -> dict[str, object]: + return enrollment_requests.control(command, arguments, enrollment_accounts()) @asynccontextmanager async def lifespan(_app: object) -> AsyncGenerator[None, None]: diff --git a/server/src/arbiter_server/pairing_carrier.py b/server/src/arbiter_server/pairing_carrier.py index 2c4dc21..a0b4116 100644 --- a/server/src/arbiter_server/pairing_carrier.py +++ b/server/src/arbiter_server/pairing_carrier.py @@ -1,4 +1,4 @@ -"""Bounded RFC 6455 carrier for pairing; it cannot activate an enrollment.""" +"""Bounded RFC 6455 carrier for authenticated pairing and receipt control.""" from __future__ import annotations @@ -27,8 +27,10 @@ def __init__(self, carrier: PairingProtocol, session: PairingSession) -> None: self._carrier = carrier self.session = session - async def prove(self, root: Path) -> None: - """Prepare only after mutual proof; never activate an enrollment.""" + async def prove( + self, root: Path, accounts: Callable[[], set[tuple[str, str]]] + ) -> None: + """Commit only after mutual proof and durable client readiness.""" proof = await self._carrier.responder.prove_server_from_authority( self.session, root, datetime.now(timezone.utc) ) @@ -36,12 +38,11 @@ async def prove(self, root: Path) -> None: await self._carrier.responder.prove_client( self.session, await self._carrier.receive() ) - from .prepared import PREPARED_COMPLETE - result = await self._carrier.responder.prepare(self.session, root) await self.send(result) await self._carrier.responder.ready(self.session, await self.receive()) - await self.send(PREPARED_COMPLETE) + receipt = await self._carrier.responder.activate(self.session, root, accounts) + await self._carrier.send(receipt) async def receive(self) -> bytes: return await self._carrier.responder.message( diff --git a/server/src/arbiter_server/prepared.py b/server/src/arbiter_server/prepared.py index 09fec13..1d809f1 100644 --- a/server/src/arbiter_server/prepared.py +++ b/server/src/arbiter_server/prepared.py @@ -19,7 +19,6 @@ PREPARED_TAG = b"arbiter-enrollment-prepared-v1\0" READY_TAG = b"arbiter-enrollment-ready-v1\0" -PREPARED_COMPLETE = b"arbiter-enrollment-prepared-not-active-v1\0" def prepare(root: Path, approval: ApprovedRequest, expires_at: int) -> bytes: diff --git a/server/tests/integration/test_activation_restart_peer.py b/server/tests/integration/test_activation_restart_peer.py new file mode 100644 index 0000000..f1028ac --- /dev/null +++ b/server/tests/integration/test_activation_restart_peer.py @@ -0,0 +1,158 @@ +"""Kill the actual Python server after commit; recover with a fresh Go process.""" + +import json +import os +from pathlib import Path +import shutil +import socket +import subprocess +import sys +import threading + +import pytest + +from arbiter_server.authority import initialize_authority +from arbiter_server.main import _generate_self_signed_tls_certificate +from server.tests.unit.conftest import private_windows_test_parent, tmp_path + + +SERVER = """ +import asyncio,json,sys +from pathlib import Path +from server.tests.unit.test_native_http_protocol import _client +from server.tests.unit.test_artifact_http_admission import live_server +d=json.loads(sys.stdin.readline()) +root=Path(d['root']); app=_client(root)._app +responder=app.state.pairing_responder +original=responder.activate +async def activate(*args): + wire=await original(*args) + print('COMMITTED',flush=True) + await asyncio.Event().wait() +if d['drop']: responder.activate=activate +with live_server(app,ws=app.state.native_websocket_protocol,http=app.state.native_http_protocol, + bind_port=d['port'],ssl_certfile=str(root/'outer.crt'),ssl_keyfile=str(root/'outer.key')) as port: + requests=app.state.enrollment_requests + if d['drop']: requests.open_window() + print(json.dumps(dict(port=port,pending=len(requests.pending()))),flush=True) + for line in sys.stdin: + command=json.loads(line) + if command=='stop': break + pending=next(p for p in requests.pending() if p.advertisement.selector==command['selector']) + requests.control('approve',dict(selector=pending.advertisement.selector,revision=pending.revision, + request_digest=pending.advertisement.digest,psk=command['secret'],grants=[]),set()) + print('APPROVED',flush=True) +""" + + +def test_real_server_crash_then_fresh_go_resume(tmp_path, private_windows_test_parent): + if shutil.which("go") is None: + pytest.skip("go is not installed") + initialize_authority( + tmp_path / "server" / "authority", recovery_file=tmp_path / "operator.json" + ) + _generate_self_signed_tls_certificate( + cert_path=tmp_path / "outer.crt", + key_path=tmp_path / "outer.key", + names=("127.0.0.1",), + ) + repo = Path(__file__).parents[3] + server = client = None + + def stop(): + for process in (client, server): + if process is not None and process.poll() is None: + process.kill() + process.wait(timeout=10) + + watchdog = threading.Timer(90, stop) + watchdog.start() + with (tmp_path / "server.log").open("w+") as log: + try: + port = 0 + for mode in ("commit-stop", "resume"): + server = subprocess.Popen( + [sys.executable, "-c", SERVER], + cwd=repo, + env={**os.environ, "PYTHONPATH": str(repo / "server" / "src")}, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=log, + text=True, + ) + assert server.stdin is not None and server.stdout is not None + server.stdin.write( + json.dumps( + dict(root=str(tmp_path), port=port, drop=mode == "commit-stop") + ) + + "\n" + ) + server.stdin.flush() + started = server.stdout.readline() + if not started: + log.seek(0) + pytest.fail(f"server startup failed in {mode}: {log.read()}") + status = json.loads(started) + port = status["port"] + assert status["pending"] == 0 + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + listener.settimeout(45) + client = subprocess.Popen( + [ + "go", + "test", + "-count=1", + "-run", + "^TestPythonEnrollmentCLI$", + "./internal/cli", + ], + cwd=repo / "client" / "go-cli", + env={ + **os.environ, + "ARBITER_TEST_ENROLLMENT_CONTROL": f"127.0.0.1:{listener.getsockname()[1]}", + }, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + connection, _ = listener.accept() + with connection, connection.makefile("rwb") as stream: + connection.settimeout(20) + stream.write( + json.dumps( + dict( + URL=f"https://127.0.0.1:{port}", + Directory=str(tmp_path / "client"), + Mode=mode, + ) + ).encode() + + b"\n" + ) + stream.flush() + if mode == "commit-stop": + handoff = json.loads(stream.readline(1025)) + server.stdin.write(json.dumps(handoff) + "\n") + server.stdin.flush() + assert server.stdout.readline().strip() == "APPROVED" + stream.write(b"true\n") + stream.flush() + assert server.stdout.readline().strip() == "COMMITTED" + server.kill() + server.wait(timeout=10) + output, _ = client.communicate(timeout=60) + assert client.returncode == 0, output + if mode == "commit-stop": + assert list((tmp_path / "client" / "prepared").glob("*.json")) + assert not (tmp_path / "client" / "enrolled").exists() + else: + server.stdin.write('"stop"\n') + server.stdin.flush() + server.wait(timeout=15) + assert server.returncode == 0 + assert not list((tmp_path / "client" / "prepared").glob("*.json")) + assert len(list((tmp_path / "client" / "enrolled").glob("*.json"))) == 1 + finally: + watchdog.cancel() + stop() diff --git a/server/tests/integration/test_enrollment_cli_peer.py b/server/tests/integration/test_enrollment_cli_peer.py index f5b60fe..7b1a98b 100644 --- a/server/tests/integration/test_enrollment_cli_peer.py +++ b/server/tests/integration/test_enrollment_cli_peer.py @@ -4,6 +4,7 @@ Secrets stay in memory and are never printed, passed in argv/env or persisted. """ +import asyncio from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone import hashlib @@ -20,6 +21,7 @@ from starlette.applications import Starlette from arbiter_server.authority import initialize_authority +from arbiter_server.limits import NativeTransportLimitsConfig from arbiter_server.main import _generate_self_signed_tls_certificate from arbiter_server.prepared import PREPARED_TAG from server.tests.unit.conftest import private_windows_test_parent, tmp_path @@ -45,6 +47,11 @@ "trailing", "truncated", "save-failure", + "lost-reply", + "delayed-reply", + "reject-ready", + "cancel-ready", + "promotion-failure", ], ) def test_fresh_python_go_enrollment_cli( @@ -52,15 +59,28 @@ def test_fresh_python_go_enrollment_cli( ): if shutil.which("go") is None: pytest.skip("go is not installed") + deadline_modes = {"lost-reply", "delayed-reply"} server_root = tmp_path / "server" / "authority" initialize_authority(server_root, recovery_file=tmp_path / "operator.json") - app = cast(Starlette, _client(tmp_path)._app) + native_transport = ( + NativeTransportLimitsConfig(handshake_timeout_seconds=10.0) + if mode in deadline_modes + else None + ) + app = cast(Starlette, _client(tmp_path, native_transport=native_transport)._app) responder = app.state.pairing_responder original_prepare, original_ready = responder.prepare, responder.ready + original_activate = responder.activate ready_calls = [] saved_result = [] + request_clock = [0.0] + request_deadline = None + prepared_expiry = None + commit_before_deadline = False + reply_after_deadline = False async def prepare(session, root): + nonlocal prepared_expiry raw = await original_prepare(session, root) saved_result.append(raw) fixed = len(PREPARED_TAG) + 144 + 22 + 32 + 32 + 8 @@ -90,6 +110,19 @@ async def prepare(session, root): raw = bytes(header) + b"".join( len(value).to_bytes(2, "big") + value for value in fields ) + if mode in deadline_modes: + assert raw == saved_result[-1] + assert request_deadline is not None + remaining = request_deadline - request_clock[0] + assert 5 <= remaining <= 7 + prepared_expiry = int.from_bytes(raw[fixed - 8 : fixed], "big") + wall_now = datetime.now(timezone.utc).timestamp() + assert ( + int(wall_now + remaining) - 1 + <= prepared_expiry + <= int(wall_now + remaining) + 1 + ) + assert prepared_expiry > int(wall_now) + 4 if mode == "trailing": raw += b"X" if mode == "truncated": @@ -120,9 +153,32 @@ async def ready(session, data): assert "psk" not in private and "secret" not in private ready_calls.append(hashlib.sha256(data).hexdigest()) await original_ready(session, data) + if mode == "reject-ready": + app.state.enrollment_requests.exhaust_approval(session.approval) + if mode == "cancel-ready": + session.lease.close() + + async def activate(*args): + nonlocal commit_before_deadline, reply_after_deadline + receipt = await original_activate(*args) + if mode in deadline_modes: + assert request_deadline is not None and prepared_expiry is not None + commit_before_deadline = request_clock[0] < request_deadline + assert commit_before_deadline + request_clock[0] = request_deadline + 1 + assert request_clock[0] > request_deadline + await asyncio.sleep(8) + assert datetime.now(timezone.utc).timestamp() > prepared_expiry + reply_after_deadline = True + if mode == "lost-reply": + raise ValueError( + "synthetic dropped reply after durable commit and expiry" + ) + return receipt monkeypatch.setattr(responder, "prepare", prepare) monkeypatch.setattr(responder, "ready", ready) + monkeypatch.setattr(responder, "activate", activate) cert, key = tmp_path / "outer.crt", tmp_path / "outer.key" _generate_self_signed_tls_certificate( cert_path=cert, key_path=key, names=("127.0.0.1",) @@ -135,6 +191,8 @@ async def ready(session, data): ssl_keyfile=str(key), ) as port: requests = app.state.enrollment_requests + if mode in deadline_modes: + requests._clock = lambda: request_clock[0] if mode != "closed": requests.open_window() with socket.socket() as listener: @@ -143,6 +201,7 @@ async def ready(session, data): listener.settimeout(30) def operator(): + nonlocal request_deadline connection, _ = listener.accept() with connection, connection.makefile("rwb") as stream: connection.settimeout(20) @@ -182,6 +241,13 @@ def operator(): ), set(), ) + if mode in deadline_modes: + request_deadline = pending.deadline + assert pending.deadline - pending.created_at == pytest.approx( + 600 + ) + request_clock[0] = request_deadline - 6 + assert request_clock[0] < request_deadline if mode == "restart": requests.reset() if mode == "save-failure": @@ -195,6 +261,10 @@ def operator(): ) path.write_text("preserve existing private record") path.chmod(0o600) + if mode == "promotion-failure": + (tmp_path / "client" / "enrolled").write_text( + "preserve existing file" + ) stream.write(b"true\n") stream.flush() @@ -220,13 +290,49 @@ def operator(): ) assert result.returncode == 0, result.stdout + result.stderr owner.result(timeout=5) - # Prepared credentials do not create an enrollment or grant access. - assert not (tmp_path / "client" / "enrolled").exists() - assert len(ready_calls) == (1 if mode == "ok" else 0) - if mode == "ok": + success = mode in {"ok", "lost-reply", "delayed-reply"} + if mode in deadline_modes: + assert commit_before_deadline + assert reply_after_deadline + assert prepared_expiry is not None + assert datetime.now(timezone.utc).timestamp() > prepared_expiry + assert (tmp_path / "client" / "enrolled").is_dir() == success + assert len(ready_calls) == ( + 1 + if success or mode in {"reject-ready", "cancel-ready", "promotion-failure"} + else 0 + ) + if mode in {"reject-ready", "cancel-ready", "promotion-failure"}: assert len(list((tmp_path / "client" / "prepared").glob("*.json"))) == 1 elif mode != "save-failure": assert not list((tmp_path / "client" / "prepared").glob("*.json")) - if mode == "ok": + if success: + enrolled_files = list((tmp_path / "client" / "enrolled").glob("*.json")) + assert len(enrolled_files) == 1 + enrolled = json.loads(enrolled_files[0].read_text()) + assert ( + base64.b64decode(enrolled["result"], validate=True) == saved_result[-1] + ) + assert set(enrolled["provisional"]) >= { + "access_key_pkcs8", + "portable_key_pkcs8", + "recipient_key_pkcs8", + } + assert not list((tmp_path / "client" / "prepared").glob("*.json")) sessions = app.state.pairing_responder._attempts.values() assert all(state.active is None for state in sessions) + from contextlib import closing + import sqlite3 + from server.tests.unit.test_enrollment_activation import database + + enrollment_id = saved_result[-1][ + len(PREPARED_TAG) + 108 : len(PREPARED_TAG) + 144 + ].decode() + with closing(sqlite3.connect(database(server_root))) as db: + row = db.execute( + "SELECT COUNT(*), prepared FROM enrollments WHERE enrollment_id=?", + (enrollment_id,), + ).fetchone() + assert row[0] == 1 and bytes(row[1]) == saved_result[-1] + assert db.execute("SELECT COUNT(*) FROM enrollments").fetchone() == (1,) + assert not requests.pending() and not responder._attempts diff --git a/server/tests/unit/test_artifact_http_admission.py b/server/tests/unit/test_artifact_http_admission.py index 1f1c765..fd911e9 100644 --- a/server/tests/unit/test_artifact_http_admission.py +++ b/server/tests/unit/test_artifact_http_admission.py @@ -325,7 +325,9 @@ def live_server(app, **config): import uvicorn listener = socket.socket() - listener.bind(("127.0.0.1", 0)) + if "bind_port" in config: + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", config.pop("bind_port", 0))) port = listener.getsockname()[1] access_logger = logging.getLogger("uvicorn.access") logging_patch = pytest.MonkeyPatch() diff --git a/website/docs/operate/credentials.md b/website/docs/operate/credentials.md index f0efd92..09a71f6 100644 --- a/website/docs/operate/credentials.md +++ b/website/docs/operate/credentials.md @@ -5,8 +5,8 @@ title: Development Credential Profiles Offline `arbiter-server authority init` creates separate owner-issuer, server-authority and inner server-TLS credentials. They are committed with the owner/server IDs in the private SQLite database. Status returns public identity -metadata, never private keys. Setup is still development work: pairing and runtime -caller authentication are separate milestones. Keep trusted-local-only operation. +metadata, never private keys. Pairing can activate an enrollment; ordinary runtime +caller authentication remains a later milestone. Keep trusted-local-only operation. ## Certificate profiles, version 1 @@ -41,7 +41,7 @@ The native Go verifier applies the same version-one profiles. Shared positive and negative vectors and a test using freshly issued Python credentials check cross-language agreement. A caller supplies the expected role, IDs, registered issuer and verification time; the verifier does not discover trust from a peer's -certificate. This development foundation does not enable client enrollment or +certificate. Certificate verification alone does not enroll a client or authorize ordinary authenticated API traffic. Version-one profiles support noncritical Subject Key Identifier values with one @@ -258,10 +258,20 @@ For an expired, restarted, rejected or unavailable request, ask the operator and start again. Private provisional files are removed on exit, with the existing bounded startup cleanup handling interruptions. -This development step saves verified prepared credentials, keys and public -trust before sending ready. It explicitly reports `Enrollment is not active` -and grants no ordinary API access. Durable activation and receipt reconciliation -follow in later delivery slices. +The command saves verified prepared credentials, keys and public trust before +sending ready. The server commits the approved identity, credentials, grants and +exact result together. The client verifies the committed result through remembered +server trust, flushes complete private state into `enrolled/`, then removes its +prepared record. It reports `Enrollment activated and saved`. Ordinary authenticated +API access remains unavailable in this milestone. + +If the final reply or private publication fails, keep the saved state and run +`arbiter enrol resume=SELECTOR`. This needs no terminal secret or new approval. +It authenticates the server and retrieves only that exact committed result. A +retry after successful promotion is also safe. Denial, inactivity or an absent +commit prevents confirmation. If the server never committed, ask the operator and +explicitly start a new pairing. Do not restart pairing merely to recover a lost +receipt: new pairing discards abandoned prepared records. ### Public enrollment request boundary @@ -345,10 +355,11 @@ a private `prepared/` record before signing a ready proof over that result and the current Noise transcript. The record carries `NEVER SHARE THIS` and contains no pairing secret. -This is still uncommitted enrollment. Prepared certificates and a ready proof -cannot authorize ordinary work. The CLI reports that activation is unavailable; -durable server activation and authenticated receipt reconciliation follow in -later slices. One prepared result per request shares the existing bounded +Prepared certificates and a ready proof alone cannot authorize ordinary work. +Only the final conditional transaction activates the exact result, after rechecking +live approval, original expiry, attempt ownership and configured account names. +Committed results survive restart without a PSK; rejected or expired uncommitted +requests do not. One prepared result per request shares the existing bounded pending capacity and original deadline. Rejection, expiry and server restart invalidate it. Explicitly starting another enrollment discards abandoned private prepared records in bounded batches while holding the client enrollment owner. @@ -458,13 +469,14 @@ plaintext limit to the carrier frame limit, reserving space for TLS and framing. The connect budget covers outer dial, upgrade and inner handshake. A separate absolute ten-second control budget starts once the inner handshake authenticates the server. Progress and pings reset neither budget. Carrier byte, -buffer and shared crypto limits also apply. The Python server foundation and Go -client expose the RFC 9266 TLS exporter for later proof binding. The Go client +buffer and shared crypto limits also apply. The Python server and Go client +expose the RFC 9266 TLS exporter for channel-bound receipt proofs. The Go client verifies the remembered server authority and server ID before exposing the stream. This transport foundation supports authenticated retrieval of the exact -result for an already committed enrollment, but it cannot activate enrollment, -promote a client durably or dispatch ordinary operations. Those controls are -delivered separately. A prepared client is still not enrolled. +result for an already committed enrollment. The `enrol` command uses that +authenticated receipt to publish complete enrolled custody. Only the pairing +ready/activation transaction can activate enrollment; this transport cannot +dispatch ordinary operations. Prepared state alone remains uncommitted. ## Operator recovery custody @@ -534,9 +546,9 @@ after the inner handshake; a shorter caller deadline still applies. One stream permits one nonempty request and reply, each at most 64 KiB decoded. Cancellation closes the carrier, and exchanges are never automatically replayed. -The transport and fresh Python/Go tests are development foundations. Receipt -reconciliation is available only for already committed results. Durable enrollment -activation is a separate slice; no arbitrary control or HTTP operation is enabled. +Receipt reconciliation is available only for already committed results. Pairing +can now commit such a result. No arbitrary control or ordinary HTTP operation is +enabled by this transport. ## Enrollment receipt reconciliation @@ -547,7 +559,9 @@ TLS connection and exact prepared-result digest. No pairing secret is required. Denial and inactivity prevent retrieval. A network failure does not prove that enrollment failed; retain the private prepared record for a later authenticated retry. Receipt retrieval cannot create an identity, grant access, or enter the -ordinary API. The activation and durable client-promotion flow is delivered -separately; this transport foundation does not yet enable an enrollment CLI to -become an ordinary authenticated client. +ordinary API. The CLI promotes the complete private result only after authenticated +confirmation, using the same private atomic custody contract as preparation. +Enrolled records are separate from provisional/prepared cleanup. Startup under the +exclusive authority instance lock recovers a validated interrupted SQLite journal; +network control readers never perform that recovery.