From 92f57c8036f81efbb746f959491d272bf68be316 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Mon, 2 Nov 2026 00:44:46 +0800 Subject: [PATCH] [arbiter-authentication/2.10.4] Public CLI reuse and installed acceptance Expose portable identity reuse through the enrollment CLI and verify the complete installed flow. A selected enrolled identity keeps its portable key and certificate while the target issues independent access and approval. Hold the native enrollment lock across source loading, target exchange and promotion. Reject equivalent source/target enrollment endpoint URLs before cleanup, including ignored trailing slashes on root and prefixed paths. Require registration wire version2 on the public endpoint, retain historical receipt decoding, and migrate the existing native approval fixture. Installed acceptance exercises the actual local approval authority, offline source server, lost commit replies and exact resume. Update enrollment documentation and the server release note. Validation after the URL regression repair: 1,855 Python tests passed (43 skipped) using a freshly rebuilt Go client; full Go tests and vet passed; explicit Black check passed over140 targets; complete configured type check passed with zero errors in the existing lint environment. The new focused regression fails against original production bytes and passes after repair. Native platform CI and current remote review remain publication gates. --- client/go-cli/internal/cli/enrollment.go | 15 ++- client/go-cli/internal/cli/enrollment_test.go | 21 ++++ .../internal/identity/enrollment_test.go | 117 ++++++++++++++++++ .../internal/identity/provisional_session.go | 57 ++++++++- .../identity/registration_peer_test.go | 9 +- docs/enrollment.md | 37 +++++- server/news/+portable-enrollment.feature.md | 1 + server/src/arbiter_server/main.py | 20 ++- .../integration/enrollment_terminal_driver.py | 2 + .../integration/test_installed_enrollment.py | 82 ++++++++++-- server/tests/unit/test_enrollment_admin.py | 2 +- server/tests/unit/test_enrollment_requests.py | 20 ++- 12 files changed, 356 insertions(+), 27 deletions(-) create mode 100644 server/news/+portable-enrollment.feature.md diff --git a/client/go-cli/internal/cli/enrollment.go b/client/go-cli/internal/cli/enrollment.go index f5d426c..a332edd 100644 --- a/client/go-cli/internal/cli/enrollment.go +++ b/client/go-cli/internal/cli/enrollment.go @@ -18,14 +18,15 @@ 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 | arbiter enrol resume=SELECTOR") + fmt.Fprintln(stdout, "usage: arbiter enrol url=https://HOST name=NAME [identity=ENROLLED_SELECTOR] | arbiter enrol resume=SELECTOR") + fmt.Fprintln(stdout, "identity reuses a saved portable identity on a different server, which must independently trust its issuer and approve enrollment. Do not combine identity with resume.") 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" && key != "resume") || value == "" || values[key] != "" { + if !ok || (key != "url" && key != "name" && key != "resume" && key != "identity") || value == "" || values[key] != "" { printClientError(stderr, safeClientError("expected: arbiter enrol url=https://HOST name=NAME"), "usage") return 2 } @@ -39,8 +40,8 @@ func runEnrol(args []string, options globalOptions, stdout, stderr io.Writer, return 2 } } - if !resume && (len(values) != 2 || values["url"] == "" || values["name"] == "") { - printClientError(stderr, safeClientError("expected: arbiter enrol url=https://HOST name=NAME"), "usage") + if !resume && (values["resume"] != "" || values["url"] == "" || values["name"] == "" || len(values) != 2 && (len(values) != 3 || values["identity"] == "")) { + printClientError(stderr, safeClientError("expected: arbiter enrol url=https://HOST name=NAME [identity=ENROLLED_SELECTOR]; identity cannot combine with resume"), "usage") return 2 } config, err := ResolveClientConfig(options.Overrides, lookupEnv, homeDir, options) @@ -102,9 +103,9 @@ func runEnrol(args []string, options globalOptions, stdout, stderr io.Writer, printClientError(stderr, err, "enrollment") return 1 } - err = identity.RunProvisional(ctx, filepath.Join(filepath.Dir(configPath), "provisional"), values["url"], values["name"], + err = identity.RunEnrollment(ctx, filepath.Dir(configPath), values["url"], values["name"], values["identity"], func(ctx context.Context, p *identity.Provisional, secret identity.PairingSecret) error { - // RunProvisional holds the one live enrollment owner. A second + // RunEnrollment holds the one live enrollment owner. A second // invocation cannot remove a result while the first sends ready. if err := identity.CleanupPrepared(ctx, preparedDirectory); err != nil { return err @@ -130,6 +131,8 @@ func runEnrol(args []string, options globalOptions, stdout, stderr io.Writer, err = safeClientError(identity.ErrEnrollmentClosed.Error()) } else if errors.Is(err, identity.ErrEnrollmentUnavailable) { err = safeClientError(identity.ErrEnrollmentUnavailable.Error()) + } else if errors.Is(err, identity.ErrEnrollmentSource) { + err = safeClientError(identity.ErrEnrollmentSource.Error()) } printClientError(stderr, err, "enrollment") return 1 diff --git a/client/go-cli/internal/cli/enrollment_test.go b/client/go-cli/internal/cli/enrollment_test.go index 7670169..3770152 100644 --- a/client/go-cli/internal/cli/enrollment_test.go +++ b/client/go-cli/internal/cli/enrollment_test.go @@ -206,6 +206,27 @@ func TestEnrollmentRequiresTerminalAndRejectsSecretArguments(t *testing.T) { } } +func TestEnrollmentIdentityArgumentsAreExclusive(t *testing.T) { + previous := openEnrollmentTerminal + defer func() { openEnrollmentTerminal = previous }() + openEnrollmentTerminal = func() (*os.File, *os.File, error) { + t.Fatal("invalid arguments reached terminal") + return nil, nil, nil + } + for _, args := range [][]string{ + {"identity=private-input", "resume=private-input"}, + {"url=https://localhost", "name=target", "identity=private-input", "resume=private-input"}, + {"url=https://localhost", "name=target", "identity="}, + {"url=https://localhost", "name=target", "identity=a", "identity=b"}, + } { + var out, diagnostics bytes.Buffer + code := Main(append([]string{"enrol"}, args...), &out, &diagnostics, func(string) (string, bool) { return "", false }, func() (string, error) { t.Fatal("invalid arguments read config"); return "", nil }) + if code != 2 || strings.Contains(out.String()+diagnostics.String(), "private-input") { + t.Fatal("unsafe argument handling", code) + } + } +} + func TestEnrollmentInvalidCustodyDoesNotAdvertise(t *testing.T) { var requests atomic.Int32 server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { diff --git a/client/go-cli/internal/identity/enrollment_test.go b/client/go-cli/internal/identity/enrollment_test.go index af1e5ab..2cd8080 100644 --- a/client/go-cli/internal/identity/enrollment_test.go +++ b/client/go-cli/internal/identity/enrollment_test.go @@ -1,15 +1,19 @@ package identity import ( + "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "time" @@ -17,6 +21,119 @@ import ( "github.com/gorilla/websocket" ) +func TestEnrollmentSourceValidationSharesOwnerAndPrecedesCleanup(t *testing.T) { + for _, mode := range []string{"fresh", "reuse", "reuse-other-host", "reuse-other-port", "reuse-other-prefix", "reuse-other-ipv6", "reuse-other-ipv6-zone", "missing", "prepared-only", "expired", "same-server", "same-server-trailing-slash", "same-server-prefix", "same-server-host-case", "same-server-default-port", "same-server-source-default-port", "same-server-padded-default-port", "same-server-padded-source-port", "same-server-padded-nondefault-port", "same-server-ipv6", "same-server-mapped-ipv4"} { + t.Run(mode, func(t *testing.T) { + state := custodyTestDir(t) + age := time.Duration(0) + if mode == "expired" { + age = 366 * 24 * time.Hour + } + original, raw, _ := portableFixture(t, age, 2*365*24*time.Hour) + sourceURL, target := "https://source.invalid", "https://target.invalid" + sameServer := false + switch mode { + case "same-server": + target, sameServer = sourceURL, true + case "same-server-trailing-slash": + target, sameServer = sourceURL+"/", true + case "same-server-prefix": + sourceURL, target, sameServer = sourceURL+"/tenant/", sourceURL+"/tenant", true + case "same-server-host-case": + target, sameServer = "https://SOURCE.invalid", true + case "same-server-default-port": + target, sameServer = sourceURL+":443", true + case "same-server-source-default-port": + sourceURL, target, sameServer = sourceURL+":443", sourceURL, true + case "same-server-padded-default-port": + target, sameServer = sourceURL+":0443", true + case "same-server-padded-source-port": + sourceURL, target, sameServer = sourceURL+":00443", sourceURL, true + case "same-server-padded-nondefault-port": + sourceURL, target, sameServer = sourceURL+":444", sourceURL+":00444", true + case "same-server-ipv6": + sourceURL, target, sameServer = "https://[2001:db8::1]", "https://[2001:0db8:0:0:0:0:0:1]", true + case "same-server-mapped-ipv4": + sourceURL, target, sameServer = "https://127.0.0.1", "https://[::ffff:127.0.0.1]", true + case "reuse-other-host": + target = "https://other.invalid" + case "reuse-other-port": + target = sourceURL + ":444" + case "reuse-other-prefix": + target = sourceURL + "/tenant" + case "reuse-other-ipv6": + sourceURL, target = "https://[2001:db8::1]", "https://[2001:db8::2]" + case "reuse-other-ipv6-zone": + sourceURL, target = "https://[fe80::1%25zoneA]", "https://[fe80::1%25zonea]" + } + original.record.ServerURL = sourceURL + domain := "enrolled" + if mode == "prepared-only" { + domain = "prepared" + } + before := savePortableFixture(t, original, raw, state, domain) + directory := filepath.Join(state, "provisional") + orphan, _, err := NewProvisional("https://abandoned.invalid", "abandoned", time.Now()) + if err != nil { + t.Fatal(err) + } + if err = SaveProvisional(context.Background(), directory, orphan); err != nil { + t.Fatal(err) + } + orphanPath := filepath.Join(directory, orphan.record.Selector+".json") + orphanBefore, err := os.ReadFile(orphanPath) + if err != nil { + t.Fatal(err) + } + selector := original.record.Selector + if mode == "fresh" { + selector = "" + } + if mode == "missing" { + selector = orphan.record.Selector + } + called := false + err = RunEnrollment(context.Background(), state, target, "target", selector, + func(ctx context.Context, p *Provisional, _ PairingSecret) error { + called = true + if p.record.Version != 2 { + t.Fatal("new public enrollment is not v2") + } + if _, e := os.Stat(orphanPath); !errors.Is(e, os.ErrNotExist) { + t.Fatal("abandoned request was not cleaned", e) + } + if e := CleanupProvisional(ctx, directory); !errors.Is(e, ErrProvisionalBusy) { + t.Fatal("source/exchange lost shared owner", e) + } + if strings.HasPrefix(mode, "reuse") && (!samePublicKey(p.portable.Public(), original.portable.Public()) || samePublicKey(p.access.Public(), original.access.Public()) || bytes.Equal(p.recipient.Bytes(), original.recipient.Bytes())) { + t.Fatal("incorrect source or target keys") + } + if _, e := LoadProvisional(directory, p.record.Selector); e != nil { + t.Fatal("new material not saved before exchange", e) + } + return nil + }) + valid := mode == "fresh" || strings.HasPrefix(mode, "reuse") + if sameServer { + valid = false + } + if called != valid || valid && err != nil || !valid && !errors.Is(err, ErrEnrollmentSource) { + t.Fatal("source validation outcome", err) + } + if !valid { + after, e := os.ReadFile(orphanPath) + if e != nil || !bytes.Equal(after, orphanBefore) { + t.Fatal("invalid source cleaned existing state", e) + } + } + after, e := os.ReadFile(filepath.Join(state, domain, original.record.Selector+".json")) + if e != nil || !bytes.Equal(after, before) { + t.Fatal("source state changed", e) + } + }) + } +} + func TestEnrollmentRegistrationAndCarrierBounds(t *testing.T) { for _, mode := range []string{"closed", "redirect", "digest", "oversize", "header", "text", "fragment", "controls", "deadline", "cancel"} { t.Run(mode, func(t *testing.T) { diff --git a/client/go-cli/internal/identity/provisional_session.go b/client/go-cli/internal/identity/provisional_session.go index 9e0448a..e6dc756 100644 --- a/client/go-cli/internal/identity/provisional_session.go +++ b/client/go-cli/internal/identity/provisional_session.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "net/netip" "os" "path/filepath" "strings" @@ -13,6 +14,7 @@ import ( var ErrProvisionalBusy = errors.New("another enrollment owns this provisional directory") var ErrProvisionalCleanupIncomplete = errors.New("provisional cleanup batch completed; retry cleanup before enrollment") var ErrProvisionalUnexpectedContents = errors.New("provisional cleanup made no progress; move unrelated files out of the provisional directory before retrying") +var ErrEnrollmentSource = errors.New("identity must select a valid saved enrolled identity from a different server; keep saved state and choose another source or target") const provisionalLockName = ".enrollment.lock" const provisionalCleanupBatch = 64 @@ -153,7 +155,9 @@ func CleanupProvisional(ctx context.Context, directory string) (result error) { return cleanupProvisional(ctx, root) } -// RunProvisional saves fresh keys before exposing anything to exchange. It +// RunProvisional retains the v1 custody/receipt fixture entry point. The product +// enrollment command uses RunEnrollment for fresh and reused v2 registrations. +// It saves fresh keys before exposing anything to exchange. It // cleans abandoned files first and removes this request when exchange returns. // Exchange must observe cancellation; ownership remains until it actually ends. // Any prepared/activated state must be persisted separately before returning. @@ -165,6 +169,21 @@ func RunProvisional(ctx context.Context, directory, serverURL, name string, func runProvisional(ctx context.Context, directory, serverURL, name string, lifetime time.Duration, exchange func(context.Context, *Provisional, PairingSecret) error, +) (result error) { + return runProvisionalSource(ctx, directory, serverURL, name, lifetime, nil, exchange) +} + +// RunEnrollment uses the same owner for source validation, cleanup and the +// entire exchange. An empty selector generates fresh v2 portable material. +func RunEnrollment(ctx context.Context, stateRoot, serverURL, name, sourceSelector string, + exchange func(context.Context, *Provisional, PairingSecret) error, +) error { + return runProvisionalSource(ctx, filepath.Join(stateRoot, "provisional"), serverURL, name, + 10*time.Minute, &sourceSelector, exchange) +} + +func runProvisionalSource(ctx context.Context, directory, serverURL, name string, lifetime time.Duration, + sourceSelector *string, exchange func(context.Context, *Provisional, PairingSecret) error, ) (result error) { if exchange == nil { return errCustody @@ -174,11 +193,45 @@ func runProvisional(ctx context.Context, directory, serverURL, name string, life return err } defer func() { result = errors.Join(result, lock.Close()) }() + var source *PortableIdentity + if sourceSelector != nil && *sourceSelector != "" { + source, err = LoadPortableIdentity(filepath.Dir(root), *sourceSelector, time.Now().UTC()) + if err != nil { + return ErrEnrollmentSource + } + sourceEndpoint, sourceEndpointErr := enrollmentURL(source.serverURL, "/api/v1/enrollment/requests") + targetEndpoint, targetEndpointErr := enrollmentURL(serverURL, "/api/v1/enrollment/requests") + if sourceEndpointErr == nil && targetEndpointErr == nil { + endpointHost := func(host string) string { + if address, err := netip.ParseAddr(host); err == nil { + return address.Unmap().String() + } + return strings.ToLower(host) + } + endpointPort := func(port string) string { + if port == "" { + port = "443" + } + // URL parsing already requires decimal port syntax. + return strings.TrimLeft(port, "0") + } + if endpointHost(sourceEndpoint.Hostname()) == endpointHost(targetEndpoint.Hostname()) && + endpointPort(sourceEndpoint.Port()) == endpointPort(targetEndpoint.Port()) && sourceEndpoint.Path == targetEndpoint.Path { + return ErrEnrollmentSource + } + } + } if err := cleanupProvisional(ctx, root); err != nil { return err } now := time.Now().UTC() - p, secret, err := NewProvisional(serverURL, name, now) + var p *Provisional + var secret PairingSecret + if sourceSelector == nil { + p, secret, err = NewProvisional(serverURL, name, now) + } else { + p, secret, err = NewProvisionalFromIdentity(serverURL, name, now, source) + } if err != nil { return err } diff --git a/client/go-cli/internal/identity/registration_peer_test.go b/client/go-cli/internal/identity/registration_peer_test.go index 697cf6c..f195e08 100644 --- a/client/go-cli/internal/identity/registration_peer_test.go +++ b/client/go-cli/internal/identity/registration_peer_test.go @@ -32,7 +32,10 @@ func TestPythonRegistrationPeer(t *testing.T) { CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} defer client.CloseIdleConnections() root := filepath.Join(custodyTestDir(t), "state") - err = RunProvisional(context.Background(), root, "https://localhost", "Agent Smith", + if err := os.Mkdir(root, 0700); err != nil { + t.Fatal(err) + } + err = RunEnrollment(context.Background(), root, "https://localhost", "Agent Smith", "", func(ctx context.Context, p *Provisional, _ PairingSecret) error { advertisement, err := p.Public() if err != nil { @@ -71,7 +74,7 @@ func TestPythonRegistrationPeer(t *testing.T) { if err != nil { t.Fatal("Go registration peer failed", err) } - assertOnlyProvisionalLock(t, root) + assertOnlyProvisionalLock(t, filepath.Join(root, "provisional")) } // Real A/B servers supply the production HTTP/Noise/TLS exchange. Only local @@ -99,7 +102,7 @@ func TestPythonPortableEnrollmentPeer(t *testing.T) { ctx := context.Background() root := filepath.Join(custodyTestDir(t), "state") err = WithEnrollmentOwner(ctx, root, func() error { - original, secret, e := NewProvisional(config.A, "source", time.Now()) + original, secret, e := NewProvisionalFromIdentity(config.A, "source", time.Now(), nil) if e != nil { return e } diff --git a/docs/enrollment.md b/docs/enrollment.md index a831d76..0785c1c 100644 --- a/docs/enrollment.md +++ b/docs/enrollment.md @@ -3,7 +3,8 @@ Fresh client enrollment uses the existing `arbiter enrol` command and local operator approval. Registering an owner issuer establishes public trust only; it does not enroll a client or grant account access. Selecting an existing -portable identity for another server remains unavailable in this revision. +portable identity for another server requires the target's separate issuer trust +and enrollment approval. Use `arbiter-server authority owner-issuer` through the same authenticated local OS account as server administration. Commands work while the instance is stopped @@ -46,11 +47,39 @@ The portable identity codec groundwork validates a source only from complete time without contacting the source server or requiring its access certificate to remain valid. Saved receipt recovery checks portable evidence at issuance; this does not permit an expired portable identity to start another enrollment. -The public CLI continues to create fresh identities at this intermediate cut. +The CLI creates fresh portable material unless an enrolled source is selected. The internal portable enrollment path now keeps a locally selected agent identity through target preparation and atomic activation. Target approval binds the exact registered issuer revision; removal and re-registration require a new approval. Existing target bindings, including denied or inactive enrollments, require -owner-approved repair. Public registration and CLI source selection remain on the -fresh enrollment path until the final portable-enrollment cutover. +owner-approved repair. + +To enroll a saved portable identity on another server, use the same local config +directory and select its enrolled record: + +```sh +arbiter --config-dir STATE enrol url=https://SERVER-B name=NAME identity=SELECTOR +``` + +The source must be a complete record under `STATE/enrolled/`. Its portable +certificate must still be valid and its private key must match. Source validation +runs under the enrollment lock before cleanup or registration. Missing or invalid +sources leave saved state intact. The source server can be offline. B receives +fresh access and recipient keys and a separate enrollment ID; the portable key, +certificate and agent ID stay the same. The server-local enrollment IDs may differ. + +Before approving on B, register the exact public owner issuer from A using the +commands above. Then open B's enrollment window and use the existing local +enrollment list/approve commands and deliberate terminal secret handoff. Issuer +registration alone grants no access. A denial on B cannot be bypassed by selecting +the identity again, and A's access certificate does not authenticate to B. + +Do not combine `identity=...` with `resume=...`. After a lost reply or failed local +promotion, keep saved state and run `arbiter enrol resume=TARGET_SELECTOR` using +the same config directory. This retrieves the exact committed target result; it +does not start another enrollment. Existing committed v1 records remain recoverable. + +New public enrollment requires v2 on both client and server. Update older clients +instead of deleting identities or retrying the old wire format. Ordinary +authenticated API access remains future milestone 3 work. diff --git a/server/news/+portable-enrollment.feature.md b/server/news/+portable-enrollment.feature.md new file mode 100644 index 0000000..1d66f0b --- /dev/null +++ b/server/news/+portable-enrollment.feature.md @@ -0,0 +1 @@ +Enroll an existing saved portable identity on another server with `arbiter enrol url=... name=... identity=SELECTOR`. The target server must register the exact owner issuer and independently approve the request. New public enrollment uses v2; update older clients and keep saved identity state. Existing committed enrollment remains recoverable with `arbiter enrol resume=SELECTOR`. diff --git a/server/src/arbiter_server/main.py b/server/src/arbiter_server/main.py index 99f72db..2bdb0fd 100644 --- a/server/src/arbiter_server/main.py +++ b/server/src/arbiter_server/main.py @@ -2317,7 +2317,12 @@ def enrollment_accounts() -> set[tuple[str, str]]: def enrollment_control( command: str, arguments: dict[str, object] ) -> dict[str, object]: - return enrollment_requests.control(command, arguments, enrollment_accounts()) + return enrollment_requests.control( + command, + arguments, + enrollment_accounts(), + root=_server_data_root(_storage_config(cfg)) / "authority", + ) @asynccontextmanager async def lifespan(_app: object) -> AsyncGenerator[None, None]: @@ -2358,7 +2363,18 @@ def register(bridge: LoopBridge) -> object: REGISTRATION_TIMEOUT, ) ) - pending = enrollment_requests.register(body) + try: + pending = enrollment_requests.register(body, version=2) + except OperationError as exc: + if exc.code != "validation_error": + raise + raise OperationError( + "validation_error", + "Invalid enrollment format. Update the client for v2 enrollment; " + "keep saved identities and use resume for a previously committed enrollment.", + transient="no", + replay="safe", + ) from exc return _native_response( { "selector": pending.advertisement.selector, diff --git a/server/tests/integration/enrollment_terminal_driver.py b/server/tests/integration/enrollment_terminal_driver.py index a75521e..7dc3db3 100644 --- a/server/tests/integration/enrollment_terminal_driver.py +++ b/server/tests/integration/enrollment_terminal_driver.py @@ -89,6 +89,8 @@ def enter(): "url=" + config["url"], "name=Installed CLI", ] + if config.get("identity"): + command.append("identity=" + config["identity"]) process: subprocess.Popen[bytes] | None = None try: process = subprocess.Popen( diff --git a/server/tests/integration/test_installed_enrollment.py b/server/tests/integration/test_installed_enrollment.py index 0e2b5a7..2b74389 100644 --- a/server/tests/integration/test_installed_enrollment.py +++ b/server/tests/integration/test_installed_enrollment.py @@ -5,6 +5,7 @@ from contextlib import closing, contextmanager import json from pathlib import Path +import os import select import socket import socketserver @@ -20,6 +21,9 @@ from starlette.applications import Starlette from arbiter_server.authority import initialize_authority +from arbiter_server.enrollment_receipt import result_fields +from arbiter_server.local_control import enrollment_request +from arbiter_server.owner_issuers import control as issuer_control from arbiter_server.main import _generate_self_signed_tls_certificate from server.tests.integration.test_cli_entrypoint import ( ClientCommand, @@ -72,7 +76,7 @@ def handle(self): assert not thread.is_alive() -@pytest.mark.parametrize("mode", ["ok", "lost-reply", "promotion-failure"]) +@pytest.mark.parametrize("mode", ["ok", "lost-reply", "promotion-failure", "reuse"]) def test_installed_enrollment_and_resume( tmp_path, private_windows_test_parent, @@ -80,8 +84,38 @@ def test_installed_enrollment_and_resume( monkeypatch, mode, ): + source = None + if mode == "reuse": + source_root = tmp_path / "source" + source_root.mkdir(mode=0o777 if os.name == "nt" else 0o700) + source = installed_journey( + source_root, packaged_arbiter_client_command, monkeypatch, "ok" + ) + # The first journey has closed A and its proxy before B is started. + with pytest.raises(OSError): + socket.create_connection(("127.0.0.1", source["port"]), timeout=0.5) + tmp_path = tmp_path / "target" + tmp_path.mkdir(mode=0o777 if os.name == "nt" else 0o700) + mode = "lost-reply" + installed_journey( + tmp_path, packaged_arbiter_client_command, monkeypatch, mode, source + ) + + +def installed_journey(tmp_path, command, monkeypatch, mode, source=None): root = tmp_path / "server" / "authority" - initialize_authority(root, recovery_file=tmp_path / "operator.json") + initialize_authority( + root, + recovery_file=(source["base"] if source else tmp_path) / "operator.json", + reuse_recovery_file=source is not None, + ) + if source: + _, fields = result_fields( + base64.b64decode(source["saved"]["result"], validate=True) + ) + issuer_control( + root, "register", dict(certificate_der=base64.b64encode(fields[2]).decode()) + ) app = cast(Starlette, _client(tmp_path)._app) responder, requests = app.state.pairing_responder, app.state.enrollment_requests original_activate = responder.activate @@ -101,8 +135,9 @@ async def activate(*args): key_path=key, names=("127.0.0.1",), ) - directory = tmp_path / "installed-custody" - directory.mkdir(mode=0o700) + directory = source["directory"] if source else tmp_path / "installed-custody" + if not source: + directory.mkdir(mode=0o700) marker = directory / "enrolled" if mode == "promotion-failure": marker.write_text("preserve unrelated existing file") @@ -132,7 +167,8 @@ def approve(): for p in requests.pending() if p.advertisement.selector == handoff["selector"] ) - requests.control( + enrollment_request( + root, "approve", dict( selector=pending.advertisement.selector, @@ -141,17 +177,17 @@ def approve(): psk=handoff["secret"], grants=[], ), - set(), ) stream.write(b"true\n") stream.flush() config = dict( - command=str(packaged_arbiter_client_command.command), + command=str(command.command), directory=str(directory), url=f"https://127.0.0.1:{proxy_port}", control=listener.getsockname()[1], recover=mode == "promotion-failure", + identity=source["selector"] if source else "", ) with ThreadPoolExecutor(max_workers=1) as pool: owner = pool.submit(approve) @@ -202,14 +238,36 @@ def approve(): assert "Enrollment activated and saved" in resumed.stdout enrolled = directory / "enrolled" / (selector + ".json") assert enrolled.read_bytes() == before - assert list((directory / "enrolled").glob("*.json")) == [enrolled] + expected = [enrolled] + if source: + expected.append(directory / "enrolled" / (source["selector"] + ".json")) + assert set((directory / "enrolled").glob("*.json")) == set(expected) assert not list((directory / "prepared").glob("*.json")) saved = json.loads(before) + assert saved["provisional"]["version"] == 2 assert set(saved["provisional"]) >= { "access_key_pkcs8", "portable_key_pkcs8", "recipient_key_pkcs8", } + if source: + original = directory / "enrolled" / (source["selector"] + ".json") + assert original.read_bytes() == source["raw"] + for key in ("access_key_pkcs8", "recipient_key_pkcs8"): + assert saved["provisional"][key] != source["saved"]["provisional"][key] + assert ( + saved["provisional"]["portable_key_pkcs8"] + == source["saved"]["provisional"]["portable_key_pkcs8"] + ) + a_ids, a_fields = result_fields( + base64.b64decode(source["saved"]["result"], validate=True) + ) + b_ids, b_fields = result_fields( + base64.b64decode(saved["result"], validate=True) + ) + assert a_ids[1:3] == b_ids[1:3] # Owner and portable agent identity. + assert a_ids[0] != b_ids[0] and a_ids[3] != b_ids[3] + assert a_fields[2:4] == b_fields[2:4] with closing(sqlite3.connect(database(root))) as db: rows = db.execute("SELECT prepared FROM enrollments").fetchall() assert len(rows) == 1 @@ -223,3 +281,11 @@ def approve(): timeout=5, ) as health: assert health.status == 200 and json.load(health) == {"status": "ok"} + return dict( + directory=directory, + selector=selector, + saved=saved, + raw=before, + base=tmp_path, + port=proxy_port, + ) diff --git a/server/tests/unit/test_enrollment_admin.py b/server/tests/unit/test_enrollment_admin.py index 4a2a847..fd3c1ef 100644 --- a/server/tests/unit/test_enrollment_admin.py +++ b/server/tests/unit/test_enrollment_admin.py @@ -354,7 +354,7 @@ async def exercise(): await _asgi_request(app, "POST", ROUTE, body_chunks=[b"{}"]) ).status_code == 403 assert (await command("open"))["open"] is True - advertised = advertisement() + advertised = dict(advertisement(), version=2, portable_identity=None) advertised["selector"] = "-" + "A" * 21 raw = json.dumps(advertised).encode() response = await _asgi_request(app, "POST", ROUTE, body_chunks=[raw]) diff --git a/server/tests/unit/test_enrollment_requests.py b/server/tests/unit/test_enrollment_requests.py index edef982..995f0ad 100644 --- a/server/tests/unit/test_enrollment_requests.py +++ b/server/tests/unit/test_enrollment_requests.py @@ -281,7 +281,9 @@ async def exercise(): extra_headers=[(b"content-length", b"1")], ) assert response.status_code == status - raw = json.dumps(advertisement()).encode() + raw = json.dumps( + dict(advertisement(), version=2, portable_identity=None) + ).encode() response = await _asgi_request( app, "POST", ROUTE, body_chunks=[raw[:10], raw[10:]] ) @@ -293,6 +295,22 @@ async def exercise(): asyncio.run(exercise()) +def test_public_registration_rejects_old_wire_without_allocating(tmp_path): + app, requests = registration_app(tmp_path) + requests.open_window() + + async def exercise(): + response = await _asgi_request( + app, "POST", ROUTE, body_chunks=[json.dumps(advertisement()).encode()] + ) + assert response.status_code == 422 + message = response.json()["error"]["message"] + assert "Update the client" in message and "keep saved identities" in message + assert requests.pending() == () + + asyncio.run(exercise()) + + def test_registration_admits_before_body_and_cancellation_recovers(tmp_path): app, requests = registration_app(tmp_path, max_blocking_workers=1) requests.open_window()