diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b0efb90..c41a744e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,11 +1,12 @@ # SPDX-FileCopyrightText: 2026 Hari Srinivasan +# SPDX-FileCopyrightText: 2026 Lokesh # SPDX-License-Identifier: Apache-2.0 name: CI on: pull_request: - branches: [main, "release/**"] + branches: [main, RC, "release/**"] merge_group: types: [checks_requested] push: @@ -27,6 +28,8 @@ jobs: outputs: code: ${{ steps.filter.outputs.code }} workflows: ${{ steps.filter.outputs.workflows }} + relay: ${{ steps.filter.outputs.relay }} + e2ee: ${{ steps.filter.outputs.e2ee }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4 @@ -35,6 +38,7 @@ jobs: filters: | code: - 'packages/**' + - 'services/**' - 'distribution/**' - 'scripts/**' - 'package.json' @@ -46,6 +50,20 @@ jobs: - '.github/workflows/ci.yml' workflows: - '.github/workflows/**' + e2ee: + - 'packages/e2ee/**' + - '.github/workflows/ci.yml' + relay: + - 'services/relay/**' + - 'services/control-plane/**' + - 'packages/daemon/src/remote-*.ts' + - 'packages/daemon/test/remote-*.test.ts' + - 'packages/sdk/src/remote-*.ts' + - 'packages/sdk/test/remote-*.test.ts' + - 'packages/protocol/src/remote-transport.ts' + - 'packages/protocol/test/fixtures/remote-transport-v1.json' + - 'packages/protocol/test/fixtures/internal-relay-api-v1.json' + - '.github/workflows/ci.yml' quality: name: Build and test @@ -74,6 +92,84 @@ jobs: if: needs.changes.outputs.code == 'true' run: pnpm check + e2ee: + name: E2EE core + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: changes + steps: + - name: No E2EE changes + if: needs.changes.outputs.e2ee != 'true' + run: echo "No E2EE changes detected; required check reports success." + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + if: needs.changes.outputs.e2ee == 'true' + - name: Install pinned Rust audit tools + if: needs.changes.outputs.e2ee == 'true' + run: | + cargo install cargo-audit --version 0.22.2 --locked + cargo install cargo-deny --version 0.20.2 --locked + - name: Test, lint, and audit the E2EE core + if: needs.changes.outputs.e2ee == 'true' + working-directory: packages/e2ee + run: | + cargo test --locked + cargo fmt --check + cargo clippy --locked --all-targets -- -D warnings + cargo audit --deny warnings + cargo deny check + + relay: + name: Relay build and test + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: changes + steps: + - name: No relay changes + if: needs.changes.outputs.relay != 'true' + run: echo "No relay changes detected; required check reports success." + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + if: needs.changes.outputs.relay == 'true' + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + if: needs.changes.outputs.relay == 'true' + with: + standalone: true + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + if: needs.changes.outputs.relay == 'true' + with: + node-version: 24 + cache: pnpm + - name: Install Node dependencies + if: needs.changes.outputs.relay == 'true' + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Set up Erlang and Elixir + if: needs.changes.outputs.relay == 'true' + uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 + with: + otp-version: 27.3.4.17 + elixir-version: 1.18.5 + - name: Restore Mix caches + if: needs.changes.outputs.relay == 'true' + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: | + services/relay/deps + services/relay/_build + ~/.mix + key: relay-${{ runner.os }}-${{ hashFiles('services/relay/mix.lock') }} + - name: Fetch relay dependencies + if: needs.changes.outputs.relay == 'true' + working-directory: services/relay + run: mix deps.get --check-locked + - name: Format, compile, test, analyze, and audit relay + if: needs.changes.outputs.relay == 'true' + working-directory: services/relay + run: mix format --check-formatted && mix compile --warnings-as-errors && mix test && mix credo --strict && mix dialyzer && mix deps.audit + - name: Run disposable hosted-path integration + if: needs.changes.outputs.relay == 'true' + env: + AXL_RUN_HOSTED_PATH_INTEGRATION: '1' + run: pnpm --filter @axl/daemon test + licenses: name: REUSE licenses runs-on: ubuntu-latest diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b0fc0de1..7895d493 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -7,7 +7,7 @@ on: push: branches: [main, "release/**"] pull_request: - branches: [main, "release/**"] + branches: [main, RC, "release/**"] merge_group: types: [checks_requested] schedule: diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index de8c6f08..e369cb29 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -5,7 +5,7 @@ name: Dependency Review on: pull_request: - branches: [main, "release/**"] + branches: [main, RC, "release/**"] merge_group: types: [checks_requested] diff --git a/.gitignore b/.gitignore index 5da15464..b58a8772 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,14 @@ # SPDX-FileCopyrightText: 2026 Hari Srinivasan +# SPDX-FileCopyrightText: 2026 Lokesh # SPDX-License-Identifier: Apache-2.0 node_modules/ dist/ +target/ .release/ coverage/ +services/relay/_build/ +services/relay/deps/ docs/screenshots/ *.tsbuildinfo .env diff --git a/AGENTS.md b/AGENTS.md index 0173542f..49b0d412 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,5 @@ + # Axl development guide diff --git a/CODE_STRUCTURE.md b/CODE_STRUCTURE.md index 6461c9d8..22cbd674 100644 --- a/CODE_STRUCTURE.md +++ b/CODE_STRUCTURE.md @@ -7,7 +7,7 @@ Status: working plan. This document accompanies [ROADMAP.md](ROADMAP.md) and [OPEN_SOURCE.md](OPEN_SOURCE.md). -Updated: 2026-08-28 +Updated: 2026-09-13 ## 1. Keep everything in one repository @@ -29,9 +29,11 @@ Codex offers a useful contrast. Its CLI and Rust core share a repository, while ## 2. Languages -- Use **TypeScript** for the kernel, protocol, daemon, adoption compiler, terminal client, web client, and extensions. It matches the ecosystems and standards Axl integrates with. +- Use **TypeScript** for the kernel, protocol, daemon, adoption compiler, terminal client, web client, extensions, and hosted control plane. It matches the ecosystems and standards Axl integrates with. +- Use **Elixir/OTP only for the hosted ciphertext relay** under `services/relay/`. The relay is a bounded transport process and must not own daemon, RPC, account, persistence, or cryptographic behavior. - Use **Kotlin with Jetpack Compose** for Android and **Swift with SwiftUI** for iOS. Choose protocol code generation when the first of these clients is built. -- Do not add another application language. Tooling should use TypeScript or POSIX shell. +- A narrowly scoped **Rust endpoint-E2EE core** is the only approved exception. It may be added in Session 40 after human approval and merge of [`docs/architecture/remote-e2ee-openmls.md`](docs/architecture/remote-e2ee-openmls.md) and its dependency and license decision. Successful browser/WASM compilation is sufficient to begin the platform-neutral core. Session 50 adds and tests private Node and browser/WASM binding surfaces and artifacts as specified in [`docs/architecture/e2ee-platform-bindings.md`](docs/architecture/e2ee-platform-bindings.md). Build and fixture support does not establish production storage readiness. Swift, Kotlin, C ABI, JNI, generated public SDKs, mobile stack selection, iOS and Android applications, and production mobile secure storage remain in Phase 13. Remote web stays disabled until mandatory browser persistence tests pass and a reviewed rollback-anchor design exists. The core must not absorb daemon, SDK, relay, account, authorization, persistence-engine, or presentation behavior. +- Do not add another application language outside that reviewed exception. Other tooling should use TypeScript or POSIX shell. ## 3. Repository layout @@ -50,7 +52,11 @@ axl/ web/ # web client ui/ # shared presentation tokens and React renderers sdk/ # shared TypeScript client SDK when multiple clients need it + e2ee/ # proposed Rust endpoint-E2EE core; create only after its architecture gate extensions/ # first-party extensions, one package per feature (roadmap ยง2.9) + services/ + control-plane/ # separately deployable TypeScript hosted control plane + relay/ # separately deployable Elixir/OTP opaque WebSocket relay apps/ android/ # Gradle project using the generated Kotlin SDK ios/ # Xcode project using the generated Swift SDK @@ -65,8 +71,11 @@ These rules keep package ownership clear: - `packages/protocol` has no runtime dependencies. - `packages/kernel` depends only on `packages/protocol` and Node.js built-ins. - First-party extensions use the same public extension API as third-party extensions. -- `packages/protocol` is the only source of wire-format truth. TypeScript definitions stay authoritative until a non-TypeScript client creates a real need for generation. +- `packages/protocol` is the only source of wire-format truth. TypeScript definitions stay authoritative until a non-TypeScript presentation client creates a real need for generation. The Elixir relay implements only its narrow transport and internal-service framing against canonical byte and JSON fixtures; it is not a daemon-protocol client. - Apps use the public protocol SDK rather than package internals. +- `services/control-plane` may depend on `packages/protocol`. It owns hosted account, installation, device, ticket, opaque OpenMLS KeyPackage and Welcome rendezvous, grant, upload-reservation, quota, and security-audit mutation. It never owns private E2EE state, decrypted Welcome contents, MLS group state, or application plaintext. Identity providers, persistent datastores, and production service authentication stay behind injected interfaces until approved. +- `services/relay` consumes versioned language-neutral fixtures. It must not import TypeScript package internals, access the control-plane datastore, decrypt envelopes, interpret daemon RPC, persist canonical history, or store attachment bodies. It calls the authenticated control-plane admission API once per new connection and accepts authenticated revocation notifications. +- The control plane and relay are separate deployables. They share no private implementation imports and communicate only through their versioned internal HTTP contract. - `packages/runtime` assembles providers, tools, extensions, sandboxing, and the authoritative daemon without importing a presentation client. - `packages/tui` is a daemon client projection. It does not construct the runtime or depend at runtime on sandbox, kernel, or concrete extension implementations. It may depend on the dependency-free public `@axl/extension-api` for client-local presentation customization. - `packages/ui` owns shared presentation tokens and React renderers. It may depend only on `packages/sdk` and presentation libraries. It owns no daemon or process authority. @@ -80,7 +89,7 @@ The protocol package owns the contract between the daemon and every client. - TypeScript definitions are authoritative while all clients use TypeScript. - A schema change requires prior design discussion and compatibility notes. -- The first Swift or Kotlin client triggers a decision on the schema language and generator. +- The first Swift or Kotlin client triggers a decision on the schema language, binding mechanism, and generator. Session 50 adds no Swift, Kotlin, C ABI, or JNI surface. The relay's bounded outer-frame parser does not trigger client SDK generation because it does not parse daemon RPC or canonical events. - Generated SDKs then ship through their native package systems so external and in-tree clients use the same contract. ## 5. Independent implementation @@ -91,8 +100,8 @@ Any approved adaptation records its source, commit, and changes in an SPDX heade ## 6. Build tools -- Use pnpm workspaces for package management. Add a task runner with remote caching only when repository scale justifies it. -- Keep Gradle and Xcode native. CI coordinates the build systems but the JavaScript toolchain does not wrap them. +- Use pnpm workspaces for TypeScript package and service management. Add a task runner with remote caching only when repository scale justifies it. +- Keep Mix native for `services/relay`, and keep Gradle and Xcode native. CI coordinates the build systems but the JavaScript toolchain does not wrap them. - Version packages in `packages/` together. Mobile apps keep their own store versions. Bazel would add more contributor cost than value at the current scale. @@ -110,6 +119,8 @@ Bazel would add more contributor cost than value at the current scale. Every required check reports a result. Path filters decide whether the full job runs or a small gate job reports that no relevant files changed. - Kernel, protocol, and SDK changes run all builds, including both mobile apps. +- Control-plane changes run the root TypeScript checks and package-boundary checks. +- Relay, control-plane, or shared remote-transport changes run Mix formatting, compilation with warnings as errors, tests, Credo, Dialyzer, dependency audit, the disposable cross-runtime hosted-path test, package-boundary checks, and REUSE. - App-only changes run that app and lint checks. - Documentation and plan changes run formatting, link checking, and REUSE checks. - CodeQL, Gitleaks, and dependency review run for every merge candidate. diff --git a/NOTICE b/NOTICE index 787900d1..f85c0680 100644 --- a/NOTICE +++ b/NOTICE @@ -24,3 +24,9 @@ Its JavaScript package includes an MIT notice, and its platform launcher package are distributed under BSD-3-Clause. Those notices remain in the installed dependency packages. Axl invokes the launcher as an external process and does not copy or modify its source. + +Axl's endpoint E2EE core uses OpenMLS 0.9.0, `openmls_basic_credential` 0.6.0, +and `openmls_libcrux_crypto` 0.4.0 as unmodified external dependencies under MIT. +Their dependency graph includes the unmodified `hpke-rs`, `hpke-rs-crypto`, and +`hpke-rs-libcrux` 0.7.0 crates under MPL-2.0. Applicable license texts and notices +remain in Cargo source distributions and must be included in distributed packages. diff --git a/REUSE.toml b/REUSE.toml index 58e69673..a7170140 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -1,4 +1,5 @@ # SPDX-FileCopyrightText: 2026 Hari Srinivasan +# SPDX-FileCopyrightText: 2026 Lokesh # SPDX-License-Identifier: Apache-2.0 version = 1 @@ -11,7 +12,6 @@ path = [ "LICENSES/Apache-2.0.txt", "biome.json", "distribution/npm/package.json", - "package.json", "packages/ai/tsconfig.build.json", "packages/ai/tsconfig.json", "packages/daemon/tsconfig.build.json", @@ -42,6 +42,14 @@ path = [ SPDX-FileCopyrightText = "2026 Hari Srinivasan" SPDX-License-Identifier = "Apache-2.0" +[[annotations]] +path = ["package.json"] +SPDX-FileCopyrightText = [ + "2026 Hari Srinivasan", + "2026 Lokesh", +] +SPDX-License-Identifier = "Apache-2.0" + [[annotations]] path = [ "packages/ai/package.json", @@ -60,6 +68,27 @@ SPDX-FileCopyrightText = [ ] SPDX-License-Identifier = "Apache-2.0" +[[annotations]] +path = [ + "packages/protocol/test/fixtures/internal-relay-api-v1.json", + "packages/protocol/test/fixtures/remote-transport-v1.json", + "services/control-plane/package.json", + "services/control-plane/tsconfig.build.json", + "services/control-plane/tsconfig.json", + "services/relay/.tool-versions", + "services/relay/mix.lock", +] +SPDX-FileCopyrightText = "2026 Lokesh" +SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = [ + "packages/e2ee/Cargo.lock", + "packages/e2ee/fixtures/v1/*.tls", +] +SPDX-FileCopyrightText = "2026 VishnuM449" +SPDX-License-Identifier = "Apache-2.0" + [[annotations]] path = ["NOTICE"] SPDX-FileCopyrightText = [ diff --git a/ROADMAP.md b/ROADMAP.md index f7d96246..59a5e011 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ Status: living product plan and delivery snapshot. -Updated: 2026-09-12 +Updated: 2026-09-16 This document records product intent, candidate designs, and a proposed implementation sequence. It is not normative agent instructions or the sole source of truth. Future features, ordering, languages, frameworks, and technology choices remain plans until adopted by current code or a focused architecture or policy document. @@ -1361,6 +1361,10 @@ Requirements: The current mobile plan favors SwiftUI on iOS and Jetpack Compose on Android because native code supports Live Activities, Android foreground services, notification actions, widgets, share sheets, and efficient streaming text. This is not a binding stack decision. Choose the implementation when mobile work begins and its requirements are concrete. +Remote transport uses pairwise application-level E2EE in addition to TLS. The prior PQXDH plus Triple Ratchet direction is superseded. The selected direction is one two-member OpenMLS group per remote device and daemon installation using revision 1 of the Axl-private `axl-e2ee-mls-pq-v1` profile. Revision 1 pins OpenMLS 0.9.0, `openmls_libcrux_crypto` 0.4.0, suite value `0x004e`, and the upstream `XWingDraft06` implementation. It makes no IETF draft-06 interoperability claim. The daemon is the sole commit creator; a phone generates its own private replacement leaf and sends a signed self-Update proposal. The profile and candidate graph may enter core implementation after [`docs/architecture/remote-e2ee-openmls.md`](docs/architecture/remote-e2ee-openmls.md) and its dependency decision receive human approval and merge into `RC`. Browser/WASM persistence remains mandatory Session 50 work. Production release remains blocked on transactional storage tests, cross-platform fixtures, browser and mobile measurements, packaging, and independent security review. Transport code treats every MLS application message, proposal, commit, receipt, KeyPackage, and Welcome as bounded opaque bytes. The relay never imports the E2EE implementation or decrypts traffic. The proposed remote action-binding and approval rules are in [`docs/architecture/remote-permission-authorization.md`](docs/architecture/remote-permission-authorization.md); that draft does not enable remote approval. + +The managed path uses two separately deployable services: the TypeScript control plane owns hosted state and one-use admission, while the Elixir/OTP relay owns bounded in-memory WebSocket routing. The daemon remains the command and session authority. Transport proof uses only disposable sessions, a deterministic fake provider, opaque fixtures, and a test-only fake E2EE adapter. Ordinary-session steering and remote permission approval remain disabled until the E2EE and release gates pass. + #### 16.4 Headless and automation The same daemon serves non-interactive callers: @@ -1573,7 +1577,7 @@ Phases 0 through 4 are complete. Selected TUI, web-tool, Agent Skills, and MCP w 8. Add one focused runnable check for every non-trivial behavior. 9. Do not implement a later phase merely to prepare for hypothetical use. Preserve the seam and stop. 10. Complete security prerequisites before activating the feature that depends on them. -11. The current plan defers protocol code generation until a second implementation language creates a real need. +11. The current plan defers Swift and Kotlin bindings, daemon-protocol code generation, mobile secure storage, and mobile application stack selection until Phase 13 and a real native client creates the need. Session 50 is limited to the Rust pairing contract and Node and browser/WASM endpoint bindings. ### Foundational dependency decisions @@ -2109,7 +2113,7 @@ Child sessions remain inspectable, budgeted, cancellable, policy-narrowed, repla The local web client and only the TypeScript SDK, wire-protocol, transport, security, workspace, packaging, and transport-neutral browser boundaries required for it are brought forward as an explicit exception to phase ordering. This work may proceed while the current dogfood follow-up remains incomplete, but those prerequisites still block expanded dogfooding of credentialed or untrusted capabilities. Unavailable features remain explicitly unsupported. This exception does not bring forward remote accounts, pairing, encrypted relay transport, hosted-service deployment, the session viewer, media roles, public SDK publication, multi-language generation, cloud placement, or unrelated protocol work, and it does not mark Phase 9 complete. -The current plan defers public or multi-language SDKs until a real second client creates the need. +The current plan defers public or multi-language daemon SDKs until a real second client creates the need. Session 50 adds no Swift, Kotlin, C ABI, JNI, generated SDK, or mobile application code. #### Wire protocol @@ -2129,7 +2133,7 @@ The current plan defers public or multi-language SDKs until a real second client - [ ] Make in-tree clients consume the public SDK surface. - [ ] Publish the SDK only when an external consumer exists. - [ ] Choose TypeSpec, Protobuf, or another generator only when the first non-TypeScript client creates a concrete need. -- [ ] Keep Swift and Kotlin generation in Phase 13 with mobile implementation. +- [ ] Keep Swift and Kotlin bindings, daemon-protocol generation, mobile secure storage, and mobile implementation in Phase 13. Session 50 is limited to the Rust pairing contract and Node and browser/WASM endpoint bindings. #### Web client @@ -2328,7 +2332,7 @@ The shared remote-connectivity and remote-web subsections are a scoped sequencin #### Shared remote connectivity -- [ ] Write and approve the pairing and remote-transport security RFC before implementing remote access. +- [ ] Approve and merge revision 1 of the Axl-private OpenMLS security RFC before Session 40 begins. Core implementation may then proceed under its exact pinning and audit rules; this does not enable remote access or approve production release. - [ ] Add revocable device identities and observer, steering, approval, and session-management grants that the daemon maps to protocol capabilities. - [ ] Add bounded encrypted application frames, replay protection, reconnect, and published protocol test vectors. - [ ] Add the daemon's opt-in outbound connection and a ciphertext-only hosted relay with no public daemon port. @@ -2341,16 +2345,58 @@ The shared remote-connectivity and remote-web subsections are a scoped sequencin - [ ] Serve a protocol-independent static account and installation shell from `app.axldev.ai`. - [ ] Retain immutable client bundles by compatible web-asset and wire version rather than placing multiple protocol implementations in one bundle. -- [ ] Store a non-extractable browser device key through an IndexedDB adapter and require re-pairing when it is lost. +- [ ] In Session 50, exercise browser OpenMLS identity and group state through the reviewed transactional browser adapter and require re-pairing when protected state is lost. Browser execution and durable-storage tests must pass before remote web is enabled, but they do not block Session 40's shared-core implementation. Pure-browser pairing remains disabled until an independent monotonic rollback anchor or a separately reviewed authenticated peer-witness design satisfies the endpoint storage contract. - [ ] Obtain a one-use, device-bound relay ticket through the authenticated API, then authenticate the WebSocket with a bounded initial frame rather than a URL or subprotocol credential. - [ ] Terminate end-to-end encryption in the browser and expose decrypted validated messages through the normal SDK transport contract. - [ ] List installations through the control plane, but obtain sessions, transcripts, and live state only from the selected daemon after encrypted attachment. - [ ] Keep disconnected input as an explicit draft until the daemon durably accepts it; do not create a browser-authoritative prompt queue. - [ ] Support existing-session observation and steering first. Require a daemon-owned approved workspace identifier before creating a remote Code session. +The transport-first remote-control slice is an approved exception to phase ordering. It may establish service boundaries, opaque framing, one-use ticket admission, bounded relay routing, daemon authorization behind a test-only fake E2EE adapter, and reusable SDK delivery machinery. It must not implement cryptography, select production identity or storage infrastructure, enable ordinary-session remote access, or advertise production remote control. + +The original private transport slice was integrated into the shared `RC` branch. Draft PR #394 is the aggregate `RC` to `main` review. Each remaining Person 1 or Person 2 milestone branches from current `RC`, opens a focused PR targeting `RC`, and stops at its own review gate. Contributors do not push implementation directly to `RC` or rewrite shared integration history. + +#### Remote transport preflight + +- [x] Record the prior PQXDH plus Triple Ratchet direction as superseded and propose the versioned pairwise hybrid OpenMLS profile for architecture and security review. +- [x] Add the separately deployable TypeScript control plane under `services/control-plane/` with authenticated ticket issuance and atomic one-use consumption through injected interfaces. +- [x] Add the separately deployable Elixir/OTP relay under `services/relay/` with authenticated admission, opaque bounded framing, in-memory installation-scoped routing, backpressure, heartbeat, lease, revocation, and draining behavior. +- [x] Publish language-neutral admission, revocation, and exact binary accept/reject fixtures consumed by both implementations. +- [x] Run TypeScript and Mix formatting, compilation, tests, static analysis, dependency auditing, package-boundary, and SPDX/REUSE checks in CI. +- [x] Draft the daemon-owned remote permission action-binding contract without enabling it. +- [x] Stop at the transport architecture checkpoint before daemon, SDK, cryptographic rendezvous, attachment, or production integration work. + +#### Remote daemon authority checkpoint + +The transport checkpoint was approved. The next integration slice remains disabled for ordinary sessions and uses only the test fake E2EE adapter. + +- [x] Define independent `observe`, `steer`, `approve_within_policy`, and `manage_sessions` scopes. +- [x] Persist installation-bound local device grants and hosted narrowing generations in the daemon data directory. +- [x] Authorize from the intersection of current local and hosted grants. +- [x] Make local and hosted revocation terminal for one device identity. +- [x] Reject stale and conflicting hosted generations atomically. +- [x] Verify authorization before the existing durable command-idempotency path with fake E2EE fixtures. +- [x] Map each remotely callable daemon RPC to an explicit scope and connect the internal dispatcher. +- [ ] Implement the reviewed permission-action events and RPC after protocol review. +- [ ] Keep relay, runtime, CLI, SDK, and ordinary-session wiring disabled until their later gates. + +#### Remote SDK delivery checkpoint + +- [x] Add an injected atomic durable-outbox interface for opaque encrypted requests. +- [x] Persist a stable crypto-session destination and resolve ephemeral relay routes for each attempt. +- [x] Retry byte-identical opaque envelopes with new transport attempt IDs. +- [x] Acquire one-use tickets and perform bounded first-frame WebSocket admission. +- [x] Track route snapshots, replacements, daemon availability, and bounded reconnect. +- [x] Keep relay admission and forwarding receipts diagnostic only. +- [x] Permit removal only after authenticated daemon acceptance. +- [x] Reset uncertain sending state to queued on reconnect without re-encryption. +- [x] Prove the real control plane, relay, daemon authority, SDK, cursor resume, restart, duplicate, revocation, and overflow boundaries in one disposable fake-E2EE test. +- [ ] Replace the standalone opaque-outbox transaction with Person 1's reviewed OpenMLS transaction that persists state advancement and exact ciphertext together. +- [ ] Implement the reviewed bounded authority-audit sink described in [`docs/architecture/remote-hosted-path.md`](docs/architecture/remote-hosted-path.md). + #### Mobile clients -- [ ] Choose mobile implementation stacks when work begins, based on concrete platform and product requirements. +- [ ] Choose mobile implementation stacks when work begins, based on concrete platform and product requirements. Session 50 adds no Swift, Kotlin, C ABI, JNI, or mobile secure-storage implementation. - [ ] Add client SDKs through the current protocol contract, introducing schema generation only when the selected implementations need it. - [ ] Build the selected mobile clients with session list, start, open, live events, steering, permissions, diff review, detach, and reconnect. - [ ] Reuse the reviewed remote pairing, encryption, scope, relay, and revocation contracts. diff --git a/biome.json b/biome.json index c211fda6..597ca8cf 100644 --- a/biome.json +++ b/biome.json @@ -3,7 +3,7 @@ "files": { "includes": [ "**/*.{ts,json}", - "!**/{dist,node_modules}", + "!**/{dist,node_modules,target}", "!.release", "!packages/ai/src/catalog.generated.ts", "!packages/ai/src/catalog.generated" diff --git a/docs/architecture/decisions.md b/docs/architecture/decisions.md index 12746618..f3bc5816 100644 --- a/docs/architecture/decisions.md +++ b/docs/architecture/decisions.md @@ -56,6 +56,12 @@ The standard model-visible tools are `read`, `write`, `edit`, `bash`, `web_fetch The built-in web transport uses pinned public DNS results, rejects private and reserved addresses across redirects, forwards no ambient credentials, and bounds request time and response size. Keyless search uses DuckDuckGo Instant Answers. A configured `BRAVE_SEARCH_API_KEY` selects Brave Search and is included in write-boundary redaction. Full egress policy and credential brokering remain later work. +## Remote endpoint E2EE direction + +Decision from 2026-09-14: replace the prior PQXDH plus Triple Ratchet plan with the versioned pairwise OpenMLS direction in [`remote-e2ee-openmls.md`](remote-e2ee-openmls.md). Revision 1 of the Axl-private profile `axl-e2ee-mls-pq-v1` binds OpenMLS 0.9.0 at tag `openmls-v0.9.0`, commit `3a3e35de3feeca8f6605143c464d5452ae584d43`, `openmls_libcrux_crypto` 0.4.0, suite value `0x004e`, `XWingDraft06`, and the named hybrid suite. It makes no IETF draft-06 interoperability claim. + +The decision approves the exact OpenMLS/libcrux candidates to enter Session 40 after the RFC and dependency decision receive human approval and merge into `RC`. Session 40 must pin the toolchain and lockfile and preserve the owned transactional persistence boundary. Browser/WASM remains mandatory work for Session 50. Remote access and production release stay disabled until their browser, native, interoperability, packaging, recovery, and independent-review gates pass. + ## Generated files Generated TypeScript files use the `*.generated.ts` suffix and begin with `@generated by ; do not edit.` Their TypeScript generator must support `--check`, which `pnpm check:generated` runs. Edit the source and regenerate instead of changing generated output directly. diff --git a/docs/architecture/e2ee-platform-bindings.md b/docs/architecture/e2ee-platform-bindings.md new file mode 100644 index 00000000..42c05c7b --- /dev/null +++ b/docs/architecture/e2ee-platform-bindings.md @@ -0,0 +1,340 @@ + + + +# Endpoint E2EE platform bindings + +Status: proposed Session 50 architecture; pending RC review + +Drafted: 2026-09-16 + +## Scope + +Session 50 carries the Axl-private `axl-e2ee-mls-pq-v1` endpoint profile to Node and real browsers. It defines narrow, versioned Node and browser endpoint APIs over the same Rust implementation and canonical fixtures. + +Swift, Kotlin, C ABI, JNI, generated SDKs, iOS and Android applications, mobile secure storage, and final mobile stack decisions remain in Phase 13. No Session 50 package connects to the relay, grants daemon authority, or enables remote access. + +Browser pairing remains disabled until a separate approved design supplies either an independent monotonic rollback anchor or an authenticated peer-witness protocol with an explicit threat model and recovery contract. + +## Session 50 sequence + +Session 50 is split into focused RC-targeted changes. Each branch starts from the latest merged `origin/RC`, stops for review, and must merge before the next branch starts: + +1. planning reconciliation and dependency evaluation; +2. canonical pairing transcript and failure-state tests; +3. safe endpoint facade plus KeyPackage, Welcome, activation, replacement, removal, reset, and typed lifecycle outcomes; +4. private Node binding and package smoke tests; +5. browser/WASM execution, secure-randomness checks, CSP-compatible artifacts, and real-browser cryptographic fixtures; +6. IndexedDB transaction, Web Locks ownership, close/reopen, abort, quota, storage-loss, and exact-byte retry evidence. + +The sequence stops with browser pairing and remote web disabled if the rollback-anchor gate remains unresolved. It does not continue into daemon, SDK, relay, control-plane, account, authorization, attachment, S3, UI, hosted deployment, or Session 60 work. + +## Ownership and package boundaries + +All profile behavior remains in `packages/e2ee`: + +```text +packages/e2ee/ + src/ Rust profile, pairing, lifecycle, and safe endpoint facade + bindings/node/ private Node-API addon and package loader + bindings/browser/ private WASM module, worker, and IndexedDB adapter + fixtures/v1/ canonical binary fixtures and manifest +``` + +The bindings may expose only typed endpoint operations. They must not expose mutable `MlsGroup` values, provider storage, signing keys, state data-encryption keys, cryptographic primitives, or transaction handles. Opaque endpoint references may select one owned endpoint, but callers cannot inspect or mutate the underlying group and cannot bypass operation serialization. + +This endpoint ABI is distinct from the daemon client protocol in `packages/protocol` and the client behavior in `packages/sdk`. Session 50 does not add a C ABI, JNI, Swift or Kotlin code, generated SDK, or mobile application. It also does not wire the Node binding into `packages/daemon` or `packages/sdk`. + +Successful endpoint authentication identifies a paired endpoint. It never grants a daemon capability or authorizes an operation. + +## Versioned platform ABI + +ABI version 1 uses fixed-width identifiers, bounded byte strings, tagged requests, and tagged outcomes. Every boundary validates the ABI version, profile ID, profile revision, lengths, enum values, identifiers, and operation state before invoking OpenMLS. + +The safe operation set is limited to: + +- pairing invitation and claim creation and validation; +- KeyPackage creation and validation; +- daemon reservation input and result validation; +- Welcome creation, exact-byte recovery, and device join; +- pair activation acknowledgement; +- application send and receive; +- device self-Update proposal creation; +- daemon proposal acceptance and commit creation; +- device commit application and epoch-ready creation; +- outbox and receive acknowledgement; +- replacement, revocation, member removal, reset, and status; +- explicit close and reload. + +The ABI returns immutable byte results. Native byte outputs have one documented owner and one matching release operation. Inputs are borrowed only for the duration of a synchronous call or copied before asynchronous work begins. No caller-owned mutable buffer remains aliased by Rust. + +Stable outcomes include: + +```text +committed +duplicate_recovered +replay_rejected +stale_epoch +future_epoch_bufferable +future_epoch_too_far +missing_commit +fork_detected +rollback_detected +clock_rollback +state_loss +secure_store_unavailable +rollback_anchor_unavailable +storage_unavailable +lifecycle_busy +retention_exceeded +re_pair_required +profile_mismatch +identity_mismatch +corrupt_state +bound_exceeded +``` + +A duplicate with the same operation fingerprint returns the committed typed result and exact stored ciphertext. The same operation identifier with different input returns a conflict. Rollback, fork, corruption, profile mismatch, identity mismatch, or unexplained state loss quarantines the pair and never triggers an automatic reset. + +## Exact bounds + +Every binding rejects oversized input before copying into an owned buffer, growing WASM memory, decoding TLS, or invoking OpenMLS. + +| Value | Maximum bytes | +| --- | ---: | +| Complete relay payload or MLS envelope | 65,497 | +| Application plaintext | 60,000 | +| KeyPackage | 16,384 | +| Welcome | 16,384 | +| Update proposal | 16,384 | +| Commit | 16,384 | +| Pairing invitation | 2,048 | +| Pairing claim | 17,320 | +| Epoch-ready, pair-activation, or resync plaintext | 2,048 | +| Axl credential | 512 | +| AAD | 512 | + +The PairingClaimV1 maximum is `2 + (1 + 255) + 2 + 48 + 48 + (2 + 512) + (2 + 16,384) + 64 = 17,320` bytes. PR 50.1 must derive or assert this bound in tests against the canonical encoder so the documented limit and implementation cannot diverge. + +The binding framing must fit inside the 65,497-byte relay payload rather than treating that value as additional capacity. Node checks `Buffer.byteLength` before making its owned copy. Browser JavaScript checks `Uint8Array.byteLength` before copying into WASM, and Rust checks the length again. + +## Pairing transcript + +The invitation remains the canonical TLS structure in `remote-e2ee-openmls.md`. The complete encoded invitation, including its signature, is the value hashed by the claim and comparison transcript. + +Revision 1 fixes the previously implicit claim encoding as: + +```text +struct { + uint16 version = 1; + opaque profile_id<1..255>; + uint16 profile_revision = 1; + opaque account_id[16]; + opaque installation_id[16]; + opaque crypto_session_id[16]; + opaque invitation_nonce_hash[48]; + opaque device_credential<1..512>; + opaque key_package<1..16384>; + opaque device_signature<64>; +} PairingClaimV1; +``` + +This completes a previously unspecified, unimplemented revision 1 transcript. No deployed encoding, checked-in claim fixture, or persisted pairing state is being changed. After PR 50.1 commits canonical fixtures, an incompatible transcript change requires a new authenticated profile revision. + +`device_signature` is excluded from the signed prefix and is calculated exactly as: + +```text +Ed25519.Sign( + device_private_key, + "Axl pairing claim v1" || + SHA-384(complete PairingInvitation TLS bytes) || + SHA-384(KeyPackage TLS bytes) || + SHA-384(device credential TLS bytes) +) +``` + +The comparison value is the first 39 bits of: + +```text +SHA-384( + "Axl pairing compare v1" || + complete PairingInvitation TLS bytes || + complete PairingClaimV1 TLS bytes +) +``` + +It is interpreted as an unsigned big-endian integer and rendered as exactly 12 decimal digits with leading zeroes, grouped as `ddd ddd ddd ddd`. + +The daemon creates a cryptographically random 32-byte invitation nonce. The invitation expires ten minutes after issuance and is consumed once. Oversized, non-canonical, wrong-version, wrong-profile, wrong-session, unknown, expired, consumed, and cancelled requests do not increment failed-claim state. Only a canonically decoded claim matching the invitation identifiers and nonce hash is eligible to count. The daemon hashes the complete canonical claim bytes, records that hash with the terminal typed result, and returns the same result without another increment when the same failed claim is repeated after an ambiguous response. Five distinct eligible failed claims commit cancellation. These updates are atomic and do not echo rejected secret material. + +Repeating the accepted claim returns the exact stored Welcome. A different claim after confirmation or consumption fails closed without changing the accepted result. The nonce may appear only in the QR payload and the daemon's encrypted pending-invitation state. It must not enter URLs, hosted storage, logs, analytics, metrics, canonical events, crash reports, or fixture diagnostics. + +## Pre-pair durable-state ownership + +Pairing uses two different durable owners before the MLS group exists. + +### Daemon pending invitation + +The daemon endpoint owns an encrypted pending-invitation record keyed by `crypto_session_id`. It contains the account and installation binding, profile ID and revision, issue and expiry times, invitation hash, nonce, a bounded set of at most five distinct eligible failed-claim hashes and terminal results, state, and accepted claim hash and exact result when present. It lives in the same per-session transactional store that will own the daemon group, not in a second application database. The invitation record commits before QR bytes are returned. It is not canonical session state and is not stored by the relay or hosted rendezvous service. + +Its state transitions are: + +```text +issued -- eligible claim begins --> claim_pending +claim_pending -- first through fourth distinct eligible failure --> issued +issued or claim_pending -- fifth distinct eligible failure --> cancelled +issued or claim_pending -- explicit user cancellation --> cancelled +claim_pending -- user confirmation and reservation intent --> confirmed +confirmed -- atomic group state and exact Welcome commit --> consumed +issued, claim_pending, or confirmed -- lifetime expires --> expired +``` + +Explicit user cancellation after `confirmed` is not a revision 1 transition. The confirmed operation must recover to `consumed` or expire. A conflicting claim after `confirmed` or `consumed` is a rejected operation, not a lifecycle transition, and cannot change the accepted record. Group creation, the complete successor provider image, exact Welcome bytes, and the transition to `consumed` commit atomically in the same store. Repeating the accepted claim recovers that operation and returns the exact Welcome. + +### Device pre-join state + +The device endpoint owns the per-pair Ed25519 signer, KeyPackage private material, exact KeyPackage bytes, invitation hash, claim bytes, profile binding, expiry, and operation records before it uploads the claim. They live in the device's per-session transactional store and must commit before claim publication. The store is bound to the invitation's `crypto_session_id` but has no relay route and no MLS group ID before Welcome validation. + +A failed or ambiguous claim upload retries the exact claim and KeyPackage bytes. Expiry, cancellation, loss of protected state, or an ambiguous Welcome without a durable exact copy requires a new invitation, device ID, crypto session ID, KeyPackage, and later group ID. + +### Hosted handoff + +Session 50 defines typed reservation and publication values only. The later control-plane implementation owns atomic reservation, opaque artifact storage, and expiry. It never owns the nonce, device private key, KeyPackage private material, Welcome plaintext, or MLS group state. + +## KeyPackage and Welcome lifecycle + +The endpoint API supports: + +1. device creation and durable one-invitation KeyPackage creation; +2. complete credential, profile, suite, capability, lifetime, transcript, and size validation; +3. a reservation request and typed reserved, busy, expired, consumed, rejected, or unavailable result; +4. daemon group creation with a fresh random group ID after user confirmation; +5. atomic daemon state advancement and exact Welcome persistence before publication; +6. byte-identical Welcome retry until activation acknowledgement or expiry; +7. device validation of the transcript, group ID, daemon identity, own leaf, suite, profile, and exactly two members before durable join; +8. an MLS-protected activation acknowledgement committed before transmission; +9. replacement and revocation without reusing a KeyPackage or Welcome; +10. daemon-only removal commits and device self-Update proposals; +11. explicit local reset and re-pairing with fresh device, crypto-session, and group identifiers. + +KeyPackage reservation is 60 seconds. Invitation, KeyPackage, and Welcome lifetime is ten minutes. No lifecycle operation grants a daemon scope. + +## Native transaction rule and asynchronous browser rule + +The native rule remains unchanged: the durable read-write transaction starts before a state-advancing OpenMLS call and supplies transaction-local provider storage. + +IndexedDB transactions cannot safely span arbitrary asynchronous work. They become inactive when control returns to the event loop without another queued request. Browser storage therefore uses an equivalent serialized prepare-and-compare protocol rather than pretending to implement the synchronous native trait: + +1. Acquire and retain the exclusive per-session Web Lock. +2. In a short read transaction, load the complete committed snapshot and operation record. +3. Authenticate the snapshot and verify its generation and rollback evidence. A production browser stops with `rollback_anchor_unavailable` here; persistence tests may inject an explicitly test-only anchor. +4. Instantiate a private WASM endpoint from that snapshot. +5. Perform one OpenMLS transition entirely inside the worker and create an internal pending mutation containing the complete successor state, exact ciphertext or accepted-message identity, operation fingerprint, and expected generation. +6. Open one short IndexedDB `readwrite` transaction over every affected object store with `durability: "strict"`. +7. Recheck the stored session, profile, generation, rollback counter, and operation fingerprint inside that transaction. +8. Write the successor state, metadata, operation result, outbox or accepted-message record, and manifest in that same transaction. +9. Wait for the transaction's `complete` event. +10. Only after completion may the binding return ciphertext for transport or plaintext for authorization or presentation. + +The internal pending mutation never crosses the public platform ABI and cannot be transmitted. A generation conflict, abort, callback exception, worker loss, or ambiguous transaction completion destroys the transient WASM endpoint. Recovery opens a new database connection, reloads only committed state, authenticates it, and checks the operation record. A committed operation returns its exact stored result; an absent operation starts again from the old committed state. No state produced before the failed transaction is reused. + +This preserves the atomic advanced-state plus exact-ciphertext guarantee while acknowledging that the browser cannot hold an IndexedDB transaction open across the OpenMLS computation. + +## Browser database + +The version 1 IndexedDB state database uses one key space bound to the origin and stores records under `crypto_session_id`. Its stores mirror the native logical schema: + +- metadata and lifecycle; +- sealed current state and authenticated manifest; +- operations and input fingerprints; +- exact outbox ciphertext; +- accepted-message identities and sealed pending plaintext. + +A separate key database holds the non-extractable WebCrypto wrapping key and prepared, active, or obsolete wrapped-DEK records for feasibility testing. It is not an independent rollback domain. Key preparation happens before the state transaction, activation happens after its completion, and restart reconciliation activates only the key referenced by authenticated committed state and removes only unreferenced inactive records. Obsolete active keys are removed only after anchor reconciliation. + +A single `readwrite` transaction covers all state, operation, outbox, and accepted-message stores affected by an operation. No unrelated promise, WebCrypto request, network request, timer, or UI callback occurs inside it. + +Schema upgrades run only in `versionchange`. Existing connections close on `versionchange`; a blocked upgrade reports `storage_unavailable`. A failed upgrade aborts and retains the old version. Unknown newer versions fail closed. The implementation never deletes and recreates a database as migration recovery. + +`QuotaExceededError`, forced close, unavailable storage, failed persistence request, or transaction abort returns a typed storage outcome and releases no ciphertext or plaintext. Missing or evicted committed state returns `state_loss` followed by `re_pair_required`. Private browsing is not guessed from browser heuristics. Pairing remains unavailable whenever required durability cannot be demonstrated. + +## Browser single-writer ownership + +Each endpoint runs in a dedicated worker and requests an exclusive Web Lock named: + +```text +axl-e2ee-v1: +``` + +The request uses `ifAvailable: true`, never `steal`, and holds the lock by keeping its callback promise pending for the endpoint lifetime. + +A second tab receives `lifecycle_busy` and cannot create or load a second writer. While the worker context and lock callback remain alive, the browser-owned lock prevents another cooperative same-origin endpoint from becoming the writer. Worker or page termination releases the lock. Suspension, freezing, restoration, and termination behavior must be verified separately in every supported browser. Every successor must acquire the lock, reload committed state, and authenticate it before use. If the lock callback resolves or rejects unexpectedly while the worker remains alive, the binding marks the endpoint unusable and terminates the worker. + +## Browser key protection and rollback + +A non-extractable WebCrypto key stored through IndexedDB can prevent API-level export. It does not prove hardware backing, prevent an origin from invoking the key, survive storage loss, provide forensic erasure, or detect rollback of the browser profile. IndexedDB atomicity and persistent-storage permission do not create an independent monotonic anchor. WebAuthn signature counters are optional and are not a portable application-controlled monotonic store. + +The browser adapter may use a non-extractable AES wrapping key for at-rest feasibility tests, but it must report its properties accurately. It must not claim that this satisfies the native `EnvelopeKeyStore` and `RollbackAnchor` threat model. + +No supported pure-browser configuration currently satisfies the required independent rollback anchor. Production browser pairing therefore returns `rollback_anchor_unavailable`. Enabling it requires a separate RFC decision for either: + +- a trusted native or hardware-backed companion that supplies the existing anchor contract; or +- an authenticated peer-witness protocol revision that defines online requirements, fork handling, witness rollback, recovery, privacy, and availability. + +The latter would change the current storage contract and requires separate security review. Session 50 does not implement it. + +## Node binding + +The Node binding uses Node-API through napi-rs. Node-API is preferred over a generic C ABI because a C ABI would still need a Node addon shim and would add a second unsafe ownership boundary. + +The private artifact is `@axl/e2ee-node`. It contains an ESM loader, TypeScript declarations, one platform `.node` binary, license notices, and a manifest with ABI, profile, source, and artifact hashes. Initial build and fixture targets are Node 22.19 and Node 24 on `darwin-arm64`, `darwin-x64`, `linux-x64-gnu`, and `linux-arm64-gnu`. An artifact target means build and fixture support, not production storage readiness. Windows and musl fail with `unsupported_platform` until their storage behavior is reviewed. + +Operations that touch storage or OpenMLS run as Node-API asynchronous work. Incoming buffers are copied before leaving the JavaScript call because JavaScript may retain and mutate their backing stores. Returned buffers own their allocations. Stable Axl codes and bounded safe details are attached to one `AxlE2eeError`; raw Rust, redb, or OpenMLS errors do not cross the boundary. + +The Node-API surface never accepts raw wrapping keys, DEKs, rollback counters, or JavaScript key-management callbacks. Session 50 tests may inject clearly test-only `EnvelopeKeyStore` and `RollbackAnchor` implementations below the public binding boundary. Without approved native implementations, the artifact cannot create or open a production endpoint and fails with `secure_store_unavailable` or `rollback_anchor_unavailable`. Selecting production macOS and Linux implementations is a separate architecture, dependency, and platform-evidence gate before daemon consumption. + +Prebuilt artifacts are intended before daemon consumption so installed users do not need Rust or a native compiler. Session 50 builds and smoke-tests private artifacts but does not publish them or connect them to the daemon. + +## Browser/WASM binding + +The private artifact is `@axl/e2ee-browser`. It contains an ES module loader, generated JavaScript glue, the `.wasm` module, a dedicated worker, TypeScript declarations, license notices, and an integrity manifest. Generated files are produced from source and checked by their generator; they are never edited manually. + +The binding enables OpenMLS's `js` feature and obtains randomness only from `crypto.getRandomValues()`. Missing secure randomness fails with `secure_random_unavailable`; no deterministic, classical-only, or non-cryptographic fallback exists. + +The initial implementation is single-threaded. It does not require shared WASM memory or `SharedArrayBuffer`. The serving policy permits WebAssembly narrowly with `script-src 'self' 'wasm-unsafe-eval'` and permits only same-origin workers with `worker-src 'self'`. General `unsafe-eval`, inline scripts, cross-origin workers, and runtime code downloads remain forbidden. + +Rust-owned secret buffers are zeroed where practical. JavaScript temporary buffers are overwritten after use and the worker is terminated after fatal state errors. Axl does not claim reliable zeroization of browser copies, WASM linear memory after process termination, JIT state, caches, profile storage, swap, crash dumps, backups, snapshots, or physical media. + +## Deferred native mobile work + +Session 50 adds no Swift, Kotlin, C ABI, JNI, generated binding, mobile secure-storage adapter, or mobile application code. Phase 13 will select the mobile stacks and binding mechanism from concrete client requirements, then evaluate its complete dependency and toolchain graph separately. + +No UniFFI, cbindgen, JNI crate, Android Gradle plugin, Android NDK project, Xcode project, XCFramework, AAR, Keychain adapter, Android Keystore adapter, iOS application, or Android application is introduced in Session 50. + +## Cross-platform fixtures + +Native Rust is the sole producer of checked-in revision 1 fixtures. Fixture generation uses production CSPRNG for cryptographic material and marks private fixture state as test-only. Deterministic randomness is never compiled into production code. + +Fixtures cover credentials, invitation and claim transcripts, KeyPackage, Welcome, pair activation, application messages, self-Update proposals, daemon commits, AAD, epoch-ready evidence, corruption, replay, identity mismatch, profile mismatch, and exact-byte retry. Native Rust validates every fixture. Node and browser consumers validate the same bytes and expected typed outcomes. Each executable target also runs a fresh-randomness local round trip. Phase 13 Swift and Kotlin bindings must consume this same corpus. + +## Browser and platform test gates + +Required evidence is: + +- native Rust tests on Linux and macOS; +- Node 22.19 and Node 24 import and fixture tests for every produced native artifact; +- branded Chrome, Playwright Firefox, and Playwright WebKit execution; +- actual Safari execution through Safari WebDriver on macOS, reported separately from WebKit; +- browser transaction abort, ambiguous completion, close/reopen, worker termination, multi-tab contention, quota failure, storage loss, schema upgrade, and exact-byte retry; +- packaging and import smoke tests for every produced Node and browser artifact. + +Compile-only WASM, Playwright WebKit alone, mocked IndexedDB, or an in-memory anchor does not count as browser interoperability or production-pairing evidence. + +## Dependency decision + +Session 50 evaluates the exact candidates recorded in `openmls-dependency-decision.md`. A dependency-bearing PR requires a separately approved complete locked normal, build, development, and downloaded-binary graph. Standard browser APIs are preferred over an IndexedDB wrapper or lock package. + +The current `proc-macro-error2` 2.0.1 exception remains limited to the existing OpenMLS/libcrux path. It expires on 2026-12-15 or the next relevant OpenMLS/libcrux release, whichever comes first. Each dependency-bearing PR must re-run the advisory-path check and may not suppress a vulnerability. + +## Exclusions + +Session 50 does not implement control-plane storage, relay changes, account behavior, daemon authorization, daemon or SDK wiring, ordinary-session steering, remote approval, attachments, S3, hosted deployment, or user interface behavior. Browser remote access stays disabled throughout this work. diff --git a/docs/architecture/e2ee-transport-preflight.md b/docs/architecture/e2ee-transport-preflight.md new file mode 100644 index 00000000..bfae4147 --- /dev/null +++ b/docs/architecture/e2ee-transport-preflight.md @@ -0,0 +1,154 @@ + + + +# E2EE transport preflight + +Status: completed transport checkpoint; endpoint cryptography superseded + +## Integration base + +The original private implementation branch was `feature/e2ee-transport`. Its completed transport checkpoint was integrated into the shared `RC` branch. Draft PR #394 is the aggregate `RC` to `main` review. New remote-control milestones use focused branches from current `RC` and target `RC`; they do not push implementation directly to the integration branch. + +## Scope + +This checkpoint proves bounded opaque transport. It does not provide E2EE or production remote control. + +Allowed work is limited to: + +- the TypeScript control-plane boundary and deterministic in-memory stores +- one-use relay tickets and authenticated internal admission +- the Elixir/OTP WebSocket relay +- opaque structural schemas and cross-language fixtures +- bounded routing, queues, heartbeat, lease expiry, revocation, draining, and rate limits +- later daemon authorization and SDK delivery tests behind a test-only fake E2EE adapter + +Person 1 exclusively owns the endpoint OpenMLS profile, pairwise group lifecycle, pairing cryptography, credentials and signatures, KeyPackage and Welcome validation and consumption, cryptographic replay and epoch behavior, secure group-state storage, encryption and decryption, associated data, attachment cryptography, and cryptographic test vectors. + +The prior PQXDH plus Triple Ratchet direction is superseded. The selected successor is revision 1 of the Axl-private pairwise OpenMLS profile in [Remote endpoint E2EE with OpenMLS](remote-e2ee-openmls.md), with daemon-only commits, phone self-Update proposals, an opaque KeyPackage and Welcome rendezvous, and explicit profile migration. After that RFC and its dependency decision receive human approval and merge into `RC`, Session 40 may implement the shared core with exact dependency, toolchain, lockfile, audit, and transactional-storage controls. Browser/WASM implementation belongs to Session 50 and remains mandatory before remote web is enabled. Production release remains blocked on all interoperability, persistence, packaging, and independent-review gates. This transport document defines no OpenMLS wire fields or persistence format. + +## Service ownership + +`services/control-plane` is the only hosted component allowed to mutate account, installation, device, ticket, opaque KeyPackage and Welcome rendezvous, grant, upload-reservation, quota, and security-audit state. This slice implements ticket state only. Authentication, authorization, proof verification, clocks, and persistence are injected. Test adapters are deterministic and are not production defaults. The control plane never receives private MLS keys, decrypted Welcome contents, MLS group state, or application plaintext. + +`services/relay` owns ticket-authenticated WebSocket admission and bounded in-memory routing. It has no database access, E2EE dependency, RPC knowledge, canonical history, durable mailbox, or attachment storage. The relay derives the source route from consumed-ticket state and never accepts it from a sender. + +The daemon remains authoritative for grants, revocation, session authorization, idempotency, durable acceptance, canonical JSONL, and execution. Cryptographic authentication will identify a sender but will never authorize a command. + +## Internal service contract + +The relay sends `POST /internal/v1/relay/tickets/consume` once during admission. The exact JSON request and response fixture is `packages/protocol/test/fixtures/internal-relay-api-v1.json`. Binary proof bytes use canonical base64 in JSON. The control plane validates the body at runtime and atomically consumes one unexpired ticket. One concurrent consumer succeeds. Replays fail. + +The control plane sends `POST /internal/v1/revocations` to the relay. The same fixture defines its versioned request and response. Notifications are best effort. The daemon will still recheck current authority before durable command acceptance. + +Both HTTP boundaries require injected service authentication and fail closed when it is absent or rejects the request. Tickets and internal credentials are forbidden in URLs, logs, metrics, and canonical events. + +Production service authentication is distinct from user authentication and ticket proof. It answers whether this exact relay instance may consume tickets and whether this exact control-plane instance may revoke routes. TLS without client authentication protects bytes in transit but does not establish that caller authority. The production mechanism remains an owner decision because it depends on deployment identity: + +- Prefer mutually authenticated TLS with short-lived workload certificates when both services have stable workload identities. +- A cloud-native signed workload token is acceptable when the selected platform provides audience-bound, short-lived service identities. +- Do not use a long-lived static bearer secret as the production design. +- Bind credentials to service role, environment, and endpoint audience. Rotate them without reconnecting existing leased clients. +- Authenticate the exact request body before parsing it, reject replays within the chosen mechanism, and redact all credential material. + +The current code therefore injects authentication on both sides and provides no production credential implementation. Selecting mTLS, SPIFFE, or a cloud IAM mechanism waits for the deployment decision. Tests use obvious fixture credentials only. + +If the control plane is unavailable, new admissions fail. Existing connections continue only through their consumed-ticket lease. + +## WebSocket admission + +Clients connect to `/v1/connect` with compression disabled. They do not put a ticket in the URL. The first binary message is bounded JSON with exactly: + +```json +{ + "version": 1, + "ticket": "opaque", + "connectionNonce": "opaque", + "possessionProof": "canonical-base64" +} +``` + +The relay adds its own instance ID and calls the control plane. Proof bytes and proof verification are fake and test-only in this checkpoint. No production proof construction is implied. + +After admission, the relay sends a `route_snapshot` control message with the connection's ephemeral source route and only opposite-role peers from the same installation. A device sees at most the current daemon route. The daemon sees authorized device routes and their opaque device IDs. `route_available` and `route_unavailable` messages update this view after reconnects. Devices never enumerate other devices. + +One daemon route is active per installation and one route is active per device ID. A newer authenticated connection replaces the older same-identity route. Routing permits only `device -> daemon` and `daemon -> device`; same-role and cross-installation delivery returns `forbidden_route`. + +## Binary relay framing + +`packages/protocol/test/fixtures/remote-transport-v1.json` is the byte-level cross-language fixture. Every integer is unsigned big-endian. UUIDs use their 16 RFC 9562 bytes. + +```text +bytes size field +0 4 ASCII AXLR +4 1 transport version (1) +5 1 kind: send=1, delivery=2, receipt=3, failure=4 +6 16 transport attempt UUID +``` + +Send and delivery continue with: + +```text +22 16 destination route for send; source route for delivery +38 n opaque payload through the end of the WebSocket message +``` + +The revised encoding deliberately has no inner payload-length field. One binary WebSocket message is exactly one relay frame, so the WebSocket message boundary is authoritative. Removing the duplicate untrusted length avoids a second allocation decision and one class of inconsistent-length input. + +Receipt and failure frames instead contain one byte at offset 22. Receipt values are `admitted=1` and `forwarded=2`. Failure values are permanently assigned as follows: + +```text +1 bad_frame 7 destination_offline +2 unsupported_transport_version 8 rate_limited +3 unauthorized 9 queue_full +4 forbidden_route 10 slow_consumer +5 ticket_expired 11 service_unavailable +6 ticket_consumed 12 ticket_revoked +``` + +These assignments must not be reordered. A new failure receives a new number or requires a transport-version change. + +A complete WebSocket message, including this framing, is at most 65,535 bytes. Therefore the largest opaque payload is 65,497 bytes. The relay rejects oversized messages through the WebSocket parser ceiling and checks negotiated limits again before parsing or enqueueing. + +`attemptId` is transport-local. Retrying exact opaque bytes uses a new attempt ID while retaining the encrypted request and daemon idempotency identifiers inside the opaque payload. The relay does not define or inspect that payload. + +## Receipt meaning + +- `admitted`: the relay accepted one valid bounded frame. +- `forwarded`: the relay enqueued the bytes into the destination WebSocket process after route and queue checks. It does not prove a network write, endpoint receipt, parsing, decryption, or daemon acceptance. +- `daemon_accepted`: not a relay receipt. It is produced only after daemon authorization and durable acceptance. + +A client may remove a mutation from its durable outbox only after `daemon_accepted`. + +## Approved relay dependencies + +The first relay slice uses pinned Bandit, Plug, and WebSock Adapter production dependencies. They are approved for this boundary. Cowboy was evaluated and rejected after its locked version reported active security advisories. Credo, Dialyxir, and mix_audit are development-only checks. + +## Heartbeats and half-open connections + +The relay sends a ping every 20 seconds and records inbound activity with a monotonic clock. A valid binary frame, ping, or pong updates liveness. Outbound pings do not. A connection closes with `idle_timeout` after 60 seconds without valid inbound activity. Ticket lease expiry is an independent hard deadline and is never extended by heartbeat traffic. + +## Slow consumers + +Each route has a 512 KiB application queue ceiling. Reaching the ceiling starts a 10-second saturation timer and further enqueue attempts fail with `queue_full`. If queued bytes do not fall to 256 KiB or less before the timer fires, the relay evicts the destination with `slow_consumer`. Bytes remain charged until the WebSocket adapter accepts the push. `forwarded` still does not prove endpoint or network receipt. Deployment must separately bound kernel socket buffers, and load tests must measure them. + +## Revocation races + +Every ticket stores the hosted grant generation observed at issuance. Atomic consumption rechecks the current generation and rejects a missing or changed grant with `ticket_revoked`. The admission result carries that generation. Relay revocation notifications close routes admitted at or before the revoked generation and prevent their stale re-registration. A missed relay notification still cannot authorize a daemon command because the daemon rechecks current grants before durable acceptance. + +## Reviewed limits + +```text +maximum complete relay frame: 65,535 bytes +pending bytes per connection: 512 KiB +heartbeat interval: 20 seconds +idle timeout: 60 seconds +maximum ticket lifetime: 60 seconds +``` + +## Review resolutions + +The architecture review selected role-filtered relay discovery, strict opposite-role topology, monotonic inbound-idle tracking, timed slow-consumer eviction, and grant-generation-bound ticket consumption. The implementation and cross-language fixtures now enforce those decisions. Socket-adapter acceptance remains distinct from network or endpoint receipt, and production socket-memory bounds remain a deployment and load-test requirement. + +## Review boundary + +This historical preflight checkpoint was followed by daemon authority, SDK delivery, and a disposable hosted-path test behind fake E2EE. See [`remote-hosted-path.md`](remote-hosted-path.md). OpenMLS endpoint implementation, KeyPackage and Welcome rendezvous storage, attachment cryptography, real E2EE integration, ordinary-session steering, and permission approvals remain separate reviewed milestones. diff --git a/docs/architecture/openmls-dependency-decision.md b/docs/architecture/openmls-dependency-decision.md new file mode 100644 index 00000000..7a74c65e --- /dev/null +++ b/docs/architecture/openmls-dependency-decision.md @@ -0,0 +1,304 @@ + + + +# OpenMLS dependency decision + +Status: OpenMLS/libcrux candidates approved to enter Session 40; Session 50 binding candidates approved for evaluation only; production release approval deferred + +Reviewed: 2026-09-16 + +## Decision + +No Rust dependency is added to Axl by this Session 30 change. The repository owner approves the exact OpenMLS/libcrux candidates below to enter Session 40. This is implementation approval, not production-release approval. Session 40 creates the implementation package, pins the exact Rust toolchain and Cargo graph, commits `Cargo.lock`, and runs the required audits. + +| Input | Exact candidate | +| --- | --- | +| `openmls` | 0.9.0; crates.io checksum `b6b08d90fc020cb5354d5f08ca17711b84c82e2bcc7331753fd94f000d99a8c8`; MIT | +| `openmls_libcrux_crypto` | 0.4.0; crates.io checksum `41e6367fb30f91f21e4d30f4f58a8d3b41f96f55c3e4b5acfa1d6d18c9dd4855`; MIT | +| `openmls_basic_credential` | 0.6.0; crates.io checksum `dbd3f0c3422e7c7a8496f042b547b0c28d0793f8ba97feb401966e58196a1e40`; MIT; approved direct implementation dependency | +| OpenMLS source | tag `openmls-v0.9.0`, commit `3a3e35de3feeca8f6605143c464d5452ae584d43`, 2026-08-25 | +| Features | `openmls/draft-ietf-mls-pq-ciphersuites`, `openmls/js` for WASM, `openmls_libcrux_crypto/draft-ietf-mls-pq-ciphersuites`; default features disabled | +| Research toolchain | Rust 1.96.0 (`ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96`, 2026-05-25); both direct crates declare MSRV 1.91.0 | +| WASM compile evidence | `wasm32-unknown-unknown` from Rust 1.96.0 passed with the `openmls/js` feature | +| Session 40 pin | Session 40 records the exact Rust and Cargo versions, installed target components, direct dependency declarations, features, checksums, and committed `Cargo.lock` before implementation proceeds | +| OpenMLS storage contract | `openmls_traits` 0.6.0, provider schema version 1 | +| Disposable research lock | Generated 2026-09-14; SHA-256 `c0a6fc287e663ce4ae9fedd6907c5ba4b20eb136318b69337d3be4a7a268f504` | + +The research lock under `/tmp` is evidence, not the implementation lockfile. Cargo resolved 264 packages because a lock records optional and target-specific alternatives. After Session 40B, the table below is the complete 156-package normal and build dependency closure selected by `cargo tree --locked --target all --edges normal,build` for the stated features. Development-only dependencies and optional packages not selected by that command are excluded. Session 40A reconciled the implementation graph and received explicit human approval for `openmls_basic_credential` 0.6.0 and its additional normal/build closure. Session 40B adds only `redb` 4.2.0; its sole normal dependency, `libc` 0.2.189, was already selected. Cargo's lockfile also records optional, target-specific, and dependency-development alternatives that are not selected by this command. + +## Session 50 evaluation candidates + +The following versions are approved only for isolated evaluation and planning. This approval does not permit changing a repository manifest or lockfile. Each dependency-bearing Session 50 PR must present its exact resulting lock, selected normal/build/development graph, licenses, advisories, build scripts, downloaded binaries, and target evidence for separate human approval before insertion. + +| Use | Exact evaluation candidate | Source and integrity | Scope | +| --- | --- | --- | --- | +| Node runtime | `napi` 3.12.5, Node-API 9, default features disabled | MIT; crates.io checksum `f0c007d4a8ead952a81887661d41fd56b7e7a70d3d4d921f445fb37a8f6efa1e`; tag `napi-v3.12.5`, commit `c69066bc9b2fc848aea9fd83f478e815085fbe1e` | Proposed production dependency for the private Node binding only | +| Node macros | `napi-derive` 3.6.6 with `strict` and `type-def` | MIT; checksum `e8872852c2d050fc5859749864119bc5d50e3f6ad5957874d61d1a07c76771cb`; tag commit `718349e1e4c8ec666ce8ba0b6eee59babd7e0dd6` | Proposed build-time macro dependency for the private Node binding | +| Node build | `napi-build` 2.4.2, default features disabled | MIT; checksum `860e7c40864f95cfb83cde99f9ebadd88ef3d9bdccd7dd2cee0cc96a2dd4ffa7`; tag commit `fce13f61caff9b4c0d1d6d093d1ea24dcdd7af31` | Proposed build dependency; no runtime npm package | +| WASM ABI | `wasm-bindgen` 0.2.128 | MIT OR Apache-2.0; checksum `aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf`; tag `0.2.128`, commit `246946fddd62163e778c3a1f6afe7264347adceb` | Already resolved in the current Cargo lock; proposed direct binding dependency | +| WASM build tool | `wasm-bindgen-cli` 0.2.128 | MIT OR Apache-2.0; checksum `2e29140c04e81832902b70e5d37b9ce1bfa811c8fe4563bea08f76df8388cf20`; same upstream tag | Proposed pinned build tool only | +| Legacy RNG bridge | `getrandom` 0.2.17 with `js` | MIT OR Apache-2.0; checksum `ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0` | Already resolved transitively; proposed direct target dependency to enable browser Web Crypto for the credential helper graph | +| WASM time | `web-time` 1.1.0 | MIT OR Apache-2.0; checksum `5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb` | New transitive dependency selected by `openmls/js` | +| Browser tests | `@playwright/test` 1.63.0 | Apache-2.0; npm integrity `sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==`; tag `v1.63.0`, commit `1b025d7e20a026371cd5f98ba0cdce48892737c8` | Proposed development dependency; resolves `playwright` and `playwright-core` 1.63.0 and downloads pinned browser builds | + +No IndexedDB wrapper, Web Locks package, WebCrypto package, `@napi-rs/cli`, UniFFI, cbindgen, JNI crate, Swift package dependency, Kotlin library, Android Gradle plugin, or mobile SDK is selected. Browser persistence uses standard Web APIs. Swift, Kotlin, C ABI, JNI, generated SDKs, mobile secure storage, and mobile applications remain Phase 13. + +A temporary Node candidate graph contained 19 new external package/version pairs beyond the current core graph. Its isolated build passed. Its license evaluation passed the existing allow-list; the temporary path dependency intentionally tripped the repository wildcard-dependency ban. `cargo audit` found no vulnerability and reported only the existing `proc-macro-error2` maintenance warning when the graph was combined with the OpenMLS core. + +A direct `openmls/js` check against `wasm32-unknown-unknown` currently fails because the credential helper's `getrandom` 0.2.17 branch lacks its `js` feature. An isolated candidate that enabled `getrandom/js` compiled successfully on the real target, selected 124 normal/build package names, and added only `web-time` 1.1.0 to the lock. This is compile evidence, not browser execution evidence. + +An isolated pnpm lock for `@playwright/test` 1.63.0 selected only `@playwright/test`, `playwright`, and `playwright-core` at 1.63.0. `pnpm audit --audit-level high` reported no known vulnerability. The downloaded Chromium, Firefox, and WebKit revisions and their binary checksums must be recorded by the dependency-bearing PR. Playwright WebKit does not count as actual Safari evidence. + +`openmls_basic_credential` 0.6.0 is an approved direct implementation dependency for revision 1. It supplies the OpenMLS `SignatureKeyPair` implementation used to create and retain endpoint signing keys. Its helper graph contains implementations for Ed25519, ECDSA over P-256 and P-384, and ML-DSA, but revision 1 selects only Ed25519 through the fixed suite. Axl exposes no signature-scheme negotiation and does not enable any additional MLS cipher suite, signature scheme, or algorithm negotiation. The presence of unused implementations in the helper graph does not make them part of the revision 1 profile. + +Session 40B selected `redb` 4.2.0 after separate human approval. It is a pure-Rust, +MIT-or-Apache-2.0 ACID engine and adds no native database library or external database binary. +Axl uses one database per `crypto_session_id`, explicitly selects immediate durability and two-phase +commit for each security-sensitive transaction, and does not use persistent savepoints. The Axl +adapter, rather than redb, owns encrypted OpenMLS state envelopes, exact-ciphertext records, +idempotency, rollback anchors, and envelope-key lifecycle. Session 50 still owns browser-specific +adapter dependencies. Native and browser engines may differ, but both implement the same atomic +state, exact-ciphertext, rollback, reload, and typed-outcome contract. + +## Complete selected dependency and license table + +| Package and version | Declared SPDX expression | +| --- | --- | +| `autocfg v1.5.1` | `Apache-2.0 OR MIT` | +| `base16ct v0.2.0` | `Apache-2.0 OR MIT` | +| `base64ct v1.8.3` | `Apache-2.0 OR MIT` | +| `bindgen v0.72.1` | `BSD-3-Clause` | +| `bitflags v2.13.2` | `MIT OR Apache-2.0` | +| `block-buffer v0.10.4` | `MIT OR Apache-2.0` | +| `cc v1.4.6` | `MIT OR Apache-2.0` | +| `cexpr v0.6.0` | `Apache-2.0/MIT` | +| `cfg-if v1.0.4` | `MIT OR Apache-2.0` | +| `chacha20 v0.10.2` | `MIT OR Apache-2.0` | +| `clang-sys v1.9.1` | `Apache-2.0` | +| `cmov v0.5.4` | `Apache-2.0 OR MIT` | +| `const-oid v0.10.2` | `Apache-2.0 OR MIT` | +| `const-oid v0.9.6` | `Apache-2.0 OR MIT` | +| `core-models v0.0.7` | `Apache-2.0` | +| `cpufeatures v0.2.17` | `MIT OR Apache-2.0` | +| `cpufeatures v0.3.1` | `MIT OR Apache-2.0` | +| `crabgrind v0.2.6` | `MIT` | +| `crossbeam-deque v0.8.8` | `MIT OR Apache-2.0` | +| `crossbeam-epoch v0.9.21` | `MIT OR Apache-2.0` | +| `crossbeam-utils v0.8.23` | `MIT OR Apache-2.0` | +| `crypto-bigint v0.5.5` | `Apache-2.0 OR MIT` | +| `crypto-common v0.1.7` | `MIT OR Apache-2.0` | +| `crypto-common v0.2.2` | `MIT OR Apache-2.0` | +| `ctutils v0.4.2` | `Apache-2.0 OR MIT` | +| `curve25519-dalek v4.1.3` | `BSD-3-Clause` | +| `curve25519-dalek-derive v0.1.1` | `MIT/Apache-2.0` | +| `der v0.7.10` | `Apache-2.0 OR MIT` | +| `der v0.8.2` | `Apache-2.0 OR MIT` | +| `digest v0.10.7` | `MIT OR Apache-2.0` | +| `digest v0.11.3` | `MIT OR Apache-2.0` | +| `ecdsa v0.16.9` | `Apache-2.0 OR MIT` | +| `ed25519 v2.2.3` | `Apache-2.0 OR MIT` | +| `ed25519-dalek v2.2.0` | `BSD-3-Clause` | +| `either v1.18.0` | `MIT OR Apache-2.0` | +| `elliptic-curve v0.13.8` | `Apache-2.0 OR MIT` | +| `ff v0.13.1` | `MIT/Apache-2.0` | +| `fiat-crypto v0.2.9` | `MIT OR Apache-2.0 OR BSD-1-Clause` | +| `find-msvc-tools v0.1.12` | `MIT OR Apache-2.0` | +| `generic-array v0.14.7` | `MIT` | +| `getrandom v0.2.17` | `MIT OR Apache-2.0` | +| `getrandom v0.4.3` | `MIT OR Apache-2.0` | +| `glob v0.3.4` | `MIT OR Apache-2.0` | +| `group v0.13.0` | `MIT/Apache-2.0` | +| `hax-lib v0.3.7` | `Apache-2.0` | +| `hax-lib-macros v0.3.7` | `Apache-2.0` | +| `hax-lib-macros-types v0.3.7` | `Apache-2.0` | +| `hkdf v0.12.4` | `MIT OR Apache-2.0` | +| `hmac v0.12.1` | `MIT OR Apache-2.0` | +| `hpke-rs v0.7.0` | `MPL-2.0` | +| `hpke-rs-crypto v0.7.0` | `MPL-2.0` | +| `hpke-rs-libcrux v0.7.0` | `MPL-2.0` | +| `hybrid-array v0.4.15` | `MIT OR Apache-2.0` | +| `itertools v0.13.0` | `MIT OR Apache-2.0` | +| `itoa v1.0.18` | `MIT OR Apache-2.0` | +| `keccak v0.2.2` | `Apache-2.0 OR MIT` | +| `libc v0.2.189` | `MIT OR Apache-2.0` | +| `libcrux-aead v0.0.9` | `Apache-2.0` | +| `libcrux-aes v0.0.9` | `Apache-2.0` | +| `libcrux-chacha20poly1305 v0.0.9` | `Apache-2.0` | +| `libcrux-curve25519 v0.0.8` | `Apache-2.0` | +| `libcrux-ecdh v0.0.8` | `Apache-2.0` | +| `libcrux-ed25519 v0.0.9` | `Apache-2.0` | +| `libcrux-hacl-rs v0.0.5` | `Apache-2.0` | +| `libcrux-hkdf v0.0.8` | `Apache-2.0` | +| `libcrux-hmac v0.0.8` | `Apache-2.0` | +| `libcrux-hmac-drbg v0.0.1` | `Apache-2.0` | +| `libcrux-intrinsics v0.0.8` | `Apache-2.0` | +| `libcrux-kem v0.0.9` | `Apache-2.0` | +| `libcrux-macros v0.0.3` | `Apache-2.0` | +| `libcrux-ml-kem v0.0.10` | `Apache-2.0` | +| `libcrux-p256 v0.0.8` | `Apache-2.0` | +| `libcrux-platform v0.0.3` | `Apache-2.0` | +| `libcrux-poly1305 v0.0.6` | `Apache-2.0` | +| `libcrux-secrets v0.0.6` | `Apache-2.0` | +| `libcrux-sha2 v0.0.8` | `Apache-2.0` | +| `libcrux-sha3 v0.0.10` | `Apache-2.0` | +| `libcrux-traits v0.0.8` | `Apache-2.0` | +| `libloading v0.8.9` | `ISC` | +| `log v0.4.34` | `MIT OR Apache-2.0` | +| `memchr v2.8.3` | `Unlicense OR MIT` | +| `minimal-lexical v0.2.1` | `MIT/Apache-2.0` | +| `ml-dsa v0.1.1` | `Apache-2.0 OR MIT` | +| `module-lattice v0.2.3` | `Apache-2.0 OR MIT` | +| `nom v7.1.3` | `MIT` | +| `num-bigint v0.4.8` | `MIT OR Apache-2.0` | +| `num-integer v0.1.47` | `MIT OR Apache-2.0` | +| `num-traits v0.2.19` | `MIT OR Apache-2.0` | +| `openmls v0.9.0` | `MIT` | +| `openmls_basic_credential v0.6.0` | `MIT` | +| `openmls_libcrux_crypto v0.4.0` | `MIT` | +| `openmls_memory_storage v0.6.0` | `MIT` | +| `openmls_serialization_helpers v0.1.0` | `MIT` | +| `openmls_traits v0.6.0` | `MIT` | +| `p256 v0.13.2` | `Apache-2.0 OR MIT` | +| `p384 v0.13.1` | `Apache-2.0 OR MIT` | +| `pastey v0.2.3` | `MIT OR Apache-2.0` | +| `pem-rfc7468 v0.7.0` | `Apache-2.0 OR MIT` | +| `pkcs8 v0.10.2` | `Apache-2.0 OR MIT` | +| `pkcs8 v0.11.0` | `Apache-2.0 OR MIT` | +| `pkg-config v0.3.34` | `MIT OR Apache-2.0` | +| `ppv-lite86 v0.2.21` | `MIT OR Apache-2.0` | +| `prettyplease v0.2.37` | `MIT OR Apache-2.0` | +| `primeorder v0.13.6` | `Apache-2.0 OR MIT` | +| `proc-macro-error-attr2 v2.0.0` | `MIT OR Apache-2.0` | +| `proc-macro-error2 v2.0.1` | `MIT OR Apache-2.0` | +| `proc-macro2 v1.0.107` | `MIT OR Apache-2.0` | +| `quote v1.0.47` | `MIT OR Apache-2.0` | +| `r-efi v6.0.0` | `MIT OR Apache-2.0 OR LGPL-2.1-or-later` | +| `rand v0.10.2` | `MIT OR Apache-2.0` | +| `rand_chacha v0.10.0` | `MIT OR Apache-2.0` | +| `rand_core v0.10.1` | `MIT OR Apache-2.0` | +| `rand_core v0.6.4` | `MIT OR Apache-2.0` | +| `rayon v1.12.0` | `MIT OR Apache-2.0` | +| `rayon-core v1.13.0` | `MIT OR Apache-2.0` | +| `redb v4.2.0` | `MIT OR Apache-2.0` | +| `regex v1.13.1` | `MIT OR Apache-2.0` | +| `regex-automata v0.4.18` | `MIT OR Apache-2.0` | +| `regex-syntax v0.8.11` | `MIT OR Apache-2.0` | +| `rfc6979 v0.4.0` | `Apache-2.0 OR MIT` | +| `rustc_version v0.4.1` | `MIT OR Apache-2.0` | +| `rustc-hash v2.1.3` | `Apache-2.0 OR MIT` | +| `sec1 v0.7.3` | `Apache-2.0 OR MIT` | +| `semver v1.0.28` | `MIT OR Apache-2.0` | +| `serde v1.0.229` | `MIT OR Apache-2.0` | +| `serde_bytes v0.11.19` | `MIT OR Apache-2.0` | +| `serde_core v1.0.229` | `MIT OR Apache-2.0` | +| `serde_derive v1.0.229` | `MIT OR Apache-2.0` | +| `serde_json v1.0.151` | `MIT OR Apache-2.0` | +| `sha2 v0.10.9` | `MIT OR Apache-2.0` | +| `shake v0.1.0` | `MIT OR Apache-2.0` | +| `shlex v1.3.0` | `MIT OR Apache-2.0` | +| `shlex v2.0.1` | `MIT OR Apache-2.0` | +| `signature v2.2.0` | `Apache-2.0 OR MIT` | +| `signature v3.0.0` | `Apache-2.0 OR MIT` | +| `spki v0.7.3` | `Apache-2.0 OR MIT` | +| `spki v0.8.0` | `Apache-2.0 OR MIT` | +| `sponge-cursor v0.1.0` | `MIT OR Apache-2.0` | +| `subtle v2.6.1` | `BSD-3-Clause` | +| `syn v2.0.119` | `MIT OR Apache-2.0` | +| `syn v3.0.5` | `MIT OR Apache-2.0` | +| `thiserror v2.0.20` | `MIT OR Apache-2.0` | +| `thiserror-impl v2.0.20` | `MIT OR Apache-2.0` | +| `tls_codec v0.5.0` | `Apache-2.0 OR MIT` | +| `tls_codec_derive v0.5.0` | `Apache-2.0 OR MIT` | +| `typenum v1.20.1` | `MIT OR Apache-2.0` | +| `unicode-ident v1.0.24` | `(MIT OR Apache-2.0) AND Unicode-3.0` | +| `uuid v1.26.1` | `Apache-2.0 OR MIT` | +| `version_check v0.9.5` | `MIT/Apache-2.0` | +| `wasi v0.11.1+wasi-snapshot-preview1` | `Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT` | +| `windows-link v0.2.1` | `MIT OR Apache-2.0` | +| `zerocopy v0.8.57` | `BSD-2-Clause OR Apache-2.0 OR MIT` | +| `zerocopy-derive v0.8.57` | `BSD-2-Clause OR Apache-2.0 OR MIT` | +| `zeroize v1.9.0` | `Apache-2.0 OR MIT` | +| `zeroize_derive v1.5.0` | `Apache-2.0 OR MIT` | +| `zmij v1.0.23` | `MIT` | + +## Obligations + +Axl may select the permissive branch of dual-license expressions where the package offers one. Before distribution, the implementation change must preserve all license texts and copyright notices in the source and binary notices or SBOM required by those licenses. + +Specific obligations are: + +- **MIT, ISC, BSD-2-Clause, BSD-3-Clause, and Unlicense:** retain the applicable copyright, permission, and disclaimer text in distributions. Do not treat a crates.io SPDX expression as the license text. +- **Apache-2.0:** include the Apache-2.0 license, preserve notices and attribution, mark modified files where required, and respect the patent and trademark terms. +- **Unicode-3.0:** retain the Unicode license and data-file notices. `unicode-ident` requires both a permissive code-license choice and Unicode-3.0. +- **MPL-2.0:** `hpke-rs`, `hpke-rs-crypto`, and `hpke-rs-libcrux` are file-level copyleft. If Axl distributes covered source files or modified versions, it must keep those files under MPL-2.0, make source for the covered files available as required, preserve notices, and document modifications. Linking does not relicense Axl as a whole. +- **`r-efi`:** choose its MIT or Apache-2.0 option. Axl does not select LGPL-2.1-or-later. +- **Procedural and build dependencies:** retain notices when their licensed material is included in generated or distributed artifacts. Confirm this from packaged output rather than assuming build-time use creates no obligation. + +No AGPL dependency appears in the selected graph. No Signal or libsignal source was inspected or used. + +## Maintenance exception + +Session 30 accepts one narrow maintenance exception: + +| Field | Decision | +| --- | --- | +| Package | `proc-macro-error2` 2.0.1 | +| Crates.io checksum | `11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802` | +| Advisory | `RUSTSEC-2026-0173`, unmaintained; no vulnerability is reported | +| Complete dependency path | `proc-macro-error2 2.0.1 -> hax-lib-macros 0.3.7 -> hax-lib 0.3.7 -> libcrux-sha3 0.0.10 -> hpke-rs 0.7.0 -> openmls_libcrux_crypto 0.4.0` | +| Scope | Transitive procedural-macro/build dependency in the exact approved candidate graph only | +| Expiry | 2026-12-15 or the next relevant OpenMLS/libcrux release, whichever comes first | + +The path above is one complete traced route from the procedural macro to the provider. The same `hax-lib` dependency also reaches the provider through libcrux crates including `libcrux-intrinsics` 0.0.8, `libcrux-ml-kem` 0.0.10, and `libcrux-secrets` 0.0.6. The exception does not suppress vulnerabilities, permit another version or path, or waive ordinary source and license review. Re-evaluate it on every OpenMLS or libcrux update and remove it immediately when upstream removes the dependency. + +Session 40 must configure `cargo-audit` and `cargo-deny` so this exact exception, reason, path, owner decision, and expiry are explicit. Actual vulnerability findings remain fatal and may not be ignored under this maintenance exception. + +## Audit and assurance + +`cargo-audit` 0.22.2 scanned the disposable candidate lock against 1,246 RustSec advisories on 2026-09-14. It found no reported vulnerability and emitted only the accepted `RUSTSEC-2026-0173` maintenance warning. `cargo-deny` 0.20.2 reported the same warning. Its bans and sources checks passed; its license check failed because the disposable project deliberately had no allow-list configuration. Session 40 must add the reviewed policy for the committed graph. The selected libcrux versions are newer than the affected versions identified in `GHSA-435g-fcv3-8j26`, but the provider still requires focused review before production release. + +The 2026 SRLabs OpenMLS assessment covered `openmls`, `traits`, and `basic_credential` through commit `a3402f2` from 2025-10-22. It excluded crypto and storage providers. OpenMLS 0.9.0, its PQ feature, `openmls_libcrux_crypto` 0.4.0, and every Axl adapter require review before production release. That release gate does not block the approved Session 40 implementation work. + +## Sequenced gates + +Before Session 40: + +- exact Axl-private profile definition and security claims; +- exact dependency candidates and license inventory; +- the maintenance exception above; +- the platform-neutral transactional storage contract; +- human approval and merge of the Session 30 decision. + +During Sessions 40 and 50: + +- exact committed Rust toolchain and `Cargo.lock`; +- configured `cargo audit` and `cargo deny`; +- native persistence and fault injection; +- Node and browser/WASM bindings; +- Swift, Kotlin, C ABI, JNI, generated SDKs, and mobile work deferred to Phase 13; +- positive and negative native Rust, Node, and browser fixtures; +- browser persistence tests with pairing disabled until the rollback-anchor gate is resolved; +- packaging and license notices. + +Before production release: + +- complete platform interoperability and runtime measurements; +- provider, wrapper, binding, storage, operational, and recovery review; +- independent implementation review; +- final MPL-2.0 and third-party notice verification; +- all native, browser, fault-injection, and release gates. + +OpenMLS/libcrux are approved implementation candidates for Session 40. Session 50 binding versions above are evaluation candidates only and require dependency-specific approval before any manifest or lockfile change. Production release remains fail-closed until the later gates pass. + +## Primary sources + +- [`openmls` 0.9.0 metadata](https://crates.io/api/v1/crates/openmls/0.9.0) +- [`openmls_libcrux_crypto` 0.4.0 metadata](https://crates.io/api/v1/crates/openmls_libcrux_crypto/0.4.0) +- [OpenMLS 0.9.0 release](https://blog.openmls.tech/posts/2026-08-25-0.9.0-release/) +- [OpenMLS source tag](https://github.com/openmls/openmls/tree/openmls-v0.9.0) +- [OpenMLS persistence requirements](https://book.openmls.tech/user_manual/persistence.html) +- [SRLabs OpenMLS assessment](https://blog.openmls.tech/SRL-OpenMLS_security_assurance_assessment.pdf) +- [libcrux advisory `GHSA-435g-fcv3-8j26`](https://github.com/cryspen/libcrux/security/advisories/GHSA-435g-fcv3-8j26) +- [`RUSTSEC-2026-0173`](https://rustsec.org/advisories/RUSTSEC-2026-0173.html) +- [RustSec advisory database](https://rustsec.org/advisories/) +- [MPL 2.0](https://www.mozilla.org/MPL/2.0/) diff --git a/docs/architecture/remote-daemon-authority.md b/docs/architecture/remote-daemon-authority.md new file mode 100644 index 00000000..4b540a2f --- /dev/null +++ b/docs/architecture/remote-daemon-authority.md @@ -0,0 +1,77 @@ + + + +# Remote daemon authority + +Status: approved infrastructure behind test-only fake E2EE + +## Scope + +This slice establishes durable installation-scoped device authority without enabling production network access in the daemon. `packages/daemon/src/remote-authority.ts` owns the local record and effective grant calculation. An internal authenticated attachment connects an explicitly allowlisted subset of existing RPCs to the same daemon dispatcher and command journal. The relay and control plane cannot widen daemon authority. A disposable hosted-path test now wires this attachment to the real relay through test-only fake E2EE; no production runtime starts that bridge. + +The processing contract remains: + +```text +open through injected endpoint crypto + -> authenticated device identity + -> validate typed request + -> load current grants and revocation + -> authorize the required remote scope + -> apply daemon command idempotency + -> durably accept + -> execute +``` + +Tests exercise this ordering with the deterministic fake E2EE adapter, internal authenticated attachment, and daemon command journal. Production code does not import or expose the fake adapter. + +## Grant model + +A paired device has two independent grants: + +- **local grant:** created by the authoritative daemon during pairing +- **hosted grant:** a control-plane restriction delivered with a monotonic generation + +Effective scopes are the exact intersection. A hosted grant can never add a scope absent from the local grant. Missing hosted state fails closed for hosted remote access. + +Initial scope identifiers are: + +```text +observe +steer +approve_within_policy +manage_sessions +``` + +Scopes are independent. Holding `steer` does not implicitly grant `observe`, approval, or session management. + +## Persistence + +The daemon stores `remote-authority.json` under its protected data directory with mode `0600`. Writes use a private temporary file, file synchronization, atomic rename, and directory synchronization. Readers reject symlinks, non-regular files, files above 1 MiB before parsing, malformed records, more than 256 devices, duplicate device IDs, unknown scopes, invalid identifiers, and installation-identity mismatch. + +The record contains no private key, credential, relay ticket, ratchet state, ciphertext, prompt, or command body. + +## Generations and revocation + +Local and hosted grants have separate positive generations. A hosted update must have a greater generation, or be a byte-equivalent retry of the current generation. A conflicting equal generation and every lower generation fail. + +Revocation is terminal for one device identity. Neither a local re-registration nor a later hosted grant may restore it. Restoring access requires a new pairing and new device identity. This avoids reviving a lost-device key through stale or compromised hosted state. + +Every request reloads the current in-memory state after serialized durable updates. Revocation prevents new authorization. Work already durably accepted remains daemon-owned. + +## Internal RPC mapping + +The internal attachment supports only methods listed in `packages/daemon/src/remote-rpc.ts`. Observation methods require `observe`; send, steering, queued-input, and interrupt methods require `steer`. Direct shell execution, provider authentication, generic MCP interaction responses, session administration, blob upload, configuration changes, and every unknown or future method are denied by default. + +Retryable mutations enter the existing daemon command journal while the authority store serializes grant checks through durable acceptance. Revocation may proceed immediately after acceptance without waiting for operation completion. Non-mutating requests recheck authority immediately before dispatch. + +## Current non-capabilities + +This module is not wired to the production runtime, CLI, or ordinary sessions. Outside the explicit disposable integration test, it does not: + +- authenticate cryptography +- define pairing or key storage +- close a relay route through a production transport +- implement permission interactions +- enable remote control + +Those integrations remain behind later review gates. diff --git a/docs/architecture/remote-e2ee-openmls.md b/docs/architecture/remote-e2ee-openmls.md new file mode 100644 index 00000000..c9b0f2df --- /dev/null +++ b/docs/architecture/remote-e2ee-openmls.md @@ -0,0 +1,445 @@ + + + +# Remote endpoint E2EE with OpenMLS + +Status: Session 40B native durable adapter implemented; Session 50 platform reconciliation pending RC review; platform and production release gates remain closed + +Reviewed: 2026-09-16 + +## Decision and scope + +Axl reserves one transport-independent Rust endpoint core for remote E2EE. It uses one two-member MLS group per remote device and daemon installation. The daemon is the sole commit creator. A remote device creates its own replacement leaf and sends a self-Update proposal. + +This RFC fixes the Axl behavior that Session 40 and later implementation must satisfy. It adds no dependency, does not approve any dependency for production release, and does not enable remote access. It approves the exact Axl-private profile and OpenMLS/libcrux candidate graph to enter Session 40 after this decision receives human approval and merges into `RC`. + +The transport and authority boundaries do not change: + +```text +remote client + -> application-level MLS ciphertext + -> ciphertext-only relay + -> daemon endpoint + -> authenticated paired-device identity + -> daemon authorization and durable acceptance + -> canonical session behavior +``` + +TLS protects network hops. MLS protects application content end to end. Successful MLS authentication identifies a paired device. It does not authorize an operation. + +## Profile registry + +The profile identifier is the ASCII string `axl-e2ee-mls-pq-v1`. Pairing and migration authenticate both that identifier and profile revision `1`. There is no algorithm negotiation within revision 1. + +| Property | Revision 1 binding | +| --- | --- | +| Profile ID | `axl-e2ee-mls-pq-v1` | +| Profile revision | `1` | +| MLS base protocol | RFC 9420 as implemented by the pinned OpenMLS source | +| Cipher suite name | `MLS_128_MLKEM768X25519_AES256GCM_SHA384_Ed25519` | +| OpenMLS suite value | `0x004e` | +| OpenMLS KEM implementation | `XWingDraft06` | +| Confidentiality components | ML-KEM-768 plus X25519 in the pinned upstream implementation | +| MLS KDF and transcript hash | HKDF-SHA-384 and SHA-384 in the pinned upstream implementation | +| AEAD | AES-256-GCM | +| Authentication signature | Ed25519 | +| MLS encoding | RFC 9420 TLS Presentation Language encoding produced by the pinned source | +| Credential type | MLS `basic` credential containing the Axl credential described below | +| Library | `openmls` 0.9.0, crates.io checksum `b6b08d90fc020cb5354d5f08ca17711b84c82e2bcc7331753fd94f000d99a8c8` | +| Provider | `openmls_libcrux_crypto` 0.4.0, crates.io checksum `41e6367fb30f91f21e4d30f4f58a8d3b41f96f55c3e4b5acfa1d6d18c9dd4855` | +| Upstream source | OpenMLS tag `openmls-v0.9.0`, commit `3a3e35de3feeca8f6605143c464d5452ae584d43` dated 2026-08-25 | +| Enabled Cargo features | `openmls/draft-ietf-mls-pq-ciphersuites`; `openmls/js` only for browser/WASM; `openmls_libcrux_crypto/draft-ietf-mls-pq-ciphersuites`; default features disabled | +| Toolchain and graph lock | Exact Rust toolchain, Cargo version, target components, and committed `Cargo.lock` selected and recorded by Session 40 | +| OpenMLS storage contract | `openmls_traits` 0.6.0, storage provider version 1 | + +Axl is not defining, patching, or assigning a cipher suite. Both endpoints use the same exact pinned upstream OpenMLS implementation. The Axl profile binds its private interoperability contract to those bytes and behaviors. + +This profile does not claim to implement or interoperate with `draft-ietf-mls-pq-ciphersuites-06`, any final IETF PQ MLS specification, or any implementation selected only by the same suite name. The draft is research context, not part of revision 1's wire contract. + +The cryptographic meaning remains deliberately split: + +- Confidentiality uses the pinned upstream hybrid ML-KEM-768 plus X25519 construction. Revision 1 never accepts a classical-only KEM or epoch. +- Authentication is Ed25519 and is classical. Revision 1 does not provide post-quantum signatures. + +Every pairing invitation, claim, KeyPackage reservation, Welcome activation, persisted group record, and migration transcript binds the profile ID and revision. Any incompatible source, suite value, KEM construction, algorithm, encoding, credential, AAD, state schema, feature, or behavior-fixture change requires a new authenticated profile revision or profile ID and an authenticated migration or re-pairing. Toolchain and lockfile changes require dependency review and complete compatibility fixtures; they require a new profile revision only when they change wire bytes, persisted state, security behavior, or another profile binding. Stored state is never silently reinterpreted under another binding. + +## Pairwise group and identity mapping + +A group has exactly two leaves: + +```text +daemon installation <-> one remote device +``` + +Every additional phone or browser gets an independent group. An offline device cannot block another device's epoch progress. Removal or reset affects one pair. + +The durable mapping is one-to-one: + +```text +(account_id, installation_id, device_id, profile_id, profile_revision) + -> crypto_session_id + -> MLS group_id + -> daemon credential fingerprint + -> device credential fingerprint + -> active epoch and epoch authenticator +``` + +Identifiers have these forms: + +- `installation_id`, `device_id`, and `crypto_session_id` are canonical 16-byte UUIDv7 values. +- `group_id` is 32 random bytes generated by the daemon. It is never reused. +- A credential fingerprint is `SHA-384(credential_tls_bytes)`. +- An Axl basic credential is the canonical TLS encoding of version `1`, role (`daemon` or `device`), account UUID, installation UUID, device UUID, profile ID, profile revision, and Ed25519 public verification key. The daemon credential uses the all-zero device UUID; a device credential may not. + +The daemon leaf uses the installation's Ed25519 identity. The device leaf uses a new per-pair Ed25519 identity. Private keys remain at their endpoint. Plaintext identifiers cannot override the credential and group mapping. + +## QR pairing and possession proof + +The QR payload is canonical TLS encoding, not JSON. It contains: + +```text +struct { + uint16 version = 1; + opaque profile_id<1..255>; + uint16 profile_revision = 1; + opaque account_id[16]; + opaque installation_id[16]; + opaque crypto_session_id[16]; + opaque daemon_credential<1..512>; + uint64 issued_at_ms; + uint64 expires_at_ms; + opaque invitation_nonce[32]; + opaque daemon_signature<64>; +} PairingInvitation; +``` + +`daemon_signature` is Ed25519 over `"Axl pairing invitation v1" || TLS(fields before daemon_signature)`. The invitation expires after 10 minutes, is single-use, and is cancelled after five distinct eligible failed claims under the accounting rules below. `invitation_nonce` is a 256-bit random possession secret. It must not enter URLs, logs, metrics, analytics, or canonical events. A QR image is therefore a short-lived credential and the UI must say so. + +The device validates the profile, times, expected signed-in account, installation name shown by the local daemon, daemon credential, and signature. It then generates its credential and KeyPackage. The claim is canonical TLS encoding, not JSON: + +```text +struct { + uint16 version = 1; + opaque profile_id<1..255>; + uint16 profile_revision = 1; + opaque account_id[16]; + opaque installation_id[16]; + opaque crypto_session_id[16]; + opaque invitation_nonce_hash[48]; + opaque device_credential<1..512>; + opaque key_package<1..16384>; + opaque device_signature<64>; +} PairingClaimV1; +``` + +This completes a previously unspecified, unimplemented revision 1 transcript. No deployed encoding, checked-in claim fixture, or persisted pairing state is being changed. After Session 50 PR 50.1 commits canonical fixtures, an incompatible transcript change requires a new authenticated profile revision. + +`device_signature` is excluded from its own signed prefix and is: + +```text +device_signature = Ed25519.Sign( + device_private_key, + "Axl pairing claim v1" || + SHA-384(complete PairingInvitation TLS bytes) || + SHA-384(KeyPackage TLS bytes) || + SHA-384(device credential TLS bytes) +) +``` + +The daemon accepts a claim only when it has the original nonce, all hashes match, the device signature validates, the invitation is live and unconsumed, and the user confirms the device name and a 12-digit comparison value. That value is the first 39 bits of `SHA-384("Axl pairing compare v1" || complete PairingInvitation TLS bytes || complete PairingClaimV1 TLS bytes)`, interpreted as an unsigned big-endian integer and rendered with leading zeroes as four three-digit groups. The comparison value is a UX check, not an additional cryptographic primitive or authorization grant. + +Account authentication alone cannot complete pairing. The control plane sees only identifiers, expiry, the nonce hash, credentials, signatures, and opaque KeyPackage or Welcome bytes. It never receives the QR nonce or private MLS state. + +Before a group exists, the daemon owns an encrypted pending-invitation record in the same per-session transactional store that will own its group. The record contains the original nonce, invitation hash, profile and identity binding, expiry, a bounded set of at most five distinct eligible failed-claim hashes and terminal results, and issued, pending, confirmed, consumed, cancelled, or expired state. It commits before QR bytes are returned. This is endpoint state, not canonical session state or hosted rendezvous state. + +Oversized, non-canonical, wrong-version, wrong-profile, wrong-session, unknown, expired, consumed, and cancelled requests do not count. Only a canonically decoded claim matching the invitation identifiers and nonce hash is eligible. The daemon records the hash of the complete canonical claim bytes with its terminal typed result. Repeating that failed claim returns the recorded result without incrementing again. The fifth distinct eligible failed claim commits cancellation. Group creation, the complete successor provider image, exact Welcome bytes, accepted claim hash and result, and invitation consumption commit atomically. Repeating the accepted claim returns the exact stored Welcome. A different claim after confirmation or consumption fails closed without changing the accepted result. + +The device owns its pre-join Ed25519 signer, KeyPackage private material, exact KeyPackage and claim bytes, invitation hash, profile binding, expiry, and operation records in its per-session transactional store. It commits that state before publishing the claim. A retry publishes the same bytes. Expiry, cancellation, protected-state loss, or an ambiguous Welcome without a durable exact copy requires a fresh invitation, device ID, crypto session ID, KeyPackage, and group ID. Detailed ownership and transitions are specified in [Endpoint E2EE platform bindings](e2ee-platform-bindings.md). + +## KeyPackage and Welcome lifecycle + +A device creates exactly one KeyPackage for one invitation. The surrounding signed pairing transcript binds it to `axl-e2ee-mls-pq-v1`, revision `1`, the selected cipher suite, the device basic credential, and capabilities needed by this profile. + +Limits and lifecycle: + +| Artifact | Limit | Lifetime and consumption | +| --- | ---: | --- | +| Pairing invitation | 2 KiB | 10 minutes, one successful claim | +| Pairing claim | 17,320 bytes | exact-byte retry until accepted, rejected, cancelled, or expired | +| KeyPackage | 16 KiB | 10 minutes, reserved atomically for 60 seconds, consumed once by the daemon | +| Welcome | 16 KiB | 10 minutes, byte-identical retry until device activation acknowledgement | + +A KeyPackage reservation binds account, installation, device, crypto session, profile ID, profile revision, credential fingerprint, and KeyPackage hash. One concurrent reservation wins. Failed group creation releases the reservation only while it remains live and no Welcome exists. Successful group creation consumes it permanently. A consumed, expired, malformed, wrong-profile, or wrong-credential KeyPackage is deleted and cannot be retried. + +The daemon creates a fresh group ID, adds the reserved KeyPackage, persists the group transition and exact Welcome bytes atomically, and only then publishes the Welcome. The device validates the complete pairing transcript, profile, group ID, daemon credential, member count of two, and its own leaf before persisting the joined state. It returns an MLS-protected activation acknowledgement. The control plane deletes the Welcome after that acknowledgement or expiry. Expiry or ambiguous state requires a new invitation and KeyPackage. KeyPackages and Welcomes never authorize daemon scopes. + +## Canonical authenticated data + +Every MLS private message sets authenticated data explicitly. The encoding is TLS Presentation Language encoding with fixed field order and minimal integer encodings: + +```text +struct { + uint16 aad_version = 1; + opaque profile_id<1..255>; + uint16 profile_revision = 1; + opaque crypto_session_id[16]; + opaque group_id[32]; + opaque source_device_id[16]; + opaque destination_device_id[16]; + opaque installation_id[16]; + uint8 message_class; + opaque logical_message_id[16]; + uint64 hosted_grant_generation; +} AxlMlsAadV1; +``` + +The maximum encoded AAD is 512 bytes. IDs are raw canonical bytes. Strings are UTF-8 and profile IDs are ASCII. An endpoint reconstructs expected AAD from durable local mapping and compares it byte for byte before releasing plaintext. The sender cannot select identity fields from plaintext. + +Relay `attempt_id` and ephemeral route IDs are excluded. Retries retain identical MLS bytes while those transport values change. + +## Message classes and bounds + +| Value | Class | Direction | Plaintext maximum | Ordering | +| ---: | --- | --- | ---: | --- | +| 1 | `application_request` | device to daemon | 60,000 bytes | durable outbox, daemon acceptance required | +| 2 | `application_delivery` | daemon to device | 60,000 bytes | cursor-resumable, re-encrypt after epoch sync | +| 3 | `update_proposal` | device to daemon | 16 KiB | before commit, exact retry | +| 4 | `commit` | daemon to device | 16 KiB | highest priority, exact retry | +| 5 | `epoch_ready` | device to daemon | 2 KiB | highest priority, exact retry | +| 6 | `pair_activation` | either direction | 2 KiB | pairing only | +| 7 | `resync_control` | either direction | 2 KiB | no application authority | + +The complete relay payload remains at most 65,497 bytes. The endpoint rejects an MLS envelope that exceeds this value before allocation or parsing. It also bounds decoded TLS vectors, credentials to 512 bytes, AAD to 512 bytes, and the plaintext limits above. Attachments are not part of this profile and require a later profile and key-schedule review. + +Control classes are processed before application delivery, but the relay remains opaque and supplies no semantic priority. The endpoint maintains separate bounded queues. There is no durable cloud mailbox. + +## Commit ownership and epoch barrier + +Only the daemon creates commits. A device that needs a new leaf generates the private replacement leaf locally and sends a signed MLS self-Update proposal. The daemon validates the proposal, rejects proposals that change identity or profile, optionally adds its own update, and creates the one successor commit. + +Each pair follows: + +```text +ACTIVE(E) + -> DRAINING(E) + -> COMMIT_PERSISTED(E -> E+1) + -> WAITING_FOR_EPOCH_READY(E+1) + -> ACTIVE(E+1) +``` + +The daemon stops accepting new old-epoch mutations, drains accepted mutations to `daemon_accepted` or a typed terminal result, and atomically persists next MLS state plus exact commit bytes. It sends those bytes until acknowledged. The device atomically applies the commit and persists its next state, then sends: + +```text +struct { + uint16 version = 1; + opaque profile_id<1..255>; + uint16 profile_revision = 1; + opaque crypto_session_id[16]; + opaque group_id[32]; + opaque commit_id[48]; + uint64 target_epoch; + opaque epoch_authenticator[48]; +} EpochReadyV1; +``` + +`commit_id` is `SHA-384(exact_commit_bytes)`. The receipt is an MLS application message with class `epoch_ready`. The daemon compares every field and the expected epoch authenticator in constant time where applicable. A duplicate commit causes a byte-identical receipt retry, not a second apply. A duplicate valid receipt is harmless. New-epoch application ciphertext cannot pass the commit barrier in either direction. + +Updates serialize per crypto session. There is no global MLS lock. + +## Epoch windows + +The fixed initial limits are: + +```text +past receive-only epochs: 2 +past epoch maximum age: 5 minutes from local commit persistence +future epochs buffered: only E+1 +future message count: 32 per crypto session +future ciphertext bytes: 512 KiB per crypto session +future wait: 10 seconds +``` + +Past epochs never permit sending. Current revocation, hosted generation, scope, policy, replay, and idempotency checks still run after old-epoch decryption. A future application message triggers one bounded commit retransmission request. Commits and epoch-ready receipts have reserved queue capacity and cannot be displaced by application traffic. + +Too-old, too-far-future, missing-commit, wrong-profile, wrong-suite, and authenticator mismatch errors are typed and bounded. They quarantine the pair for explicit resynchronization or re-pairing. There is no silent reset or downgrade. + +## Update policy + +A hybrid update is due at the earliest of: + +- 24 hours since the last successful hybrid commit while both endpoints are reachable; +- 1,000 sent plus received MLS application messages in the current epoch; +- reconnect after at least 15 minutes without an authenticated endpoint exchange; +- device membership, credential, local grant, hosted grant, or revocation change; +- suspected endpoint or state exposure; +- before a policy-marked sensitive action when the last successful hybrid update predates that action's authorization context. + +The 24-hour and message-count triggers are routine. Routine work may batch until both endpoints are reachable. Low-power or background state may defer a routine update for at most seven days, after which remote mutation is blocked until update completion. Observation may continue only if current policy allows it and the epoch is otherwise valid. + +Reconnect, membership, credential, revocation, suspected-exposure, and sensitive-action triggers are security-required. A security-required action is blocked until the update and epoch-ready barrier complete. Low-power mode never changes the suite, removes ML-KEM, or enables a classical fallback. + +The thresholds are profile behavior, not security proofs. Gate D must measure them on representative phones before release. Changing them requires a reviewed profile-policy revision and compatible behavior fixtures. + +## Atomic state and ciphertext persistence + +Every state-advancing send is one logical storage transaction: + +```text +BEGIN IMMEDIATE / strict read-write transaction + compare stored generation and rollback counter + write complete next OpenMLS state + insert exact ciphertext, logical message ID, class, epoch, + stable crypto_session_id, and retry state + update receive replay or send generation state +COMMIT DURABLY +``` + +A synchronous native provider begins its physical write transaction before calling OpenMLS and supplies transaction-local provider state. IndexedDB cannot safely remain active across arbitrary asynchronous browser work. The reviewed browser equivalent therefore holds an exclusive per-session Web Lock, authenticates one committed snapshot, performs one OpenMLS transition in a private worker, and then opens one short strict IndexedDB read-write transaction. That transaction rechecks the generation and rollback evidence and atomically writes the complete successor state, operation result, and exact ciphertext or accepted-message record. The pending mutation is internal to the binding and is never a public transaction handle. Any conflict, abort, worker loss, or ambiguous completion destroys the transient WASM endpoint and reloads only committed state. Full sequencing is specified in [Endpoint E2EE platform bindings](e2ee-platform-bindings.md). + +Network transmission begins only after durable commit. A durable record stores `crypto_session_id`, never a relay route. Every attempt resolves the current route and creates a new transport attempt ID. A retry sends byte-identical ciphertext. It never calls MLS encryption again. + +Any storage error or rollback invalidates the in-memory `MlsGroup` and all prepared handles. The endpoint closes the provider, reloads committed state, verifies the rollback counter and epoch authenticator, and only then permits another operation. The public core API returns immutable prepared envelopes and typed transaction outcomes. It never exposes mutable `MlsGroup` state. + +Receive-side replay advancement and durable accepted-message identity commit before plaintext is released to daemon authorization or a client projection. + +Session 40B implements this contract for native hosts with one redb database per +`crypto_session_id`. Every security-sensitive write explicitly uses immediate durability and +redb's two-phase commit mode. OpenMLS runs against transaction-local storage only after the write +transaction starts. The complete encrypted provider image and exact outbox or accepted-message +record then commit together. An AEAD-protected manifest authenticates every durable metadata table. +Initializing databases require explicit publication or cleanup, and marker presence alone never +proves that lifecycle. Creation, open recovery, and cleanup hold one exclusive OS-backed per-session +lifecycle claim across their complete filesystem transition; a competing operation fails closed and +a process exit automatically releases the claim. Cleanup is destructive only for a bound +`initializing` database with no committed cryptographic state. Open authenticates and publishes +complete committed `initializing` state instead of deleting it. A stale marker beside authenticated +`ready` state is removed only after current-key activation, durable-state authentication, anchor +reconciliation, and obsolete-key erasure complete in that order. Previous-epoch deadlines and +rollback-safe clock state survive restart. +Acknowledged idempotency and replay records have a 4,096-generation retry horizon; pending records +are never pruned, and outbox acknowledgement is durable. Only the safe durable daemon and phone +operations are public; the native provider, transaction handles, mutation staging, and fault +injector remain crate-private. Operations reconstruct the group from committed storage, and +uncertain commit recovery closes and reopens the database before consulting the authenticated +operation record. The schema and limitations are documented in +[`packages/e2ee/STORAGE.md`](../../packages/e2ee/STORAGE.md). +Browser persistence and secure platform key implementations remain Session 50 gates. + +### Erasure boundary + +Used message keys must be absent from the committed next state. Serialized state records are encrypted under per-state data-encryption keys. A superseded state's wrapping record is destroyed only after the successor transaction is durable. WAL, rollback journals, temporary files, crash dumps, exported diagnostics, and unencrypted backups must not retain plaintext state or wrapping keys. + +This is a required design, not a completed claim. Static whole-database encryption with one long-lived key is insufficient because it leaves stale pages decryptable. Native and browser adapters must pass forensic-remnant and fault-injection tests and document platform backup and snapshot exclusions. Until they do, Axl makes no forward-secrecy claim for persisted-state compromise. + +## Loss, fork, reset, migration, and re-pairing + +- **Identity or group-state loss:** revoke the old device record when possible, quarantine remaining artifacts, create a new device ID, crypto session, group ID, invitation, KeyPackage, and grant. Never reconstruct missing secrets from hosted data. +- **Rollback:** a lower rollback counter, epoch, or unexpected authenticator quarantines the pair. Reload once from durable state. Persistent mismatch requires re-pairing. +- **Fork:** two valid successors, a commit hash mismatch, or epoch-authenticator mismatch quarantines both branches. No branch is selected automatically. Re-pair with a fresh group ID. +- **Local reset:** requires explicit user confirmation and revocation. It never preserves the old group ID or device credential. +- **Profile migration:** create a second pairwise group under the new profile. Authenticate the migration transcript inside the old group and require activation in the new group before revoking the old pair. If the old group is unavailable or suspect, use QR re-pairing. State is never decoded under a different profile. +- **Hosted artifact loss:** retry from endpoint durable state when exact bytes exist. Otherwise expire the pairing and start again. Hosted state is not a recovery copy of MLS secrets. + +## Platform feasibility + +| Platform | Evidence as of 2026-09-14 | Decision | +| --- | --- | --- | +| Node daemon | Rust crates support the native target. The external spike exercised two-member groups and SQLite reopen, but it is research only. Node FFI and crash-safe storage are untested. | Feasible in principle; blocked before production. | +| Browser/WASM | With Rust 1.96.0, `openmls` 0.9.0 plus `js`, the libcrux provider, and the required `getrandom` 0.2 `js` feature compile for `wasm32-unknown-unknown`. Web Crypto supplies a CSPRNG. IndexedDB can atomically update multiple records and offers a `strict` durability hint. While the worker and lock callback remain alive, the reviewed adapter uses an exclusive Web Lock to prevent another cooperative same-origin endpoint from becoming the writer. Suspension, freezing, restoration, and termination behavior must be verified separately in every supported browser. No reviewed browser API supplies the independent monotonic rollback anchor required by the native contract. | Session 50 must execute the core and persistence fault matrix in real browsers. Pairing and remote web remain disabled until an independent anchor or reviewed peer-witness design is approved. | +| Swift/iOS | A Rust static library and a reviewed binding are feasible in principle. Keychain can hold a wrapping key, but hardware backing and an independent monotonic rollback anchor are not assumed. | Swift bindings, binding generation, iOS packaging, secure storage, and device tests remain Phase 13. | +| Kotlin/Android | A Rust library can be called through a reviewed Android binding. Android Keystore can hold an AES wrapping key, but hardware properties vary and it is not a generic monotonic counter. | Kotlin bindings, JNI or another selected mechanism, Android packaging, secure storage, and device tests remain Phase 13. | + +Browser/WASM compilation is sufficient to begin the shared Rust core in Session 40. Session 40 must keep persistence behind an Axl-owned platform-neutral transaction abstraction. The core must not depend exclusively on native SQLite. The abstraction must atomically persist advanced OpenMLS state with exact ciphertext, require discard and reload after rollback, and support native and browser adapters with the same typed outcomes. + +Browser execution and persistence remain mandatory implementation and shipping gates assigned to Session 50. Session 50 must run OpenMLS in real browsers and prove the reviewed IndexedDB adapter across atomic state-plus-ciphertext commit, abort, crash, reload, exact-byte retry, rollback and epoch mismatch, storage loss and eviction, and Web Locks single-writer ownership. It must test Chrome, Firefox, Playwright WebKit, and actual Safari. Compile-only WASM and Playwright WebKit alone are not Safari evidence. + +A non-extractable WebCrypto key stored through IndexedDB does not provide an independent monotonic rollback anchor. IndexedDB transactions and persistent-storage permission do not add that property. No supported pure-browser configuration currently satisfies the native `RollbackAnchor` guarantee. Browser pairing therefore remains disabled with `rollback_anchor_unavailable`, even after transaction feasibility tests pass, until an independent platform anchor or a separately reviewed authenticated peer-witness protocol is approved. Storage loss, eviction, or protected-key loss requires fail-closed re-pairing. + +Browser revision 1 explicitly makes no forensic-deletion claim for browser profiles, backups, snapshots, caches, crash dumps, WASM linear memory after termination, or physical media. That non-claim does not relax live-state key deletion, transaction, rollback, or re-pairing requirements. + +## Security claims and non-claims + +After all implementation and review gates pass, this profile is intended to provide: + +- end-to-end confidentiality and integrity against the relay and control plane; +- hybrid confidentiality when either ML-KEM-768 or X25519 retains its applicable security property, subject to the reviewed combiner and implementation; +- classical device authentication through Ed25519; +- unique MLS application keys and deletion of used live secrets; +- forward secrecy for message keys erased from live and recoverable persisted state, only within the approved storage threat model; +- classical post-compromise confidentiality recovery after a successful X25519-bearing update and epoch-ready barrier, after the attacker loses endpoint access; +- post-quantum confidentiality recovery after a successful ML-KEM-bearing update and epoch-ready barrier, after the attacker loses endpoint access; +- bounded replay rejection and explicit fork detection. + +Axl does not claim: + +- post-quantum authentication or signatures; +- final IETF interoperability or stable IANA code points; +- production security from an Internet-Draft or a successful compile; +- Signal compatibility, Double Ratchet, Triple Ratchet, or SPQR behavior; +- public-key ratcheting on every application message; +- recovery while malware still controls an endpoint; +- recovery of a stolen durable identity without revocation and re-pairing; +- protection from a compromised plaintext endpoint; +- deletion from device snapshots, backups, crash dumps, or forensic media until each platform threat model says so; +- security of the libcrux provider or Axl storage wrapper from the 2025 OpenMLS audit, because both were outside that audit's scope. + +## Dependency and assurance decision + +The candidate graph and obligations are recorded in [OpenMLS dependency decision](openmls-dependency-decision.md). The candidates are approved for implementation in Session 40, not for production release. + +The SRLabs report version 1.2, dated 2026-03-11, reviewed OpenMLS through commit `a3402f2` from 2025-10-22. It excluded crypto and storage providers. It recorded one acknowledged low-severity state/storage desynchronization risk and accepted an informational unbounded-allocation risk. OpenMLS 0.9.0 commit `3a3e35d` and the libcrux provider are therefore outside that assurance scope. An independent review must cover the exact pinned source, enabled PQ feature, provider, Axl wrapper, parsers, bounds, and storage adapters. + +The implementation and release gates are sequenced as follows. + +Before Session 40: + +1. Approve and merge this exact Axl-private profile definition. +2. Approve the candidate versions, complete license inventory, and narrow maintenance exception in the dependency decision. +3. Approve the platform-neutral transactional persistence contract above. +4. Preserve the security claims and non-claims in this RFC. + +During Sessions 40 and 50: + +1. Build the shared Rust core and commit its exact Rust toolchain and `Cargo.lock`. +2. Configure and run `cargo audit` and `cargo deny`, including the explicit time-bounded maintenance exception. +3. Implement native persistence and transaction fault injection in Session 40. +4. Implement Node and browser/WASM bindings and positive and negative fixtures in Session 50. Swift, Kotlin, C ABI, JNI, generated SDKs, mobile secure storage, and mobile applications remain Phase 13. +5. Complete browser persistence tests, keep browser pairing disabled while the rollback-anchor gate is unresolved, and package all required license texts and notices. +6. Propose the smallest native storage adapter and obtain approval before adding any production storage dependency beyond the approved OpenMLS/libcrux graph. Select browser-specific dependencies separately in Session 50. + +Before production release: + +1. Complete the platform interoperability matrix and mobile and browser runtime measurements. +2. Review the provider, wrapper, bindings, native and browser storage, operational recovery, and side-channel posture. +3. Obtain independent implementation review. +4. Verify final MPL-2.0 and all third-party packaging and notice obligations. +5. Pass every native, browser, interoperability, fault-injection, recovery, and release gate. + +Completed platform bindings are not prerequisites for Session 40. Session 40 creates the shared core those bindings consume. + +AGPL libsignal and SPQR implementations must not be linked, copied, translated, vendored, or added to the lockfile. + +## Gate result + +Session 30 approves revision 1 of the exact Axl-private OpenMLS profile for implementation. It makes no IETF draft-06 interoperability claim. OpenMLS 0.9.0 and `openmls_libcrux_crypto` 0.4.0 are approved to enter Session 40 under exact pinning, committed-lock, audit, and maintenance-exception requirements. This session adds no production dependency or production E2EE. + +Browser/WASM remains mandatory and is assigned to Session 50. Remote web stays disabled until its browser execution and persistence tests pass. Production release remains fail-closed until all browser, native, interoperability, packaging, recovery, and independent-review gates pass. + +Session 40 may begin after this RFC and the dependency decision receive human approval and merge into `RC`. + +## Primary sources + +- [RFC 9420](https://www.rfc-editor.org/rfc/rfc9420.html) +- [`draft-ietf-mls-pq-ciphersuites-06`, non-binding research context, 2026-07-21](https://datatracker.ietf.org/doc/html/draft-ietf-mls-pq-ciphersuites-06) +- [OpenMLS 0.9.0 release](https://blog.openmls.tech/posts/2026-08-25-0.9.0-release/) +- [`openmls` 0.9.0 crates.io metadata](https://crates.io/api/v1/crates/openmls/0.9.0) +- [`openmls_libcrux_crypto` 0.4.0 crates.io metadata](https://crates.io/api/v1/crates/openmls_libcrux_crypto/0.4.0) +- [OpenMLS persistence requirements](https://book.openmls.tech/user_manual/persistence.html) +- [SRLabs OpenMLS security assessment v1.2](https://blog.openmls.tech/SRL-OpenMLS_security_assurance_assessment.pdf) +- [Indexed Database API 3.0](https://www.w3.org/TR/IndexedDB-3/) +- [MDN `IDBTransaction`](https://developer.mozilla.org/en-US/docs/Web/API/IDBTransaction) +- [MDN `Crypto.getRandomValues`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) +- [Rust `wasm32-unknown-unknown` support](https://doc.rust-lang.org/nightly/rustc/platform-support/wasm32-unknown-unknown.html) +- [SQLite WASM persistence](https://sqlite.org/wasm/doc/trunk/persistence.md) +- [Apple Keychain key storage](https://developer.apple.com/documentation/cryptokit/storing-cryptokit-keys-in-the-keychain) +- [Android Keystore](https://developer.android.com/privacy-and-security/keystore) diff --git a/docs/architecture/remote-hosted-path.md b/docs/architecture/remote-hosted-path.md new file mode 100644 index 00000000..e4a37d82 --- /dev/null +++ b/docs/architecture/remote-hosted-path.md @@ -0,0 +1,77 @@ + + + +# Remote hosted-path checkpoint + +Status: test-only fake-E2EE integration + +## Scope + +This checkpoint connects the real TypeScript control plane, Elixir relay, daemon remote-authority boundary, and TypeScript SDK in one disposable test. It proves transport and authorization behavior. It does not enable ordinary-session remote access or provide production cryptography, identity, storage, service credentials, deployment, or permission approval. + +The deterministic fake E2EE adapter remains under protocol test support. Production source consumes only opaque prepared envelopes and an injected authenticated opener. + +## Stable destination and ephemeral routes + +A durable outbox record stores a stable opaque crypto-session identifier. It never stores a relay route. The SDK resolves the currently advertised daemon route immediately before each transport attempt. A reconnect therefore changes the transport attempt ID and route while retaining byte-identical prepared ciphertext, request ID, and daemon idempotency key. + +The crypto-session identifier is an integration seam, not an OpenMLS state format. Person 1 owns the final destination identity contract and the transaction that advances cryptographic state and inserts immutable ciphertext. The current `OpaqueOutboxStore` remains fake-E2EE scaffolding and must not be treated as the production OpenMLS transaction. + +## SDK delivery boundary + +The SDK now provides: + +- authenticated HTTP ticket acquisition with a bounded proof interface +- a bounded binary first-frame WebSocket admission +- route snapshot, availability, unavailability, and replacement handling +- bounded exponential reconnect with jitter +- per-attempt route resolution +- relay admitted and forwarded diagnostics +- opaque inbound delivery through an injected authenticated opener +- authenticated daemon acceptance, result, error, and ordinary server-delivery messages +- durable outbox removal only after matching authenticated daemon acceptance +- ephemeral prepared sends for read requests such as subscription resume + +The SDK does not encrypt, decrypt, advance epochs, create prepared records, or claim command authority. Relay receipts never remove durable mutations. + +## Disposable topology + +The explicit hosted-path integration test starts: + +1. an in-process real control-plane HTTP server with deterministic injected identity, authorization, proof, clock, and ticket storage, +2. a separately running real Elixir relay using its HTTP control-plane client, +3. a real sandboxed daemon with durable remote authority and command journal, +4. daemon and device relay WebSocket connections, +5. test-only fake E2EE endpoints, and +6. the real SDK outbox and delivery coordinator. + +It verifies ticket issuance and consumption, route discovery, fake authenticated opening, daemon authorization, durable command acceptance, response delivery, relay restart, changed-route retry with byte-identical ciphertext, cursor-based subscription resume, daemon restart, duplicate idempotency, revocation, and oversized-payload rejection. The test is opt-in outside the relay CI job because it requires the pinned Elixir toolchain. + +## Selected cryptographic direction + +The selected endpoint direction is revision 1 of the Axl-private `axl-e2ee-mls-pq-v1` profile with one pairwise group for each daemon-device relationship. Pairing uses an opaque KeyPackage and Welcome rendezvous owned by the control plane. The daemon is the only committer. A phone may submit Update proposals but does not commit group state. Pairing, persistence, and migration authenticate the profile ID and revision; incompatible changes require authenticated migration or re-pairing. + +This document defines no OpenMLS fields, algorithms, validation rules, storage representation, or transaction implementation. The approved [OpenMLS RFC](remote-e2ee-openmls.md) owns those decisions. Session 40 supplies the transport-independent core and prepared-envelope transaction. Session 50 supplies mandatory browser/WASM persistence and cross-platform fixtures before real E2EE integration can enable remote web. + +## Authority audit gate + +The authority store does not yet emit a complete security-audit stream. Before real E2EE or ordinary-session access is enabled, a reviewed daemon-owned audit sink must durably record bounded events for: + +- local device registration +- local scope narrowing +- hosted grant installation or narrowing +- local and hosted revocation +- failed authorization by stable reason code + +Audit records may contain installation/device identifiers, generations, scope names, timestamps, and reason codes. They must not contain credentials, relay tickets, possession proofs, ciphertext, key material, plaintext request bodies, prompts, or sensitive parameters. Audit persistence and authority mutation ordering must be defined before implementation; this checkpoint does not add a non-atomic best-effort sink. + +## Remaining gates + +Before production remote control: + +- Person 1 must provide the reviewed OpenMLS prepared-envelope transaction and browser/WASM persistence strategy. +- MLS application, Update, commit, and epoch-ready delivery classes need an ordered priority contract. +- Production identity, datastore, workload authentication, quotas, deployment, and TLS termination must be selected. +- The authority audit gate above must be implemented. +- Permission lifecycle, action-digest binding, policy generations, and race resolution must be implemented and reviewed. +- Ordinary-session remote exposure must receive an explicit enablement review. diff --git a/docs/architecture/remote-permission-authorization.md b/docs/architecture/remote-permission-authorization.md new file mode 100644 index 00000000..be6a21a3 --- /dev/null +++ b/docs/architecture/remote-permission-authorization.md @@ -0,0 +1,222 @@ + + + +# Remote permission authorization contract + +Status: proposed for architecture and security review + +## Purpose + +This contract binds a remote permission response to one pending daemon action. It defines authorization and durable acceptance after endpoint authentication. It does not define E2EE, pairing, signatures, or ratchet behavior. + +The existing `permission.requested` event does not carry enough action-binding data, and the existing `session.interaction.respond` RPC covers MCP interactions rather than daemon policy approval. Neither existing surface is remotely approvable under this contract. + +## Initial release boundary + +The first remotely approvable action is a gated tool call in an ordinary session that: + +- runs under an enforced sandbox +- remains within the daemon's current policy ceiling +- is already pending local permission review +- exposes `allow_once` and `deny` only +- comes from a device with the effective `approve_within_policy` scope + +Remote `allow_session` is excluded initially because it changes authority for future actions. Unsafe sessions, sandbox bypasses, credential grants, device administration, policy changes, network or filesystem widening, audit changes, and generated-code activation are never remotely approvable. + +Observer devices cannot respond, including with a denial. This prevents an observer from cancelling work. + +## Identifiers + +Use distinct nominal types: + +```ts +type PermissionInteractionId = EventId; +type PolicyGeneration = string; // lowercase RFC 9562 UUID +type ActionDigest = string; // 64 lowercase hexadecimal SHA-256 characters +type DeviceGrantGeneration = number; +``` + +`PermissionInteractionId` is the canonical `permission.action_requested` event ID. It is never reused. No identifier grants authority. + +`PolicyGeneration` is an opaque equality token created and durably stored by the daemon. It changes whenever any input to the effective action policy changes, including permission profile, sandbox enforcement, filesystem or network policy, credential policy, project policy, or administrator ceiling. It is not a counter supplied by a client. + +Hosted and local device-grant generations remain separate from `PolicyGeneration`. The daemon checks all three at acceptance time. + +## Canonical action binding + +The daemon constructs this record only after typed tool input, paths, destinations, and policy effects have been normalized: + +```ts +interface PermissionActionBindingV1 { + readonly version: 1; + readonly sessionId: SessionId; + readonly operationId: OperationId; + readonly interactionId: PermissionInteractionId; + readonly capability: string; + readonly subject: { + readonly kind: "tool_call"; + readonly toolCallEventId: EventId; + readonly callId: string; + readonly toolName: string; + readonly canonicalInputHash: string; + }; + readonly effects: readonly PermissionEffect[]; + readonly policyGeneration: PolicyGeneration; + readonly sandbox: { + readonly securityMode: "sandboxed"; + readonly provider: string; + readonly policyHash: string; + }; + readonly allowedDecisions: readonly ["allow_once", "deny"]; + readonly expiresAt: number; +} +``` + +A `PermissionEffect` is a typed, normalized consequence. Initial variants are: + +```ts +type PermissionEffect = + | { readonly kind: "filesystem_read"; readonly canonicalPath: string } + | { readonly kind: "filesystem_write"; readonly canonicalPath: string } + | { readonly kind: "network_connect"; readonly scheme: string; readonly host: string; readonly port: number } + | { readonly kind: "process_execute"; readonly executable: string } + | { readonly kind: "capability_use"; readonly capability: string }; +``` + +Paths are canonicalized before this record is created. Network hosts use the daemon's canonical host representation. Effects are sorted by the dependency-free canonical JSON encoder's defined order. Duplicate effects are removed. Unknown effect kinds are rejected rather than converted to text. + +`canonicalInputHash` is the existing lowercase SHA-256 hash of the fully validated canonical tool input. Raw arguments remain in their existing canonical tool-call event and are not duplicated into the permission event. + +`policyHash` is the lowercase SHA-256 hash of the normalized effective policy record used for this decision. That record contains rules and credential identifiers, never credential values. The hash is audit binding, not authority. The current policy object remains authoritative. + +## Action digest + +`actionDigest` is lowercase hexadecimal SHA-256 over the exact dependency-free canonical UTF-8 encoding of: + +```text +{ type: "axl.permission-action", binding: PermissionActionBindingV1 } +``` + +The digest excludes transport IDs, device IDs, timestamps, descriptions, UI labels, and the digest itself. It is computed once by the daemon and stored with the canonical request event. + +The digest detects accidental or malicious substitution after endpoint authentication. It is not a signature, possession proof, or replacement for E2EE. + +`expiresAt` is the daemon-created absolute expiry for this interaction. Expiry never extends because a client reconnects or retries. + +Any change to the action, effects, sandbox, policy, or expiry produces a new interaction and digest. The old interaction becomes stale. A digest algorithm or encoding change requires a new binding version. + +## Canonical events + +Add new variants instead of changing historical permission-event meanings. + +```ts +interface PermissionActionRequestedPayload { + readonly binding: PermissionActionBindingV1; + readonly actionDigest: ActionDigest; + readonly description: string; +} + +interface PermissionActionResolvedPayload { + readonly interactionId: PermissionInteractionId; + readonly actionDigest: ActionDigest; + readonly policyGeneration: PolicyGeneration; + readonly decision: "allow_once" | "deny"; + readonly actor: + | { readonly kind: "local_attachment"; readonly attachmentId: string } + | { readonly kind: "remote_device"; readonly deviceId: DeviceId }; +} +``` + +The event types are `permission.action_requested` and `permission.action_resolved`. The request event is appended and synced before any client may answer. The resolved event is the single canonical winner and is appended before execution proceeds. + +Descriptions are presentation text and never participate in the digest. Events contain no credentials, relay tickets, E2EE material, or internal service credentials. + +## Remote response RPC + +Add a daemon RPC named `session.permission.respond`: + +```ts +interface RemotePermissionResponseV1 { + readonly version: 1; + readonly sessionId: SessionId; + readonly interactionId: PermissionInteractionId; + readonly actionDigest: ActionDigest; + readonly policyGeneration: PolicyGeneration; + readonly decision: "allow_once" | "deny"; +} +``` + +The ordinary RPC request ID and UUID idempotency key remain transport metadata. Both are required for a remote mutation. The response does not contain `deviceId`; the daemon uses only the identity established by successful endpoint authentication. + +A local client may use the same RPC with attachment authority. The daemon records the actual actor after authorization. + +## Authorization and acceptance order + +The daemon performs these steps in order: + +1. Bound and parse the outer frame. +2. Authenticate and open it through the injected E2EE boundary. +3. Establish the authenticated device identity. +4. Validate the plaintext RPC schema. +5. Load current hosted and local grants and their revocation generations. +6. Require the effective `approve_within_policy` scope. +7. Require an enforced sandbox and reject unsafe mode or bypass actions. +8. Load the exact pending interaction and reject it after its fixed expiry. +9. Compare session, interaction ID, action digest, and policy generation exactly. +10. Recompute the current policy ceiling and confirm `allow_once` remains an offered decision. +11. Apply the command journal's idempotency and request-hash rules. +12. Atomically accept the first unresolved response. +13. Append and sync `permission.action_resolved` with the authenticated actor. +14. Continue or deny the daemon-owned operation. +15. Seal the response through the endpoint E2EE boundary. + +Decryption establishes identity only. Steps 5 through 13 establish authority and durable acceptance. + +## Races and recovery + +- The first durably accepted response wins across local and remote clients. +- A retry with the same idempotency key and request hash returns the original result. +- Reusing the key for another response returns `idempotency_conflict`. +- A different key after resolution returns `permission_already_resolved` and the canonical resolution event ID. +- A changed policy generation returns `stale_policy` without resolving the interaction. +- A changed digest returns `action_mismatch` without revealing the current action. +- A revoked or narrowed device returns `unauthorized` and creates no acceptance record. +- If acceptance is synced but the resolution event is missing after a crash, restart reconciliation either appends the deterministic resolution or proves no action resumed. It never asks the user to guess. +- Revocation after durable acceptance does not cancel the already daemon-owned operation. A separately authorized interrupt is required. + +## Stable rejection classes + +```text +unknown_permission +permission_expired +permission_already_resolved +stale_policy +action_mismatch +decision_not_allowed +observer_forbidden +device_revoked +scope_forbidden +unsafe_remote_approval_forbidden +sandbox_bypass_forbidden +idempotency_conflict +``` + +Public errors remain bounded and do not echo tool input, paths, commands, policy records, credentials, or device secrets. + +## Required tests before implementation can ship + +- modified action digest, policy generation, session, or interaction fails +- observer, revoked device, narrowed hosted grant, and narrowed local grant fail +- unsafe sessions and sandbox bypasses fail +- remotely supplied device identity is impossible +- `allow_session` is rejected remotely +- simultaneous local and remote responses produce one canonical winner +- same-key retry replays and conflicting-key reuse fails +- restart between acceptance and resolution reconciles without executing twice +- policy changes invalidate every old response +- permission events and diagnostics contain no credentials or cryptographic material +- successful approval cannot exceed the current daemon policy ceiling + +## Release gate + +This draft does not enable remote approvals. Implementation starts only after protocol and security review. User release still requires Person 1's E2EE library, secure state storage, integrated revocation tests, lost-device tests, and independent security review. diff --git a/package.json b/package.json index 838d22b4..73ef4290 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "node": "^22.19.0 || >=24.0.0" }, "scripts": { - "build": "tsc -b packages/*/tsconfig.build.json packages/extensions/*/tsconfig.build.json --force && pnpm --filter @axl/web build", + "build": "tsc -b packages/*/tsconfig.build.json packages/extensions/*/tsconfig.build.json services/*/tsconfig.build.json --force && pnpm --filter @axl/web build", "build:release": "node scripts/build-release-package.ts", "build:release-metadata": "node scripts/build-release-metadata.ts", "check": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm test && pnpm check:boundaries && pnpm check:generated", @@ -20,7 +20,8 @@ "lint": "biome lint --error-on-warnings .", "release": "node scripts/release.ts", "release:preview": "node scripts/release.ts --preview", - "test": "pnpm build && node --test --test-concurrency=1 --test-timeout=30000 packages/*/test/*.test.ts packages/extensions/*/test/*.test.ts scripts/*.test.ts", + "relay:check": "cd services/relay && mix format --check-formatted && mix compile --warnings-as-errors && mix test && mix credo --strict && mix dialyzer && mix deps.audit", + "test": "pnpm build && node --test --test-concurrency=1 --test-timeout=30000 packages/*/test/*.test.ts packages/extensions/*/test/*.test.ts services/*/test/*.test.ts scripts/*.test.ts", "typecheck": "tsc --noEmit && pnpm --filter @axl/ui typecheck && pnpm --filter @axl/web typecheck" }, "devDependencies": { diff --git a/packages/daemon/README.md b/packages/daemon/README.md index 6aad0fba..85441f2b 100644 --- a/packages/daemon/README.md +++ b/packages/daemon/README.md @@ -1,5 +1,6 @@ + # `@axl/daemon` @@ -7,3 +8,5 @@ The daemon owns sessions, agent loops, event logs, and active operations. Clients connect through a local Unix socket, load bounded history pages, and follow the live event stream. They do not keep their own copy of the agent loop. The local wire protocol uses newline-delimited JSON and requires an exact `WIRE_PROTOCOL_VERSION` match. It currently supports daemon security and sandbox identity, session creation and profiles, listing, paged history, resume, fork, clone, manual compaction, daemon-owned steering and follow-ups, subscriptions, turns, interruption, reload, live activity, abortable blob transport, workspace review, model, thinking, and web-tool configuration, and user interactions requested by extensions. Phase 9 adds the full RPC surface and generated SDK. + +Remote-authority infrastructure persists local device grants, intersects them with hosted narrowing grants, and enforces terminal revocation. An internal authenticated attachment maps an explicit RPC subset to `observe` or `steer` and reuses the existing dispatcher and durable command journal. It is not connected to a relay or ordinary session and does not provide cryptography. Its current integration coverage uses only the test fake E2EE adapter. diff --git a/packages/daemon/src/command-journal.ts b/packages/daemon/src/command-journal.ts index 5bbef511..7463b56d 100644 --- a/packages/daemon/src/command-journal.ts +++ b/packages/daemon/src/command-journal.ts @@ -219,6 +219,11 @@ async function appendSynced(path: string, record: CommandRecord): Promise } } +export interface StartedCommand { + readonly acceptance: Promise; + readonly completion: Promise; +} + export class CommandJournal { readonly path: string; private readonly entries = new Map(); @@ -315,17 +320,36 @@ export class CommandJournal { }, effect: (acceptance: CommandAcceptance) => Promise>, ): Promise> { - return this.accept(input).then((entry) => { - const completion = entry.completion; - if (completion?.type === "succeeded") { - return parseRpcResult(input.method, completion.result); + const started = this.start(input, effect); + void started.acceptance.catch(() => undefined); + return started.completion; + } + + start( + input: { + readonly idempotencyKey: string; + readonly method: Method; + readonly requestHash: string; + readonly targetSessionId?: SessionId; + readonly intendedSessionId?: SessionId; + readonly affectedOperationId?: string; + readonly interactionId?: string; + }, + effect: (acceptance: CommandAcceptance) => Promise>, + ): StartedCommand> { + const entryPromise = this.accept(input); + const acceptance = entryPromise.then((entry) => entry.acceptance); + const completion = entryPromise.then((entry) => { + const persisted = entry.completion; + if (persisted?.type === "succeeded") { + return parseRpcResult(input.method, persisted.result); } - if (completion?.type === "failed") { + if (persisted?.type === "failed") { throw new CommandJournalError( - completion.error.code, - completion.error.message, - completion.error.retryable, - completion.error.details, + persisted.error.code, + persisted.error.message, + persisted.error.retryable, + persisted.error.details, ); } if (entry.running !== undefined) return entry.running as Promise>; @@ -384,6 +408,7 @@ export class CommandJournal { ); return running; }); + return { acceptance, completion }; } private accept(input: { diff --git a/packages/daemon/src/daemon.ts b/packages/daemon/src/daemon.ts index 3c06a64a..56d9d29e 100644 --- a/packages/daemon/src/daemon.ts +++ b/packages/daemon/src/daemon.ts @@ -13,7 +13,9 @@ import { StringDecoder } from "node:string_decoder"; import { type AttachmentPresence, + type AuthenticatedRemoteRequest, type DaemonHostStatus, + type DeviceId, type HostContext, type HostResponse, HOST_CONTROL_VERSION, @@ -32,10 +34,13 @@ import { MAX_CANONICAL_EVENT_BYTES, MAX_WIRE_MESSAGE_BYTES, ProtocolValidationError, + parseAuthenticatedRemoteRequest, parseOperationId, parseRpcResult, parseSessionId, parseWireRequest, + type RemoteDeviceScope, + type RequestId, type RetryableMutationMethod, RPC_METHODS, requiredCapability, @@ -54,6 +59,8 @@ import { commandCatalog } from "./command-catalog.ts"; import { type CommandAcceptance, CommandJournal, CommandJournalError } from "./command-journal.ts"; import { DataDirectoryLock } from "./data-directory-lock.ts"; import type { ProviderManagementService } from "./provider-management.ts"; +import { RemoteAuthorityError, type RemoteDeviceAuthorityStore } from "./remote-authority.ts"; +import { requiredRemoteScope } from "./remote-rpc.ts"; import { DaemonError, SessionManager, type SessionManagerOptions } from "./session-manager.ts"; export type DaemonSecurityMode = "sandboxed" | "unsafe"; @@ -74,6 +81,29 @@ export interface DaemonOptions extends SessionManagerOptions { readonly providerManagement?: ProviderManagementService; } +export interface AuthenticatedRemoteAttachmentOptions { + readonly deviceId: DeviceId; + readonly authority: RemoteDeviceAuthorityStore; + readonly send: (message: ServerMessage) => void; +} + +export interface AuthenticatedRemoteRequestResult { + readonly requestId: RequestId; + readonly method: WireRequest["method"]; + readonly result: unknown; +} + +export interface AuthenticatedRemoteAttachment { + request(value: unknown): Promise; + close(): void; +} + +interface RemoteExecutionAuthority { + readonly store: RemoteDeviceAuthorityStore; + readonly deviceId: DeviceId; + readonly scope: RemoteDeviceScope; +} + const MAX_PENDING_REQUESTS = 64; const MAX_ATTACHMENTS = 256; const MAX_SNAPSHOT_PAGE_BYTES = MAX_CANONICAL_EVENT_BYTES; @@ -225,6 +255,8 @@ export class AxlDaemon { private socketIdentity: SocketIdentity | undefined; private readonly connections = new Set(); private readonly connectionStates = new Set(); + private readonly remoteConnectionStates = new Set(); + private readonly remoteAttachmentClosers = new Set<() => void>(); private readonly cursors = new Map(); private sessionCatalogGeneration = 0; @@ -332,7 +364,7 @@ export class AxlDaemon { if (this.stopping !== undefined) return this.stopping; this.lifecycle = "stopping"; this.sessions.beginShutdown(); - for (const state of this.connectionStates) { + for (const state of [...this.connectionStates, ...this.remoteConnectionStates]) { for (const controller of state.cancellableRequests.values()) controller.abort(); } this.stopping = this.finishShutdown().catch((error: unknown) => { @@ -343,6 +375,121 @@ export class AxlDaemon { return this.stopping; } + attachAuthenticatedRemoteDevice( + options: AuthenticatedRemoteAttachmentOptions, + ): AuthenticatedRemoteAttachment { + if (this.lifecycle !== "running" || this.commandJournal === undefined) { + throw new DaemonError("daemon_stopping", "Daemon is not accepting remote attachments"); + } + let closed = false; + let nextWireRequestId = 0; + const state: ConnectionState = { + initialized: true, + control: false, + attachmentId: randomUUID(), + client: { kind: "remote", version: "internal", instanceId: options.deviceId }, + connectedAt: Date.now(), + lastSeenAt: Date.now(), + grantedCapabilities: new Set(this.capabilities), + pendingRequests: 0, + cancellableRequests: new Map(), + sessionListPages: new Map(), + subscriptions: new Map(), + send: (message) => { + if (!closed) options.send(message); + }, + }; + this.remoteConnectionStates.add(state); + const close = (): void => { + if (closed) return; + closed = true; + for (const controller of state.cancellableRequests.values()) controller.abort(); + for (const subscription of state.subscriptions.values()) subscription.unsubscribe(); + state.cancellableRequests.clear(); + state.subscriptions.clear(); + this.remoteConnectionStates.delete(state); + this.remoteAttachmentClosers.delete(close); + removeRevocationListener(); + }; + const removeRevocationListener = options.authority.onDeviceRevoked((deviceId) => { + if (deviceId === options.deviceId) close(); + }); + this.remoteAttachmentClosers.add(close); + + return { + request: (value) => { + const operation = (async (): Promise => { + if (closed) + throw new RemoteAuthorityError("device_revoked", "Remote attachment is closed"); + if (state.pendingRequests >= MAX_PENDING_REQUESTS) { + throw new DaemonError("rate_limited", "Too many pending remote requests"); + } + const remoteRequest: AuthenticatedRemoteRequest = parseAuthenticatedRemoteRequest(value); + if (remoteRequest.deviceId !== options.deviceId) { + throw new RemoteAuthorityError( + "device_identity_mismatch", + "Authenticated device does not match the request", + ); + } + const wireRequest = parseWireRequest({ + kind: "request", + id: nextWireRequestId, + method: remoteRequest.method, + params: remoteRequest.params, + ...(remoteRequest.idempotencyKey === undefined + ? {} + : { idempotencyKey: remoteRequest.idempotencyKey }), + }); + nextWireRequestId = + nextWireRequestId === Number.MAX_SAFE_INTEGER ? 0 : nextWireRequestId + 1; + const scope = requiredRemoteScope(wireRequest.method); + if (scope === undefined) { + throw new RemoteAuthorityError( + "remote_method_forbidden", + "RPC method is not available to remote devices", + ); + } + if (this.securityMode === "unsafe" && scope !== "observe") { + throw new RemoteAuthorityError( + "unsafe_remote_forbidden", + "Remote mutation is unavailable for unsafe sessions", + ); + } + + state.pendingRequests += 1; + state.lastSeenAt = Date.now(); + const admissionId = randomUUID(); + this.admitted.set(admissionId, wireRequest); + try { + const result = await this.executeRequest(wireRequest, state.send, state, undefined, { + store: options.authority, + deviceId: options.deviceId, + scope, + }); + const validated = parseRpcResult(wireRequest.method, result); + this.activateReadySubscriptions(state, state.send); + return { + requestId: remoteRequest.requestId, + method: wireRequest.method, + result: validated, + }; + } finally { + this.admitted.delete(admissionId); + state.pendingRequests -= 1; + } + })(); + const tracked = operation.then( + () => undefined, + () => undefined, + ); + this.pending.add(tracked); + void tracked.finally(() => this.pending.delete(tracked)); + return operation; + }, + close, + }; + } + private async finishShutdown(): Promise { // Every request admitted before the gate must finish its journal outcome first. await Promise.all([...this.pending]); @@ -353,7 +500,7 @@ export class AxlDaemon { await this.removeOwnedSocket(); this.lifecycle = "stopped"; this.cursors.clear(); - for (const state of this.connectionStates) { + for (const state of [...this.connectionStates, ...this.remoteConnectionStates]) { if (!state.control) state.send({ kind: "error", @@ -365,6 +512,7 @@ export class AxlDaemon { }, }); } + for (const close of [...this.remoteAttachmentClosers]) close(); const server = this.server; this.server = undefined; if (server?.listening) server.close(() => this.hostOptions.onStopped?.()); @@ -894,6 +1042,7 @@ export class AxlDaemon { send: (message: ServerMessage) => void, state: ConnectionState, signal?: AbortSignal, + remoteAuthority?: RemoteExecutionAuthority, ): Promise { let normalized = request; if (request.method === "session.create") { @@ -930,6 +1079,7 @@ export class AxlDaemon { normalized = { ...request, params: { ...request.params, cwd } }; } if (!isRetryableMutationMethod(normalized.method)) { + remoteAuthority?.store.authorize(remoteAuthority.deviceId, remoteAuthority.scope); return this.dispatch(normalized, send, state, undefined, signal); } const idempotencyKey = normalized.idempotencyKey; @@ -966,19 +1116,26 @@ export class AxlDaemon { affectedOperationId, ); } + const journalInput = { + idempotencyKey, + method: normalized.method as RetryableMutationMethod, + requestHash: hashCanonicalRequest(normalized.method, normalized.params as never), + ...(params.sessionId === undefined ? {} : { targetSessionId: params.sessionId }), + ...(intendedSessionId === undefined ? {} : { intendedSessionId }), + ...(affectedOperationId === undefined ? {} : { affectedOperationId }), + ...(interactionId === undefined ? {} : { interactionId }), + }; + const effect = (acceptance: CommandAcceptance) => + this.dispatch(normalized, send, state, acceptance) as never; try { - return await journal.execute( - { - idempotencyKey, - method: normalized.method as RetryableMutationMethod, - requestHash: hashCanonicalRequest(normalized.method, normalized.params as never), - ...(params.sessionId === undefined ? {} : { targetSessionId: params.sessionId }), - ...(intendedSessionId === undefined ? {} : { intendedSessionId }), - ...(affectedOperationId === undefined ? {} : { affectedOperationId }), - ...(interactionId === undefined ? {} : { interactionId }), - }, - (acceptance) => this.dispatch(normalized, send, state, acceptance) as never, - ); + if (remoteAuthority !== undefined) { + return await remoteAuthority.store.runAuthorizedUntilAccepted( + remoteAuthority.deviceId, + remoteAuthority.scope, + () => journal.start(journalInput, effect), + ); + } + return await journal.execute(journalInput, effect); } finally { if (interruptDeliveryOperationId !== undefined) { this.sessions.releaseInterruptDelivery(params.sessionId, interruptDeliveryOperationId); diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 85d520fe..dc345abd 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -5,5 +5,7 @@ export * from "./daemon.ts"; export * from "./event-migration.ts"; export * from "./provider-management.ts"; +export * from "./remote-authority.ts"; +export * from "./remote-rpc.ts"; export * from "./session-manager.ts"; export type { WireEvent } from "@axl/protocol"; diff --git a/packages/daemon/src/remote-authority.ts b/packages/daemon/src/remote-authority.ts new file mode 100644 index 00000000..cd7ee03f --- /dev/null +++ b/packages/daemon/src/remote-authority.ts @@ -0,0 +1,539 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { constants, type Stats } from "node:fs"; +import { lstat, mkdir, open, rename, rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { randomUUID } from "node:crypto"; + +import { + parseDeviceId, + parseInstallationId, + parseRemoteDeviceScopes, + type DeviceId, + type InstallationId, + type RemoteDeviceScope, +} from "@axl/protocol"; + +const AUTHORITY_FORMAT_VERSION = 1 as const; +const AUTHORITY_FILE_NAME = "remote-authority.json"; +const MAX_REMOTE_AUTHORITY_BYTES = 1024 * 1024; +const MAX_REMOTE_DEVICES = 256; + +interface GrantState { + readonly generation: number; + readonly scopes: readonly RemoteDeviceScope[]; + readonly revokedAt?: number; +} + +interface DeviceAuthorityRecord { + readonly deviceId: DeviceId; + readonly createdAt: number; + readonly local: GrantState; + readonly hosted?: GrantState; +} + +interface PersistedAuthorityState { + readonly version: typeof AUTHORITY_FORMAT_VERSION; + readonly installationId: InstallationId; + readonly devices: readonly DeviceAuthorityRecord[]; +} + +export interface RemoteDeviceAuthoritySnapshot { + readonly deviceId: DeviceId; + readonly createdAt: number; + readonly localGeneration: number; + readonly hostedGeneration?: number; + readonly localScopes: readonly RemoteDeviceScope[]; + readonly hostedScopes?: readonly RemoteDeviceScope[]; + readonly effectiveScopes: readonly RemoteDeviceScope[]; + readonly locallyRevoked: boolean; + readonly hostedRevoked: boolean; +} + +export interface RemoteAuthorizationContext { + readonly installationId: InstallationId; + readonly deviceId: DeviceId; + readonly localGrantGeneration: number; + readonly hostedGrantGeneration: number; + readonly effectiveScopes: readonly RemoteDeviceScope[]; +} + +export interface StartedAuthorizedOperation { + /** Resolves only after durable command acceptance. */ + readonly acceptance: Promise; + readonly completion: Promise; +} + +export type RemoteAuthorityErrorCode = + | "unknown_device" + | "device_revoked" + | "hosted_grant_missing" + | "scope_forbidden" + | "grant_conflict" + | "stale_grant_generation" + | "device_limit_reached" + | "device_identity_mismatch" + | "remote_method_forbidden" + | "unsafe_remote_forbidden"; + +export class RemoteAuthorityError extends Error { + readonly code: RemoteAuthorityErrorCode; + + constructor(code: RemoteAuthorityErrorCode, message: string) { + super(message); + this.name = "RemoteAuthorityError"; + this.code = code; + } +} + +function object(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${path} must be an object`); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`${path} must be a plain object`); + } + return value as Record; +} + +function exact( + value: Record, + path: string, + required: readonly string[], + optional: readonly string[] = [], +): void { + const allowed = new Set([...required, ...optional]); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new Error(`${path}.${key} is unknown`); + } + for (const key of required) { + if (!(key in value)) throw new Error(`${path}.${key} is required`); + } +} + +function nonNegativeInteger(value: unknown, path: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error(`${path} must be a non-negative safe integer`); + } + return value as number; +} + +function positiveInteger(value: unknown, path: string): number { + const parsed = nonNegativeInteger(value, path); + if (parsed === 0) throw new Error(`${path} must be positive`); + return parsed; +} + +function parseGrant(value: unknown, path: string): GrantState { + const grant = object(value, path); + exact(grant, path, ["generation", "scopes"], ["revokedAt"]); + return { + generation: positiveInteger(grant.generation, `${path}.generation`), + scopes: parseRemoteDeviceScopes(grant.scopes, `${path}.scopes`), + ...(grant.revokedAt === undefined + ? {} + : { revokedAt: nonNegativeInteger(grant.revokedAt, `${path}.revokedAt`) }), + }; +} + +function parseAuthorityState(value: unknown): PersistedAuthorityState { + const state = object(value, "remote authority"); + exact(state, "remote authority", ["version", "installationId", "devices"]); + if (state.version !== AUTHORITY_FORMAT_VERSION) { + throw new Error(`remote authority.version must be ${AUTHORITY_FORMAT_VERSION}`); + } + if (!Array.isArray(state.devices)) throw new Error("remote authority.devices must be an array"); + if (state.devices.length > MAX_REMOTE_DEVICES) { + throw new Error(`remote authority.devices must not exceed ${MAX_REMOTE_DEVICES} entries`); + } + const seen = new Set(); + const devices = state.devices.map((value, index): DeviceAuthorityRecord => { + const path = `remote authority.devices[${index}]`; + const device = object(value, path); + exact(device, path, ["deviceId", "createdAt", "local"], ["hosted"]); + const deviceId = parseDeviceId(device.deviceId, `${path}.deviceId`); + if (seen.has(deviceId)) throw new Error(`${path}.deviceId is duplicated`); + seen.add(deviceId); + return { + deviceId, + createdAt: nonNegativeInteger(device.createdAt, `${path}.createdAt`), + local: parseGrant(device.local, `${path}.local`), + ...(device.hosted === undefined + ? {} + : { hosted: parseGrant(device.hosted, `${path}.hosted`) }), + }; + }); + return { + version: AUTHORITY_FORMAT_VERSION, + installationId: parseInstallationId(state.installationId, "remote authority.installationId"), + devices, + }; +} + +function canonicalScopes(scopes: readonly RemoteDeviceScope[]): readonly RemoteDeviceScope[] { + return parseRemoteDeviceScopes(scopes); +} + +function nextGeneration(current: number): number { + if (current >= Number.MAX_SAFE_INTEGER) { + throw new RemoteAuthorityError("grant_conflict", "Local grant generation is exhausted"); + } + return current + 1; +} + +function sameScopes( + left: readonly RemoteDeviceScope[], + right: readonly RemoteDeviceScope[], +): boolean { + return left.length === right.length && left.every((scope, index) => scope === right[index]); +} + +function effectiveScopes(record: DeviceAuthorityRecord): readonly RemoteDeviceScope[] { + if (record.local.revokedAt !== undefined || record.hosted?.revokedAt !== undefined) return []; + const hosted = new Set(record.hosted?.scopes ?? []); + return record.local.scopes.filter((scope) => hosted.has(scope)); +} + +async function readExisting(path: string): Promise { + let status: Stats; + try { + status = await lstat(path); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined; + throw error; + } + if (!status.isFile() || status.isSymbolicLink()) { + throw new Error(`Remote authority path ${JSON.stringify(path)} must be a regular file`); + } + if (status.size > MAX_REMOTE_AUTHORITY_BYTES) { + throw new Error(`Remote authority store exceeds ${MAX_REMOTE_AUTHORITY_BYTES} bytes`); + } + const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0; + const handle = await open(path, constants.O_RDONLY | noFollow); + try { + const openedStatus = await handle.stat(); + if (!openedStatus.isFile()) + throw new Error("Remote authority store must remain a regular file"); + if (openedStatus.size > MAX_REMOTE_AUTHORITY_BYTES) { + throw new Error(`Remote authority store exceeds ${MAX_REMOTE_AUTHORITY_BYTES} bytes`); + } + await handle.chmod(0o600); + return await handle.readFile(); + } finally { + await handle.close(); + } +} + +async function writeAtomic(path: string, state: PersistedAuthorityState): Promise { + const directory = dirname(path); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const temporary = `${path}.${randomUUID()}.tmp`; + const bytes = new TextEncoder().encode(`${JSON.stringify(state)}\n`); + try { + const handle = await open(temporary, "wx", 0o600); + try { + await handle.writeFile(bytes); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temporary, path); + const directoryHandle = await open(directory, "r"); + try { + await directoryHandle.sync(); + } finally { + await directoryHandle.close(); + } + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } +} + +/** Durable local and hosted device-grant intersection for remote daemon requests. */ +export class RemoteDeviceAuthorityStore { + readonly path: string; + readonly installationId: InstallationId; + private devices: Map; + private readonly revocationListeners = new Set<(deviceId: DeviceId) => void>(); + private tail: Promise = Promise.resolve(); + + private constructor(path: string, state: PersistedAuthorityState) { + this.path = path; + this.installationId = state.installationId; + this.devices = new Map(state.devices.map((record) => [record.deviceId, record])); + } + + static async open( + dataDirectory: string, + installationId: InstallationId, + ): Promise { + const path = resolve(dataDirectory, AUTHORITY_FILE_NAME); + const bytes = await readExisting(path); + if (bytes === undefined) { + const initial: PersistedAuthorityState = { + version: AUTHORITY_FORMAT_VERSION, + installationId, + devices: [], + }; + await writeAtomic(path, initial); + return new RemoteDeviceAuthorityStore(path, initial); + } + let state: PersistedAuthorityState; + try { + state = parseAuthorityState( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)), + ); + } catch (cause) { + throw new Error(`Corrupt remote authority store ${JSON.stringify(path)}`, { cause }); + } + if (state.installationId !== installationId) { + throw new Error("Remote authority installation identity does not match this daemon"); + } + return new RemoteDeviceAuthorityStore(path, state); + } + + snapshot(deviceId: DeviceId): RemoteDeviceAuthoritySnapshot | undefined { + const record = this.devices.get(deviceId); + if (record === undefined) return undefined; + return { + deviceId: record.deviceId, + createdAt: record.createdAt, + localGeneration: record.local.generation, + ...(record.hosted === undefined ? {} : { hostedGeneration: record.hosted.generation }), + localScopes: [...record.local.scopes], + ...(record.hosted === undefined ? {} : { hostedScopes: [...record.hosted.scopes] }), + effectiveScopes: effectiveScopes(record), + locallyRevoked: record.local.revokedAt !== undefined, + hostedRevoked: record.hosted?.revokedAt !== undefined, + }; + } + + registerLocalDevice( + deviceId: DeviceId, + scopes: readonly RemoteDeviceScope[], + now = Date.now(), + ): Promise { + return this.mutate((devices) => { + const normalizedScopes = canonicalScopes(scopes); + const existing = devices.get(deviceId); + if (existing === undefined && devices.size >= MAX_REMOTE_DEVICES) { + throw new RemoteAuthorityError("device_limit_reached", "Device limit has been reached"); + } + if (existing !== undefined) { + if ( + existing.local.revokedAt !== undefined || + !sameScopes(existing.local.scopes, normalizedScopes) + ) { + throw new RemoteAuthorityError( + "grant_conflict", + "Device identity is already bound to another local grant", + ); + } + return devices; + } + devices.set(deviceId, { + deviceId, + createdAt: nonNegativeInteger(now, "now"), + local: { generation: 1, scopes: normalizedScopes }, + }); + return devices; + }).then(() => this.requiredSnapshot(deviceId)); + } + + narrowLocalGrant( + deviceId: DeviceId, + scopes: readonly RemoteDeviceScope[], + ): Promise { + return this.mutate((devices) => { + const record = devices.get(deviceId); + if (record === undefined) { + throw new RemoteAuthorityError("unknown_device", "Device is not locally paired"); + } + if (record.local.revokedAt !== undefined) { + throw new RemoteAuthorityError("device_revoked", "Device is revoked"); + } + const normalizedScopes = canonicalScopes(scopes); + const currentScopes = new Set(record.local.scopes); + if (normalizedScopes.some((scope) => !currentScopes.has(scope))) { + throw new RemoteAuthorityError( + "grant_conflict", + "A local grant update may only narrow current scopes", + ); + } + if (sameScopes(record.local.scopes, normalizedScopes)) return devices; + devices.set(deviceId, { + ...record, + local: { + generation: nextGeneration(record.local.generation), + scopes: normalizedScopes, + }, + }); + return devices; + }).then(() => this.requiredSnapshot(deviceId)); + } + + onDeviceRevoked(listener: (deviceId: DeviceId) => void): () => void { + this.revocationListeners.add(listener); + return () => this.revocationListeners.delete(listener); + } + + applyHostedGrant( + deviceId: DeviceId, + generation: number, + scopes: readonly RemoteDeviceScope[], + revokedAt?: number, + ): Promise { + return this.mutate((devices) => { + const record = devices.get(deviceId); + if (record === undefined) { + throw new RemoteAuthorityError("unknown_device", "Device is not locally paired"); + } + const normalizedGeneration = positiveInteger(generation, "generation"); + const normalizedScopes = canonicalScopes(scopes); + const normalizedRevokedAt = + revokedAt === undefined ? undefined : nonNegativeInteger(revokedAt, "revokedAt"); + const hosted = record.hosted; + if (hosted !== undefined && normalizedGeneration < hosted.generation) { + throw new RemoteAuthorityError( + "stale_grant_generation", + "Hosted grant generation is stale", + ); + } + if (hosted?.revokedAt !== undefined && normalizedRevokedAt === undefined) { + throw new RemoteAuthorityError( + "grant_conflict", + "Revoked device identity cannot be restored", + ); + } + if (hosted !== undefined && normalizedGeneration === hosted.generation) { + if ( + !sameScopes(hosted.scopes, normalizedScopes) || + hosted.revokedAt !== normalizedRevokedAt + ) { + throw new RemoteAuthorityError( + "grant_conflict", + "Hosted grant generation is bound to another value", + ); + } + return devices; + } + devices.set(deviceId, { + ...record, + hosted: { + generation: normalizedGeneration, + scopes: normalizedScopes, + ...(normalizedRevokedAt === undefined ? {} : { revokedAt: normalizedRevokedAt }), + }, + }); + return devices; + }).then(() => { + const snapshot = this.requiredSnapshot(deviceId); + if (snapshot.hostedRevoked) this.publishRevocation(deviceId); + return snapshot; + }); + } + + revokeLocalDevice(deviceId: DeviceId, now = Date.now()): Promise { + return this.mutate((devices) => { + const record = devices.get(deviceId); + if (record === undefined) { + throw new RemoteAuthorityError("unknown_device", "Device is not locally paired"); + } + if (record.local.revokedAt !== undefined) return devices; + devices.set(deviceId, { + ...record, + local: { + generation: nextGeneration(record.local.generation), + scopes: record.local.scopes, + revokedAt: nonNegativeInteger(now, "now"), + }, + }); + return devices; + }).then(() => { + const snapshot = this.requiredSnapshot(deviceId); + this.publishRevocation(deviceId); + return snapshot; + }); + } + + authorize(deviceId: DeviceId, requiredScope: RemoteDeviceScope): RemoteAuthorizationContext { + const record = this.devices.get(deviceId); + if (record === undefined) { + throw new RemoteAuthorityError("unknown_device", "Device is not locally paired"); + } + if (record.local.revokedAt !== undefined || record.hosted?.revokedAt !== undefined) { + throw new RemoteAuthorityError("device_revoked", "Device is revoked"); + } + if (record.hosted === undefined) { + throw new RemoteAuthorityError("hosted_grant_missing", "Hosted device grant is unavailable"); + } + const scopes = effectiveScopes(record); + if (!scopes.includes(requiredScope)) { + throw new RemoteAuthorityError("scope_forbidden", "Device does not hold the required scope"); + } + return { + installationId: this.installationId, + deviceId, + localGrantGeneration: record.local.generation, + hostedGrantGeneration: record.hosted.generation, + effectiveScopes: scopes, + }; + } + + runAuthorizedUntilAccepted( + deviceId: DeviceId, + requiredScope: RemoteDeviceScope, + start: (context: RemoteAuthorizationContext) => StartedAuthorizedOperation, + ): Promise { + const admitted = this.serialize(async () => { + const context = this.authorize(deviceId, requiredScope); + const operation = start(context); + void operation.completion.catch(() => undefined); + await operation.acceptance; + return { completion: operation.completion }; + }); + return admitted.then(({ completion }) => completion); + } + + private publishRevocation(deviceId: DeviceId): void { + for (const listener of this.revocationListeners) listener(deviceId); + } + + private requiredSnapshot(deviceId: DeviceId): RemoteDeviceAuthoritySnapshot { + const snapshot = this.snapshot(deviceId); + if (snapshot === undefined) throw new Error("Remote authority mutation lost its device record"); + return snapshot; + } + + private mutate( + operation: ( + devices: Map, + ) => Map, + ): Promise { + return this.serialize(async () => { + const candidate = new Map(this.devices); + const next = operation(candidate); + const state: PersistedAuthorityState = { + version: AUTHORITY_FORMAT_VERSION, + installationId: this.installationId, + devices: [...next.values()].sort((left, right) => + left.deviceId.localeCompare(right.deviceId), + ), + }; + await writeAtomic(this.path, state); + this.devices = next; + }); + } + + private serialize(operation: () => Promise): Promise { + const result = this.tail.then(operation); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} diff --git a/packages/daemon/src/remote-rpc.ts b/packages/daemon/src/remote-rpc.ts new file mode 100644 index 00000000..9a04a57e --- /dev/null +++ b/packages/daemon/src/remote-rpc.ts @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import type { RemoteDeviceScope, RpcMethod } from "@axl/protocol"; + +const REMOTE_RPC_SCOPES = Object.freeze({ + "daemon.info": "observe", + "session.list": "observe", + "session.history": "observe", + "session.ack": "observe", + "session.unsubscribe": "observe", + "session.subscribe": "observe", + "session.workspace.list": "observe", + "session.workspace.read": "observe", + "session.workspace.status": "observe", + "session.workspace.diff": "observe", + "session.send": "steer", + "session.steer": "steer", + "session.followUp": "steer", + "session.interruptAndDeliver": "steer", + "session.queue.enqueue": "steer", + "session.queue.requeue": "steer", + "session.interrupt": "steer", +} as const satisfies Partial>); + +export type RemoteRpcMethod = keyof typeof REMOTE_RPC_SCOPES; + +export function requiredRemoteScope(method: RpcMethod): RemoteDeviceScope | undefined { + return REMOTE_RPC_SCOPES[method as RemoteRpcMethod]; +} + +export function remoteRpcMethods(): readonly RemoteRpcMethod[] { + return Object.keys(REMOTE_RPC_SCOPES) as RemoteRpcMethod[]; +} diff --git a/packages/daemon/test/remote-authority.test.ts b/packages/daemon/test/remote-authority.test.ts new file mode 100644 index 00000000..80ce2188 --- /dev/null +++ b/packages/daemon/test/remote-authority.test.ts @@ -0,0 +1,401 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { mkdtemp, realpath, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { type TestContext } from "node:test"; + +import { type ModelPort, ToolRegistry } from "@axl/kernel"; +import { + type ModelStreamEvent, + hashCanonicalRequest, + parseDeviceId, + parseInstallationId, + parseOperationId, + parseSessionId, +} from "@axl/protocol"; + +import { DeterministicFakeRemoteCryptoAdapter } from "../../protocol/test/support/fake-remote-crypto.ts"; +import { CommandJournal, CommandJournalError } from "../src/command-journal.ts"; +import { AxlDaemon } from "../src/daemon.ts"; +import { RemoteAuthorityError, RemoteDeviceAuthorityStore } from "../src/remote-authority.ts"; +import { remoteRpcMethods, requiredRemoteScope } from "../src/remote-rpc.ts"; + +const installationId = parseInstallationId("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); +const deviceId = parseDeviceId("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); +const daemonEndpointId = parseDeviceId("cccccccc-cccc-4ccc-8ccc-cccccccccccc"); + +async function directory(): Promise { + return mkdtemp(join(tmpdir(), "axl-remote-authority-")); +} + +function replyPort(): ModelPort { + return { + stream() { + return (async function* (): AsyncGenerator { + yield { + type: "completed", + stopReason: "stop", + usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }; + })(); + }, + }; +} + +async function startDaemon( + context: TestContext, + securityMode: "sandboxed" | "unsafe" = "sandboxed", +) { + const root = await directory(); + context.after(() => rm(root, { recursive: true, force: true })); + const cwd = await realpath(root); + const dataDirectory = join(root, "data"); + const daemon = new AxlDaemon({ + socketPath: join(root, "daemon.sock"), + dataDirectory, + securityMode, + sandboxProvider: "fixture", + runtime: () => ({ model: replyPort(), tools: new ToolRegistry(), system: "test" }), + }); + await daemon.start(); + context.after(() => daemon.stop()); + return { daemon, dataDirectory, cwd }; +} + +test("remote RPC scope mapping is explicit and excludes dangerous surfaces", () => { + assert.equal(requiredRemoteScope("daemon.info"), "observe"); + assert.equal(requiredRemoteScope("session.send"), "steer"); + assert.equal(requiredRemoteScope("session.shell"), undefined); + assert.equal(requiredRemoteScope("session.interaction.respond"), undefined); + assert.equal(requiredRemoteScope("provider.auth.login"), undefined); + assert.ok(remoteRpcMethods().length > 0); +}); + +test("intersects local and hosted grants without allowing hosted widening", async () => { + const dataDirectory = await directory(); + const store = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + + await store.registerLocalDevice(deviceId, ["steer", "observe"]); + assert.throws( + () => store.authorize(deviceId, "observe"), + (error) => error instanceof RemoteAuthorityError && error.code === "hosted_grant_missing", + ); + + const snapshot = await store.applyHostedGrant(deviceId, 1, [ + "manage_sessions", + "observe", + "steer", + ]); + assert.deepEqual(snapshot.effectiveScopes, ["observe", "steer"]); + assert.deepEqual(store.authorize(deviceId, "steer"), { + installationId, + deviceId, + localGrantGeneration: 1, + hostedGrantGeneration: 1, + effectiveScopes: ["observe", "steer"], + }); + assert.throws( + () => store.authorize(deviceId, "manage_sessions"), + (error) => error instanceof RemoteAuthorityError && error.code === "scope_forbidden", + ); + + const narrowed = await store.narrowLocalGrant(deviceId, ["observe"]); + assert.equal(narrowed.localGeneration, 2); + assert.deepEqual(narrowed.effectiveScopes, ["observe"]); + await assert.rejects( + store.narrowLocalGrant(deviceId, ["observe", "steer"]), + (error) => error instanceof RemoteAuthorityError && error.code === "grant_conflict", + ); + + assert.equal((await stat(store.path)).mode & 0o777, 0o600); +}); + +test("serializes hosted generations and rejects stale or conflicting updates", async () => { + const store = await RemoteDeviceAuthorityStore.open(await directory(), installationId); + await store.registerLocalDevice(deviceId, ["observe", "steer"]); + + const competing = await Promise.allSettled([ + store.applyHostedGrant(deviceId, 1, ["observe"]), + store.applyHostedGrant(deviceId, 1, ["steer"]), + ]); + assert.equal(competing.filter((result) => result.status === "fulfilled").length, 1); + const rejected = competing.find((result) => result.status === "rejected"); + assert.ok(rejected?.status === "rejected"); + assert.ok(rejected.reason instanceof RemoteAuthorityError); + assert.equal(rejected.reason.code, "grant_conflict"); + + await assert.rejects( + store.applyHostedGrant(deviceId, 0, ["observe"]), + /generation must be positive/, + ); + + await store.applyHostedGrant(deviceId, 2, ["observe", "steer"]); + await assert.rejects( + store.applyHostedGrant(deviceId, 1, ["observe"]), + (error) => error instanceof RemoteAuthorityError && error.code === "stale_grant_generation", + ); +}); + +test("persists irreversible revocation and rechecks it after fake E2EE authentication", async () => { + const dataDirectory = await directory(); + const store = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + await store.registerLocalDevice(deviceId, ["observe", "steer"]); + await store.applyHostedGrant(deviceId, 1, ["observe", "steer"]); + + const deviceCrypto = new DeterministicFakeRemoteCryptoAdapter(deviceId, daemonEndpointId); + const daemonCrypto = new DeterministicFakeRemoteCryptoAdapter(daemonEndpointId, deviceId); + const envelope = await deviceCrypto.seal( + daemonEndpointId, + new TextEncoder().encode('{"method":"test.ping"}'), + ); + const authenticated = await daemonCrypto.open(envelope); + assert.equal(authenticated.authenticatedDeviceId, deviceId); + assert.equal(store.authorize(authenticated.authenticatedDeviceId, "steer").deviceId, deviceId); + + await store.revokeLocalDevice(deviceId, 1_900_000_000_000); + assert.throws( + () => store.authorize(authenticated.authenticatedDeviceId, "steer"), + (error) => error instanceof RemoteAuthorityError && error.code === "device_revoked", + ); + + const restored = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + assert.equal(restored.snapshot(deviceId)?.locallyRevoked, true); + assert.throws( + () => restored.authorize(deviceId, "observe"), + (error) => error instanceof RemoteAuthorityError && error.code === "device_revoked", + ); + await assert.rejects( + restored.registerLocalDevice(deviceId, ["observe", "steer"]), + (error) => error instanceof RemoteAuthorityError && error.code === "grant_conflict", + ); +}); + +test("authorizes before applying durable command idempotency behind fake E2EE", async () => { + const dataDirectory = await directory(); + const store = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + await store.registerLocalDevice(deviceId, ["observe", "steer"]); + await store.applyHostedGrant(deviceId, 1, ["observe", "steer"]); + + const deviceCrypto = new DeterministicFakeRemoteCryptoAdapter(deviceId, daemonEndpointId); + const daemonCrypto = new DeterministicFakeRemoteCryptoAdapter(daemonEndpointId, deviceId); + const sessionId = parseSessionId("dddddddd-dddd-4ddd-8ddd-dddddddddddd"); + const idempotencyKey = parseOperationId("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"); + const params = { sessionId }; + const envelope = await deviceCrypto.seal( + daemonEndpointId, + new TextEncoder().encode(JSON.stringify({ method: "session.interrupt", params })), + ); + const opened = await daemonCrypto.open(envelope); + const journal = await CommandJournal.open(dataDirectory); + let executions = 0; + const execute = () => + store.runAuthorizedUntilAccepted(opened.authenticatedDeviceId, "steer", () => + journal.start( + { + idempotencyKey, + method: "session.interrupt", + requestHash: hashCanonicalRequest("session.interrupt", params), + targetSessionId: sessionId, + }, + async () => { + executions += 1; + await new Promise((resolve) => setTimeout(resolve, 5)); + return { interrupted: false }; + }, + ), + ); + + assert.deepEqual(await Promise.all([execute(), execute()]), [ + { interrupted: false }, + { interrupted: false }, + ]); + assert.equal(executions, 1); + await assert.rejects( + store.runAuthorizedUntilAccepted(opened.authenticatedDeviceId, "steer", () => + journal.start( + { + idempotencyKey, + method: "session.interrupt", + requestHash: hashCanonicalRequest("session.interrupt", { + sessionId: parseSessionId("ffffffff-ffff-4fff-8fff-ffffffffffff"), + }), + }, + async () => ({ interrupted: false }), + ), + ), + (error) => error instanceof CommandJournalError && error.code === "idempotency_conflict", + ); +}); + +test("revocation waits for durable acceptance but not operation completion", async () => { + const dataDirectory = await directory(); + const store = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + await store.registerLocalDevice(deviceId, ["steer"]); + await store.applyHostedGrant(deviceId, 1, ["steer"]); + const journal = await CommandJournal.open(dataDirectory); + const sessionId = parseSessionId("dddddddd-dddd-4ddd-8ddd-dddddddddddd"); + const idempotencyKey = parseOperationId("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"); + let release!: () => void; + const hold = new Promise((resolve) => { + release = resolve; + }); + let completed = false; + + const completion = store + .runAuthorizedUntilAccepted(deviceId, "steer", () => + journal.start( + { + idempotencyKey, + method: "session.interrupt", + requestHash: hashCanonicalRequest("session.interrupt", { sessionId }), + targetSessionId: sessionId, + }, + async () => { + await hold; + return { interrupted: false }; + }, + ), + ) + .finally(() => { + completed = true; + }); + await store.revokeLocalDevice(deviceId, 1_900_000_000_000); + assert.equal(completed, false); + assert.throws( + () => store.authorize(deviceId, "steer"), + (error) => error instanceof RemoteAuthorityError && error.code === "device_revoked", + ); + + release(); + assert.deepEqual(await completion, { interrupted: false }); +}); + +test("makes hosted revocation irreversible for one device identity", async () => { + const store = await RemoteDeviceAuthorityStore.open(await directory(), installationId); + await store.registerLocalDevice(deviceId, ["observe", "steer"]); + await store.applyHostedGrant(deviceId, 1, ["observe", "steer"]); + await store.applyHostedGrant(deviceId, 2, ["observe", "steer"], 1_900_000_000_000); + + assert.throws( + () => store.authorize(deviceId, "observe"), + (error) => error instanceof RemoteAuthorityError && error.code === "device_revoked", + ); + await assert.rejects( + store.applyHostedGrant(deviceId, 3, ["observe", "steer"]), + (error) => error instanceof RemoteAuthorityError && error.code === "grant_conflict", + ); +}); + +test("internal dispatcher enforces scope, method allowlist, identity, and active revocation", async (context) => { + const { daemon, dataDirectory } = await startDaemon(context); + const authority = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + await authority.registerLocalDevice(deviceId, ["observe"]); + await authority.applyHostedGrant(deviceId, 1, ["observe", "steer"]); + const deliveries: unknown[] = []; + const attachment = daemon.attachAuthenticatedRemoteDevice({ + deviceId, + authority, + send: (message) => deliveries.push(message), + }); + + const info = await attachment.request({ + deviceId, + requestId: "11111111-1111-4111-8111-111111111111", + method: "daemon.info", + params: {}, + }); + assert.equal(info.method, "daemon.info"); + assert.deepEqual(info.result, { + securityMode: "sandboxed", + sandboxProvider: "fixture", + }); + assert.deepEqual(deliveries, []); + + await assert.rejects( + attachment.request({ + deviceId, + requestId: "22222222-2222-4222-8222-222222222222", + method: "session.interrupt", + params: { sessionId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd" }, + idempotencyKey: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + }), + (error) => error instanceof RemoteAuthorityError && error.code === "scope_forbidden", + ); + await assert.rejects( + attachment.request({ + deviceId, + requestId: "33333333-3333-4333-8333-333333333333", + method: "connection.ping", + params: {}, + }), + (error) => error instanceof RemoteAuthorityError && error.code === "remote_method_forbidden", + ); + await assert.rejects( + attachment.request({ + deviceId: "ffffffff-ffff-4fff-8fff-ffffffffffff", + requestId: "44444444-4444-4444-8444-444444444444", + method: "daemon.info", + params: {}, + }), + (error) => error instanceof RemoteAuthorityError && error.code === "device_identity_mismatch", + ); + + await authority.revokeLocalDevice(deviceId); + await assert.rejects( + attachment.request({ + deviceId, + requestId: "55555555-5555-4555-8555-555555555555", + method: "daemon.info", + params: {}, + }), + (error) => error instanceof RemoteAuthorityError && error.code === "device_revoked", + ); +}); + +test("internal dispatcher rejects remote mutations in unsafe mode", async (context) => { + const { daemon, dataDirectory } = await startDaemon(context, "unsafe"); + const authority = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + await authority.registerLocalDevice(deviceId, ["observe", "steer"]); + await authority.applyHostedGrant(deviceId, 1, ["observe", "steer"]); + const attachment = daemon.attachAuthenticatedRemoteDevice({ + deviceId, + authority, + send: () => undefined, + }); + + await assert.rejects( + attachment.request({ + deviceId, + requestId: "66666666-6666-4666-8666-666666666666", + method: "session.interrupt", + params: { sessionId: "dddddddd-dddd-4ddd-8ddd-dddddddddddd" }, + idempotencyKey: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + }), + (error) => error instanceof RemoteAuthorityError && error.code === "unsafe_remote_forbidden", + ); +}); + +test("rejects an oversized authority store before parsing", async () => { + const dataDirectory = await directory(); + await writeFile(join(dataDirectory, "remote-authority.json"), new Uint8Array(1024 * 1024 + 1)); + + await assert.rejects( + RemoteDeviceAuthorityStore.open(dataDirectory, installationId), + /exceeds 1048576 bytes/, + ); +}); + +test("rejects a symlinked authority store", async () => { + const dataDirectory = await directory(); + const target = join(dataDirectory, "outside.json"); + await writeFile(target, "{}\n"); + await symlink(target, join(dataDirectory, "remote-authority.json")); + + await assert.rejects( + RemoteDeviceAuthorityStore.open(dataDirectory, installationId), + /must be a regular file/, + ); +}); diff --git a/packages/daemon/test/remote-hosted-path.test.ts b/packages/daemon/test/remote-hosted-path.test.ts new file mode 100644 index 00000000..7f085836 --- /dev/null +++ b/packages/daemon/test/remote-hosted-path.test.ts @@ -0,0 +1,578 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import test, { type TestContext } from "node:test"; + +import { type ModelPort, ToolRegistry } from "@axl/kernel"; +import { + MAX_RELAY_OPAQUE_PAYLOAD_BYTES, + REMOTE_TRANSPORT_VERSION, + encodeRemoteDaemonMessage, + parseAuthenticatedRemoteRequest, + parseCryptoSessionId, + parseDeviceId, + parseIdempotencyKey, + parseInstallationId, + parseRemoteRequestId, + parseSessionId, + parseTransportAttemptId, + type AuthenticatedRemoteRequest, + type ModelStreamEvent, + type OpaqueOutboxRecord, + type RemoteDaemonMessage, + type RequestId, + type RouteId, +} from "@axl/protocol"; +import { + HttpRelayTicketProvider, + OpaqueOutbox, + RemoteHostedDelivery, + RemoteRelayConnection, + type OpaqueOutboxStore, + type OpaqueOutboxTransaction, + type RemoteWebSocket, + type RemoteWebSocketFactory, + type TransportAttemptIdFactory, +} from "@axl/sdk"; +import { DeterministicFakeRemoteCryptoAdapter } from "../../protocol/test/support/fake-remote-crypto.ts"; +import { + InMemoryRelayTicketStore, + RelayTicketService, + createControlPlaneHandler, +} from "../../../services/control-plane/src/index.ts"; +import { AxlDaemon, type AuthenticatedRemoteAttachment } from "../src/daemon.ts"; +import { RemoteDeviceAuthorityStore } from "../src/remote-authority.ts"; + +const repositoryRoot = dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url))))); +const installationId = parseInstallationId("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); +const deviceId = parseDeviceId("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); +const daemonId = parseDeviceId("cccccccc-cccc-4ccc-8ccc-cccccccccccc"); +const cryptoSessionId = parseCryptoSessionId("dddddddd-dddd-4ddd-8ddd-dddddddddddd"); +const requestId = parseRemoteRequestId("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"); +const idempotencyKey = parseIdempotencyKey("ffffffff-ffff-4fff-8fff-ffffffffffff"); + +class MemoryOutboxStore implements OpaqueOutboxStore { + private readonly records = new Map(); + + async transact( + id: RequestId, + operation: (current: OpaqueOutboxRecord | undefined) => OpaqueOutboxTransaction, + ): Promise { + const transaction = operation(this.records.get(id)); + if (transaction.record === undefined) this.records.delete(id); + else this.records.set(id, transaction.record); + return transaction.result; + } + + async list(): Promise { + return [...this.records.values()]; + } +} + +class LoopbackWebSocketFactory implements RemoteWebSocketFactory { + private readonly url: string; + + constructor(url: string) { + this.url = url; + } + + connect(): RemoteWebSocket { + const Constructor = (globalThis as unknown as { WebSocket: new (url: string) => unknown }) + .WebSocket; + return new Constructor(this.url) as RemoteWebSocket; + } +} + +function replyPort(): ModelPort { + return { + stream() { + return (async function* (): AsyncGenerator { + yield { + type: "completed", + stopReason: "stop", + usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }; + })(); + }, + }; +} + +async function freePort(): Promise { + const server = createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert.ok(address !== null && typeof address !== "string"); + const port = address.port; + await new Promise((resolve, reject) => + server.close((error) => (error === undefined ? resolve() : reject(error))), + ); + return port; +} + +async function waitFor( + description: string, + predicate: () => boolean, + timeoutMs = 10_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${description}`); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +async function startRelay(port: number, controlPlaneOrigin: string): Promise { + const child = spawn("mix", ["run", "--no-halt", "test/support/hosted_path_server.exs"], { + cwd: join(repositoryRoot, "services/relay"), + env: { + ...process.env, + MIX_ENV: "test", + AXL_RELAY_TEST_PORT: String(port), + AXL_CONTROL_PLANE_TEST_ORIGIN: controlPlaneOrigin, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout?.on("data", (chunk: Uint8Array) => { + output += Buffer.from(chunk).toString("utf8"); + }); + child.stderr?.on("data", (chunk: Uint8Array) => { + output += Buffer.from(chunk).toString("utf8"); + }); + await Promise.race([ + waitFor("relay startup", () => output.includes("AXL_RELAY_TEST_READY"), 30_000), + once(child, "exit").then(([code]) => { + throw new Error(`Relay exited during startup with ${String(code)}: ${output}`); + }), + ]); + return child; +} + +async function stopRelay(child: ChildProcess): Promise { + if (child.exitCode !== null) return; + child.kill("SIGTERM"); + await Promise.race([ + once(child, "exit").then(() => undefined), + new Promise((resolve) => setTimeout(resolve, 5_000)), + ]); + if (child.exitCode === null) child.kill("SIGKILL"); +} + +function attempts(prefix: string): TransportAttemptIdFactory { + let counter = 0; + return { + create() { + counter += 1; + return parseTransportAttemptId( + `${prefix.slice(0, 24)}-${counter.toString().padStart(12, "0")}`, + ); + }, + }; +} + +async function sealRequest( + crypto: DeterministicFakeRemoteCryptoAdapter, + request: AuthenticatedRemoteRequest, +): Promise { + return crypto.seal(daemonId, new TextEncoder().encode(JSON.stringify(request))); +} + +const runHostedIntegration = + process.env.AXL_RUN_HOSTED_PATH_INTEGRATION === "1" && + spawnSync("mix", ["--version"], { + cwd: join(repositoryRoot, "services/relay"), + stdio: "ignore", + }).status === 0; + +test( + "real hosted path retries exact fake ciphertext through new routes with one durable effect", + { + skip: runHostedIntegration ? false : "set AXL_RUN_HOSTED_PATH_INTEGRATION=1 with Mix installed", + }, + async (context: TestContext) => { + const root = await mkdtemp(join(tmpdir(), "axl-hosted-path-")); + context.after(() => rm(root, { recursive: true, force: true })); + const cwd = await realpath(root); + const dataDirectory = join(root, "daemon-data"); + const socketPath = join(root, "daemon.sock"); + const relayPort = await freePort(); + let hostedGeneration: number | undefined = 1; + let tokenCounter = 0; + let routeCounter = 0; + + const tickets = new RelayTicketService({ + store: new InMemoryRelayTicketStore(), + authorizer: { + async currentGeneration(principal, requested) { + if ( + principal.accountId !== "account-fixture" || + requested.installationId !== installationId + ) { + return undefined; + } + if (requested.role === "device" && requested.deviceId !== deviceId) return undefined; + return hostedGeneration; + }, + }, + proofVerifier: { + async verify(_ticket, requested) { + return Buffer.from(requested.possessionProof).equals(Buffer.from([0, 1, 2, 3, 255])); + }, + }, + relayUrl: "wss://relay.invalid/v1/connect", + randomToken: () => { + tokenCounter += 1; + return `hosted-path-ticket-${tokenCounter}`; + }, + randomId: () => { + routeCounter += 1; + return `11111111-1111-4111-8111-${routeCounter.toString().padStart(12, "0")}`; + }, + }); + const controlPlane = createServer( + createControlPlaneHandler({ + tickets, + publicAuthentication: { + async authenticate(request) { + return request.headers.authorization === "Bearer public-fixture" + ? { accountId: "account-fixture" } + : undefined; + }, + }, + internalAuthentication: { + async authenticate(request) { + return request.headers.authorization === "Bearer internal-fixture"; + }, + }, + }), + ); + controlPlane.listen(0, "127.0.0.1"); + await once(controlPlane, "listening"); + context.after( + () => + new Promise((resolve) => { + controlPlane.closeAllConnections(); + controlPlane.close(() => resolve()); + }), + ); + const controlAddress = controlPlane.address(); + assert.ok(controlAddress !== null && typeof controlAddress !== "string"); + const controlOrigin = `http://127.0.0.1:${controlAddress.port}`; + + let relay = await startRelay(relayPort, controlOrigin); + context.after(() => stopRelay(relay)); + const sockets = new LoopbackWebSocketFactory(`ws://127.0.0.1:${relayPort}/v1/connect`); + const proof = { + async create() { + return { + connectionNonce: "hosted-path-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }; + }, + }; + const ticketProvider = (role: "daemon" | "device") => + new HttpRelayTicketProvider({ + controlPlaneOrigin: controlOrigin, + request: { + installationId, + role, + ...(role === "device" ? { deviceId } : {}), + }, + authenticationHeaders: async () => ({ authorization: "Bearer public-fixture" }), + proof, + allowInsecureLoopbackForTests: true, + }); + const connectionOptions = { + sockets, + reconnect: { + maximumAttempts: 20, + initialDelayMs: 50, + maximumDelayMs: 200, + jitterRatio: 0, + }, + routeWaitMs: 5_000, + } as const; + const daemonConnection = new RemoteRelayConnection({ + ...connectionOptions, + tickets: ticketProvider("daemon"), + }); + const deviceConnection = new RemoteRelayConnection({ + ...connectionOptions, + tickets: ticketProvider("device"), + destinationCryptoSessionId: cryptoSessionId, + }); + + let daemon = new AxlDaemon({ + socketPath, + dataDirectory, + securityMode: "sandboxed", + sandboxProvider: "fixture", + runtime: () => ({ model: replyPort(), tools: new ToolRegistry(), system: "test" }), + }); + await daemon.start(); + context.after(() => daemon.stop()); + let authority = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + await authority.registerLocalDevice(deviceId, ["observe", "steer"]); + await authority.applyHostedGrant(deviceId, 1, ["observe", "steer"]); + const created = await daemon.sessions.create(cwd); + const sessionId = parseSessionId(created.sessionId); + let attachment: AuthenticatedRemoteAttachment = daemon.attachAuthenticatedRemoteDevice({ + deviceId, + authority, + send: () => undefined, + }); + + const deviceCrypto = new DeterministicFakeRemoteCryptoAdapter(deviceId, daemonId); + const daemonCrypto = new DeterministicFakeRemoteCryptoAdapter(daemonId, deviceId); + const daemonAttempts = attempts("22222222-2222-4222-8222"); + const deviceAttempts = attempts("33333333-3333-4333-8333"); + const receivedCiphertexts: Uint8Array[] = []; + const receivedRoutes: RouteId[] = []; + const relayDiagnostics: string[] = []; + deviceConnection.onReceipt((receipt) => + relayDiagnostics.push(`receipt:${receipt.status}:${receipt.attemptId}`), + ); + deviceConnection.onFailure((failure) => + relayDiagnostics.push(`failure:${failure.code}:${failure.attemptId}`), + ); + daemonConnection.onFailure((failure) => + relayDiagnostics.push(`daemon-failure:${failure.code}:${failure.attemptId}`), + ); + let dropNextResponse = false; + let mutationDeliveries = 0; + const bridgeErrors: Error[] = []; + + const sendDaemonMessage = async ( + destinationRoute: RouteId, + message: RemoteDaemonMessage, + ): Promise => { + const ciphertext = await daemonCrypto.seal(deviceId, encodeRemoteDaemonMessage(message)); + daemonConnection.send(destinationRoute, daemonAttempts.create(), ciphertext); + }; + + daemonConnection.onDelivery((delivery) => { + void (async () => { + const opened = await daemonCrypto.open(delivery.opaquePayload); + const request = parseAuthenticatedRemoteRequest( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(opened.plaintext)), + ); + const isMutation = request.requestId === requestId; + if (isMutation) { + receivedCiphertexts.push(delivery.opaquePayload.slice()); + receivedRoutes.push(delivery.sourceRouteId); + } + const response = await attachment.request(request); + if (isMutation) mutationDeliveries += 1; + if (dropNextResponse) { + dropNextResponse = false; + return; + } + if (request.idempotencyKey !== undefined) { + await sendDaemonMessage(delivery.sourceRouteId, { + version: REMOTE_TRANSPORT_VERSION, + type: "daemon_accepted", + requestId: request.requestId, + idempotencyKey: request.idempotencyKey, + }); + } + await sendDaemonMessage(delivery.sourceRouteId, { + version: REMOTE_TRANSPORT_VERSION, + type: "daemon_result", + requestId: response.requestId, + method: response.method, + result: response.result, + }); + })().catch((error: unknown) => { + bridgeErrors.push(error instanceof Error ? error : new Error(String(error))); + }); + }); + + await daemonConnection.start(); + const store = new MemoryOutboxStore(); + const outbox = new OpaqueOutbox(store, deviceAttempts, deviceConnection); + const delivery = new RemoteHostedDelivery({ + connection: deviceConnection, + outbox, + attemptIds: deviceAttempts, + expectedDaemonId: daemonId, + opener: { + async open(ciphertext) { + const opened = await deviceCrypto.open(ciphertext); + return { authenticatedPeerId: opened.authenticatedDeviceId, plaintext: opened.plaintext }; + }, + }, + }); + const messages: RemoteDaemonMessage[] = []; + const deliveryErrors: Error[] = []; + delivery.onMessage((message) => messages.push(message)); + delivery.onError((error) => deliveryErrors.push(error)); + await delivery.start(); + + const sendEphemeralRequest = async (request: AuthenticatedRemoteRequest): Promise => { + await delivery.sendPreparedEphemeral( + cryptoSessionId, + await sealRequest(deviceCrypto, request), + ); + }; + const waitForResult = async (id: RequestId, count = 1) => { + await waitFor( + `daemon result ${id}`, + () => + messages.filter((message) => message.type === "daemon_result" && message.requestId === id) + .length >= count, + ); + const result = messages.findLast( + (message) => message.type === "daemon_result" && message.requestId === id, + ); + assert.ok(result?.type === "daemon_result"); + return result.result; + }; + + const subscribeId = parseRemoteRequestId("44444444-4444-4444-8444-444444444444"); + await sendEphemeralRequest({ + deviceId, + requestId: subscribeId, + method: "session.subscribe", + params: { sessionId }, + }); + const initialSubscription = (await waitForResult(subscribeId)) as { + readonly subscriptionId: string; + readonly snapshot?: { readonly boundaryCursor: string }; + }; + const cursor = initialSubscription.snapshot?.boundaryCursor; + assert.ok(cursor); + const ackId = parseRemoteRequestId("55555555-5555-4555-8555-555555555555"); + await sendEphemeralRequest({ + deviceId, + requestId: ackId, + method: "session.ack", + params: { subscriptionId: initialSubscription.subscriptionId, cursor }, + }); + await waitForResult(ackId); + + const mutation: AuthenticatedRemoteRequest = { + deviceId, + requestId, + idempotencyKey, + method: "session.interrupt", + params: { sessionId }, + }; + const preparedCiphertext = await sealRequest(deviceCrypto, mutation); + const preparedRecord: OpaqueOutboxRecord = { + requestId, + idempotencyKey, + destinationCryptoSessionId: cryptoSessionId, + opaqueEnvelope: preparedCiphertext, + createdAt: Date.now(), + state: "queued_local", + }; + dropNextResponse = true; + await delivery.enqueuePrepared(preparedRecord); + try { + await waitFor("first durable daemon execution", () => mutationDeliveries === 1); + } catch (cause) { + throw new Error( + `Hosted request did not reach the daemon; diagnostics=${JSON.stringify(relayDiagnostics)} bridgeErrors=${bridgeErrors.map((error) => error.message).join("|")}`, + { cause }, + ); + } + + await stopRelay(relay); + relay = await startRelay(relayPort, controlOrigin); + await waitFor("retry through restarted relay", () => mutationDeliveries >= 2, 20_000); + await waitForResult(requestId); + assert.deepEqual(receivedCiphertexts[1], receivedCiphertexts[0]); + assert.notEqual(receivedRoutes[1], receivedRoutes[0]); + + const resumeId = parseRemoteRequestId("66666666-6666-4666-8666-666666666666"); + attachment.close(); + attachment = daemon.attachAuthenticatedRemoteDevice({ + deviceId, + authority, + send: () => undefined, + }); + await sendEphemeralRequest({ + deviceId, + requestId: resumeId, + method: "session.subscribe", + params: { sessionId, after: cursor }, + }); + const resumed = (await waitForResult(resumeId)) as { readonly resumedFrom?: string }; + assert.equal(resumed.resumedFrom, cursor); + + await daemon.stop(); + daemon = new AxlDaemon({ + socketPath, + dataDirectory, + securityMode: "sandboxed", + sandboxProvider: "fixture", + runtime: () => ({ model: replyPort(), tools: new ToolRegistry(), system: "test" }), + }); + await daemon.start(); + authority = await RemoteDeviceAuthorityStore.open(dataDirectory, installationId); + attachment = daemon.attachAuthenticatedRemoteDevice({ + deviceId, + authority, + send: () => undefined, + }); + await delivery.enqueuePrepared(preparedRecord); + await waitFor("duplicate after daemon restart", () => mutationDeliveries >= 3); + await waitForResult(requestId, 2); + + const journal = (await readFile(join(dataDirectory, "commands.jsonl"), "utf8")) + .trim() + .split("\n") + .map( + (line) => JSON.parse(line) as { readonly type: string; readonly idempotencyKey: string }, + ); + assert.equal( + journal.filter( + (record) => record.type === "accepted" && record.idempotencyKey === idempotencyKey, + ).length, + 1, + ); + + const daemonRoute = await deviceConnection.resolve(cryptoSessionId); + assert.throws(() => + deviceConnection.send( + daemonRoute, + deviceAttempts.create(), + new Uint8Array(MAX_RELAY_OPAQUE_PAYLOAD_BYTES + 1), + ), + ); + + hostedGeneration = undefined; + await authority.applyHostedGrant(deviceId, 2, ["observe", "steer"], Date.now()); + const revocation = await fetch(`http://127.0.0.1:${relayPort}/internal/v1/revocations`, { + method: "POST", + headers: { authorization: "Bearer internal-fixture", "content-type": "application/json" }, + body: JSON.stringify({ + version: 1, + installationId, + deviceId, + generation: 2, + effectiveAt: Date.now(), + }), + }); + assert.equal(revocation.status, 200); + await waitFor( + "revoked device disconnect", + () => deviceConnection.state === "disconnected", + 20_000, + ); + + assert.deepEqual(bridgeErrors, []); + assert.deepEqual(deliveryErrors, []); + delivery.close(); + daemonConnection.close(); + }, +); diff --git a/packages/e2ee/.cargo/audit.toml b/packages/e2ee/.cargo/audit.toml new file mode 100644 index 00000000..4e8a1549 --- /dev/null +++ b/packages/e2ee/.cargo/audit.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 VishnuM449 +# SPDX-License-Identifier: Apache-2.0 + +[advisories] +# RUSTSEC-2026-0173 reports maintenance status, not a vulnerability. The exception is limited to +# proc-macro-error2 2.0.1 with checksum +# 11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802 through: +# proc-macro-error2 -> hax-lib-macros -> hax-lib -> libcrux-sha3 -> hpke-rs +# -> openmls_libcrux_crypto. It expires 2026-12-15. Vulnerabilities remain denied. +ignore = ["RUSTSEC-2026-0173"] diff --git a/packages/e2ee/Cargo.lock b/packages/e2ee/Cargo.lock new file mode 100644 index 00000000..ad250663 --- /dev/null +++ b/packages/e2ee/Cargo.lock @@ -0,0 +1,2671 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axl-e2ee" +version = "0.1.0" +dependencies = [ + "openmls", + "openmls_basic_credential", + "openmls_libcrux_crypto", + "openmls_memory_storage", + "openmls_traits", + "redb", + "tls_codec", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-models" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3344d349528f449816cfa85e558c439bdca5a6d8a738cfb21e69f0b2b1952f" +dependencies = [ + "hax-lib", + "pastey", + "rand", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crabgrind" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7459e07732f6de74001fcf73a3fdfbb0f368e4fd8e2392f4a2206a065e6e95" +dependencies = [ + "bindgen", + "cc", + "pkg-config", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf 0.12.4", + "pem-rfc7468", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "hax-lib" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a33cb227f97ee5c419064c31d7c55a7d895f28d8019ccdadc311cc036500c4" +dependencies = [ + "hax-lib-macros", + "num-bigint", + "num-traits", +] + +[[package]] +name = "hax-lib-macros" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28da835a5f153e9f3d0cc1f42ee605a1cfa9093c93348193793d60a1b0908b51" +dependencies = [ + "hax-lib-macros-types", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hax-lib-macros-types" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed327e9a28d6ee018a4a9ba842d4ee27e378c8a591d2beb6f42f32b24a02fcb" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hpke-rs" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812de62ab573b876c6c40d7553bae9402ae3bad95b64af06f964388eecb9b7b1" +dependencies = [ + "hpke-rs-crypto", + "hpke-rs-libcrux", + "hpke-rs-rust-crypto", + "libcrux-sha3", + "log", + "serde", + "subtle", + "tls_codec", + "zeroize", +] + +[[package]] +name = "hpke-rs-crypto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c461da2e0c9c93d875b597f0c78214fb0d2d5c57834b2ef2a2fa057fa20e24" +dependencies = [ + "rand", + "zeroize", +] + +[[package]] +name = "hpke-rs-libcrux" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c1ace95ef11fbbd84527eada5fc1304f66720fc4c008db30d888d62d3c52613" +dependencies = [ + "hpke-rs-crypto", + "libcrux-aead", + "libcrux-ecdh", + "libcrux-hkdf", + "libcrux-kem", + "libcrux-traits", + "rand", + "rand_chacha 0.10.0", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "hpke-rs-rust-crypto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a606ac19851da841862ef5f39d26d6227bfcfb4047417801a66c1f6e6652d71" +dependencies = [ + "aes-gcm", + "chacha20poly1305", + "hkdf 0.13.0", + "hpke-rs-crypto", + "k256", + "ml-kem", + "p256", + "p384", + "rand", + "rand_chacha 0.10.0", + "rand_core 0.10.1", + "sha2 0.11.0", + "subtle", + "x-wing", + "x25519-dalek 2.0.1", + "zeroize", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "ctutils", + "typenum", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "elliptic-curve", +] + +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libcrux-aead" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22acbe68be84b7b41aaba6e39b87036fc4324dee628d94773750bf3d7e906d69" +dependencies = [ + "libcrux-aes", + "libcrux-chacha20poly1305", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-aes" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57cc95b11dbc797b1e169467b1fc4205c4e6ee2ce516a035ad7342ce5f6ae971" +dependencies = [ + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-chacha20poly1305" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537e6eee5cdc9a980014d058784b0be09614f0596df789b114f7833aa9f35d75" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-poly1305", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-curve25519" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4f3cfd5e31cb9745e290ee061222c380ec42884654b09fc7d12a5b0ed63e028" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-ecdh" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a227590b89ff55ce7cd97b5a7468c82d231db8f3b890bb4d4fb6d271e7524d7" +dependencies = [ + "libcrux-curve25519", + "libcrux-p256", + "rand", + "tls_codec", +] + +[[package]] +name = "libcrux-ed25519" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc463d32f41c47e03dc664547596928a13cb3d79c03453a9b3fe654649313982" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-sha2", + "rand_core 0.10.1", +] + +[[package]] +name = "libcrux-hacl-rs" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66106db376ae249af86911aee048cf73ea1f394d6653026fc988dd22cf2a4ace" +dependencies = [ + "libcrux-macros", +] + +[[package]] +name = "libcrux-hkdf" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc94e651dca4d47edbcdd88bb2428ce16a2bd86b7a4e7170da87b505478f9839" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-hmac", + "libcrux-secrets", +] + +[[package]] +name = "libcrux-hmac" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500ad9b32c715161b594172ca1fb11503a1c033b393ff4c0c33505ce51c1943c" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-sha2", + "libcrux-traits", +] + +[[package]] +name = "libcrux-hmac-drbg" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "860985647c344b7357ce950bf8e13f076db47df36132d49b48a6fff30abe5be6" +dependencies = [ + "libcrux-hmac", + "rand", +] + +[[package]] +name = "libcrux-intrinsics" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a0c574d4eb81d0814bc2b91e4a433d7e0b35851b1d62eb5dd95c064f1f76d0" +dependencies = [ + "core-models", + "hax-lib", +] + +[[package]] +name = "libcrux-kem" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "541a7377fb35060892e0620982e224e47419f10da8c212453bf642dafe529691" +dependencies = [ + "libcrux-curve25519", + "libcrux-ecdh", + "libcrux-ml-kem", + "libcrux-p256", + "libcrux-sha3", + "libcrux-traits", + "rand", +] + +[[package]] +name = "libcrux-macros" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd6aa2dcd5be681662001b81d493f1569c6d49a32361f470b0c955465cd0338" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "libcrux-ml-kem" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8160f7d64fd2716b4fd05cc886a042f8dcda18d9206c0d506e2c67bdf97daa" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-sha3", + "libcrux-traits", + "rand", + "tls_codec", +] + +[[package]] +name = "libcrux-p256" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3400732702d578be622257b98cec85e9b0cd34a67f1f06d3ebe9ae34963cdf02" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-sha2", + "libcrux-traits", +] + +[[package]] +name = "libcrux-platform" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9e21d7ed31a92ac539bd69a8c970b183ee883872d2d19ce27036e24cb8ecc4" +dependencies = [ + "libc", +] + +[[package]] +name = "libcrux-poly1305" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a9144949845813a0b8787d08cbba47baabe59c83f3043e558e6e92385c40cc" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", +] + +[[package]] +name = "libcrux-secrets" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79054fc9037cb70d6a546cf094cea7d7df06af5e49d230ba24103d27ccc886f1" +dependencies = [ + "crabgrind", + "hax-lib", +] + +[[package]] +name = "libcrux-sha2" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e46e1be0b77098cc6ce82137864936ecad3a1b9fd4303dd51202ca140dd1e" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-traits", +] + +[[package]] +name = "libcrux-sha3" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c09f5c39afae0528e1f70a3c1e3c6ee649ec1f19dda26fc9b6ea91108fb879ef" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-traits", +] + +[[package]] +name = "libcrux-traits" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa7a21e8c2e8baa8b40f0b176740f2ca4baadd79d1b00e48d6b1e363c42085a" +dependencies = [ + "libcrux-secrets", + "rand", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "ml-dsa" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" +dependencies = [ + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8 0.11.0", + "shake", + "signature 3.0.0", +] + +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "rand_core 0.10.1", + "sha3 0.11.0", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openmls" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b08d90fc020cb5354d5f08ca17711b84c82e2bcc7331753fd94f000d99a8c8" +dependencies = [ + "log", + "openmls_basic_credential", + "openmls_libcrux_crypto", + "openmls_memory_storage", + "openmls_rust_crypto", + "openmls_serialization_helpers", + "openmls_sqlite_storage", + "openmls_test", + "openmls_traits", + "rayon", + "serde", + "serde_bytes", + "thiserror", + "tls_codec", + "zeroize", +] + +[[package]] +name = "openmls_basic_credential" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbd3f0c3422e7c7a8496f042b547b0c28d0793f8ba97feb401966e58196a1e40" +dependencies = [ + "ed25519-dalek", + "ml-dsa", + "openmls_traits", + "p256", + "p384", + "rand_core 0.6.4", + "serde", + "tls_codec", + "zeroize", +] + +[[package]] +name = "openmls_libcrux_crypto" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e6367fb30f91f21e4d30f4f58a8d3b41f96f55c3e4b5acfa1d6d18c9dd4855" +dependencies = [ + "hpke-rs", + "hpke-rs-crypto", + "hpke-rs-libcrux", + "libcrux-aead", + "libcrux-ed25519", + "libcrux-hkdf", + "libcrux-hmac", + "libcrux-hmac-drbg", + "libcrux-sha2", + "libcrux-traits", + "openmls_memory_storage", + "openmls_traits", + "rand", + "tls_codec", +] + +[[package]] +name = "openmls_memory_storage" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce927945a16eaa19302ed5ccef052f01ba68f2ba7ae67e4ac4ff2fc661445364" +dependencies = [ + "hex", + "log", + "openmls_traits", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "openmls_rust_crypto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36bf3fe824ead6e81d22f49a9cd7feffca3494979fac14c1248e19516e3625f1" +dependencies = [ + "aes-gcm", + "chacha20poly1305", + "ed25519-dalek", + "hkdf 0.13.0", + "hmac 0.13.0", + "hpke-rs", + "hpke-rs-crypto", + "hpke-rs-rust-crypto", + "ml-dsa", + "openmls_memory_storage", + "openmls_traits", + "p256", + "p384", + "rand_chacha 0.3.1", + "rand_core 0.10.1", + "rand_core 0.6.4", + "sha2 0.11.0", + "thiserror", + "tls_codec", +] + +[[package]] +name = "openmls_serialization_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e2cf2193541e83ce1a89aaa44c2fd4783abd54853823075a32b60ef032284c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror", +] + +[[package]] +name = "openmls_sqlite_storage" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a48acaffbed1bbed61c5030193e374ff4914331ed5f4a604bd5a4692c0a713" +dependencies = [ + "openmls_traits", + "refinery", + "rusqlite", + "serde", +] + +[[package]] +name = "openmls_test" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b4f925203b593f1ac9b5155d71f74355ef6a3ef34a081a4749bbc92288c761" +dependencies = [ + "openmls_libcrux_crypto", + "openmls_rust_crypto", + "openmls_sqlite_storage", + "openmls_traits", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openmls_traits" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac13edad8698eb1917cbc1dab99591fa1594d84320904fe3fc5121854f95831c" +dependencies = [ + "serde", + "tls_codec", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.2", + "spki 0.8.0", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.2", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redb" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36" +dependencies = [ + "libc", +] + +[[package]] +name = "refinery" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2a344cdb48871e27addeafbbaffab8828cc12cec2b9041119e9bea0c0f551a" +dependencies = [ + "refinery-core", + "refinery-macros", +] + +[[package]] +name = "refinery-core" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24eeafd893124f29183dd6afa9137a27e7bef59250223b8b660005279c60aea4" +dependencies = [ + "async-trait", + "cfg-if", + "log", + "regex", + "rusqlite", + "serde", + "siphasher", + "thiserror", + "time", + "toml", + "url", + "walkdir", +] + +[[package]] +name = "refinery-macros" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90cea6d11a9a4e8a85a884b6305461004101b28ca65dd35ec028e009a898e16" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "refinery-core", + "regex", + "syn 2.0.119", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", +] + +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.2", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tls_codec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18cc98286004cea38f717e2b03d990fc774fbfd38a82720de40e5c94365067c8" +dependencies = [ + "serde", + "serde_bytes", + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "674f41dd95f76cdeb005c83f9444e20cf43daebef37cde9e49a9ca6f4e87b423" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x-wing" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b51507b887016c3925c84591108dadb8c099f776eb04fec6a60ae3519fee856f" +dependencies = [ + "kem", + "ml-kem", + "sha3 0.12.0", + "shake", + "x25519-dalek 3.0.0", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "x25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" +dependencies = [ + "curve25519-dalek 5.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/e2ee/Cargo.toml b/packages/e2ee/Cargo.toml new file mode 100644 index 00000000..92c8c0f9 --- /dev/null +++ b/packages/e2ee/Cargo.toml @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2026 VishnuM449 +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "axl-e2ee" +version = "0.1.0" +edition = "2024" +rust-version = "1.96" +license = "Apache-2.0" +publish = false + +[dependencies] +openmls = { version = "=0.9.0", default-features = false, features = ["draft-ietf-mls-pq-ciphersuites"] } +openmls_basic_credential = { version = "=0.6.0", default-features = false } +openmls_libcrux_crypto = { version = "=0.4.0", default-features = false, features = ["draft-ietf-mls-pq-ciphersuites"] } +openmls_memory_storage = { version = "=0.6.0", default-features = false } +openmls_traits = { version = "=0.6.0", default-features = false } +tls_codec = { version = "=0.5.0", default-features = false, features = ["std"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +redb = { version = "=4.2.0", default-features = false, features = ["std"] } diff --git a/packages/e2ee/DEPENDENCIES.md b/packages/e2ee/DEPENDENCIES.md new file mode 100644 index 00000000..305cfbfc --- /dev/null +++ b/packages/e2ee/DEPENDENCIES.md @@ -0,0 +1,61 @@ + + + +# Dependency record + +## Profile binding + +| Item | Pinned value | +| --- | --- | +| Profile | `axl-e2ee-mls-pq-v1`, revision 1 | +| OpenMLS | 0.9.0, crates.io checksum `b6b08d90fc020cb5354d5f08ca17711b84c82e2bcc7331753fd94f000d99a8c8` | +| Upstream source | tag `openmls-v0.9.0`, commit `3a3e35de3feeca8f6605143c464d5452ae584d43` | +| Provider | `openmls_libcrux_crypto` 0.4.0, checksum `41e6367fb30f91f21e4d30f4f58a8d3b41f96f55c3e4b5acfa1d6d18c9dd4855` | +| Suite | value `0x004e`, `MLS_128_MLKEM768X25519_AES256GCM_SHA384_Ed25519` | +| KEM implementation | upstream `XWingDraft06` | +| Features | `openmls/draft-ietf-mls-pq-ciphersuites`, `openmls_libcrux_crypto/draft-ietf-mls-pq-ciphersuites`; default features disabled | +| Toolchain | rustc 1.96.0 commit `ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96`; Cargo 1.96.0 commit `30a34c682`; `rustfmt`, `clippy`, and `wasm32-unknown-unknown` | +| Native transaction engine | `redb` 4.2.0, checksum `de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36`, `MIT OR Apache-2.0` | +| Implementation lock | SHA-256 `a3727409f1b430200fbdaecd1d7b0779555d8e18be18ce983367dea19b1eabe1` | + +The direct dependencies besides the selected implementation and provider are +`openmls_basic_credential` 0.6.0 for OpenMLS signing-key storage, `openmls_memory_storage` 0.6.0 and +`openmls_traits` 0.6.0 for transaction-local provider composition, `tls_codec` 0.5.0 for the +OpenMLS wire encoding API, and `redb` 4.2.0 for native durable transactions. The OpenMLS crates were +already members of the approved candidate graph. `redb` is the approved Session 40B addition. +It is pure Rust and adds only itself to the selected external closure because its sole normal +platform dependency, `libc` 0.2.189, was already selected. It requires no database binary, C/C++ +compiler, native database library, `pkg-config`, vcpkg, Clang, Java, or code generator. No binding, +transport, Signal, or libsignal dependency is selected. Cargo's lock resolution records OpenMLS's +optional/development SQLite alternatives, but `cargo tree --locked --target all --edges +normal,build` confirms they are not in this package's selected build graph. + +The selected normal/build closure contains 156 external package/version pairs on `--target all`, +a net increase of one from Session 40A and 41 over Session 30's 115-row inventory. Reconciliation adds 54 exact entries, primarily the +signing implementation closure pulled by `openmls_basic_credential` 0.6.0, and removes 14 +browser-only entries that are not selected without OpenMLS's Session 50 `js` feature. The Session +30 disposable spike declared the same credential crate, but its inventory table omitted that +branch. `Cargo.lock` is authoritative for this package and also records optional, target-specific, +and dependency-development alternatives that are not selected by the normal/build tree. + +## Maintenance exception + +| Field | Value | +| --- | --- | +| Package | `proc-macro-error2` 2.0.1 | +| Checksum | `11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802` | +| Complete path | `proc-macro-error2 2.0.1 -> hax-lib-macros 0.3.7 -> hax-lib 0.3.7 -> libcrux-sha3 0.0.10 -> hpke-rs 0.7.0 -> openmls_libcrux_crypto 0.4.0` | +| Reason | Unmaintained transitive procedural macro in the exact approved OpenMLS/libcrux graph; no vulnerability is reported | +| Expiry | 2026-12-15, or the next relevant OpenMLS/libcrux release, whichever comes first | +| Scope | Maintenance warning only; no vulnerability suppression | + +The exception appears in both `.cargo/audit.toml` and `deny.toml`. Every vulnerability finding +remains fatal. Re-evaluate and remove the exception as soon as upstream removes the dependency. + +## License policy + +`deny.toml` permits only the reviewed Apache-2.0, MIT, ISC, BSD-2-Clause, BSD-3-Clause, Unlicense, +Unicode-3.0, and MPL-2.0 expressions. MPL-2.0 applies to the unmodified `hpke-rs` family at file +scope and does not relicense Axl. Distribution packaging must retain all applicable third-party +license texts and notices. Final packaging review remains a production-release gate. `redb` is used under Apache-2.0 and its +license text and notices must be retained in source and binary distributions. diff --git a/packages/e2ee/README.md b/packages/e2ee/README.md new file mode 100644 index 00000000..3f8d8431 --- /dev/null +++ b/packages/e2ee/README.md @@ -0,0 +1,56 @@ + + + +# Axl endpoint E2EE core + +This package is the transport-independent OpenMLS core and native durable adapter for revision 1 +of Axl's private `axl-e2ee-mls-pq-v1` profile. + +It provides: + +- one daemon and one device per group; +- canonical revision 1 pairing invitation and claim encoding, signatures, comparison values, and + crate-private in-memory failed-claim accounting for known invitations; durable unknown-invitation + lookup remains Session 50.2 work; +- bounded KeyPackage and Welcome handling; +- canonical Axl credential and AAD validation; +- bidirectional private application messages; +- phone-owned self-Update proposals and daemon-only commits; +- immutable prepared envelopes and a platform-neutral transaction contract; +- a native redb adapter with per-pair databases, immediate-durability two-phase commits, exact-byte + outbox recovery, durable acknowledgement and bounded retry retention, accepted-message-before- + plaintext behavior, authenticated metadata manifests, restart-stable previous-epoch windows, and + deterministic fault injection; +- injected active-only envelope-key and monotonic rollback-anchor interfaces with crash + reconciliation for prepared keys; +- fail-closed creation recovery serialized across threads and processes by an OS-backed per-session + lifecycle claim; cleanup requires proof that no cryptographic state committed, while open finishes + publication of authenticated state and removes only a stale `.initializing` marker. + +The package does not provide transport, relay routing, accounts, authorization, completed platform +bindings, browser persistence, or presentation behavior. The durable API reloads committed OpenMLS +and signer state inside every native transaction, so rolled-back state and prepared handles cannot +be reused. See [`STORAGE.md`](STORAGE.md) for the schema, transaction order, migrations, rollback +detection, erasure boundary, and explicit exclusions. Session 50's approved platform boundary is in +[`../../docs/architecture/e2ee-platform-bindings.md`](../../docs/architecture/e2ee-platform-bindings.md). +Browser transaction evidence remains required, but browser pairing stays disabled until an +independent rollback anchor or reviewed peer-witness design is approved. Keychain, Android Keystore, +generated mobile SDKs, and mobile applications remain Phase 13 work. + +Revision 1 uses OpenMLS 0.9.0 and `openmls_libcrux_crypto` 0.4.0 with suite value `0x004e` and the +upstream `XWingDraft06` KEM implementation. It has no classical-only fallback. Axl does not claim +IETF draft-06 interoperability. + +## Checks + +```sh +cargo test --locked +cargo test --locked persistence_tests +cargo fmt --check +cargo clippy --locked --all-targets -- -D warnings +cargo audit --deny warnings +cargo deny check +``` + +The exact toolchain is in `rust-toolchain.toml`. The implementation dependency and maintenance +exception record is in `DEPENDENCIES.md`. diff --git a/packages/e2ee/STORAGE.md b/packages/e2ee/STORAGE.md new file mode 100644 index 00000000..fb5424e8 --- /dev/null +++ b/packages/e2ee/STORAGE.md @@ -0,0 +1,236 @@ + + + +# Native E2EE storage schema + +Status: Session 40B native schema, version 1 + +## Database ownership + +Each redb file is permanently bound to one `crypto_session_id`, profile +`axl-e2ee-mls-pq-v1`, and profile revision `1`. The filename is the lowercase hexadecimal +encoding of the 16-byte crypto session ID. Relay route IDs are never persisted. + +Creation and opening are separate operations. The initial anchor is validated before a durable file +is created. Creation acquires an exclusive operating-system lock on a permanent per-session +`.lifecycle.lock` file before publishing a restrictive external `.initializing` marker. It holds the +claim through the `ready` commit, marker removal, and parent-directory synchronization. The database +lifecycle remains `initializing` until the first encrypted state, operation record, anchor +advancement, and wrapping-key activation complete. Marker presence does not prove the database +lifecycle. `mark_ready` first commits `ready`, then removes the marker and synchronizes the parent +directory. If a crash leaves either complete authenticated `initializing` state or a marker beside a +`ready` database, opening holds the same lifecycle claim while it validates the schema, session and +profile binding, authenticated durable state, current external key, rollback anchor, epoch, and +authenticator. It finishes an interrupted `ready` commit when necessary, removes only the stale +marker, and opens normally. Committed initialization recognizes both daemon group state with a +48-byte epoch authenticator and pre-join phone KeyPackage state at epoch zero with an intentionally +empty authenticator. In both cases final publication still requires AEAD decryption, durable-manifest +validation, typed daemon or phone loading, external-key reconciliation, and rollback reconciliation. + +`discard_interrupted_creation` acquires the lifecycle claim before inspecting either path. It removes +a marker with no database or a bound `initializing` database only when generation, rollback, epoch, +authenticator, pending erasure, encrypted state, operations, outbox, and accepted-message tables all +prove that no cryptographic state committed. Complete committed state is recovered by normal open, +not deleted. Cleanup rejects `ready`, committed, malformed, unreadable, unsupported, mismatched, +symlinked, or otherwise unprovable databases without calling session-key destruction or deleting the +database. The claim remains held through key destruction, database and marker deletion, and parent +synchronization. A competing creator, opener, or cleanup receives `LifecycleBusy`. The lock file is +never used as lifecycle evidence and is deliberately retained; the operating system releases its +claim automatically when a process exits. A consumed anchor requires a fresh crypto session ID. +Creation otherwise rejects an existing file, and opening rejects a missing file. Paths are +canonicalized, symlink roots and database files are rejected, and Unix directories and files are +restricted to modes `0700` and `0600` respectively. + +Every security-sensitive write transaction explicitly selects `redb::Durability::Immediate` and +enables redb two-phase commit. Persistent savepoints are not used. + +## Version 1 tables + +| Table | Key | Value | +| --- | --- | --- | +| `metadata_v1` | fixed numeric field ID | schema version, lifecycle, crypto session binding, profile binding, generation, rollback counter, committed epoch, epoch authenticator, and pending obsolete wrapping-record ID | +| `encrypted_state_v1` | fixed current-state key | versioned AES-256-GCM envelope containing the complete OpenMLS provider image, restart clock state, and authenticated durable-record manifest | +| `operations_v1` | 16-byte operation ID | canonical input fingerprint, committed generation, and exact typed result | +| `outbox_v1` | 16-byte operation ID | stable crypto session ID, logical message ID, class, epoch, profile revision, retry state, exact MLS ciphertext, and optional commit ID, target epoch, and epoch authenticator | +| `accepted_messages_v1` | 16-byte operation ID | stable crypto session ID, logical message ID, class, epoch, profile revision, and acknowledgement state | + +The encrypted provider image includes OpenMLS group state, signer material, replay state, retained +past-epoch deadlines, the last accepted wall-clock value, endpoint identity binding, the +cryptographically authenticated durable-record manifest, and any unacknowledged receive result. A +receive result is sealed before its plaintext is released. It is deleted from the next encrypted +image after acknowledgement. Consumed MLS message keys are not reconstructed. + +The manifest is a SHA-384 digest over domain-separated SHA-384 digests of cryptographic metadata, +operation, outbox, and accepted-message entries. The initialization/ready publication marker is +excluded: changing it can only block opening or reach the ordinary encrypted-state validation; it +cannot make missing or altered cryptographic state valid. The manifest is stored inside the +AES-256-GCM state envelope and checked before any durable record is trusted. Modification of any logical ID, class, profile, +session binding, acknowledgement, retry state, operation mapping, or exact ciphertext quarantines +the group. + +## Transaction order + +1. Acquire Axl's per-database operation mutex. It covers external-key reconciliation, state load, + the redb writer transaction, anchor advancement, and obsolete-key erasure. +2. Reconcile prepared external keys, activating the key referenced by committed current state and + removing unreferenced inactive records. +3. Authenticate the durable-record manifest, reconcile the monotonic anchor, and only then erase + the obsolete active key referenced by committed metadata. +4. Start a read-write transaction with immediate durability and two-phase commit. +5. Compare the expected generation and rollback counter with both the database and injected anchor. +6. Decrypt and load only the committed provider image, persisted clock state, and retained replay + identities. +7. Check the operation ID and canonical input fingerprint. +8. Run the state-advancing OpenMLS operation against transaction-local storage. +9. Insert the exact outbox or accepted-message record, operation result, successor generation, + rollback counter, epoch, authenticator, and obsolete wrapping-record reference. +10. Compact acknowledged operation, outbox, accepted-message, and fingerprint records beyond the + retry horizon, then calculate their authenticated manifest. +11. Generate a fresh state DEK and nonce with the libcrux provider and prepare its external inactive + wrapping record. +12. Encrypt the complete successor state and manifest with upstream AES-256-GCM and insert it into + the same redb transaction. +13. Commit redb durably. +14. Activate the committed current-state key. +15. Advance the external monotonic anchor. +16. Only then erase the obsolete active key. +17. Only then return bytes for transmission or plaintext for processing. + +A non-poisoned commit error has an uncertain outcome. Recovery closes and reopens redb, activates +and validates the key referenced by committed current state, authenticates the durable manifest, +advances or verifies the external anchor, and only then erases the obsolete key. It finally reads +the authenticated operation record and returns its exact result when present. `load` accepts active +keys only; prepared-but-inactive keys become loadable solely through idempotent committed-key +activation or prepared-record reconciliation. +Every operation reconstructs `MlsGroup` and its signer from the committed encrypted image after the +transaction starts. Failed, rolled-back, and completed operations retain no reusable in-memory group +or prepared handle. + +## Browser transaction equivalence + +The transaction order above is normative for synchronous native stores. IndexedDB transactions +cannot safely remain active across arbitrary asynchronous OpenMLS and WebCrypto work because they +become inactive when control returns to the event loop without another queued request. The browser +adapter therefore uses the serialized prepare-and-compare protocol in +[`docs/architecture/e2ee-platform-bindings.md`](../../docs/architecture/e2ee-platform-bindings.md). + +While the worker and lock callback remain alive, the reviewed adapter uses an exclusive per-session +Web Lock to prevent another cooperative same-origin endpoint from becoming the writer. One short +strict IndexedDB read-write transaction then compares the generation and rollback evidence and +atomically writes the complete successor state, operation result, and exact outbox or +accepted-message record. Nothing is sent and no plaintext is released before the transaction +completes. Conflict, abort, worker loss, or ambiguous completion destroys transient state and reloads +only committed state. The internal pending mutation is not part of the public platform ABI and is +not a reusable transaction handle. Suspension, freezing, restoration, and termination behavior +must be verified separately in every supported browser. + +This equivalence does not relax rollback detection. IndexedDB, persistent-storage permission, and a +non-extractable WebCrypto key do not supply an independent monotonic anchor. Browser pairing remains +disabled until that requirement is met by a separately approved design. + +## Planned Session 50.2 pre-pair records + +The current Session 40B schema implements and recovers device pre-join state. It does not implement +or recover daemon pending-invitation state. Daemon pending-invitation storage is a Session 50.2 +requirement, not an implemented Session 40B capability. + +Before implementation, PR 50.2 must define the pending-invitation table or encrypted-state +representation, schema-version and migration decision, initialization recovery, lifecycle-lock +behavior, authenticated-manifest coverage, cleanup behavior, and fault-injection tests. The planned +record includes the nonce, invitation and identity binding, lifecycle state, a bounded set of at most +five eligible failed-claim hashes and terminal results, and the accepted claim hash and exact result. +It must live in the daemon's per-session transactional store and commit before QR bytes are returned. +Until that work merges, the current implementation cannot create or recover daemon +pending-invitation state. + +The currently implemented device side owns its durable pre-join signer, KeyPackage private +material, exact KeyPackage bytes, KeyPackage operation record, and existing profile and session +binding. A pre-join device database is valid at epoch zero with an empty epoch authenticator only +when its authenticated manifest and typed device state prove that the KeyPackage operation +committed. Cleanup never turns an ambiguous pre-join database into a reusable empty store. + +PR 50.1 and PR 50.2 must add the invitation hash, exact claim bytes, claim expiry and accounting, and +associated lifecycle metadata before claim publication can use the planned contract. Once those +records exist, pairing expiry or protected-state loss requires a fresh device ID, crypto session ID, +KeyPackage, and group ID. Neither endpoint stores a relay route. Hosted services may later store only +the reviewed nonce hash and opaque artifacts. + +## Retry, replay, and clock retention + +Pending outbox and unacknowledged receive operations are never pruned. Public acknowledgement APIs +durably transition outbox retry state to `Acknowledged` and remove sealed receive plaintext. +Acknowledged idempotency, replay-identity, and fingerprint records remain available for 4,096 +successor generations, then compact atomically with the next encrypted manifest. Network and SDK +layers must not retry an already acknowledged operation after that horizon and must never reuse an +operation ID. More than 4,096 simultaneous unacknowledged operations fails closed with +`RetentionExceeded` until acknowledgements make progress. Close/reopen tests pass beyond the +configured test retry horizon for both outbox and receive identities. + +Previous-epoch deadlines are absolute milliseconds from the injected wall clock and remain bounded +to the profile's two retained epochs. The last observed wall-clock value is encrypted with endpoint +state. A clock value lower than that committed value fails closed with `ClockRollback`; it never +extends the five-minute grace window. Tests cover allowed previous-epoch receive after restart, +expiry after five minutes, and clock rollback. + +## Fault-injection coverage + +The native close/reopen suite injects deterministic failures at every Session 40B boundary: + +| Boundary | Required recovery evidence | +| --- | --- | +| Before OpenMLS writes; during provider writes | Reopen sees the old generation and retry performs one fresh operation | +| Before exact-ciphertext insertion; after insertion; before commit | Transaction abort leaves neither successor state nor outbox record | +| After durable commit before success; after commit before network send | Operation lookup returns the committed byte-identical ciphertext without MLS encryption | +| During receive/replay writes | Plaintext is not returned; reopen sees old replay state and a retry commits once | +| Before receiver acknowledgement; after acknowledgement loss | Reopen returns the sealed committed plaintext once; acknowledgement retry is idempotent | +| During restart/reload | Opening fails closed; a later explicit reopen deterministically loads committed state | +| During current-key activation, prepared-key reconciliation, anchor recovery, wrapping-record replacement, or erasure | Recovery activates and authenticates current state, advances the anchor, and only then erases the obsolete key | +| During duplicate operation IDs | Matching input recovers the prior exact result; conflicting input is rejected | +| During generation conflicts | A queued real operation reloads the committed generation and succeeds exactly once | +| After marker creation but before a provable schema | Reopen and cleanup reject the malformed database without deleting its file or external keys | +| After schema commit but before any cryptographic state commits | Reopen reports `InitializationIncomplete`; explicit cleanup verifies the binding, `initializing` lifecycle, and absence of committed state before removing the marker, file, and session key records | +| After cryptographic state commits but before the `ready` lifecycle commit | Cleanup refuses destruction; open authenticates and reconciles the committed state, commits `ready`, and finishes publication | +| After the `ready` lifecycle commit but before marker removal | Open authenticates and reconciles committed state, removes only the stale marker, synchronizes the parent directory, and preserves exact operation results, epoch state, database contents, and external keys | +| While another creator, opener, or cleanup holds the lifecycle claim | The competing operation fails closed with `LifecycleBusy`; no path or external key record is changed | + +Additional real temporary-database tests close and reopen after application sends, receives, update +proposals, commits, and epoch changes. They verify per-group writer serialization, parallel progress +for separate group databases, schema and rollback quarantine, exact-byte retry, and absence of test +plaintext and exposed DEKs from the database file. Tests also tamper each durable metadata table, +exercise active-only key loading, reconcile orphan prepared keys, and run real concurrent committing +operations against one group. + +## Migrations and failure policy + +Schema and encrypted-state formats have independent explicit version fields. Version 1 has no +predecessor and therefore no migration. Future migrations must be one-way transactions with fixture +coverage. A newer schema, downgrade, malformed record, AEAD failure, missing wrapping record, +crypto-session/profile mismatch, generation mismatch, rollback, or epoch-authenticator mismatch +quarantines the group or fails opening. None triggers automatic reset. + +## At-rest boundary + +redb stores encrypted MLS state and opaque wrapping-record identifiers. It never stores a DEK or +wrapping key. The injected `EnvelopeKeyStore` keeps wrapping records outside the redb snapshot +domain and must support enumeration and reconciliation of inactive prepared records per crypto +session. `load` rejects inactive records; `activate`, `erase`, reconciliation, and session destruction +are idempotent. Session destruction is available only for explicit cleanup of an initializing +database. The injected `RollbackAnchor` keeps monotonic state outside the database snapshot domain. +Both dependencies must report availability or the adapter fails closed. + +The implementation uses the pinned libcrux provider's CSPRNG and AES-256-GCM. It defines no KDF or +new cryptographic primitive. A fresh 256-bit DEK protects each successor state image. AAD binds the +schema, profile revision, crypto session, generation, rollback counter, and epoch. + +Deletion of an obsolete wrapping record makes the old encrypted image unusable only within the +security properties of the future platform key implementation. This implementation does not claim +forensic erasure from redb page reuse, file deletion, checkpointing, compaction, or filesystem +operations. Filesystem snapshots, backups, crash dumps, storage-controller caches, and physical +media are excluded. Session 50 evaluates browser WebCrypto and IndexedDB behavior without claiming +that they supply an independent rollback anchor. Keychain, Android Keystore, generated mobile SDKs, +and production mobile applications remain Phase 13 work. + +The monotonic anchor detects a database older than the last anchored commit. The peer epoch +authenticator detects a divergent epoch once authenticated peer evidence is available. Rollback of +the database and anchor together, rollback before anchor advancement becomes durable, and loss of +all peer evidence are not claimed to be detectable. diff --git a/packages/e2ee/deny.toml b/packages/e2ee/deny.toml new file mode 100644 index 00000000..064ad690 --- /dev/null +++ b/packages/e2ee/deny.toml @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: 2026 VishnuM449 +# SPDX-License-Identifier: Apache-2.0 + +[graph] +targets = [ + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "wasm32-unknown-unknown", +] +all-features = false + +[advisories] +# Maintenance-only exception. This does not suppress vulnerability advisories. +# Checksum: 11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802 +# Complete path: proc-macro-error2 2.0.1 -> hax-lib-macros 0.3.7 -> hax-lib 0.3.7 +# -> libcrux-sha3 0.0.10 -> hpke-rs 0.7.0 -> openmls_libcrux_crypto 0.4.0 +# Reason: exact approved OpenMLS/libcrux graph; unmaintained procedural macro, no vulnerability. +# Owner decision: Session 30 dependency approval. Expiry: 2026-12-15. +ignore = [ + { id = "RUSTSEC-2026-0173", reason = "Maintenance-only exception for the exact approved transitive path; expires 2026-12-15; no vulnerability suppression" }, +] + +[licenses] +confidence-threshold = 0.8 +allow = [ + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "MIT", + "MPL-2.0", + "Unicode-3.0", + "Unlicense", +] + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/packages/e2ee/fixtures/v1/README.md b/packages/e2ee/fixtures/v1/README.md new file mode 100644 index 00000000..39d194a1 --- /dev/null +++ b/packages/e2ee/fixtures/v1/README.md @@ -0,0 +1,22 @@ + + + +# Revision 1 pairing fixtures + +Native Rust is the sole producer of these canonical TLS fixtures. + +- `pairing-invitation.tls` is a valid signed invitation. +- `pairing-claim-v1.tls` is its valid signed claim with a real OpenMLS KeyPackage. +- `pairing-claim-v1-maximum.tls` is the structurally canonical 17,320-byte schema-boundary value. It intentionally uses maximum-width fields and is not a semantically valid profile claim. +- `expected.txt` contains non-secret transcript metadata, SHA-384 digests, and the comparison value. + +The installation, device, and crypto-session identifiers use canonical UUIDv7 version and RFC 4122 +variant bits. Account IDs remain governed by their separate account-ID contract. + +The generator is the opt-in `generate_checked_in_native_fixtures` test in `src/pairing.rs`. Run it explicitly with: + +```sh +AXL_REGENERATE_PAIRING_FIXTURES=1 cargo test --locked pairing::tests::generate_checked_in_native_fixtures -- --exact +``` + +The generator uses the production OpenMLS/libcrux random provider and the production Ed25519 credential implementation. The binary invitation contains a test-only random nonce because the nonce is part of the QR payload. The nonce is not duplicated in the manifest, test names, diagnostics, or assertion messages. No private signing key or KeyPackage private state is checked in. diff --git a/packages/e2ee/fixtures/v1/expected.txt b/packages/e2ee/fixtures/v1/expected.txt new file mode 100644 index 00000000..c73239a5 --- /dev/null +++ b/packages/e2ee/fixtures/v1/expected.txt @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2026 VishnuM449 +# SPDX-License-Identifier: Apache-2.0 +# Test-only native Rust pairing fixtures. No private signing key or standalone nonce is included. +profile_id=axl-e2ee-mls-pq-v1 +profile_revision=1 +account_id=2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a +installation_id=2b2b2b2b2b2b7b2bab2b2b2b2b2b2b2b +crypto_session_id=2c2c2c2c2c2c7c2cac2c2c2c2c2c2c2c +issued_at_ms=1000000 +expires_at_ms=1600000 +daemon_credential_sha384=979243710bf666250138643d0c9ca897c7f1b52fe09ebace80c6eabc0b4c35c4fdb5b02eb83c32dbd235ad7b37891234 +device_credential_sha384=2d6d4214afd66e6f4e86e3f6ae109e174204773bccdf7a358e822f1bc27bc79c87f47101a94cc6ce9a1d7216d1ef9aa6 +invitation_sha384=3301641b6e8d1c9fe263f1b72f4c3ea622f625a0529c8fb47c20b516448e2346d2461fb4bcc88923f844875597d91ec5 +claim_sha384=dd1eb970b6a8cb86badcb00f95e0c5e3682046debdd1da3283ccac4347f0628e71628ea88aece9e66939e1ca7baaa998 +comparison=270 783 807 420 +maximum_claim_bytes=17320 diff --git a/packages/e2ee/fixtures/v1/pairing-claim-v1-maximum.tls b/packages/e2ee/fixtures/v1/pairing-claim-v1-maximum.tls new file mode 100644 index 00000000..70a9e1f3 Binary files /dev/null and b/packages/e2ee/fixtures/v1/pairing-claim-v1-maximum.tls differ diff --git a/packages/e2ee/fixtures/v1/pairing-claim-v1.tls b/packages/e2ee/fixtures/v1/pairing-claim-v1.tls new file mode 100644 index 00000000..09a520d7 Binary files /dev/null and b/packages/e2ee/fixtures/v1/pairing-claim-v1.tls differ diff --git a/packages/e2ee/fixtures/v1/pairing-invitation.tls b/packages/e2ee/fixtures/v1/pairing-invitation.tls new file mode 100644 index 00000000..78c94ccb Binary files /dev/null and b/packages/e2ee/fixtures/v1/pairing-invitation.tls differ diff --git a/packages/e2ee/rust-toolchain.toml b/packages/e2ee/rust-toolchain.toml new file mode 100644 index 00000000..4be44aba --- /dev/null +++ b/packages/e2ee/rust-toolchain.toml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2026 VishnuM449 +# SPDX-License-Identifier: Apache-2.0 + +[toolchain] +channel = "1.96.0" +profile = "minimal" +components = ["clippy", "rustfmt"] +targets = ["wasm32-unknown-unknown"] diff --git a/packages/e2ee/src/core_tests.rs b/packages/e2ee/src/core_tests.rs new file mode 100644 index 00000000..56ea3c85 --- /dev/null +++ b/packages/e2ee/src/core_tests.rs @@ -0,0 +1,365 @@ +// SPDX-FileCopyrightText: 2026 VishnuM449 +// SPDX-License-Identifier: Apache-2.0 + +use crate::{ + APPLICATION_MAX_BYTES, Aad, Daemon, Error, Identity, MessageClass, PROFILE_ID, + PROFILE_REVISION, PairContext, Phone, SUITE_VALUE, TransactionOutcome, +}; + +fn id(value: u8) -> [u8; 16] { + [value; 16] +} + +fn context(seed: u8) -> PairContext { + PairContext { + crypto_session_id: id(seed), + group_id: [seed; 32], + account_id: id(seed + 1), + installation_id: id(seed + 2), + device_id: id(seed + 3), + } +} + +fn pair(seed: u8) -> (Daemon, Phone, PairContext, usize) { + let context = context(seed); + let daemon_identity = Identity::daemon(context.account_id, context.installation_id); + let phone_identity = Identity::device( + context.account_id, + context.installation_id, + context.device_id, + ) + .unwrap(); + let (mut phone, key_package) = Phone::create(phone_identity).unwrap(); + let mut daemon = Daemon::create(daemon_identity, context.clone()).unwrap(); + let welcome = daemon.consume_key_package(key_package).unwrap(); + let welcome_size = welcome.bytes().len(); + daemon + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + phone.join(welcome, &context).unwrap(); + phone + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + assert_eq!( + daemon.epoch_authenticator().unwrap(), + phone.epoch_authenticator().unwrap() + ); + (daemon, phone, context, welcome_size) +} + +fn update(daemon: &mut Daemon, phone: &mut Phone, seed: u8) -> Vec { + let proposal_id = id(seed); + let proposal = phone.prepare_self_update(proposal_id, 7).unwrap(); + phone + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + daemon + .receive_update_proposal(proposal.ciphertext(), proposal_id, 7) + .unwrap(); + daemon + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + let commit_id = id(seed.wrapping_add(1)); + let commit = daemon.prepare_commit(commit_id, 7).unwrap(); + let metadata = commit.commit_metadata().unwrap(); + assert_eq!(metadata.target_epoch, commit.epoch() + 1); + assert_eq!(metadata.commit_id.len(), 48); + assert_eq!(metadata.epoch_authenticator.len(), 48); + let bytes = commit.ciphertext().to_vec(); + daemon + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + phone.apply_commit(&bytes, commit_id, 7).unwrap(); + phone + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + assert_eq!( + daemon.epoch_authenticator().unwrap(), + phone.epoch_authenticator().unwrap() + ); + bytes +} + +#[test] +fn profile_and_canonical_aad_are_exact() { + assert_eq!(PROFILE_ID, "axl-e2ee-mls-pq-v1"); + assert_eq!(PROFILE_REVISION, 1); + assert_eq!(SUITE_VALUE, 0x004e); + + let expected = Aad { + crypto_session_id: id(1), + group_id: [2; 32], + source_device_id: id(3), + destination_device_id: id(4), + installation_id: id(5), + message_class: MessageClass::ApplicationRequest, + logical_message_id: id(6), + hosted_grant_generation: 9, + }; + let bytes = expected.encode(); + assert_eq!(Aad::decode(&bytes).unwrap(), expected); + assert!(bytes.len() <= 512); + + let mut wrong_profile = bytes.clone(); + wrong_profile[3] ^= 1; + assert_eq!(Aad::decode(&wrong_profile), Err(Error::WrongProfile)); + let mut noncanonical = bytes.clone(); + noncanonical.push(0); + assert_eq!( + Aad::validate_exact(&noncanonical, &expected), + Err(Error::InvalidAad) + ); +} + +#[test] +fn key_package_welcome_membership_and_identity_are_bounded() { + let expected = context(10); + let daemon_identity = Identity::daemon(expected.account_id, expected.installation_id); + let expected_phone = Identity::device( + expected.account_id, + expected.installation_id, + expected.device_id, + ) + .unwrap(); + let wrong_phone = + Identity::device(expected.account_id, expected.installation_id, id(99)).unwrap(); + + let (_, wrong_package) = Phone::create(wrong_phone).unwrap(); + let mut daemon = Daemon::create(daemon_identity.clone(), expected.clone()).unwrap(); + assert_eq!( + daemon.consume_key_package(wrong_package).unwrap_err(), + Error::InvalidIdentity("KeyPackage identity does not match pair") + ); + + let (mut phone, package) = Phone::create(expected_phone).unwrap(); + let welcome = daemon.consume_key_package(package).unwrap(); + assert!(welcome.bytes().len() <= 16 * 1024); + let mut wrong_group = expected.clone(); + wrong_group.group_id[0] ^= 1; + assert_eq!(phone.join(welcome, &wrong_group), Err(Error::WrongGroup)); +} + +#[test] +fn application_roundtrip_reorders_and_rejects_duplicates_and_mutation() { + let (mut daemon, mut phone, _, welcome_size) = pair(20); + let first = phone.prepare_application(id(1), 3, b"first").unwrap(); + phone + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + let second = phone.prepare_application(id(2), 3, b"second").unwrap(); + phone + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + + assert_eq!( + daemon + .receive_application(second.ciphertext(), id(2), 3) + .unwrap() + .plaintext(), + b"second" + ); + daemon + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + assert_eq!( + daemon + .receive_application(first.ciphertext(), id(1), 3) + .unwrap() + .plaintext(), + b"first" + ); + daemon + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + assert_eq!( + daemon + .receive_application(first.ciphertext(), id(1), 3) + .unwrap_err(), + Error::DuplicateCiphertext + ); + + let fresh = phone.prepare_application(id(3), 3, b"mutate me").unwrap(); + phone + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + let mut mutated = fresh.ciphertext().to_vec(); + *mutated.last_mut().unwrap() ^= 1; + assert_eq!( + daemon.receive_application(&mutated, id(3), 3).unwrap_err(), + Error::InvalidCiphertext + ); + + let reply = daemon.prepare_application(id(4), 3, b"reply").unwrap(); + daemon + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + assert_eq!( + phone + .receive_application(reply.ciphertext(), id(4), 3) + .unwrap() + .plaintext(), + b"reply" + ); + phone + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + + eprintln!( + "measured sizes: welcome={welcome_size}, small_application={}, mutated_application={}", + first.ciphertext().len(), + fresh.ciphertext().len() + ); +} + +#[test] +fn maximum_application_fits_the_relay_payload() { + let (mut daemon, mut phone, _, _) = pair(30); + let plaintext = vec![0x41; APPLICATION_MAX_BYTES]; + let envelope = phone.prepare_application(id(1), 1, &plaintext).unwrap(); + phone + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + assert!(envelope.ciphertext().len() <= 65_497); + assert_eq!( + daemon + .receive_application(envelope.ciphertext(), id(1), 1) + .unwrap() + .plaintext(), + plaintext + ); + eprintln!( + "measured size: 60000-byte application={}", + envelope.ciphertext().len() + ); +} + +#[test] +fn phone_proposes_daemon_commits_and_previous_epoch_is_receive_only() { + let (mut daemon, mut phone, _, _) = pair(40); + let delayed = phone.prepare_application(id(1), 8, b"old epoch").unwrap(); + phone + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + let old_epoch = delayed.epoch(); + + let commit = update(&mut daemon, &mut phone, 10); + assert_eq!(daemon.epoch().unwrap(), old_epoch + 1); + assert_eq!( + daemon + .receive_application(delayed.ciphertext(), id(1), 8) + .unwrap() + .plaintext(), + b"old epoch" + ); + daemon + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + + assert_eq!( + phone.apply_commit(&commit, id(11), 7), + Err(Error::CompetingCommit) + ); + eprintln!("measured size: self-update commit={}", commit.len()); +} + +#[test] +fn stale_and_future_epochs_are_rejected() { + let (mut daemon, mut phone, _, _) = pair(50); + let stale = phone + .prepare_application(id(1), 1, b"eventually stale") + .unwrap(); + phone + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + update(&mut daemon, &mut phone, 20); + update(&mut daemon, &mut phone, 22); + update(&mut daemon, &mut phone, 24); + assert_eq!( + daemon + .receive_application(stale.ciphertext(), id(1), 1) + .unwrap_err(), + Error::StaleEpoch + ); + + let current = phone + .prepare_application(id(2), 1, b"future marker") + .unwrap(); + phone + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + let mut future = current.ciphertext().to_vec(); + // MLSMessage.version (2), wire_format (2), group_id vector length (1), group_id (32), epoch (8). + let epoch_offset = 2 + 2 + 1 + 32; + let future_epoch = daemon.epoch().unwrap() + 2; + future[epoch_offset..epoch_offset + 8].copy_from_slice(&future_epoch.to_be_bytes()); + assert_eq!( + daemon.receive_application(&future, id(2), 1).unwrap_err(), + Error::FutureEpoch + ); +} + +#[test] +fn aad_identity_and_group_mismatches_are_rejected() { + let (mut daemon_a, mut phone_a, _, _) = pair(60); + let (mut daemon_b, mut phone_b, _, _) = pair(70); + + let foreign = phone_b.prepare_application(id(1), 1, b"foreign").unwrap(); + phone_b + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + assert_eq!( + daemon_a + .receive_application(foreign.ciphertext(), id(1), 1) + .unwrap_err(), + Error::WrongGroup + ); + + let message = phone_a.prepare_application(id(2), 4, b"bound aad").unwrap(); + phone_a + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + assert_eq!( + daemon_a + .receive_application(message.ciphertext(), id(2), 5) + .unwrap_err(), + Error::InvalidAad + ); + // The failed authenticated-data check can consume receive ratchet state. The core therefore + // invalidates the in-memory endpoint immediately and requires a durable adapter to reload it. + assert_eq!(daemon_a.epoch(), Err(Error::InactiveAfterRollback)); + + let outbound = daemon_b + .prepare_application(id(3), 1, b"other direction") + .unwrap(); + daemon_b + .finish_transaction(TransactionOutcome::Committed) + .unwrap(); + assert_eq!( + phone_a + .receive_application(outbound.ciphertext(), id(3), 1) + .unwrap_err(), + Error::WrongGroup + ); +} + +#[test] +fn prepared_output_is_immutable_and_retry_is_byte_identical() { + let (_, mut phone, context, _) = pair(80); + let prepared = phone + .prepare_application(id(1), 2, b"retry exactly") + .unwrap(); + let first_attempt = prepared.ciphertext().to_vec(); + let second_attempt = prepared.ciphertext().to_vec(); + assert_eq!(first_attempt, second_attempt); + assert_eq!(prepared.crypto_session_id(), context.crypto_session_id); + assert_eq!( + phone + .prepare_application(id(2), 2, b"must wait") + .unwrap_err(), + Error::TransactionPending + ); + phone + .finish_transaction(TransactionOutcome::RolledBack) + .unwrap(); + assert_eq!(phone.epoch(), Err(Error::InactiveAfterRollback)); +} diff --git a/packages/e2ee/src/lib.rs b/packages/e2ee/src/lib.rs new file mode 100644 index 00000000..6176e208 --- /dev/null +++ b/packages/e2ee/src/lib.rs @@ -0,0 +1,1453 @@ +// SPDX-FileCopyrightText: 2026 VishnuM449 +// SPDX-License-Identifier: Apache-2.0 + +//! Transport-independent endpoint encryption for the Axl private OpenMLS profile. +//! +//! This crate intentionally owns no transport, account, authorization, presentation, or +//! persistence-engine behavior. It does not claim interoperability with an IETF PQ MLS draft. + +use std::{ + collections::{BTreeMap, BTreeSet}, + error::Error as StdError, + fmt, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use openmls::prelude::*; +use openmls_basic_credential::SignatureKeyPair; +use openmls_libcrux_crypto::CryptoProvider; +use openmls_memory_storage::MemoryStorage; +use openmls_traits::OpenMlsProvider; +use tls_codec::{Deserialize as TlsDeserialize, Serialize as TlsSerialize}; + +pub mod pairing; + +#[cfg(not(target_arch = "wasm32"))] +pub mod persistence; + +/// The only profile accepted by revision 1. +pub const PROFILE_ID: &str = "axl-e2ee-mls-pq-v1"; +/// The only supported profile revision. +pub const PROFILE_REVISION: u16 = 1; +/// The private OpenMLS suite code fixed by the profile. +pub const SUITE_VALUE: u16 = 0x004e; +/// Maximum encoded KeyPackage and Welcome length. +pub const HANDSHAKE_MAX_BYTES: usize = 16 * 1024; +/// KeyPackage lifetime fixed by the pairing profile. +pub const KEY_PACKAGE_LIFETIME_SECONDS: u64 = 10 * 60; +/// Maximum authenticated-data length. +pub const AAD_MAX_BYTES: usize = 512; +/// Maximum application plaintext length. +pub const APPLICATION_MAX_BYTES: usize = 60_000; +/// Maximum complete opaque MLS envelope length accepted by the relay contract. +pub const ENVELOPE_MAX_BYTES: usize = 65_497; +/// Number of previous epochs retained for receive-only processing. +pub const MAX_PAST_EPOCHS: u32 = 2; +/// Maximum local grace period for a retained previous epoch. +pub const PAST_EPOCH_MAX_AGE: Duration = Duration::from_secs(5 * 60); + +/// Wall-clock source used to preserve bounded previous-epoch receive windows across restarts. +pub(crate) trait Clock: Send + Sync { + fn now_ms(&self) -> Result; +} + +/// Host wall clock. Durable state rejects clock rollback instead of extending a grace window. +pub(crate) struct SystemClock; + +impl Clock for SystemClock { + fn now_ms(&self) -> Result { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Error::ClockRollback)?; + u64::try_from(duration.as_millis()).map_err(|_| Error::ClockRollback) + } +} + +const SUITE: Ciphersuite = Ciphersuite::MLS_128_MLKEM768X25519_AES256GCM_SHA384_Ed25519; +const ZERO_ID: [u8; 16] = [0; 16]; + +/// The libcrux cryptographic provider paired with Axl-owned replaceable storage. +/// +/// Durable operations clone the committed storage image into this provider only after opening +/// their native transaction. The image is staged back into that same transaction before commit. +pub(crate) struct CoreProvider { + crypto: CryptoProvider, + storage: MemoryStorage, +} + +impl CoreProvider { + fn new() -> Result { + Ok(Self { + crypto: CryptoProvider::new()?, + storage: MemoryStorage::default(), + }) + } + + pub(crate) fn from_storage_values( + values: BTreeMap, Vec>, + ) -> Result { + Ok(Self { + crypto: CryptoProvider::new()?, + storage: MemoryStorage { + values: std::sync::RwLock::new(values.into_iter().collect()), + }, + }) + } + + pub(crate) fn storage_values(&self) -> BTreeMap, Vec> { + self.storage + .values + .read() + .expect("OpenMLS memory storage lock poisoned") + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + } + + pub(crate) fn insert_internal(&self, key: Vec, value: Vec) { + self.storage + .values + .write() + .expect("OpenMLS memory storage lock poisoned") + .insert(key, value); + } + + pub(crate) fn internal(&self, key: &[u8]) -> Option> { + self.storage + .values + .read() + .expect("OpenMLS memory storage lock poisoned") + .get(key) + .cloned() + } + + pub(crate) fn remove_internal(&self, key: &[u8]) { + self.storage + .values + .write() + .expect("OpenMLS memory storage lock poisoned") + .remove(key); + } +} + +impl OpenMlsProvider for CoreProvider { + type CryptoProvider = CryptoProvider; + type RandProvider = CryptoProvider; + type StorageProvider = MemoryStorage; + + fn storage(&self) -> &Self::StorageProvider { + &self.storage + } + + fn crypto(&self) -> &Self::CryptoProvider { + &self.crypto + } + + fn rand(&self) -> &Self::RandProvider { + &self.crypto + } +} + +/// A stable 16-byte UUID representation. Canonical UUID validation belongs to the caller that +/// parses textual UUIDs; this core accepts only the already-canonical bytes. +pub type Id = [u8; 16]; +/// A random, never-reused 32-byte MLS group identifier. +pub type GroupIdBytes = [u8; 32]; + +/// Endpoint role encoded in an Axl MLS basic credential. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum Role { + Daemon = 1, + Device = 2, +} + +/// Identity fields authenticated by an MLS basic credential. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Identity { + pub role: Role, + pub account_id: Id, + pub installation_id: Id, + pub device_id: Id, +} + +impl Identity { + pub fn daemon(account_id: Id, installation_id: Id) -> Self { + Self { + role: Role::Daemon, + account_id, + installation_id, + device_id: ZERO_ID, + } + } + + pub fn device(account_id: Id, installation_id: Id, device_id: Id) -> Result { + if device_id == ZERO_ID { + return Err(Error::InvalidIdentity("device id must not be zero")); + } + Ok(Self { + role: Role::Device, + account_id, + installation_id, + device_id, + }) + } + + fn validate(&self) -> Result<(), Error> { + match self.role { + Role::Daemon if self.device_id != ZERO_ID => { + Err(Error::InvalidIdentity("daemon device id must be zero")) + } + Role::Device if self.device_id == ZERO_ID => { + Err(Error::InvalidIdentity("device id must not be zero")) + } + _ => Ok(()), + } + } + + fn credential_bytes(&self, signature_key: &[u8]) -> Result, Error> { + self.validate()?; + if signature_key.len() != 32 { + return Err(Error::InvalidIdentity( + "Ed25519 public key must be 32 bytes", + )); + } + let mut out = Vec::with_capacity(2 + 1 + 16 * 3 + 1 + PROFILE_ID.len() + 2 + 32); + put_u16(&mut out, 1); + out.push(self.role as u8); + out.extend_from_slice(&self.account_id); + out.extend_from_slice(&self.installation_id); + out.extend_from_slice(&self.device_id); + put_u8_vector(&mut out, PROFILE_ID.as_bytes())?; + put_u16(&mut out, PROFILE_REVISION); + out.extend_from_slice(signature_key); + Ok(out) + } + + fn parse_credential(bytes: &[u8]) -> Result { + let mut cursor = Cursor::new(bytes); + if cursor.u16()? != 1 { + return Err(Error::WrongProfile); + } + let role = match cursor.u8()? { + 1 => Role::Daemon, + 2 => Role::Device, + _ => return Err(Error::InvalidIdentity("unknown credential role")), + }; + let account_id = cursor.array()?; + let installation_id = cursor.array()?; + let device_id = cursor.array()?; + let profile = cursor.u8_vector()?; + if profile != PROFILE_ID.as_bytes() || cursor.u16()? != PROFILE_REVISION { + return Err(Error::WrongProfile); + } + let _: [u8; 32] = cursor.array()?; + cursor.finish()?; + let identity = Self { + role, + account_id, + installation_id, + device_id, + }; + identity.validate()?; + Ok(identity) + } +} + +/// Message classes fixed by revision 1. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum MessageClass { + ApplicationRequest = 1, + ApplicationDelivery = 2, + UpdateProposal = 3, + Commit = 4, + EpochReady = 5, + PairActivation = 6, + ResyncControl = 7, +} + +impl TryFrom for MessageClass { + type Error = Error; + + fn try_from(value: u8) -> Result { + match value { + 1 => Ok(Self::ApplicationRequest), + 2 => Ok(Self::ApplicationDelivery), + 3 => Ok(Self::UpdateProposal), + 4 => Ok(Self::Commit), + 5 => Ok(Self::EpochReady), + 6 => Ok(Self::PairActivation), + 7 => Ok(Self::ResyncControl), + _ => Err(Error::InvalidAad), + } + } +} + +/// Canonical authenticated data for every private MLS message. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Aad { + pub crypto_session_id: Id, + pub group_id: GroupIdBytes, + pub source_device_id: Id, + pub destination_device_id: Id, + pub installation_id: Id, + pub message_class: MessageClass, + pub logical_message_id: Id, + pub hosted_grant_generation: u64, +} + +impl Aad { + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(2 + 1 + PROFILE_ID.len() + 2 + 16 + 32 + 16 * 3 + 1 + 8); + put_u16(&mut out, 1); + // The fixed profile fits the one-byte TLS vector length by construction. + out.push(PROFILE_ID.len() as u8); + out.extend_from_slice(PROFILE_ID.as_bytes()); + put_u16(&mut out, PROFILE_REVISION); + out.extend_from_slice(&self.crypto_session_id); + out.extend_from_slice(&self.group_id); + out.extend_from_slice(&self.source_device_id); + out.extend_from_slice(&self.destination_device_id); + out.extend_from_slice(&self.installation_id); + out.push(self.message_class as u8); + out.extend_from_slice(&self.logical_message_id); + out.extend_from_slice(&self.hosted_grant_generation.to_be_bytes()); + debug_assert!(out.len() <= AAD_MAX_BYTES); + out + } + + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > AAD_MAX_BYTES { + return Err(Error::BoundExceeded("AAD")); + } + let mut cursor = Cursor::new(bytes); + if cursor.u16()? != 1 { + return Err(Error::WrongProfile); + } + let profile = cursor.u8_vector()?; + if profile != PROFILE_ID.as_bytes() || cursor.u16()? != PROFILE_REVISION { + return Err(Error::WrongProfile); + } + let result = Self { + crypto_session_id: cursor.array()?, + group_id: cursor.array()?, + source_device_id: cursor.array()?, + destination_device_id: cursor.array()?, + installation_id: cursor.array()?, + message_class: cursor.u8()?.try_into()?, + logical_message_id: cursor.array()?, + hosted_grant_generation: cursor.u64()?, + }; + cursor.finish()?; + Ok(result) + } + + pub fn validate_exact(bytes: &[u8], expected: &Self) -> Result<(), Error> { + let decoded = Self::decode(bytes)?; + if decoded != *expected || bytes != expected.encode() { + return Err(Error::InvalidAad); + } + Ok(()) + } +} + +/// Stable pair metadata used to reconstruct expected AAD. It contains no relay route. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PairContext { + pub crypto_session_id: Id, + pub group_id: GroupIdBytes, + pub account_id: Id, + pub installation_id: Id, + pub device_id: Id, +} + +impl PairContext { + fn aad( + &self, + sender: Role, + class: MessageClass, + logical_message_id: Id, + generation: u64, + ) -> Aad { + let (source_device_id, destination_device_id) = match sender { + Role::Daemon => (ZERO_ID, self.device_id), + Role::Device => (self.device_id, ZERO_ID), + }; + Aad { + crypto_session_id: self.crypto_session_id, + group_id: self.group_id, + source_device_id, + destination_device_id, + installation_id: self.installation_id, + message_class: class, + logical_message_id, + hosted_grant_generation: generation, + } + } +} + +/// An immutable output prepared for one future platform transaction. +/// +/// An adapter must atomically commit the OpenMLS provider writes and these exact bytes before +/// transmission. Retrying uses [`PreparedEnvelope::ciphertext`] again and never invokes MLS. +#[derive(Debug)] +pub struct PreparedEnvelope { + crypto_session_id: Id, + logical_message_id: Id, + class: MessageClass, + epoch: u64, + commit: Option, + ciphertext: Box<[u8]>, +} + +/// Metadata persisted with a daemon-created commit. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommitMetadata { + pub commit_id: [u8; 48], + pub target_epoch: u64, + pub epoch_authenticator: [u8; 48], +} + +impl PreparedEnvelope { + pub fn crypto_session_id(&self) -> Id { + self.crypto_session_id + } + pub fn logical_message_id(&self) -> Id { + self.logical_message_id + } + pub fn class(&self) -> MessageClass { + self.class + } + pub fn epoch(&self) -> u64 { + self.epoch + } + pub fn commit_metadata(&self) -> Option<&CommitMetadata> { + self.commit.as_ref() + } + pub fn ciphertext(&self) -> &[u8] { + &self.ciphertext + } +} + +/// Outcome reported by the platform transaction enclosing one prepared operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TransactionOutcome { + Committed, + RolledBack, +} + +/// Platform-neutral provider contract Session 40B must implement with durable storage. +/// +/// The transaction's provider is the provider passed to OpenMLS while it advances state. The +/// adapter then stages the immutable envelope or accepted-message identity in that same +/// transaction. Network transmission and plaintext release are forbidden until `commit` returns. +/// No relay route appears in this contract. +pub trait TransactionalProvider { + type TransactionError: StdError + Send + Sync + 'static; + type Transaction<'a>: GroupTransaction + where + Self: 'a; + + fn begin_transaction( + &self, + crypto_session_id: Id, + expected_generation: u64, + expected_rollback_counter: u64, + ) -> Result, Self::TransactionError>; +} + +/// One strict read-write transaction around OpenMLS state and Axl delivery metadata. +pub trait GroupTransaction { + type Provider: OpenMlsProvider; + type Error: StdError + Send + Sync + 'static; + + fn provider(&self) -> &Self::Provider; + fn stage_envelope(&mut self, envelope: &PreparedEnvelope) -> Result<(), Self::Error>; + fn stage_received( + &mut self, + crypto_session_id: Id, + logical_message_id: Id, + epoch: u64, + ) -> Result<(), Self::Error>; + fn commit(self) -> Result<(), Self::Error>; + fn rollback(self) -> Result<(), Self::Error>; +} + +/// Opaque, bounded phone KeyPackage tied to its expected credential. +#[derive(Debug)] +pub struct PhoneKeyPackage { + bytes: Box<[u8]>, + identity: Identity, +} + +impl PhoneKeyPackage { + pub fn bytes(&self) -> &[u8] { + &self.bytes + } +} + +/// Opaque, immutable Welcome and the authenticated pair metadata needed before joining. +#[derive(Debug)] +pub struct PairWelcome { + bytes: Box<[u8]>, + context: PairContext, + daemon_identity: Identity, + device_identity: Identity, +} + +impl PairWelcome { + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + pub fn context(&self) -> &PairContext { + &self.context + } +} + +/// Successfully decrypted application data. Receive-state durability must be confirmed before +/// the caller releases this plaintext to authorization or presentation code. +#[derive(Debug)] +pub struct PreparedPlaintext { + pub logical_message_id: Id, + pub epoch: u64, + plaintext: Box<[u8]>, +} + +impl PreparedPlaintext { + pub fn plaintext(&self) -> &[u8] { + &self.plaintext + } +} + +/// A typed, bounded failure from the core boundary. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Error { + BoundExceeded(&'static str), + ConsumedKeyPackage, + ClockRollback, + CompetingCommit, + Crypto(&'static str), + DuplicateCiphertext, + FutureEpoch, + InactiveAfterRollback, + InvalidAad, + InvalidCiphertext, + InvalidIdentity(&'static str), + InvalidMessageClass, + NoPreparedTransaction, + NotTwoMembers, + StaleEpoch, + TransactionPending, + UnexpectedMessage, + WrongGroup, + WrongProfile, + WrongSuite, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BoundExceeded(name) => write!(f, "{name} exceeds the revision 1 bound"), + Self::InvalidIdentity(reason) => write!(f, "invalid identity: {reason}"), + other => write!(f, "{other:?}"), + } + } +} +impl StdError for Error {} + +struct Endpoint { + provider: CoreProvider, + signer: SignatureKeyPair, + group: Option, + identity: Identity, + peer: Identity, + context: PairContext, + accepted: BTreeSet, + previous_epoch_deadlines: BTreeMap, + last_wall_time_ms: u64, + clock: Arc, + transaction_pending: bool, +} + +impl Endpoint { + fn group(&self) -> Result<&MlsGroup, Error> { + self.group.as_ref().ok_or(Error::InactiveAfterRollback) + } + fn invalidate(&mut self) { + self.group = None; + self.transaction_pending = false; + } + fn ensure_ready(&self) -> Result<(), Error> { + if self.transaction_pending { + Err(Error::TransactionPending) + } else { + self.group()?; + Ok(()) + } + } + + fn checked_now_ms(&mut self) -> Result { + let now = self.clock.now_ms()?; + if now < self.last_wall_time_ms { + self.invalidate(); + return Err(Error::ClockRollback); + } + self.last_wall_time_ms = now; + Ok(now) + } + + fn validate_incoming(&mut self, message: &ProtocolMessage) -> Result<(), Error> { + let now = self.checked_now_ms()?; + let group = self.group()?; + if message.group_id() != group.group_id() { + return Err(Error::WrongGroup); + } + let current = group.epoch().as_u64(); + let incoming = message.epoch().as_u64(); + if incoming > current { + return Err(Error::FutureEpoch); + } + if incoming < current + && self + .previous_epoch_deadlines + .get(&incoming) + .is_none_or(|deadline| now > *deadline) + { + return Err(Error::StaleEpoch); + } + Ok(()) + } + + fn mark_epoch_advanced(&mut self, old_epoch: u64) -> Result<(), Error> { + let now = self.checked_now_ms()?; + let current = self + .group + .as_ref() + .map_or(old_epoch, |group| group.epoch().as_u64()); + self.previous_epoch_deadlines.insert( + old_epoch, + now.checked_add(PAST_EPOCH_MAX_AGE.as_millis() as u64) + .ok_or(Error::ClockRollback)?, + ); + self.previous_epoch_deadlines + .retain(|epoch, _| current.saturating_sub(*epoch) <= u64::from(MAX_PAST_EPOCHS)); + Ok(()) + } + + fn expected_aad(&self, class: MessageClass, id: Id, generation: u64) -> Aad { + self.context.aad(self.peer.role, class, id, generation) + } + + fn prepare_application( + &mut self, + class: MessageClass, + logical_message_id: Id, + generation: u64, + plaintext: &[u8], + ) -> Result { + self.ensure_ready()?; + if plaintext.len() > APPLICATION_MAX_BYTES { + return Err(Error::BoundExceeded("application plaintext")); + } + let expected_class = match self.identity.role { + Role::Daemon => MessageClass::ApplicationDelivery, + Role::Device => MessageClass::ApplicationRequest, + }; + if class != expected_class { + return Err(Error::InvalidMessageClass); + } + let aad = self + .context + .aad(self.identity.role, class, logical_message_id, generation) + .encode(); + let provider = &self.provider; + let signer = &self.signer; + let group = self.group.as_mut().ok_or(Error::InactiveAfterRollback)?; + let epoch = group.epoch().as_u64(); + group.set_aad(aad); + let bytes = group + .create_message(provider, signer, plaintext) + .map_err(|_| Error::Crypto("application encryption failed"))? + .tls_serialize_detached() + .map_err(|_| Error::Crypto("application serialization failed"))?; + let envelope = bounded_envelope( + bytes, + self.context.crypto_session_id, + logical_message_id, + class, + epoch, + )?; + self.transaction_pending = true; + Ok(envelope) + } + + fn receive_application( + &mut self, + envelope: &[u8], + class: MessageClass, + logical_message_id: Id, + generation: u64, + ) -> Result { + self.ensure_ready()?; + if envelope.len() > ENVELOPE_MAX_BYTES { + return Err(Error::BoundExceeded("MLS envelope")); + } + if self.accepted.contains(&logical_message_id) { + return Err(Error::DuplicateCiphertext); + } + let protocol = decode_protocol(envelope)?; + self.validate_incoming(&protocol)?; + let provider = &self.provider; + let processed = self + .group + .as_mut() + .ok_or(Error::InactiveAfterRollback)? + .process_message(provider, protocol) + .map_err(|_| Error::InvalidCiphertext)?; + if let Err(error) = Aad::validate_exact( + processed.aad(), + &self.expected_aad(class, logical_message_id, generation), + ) { + self.invalidate(); + return Err(error); + } + if let Err(error) = validate_sender(&processed, &self.peer) { + self.invalidate(); + return Err(error); + } + let epoch = processed.epoch().as_u64(); + let ProcessedMessageContent::ApplicationMessage(application) = processed.into_content() + else { + self.invalidate(); + return Err(Error::UnexpectedMessage); + }; + self.accepted.insert(logical_message_id); + self.transaction_pending = true; + Ok(PreparedPlaintext { + logical_message_id, + epoch, + plaintext: application.into_bytes().into_boxed_slice(), + }) + } + + #[cfg(test)] + fn finish_transaction(&mut self, outcome: TransactionOutcome) -> Result<(), Error> { + if !self.transaction_pending { + return Err(Error::NoPreparedTransaction); + } + match outcome { + TransactionOutcome::Committed => self.transaction_pending = false, + TransactionOutcome::RolledBack => self.invalidate(), + } + Ok(()) + } + + fn epoch(&self) -> Result { + Ok(self.group()?.epoch().as_u64()) + } + + fn epoch_authenticator(&self) -> Result, Error> { + Ok(self.group()?.epoch_authenticator().as_slice().to_vec()) + } +} + +/// Daemon member. This is the only type that exposes commit creation. +pub(crate) struct Daemon { + endpoint: Endpoint, + key_package_consumed: bool, +} + +impl Daemon { + pub(crate) fn create(identity: Identity, context: PairContext) -> Result { + if identity.role != Role::Daemon + || identity.account_id != context.account_id + || identity.installation_id != context.installation_id + { + return Err(Error::InvalidIdentity("daemon does not match pair context")); + } + let provider = + CoreProvider::new().map_err(|_| Error::Crypto("provider initialization failed"))?; + ensure_suite(&provider)?; + let (credential, signer) = make_credential(&provider, &identity)?; + let group = MlsGroup::builder() + .with_group_id(GroupId::from_slice(&context.group_id)) + .ciphersuite(SUITE) + .use_ratchet_tree_extension(true) + .sender_ratchet_configuration(SenderRatchetConfiguration::new(32, 1000)) + .max_past_epochs(MAX_PAST_EPOCHS as usize) + .build(&provider, &signer, credential) + .map_err(|_| Error::Crypto("group creation failed"))?; + let peer = Identity::device( + context.account_id, + context.installation_id, + context.device_id, + )?; + let clock: Arc = Arc::new(SystemClock); + let last_wall_time_ms = clock.now_ms()?; + Ok(Self { + endpoint: Endpoint { + provider, + signer, + group: Some(group), + identity, + peer, + context, + accepted: BTreeSet::new(), + previous_epoch_deadlines: BTreeMap::new(), + last_wall_time_ms, + clock, + transaction_pending: false, + }, + key_package_consumed: false, + }) + } + + pub(crate) fn consume_key_package( + &mut self, + package: PhoneKeyPackage, + ) -> Result { + if self.key_package_consumed { + return Err(Error::ConsumedKeyPackage); + } + if package.bytes.len() > HANDSHAKE_MAX_BYTES { + return Err(Error::BoundExceeded("KeyPackage")); + } + if package.identity != self.endpoint.peer { + return Err(Error::InvalidIdentity( + "KeyPackage identity does not match pair", + )); + } + let key_package_in = KeyPackageIn::tls_deserialize_exact(package.bytes.as_ref()) + .map_err(|_| Error::Crypto("invalid KeyPackage encoding"))?; + let unverified = key_package_in.unverified_credential(); + if Identity::parse_credential(unverified.credential.serialized_content())? + != package.identity + { + return Err(Error::InvalidIdentity( + "KeyPackage credential does not match claim", + )); + } + let key_package = key_package_in + .validate(self.endpoint.provider.crypto(), ProtocolVersion::Mls10) + .map_err(|_| Error::Crypto("KeyPackage validation failed"))?; + if key_package.ciphersuite() != SUITE { + return Err(Error::WrongSuite); + } + let provider = &self.endpoint.provider; + let signer = &self.endpoint.signer; + let group = self + .endpoint + .group + .as_mut() + .ok_or(Error::InactiveAfterRollback)?; + let (_, welcome, _) = group + .add_members(provider, signer, &[key_package]) + .map_err(|_| Error::Crypto("member addition failed"))?; + group + .merge_pending_commit(provider) + .map_err(|_| Error::Crypto("initial commit merge failed"))?; + validate_members(group, &self.endpoint.identity, &self.endpoint.peer)?; + let bytes = welcome + .tls_serialize_detached() + .map_err(|_| Error::Crypto("Welcome serialization failed"))?; + if bytes.len() > HANDSHAKE_MAX_BYTES { + return Err(Error::BoundExceeded("Welcome")); + } + self.key_package_consumed = true; + self.endpoint.transaction_pending = true; + Ok(PairWelcome { + bytes: bytes.into_boxed_slice(), + context: self.endpoint.context.clone(), + daemon_identity: self.endpoint.identity.clone(), + device_identity: self.endpoint.peer.clone(), + }) + } + + pub(crate) fn prepare_application( + &mut self, + id: Id, + generation: u64, + plaintext: &[u8], + ) -> Result { + self.endpoint.prepare_application( + MessageClass::ApplicationDelivery, + id, + generation, + plaintext, + ) + } + + pub(crate) fn receive_application( + &mut self, + bytes: &[u8], + id: Id, + generation: u64, + ) -> Result { + self.endpoint + .receive_application(bytes, MessageClass::ApplicationRequest, id, generation) + } + + pub(crate) fn receive_update_proposal( + &mut self, + bytes: &[u8], + id: Id, + generation: u64, + ) -> Result<(), Error> { + self.endpoint.ensure_ready()?; + let protocol = decode_protocol(bytes)?; + self.endpoint.validate_incoming(&protocol)?; + let provider = &self.endpoint.provider; + let processed = self + .endpoint + .group + .as_mut() + .ok_or(Error::InactiveAfterRollback)? + .process_message(provider, protocol) + .map_err(|_| Error::InvalidCiphertext)?; + if let Err(error) = Aad::validate_exact( + processed.aad(), + &self + .endpoint + .expected_aad(MessageClass::UpdateProposal, id, generation), + ) { + self.endpoint.invalidate(); + return Err(error); + } + if let Err(error) = validate_sender(&processed, &self.endpoint.peer) { + self.endpoint.invalidate(); + return Err(error); + } + let ProcessedMessageContent::ProposalMessage(proposal) = processed.into_content() else { + self.endpoint.invalidate(); + return Err(Error::UnexpectedMessage); + }; + self.endpoint + .group + .as_mut() + .ok_or(Error::InactiveAfterRollback)? + .store_pending_proposal(self.endpoint.provider.storage(), *proposal) + .map_err(|_| Error::Crypto("proposal storage failed"))?; + self.endpoint.transaction_pending = true; + Ok(()) + } + + pub(crate) fn prepare_commit( + &mut self, + id: Id, + generation: u64, + ) -> Result { + self.endpoint.ensure_ready()?; + let aad = self + .endpoint + .context + .aad(Role::Daemon, MessageClass::Commit, id, generation) + .encode(); + let provider = &self.endpoint.provider; + let signer = &self.endpoint.signer; + let group = self + .endpoint + .group + .as_mut() + .ok_or(Error::InactiveAfterRollback)?; + let epoch = group.epoch().as_u64(); + if group.pending_proposals().count() != 1 { + return Err(Error::CompetingCommit); + } + group.set_aad(aad); + let (commit, _, _) = group + .commit_to_pending_proposals(provider, signer) + .map_err(|_| Error::Crypto("commit creation failed"))?; + let bytes = commit + .tls_serialize_detached() + .map_err(|_| Error::Crypto("commit serialization failed"))?; + group + .merge_pending_commit(provider) + .map_err(|_| Error::Crypto("commit merge failed"))?; + validate_members(group, &self.endpoint.identity, &self.endpoint.peer)?; + let commit_id: [u8; 48] = provider + .crypto() + .hash(SUITE.hash_algorithm(), &bytes) + .map_err(|_| Error::Crypto("commit hash failed"))? + .try_into() + .map_err(|_| Error::Crypto("unexpected commit hash length"))?; + let epoch_authenticator = group + .epoch_authenticator() + .as_slice() + .try_into() + .map_err(|_| Error::Crypto("unexpected epoch authenticator length"))?; + let target_epoch = group.epoch().as_u64(); + if let Err(error) = self.endpoint.mark_epoch_advanced(epoch) { + self.endpoint.invalidate(); + return Err(error); + } + let mut envelope = bounded_envelope( + bytes, + self.endpoint.context.crypto_session_id, + id, + MessageClass::Commit, + epoch, + )?; + envelope.commit = Some(CommitMetadata { + commit_id, + target_epoch, + epoch_authenticator, + }); + self.endpoint.transaction_pending = true; + Ok(envelope) + } + + #[cfg(test)] + pub(crate) fn finish_transaction(&mut self, outcome: TransactionOutcome) -> Result<(), Error> { + self.endpoint.finish_transaction(outcome) + } + #[cfg(test)] + pub(crate) fn epoch(&self) -> Result { + self.endpoint.epoch() + } + #[cfg(test)] + pub(crate) fn epoch_authenticator(&self) -> Result, Error> { + self.endpoint.epoch_authenticator() + } +} + +/// Phone member. It may create self-Update proposals but cannot create commits. +pub(crate) struct Phone { + endpoint: Option, + provider: CoreProvider, + signer: SignatureKeyPair, + identity: Identity, +} + +impl Phone { + pub(crate) fn create(identity: Identity) -> Result<(Self, PhoneKeyPackage), Error> { + if identity.role != Role::Device { + return Err(Error::InvalidIdentity("phone must use device role")); + } + identity.validate()?; + let provider = + CoreProvider::new().map_err(|_| Error::Crypto("provider initialization failed"))?; + ensure_suite(&provider)?; + let (credential, signer) = make_credential(&provider, &identity)?; + let bundle = KeyPackage::builder() + .key_package_lifetime(Lifetime::new(KEY_PACKAGE_LIFETIME_SECONDS)) + .build(SUITE, &provider, &signer, credential) + .map_err(|_| Error::Crypto("KeyPackage creation failed"))?; + let bytes = bundle + .key_package() + .tls_serialize_detached() + .map_err(|_| Error::Crypto("KeyPackage serialization failed"))?; + if bytes.len() > HANDSHAKE_MAX_BYTES { + return Err(Error::BoundExceeded("KeyPackage")); + } + Ok(( + Self { + endpoint: None, + provider, + signer, + identity: identity.clone(), + }, + PhoneKeyPackage { + bytes: bytes.into_boxed_slice(), + identity, + }, + )) + } + + pub(crate) fn join( + &mut self, + welcome: PairWelcome, + expected: &PairContext, + ) -> Result<(), Error> { + if &welcome.context != expected { + return Err(Error::WrongGroup); + } + if welcome.device_identity != self.identity { + return Err(Error::InvalidIdentity("Welcome device identity mismatch")); + } + if welcome.bytes.len() > HANDSHAKE_MAX_BYTES { + return Err(Error::BoundExceeded("Welcome")); + } + let message = MlsMessageIn::tls_deserialize_exact(welcome.bytes.as_ref()) + .map_err(|_| Error::Crypto("invalid Welcome encoding"))?; + let MlsMessageBodyIn::Welcome(welcome_message) = message.extract() else { + return Err(Error::UnexpectedMessage); + }; + if welcome_message.ciphersuite() != SUITE { + return Err(Error::WrongSuite); + } + let staged = + StagedWelcome::new_from_welcome(&self.provider, &join_config(), welcome_message, None) + .map_err(|_| Error::Crypto("Welcome join failed"))?; + if staged.group_context().group_id().as_slice() != expected.group_id { + return Err(Error::WrongGroup); + } + validate_members_iter(staged.members(), &welcome.daemon_identity, &self.identity)?; + let group = staged + .into_group(&self.provider) + .map_err(|_| Error::Crypto("Welcome persistence failed"))?; + let clock: Arc = Arc::new(SystemClock); + let last_wall_time_ms = clock.now_ms()?; + let endpoint = Endpoint { + provider: std::mem::replace( + &mut self.provider, + CoreProvider::new().map_err(|_| Error::Crypto("provider initialization failed"))?, + ), + signer: std::mem::replace( + &mut self.signer, + SignatureKeyPair::new(SUITE.signature_algorithm()) + .map_err(|_| Error::Crypto("signer initialization failed"))?, + ), + group: Some(group), + identity: self.identity.clone(), + peer: welcome.daemon_identity, + context: expected.clone(), + accepted: BTreeSet::new(), + previous_epoch_deadlines: BTreeMap::new(), + last_wall_time_ms, + clock, + transaction_pending: true, + }; + self.endpoint = Some(endpoint); + Ok(()) + } + + #[cfg(test)] + fn endpoint(&self) -> Result<&Endpoint, Error> { + self.endpoint.as_ref().ok_or(Error::WrongGroup) + } + fn endpoint_mut(&mut self) -> Result<&mut Endpoint, Error> { + self.endpoint.as_mut().ok_or(Error::WrongGroup) + } + + pub(crate) fn prepare_application( + &mut self, + id: Id, + generation: u64, + plaintext: &[u8], + ) -> Result { + self.endpoint_mut()?.prepare_application( + MessageClass::ApplicationRequest, + id, + generation, + plaintext, + ) + } + pub(crate) fn receive_application( + &mut self, + bytes: &[u8], + id: Id, + generation: u64, + ) -> Result { + self.endpoint_mut()?.receive_application( + bytes, + MessageClass::ApplicationDelivery, + id, + generation, + ) + } + pub(crate) fn prepare_self_update( + &mut self, + id: Id, + generation: u64, + ) -> Result { + let endpoint = self.endpoint_mut()?; + endpoint.ensure_ready()?; + let aad = endpoint + .context + .aad(Role::Device, MessageClass::UpdateProposal, id, generation) + .encode(); + let provider = &endpoint.provider; + let signer = &endpoint.signer; + let group = endpoint + .group + .as_mut() + .ok_or(Error::InactiveAfterRollback)?; + let epoch = group.epoch().as_u64(); + group.set_aad(aad); + let (proposal, _) = group + .propose_self_update(provider, signer, LeafNodeParameters::default()) + .map_err(|_| Error::Crypto("self-Update proposal failed"))?; + let bytes = proposal + .tls_serialize_detached() + .map_err(|_| Error::Crypto("proposal serialization failed"))?; + let envelope = bounded_envelope( + bytes, + endpoint.context.crypto_session_id, + id, + MessageClass::UpdateProposal, + epoch, + )?; + endpoint.transaction_pending = true; + Ok(envelope) + } + pub(crate) fn apply_commit( + &mut self, + bytes: &[u8], + id: Id, + generation: u64, + ) -> Result<(), Error> { + let endpoint = self.endpoint_mut()?; + endpoint.ensure_ready()?; + let protocol = decode_protocol(bytes)?; + if protocol.group_id() != endpoint.group()?.group_id() { + return Err(Error::WrongGroup); + } + if protocol.epoch() != endpoint.group()?.epoch() { + return Err(Error::CompetingCommit); + } + let old_epoch = endpoint.group()?.epoch().as_u64(); + let provider = &endpoint.provider; + let processed = endpoint + .group + .as_mut() + .ok_or(Error::InactiveAfterRollback)? + .process_message(provider, protocol) + .map_err(|_| Error::CompetingCommit)?; + if let Err(error) = Aad::validate_exact( + processed.aad(), + &endpoint.expected_aad(MessageClass::Commit, id, generation), + ) { + endpoint.invalidate(); + return Err(error); + } + if let Err(error) = validate_sender(&processed, &endpoint.peer) { + endpoint.invalidate(); + return Err(error); + } + let ProcessedMessageContent::StagedCommitMessage(staged) = processed.into_content() else { + endpoint.invalidate(); + return Err(Error::UnexpectedMessage); + }; + endpoint + .group + .as_mut() + .ok_or(Error::InactiveAfterRollback)? + .merge_staged_commit(provider, *staged) + .map_err(|_| Error::CompetingCommit)?; + validate_members(endpoint.group()?, &endpoint.peer, &endpoint.identity)?; + if let Err(error) = endpoint.mark_epoch_advanced(old_epoch) { + endpoint.invalidate(); + return Err(error); + } + endpoint.transaction_pending = true; + Ok(()) + } + #[cfg(test)] + pub(crate) fn finish_transaction(&mut self, outcome: TransactionOutcome) -> Result<(), Error> { + self.endpoint_mut()?.finish_transaction(outcome) + } + #[cfg(test)] + pub(crate) fn epoch(&self) -> Result { + self.endpoint()?.epoch() + } + #[cfg(test)] + pub(crate) fn epoch_authenticator(&self) -> Result, Error> { + self.endpoint()?.epoch_authenticator() + } +} + +fn make_credential( + provider: &CoreProvider, + identity: &Identity, +) -> Result<(CredentialWithKey, SignatureKeyPair), Error> { + let signer = SignatureKeyPair::new(SUITE.signature_algorithm()) + .map_err(|_| Error::Crypto("signing key generation failed"))?; + signer + .store(provider.storage()) + .map_err(|_| Error::Crypto("signing key storage failed"))?; + let credential = BasicCredential::new(identity.credential_bytes(signer.public())?); + Ok(( + CredentialWithKey { + credential: credential.into(), + signature_key: signer.public().into(), + }, + signer, + )) +} + +fn ensure_suite(provider: &CoreProvider) -> Result<(), Error> { + if u16::from(SUITE) != SUITE_VALUE { + return Err(Error::WrongSuite); + } + provider + .crypto() + .supports(SUITE) + .map_err(|_| Error::WrongSuite) +} + +fn join_config() -> MlsGroupJoinConfig { + MlsGroupJoinConfig::builder() + .use_ratchet_tree_extension(true) + .sender_ratchet_configuration(SenderRatchetConfiguration::new(32, 1000)) + .max_past_epochs(MAX_PAST_EPOCHS as usize) + .build() +} + +fn decode_protocol(bytes: &[u8]) -> Result { + if bytes.len() > ENVELOPE_MAX_BYTES { + return Err(Error::BoundExceeded("MLS envelope")); + } + MlsMessageIn::tls_deserialize_exact(bytes) + .map_err(|_| Error::InvalidCiphertext)? + .try_into_protocol_message() + .map_err(|_| Error::UnexpectedMessage) +} + +fn validate_sender(processed: &ProcessedMessage, expected: &Identity) -> Result<(), Error> { + if Identity::parse_credential(processed.credential().serialized_content())? != *expected { + return Err(Error::InvalidIdentity( + "authenticated sender does not match pair", + )); + } + Ok(()) +} + +fn validate_members(group: &MlsGroup, daemon: &Identity, device: &Identity) -> Result<(), Error> { + validate_members_iter(group.members(), daemon, device) +} + +fn validate_members_iter( + members: impl Iterator, + daemon: &Identity, + device: &Identity, +) -> Result<(), Error> { + let identities = members + .map(|member| Identity::parse_credential(member.credential.serialized_content())) + .collect::, _>>()?; + if identities.len() != 2 || !identities.contains(daemon) || !identities.contains(device) { + return Err(Error::NotTwoMembers); + } + Ok(()) +} + +fn bounded_envelope( + bytes: Vec, + crypto_session_id: Id, + logical_message_id: Id, + class: MessageClass, + epoch: u64, +) -> Result { + let class_limit = match class { + MessageClass::ApplicationRequest | MessageClass::ApplicationDelivery => ENVELOPE_MAX_BYTES, + MessageClass::UpdateProposal | MessageClass::Commit => HANDSHAKE_MAX_BYTES, + MessageClass::EpochReady | MessageClass::PairActivation | MessageClass::ResyncControl => { + 2 * 1024 + } + }; + if bytes.len() > class_limit { + return Err(Error::BoundExceeded("MLS envelope")); + } + Ok(PreparedEnvelope { + crypto_session_id, + logical_message_id, + class, + epoch, + commit: None, + ciphertext: bytes.into_boxed_slice(), + }) +} + +fn put_u16(out: &mut Vec, value: u16) { + out.extend_from_slice(&value.to_be_bytes()); +} +fn put_u8_vector(out: &mut Vec, bytes: &[u8]) -> Result<(), Error> { + let len = u8::try_from(bytes.len()).map_err(|_| Error::BoundExceeded("TLS vector"))?; + out.push(len); + out.extend_from_slice(bytes); + Ok(()) +} + +struct Cursor<'a> { + bytes: &'a [u8], + offset: usize, +} +impl<'a> Cursor<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + fn take(&mut self, len: usize) -> Result<&'a [u8], Error> { + let end = self.offset.checked_add(len).ok_or(Error::InvalidAad)?; + let result = self.bytes.get(self.offset..end).ok_or(Error::InvalidAad)?; + self.offset = end; + Ok(result) + } + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + fn u16(&mut self) -> Result { + Ok(u16::from_be_bytes(self.array()?)) + } + fn u64(&mut self) -> Result { + Ok(u64::from_be_bytes(self.array()?)) + } + fn array(&mut self) -> Result<[u8; N], Error> { + self.take(N)?.try_into().map_err(|_| Error::InvalidAad) + } + fn u8_vector(&mut self) -> Result<&'a [u8], Error> { + let len = usize::from(self.u8()?); + self.take(len) + } + fn finish(self) -> Result<(), Error> { + if self.offset == self.bytes.len() { + Ok(()) + } else { + Err(Error::InvalidAad) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_a_valid_key_package_from_another_suite() { + let context = PairContext { + crypto_session_id: [1; 16], + group_id: [2; 32], + account_id: [3; 16], + installation_id: [4; 16], + device_id: [5; 16], + }; + let identity = Identity::device( + context.account_id, + context.installation_id, + context.device_id, + ) + .unwrap(); + let provider = CoreProvider::new().unwrap(); + let other_suite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519; + let signer = SignatureKeyPair::new(other_suite.signature_algorithm()).unwrap(); + signer.store(provider.storage()).unwrap(); + let credential = BasicCredential::new(identity.credential_bytes(signer.public()).unwrap()); + let credential = CredentialWithKey { + credential: credential.into(), + signature_key: signer.public().into(), + }; + let bundle = KeyPackage::builder() + .build(other_suite, &provider, &signer, credential) + .unwrap(); + let package = PhoneKeyPackage { + bytes: bundle + .key_package() + .tls_serialize_detached() + .unwrap() + .into_boxed_slice(), + identity, + }; + let daemon_identity = Identity::daemon(context.account_id, context.installation_id); + let mut daemon = Daemon::create(daemon_identity, context).unwrap(); + assert_eq!( + daemon.consume_key_package(package).unwrap_err(), + Error::WrongSuite + ); + } +} + +#[cfg(test)] +mod core_tests; + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod persistence_tests; diff --git a/packages/e2ee/src/pairing.rs b/packages/e2ee/src/pairing.rs new file mode 100644 index 00000000..d158d7cf --- /dev/null +++ b/packages/e2ee/src/pairing.rs @@ -0,0 +1,1864 @@ +// SPDX-FileCopyrightText: 2026 VishnuM449 +// SPDX-License-Identifier: Apache-2.0 + +//! Canonical revision 1 pairing transcripts and transport-independent attempt accounting. +//! +//! The in-memory accountant operates only after a pending invitation has been found. Unknown +//! invitation lookup and its non-counting result belong to the durable Session 50.2 owner. + +use std::{collections::BTreeMap, fmt, sync::Arc}; + +use openmls_basic_credential::SignatureKeyPair; +use openmls_traits::{ + OpenMlsProvider, crypto::OpenMlsCrypto as _, random::OpenMlsRand as _, signatures::Signer as _, +}; +use tls_codec::Deserialize as _; + +use crate::{Clock, CoreProvider, Id, Identity, PROFILE_ID, PROFILE_REVISION, Role, SUITE}; + +pub const PAIRING_INVITATION_MAX_BYTES: usize = 2_048; +pub const PAIRING_CLAIM_MAX_BYTES: usize = + 2 + (1 + 255) + 2 + (16 * 3) + 48 + (2 + 512) + (2 + 16_384) + 64; +pub const PAIRING_CREDENTIAL_MAX_BYTES: usize = 512; +pub const PAIRING_KEY_PACKAGE_MAX_BYTES: usize = 16_384; +pub const PAIRING_INVITATION_LIFETIME_MS: u64 = 10 * 60 * 1_000; +pub const PAIRING_MAX_FAILED_CLAIMS: usize = 5; + +const INVITATION_SIGNATURE_LABEL: &[u8] = b"Axl pairing invitation v1"; +const CLAIM_SIGNATURE_LABEL: &[u8] = b"Axl pairing claim v1"; +const COMPARISON_LABEL: &[u8] = b"Axl pairing compare v1"; +const ZERO_ID: Id = [0; 16]; + +/// Canonical Axl basic credential bytes used by the pairing transcript. +/// +/// Debug output intentionally omits the credential and verification-key bytes. +#[derive(Clone, Eq, PartialEq)] +pub struct PairingCredential { + bytes: Box<[u8]>, + identity: Identity, + verification_key: [u8; 32], +} + +impl fmt::Debug for PairingCredential { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PairingCredential") + .field("identity", &self.identity) + .field("bytes", &"[redacted]") + .field("verification_key", &"[redacted]") + .finish() + } +} + +impl PairingCredential { + pub fn new(identity: Identity, signer: &SignatureKeyPair) -> Result { + let bytes = identity + .credential_bytes(signer.public()) + .map_err(|_| PairingError::InvalidCredential)?; + Self::decode(&bytes) + } + + pub fn decode(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > PAIRING_CREDENTIAL_MAX_BYTES { + return Err(PairingError::BoundExceeded); + } + let mut cursor = PairingCursor::new(bytes); + if cursor.u16()? != 1 { + return Err(PairingError::WrongVersion); + } + let role = match cursor.u8()? { + 1 => Role::Daemon, + 2 => Role::Device, + _ => return Err(PairingError::InvalidCredential), + }; + let identity = Identity { + role, + account_id: cursor.array()?, + installation_id: cursor.array()?, + device_id: cursor.array()?, + }; + let profile = cursor.u8_vector(255)?; + if profile != PROFILE_ID.as_bytes() { + return Err(PairingError::WrongProfile); + } + if cursor.u16()? != PROFILE_REVISION { + return Err(PairingError::WrongProfileRevision); + } + let verification_key = cursor.array()?; + cursor.finish()?; + identity + .validate() + .map_err(|_| PairingError::InvalidCredential)?; + let value = Self { + bytes: bytes.to_vec().into_boxed_slice(), + identity, + verification_key, + }; + if value.canonical_bytes()? != bytes { + return Err(PairingError::NonCanonical); + } + Ok(value) + } + + fn canonical_bytes(&self) -> Result, PairingError> { + self.identity + .credential_bytes(&self.verification_key) + .map_err(|_| PairingError::InvalidCredential) + } + + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + pub fn identity(&self) -> &Identity { + &self.identity + } + + pub fn verification_key(&self) -> &[u8; 32] { + &self.verification_key + } +} + +/// Canonical revision 1 QR invitation. +#[derive(Clone, Eq, PartialEq)] +pub struct PairingInvitation { + version: u16, + profile_id: Box, + profile_revision: u16, + account_id: Id, + installation_id: Id, + crypto_session_id: Id, + daemon_credential: PairingCredential, + issued_at_ms: u64, + expires_at_ms: u64, + invitation_nonce: [u8; 32], + daemon_signature: [u8; 64], +} + +impl fmt::Debug for PairingInvitation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PairingInvitation") + .field("version", &self.version) + .field("profile_id", &self.profile_id) + .field("profile_revision", &self.profile_revision) + .field("account_id", &self.account_id) + .field("installation_id", &self.installation_id) + .field("crypto_session_id", &self.crypto_session_id) + .field("daemon_credential", &self.daemon_credential) + .field("issued_at_ms", &self.issued_at_ms) + .field("expires_at_ms", &self.expires_at_ms) + .field("invitation_nonce", &"[redacted]") + .field("daemon_signature", &"[redacted]") + .finish() + } +} + +impl PairingInvitation { + pub fn create( + account_id: Id, + installation_id: Id, + crypto_session_id: Id, + daemon_credential: PairingCredential, + daemon_signer: &SignatureKeyPair, + ) -> Result { + Self::create_with_clock( + account_id, + installation_id, + crypto_session_id, + daemon_credential, + daemon_signer, + &crate::SystemClock, + ) + } + + pub(crate) fn create_with_clock( + account_id: Id, + installation_id: Id, + crypto_session_id: Id, + daemon_credential: PairingCredential, + daemon_signer: &SignatureKeyPair, + clock: &dyn Clock, + ) -> Result { + let issued_at_ms = clock.now_ms().map_err(|_| PairingError::ClockRollback)?; + let expires_at_ms = issued_at_ms + .checked_add(PAIRING_INVITATION_LIFETIME_MS) + .ok_or(PairingError::InvalidTime)?; + let provider = provider()?; + let invitation_nonce = provider + .rand() + .random_array::<32>() + .map_err(|_| PairingError::CryptographicFailure)?; + if daemon_signer.public() != daemon_credential.verification_key() { + return Err(PairingError::IdentityMismatch); + } + let mut invitation = Self { + version: 1, + profile_id: PROFILE_ID.into(), + profile_revision: PROFILE_REVISION, + account_id, + installation_id, + crypto_session_id, + daemon_credential, + issued_at_ms, + expires_at_ms, + invitation_nonce, + daemon_signature: [0; 64], + }; + invitation.validate_bindings()?; + let signature = daemon_signer + .sign(&invitation.signature_input()?) + .map_err(|_| PairingError::CryptographicFailure)?; + invitation.daemon_signature = signature + .try_into() + .map_err(|_| PairingError::CryptographicFailure)?; + Ok(invitation) + } + + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > PAIRING_INVITATION_MAX_BYTES { + return Err(PairingError::BoundExceeded); + } + let mut cursor = PairingCursor::new(bytes); + let value = Self { + version: cursor.u16()?, + profile_id: str::from_utf8(cursor.u8_vector(255)?) + .map_err(|_| PairingError::NonCanonical)? + .into(), + profile_revision: cursor.u16()?, + account_id: cursor.array()?, + installation_id: cursor.array()?, + crypto_session_id: cursor.array()?, + daemon_credential: PairingCredential::decode( + cursor.u16_vector(PAIRING_CREDENTIAL_MAX_BYTES)?, + )?, + issued_at_ms: cursor.u64()?, + expires_at_ms: cursor.u64()?, + invitation_nonce: cursor.array()?, + daemon_signature: cursor.array()?, + }; + cursor.finish()?; + value.validate_bindings()?; + if value.encode()? != bytes { + return Err(PairingError::NonCanonical); + } + Ok(value) + } + + pub fn version(&self) -> u16 { + self.version + } + + pub fn profile_id(&self) -> &str { + &self.profile_id + } + + pub fn profile_revision(&self) -> u16 { + self.profile_revision + } + + pub fn account_id(&self) -> Id { + self.account_id + } + + pub fn installation_id(&self) -> Id { + self.installation_id + } + + pub fn crypto_session_id(&self) -> Id { + self.crypto_session_id + } + + pub fn daemon_credential(&self) -> &PairingCredential { + &self.daemon_credential + } + + pub fn issued_at_ms(&self) -> u64 { + self.issued_at_ms + } + + pub fn expires_at_ms(&self) -> u64 { + self.expires_at_ms + } + + pub fn encode(&self) -> Result, PairingError> { + self.validate_bindings()?; + let mut out = self.fields_before_signature()?; + out.extend_from_slice(&self.daemon_signature); + if out.len() > PAIRING_INVITATION_MAX_BYTES { + return Err(PairingError::BoundExceeded); + } + Ok(out) + } + + pub fn verify(&self) -> Result<(), PairingError> { + self.verify_with_clock(&crate::SystemClock) + } + + pub fn verify_signature(&self) -> Result<(), PairingError> { + self.validate_bindings()?; + provider()? + .crypto() + .verify_signature( + SUITE.signature_algorithm(), + &self.signature_input()?, + self.daemon_credential.verification_key(), + &self.daemon_signature, + ) + .map_err(|_| PairingError::InvalidSignature) + } + + pub(crate) fn verify_with_clock(&self, clock: &dyn Clock) -> Result<(), PairingError> { + let now_ms = clock.now_ms().map_err(|_| PairingError::ClockRollback)?; + self.validate_time(now_ms)?; + self.verify_signature() + } + + pub fn invitation_hash(&self) -> Result<[u8; 48], PairingError> { + sha384(&self.encode()?) + } + + pub fn nonce_hash(&self) -> Result<[u8; 48], PairingError> { + sha384(&self.invitation_nonce) + } + + fn fields_before_signature(&self) -> Result, PairingError> { + let mut out = Vec::new(); + put_u16(&mut out, self.version); + put_u8_vector(&mut out, self.profile_id.as_bytes(), 255)?; + put_u16(&mut out, self.profile_revision); + out.extend_from_slice(&self.account_id); + out.extend_from_slice(&self.installation_id); + out.extend_from_slice(&self.crypto_session_id); + put_u16_vector( + &mut out, + self.daemon_credential.bytes(), + PAIRING_CREDENTIAL_MAX_BYTES, + )?; + out.extend_from_slice(&self.issued_at_ms.to_be_bytes()); + out.extend_from_slice(&self.expires_at_ms.to_be_bytes()); + out.extend_from_slice(&self.invitation_nonce); + Ok(out) + } + + fn signature_input(&self) -> Result, PairingError> { + let mut input = Vec::from(INVITATION_SIGNATURE_LABEL); + input.extend_from_slice(&self.fields_before_signature()?); + Ok(input) + } + + fn validate_bindings(&self) -> Result<(), PairingError> { + if self.version != 1 { + return Err(PairingError::WrongVersion); + } + if self.profile_id.as_ref() != PROFILE_ID { + return Err(PairingError::WrongProfile); + } + if self.profile_revision != PROFILE_REVISION { + return Err(PairingError::WrongProfileRevision); + } + validate_uuid_v7(self.installation_id)?; + validate_uuid_v7(self.crypto_session_id)?; + let identity = self.daemon_credential.identity(); + if identity.role != Role::Daemon + || identity.account_id != self.account_id + || identity.installation_id != self.installation_id + || identity.device_id != ZERO_ID + { + return Err(PairingError::IdentityMismatch); + } + self.validate_time_shape() + } + + fn validate_time_shape(&self) -> Result<(), PairingError> { + if self.expires_at_ms < self.issued_at_ms + || self.expires_at_ms - self.issued_at_ms != PAIRING_INVITATION_LIFETIME_MS + { + return Err(PairingError::InvalidTime); + } + Ok(()) + } + + fn validate_time(&self, now_ms: u64) -> Result<(), PairingError> { + self.validate_time_shape()?; + if now_ms < self.issued_at_ms { + return Err(PairingError::ClockRollback); + } + if now_ms >= self.expires_at_ms { + return Err(PairingError::Expired); + } + Ok(()) + } +} + +/// Canonical revision 1 device claim. +#[derive(Clone, Eq, PartialEq)] +pub struct PairingClaimV1 { + version: u16, + profile_id: Box, + profile_revision: u16, + account_id: Id, + installation_id: Id, + crypto_session_id: Id, + invitation_nonce_hash: [u8; 48], + device_credential: PairingCredential, + key_package: Box<[u8]>, + device_signature: [u8; 64], +} + +impl fmt::Debug for PairingClaimV1 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PairingClaimV1") + .field("version", &self.version) + .field("profile_id", &self.profile_id) + .field("profile_revision", &self.profile_revision) + .field("account_id", &self.account_id) + .field("installation_id", &self.installation_id) + .field("crypto_session_id", &self.crypto_session_id) + .field("invitation_nonce_hash", &"[redacted]") + .field("device_credential", &self.device_credential) + .field("key_package", &"[redacted]") + .field("device_signature", &"[redacted]") + .finish() + } +} + +impl PairingClaimV1 { + pub fn create( + invitation: &PairingInvitation, + device_credential: PairingCredential, + key_package: &[u8], + device_signer: &SignatureKeyPair, + ) -> Result { + invitation.verify()?; + Self::create_after_verification(invitation, device_credential, key_package, device_signer) + } + + fn create_after_verification( + invitation: &PairingInvitation, + device_credential: PairingCredential, + key_package: &[u8], + device_signer: &SignatureKeyPair, + ) -> Result { + if key_package.is_empty() || key_package.len() > PAIRING_KEY_PACKAGE_MAX_BYTES { + return Err(PairingError::BoundExceeded); + } + if device_signer.public() != device_credential.verification_key() { + return Err(PairingError::IdentityMismatch); + } + let mut claim = Self { + version: invitation.version, + profile_id: invitation.profile_id.clone(), + profile_revision: invitation.profile_revision, + account_id: invitation.account_id, + installation_id: invitation.installation_id, + crypto_session_id: invitation.crypto_session_id, + invitation_nonce_hash: invitation.nonce_hash()?, + device_credential, + key_package: key_package.to_vec().into_boxed_slice(), + device_signature: [0; 64], + }; + claim.validate_bindings(invitation)?; + let signature = device_signer + .sign(&claim.signature_input(invitation)?) + .map_err(|_| PairingError::CryptographicFailure)?; + claim.device_signature = signature + .try_into() + .map_err(|_| PairingError::CryptographicFailure)?; + Ok(claim) + } + + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > PAIRING_CLAIM_MAX_BYTES { + return Err(PairingError::BoundExceeded); + } + let mut cursor = PairingCursor::new(bytes); + let value = Self { + version: cursor.u16()?, + profile_id: str::from_utf8(cursor.u8_vector(255)?) + .map_err(|_| PairingError::NonCanonical)? + .into(), + profile_revision: cursor.u16()?, + account_id: cursor.array()?, + installation_id: cursor.array()?, + crypto_session_id: cursor.array()?, + invitation_nonce_hash: cursor.array()?, + device_credential: PairingCredential::decode( + cursor.u16_vector(PAIRING_CREDENTIAL_MAX_BYTES)?, + )?, + key_package: cursor + .u16_vector(PAIRING_KEY_PACKAGE_MAX_BYTES)? + .to_vec() + .into_boxed_slice(), + device_signature: cursor.array()?, + }; + cursor.finish()?; + value.validate_self()?; + if value.encode()? != bytes { + return Err(PairingError::NonCanonical); + } + Ok(value) + } + + pub fn version(&self) -> u16 { + self.version + } + + pub fn profile_id(&self) -> &str { + &self.profile_id + } + + pub fn profile_revision(&self) -> u16 { + self.profile_revision + } + + pub fn account_id(&self) -> Id { + self.account_id + } + + pub fn installation_id(&self) -> Id { + self.installation_id + } + + pub fn crypto_session_id(&self) -> Id { + self.crypto_session_id + } + + pub fn invitation_nonce_hash(&self) -> &[u8; 48] { + &self.invitation_nonce_hash + } + + pub fn device_credential(&self) -> &PairingCredential { + &self.device_credential + } + + pub fn encode(&self) -> Result, PairingError> { + self.validate_self()?; + let mut out = Vec::new(); + put_u16(&mut out, self.version); + put_u8_vector(&mut out, self.profile_id.as_bytes(), 255)?; + put_u16(&mut out, self.profile_revision); + out.extend_from_slice(&self.account_id); + out.extend_from_slice(&self.installation_id); + out.extend_from_slice(&self.crypto_session_id); + out.extend_from_slice(&self.invitation_nonce_hash); + put_u16_vector( + &mut out, + self.device_credential.bytes(), + PAIRING_CREDENTIAL_MAX_BYTES, + )?; + put_u16_vector(&mut out, &self.key_package, PAIRING_KEY_PACKAGE_MAX_BYTES)?; + out.extend_from_slice(&self.device_signature); + if out.len() > PAIRING_CLAIM_MAX_BYTES { + return Err(PairingError::BoundExceeded); + } + Ok(out) + } + + pub fn verify(&self, invitation: &PairingInvitation) -> Result<(), PairingError> { + self.verify_with_clock(invitation, &crate::SystemClock) + } + + pub fn verify_signature_and_bindings( + &self, + invitation: &PairingInvitation, + ) -> Result<(), PairingError> { + invitation.verify_signature()?; + self.validate_bindings(invitation)?; + provider()? + .crypto() + .verify_signature( + SUITE.signature_algorithm(), + &self.signature_input(invitation)?, + self.device_credential.verification_key(), + &self.device_signature, + ) + .map_err(|_| PairingError::InvalidSignature)?; + self.validate_key_package_credential() + } + + pub(crate) fn verify_with_clock( + &self, + invitation: &PairingInvitation, + clock: &dyn Clock, + ) -> Result<(), PairingError> { + let now_ms = clock.now_ms().map_err(|_| PairingError::ClockRollback)?; + invitation.validate_time(now_ms)?; + self.verify_signature_and_bindings(invitation) + } + + pub fn claim_hash(&self) -> Result<[u8; 48], PairingError> { + sha384(&self.encode()?) + } + + pub fn key_package(&self) -> &[u8] { + &self.key_package + } + + fn signature_input(&self, invitation: &PairingInvitation) -> Result, PairingError> { + let mut input = Vec::from(CLAIM_SIGNATURE_LABEL); + input.extend_from_slice(&invitation.invitation_hash()?); + input.extend_from_slice(&sha384(&self.key_package)?); + input.extend_from_slice(&sha384(self.device_credential.bytes())?); + Ok(input) + } + + fn validate_key_package_credential(&self) -> Result<(), PairingError> { + let package = openmls::prelude::KeyPackageIn::tls_deserialize_exact(&self.key_package) + .map_err(|_| PairingError::CryptographicFailure)?; + let credential = package.unverified_credential(); + if credential.credential.serialized_content() != self.device_credential.bytes() + || credential.signature_key.as_slice() != self.device_credential.verification_key() + { + return Err(PairingError::IdentityMismatch); + } + Ok(()) + } + + fn validate_self(&self) -> Result<(), PairingError> { + if self.version != 1 { + return Err(PairingError::WrongVersion); + } + if self.profile_id.as_ref() != PROFILE_ID { + return Err(PairingError::WrongProfile); + } + if self.profile_revision != PROFILE_REVISION { + return Err(PairingError::WrongProfileRevision); + } + validate_uuid_v7(self.installation_id)?; + validate_uuid_v7(self.crypto_session_id)?; + let identity = self.device_credential.identity(); + if identity.role != Role::Device + || identity.account_id != self.account_id + || identity.installation_id != self.installation_id + || validate_uuid_v7(identity.device_id).is_err() + { + return Err(PairingError::IdentityMismatch); + } + if self.key_package.is_empty() || self.key_package.len() > PAIRING_KEY_PACKAGE_MAX_BYTES { + return Err(PairingError::BoundExceeded); + } + Ok(()) + } + + fn validate_bindings(&self, invitation: &PairingInvitation) -> Result<(), PairingError> { + self.validate_self()?; + if self.version != invitation.version { + return Err(PairingError::WrongVersion); + } + if self.profile_id != invitation.profile_id { + return Err(PairingError::WrongProfile); + } + if self.profile_revision != invitation.profile_revision { + return Err(PairingError::WrongProfileRevision); + } + if self.account_id != invitation.account_id + || self.installation_id != invitation.installation_id + || self.crypto_session_id != invitation.crypto_session_id + { + return Err(PairingError::IdentityMismatch); + } + if self.invitation_nonce_hash != invitation.nonce_hash()? { + return Err(PairingError::NonceMismatch); + } + Ok(()) + } +} + +pub fn comparison_value( + invitation: &PairingInvitation, + claim: &PairingClaimV1, +) -> Result { + let mut transcript = Vec::from(COMPARISON_LABEL); + transcript.extend_from_slice(&invitation.encode()?); + transcript.extend_from_slice(&claim.encode()?); + let digest = sha384(&transcript)?; + let value = (u64::from(digest[0]) << 31) + | (u64::from(digest[1]) << 23) + | (u64::from(digest[2]) << 15) + | (u64::from(digest[3]) << 7) + | (u64::from(digest[4]) >> 1); + let digits = format!("{value:012}"); + Ok(format!( + "{} {} {} {}", + &digits[0..3], + &digits[3..6], + &digits[6..9], + &digits[9..12] + )) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PairingError { + BoundExceeded, + ClockRollback, + CryptographicFailure, + Expired, + IdentityMismatch, + InvalidCredential, + InvalidSignature, + InvalidTime, + NonCanonical, + NonCounting, + NonceMismatch, + WrongProfile, + WrongProfileRevision, + WrongVersion, +} + +impl fmt::Display for PairingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "pairing request failed: {self:?}") + } +} +impl std::error::Error for PairingError {} + +// Session 50.2 will consume this crate-private classifier from the durable owner. +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum FailedClaimReason { + Credential, + KeyPackage, + Signature, +} + +/// Exact accepted result. Debug output never prints its bytes. +#[allow(dead_code)] +#[derive(Clone, Eq, PartialEq)] +pub(crate) struct AcceptedPairingResult(Box<[u8]>); + +#[allow(dead_code)] +impl AcceptedPairingResult { + pub(crate) fn new(bytes: &[u8]) -> Result { + if bytes.len() > PAIRING_KEY_PACKAGE_MAX_BYTES { + return Err(PairingError::BoundExceeded); + } + Ok(Self(bytes.to_vec().into_boxed_slice())) + } + pub(crate) fn bytes(&self) -> &[u8] { + &self.0 + } +} +impl fmt::Debug for AcceptedPairingResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AcceptedPairingResult([redacted])") + } +} + +#[allow(dead_code)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ClaimDecision { + Accepted(AcceptedPairingResult), + Failed(FailedClaimReason), +} + +#[allow(dead_code)] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum PublicClaimResult { + Accepted(AcceptedPairingResult), + Cancelled, + Conflict, + Rejected, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InvitationState { + Issued, + Confirmed, + Consumed, + Cancelled, +} + +/// Pure in-memory revision 1 failure accounting. Durable ownership belongs to Session 50.2. +#[allow(dead_code)] +pub(crate) struct PairingClaimAccountant { + invitation: PairingInvitation, + nonce_hash: [u8; 48], + state: InvitationState, + failed: BTreeMap<[u8; 48], PublicClaimResult>, + accepted: Option<([u8; 48], AcceptedPairingResult)>, + clock: Arc, + last_now_ms: u64, +} + +impl fmt::Debug for PairingClaimAccountant { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PairingClaimAccountant") + .field("invitation", &"[redacted]") + .field("state", &self.state) + .field("failed_claim_count", &self.failed.len()) + .field("accepted", &self.accepted.as_ref().map(|_| "[redacted]")) + .field("clock", &"[redacted]") + .finish() + } +} + +#[allow(dead_code)] +impl PairingClaimAccountant { + pub(crate) fn new_with_clock( + invitation: PairingInvitation, + clock: Arc, + ) -> Result { + let last_now_ms = clock.now_ms().map_err(|_| PairingError::ClockRollback)?; + invitation.validate_time(last_now_ms)?; + invitation.verify_signature()?; + Ok(Self { + nonce_hash: invitation.nonce_hash()?, + invitation, + state: InvitationState::Issued, + failed: BTreeMap::new(), + accepted: None, + clock, + last_now_ms, + }) + } + + pub(crate) fn submit( + &mut self, + claim_bytes: &[u8], + decision: ClaimDecision, + ) -> PublicClaimResult { + let now_ms = match self.clock.now_ms() { + Ok(now) if now >= self.last_now_ms => now, + _ => return PublicClaimResult::Rejected, + }; + self.last_now_ms = now_ms; + if self.invitation.validate_time(now_ms).is_err() { + return PublicClaimResult::Rejected; + } + if claim_bytes.len() > PAIRING_CLAIM_MAX_BYTES { + return PublicClaimResult::Rejected; + } + let claim = match PairingClaimV1::decode(claim_bytes) { + Ok(claim) => claim, + Err(_) => return PublicClaimResult::Rejected, + }; + if claim.validate_bindings(&self.invitation).is_err() + || claim.invitation_nonce_hash != self.nonce_hash + { + return PublicClaimResult::Rejected; + } + let claim_hash = match sha384(claim_bytes) { + Ok(hash) => hash, + Err(_) => return PublicClaimResult::Rejected, + }; + if let Some((accepted_hash, result)) = &self.accepted { + return if *accepted_hash == claim_hash { + PublicClaimResult::Accepted(result.clone()) + } else { + PublicClaimResult::Conflict + }; + } + if let Some(result) = self.failed.get(&claim_hash) { + return result.clone(); + } + if matches!(self.state, InvitationState::Cancelled) { + return PublicClaimResult::Cancelled; + } + let decision = match decision { + ClaimDecision::Accepted(result) + if claim + .verify_signature_and_bindings(&self.invitation) + .is_ok() => + { + ClaimDecision::Accepted(result) + } + ClaimDecision::Accepted(_) => ClaimDecision::Failed(FailedClaimReason::Signature), + failed => failed, + }; + match decision { + ClaimDecision::Accepted(result) => { + self.state = InvitationState::Confirmed; + self.accepted = Some((claim_hash, result.clone())); + PublicClaimResult::Accepted(result) + } + ClaimDecision::Failed(_) => { + let result = if self.failed.len() + 1 == PAIRING_MAX_FAILED_CLAIMS { + self.state = InvitationState::Cancelled; + PublicClaimResult::Cancelled + } else { + PublicClaimResult::Rejected + }; + self.failed.insert(claim_hash, result.clone()); + result + } + } + } + + pub(crate) fn mark_consumed(&mut self) -> Result<(), PairingError> { + if self.state != InvitationState::Confirmed || self.accepted.is_none() { + return Err(PairingError::NonCounting); + } + self.state = InvitationState::Consumed; + Ok(()) + } + + pub(crate) fn cancel(&mut self) { + if self.state == InvitationState::Issued { + self.state = InvitationState::Cancelled; + } + } + + pub(crate) fn failed_claim_count(&self) -> usize { + self.failed.len() + } +} + +fn provider() -> Result { + CoreProvider::new().map_err(|_| PairingError::CryptographicFailure) +} + +fn sha384(bytes: &[u8]) -> Result<[u8; 48], PairingError> { + provider()? + .crypto() + .hash(SUITE.hash_algorithm(), bytes) + .map_err(|_| PairingError::CryptographicFailure)? + .try_into() + .map_err(|_| PairingError::CryptographicFailure) +} + +fn validate_uuid_v7(id: Id) -> Result<(), PairingError> { + if id == ZERO_ID || id[6] >> 4 != 0x07 || id[8] >> 6 != 0b10 { + Err(PairingError::IdentityMismatch) + } else { + Ok(()) + } +} + +fn put_u16(out: &mut Vec, value: u16) { + out.extend_from_slice(&value.to_be_bytes()); +} +fn put_u8_vector(out: &mut Vec, bytes: &[u8], max: usize) -> Result<(), PairingError> { + if bytes.is_empty() || bytes.len() > max { + return Err(PairingError::BoundExceeded); + } + let len = u8::try_from(bytes.len()).map_err(|_| PairingError::BoundExceeded)?; + out.push(len); + out.extend_from_slice(bytes); + Ok(()) +} +fn put_u16_vector(out: &mut Vec, bytes: &[u8], max: usize) -> Result<(), PairingError> { + if bytes.is_empty() || bytes.len() > max { + return Err(PairingError::BoundExceeded); + } + let len = u16::try_from(bytes.len()).map_err(|_| PairingError::BoundExceeded)?; + put_u16(out, len); + out.extend_from_slice(bytes); + Ok(()) +} + +struct PairingCursor<'a> { + bytes: &'a [u8], + offset: usize, +} +impl<'a> PairingCursor<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + fn take(&mut self, len: usize) -> Result<&'a [u8], PairingError> { + let end = self + .offset + .checked_add(len) + .ok_or(PairingError::NonCanonical)?; + let value = self + .bytes + .get(self.offset..end) + .ok_or(PairingError::NonCanonical)?; + self.offset = end; + Ok(value) + } + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + fn u16(&mut self) -> Result { + Ok(u16::from_be_bytes(self.array()?)) + } + fn u64(&mut self) -> Result { + Ok(u64::from_be_bytes(self.array()?)) + } + fn array(&mut self) -> Result<[u8; N], PairingError> { + self.take(N)? + .try_into() + .map_err(|_| PairingError::NonCanonical) + } + fn u8_vector(&mut self, max: usize) -> Result<&'a [u8], PairingError> { + let len = usize::from(self.u8()?); + if len == 0 || len > max { + return Err(PairingError::NonCanonical); + } + self.take(len) + } + fn u16_vector(&mut self, max: usize) -> Result<&'a [u8], PairingError> { + let len = usize::from(self.u16()?); + if len == 0 || len > max { + return Err(PairingError::BoundExceeded); + } + self.take(len) + } + fn finish(self) -> Result<(), PairingError> { + if self.offset == self.bytes.len() { + Ok(()) + } else { + Err(PairingError::NonCanonical) + } + } +} + +#[cfg(test)] +mod tests { + use std::{ + collections::{BTreeMap, BTreeSet, VecDeque}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, AtomicUsize, Ordering}, + }, + }; + + use openmls::prelude::{KeyPackage, Lifetime}; + use openmls_basic_credential::SignatureKeyPair; + use tls_codec::Serialize as _; + + use super::*; + use crate::Error; + + struct ManualClock(AtomicU64); + impl ManualClock { + fn new(now: u64) -> Arc { + Arc::new(Self(AtomicU64::new(now))) + } + fn set(&self, now: u64) { + self.0.store(now, Ordering::SeqCst); + } + } + impl Clock for ManualClock { + fn now_ms(&self) -> Result { + Ok(self.0.load(Ordering::SeqCst)) + } + } + + struct ScriptedClock { + samples: Mutex>, + calls: AtomicUsize, + } + impl ScriptedClock { + fn new(samples: impl IntoIterator) -> Arc { + Arc::new(Self { + samples: Mutex::new(samples.into_iter().collect()), + calls: AtomicUsize::new(0), + }) + } + } + impl Clock for ScriptedClock { + fn now_ms(&self) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(self + .samples + .lock() + .expect("scripted clock lock poisoned") + .pop_front() + .expect("scripted clock exhausted")) + } + } + + fn uuid_v7(seed: u8) -> Id { + let mut id = [seed; 16]; + id[6] = 0x70 | (seed & 0x0f); + id[8] = 0x80 | (seed & 0x3f); + id + } + + struct Transcript { + invitation: PairingInvitation, + claim: PairingClaimV1, + clock: Arc, + device_signer: SignatureKeyPair, + } + + fn transcript(seed: u8) -> Transcript { + let clock = ManualClock::new(1_000_000); + let account = [seed; 16]; + let installation = uuid_v7(seed.wrapping_add(1)); + let session = uuid_v7(seed.wrapping_add(2)); + let device = uuid_v7(seed.wrapping_add(3)); + let daemon_signer = SignatureKeyPair::new(SUITE.signature_algorithm()).unwrap(); + let daemon_credential = + PairingCredential::new(Identity::daemon(account, installation), &daemon_signer) + .unwrap(); + let invitation = PairingInvitation::create_with_clock( + account, + installation, + session, + daemon_credential, + &daemon_signer, + clock.as_ref(), + ) + .unwrap(); + let device_identity = Identity::device(account, installation, device).unwrap(); + let provider = CoreProvider::new().unwrap(); + let (mls_credential, device_signer) = + crate::make_credential(&provider, &device_identity).unwrap(); + let package = KeyPackage::builder() + .key_package_lifetime(Lifetime::new(crate::KEY_PACKAGE_LIFETIME_SECONDS)) + .build(SUITE, &provider, &device_signer, mls_credential) + .unwrap() + .key_package() + .tls_serialize_detached() + .unwrap(); + let device_credential = PairingCredential::new(device_identity, &device_signer).unwrap(); + let claim = PairingClaimV1::create_after_verification( + &invitation, + device_credential, + &package, + &device_signer, + ) + .unwrap(); + Transcript { + invitation, + claim, + clock, + device_signer, + } + } + + #[test] + fn invitation_and_claim_round_trip_and_verify_exact_bindings() { + let fixture = transcript(1); + let invitation_bytes = fixture.invitation.encode().unwrap(); + assert!(invitation_bytes.len() <= PAIRING_INVITATION_MAX_BYTES); + let invitation = PairingInvitation::decode(&invitation_bytes).unwrap(); + invitation + .verify_with_clock(fixture.clock.as_ref()) + .unwrap(); + assert_eq!(invitation, fixture.invitation); + + let claim_bytes = fixture.claim.encode().unwrap(); + assert!(claim_bytes.len() <= PAIRING_CLAIM_MAX_BYTES); + let claim = PairingClaimV1::decode(&claim_bytes).unwrap(); + claim + .verify_with_clock(&invitation, fixture.clock.as_ref()) + .unwrap(); + assert_eq!(claim, fixture.claim); + assert_ne!( + invitation.invitation_hash().unwrap(), + claim.claim_hash().unwrap() + ); + } + + #[test] + fn rejects_truncation_trailing_bytes_and_invalid_vectors_before_allocation() { + let fixture = transcript(2); + let invitation = fixture.invitation.encode().unwrap(); + assert_eq!( + PairingInvitation::decode(&invitation[..invitation.len() - 1]), + Err(PairingError::NonCanonical) + ); + let mut trailing = invitation.clone(); + trailing.push(0); + assert_eq!( + PairingInvitation::decode(&trailing), + Err(PairingError::NonCanonical) + ); + let mut zero_profile = invitation; + zero_profile[2] = 0; + assert_eq!( + PairingInvitation::decode(&zero_profile), + Err(PairingError::NonCanonical) + ); + + let claim = fixture.claim.encode().unwrap(); + assert_eq!( + PairingClaimV1::decode(&claim[..claim.len() - 1]), + Err(PairingError::NonCanonical) + ); + let mut trailing = claim.clone(); + trailing.push(0); + assert_eq!( + PairingClaimV1::decode(&trailing), + Err(PairingError::NonCanonical) + ); + assert_eq!( + PairingInvitation::decode(&vec![0; PAIRING_INVITATION_MAX_BYTES + 1]), + Err(PairingError::BoundExceeded) + ); + assert_eq!( + PairingClaimV1::decode(&vec![0; PAIRING_CLAIM_MAX_BYTES + 1]), + Err(PairingError::BoundExceeded) + ); + } + + #[test] + fn schema_maximum_is_exact_and_component_bounds_are_enforced() { + assert_eq!(PAIRING_CLAIM_MAX_BYTES, 17_320); + assert_eq!( + PAIRING_CLAIM_MAX_BYTES, + 2 + (1 + u8::MAX as usize) + + 2 + + 48 + + 48 + + (2 + PAIRING_CREDENTIAL_MAX_BYTES) + + (2 + PAIRING_KEY_PACKAGE_MAX_BYTES) + + 64 + ); + let fixture = transcript(3); + assert_eq!( + PairingClaimV1::create_after_verification( + &fixture.invitation, + fixture.claim.device_credential.clone(), + &vec![0; PAIRING_KEY_PACKAGE_MAX_BYTES + 1], + &fixture.device_signer, + ), + Err(PairingError::BoundExceeded) + ); + assert_eq!( + PairingCredential::decode(&vec![0; PAIRING_CREDENTIAL_MAX_BYTES + 1]), + Err(PairingError::BoundExceeded) + ); + } + + #[test] + fn signatures_bind_invitation_key_package_and_credential() { + let fixture = transcript(4); + let mut invitation = fixture.invitation.clone(); + invitation.daemon_signature[0] ^= 1; + assert_eq!( + invitation.verify_with_clock(fixture.clock.as_ref()), + Err(PairingError::InvalidSignature) + ); + + let mut claim = fixture.claim.clone(); + claim.device_signature[0] ^= 1; + assert_eq!( + claim.verify_with_clock(&fixture.invitation, fixture.clock.as_ref()), + Err(PairingError::InvalidSignature) + ); + + let mut substituted_package = fixture.claim.clone(); + substituted_package.key_package[0] ^= 1; + assert_eq!( + substituted_package.verify_with_clock(&fixture.invitation, fixture.clock.as_ref()), + Err(PairingError::InvalidSignature) + ); + + let other = transcript(5); + let mut substituted_credential = fixture.claim.clone(); + substituted_credential.device_credential = other.claim.device_credential; + assert_eq!( + substituted_credential.verify_with_clock(&fixture.invitation, fixture.clock.as_ref()), + Err(PairingError::IdentityMismatch) + ); + + let replacement_signer = SignatureKeyPair::new(SUITE.signature_algorithm()).unwrap(); + let mut substituted_key = fixture.claim.clone(); + substituted_key.device_credential = PairingCredential::new( + substituted_key.device_credential.identity().clone(), + &replacement_signer, + ) + .unwrap(); + substituted_key.device_signature = replacement_signer + .sign( + &substituted_key + .signature_input(&fixture.invitation) + .unwrap(), + ) + .unwrap() + .try_into() + .unwrap(); + assert_eq!( + substituted_key.verify_with_clock(&fixture.invitation, fixture.clock.as_ref()), + Err(PairingError::IdentityMismatch) + ); + } + + #[test] + fn wrong_versions_profiles_identifiers_roles_and_nonce_are_rejected() { + let fixture = transcript(6); + let mut cases = Vec::new(); + let mut wrong = fixture.claim.clone(); + wrong.version = 2; + cases.push((wrong, PairingError::WrongVersion)); + let mut wrong = fixture.claim.clone(); + wrong.profile_id = "other".into(); + cases.push((wrong, PairingError::WrongProfile)); + let mut wrong = fixture.claim.clone(); + wrong.profile_revision = 2; + cases.push((wrong, PairingError::WrongProfileRevision)); + let mut wrong = fixture.claim.clone(); + wrong.account_id[0] ^= 1; + cases.push((wrong, PairingError::IdentityMismatch)); + let mut wrong = fixture.claim.clone(); + wrong.installation_id[0] ^= 1; + cases.push((wrong, PairingError::IdentityMismatch)); + let mut wrong = fixture.claim.clone(); + wrong.crypto_session_id[0] ^= 1; + cases.push((wrong, PairingError::IdentityMismatch)); + let mut wrong = fixture.claim.clone(); + wrong.invitation_nonce_hash[0] ^= 1; + cases.push((wrong, PairingError::NonceMismatch)); + for (claim, expected) in cases { + assert_eq!(claim.validate_bindings(&fixture.invitation), Err(expected)); + } + + let device_signer = SignatureKeyPair::new(SUITE.signature_algorithm()).unwrap(); + let device_as_daemon = PairingCredential::new( + Identity::device( + fixture.invitation.account_id, + fixture.invitation.installation_id, + uuid_v7(99), + ) + .unwrap(), + &device_signer, + ) + .unwrap(); + let mut wrong_daemon_role = fixture.invitation.clone(); + wrong_daemon_role.daemon_credential = device_as_daemon; + assert_eq!( + wrong_daemon_role.validate_bindings(), + Err(PairingError::IdentityMismatch) + ); + + let daemon_signer = SignatureKeyPair::new(SUITE.signature_algorithm()).unwrap(); + let daemon_as_device = PairingCredential::new( + Identity::daemon( + fixture.invitation.account_id, + fixture.invitation.installation_id, + ), + &daemon_signer, + ) + .unwrap(); + let mut wrong_role = fixture.claim.clone(); + wrong_role.device_credential = daemon_as_device; + assert_eq!( + wrong_role.validate_bindings(&fixture.invitation), + Err(PairingError::IdentityMismatch) + ); + assert_eq!( + Identity::device([1; 16], [2; 16], [0; 16]), + Err(crate::Error::InvalidIdentity("device id must not be zero")) + ); + + let invitation_id_offset = 2 + 1 + PROFILE_ID.len() + 2 + 16; + let mut invalid_installation_version = fixture.invitation.encode().unwrap(); + invalid_installation_version[invitation_id_offset + 6] = 0x60; + assert_eq!( + PairingInvitation::decode(&invalid_installation_version), + Err(PairingError::IdentityMismatch) + ); + let mut invalid_session_variant = fixture.invitation.encode().unwrap(); + invalid_session_variant[invitation_id_offset + 16 + 8] = 0x40; + assert_eq!( + PairingInvitation::decode(&invalid_session_variant), + Err(PairingError::IdentityMismatch) + ); + let invalid_device = Identity::device( + fixture.invitation.account_id, + fixture.invitation.installation_id, + [0x11; 16], + ) + .unwrap(); + let invalid_device_signer = SignatureKeyPair::new(SUITE.signature_algorithm()).unwrap(); + let mut invalid_device_claim = fixture.claim.clone(); + invalid_device_claim.device_credential = + PairingCredential::new(invalid_device, &invalid_device_signer).unwrap(); + assert_eq!( + invalid_device_claim.validate_self(), + Err(PairingError::IdentityMismatch) + ); + let mut invalid_device_variant = fixture.claim.encode().unwrap(); + let credential_offset = 2 + 1 + PROFILE_ID.len() + 2 + (16 * 3) + 48 + 2; + let credential_device_offset = 2 + 1 + 16 + 16; + invalid_device_variant[credential_offset + credential_device_offset + 8] = 0x40; + assert_eq!( + PairingClaimV1::decode(&invalid_device_variant), + Err(PairingError::IdentityMismatch) + ); + + let mut invalid_version = uuid_v7(100); + invalid_version[6] = 0x60; + assert_eq!( + validate_uuid_v7(invalid_version), + Err(PairingError::IdentityMismatch) + ); + let mut invalid_variant = uuid_v7(101); + invalid_variant[8] = 0x40; + assert_eq!( + validate_uuid_v7(invalid_variant), + Err(PairingError::IdentityMismatch) + ); + } + + #[test] + fn exact_lifetime_expiry_extension_and_clock_rollback_fail_closed() { + let fixture = transcript(7); + fixture.clock.set(fixture.invitation.expires_at_ms - 1); + fixture + .invitation + .verify_with_clock(fixture.clock.as_ref()) + .unwrap(); + fixture.clock.set(fixture.invitation.expires_at_ms); + assert_eq!( + fixture.invitation.verify_with_clock(fixture.clock.as_ref()), + Err(PairingError::Expired) + ); + + let mut invalid = fixture.invitation.clone(); + invalid.expires_at_ms = invalid.issued_at_ms - 1; + assert_eq!( + invalid.validate_time_shape(), + Err(PairingError::InvalidTime) + ); + let mut extended = fixture.invitation.clone(); + extended.expires_at_ms += 1; + assert_eq!( + extended.validate_time_shape(), + Err(PairingError::InvalidTime) + ); + fixture.clock.set(fixture.invitation.issued_at_ms - 1); + assert_eq!( + fixture.invitation.verify_with_clock(fixture.clock.as_ref()), + Err(PairingError::ClockRollback) + ); + } + + #[test] + fn comparison_is_first_39_bits_and_has_exact_grouping_with_leading_zeroes() { + let mut fixture = transcript(8); + let value = comparison_value(&fixture.invitation, &fixture.claim).unwrap(); + assert_eq!(value.len(), 15); + assert_eq!(value.as_bytes()[3], b' '); + assert_eq!(value.as_bytes()[7], b' '); + assert_eq!(value.as_bytes()[11], b' '); + assert!(value.bytes().filter(u8::is_ascii_digit).count() == 12); + + for byte in 0..=u8::MAX { + fixture.claim.key_package[0] = byte; + fixture.claim.device_signature = fixture + .device_signer + .sign(&fixture.claim.signature_input(&fixture.invitation).unwrap()) + .unwrap() + .try_into() + .unwrap(); + let candidate = comparison_value(&fixture.invitation, &fixture.claim).unwrap(); + if candidate.starts_with('0') { + assert_eq!(candidate.len(), 15); + return; + } + } + panic!("expected a leading-zero comparison fixture"); + } + + #[test] + fn failure_accounting_is_idempotent_and_cancels_on_five_distinct_eligible_claims() { + let fixture = transcript(9); + let mut accountant = PairingClaimAccountant::new_with_clock( + fixture.invitation.clone(), + fixture.clock.clone(), + ) + .unwrap(); + let bytes = fixture.claim.encode().unwrap(); + assert_eq!( + accountant.submit(&bytes, ClaimDecision::Failed(FailedClaimReason::Signature)), + PublicClaimResult::Rejected + ); + assert_eq!( + accountant.submit(&bytes, ClaimDecision::Failed(FailedClaimReason::Signature)), + PublicClaimResult::Rejected + ); + assert_eq!(accountant.failed_claim_count(), 1); + + let mut malformed = bytes.clone(); + malformed.push(0); + assert_eq!( + accountant.submit( + &malformed, + ClaimDecision::Failed(FailedClaimReason::Signature) + ), + PublicClaimResult::Rejected + ); + let mut wrong = bytes.clone(); + let account_offset = 2 + 1 + PROFILE_ID.len() + 2; + wrong[account_offset] ^= 1; + assert_eq!( + accountant.submit(&wrong, ClaimDecision::Failed(FailedClaimReason::Signature)), + PublicClaimResult::Rejected + ); + assert_eq!(accountant.failed_claim_count(), 1); + + for index in 1..5 { + let mut distinct = fixture.claim.clone(); + distinct.device_signature[index] ^= 1; + let reason = match index { + 1 => FailedClaimReason::Credential, + 2 => FailedClaimReason::KeyPackage, + _ => FailedClaimReason::Signature, + }; + let result = + accountant.submit(&distinct.encode().unwrap(), ClaimDecision::Failed(reason)); + assert_eq!( + result, + if index == 4 { + PublicClaimResult::Cancelled + } else { + PublicClaimResult::Rejected + } + ); + } + assert_eq!(accountant.failed_claim_count(), 5); + assert_eq!( + accountant.submit(&bytes, ClaimDecision::Failed(FailedClaimReason::Signature)), + PublicClaimResult::Rejected + ); + let mut sixth = fixture.claim.clone(); + sixth.device_signature[6] ^= 1; + assert_eq!( + accountant.submit( + &sixth.encode().unwrap(), + ClaimDecision::Failed(FailedClaimReason::Signature) + ), + PublicClaimResult::Cancelled + ); + assert_eq!(accountant.failed_claim_count(), 5); + } + + #[test] + fn expired_cancelled_and_clock_rollback_requests_do_not_consume_attempts() { + let expired = transcript(12); + let bytes = expired.claim.encode().unwrap(); + let mut expired_accountant = PairingClaimAccountant::new_with_clock( + expired.invitation.clone(), + expired.clock.clone(), + ) + .unwrap(); + expired.clock.set(expired.invitation.expires_at_ms + 1); + assert_eq!( + expired_accountant.submit(&bytes, ClaimDecision::Failed(FailedClaimReason::Signature)), + PublicClaimResult::Rejected + ); + assert_eq!(expired_accountant.failed_claim_count(), 0); + + let cancelled = transcript(13); + let bytes = cancelled.claim.encode().unwrap(); + let mut cancelled_accountant = + PairingClaimAccountant::new_with_clock(cancelled.invitation, cancelled.clock).unwrap(); + cancelled_accountant.cancel(); + assert_eq!( + cancelled_accountant + .submit(&bytes, ClaimDecision::Failed(FailedClaimReason::Signature)), + PublicClaimResult::Cancelled + ); + assert_eq!(cancelled_accountant.failed_claim_count(), 0); + + let rollback = transcript(14); + let bytes = rollback.claim.encode().unwrap(); + let mut rollback_accountant = + PairingClaimAccountant::new_with_clock(rollback.invitation, rollback.clock.clone()) + .unwrap(); + rollback.clock.set(999_999); + assert_eq!( + rollback_accountant.submit(&bytes, ClaimDecision::Failed(FailedClaimReason::Signature)), + PublicClaimResult::Rejected + ); + assert_eq!(rollback_accountant.failed_claim_count(), 0); + } + + #[test] + fn accountant_constructor_uses_one_clock_sample_for_validation_and_baseline() { + let fixture = transcript(15); + let clock = ScriptedClock::new([1_500_000, 1_400_000]); + let accountant = + PairingClaimAccountant::new_with_clock(fixture.invitation, clock.clone()).unwrap(); + + assert_eq!(clock.calls.load(Ordering::SeqCst), 1); + assert_eq!(accountant.last_now_ms, 1_500_000); + } + + #[test] + fn accepted_duplicate_recovers_exact_result_and_conflict_cannot_replace_it() { + let fixture = transcript(10); + let mut accountant = PairingClaimAccountant::new_with_clock( + fixture.invitation.clone(), + fixture.clock.clone(), + ) + .unwrap(); + let bytes = fixture.claim.encode().unwrap(); + let accepted = AcceptedPairingResult::new(b"test-only exact accepted result").unwrap(); + assert_eq!(accepted.bytes(), b"test-only exact accepted result"); + assert_eq!( + accountant.submit(&bytes, ClaimDecision::Accepted(accepted.clone())), + PublicClaimResult::Accepted(accepted.clone()) + ); + accountant.mark_consumed().unwrap(); + assert_eq!( + accountant.submit(&bytes, ClaimDecision::Failed(FailedClaimReason::Signature)), + PublicClaimResult::Accepted(accepted.clone()) + ); + let mut conflicting = fixture.claim.clone(); + conflicting.device_signature[0] ^= 1; + assert_eq!( + accountant.submit( + &conflicting.encode().unwrap(), + ClaimDecision::Accepted(AcceptedPairingResult::new(b"replacement").unwrap()) + ), + PublicClaimResult::Conflict + ); + fixture.clock.set(fixture.invitation.expires_at_ms); + assert_eq!( + accountant.submit(&bytes, ClaimDecision::Accepted(accepted)), + PublicClaimResult::Rejected + ); + } + + fn maximum_claim_fixture() -> Vec { + let mut bytes = Vec::with_capacity(PAIRING_CLAIM_MAX_BYTES); + put_u16(&mut bytes, 1); + put_u8_vector(&mut bytes, &[b'x'; 255], 255).unwrap(); + put_u16(&mut bytes, 1); + bytes.extend_from_slice(&[1; 16 * 3]); + bytes.extend_from_slice(&[2; 48]); + put_u16_vector( + &mut bytes, + &[3; PAIRING_CREDENTIAL_MAX_BYTES], + PAIRING_CREDENTIAL_MAX_BYTES, + ) + .unwrap(); + put_u16_vector( + &mut bytes, + &[4; PAIRING_KEY_PACKAGE_MAX_BYTES], + PAIRING_KEY_PACKAGE_MAX_BYTES, + ) + .unwrap(); + bytes.extend_from_slice(&[5; 64]); + assert_eq!(bytes.len(), PAIRING_CLAIM_MAX_BYTES); + bytes + } + + fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut value = String::with_capacity(bytes.len() * 2); + for byte in bytes { + value.push(DIGITS[(byte >> 4) as usize] as char); + value.push(DIGITS[(byte & 0x0f) as usize] as char); + } + value + } + + fn parse_hex(value: &str) -> Result<[u8; N], &'static str> { + if value.len() != N * 2 { + return Err("wrong hex length"); + } + let mut bytes = [0; N]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + let text = std::str::from_utf8(pair).map_err(|_| "non-UTF-8 hex")?; + bytes[index] = u8::from_str_radix(text, 16).map_err(|_| "invalid hex")?; + } + Ok(bytes) + } + + fn parse_fixture_manifest(text: &str) -> Result, &'static str> { + const KEYS: [&str; 13] = [ + "profile_id", + "profile_revision", + "account_id", + "installation_id", + "crypto_session_id", + "issued_at_ms", + "expires_at_ms", + "daemon_credential_sha384", + "device_credential_sha384", + "invitation_sha384", + "claim_sha384", + "comparison", + "maximum_claim_bytes", + ]; + let allowed = KEYS.into_iter().collect::>(); + let mut values = BTreeMap::new(); + for line in text.lines() { + if line.starts_with('#') { + continue; + } + let (key, value) = line.split_once('=').ok_or("malformed entry")?; + if key.is_empty() || value.is_empty() || value.contains('=') { + return Err("malformed entry"); + } + if !allowed.contains(key) { + return Err("unknown entry"); + } + if values.insert(key.to_owned(), value.to_owned()).is_some() { + return Err("duplicate entry"); + } + } + if values.len() != allowed.len() || allowed.iter().any(|key| !values.contains_key(*key)) { + return Err("missing entry"); + } + values["profile_revision"] + .parse::() + .map_err(|_| "invalid profile revision")?; + parse_hex::<16>(&values["account_id"])?; + parse_hex::<16>(&values["installation_id"])?; + parse_hex::<16>(&values["crypto_session_id"])?; + values["issued_at_ms"] + .parse::() + .map_err(|_| "invalid issue time")?; + values["expires_at_ms"] + .parse::() + .map_err(|_| "invalid expiry time")?; + parse_hex::<48>(&values["daemon_credential_sha384"])?; + parse_hex::<48>(&values["device_credential_sha384"])?; + parse_hex::<48>(&values["invitation_sha384"])?; + parse_hex::<48>(&values["claim_sha384"])?; + let groups = values["comparison"].split(' ').collect::>(); + if groups.len() != 4 + || groups + .iter() + .any(|group| group.len() != 3 || !group.bytes().all(|byte| byte.is_ascii_digit())) + { + return Err("invalid comparison"); + } + values["maximum_claim_bytes"] + .parse::() + .map_err(|_| "invalid maximum")?; + Ok(values) + } + + #[test] + fn fixture_manifest_is_strict() { + let valid = include_str!("../fixtures/v1/expected.txt"); + assert!(parse_fixture_manifest(valid).is_ok()); + assert_eq!( + parse_fixture_manifest(&format!("{valid}profile_id={PROFILE_ID}\n")), + Err("duplicate entry") + ); + assert_eq!( + parse_fixture_manifest(&format!("{valid}unknown=value\n")), + Err("unknown entry") + ); + assert_eq!( + parse_fixture_manifest(&valid.replace(&format!("profile_id={PROFILE_ID}\n"), "")), + Err("missing entry") + ); + assert_eq!( + parse_fixture_manifest(&valid.replace("profile_revision=1", "profile_revision=no")), + Err("invalid profile revision") + ); + assert_eq!( + parse_fixture_manifest(&format!("{valid}malformed\n")), + Err("malformed entry") + ); + } + + #[test] + fn checked_in_native_fixtures_match_the_canonical_transcript() { + let invitation_bytes = include_bytes!("../fixtures/v1/pairing-invitation.tls"); + let claim_bytes = include_bytes!("../fixtures/v1/pairing-claim-v1.tls"); + let maximum_claim = include_bytes!("../fixtures/v1/pairing-claim-v1-maximum.tls"); + let expected = parse_fixture_manifest(include_str!("../fixtures/v1/expected.txt")).unwrap(); + let invitation = PairingInvitation::decode(invitation_bytes).unwrap(); + let claim = PairingClaimV1::decode(claim_bytes).unwrap(); + claim.verify_signature_and_bindings(&invitation).unwrap(); + + assert_eq!(expected["profile_id"], invitation.profile_id()); + assert_eq!( + expected["profile_revision"].parse::().unwrap(), + invitation.profile_revision() + ); + assert_eq!( + parse_hex::<16>(&expected["account_id"]).unwrap(), + invitation.account_id() + ); + assert_eq!( + parse_hex::<16>(&expected["installation_id"]).unwrap(), + invitation.installation_id() + ); + assert_eq!( + parse_hex::<16>(&expected["crypto_session_id"]).unwrap(), + invitation.crypto_session_id() + ); + assert_eq!( + expected["issued_at_ms"].parse::().unwrap(), + invitation.issued_at_ms() + ); + assert_eq!( + expected["expires_at_ms"].parse::().unwrap(), + invitation.expires_at_ms() + ); + assert_eq!( + parse_hex::<48>(&expected["daemon_credential_sha384"]).unwrap(), + sha384(invitation.daemon_credential().bytes()).unwrap() + ); + assert_eq!( + parse_hex::<48>(&expected["device_credential_sha384"]).unwrap(), + sha384(claim.device_credential().bytes()).unwrap() + ); + assert_eq!( + parse_hex::<48>(&expected["invitation_sha384"]).unwrap(), + invitation.invitation_hash().unwrap() + ); + assert_eq!( + parse_hex::<48>(&expected["claim_sha384"]).unwrap(), + claim.claim_hash().unwrap() + ); + assert_eq!( + expected["comparison"], + comparison_value(&invitation, &claim).unwrap() + ); + assert_eq!( + expected["maximum_claim_bytes"].parse::().unwrap(), + PAIRING_CLAIM_MAX_BYTES + ); + assert_eq!(maximum_claim.len(), PAIRING_CLAIM_MAX_BYTES); + assert_eq!(maximum_claim.as_slice(), maximum_claim_fixture()); + } + + #[test] + fn generate_checked_in_native_fixtures() { + if std::env::var_os("AXL_REGENERATE_PAIRING_FIXTURES").as_deref() + != Some(std::ffi::OsStr::new("1")) + { + assert!(!include_bytes!("../fixtures/v1/pairing-invitation.tls").is_empty()); + assert!(!include_bytes!("../fixtures/v1/pairing-claim-v1.tls").is_empty()); + return; + } + let fixture = transcript(42); + let invitation = fixture.invitation.encode().unwrap(); + let claim = fixture.claim.encode().unwrap(); + let directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/v1"); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("pairing-invitation.tls"), &invitation).unwrap(); + std::fs::write(directory.join("pairing-claim-v1.tls"), &claim).unwrap(); + std::fs::write( + directory.join("pairing-claim-v1-maximum.tls"), + maximum_claim_fixture(), + ) + .unwrap(); + let metadata = format!( + "# SPDX-FileCopyrightText: 2026 VishnuM449\n# SPDX-License-Identifier: Apache-2.0\n# Test-only native Rust pairing fixtures. No private signing key or standalone nonce is included.\nprofile_id={}\nprofile_revision={}\naccount_id={}\ninstallation_id={}\ncrypto_session_id={}\nissued_at_ms={}\nexpires_at_ms={}\ndaemon_credential_sha384={}\ndevice_credential_sha384={}\ninvitation_sha384={}\nclaim_sha384={}\ncomparison={}\nmaximum_claim_bytes={}\n", + PROFILE_ID, + PROFILE_REVISION, + hex(&fixture.invitation.account_id), + hex(&fixture.invitation.installation_id), + hex(&fixture.invitation.crypto_session_id), + fixture.invitation.issued_at_ms, + fixture.invitation.expires_at_ms, + hex(&sha384(fixture.invitation.daemon_credential.bytes()).unwrap()), + hex(&sha384(fixture.claim.device_credential.bytes()).unwrap()), + hex(&fixture.invitation.invitation_hash().unwrap()), + hex(&fixture.claim.claim_hash().unwrap()), + comparison_value(&fixture.invitation, &fixture.claim).unwrap(), + PAIRING_CLAIM_MAX_BYTES, + ); + std::fs::write(directory.join("expected.txt"), metadata).unwrap(); + } + + #[test] + fn debug_and_public_errors_do_not_disclose_secret_material() { + let fixture = transcript(11); + let invitation_debug = format!("{:?}", fixture.invitation); + let claim_debug = format!("{:?}", fixture.claim); + assert!(invitation_debug.contains("[redacted]")); + assert!(claim_debug.contains("[redacted]")); + assert!(!invitation_debug.contains(&hex(&fixture.invitation.invitation_nonce))); + assert!(!claim_debug.contains(&hex(fixture.claim.device_credential.bytes()))); + assert!(!claim_debug.contains(&hex(fixture.claim.key_package()))); + let error = PairingError::InvalidSignature.to_string(); + assert!(!error.contains("credential")); + assert!(!error.contains("signature bytes")); + } +} diff --git a/packages/e2ee/src/persistence.rs b/packages/e2ee/src/persistence.rs new file mode 100644 index 00000000..a0f4aa27 --- /dev/null +++ b/packages/e2ee/src/persistence.rs @@ -0,0 +1,3674 @@ +// SPDX-FileCopyrightText: 2026 VishnuM449 +// SPDX-License-Identifier: Apache-2.0 + +//! Native durable storage for the endpoint E2EE core. +//! +//! One database is permanently bound to one crypto session. Database values contain encrypted +//! OpenMLS storage images, exact ciphertext, and non-secret routing-independent metadata. Wrapping +//! keys and rollback anchors are supplied by the platform and never enter redb. + +use std::{ + collections::{BTreeMap, BTreeSet}, + error::Error as StdError, + fmt, + fs::{self, File, OpenOptions}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; + +use openmls::{group::MlsGroup, prelude::GroupId}; +use openmls_basic_credential::SignatureKeyPair; +use openmls_traits::{ + OpenMlsProvider, crypto::OpenMlsCrypto as _, random::OpenMlsRand as _, types::AeadType, +}; +use redb::{ + Database, Durability, ReadableDatabase, ReadableTable, TableDefinition, TableHandle, + WriteTransaction, +}; + +use crate::{ + Clock, CommitMetadata, CoreProvider, Daemon, Endpoint, Error as CoreError, GroupTransaction, + Id, Identity, MAX_PAST_EPOCHS, MessageClass, PROFILE_ID, PROFILE_REVISION, PairContext, + PairWelcome, Phone, PhoneKeyPackage, PreparedEnvelope, Role, SUITE, SystemClock, + TransactionalProvider, +}; + +/// Current Axl native E2EE storage schema. +pub const STORAGE_SCHEMA_VERSION: u16 = 1; +const STATE_FORMAT_VERSION: u16 = 1; +const MAX_STATE_ENTRIES: usize = 8192; +const MAX_STATE_BYTES: usize = 16 * 1024 * 1024; +const STATE_AAD_LABEL: &[u8] = b"Axl encrypted OpenMLS state v1"; + +const META: TableDefinition = TableDefinition::new("metadata_v1"); +const STATE: TableDefinition = TableDefinition::new("encrypted_state_v1"); +const OPERATIONS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("operations_v1"); +const OUTBOX: TableDefinition<&[u8], &[u8]> = TableDefinition::new("outbox_v1"); +const ACCEPTED: TableDefinition<&[u8], &[u8]> = TableDefinition::new("accepted_messages_v1"); + +const META_SCHEMA: u8 = 1; +const META_SESSION: u8 = 2; +const META_PROFILE: u8 = 3; +const META_PROFILE_REVISION: u8 = 4; +const META_GENERATION: u8 = 5; +const META_ROLLBACK_COUNTER: u8 = 6; +const META_EPOCH: u8 = 7; +const META_EPOCH_AUTHENTICATOR: u8 = 8; +const META_PENDING_ERASE: u8 = 9; +const META_LIFECYCLE: u8 = 10; +const LIFECYCLE_INITIALIZING: u8 = 1; +const LIFECYCLE_READY: u8 = 2; +const STATE_CURRENT: u8 = 1; +const ENDPOINT_METADATA_KEY: &[u8] = b"\0axl-endpoint-metadata-v1"; +const PENDING_PLAINTEXT_PREFIX: &[u8] = b"\0axl-pending-plaintext-v1"; +const DURABLE_MANIFEST_KEY: &[u8] = b"\0axl-durable-manifest-v1"; +#[cfg(not(test))] +pub const IDEMPOTENCY_RETENTION_GENERATIONS: u64 = 4096; +#[cfg(test)] +pub const IDEMPOTENCY_RETENTION_GENERATIONS: u64 = 64; + +/// Deterministic fault points exercised by the persistence test matrix. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum FaultPoint { + BeforeOpenMlsStateWrites, + DuringOpenMlsProviderWrites, + BeforeCiphertextInsertion, + AfterCiphertextInsertion, + BeforeCommit, + AfterDurableCommit, + AfterCommitBeforeNetworkSend, + DuringReceiverStateWrites, + BeforeReceiverAcknowledgement, + AfterAcknowledgementLoss, + DuringRestartReload, + DuringWrappingRecordReplacement, + DuringWrappingRecordErasure, + DuringCurrentKeyActivation, + DuringPreparedKeyReconciliation, + BeforeAnchorRecovery, + AfterAnchorRecoveryBeforeErasure, + DuringDuplicateOperation, + DuringGenerationConflict, + AfterInitializationMarkerCreation, + AfterInitializationFileCreation, + AfterInitializationSchemaCommit, + BeforeInitializationReady, + AfterInitializationReadyCommit, +} + +/// Injected deterministic fault policy. Production uses [`NoFaults`]. +pub(crate) trait FaultInjector: Send + Sync { + fn check(&self, point: FaultPoint) -> Result<(), PersistenceError>; +} + +/// Fault policy that never injects a failure. +pub(crate) struct NoFaults; + +pub(crate) struct RuntimeHooks { + pub(crate) faults: Arc, + pub(crate) clock: Arc, +} + +impl FaultInjector for NoFaults { + fn check(&self, _point: FaultPoint) -> Result<(), PersistenceError> { + Ok(()) + } +} + +/// Platform-owned envelope-key boundary. +/// +/// Implementations keep wrapping keys and wrapping records outside redb. `prepare` creates an +/// inactive record bound to one crypto session and authenticated context. `load` accepts active +/// records only. `activate` makes a committed prepared record loadable and is idempotent. +/// `reconcile_prepared` must enumerate the session's prepared records, activate the committed +/// current record when supplied, and erase every other inactive orphan. It must not erase active +/// obsolete records; Axl does that only after rollback-anchor reconciliation. `activate`, `erase`, +/// `reconcile_prepared`, and `destroy_session` are idempotent. Platform secure-key +/// implementations are intentionally deferred to Session 50. +pub trait EnvelopeKeyStore: Send + Sync { + fn available(&self) -> bool; + fn prepare( + &self, + crypto_session_id: Id, + key_id: [u8; 16], + data_key: &[u8; 32], + authenticated_context: &[u8], + ) -> Result<(), PersistenceError>; + fn load( + &self, + crypto_session_id: Id, + key_id: [u8; 16], + authenticated_context: &[u8], + ) -> Result<[u8; 32], PersistenceError>; + fn activate( + &self, + crypto_session_id: Id, + key_id: [u8; 16], + authenticated_context: &[u8], + ) -> Result<(), PersistenceError>; + fn reconcile_prepared( + &self, + crypto_session_id: Id, + committed_current: Option<([u8; 16], Vec)>, + ) -> Result<(), PersistenceError>; + fn erase(&self, crypto_session_id: Id, key_id: [u8; 16]) -> Result<(), PersistenceError>; + fn destroy_session(&self, crypto_session_id: Id) -> Result<(), PersistenceError>; +} + +/// State held outside the redb snapshot domain by a monotonic platform service. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RollbackState { + pub counter: u64, + pub epoch: u64, + pub epoch_authenticator: Vec, +} + +/// Platform-owned monotonic rollback anchor. +pub trait RollbackAnchor: Send + Sync { + fn available(&self) -> bool; + fn read(&self, crypto_session_id: Id) -> Result; + fn advance( + &self, + crypto_session_id: Id, + expected: &RollbackState, + next: &RollbackState, + operation_id: Id, + ) -> Result<(), PersistenceError>; +} + +/// Durable exact-ciphertext record. Relay route identifiers are deliberately absent. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OutboxRecord { + pub operation_id: Id, + pub crypto_session_id: Id, + pub logical_message_id: Id, + pub class: MessageClass, + pub epoch: u64, + pub profile_revision: u16, + pub retry_state: RetryState, + pub ciphertext: Vec, + pub commit: Option, +} + +/// Durable retry state for an exact ciphertext. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum RetryState { + Pending = 1, + Acknowledged = 2, +} + +/// Durable accepted-message identity. Plaintext is held only in the encrypted state envelope. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AcceptedMessageRecord { + pub operation_id: Id, + pub crypto_session_id: Id, + pub logical_message_id: Id, + pub class: MessageClass, + pub epoch: u64, + pub profile_revision: u16, + pub acknowledged: bool, +} + +/// Result recorded for operation-id recovery. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CommittedOperation { + Envelope(OutboxRecord), + Accepted(AcceptedMessageRecord), + OutboxAcknowledged(OutboxRecord), + ReceiveAcknowledged(AcceptedMessageRecord), +} + +/// Errors from the durable boundary. Messages never include keys, plaintext, or ciphertext. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PersistenceError { + AlreadyAcknowledged, + AlreadyExists, + AnchorUnavailable, + Conflict, + Corrupt, + InjectedFault, + GenerationConflict, + IdentityMismatch, + InitializationIncomplete, + Io, + LifecycleBusy, + KeyUnavailable, + NotFound, + Quarantined, + RetentionExceeded, + Storage, + UnsupportedSchema, + Core(CoreError), +} + +impl fmt::Display for PersistenceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{self:?}") + } +} +impl StdError for PersistenceError {} + +impl From for PersistenceError { + fn from(value: CoreError) -> Self { + Self::Core(value) + } +} + +/// Native transactional provider backed by one redb file per pairwise group. +pub(crate) struct NativeTransactionalProvider { + path: PathBuf, + initialization_marker: PathBuf, + crypto_session_id: Id, + lifecycle_claim: Mutex>, + database: Mutex>, + operation_lock: Mutex<()>, + envelope_keys: Arc, + rollback_anchor: Arc, + faults: Arc, + clock: Arc, +} + +/// Explicitly remove an incomplete creation after the caller has abandoned that pairing attempt. +/// Ready databases are never removed by this function, and a consumed monotonic anchor means the +/// caller must choose a fresh crypto session ID for the next attempt. +pub fn discard_interrupted_creation( + root: &Path, + crypto_session_id: Id, + envelope_keys: Arc, +) -> Result<(), PersistenceError> { + let root = canonical_storage_root(root)?; + let _lifecycle_claim = acquire_session_lifecycle_claim(&root, crypto_session_id)?; + let path = root.join(format!("{}.redb", hex_id(crypto_session_id))); + let marker = initializing_marker_for_database(&path); + validate_regular_file(&marker, PersistenceError::NotFound)?; + + let database_metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => Some(metadata), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return Err(PersistenceError::Io), + }; + let Some(database_metadata) = database_metadata else { + envelope_keys.destroy_session(crypto_session_id)?; + fs::remove_file(marker).map_err(|_| PersistenceError::Io)?; + return sync_parent_directory(&path); + }; + if database_metadata.file_type().is_symlink() || !database_metadata.is_file() { + return Err(PersistenceError::IdentityMismatch); + } + let canonical = path.canonicalize().map_err(|_| PersistenceError::Io)?; + if canonical.parent() != Some(root.as_path()) { + return Err(PersistenceError::IdentityMismatch); + } + + // The lifecycle claim excludes creators and openers, so writable open is safe here and lets + // redb recover its own bookkeeping after an abruptly terminated process before inspection. + let database = Database::open(&canonical).map_err(map_database_error)?; + match ( + inspect_database_lifecycle(&database, crypto_session_id)?, + inspect_initialization_state(&database, crypto_session_id)?, + ) { + (LIFECYCLE_INITIALIZING, InitializationState::Pristine) => {} + (LIFECYCLE_INITIALIZING, InitializationState::Committed) => { + return Err(PersistenceError::InitializationIncomplete); + } + (LIFECYCLE_INITIALIZING, InitializationState::Inconsistent) => { + return Err(PersistenceError::Corrupt); + } + (LIFECYCLE_READY, _) => return Err(PersistenceError::AlreadyExists), + _ => return Err(PersistenceError::Corrupt), + } + drop(database); + + envelope_keys.destroy_session(crypto_session_id)?; + fs::remove_file(&canonical).map_err(|_| PersistenceError::Io)?; + fs::remove_file(marker).map_err(|_| PersistenceError::Io)?; + sync_parent_directory(&canonical) +} + +impl NativeTransactionalProvider { + /// Explicitly create new cryptographic storage. Existing paths are never reset or reused. + pub(crate) fn create( + root: &Path, + crypto_session_id: Id, + envelope_keys: Arc, + rollback_anchor: Arc, + faults: Arc, + clock: Arc, + ) -> Result, PersistenceError> { + require_dependencies(&*envelope_keys, &*rollback_anchor)?; + let initial_anchor = rollback_anchor.read(crypto_session_id)?; + if initial_anchor + != (RollbackState { + counter: 0, + epoch: 0, + epoch_authenticator: Vec::new(), + }) + { + return Err(PersistenceError::IdentityMismatch); + } + let root = prepare_storage_root(root, true)?; + let lifecycle_claim = acquire_session_lifecycle_claim(&root, crypto_session_id)?; + let path = database_path(&root, crypto_session_id, true, Some(&*faults))?; + let database = Database::create(&path).map_err(map_database_error)?; + restrict_file(&path)?; + let initialization_marker = initializing_marker_for_database(&path); + let this = Arc::new(Self { + path, + initialization_marker, + crypto_session_id, + lifecycle_claim: Mutex::new(Some(lifecycle_claim)), + database: Mutex::new(Some(database)), + operation_lock: Mutex::new(()), + envelope_keys, + rollback_anchor, + faults, + clock, + }); + this.faults + .check(FaultPoint::AfterInitializationFileCreation)?; + this.initialize_schema()?; + this.faults + .check(FaultPoint::AfterInitializationSchemaCommit)?; + Ok(this) + } + + /// Open existing cryptographic storage. Missing, corrupt, mismatched, or unsupported storage + /// fails closed and is never recreated. + pub(crate) fn open( + root: &Path, + crypto_session_id: Id, + envelope_keys: Arc, + rollback_anchor: Arc, + faults: Arc, + clock: Arc, + ) -> Result, PersistenceError> { + require_dependencies(&*envelope_keys, &*rollback_anchor)?; + let root = prepare_storage_root(root, false)?; + let lifecycle_claim = acquire_session_lifecycle_claim(&root, crypto_session_id)?; + let path = database_path(&root, crypto_session_id, false, None)?; + let initialization_marker = initializing_marker_for_database(&path); + regular_file_exists(&initialization_marker)?; + faults.check(FaultPoint::DuringRestartReload)?; + let database = Database::open(&path).map_err(map_database_error)?; + let lifecycle = inspect_database_lifecycle(&database, crypto_session_id)?; + if lifecycle == LIFECYCLE_INITIALIZING { + match inspect_initialization_state(&database, crypto_session_id)? { + InitializationState::Pristine => { + return Err(PersistenceError::InitializationIncomplete); + } + InitializationState::Committed => {} + InitializationState::Inconsistent => return Err(PersistenceError::Corrupt), + } + } else if lifecycle != LIFECYCLE_READY { + return Err(PersistenceError::Corrupt); + } + let this = Arc::new(Self { + path, + initialization_marker, + crypto_session_id, + lifecycle_claim: Mutex::new(Some(lifecycle_claim)), + database: Mutex::new(Some(database)), + operation_lock: Mutex::new(()), + envelope_keys, + rollback_anchor, + faults, + clock, + }); + this.recover_storage()?; + if lifecycle == LIFECYCLE_READY { + this.validate_ready()?; + } + Ok(this) + } + + fn remove_stale_initialization_marker(&self) -> Result<(), PersistenceError> { + if !regular_file_exists(&self.initialization_marker)? { + return Ok(()); + } + fs::remove_file(&self.initialization_marker).map_err(|_| PersistenceError::Io)?; + sync_parent_directory(&self.path) + } + + fn finish_opening(&self) -> Result<(), PersistenceError> { + let lifecycle = { + let database = self.database_lock()?; + let database = database.as_ref().ok_or(PersistenceError::Storage)?; + inspect_database_lifecycle(database, self.crypto_session_id)? + }; + if lifecycle == LIFECYCLE_INITIALIZING { + self.mark_ready() + } else if lifecycle == LIFECYCLE_READY { + self.remove_stale_initialization_marker()?; + self.release_lifecycle_claim() + } else { + Err(PersistenceError::Corrupt) + } + } + + fn release_lifecycle_claim(&self) -> Result<(), PersistenceError> { + self.lifecycle_claim + .lock() + .map_err(|_| PersistenceError::Storage)? + .take(); + Ok(()) + } + + fn mark_ready(&self) -> Result<(), PersistenceError> { + let database = self.database_lock()?; + let mut write = database + .as_ref() + .ok_or(PersistenceError::Storage)? + .begin_write() + .map_err(map_transaction_error)?; + configure_transaction(&mut write)?; + { + let mut meta = write.open_table(META).map_err(map_table_error)?; + meta.insert(META_LIFECYCLE, &[LIFECYCLE_READY] as &[u8]) + .map_err(map_storage_error)?; + } + write.commit().map_err(|_| PersistenceError::Storage)?; + self.faults + .check(FaultPoint::AfterInitializationReadyCommit)?; + fs::remove_file(&self.initialization_marker).map_err(|_| PersistenceError::Io)?; + sync_parent_directory(&self.path)?; + self.release_lifecycle_claim() + } + + fn validate_ready(&self) -> Result<(), PersistenceError> { + let database = self.database_lock()?; + let read = database + .as_ref() + .ok_or(PersistenceError::Storage)? + .begin_read() + .map_err(map_transaction_error)?; + let meta = read.open_table(META).map_err(map_table_error)?; + match read_bytes(&meta, META_LIFECYCLE)?.as_slice() { + [LIFECYCLE_READY] => Ok(()), + [LIFECYCLE_INITIALIZING] => Err(PersistenceError::InitializationIncomplete), + _ => Err(PersistenceError::Corrupt), + } + } + + #[cfg(test)] + pub(crate) fn crypto_session_id(&self) -> Id { + self.crypto_session_id + } + + #[cfg(test)] + pub(crate) fn path(&self) -> &Path { + &self.path + } + + /// Close the database handle. Existing transactions retain their own engine handle and must + /// finish before a reopen is attempted. + pub(crate) fn close(&self) -> Result<(), PersistenceError> { + *self + .database + .lock() + .map_err(|_| PersistenceError::Storage)? = None; + Ok(()) + } + + #[cfg(test)] + pub(crate) fn reopen(&self) -> Result<(), PersistenceError> { + self.faults.check(FaultPoint::DuringRestartReload)?; + self.reopen_database_only()?; + self.recover_storage()?; + self.validate_ready() + } + + fn reopen_database_only(&self) -> Result<(), PersistenceError> { + let database = Database::open(&self.path).map_err(map_database_error)?; + *self + .database + .lock() + .map_err(|_| PersistenceError::Storage)? = Some(database); + Ok(()) + } + + fn recover_storage(&self) -> Result<(), PersistenceError> { + require_dependencies(&*self.envelope_keys, &*self.rollback_anchor)?; + self.validate_binding()?; + self.faults + .check(FaultPoint::DuringPreparedKeyReconciliation)?; + self.activate_and_validate_current_state()?; + self.reconcile_anchor_only()?; + self.finish_pending_erasure() + } + + pub(crate) fn generation(&self) -> Result { + self.read_u64_meta(META_GENERATION) + } + + pub(crate) fn rollback_counter(&self) -> Result { + self.read_u64_meta(META_ROLLBACK_COUNTER) + } + + #[cfg(test)] + pub(crate) fn rollback_state(&self) -> Result { + self.database_rollback_state() + } + + pub(crate) fn operation( + &self, + operation_id: Id, + ) -> Result, PersistenceError> { + let database = self.database_lock()?; + let read = database + .as_ref() + .ok_or(PersistenceError::Storage)? + .begin_read() + .map_err(map_transaction_error)?; + let table = read.open_table(OPERATIONS).map_err(map_table_error)?; + let value = table + .get(operation_id.as_slice()) + .map_err(map_storage_error)?; + value + .map(|bytes| { + let operation = decode_operation(bytes.value())?; + validate_operation_binding(&operation, operation_id, self.crypto_session_id)?; + Ok(operation) + }) + .transpose() + } + + #[cfg(test)] + pub(crate) fn outbox( + &self, + operation_id: Id, + ) -> Result, PersistenceError> { + let database = self.database_lock()?; + let read = database + .as_ref() + .ok_or(PersistenceError::Storage)? + .begin_read() + .map_err(map_transaction_error)?; + let table = read.open_table(OUTBOX).map_err(map_table_error)?; + let value = table + .get(operation_id.as_slice()) + .map_err(map_storage_error)?; + value + .map(|bytes| { + let record = decode_outbox(bytes.value())?; + if record.operation_id != operation_id + || record.crypto_session_id != self.crypto_session_id + { + return Err(PersistenceError::IdentityMismatch); + } + Ok(record) + }) + .transpose() + } + + fn initialize_schema(&self) -> Result<(), PersistenceError> { + let database = self.database_lock()?; + let mut write = database + .as_ref() + .ok_or(PersistenceError::Storage)? + .begin_write() + .map_err(map_transaction_error)?; + configure_transaction(&mut write)?; + { + let mut meta = write.open_table(META).map_err(map_table_error)?; + meta.insert(META_SCHEMA, STORAGE_SCHEMA_VERSION.to_be_bytes().as_slice()) + .map_err(map_storage_error)?; + meta.insert(META_SESSION, self.crypto_session_id.as_slice()) + .map_err(map_storage_error)?; + meta.insert(META_PROFILE, PROFILE_ID.as_bytes()) + .map_err(map_storage_error)?; + meta.insert( + META_PROFILE_REVISION, + PROFILE_REVISION.to_be_bytes().as_slice(), + ) + .map_err(map_storage_error)?; + meta.insert(META_GENERATION, 0_u64.to_be_bytes().as_slice()) + .map_err(map_storage_error)?; + meta.insert(META_ROLLBACK_COUNTER, 0_u64.to_be_bytes().as_slice()) + .map_err(map_storage_error)?; + meta.insert(META_EPOCH, 0_u64.to_be_bytes().as_slice()) + .map_err(map_storage_error)?; + meta.insert(META_EPOCH_AUTHENTICATOR, &[] as &[u8]) + .map_err(map_storage_error)?; + meta.insert(META_PENDING_ERASE, &[] as &[u8]) + .map_err(map_storage_error)?; + meta.insert(META_LIFECYCLE, &[LIFECYCLE_INITIALIZING] as &[u8]) + .map_err(map_storage_error)?; + write.open_table(STATE).map_err(map_table_error)?; + write.open_table(OPERATIONS).map_err(map_table_error)?; + write.open_table(OUTBOX).map_err(map_table_error)?; + write.open_table(ACCEPTED).map_err(map_table_error)?; + } + write.commit().map_err(|_| PersistenceError::Storage) + } + + fn validate_binding(&self) -> Result<(), PersistenceError> { + let database = self.database_lock()?; + let read = database + .as_ref() + .ok_or(PersistenceError::Storage)? + .begin_read() + .map_err(map_transaction_error)?; + let meta = read.open_table(META).map_err(map_table_error)?; + if read_u16(&meta, META_SCHEMA)? != STORAGE_SCHEMA_VERSION { + return Err(PersistenceError::UnsupportedSchema); + } + if read_bytes(&meta, META_SESSION)? != self.crypto_session_id + || read_bytes(&meta, META_PROFILE)? != PROFILE_ID.as_bytes() + || read_u16(&meta, META_PROFILE_REVISION)? != PROFILE_REVISION + { + return Err(PersistenceError::IdentityMismatch); + } + Ok(()) + } + + fn activate_and_validate_current_state(&self) -> Result<(), PersistenceError> { + let database = self.database_lock()?; + let read = database + .as_ref() + .ok_or(PersistenceError::Storage)? + .begin_read() + .map_err(map_transaction_error)?; + let meta = read.open_table(META).map_err(map_table_error)?; + let generation = read_u64(&meta, META_GENERATION)?; + let rollback_counter = read_u64(&meta, META_ROLLBACK_COUNTER)?; + let epoch = read_u64(&meta, META_EPOCH)?; + drop(meta); + let state = read.open_table(STATE).map_err(map_table_error)?; + let state_blob = state + .get(STATE_CURRENT) + .map_err(map_storage_error)? + .map(|value| value.value().to_vec()); + drop(state); + let Some(blob) = state_blob else { + self.envelope_keys + .reconcile_prepared(self.crypto_session_id, None)?; + return Ok(()); + }; + let sealed = SealedState::decode(&blob)?; + let aad = state_aad(self.crypto_session_id, generation, rollback_counter, epoch); + self.envelope_keys + .reconcile_prepared(self.crypto_session_id, Some((sealed.key_id, aad.clone())))?; + let mut key = self + .envelope_keys + .load(self.crypto_session_id, sealed.key_id, &aad)?; + let crypto = CoreProvider::new().map_err(|_| PersistenceError::Storage)?; + let decrypted = crypto.crypto().aead_decrypt( + AeadType::Aes256Gcm, + &key, + &sealed.ciphertext, + &sealed.nonce, + &aad, + ); + key.fill(0); + let values = decode_storage_image(&decrypted.map_err(|_| PersistenceError::Corrupt)?)?; + let expected_manifest = values + .get(DURABLE_MANIFEST_KEY) + .ok_or(PersistenceError::Corrupt)?; + let actual_manifest = durable_manifest_read(&read, crypto.crypto())?; + if expected_manifest.as_slice() != actual_manifest { + return Err(PersistenceError::Quarantined); + } + Ok(()) + } + + fn reconcile_anchor_only(&self) -> Result<(), PersistenceError> { + if !self.rollback_anchor.available() { + return Err(PersistenceError::AnchorUnavailable); + } + let database_state = self.database_rollback_state()?; + let anchor_state = self.rollback_anchor.read(self.crypto_session_id)?; + if database_state == anchor_state { + return Ok(()); + } + if database_state.counter == anchor_state.counter.saturating_add(1) { + let operation_id = self + .latest_operation_id()? + .ok_or(PersistenceError::Corrupt)?; + self.faults.check(FaultPoint::BeforeAnchorRecovery)?; + self.rollback_anchor.advance( + self.crypto_session_id, + &anchor_state, + &database_state, + operation_id, + )?; + self.faults + .check(FaultPoint::AfterAnchorRecoveryBeforeErasure)?; + return Ok(()); + } + Err(PersistenceError::Quarantined) + } + + fn finish_pending_erasure(&self) -> Result<(), PersistenceError> { + let pending = { + let database = self.database_lock()?; + let read = database + .as_ref() + .ok_or(PersistenceError::Storage)? + .begin_read() + .map_err(map_transaction_error)?; + let meta = read.open_table(META).map_err(map_table_error)?; + read_bytes(&meta, META_PENDING_ERASE)? + }; + if pending.is_empty() { + return Ok(()); + } + let key_id: [u8; 16] = pending.try_into().map_err(|_| PersistenceError::Corrupt)?; + let current_key_id = { + let database = self.database_lock()?; + let read = database + .as_ref() + .ok_or(PersistenceError::Storage)? + .begin_read() + .map_err(map_transaction_error)?; + let state = read.open_table(STATE).map_err(map_table_error)?; + state + .get(STATE_CURRENT) + .map_err(map_storage_error)? + .map(|value| SealedState::decode(value.value()).map(|sealed| sealed.key_id)) + .transpose()? + }; + if current_key_id == Some(key_id) { + return Err(PersistenceError::Quarantined); + } + self.faults.check(FaultPoint::DuringWrappingRecordErasure)?; + self.envelope_keys.erase(self.crypto_session_id, key_id) + } + + fn latest_operation_id(&self) -> Result, PersistenceError> { + let database = self.database_lock()?; + let read = database + .as_ref() + .ok_or(PersistenceError::Storage)? + .begin_read() + .map_err(map_transaction_error)?; + let table = read.open_table(OPERATIONS).map_err(map_table_error)?; + let mut latest: Option<(u64, Id)> = None; + for entry in table.iter().map_err(map_storage_error)? { + let (key, value) = entry.map_err(map_storage_error)?; + let generation = decode_operation_generation(value.value())?; + let id: Id = key + .value() + .try_into() + .map_err(|_| PersistenceError::Corrupt)?; + let operation = decode_operation(value.value())?; + validate_operation_binding(&operation, id, self.crypto_session_id)?; + if latest.is_none_or(|(current, _)| generation > current) { + latest = Some((generation, id)); + } + } + Ok(latest.map(|(_, id)| id)) + } + + fn database_rollback_state(&self) -> Result { + let database = self.database_lock()?; + let read = database + .as_ref() + .ok_or(PersistenceError::Storage)? + .begin_read() + .map_err(map_transaction_error)?; + let meta = read.open_table(META).map_err(map_table_error)?; + Ok(RollbackState { + counter: read_u64(&meta, META_ROLLBACK_COUNTER)?, + epoch: read_u64(&meta, META_EPOCH)?, + epoch_authenticator: read_bytes(&meta, META_EPOCH_AUTHENTICATOR)?.to_vec(), + }) + } + + fn read_u64_meta(&self, key: u8) -> Result { + let database = self.database_lock()?; + let read = database + .as_ref() + .ok_or(PersistenceError::Storage)? + .begin_read() + .map_err(map_transaction_error)?; + let meta = read.open_table(META).map_err(map_table_error)?; + read_u64(&meta, key) + } + + fn database_lock( + &self, + ) -> Result>, PersistenceError> { + let guard = self + .database + .lock() + .map_err(|_| PersistenceError::Storage)?; + if guard.is_none() { + return Err(PersistenceError::Storage); + } + Ok(guard) + } + + fn recover_uncertain( + &self, + operation_id: Id, + prepared_key: Option<[u8; 16]>, + ) -> Result { + self.close()?; + self.reopen_database_only()?; + self.recover_storage()?; + if let Some(operation) = self.operation(operation_id)? { + return Ok(operation); + } + if let Some(key_id) = prepared_key { + self.faults.check(FaultPoint::DuringWrappingRecordErasure)?; + self.envelope_keys.erase(self.crypto_session_id, key_id)?; + } + Err(PersistenceError::Storage) + } +} + +impl TransactionalProvider for Arc { + type TransactionError = PersistenceError; + type Transaction<'a> + = NativeGroupTransaction<'a> + where + Self: 'a; + + fn begin_transaction( + &self, + crypto_session_id: Id, + expected_generation: u64, + expected_rollback_counter: u64, + ) -> Result, Self::TransactionError> { + let operation_guard = self + .operation_lock + .lock() + .map_err(|_| PersistenceError::Storage)?; + self.recover_storage()?; + if crypto_session_id != self.crypto_session_id { + return Err(PersistenceError::IdentityMismatch); + } + require_dependencies(&*self.envelope_keys, &*self.rollback_anchor)?; + let database = self.database_lock()?; + let mut write = database + .as_ref() + .ok_or(PersistenceError::Storage)? + .begin_write() + .map_err(map_transaction_error)?; + configure_transaction(&mut write)?; + let (generation, rollback_counter, epoch, authenticator, state_blob) = { + let meta = write.open_table(META).map_err(map_table_error)?; + let generation = read_u64(&meta, META_GENERATION)?; + let rollback_counter = read_u64(&meta, META_ROLLBACK_COUNTER)?; + if generation != expected_generation || rollback_counter != expected_rollback_counter { + self.faults.check(FaultPoint::DuringGenerationConflict)?; + return Err(PersistenceError::GenerationConflict); + } + let epoch = read_u64(&meta, META_EPOCH)?; + let authenticator = read_bytes(&meta, META_EPOCH_AUTHENTICATOR)?.to_vec(); + drop(meta); + let state = write.open_table(STATE).map_err(map_table_error)?; + let state_blob = state + .get(STATE_CURRENT) + .map_err(map_storage_error)? + .map(|value| value.value().to_vec()); + ( + generation, + rollback_counter, + epoch, + authenticator, + state_blob, + ) + }; + let anchor = self.rollback_anchor.read(self.crypto_session_id)?; + if anchor.counter != rollback_counter + || anchor.epoch != epoch + || anchor.epoch_authenticator != authenticator + { + return Err(PersistenceError::Quarantined); + } + let (provider, old_key_id) = if let Some(blob) = state_blob { + let sealed = SealedState::decode(&blob)?; + let aad = state_aad(self.crypto_session_id, generation, rollback_counter, epoch); + let mut key = self + .envelope_keys + .load(self.crypto_session_id, sealed.key_id, &aad)?; + let crypto = CoreProvider::new().map_err(|_| PersistenceError::Storage)?; + let decrypted = crypto.crypto().aead_decrypt( + AeadType::Aes256Gcm, + &key, + &sealed.ciphertext, + &sealed.nonce, + &aad, + ); + key.fill(0); + let plaintext = decrypted.map_err(|_| PersistenceError::Corrupt)?; + let values = decode_storage_image(&plaintext)?; + ( + CoreProvider::from_storage_values(values).map_err(|_| PersistenceError::Storage)?, + Some(sealed.key_id), + ) + } else { + ( + CoreProvider::new().map_err(|_| PersistenceError::Storage)?, + None, + ) + }; + if old_key_id.is_some() { + let expected_manifest = provider + .internal(DURABLE_MANIFEST_KEY) + .ok_or(PersistenceError::Corrupt)?; + let actual_manifest = durable_manifest(&write, provider.crypto())?; + if expected_manifest.as_slice() != actual_manifest { + return Err(PersistenceError::Quarantined); + } + } + let accepted_ids = load_accepted_ids(&write, self.crypto_session_id)?; + drop(database); + Ok(NativeGroupTransaction { + owner: Arc::clone(self), + write: Some(write), + provider, + expected_generation: generation, + expected_rollback_counter: rollback_counter, + old_epoch: epoch, + old_authenticator: authenticator, + operation_id: None, + operation_fingerprint: None, + staged: None, + next_epoch: epoch, + next_authenticator: Vec::new(), + old_key_id, + prepared_key_id: None, + accepted_updates: Vec::new(), + outbox_updates: Vec::new(), + operation_updates: Vec::new(), + accepted_ids, + _operation_guard: operation_guard, + }) + } +} + +/// One strict redb transaction and its transaction-local OpenMLS provider. +pub(crate) struct NativeGroupTransaction<'a> { + owner: Arc, + write: Option, + provider: CoreProvider, + expected_generation: u64, + expected_rollback_counter: u64, + old_epoch: u64, + old_authenticator: Vec, + operation_id: Option, + operation_fingerprint: Option<[u8; 48]>, + staged: Option, + next_epoch: u64, + next_authenticator: Vec, + old_key_id: Option<[u8; 16]>, + prepared_key_id: Option<[u8; 16]>, + accepted_updates: Vec<(Id, AcceptedMessageRecord)>, + outbox_updates: Vec<(Id, OutboxRecord)>, + operation_updates: Vec<(Id, Vec)>, + accepted_ids: BTreeSet, + _operation_guard: std::sync::MutexGuard<'a, ()>, +} + +impl NativeGroupTransaction<'_> { + /// Bind an idempotency key and canonical input hash before mutation. + fn bind_operation( + &mut self, + operation_id: Id, + fingerprint: [u8; 48], + ) -> Result, PersistenceError> { + let write = self.write.as_ref().ok_or(PersistenceError::Storage)?; + let table = write.open_table(OPERATIONS).map_err(map_table_error)?; + let existing = table + .get(operation_id.as_slice()) + .map_err(map_storage_error)? + .map(|value| value.value().to_vec()); + drop(table); + if let Some(bytes) = existing { + self.owner + .faults + .check(FaultPoint::DuringDuplicateOperation)?; + let (stored_fingerprint, operation) = decode_operation_with_fingerprint(&bytes)?; + validate_operation_binding(&operation, operation_id, self.owner.crypto_session_id)?; + if stored_fingerprint != fingerprint { + return Err(PersistenceError::Conflict); + } + return Ok(Some(operation)); + } + self.operation_id = Some(operation_id); + self.operation_fingerprint = Some(fingerprint); + Ok(None) + } + + fn set_successor_epoch(&mut self, epoch: u64, authenticator: &[u8]) { + self.next_epoch = epoch; + self.next_authenticator.clear(); + self.next_authenticator.extend_from_slice(authenticator); + } + + fn write(&mut self) -> Result<&mut WriteTransaction, PersistenceError> { + self.write.as_mut().ok_or(PersistenceError::Storage) + } + + pub(crate) fn replace_provider_values( + &mut self, + values: BTreeMap, Vec>, + ) -> Result<(), PersistenceError> { + self.provider = + CoreProvider::from_storage_values(values).map_err(|_| PersistenceError::Storage)?; + Ok(()) + } + + pub(crate) fn stage_accepted_record( + &mut self, + record: AcceptedMessageRecord, + ) -> Result<(), PersistenceError> { + self.owner + .faults + .check(FaultPoint::DuringReceiverStateWrites)?; + self.stage_operation(CommittedOperation::Accepted(record)) + } + + fn update_accepted(&mut self, operation_id: Id, record: AcceptedMessageRecord) { + self.accepted_updates.push((operation_id, record)); + } + + fn update_outbox(&mut self, operation_id: Id, record: OutboxRecord) { + self.outbox_updates.push((operation_id, record)); + } + + fn update_operation( + &mut self, + operation_id: Id, + operation: CommittedOperation, + ) -> Result<(), PersistenceError> { + let write = self.write.as_ref().ok_or(PersistenceError::Storage)?; + let table = write.open_table(OPERATIONS).map_err(map_table_error)?; + let bytes = table + .get(operation_id.as_slice()) + .map_err(map_storage_error)? + .ok_or(PersistenceError::NotFound)?; + let (fingerprint, generation, existing) = decode_operation_record(bytes.value())?; + validate_operation_binding(&existing, operation_id, self.owner.crypto_session_id)?; + drop(bytes); + drop(table); + self.operation_updates.push(( + operation_id, + encode_operation(fingerprint, generation, &operation)?, + )); + Ok(()) + } + + fn stage_operation(&mut self, operation: CommittedOperation) -> Result<(), PersistenceError> { + if self.operation_id.is_none() + || self.operation_fingerprint.is_none() + || self.staged.is_some() + { + return Err(PersistenceError::Conflict); + } + self.staged = Some(operation); + Ok(()) + } + + fn commit_inner(mut self) -> Result { + let operation_id = self.operation_id.ok_or(PersistenceError::Conflict)?; + let fingerprint = self + .operation_fingerprint + .ok_or(PersistenceError::Conflict)?; + let operation = self.staged.clone().ok_or(PersistenceError::Conflict)?; + let next_generation = self + .expected_generation + .checked_add(1) + .ok_or(PersistenceError::Corrupt)?; + let next_counter = self + .expected_rollback_counter + .checked_add(1) + .ok_or(PersistenceError::Corrupt)?; + + self.owner + .faults + .check(FaultPoint::DuringOpenMlsProviderWrites)?; + self.owner + .faults + .check(FaultPoint::BeforeCiphertextInsertion)?; + let next_epoch_bytes = self.next_epoch.to_be_bytes(); + let next_authenticator = self.next_authenticator.clone(); + let pending_erase = self.old_key_id.map_or(Vec::new(), |key_id| key_id.to_vec()); + let accepted_updates = self.accepted_updates.clone(); + let outbox_updates = self.outbox_updates.clone(); + let operation_updates = self.operation_updates.clone(); + { + let write = self.write()?; + for (accepted_id, record) in &accepted_updates { + let bytes = encode_accepted(record); + let mut accepted = write.open_table(ACCEPTED).map_err(map_table_error)?; + accepted + .insert(accepted_id.as_slice(), bytes.as_slice()) + .map_err(map_storage_error)?; + } + for (updated_id, bytes) in &operation_updates { + let mut operations = write.open_table(OPERATIONS).map_err(map_table_error)?; + operations + .insert(updated_id.as_slice(), bytes.as_slice()) + .map_err(map_storage_error)?; + } + for (outbox_id, record) in &outbox_updates { + let bytes = encode_outbox(record)?; + let mut outbox = write.open_table(OUTBOX).map_err(map_table_error)?; + outbox + .insert(outbox_id.as_slice(), bytes.as_slice()) + .map_err(map_storage_error)?; + } + match &operation { + CommittedOperation::Envelope(record) => { + let bytes = encode_outbox(record)?; + let mut outbox = write.open_table(OUTBOX).map_err(map_table_error)?; + outbox + .insert(operation_id.as_slice(), bytes.as_slice()) + .map_err(map_storage_error)?; + } + CommittedOperation::Accepted(record) => { + let bytes = encode_accepted(record); + let mut accepted = write.open_table(ACCEPTED).map_err(map_table_error)?; + accepted + .insert(operation_id.as_slice(), bytes.as_slice()) + .map_err(map_storage_error)?; + } + CommittedOperation::OutboxAcknowledged(_) + | CommittedOperation::ReceiveAcknowledged(_) => {} + } + let bytes = encode_operation(fingerprint, next_generation, &operation)?; + let mut operations = write.open_table(OPERATIONS).map_err(map_table_error)?; + operations + .insert(operation_id.as_slice(), bytes.as_slice()) + .map_err(map_storage_error)?; + drop(operations); + let mut meta = write.open_table(META).map_err(map_table_error)?; + meta.insert(META_GENERATION, next_generation.to_be_bytes().as_slice()) + .map_err(map_storage_error)?; + meta.insert(META_ROLLBACK_COUNTER, next_counter.to_be_bytes().as_slice()) + .map_err(map_storage_error)?; + meta.insert(META_EPOCH, next_epoch_bytes.as_slice()) + .map_err(map_storage_error)?; + meta.insert(META_EPOCH_AUTHENTICATOR, next_authenticator.as_slice()) + .map_err(map_storage_error)?; + meta.insert(META_PENDING_ERASE, pending_erase.as_slice()) + .map_err(map_storage_error)?; + } + self.owner + .faults + .check(FaultPoint::AfterCiphertextInsertion)?; + prune_durable_records(self.write()?, next_generation)?; + let manifest_crypto = CoreProvider::new().map_err(|_| PersistenceError::Storage)?; + let manifest = durable_manifest(self.write()?, manifest_crypto.crypto())?; + self.provider + .insert_internal(DURABLE_MANIFEST_KEY.to_vec(), manifest.to_vec()); + + let plaintext = encode_storage_image(&self.provider.storage_values())?; + let crypto = self.provider.crypto(); + let mut data_key = self + .provider + .rand() + .random_array::<32>() + .map_err(|_| PersistenceError::Storage)?; + let key_id = self + .provider + .rand() + .random_array::<16>() + .map_err(|_| PersistenceError::Storage)?; + let nonce = self + .provider + .rand() + .random_array::<12>() + .map_err(|_| PersistenceError::Storage)?; + let aad = state_aad( + self.owner.crypto_session_id, + next_generation, + next_counter, + self.next_epoch, + ); + self.owner + .envelope_keys + .prepare(self.owner.crypto_session_id, key_id, &data_key, &aad)?; + self.prepared_key_id = Some(key_id); + let encrypted = + crypto.aead_encrypt(AeadType::Aes256Gcm, &data_key, &plaintext, &nonce, &aad); + data_key.fill(0); + let ciphertext = encrypted.map_err(|_| PersistenceError::Storage)?; + let sealed = SealedState { + key_id, + nonce, + ciphertext, + } + .encode()?; + { + let write = self.write()?; + let mut state = write.open_table(STATE).map_err(map_table_error)?; + state + .insert(STATE_CURRENT, sealed.as_slice()) + .map_err(map_storage_error)?; + } + self.owner.faults.check(FaultPoint::BeforeCommit)?; + let write = self.write.take().ok_or(PersistenceError::Storage)?; + self.prepared_key_id = None; + if write.commit().is_err() { + return self.owner.recover_uncertain(operation_id, Some(key_id)); + } + self.owner + .faults + .check(FaultPoint::DuringCurrentKeyActivation)?; + self.owner + .envelope_keys + .activate(self.owner.crypto_session_id, key_id, &aad)?; + if self + .owner + .faults + .check(FaultPoint::AfterDurableCommit) + .is_err() + { + return self.owner.recover_uncertain(operation_id, None); + } + let expected_anchor = RollbackState { + counter: self.expected_rollback_counter, + epoch: self.old_epoch, + epoch_authenticator: self.old_authenticator.clone(), + }; + let next_anchor = RollbackState { + counter: next_counter, + epoch: self.next_epoch, + epoch_authenticator: self.next_authenticator.clone(), + }; + self.owner.faults.check(FaultPoint::BeforeAnchorRecovery)?; + self.owner.rollback_anchor.advance( + self.owner.crypto_session_id, + &expected_anchor, + &next_anchor, + operation_id, + )?; + self.owner + .faults + .check(FaultPoint::AfterAnchorRecoveryBeforeErasure)?; + if self.old_key_id.is_some() { + self.owner + .faults + .check(FaultPoint::DuringWrappingRecordReplacement)?; + self.owner.finish_pending_erasure()?; + } + Ok(operation) + } +} + +impl Drop for NativeGroupTransaction<'_> { + fn drop(&mut self) { + if let Some(key_id) = self.prepared_key_id.take() { + let _ = self + .owner + .envelope_keys + .erase(self.owner.crypto_session_id, key_id); + } + } +} + +impl GroupTransaction for NativeGroupTransaction<'_> { + type Provider = CoreProvider; + type Error = PersistenceError; + + fn provider(&self) -> &Self::Provider { + &self.provider + } + + fn stage_envelope(&mut self, envelope: &PreparedEnvelope) -> Result<(), Self::Error> { + self.stage_operation(CommittedOperation::Envelope(OutboxRecord { + operation_id: self.operation_id.ok_or(PersistenceError::Conflict)?, + crypto_session_id: envelope.crypto_session_id, + logical_message_id: envelope.logical_message_id, + class: envelope.class, + epoch: envelope.epoch, + profile_revision: PROFILE_REVISION, + retry_state: RetryState::Pending, + ciphertext: envelope.ciphertext.to_vec(), + commit: envelope.commit.clone(), + })) + } + + fn stage_received( + &mut self, + crypto_session_id: Id, + logical_message_id: Id, + epoch: u64, + ) -> Result<(), Self::Error> { + if crypto_session_id != self.owner.crypto_session_id { + return Err(PersistenceError::IdentityMismatch); + } + self.owner + .faults + .check(FaultPoint::DuringReceiverStateWrites)?; + self.stage_operation(CommittedOperation::Accepted(AcceptedMessageRecord { + operation_id: self.operation_id.ok_or(PersistenceError::Conflict)?, + crypto_session_id, + logical_message_id, + class: MessageClass::ApplicationRequest, + epoch, + profile_revision: PROFILE_REVISION, + acknowledged: false, + })) + } + + fn commit(self) -> Result<(), Self::Error> { + self.commit_inner().map(|_| ()) + } + + fn rollback(mut self) -> Result<(), Self::Error> { + if let Some(write) = self.write.take() { + write.abort().map_err(|_| PersistenceError::Storage)?; + } + Ok(()) + } +} + +impl NativeGroupTransaction<'_> { + fn commit_operation(self) -> Result { + self.commit_inner() + } +} + +/// Durable daemon endpoint. Every method reloads committed MLS state after opening its redb +/// transaction; no `MlsGroup` survives a failed or completed operation. +#[derive(Clone)] +pub struct DurableDaemon { + store: Arc, +} + +/// Durable phone endpoint with the same transaction and reload guarantees as [`DurableDaemon`]. +#[derive(Clone)] +pub struct DurablePhone { + store: Arc, +} + +/// Plaintext released only after receive-state and accepted-message commit complete. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DurablePlaintext { + pub operation_id: Id, + pub logical_message_id: Id, + pub epoch: u64, + plaintext: Vec, +} + +impl DurablePlaintext { + pub fn plaintext(&self) -> &[u8] { + &self.plaintext + } +} + +impl DurableDaemon { + pub fn create( + root: &Path, + identity: Identity, + context: PairContext, + operation_id: Id, + envelope_keys: Arc, + rollback_anchor: Arc, + ) -> Result { + Self::create_with_runtime( + root, + identity, + context, + operation_id, + envelope_keys, + rollback_anchor, + RuntimeHooks { + faults: Arc::new(NoFaults), + clock: Arc::new(SystemClock), + }, + ) + } + + pub(crate) fn create_with_runtime( + root: &Path, + identity: Identity, + context: PairContext, + operation_id: Id, + envelope_keys: Arc, + rollback_anchor: Arc, + runtime: RuntimeHooks, + ) -> Result { + let store = NativeTransactionalProvider::create( + root, + context.crypto_session_id, + envelope_keys, + rollback_anchor, + runtime.faults, + runtime.clock, + )?; + let mut transaction = store.begin_transaction(context.crypto_session_id, 0, 0)?; + transaction.bind_operation(operation_id, operation_fingerprint(1, &[])?)?; + store.faults.check(FaultPoint::BeforeOpenMlsStateWrites)?; + let mut daemon = Daemon::create(identity, context)?; + daemon.endpoint.clock = Arc::clone(&store.clock); + daemon.endpoint.last_wall_time_ms = store.clock.now_ms().map_err(PersistenceError::Core)?; + store + .faults + .check(FaultPoint::DuringOpenMlsProviderWrites)?; + persist_endpoint_metadata(&daemon.endpoint, &daemon.endpoint.provider); + transaction.replace_provider_values(daemon.endpoint.provider.storage_values())?; + transaction.set_successor_epoch( + daemon.endpoint.epoch()?, + &daemon.endpoint.epoch_authenticator()?, + ); + transaction + .stage_accepted_record(initialized_record(operation_id, store.crypto_session_id))?; + transaction.commit_operation()?; + store.faults.check(FaultPoint::BeforeInitializationReady)?; + store.mark_ready()?; + Ok(Self { store }) + } + + pub fn open( + root: &Path, + crypto_session_id: Id, + envelope_keys: Arc, + rollback_anchor: Arc, + ) -> Result { + Self::open_with_runtime( + root, + crypto_session_id, + envelope_keys, + rollback_anchor, + RuntimeHooks { + faults: Arc::new(NoFaults), + clock: Arc::new(SystemClock), + }, + ) + } + + pub(crate) fn open_with_runtime( + root: &Path, + crypto_session_id: Id, + envelope_keys: Arc, + rollback_anchor: Arc, + runtime: RuntimeHooks, + ) -> Result { + let store = NativeTransactionalProvider::open( + root, + crypto_session_id, + envelope_keys, + rollback_anchor, + runtime.faults, + runtime.clock, + )?; + // Loading inside a read-write transaction validates all committed OpenMLS and signer state. + let generation = store.generation()?; + let rollback = store.rollback_counter()?; + let transaction = store.begin_transaction(crypto_session_id, generation, rollback)?; + let _ = load_daemon( + &transaction.provider, + Arc::clone(&store.clock), + transaction.accepted_ids.clone(), + )?; + transaction.rollback()?; + store.finish_opening()?; + Ok(Self { store }) + } + + #[cfg(test)] + pub(crate) fn store(&self) -> &Arc { + &self.store + } + + pub fn consume_key_package( + &mut self, + operation_id: Id, + package: PhoneKeyPackage, + ) -> Result { + let fingerprint = operation_fingerprint(2, package.bytes())?; + let mut transaction = self.begin_current()?; + if let Some(existing) = transaction.bind_operation(operation_id, fingerprint)? { + transaction.rollback()?; + return welcome_from_operation(existing, transaction_metadata_from_store(&self.store)?); + } + self.store + .faults + .check(FaultPoint::BeforeOpenMlsStateWrites)?; + let mut daemon = load_daemon( + &transaction.provider, + Arc::clone(&self.store.clock), + transaction.accepted_ids.clone(), + )?; + let welcome = daemon.consume_key_package(package)?; + self.store + .faults + .check(FaultPoint::DuringOpenMlsProviderWrites)?; + persist_endpoint_metadata(&daemon.endpoint, &daemon.endpoint.provider); + transaction.replace_provider_values(daemon.endpoint.provider.storage_values())?; + transaction.set_successor_epoch( + daemon.endpoint.epoch()?, + &daemon.endpoint.epoch_authenticator()?, + ); + let record = OutboxRecord { + operation_id, + crypto_session_id: self.store.crypto_session_id, + logical_message_id: operation_id, + class: MessageClass::PairActivation, + epoch: daemon.endpoint.epoch()?, + profile_revision: PROFILE_REVISION, + retry_state: RetryState::Pending, + ciphertext: welcome.bytes.to_vec(), + commit: None, + }; + transaction.stage_operation(CommittedOperation::Envelope(record))?; + transaction.commit_operation()?; + self.store + .faults + .check(FaultPoint::AfterCommitBeforeNetworkSend)?; + Ok(welcome) + } + + pub fn prepare_application( + &mut self, + operation_id: Id, + logical_message_id: Id, + hosted_generation: u64, + plaintext: &[u8], + ) -> Result { + self.send_operation( + operation_id, + operation_fingerprint_parts( + 3, + &[ + &logical_message_id, + &hosted_generation.to_be_bytes(), + plaintext, + ], + )?, + |daemon| daemon.prepare_application(logical_message_id, hosted_generation, plaintext), + ) + } + + pub fn receive_application( + &mut self, + operation_id: Id, + ciphertext: &[u8], + logical_message_id: Id, + hosted_generation: u64, + ) -> Result { + self.receive_operation( + operation_id, + operation_fingerprint_parts( + 4, + &[ + &logical_message_id, + &hosted_generation.to_be_bytes(), + ciphertext, + ], + )?, + MessageClass::ApplicationRequest, + |daemon| daemon.receive_application(ciphertext, logical_message_id, hosted_generation), + ) + } + + pub fn receive_update_proposal( + &mut self, + operation_id: Id, + ciphertext: &[u8], + logical_message_id: Id, + hosted_generation: u64, + ) -> Result { + let fingerprint = operation_fingerprint_parts( + 5, + &[ + &logical_message_id, + &hosted_generation.to_be_bytes(), + ciphertext, + ], + )?; + let mut transaction = self.begin_current()?; + if let Some(existing) = transaction.bind_operation(operation_id, fingerprint)? { + transaction.rollback()?; + return match existing { + CommittedOperation::Accepted(record) => Ok(record), + _ => Err(PersistenceError::Conflict), + }; + } + self.store + .faults + .check(FaultPoint::BeforeOpenMlsStateWrites)?; + let mut daemon = load_daemon( + &transaction.provider, + Arc::clone(&self.store.clock), + transaction.accepted_ids.clone(), + )?; + daemon.receive_update_proposal(ciphertext, logical_message_id, hosted_generation)?; + self.store + .faults + .check(FaultPoint::DuringOpenMlsProviderWrites)?; + persist_endpoint_metadata(&daemon.endpoint, &daemon.endpoint.provider); + transaction.replace_provider_values(daemon.endpoint.provider.storage_values())?; + transaction.set_successor_epoch( + daemon.endpoint.epoch()?, + &daemon.endpoint.epoch_authenticator()?, + ); + let record = AcceptedMessageRecord { + operation_id, + crypto_session_id: self.store.crypto_session_id, + logical_message_id, + class: MessageClass::UpdateProposal, + epoch: daemon.endpoint.epoch()?, + profile_revision: PROFILE_REVISION, + acknowledged: false, + }; + transaction.stage_accepted_record(record.clone())?; + transaction.commit_operation()?; + Ok(record) + } + + pub fn acknowledge_receive( + &mut self, + acknowledgement_operation_id: Id, + receive_operation_id: Id, + ) -> Result { + acknowledge_receive( + &self.store, + acknowledgement_operation_id, + receive_operation_id, + ) + } + + pub fn acknowledge_outbox( + &mut self, + acknowledgement_operation_id: Id, + outbox_operation_id: Id, + ) -> Result { + acknowledge_outbox( + &self.store, + acknowledgement_operation_id, + outbox_operation_id, + ) + } + + pub fn prepare_commit( + &mut self, + operation_id: Id, + logical_message_id: Id, + hosted_generation: u64, + ) -> Result { + self.send_operation( + operation_id, + operation_fingerprint_parts( + 6, + &[&logical_message_id, &hosted_generation.to_be_bytes()], + )?, + |daemon| daemon.prepare_commit(logical_message_id, hosted_generation), + ) + } + + fn begin_current(&self) -> Result, PersistenceError> { + begin_current(&self.store) + } + + fn send_operation( + &mut self, + operation_id: Id, + fingerprint: [u8; 48], + operation: impl FnOnce(&mut Daemon) -> Result, + ) -> Result { + let mut transaction = self.begin_current()?; + if let Some(existing) = transaction.bind_operation(operation_id, fingerprint)? { + transaction.rollback()?; + return match existing { + CommittedOperation::Envelope(record) => Ok(record), + _ => Err(PersistenceError::Conflict), + }; + } + self.store + .faults + .check(FaultPoint::BeforeOpenMlsStateWrites)?; + let mut daemon = load_daemon( + &transaction.provider, + Arc::clone(&self.store.clock), + transaction.accepted_ids.clone(), + )?; + let envelope = operation(&mut daemon)?; + self.store + .faults + .check(FaultPoint::DuringOpenMlsProviderWrites)?; + persist_endpoint_metadata(&daemon.endpoint, &daemon.endpoint.provider); + transaction.replace_provider_values(daemon.endpoint.provider.storage_values())?; + transaction.set_successor_epoch( + daemon.endpoint.epoch()?, + &daemon.endpoint.epoch_authenticator()?, + ); + transaction.stage_envelope(&envelope)?; + let committed = transaction.commit_operation()?; + self.store + .faults + .check(FaultPoint::AfterCommitBeforeNetworkSend)?; + match committed { + CommittedOperation::Envelope(record) => Ok(record), + _ => Err(PersistenceError::Corrupt), + } + } + + fn receive_operation( + &mut self, + operation_id: Id, + fingerprint: [u8; 48], + class: MessageClass, + operation: impl FnOnce(&mut Daemon) -> Result, + ) -> Result { + let mut transaction = self.begin_current()?; + if let Some(existing) = transaction.bind_operation(operation_id, fingerprint)? { + let plaintext = pending_plaintext(&transaction.provider, operation_id)?; + transaction.rollback()?; + return durable_plaintext(existing, plaintext); + } + self.store + .faults + .check(FaultPoint::BeforeOpenMlsStateWrites)?; + let mut daemon = load_daemon( + &transaction.provider, + Arc::clone(&self.store.clock), + transaction.accepted_ids.clone(), + )?; + let prepared = operation(&mut daemon)?; + self.store + .faults + .check(FaultPoint::DuringOpenMlsProviderWrites)?; + daemon.endpoint.provider.insert_internal( + pending_plaintext_key(operation_id), + prepared.plaintext.to_vec(), + ); + persist_endpoint_metadata(&daemon.endpoint, &daemon.endpoint.provider); + transaction.replace_provider_values(daemon.endpoint.provider.storage_values())?; + transaction.set_successor_epoch( + daemon.endpoint.epoch()?, + &daemon.endpoint.epoch_authenticator()?, + ); + let record = AcceptedMessageRecord { + operation_id, + crypto_session_id: self.store.crypto_session_id, + logical_message_id: prepared.logical_message_id, + class, + epoch: prepared.epoch, + profile_revision: PROFILE_REVISION, + acknowledged: false, + }; + transaction.stage_accepted_record(record.clone())?; + transaction.commit_operation()?; + self.store + .faults + .check(FaultPoint::BeforeReceiverAcknowledgement)?; + self.store + .faults + .check(FaultPoint::AfterAcknowledgementLoss)?; + Ok(DurablePlaintext { + operation_id, + logical_message_id: prepared.logical_message_id, + epoch: prepared.epoch, + plaintext: prepared.plaintext.to_vec(), + }) + } +} + +impl DurablePhone { + pub fn create( + root: &Path, + identity: Identity, + crypto_session_id: Id, + operation_id: Id, + envelope_keys: Arc, + rollback_anchor: Arc, + ) -> Result<(Self, PhoneKeyPackage), PersistenceError> { + Self::create_with_runtime( + root, + identity, + crypto_session_id, + operation_id, + envelope_keys, + rollback_anchor, + RuntimeHooks { + faults: Arc::new(NoFaults), + clock: Arc::new(SystemClock), + }, + ) + } + + pub(crate) fn create_with_runtime( + root: &Path, + identity: Identity, + crypto_session_id: Id, + operation_id: Id, + envelope_keys: Arc, + rollback_anchor: Arc, + runtime: RuntimeHooks, + ) -> Result<(Self, PhoneKeyPackage), PersistenceError> { + let store = NativeTransactionalProvider::create( + root, + crypto_session_id, + envelope_keys, + rollback_anchor, + runtime.faults, + runtime.clock, + )?; + let mut transaction = store.begin_transaction(crypto_session_id, 0, 0)?; + transaction.bind_operation(operation_id, operation_fingerprint(10, &[])?)?; + store.faults.check(FaultPoint::BeforeOpenMlsStateWrites)?; + let (phone, package) = Phone::create(identity.clone())?; + let provider = &phone.provider; + persist_phone_metadata( + &phone, + provider, + None, + store.clock.now_ms().map_err(PersistenceError::Core)?, + ); + transaction.replace_provider_values(provider.storage_values())?; + let envelope = PreparedEnvelope { + crypto_session_id, + logical_message_id: operation_id, + class: MessageClass::PairActivation, + epoch: 0, + commit: None, + ciphertext: package.bytes.to_vec().into_boxed_slice(), + }; + transaction.stage_envelope(&envelope)?; + transaction.commit_operation()?; + store.faults.check(FaultPoint::BeforeInitializationReady)?; + store.mark_ready()?; + Ok((Self { store }, package)) + } + + pub fn open( + root: &Path, + crypto_session_id: Id, + envelope_keys: Arc, + rollback_anchor: Arc, + ) -> Result { + Self::open_with_runtime( + root, + crypto_session_id, + envelope_keys, + rollback_anchor, + RuntimeHooks { + faults: Arc::new(NoFaults), + clock: Arc::new(SystemClock), + }, + ) + } + + pub(crate) fn open_with_runtime( + root: &Path, + crypto_session_id: Id, + envelope_keys: Arc, + rollback_anchor: Arc, + runtime: RuntimeHooks, + ) -> Result { + let store = NativeTransactionalProvider::open( + root, + crypto_session_id, + envelope_keys, + rollback_anchor, + runtime.faults, + runtime.clock, + )?; + let transaction = store.begin_transaction( + crypto_session_id, + store.generation()?, + store.rollback_counter()?, + )?; + let _ = load_phone( + &transaction.provider, + Arc::clone(&store.clock), + transaction.accepted_ids.clone(), + )?; + transaction.rollback()?; + store.finish_opening()?; + Ok(Self { store }) + } + + #[cfg(test)] + pub(crate) fn store(&self) -> &Arc { + &self.store + } + + pub fn join( + &mut self, + operation_id: Id, + welcome: PairWelcome, + expected: &PairContext, + ) -> Result<(), PersistenceError> { + let fingerprint = operation_fingerprint(11, welcome.bytes())?; + let mut transaction = self.begin_current()?; + if transaction + .bind_operation(operation_id, fingerprint)? + .is_some() + { + transaction.rollback()?; + return Ok(()); + } + self.store + .faults + .check(FaultPoint::BeforeOpenMlsStateWrites)?; + let mut phone = load_phone( + &transaction.provider, + Arc::clone(&self.store.clock), + transaction.accepted_ids.clone(), + )?; + phone.join(welcome, expected)?; + { + let endpoint = phone.endpoint.as_mut().ok_or(PersistenceError::Corrupt)?; + endpoint.clock = Arc::clone(&self.store.clock); + endpoint.last_wall_time_ms = + self.store.clock.now_ms().map_err(PersistenceError::Core)?; + } + let endpoint = phone.endpoint.as_ref().ok_or(PersistenceError::Corrupt)?; + persist_phone_metadata(&phone, &endpoint.provider, Some(expected), 0); + transaction.replace_provider_values(endpoint.provider.storage_values())?; + transaction.set_successor_epoch(endpoint.epoch()?, &endpoint.epoch_authenticator()?); + transaction.stage_accepted_record(initialized_record( + operation_id, + self.store.crypto_session_id, + ))?; + transaction.commit_operation()?; + Ok(()) + } + + pub fn prepare_application( + &mut self, + operation_id: Id, + logical_message_id: Id, + hosted_generation: u64, + plaintext: &[u8], + ) -> Result { + self.send_operation( + operation_id, + operation_fingerprint_parts( + 12, + &[ + &logical_message_id, + &hosted_generation.to_be_bytes(), + plaintext, + ], + )?, + |phone| phone.prepare_application(logical_message_id, hosted_generation, plaintext), + ) + } + + pub fn acknowledge_receive( + &mut self, + acknowledgement_operation_id: Id, + receive_operation_id: Id, + ) -> Result { + acknowledge_receive( + &self.store, + acknowledgement_operation_id, + receive_operation_id, + ) + } + + pub fn acknowledge_outbox( + &mut self, + acknowledgement_operation_id: Id, + outbox_operation_id: Id, + ) -> Result { + acknowledge_outbox( + &self.store, + acknowledgement_operation_id, + outbox_operation_id, + ) + } + + pub fn prepare_self_update( + &mut self, + operation_id: Id, + logical_message_id: Id, + hosted_generation: u64, + ) -> Result { + self.send_operation( + operation_id, + operation_fingerprint_parts( + 13, + &[&logical_message_id, &hosted_generation.to_be_bytes()], + )?, + |phone| phone.prepare_self_update(logical_message_id, hosted_generation), + ) + } + + pub fn apply_commit( + &mut self, + operation_id: Id, + ciphertext: &[u8], + logical_message_id: Id, + hosted_generation: u64, + ) -> Result { + let fingerprint = operation_fingerprint_parts( + 14, + &[ + &logical_message_id, + &hosted_generation.to_be_bytes(), + ciphertext, + ], + )?; + let mut transaction = self.begin_current()?; + if let Some(existing) = transaction.bind_operation(operation_id, fingerprint)? { + transaction.rollback()?; + return match existing { + CommittedOperation::Accepted(record) => Ok(record), + _ => Err(PersistenceError::Conflict), + }; + } + self.store + .faults + .check(FaultPoint::BeforeOpenMlsStateWrites)?; + let mut phone = load_phone( + &transaction.provider, + Arc::clone(&self.store.clock), + transaction.accepted_ids.clone(), + )?; + phone.apply_commit(ciphertext, logical_message_id, hosted_generation)?; + let endpoint = phone.endpoint.as_ref().ok_or(PersistenceError::Corrupt)?; + self.store + .faults + .check(FaultPoint::DuringOpenMlsProviderWrites)?; + persist_phone_metadata(&phone, &endpoint.provider, Some(&endpoint.context), 0); + transaction.replace_provider_values(endpoint.provider.storage_values())?; + transaction.set_successor_epoch(endpoint.epoch()?, &endpoint.epoch_authenticator()?); + let record = AcceptedMessageRecord { + operation_id, + crypto_session_id: self.store.crypto_session_id, + logical_message_id, + class: MessageClass::Commit, + epoch: endpoint.epoch()?, + profile_revision: PROFILE_REVISION, + acknowledged: false, + }; + transaction.stage_accepted_record(record.clone())?; + transaction.commit_operation()?; + Ok(record) + } + + pub fn receive_application( + &mut self, + operation_id: Id, + ciphertext: &[u8], + logical_message_id: Id, + hosted_generation: u64, + ) -> Result { + let fingerprint = operation_fingerprint_parts( + 15, + &[ + &logical_message_id, + &hosted_generation.to_be_bytes(), + ciphertext, + ], + )?; + let mut transaction = self.begin_current()?; + if let Some(existing) = transaction.bind_operation(operation_id, fingerprint)? { + let plaintext = pending_plaintext(&transaction.provider, operation_id)?; + transaction.rollback()?; + return durable_plaintext(existing, plaintext); + } + self.store + .faults + .check(FaultPoint::BeforeOpenMlsStateWrites)?; + let mut phone = load_phone( + &transaction.provider, + Arc::clone(&self.store.clock), + transaction.accepted_ids.clone(), + )?; + let prepared = + phone.receive_application(ciphertext, logical_message_id, hosted_generation)?; + let endpoint = phone.endpoint.as_ref().ok_or(PersistenceError::Corrupt)?; + endpoint.provider.insert_internal( + pending_plaintext_key(operation_id), + prepared.plaintext.to_vec(), + ); + persist_phone_metadata(&phone, &endpoint.provider, Some(&endpoint.context), 0); + transaction.replace_provider_values(endpoint.provider.storage_values())?; + transaction.set_successor_epoch(endpoint.epoch()?, &endpoint.epoch_authenticator()?); + let record = AcceptedMessageRecord { + operation_id, + crypto_session_id: self.store.crypto_session_id, + logical_message_id, + class: MessageClass::ApplicationDelivery, + epoch: prepared.epoch, + profile_revision: PROFILE_REVISION, + acknowledged: false, + }; + transaction.stage_accepted_record(record)?; + transaction.commit_operation()?; + self.store + .faults + .check(FaultPoint::BeforeReceiverAcknowledgement)?; + self.store + .faults + .check(FaultPoint::AfterAcknowledgementLoss)?; + Ok(DurablePlaintext { + operation_id, + logical_message_id: prepared.logical_message_id, + epoch: prepared.epoch, + plaintext: prepared.plaintext.to_vec(), + }) + } + + fn begin_current(&self) -> Result, PersistenceError> { + begin_current(&self.store) + } + + fn send_operation( + &mut self, + operation_id: Id, + fingerprint: [u8; 48], + operation: impl FnOnce(&mut Phone) -> Result, + ) -> Result { + let mut transaction = self.begin_current()?; + if let Some(existing) = transaction.bind_operation(operation_id, fingerprint)? { + transaction.rollback()?; + return match existing { + CommittedOperation::Envelope(record) => Ok(record), + _ => Err(PersistenceError::Conflict), + }; + } + self.store + .faults + .check(FaultPoint::BeforeOpenMlsStateWrites)?; + let mut phone = load_phone( + &transaction.provider, + Arc::clone(&self.store.clock), + transaction.accepted_ids.clone(), + )?; + let envelope = operation(&mut phone)?; + let endpoint = phone.endpoint.as_ref().ok_or(PersistenceError::Corrupt)?; + self.store + .faults + .check(FaultPoint::DuringOpenMlsProviderWrites)?; + persist_phone_metadata(&phone, &endpoint.provider, Some(&endpoint.context), 0); + transaction.replace_provider_values(endpoint.provider.storage_values())?; + transaction.set_successor_epoch(endpoint.epoch()?, &endpoint.epoch_authenticator()?); + transaction.stage_envelope(&envelope)?; + let committed = transaction.commit_operation()?; + self.store + .faults + .check(FaultPoint::AfterCommitBeforeNetworkSend)?; + match committed { + CommittedOperation::Envelope(record) => Ok(record), + _ => Err(PersistenceError::Corrupt), + } + } +} + +fn begin_current( + store: &Arc, +) -> Result, PersistenceError> { + loop { + let generation = store.generation()?; + let rollback = store.rollback_counter()?; + match store.begin_transaction(store.crypto_session_id, generation, rollback) { + Err(PersistenceError::GenerationConflict) => continue, + result => return result, + } + } +} + +fn acknowledge_outbox( + store: &Arc, + acknowledgement_operation_id: Id, + outbox_operation_id: Id, +) -> Result { + let fingerprint = operation_fingerprint_parts(19, &[&outbox_operation_id])?; + let mut transaction = begin_current(store)?; + if let Some(existing) = transaction.bind_operation(acknowledgement_operation_id, fingerprint)? { + transaction.rollback()?; + return match existing { + CommittedOperation::OutboxAcknowledged(mut record) => { + record.operation_id = outbox_operation_id; + Ok(record) + } + _ => Err(PersistenceError::Conflict), + }; + } + let mut record = read_outbox_in_transaction(&transaction, outbox_operation_id)? + .ok_or(PersistenceError::NotFound)?; + if record.retry_state == RetryState::Acknowledged { + return Err(PersistenceError::Conflict); + } + record.retry_state = RetryState::Acknowledged; + transaction.update_operation( + outbox_operation_id, + CommittedOperation::Envelope(record.clone()), + )?; + transaction.set_successor_epoch( + transaction.old_epoch, + &transaction.old_authenticator.clone(), + ); + transaction.update_outbox(outbox_operation_id, record.clone()); + let mut result = record.clone(); + result.operation_id = acknowledgement_operation_id; + transaction.stage_operation(CommittedOperation::OutboxAcknowledged(result))?; + transaction.commit_operation()?; + Ok(record) +} + +fn acknowledge_receive( + store: &Arc, + acknowledgement_operation_id: Id, + receive_operation_id: Id, +) -> Result { + store + .faults + .check(FaultPoint::BeforeReceiverAcknowledgement)?; + let fingerprint = operation_fingerprint_parts(20, &[&receive_operation_id])?; + let mut transaction = begin_current(store)?; + if let Some(existing) = transaction.bind_operation(acknowledgement_operation_id, fingerprint)? { + transaction.rollback()?; + return match existing { + CommittedOperation::ReceiveAcknowledged(record) => Ok(record), + _ => Err(PersistenceError::Conflict), + }; + } + let mut accepted = read_accepted_in_transaction(&transaction, receive_operation_id)? + .ok_or(PersistenceError::NotFound)?; + if accepted.acknowledged { + return Err(PersistenceError::Conflict); + } + accepted.acknowledged = true; + transaction.update_operation( + receive_operation_id, + CommittedOperation::Accepted(accepted.clone()), + )?; + transaction + .provider + .remove_internal(&pending_plaintext_key(receive_operation_id)); + transaction.set_successor_epoch( + transaction.old_epoch, + &transaction.old_authenticator.clone(), + ); + transaction.update_accepted(receive_operation_id, accepted.clone()); + let acknowledgement = AcceptedMessageRecord { + operation_id: acknowledgement_operation_id, + ..accepted + }; + transaction.stage_operation(CommittedOperation::ReceiveAcknowledged( + acknowledgement.clone(), + ))?; + transaction.commit_operation()?; + store.faults.check(FaultPoint::AfterAcknowledgementLoss)?; + Ok(acknowledgement) +} + +fn initialized_record(operation_id: Id, session: Id) -> AcceptedMessageRecord { + AcceptedMessageRecord { + operation_id, + crypto_session_id: session, + logical_message_id: operation_id, + class: MessageClass::PairActivation, + epoch: 0, + profile_revision: PROFILE_REVISION, + acknowledged: true, + } +} + +fn persist_endpoint_metadata(endpoint: &Endpoint, provider: &CoreProvider) { + provider.insert_internal( + ENDPOINT_METADATA_KEY.to_vec(), + encode_endpoint_metadata( + endpoint.identity.clone(), + endpoint.peer.clone(), + Some(endpoint.context.clone()), + endpoint.signer.public(), + &endpoint.previous_epoch_deadlines, + endpoint.last_wall_time_ms, + ), + ); +} + +fn persist_phone_metadata( + phone: &Phone, + provider: &CoreProvider, + context: Option<&PairContext>, + fallback_wall_time_ms: u64, +) { + let endpoint = phone.endpoint.as_ref(); + let peer = endpoint.map(|value| value.peer.clone()).unwrap_or_else(|| { + Identity::daemon(phone.identity.account_id, phone.identity.installation_id) + }); + let signer_public = + endpoint.map_or_else(|| phone.signer.public(), |value| value.signer.public()); + let deadlines = endpoint.map_or_else(BTreeMap::new, |value| { + value.previous_epoch_deadlines.clone() + }); + let last_wall_time_ms = endpoint.map_or(fallback_wall_time_ms, |value| value.last_wall_time_ms); + provider.insert_internal( + ENDPOINT_METADATA_KEY.to_vec(), + encode_endpoint_metadata( + phone.identity.clone(), + peer, + context.cloned(), + signer_public, + &deadlines, + last_wall_time_ms, + ), + ); +} + +fn load_daemon( + provider: &CoreProvider, + clock: Arc, + accepted: BTreeSet, +) -> Result { + let metadata = decode_endpoint_metadata( + &provider + .internal(ENDPOINT_METADATA_KEY) + .ok_or(PersistenceError::Corrupt)?, + )?; + if metadata.identity.role != Role::Daemon { + return Err(PersistenceError::IdentityMismatch); + } + let context = metadata.context.ok_or(PersistenceError::Corrupt)?; + let now_ms = clock.now_ms().map_err(PersistenceError::Core)?; + if now_ms < metadata.last_wall_time_ms { + return Err(PersistenceError::Core(CoreError::ClockRollback)); + } + let group = MlsGroup::load(provider.storage(), &GroupId::from_slice(&context.group_id)) + .map_err(|_| PersistenceError::Corrupt)? + .ok_or(PersistenceError::Corrupt)?; + let signer = SignatureKeyPair::read( + provider.storage(), + &metadata.signer_public, + SUITE.signature_algorithm(), + ) + .ok_or(PersistenceError::Corrupt)?; + let key_package_consumed = group.members().count() == 2; + Ok(Daemon { + endpoint: Endpoint { + provider: CoreProvider::from_storage_values(provider.storage_values()) + .map_err(|_| PersistenceError::Storage)?, + signer, + group: Some(group), + identity: metadata.identity, + peer: metadata.peer, + context, + accepted, + previous_epoch_deadlines: metadata.previous_epoch_deadlines, + last_wall_time_ms: now_ms, + clock, + transaction_pending: false, + }, + key_package_consumed, + }) +} + +fn load_phone( + provider: &CoreProvider, + clock: Arc, + accepted: BTreeSet, +) -> Result { + let metadata = decode_endpoint_metadata( + &provider + .internal(ENDPOINT_METADATA_KEY) + .ok_or(PersistenceError::Corrupt)?, + )?; + if metadata.identity.role != Role::Device { + return Err(PersistenceError::IdentityMismatch); + } + let now_ms = clock.now_ms().map_err(PersistenceError::Core)?; + if now_ms < metadata.last_wall_time_ms { + return Err(PersistenceError::Core(CoreError::ClockRollback)); + } + let signer = SignatureKeyPair::read( + provider.storage(), + &metadata.signer_public, + SUITE.signature_algorithm(), + ) + .ok_or(PersistenceError::Corrupt)?; + let provider_copy = CoreProvider::from_storage_values(provider.storage_values()) + .map_err(|_| PersistenceError::Storage)?; + let Some(context) = metadata.context else { + return Ok(Phone { + endpoint: None, + provider: provider_copy, + signer, + identity: metadata.identity, + }); + }; + let group = MlsGroup::load( + provider_copy.storage(), + &GroupId::from_slice(&context.group_id), + ) + .map_err(|_| PersistenceError::Corrupt)? + .ok_or(PersistenceError::Corrupt)?; + let endpoint = Endpoint { + provider: provider_copy, + signer, + group: Some(group), + identity: metadata.identity.clone(), + peer: metadata.peer, + context, + accepted, + previous_epoch_deadlines: metadata.previous_epoch_deadlines, + last_wall_time_ms: now_ms, + clock, + transaction_pending: false, + }; + Ok(Phone { + endpoint: Some(endpoint), + provider: CoreProvider::new().map_err(|_| PersistenceError::Storage)?, + signer: SignatureKeyPair::new(SUITE.signature_algorithm()) + .map_err(|_| PersistenceError::Storage)?, + identity: metadata.identity, + }) +} + +struct EndpointMetadata { + identity: Identity, + peer: Identity, + context: Option, + signer_public: Vec, + previous_epoch_deadlines: BTreeMap, + last_wall_time_ms: u64, +} + +fn encode_endpoint_metadata( + identity: Identity, + peer: Identity, + context: Option, + signer_public: &[u8], + previous_epoch_deadlines: &BTreeMap, + last_wall_time_ms: u64, +) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&STORAGE_SCHEMA_VERSION.to_be_bytes()); + encode_identity(&mut out, &identity); + encode_identity(&mut out, &peer); + match context { + Some(context) => { + out.push(1); + out.extend_from_slice(&context.crypto_session_id); + out.extend_from_slice(&context.group_id); + out.extend_from_slice(&context.account_id); + out.extend_from_slice(&context.installation_id); + out.extend_from_slice(&context.device_id); + } + None => out.push(0), + } + out.push(signer_public.len() as u8); + out.extend_from_slice(signer_public); + out.extend_from_slice(&(previous_epoch_deadlines.len() as u32).to_be_bytes()); + for (epoch, deadline_ms) in previous_epoch_deadlines { + out.extend_from_slice(&epoch.to_be_bytes()); + out.extend_from_slice(&deadline_ms.to_be_bytes()); + } + out.extend_from_slice(&last_wall_time_ms.to_be_bytes()); + out +} + +fn decode_endpoint_metadata(bytes: &[u8]) -> Result { + let mut cursor = BinaryCursor::new(bytes); + if cursor.u16()? != STORAGE_SCHEMA_VERSION { + return Err(PersistenceError::UnsupportedSchema); + } + let identity = decode_identity(&mut cursor)?; + let peer = decode_identity(&mut cursor)?; + let context = match cursor.u8()? { + 0 => None, + 1 => Some(PairContext { + crypto_session_id: cursor.array()?, + group_id: cursor.array()?, + account_id: cursor.array()?, + installation_id: cursor.array()?, + device_id: cursor.array()?, + }), + _ => return Err(PersistenceError::Corrupt), + }; + let signer_len = cursor.u8()? as usize; + let signer_public = cursor.take(signer_len)?.to_vec(); + let deadline_count = cursor.u32()? as usize; + if deadline_count > MAX_PAST_EPOCHS as usize { + return Err(PersistenceError::Corrupt); + } + let mut previous_epoch_deadlines = BTreeMap::new(); + for _ in 0..deadline_count { + let epoch = cursor.u64()?; + let deadline_ms = cursor.u64()?; + if previous_epoch_deadlines + .insert(epoch, deadline_ms) + .is_some() + { + return Err(PersistenceError::Corrupt); + } + } + let last_wall_time_ms = cursor.u64()?; + cursor.finish()?; + if signer_public.len() != 32 { + return Err(PersistenceError::Corrupt); + } + if let Some(context) = &context + && (context.crypto_session_id == [0; 16] + || context.account_id != identity.account_id + || context.installation_id != identity.installation_id) + { + return Err(PersistenceError::IdentityMismatch); + } + Ok(EndpointMetadata { + identity, + peer, + context, + signer_public, + previous_epoch_deadlines, + last_wall_time_ms, + }) +} + +fn encode_identity(out: &mut Vec, identity: &Identity) { + out.push(identity.role as u8); + out.extend_from_slice(&identity.account_id); + out.extend_from_slice(&identity.installation_id); + out.extend_from_slice(&identity.device_id); +} + +fn decode_identity(cursor: &mut BinaryCursor<'_>) -> Result { + let role = match cursor.u8()? { + 1 => Role::Daemon, + 2 => Role::Device, + _ => return Err(PersistenceError::Corrupt), + }; + let identity = Identity { + role, + account_id: cursor.array()?, + installation_id: cursor.array()?, + device_id: cursor.array()?, + }; + identity.validate().map_err(PersistenceError::Core)?; + Ok(identity) +} + +fn operation_fingerprint(kind: u8, bytes: &[u8]) -> Result<[u8; 48], PersistenceError> { + operation_fingerprint_parts(kind, &[bytes]) +} + +fn operation_fingerprint_parts(kind: u8, parts: &[&[u8]]) -> Result<[u8; 48], PersistenceError> { + let provider = CoreProvider::new().map_err(|_| PersistenceError::Storage)?; + let mut input = vec![kind]; + for part in parts { + put_bytes(&mut input, part)?; + } + provider + .crypto() + .hash(SUITE.hash_algorithm(), &input) + .map_err(|_| PersistenceError::Storage)? + .try_into() + .map_err(|_| PersistenceError::Storage) +} + +fn pending_plaintext_key(operation_id: Id) -> Vec { + let mut key = PENDING_PLAINTEXT_PREFIX.to_vec(); + key.extend_from_slice(&operation_id); + key +} + +fn pending_plaintext( + provider: &CoreProvider, + operation_id: Id, +) -> Result, PersistenceError> { + provider + .internal(&pending_plaintext_key(operation_id)) + .ok_or(PersistenceError::AlreadyAcknowledged) +} + +fn durable_plaintext( + operation: CommittedOperation, + plaintext: Vec, +) -> Result { + match operation { + CommittedOperation::Accepted(record) => Ok(DurablePlaintext { + operation_id: record.operation_id, + logical_message_id: record.logical_message_id, + epoch: record.epoch, + plaintext, + }), + _ => Err(PersistenceError::Conflict), + } +} + +fn welcome_from_operation( + operation: CommittedOperation, + metadata: EndpointMetadata, +) -> Result { + let CommittedOperation::Envelope(record) = operation else { + return Err(PersistenceError::Conflict); + }; + Ok(PairWelcome { + bytes: record.ciphertext.into_boxed_slice(), + context: metadata.context.ok_or(PersistenceError::Corrupt)?, + daemon_identity: metadata.identity, + device_identity: metadata.peer, + }) +} + +fn transaction_metadata_from_store( + store: &Arc, +) -> Result { + let transaction = store.begin_transaction( + store.crypto_session_id, + store.generation()?, + store.rollback_counter()?, + )?; + let metadata = decode_endpoint_metadata( + &transaction + .provider + .internal(ENDPOINT_METADATA_KEY) + .ok_or(PersistenceError::Corrupt)?, + )?; + transaction.rollback()?; + Ok(metadata) +} + +#[derive(Debug)] +struct SealedState { + key_id: [u8; 16], + nonce: [u8; 12], + ciphertext: Vec, +} + +impl SealedState { + fn encode(&self) -> Result, PersistenceError> { + let mut out = Vec::with_capacity(2 + 16 + 12 + 4 + self.ciphertext.len()); + out.extend_from_slice(&STATE_FORMAT_VERSION.to_be_bytes()); + out.extend_from_slice(&self.key_id); + out.extend_from_slice(&self.nonce); + put_bytes(&mut out, &self.ciphertext)?; + Ok(out) + } + + fn decode(bytes: &[u8]) -> Result { + let mut cursor = BinaryCursor::new(bytes); + if cursor.u16()? != STATE_FORMAT_VERSION { + return Err(PersistenceError::UnsupportedSchema); + } + let value = Self { + key_id: cursor.array()?, + nonce: cursor.array()?, + ciphertext: cursor.bytes()?.to_vec(), + }; + cursor.finish()?; + Ok(value) + } +} + +fn configure_transaction(write: &mut WriteTransaction) -> Result<(), PersistenceError> { + write + .set_durability(Durability::Immediate) + .map_err(|_| PersistenceError::Storage)?; + write.set_two_phase_commit(true); + Ok(()) +} + +fn require_dependencies( + envelope_keys: &dyn EnvelopeKeyStore, + rollback_anchor: &dyn RollbackAnchor, +) -> Result<(), PersistenceError> { + if !envelope_keys.available() { + return Err(PersistenceError::KeyUnavailable); + } + if !rollback_anchor.available() { + return Err(PersistenceError::AnchorUnavailable); + } + Ok(()) +} + +struct SessionLifecycleClaim { + _file: File, +} + +fn prepare_storage_root(root: &Path, create: bool) -> Result { + if create { + if root.exists() + && fs::symlink_metadata(root) + .map_err(|_| PersistenceError::Io)? + .file_type() + .is_symlink() + { + return Err(PersistenceError::IdentityMismatch); + } + fs::create_dir_all(root).map_err(|_| PersistenceError::Io)?; + restrict_directory(root)?; + } + canonical_storage_root(root) +} + +fn acquire_session_lifecycle_claim( + root: &Path, + session: Id, +) -> Result { + let path = root.join(format!("{}.redb.lifecycle.lock", hex_id(session))); + let (file, created) = match OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&path) + { + Ok(file) => (file, true), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + validate_regular_file(&path, PersistenceError::Io)?; + ( + OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .map_err(|_| PersistenceError::Io)?, + false, + ) + } + Err(_) => return Err(PersistenceError::Io), + }; + restrict_file(&path)?; + if created { + sync_parent_directory(&path)?; + } + file.try_lock().map_err(|error| match error { + fs::TryLockError::WouldBlock => PersistenceError::LifecycleBusy, + fs::TryLockError::Error(_) => PersistenceError::Io, + })?; + Ok(SessionLifecycleClaim { _file: file }) +} + +fn database_path( + root: &Path, + session: Id, + create: bool, + faults: Option<&dyn FaultInjector>, +) -> Result { + let root = canonical_storage_root(root)?; + let filename = format!("{}.redb", hex_id(session)); + let path = root.join(filename); + let initialization_marker = initializing_marker_for_database(&path); + let database_exists = regular_file_exists(&path)?; + let marker_exists = regular_file_exists(&initialization_marker)?; + if database_exists { + if create { + return if marker_exists { + Err(PersistenceError::InitializationIncomplete) + } else { + Err(PersistenceError::AlreadyExists) + }; + } + let canonical = path.canonicalize().map_err(|_| PersistenceError::Io)?; + if canonical.parent() != Some(root.as_path()) { + return Err(PersistenceError::IdentityMismatch); + } + restrict_file(&canonical)?; + Ok(canonical) + } else if create { + if marker_exists { + return Err(PersistenceError::InitializationIncomplete); + } + create_private_file(&initialization_marker)?; + sync_parent_directory(&initialization_marker)?; + if let Some(faults) = faults { + faults.check(FaultPoint::AfterInitializationMarkerCreation)?; + } + create_private_file(&path)?; + sync_parent_directory(&path)?; + Ok(path) + } else if marker_exists { + Err(PersistenceError::InitializationIncomplete) + } else { + Err(PersistenceError::NotFound) + } +} + +fn initializing_marker_for_database(database: &Path) -> PathBuf { + database.with_extension("redb.initializing") +} + +fn canonical_storage_root(root: &Path) -> Result { + let metadata = fs::symlink_metadata(root).map_err(|_| PersistenceError::Io)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(PersistenceError::IdentityMismatch); + } + root.canonicalize().map_err(|_| PersistenceError::Io) +} + +fn regular_file_exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err(PersistenceError::IdentityMismatch) + } + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(_) => Err(PersistenceError::Io), + } +} + +fn validate_regular_file(path: &Path, missing: PersistenceError) -> Result<(), PersistenceError> { + if regular_file_exists(path)? { + Ok(()) + } else { + Err(missing) + } +} + +fn inspect_database_lifecycle( + database: &impl ReadableDatabase, + crypto_session_id: Id, +) -> Result { + let read = database.begin_read().map_err(map_transaction_error)?; + let meta = read.open_table(META).map_err(map_table_error)?; + if read_u16(&meta, META_SCHEMA)? != STORAGE_SCHEMA_VERSION { + return Err(PersistenceError::UnsupportedSchema); + } + if read_bytes(&meta, META_SESSION)? != crypto_session_id + || read_bytes(&meta, META_PROFILE)? != PROFILE_ID.as_bytes() + || read_u16(&meta, META_PROFILE_REVISION)? != PROFILE_REVISION + { + return Err(PersistenceError::IdentityMismatch); + } + match read_bytes(&meta, META_LIFECYCLE)?.as_slice() { + [lifecycle @ (LIFECYCLE_INITIALIZING | LIFECYCLE_READY)] => Ok(*lifecycle), + _ => Err(PersistenceError::Corrupt), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InitializationState { + Pristine, + Committed, + Inconsistent, +} + +fn inspect_initialization_state( + database: &impl ReadableDatabase, + crypto_session_id: Id, +) -> Result { + let read = database.begin_read().map_err(map_transaction_error)?; + let expected_tables = BTreeSet::from([ + META.name().to_owned(), + STATE.name().to_owned(), + OPERATIONS.name().to_owned(), + OUTBOX.name().to_owned(), + ACCEPTED.name().to_owned(), + ]); + let actual_tables = read + .list_tables() + .map_err(map_storage_error)? + .map(|table| table.name().to_owned()) + .collect::>(); + if actual_tables != expected_tables + || read + .list_multimap_tables() + .map_err(map_storage_error)? + .next() + .is_some() + { + return Ok(InitializationState::Inconsistent); + } + + let meta = read.open_table(META).map_err(map_table_error)?; + let mut metadata_entries = 0_usize; + for entry in meta.iter().map_err(map_storage_error)? { + entry.map_err(map_storage_error)?; + metadata_entries += 1; + } + let generation = read_u64(&meta, META_GENERATION)?; + let rollback_counter = read_u64(&meta, META_ROLLBACK_COUNTER)?; + let epoch = read_u64(&meta, META_EPOCH)?; + let authenticator = read_bytes(&meta, META_EPOCH_AUTHENTICATOR)?; + let pending_erase = read_bytes(&meta, META_PENDING_ERASE)?; + drop(meta); + + let state_entries = read + .open_table(STATE) + .map_err(map_table_error)? + .iter() + .map_err(map_storage_error)? + .count(); + let operation_entries = read + .open_table(OPERATIONS) + .map_err(map_table_error)? + .iter() + .map_err(map_storage_error)? + .count(); + let outbox_entries = read + .open_table(OUTBOX) + .map_err(map_table_error)? + .iter() + .map_err(map_storage_error)? + .count(); + let accepted_entries = read + .open_table(ACCEPTED) + .map_err(map_table_error)? + .iter() + .map_err(map_storage_error)? + .count(); + + if metadata_entries == 10 + && generation == 0 + && rollback_counter == 0 + && epoch == 0 + && authenticator.is_empty() + && pending_erase.is_empty() + && state_entries == 0 + && operation_entries == 0 + && outbox_entries == 0 + && accepted_entries == 0 + { + return Ok(InitializationState::Pristine); + } + + let creation_operation = if operation_entries == 1 { + let operations = read.open_table(OPERATIONS).map_err(map_table_error)?; + let mut entries = operations.iter().map_err(map_storage_error)?; + let Some(entry) = entries.next() else { + return Ok(InitializationState::Inconsistent); + }; + let (key, value) = entry.map_err(map_storage_error)?; + let (fingerprint, operation_generation, operation) = + decode_operation_record(value.value())?; + Some(( + key.value().to_vec(), + fingerprint, + operation_generation, + operation, + )) + } else { + None + }; + + let valid_creation_shape = match creation_operation { + Some((key, fingerprint, 1, CommittedOperation::Envelope(record))) => { + let outbox = read.open_table(OUTBOX).map_err(map_table_error)?; + let persisted = outbox + .get(record.operation_id.as_slice()) + .map_err(map_storage_error)? + .map(|value| decode_outbox(value.value())) + .transpose()?; + epoch == 0 + && authenticator.is_empty() + && outbox_entries == 1 + && accepted_entries == 0 + && key.as_slice() == record.operation_id + && fingerprint == operation_fingerprint(10, &[])? + && record.crypto_session_id == crypto_session_id + && record.logical_message_id == record.operation_id + && record.class == MessageClass::PairActivation + && record.epoch == 0 + && record.profile_revision == PROFILE_REVISION + && record.retry_state == RetryState::Pending + && !record.ciphertext.is_empty() + && record.commit.is_none() + && persisted.as_ref() == Some(&record) + } + Some((key, fingerprint, 1, CommittedOperation::Accepted(record))) => { + let accepted = read.open_table(ACCEPTED).map_err(map_table_error)?; + let persisted = accepted + .get(record.operation_id.as_slice()) + .map_err(map_storage_error)? + .map(|value| decode_accepted(value.value())) + .transpose()?; + authenticator.len() == 48 + && outbox_entries == 0 + && accepted_entries == 1 + && key.as_slice() == record.operation_id + && fingerprint == operation_fingerprint(1, &[])? + && record == initialized_record(record.operation_id, crypto_session_id) + && persisted.as_ref() == Some(&record) + } + _ => false, + }; + + let group_state_candidate = generation > 0 + && rollback_counter == generation + && authenticator.len() == 48 + && matches!(pending_erase.len(), 0 | 16) + && state_entries == 1 + && operation_entries > 0 + && outbox_entries + accepted_entries > 0; + let initial_state_candidate = generation == 1 + && rollback_counter == 1 + && pending_erase.is_empty() + && state_entries == 1 + && valid_creation_shape; + + if metadata_entries == 10 && (group_state_candidate || initial_state_candidate) { + Ok(InitializationState::Committed) + } else { + Ok(InitializationState::Inconsistent) + } +} + +fn create_private_file(path: &Path) -> Result<(), PersistenceError> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(path).map_err(|_| PersistenceError::Io)?; + drop(file); + restrict_file(path) +} + +#[cfg(unix)] +fn sync_parent_directory(path: &Path) -> Result<(), PersistenceError> { + let parent = path.parent().ok_or(PersistenceError::Io)?; + fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|_| PersistenceError::Io) +} + +#[cfg(not(unix))] +fn sync_parent_directory(_path: &Path) -> Result<(), PersistenceError> { + Ok(()) +} + +#[cfg(unix)] +fn restrict_directory(path: &Path) -> Result<(), PersistenceError> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|_| PersistenceError::Io) +} +#[cfg(not(unix))] +fn restrict_directory(_path: &Path) -> Result<(), PersistenceError> { + Ok(()) +} + +#[cfg(unix)] +fn restrict_file(path: &Path) -> Result<(), PersistenceError> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|_| PersistenceError::Io) +} +#[cfg(not(unix))] +fn restrict_file(_path: &Path) -> Result<(), PersistenceError> { + Ok(()) +} + +fn state_aad(session: Id, generation: u64, rollback: u64, epoch: u64) -> Vec { + let mut aad = Vec::with_capacity(STATE_AAD_LABEL.len() + 16 + 2 + 2 + 8 * 3); + aad.extend_from_slice(STATE_AAD_LABEL); + aad.extend_from_slice(&session); + aad.extend_from_slice(&STORAGE_SCHEMA_VERSION.to_be_bytes()); + aad.extend_from_slice(&PROFILE_REVISION.to_be_bytes()); + aad.extend_from_slice(&generation.to_be_bytes()); + aad.extend_from_slice(&rollback.to_be_bytes()); + aad.extend_from_slice(&epoch.to_be_bytes()); + aad +} + +fn encode_storage_image(values: &BTreeMap, Vec>) -> Result, PersistenceError> { + if values.len() > MAX_STATE_ENTRIES { + return Err(PersistenceError::Corrupt); + } + let mut out = Vec::new(); + out.extend_from_slice(&STATE_FORMAT_VERSION.to_be_bytes()); + out.extend_from_slice(&(values.len() as u32).to_be_bytes()); + for (key, value) in values { + put_bytes(&mut out, key)?; + put_bytes(&mut out, value)?; + if out.len() > MAX_STATE_BYTES { + return Err(PersistenceError::Corrupt); + } + } + Ok(out) +} + +fn decode_storage_image(bytes: &[u8]) -> Result, Vec>, PersistenceError> { + if bytes.len() > MAX_STATE_BYTES { + return Err(PersistenceError::Corrupt); + } + let mut cursor = BinaryCursor::new(bytes); + if cursor.u16()? != STATE_FORMAT_VERSION { + return Err(PersistenceError::UnsupportedSchema); + } + let count = cursor.u32()? as usize; + if count > MAX_STATE_ENTRIES { + return Err(PersistenceError::Corrupt); + } + let mut values = BTreeMap::new(); + for _ in 0..count { + let key = cursor.bytes()?.to_vec(); + let value = cursor.bytes()?.to_vec(); + if values.insert(key, value).is_some() { + return Err(PersistenceError::Corrupt); + } + } + cursor.finish()?; + Ok(values) +} + +fn encode_outbox(record: &OutboxRecord) -> Result, PersistenceError> { + let mut out = Vec::new(); + out.extend_from_slice(&record.operation_id); + out.extend_from_slice(&record.crypto_session_id); + out.extend_from_slice(&record.logical_message_id); + out.push(record.class as u8); + out.extend_from_slice(&record.epoch.to_be_bytes()); + out.extend_from_slice(&record.profile_revision.to_be_bytes()); + out.push(record.retry_state as u8); + put_bytes(&mut out, &record.ciphertext)?; + match &record.commit { + Some(commit) => { + out.push(1); + out.extend_from_slice(&commit.commit_id); + out.extend_from_slice(&commit.target_epoch.to_be_bytes()); + out.extend_from_slice(&commit.epoch_authenticator); + } + None => out.push(0), + } + Ok(out) +} + +fn decode_outbox(bytes: &[u8]) -> Result { + let mut cursor = BinaryCursor::new(bytes); + let operation_id = cursor.array()?; + let crypto_session_id = cursor.array()?; + let logical_message_id = cursor.array()?; + let class = cursor + .u8()? + .try_into() + .map_err(|_| PersistenceError::Corrupt)?; + let epoch = cursor.u64()?; + let profile_revision = cursor.u16()?; + let retry_state = match cursor.u8()? { + 1 => RetryState::Pending, + 2 => RetryState::Acknowledged, + _ => return Err(PersistenceError::Corrupt), + }; + let ciphertext = cursor.bytes()?.to_vec(); + let commit = match cursor.u8()? { + 0 => None, + 1 => Some(CommitMetadata { + commit_id: cursor.array()?, + target_epoch: cursor.u64()?, + epoch_authenticator: cursor.array()?, + }), + _ => return Err(PersistenceError::Corrupt), + }; + cursor.finish()?; + if crypto_session_id == [0; 16] || profile_revision != PROFILE_REVISION { + return Err(PersistenceError::Corrupt); + } + Ok(OutboxRecord { + operation_id, + crypto_session_id, + logical_message_id, + class, + epoch, + profile_revision, + retry_state, + ciphertext, + commit, + }) +} + +fn encode_accepted(record: &AcceptedMessageRecord) -> Vec { + let mut out = Vec::with_capacity(16 * 3 + 1 + 8 + 2 + 1); + out.extend_from_slice(&record.operation_id); + out.extend_from_slice(&record.crypto_session_id); + out.extend_from_slice(&record.logical_message_id); + out.push(record.class as u8); + out.extend_from_slice(&record.epoch.to_be_bytes()); + out.extend_from_slice(&record.profile_revision.to_be_bytes()); + out.push(u8::from(record.acknowledged)); + out +} + +fn decode_accepted(bytes: &[u8]) -> Result { + let mut cursor = BinaryCursor::new(bytes); + let record = AcceptedMessageRecord { + operation_id: cursor.array()?, + crypto_session_id: cursor.array()?, + logical_message_id: cursor.array()?, + class: cursor + .u8()? + .try_into() + .map_err(|_| PersistenceError::Corrupt)?, + epoch: cursor.u64()?, + profile_revision: cursor.u16()?, + acknowledged: match cursor.u8()? { + 0 => false, + 1 => true, + _ => return Err(PersistenceError::Corrupt), + }, + }; + cursor.finish()?; + if record.crypto_session_id == [0; 16] || record.profile_revision != PROFILE_REVISION { + return Err(PersistenceError::Corrupt); + } + Ok(record) +} + +fn durable_manifest( + write: &WriteTransaction, + crypto: &impl openmls_traits::crypto::OpenMlsCrypto, +) -> Result<[u8; 48], PersistenceError> { + let mut canonical = b"Axl durable record manifest v1".to_vec(); + { + let table = write.open_table(META).map_err(map_table_error)?; + for entry in table.iter().map_err(map_storage_error)? { + let (key, value) = entry.map_err(map_storage_error)?; + if key.value() == META_LIFECYCLE { + continue; + } + append_manifest_entry( + &mut canonical, + crypto, + b"meta", + &[key.value()], + value.value(), + )?; + } + } + for (label, definition) in [ + (b"operations".as_slice(), OPERATIONS), + (b"outbox".as_slice(), OUTBOX), + (b"accepted".as_slice(), ACCEPTED), + ] { + let table = write.open_table(definition).map_err(map_table_error)?; + for entry in table.iter().map_err(map_storage_error)? { + let (key, value) = entry.map_err(map_storage_error)?; + append_manifest_entry(&mut canonical, crypto, label, key.value(), value.value())?; + } + } + crypto + .hash(SUITE.hash_algorithm(), &canonical) + .map_err(|_| PersistenceError::Storage)? + .try_into() + .map_err(|_| PersistenceError::Storage) +} + +fn durable_manifest_read( + read: &redb::ReadTransaction, + crypto: &impl openmls_traits::crypto::OpenMlsCrypto, +) -> Result<[u8; 48], PersistenceError> { + let mut canonical = b"Axl durable record manifest v1".to_vec(); + { + let table = read.open_table(META).map_err(map_table_error)?; + for entry in table.iter().map_err(map_storage_error)? { + let (key, value) = entry.map_err(map_storage_error)?; + if key.value() == META_LIFECYCLE { + continue; + } + append_manifest_entry( + &mut canonical, + crypto, + b"meta", + &[key.value()], + value.value(), + )?; + } + } + for (label, definition) in [ + (b"operations".as_slice(), OPERATIONS), + (b"outbox".as_slice(), OUTBOX), + (b"accepted".as_slice(), ACCEPTED), + ] { + let table = read.open_table(definition).map_err(map_table_error)?; + for entry in table.iter().map_err(map_storage_error)? { + let (key, value) = entry.map_err(map_storage_error)?; + append_manifest_entry(&mut canonical, crypto, label, key.value(), value.value())?; + } + } + crypto + .hash(SUITE.hash_algorithm(), &canonical) + .map_err(|_| PersistenceError::Storage)? + .try_into() + .map_err(|_| PersistenceError::Storage) +} + +fn append_manifest_entry( + canonical: &mut Vec, + crypto: &impl openmls_traits::crypto::OpenMlsCrypto, + label: &[u8], + key: &[u8], + value: &[u8], +) -> Result<(), PersistenceError> { + let mut entry = Vec::with_capacity(label.len() + key.len() + value.len() + 8); + put_bytes(&mut entry, label)?; + put_bytes(&mut entry, key)?; + put_bytes(&mut entry, value)?; + let digest = crypto + .hash(SUITE.hash_algorithm(), &entry) + .map_err(|_| PersistenceError::Storage)?; + canonical.extend_from_slice(&digest); + Ok(()) +} + +fn load_accepted_ids( + write: &WriteTransaction, + crypto_session_id: Id, +) -> Result, PersistenceError> { + let table = write.open_table(ACCEPTED).map_err(map_table_error)?; + let mut ids = BTreeSet::new(); + for entry in table.iter().map_err(map_storage_error)? { + let (key, value) = entry.map_err(map_storage_error)?; + let operation_id: Id = key + .value() + .try_into() + .map_err(|_| PersistenceError::Corrupt)?; + let record = decode_accepted(value.value())?; + if record.operation_id != operation_id || record.crypto_session_id != crypto_session_id { + return Err(PersistenceError::IdentityMismatch); + } + if matches!( + record.class, + MessageClass::ApplicationRequest | MessageClass::ApplicationDelivery + ) { + ids.insert(record.logical_message_id); + } + } + Ok(ids) +} + +fn prune_durable_records( + write: &mut WriteTransaction, + next_generation: u64, +) -> Result<(), PersistenceError> { + let cutoff = next_generation.saturating_sub(IDEMPOTENCY_RETENTION_GENERATIONS); + let entries = { + let operations = write.open_table(OPERATIONS).map_err(map_table_error)?; + let mut entries = Vec::new(); + for entry in operations.iter().map_err(map_storage_error)? { + let (key, value) = entry.map_err(map_storage_error)?; + entries.push(( + key.value().to_vec(), + decode_operation_generation(value.value())?, + decode_operation(value.value())?, + )); + } + entries + }; + let mut pending = 0usize; + let mut remove = Vec::new(); + for (key, generation, operation) in entries { + let acknowledged = match &operation { + CommittedOperation::Envelope(record) => { + let outbox = write.open_table(OUTBOX).map_err(map_table_error)?; + outbox + .get(record.operation_id.as_slice()) + .map_err(map_storage_error)? + .map(|value| decode_outbox(value.value())) + .transpose()? + .is_some_and(|stored| stored.retry_state == RetryState::Acknowledged) + } + CommittedOperation::Accepted(record) => { + let accepted = write.open_table(ACCEPTED).map_err(map_table_error)?; + accepted + .get(record.operation_id.as_slice()) + .map_err(map_storage_error)? + .map(|value| decode_accepted(value.value())) + .transpose()? + .is_some_and(|stored| stored.acknowledged) + } + CommittedOperation::OutboxAcknowledged(_) + | CommittedOperation::ReceiveAcknowledged(_) => true, + }; + if acknowledged && generation <= cutoff { + remove.push((key, operation)); + } else if !acknowledged { + pending = pending.saturating_add(1); + } + } + if pending > IDEMPOTENCY_RETENTION_GENERATIONS as usize { + return Err(PersistenceError::RetentionExceeded); + } + for (key, operation) in remove { + write + .open_table(OPERATIONS) + .map_err(map_table_error)? + .remove(key.as_slice()) + .map_err(map_storage_error)?; + match operation { + CommittedOperation::Envelope(record) => { + write + .open_table(OUTBOX) + .map_err(map_table_error)? + .remove(record.operation_id.as_slice()) + .map_err(map_storage_error)?; + } + CommittedOperation::Accepted(record) => { + write + .open_table(ACCEPTED) + .map_err(map_table_error)? + .remove(record.operation_id.as_slice()) + .map_err(map_storage_error)?; + } + CommittedOperation::OutboxAcknowledged(_) + | CommittedOperation::ReceiveAcknowledged(_) => {} + } + } + Ok(()) +} + +fn read_outbox_in_transaction( + transaction: &NativeGroupTransaction<'_>, + operation_id: Id, +) -> Result, PersistenceError> { + let write = transaction + .write + .as_ref() + .ok_or(PersistenceError::Storage)?; + let table = write.open_table(OUTBOX).map_err(map_table_error)?; + let record = table + .get(operation_id.as_slice()) + .map_err(map_storage_error)? + .map(|value| decode_outbox(value.value())) + .transpose()?; + if let Some(record) = &record + && (record.operation_id != operation_id + || record.crypto_session_id != transaction.owner.crypto_session_id) + { + return Err(PersistenceError::IdentityMismatch); + } + Ok(record) +} + +fn read_accepted_in_transaction( + transaction: &NativeGroupTransaction<'_>, + operation_id: Id, +) -> Result, PersistenceError> { + let write = transaction + .write + .as_ref() + .ok_or(PersistenceError::Storage)?; + let table = write.open_table(ACCEPTED).map_err(map_table_error)?; + let record = table + .get(operation_id.as_slice()) + .map_err(map_storage_error)? + .map(|value| decode_accepted(value.value())) + .transpose()?; + if let Some(record) = &record + && (record.operation_id != operation_id + || record.crypto_session_id != transaction.owner.crypto_session_id) + { + return Err(PersistenceError::IdentityMismatch); + } + Ok(record) +} + +fn validate_operation_binding( + operation: &CommittedOperation, + operation_id: Id, + crypto_session_id: Id, +) -> Result<(), PersistenceError> { + let (stored_operation_id, stored_session_id) = match operation { + CommittedOperation::Envelope(record) | CommittedOperation::OutboxAcknowledged(record) => { + (record.operation_id, record.crypto_session_id) + } + CommittedOperation::Accepted(record) | CommittedOperation::ReceiveAcknowledged(record) => { + (record.operation_id, record.crypto_session_id) + } + }; + if stored_operation_id != operation_id || stored_session_id != crypto_session_id { + return Err(PersistenceError::IdentityMismatch); + } + Ok(()) +} + +fn encode_operation( + fingerprint: [u8; 48], + generation: u64, + operation: &CommittedOperation, +) -> Result, PersistenceError> { + let (kind, result) = match operation { + CommittedOperation::Envelope(record) => (1, encode_outbox(record)?), + CommittedOperation::Accepted(record) => (2, encode_accepted(record)), + CommittedOperation::OutboxAcknowledged(record) => (3, encode_outbox(record)?), + CommittedOperation::ReceiveAcknowledged(record) => (4, encode_accepted(record)), + }; + let mut out = Vec::new(); + out.extend_from_slice(&fingerprint); + out.extend_from_slice(&generation.to_be_bytes()); + out.push(kind); + put_bytes(&mut out, &result)?; + Ok(out) +} + +fn decode_operation_record( + bytes: &[u8], +) -> Result<([u8; 48], u64, CommittedOperation), PersistenceError> { + let mut cursor = BinaryCursor::new(bytes); + let fingerprint = cursor.array()?; + let generation = cursor.u64()?; + let kind = cursor.u8()?; + let result = cursor.bytes()?; + cursor.finish()?; + let operation = match kind { + 1 => CommittedOperation::Envelope(decode_outbox(result)?), + 2 => CommittedOperation::Accepted(decode_accepted(result)?), + 3 => CommittedOperation::OutboxAcknowledged(decode_outbox(result)?), + 4 => CommittedOperation::ReceiveAcknowledged(decode_accepted(result)?), + _ => return Err(PersistenceError::Corrupt), + }; + Ok((fingerprint, generation, operation)) +} + +fn decode_operation_with_fingerprint( + bytes: &[u8], +) -> Result<([u8; 48], CommittedOperation), PersistenceError> { + decode_operation_record(bytes).map(|(fingerprint, _, operation)| (fingerprint, operation)) +} + +fn decode_operation(bytes: &[u8]) -> Result { + decode_operation_record(bytes).map(|(_, _, operation)| operation) +} + +fn decode_operation_generation(bytes: &[u8]) -> Result { + let mut cursor = BinaryCursor::new(bytes); + let _: [u8; 48] = cursor.array()?; + cursor.u64() +} + +fn put_bytes(out: &mut Vec, bytes: &[u8]) -> Result<(), PersistenceError> { + let len = u32::try_from(bytes.len()).map_err(|_| PersistenceError::Corrupt)?; + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(bytes); + Ok(()) +} + +struct BinaryCursor<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> BinaryCursor<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + fn take(&mut self, len: usize) -> Result<&'a [u8], PersistenceError> { + let end = self + .offset + .checked_add(len) + .ok_or(PersistenceError::Corrupt)?; + let result = self + .bytes + .get(self.offset..end) + .ok_or(PersistenceError::Corrupt)?; + self.offset = end; + Ok(result) + } + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + fn u16(&mut self) -> Result { + Ok(u16::from_be_bytes(self.array()?)) + } + fn u32(&mut self) -> Result { + Ok(u32::from_be_bytes(self.array()?)) + } + fn u64(&mut self) -> Result { + Ok(u64::from_be_bytes(self.array()?)) + } + fn array(&mut self) -> Result<[u8; N], PersistenceError> { + self.take(N)? + .try_into() + .map_err(|_| PersistenceError::Corrupt) + } + fn bytes(&mut self) -> Result<&'a [u8], PersistenceError> { + let len = self.u32()? as usize; + self.take(len) + } + fn finish(self) -> Result<(), PersistenceError> { + if self.offset == self.bytes.len() { + Ok(()) + } else { + Err(PersistenceError::Corrupt) + } + } +} + +fn read_bytes( + table: &impl ReadableTable, + key: u8, +) -> Result, PersistenceError> { + let value = table + .get(key) + .map_err(map_storage_error)? + .ok_or(PersistenceError::Corrupt)?; + Ok(value.value().to_vec()) +} + +fn read_u16( + table: &impl ReadableTable, + key: u8, +) -> Result { + let bytes = table + .get(key) + .map_err(map_storage_error)? + .ok_or(PersistenceError::Corrupt)?; + Ok(u16::from_be_bytes( + bytes + .value() + .try_into() + .map_err(|_| PersistenceError::Corrupt)?, + )) +} + +fn read_u64( + table: &impl ReadableTable, + key: u8, +) -> Result { + let bytes = table + .get(key) + .map_err(map_storage_error)? + .ok_or(PersistenceError::Corrupt)?; + Ok(u64::from_be_bytes( + bytes + .value() + .try_into() + .map_err(|_| PersistenceError::Corrupt)?, + )) +} + +fn hex_id(id: Id) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(32); + for byte in id { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} + +fn map_database_error(_error: redb::DatabaseError) -> PersistenceError { + PersistenceError::Corrupt +} +fn map_transaction_error(_error: redb::TransactionError) -> PersistenceError { + PersistenceError::Storage +} +fn map_table_error(_error: redb::TableError) -> PersistenceError { + PersistenceError::Corrupt +} +fn map_storage_error(_error: redb::StorageError) -> PersistenceError { + PersistenceError::Storage +} diff --git a/packages/e2ee/src/persistence_tests.rs b/packages/e2ee/src/persistence_tests.rs new file mode 100644 index 00000000..1438a5dd --- /dev/null +++ b/packages/e2ee/src/persistence_tests.rs @@ -0,0 +1,2177 @@ +// SPDX-FileCopyrightText: 2026 VishnuM449 +// SPDX-License-Identifier: Apache-2.0 + +use std::{ + collections::BTreeMap, + fs, + path::PathBuf, + process::Command, + sync::{ + Arc, Condvar, Mutex, + atomic::{AtomicU64, Ordering}, + mpsc, + }, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use redb::{Database, Durability, ReadableDatabase, ReadableTable, TableDefinition}; + +use crate::{ + Clock, Error, Identity, PairContext, SystemClock, TransactionalProvider, + persistence::{ + CommittedOperation, DurableDaemon, DurablePhone, EnvelopeKeyStore, FaultInjector, + FaultPoint, NativeTransactionalProvider, NoFaults, PersistenceError, RollbackAnchor, + RollbackState, RuntimeHooks, discard_interrupted_creation, + }, +}; + +fn id(value: u8) -> [u8; 16] { + [value; 16] +} + +struct ManualClock(AtomicU64); + +impl ManualClock { + fn new(now_ms: u64) -> Arc { + Arc::new(Self(AtomicU64::new(now_ms))) + } + + fn advance(&self, milliseconds: u64) { + self.0.fetch_add(milliseconds, Ordering::SeqCst); + } + + fn set(&self, milliseconds: u64) { + self.0.store(milliseconds, Ordering::SeqCst); + } +} + +impl Clock for ManualClock { + fn now_ms(&self) -> Result { + Ok(self.0.load(Ordering::SeqCst)) + } +} + +fn sequence_id(tag: u8, value: u64) -> [u8; 16] { + let mut id = [tag; 16]; + id[8..].copy_from_slice(&value.to_be_bytes()); + id +} + +fn context(seed: u8) -> PairContext { + PairContext { + crypto_session_id: id(seed), + group_id: [seed; 32], + account_id: id(seed + 1), + installation_id: id(seed + 2), + device_id: id(seed + 3), + } +} + +fn database_path(root: &std::path::Path, session: [u8; 16]) -> PathBuf { + root.join(format!( + "{}.redb", + session + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + )) +} + +fn marker_path(root: &std::path::Path, session: [u8; 16]) -> PathBuf { + database_path(root, session).with_extension("redb.initializing") +} + +fn lifecycle_claim_path(root: &std::path::Path, session: [u8; 16]) -> PathBuf { + database_path(root, session).with_extension("redb.lifecycle.lock") +} + +fn tamper_record(path: &std::path::Path, table_name: &'static str, key: [u8; 16]) { + let database = Database::open(path).unwrap(); + let mut write = database.begin_write().unwrap(); + write.set_durability(Durability::Immediate).unwrap(); + write.set_two_phase_commit(true); + { + let definition: TableDefinition<&[u8], &[u8]> = TableDefinition::new(table_name); + let mut table = write.open_table(definition).unwrap(); + let mut bytes = table.get(key.as_slice()).unwrap().unwrap().value().to_vec(); + let index = bytes.len() / 2; + bytes[index] ^= 1; + table.insert(key.as_slice(), bytes.as_slice()).unwrap(); + } + write.commit().unwrap(); +} + +fn temp_root(label: &str) -> PathBuf { + static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "axl-e2ee-{label}-{}-{nonce}-{}", + std::process::id(), + NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + path +} + +struct TestKeyRecord { + crypto_session_id: [u8; 16], + data_key: [u8; 32], + context: Vec, + active: bool, +} + +type TestKeySnapshot = ([u8; 16], [u8; 16], [u8; 32], Vec, bool); + +#[derive(Default)] +struct TestKeys { + keys: Mutex>, + available: Mutex, + destroy_calls: AtomicU64, +} + +impl TestKeys { + fn enabled() -> Arc { + Arc::new(Self { + keys: Mutex::new(BTreeMap::new()), + available: Mutex::new(true), + destroy_calls: AtomicU64::new(0), + }) + } + + fn data_keys(&self) -> Vec<[u8; 32]> { + self.keys + .lock() + .unwrap() + .values() + .map(|record| record.data_key) + .collect() + } + + fn activity_counts(&self) -> (usize, usize) { + let keys = self.keys.lock().unwrap(); + let active = keys.values().filter(|record| record.active).count(); + (active, keys.len() - active) + } + + fn snapshot(&self) -> Vec { + self.keys + .lock() + .unwrap() + .iter() + .map(|(key_id, record)| { + ( + *key_id, + record.crypto_session_id, + record.data_key, + record.context.clone(), + record.active, + ) + }) + .collect() + } + + fn destroy_calls(&self) -> u64 { + self.destroy_calls.load(Ordering::SeqCst) + } +} + +impl EnvelopeKeyStore for TestKeys { + fn available(&self) -> bool { + *self.available.lock().unwrap() + } + + fn prepare( + &self, + crypto_session_id: [u8; 16], + key_id: [u8; 16], + data_key: &[u8; 32], + context: &[u8], + ) -> Result<(), PersistenceError> { + let mut keys = self.keys.lock().unwrap(); + if keys + .insert( + key_id, + TestKeyRecord { + crypto_session_id, + data_key: *data_key, + context: context.to_vec(), + active: false, + }, + ) + .is_some() + { + return Err(PersistenceError::Conflict); + } + Ok(()) + } + + fn load( + &self, + crypto_session_id: [u8; 16], + key_id: [u8; 16], + context: &[u8], + ) -> Result<[u8; 32], PersistenceError> { + let keys = self.keys.lock().unwrap(); + let record = keys.get(&key_id).ok_or(PersistenceError::KeyUnavailable)?; + if record.crypto_session_id != crypto_session_id || record.context != context { + return Err(PersistenceError::IdentityMismatch); + } + if !record.active { + return Err(PersistenceError::KeyUnavailable); + } + Ok(record.data_key) + } + + fn activate( + &self, + crypto_session_id: [u8; 16], + key_id: [u8; 16], + context: &[u8], + ) -> Result<(), PersistenceError> { + let mut keys = self.keys.lock().unwrap(); + let record = keys + .get_mut(&key_id) + .ok_or(PersistenceError::KeyUnavailable)?; + if record.crypto_session_id != crypto_session_id || record.context != context { + return Err(PersistenceError::IdentityMismatch); + } + record.active = true; + Ok(()) + } + + fn reconcile_prepared( + &self, + crypto_session_id: [u8; 16], + committed_current: Option<([u8; 16], Vec)>, + ) -> Result<(), PersistenceError> { + let mut keys = self.keys.lock().unwrap(); + if let Some((key_id, context)) = committed_current { + let record = keys + .get_mut(&key_id) + .ok_or(PersistenceError::KeyUnavailable)?; + if record.crypto_session_id != crypto_session_id || record.context != context { + return Err(PersistenceError::IdentityMismatch); + } + record.active = true; + } + keys.retain(|_, record| record.crypto_session_id != crypto_session_id || record.active); + Ok(()) + } + + fn erase(&self, crypto_session_id: [u8; 16], key_id: [u8; 16]) -> Result<(), PersistenceError> { + let mut keys = self.keys.lock().unwrap(); + if let Some(record) = keys.get(&key_id) + && record.crypto_session_id != crypto_session_id + { + return Err(PersistenceError::IdentityMismatch); + } + keys.remove(&key_id); + Ok(()) + } + + fn destroy_session(&self, crypto_session_id: [u8; 16]) -> Result<(), PersistenceError> { + self.destroy_calls.fetch_add(1, Ordering::SeqCst); + self.keys + .lock() + .unwrap() + .retain(|_, record| record.crypto_session_id != crypto_session_id); + Ok(()) + } +} + +struct TestAnchor { + state: Mutex, + available: Mutex, +} + +impl TestAnchor { + fn new() -> Arc { + Arc::new(Self { + state: Mutex::new(RollbackState { + counter: 0, + epoch: 0, + epoch_authenticator: Vec::new(), + }), + available: Mutex::new(true), + }) + } +} + +impl RollbackAnchor for TestAnchor { + fn available(&self) -> bool { + *self.available.lock().unwrap() + } + + fn read(&self, _session: [u8; 16]) -> Result { + if !self.available() { + return Err(PersistenceError::AnchorUnavailable); + } + Ok(self.state.lock().unwrap().clone()) + } + + fn advance( + &self, + _session: [u8; 16], + expected: &RollbackState, + next: &RollbackState, + _operation_id: [u8; 16], + ) -> Result<(), PersistenceError> { + if !self.available() { + return Err(PersistenceError::AnchorUnavailable); + } + let mut state = self.state.lock().unwrap(); + if *state != *expected || next.counter != expected.counter + 1 { + return Err(PersistenceError::Quarantined); + } + *state = next.clone(); + Ok(()) + } +} + +#[derive(Default)] +struct OneShotFault { + point: Mutex>, + block: Mutex>, + changed: Condvar, +} + +impl OneShotFault { + fn new() -> Arc { + Arc::new(Self::default()) + } + + fn arm(&self, point: FaultPoint) { + *self.point.lock().unwrap() = Some(point); + } + + fn block_at(&self, point: FaultPoint) { + *self.block.lock().unwrap() = Some((point, false, false)); + } + + fn wait_until_blocked(&self) { + let mut state = self.block.lock().unwrap(); + while state.as_ref().is_some_and(|(_, reached, _)| !reached) { + state = self.changed.wait(state).unwrap(); + } + } + + fn release(&self) { + let mut state = self.block.lock().unwrap(); + if let Some((_, _, released)) = state.as_mut() { + *released = true; + } + self.changed.notify_all(); + } +} + +impl FaultInjector for OneShotFault { + fn check(&self, point: FaultPoint) -> Result<(), PersistenceError> { + { + let mut armed = self.point.lock().unwrap(); + if *armed == Some(point) { + *armed = None; + return Err(PersistenceError::InjectedFault); + } + } + let mut block = self.block.lock().unwrap(); + if block + .as_ref() + .is_some_and(|(blocked, _, _)| *blocked == point) + { + if let Some((_, reached, _)) = block.as_mut() { + *reached = true; + } + self.changed.notify_all(); + while block.as_ref().is_some_and(|(_, _, released)| !released) { + block = self.changed.wait(block).unwrap(); + } + *block = None; + } + Ok(()) + } +} + +struct DurablePair { + daemon: DurableDaemon, + phone: DurablePhone, + phone_keys: Arc, + daemon_anchor: Arc, + phone_anchor: Arc, + daemon_faults: Arc, + phone_faults: Arc, + clock: Arc, +} + +fn durable_pair(seed: u8) -> DurablePair { + let context = context(seed); + let daemon_root = temp_root("daemon"); + let phone_root = temp_root("phone"); + let daemon_keys = TestKeys::enabled(); + let phone_keys = TestKeys::enabled(); + let daemon_anchor = TestAnchor::new(); + let phone_anchor = TestAnchor::new(); + let daemon_faults = OneShotFault::new(); + let phone_faults = OneShotFault::new(); + let clock = ManualClock::new(1_000_000); + let daemon_identity = Identity::daemon(context.account_id, context.installation_id); + let phone_identity = Identity::device( + context.account_id, + context.installation_id, + context.device_id, + ) + .unwrap(); + let mut daemon = DurableDaemon::create_with_runtime( + &daemon_root, + daemon_identity, + context.clone(), + id(100), + daemon_keys.clone(), + daemon_anchor.clone(), + RuntimeHooks { + faults: daemon_faults.clone(), + clock: clock.clone(), + }, + ) + .unwrap(); + let (mut phone, package) = DurablePhone::create_with_runtime( + &phone_root, + phone_identity, + context.crypto_session_id, + id(101), + phone_keys.clone(), + phone_anchor.clone(), + RuntimeHooks { + faults: phone_faults.clone(), + clock: clock.clone(), + }, + ) + .unwrap(); + let welcome = daemon.consume_key_package(id(102), package).unwrap(); + phone.join(id(103), welcome, &context).unwrap(); + DurablePair { + daemon, + phone, + phone_keys, + daemon_anchor, + phone_anchor, + daemon_faults, + phone_faults, + clock, + } +} + +#[test] +fn durable_send_receive_reopens_and_retries_exact_bytes() { + let mut pair = durable_pair(20); + let sent = pair + .phone + .prepare_application(id(1), id(2), 7, b"persist me") + .unwrap(); + let exact = sent.ciphertext.clone(); + + pair.phone.store().close().unwrap(); + pair.phone.store().reopen().unwrap(); + let retry = pair + .phone + .prepare_application(id(1), id(2), 7, b"persist me") + .unwrap(); + assert_eq!(retry.ciphertext, exact); + + let received = pair + .daemon + .receive_application(id(3), &exact, id(2), 7) + .unwrap(); + assert_eq!(received.plaintext(), b"persist me"); + + pair.daemon.store().close().unwrap(); + pair.daemon.store().reopen().unwrap(); + let recovered = pair + .daemon + .receive_application(id(3), &exact, id(2), 7) + .unwrap(); + assert_eq!(recovered.plaintext(), b"persist me"); +} + +#[test] +fn outbox_acknowledgement_is_durable_and_idempotent() { + let mut pair = durable_pair(29); + let sent = pair + .phone + .prepare_application(id(52), id(53), 2, b"acknowledge") + .unwrap(); + assert_eq!(sent.retry_state, crate::persistence::RetryState::Pending); + let acknowledged = pair.phone.acknowledge_outbox(id(54), id(52)).unwrap(); + assert_eq!( + acknowledged.retry_state, + crate::persistence::RetryState::Acknowledged + ); + pair.phone.store().close().unwrap(); + pair.phone.store().reopen().unwrap(); + let retry = pair.phone.acknowledge_outbox(id(54), id(52)).unwrap(); + assert_eq!(retry, acknowledged); + let send_retry = pair + .phone + .prepare_application(id(52), id(53), 2, b"acknowledge") + .unwrap(); + assert_eq!( + send_retry.retry_state, + crate::persistence::RetryState::Acknowledged + ); + assert_eq!(send_retry.ciphertext, sent.ciphertext); + assert_eq!( + pair.phone + .store() + .outbox(id(52)) + .unwrap() + .unwrap() + .retry_state, + crate::persistence::RetryState::Acknowledged + ); +} + +#[test] +fn durable_update_commit_reloads_epoch_and_authenticator() { + let mut pair = durable_pair(30); + let proposal = pair.phone.prepare_self_update(id(60), id(61), 9).unwrap(); + pair.daemon + .receive_update_proposal(id(62), &proposal.ciphertext, id(61), 9) + .unwrap(); + let commit = pair.daemon.prepare_commit(id(63), id(64), 9).unwrap(); + let metadata = commit.commit.as_ref().unwrap().clone(); + pair.phone + .apply_commit(id(65), &commit.ciphertext, id(64), 9) + .unwrap(); + pair.daemon.store().close().unwrap(); + pair.daemon.store().reopen().unwrap(); + pair.phone.store().close().unwrap(); + pair.phone.store().reopen().unwrap(); + assert_eq!( + pair.daemon.store().outbox(id(63)).unwrap().unwrap().commit, + Some(metadata) + ); + let post_commit = pair + .phone + .prepare_application(id(66), id(67), 9, b"new epoch") + .unwrap(); + assert_eq!(post_commit.epoch, commit.commit.unwrap().target_epoch); +} + +#[test] +fn previous_epoch_window_survives_restart_and_rejects_clock_rollback() { + let mut pair = durable_pair(31); + let delayed = pair + .phone + .prepare_application(id(80), id(81), 3, b"previous epoch") + .unwrap(); + let proposal = pair.phone.prepare_self_update(id(82), id(83), 3).unwrap(); + pair.daemon + .receive_update_proposal(id(84), &proposal.ciphertext, id(83), 3) + .unwrap(); + let commit = pair.daemon.prepare_commit(id(85), id(86), 3).unwrap(); + pair.phone + .apply_commit(id(87), &commit.ciphertext, id(86), 3) + .unwrap(); + pair.daemon.store().close().unwrap(); + pair.daemon.store().reopen().unwrap(); + assert_eq!( + pair.daemon + .receive_application(id(88), &delayed.ciphertext, id(81), 3) + .unwrap() + .plaintext(), + b"previous epoch" + ); + + let prior = pair.clock.now_ms().unwrap(); + pair.clock.set(prior - 1); + pair.daemon.store().close().unwrap(); + pair.daemon.store().reopen().unwrap(); + assert_eq!( + pair.daemon + .prepare_application(id(89), id(90), 3, b"clock rollback") + .unwrap_err(), + PersistenceError::Core(Error::ClockRollback) + ); +} + +#[test] +fn previous_epoch_window_expires_from_the_persisted_deadline() { + let mut pair = durable_pair(32); + let delayed = pair + .phone + .prepare_application(id(90), id(91), 3, b"expired epoch") + .unwrap(); + let proposal = pair.phone.prepare_self_update(id(92), id(93), 3).unwrap(); + pair.daemon + .receive_update_proposal(id(94), &proposal.ciphertext, id(93), 3) + .unwrap(); + let commit = pair.daemon.prepare_commit(id(95), id(96), 3).unwrap(); + pair.phone + .apply_commit(id(97), &commit.ciphertext, id(96), 3) + .unwrap(); + pair.clock.advance(5 * 60 * 1000 + 1); + pair.daemon.store().close().unwrap(); + pair.daemon.store().reopen().unwrap(); + assert_eq!( + pair.daemon + .receive_application(id(98), &delayed.ciphertext, id(91), 3) + .unwrap_err(), + PersistenceError::Core(Error::StaleEpoch) + ); +} + +#[test] +fn acknowledged_idempotency_records_are_compacted_beyond_the_retry_horizon() { + let mut pair = durable_pair(33); + let first_operation = sequence_id(1, 0); + for index in 0..(crate::persistence::IDEMPOTENCY_RETENTION_GENERATIONS + 8) { + let operation_id = sequence_id(1, index); + let logical_id = sequence_id(2, index); + pair.phone + .prepare_application(operation_id, logical_id, 3, b"bounded history") + .unwrap(); + pair.phone + .acknowledge_outbox(sequence_id(3, index), operation_id) + .unwrap(); + } + assert!( + pair.phone + .store() + .operation(first_operation) + .unwrap() + .is_none() + ); + pair.phone + .prepare_application(sequence_id(4, 1), sequence_id(5, 1), 3, b"still usable") + .unwrap(); +} + +#[test] +fn acknowledged_receive_identities_are_compacted_beyond_the_retry_horizon() { + let mut pair = durable_pair(34); + let first_receive = sequence_id(12, 0); + for index in 0..(crate::persistence::IDEMPOTENCY_RETENTION_GENERATIONS + 4) { + let send_id = sequence_id(10, index); + let logical_id = sequence_id(11, index); + let receive_id = sequence_id(12, index); + let sent = pair + .phone + .prepare_application(send_id, logical_id, 3, b"bounded accepted history") + .unwrap(); + pair.daemon + .receive_application(receive_id, &sent.ciphertext, logical_id, 3) + .unwrap(); + pair.daemon + .acknowledge_receive(sequence_id(13, index), receive_id) + .unwrap(); + pair.phone + .acknowledge_outbox(sequence_id(14, index), send_id) + .unwrap(); + } + assert!( + pair.daemon + .store() + .operation(first_receive) + .unwrap() + .is_none() + ); + let sent = pair + .phone + .prepare_application( + sequence_id(15, 1), + sequence_id(16, 1), + 3, + b"still receiving", + ) + .unwrap(); + assert_eq!( + pair.daemon + .receive_application(sequence_id(17, 1), &sent.ciphertext, sequence_id(16, 1), 3) + .unwrap() + .plaintext(), + b"still receiving" + ); +} + +#[test] +fn precommit_faults_leave_complete_old_state_and_retry_once() { + for (index, point) in [ + FaultPoint::DuringPreparedKeyReconciliation, + FaultPoint::BeforeOpenMlsStateWrites, + FaultPoint::DuringOpenMlsProviderWrites, + FaultPoint::BeforeCiphertextInsertion, + FaultPoint::AfterCiphertextInsertion, + FaultPoint::BeforeCommit, + ] + .into_iter() + .enumerate() + { + let mut pair = durable_pair(40 + index as u8); + let generation = pair.phone.store().generation().unwrap(); + pair.phone_faults.arm(point); + assert_eq!( + pair.phone + .prepare_application(id(10), id(11), 1, b"atomic") + .unwrap_err(), + PersistenceError::InjectedFault + ); + pair.phone.store().close().unwrap(); + pair.phone.store().reopen().unwrap(); + assert_eq!(pair.phone.store().generation().unwrap(), generation); + let sent = pair + .phone + .prepare_application(id(10), id(11), 1, b"atomic") + .unwrap(); + assert!(!sent.ciphertext.is_empty()); + } +} + +#[test] +fn uncertain_recovery_activates_current_key_before_erasing_obsolete_key() { + let mut pair = durable_pair(58); + assert_eq!(pair.phone_keys.activity_counts(), (1, 0)); + pair.phone_faults + .arm(FaultPoint::DuringCurrentKeyActivation); + assert_eq!( + pair.phone + .prepare_application(id(18), id(19), 1, b"activation order") + .unwrap_err(), + PersistenceError::InjectedFault + ); + assert_eq!(pair.phone_keys.activity_counts(), (1, 1)); + + pair.phone_faults + .arm(FaultPoint::DuringPreparedKeyReconciliation); + assert_eq!( + pair.phone + .prepare_application(id(18), id(19), 1, b"activation order") + .unwrap_err(), + PersistenceError::InjectedFault + ); + assert_eq!(pair.phone_keys.activity_counts(), (1, 1)); + + let recovered = pair + .phone + .prepare_application(id(18), id(19), 1, b"activation order") + .unwrap(); + assert!(!recovered.ciphertext.is_empty()); + assert_eq!(pair.phone_keys.activity_counts(), (1, 0)); +} + +#[test] +fn postcommit_faults_recover_the_committed_exact_result() { + for (index, point) in [ + FaultPoint::DuringCurrentKeyActivation, + FaultPoint::AfterDurableCommit, + FaultPoint::BeforeAnchorRecovery, + FaultPoint::AfterAnchorRecoveryBeforeErasure, + FaultPoint::AfterCommitBeforeNetworkSend, + FaultPoint::DuringWrappingRecordReplacement, + FaultPoint::DuringWrappingRecordErasure, + ] + .into_iter() + .enumerate() + { + let mut pair = durable_pair(60 + index as u8); + pair.phone_faults.arm(point); + let first = pair + .phone + .prepare_application(id(20), id(21), 1, b"uncertain"); + let retry = pair + .phone + .prepare_application(id(20), id(21), 1, b"uncertain") + .unwrap(); + if let Ok(first) = first { + assert_eq!(first.ciphertext, retry.ciphertext); + } + assert_eq!( + pair.phone + .store() + .outbox(id(20)) + .unwrap() + .unwrap() + .ciphertext, + retry.ciphertext + ); + } +} + +#[test] +fn duplicate_ids_conflicts_and_receive_faults_fail_closed() { + let mut pair = durable_pair(80); + let sent = pair + .phone + .prepare_application(id(30), id(31), 4, b"one") + .unwrap(); + assert_eq!( + pair.phone + .prepare_application(id(30), id(31), 4, b"different") + .unwrap_err(), + PersistenceError::Conflict + ); + + pair.daemon_faults + .arm(FaultPoint::DuringReceiverStateWrites); + assert_eq!( + pair.daemon + .receive_application(id(32), &sent.ciphertext, id(31), 4) + .unwrap_err(), + PersistenceError::InjectedFault + ); + pair.daemon.store().close().unwrap(); + pair.daemon.store().reopen().unwrap(); + let plaintext = pair + .daemon + .receive_application(id(32), &sent.ciphertext, id(31), 4) + .unwrap(); + assert_eq!(plaintext.plaintext(), b"one"); + + pair.daemon_faults.arm(FaultPoint::DuringDuplicateOperation); + assert_eq!( + pair.daemon + .receive_application(id(32), &sent.ciphertext, id(31), 4) + .unwrap_err(), + PersistenceError::InjectedFault + ); +} + +#[test] +fn restart_generation_and_acknowledgement_faults_recover_deterministically() { + let mut pair = durable_pair(85); + let sent = pair + .phone + .prepare_application(id(40), id(41), 5, b"ack recovery") + .unwrap(); + let generation = pair.daemon.store().generation().unwrap(); + let rollback = pair.daemon.store().rollback_counter().unwrap(); + pair.daemon_faults.arm(FaultPoint::DuringGenerationConflict); + assert!(matches!( + pair.daemon.store().begin_transaction( + pair.daemon.store().crypto_session_id(), + generation.saturating_sub(1), + rollback, + ), + Err(PersistenceError::InjectedFault) + )); + assert!(matches!( + pair.daemon.store().begin_transaction( + pair.daemon.store().crypto_session_id(), + generation.saturating_sub(1), + rollback, + ), + Err(PersistenceError::GenerationConflict) + )); + + pair.daemon_faults + .arm(FaultPoint::BeforeReceiverAcknowledgement); + assert_eq!( + pair.daemon + .receive_application(id(42), &sent.ciphertext, id(41), 5) + .unwrap_err(), + PersistenceError::InjectedFault + ); + let recovered = pair + .daemon + .receive_application(id(42), &sent.ciphertext, id(41), 5) + .unwrap(); + assert_eq!(recovered.plaintext(), b"ack recovery"); + + pair.daemon_faults.arm(FaultPoint::AfterAcknowledgementLoss); + assert_eq!( + pair.daemon.acknowledge_receive(id(43), id(42)).unwrap_err(), + PersistenceError::InjectedFault + ); + let acknowledgement = pair.daemon.acknowledge_receive(id(43), id(42)).unwrap(); + assert!(acknowledgement.acknowledged); + assert_eq!( + pair.daemon + .receive_application(id(42), &sent.ciphertext, id(41), 5) + .unwrap_err(), + PersistenceError::AlreadyAcknowledged + ); + + pair.daemon_faults.arm(FaultPoint::DuringRestartReload); + pair.daemon.store().close().unwrap(); + assert_eq!( + pair.daemon.store().reopen().unwrap_err(), + PersistenceError::InjectedFault + ); + pair.daemon.store().reopen().unwrap(); +} + +#[test] +fn database_contains_neither_plaintext_nor_data_encryption_keys() { + let mut pair = durable_pair(88); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(pair.phone.store().path()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(pair.phone.store().path().parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + } + let marker = b"plaintext-must-not-appear-in-redb"; + pair.phone + .prepare_application(id(50), id(51), 6, marker) + .unwrap(); + pair.phone.store().close().unwrap(); + let bytes = fs::read(pair.phone.store().path()).unwrap(); + assert!(!bytes.windows(marker.len()).any(|window| window == marker)); + let data_keys = pair.phone_keys.data_keys(); + assert_eq!( + data_keys.len(), + 1, + "obsolete wrapping records must be erased" + ); + for key in data_keys { + assert!(!bytes.windows(key.len()).any(|window| window == key)); + } + + // The deterministic test key store exposes DEKs only for this negative file scan. Production + // key implementations remain outside this crate and never expose wrapping keys. + pair.phone.store().reopen().unwrap(); +} + +#[test] +fn tampered_durable_metadata_is_quarantined() { + const OUTBOX: TableDefinition<&[u8], &[u8]> = TableDefinition::new("outbox_v1"); + let mut pair = durable_pair(86); + pair.phone + .prepare_application(id(110), id(111), 2, b"authenticated metadata") + .unwrap(); + pair.phone.store().close().unwrap(); + let database = Database::open(pair.phone.store().path()).unwrap(); + let mut write = database.begin_write().unwrap(); + write.set_durability(Durability::Immediate).unwrap(); + write.set_two_phase_commit(true); + { + let mut table = write.open_table(OUTBOX).unwrap(); + let mut bytes = table + .get(id(110).as_slice()) + .unwrap() + .unwrap() + .value() + .to_vec(); + bytes[50] ^= 1; + table.insert(id(110).as_slice(), bytes.as_slice()).unwrap(); + } + write.commit().unwrap(); + drop(database); + assert_eq!( + pair.phone.store().reopen().unwrap_err(), + PersistenceError::Quarantined + ); +} + +#[test] +fn tampered_operation_and_accepted_records_are_quarantined() { + let mut operation_pair = durable_pair(85); + operation_pair + .phone + .prepare_application(id(112), id(113), 2, b"operation manifest") + .unwrap(); + operation_pair.phone.store().close().unwrap(); + tamper_record( + operation_pair.phone.store().path(), + "operations_v1", + id(112), + ); + assert_eq!( + operation_pair.phone.store().reopen().unwrap_err(), + PersistenceError::Quarantined + ); + + let mut accepted_pair = durable_pair(84); + let sent = accepted_pair + .phone + .prepare_application(id(114), id(115), 2, b"accepted manifest") + .unwrap(); + accepted_pair + .daemon + .receive_application(id(116), &sent.ciphertext, id(115), 2) + .unwrap(); + accepted_pair.daemon.store().close().unwrap(); + tamper_record( + accepted_pair.daemon.store().path(), + "accepted_messages_v1", + id(116), + ); + assert_eq!( + accepted_pair.daemon.store().reopen().unwrap_err(), + PersistenceError::Quarantined + ); +} + +#[test] +fn unsupported_schema_identity_mismatch_and_rollback_quarantine() { + const META: TableDefinition = TableDefinition::new("metadata_v1"); + + let pair = durable_pair(87); + pair.phone.store().close().unwrap(); + let wrong_session = id(199); + let wrong_name = format!( + "{}.redb", + wrong_session + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + let wrong_path = pair.phone.store().path().parent().unwrap().join(wrong_name); + fs::copy(pair.phone.store().path(), &wrong_path).unwrap(); + assert!(matches!( + NativeTransactionalProvider::open( + wrong_path.parent().unwrap(), + wrong_session, + Arc::clone(&pair.phone_keys) as Arc, + TestAnchor::new(), + Arc::new(NoFaults), + Arc::new(SystemClock), + ), + Err(PersistenceError::IdentityMismatch) + )); + + let pair = durable_pair(89); + pair.phone.store().close().unwrap(); + let database = Database::open(pair.phone.store().path()).unwrap(); + let mut write = database.begin_write().unwrap(); + write.set_durability(Durability::Immediate).unwrap(); + write.set_two_phase_commit(true); + { + let mut meta = write.open_table(META).unwrap(); + meta.insert(1, 99_u16.to_be_bytes().as_slice()).unwrap(); + } + write.commit().unwrap(); + drop(database); + assert_eq!( + pair.phone.store().reopen().unwrap_err(), + PersistenceError::UnsupportedSchema + ); + + let pair = durable_pair(90); + pair.daemon.store().close().unwrap(); + *pair.daemon_anchor.state.lock().unwrap() = RollbackState { + counter: 0, + epoch: 0, + epoch_authenticator: Vec::new(), + }; + assert_eq!( + pair.daemon.store().reopen().unwrap_err(), + PersistenceError::Quarantined + ); +} + +#[test] +fn ready_commit_fault_recovers_without_losing_database_or_external_state() { + let root = temp_root("ready-marker-crash"); + let pair_context = context(140); + let operation_id = id(150); + let keys = TestKeys::enabled(); + let anchor = TestAnchor::new(); + let faults = OneShotFault::new(); + let clock = ManualClock::new(1_000_000); + faults.arm(FaultPoint::AfterInitializationReadyCommit); + + assert!(matches!( + DurableDaemon::create_with_runtime( + &root, + Identity::daemon(pair_context.account_id, pair_context.installation_id), + pair_context.clone(), + operation_id, + keys.clone(), + anchor.clone(), + RuntimeHooks { + faults: faults.clone(), + clock: clock.clone(), + }, + ), + Err(PersistenceError::InjectedFault) + )); + + let database = database_path(&root, pair_context.crypto_session_id); + let marker = marker_path(&root, pair_context.crypto_session_id); + let database_bytes = fs::read(&database).unwrap(); + assert!(!database_bytes.is_empty()); + let key_records = keys.snapshot(); + let rollback_anchor = anchor.state.lock().unwrap().clone(); + assert!(marker.is_file()); + assert_eq!(keys.activity_counts(), (1, 0)); + assert_eq!(keys.destroy_calls(), 0); + + assert_eq!( + discard_interrupted_creation(&root, pair_context.crypto_session_id, keys.clone()) + .unwrap_err(), + PersistenceError::AlreadyExists + ); + assert!(database.is_file()); + assert!(!fs::read(&database).unwrap().is_empty()); + assert_eq!(keys.snapshot(), key_records); + assert_eq!(*anchor.state.lock().unwrap(), rollback_anchor); + assert_eq!(keys.destroy_calls(), 0); + + let opened = DurableDaemon::open_with_runtime( + &root, + pair_context.crypto_session_id, + keys.clone(), + anchor.clone(), + RuntimeHooks { faults, clock }, + ) + .unwrap(); + assert!(!marker.exists()); + assert!(database.is_file()); + assert!(!fs::read(&database).unwrap().is_empty()); + assert_eq!(keys.snapshot(), key_records); + assert_eq!(*anchor.state.lock().unwrap(), rollback_anchor); + let exact_operation = opened.store().operation(operation_id).unwrap().unwrap(); + let CommittedOperation::Accepted(initialized) = &exact_operation else { + panic!("creation operation must remain the exact accepted result"); + }; + assert_eq!(initialized.operation_id, operation_id); + assert_eq!( + initialized.crypto_session_id, + pair_context.crypto_session_id + ); + assert_eq!(initialized.logical_message_id, operation_id); + assert_eq!(initialized.epoch, rollback_anchor.epoch); + assert_eq!( + opened.store().rollback_counter().unwrap(), + rollback_anchor.counter + ); + assert_eq!(opened.store().rollback_state().unwrap(), rollback_anchor); + assert_eq!(rollback_anchor.epoch_authenticator.len(), 48); + + opened.store().close().unwrap(); + let reopened = DurableDaemon::open( + &root, + pair_context.crypto_session_id, + keys.clone(), + anchor.clone(), + ) + .unwrap(); + assert_eq!( + reopened.store().operation(operation_id).unwrap(), + Some(exact_operation) + ); + assert!(database.is_file()); + assert!(!fs::read(&database).unwrap().is_empty()); + assert_eq!(keys.snapshot(), key_records); + assert_eq!(*anchor.state.lock().unwrap(), rollback_anchor); +} + +#[test] +fn injected_marker_cannot_authorize_ready_database_cleanup() { + let pair = durable_pair(141); + pair.phone.store().close().unwrap(); + let database = pair.phone.store().path().to_path_buf(); + let marker = database.with_extension("redb.initializing"); + fs::write(&marker, []).unwrap(); + let database_bytes = fs::read(&database).unwrap(); + assert!(!database_bytes.is_empty()); + let key_records = pair.phone_keys.snapshot(); + + for _ in 0..2 { + assert_eq!( + discard_interrupted_creation( + database.parent().unwrap(), + pair.phone.store().crypto_session_id(), + pair.phone_keys.clone(), + ) + .unwrap_err(), + PersistenceError::AlreadyExists + ); + assert!(database.is_file()); + assert!(!fs::read(&database).unwrap().is_empty()); + assert_eq!(pair.phone_keys.snapshot(), key_records); + assert_eq!(pair.phone_keys.destroy_calls(), 0); + } + + let opened = DurablePhone::open( + database.parent().unwrap(), + pair.phone.store().crypto_session_id(), + pair.phone_keys.clone(), + pair.phone_anchor.clone(), + ) + .unwrap(); + assert!(!marker.exists()); + assert!(database.is_file()); + assert!(!fs::read(&database).unwrap().is_empty()); + assert_eq!(pair.phone_keys.snapshot(), key_records); + opened.store().close().unwrap(); + DurablePhone::open( + database.parent().unwrap(), + pair.phone.store().crypto_session_id(), + pair.phone_keys.clone(), + pair.phone_anchor.clone(), + ) + .unwrap(); +} + +#[test] +fn committed_prejoin_phone_initialization_recovers_exact_key_package() { + const META: TableDefinition = TableDefinition::new("metadata_v1"); + const OPERATIONS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("operations_v1"); + const OUTBOX: TableDefinition<&[u8], &[u8]> = TableDefinition::new("outbox_v1"); + const META_LIFECYCLE: u8 = 10; + const META_EPOCH: u8 = 7; + const META_AUTHENTICATOR: u8 = 8; + + let pair_context = context(159); + let session = pair_context.crypto_session_id; + let operation_id = id(160); + let root = temp_root("prejoin-phone-recovery"); + let keys = TestKeys::enabled(); + let anchor = TestAnchor::new(); + let faults = OneShotFault::new(); + faults.arm(FaultPoint::BeforeInitializationReady); + assert!(matches!( + DurablePhone::create_with_runtime( + &root, + Identity::device( + pair_context.account_id, + pair_context.installation_id, + pair_context.device_id, + ) + .unwrap(), + session, + operation_id, + keys.clone(), + anchor.clone(), + RuntimeHooks { + faults, + clock: ManualClock::new(1_000_000), + }, + ), + Err(PersistenceError::InjectedFault) + )); + + let database_path = database_path(&root, session); + let marker = marker_path(&root, session); + let database = Database::open(&database_path).unwrap(); + let read = database.begin_read().unwrap(); + let meta = read.open_table(META).unwrap(); + assert_eq!(meta.get(META_LIFECYCLE).unwrap().unwrap().value(), &[1]); + assert_eq!( + meta.get(META_EPOCH).unwrap().unwrap().value(), + 0_u64.to_be_bytes() + ); + assert!( + meta.get(META_AUTHENTICATOR) + .unwrap() + .unwrap() + .value() + .is_empty() + ); + drop(meta); + let operation_bytes = read + .open_table(OPERATIONS) + .unwrap() + .get(operation_id.as_slice()) + .unwrap() + .unwrap() + .value() + .to_vec(); + let outbox_bytes = read + .open_table(OUTBOX) + .unwrap() + .get(operation_id.as_slice()) + .unwrap() + .unwrap() + .value() + .to_vec(); + drop(read); + drop(database); + assert!(!operation_bytes.is_empty()); + assert!(!outbox_bytes.is_empty()); + + assert_eq!( + discard_interrupted_creation(&root, session, keys.clone()).unwrap_err(), + PersistenceError::InitializationIncomplete + ); + assert_eq!(keys.destroy_calls(), 0); + assert!(database_path.is_file()); + assert!(marker.is_file()); + + let opened = DurablePhone::open(&root, session, keys.clone(), anchor.clone()).unwrap(); + let outbox = opened.store().outbox(operation_id).unwrap().unwrap(); + let operation = opened.store().operation(operation_id).unwrap().unwrap(); + assert_eq!(operation, CommittedOperation::Envelope(outbox.clone())); + assert!(!outbox.ciphertext.is_empty()); + assert_eq!(outbox.class, crate::MessageClass::PairActivation); + assert_eq!(outbox.epoch, 0); + assert_eq!( + opened.store().rollback_state().unwrap(), + RollbackState { + counter: 1, + epoch: 0, + epoch_authenticator: Vec::new(), + } + ); + assert!(!marker.exists()); + assert_eq!(keys.destroy_calls(), 0); + opened.store().close().unwrap(); + drop(opened); + + let database = Database::open(&database_path).unwrap(); + let read = database.begin_read().unwrap(); + assert_eq!( + read.open_table(OPERATIONS) + .unwrap() + .get(operation_id.as_slice()) + .unwrap() + .unwrap() + .value(), + operation_bytes + ); + assert_eq!( + read.open_table(OUTBOX) + .unwrap() + .get(operation_id.as_slice()) + .unwrap() + .unwrap() + .value(), + outbox_bytes + ); + drop(read); + drop(database); + + let reopened = DurablePhone::open(&root, session, keys, anchor).unwrap(); + assert_eq!(reopened.store().outbox(operation_id).unwrap(), Some(outbox)); + assert_eq!( + reopened.store().rollback_state().unwrap(), + RollbackState { + counter: 1, + epoch: 0, + epoch_authenticator: Vec::new(), + } + ); + reopened.store().close().unwrap(); +} + +#[test] +fn malformed_prejoin_phone_state_fails_authenticated_recovery() { + const STATE: TableDefinition = TableDefinition::new("encrypted_state_v1"); + const STATE_CURRENT: u8 = 1; + + let pair_context = context(161); + let session = pair_context.crypto_session_id; + let root = temp_root("malformed-prejoin-phone"); + let keys = TestKeys::enabled(); + let anchor = TestAnchor::new(); + let faults = OneShotFault::new(); + faults.arm(FaultPoint::BeforeInitializationReady); + assert!(matches!( + DurablePhone::create_with_runtime( + &root, + Identity::device( + pair_context.account_id, + pair_context.installation_id, + pair_context.device_id, + ) + .unwrap(), + session, + id(162), + keys.clone(), + anchor.clone(), + RuntimeHooks { + faults, + clock: ManualClock::new(1_000_000), + }, + ), + Err(PersistenceError::InjectedFault) + )); + + let database_path = database_path(&root, session); + let database = Database::open(&database_path).unwrap(); + let mut write = database.begin_write().unwrap(); + write.set_durability(Durability::Immediate).unwrap(); + write.set_two_phase_commit(true); + let mut state = write.open_table(STATE).unwrap(); + let mut sealed = state.get(STATE_CURRENT).unwrap().unwrap().value().to_vec(); + let last = sealed.last_mut().unwrap(); + *last ^= 0x01; + state.insert(STATE_CURRENT, sealed.as_slice()).unwrap(); + drop(state); + write.commit().unwrap(); + drop(database); + + assert_eq!( + discard_interrupted_creation(&root, session, keys.clone()).unwrap_err(), + PersistenceError::InitializationIncomplete + ); + assert_eq!(keys.destroy_calls(), 0); + assert!(matches!( + DurablePhone::open(&root, session, keys.clone(), anchor), + Err(PersistenceError::Corrupt) | Err(PersistenceError::Quarantined) + )); + assert_eq!(keys.destroy_calls(), 0); + assert!(database_path.is_file()); + assert!(marker_path(&root, session).is_file()); +} + +#[test] +fn altered_ready_lifecycle_cannot_authorize_destructive_cleanup() { + const META: TableDefinition = TableDefinition::new("metadata_v1"); + const META_LIFECYCLE: u8 = 10; + const LIFECYCLE_INITIALIZING: u8 = 1; + + let pair = durable_pair(149); + let root = pair.phone.store().path().parent().unwrap().to_path_buf(); + let session = pair.phone.store().crypto_session_id(); + let database_path = pair.phone.store().path().to_path_buf(); + let marker = marker_path(&root, session); + pair.phone.store().close().unwrap(); + + let database = Database::open(&database_path).unwrap(); + let mut write = database.begin_write().unwrap(); + write.set_durability(Durability::Immediate).unwrap(); + write.set_two_phase_commit(true); + write + .open_table(META) + .unwrap() + .insert(META_LIFECYCLE, &[LIFECYCLE_INITIALIZING] as &[u8]) + .unwrap(); + write.commit().unwrap(); + drop(database); + fs::write(&marker, []).unwrap(); + + let database_bytes = fs::read(&database_path).unwrap(); + assert!(!database_bytes.is_empty()); + let key_records = pair.phone_keys.snapshot(); + assert_eq!( + discard_interrupted_creation(&root, session, pair.phone_keys.clone()).unwrap_err(), + PersistenceError::InitializationIncomplete + ); + assert!(!fs::read(&database_path).unwrap().is_empty()); + assert_eq!(pair.phone_keys.snapshot(), key_records); + assert_eq!(pair.phone_keys.destroy_calls(), 0); + + let opened = DurablePhone::open( + &root, + session, + pair.phone_keys.clone(), + pair.phone_anchor.clone(), + ) + .unwrap(); + assert!(database_path.is_file()); + assert!(!marker.exists()); + assert_eq!(pair.phone_keys.destroy_calls(), 0); + opened.store().close().unwrap(); +} + +#[test] +fn lifecycle_claim_serializes_ready_publication_against_cleanup() { + let root = temp_root("ready-publication-claim"); + let pair_context = context(150); + let session = pair_context.crypto_session_id; + let creator_identity = Identity::daemon(pair_context.account_id, pair_context.installation_id); + let keys = TestKeys::enabled(); + let anchor = TestAnchor::new(); + let faults = OneShotFault::new(); + faults.block_at(FaultPoint::BeforeInitializationReady); + + let creator_root = root.clone(); + let creator_keys = keys.clone(); + let creator_anchor = anchor.clone(); + let creator_faults = faults.clone(); + let creator = thread::spawn(move || { + DurableDaemon::create_with_runtime( + &creator_root, + creator_identity, + pair_context, + id(152), + creator_keys, + creator_anchor, + RuntimeHooks { + faults: creator_faults, + clock: ManualClock::new(1_000_000), + }, + ) + }); + + faults.wait_until_blocked(); + assert!(marker_path(&root, session).is_file()); + assert!(database_path(&root, session).is_file()); + assert_eq!( + discard_interrupted_creation(&root, session, keys.clone()).unwrap_err(), + PersistenceError::LifecycleBusy + ); + assert_eq!(keys.destroy_calls(), 0); + assert!(marker_path(&root, session).is_file()); + assert!(database_path(&root, session).is_file()); + + faults.release(); + let daemon = creator.join().unwrap().unwrap(); + assert!(!marker_path(&root, session).exists()); + assert!(database_path(&root, session).is_file()); + assert_eq!(keys.destroy_calls(), 0); + daemon.store().close().unwrap(); + drop(daemon); + DurableDaemon::open(&root, session, keys, anchor).unwrap(); +} + +#[test] +fn lifecycle_claim_serializes_marker_publication_against_cleanup() { + let root = temp_root("marker-publication-claim"); + let pair_context = context(153); + let session = pair_context.crypto_session_id; + let creator_identity = Identity::device( + pair_context.account_id, + pair_context.installation_id, + pair_context.device_id, + ) + .unwrap(); + let keys = TestKeys::enabled(); + let anchor = TestAnchor::new(); + let faults = OneShotFault::new(); + faults.block_at(FaultPoint::AfterInitializationMarkerCreation); + + let creator_root = root.clone(); + let creator_keys = keys.clone(); + let creator_anchor = anchor.clone(); + let creator_faults = faults.clone(); + let creator = thread::spawn(move || { + DurablePhone::create_with_runtime( + &creator_root, + creator_identity, + session, + id(155), + creator_keys, + creator_anchor, + RuntimeHooks { + faults: creator_faults, + clock: ManualClock::new(1_000_000), + }, + ) + }); + + faults.wait_until_blocked(); + assert!(marker_path(&root, session).is_file()); + assert!(!database_path(&root, session).exists()); + assert_eq!( + discard_interrupted_creation(&root, session, keys.clone()).unwrap_err(), + PersistenceError::LifecycleBusy + ); + assert_eq!(keys.destroy_calls(), 0); + assert!(marker_path(&root, session).is_file()); + + faults.release(); + let (phone, _) = creator.join().unwrap().unwrap(); + assert!(!marker_path(&root, session).exists()); + assert!(database_path(&root, session).is_file()); + assert_eq!(keys.destroy_calls(), 0); + phone.store().close().unwrap(); + drop(phone); + DurablePhone::open(&root, session, keys, anchor).unwrap(); +} + +#[test] +fn stale_marker_recovery_holds_lifecycle_claim_against_cleanup() { + let pair = durable_pair(156); + let root = pair.phone.store().path().parent().unwrap().to_path_buf(); + let session = pair.phone.store().crypto_session_id(); + let marker = marker_path(&root, session); + pair.phone.store().close().unwrap(); + fs::write(&marker, []).unwrap(); + + let faults = OneShotFault::new(); + faults.block_at(FaultPoint::DuringRestartReload); + let opener_root = root.clone(); + let opener_keys = pair.phone_keys.clone(); + let opener_anchor = pair.phone_anchor.clone(); + let opener_faults = faults.clone(); + let opener = thread::spawn(move || { + DurablePhone::open_with_runtime( + &opener_root, + session, + opener_keys, + opener_anchor, + RuntimeHooks { + faults: opener_faults, + clock: ManualClock::new(1_000_000), + }, + ) + }); + + faults.wait_until_blocked(); + assert_eq!( + discard_interrupted_creation(&root, session, pair.phone_keys.clone()).unwrap_err(), + PersistenceError::LifecycleBusy + ); + assert_eq!(pair.phone_keys.destroy_calls(), 0); + assert!(marker.is_file()); + assert!(database_path(&root, session).is_file()); + + faults.release(); + let opened = opener.join().unwrap().unwrap(); + assert!(!marker.exists()); + assert!(database_path(&root, session).is_file()); + assert_eq!(pair.phone_keys.destroy_calls(), 0); + opened.store().close().unwrap(); +} + +#[test] +fn lifecycle_claim_recovers_after_process_death() { + const CHILD_ROOT: &str = "AXL_E2EE_LIFECYCLE_CLAIM_CHILD_ROOT"; + let session = id(158); + if let Some(root) = std::env::var_os(CHILD_ROOT) { + let root = PathBuf::from(root); + let _store = NativeTransactionalProvider::create( + &root, + session, + TestKeys::enabled(), + TestAnchor::new(), + Arc::new(NoFaults), + Arc::new(SystemClock), + ) + .unwrap(); + fs::write(root.join("child-holds-claim"), []).unwrap(); + loop { + thread::sleep(Duration::from_secs(60)); + } + } + + let root = temp_root("process-lifecycle-claim"); + let signal = root.join("child-holds-claim"); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "persistence_tests::lifecycle_claim_recovers_after_process_death", + "--nocapture", + ]) + .env(CHILD_ROOT, &root) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while !signal.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + assert!(signal.exists()); + + let keys = TestKeys::enabled(); + assert_eq!( + discard_interrupted_creation(&root, session, keys.clone()).unwrap_err(), + PersistenceError::LifecycleBusy + ); + assert_eq!(keys.destroy_calls(), 0); + child.kill().unwrap(); + child.wait().unwrap(); + fs::remove_file(signal).unwrap(); + + discard_interrupted_creation(&root, session, keys.clone()).unwrap(); + assert_eq!(keys.destroy_calls(), 1); + assert!(!database_path(&root, session).exists()); + assert!(!marker_path(&root, session).exists()); + assert!(lifecycle_claim_path(&root, session).is_file()); +} + +#[test] +fn orphan_prepared_keys_are_reconciled_on_restart() { + let root = temp_root("orphan-key"); + let session = id(83); + let keys = TestKeys::enabled(); + let anchor = TestAnchor::new(); + let store = NativeTransactionalProvider::create( + &root, + session, + keys.clone(), + anchor, + Arc::new(NoFaults), + Arc::new(SystemClock), + ) + .unwrap(); + keys.prepare(session, id(122), &[7; 32], b"orphan").unwrap(); + assert_eq!(keys.activity_counts(), (0, 1)); + store.close().unwrap(); + // The schema is still initializing, but restart reconciliation removes the orphan first. + assert_eq!( + store.reopen().unwrap_err(), + PersistenceError::InitializationIncomplete + ); + assert_eq!(keys.activity_counts(), (0, 0)); + store.close().unwrap(); + drop(store); + discard_interrupted_creation(&root, session, keys).unwrap(); +} + +#[test] +fn initialization_boundaries_clean_or_recover_by_committed_state() { + let marker_only_root = temp_root("marker-only"); + let marker_only_session = id(129); + let marker_name = format!( + "{}.redb.initializing", + marker_only_session + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + fs::write(marker_only_root.join(marker_name), []).unwrap(); + let marker_keys = TestKeys::enabled(); + assert!(matches!( + DurableDaemon::open( + &marker_only_root, + marker_only_session, + marker_keys.clone(), + TestAnchor::new(), + ), + Err(PersistenceError::InitializationIncomplete) + )); + discard_interrupted_creation(&marker_only_root, marker_only_session, marker_keys.clone()) + .unwrap(); + assert_eq!(marker_keys.destroy_calls(), 1); + assert_eq!( + discard_interrupted_creation(&marker_only_root, marker_only_session, marker_keys) + .unwrap_err(), + PersistenceError::NotFound + ); + + let malformed_root = temp_root("pre-schema-boundary"); + let malformed_context = context(128); + let malformed_keys = TestKeys::enabled(); + let malformed_faults = OneShotFault::new(); + malformed_faults.arm(FaultPoint::AfterInitializationFileCreation); + assert!(matches!( + DurableDaemon::create_with_runtime( + &malformed_root, + Identity::daemon( + malformed_context.account_id, + malformed_context.installation_id, + ), + malformed_context.clone(), + id(127), + malformed_keys.clone(), + TestAnchor::new(), + RuntimeHooks { + faults: malformed_faults, + clock: ManualClock::new(1_000_000), + }, + ), + Err(PersistenceError::InjectedFault) + )); + let malformed_database = database_path(&malformed_root, malformed_context.crypto_session_id); + let malformed_bytes = fs::read(&malformed_database).unwrap(); + assert!(!malformed_bytes.is_empty()); + malformed_keys + .prepare( + malformed_context.crypto_session_id, + id(126), + &[7; 32], + b"unproven", + ) + .unwrap(); + let malformed_key_records = malformed_keys.snapshot(); + assert_eq!( + discard_interrupted_creation( + &malformed_root, + malformed_context.crypto_session_id, + malformed_keys.clone(), + ) + .unwrap_err(), + PersistenceError::Corrupt + ); + assert!(malformed_database.is_file()); + assert!(!fs::read(malformed_database).unwrap().is_empty()); + assert_eq!(malformed_keys.snapshot(), malformed_key_records); + assert_eq!(malformed_keys.destroy_calls(), 0); + + for (index, point) in [ + FaultPoint::AfterInitializationSchemaCommit, + FaultPoint::DuringPreparedKeyReconciliation, + FaultPoint::BeforeOpenMlsStateWrites, + FaultPoint::DuringOpenMlsProviderWrites, + FaultPoint::BeforeCiphertextInsertion, + FaultPoint::AfterCiphertextInsertion, + FaultPoint::BeforeCommit, + FaultPoint::DuringCurrentKeyActivation, + FaultPoint::BeforeAnchorRecovery, + FaultPoint::AfterAnchorRecoveryBeforeErasure, + FaultPoint::BeforeInitializationReady, + ] + .into_iter() + .enumerate() + { + let root = temp_root("initialization-boundary"); + let context = context(130 + index as u8); + let keys = TestKeys::enabled(); + let anchor = TestAnchor::new(); + let faults = OneShotFault::new(); + faults.arm(point); + assert!(matches!( + DurableDaemon::create_with_runtime( + &root, + Identity::daemon(context.account_id, context.installation_id), + context.clone(), + sequence_id(20, index as u64), + keys.clone(), + anchor.clone(), + RuntimeHooks { + faults, + clock: ManualClock::new(1_000_000), + }, + ), + Err(PersistenceError::InjectedFault) + )); + if matches!( + point, + FaultPoint::DuringCurrentKeyActivation + | FaultPoint::BeforeAnchorRecovery + | FaultPoint::AfterAnchorRecoveryBeforeErasure + | FaultPoint::BeforeInitializationReady + ) { + assert_eq!( + discard_interrupted_creation(&root, context.crypto_session_id, keys.clone()) + .unwrap_err(), + PersistenceError::InitializationIncomplete + ); + assert_eq!(keys.destroy_calls(), 0); + let opened = + DurableDaemon::open(&root, context.crypto_session_id, keys.clone(), anchor) + .unwrap(); + assert!(!marker_path(&root, context.crypto_session_id).exists()); + assert_eq!(keys.destroy_calls(), 0); + opened.store().close().unwrap(); + } else { + assert!(matches!( + DurableDaemon::open(&root, context.crypto_session_id, keys.clone(), anchor,), + Err(PersistenceError::InitializationIncomplete) + )); + discard_interrupted_creation(&root, context.crypto_session_id, keys).unwrap(); + } + } +} + +#[test] +fn interrupted_creation_is_explicitly_cleanable() { + let root = temp_root("initializing"); + let context = context(84); + let keys = TestKeys::enabled(); + let anchor = TestAnchor::new(); + let faults = OneShotFault::new(); + faults.arm(FaultPoint::AfterInitializationSchemaCommit); + assert!(matches!( + DurableDaemon::create_with_runtime( + &root, + Identity::daemon(context.account_id, context.installation_id), + context.clone(), + id(120), + keys.clone(), + anchor.clone(), + RuntimeHooks { + faults, + clock: ManualClock::new(1_000_000), + }, + ), + Err(PersistenceError::InjectedFault) + )); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let marker = fs::read_dir(&root) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| { + path.extension() + .is_some_and(|extension| extension == "initializing") + }) + .unwrap(); + assert_eq!( + fs::metadata(marker).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + assert!(matches!( + DurableDaemon::open(&root, context.crypto_session_id, keys.clone(), anchor,), + Err(PersistenceError::InitializationIncomplete) | Err(PersistenceError::Quarantined) + )); + discard_interrupted_creation(&root, context.crypto_session_id, keys.clone()).unwrap(); + assert_eq!( + fs::read_dir(&root) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(), + vec![lifecycle_claim_path(&root, context.crypto_session_id)] + ); + assert!(keys.keys.lock().unwrap().is_empty()); + assert_eq!(keys.destroy_calls(), 1); + assert_eq!( + discard_interrupted_creation(&root, context.crypto_session_id, keys).unwrap_err(), + PersistenceError::NotFound + ); +} + +#[test] +fn cleanup_rejects_unsupported_wrong_session_unreadable_and_symlinked_databases() { + const META: TableDefinition = TableDefinition::new("metadata_v1"); + + let unsupported = durable_pair(142); + unsupported.phone.store().close().unwrap(); + let unsupported_database = unsupported.phone.store().path().to_path_buf(); + fs::write(unsupported_database.with_extension("redb.initializing"), []).unwrap(); + let database = Database::open(&unsupported_database).unwrap(); + let mut write = database.begin_write().unwrap(); + write.set_durability(Durability::Immediate).unwrap(); + write.set_two_phase_commit(true); + { + let mut meta = write.open_table(META).unwrap(); + meta.insert(1, 99_u16.to_be_bytes().as_slice()).unwrap(); + } + write.commit().unwrap(); + drop(database); + let unsupported_bytes = fs::read(&unsupported_database).unwrap(); + assert!(!unsupported_bytes.is_empty()); + let unsupported_keys = unsupported.phone_keys.snapshot(); + assert_eq!( + discard_interrupted_creation( + unsupported_database.parent().unwrap(), + unsupported.phone.store().crypto_session_id(), + unsupported.phone_keys.clone(), + ) + .unwrap_err(), + PersistenceError::UnsupportedSchema + ); + assert!(unsupported_database.is_file()); + assert!(!fs::read(&unsupported_database).unwrap().is_empty()); + assert_eq!(unsupported.phone_keys.snapshot(), unsupported_keys); + assert_eq!(unsupported.phone_keys.destroy_calls(), 0); + + let wrong = durable_pair(143); + wrong.phone.store().close().unwrap(); + let wrong_session = id(200); + let wrong_database = database_path(wrong.phone.store().path().parent().unwrap(), wrong_session); + fs::copy(wrong.phone.store().path(), &wrong_database).unwrap(); + fs::write(wrong_database.with_extension("redb.initializing"), []).unwrap(); + let wrong_bytes = fs::read(&wrong_database).unwrap(); + assert!(!wrong_bytes.is_empty()); + let wrong_keys = wrong.phone_keys.snapshot(); + for _ in 0..2 { + assert_eq!( + discard_interrupted_creation( + wrong_database.parent().unwrap(), + wrong_session, + wrong.phone_keys.clone(), + ) + .unwrap_err(), + PersistenceError::IdentityMismatch + ); + assert!(wrong_database.is_file()); + assert!(!fs::read(&wrong_database).unwrap().is_empty()); + assert_eq!(wrong.phone_keys.snapshot(), wrong_keys); + assert_eq!(wrong.phone_keys.destroy_calls(), 0); + } + + #[cfg(unix)] + { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let unreadable = durable_pair(144); + unreadable.phone.store().close().unwrap(); + let unreadable_database = unreadable.phone.store().path().to_path_buf(); + fs::write(unreadable_database.with_extension("redb.initializing"), []).unwrap(); + let unreadable_bytes = fs::read(&unreadable_database).unwrap(); + assert!(!unreadable_bytes.is_empty()); + let unreadable_keys = unreadable.phone_keys.snapshot(); + fs::set_permissions(&unreadable_database, fs::Permissions::from_mode(0o000)).unwrap(); + assert_eq!( + discard_interrupted_creation( + unreadable_database.parent().unwrap(), + unreadable.phone.store().crypto_session_id(), + unreadable.phone_keys.clone(), + ) + .unwrap_err(), + PersistenceError::Corrupt + ); + fs::set_permissions(&unreadable_database, fs::Permissions::from_mode(0o600)).unwrap(); + assert!(unreadable_database.is_file()); + assert!(!fs::read(&unreadable_database).unwrap().is_empty()); + assert_eq!(unreadable.phone_keys.snapshot(), unreadable_keys); + assert_eq!(unreadable.phone_keys.destroy_calls(), 0); + + let symlinked = durable_pair(145); + symlinked.phone.store().close().unwrap(); + let symlink_session = id(201); + let symlink_database = database_path( + symlinked.phone.store().path().parent().unwrap(), + symlink_session, + ); + symlink(symlinked.phone.store().path(), &symlink_database).unwrap(); + fs::write(symlink_database.with_extension("redb.initializing"), []).unwrap(); + let symlink_keys = symlinked.phone_keys.snapshot(); + assert_eq!( + discard_interrupted_creation( + symlink_database.parent().unwrap(), + symlink_session, + symlinked.phone_keys.clone(), + ) + .unwrap_err(), + PersistenceError::IdentityMismatch + ); + assert!(symlinked.phone.store().path().exists()); + assert_eq!(symlinked.phone_keys.snapshot(), symlink_keys); + assert_eq!(symlinked.phone_keys.destroy_calls(), 0); + } +} + +#[test] +fn unavailable_keys_anchor_identity_and_symlinks_fail_closed() { + let mut pair = durable_pair(88); + let generation = pair.phone.store().generation().unwrap(); + *pair.phone_keys.available.lock().unwrap() = false; + assert_eq!( + pair.phone + .prepare_application(id(118), id(119), 1, b"unavailable key store") + .unwrap_err(), + PersistenceError::KeyUnavailable + ); + assert_eq!(pair.phone.store().generation().unwrap(), generation); + *pair.phone_keys.available.lock().unwrap() = true; + *pair.phone_anchor.available.lock().unwrap() = false; + assert_eq!( + pair.phone + .prepare_application(id(120), id(121), 1, b"unavailable anchor") + .unwrap_err(), + PersistenceError::AnchorUnavailable + ); + assert_eq!(pair.phone.store().generation().unwrap(), generation); + + let nonzero_root = temp_root("nonzero-anchor"); + let nonzero_anchor = TestAnchor::new(); + nonzero_anchor.state.lock().unwrap().counter = 1; + assert!(matches!( + NativeTransactionalProvider::create( + &nonzero_root, + id(89), + TestKeys::enabled(), + nonzero_anchor, + Arc::new(NoFaults), + Arc::new(SystemClock), + ), + Err(PersistenceError::IdentityMismatch) + )); + assert!(fs::read_dir(nonzero_root).unwrap().next().is_none()); + + let root = temp_root("closed"); + let keys = TestKeys::enabled(); + let anchor = TestAnchor::new(); + *keys.available.lock().unwrap() = false; + assert!(matches!( + NativeTransactionalProvider::create( + &root, + id(90), + keys, + anchor, + Arc::new(NoFaults), + Arc::new(SystemClock), + ), + Err(PersistenceError::KeyUnavailable) + )); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let target = temp_root("target"); + let link = target.with_extension("link"); + symlink(&target, &link).unwrap(); + assert!(matches!( + NativeTransactionalProvider::create( + &link, + id(91), + TestKeys::enabled(), + TestAnchor::new(), + Arc::new(NoFaults), + Arc::new(SystemClock), + ), + Err(PersistenceError::IdentityMismatch) + )); + } +} + +#[test] +fn operations_against_one_group_reload_after_the_first_writer_commits() { + let pair = durable_pair(95); + let initial_generation = pair.phone.store().generation().unwrap(); + pair.phone_faults.block_at(FaultPoint::BeforeCommit); + let mut first_phone = pair.phone.clone(); + let first = thread::spawn(move || first_phone.prepare_application(id(70), id(71), 1, b"first")); + pair.phone_faults.wait_until_blocked(); + + let (sender, receiver) = mpsc::channel(); + let mut second_phone = pair.phone.clone(); + let second_handle = thread::spawn(move || { + sender + .send(second_phone.prepare_application(id(72), id(73), 1, b"second")) + .unwrap(); + }); + assert!( + receiver + .recv_timeout(std::time::Duration::from_millis(50)) + .is_err() + ); + pair.phone_faults.release(); + let first = first.join().unwrap().unwrap(); + let second = receiver + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap() + .unwrap(); + second_handle.join().unwrap(); + + assert_ne!(first.ciphertext, second.ciphertext); + assert_eq!( + pair.phone.store().generation().unwrap(), + initial_generation + 2 + ); + assert_eq!( + pair.phone + .store() + .outbox(id(70)) + .unwrap() + .unwrap() + .ciphertext, + first.ciphertext + ); + assert_eq!( + pair.phone + .store() + .outbox(id(72)) + .unwrap() + .unwrap() + .ciphertext, + second.ciphertext + ); +} + +#[test] +fn unrelated_groups_make_progress_on_separate_databases() { + let roots = [temp_root("parallel-a"), temp_root("parallel-b")]; + let handles = roots.into_iter().enumerate().map(|(index, root)| { + thread::spawn(move || { + let pair = durable_pair(100 + index as u8); + // Opening separate per-session files does not share a writer transaction or lock. + let _extra = NativeTransactionalProvider::create( + &root, + id(120 + index as u8), + TestKeys::enabled(), + TestAnchor::new(), + Arc::new(NoFaults), + Arc::new(SystemClock), + ) + .unwrap(); + pair.phone.store().generation().unwrap() + }) + }); + for handle in handles { + assert!(handle.join().unwrap() > 0); + } +} diff --git a/packages/protocol/README.md b/packages/protocol/README.md index 3adc6e75..d1383650 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -1,6 +1,7 @@ + # `@axl/protocol` -This dependency-free package defines Axl's versioned JSONL events, model stream messages, and local wire protocol. The current wire format covers session creation, listing, paged history, resume, fork, clone, rename, deletion, import, export, catalog invalidation, subscriptions, turns, steering, follow-ups, interruption, reload, live activity, abortable blob transport, workspace review, extension interactions, and model, thinking, and web-tool configuration. Runtime parsers validate every value received from an untrusted boundary. +This dependency-free package defines Axl's versioned JSONL events, model stream messages, local wire protocol, and opaque remote-transport framing. The current local wire format covers session creation, listing, paged history, resume, fork, clone, rename, deletion, import, export, catalog invalidation, subscriptions, turns, steering, follow-ups, interruption, reload, live activity, abortable blob transport, workspace review, extension interactions, and model, thinking, and web-tool configuration. Remote transport contracts define stable crypto-session destinations, ephemeral routing identifiers, limits, tickets, receipts, authenticated daemon-message shapes, delivery states, and bounded binary frames without defining or implementing cryptography. Runtime parsers validate every value received from an untrusted boundary. diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 60892b73..23450afd 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -8,6 +8,7 @@ export * from "./event-envelope.ts"; export * from "./events.ts"; export * from "./model-stream.ts"; export * from "./provider-management.ts"; +export * from "./remote-transport.ts"; export * from "./version.ts"; export * from "./wire.ts"; export * from "./host-control.ts"; diff --git a/packages/protocol/src/remote-transport.ts b/packages/protocol/src/remote-transport.ts new file mode 100644 index 00000000..12f533ee --- /dev/null +++ b/packages/protocol/src/remote-transport.ts @@ -0,0 +1,923 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { ProtocolValidationError } from "./event-envelope.ts"; +import { parseServerMessage, type ServerMessage } from "./wire.ts"; + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +const methodPattern = /^[a-z][a-z0-9]*(?:[._-][a-zA-Z0-9]+)*$/; +const frameMagic = Uint8Array.of(0x41, 0x58, 0x4c, 0x52); +const routedFrameHeaderBytes = 38; +const shortFrameBytes = 23; + +declare const installationIdBrand: unique symbol; +declare const deviceIdBrand: unique symbol; +declare const cryptoSessionIdBrand: unique symbol; +declare const axlSessionIdBrand: unique symbol; +declare const routeIdBrand: unique symbol; +declare const transportAttemptIdBrand: unique symbol; +declare const envelopeIdBrand: unique symbol; +declare const requestIdBrand: unique symbol; +declare const idempotencyKeyBrand: unique symbol; +declare const objectIdBrand: unique symbol; + +type Nominal = string & { readonly [Key in Brand]: true }; + +export type InstallationId = Nominal; +export type DeviceId = Nominal; +export type CryptoSessionId = Nominal; +export type AxlSessionId = Nominal; +export type RouteId = Nominal; +export type TransportAttemptId = Nominal; +export type EnvelopeId = Nominal; +export type RequestId = Nominal; +export type IdempotencyKey = Nominal; +export type ObjectId = Nominal; + +export const REMOTE_TRANSPORT_VERSION = 1 as const; +export const INTERNAL_RELAY_API_VERSION = 1 as const; +export const MAX_RELAY_FRAME_BYTES = 65_535; +export const MAX_RELAY_QUEUED_BYTES = 512 * 1024; +export const RELAY_HEARTBEAT_INTERVAL_MS = 20_000; +export const RELAY_IDLE_TIMEOUT_MS = 60_000; +export const RELAY_TICKET_LIFETIME_MS = 60_000; +export const MAX_RELAY_OPAQUE_PAYLOAD_BYTES = MAX_RELAY_FRAME_BYTES - routedFrameHeaderBytes; + +export interface RelayLimits { + readonly maxFrameBytes: number; + readonly maxQueuedBytes: number; + readonly heartbeatIntervalMs: number; + readonly idleTimeoutMs: number; +} + +export const DEFAULT_RELAY_LIMITS: RelayLimits = Object.freeze({ + maxFrameBytes: MAX_RELAY_FRAME_BYTES, + maxQueuedBytes: MAX_RELAY_QUEUED_BYTES, + heartbeatIntervalMs: RELAY_HEARTBEAT_INTERVAL_MS, + idleTimeoutMs: RELAY_IDLE_TIMEOUT_MS, +}); + +export interface IssueRelayTicketRequest { + readonly installationId: InstallationId; + readonly deviceId?: DeviceId; + readonly role: "daemon" | "device"; +} + +export interface IssueRelayTicketResult { + readonly ticket: string; + readonly relayUrl: string; + readonly expiresAt: number; + readonly proofSchemeVersion: number; + readonly limits: RelayLimits; +} + +export interface ConsumeRelayTicketRequest { + readonly ticket: string; + readonly relayInstanceId: string; + readonly connectionNonce: string; + readonly possessionProof: Uint8Array; +} + +export interface ConsumeRelayTicketResult { + readonly installationId: InstallationId; + readonly deviceId?: DeviceId; + readonly sourceRouteId: RouteId; + readonly role: "daemon" | "device"; + readonly grantGeneration: number; + readonly leaseExpiresAt: number; + readonly limits: RelayLimits; +} + +export interface RelaySendFrame { + readonly transportVersion: typeof REMOTE_TRANSPORT_VERSION; + readonly attemptId: TransportAttemptId; + readonly destinationRouteId: RouteId; + readonly opaquePayload: Uint8Array; +} + +export interface RelayDelivery { + readonly transportVersion: typeof REMOTE_TRANSPORT_VERSION; + readonly attemptId: TransportAttemptId; + readonly sourceRouteId: RouteId; + readonly opaquePayload: Uint8Array; +} + +export type RelayReceiptStatus = "admitted" | "forwarded"; + +export interface RelayReceipt { + readonly transportVersion: typeof REMOTE_TRANSPORT_VERSION; + readonly attemptId: TransportAttemptId; + readonly status: RelayReceiptStatus; +} + +export const RELAY_FAILURE_CODE_VALUES = Object.freeze({ + bad_frame: 1, + unsupported_transport_version: 2, + unauthorized: 3, + forbidden_route: 4, + ticket_expired: 5, + ticket_consumed: 6, + destination_offline: 7, + rate_limited: 8, + queue_full: 9, + slow_consumer: 10, + service_unavailable: 11, + ticket_revoked: 12, +} as const); + +export type RelayFailureCode = keyof typeof RELAY_FAILURE_CODE_VALUES; +export const RELAY_FAILURE_CODES = Object.freeze( + Object.keys(RELAY_FAILURE_CODE_VALUES) as RelayFailureCode[], +); + +export interface RelayFailure { + readonly transportVersion: typeof REMOTE_TRANSPORT_VERSION; + readonly attemptId: TransportAttemptId; + readonly code: RelayFailureCode; +} + +export type RelayBinaryFrame = RelaySendFrame | RelayDelivery | RelayReceipt | RelayFailure; + +export const REMOTE_DEVICE_SCOPES = [ + "observe", + "steer", + "approve_within_policy", + "manage_sessions", +] as const; + +export type RemoteDeviceScope = (typeof REMOTE_DEVICE_SCOPES)[number]; + +export interface AuthenticatedRemoteRequest { + readonly deviceId: DeviceId; + readonly requestId: RequestId; + readonly idempotencyKey?: IdempotencyKey; + readonly method: string; + readonly params: unknown; +} + +export type RemoteDeliveryState = + | "queued_local" + | "sending" + | "relay_admitted" + | "relay_forwarded" + | "daemon_accepted" + | "operation_running" + | "completed" + | "failed"; + +export interface OpaqueOutboxRecord { + readonly requestId: RequestId; + readonly idempotencyKey: IdempotencyKey; + /** Stable crypto-session identity. Ephemeral relay routes must never be persisted here. */ + readonly destinationCryptoSessionId: CryptoSessionId; + readonly opaqueEnvelope: Uint8Array; + readonly createdAt: number; + readonly state: "queued_local" | "sending" | "daemon_accepted"; +} + +export type RemoteDaemonMessage = + | { + readonly version: typeof REMOTE_TRANSPORT_VERSION; + readonly type: "daemon_accepted"; + readonly requestId: RequestId; + readonly idempotencyKey: IdempotencyKey; + } + | { + readonly version: typeof REMOTE_TRANSPORT_VERSION; + readonly type: "daemon_result"; + readonly requestId: RequestId; + readonly method: string; + readonly result: unknown; + } + | { + readonly version: typeof REMOTE_TRANSPORT_VERSION; + readonly type: "daemon_error"; + readonly requestId: RequestId; + readonly code: string; + readonly message: string; + readonly retryable: boolean; + } + | { + readonly version: typeof REMOTE_TRANSPORT_VERSION; + readonly type: "daemon_delivery"; + readonly message: ServerMessage; + }; + +export interface RelayPeerRoute { + readonly routeId: RouteId; + readonly role: "daemon" | "device"; + readonly deviceId?: DeviceId; +} + +export interface RelayDiscoveryMessage { + readonly version: typeof REMOTE_TRANSPORT_VERSION; + readonly type: "route_snapshot" | "route_available" | "route_unavailable"; + readonly sourceRoute?: RelayPeerRoute; + readonly peers: readonly RelayPeerRoute[]; +} + +export interface RelayRevocationNotification { + readonly version: typeof INTERNAL_RELAY_API_VERSION; + readonly installationId: InstallationId; + readonly deviceId?: DeviceId; + readonly generation: number; + readonly effectiveAt: number; +} + +export interface RelayRevocationResult { + readonly version: typeof INTERNAL_RELAY_API_VERSION; + readonly accepted: true; +} + +export type InternalConsumeRelayTicketWireRequest = Omit< + ConsumeRelayTicketRequest, + "possessionProof" +> & { + readonly version: typeof INTERNAL_RELAY_API_VERSION; + readonly possessionProof: string; +}; + +export type InternalConsumeRelayTicketWireResult = ConsumeRelayTicketResult & { + readonly version: typeof INTERNAL_RELAY_API_VERSION; +}; + +function fail(path: string, message: string): never { + throw new ProtocolValidationError(path, message); +} + +function object(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + fail(path, "must be an object"); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) fail(path, "must be a plain object"); + return value as Record; +} + +function exact( + value: Record, + path: string, + required: readonly string[], + optional: readonly string[] = [], +): void { + const allowed = new Set([...required, ...optional]); + for (const key of Object.keys(value)) if (!allowed.has(key)) fail(`${path}.${key}`, "is unknown"); + for (const key of required) if (!(key in value)) fail(`${path}.${key}`, "is required"); +} + +function boundedString(value: unknown, path: string, maximum: number): string { + if (typeof value !== "string" || value.length === 0 || value.length > maximum) { + fail(path, `must be a non-empty string no longer than ${maximum} characters`); + } + return value; +} + +function integer(value: unknown, path: string, minimum: number, maximum: number): number { + if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) { + fail(path, `must be an integer from ${minimum} through ${maximum}`); + } + return value as number; +} + +function timestamp(value: unknown, path: string): number { + return integer(value, path, 0, Number.MAX_SAFE_INTEGER); +} + +function role(value: unknown, path: string): "daemon" | "device" { + if (value !== "daemon" && value !== "device") fail(path, "must be daemon or device"); + return value; +} + +function uuid(value: unknown, path: string): Nominal { + if (typeof value !== "string" || !uuidPattern.test(value)) { + fail(path, "must be a lowercase RFC 9562 UUID"); + } + return value as Nominal; +} + +export function parseInstallationId(value: unknown, path = "installationId"): InstallationId { + return uuid(value, path); +} + +export function parseDeviceId(value: unknown, path = "deviceId"): DeviceId { + return uuid(value, path); +} + +export function parseCryptoSessionId(value: unknown, path = "cryptoSessionId"): CryptoSessionId { + return uuid(value, path); +} + +export function parseAxlSessionId(value: unknown, path = "axlSessionId"): AxlSessionId { + return uuid(value, path); +} + +export function parseRouteId(value: unknown, path = "routeId"): RouteId { + return uuid(value, path); +} + +export function parseTransportAttemptId(value: unknown, path = "attemptId"): TransportAttemptId { + return uuid(value, path); +} + +export function parseEnvelopeId(value: unknown, path = "envelopeId"): EnvelopeId { + return uuid(value, path); +} + +export function parseRemoteRequestId(value: unknown, path = "requestId"): RequestId { + return uuid(value, path); +} + +export function parseIdempotencyKey(value: unknown, path = "idempotencyKey"): IdempotencyKey { + return uuid(value, path); +} + +export function parseObjectId(value: unknown, path = "objectId"): ObjectId { + return uuid(value, path); +} + +export function parseRelayLimits(value: unknown, path = "limits"): RelayLimits { + const candidate = object(value, path); + exact(candidate, path, [ + "maxFrameBytes", + "maxQueuedBytes", + "heartbeatIntervalMs", + "idleTimeoutMs", + ]); + return { + maxFrameBytes: integer( + candidate.maxFrameBytes, + `${path}.maxFrameBytes`, + 1, + MAX_RELAY_FRAME_BYTES, + ), + maxQueuedBytes: integer( + candidate.maxQueuedBytes, + `${path}.maxQueuedBytes`, + 1, + MAX_RELAY_QUEUED_BYTES, + ), + heartbeatIntervalMs: integer( + candidate.heartbeatIntervalMs, + `${path}.heartbeatIntervalMs`, + 1, + 300_000, + ), + idleTimeoutMs: integer(candidate.idleTimeoutMs, `${path}.idleTimeoutMs`, 1, 600_000), + }; +} + +export function parseIssueRelayTicketRequest(value: unknown): IssueRelayTicketRequest { + const candidate = object(value, "request"); + exact(candidate, "request", ["installationId", "role"], ["deviceId"]); + const parsedRole = role(candidate.role, "request.role"); + const deviceId = + candidate.deviceId === undefined + ? undefined + : parseDeviceId(candidate.deviceId, "request.deviceId"); + if (parsedRole === "device" && deviceId === undefined) { + fail("request.deviceId", "is required for the device role"); + } + if (parsedRole === "daemon" && deviceId !== undefined) { + fail("request.deviceId", "is not allowed for the daemon role"); + } + return { + installationId: parseInstallationId(candidate.installationId, "request.installationId"), + ...(deviceId === undefined ? {} : { deviceId }), + role: parsedRole, + }; +} + +export function parseIssueRelayTicketResult(value: unknown): IssueRelayTicketResult { + const candidate = object(value, "result"); + exact(candidate, "result", ["ticket", "relayUrl", "expiresAt", "proofSchemeVersion", "limits"]); + const relayUrl = boundedString(candidate.relayUrl, "result.relayUrl", 2_048); + let parsedUrl: URL; + try { + parsedUrl = new URL(relayUrl); + } catch { + fail("result.relayUrl", "must be an absolute URL"); + } + if (parsedUrl.protocol !== "wss:") { + fail("result.relayUrl", "must use wss"); + } + return { + ticket: boundedString(candidate.ticket, "result.ticket", 1_024), + relayUrl, + expiresAt: timestamp(candidate.expiresAt, "result.expiresAt"), + proofSchemeVersion: integer(candidate.proofSchemeVersion, "result.proofSchemeVersion", 1, 255), + limits: parseRelayLimits(candidate.limits, "result.limits"), + }; +} + +export function encodeBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let encoded = ""; + for (let offset = 0; offset < binary.length; offset += 3) { + const first = binary.charCodeAt(offset); + const hasSecond = offset + 1 < binary.length; + const hasThird = offset + 2 < binary.length; + const second = hasSecond ? binary.charCodeAt(offset + 1) : 0; + const third = hasThird ? binary.charCodeAt(offset + 2) : 0; + const bits = (first << 16) | (second << 8) | third; + encoded += alphabet[(bits >>> 18) & 63]; + encoded += alphabet[(bits >>> 12) & 63]; + encoded += hasSecond ? alphabet[(bits >>> 6) & 63] : "="; + encoded += hasThird ? alphabet[bits & 63] : "="; + } + return encoded; +} + +export function decodeBase64(value: unknown, path: string, maximumBytes: number): Uint8Array { + const encoded = boundedString(value, path, Math.ceil(maximumBytes / 3) * 4); + if (!base64Pattern.test(encoded)) fail(path, "must be canonical base64"); + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const output: number[] = []; + for (let offset = 0; offset < encoded.length; offset += 4) { + const chars = encoded.slice(offset, offset + 4); + const values = [...chars].map((character) => + character === "=" ? 0 : alphabet.indexOf(character), + ); + if (values.some((entry) => entry < 0)) fail(path, "must be canonical base64"); + const [first, second, third, fourth] = values; + if ( + first === undefined || + second === undefined || + third === undefined || + fourth === undefined + ) { + fail(path, "must be canonical base64"); + } + const bits = (first << 18) | (second << 12) | (third << 6) | fourth; + output.push((bits >>> 16) & 0xff); + if (chars[2] !== "=") output.push((bits >>> 8) & 0xff); + if (chars[3] !== "=") output.push(bits & 0xff); + } + if (output.length > maximumBytes || encodeBase64(Uint8Array.from(output)) !== encoded) { + fail(path, `must encode no more than ${maximumBytes} bytes`); + } + return Uint8Array.from(output); +} + +export function parseInternalConsumeRelayTicketRequest(value: unknown): ConsumeRelayTicketRequest { + const candidate = object(value, "request"); + exact(candidate, "request", [ + "version", + "ticket", + "relayInstanceId", + "connectionNonce", + "possessionProof", + ]); + if (candidate.version !== INTERNAL_RELAY_API_VERSION) { + fail("request.version", `must equal ${INTERNAL_RELAY_API_VERSION}`); + } + return { + ticket: boundedString(candidate.ticket, "request.ticket", 1_024), + relayInstanceId: boundedString(candidate.relayInstanceId, "request.relayInstanceId", 128), + connectionNonce: boundedString(candidate.connectionNonce, "request.connectionNonce", 256), + possessionProof: decodeBase64(candidate.possessionProof, "request.possessionProof", 1_024), + }; +} + +export function encodeInternalConsumeRelayTicketRequest( + request: ConsumeRelayTicketRequest, +): InternalConsumeRelayTicketWireRequest { + return { + version: INTERNAL_RELAY_API_VERSION, + ticket: request.ticket, + relayInstanceId: request.relayInstanceId, + connectionNonce: request.connectionNonce, + possessionProof: encodeBase64(request.possessionProof), + }; +} + +export function parseInternalConsumeRelayTicketResult(value: unknown): ConsumeRelayTicketResult { + const candidate = object(value, "result"); + exact( + candidate, + "result", + [ + "version", + "installationId", + "sourceRouteId", + "role", + "grantGeneration", + "leaseExpiresAt", + "limits", + ], + ["deviceId"], + ); + if (candidate.version !== INTERNAL_RELAY_API_VERSION) { + fail("result.version", `must equal ${INTERNAL_RELAY_API_VERSION}`); + } + const parsedRole = role(candidate.role, "result.role"); + const deviceId = + candidate.deviceId === undefined + ? undefined + : parseDeviceId(candidate.deviceId, "result.deviceId"); + if (parsedRole === "device" && deviceId === undefined) fail("result.deviceId", "is required"); + if (parsedRole === "daemon" && deviceId !== undefined) fail("result.deviceId", "is not allowed"); + return { + installationId: parseInstallationId(candidate.installationId, "result.installationId"), + ...(deviceId === undefined ? {} : { deviceId }), + sourceRouteId: parseRouteId(candidate.sourceRouteId, "result.sourceRouteId"), + role: parsedRole, + grantGeneration: integer( + candidate.grantGeneration, + "result.grantGeneration", + 1, + Number.MAX_SAFE_INTEGER, + ), + leaseExpiresAt: timestamp(candidate.leaseExpiresAt, "result.leaseExpiresAt"), + limits: parseRelayLimits(candidate.limits, "result.limits"), + }; +} + +export function encodeInternalConsumeRelayTicketResult( + result: ConsumeRelayTicketResult, +): InternalConsumeRelayTicketWireResult { + return { version: INTERNAL_RELAY_API_VERSION, ...result }; +} + +function parseRelayPeerRoute(value: unknown, path: string): RelayPeerRoute { + const candidate = object(value, path); + exact(candidate, path, ["routeId", "role"], ["deviceId"]); + const parsedRole = role(candidate.role, `${path}.role`); + const deviceId = + candidate.deviceId === undefined + ? undefined + : parseDeviceId(candidate.deviceId, `${path}.deviceId`); + if (parsedRole === "device" && deviceId === undefined) fail(`${path}.deviceId`, "is required"); + if (parsedRole === "daemon" && deviceId !== undefined) fail(`${path}.deviceId`, "is not allowed"); + return { + routeId: parseRouteId(candidate.routeId, `${path}.routeId`), + role: parsedRole, + ...(deviceId === undefined ? {} : { deviceId }), + }; +} + +export function parseRelayDiscoveryMessage(value: unknown): RelayDiscoveryMessage { + const candidate = object(value, "discovery"); + exact(candidate, "discovery", ["version", "type", "peers"], ["sourceRoute"]); + if (candidate.version !== REMOTE_TRANSPORT_VERSION) { + fail("discovery.version", `must equal ${REMOTE_TRANSPORT_VERSION}`); + } + if ( + candidate.type !== "route_snapshot" && + candidate.type !== "route_available" && + candidate.type !== "route_unavailable" + ) { + fail("discovery.type", "is invalid"); + } + if (!Array.isArray(candidate.peers) || candidate.peers.length > 256) { + fail("discovery.peers", "must be an array of at most 256 routes"); + } + if (candidate.type === "route_snapshot" && candidate.sourceRoute === undefined) { + fail("discovery.sourceRoute", "is required for a snapshot"); + } + if (candidate.type !== "route_snapshot" && candidate.sourceRoute !== undefined) { + fail("discovery.sourceRoute", "is allowed only for a snapshot"); + } + return { + version: REMOTE_TRANSPORT_VERSION, + type: candidate.type, + ...(candidate.sourceRoute === undefined + ? {} + : { sourceRoute: parseRelayPeerRoute(candidate.sourceRoute, "discovery.sourceRoute") }), + peers: candidate.peers.map((peer, index) => + parseRelayPeerRoute(peer, `discovery.peers[${index}]`), + ), + }; +} + +export function parseRelayRevocationNotification(value: unknown): RelayRevocationNotification { + const candidate = object(value, "request"); + exact( + candidate, + "request", + ["version", "installationId", "generation", "effectiveAt"], + ["deviceId"], + ); + if (candidate.version !== INTERNAL_RELAY_API_VERSION) { + fail("request.version", `must equal ${INTERNAL_RELAY_API_VERSION}`); + } + return { + version: INTERNAL_RELAY_API_VERSION, + installationId: parseInstallationId(candidate.installationId, "request.installationId"), + ...(candidate.deviceId === undefined + ? {} + : { deviceId: parseDeviceId(candidate.deviceId, "request.deviceId") }), + generation: integer(candidate.generation, "request.generation", 1, Number.MAX_SAFE_INTEGER), + effectiveAt: timestamp(candidate.effectiveAt, "request.effectiveAt"), + }; +} + +export function parseOpaqueOutboxRecord(value: unknown): OpaqueOutboxRecord { + const candidate = object(value, "outboxRecord"); + exact(candidate, "outboxRecord", [ + "requestId", + "idempotencyKey", + "destinationCryptoSessionId", + "opaqueEnvelope", + "createdAt", + "state", + ]); + if (!(candidate.opaqueEnvelope instanceof Uint8Array)) { + fail("outboxRecord.opaqueEnvelope", "must be bytes"); + } + if ( + candidate.opaqueEnvelope.byteLength === 0 || + candidate.opaqueEnvelope.byteLength > MAX_RELAY_OPAQUE_PAYLOAD_BYTES + ) { + fail( + "outboxRecord.opaqueEnvelope", + `must contain 1 through ${MAX_RELAY_OPAQUE_PAYLOAD_BYTES} bytes`, + ); + } + if ( + candidate.state !== "queued_local" && + candidate.state !== "sending" && + candidate.state !== "daemon_accepted" + ) { + fail("outboxRecord.state", "is invalid"); + } + return { + requestId: parseRemoteRequestId(candidate.requestId, "outboxRecord.requestId"), + idempotencyKey: parseIdempotencyKey(candidate.idempotencyKey, "outboxRecord.idempotencyKey"), + destinationCryptoSessionId: parseCryptoSessionId( + candidate.destinationCryptoSessionId, + "outboxRecord.destinationCryptoSessionId", + ), + opaqueEnvelope: candidate.opaqueEnvelope.slice(), + createdAt: timestamp(candidate.createdAt, "outboxRecord.createdAt"), + state: candidate.state, + }; +} + +export function parseRemoteDaemonMessage(value: unknown): RemoteDaemonMessage { + const candidate = object(value, "remoteDaemonMessage"); + if (candidate.version !== REMOTE_TRANSPORT_VERSION) { + fail("remoteDaemonMessage.version", `must equal ${REMOTE_TRANSPORT_VERSION}`); + } + if (candidate.type === "daemon_accepted") { + exact(candidate, "remoteDaemonMessage", ["version", "type", "requestId", "idempotencyKey"]); + return { + version: REMOTE_TRANSPORT_VERSION, + type: "daemon_accepted", + requestId: parseRemoteRequestId(candidate.requestId, "remoteDaemonMessage.requestId"), + idempotencyKey: parseIdempotencyKey( + candidate.idempotencyKey, + "remoteDaemonMessage.idempotencyKey", + ), + }; + } + if (candidate.type === "daemon_result") { + exact(candidate, "remoteDaemonMessage", ["version", "type", "requestId", "method", "result"]); + const method = boundedString(candidate.method, "remoteDaemonMessage.method", 128); + if (!methodPattern.test(method)) + fail("remoteDaemonMessage.method", "has an invalid method name"); + return { + version: REMOTE_TRANSPORT_VERSION, + type: "daemon_result", + requestId: parseRemoteRequestId(candidate.requestId, "remoteDaemonMessage.requestId"), + method, + result: candidate.result, + }; + } + if (candidate.type === "daemon_error") { + exact(candidate, "remoteDaemonMessage", [ + "version", + "type", + "requestId", + "code", + "message", + "retryable", + ]); + if (typeof candidate.retryable !== "boolean") { + fail("remoteDaemonMessage.retryable", "must be boolean"); + } + return { + version: REMOTE_TRANSPORT_VERSION, + type: "daemon_error", + requestId: parseRemoteRequestId(candidate.requestId, "remoteDaemonMessage.requestId"), + code: boundedString(candidate.code, "remoteDaemonMessage.code", 128), + message: boundedString(candidate.message, "remoteDaemonMessage.message", 1_024), + retryable: candidate.retryable, + }; + } + if (candidate.type === "daemon_delivery") { + exact(candidate, "remoteDaemonMessage", ["version", "type", "message"]); + return { + version: REMOTE_TRANSPORT_VERSION, + type: "daemon_delivery", + message: parseServerMessage(candidate.message), + }; + } + return fail("remoteDaemonMessage.type", "is invalid"); +} + +export function encodeRemoteDaemonMessage(message: RemoteDaemonMessage): Uint8Array { + const validated = parseRemoteDaemonMessage(message); + const encoded = new TextEncoder().encode(JSON.stringify(validated)); + if (encoded.byteLength > MAX_RELAY_OPAQUE_PAYLOAD_BYTES) { + fail( + "remoteDaemonMessage", + `must encode to no more than ${MAX_RELAY_OPAQUE_PAYLOAD_BYTES} bytes`, + ); + } + return encoded; +} + +export function decodeRemoteDaemonMessage(value: Uint8Array): RemoteDaemonMessage { + if (!(value instanceof Uint8Array)) fail("remoteDaemonMessage", "must be bytes"); + if (value.byteLength === 0 || value.byteLength > MAX_RELAY_OPAQUE_PAYLOAD_BYTES) { + fail("remoteDaemonMessage", `must contain 1 through ${MAX_RELAY_OPAQUE_PAYLOAD_BYTES} bytes`); + } + try { + return parseRemoteDaemonMessage( + JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(value)), + ); + } catch (error) { + if (error instanceof ProtocolValidationError) throw error; + fail("remoteDaemonMessage", "must be valid UTF-8 JSON"); + } +} + +export function parseRelayRevocationResult(value: unknown): RelayRevocationResult { + const candidate = object(value, "result"); + exact(candidate, "result", ["version", "accepted"]); + if (candidate.version !== INTERNAL_RELAY_API_VERSION) { + fail("result.version", `must equal ${INTERNAL_RELAY_API_VERSION}`); + } + if (candidate.accepted !== true) fail("result.accepted", "must be true"); + return { version: INTERNAL_RELAY_API_VERSION, accepted: true }; +} + +export function parseRemoteDeviceScopes( + value: unknown, + path = "scopes", +): readonly RemoteDeviceScope[] { + if (!Array.isArray(value)) fail(path, "must be an array"); + const scopes = value.map((candidate, index) => { + if ( + typeof candidate !== "string" || + !(REMOTE_DEVICE_SCOPES as readonly string[]).includes(candidate) + ) { + fail(`${path}[${index}]`, "is not a known remote device scope"); + } + return candidate as RemoteDeviceScope; + }); + if (new Set(scopes).size !== scopes.length) fail(path, "must not contain duplicate scopes"); + return [...scopes].sort(); +} + +export function parseAuthenticatedRemoteRequest(value: unknown): AuthenticatedRemoteRequest { + const candidate = object(value, "request"); + exact(candidate, "request", ["deviceId", "requestId", "method", "params"], ["idempotencyKey"]); + const method = boundedString(candidate.method, "request.method", 128); + if (!methodPattern.test(method)) fail("request.method", "has an invalid method name"); + return { + deviceId: parseDeviceId(candidate.deviceId, "request.deviceId"), + requestId: parseRemoteRequestId(candidate.requestId, "request.requestId"), + ...(candidate.idempotencyKey === undefined + ? {} + : { + idempotencyKey: parseIdempotencyKey(candidate.idempotencyKey, "request.idempotencyKey"), + }), + method, + params: candidate.params, + }; +} + +function uuidBytes(value: string): Uint8Array { + const hexadecimal = value.replaceAll("-", ""); + return Uint8Array.from({ length: 16 }, (_, index) => + Number.parseInt(hexadecimal.slice(index * 2, index * 2 + 2), 16), + ); +} + +function bytesUuid(bytes: Uint8Array, offset: number, path: string): string { + const hexadecimal = [...bytes.subarray(offset, offset + 16)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + return uuidPattern.test( + `${hexadecimal.slice(0, 8)}-${hexadecimal.slice(8, 12)}-${hexadecimal.slice(12, 16)}-${hexadecimal.slice(16, 20)}-${hexadecimal.slice(20)}`, + ) + ? `${hexadecimal.slice(0, 8)}-${hexadecimal.slice(8, 12)}-${hexadecimal.slice(12, 16)}-${hexadecimal.slice(16, 20)}-${hexadecimal.slice(20)}` + : fail(path, "contains an invalid RFC 9562 UUID"); +} + +function writePrefix(output: Uint8Array, kind: number, attemptId: TransportAttemptId): void { + output.set(frameMagic, 0); + output[4] = REMOTE_TRANSPORT_VERSION; + output[5] = kind; + output.set(uuidBytes(attemptId), 6); +} + +function encodeRoutedFrame( + kind: 1 | 2, + attemptId: TransportAttemptId, + routeId: RouteId, + payload: Uint8Array, +): Uint8Array { + if (payload.byteLength > MAX_RELAY_OPAQUE_PAYLOAD_BYTES) { + fail("frame.opaquePayload", `must not exceed ${MAX_RELAY_OPAQUE_PAYLOAD_BYTES} bytes`); + } + const output = new Uint8Array(routedFrameHeaderBytes + payload.byteLength); + writePrefix(output, kind, attemptId); + output.set(uuidBytes(routeId), 22); + output.set(payload, routedFrameHeaderBytes); + return output; +} + +export function encodeRelayBinaryFrame(frame: RelayBinaryFrame): Uint8Array { + switch ( + "destinationRouteId" in frame + ? "send" + : "sourceRouteId" in frame + ? "delivery" + : "status" in frame + ? "receipt" + : "failure" + ) { + case "send": + return encodeRoutedFrame( + 1, + frame.attemptId, + (frame as RelaySendFrame).destinationRouteId, + (frame as RelaySendFrame).opaquePayload, + ); + case "delivery": + return encodeRoutedFrame( + 2, + frame.attemptId, + (frame as RelayDelivery).sourceRouteId, + (frame as RelayDelivery).opaquePayload, + ); + case "receipt": { + const output = new Uint8Array(shortFrameBytes); + writePrefix(output, 3, frame.attemptId); + const status = (frame as RelayReceipt).status; + if (status !== "admitted" && status !== "forwarded") fail("frame.status", "is invalid"); + output[22] = status === "admitted" ? 1 : 2; + return output; + } + case "failure": { + const output = new Uint8Array(shortFrameBytes); + writePrefix(output, 4, frame.attemptId); + const failureCode = RELAY_FAILURE_CODE_VALUES[(frame as RelayFailure).code]; + if (failureCode === undefined) fail("frame.code", "is invalid"); + output[22] = failureCode; + return output; + } + } +} + +export function parseRelayBinaryFrame(value: Uint8Array): RelayBinaryFrame { + if (!(value instanceof Uint8Array)) fail("frame", "must be bytes"); + if (value.byteLength < 6 || value.byteLength > MAX_RELAY_FRAME_BYTES) { + fail("frame", `must contain 6 through ${MAX_RELAY_FRAME_BYTES} bytes`); + } + if (!frameMagic.every((byte, index) => value[index] === byte)) fail("frame.magic", "is invalid"); + if (value[4] !== REMOTE_TRANSPORT_VERSION) { + fail("frame.transportVersion", `must equal ${REMOTE_TRANSPORT_VERSION}`); + } + const kind = value[5]; + const attemptId = parseTransportAttemptId(bytesUuid(value, 6, "frame.attemptId")); + if (kind === 1 || kind === 2) { + if (value.byteLength < routedFrameHeaderBytes) fail("frame", "has a truncated routed header"); + const routeId = parseRouteId(bytesUuid(value, 22, "frame.routeId")); + const opaquePayload = value.slice(routedFrameHeaderBytes); + return kind === 1 + ? { + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId, + destinationRouteId: routeId, + opaquePayload, + } + : { + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId, + sourceRouteId: routeId, + opaquePayload, + }; + } + if (value.byteLength !== shortFrameBytes) fail("frame", "has an invalid control-frame size"); + if (kind === 3) { + const status = value[22] === 1 ? "admitted" : value[22] === 2 ? "forwarded" : undefined; + if (status === undefined) fail("frame.status", "is invalid"); + return { transportVersion: REMOTE_TRANSPORT_VERSION, attemptId, status }; + } + if (kind === 4) { + const failureByte = value[22]; + if (failureByte === undefined) fail("frame.code", "is missing"); + const code = RELAY_FAILURE_CODES.find( + (candidate) => RELAY_FAILURE_CODE_VALUES[candidate] === failureByte, + ); + if (code === undefined) fail("frame.code", "is invalid"); + return { transportVersion: REMOTE_TRANSPORT_VERSION, attemptId, code }; + } + return fail("frame.kind", "is invalid"); +} diff --git a/packages/protocol/test/fixtures/internal-relay-api-v1.json b/packages/protocol/test/fixtures/internal-relay-api-v1.json new file mode 100644 index 00000000..9a1622dd --- /dev/null +++ b/packages/protocol/test/fixtures/internal-relay-api-v1.json @@ -0,0 +1,57 @@ +{ + "version": 1, + "consumeTicket": { + "request": { + "version": 1, + "ticket": "fixture-ticket-never-valid-outside-tests", + "relayInstanceId": "relay-fixture-1", + "connectionNonce": "fixture-connection-nonce", + "possessionProof": "AAECA/8=" + }, + "result": { + "version": 1, + "installationId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "deviceId": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "sourceRouteId": "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + "role": "device", + "grantGeneration": 7, + "leaseExpiresAt": 2000000000000, + "limits": { + "maxFrameBytes": 65535, + "maxQueuedBytes": 524288, + "heartbeatIntervalMs": 20000, + "idleTimeoutMs": 60000 + } + } + }, + "discovery": { + "deviceSnapshot": { + "version": 1, + "type": "route_snapshot", + "sourceRoute": { + "routeId": "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "role": "device", + "deviceId": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + }, + "peers": [ + { + "routeId": "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + "role": "daemon" + } + ] + } + }, + "revocation": { + "request": { + "version": 1, + "installationId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "deviceId": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "generation": 7, + "effectiveAt": 1900000000000 + }, + "result": { + "version": 1, + "accepted": true + } + } +} diff --git a/packages/protocol/test/fixtures/remote-transport-v1.json b/packages/protocol/test/fixtures/remote-transport-v1.json new file mode 100644 index 00000000..54b29358 --- /dev/null +++ b/packages/protocol/test/fixtures/remote-transport-v1.json @@ -0,0 +1,70 @@ +{ + "version": 1, + "accepted": [ + { + "name": "send", + "base64": "QVhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAQL/QVhMUg==", + "frame": { + "kind": "send", + "attemptId": "11111111-1111-4111-8111-111111111111", + "routeId": "22222222-2222-4222-8222-222222222222", + "opaquePayloadBase64": "AAEC/0FYTFI=" + } + }, + { + "name": "delivery", + "base64": "QVhMUgECERERERERQRGBERERERERETMzMzMzM0MzgzMzMzMzMzMAAQL/QVhMUg==", + "frame": { + "kind": "delivery", + "attemptId": "11111111-1111-4111-8111-111111111111", + "routeId": "33333333-3333-4333-8333-333333333333", + "opaquePayloadBase64": "AAEC/0FYTFI=" + } + }, + { + "name": "admitted-receipt", + "base64": "QVhMUgEDERERERERQRGBEREREREREQE=", + "frame": { + "kind": "receipt", + "attemptId": "11111111-1111-4111-8111-111111111111", + "status": "admitted" + } + }, + { + "name": "destination-offline", + "base64": "QVhMUgEEERERERERQRGBEREREREREQc=", + "frame": { + "kind": "failure", + "attemptId": "11111111-1111-4111-8111-111111111111", + "code": "destination_offline" + } + } + ], + "rejected": [ + { + "name": "wrong-magic", + "base64": "QlhMUgEBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAQL/QVhMUg==", + "errorPath": "frame.magic" + }, + { + "name": "unsupported-version", + "base64": "QVhMUgIBERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAQL/QVhMUg==", + "errorPath": "frame.transportVersion" + }, + { + "name": "truncated-header", + "base64": "QVhMUgEBERERERERQRGBERERERERESIiIiIiIkIi", + "errorPath": "frame" + }, + { + "name": "invalid-attempt-id", + "base64": "QVhMUgEBERERERERARGBERERERERESIiIiIiIkIigiIiIiIiIiIAAQL/QVhMUg==", + "errorPath": "frame.attemptId" + }, + { + "name": "unknown-kind", + "base64": "QVhMUgEJERERERERQRGBERERERERESIiIiIiIkIigiIiIiIiIiIAAQL/QVhMUg==", + "errorPath": "frame" + } + ] +} diff --git a/packages/protocol/test/remote-transport.test.ts b/packages/protocol/test/remote-transport.test.ts new file mode 100644 index 00000000..ff958c73 --- /dev/null +++ b/packages/protocol/test/remote-transport.test.ts @@ -0,0 +1,279 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + decodeBase64, + decodeRemoteDaemonMessage, + DEFAULT_RELAY_LIMITS, + encodeBase64, + encodeRemoteDaemonMessage, + encodeInternalConsumeRelayTicketRequest, + encodeRelayBinaryFrame, + MAX_RELAY_FRAME_BYTES, + MAX_RELAY_OPAQUE_PAYLOAD_BYTES, + parseInternalConsumeRelayTicketRequest, + parseDeviceId, + parseIdempotencyKey, + parseInternalConsumeRelayTicketResult, + parseIssueRelayTicketRequest, + parseOpaqueOutboxRecord, + parseRelayBinaryFrame, + parseRelayDiscoveryMessage, + parseRelayRevocationNotification, + parseRemoteDeviceScopes, + parseRemoteRequestId, + ProtocolValidationError, + RELAY_FAILURE_CODE_VALUES, + REMOTE_TRANSPORT_VERSION, + type RelayBinaryFrame, +} from "../src/index.ts"; +import { DeterministicFakeRemoteCryptoAdapter } from "./support/fake-remote-crypto.ts"; + +interface BinaryFixture { + readonly accepted: readonly { + readonly name: string; + readonly base64: string; + readonly frame: Readonly>; + }[]; + readonly rejected: readonly { + readonly name: string; + readonly base64: string; + readonly errorPath: string; + }[]; +} + +const binaryFixtures = JSON.parse( + readFileSync(new URL("./fixtures/remote-transport-v1.json", import.meta.url), "utf8"), +) as BinaryFixture; +const internalFixtures = JSON.parse( + readFileSync(new URL("./fixtures/internal-relay-api-v1.json", import.meta.url), "utf8"), +) as { + readonly consumeTicket: { readonly request: unknown; readonly result: unknown }; + readonly discovery: { readonly deviceSnapshot: unknown }; + readonly revocation: { readonly request: unknown; readonly result: unknown }; +}; + +function fixtureShape(frame: RelayBinaryFrame): Readonly> { + if ("destinationRouteId" in frame) { + return { + kind: "send", + attemptId: frame.attemptId, + routeId: frame.destinationRouteId, + opaquePayloadBase64: encodeBase64(frame.opaquePayload), + }; + } + if ("sourceRouteId" in frame) { + return { + kind: "delivery", + attemptId: frame.attemptId, + routeId: frame.sourceRouteId, + opaquePayloadBase64: encodeBase64(frame.opaquePayload), + }; + } + if ("status" in frame) + return { kind: "receipt", attemptId: frame.attemptId, status: frame.status }; + return { kind: "failure", attemptId: frame.attemptId, code: frame.code }; +} + +test("accepts and reproduces every canonical relay frame", () => { + for (const fixture of binaryFixtures.accepted) { + const bytes = decodeBase64(fixture.base64, `${fixture.name}.base64`, MAX_RELAY_FRAME_BYTES); + const parsed = parseRelayBinaryFrame(bytes); + assert.deepEqual(fixtureShape(parsed), fixture.frame, fixture.name); + assert.deepEqual(encodeRelayBinaryFrame(parsed), bytes, fixture.name); + } +}); + +test("rejects every malformed canonical relay frame", () => { + for (const fixture of binaryFixtures.rejected) { + const bytes = decodeBase64(fixture.base64, `${fixture.name}.base64`, MAX_RELAY_FRAME_BYTES); + assert.throws( + () => parseRelayBinaryFrame(bytes), + (error) => error instanceof ProtocolValidationError && error.path === fixture.errorPath, + fixture.name, + ); + } +}); + +test("keeps relay failure byte assignments stable", () => { + assert.deepEqual(RELAY_FAILURE_CODE_VALUES, { + bad_frame: 1, + unsupported_transport_version: 2, + unauthorized: 3, + forbidden_route: 4, + ticket_expired: 5, + ticket_consumed: 6, + destination_offline: 7, + rate_limited: 8, + queue_full: 9, + slow_consumer: 10, + service_unavailable: 11, + ticket_revoked: 12, + }); +}); + +test("enforces the complete frame bound before encoding", () => { + const attemptId = "11111111-1111-4111-8111-111111111111" as const; + const destinationRouteId = "22222222-2222-4222-8222-222222222222" as const; + const frame = { + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId, + destinationRouteId, + opaquePayload: new Uint8Array(MAX_RELAY_OPAQUE_PAYLOAD_BYTES + 1), + } as RelayBinaryFrame; + assert.throws( + () => encodeRelayBinaryFrame(frame), + (error) => error instanceof ProtocolValidationError && error.path === "frame.opaquePayload", + ); +}); + +test("validates role-scoped route discovery messages", () => { + assert.deepEqual( + parseRelayDiscoveryMessage(internalFixtures.discovery.deviceSnapshot), + internalFixtures.discovery.deviceSnapshot, + ); + assert.throws( + () => + parseRelayDiscoveryMessage({ + version: 1, + type: "route_available", + sourceRoute: { routeId: "11111111-1111-4111-8111-111111111111", role: "daemon" }, + peers: [], + }), + (error) => error instanceof ProtocolValidationError && error.path === "discovery.sourceRoute", + ); +}); + +test("keeps durable outbox destinations stable and relay routes ephemeral", () => { + const opaqueEnvelope = Uint8Array.of(1, 2, 3); + assert.deepEqual( + parseOpaqueOutboxRecord({ + requestId: "11111111-1111-4111-8111-111111111111", + idempotencyKey: "22222222-2222-4222-8222-222222222222", + destinationCryptoSessionId: "33333333-3333-4333-8333-333333333333", + opaqueEnvelope, + createdAt: 1_900_000_000_000, + state: "queued_local", + }), + { + requestId: "11111111-1111-4111-8111-111111111111", + idempotencyKey: "22222222-2222-4222-8222-222222222222", + destinationCryptoSessionId: "33333333-3333-4333-8333-333333333333", + opaqueEnvelope, + createdAt: 1_900_000_000_000, + state: "queued_local", + }, + ); + assert.throws( + () => + parseOpaqueOutboxRecord({ + requestId: "11111111-1111-4111-8111-111111111111", + idempotencyKey: "22222222-2222-4222-8222-222222222222", + destinationRouteId: "33333333-3333-4333-8333-333333333333", + opaqueEnvelope, + createdAt: 1_900_000_000_000, + state: "queued_local", + }), + (error) => + error instanceof ProtocolValidationError && error.path === "outboxRecord.destinationRouteId", + ); +}); + +test("validates daemon acceptance only inside the authenticated payload", () => { + const message = { + version: REMOTE_TRANSPORT_VERSION, + type: "daemon_accepted" as const, + requestId: parseRemoteRequestId("11111111-1111-4111-8111-111111111111"), + idempotencyKey: parseIdempotencyKey("22222222-2222-4222-8222-222222222222"), + }; + assert.deepEqual(decodeRemoteDaemonMessage(encodeRemoteDaemonMessage(message)), message); + assert.throws( + () => + decodeRemoteDaemonMessage( + new TextEncoder().encode( + JSON.stringify({ ...message, idempotencyKey: "not-an-idempotency-key" }), + ), + ), + (error) => + error instanceof ProtocolValidationError && + error.path === "remoteDaemonMessage.idempotencyKey", + ); +}); + +test("validates and canonicalizes remote device scopes", () => { + assert.deepEqual(parseRemoteDeviceScopes(["steer", "observe"]), ["observe", "steer"]); + assert.throws( + () => parseRemoteDeviceScopes(["observe", "observe"]), + (error) => error instanceof ProtocolValidationError && error.path === "scopes", + ); + assert.throws( + () => parseRemoteDeviceScopes(["unsafe"]), + (error) => error instanceof ProtocolValidationError && error.path === "scopes[0]", + ); +}); + +test("keeps the deterministic fake E2EE adapter in test support", async () => { + const daemonId = parseDeviceId("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + const deviceId = parseDeviceId("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"); + const daemon = new DeterministicFakeRemoteCryptoAdapter(daemonId, deviceId); + const device = new DeterministicFakeRemoteCryptoAdapter(deviceId, daemonId); + const plaintext = Uint8Array.of(0, 1, 2, 255); + + const opaque = await device.seal(daemonId, plaintext); + assert.match(new TextDecoder().decode(opaque), /TEST_ONLY_NOT_ENCRYPTED/); + assert.deepEqual(await daemon.open(opaque), { + authenticatedDeviceId: deviceId, + plaintext, + }); + + const modified = JSON.parse(new TextDecoder().decode(opaque)) as Record; + modified.sourceDeviceId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + await assert.rejects(daemon.open(new TextEncoder().encode(JSON.stringify(modified)))); +}); + +test("validates ticket roles and the language-neutral internal contract", () => { + assert.deepEqual( + parseIssueRelayTicketRequest({ + installationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + deviceId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: "device", + }), + { + installationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + deviceId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: "device", + }, + ); + assert.throws( + () => + parseIssueRelayTicketRequest({ + installationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + role: "device", + }), + (error) => error instanceof ProtocolValidationError && error.path === "request.deviceId", + ); + + const request = parseInternalConsumeRelayTicketRequest(internalFixtures.consumeTicket.request); + assert.deepEqual([...request.possessionProof], [0, 1, 2, 3, 255]); + assert.deepEqual( + encodeInternalConsumeRelayTicketRequest(request), + internalFixtures.consumeTicket.request, + ); + assert.deepEqual(parseInternalConsumeRelayTicketResult(internalFixtures.consumeTicket.result), { + installationId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + deviceId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + sourceRouteId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + role: "device", + grantGeneration: 7, + leaseExpiresAt: 2_000_000_000_000, + limits: DEFAULT_RELAY_LIMITS, + }); + assert.deepEqual( + parseRelayRevocationNotification(internalFixtures.revocation.request), + internalFixtures.revocation.request, + ); +}); diff --git a/packages/protocol/test/support/fake-remote-crypto.ts b/packages/protocol/test/support/fake-remote-crypto.ts new file mode 100644 index 00000000..245313ff --- /dev/null +++ b/packages/protocol/test/support/fake-remote-crypto.ts @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { decodeBase64, encodeBase64, parseDeviceId, type DeviceId } from "../../src/index.ts"; + +export interface AuthenticatedPlaintext { + readonly authenticatedDeviceId: DeviceId; + readonly plaintext: Uint8Array; +} + +export interface RemoteCryptoAdapter { + open(opaqueEnvelope: Uint8Array): Promise; + seal(destinationDeviceId: DeviceId, plaintext: Uint8Array): Promise; +} + +interface FakeEnvelope { + readonly warning: "TEST_ONLY_NOT_ENCRYPTED"; + readonly sourceDeviceId: string; + readonly destinationDeviceId: string; + readonly plaintextBase64: string; +} + +/** Deterministic test framing. It provides no confidentiality, integrity, or replay protection. */ +export class DeterministicFakeRemoteCryptoAdapter implements RemoteCryptoAdapter { + private readonly localDeviceId: DeviceId; + private readonly expectedRemoteDeviceId: DeviceId; + + constructor(localDeviceId: DeviceId, expectedRemoteDeviceId: DeviceId) { + this.localDeviceId = localDeviceId; + this.expectedRemoteDeviceId = expectedRemoteDeviceId; + } + + async open(opaqueEnvelope: Uint8Array): Promise { + let candidate: FakeEnvelope; + try { + candidate = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(opaqueEnvelope)); + } catch (cause) { + throw new Error("Fake E2EE envelope is invalid", { cause }); + } + if ( + candidate.warning !== "TEST_ONLY_NOT_ENCRYPTED" || + parseDeviceId(candidate.sourceDeviceId) !== this.expectedRemoteDeviceId || + parseDeviceId(candidate.destinationDeviceId) !== this.localDeviceId + ) { + throw new Error("Fake E2EE envelope identity does not match the test endpoints"); + } + return { + authenticatedDeviceId: this.expectedRemoteDeviceId, + plaintext: decodeBase64(candidate.plaintextBase64, "fakeEnvelope.plaintextBase64", 65_535), + }; + } + + async seal(destinationDeviceId: DeviceId, plaintext: Uint8Array): Promise { + if (destinationDeviceId !== this.expectedRemoteDeviceId) { + throw new Error("Fake E2EE destination does not match the configured test endpoint"); + } + return new TextEncoder().encode( + JSON.stringify({ + warning: "TEST_ONLY_NOT_ENCRYPTED", + sourceDeviceId: this.localDeviceId, + destinationDeviceId, + plaintextBase64: encodeBase64(plaintext), + } satisfies FakeEnvelope), + ); + } +} diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 85168ed6..de9a603a 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -1,4 +1,5 @@ + # `@axl/sdk` @@ -28,6 +29,9 @@ The SDK owns: - explicit prompt delivery outcomes across send, steer, follow-up, queue, and interrupt workflows - bounded, content-verified blob uploads with progress and cancellation - generation-checked workspace browsing, file reads, diffs, and checkpoint controls +- an injected atomic opaque-outbox store that persists stable crypto-session destinations, resolves ephemeral routes per attempt, retries exact ciphertext bytes, and removes mutations only after authenticated daemon acceptance +- one-use relay-ticket acquisition, bounded WebSocket admission, role-filtered route discovery, and bounded reconnect +- relay receipt diagnostics and opaque inbound delivery through an injected authenticated opener The SDK does not own: @@ -38,7 +42,7 @@ The SDK does not own: - provider-specific authentication - terminal, browser, desktop, or mobile presentation -Those responsibilities remain in the daemon, kernel, runtime, provider, and client packages. +Those responsibilities remain in the daemon, kernel, runtime, provider, and client packages. The remote delivery API consumes immutable prepared envelopes. It does not create ciphertext or commit cryptographic state; the current outbox transaction is fake-E2EE scaffolding until Person 1 supplies the reviewed atomic OpenMLS prepared-envelope contract. ## Public entry points diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 21996779..c7ca37ae 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -10,6 +10,8 @@ export * from "./delivery.ts"; export * from "./models.ts"; export * from "./presentation.ts"; export * from "./projector.ts"; +export * from "./remote-outbox.ts"; +export * from "./remote-relay.ts"; export * from "./subscription.ts"; export * from "./workspace.ts"; export * from "./host.ts"; diff --git a/packages/sdk/src/remote-outbox.ts b/packages/sdk/src/remote-outbox.ts new file mode 100644 index 00000000..40c27ceb --- /dev/null +++ b/packages/sdk/src/remote-outbox.ts @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { + parseOpaqueOutboxRecord, + type CryptoSessionId, + type OpaqueOutboxRecord, + type RequestId, + type RouteId, + type TransportAttemptId, +} from "@axl/protocol"; + +export interface OpaqueOutboxTransaction { + readonly record?: OpaqueOutboxRecord; + readonly result: Result; +} + +/** Platform stores must commit each transaction atomically and durably. */ +export interface OpaqueOutboxStore { + transact( + requestId: RequestId, + operation: (current: OpaqueOutboxRecord | undefined) => OpaqueOutboxTransaction, + ): Promise; + list(): Promise; +} + +export interface TransportAttemptIdFactory { + create(): TransportAttemptId; +} + +export interface OpaqueRouteResolver { + resolve(destinationCryptoSessionId: CryptoSessionId): Promise; +} + +export interface OpaqueTransportAttempt { + readonly attemptId: TransportAttemptId; + readonly requestId: RequestId; + readonly destinationRouteId: RouteId; + readonly opaqueEnvelope: Uint8Array; +} + +export class OpaqueOutboxError extends Error { + readonly code: "outbox_conflict" | "unknown_request" | "not_daemon_accepted"; + + constructor(code: OpaqueOutboxError["code"], message: string) { + super(message); + this.name = "OpaqueOutboxError"; + this.code = code; + } +} + +function sameBytes(left: Uint8Array, right: Uint8Array): boolean { + return left.byteLength === right.byteLength && left.every((byte, index) => byte === right[index]); +} + +function sameRecord(left: OpaqueOutboxRecord, right: OpaqueOutboxRecord): boolean { + return ( + left.requestId === right.requestId && + left.idempotencyKey === right.idempotencyKey && + left.destinationCryptoSessionId === right.destinationCryptoSessionId && + left.createdAt === right.createdAt && + sameBytes(left.opaqueEnvelope, right.opaqueEnvelope) + ); +} + +/** Reliable opaque-byte delivery state. It never encrypts or re-encrypts a request. */ +export class OpaqueOutbox { + private readonly store: OpaqueOutboxStore; + private readonly attemptIds: TransportAttemptIdFactory; + private readonly routes: OpaqueRouteResolver; + + constructor( + store: OpaqueOutboxStore, + attemptIds: TransportAttemptIdFactory, + routes: OpaqueRouteResolver, + ) { + this.store = store; + this.attemptIds = attemptIds; + this.routes = routes; + } + + enqueue(value: OpaqueOutboxRecord): Promise { + const record = parseOpaqueOutboxRecord(value); + if (record.state !== "queued_local") { + throw new OpaqueOutboxError("outbox_conflict", "A new outbox record must be queued locally"); + } + return this.store.transact(record.requestId, (current) => { + if (current !== undefined && !sameRecord(current, record)) { + throw new OpaqueOutboxError( + "outbox_conflict", + "Request ID is already bound to different opaque bytes or metadata", + ); + } + return { record: current ?? record, result: undefined }; + }); + } + + async beginAttempt(requestId: RequestId): Promise { + const prepared = await this.store.transact(requestId, (current) => { + if (current === undefined) { + throw new OpaqueOutboxError("unknown_request", "Outbox request does not exist"); + } + if (current.state === "daemon_accepted") { + throw new OpaqueOutboxError("outbox_conflict", "Accepted request must not be resent"); + } + const record = parseOpaqueOutboxRecord({ ...current, state: "sending" }); + return { + record, + result: { + attemptId: this.attemptIds.create(), + requestId: record.requestId, + destinationCryptoSessionId: record.destinationCryptoSessionId, + opaqueEnvelope: record.opaqueEnvelope.slice(), + }, + }; + }); + const destinationRouteId = await this.routes.resolve(prepared.destinationCryptoSessionId); + return { + attemptId: prepared.attemptId, + requestId: prepared.requestId, + destinationRouteId, + opaqueEnvelope: prepared.opaqueEnvelope, + }; + } + + markQueued(requestId: RequestId): Promise { + return this.store.transact(requestId, (current) => { + if (current === undefined) { + throw new OpaqueOutboxError("unknown_request", "Outbox request does not exist"); + } + if (current.state === "daemon_accepted") { + return { record: current, result: undefined }; + } + return { + record: parseOpaqueOutboxRecord({ ...current, state: "queued_local" }), + result: undefined, + }; + }); + } + + markDaemonAccepted(requestId: RequestId): Promise { + return this.store.transact(requestId, (current) => { + if (current === undefined) { + throw new OpaqueOutboxError("unknown_request", "Outbox request does not exist"); + } + return { + record: parseOpaqueOutboxRecord({ ...current, state: "daemon_accepted" }), + result: undefined, + }; + }); + } + + removeAccepted(requestId: RequestId): Promise { + return this.store.transact(requestId, (current) => { + if (current === undefined) return { result: undefined }; + if (current.state !== "daemon_accepted") { + throw new OpaqueOutboxError( + "not_daemon_accepted", + "Only a daemon-accepted request may leave the outbox", + ); + } + return { result: undefined }; + }); + } + + resetSendingAfterDisconnect(): Promise { + return this.store.list().then(async (records) => { + for (const record of records) { + if (record.state !== "sending") continue; + await this.store.transact(record.requestId, (current) => { + if (current === undefined) return { result: undefined }; + if (current.state !== "sending") return { record: current, result: undefined }; + return { + record: parseOpaqueOutboxRecord({ ...current, state: "queued_local" }), + result: undefined, + }; + }); + } + }); + } + + list(): Promise { + return this.store + .list() + .then((records) => + records + .map((record) => parseOpaqueOutboxRecord(record)) + .sort( + (left, right) => + left.createdAt - right.createdAt || left.requestId.localeCompare(right.requestId), + ), + ); + } +} diff --git a/packages/sdk/src/remote-relay.ts b/packages/sdk/src/remote-relay.ts new file mode 100644 index 00000000..3cb414a7 --- /dev/null +++ b/packages/sdk/src/remote-relay.ts @@ -0,0 +1,943 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { + REMOTE_TRANSPORT_VERSION, + decodeRemoteDaemonMessage, + encodeBase64, + encodeRelayBinaryFrame, + parseDeviceId, + parseIssueRelayTicketRequest, + parseIssueRelayTicketResult, + parseRelayBinaryFrame, + parseRelayDiscoveryMessage, + type CryptoSessionId, + type DeviceId, + type IssueRelayTicketRequest, + type IssueRelayTicketResult, + type OpaqueOutboxRecord, + type RelayDelivery, + type RelayFailure, + type RelayPeerRoute, + type RelayReceipt, + type RemoteDaemonMessage, + type RemoteDeliveryState, + type RequestId, + type RouteId, + type TransportAttemptId, +} from "@axl/protocol"; + +import type { OpaqueOutbox, TransportAttemptIdFactory } from "./remote-outbox.ts"; + +const MAX_ADMISSION_BYTES = 4_096; +const DEFAULT_ROUTE_WAIT_MS = 10_000; + +export interface RelayAdmissionCredential extends IssueRelayTicketResult { + readonly connectionNonce: string; + readonly possessionProof: Uint8Array; +} + +export interface RelayTicketProvider { + acquire(): Promise; +} + +export interface RelayPossessionProofProvider { + create( + ticket: IssueRelayTicketResult, + ): Promise<{ readonly connectionNonce: string; readonly possessionProof: Uint8Array }>; +} + +export interface RemoteFetchResponse { + readonly ok: boolean; + readonly status: number; + json(): Promise; +} + +export type RemoteFetch = ( + input: string, + init: { + readonly method: "POST"; + readonly headers: Readonly>; + readonly body: string; + }, +) => Promise; + +export interface HttpRelayTicketProviderOptions { + readonly controlPlaneOrigin: string; + readonly request: IssueRelayTicketRequest; + readonly authenticationHeaders: () => Promise>>; + readonly proof: RelayPossessionProofProvider; + readonly fetch?: RemoteFetch; + /** Test-only escape hatch. Production control-plane traffic must use HTTPS. */ + readonly allowInsecureLoopbackForTests?: boolean; +} + +function validatedControlPlaneOrigin(options: HttpRelayTicketProviderOptions): string { + const url = new URL(options.controlPlaneOrigin); + if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") { + throw new TypeError("Control-plane origin must not contain credentials, query, or fragment"); + } + const loopback = url.hostname === "127.0.0.1" || url.hostname === "[::1]"; + if (url.protocol !== "https:" && !(options.allowInsecureLoopbackForTests === true && loopback)) { + throw new TypeError("Control-plane origin must use HTTPS"); + } + return url.origin; +} + +/** Acquires one-use relay admission without placing credentials in a URL. */ +export class HttpRelayTicketProvider implements RelayTicketProvider { + private readonly options: HttpRelayTicketProviderOptions; + private readonly origin: string; + private readonly request: RemoteFetch; + private readonly ticketRequest: IssueRelayTicketRequest; + + constructor(options: HttpRelayTicketProviderOptions) { + this.options = options; + this.ticketRequest = parseIssueRelayTicketRequest(options.request); + this.origin = validatedControlPlaneOrigin(options); + const fetcher = options.fetch ?? (globalThis as { fetch?: RemoteFetch }).fetch; + if (fetcher === undefined) throw new TypeError("A fetch implementation is required"); + this.request = fetcher; + } + + async acquire(): Promise { + const authentication = await this.options.authenticationHeaders(); + const response = await this.request(`${this.origin}/v1/relay/tickets`, { + method: "POST", + headers: { ...authentication, "content-type": "application/json" }, + body: JSON.stringify(this.ticketRequest), + }); + if (!response.ok) { + throw new RemoteRelayError( + "ticket_unavailable", + `Relay ticket request failed with HTTP ${response.status}`, + ); + } + const ticket = parseIssueRelayTicketResult(await response.json()); + const proof = await this.options.proof.create(ticket); + const nonceBytes = new TextEncoder().encode(proof.connectionNonce).byteLength; + if ( + nonceBytes === 0 || + nonceBytes > 256 || + !(proof.possessionProof instanceof Uint8Array) || + proof.possessionProof.byteLength === 0 || + proof.possessionProof.byteLength > 1_024 + ) { + throw new RemoteRelayError("invalid_admission", "Possession proof is outside relay bounds"); + } + return { ...ticket, ...proof }; + } +} + +interface RemoteWebSocketOpenEvent { + readonly type: "open"; +} + +interface RemoteWebSocketMessageEvent { + readonly type: "message"; + readonly data: unknown; +} + +interface RemoteWebSocketCloseEvent { + readonly type: "close"; + readonly code?: number; + readonly reason?: string; +} + +interface RemoteWebSocketErrorEvent { + readonly type: "error"; +} + +export type RemoteWebSocketEvent = + | RemoteWebSocketOpenEvent + | RemoteWebSocketMessageEvent + | RemoteWebSocketCloseEvent + | RemoteWebSocketErrorEvent; + +type RemoteWebSocketListener = (event: RemoteWebSocketEvent) => void; + +export interface RemoteWebSocket { + binaryType: string; + readonly readyState: number; + send(data: Uint8Array): void; + close(code?: number, reason?: string): void; + addEventListener(type: RemoteWebSocketEvent["type"], listener: RemoteWebSocketListener): void; + removeEventListener(type: RemoteWebSocketEvent["type"], listener: RemoteWebSocketListener): void; +} + +export interface RemoteWebSocketFactory { + connect(url: string): RemoteWebSocket; +} + +export class GlobalRemoteWebSocketFactory implements RemoteWebSocketFactory { + connect(url: string): RemoteWebSocket { + const Constructor = (globalThis as { WebSocket?: new (url: string) => RemoteWebSocket }) + .WebSocket; + if (Constructor === undefined) throw new TypeError("A WebSocket implementation is required"); + return new Constructor(url); + } +} + +export type RemoteRelayConnectionState = + | "disconnected" + | "connecting" + | "connected" + | "reconnecting" + | "closed"; + +export interface RemoteReconnectPolicy { + readonly maximumAttempts: number; + readonly initialDelayMs: number; + readonly maximumDelayMs: number; + readonly jitterRatio: number; +} + +const DEFAULT_RECONNECT_POLICY: RemoteReconnectPolicy = Object.freeze({ + maximumAttempts: 8, + initialDelayMs: 250, + maximumDelayMs: 10_000, + jitterRatio: 0.2, +}); + +export interface RemoteRelayConnectionOptions { + readonly tickets: RelayTicketProvider; + readonly sockets?: RemoteWebSocketFactory; + readonly destinationCryptoSessionId?: CryptoSessionId; + readonly reconnect?: Partial; + readonly routeWaitMs?: number; + readonly random?: () => number; + readonly sleep?: (milliseconds: number) => Promise; +} + +export type RemoteRelayErrorCode = + | "ticket_unavailable" + | "invalid_admission" + | "connection_failed" + | "connection_closed" + | "daemon_offline" + | "wrong_destination" + | "bad_relay_message" + | "frame_too_large"; + +export class RemoteRelayError extends Error { + readonly code: RemoteRelayErrorCode; + + constructor( + code: RemoteRelayErrorCode, + message: string, + options: { readonly cause?: unknown } = {}, + ) { + super(message, options); + this.name = "RemoteRelayError"; + this.code = code; + } +} + +function positiveInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${name} must be positive`); + return value; +} + +function reconnectPolicy(value: Partial = {}): RemoteReconnectPolicy { + const policy = { ...DEFAULT_RECONNECT_POLICY, ...value }; + positiveInteger(policy.maximumAttempts, "maximumAttempts"); + positiveInteger(policy.initialDelayMs, "initialDelayMs"); + positiveInteger(policy.maximumDelayMs, "maximumDelayMs"); + if (policy.maximumDelayMs < policy.initialDelayMs) { + throw new TypeError("maximumDelayMs must cover initialDelayMs"); + } + if (!Number.isFinite(policy.jitterRatio) || policy.jitterRatio < 0 || policy.jitterRatio > 1) { + throw new TypeError("jitterRatio must be from zero through one"); + } + return policy; +} + +function rejectOversizedMessage(): never { + throw new RemoteRelayError("frame_too_large", "Relay message exceeds the negotiated frame limit"); +} + +function boundedBytes(bytes: Uint8Array, maximumBytes: number): Uint8Array { + if (bytes.byteLength > maximumBytes) rejectOversizedMessage(); + return bytes; +} + +async function messageBytes(value: unknown, maximumBytes: number): Promise { + if (value instanceof Uint8Array) return boundedBytes(value, maximumBytes); + if (value instanceof ArrayBuffer) { + if (value.byteLength > maximumBytes) rejectOversizedMessage(); + return new Uint8Array(value); + } + if (ArrayBuffer.isView(value)) { + if (value.byteLength > maximumBytes) rejectOversizedMessage(); + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice(); + } + if (typeof Blob !== "undefined" && value instanceof Blob) { + if (value.size > maximumBytes) rejectOversizedMessage(); + return boundedBytes(new Uint8Array(await value.arrayBuffer()), maximumBytes); + } + if (typeof value === "string") { + if (value.length > maximumBytes) rejectOversizedMessage(); + return boundedBytes(new TextEncoder().encode(value), maximumBytes); + } + throw new RemoteRelayError("bad_relay_message", "Relay message is not supported binary data"); +} + +function isRelayFrame(bytes: Uint8Array): boolean { + return ( + bytes.byteLength >= 4 && + bytes[0] === 0x41 && + bytes[1] === 0x58 && + bytes[2] === 0x4c && + bytes[3] === 0x52 + ); +} + +function admissionBytes(credential: RelayAdmissionCredential): Uint8Array { + const bytes = new TextEncoder().encode( + JSON.stringify({ + version: REMOTE_TRANSPORT_VERSION, + ticket: credential.ticket, + connectionNonce: credential.connectionNonce, + possessionProof: encodeBase64(credential.possessionProof), + }), + ); + if (bytes.byteLength === 0 || bytes.byteLength > MAX_ADMISSION_BYTES) { + throw new RemoteRelayError("invalid_admission", "Relay admission message exceeds its bound"); + } + return bytes; +} + +type RouteWaiter = { + readonly resolve: (route: RouteId) => void; + readonly reject: (error: Error) => void; + readonly timer: ReturnType; +}; + +/** Ticket-admitted opaque WebSocket connection with bounded reconnect and route discovery. */ +export class RemoteRelayConnection { + private readonly options: RemoteRelayConnectionOptions; + private readonly sockets: RemoteWebSocketFactory; + private readonly policy: RemoteReconnectPolicy; + private readonly routeWaitMs: number; + private readonly random: () => number; + private readonly sleep: (milliseconds: number) => Promise; + private readonly receiptListeners = new Set<(receipt: RelayReceipt) => void>(); + private readonly failureListeners = new Set<(failure: RelayFailure) => void>(); + private readonly deliveryListeners = new Set<(delivery: RelayDelivery) => void>(); + private readonly routeListeners = new Set<(peers: readonly RelayPeerRoute[]) => void>(); + private readonly stateListeners = new Set<(state: RemoteRelayConnectionState) => void>(); + private readonly routeWaiters = new Set(); + private socket: RemoteWebSocket | undefined; + private sourceRoute: RelayPeerRoute | undefined; + private peers = new Map(); + private generation = 0; + private lifecycleGeneration = 0; + private stopped = true; + private starting: Promise | undefined; + private reconnecting: Promise | undefined; + private activeMaxFrameBytes: number | undefined; + private currentState: RemoteRelayConnectionState = "disconnected"; + + constructor(options: RemoteRelayConnectionOptions) { + this.options = options; + this.sockets = options.sockets ?? new GlobalRemoteWebSocketFactory(); + this.policy = reconnectPolicy(options.reconnect); + this.routeWaitMs = positiveInteger(options.routeWaitMs ?? DEFAULT_ROUTE_WAIT_MS, "routeWaitMs"); + this.random = options.random ?? Math.random; + this.sleep = + options.sleep ?? + ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + } + + get state(): RemoteRelayConnectionState { + return this.currentState; + } + + get routes(): readonly RelayPeerRoute[] { + return [...this.peers.values()]; + } + + get source(): RelayPeerRoute | undefined { + return this.sourceRoute; + } + + start(): Promise { + if (this.currentState === "connected") return Promise.resolve(); + if (this.starting !== undefined) return this.starting; + if (this.reconnecting !== undefined) return this.reconnecting; + this.stopped = false; + const lifecycleGeneration = ++this.lifecycleGeneration; + const operation = this.connectWithRetry("connecting", lifecycleGeneration); + const starting = operation.finally(() => { + if (this.starting === starting) this.starting = undefined; + }); + this.starting = starting; + return starting; + } + + close(): void { + if (this.stopped && this.currentState === "closed") return; + this.stopped = true; + this.lifecycleGeneration += 1; + this.generation += 1; + this.socket?.close(1000, "client_closed"); + this.socket = undefined; + this.activeMaxFrameBytes = undefined; + this.clearRoutes(); + this.rejectRouteWaiters( + new RemoteRelayError("connection_closed", "Relay connection is closed"), + ); + this.setState("closed"); + } + + onState(listener: (state: RemoteRelayConnectionState) => void): () => void { + this.stateListeners.add(listener); + return () => this.stateListeners.delete(listener); + } + + onRoutes(listener: (peers: readonly RelayPeerRoute[]) => void): () => void { + this.routeListeners.add(listener); + return () => this.routeListeners.delete(listener); + } + + onReceipt(listener: (receipt: RelayReceipt) => void): () => void { + this.receiptListeners.add(listener); + return () => this.receiptListeners.delete(listener); + } + + onFailure(listener: (failure: RelayFailure) => void): () => void { + this.failureListeners.add(listener); + return () => this.failureListeners.delete(listener); + } + + onDelivery(listener: (delivery: RelayDelivery) => void): () => void { + this.deliveryListeners.add(listener); + return () => this.deliveryListeners.delete(listener); + } + + async resolve(destinationCryptoSessionId: CryptoSessionId): Promise { + if ( + this.options.destinationCryptoSessionId === undefined || + destinationCryptoSessionId !== this.options.destinationCryptoSessionId + ) { + throw new RemoteRelayError( + "wrong_destination", + "Prepared envelope targets another crypto session", + ); + } + const current = this.daemonRoute(); + if (current !== undefined) return current; + if (this.stopped) throw new RemoteRelayError("connection_closed", "Relay is not running"); + return new Promise((resolve, reject) => { + const waiter: RouteWaiter = { + resolve, + reject, + timer: setTimeout(() => { + this.routeWaiters.delete(waiter); + reject(new RemoteRelayError("daemon_offline", "Daemon route is unavailable")); + }, this.routeWaitMs), + }; + this.routeWaiters.add(waiter); + }); + } + + send( + destinationRouteId: RouteId, + attemptId: RelayDelivery["attemptId"], + payload: Uint8Array, + ): void { + const socket = this.socket; + if (this.currentState !== "connected" || socket === undefined || socket.readyState !== 1) { + throw new RemoteRelayError("connection_closed", "Relay connection is not connected"); + } + const maximumBytes = this.activeMaxFrameBytes; + if (maximumBytes === undefined) { + throw new RemoteRelayError("connection_closed", "Relay connection has no active limits"); + } + const frame = encodeRelayBinaryFrame({ + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId, + destinationRouteId, + opaquePayload: payload, + }); + if (frame.byteLength > maximumBytes) { + throw new RemoteRelayError( + "frame_too_large", + "Relay frame exceeds the negotiated frame limit", + ); + } + socket.send(frame); + } + + private async connectWithRetry( + state: "connecting" | "reconnecting", + lifecycleGeneration: number, + ): Promise { + if (!this.lifecycleIsActive(lifecycleGeneration)) return; + this.setState(state); + let latest: unknown; + for (let attempt = 0; attempt < this.policy.maximumAttempts; attempt += 1) { + if (attempt > 0) { + await this.sleep(this.retryDelay(attempt - 1)); + if (!this.lifecycleIsActive(lifecycleGeneration)) return; + } + try { + await this.connectOnce(lifecycleGeneration); + return; + } catch (error) { + latest = error; + this.discardFailedSocket(); + if (!this.lifecycleIsActive(lifecycleGeneration)) return; + } + } + if (!this.lifecycleIsActive(lifecycleGeneration)) return; + this.stopped = true; + this.setState("disconnected"); + throw new RemoteRelayError("connection_failed", "Relay reconnect attempts were exhausted", { + cause: latest, + }); + } + + private lifecycleIsActive(generation: number): boolean { + return !this.stopped && generation === this.lifecycleGeneration; + } + + private async connectOnce(lifecycleGeneration: number): Promise { + const credential = await this.options.tickets.acquire(); + if (!this.lifecycleIsActive(lifecycleGeneration)) return; + if (credential.expiresAt <= Date.now()) { + throw new RemoteRelayError("invalid_admission", "Relay ticket is already expired"); + } + const generation = ++this.generation; + const socket = this.sockets.connect(credential.relayUrl); + socket.binaryType = "arraybuffer"; + this.socket = socket; + + await new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: Error): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error === undefined) resolve(); + else reject(error); + }; + const timer = setTimeout( + () => { + socket.close(1008, "route_snapshot_timeout"); + finish(new RemoteRelayError("connection_failed", "Relay route snapshot timed out")); + }, + Math.min(this.routeWaitMs, credential.limits.idleTimeoutMs), + ); + const open: RemoteWebSocketListener = () => { + try { + const admission = admissionBytes(credential); + if (admission.byteLength > credential.limits.maxFrameBytes) { + throw new RemoteRelayError( + "frame_too_large", + "Relay admission exceeds the negotiated frame limit", + ); + } + socket.send(admission); + } catch (cause) { + finish( + new RemoteRelayError("invalid_admission", "Could not send relay admission", { cause }), + ); + } + }; + const message: RemoteWebSocketListener = (event) => { + if (event.type !== "message") return; + void this.handleMessage(event.data, generation, credential.limits.maxFrameBytes) + .then((snapshot) => { + if (snapshot) finish(); + }) + .catch((cause: unknown) => { + socket.close(1008, "bad_relay_message"); + finish( + new RemoteRelayError("bad_relay_message", "Relay sent an invalid message", { cause }), + ); + }); + }; + const closed: RemoteWebSocketListener = (event) => { + if (event.type !== "close") return; + const error = new RemoteRelayError( + "connection_closed", + `Relay closed during admission${event.reason ? `: ${event.reason}` : ""}`, + ); + finish(error); + this.handleSocketClosed(generation, error); + }; + const failed: RemoteWebSocketListener = () => + finish(new RemoteRelayError("connection_failed", "Relay WebSocket failed")); + socket.addEventListener("open", open); + socket.addEventListener("message", message); + socket.addEventListener("close", closed); + socket.addEventListener("error", failed); + }); + if (generation !== this.generation || this.stopped) { + socket.close(1000, "stale_connection"); + throw new RemoteRelayError("connection_closed", "Relay connection became stale"); + } + this.activeMaxFrameBytes = credential.limits.maxFrameBytes; + this.setState("connected"); + } + + private async handleMessage( + value: unknown, + generation: number, + maximumBytes: number, + ): Promise { + if (generation !== this.generation || this.stopped) return false; + const bytes = await messageBytes(value, maximumBytes); + if (generation !== this.generation || this.stopped) return false; + if (!isRelayFrame(bytes)) { + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } catch (cause) { + throw new RemoteRelayError("bad_relay_message", "Relay discovery is invalid JSON", { + cause, + }); + } + const discovery = parseRelayDiscoveryMessage(parsed); + this.applyDiscovery(discovery); + return discovery.type === "route_snapshot"; + } + const frame = parseRelayBinaryFrame(bytes); + if ("status" in frame) { + for (const listener of this.receiptListeners) listener(frame); + } else if ("code" in frame) { + for (const listener of this.failureListeners) listener(frame); + } else if ("sourceRouteId" in frame) { + for (const listener of this.deliveryListeners) listener(frame); + } else { + throw new RemoteRelayError("bad_relay_message", "Relay sent a client-only frame"); + } + return false; + } + + private discardFailedSocket(): void { + const socket = this.socket; + this.generation += 1; + this.socket = undefined; + this.activeMaxFrameBytes = undefined; + socket?.close(1000, "connection_attempt_failed"); + this.clearRoutes(); + } + + private applyDiscovery(message: ReturnType): void { + if (message.type === "route_snapshot") { + this.sourceRoute = message.sourceRoute; + this.peers = new Map(message.peers.map((peer) => [peer.routeId, peer])); + } else if (message.type === "route_available") { + for (const peer of message.peers) { + for (const [routeId, current] of this.peers) { + if (current.role === peer.role && current.deviceId === peer.deviceId) { + this.peers.delete(routeId); + } + } + this.peers.set(peer.routeId, peer); + } + } else { + for (const peer of message.peers) this.peers.delete(peer.routeId); + } + this.resolveRouteWaiters(); + for (const listener of this.routeListeners) listener(this.routes); + } + + private daemonRoute(): RouteId | undefined { + return this.routes.find((peer) => peer.role === "daemon")?.routeId; + } + + private resolveRouteWaiters(): void { + const route = this.daemonRoute(); + if (route === undefined) return; + for (const waiter of this.routeWaiters) { + clearTimeout(waiter.timer); + waiter.resolve(route); + } + this.routeWaiters.clear(); + } + + private rejectRouteWaiters(error: Error): void { + for (const waiter of this.routeWaiters) { + clearTimeout(waiter.timer); + waiter.reject(error); + } + this.routeWaiters.clear(); + } + + private clearRoutes(): void { + this.sourceRoute = undefined; + if (this.peers.size === 0) return; + this.peers.clear(); + for (const listener of this.routeListeners) listener([]); + } + + private handleSocketClosed(generation: number, error: Error): void { + if (generation !== this.generation) return; + const wasConnected = this.currentState === "connected"; + this.socket = undefined; + this.activeMaxFrameBytes = undefined; + this.clearRoutes(); + this.rejectRouteWaiters(error); + if (!wasConnected || this.stopped || this.reconnecting !== undefined) return; + const reconnecting = this.connectWithRetry("reconnecting", this.lifecycleGeneration).catch( + () => undefined, + ); + this.reconnecting = reconnecting; + void reconnecting.finally(() => { + if (this.reconnecting === reconnecting) this.reconnecting = undefined; + }); + } + + private retryDelay(attempt: number): number { + const unjittered = Math.min( + this.policy.maximumDelayMs, + this.policy.initialDelayMs * 2 ** attempt, + ); + const random = this.random(); + if (!Number.isFinite(random) || random < 0 || random > 1) { + throw new TypeError("Reconnect random source must return a value from zero through one"); + } + const factor = 1 - this.policy.jitterRatio + 2 * this.policy.jitterRatio * random; + return Math.max(1, Math.round(unjittered * factor)); + } + + private setState(state: RemoteRelayConnectionState): void { + if (state === this.currentState) return; + this.currentState = state; + for (const listener of this.stateListeners) listener(state); + } +} + +export interface AuthenticatedRemotePayload { + readonly authenticatedPeerId: DeviceId; + readonly plaintext: Uint8Array; +} + +export interface RemotePayloadOpener { + open(opaqueEnvelope: Uint8Array): Promise; +} + +export interface RemoteDeliveryUpdate { + readonly requestId: RequestId; + readonly state: RemoteDeliveryState; + readonly attemptId?: RelayReceipt["attemptId"]; + readonly relayFailure?: RelayFailure["code"]; +} + +export interface RemoteHostedDeliveryOptions { + readonly connection: RemoteRelayConnection; + readonly outbox: OpaqueOutbox; + readonly opener: RemotePayloadOpener; + readonly expectedDaemonId: DeviceId; + readonly attemptIds: TransportAttemptIdFactory; +} + +/** Coordinates immutable prepared envelopes. It never creates or advances cryptographic state. */ +export class RemoteHostedDelivery { + private readonly options: RemoteHostedDeliveryOptions; + private readonly attempts = new Map(); + private readonly deliveryListeners = new Set<(update: RemoteDeliveryUpdate) => void>(); + private readonly messageListeners = new Set<(message: RemoteDaemonMessage) => void>(); + private readonly errorListeners = new Set<(error: Error) => void>(); + private flushTail: Promise = Promise.resolve(); + private inboundTail: Promise = Promise.resolve(); + private started = false; + private starting: Promise | undefined; + + constructor(options: RemoteHostedDeliveryOptions) { + this.options = options; + options.connection.onState((state) => { + if (state === "reconnecting" || state === "disconnected") { + this.attempts.clear(); + void options.outbox + .resetSendingAfterDisconnect() + .catch((cause: unknown) => this.reportError(cause, "Could not reset the remote outbox")); + } + if (state === "connected") { + void this.flush().catch((cause: unknown) => + this.reportError(cause, "Could not flush the remote outbox"), + ); + } + }); + options.connection.onRoutes(() => { + void this.flush().catch((cause: unknown) => + this.reportError(cause, "Could not flush the remote outbox"), + ); + }); + options.connection.onReceipt((receipt) => this.handleReceipt(receipt)); + options.connection.onFailure((failure) => { + void this.handleFailure(failure).catch((cause: unknown) => + this.reportError(cause, "Could not apply a relay failure"), + ); + }); + options.connection.onDelivery((delivery) => { + this.inboundTail = this.inboundTail + .then(() => this.handleDelivery(delivery)) + .catch((cause) => { + const error = + cause instanceof Error + ? cause + : new RemoteRelayError("bad_relay_message", "Remote delivery failed", { cause }); + for (const listener of this.errorListeners) listener(error); + }); + }); + } + + onDeliveryState(listener: (update: RemoteDeliveryUpdate) => void): () => void { + this.deliveryListeners.add(listener); + return () => this.deliveryListeners.delete(listener); + } + + onMessage(listener: (message: RemoteDaemonMessage) => void): () => void { + this.messageListeners.add(listener); + return () => this.messageListeners.delete(listener); + } + + onError(listener: (error: Error) => void): () => void { + this.errorListeners.add(listener); + return () => this.errorListeners.delete(listener); + } + + start(): Promise { + if (this.started && this.starting === undefined) return Promise.resolve(); + if (this.starting !== undefined) return this.starting; + this.started = true; + const operation = (async () => { + // A process can stop after persisting `sending` but before receiving acceptance. + // No live transport attempt survives startup, so every such record is retryable. + await this.options.outbox.resetSendingAfterDisconnect(); + await this.options.connection.start(); + await this.flush(); + })().catch((error: unknown) => { + this.started = false; + throw error; + }); + const starting = operation.finally(() => { + if (this.starting === starting) this.starting = undefined; + }); + this.starting = starting; + return starting; + } + + close(): void { + this.started = false; + this.options.connection.close(); + } + + async enqueuePrepared(record: OpaqueOutboxRecord): Promise { + await this.options.outbox.enqueue(record); + this.publish({ requestId: record.requestId, state: "queued_local" }); + await this.flush(); + } + + async sendPreparedEphemeral( + destinationCryptoSessionId: CryptoSessionId, + opaqueEnvelope: Uint8Array, + ): Promise { + const destinationRouteId = await this.options.connection.resolve(destinationCryptoSessionId); + const attemptId = this.options.attemptIds.create(); + this.options.connection.send(destinationRouteId, attemptId, opaqueEnvelope); + return attemptId; + } + + flush(): Promise { + const operation = this.flushTail.then(async () => { + if (!this.started || this.options.connection.state !== "connected") return; + const records = await this.options.outbox.list(); + let firstFailure: unknown; + for (const record of records) { + if (record.state !== "queued_local") continue; + try { + const attempt = await this.options.outbox.beginAttempt(record.requestId); + this.attempts.set(attempt.attemptId, attempt.requestId); + this.publish({ + requestId: attempt.requestId, + state: "sending", + attemptId: attempt.attemptId, + }); + this.options.connection.send( + attempt.destinationRouteId, + attempt.attemptId, + attempt.opaqueEnvelope, + ); + } catch (cause) { + await this.options.outbox.markQueued(record.requestId); + firstFailure ??= cause; + this.reportError(cause, "Could not send a prepared remote envelope"); + } + } + if (firstFailure !== undefined) throw firstFailure; + }); + this.flushTail = operation.catch(() => undefined); + return operation; + } + + private handleReceipt(receipt: RelayReceipt): void { + const requestId = this.attempts.get(receipt.attemptId); + if (requestId === undefined) return; + this.publish({ + requestId, + state: receipt.status === "admitted" ? "relay_admitted" : "relay_forwarded", + attemptId: receipt.attemptId, + }); + if (receipt.status === "forwarded") this.attempts.delete(receipt.attemptId); + } + + private async handleFailure(failure: RelayFailure): Promise { + const requestId = this.attempts.get(failure.attemptId); + if (requestId === undefined) return; + this.attempts.delete(failure.attemptId); + await this.options.outbox.markQueued(requestId); + this.publish({ + requestId, + state: "failed", + attemptId: failure.attemptId, + relayFailure: failure.code, + }); + } + + private async handleDelivery(delivery: RelayDelivery): Promise { + const opened = await this.options.opener.open(delivery.opaquePayload); + if (parseDeviceId(opened.authenticatedPeerId) !== this.options.expectedDaemonId) { + throw new RemoteRelayError( + "bad_relay_message", + "Delivery is not authenticated to the daemon", + ); + } + const message = decodeRemoteDaemonMessage(opened.plaintext); + if (message.type === "daemon_accepted") { + const record = (await this.options.outbox.list()).find( + (candidate) => candidate.requestId === message.requestId, + ); + if (record === undefined || record.idempotencyKey !== message.idempotencyKey) { + throw new RemoteRelayError( + "bad_relay_message", + "Daemon acceptance does not match the prepared request", + ); + } + await this.options.outbox.markDaemonAccepted(message.requestId); + this.publish({ requestId: message.requestId, state: "daemon_accepted" }); + await this.options.outbox.removeAccepted(message.requestId); + } else if (message.type === "daemon_result") { + this.publish({ requestId: message.requestId, state: "completed" }); + } else if (message.type === "daemon_error") { + this.publish({ requestId: message.requestId, state: "failed" }); + } + for (const listener of this.messageListeners) listener(message); + } + + private publish(update: RemoteDeliveryUpdate): void { + for (const listener of this.deliveryListeners) listener(update); + } + + private reportError(cause: unknown, message: string): void { + const error = + cause instanceof Error + ? cause + : new RemoteRelayError("connection_failed", message, { cause }); + for (const listener of this.errorListeners) listener(error); + } +} diff --git a/packages/sdk/test/remote-outbox.test.ts b/packages/sdk/test/remote-outbox.test.ts new file mode 100644 index 00000000..d5ec393b --- /dev/null +++ b/packages/sdk/test/remote-outbox.test.ts @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseCryptoSessionId, + parseIdempotencyKey, + parseRemoteRequestId, + parseRouteId, + parseTransportAttemptId, + type OpaqueOutboxRecord, + type RequestId, +} from "@axl/protocol"; + +import { + OpaqueOutbox, + OpaqueOutboxError, + type OpaqueOutboxStore, + type OpaqueOutboxTransaction, +} from "../src/remote-outbox.ts"; + +class MemoryOutboxStore implements OpaqueOutboxStore { + private readonly records = new Map(); + private tail: Promise = Promise.resolve(); + + transact( + requestId: RequestId, + operation: (current: OpaqueOutboxRecord | undefined) => OpaqueOutboxTransaction, + ): Promise { + const result = this.tail.then(() => { + const transaction = operation(this.records.get(requestId)); + if (transaction.record === undefined) this.records.delete(requestId); + else this.records.set(requestId, transaction.record); + return transaction.result; + }); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async list(): Promise { + await this.tail; + return [...this.records.values()]; + } +} + +const requestId = parseRemoteRequestId("11111111-1111-4111-8111-111111111111"); +const idempotencyKey = parseIdempotencyKey("22222222-2222-4222-8222-222222222222"); +const destinationCryptoSessionId = parseCryptoSessionId("33333333-3333-4333-8333-333333333333"); +const firstRouteId = parseRouteId("55555555-5555-4555-8555-555555555555"); +const secondRouteId = parseRouteId("66666666-6666-4666-8666-666666666666"); + +function record(bytes = Uint8Array.of(0, 1, 2, 255)): OpaqueOutboxRecord { + return { + requestId, + idempotencyKey, + destinationCryptoSessionId, + opaqueEnvelope: bytes, + createdAt: 1_900_000_000_000, + state: "queued_local", + }; +} + +function outbox(route = () => firstRouteId): OpaqueOutbox { + let attempt = 0; + return new OpaqueOutbox( + new MemoryOutboxStore(), + { + create() { + attempt += 1; + return parseTransportAttemptId( + `44444444-4444-4444-8444-${attempt.toString().padStart(12, "0")}`, + ); + }, + }, + { + async resolve() { + return route(); + }, + }, + ); +} + +test("resolves a fresh ephemeral route while retrying exact opaque bytes", async () => { + let currentRoute = firstRouteId; + const queue = outbox(() => currentRoute); + await queue.enqueue(record()); + + const first = await queue.beginAttempt(requestId); + currentRoute = secondRouteId; + const second = await queue.beginAttempt(requestId); + assert.notEqual(first.attemptId, second.attemptId); + assert.equal(first.destinationRouteId, firstRouteId); + assert.equal(second.destinationRouteId, secondRouteId); + assert.deepEqual(first.opaqueEnvelope, Uint8Array.of(0, 1, 2, 255)); + assert.deepEqual(second.opaqueEnvelope, first.opaqueEnvelope); + assert.equal((await queue.list())[0]?.state, "sending"); + + await queue.resetSendingAfterDisconnect(); + assert.equal((await queue.list())[0]?.state, "queued_local"); +}); + +test("rejects conflicting request IDs and removal before daemon acceptance", async () => { + const queue = outbox(); + await queue.enqueue(record()); + await queue.enqueue(record()); + await assert.rejects( + queue.enqueue(record(Uint8Array.of(9))), + (error) => error instanceof OpaqueOutboxError && error.code === "outbox_conflict", + ); + await assert.rejects( + queue.removeAccepted(requestId), + (error) => error instanceof OpaqueOutboxError && error.code === "not_daemon_accepted", + ); +}); + +test("removes a mutation only after daemon acceptance", async () => { + const queue = outbox(); + await queue.enqueue(record()); + await queue.beginAttempt(requestId); + + await queue.markDaemonAccepted(requestId); + assert.equal((await queue.list())[0]?.state, "daemon_accepted"); + await assert.rejects(queue.beginAttempt(requestId), /must not be resent/); + + await queue.removeAccepted(requestId); + assert.deepEqual(await queue.list(), []); + await queue.removeAccepted(requestId); +}); diff --git a/packages/sdk/test/remote-relay.test.ts b/packages/sdk/test/remote-relay.test.ts new file mode 100644 index 00000000..34db0abe --- /dev/null +++ b/packages/sdk/test/remote-relay.test.ts @@ -0,0 +1,628 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_RELAY_LIMITS, + REMOTE_TRANSPORT_VERSION, + encodeRelayBinaryFrame, + encodeRemoteDaemonMessage, + parseCryptoSessionId, + parseDeviceId, + parseIdempotencyKey, + parseInstallationId, + parseRelayBinaryFrame, + parseRemoteRequestId, + parseRouteId, + parseTransportAttemptId, + type OpaqueOutboxRecord, + type RequestId, +} from "@axl/protocol"; + +import { + OpaqueOutbox, + type OpaqueOutboxStore, + type OpaqueOutboxTransaction, +} from "../src/remote-outbox.ts"; +import { + HttpRelayTicketProvider, + RemoteHostedDelivery, + RemoteRelayConnection, + RemoteRelayError, + type RelayAdmissionCredential, + type RemoteRelayConnectionState, + type RemoteWebSocket, + type RemoteWebSocketEvent, + type RemoteWebSocketFactory, +} from "../src/remote-relay.ts"; + +class MemoryOutboxStore implements OpaqueOutboxStore { + readonly records = new Map(); + + async transact( + requestId: RequestId, + operation: (current: OpaqueOutboxRecord | undefined) => OpaqueOutboxTransaction, + ): Promise { + const transaction = operation(this.records.get(requestId)); + if (transaction.record === undefined) this.records.delete(requestId); + else this.records.set(requestId, transaction.record); + return transaction.result; + } + + async list(): Promise { + return [...this.records.values()]; + } +} + +type Listener = (event: RemoteWebSocketEvent) => void; + +class FakeSocket implements RemoteWebSocket { + binaryType = ""; + readyState = 0; + readonly sent: Uint8Array[] = []; + private readonly listeners = new Map>(); + + send(data: Uint8Array): void { + if (this.readyState !== 1) throw new Error("socket is not open"); + this.sent.push(data.slice()); + } + + close(code = 1000, reason = ""): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.emit({ type: "close", code, reason }); + } + + addEventListener(type: RemoteWebSocketEvent["type"], listener: Listener): void { + const listeners = this.listeners.get(type) ?? new Set(); + listeners.add(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type: RemoteWebSocketEvent["type"], listener: Listener): void { + this.listeners.get(type)?.delete(listener); + } + + open(): void { + this.readyState = 1; + this.emit({ type: "open" }); + } + + message(data: unknown): void { + this.emit({ type: "message", data }); + } + + fail(): void { + this.emit({ type: "error" }); + } + + private emit(event: RemoteWebSocketEvent): void { + for (const listener of this.listeners.get(event.type) ?? []) listener(event); + } +} + +class FakeSocketFactory implements RemoteWebSocketFactory { + readonly sockets: FakeSocket[] = []; + + connect(): RemoteWebSocket { + const socket = new FakeSocket(); + this.sockets.push(socket); + return socket; + } +} + +const installationId = parseInstallationId("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); +const cryptoSessionId = parseCryptoSessionId("11111111-1111-4111-8111-111111111111"); +const deviceRoute = parseRouteId("22222222-2222-4222-8222-222222222222"); +const firstDaemonRoute = parseRouteId("33333333-3333-4333-8333-333333333333"); +const secondDaemonRoute = parseRouteId("44444444-4444-4444-8444-444444444444"); +const requestId = parseRemoteRequestId("55555555-5555-4555-8555-555555555555"); +const idempotencyKey = parseIdempotencyKey("66666666-6666-4666-8666-666666666666"); +const daemonId = parseDeviceId("77777777-7777-4777-8777-777777777777"); + +function credential(maxFrameBytes = DEFAULT_RELAY_LIMITS.maxFrameBytes): RelayAdmissionCredential { + return { + ticket: "one-use-ticket", + relayUrl: "wss://relay.invalid/v1/connect", + expiresAt: Date.now() + 60_000, + proofSchemeVersion: 1, + limits: { ...DEFAULT_RELAY_LIMITS, maxFrameBytes }, + connectionNonce: "nonce", + possessionProof: Uint8Array.of(1, 2, 3), + }; +} + +function discovery( + type: "route_snapshot" | "route_available" | "route_unavailable", + route: string, +) { + return new TextEncoder().encode( + JSON.stringify({ + version: 1, + type, + ...(type === "route_snapshot" + ? { sourceRoute: { routeId: deviceRoute, role: "device", deviceId: daemonId } } + : {}), + peers: [{ routeId: route, role: "daemon" }], + }), + ); +} + +async function nextTurn(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +function deferred(): { + readonly promise: Promise; + readonly resolve: (value: Value) => void; +} { + let resolve!: (value: Value) => void; + const promise = new Promise((accept) => { + resolve = accept; + }); + return { promise, resolve }; +} + +async function connect( + factory: FakeSocketFactory, + issuedCredential = credential(), +): Promise<{ readonly connection: RemoteRelayConnection; readonly socket: FakeSocket }> { + const connection = new RemoteRelayConnection({ + tickets: { + async acquire() { + return issuedCredential; + }, + }, + sockets: factory, + destinationCryptoSessionId: cryptoSessionId, + reconnect: { initialDelayMs: 1, maximumDelayMs: 1, jitterRatio: 0, maximumAttempts: 3 }, + routeWaitMs: 100, + sleep: async () => undefined, + }); + const starting = connection.start(); + await nextTurn(); + const socket = factory.sockets[0]; + assert.ok(socket); + socket.open(); + socket.message(discovery("route_snapshot", firstDaemonRoute)); + await starting; + return { connection, socket }; +} + +test("acquires tickets in an authenticated body request and rejects insecure production origins", async () => { + assert.throws( + () => + new HttpRelayTicketProvider({ + controlPlaneOrigin: "http://control.invalid", + request: { + installationId, + deviceId: daemonId, + role: "device", + }, + authenticationHeaders: async () => ({}), + proof: { + async create() { + return { connectionNonce: "nonce", possessionProof: Uint8Array.of(1) }; + }, + }, + }), + /must use HTTPS/, + ); + + let requestedUrl = ""; + const provider = new HttpRelayTicketProvider({ + controlPlaneOrigin: "http://127.0.0.1:1234", + request: { + installationId, + deviceId: daemonId, + role: "device", + }, + authenticationHeaders: async () => ({ authorization: "Bearer fixture" }), + proof: { + async create() { + return { connectionNonce: "nonce", possessionProof: Uint8Array.of(1, 2, 3) }; + }, + }, + allowInsecureLoopbackForTests: true, + fetch: async (url, init) => { + requestedUrl = url; + assert.equal(init.headers.authorization, "Bearer fixture"); + assert.doesNotMatch(url, /one-use-ticket/); + return { + ok: true, + status: 201, + async json() { + const { connectionNonce: _nonce, possessionProof: _proof, ...issued } = credential(); + return issued; + }, + }; + }, + }); + const acquired = await provider.acquire(); + assert.equal(requestedUrl, "http://127.0.0.1:1234/v1/relay/tickets"); + assert.equal(acquired.ticket, "one-use-ticket"); + assert.deepEqual(acquired.possessionProof, Uint8Array.of(1, 2, 3)); +}); + +test("admits with a bounded first binary message and tracks route replacement", async () => { + const factory = new FakeSocketFactory(); + const { connection, socket } = await connect(factory); + + assert.equal(socket.binaryType, "arraybuffer"); + const admission = JSON.parse(new TextDecoder().decode(socket.sent[0])) as Record; + assert.equal(admission.ticket, "one-use-ticket"); + assert.equal(admission.connectionNonce, "nonce"); + assert.equal(await connection.resolve(cryptoSessionId), firstDaemonRoute); + + socket.message(discovery("route_available", secondDaemonRoute)); + await nextTurn(); + assert.equal(await connection.resolve(cryptoSessionId), secondDaemonRoute); + socket.message(discovery("route_unavailable", secondDaemonRoute)); + await nextTurn(); + await assert.rejects(connection.resolve(cryptoSessionId), /Daemon route is unavailable/); + connection.close(); +}); + +test("failed delivery startup can retry and concurrent starts share one connection attempt", async () => { + const factory = new FakeSocketFactory(); + let acquisitions = 0; + const connection = new RemoteRelayConnection({ + tickets: { + async acquire() { + acquisitions += 1; + if (acquisitions === 1) throw new Error("control plane unavailable"); + return credential(); + }, + }, + sockets: factory, + destinationCryptoSessionId: cryptoSessionId, + reconnect: { initialDelayMs: 1, maximumDelayMs: 1, jitterRatio: 0, maximumAttempts: 1 }, + routeWaitMs: 100, + sleep: async () => undefined, + }); + const store = new MemoryOutboxStore(); + const attemptIds = { + create: () => parseTransportAttemptId("88888888-8888-4888-8888-000000000001"), + }; + const delivery = new RemoteHostedDelivery({ + connection, + outbox: new OpaqueOutbox(store, attemptIds, connection), + expectedDaemonId: daemonId, + attemptIds, + opener: { + async open(ciphertext) { + return { authenticatedPeerId: daemonId, plaintext: ciphertext }; + }, + }, + }); + + await assert.rejects(delivery.start(), /attempts were exhausted/); + assert.equal(connection.state, "disconnected"); + assert.equal(acquisitions, 1); + + const firstRetry = delivery.start(); + const concurrentRetry = delivery.start(); + assert.equal(firstRetry, concurrentRetry); + await nextTurn(); + const socket = factory.sockets[0]; + assert.ok(socket); + socket.open(); + socket.message(discovery("route_snapshot", firstDaemonRoute)); + await Promise.all([firstRetry, concurrentRetry]); + + assert.equal(acquisitions, 2); + assert.equal(connection.state, "connected"); + delivery.close(); +}); + +test("failed WebSocket startup can retry with a new ticket and socket", async () => { + const factory = new FakeSocketFactory(); + let acquisitions = 0; + const connection = new RemoteRelayConnection({ + tickets: { + async acquire() { + acquisitions += 1; + return credential(); + }, + }, + sockets: factory, + destinationCryptoSessionId: cryptoSessionId, + reconnect: { initialDelayMs: 1, maximumDelayMs: 1, jitterRatio: 0, maximumAttempts: 1 }, + routeWaitMs: 100, + sleep: async () => undefined, + }); + + const failedStart = connection.start(); + const concurrentFailedStart = connection.start(); + assert.equal(failedStart, concurrentFailedStart); + await nextTurn(); + const failedSocket = factory.sockets[0]; + assert.ok(failedSocket); + failedSocket.fail(); + await assert.rejects(failedStart, /attempts were exhausted/); + + const retry = connection.start(); + const concurrentRetry = connection.start(); + assert.equal(retry, concurrentRetry); + await nextTurn(); + const retrySocket = factory.sockets[1]; + assert.ok(retrySocket); + retrySocket.open(); + retrySocket.message(discovery("route_snapshot", firstDaemonRoute)); + await retry; + + assert.equal(acquisitions, 2); + assert.equal(connection.state, "connected"); + connection.close(); +}); + +test("close during retry backoff prevents another ticket acquisition", async () => { + const factory = new FakeSocketFactory(); + const backoffStarted = deferred(); + const releaseBackoff = deferred(); + let acquisitions = 0; + const connection = new RemoteRelayConnection({ + tickets: { + async acquire() { + acquisitions += 1; + if (acquisitions === 1) throw new Error("temporary ticket failure"); + return credential(); + }, + }, + sockets: factory, + reconnect: { initialDelayMs: 1, maximumDelayMs: 1, jitterRatio: 0, maximumAttempts: 2 }, + sleep: async () => { + backoffStarted.resolve(); + await releaseBackoff.promise; + }, + }); + + const starting = connection.start(); + await backoffStarted.promise; + connection.close(); + releaseBackoff.resolve(); + await starting; + + assert.equal(connection.state, "closed"); + assert.equal(acquisitions, 1); + assert.equal(factory.sockets.length, 0); +}); + +test("close during ticket acquisition prevents WebSocket creation", async () => { + const factory = new FakeSocketFactory(); + const pendingCredential = deferred(); + let acquisitions = 0; + const connection = new RemoteRelayConnection({ + tickets: { + acquire() { + acquisitions += 1; + return pendingCredential.promise; + }, + }, + sockets: factory, + }); + + const starting = connection.start(); + await nextTurn(); + connection.close(); + pendingCredential.resolve(credential()); + await starting; + + assert.equal(connection.state, "closed"); + assert.equal(acquisitions, 1); + assert.equal(factory.sockets.length, 0); +}); + +test("close while converting a message prevents stale route application", async () => { + const factory = new FakeSocketFactory(); + const { connection, socket } = await connect(factory); + const pendingBytes = deferred(); + const delayedBlob = new Blob([Uint8Array.of(1)]); + Object.defineProperty(delayedBlob, "arrayBuffer", { value: () => pendingBytes.promise }); + + socket.message(delayedBlob); + await nextTurn(); + connection.close(); + const available = discovery("route_available", secondDaemonRoute); + pendingBytes.resolve( + available.buffer.slice( + available.byteOffset, + available.byteOffset + available.byteLength, + ) as ArrayBuffer, + ); + await nextTurn(); + await nextTurn(); + + assert.equal(connection.state, "closed"); + assert.deepEqual(connection.routes, []); +}); + +test("enforces the negotiated frame limit before conversion and outbound send", async () => { + const maximumBytes = 512; + const factory = new FakeSocketFactory(); + const { connection, socket } = await connect(factory, credential(maximumBytes)); + const oversizedPayload = new Uint8Array(maximumBytes - 37); + + assert.throws( + () => + connection.send( + firstDaemonRoute, + parseTransportAttemptId("88888888-8888-4888-8888-000000000001"), + oversizedPayload, + ), + (error) => error instanceof RemoteRelayError && error.code === "frame_too_large", + ); + + let converted = false; + const oversizedBlob = new Blob([new Uint8Array(maximumBytes + 1)]); + Object.defineProperty(oversizedBlob, "arrayBuffer", { + value: async () => { + converted = true; + return new ArrayBuffer(maximumBytes + 1); + }, + }); + socket.message(oversizedBlob); + await nextTurn(); + + assert.equal(converted, false); + assert.equal(socket.readyState, 3); + connection.close(); +}); + +test("rejects oversized discovery text before JSON parsing", async () => { + const maximumBytes = 512; + const factory = new FakeSocketFactory(); + const { connection, socket } = await connect(factory, credential(maximumBytes)); + + socket.message("{".repeat(maximumBytes + 1)); + await nextTurn(); + + assert.equal(socket.readyState, 3); + connection.close(); +}); + +test("startup retries a durable sending record with byte-identical ciphertext", async () => { + const factory = new FakeSocketFactory(); + const { connection, socket } = await connect(factory); + const store = new MemoryOutboxStore(); + const opaqueEnvelope = Uint8Array.of(0, 1, 2, 255); + store.records.set(requestId, { + requestId, + idempotencyKey, + destinationCryptoSessionId: cryptoSessionId, + opaqueEnvelope, + createdAt: 1_900_000_000_000, + state: "sending", + }); + let attempt = 0; + const attemptIds = { + create() { + attempt += 1; + return parseTransportAttemptId( + `88888888-8888-4888-8888-${attempt.toString().padStart(12, "0")}`, + ); + }, + }; + const outbox = new OpaqueOutbox(store, attemptIds, connection); + const delivery = new RemoteHostedDelivery({ + connection, + outbox, + expectedDaemonId: daemonId, + attemptIds, + opener: { + async open(ciphertext) { + return { authenticatedPeerId: daemonId, plaintext: ciphertext }; + }, + }, + }); + + await delivery.start(); + + const retried = parseRelayBinaryFrame(socket.sent.at(-1) ?? new Uint8Array()); + assert.ok("destinationRouteId" in retried); + assert.equal(retried.destinationRouteId, firstDaemonRoute); + assert.deepEqual(retried.opaquePayload, opaqueEnvelope); + assert.equal((await outbox.list())[0]?.state, "sending"); + delivery.close(); +}); + +test("reconnect resolves a new route and retries byte-identical prepared ciphertext", async () => { + const factory = new FakeSocketFactory(); + const { connection, socket: firstSocket } = await connect(factory); + const store = new MemoryOutboxStore(); + let attempt = 0; + const attemptIds = { + create() { + attempt += 1; + return parseTransportAttemptId( + `88888888-8888-4888-8888-${attempt.toString().padStart(12, "0")}`, + ); + }, + }; + const outbox = new OpaqueOutbox(store, attemptIds, connection); + const states: RemoteRelayConnectionState[] = []; + connection.onState((state) => states.push(state)); + const updates: string[] = []; + const delivery = new RemoteHostedDelivery({ + connection, + outbox, + expectedDaemonId: daemonId, + attemptIds, + opener: { + async open(opaqueEnvelope) { + return { authenticatedPeerId: daemonId, plaintext: opaqueEnvelope }; + }, + }, + }); + delivery.onDeliveryState((update) => updates.push(update.state)); + await delivery.start(); + + const opaqueEnvelope = Uint8Array.of(0, 1, 2, 255); + await delivery.enqueuePrepared({ + requestId, + idempotencyKey, + destinationCryptoSessionId: cryptoSessionId, + opaqueEnvelope, + createdAt: 1_900_000_000_000, + state: "queued_local", + }); + const firstSend = parseRelayBinaryFrame(firstSocket.sent.at(-1) ?? new Uint8Array()); + assert.ok("destinationRouteId" in firstSend); + assert.equal(firstSend.destinationRouteId, firstDaemonRoute); + assert.deepEqual(firstSend.opaquePayload, opaqueEnvelope); + + firstSocket.close(1006, "restart"); + await nextTurn(); + await nextTurn(); + const secondSocket = factory.sockets[1]; + assert.ok(secondSocket); + secondSocket.open(); + secondSocket.message(discovery("route_snapshot", secondDaemonRoute)); + await nextTurn(); + await nextTurn(); + + const secondSend = parseRelayBinaryFrame(secondSocket.sent.at(-1) ?? new Uint8Array()); + assert.ok("destinationRouteId" in secondSend); + assert.equal(secondSend.destinationRouteId, secondDaemonRoute); + assert.notEqual(secondSend.attemptId, firstSend.attemptId); + assert.deepEqual(secondSend.opaquePayload, firstSend.opaquePayload); + + secondSocket.message( + encodeRelayBinaryFrame({ + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId: secondSend.attemptId, + status: "admitted", + }), + ); + secondSocket.message( + encodeRelayBinaryFrame({ + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId: secondSend.attemptId, + status: "forwarded", + }), + ); + secondSocket.message( + encodeRelayBinaryFrame({ + transportVersion: REMOTE_TRANSPORT_VERSION, + attemptId: parseTransportAttemptId("99999999-9999-4999-8999-999999999999"), + sourceRouteId: secondDaemonRoute, + opaquePayload: encodeRemoteDaemonMessage({ + version: REMOTE_TRANSPORT_VERSION, + type: "daemon_accepted", + requestId, + idempotencyKey, + }), + }), + ); + await nextTurn(); + await nextTurn(); + + assert.ok(states.includes("reconnecting")); + assert.ok(updates.includes("relay_admitted")); + assert.ok(updates.includes("relay_forwarded")); + assert.ok(updates.includes("daemon_accepted")); + assert.deepEqual(await outbox.list(), []); + delivery.close(); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5375298..280e6c77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,6 +277,12 @@ importers: specifier: 8.2.2 version: 8.2.2(@types/node@22.19.19)(esbuild@0.28.2)(yaml@2.8.3) + services/control-plane: + dependencies: + '@axl/protocol': + specifier: workspace:* + version: link:../../packages/protocol + packages: '@aws-sdk/core@3.977.9': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9ac38dc1..ef9913f3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,9 +1,11 @@ # SPDX-FileCopyrightText: 2026 Hari Srinivasan +# SPDX-FileCopyrightText: 2026 Lokesh # SPDX-License-Identifier: Apache-2.0 packages: - packages/* - packages/extensions/* + - services/* - apps/* allowBuilds: esbuild: false diff --git a/scripts/check-boundaries.test.ts b/scripts/check-boundaries.test.ts index 29d4e159..74aa24fa 100644 --- a/scripts/check-boundaries.test.ts +++ b/scripts/check-boundaries.test.ts @@ -68,6 +68,30 @@ test("enforces protocol, kernel, runtime, TUI, and extension dependency boundari ]); }); +test("enforces control-plane and relay service boundaries", () => { + const root = mkdtempSync(join(tmpdir(), "axl-service-boundaries-")); + const controlPlane = join(root, "services/control-plane"); + mkdirSync(join(controlPlane, "src"), { recursive: true }); + writeFileSync( + join(controlPlane, "package.json"), + JSON.stringify({ name: "@axl/control-plane", dependencies: { fastify: "1.0.0" } }), + ); + writeFileSync(join(controlPlane, "src/index.ts"), 'import "@axl/daemon";\n'); + const relay = join(root, "services/relay"); + mkdirSync(relay, { recursive: true }); + writeFileSync( + join(relay, "mix.exs"), + 'defp deps, do: [{:bandit, "1.12.5"}, {:forbidden, path: "../../packages/kernel"}]\n', + ); + + assert.deepEqual(checkWorkspace(root), [ + "services/control-plane may depend only on @axl/protocol, found fastify", + "services/control-plane/src/index.ts imports @axl/daemon; control plane may import only Node.js and @axl/protocol", + "services/relay may not depend on unapproved package forbidden", + "services/relay must not use path dependencies into repository packages", + ]); +}); + test("checks real imports without interpreting embedded clipboard scripts as dependencies", () => { const root = mkdtempSync(join(tmpdir(), "axl-boundary-syntax-")); writePackage( diff --git a/scripts/check-boundaries.ts b/scripts/check-boundaries.ts index ff452dcb..c82f9a7d 100644 --- a/scripts/check-boundaries.ts +++ b/scripts/check-boundaries.ts @@ -27,7 +27,8 @@ type PackageManifest = { function walk(directory: string, visit: (path: string) => void): void { if (!existsSync(directory)) return; for (const entry of readdirSync(directory, { withFileTypes: true })) { - if ([".git", "dist", "node_modules"].includes(entry.name)) continue; + if ([".git", "_build", "deps", "dist", "node_modules"].includes(entry.name)) continue; + if (entry.isSymbolicLink()) continue; const path = resolve(directory, entry.name); if (entry.isDirectory()) walk(path, visit); else visit(path); @@ -36,9 +37,11 @@ function walk(directory: string, visit: (path: string) => void): void { function packageDirectories(root: string): string[] { const directories: string[] = []; - walk(resolve(root, "packages"), (path) => { - if (path.endsWith(`${sep}package.json`)) directories.push(dirname(path)); - }); + for (const workspaceRoot of ["packages", "services"]) { + walk(resolve(root, workspaceRoot), (path) => { + if (path.endsWith(`${sep}package.json`)) directories.push(dirname(path)); + }); + } return directories; } @@ -93,6 +96,9 @@ export function checkWorkspace(root: string): string[] { const sdk = packages.find(({ directory }) => directory === resolve(root, "packages/sdk")); const ui = packages.find(({ directory }) => directory === resolve(root, "packages/ui")); const tui = packages.find(({ directory }) => directory === resolve(root, "packages/tui")); + const controlPlane = packages.find( + ({ directory }) => directory === resolve(root, "services/control-plane"), + ); const protocolName = protocol?.manifest.name ?? "@axl/protocol"; const kernelName = kernel?.manifest.name ?? "@axl/kernel"; const tuiName = tui?.manifest.name ?? "@axl/tui"; @@ -141,6 +147,16 @@ export function checkWorkspace(root: string): string[] { } } + if (controlPlane) { + for (const dependency of runtimeDependencies(controlPlane.manifest)) { + if (dependency !== protocolName) { + errors.push( + `${relative(root, controlPlane.directory)} may depend only on ${protocolName}, found ${dependency}`, + ); + } + } + } + if (runtime && runtimeDependencies(runtime.manifest).includes(tuiName)) { errors.push( `${relative(root, runtime.directory)} must not depend on presentation package ${tuiName}`, @@ -197,6 +213,16 @@ export function checkWorkspace(root: string): string[] { `${relative(root, path)} imports ${specifier}; UI source may import only shared client presentation packages`, ); } + if ( + directory === controlPlane?.directory && + !specifier.startsWith(".") && + !specifier.startsWith("node:") && + specifier !== protocolName + ) { + errors.push( + `${relative(root, path)} imports ${specifier}; control plane may import only Node.js and ${protocolName}`, + ); + } if ( directory === tui?.directory && !specifier.startsWith(".") && @@ -224,6 +250,28 @@ export function checkWorkspace(root: string): string[] { }); } + const relayMixPath = resolve(root, "services/relay/mix.exs"); + if (existsSync(relayMixPath)) { + const relayMix = readFileSync(relayMixPath, "utf8"); + const allowedRelayDependencies = new Set([ + "bandit", + "plug", + "websock_adapter", + "credo", + "dialyxir", + "mix_audit", + ]); + for (const match of relayMix.matchAll(/\{:([a-z][a-z0-9_]*),/g)) { + const dependency = match[1] as string; + if (!allowedRelayDependencies.has(dependency)) { + errors.push(`services/relay may not depend on unapproved package ${dependency}`); + } + } + if (/\bpath:\s*/.test(relayMix)) { + errors.push("services/relay must not use path dependencies into repository packages"); + } + } + walk(resolve(root, "apps"), (path) => { const extension = path.slice(path.lastIndexOf(".")); if (!sourceExtensions.has(extension)) return; diff --git a/scripts/check-generated.test.ts b/scripts/check-generated.test.ts index b3e2b01a..204da945 100644 --- a/scripts/check-generated.test.ts +++ b/scripts/check-generated.test.ts @@ -1,8 +1,9 @@ // SPDX-FileCopyrightText: 2026 Hari Srinivasan +// SPDX-FileCopyrightText: 2026 Lokesh // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -24,3 +25,14 @@ test("requires generated files to name an existing passing generator", () => { [], ); }); + +test("ignores dependency builds and directory symlinks", () => { + const root = mkdtempSync(join(tmpdir(), "axl-generated-builds-")); + mkdirSync(join(root, "deps")); + mkdirSync(join(root, "_build")); + writeFileSync(join(root, "deps", "ignored.generated.ts"), ""); + writeFileSync(join(root, "_build", "ignored.generated.ts"), ""); + symlinkSync(join(root, "deps"), join(root, "linked-deps")); + + assert.deepEqual(checkGenerated(root), []); +}); diff --git a/scripts/check-generated.ts b/scripts/check-generated.ts index 391adf9c..b0663b96 100644 --- a/scripts/check-generated.ts +++ b/scripts/check-generated.ts @@ -1,4 +1,5 @@ // SPDX-FileCopyrightText: 2026 Hari Srinivasan +// SPDX-FileCopyrightText: 2026 Lokesh // SPDX-License-Identifier: Apache-2.0 import { execFileSync } from "node:child_process"; @@ -14,7 +15,8 @@ type GeneratorRunner = ( function walk(directory: string, visit: (path: string) => void): void { for (const entry of readdirSync(directory, { withFileTypes: true })) { - if ([".git", "dist", "node_modules"].includes(entry.name)) continue; + if ([".git", "_build", "deps", "dist", "node_modules"].includes(entry.name)) continue; + if (entry.isSymbolicLink()) continue; const path = resolve(directory, entry.name); if (entry.isDirectory()) walk(path, visit); else visit(path); diff --git a/services/control-plane/README.md b/services/control-plane/README.md new file mode 100644 index 00000000..0d34b5e5 --- /dev/null +++ b/services/control-plane/README.md @@ -0,0 +1,10 @@ + + + +# Axl control plane + +This separately deployable TypeScript service owns hosted control-plane mutations. The first slice implements authorized relay-ticket issuance, atomic one-use consumption, and the authenticated internal HTTP boundary used by the relay. + +The service uses injected principal authentication, relay authentication, authorization, proof verification, clocks, and stores. Tests use deterministic in-memory implementations. No production identity provider, datastore, service-authentication scheme, or cryptographic proof is selected. + +The service never logs or places tickets or internal credentials in URLs. Production assembly remains blocked until those deployment decisions receive owner approval. diff --git a/services/control-plane/package.json b/services/control-plane/package.json new file mode 100644 index 00000000..8c3b1499 --- /dev/null +++ b/services/control-plane/package.json @@ -0,0 +1,25 @@ +{ + "name": "@axl/control-plane", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Hosted Axl control-plane service", + "license": "Apache-2.0", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -b tsconfig.build.json --force", + "test": "pnpm --filter @axl/protocol build && node --test test/*.test.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@axl/protocol": "workspace:*" + } +} diff --git a/services/control-plane/src/index.ts b/services/control-plane/src/index.ts new file mode 100644 index 00000000..a807c194 --- /dev/null +++ b/services/control-plane/src/index.ts @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +export * from "./revocations.ts"; +export * from "./server.ts"; +export * from "./tickets.ts"; diff --git a/services/control-plane/src/revocations.ts b/services/control-plane/src/revocations.ts new file mode 100644 index 00000000..2f3eb8e5 --- /dev/null +++ b/services/control-plane/src/revocations.ts @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { + parseRelayRevocationNotification, + parseRelayRevocationResult, + type RelayRevocationNotification, + type RelayRevocationResult, +} from "@axl/protocol"; + +export interface AuthenticatedRelayInternalTransport { + post(path: string, body: unknown): Promise; +} + +export class RelayRevocationNotifier { + private readonly transport: AuthenticatedRelayInternalTransport; + + constructor(transport: AuthenticatedRelayInternalTransport) { + this.transport = transport; + } + + async notify(value: RelayRevocationNotification): Promise { + const notification = parseRelayRevocationNotification(value); + const response = await this.transport.post("/internal/v1/revocations", notification); + return parseRelayRevocationResult(response); + } +} diff --git a/services/control-plane/src/server.ts b/services/control-plane/src/server.ts new file mode 100644 index 00000000..d0ff7e09 --- /dev/null +++ b/services/control-plane/src/server.ts @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import type { IncomingMessage, RequestListener, ServerResponse } from "node:http"; + +import { + encodeInternalConsumeRelayTicketResult, + parseInternalConsumeRelayTicketRequest, + ProtocolValidationError, +} from "@axl/protocol"; + +import { RelayTicketError, type AccountPrincipal, type RelayTicketService } from "./tickets.ts"; + +const MAX_REQUEST_BYTES = 4_096; + +export interface PublicPrincipalAuthenticator { + authenticate(request: IncomingMessage): Promise; +} + +export interface InternalRelayAuthenticator { + authenticate(request: IncomingMessage, exactBody: Uint8Array): Promise; +} + +export interface ControlPlaneHandlerOptions { + readonly tickets: RelayTicketService; + readonly publicAuthentication: PublicPrincipalAuthenticator; + readonly internalAuthentication: InternalRelayAuthenticator; +} + +class HttpRequestError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "HttpRequestError"; + this.status = status; + } +} + +async function readBody(request: IncomingMessage): Promise { + const chunks: Uint8Array[] = []; + let size = 0; + for await (const chunk of request) { + const bytes = + typeof chunk === "string" ? new TextEncoder().encode(chunk) : new Uint8Array(chunk); + size += bytes.byteLength; + if (size > MAX_REQUEST_BYTES) throw new HttpRequestError(413, "Request body is too large"); + chunks.push(bytes); + } + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +function parseJson(body: Uint8Array): unknown { + if (body.byteLength === 0) throw new HttpRequestError(400, "Request body is required"); + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)); + } catch { + throw new HttpRequestError(400, "Request body must be valid UTF-8 JSON"); + } +} + +function respond(response: ServerResponse, status: number, body: unknown): void { + const bytes = new TextEncoder().encode(JSON.stringify(body)); + response.writeHead(status, { + "cache-control": "no-store", + "content-length": bytes.byteLength, + "content-type": "application/json; charset=utf-8", + "x-content-type-options": "nosniff", + }); + response.end(bytes); +} + +function respondError(response: ServerResponse, error: unknown): void { + if (error instanceof RelayTicketError) { + respond(response, error.httpStatus, { error: { code: error.code, message: error.message } }); + return; + } + if (error instanceof ProtocolValidationError) { + respond(response, 400, { + error: { code: "bad_request", message: "Request validation failed", path: error.path }, + }); + return; + } + if (error instanceof HttpRequestError) { + respond(response, error.status, { error: { code: "bad_request", message: error.message } }); + return; + } + respond(response, 503, { + error: { code: "service_unavailable", message: "Control plane is unavailable" }, + }); +} + +function requestPath(request: IncomingMessage): string | undefined { + if (request.url === undefined) return undefined; + const url = new URL(request.url, "http://control-plane.invalid"); + return url.search === "" ? url.pathname : undefined; +} + +export function createControlPlaneHandler(options: ControlPlaneHandlerOptions): RequestListener { + return (request, response) => { + void (async () => { + if (request.method !== "POST") { + respond(response, 405, { error: { code: "method_not_allowed" } }); + return; + } + const path = requestPath(request); + if (path === "/v1/relay/tickets") { + const principal = await options.publicAuthentication.authenticate(request); + if (principal === undefined) { + respond(response, 401, { error: { code: "unauthorized" } }); + return; + } + const result = await options.tickets.issue(principal, parseJson(await readBody(request))); + respond(response, 201, result); + return; + } + if (path === "/internal/v1/relay/tickets/consume") { + const body = await readBody(request); + if (!(await options.internalAuthentication.authenticate(request, body))) { + respond(response, 401, { error: { code: "unauthorized" } }); + return; + } + const result = await options.tickets.consume( + parseInternalConsumeRelayTicketRequest(parseJson(body)), + ); + respond(response, 200, encodeInternalConsumeRelayTicketResult(result)); + return; + } + respond(response, 404, { error: { code: "not_found" } }); + })().catch((error: unknown) => respondError(response, error)); + }; +} diff --git a/services/control-plane/src/tickets.ts b/services/control-plane/src/tickets.ts new file mode 100644 index 00000000..873a7ab6 --- /dev/null +++ b/services/control-plane/src/tickets.ts @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import { createHash, randomBytes, randomUUID } from "node:crypto"; + +import { + DEFAULT_RELAY_LIMITS, + parseIssueRelayTicketRequest, + parseIssueRelayTicketResult, + parseRelayLimits, + parseRouteId, + RELAY_TICKET_LIFETIME_MS, + type ConsumeRelayTicketRequest, + type ConsumeRelayTicketResult, + type IssueRelayTicketRequest, + type IssueRelayTicketResult, + type RelayLimits, +} from "@axl/protocol"; + +export interface AccountPrincipal { + readonly accountId: string; +} + +export interface Clock { + now(): number; +} + +export interface RelayTicketAuthorizer { + currentGeneration( + principal: AccountPrincipal, + request: IssueRelayTicketRequest, + ): Promise; +} + +export interface RelayTicketProofVerifier { + verify(ticket: Readonly, request: ConsumeRelayTicketRequest): Promise; +} + +export interface RelayTicketRecord extends IssueRelayTicketRequest { + readonly accountId: string; + readonly grantGeneration: number; + readonly ticketDigest: string; + readonly sourceRouteId: ConsumeRelayTicketResult["sourceRouteId"]; + readonly issuedAt: number; + readonly expiresAt: number; + readonly limits: RelayLimits; + consumedAt?: number; + consumedByRelayInstanceId?: string; +} + +export interface RelayTicketStore { + insert(record: RelayTicketRecord): Promise; + find(ticketDigest: string): Promise | undefined>; + /** Atomically returns and marks one unexpired ticket as consumed. */ + consume( + ticketDigest: string, + relayInstanceId: string, + now: number, + ): Promise>; +} + +export type RelayTicketErrorCode = + | "unauthorized" + | "forbidden_route" + | "ticket_expired" + | "ticket_consumed" + | "ticket_revoked" + | "service_unavailable"; + +export class RelayTicketError extends Error { + readonly code: RelayTicketErrorCode; + readonly httpStatus: number; + + constructor(code: RelayTicketErrorCode, message: string, httpStatus: number) { + super(message); + this.name = "RelayTicketError"; + this.code = code; + this.httpStatus = httpStatus; + } +} + +export class InMemoryRelayTicketStore implements RelayTicketStore { + private readonly records = new Map(); + + async insert(record: RelayTicketRecord): Promise { + if (this.records.has(record.ticketDigest)) throw new Error("Relay ticket digest collision"); + this.records.set(record.ticketDigest, record); + } + + async find(ticketDigest: string): Promise | undefined> { + return this.records.get(ticketDigest); + } + + async consume( + ticketDigest: string, + relayInstanceId: string, + now: number, + ): Promise> { + const record = this.records.get(ticketDigest); + if (record === undefined) { + throw new RelayTicketError("unauthorized", "Relay ticket is invalid", 401); + } + if (record.expiresAt <= now) { + throw new RelayTicketError("ticket_expired", "Relay ticket has expired", 401); + } + if (record.consumedAt !== undefined) { + throw new RelayTicketError("ticket_consumed", "Relay ticket has already been consumed", 409); + } + record.consumedAt = now; + record.consumedByRelayInstanceId = relayInstanceId; + return record; + } +} + +export interface RelayTicketServiceOptions { + readonly store: RelayTicketStore; + readonly authorizer: RelayTicketAuthorizer; + readonly proofVerifier: RelayTicketProofVerifier; + readonly relayUrl: string; + readonly clock?: Clock; + readonly limits?: RelayLimits; + readonly ticketLifetimeMs?: number; + readonly randomToken?: () => string; + readonly randomId?: () => string; +} + +function digestTicket(ticket: string): string { + return createHash("sha256").update(ticket, "utf8").digest("hex"); +} + +export class RelayTicketService { + private readonly options: RelayTicketServiceOptions; + private readonly clock: Clock; + private readonly limits: RelayLimits; + private readonly ticketLifetimeMs: number; + private readonly randomToken: () => string; + private readonly randomId: () => string; + + constructor(options: RelayTicketServiceOptions) { + this.options = options; + this.clock = options.clock ?? { now: () => Date.now() }; + this.limits = parseRelayLimits(options.limits ?? DEFAULT_RELAY_LIMITS); + this.ticketLifetimeMs = options.ticketLifetimeMs ?? RELAY_TICKET_LIFETIME_MS; + this.randomToken = options.randomToken ?? (() => randomBytes(32).toString("base64url")); + this.randomId = options.randomId ?? randomUUID; + if ( + !Number.isSafeInteger(this.ticketLifetimeMs) || + this.ticketLifetimeMs <= 0 || + this.ticketLifetimeMs > RELAY_TICKET_LIFETIME_MS + ) { + throw new TypeError(`Ticket lifetime must be from 1 through ${RELAY_TICKET_LIFETIME_MS} ms`); + } + } + + async issue(principal: AccountPrincipal, value: unknown): Promise { + const request = parseIssueRelayTicketRequest(value); + const grantGeneration = await this.options.authorizer.currentGeneration(principal, request); + if (grantGeneration === undefined) { + throw new RelayTicketError("forbidden_route", "Principal cannot access this route", 403); + } + if (!Number.isSafeInteger(grantGeneration) || grantGeneration <= 0) { + throw new Error("Grant generation must be a positive safe integer"); + } + const now = this.clock.now(); + const ticket = this.randomToken(); + const record: RelayTicketRecord = { + ...request, + accountId: principal.accountId, + grantGeneration, + ticketDigest: digestTicket(ticket), + sourceRouteId: parseRouteId(this.randomId(), "sourceRouteId"), + issuedAt: now, + expiresAt: now + this.ticketLifetimeMs, + limits: this.limits, + }; + await this.options.store.insert(record); + return parseIssueRelayTicketResult({ + ticket, + relayUrl: this.options.relayUrl, + expiresAt: record.expiresAt, + proofSchemeVersion: 1, + limits: record.limits, + }); + } + + async consume(request: ConsumeRelayTicketRequest): Promise { + const ticketDigest = digestTicket(request.ticket); + const candidate = await this.options.store.find(ticketDigest); + if (candidate === undefined) { + throw new RelayTicketError("unauthorized", "Relay ticket is invalid", 401); + } + if (!(await this.options.proofVerifier.verify(candidate, request))) { + throw new RelayTicketError("unauthorized", "Possession proof is invalid", 401); + } + const currentGeneration = await this.options.authorizer.currentGeneration( + { accountId: candidate.accountId }, + candidate, + ); + if (currentGeneration === undefined || currentGeneration !== candidate.grantGeneration) { + throw new RelayTicketError("ticket_revoked", "Relay ticket grant is no longer current", 401); + } + const consumed = await this.options.store.consume( + ticketDigest, + request.relayInstanceId, + this.clock.now(), + ); + return { + installationId: consumed.installationId, + ...(consumed.deviceId === undefined ? {} : { deviceId: consumed.deviceId }), + sourceRouteId: consumed.sourceRouteId, + role: consumed.role, + grantGeneration: consumed.grantGeneration, + leaseExpiresAt: consumed.expiresAt, + limits: consumed.limits, + }; + } +} diff --git a/services/control-plane/test/tickets.test.ts b/services/control-plane/test/tickets.test.ts new file mode 100644 index 00000000..177b302c --- /dev/null +++ b/services/control-plane/test/tickets.test.ts @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: 2026 Lokesh +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + DEFAULT_RELAY_LIMITS, + encodeInternalConsumeRelayTicketRequest, + INTERNAL_RELAY_API_VERSION, + parseInstallationId, + parseRelayRevocationNotification, +} from "@axl/protocol"; + +import { + createControlPlaneHandler, + InMemoryRelayTicketStore, + RelayRevocationNotifier, + RelayTicketError, + RelayTicketService, +} from "../src/index.ts"; + +const installationId = parseInstallationId("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); +const deviceId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" as const; +const fixture = JSON.parse( + readFileSync( + new URL("../../../packages/protocol/test/fixtures/internal-relay-api-v1.json", import.meta.url), + "utf8", + ), +) as { + readonly revocation: { readonly request: unknown; readonly result: unknown }; +}; + +function createTicketService( + clock: { now(): number } = { now: () => 1_900_000_000_000 }, + currentGeneration: () => number | undefined = () => 1, +): RelayTicketService { + let routeCounter = 0; + return new RelayTicketService({ + store: new InMemoryRelayTicketStore(), + authorizer: { + async currentGeneration(principal, request) { + return principal.accountId === "account-fixture" && + request.installationId === installationId + ? currentGeneration() + : undefined; + }, + }, + proofVerifier: { + async verify(_ticket, request) { + return Buffer.from(request.possessionProof).equals(Buffer.from([0, 1, 2, 3, 255])); + }, + }, + relayUrl: "wss://relay.invalid/v1/connect", + clock, + randomToken: () => "fixture-ticket-never-valid-outside-tests", + randomId: () => { + routeCounter += 1; + return `cccccccc-cccc-4ccc-8ccc-${routeCounter.toString().padStart(12, "0")}`; + }, + }); +} + +test("atomically consumes a relay ticket once under concurrent calls", async () => { + const service = createTicketService(); + const issued = await service.issue( + { accountId: "account-fixture" }, + { installationId, deviceId, role: "device" }, + ); + const request = { + ticket: issued.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }; + + const results = await Promise.allSettled([service.consume(request), service.consume(request)]); + assert.equal(results.filter((result) => result.status === "fulfilled").length, 1); + const rejection = results.find((result) => result.status === "rejected"); + assert.ok(rejection?.status === "rejected"); + assert.ok(rejection.reason instanceof RelayTicketError); + assert.equal(rejection.reason.code, "ticket_consumed"); +}); + +test("rejects unauthorized issuance, invalid proof, and expired tickets", async () => { + const service = createTicketService(); + await assert.rejects( + service.issue({ accountId: "another-account" }, { installationId, deviceId, role: "device" }), + (error) => error instanceof RelayTicketError && error.code === "forbidden_route", + ); + const issued = await service.issue( + { accountId: "account-fixture" }, + { installationId, deviceId, role: "device" }, + ); + await assert.rejects( + service.consume({ + ticket: issued.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(9), + }), + (error) => error instanceof RelayTicketError && error.code === "unauthorized", + ); + + let now = 1_900_000_000_000; + const expiringService = createTicketService({ now: () => now }); + const expiring = await expiringService.issue( + { accountId: "account-fixture" }, + { installationId, deviceId, role: "device" }, + ); + now += 60_000; + await assert.rejects( + expiringService.consume({ + ticket: expiring.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }), + (error) => error instanceof RelayTicketError && error.code === "ticket_expired", + ); +}); + +test("rejects a ticket when its grant generation changes before consumption", async () => { + let generation: number | undefined = 7; + const service = createTicketService(undefined, () => generation); + const issued = await service.issue( + { accountId: "account-fixture" }, + { installationId, deviceId, role: "device" }, + ); + generation = 8; + await assert.rejects( + service.consume({ + ticket: issued.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }), + (error) => error instanceof RelayTicketError && error.code === "ticket_revoked", + ); +}); + +test("serves authenticated public issuance and internal consumption without URL credentials", async (context) => { + const service = createTicketService(); + const handler = createControlPlaneHandler({ + tickets: service, + publicAuthentication: { + async authenticate(request) { + return request.headers.authorization === "Bearer public-fixture" + ? { accountId: "account-fixture" } + : undefined; + }, + }, + internalAuthentication: { + async authenticate(request) { + return request.headers.authorization === "Bearer internal-fixture"; + }, + }, + }); + const server = createServer(handler); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + context.after(() => server.close()); + const address = server.address(); + assert.ok(address !== null && typeof address !== "string"); + const origin = `http://127.0.0.1:${address.port}`; + + const issueResponse = await fetch(`${origin}/v1/relay/tickets`, { + method: "POST", + headers: { authorization: "Bearer public-fixture", "content-type": "application/json" }, + body: JSON.stringify({ installationId, deviceId, role: "device" }), + }); + assert.equal(issueResponse.status, 201); + const issued = (await issueResponse.json()) as { readonly ticket: string }; + + const unauthorized = await fetch(`${origin}/internal/v1/relay/tickets/consume`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + encodeInternalConsumeRelayTicketRequest({ + ticket: issued.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }), + ), + }); + assert.equal(unauthorized.status, 401); + + const consumeResponse = await fetch(`${origin}/internal/v1/relay/tickets/consume`, { + method: "POST", + headers: { authorization: "Bearer internal-fixture", "content-type": "application/json" }, + body: JSON.stringify( + encodeInternalConsumeRelayTicketRequest({ + ticket: issued.ticket, + relayInstanceId: "relay-fixture-1", + connectionNonce: "fixture-nonce", + possessionProof: Uint8Array.of(0, 1, 2, 3, 255), + }), + ), + }); + assert.equal(consumeResponse.status, 200); + assert.deepEqual(await consumeResponse.json(), { + version: INTERNAL_RELAY_API_VERSION, + installationId, + deviceId, + sourceRouteId: "cccccccc-cccc-4ccc-8ccc-000000000001", + role: "device", + grantGeneration: 1, + leaseExpiresAt: 1_900_000_060_000, + limits: DEFAULT_RELAY_LIMITS, + }); +}); + +test("validates the authenticated relay revocation boundary", async () => { + let observedPath: string | undefined; + let observedBody: unknown; + const notifier = new RelayRevocationNotifier({ + async post(path, body) { + observedPath = path; + observedBody = body; + return fixture.revocation.result; + }, + }); + const notification = parseRelayRevocationNotification(fixture.revocation.request); + assert.deepEqual(await notifier.notify(notification), fixture.revocation.result); + assert.equal(observedPath, "/internal/v1/revocations"); + assert.deepEqual(observedBody, notification); +}); diff --git a/services/control-plane/tsconfig.build.json b/services/control-plane/tsconfig.build.json new file mode 100644 index 00000000..083eb148 --- /dev/null +++ b/services/control-plane/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "test"], + "references": [{ "path": "../../packages/protocol/tsconfig.build.json" }] +} diff --git a/services/control-plane/tsconfig.json b/services/control-plane/tsconfig.json new file mode 100644 index 00000000..7baee43a --- /dev/null +++ b/services/control-plane/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/services/relay/.formatter.exs b/services/relay/.formatter.exs new file mode 100644 index 00000000..5301699e --- /dev/null +++ b/services/relay/.formatter.exs @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/services/relay/.tool-versions b/services/relay/.tool-versions new file mode 100644 index 00000000..5767f96d --- /dev/null +++ b/services/relay/.tool-versions @@ -0,0 +1,2 @@ +erlang 27.3.4.17 +elixir 1.18.5-otp-27 diff --git a/services/relay/README.md b/services/relay/README.md new file mode 100644 index 00000000..a1824138 --- /dev/null +++ b/services/relay/README.md @@ -0,0 +1,19 @@ + + + +# Axl relay + +This separately deployable Elixir/OTP service admits connections through the control plane and routes bounded opaque binary frames in memory. It has no E2EE, daemon RPC, canonical-event, account-database, or attachment-body dependency. + +The first slice provides: + +- one-use ticket admission through an injected control-plane client +- exact transport-v1 binary framing shared with TypeScript fixtures +- role-filtered route snapshots and updates without device-to-device enumeration +- installation-scoped `device <-> daemon` routing with same-identity replacement +- bounded per-route pending bytes and timed slow-consumer eviction +- WebSocket compression disabled and a 65,535-byte frame ceiling +- explicit inbound heartbeat deadlines, lease expiry, generation-bound revocation, and draining +- fail-closed admission and internal-authentication interfaces + +Production control-plane origins, service authentication, TLS termination, and deployment configuration remain unselected. Tests use deterministic fake adapters. The disposable cross-runtime hosted-path test may explicitly allow plain HTTP only for an exact loopback control-plane origin; production configuration remains HTTPS-only. diff --git a/services/relay/lib/axl_relay/admission.ex b/services/relay/lib/axl_relay/admission.ex new file mode 100644 index 00000000..25857830 --- /dev/null +++ b/services/relay/lib/axl_relay/admission.ex @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Admission do + @moduledoc "Parses the bounded, pre-routing WebSocket admission message." + + @max_message_bytes 4_096 + @required_keys MapSet.new([ + "version", + "ticket", + "connectionNonce", + "possessionProof" + ]) + + @spec parse(binary()) :: {:ok, map()} | {:error, :bad_frame} + def parse(message) when is_binary(message) and byte_size(message) <= @max_message_bytes do + with {:ok, decoded} <- decode_json(message), + true <- is_map(decoded), + true <- MapSet.new(Map.keys(decoded)) == @required_keys, + 1 <- decoded["version"], + ticket when is_binary(ticket) and byte_size(ticket) in 1..1024 <- decoded["ticket"], + nonce when is_binary(nonce) and byte_size(nonce) in 1..256 <- decoded["connectionNonce"], + proof when is_binary(proof) <- decoded["possessionProof"], + {:ok, proof_bytes} <- Base.decode64(proof), + true <- byte_size(proof_bytes) <= 1_024, + ^proof <- Base.encode64(proof_bytes) do + {:ok, + %{ + "ticket" => ticket, + "connectionNonce" => nonce, + "possessionProof" => proof + }} + else + _other -> {:error, :bad_frame} + end + end + + def parse(_message), do: {:error, :bad_frame} + + defp decode_json(message) do + {:ok, :json.decode(message)} + catch + _kind, _reason -> {:error, :bad_frame} + end +end + +defmodule AxlRelay.ControlPlaneClient do + @moduledoc "Injected fail-closed boundary for atomic ticket consumption." + + @callback consume_ticket(map(), String.t(), keyword()) :: + {:ok, map()} | {:error, atom()} +end diff --git a/services/relay/lib/axl_relay/application.ex b/services/relay/lib/axl_relay/application.ex new file mode 100644 index 00000000..61bf1891 --- /dev/null +++ b/services/relay/lib/axl_relay/application.ex @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Application do + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + children = + case Application.get_env(:axl_relay, :listener_options) do + nil -> [AxlRelay.RouteRegistry] + options -> [AxlRelay.RouteRegistry, {AxlRelay.Listener, options}] + end + + Supervisor.start_link(children, strategy: :one_for_one, name: AxlRelay.Supervisor) + end +end diff --git a/services/relay/lib/axl_relay/connection.ex b/services/relay/lib/axl_relay/connection.ex new file mode 100644 index 00000000..7d2a88c5 --- /dev/null +++ b/services/relay/lib/axl_relay/connection.ex @@ -0,0 +1,229 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Connection do + @moduledoc "Ticket-admitted WebSock handler for opaque relay frames." + + @behaviour WebSock + + alias AxlRelay.{Admission, Frame, RouteRegistry} + + @admission_timeout_ms 5_000 + @rate_window_ms 10_000 + @max_frames_per_window 100 + + @impl true + def init(options) do + Process.send_after(self(), :admission_timeout, @admission_timeout_ms) + + {:ok, + %{ + phase: :awaiting_admission, + control_plane: Keyword.fetch!(options, :control_plane), + control_plane_options: Keyword.get(options, :control_plane_options, []), + relay_instance_id: Keyword.fetch!(options, :relay_instance_id), + registry: Keyword.get(options, :registry, RouteRegistry), + route_id: nil, + limits: nil, + rate_window_started: System.monotonic_time(:millisecond), + last_inbound_at: nil, + rate_frames: 0, + rate_bytes: 0 + }} + end + + @impl true + def handle_in({message, opcode: :binary}, %{phase: :awaiting_admission} = state) do + with {:ok, admission} <- Admission.parse(message), + {:ok, result} <- + state.control_plane.consume_ticket( + admission, + state.relay_instance_id, + state.control_plane_options + ), + true <- result.lease_expires_at > System.system_time(:millisecond), + :ok <- RouteRegistry.register(state.registry, self(), result) do + Process.send_after(self(), :heartbeat, result.limits.heartbeat_interval_ms) + + Process.send_after( + self(), + :lease_expired, + result.lease_expires_at - System.system_time(:millisecond) + ) + + {:ok, + %{ + state + | phase: :active, + route_id: result.source_route_id, + limits: result.limits, + last_inbound_at: System.monotonic_time(:millisecond) + }} + else + {:error, code} -> close(code, state) + false -> close(:ticket_expired, state) + _other -> close(:service_unavailable, state) + end + end + + def handle_in({message, opcode: :binary}, %{phase: :active} = state) do + with true <- byte_size(message) <= state.limits.max_frame_bytes, + {:ok, %{kind: :send} = frame} <- Frame.decode(message), + {:ok, rate_state} <- rate_limit(state, byte_size(message)) do + rate_state = %{rate_state | last_inbound_at: System.monotonic_time(:millisecond)} + admitted = receipt(frame.attempt_id, :admitted) + + case RouteRegistry.forward( + state.registry, + state.route_id, + frame.route_id, + frame.attempt_id, + frame.payload + ) do + :ok -> + {:push, [binary: admitted, binary: receipt(frame.attempt_id, :forwarded)], rate_state} + + {:error, code} -> + {:push, [binary: admitted, binary: failure(frame.attempt_id, code)], rate_state} + end + else + {:error, :rate_limited} -> close(:rate_limited, state) + _other -> close(:bad_frame, state) + end + end + + def handle_in(_frame, state), do: close(:bad_frame, state) + + @impl true + def handle_control({_payload, opcode: opcode}, %{phase: :active} = state) + when opcode in [:ping, :pong], + do: {:ok, %{state | last_inbound_at: System.monotonic_time(:millisecond)}} + + def handle_control({_payload, opcode: opcode}, state) when opcode in [:ping, :pong], + do: {:ok, state} + + @impl true + def handle_info( + {:relay_delivery, source_route_id, attempt_id, payload, queued_bytes}, + %{phase: :active} = state + ) do + encoded = + encode!(%{ + kind: :delivery, + attempt_id: attempt_id, + route_id: source_route_id, + payload: payload + }) + + send(self(), {:delivery_handed_to_socket, queued_bytes}) + {:push, {:binary, encoded}, state} + end + + def handle_info({:delivery_handed_to_socket, queued_bytes}, %{phase: :active} = state) do + RouteRegistry.delivered(state.registry, state.route_id, queued_bytes) + {:ok, state} + end + + def handle_info(:heartbeat, %{phase: :active} = state) do + now = System.monotonic_time(:millisecond) + + if now - state.last_inbound_at >= state.limits.idle_timeout_ms do + close(:idle_timeout, state) + else + Process.send_after(self(), :heartbeat, state.limits.heartbeat_interval_ms) + {:push, {:ping, <<>>}, state} + end + end + + def handle_info({:route_snapshot, own, peers}, state) do + {:push, {:binary, discovery("route_snapshot", own, peers)}, state} + end + + def handle_info({:route_available, peer}, state) do + {:push, {:binary, discovery("route_available", nil, [peer])}, state} + end + + def handle_info({:route_unavailable, peer}, state) do + {:push, {:binary, discovery("route_unavailable", nil, [peer])}, state} + end + + def handle_info(:lease_expired, state), do: close(:unauthorized, state) + def handle_info(:route_revoked, state), do: close(:unauthorized, state) + def handle_info(:route_replaced, state), do: close(:unauthorized, state) + def handle_info(:slow_consumer, state), do: close(:slow_consumer, state) + def handle_info(:relay_draining, state), do: close(:service_unavailable, state) + + def handle_info(:admission_timeout, %{phase: :awaiting_admission} = state), + do: close(:unauthorized, state) + + def handle_info(:admission_timeout, state), do: {:ok, state} + def handle_info(_message, state), do: {:ok, state} + + @impl true + def terminate(_reason, %{route_id: nil}), do: :ok + + def terminate(_reason, state) do + RouteRegistry.unregister(state.registry, state.route_id) + :ok + end + + defp rate_limit(state, bytes) do + now = System.monotonic_time(:millisecond) + + current = + if now - state.rate_window_started >= @rate_window_ms do + %{state | rate_window_started: now, rate_frames: 0, rate_bytes: 0} + else + state + end + + max_bytes = current.limits.max_frame_bytes * @max_frames_per_window + + if current.rate_frames + 1 > @max_frames_per_window or current.rate_bytes + bytes > max_bytes do + {:error, :rate_limited} + else + {:ok, + %{ + current + | rate_frames: current.rate_frames + 1, + rate_bytes: current.rate_bytes + bytes + }} + end + end + + defp receipt(attempt_id, status) do + encode!(%{kind: :receipt, attempt_id: attempt_id, status: status}) + end + + defp failure(attempt_id, code) do + encode!(%{kind: :failure, attempt_id: attempt_id, code: code}) + end + + defp encode!(frame) do + {:ok, encoded} = Frame.encode(frame) + encoded + end + + defp discovery(type, own, peers) do + message = %{ + "version" => 1, + "type" => type, + "peers" => Enum.map(peers, &json_route/1) + } + + message = if own == nil, do: message, else: Map.put(message, "sourceRoute", json_route(own)) + message |> :json.encode() |> IO.iodata_to_binary() + end + + defp json_route(route) do + value = %{ + "routeId" => route.route_id, + "role" => Atom.to_string(route.role) + } + + if route.device_id == nil, do: value, else: Map.put(value, "deviceId", route.device_id) + end + + defp close(code, state), + do: {:stop, :normal, {1008, Atom.to_string(code)}, state} +end diff --git a/services/relay/lib/axl_relay/frame.ex b/services/relay/lib/axl_relay/frame.ex new file mode 100644 index 00000000..e1905009 --- /dev/null +++ b/services/relay/lib/axl_relay/frame.ex @@ -0,0 +1,169 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Frame do + @moduledoc "Bounded transport-v1 framing for opaque relay payloads." + + @magic "AXLR" + @transport_version 1 + @max_frame_bytes 65_535 + @routed_header_bytes 38 + @max_payload_bytes @max_frame_bytes - @routed_header_bytes + @failure_codes %{ + 1 => :bad_frame, + 2 => :unsupported_transport_version, + 3 => :unauthorized, + 4 => :forbidden_route, + 5 => :ticket_expired, + 6 => :ticket_consumed, + 7 => :destination_offline, + 8 => :rate_limited, + 9 => :queue_full, + 10 => :slow_consumer, + 11 => :service_unavailable, + 12 => :ticket_revoked + } + + @type relay_frame :: + %{ + kind: :send | :delivery, + attempt_id: String.t(), + route_id: String.t(), + payload: binary() + } + | %{kind: :receipt, attempt_id: String.t(), status: :admitted | :forwarded} + | %{kind: :failure, attempt_id: String.t(), code: atom()} + + @spec max_frame_bytes() :: pos_integer() + def max_frame_bytes, do: @max_frame_bytes + + @spec max_payload_bytes() :: pos_integer() + def max_payload_bytes, do: @max_payload_bytes + + @spec decode(binary()) :: {:ok, relay_frame()} | {:error, atom()} + def decode(frame) when is_binary(frame) and byte_size(frame) <= @max_frame_bytes do + decode_bounded(frame) + end + + def decode(_frame), do: {:error, :bad_frame} + + defp decode_bounded(<<@magic, version, _rest::binary>>) when version != @transport_version, + do: {:error, :unsupported_transport_version} + + defp decode_bounded( + <<@magic, @transport_version, kind, attempt::binary-size(16), route::binary-size(16), + payload::binary>> + ) + when kind in [1, 2] do + with {:ok, attempt_id} <- decode_uuid(attempt), + {:ok, route_id} <- decode_uuid(route) do + {:ok, + %{ + kind: if(kind == 1, do: :send, else: :delivery), + attempt_id: attempt_id, + route_id: route_id, + payload: payload + }} + end + end + + defp decode_bounded(<<@magic, @transport_version, 3, attempt::binary-size(16), status>>) do + with {:ok, attempt_id} <- decode_uuid(attempt), + {:ok, decoded_status} <- decode_status(status) do + {:ok, %{kind: :receipt, attempt_id: attempt_id, status: decoded_status}} + end + end + + defp decode_bounded(<<@magic, @transport_version, 4, attempt::binary-size(16), code>>) do + with {:ok, attempt_id} <- decode_uuid(attempt), + {:ok, decoded_code} <- decode_failure(code) do + {:ok, %{kind: :failure, attempt_id: attempt_id, code: decoded_code}} + end + end + + defp decode_bounded(<<@magic, @transport_version, _rest::binary>>), do: {:error, :bad_frame} + defp decode_bounded(_frame), do: {:error, :bad_frame} + + @spec encode(relay_frame()) :: {:ok, binary()} | {:error, :bad_frame} + def encode(%{kind: kind, attempt_id: attempt_id, route_id: route_id, payload: payload}) + when kind in [:send, :delivery] and is_binary(payload) and + byte_size(payload) <= @max_payload_bytes do + with {:ok, attempt} <- encode_uuid(attempt_id), + {:ok, route} <- encode_uuid(route_id) do + kind_byte = if kind == :send, do: 1, else: 2 + + {:ok, + <<@magic, @transport_version, kind_byte, attempt::binary, route::binary, payload::binary>>} + end + end + + def encode(%{kind: :receipt, attempt_id: attempt_id, status: status}) do + with {:ok, attempt} <- encode_uuid(attempt_id), + {:ok, status_byte} <- encode_status(status) do + {:ok, <<@magic, @transport_version, 3, attempt::binary, status_byte>>} + end + end + + def encode(%{kind: :failure, attempt_id: attempt_id, code: code}) do + with {:ok, attempt} <- encode_uuid(attempt_id), + {:ok, code_byte} <- encode_failure(code) do + {:ok, <<@magic, @transport_version, 4, attempt::binary, code_byte>>} + end + end + + def encode(_frame), do: {:error, :bad_frame} + + defp decode_status(1), do: {:ok, :admitted} + defp decode_status(2), do: {:ok, :forwarded} + defp decode_status(_status), do: {:error, :bad_frame} + + defp encode_status(:admitted), do: {:ok, 1} + defp encode_status(:forwarded), do: {:ok, 2} + defp encode_status(_status), do: {:error, :bad_frame} + + defp decode_failure(value) do + case Map.fetch(@failure_codes, value) do + {:ok, code} -> {:ok, code} + :error -> {:error, :bad_frame} + end + end + + defp encode_failure(code) do + case Enum.find(@failure_codes, fn {_value, candidate} -> candidate == code end) do + nil -> {:error, :bad_frame} + {value, _candidate} -> {:ok, value} + end + end + + defp encode_uuid(value) when is_binary(value) do + case Base.decode16(String.replace(value, "-", ""), case: :lower) do + {:ok, bytes} when byte_size(bytes) == 16 -> decode_uuid(bytes, bytes) + _other -> {:error, :bad_frame} + end + end + + defp encode_uuid(_value), do: {:error, :bad_frame} + + defp decode_uuid(bytes), do: decode_uuid(bytes, format_uuid(bytes)) + + defp decode_uuid(<<_::48, version::4, _::12, 2::2, _::62>>, result) + when version >= 1 and version <= 8, + do: {:ok, result} + + defp decode_uuid(_bytes, _result), do: {:error, :bad_frame} + + defp format_uuid(bytes) do + hex = Base.encode16(bytes, case: :lower) + + Enum.join( + [ + binary_part(hex, 0, 8), + binary_part(hex, 8, 4), + binary_part(hex, 12, 4), + binary_part(hex, 16, 4), + binary_part(hex, 20, 12) + ], + "-" + ) + end +end diff --git a/services/relay/lib/axl_relay/http_control_plane_client.ex b/services/relay/lib/axl_relay/http_control_plane_client.ex new file mode 100644 index 00000000..65a80c8a --- /dev/null +++ b/services/relay/lib/axl_relay/http_control_plane_client.ex @@ -0,0 +1,175 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.HttpControlPlaneClient do + @moduledoc "HTTP implementation of the authenticated control-plane admission boundary." + + @behaviour AxlRelay.ControlPlaneClient + + @impl true + def consume_ticket(admission, relay_instance_id, options) do + with {:ok, origin} <- Keyword.fetch(options, :origin), + true <- valid_origin?(origin, options), + {:ok, headers} when headers != [] <- Keyword.fetch(options, :headers), + body <- + :json.encode(%{ + "version" => 1, + "ticket" => admission["ticket"], + "relayInstanceId" => relay_instance_id, + "connectionNonce" => admission["connectionNonce"], + "possessionProof" => admission["possessionProof"] + }) + |> IO.iodata_to_binary(), + {:ok, response} <- post(origin, headers, body), + {:ok, result} <- validate_result(response) do + {:ok, result} + else + {:error, code} when is_atom(code) -> {:error, code} + _other -> {:error, :service_unavailable} + end + end + + defp valid_origin?(origin, options) when is_binary(origin) do + case URI.parse(origin) do + %URI{scheme: "https", host: host, path: path, query: nil, fragment: nil, userinfo: nil} + when is_binary(host) and path in [nil, ""] -> + true + + %URI{scheme: "http", host: host, path: path, query: nil, fragment: nil, userinfo: nil} + when host in ["127.0.0.1", "::1"] and path in [nil, ""] -> + Keyword.get(options, :allow_insecure_loopback_for_tests, false) == true + + _other -> + false + end + end + + defp valid_origin?(_origin, _options), do: false + + defp post(origin, headers, body) do + url = String.to_charlist(origin <> "/internal/v1/relay/tickets/consume") + request_headers = [{~c"content-type", ~c"application/json"} | headers] + request = {url, request_headers, ~c"application/json", body} + + case :httpc.request(:post, request, [timeout: 5_000, connect_timeout: 3_000], + body_format: :binary + ) do + {:ok, {{_http, 200, _reason}, _headers, response}} -> + decode_json(response) + + {:ok, {{_http, status, _reason}, _headers, response}} when status in [401, 409] -> + decode_error(response) + + _other -> + {:error, :service_unavailable} + end + end + + defp decode_json(body) do + {:ok, :json.decode(body)} + catch + _kind, _reason -> {:error, :service_unavailable} + end + + defp decode_error(body) do + with {:ok, %{"error" => %{"code" => code}}} <- decode_json(body), + mapped when not is_nil(mapped) <- + Map.get( + %{ + "unauthorized" => :unauthorized, + "ticket_expired" => :ticket_expired, + "ticket_consumed" => :ticket_consumed, + "ticket_revoked" => :ticket_revoked + }, + code + ) do + {:error, mapped} + else + _other -> {:error, :service_unavailable} + end + end + + @doc false + def validate_result(result) when is_map(result) do + required = + MapSet.new([ + "version", + "installationId", + "sourceRouteId", + "role", + "grantGeneration", + "leaseExpiresAt", + "limits" + ]) + + allowed = MapSet.put(required, "deviceId") + keys = MapSet.new(Map.keys(result)) + + with true <- MapSet.subset?(required, keys) and MapSet.subset?(keys, allowed), + 1 <- result["version"], + true <- uuid?(result["installationId"]), + true <- uuid?(result["sourceRouteId"]), + role when role in ["daemon", "device"] <- result["role"], + true <- valid_device?(role, result["deviceId"]), + generation when is_integer(generation) and generation > 0 <- result["grantGeneration"], + lease when is_integer(lease) and lease >= 0 <- result["leaseExpiresAt"], + {:ok, limits} <- validate_limits(result["limits"]) do + {:ok, + %{ + installation_id: result["installationId"], + device_id: result["deviceId"], + source_route_id: result["sourceRouteId"], + role: String.to_existing_atom(role), + grant_generation: generation, + lease_expires_at: lease, + limits: limits + }} + else + _other -> {:error, :service_unavailable} + end + end + + def validate_result(_result), do: {:error, :service_unavailable} + + defp validate_limits(limits) when is_map(limits) do + keys = + MapSet.new([ + "maxFrameBytes", + "maxQueuedBytes", + "heartbeatIntervalMs", + "idleTimeoutMs" + ]) + + with ^keys <- MapSet.new(Map.keys(limits)), + frame when is_integer(frame) and frame in 1..65_535 <- limits["maxFrameBytes"], + queued when is_integer(queued) and queued in 1..524_288 <- limits["maxQueuedBytes"], + heartbeat when is_integer(heartbeat) and heartbeat in 1..300_000 <- + limits["heartbeatIntervalMs"], + idle when is_integer(idle) and idle in 1..600_000 <- limits["idleTimeoutMs"] do + {:ok, + %{ + max_frame_bytes: frame, + max_queued_bytes: queued, + heartbeat_interval_ms: heartbeat, + idle_timeout_ms: idle + }} + else + _other -> {:error, :service_unavailable} + end + end + + defp validate_limits(_limits), do: {:error, :service_unavailable} + + defp valid_device?("daemon", nil), do: true + defp valid_device?("device", value), do: uuid?(value) + defp valid_device?(_role, _value), do: false + + defp uuid?(value) when is_binary(value) do + Regex.match?( + ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + value + ) + end + + defp uuid?(_value), do: false +end diff --git a/services/relay/lib/axl_relay/listener.ex b/services/relay/lib/axl_relay/listener.ex new file mode 100644 index 00000000..1ca0c91b --- /dev/null +++ b/services/relay/lib/axl_relay/listener.ex @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Listener do + @moduledoc "Configured Bandit listener for the relay's public and internal boundaries." + + def child_spec(options) do + Bandit.child_spec(bandit_options(options)) + end + + def start_link(options) do + Bandit.start_link(bandit_options(options)) + end + + defp bandit_options(options) do + [ + plug: + {AxlRelay.Router, + [ + connection_options: Keyword.fetch!(options, :connection_options), + internal_authenticator: Keyword.fetch!(options, :internal_authenticator), + internal_authenticator_options: + Keyword.get(options, :internal_authenticator_options, []), + registry: Keyword.get(options, :registry, AxlRelay.RouteRegistry) + ]}, + scheme: Keyword.get(options, :scheme, :https), + ip: Keyword.get(options, :ip, {127, 0, 0, 1}), + port: Keyword.fetch!(options, :port), + startup_log: false + ] + end +end diff --git a/services/relay/lib/axl_relay/revocation_handler.ex b/services/relay/lib/axl_relay/revocation_handler.ex new file mode 100644 index 00000000..a39b095e --- /dev/null +++ b/services/relay/lib/axl_relay/revocation_handler.ex @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.InternalAuthenticator do + @moduledoc "Injected authentication boundary for control-plane callbacks." + + @callback authenticate(Plug.Conn.t(), binary(), keyword()) :: boolean() +end + +defmodule AxlRelay.RevocationHandler do + @moduledoc "Runtime validation for best-effort route revocation." + + @doc false + def parse_notification(body) do + with decoded when is_map(decoded) <- :json.decode(body), + required <- MapSet.new(["version", "installationId", "generation", "effectiveAt"]), + allowed <- MapSet.put(required, "deviceId"), + keys <- MapSet.new(Map.keys(decoded)), + true <- MapSet.subset?(required, keys) and MapSet.subset?(keys, allowed), + 1 <- decoded["version"], + true <- uuid?(decoded["installationId"]), + true <- is_nil(decoded["deviceId"]) or uuid?(decoded["deviceId"]), + generation when is_integer(generation) and generation > 0 <- decoded["generation"], + effective_at when is_integer(effective_at) and effective_at >= 0 <- + decoded["effectiveAt"] do + {:ok, + %{ + installation_id: decoded["installationId"], + device_id: decoded["deviceId"], + generation: generation, + effective_at: effective_at + }} + else + _other -> {:error, :bad_request} + end + catch + _kind, _reason -> {:error, :bad_request} + end + + defp uuid?(value) when is_binary(value) do + Regex.match?( + ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + value + ) + end + + defp uuid?(_value), do: false +end diff --git a/services/relay/lib/axl_relay/route_registry.ex b/services/relay/lib/axl_relay/route_registry.ex new file mode 100644 index 00000000..6a528fb6 --- /dev/null +++ b/services/relay/lib/axl_relay/route_registry.ex @@ -0,0 +1,302 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.RouteRegistry do + @moduledoc "Role-scoped in-memory routes with bounded pending bytes and eviction." + + use GenServer + + @default_slow_consumer_grace_ms 10_000 + + def start_link(options \\ []) do + case Keyword.get(options, :name, __MODULE__) do + nil -> GenServer.start_link(__MODULE__, options) + name -> GenServer.start_link(__MODULE__, options, name: name) + end + end + + def register(server \\ __MODULE__, pid, admission), + do: GenServer.call(server, {:register, pid, admission}) + + def unregister(server \\ __MODULE__, route_id), + do: GenServer.call(server, {:unregister, route_id}) + + def forward(server \\ __MODULE__, source_route_id, destination_route_id, attempt_id, payload) do + GenServer.call(server, {:forward, source_route_id, destination_route_id, attempt_id, payload}) + end + + def delivered(server \\ __MODULE__, route_id, bytes), + do: GenServer.cast(server, {:delivered, route_id, bytes}) + + def revoke(server \\ __MODULE__, notification), + do: GenServer.call(server, {:revoke, notification}) + + def drain(server \\ __MODULE__), do: GenServer.call(server, :drain) + def snapshot(server \\ __MODULE__), do: GenServer.call(server, :snapshot) + + @impl true + def init(options) do + {:ok, + %{ + routes: %{}, + monitors: %{}, + generations: %{}, + draining: false, + slow_consumer_grace_ms: + Keyword.get(options, :slow_consumer_grace_ms, @default_slow_consumer_grace_ms) + }} + end + + @impl true + def handle_call({:register, _pid, _admission}, _from, %{draining: true} = state), + do: {:reply, {:error, :service_unavailable}, state} + + def handle_call({:register, pid, admission}, _from, state) do + route_id = admission.source_route_id + + cond do + Map.has_key?(state.routes, route_id) -> + {:reply, {:error, :forbidden_route}, state} + + admission.grant_generation <= revoked_generation(state, admission) -> + {:reply, {:error, :ticket_revoked}, state} + + true -> + replacements = + Enum.filter(state.routes, fn {_id, route} -> same_identity?(route, admission) end) + + Enum.each(replacements, fn {_id, route} -> send(route.pid, :route_replaced) end) + + without_replaced = + Enum.reduce(replacements, state, fn {id, _route}, current -> + remove_route(current, id, false) + end) + + monitor = Process.monitor(pid) + + route = + Map.merge(admission, %{ + pid: pid, + monitor: monitor, + queued_bytes: 0, + saturation_token: nil + }) + + next = %{ + without_replaced + | routes: Map.put(without_replaced.routes, route_id, route), + monitors: Map.put(without_replaced.monitors, monitor, route_id) + } + + peers = visible_peers(next, route) + send(pid, {:route_snapshot, descriptor(route), Enum.map(peers, &descriptor/1)}) + Enum.each(peers, fn peer -> send(peer.pid, {:route_available, descriptor(route)}) end) + {:reply, :ok, next} + end + end + + def handle_call({:unregister, route_id}, _from, state), + do: {:reply, :ok, remove_route(state, route_id)} + + def handle_call( + {:forward, source_route_id, destination_route_id, attempt_id, payload}, + _from, + state + ) do + source = state.routes[source_route_id] + destination = state.routes[destination_route_id] + queued_bytes = byte_size(payload) + 38 + + cond do + source == nil -> + {:reply, {:error, :unauthorized}, state} + + destination == nil -> + {:reply, {:error, :destination_offline}, state} + + source.installation_id != destination.installation_id or source.role == destination.role -> + {:reply, {:error, :forbidden_route}, state} + + destination.queued_bytes + queued_bytes > destination.limits.max_queued_bytes -> + {:reply, {:error, :queue_full}, mark_saturated(state, destination_route_id)} + + true -> + send( + destination.pid, + {:relay_delivery, source_route_id, attempt_id, payload, queued_bytes} + ) + + next_bytes = destination.queued_bytes + queued_bytes + next = put_in(state, [:routes, destination_route_id, :queued_bytes], next_bytes) + + next = + if next_bytes >= destination.limits.max_queued_bytes, + do: mark_saturated(next, destination_route_id), + else: next + + {:reply, :ok, next} + end + end + + def handle_call({:revoke, notification}, _from, state) do + key = {notification.installation_id, notification.device_id || :all} + previous = Map.get(state.generations, key, 0) + + if notification.generation <= previous do + {:reply, :ok, state} + else + matching = + Enum.filter(state.routes, fn {_route_id, route} -> + route.installation_id == notification.installation_id and + (notification.device_id == nil or route.device_id == notification.device_id) and + route.grant_generation <= notification.generation + end) + + Enum.each(matching, fn {_route_id, route} -> send(route.pid, :route_revoked) end) + + next = + Enum.reduce(matching, state, fn {route_id, _route}, current -> + remove_route(current, route_id) + end) + + {:reply, :ok, + %{next | generations: Map.put(next.generations, key, notification.generation)}} + end + end + + def handle_call(:drain, _from, state) do + Enum.each(state.routes, fn {_route_id, route} -> send(route.pid, :relay_draining) end) + {:reply, :ok, %{state | draining: true}} + end + + def handle_call(:snapshot, _from, state) do + routes = + Map.new(state.routes, fn {route_id, route} -> + {route_id, + %{ + installation_id: route.installation_id, + device_id: route.device_id, + role: route.role, + grant_generation: route.grant_generation, + queued_bytes: route.queued_bytes + }} + end) + + {:reply, %{routes: routes, draining: state.draining}, state} + end + + @impl true + def handle_cast({:delivered, route_id, bytes}, state) do + case state.routes[route_id] do + nil -> + {:noreply, state} + + route -> + queued = max(0, route.queued_bytes - bytes) + next = put_in(state, [:routes, route_id, :queued_bytes], queued) + + next = + if queued <= div(route.limits.max_queued_bytes, 2) do + put_in(next, [:routes, route_id, :saturation_token], nil) + else + next + end + + {:noreply, next} + end + end + + @impl true + def handle_info({:slow_consumer_check, route_id, token}, state) do + case state.routes[route_id] do + %{saturation_token: ^token} = route -> + if route.queued_bytes > div(route.limits.max_queued_bytes, 2) do + send(route.pid, :slow_consumer) + {:noreply, remove_route(state, route_id)} + else + {:noreply, put_in(state, [:routes, route_id, :saturation_token], nil)} + end + + _other -> + {:noreply, state} + end + end + + def handle_info({:DOWN, monitor, :process, _pid, _reason}, state) do + case state.monitors[monitor] do + nil -> {:noreply, state} + route_id -> {:noreply, remove_route(state, route_id)} + end + end + + defp mark_saturated(state, route_id) do + case state.routes[route_id] do + nil -> + state + + %{saturation_token: nil} -> + token = make_ref() + + Process.send_after( + self(), + {:slow_consumer_check, route_id, token}, + state.slow_consumer_grace_ms + ) + + put_in(state, [:routes, route_id, :saturation_token], token) + + _route -> + state + end + end + + defp visible_peers(state, route) do + state.routes + |> Map.values() + |> Enum.filter(fn candidate -> + candidate.source_route_id != route.source_route_id and + candidate.installation_id == route.installation_id and candidate.role != route.role + end) + end + + defp descriptor(route) do + %{ + route_id: route.source_route_id, + role: route.role, + device_id: route.device_id + } + end + + defp same_identity?(left, right) do + left.installation_id == right.installation_id and left.role == right.role and + (left.role == :daemon or left.device_id == right.device_id) + end + + defp revoked_generation(state, admission) do + all = Map.get(state.generations, {admission.installation_id, :all}, 0) + device = Map.get(state.generations, {admission.installation_id, admission.device_id}, 0) + max(all, device) + end + + defp remove_route(state, route_id, notify \\ true) do + case Map.pop(state.routes, route_id) do + {nil, _routes} -> + state + + {route, routes} -> + Process.demonitor(route.monitor, [:flush]) + next = %{state | routes: routes, monitors: Map.delete(state.monitors, route.monitor)} + + notify_unavailable(next, route, notify) + next + end + end + + defp notify_unavailable(_state, _route, false), do: :ok + + defp notify_unavailable(state, route, true) do + Enum.each(visible_peers(state, route), fn peer -> + send(peer.pid, {:route_unavailable, descriptor(route)}) + end) + end +end diff --git a/services/relay/lib/axl_relay/router.ex b/services/relay/lib/axl_relay/router.ex new file mode 100644 index 00000000..c2da801f --- /dev/null +++ b/services/relay/lib/axl_relay/router.ex @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.Router do + @moduledoc "Plug boundary for WebSocket admission and authenticated revocation." + + import Plug.Conn + + @behaviour Plug + @max_body_bytes 4_096 + + @impl true + def init(options), do: options + + @impl true + def call(%{method: "GET", path_info: ["v1", "connect"]} = connection, options) do + connection + |> WebSockAdapter.upgrade( + AxlRelay.Connection, + Keyword.fetch!(options, :connection_options), + compress: false, + timeout: 60_000, + max_frame_size: AxlRelay.Frame.max_frame_bytes() + ) + |> halt() + end + + def call( + %{method: "POST", path_info: ["internal", "v1", "revocations"]} = connection, + options + ) do + with {:ok, body, connection} <- + read_body(connection, length: @max_body_bytes, read_length: @max_body_bytes), + authenticator <- Keyword.fetch!(options, :internal_authenticator), + true <- + authenticator.authenticate( + connection, + body, + Keyword.get(options, :internal_authenticator_options, []) + ), + {:ok, notification} <- AxlRelay.RevocationHandler.parse_notification(body), + :ok <- + AxlRelay.RouteRegistry.revoke( + Keyword.get(options, :registry, AxlRelay.RouteRegistry), + notification + ) do + json(connection, 200, %{"version" => 1, "accepted" => true}) + else + false -> + json(connection, 401, %{"error" => %{"code" => "unauthorized"}}) + + {:more, _body, connection} -> + json(connection, 413, %{"error" => %{"code" => "bad_request"}}) + + _other -> + json(connection, 400, %{"error" => %{"code" => "bad_request"}}) + end + end + + def call(connection, _options) do + status = if connection.method in ["GET", "POST"], do: 404, else: 405 + json(connection, status, %{"error" => %{"code" => "not_found"}}) + end + + defp json(connection, status, body) do + encoded = body |> :json.encode() |> IO.iodata_to_binary() + + connection + |> put_resp_header("cache-control", "no-store") + |> put_resp_header("content-type", "application/json; charset=utf-8") + |> put_resp_header("x-content-type-options", "nosniff") + |> send_resp(status, encoded) + |> halt() + end +end diff --git a/services/relay/mix.exs b/services/relay/mix.exs new file mode 100644 index 00000000..84f1b0e6 --- /dev/null +++ b/services/relay/mix.exs @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.MixProject do + use Mix.Project + + def project do + [ + app: :axl_relay, + version: "0.1.0", + elixir: "~> 1.18", + start_permanent: Mix.env() == :prod, + deps: deps(), + dialyzer: [plt_add_apps: [:bandit, :inets, :ssl]] + ] + end + + def application do + [ + extra_applications: [:logger, :inets, :ssl], + mod: {AxlRelay.Application, []} + ] + end + + defp deps do + [ + {:bandit, "1.12.5"}, + {:plug, "1.20.3"}, + {:websock_adapter, "0.6.0"}, + {:credo, "1.7.12", only: [:dev, :test], runtime: false}, + {:dialyxir, "1.4.6", only: [:dev, :test], runtime: false}, + {:mix_audit, "2.1.5", only: [:dev, :test], runtime: false} + ] + end +end diff --git a/services/relay/mix.lock b/services/relay/mix.lock new file mode 100644 index 00000000..d364c596 --- /dev/null +++ b/services/relay/mix.lock @@ -0,0 +1,20 @@ +%{ + "bandit": {:hex, :bandit, "1.12.5", "af205a8e550f304caae09a97d29fd3c79a7f337526ea7cd772d2ff11d2f7c800", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "c5684ca062fa407cac115aec3256383f3e2ec9fdced7904d59cf5a7bb7ed6181"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, + "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, + "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, + "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"}, + "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, + "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"}, + "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"}, +} diff --git a/services/relay/test/frame_test.exs b/services/relay/test/frame_test.exs new file mode 100644 index 00000000..c7aaf953 --- /dev/null +++ b/services/relay/test/frame_test.exs @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.FrameTest do + use ExUnit.Case, async: true + + alias AxlRelay.Frame + + @fixture_path Path.expand( + "../../../packages/protocol/test/fixtures/remote-transport-v1.json", + __DIR__ + ) + @fixtures @fixture_path |> File.read!() |> :json.decode() + + test "accepts and reproduces the TypeScript canonical frames" do + for fixture <- @fixtures["accepted"] do + bytes = Base.decode64!(fixture["base64"]) + assert {:ok, frame} = Frame.decode(bytes), fixture["name"] + assert fixture_shape(frame) == fixture["frame"], fixture["name"] + assert {:ok, ^bytes} = Frame.encode(frame), fixture["name"] + end + end + + test "rejects every malformed canonical frame" do + for fixture <- @fixtures["rejected"] do + bytes = Base.decode64!(fixture["base64"]) + assert {:error, _reason} = Frame.decode(bytes), fixture["name"] + end + end + + test "keeps failure byte assignments stable" do + codes = [ + bad_frame: 1, + unsupported_transport_version: 2, + unauthorized: 3, + forbidden_route: 4, + ticket_expired: 5, + ticket_consumed: 6, + destination_offline: 7, + rate_limited: 8, + queue_full: 9, + slow_consumer: 10, + service_unavailable: 11, + ticket_revoked: 12 + ] + + for {code, value} <- codes do + assert {:ok, <<"AXLR", 1, 4, _attempt::binary-size(16), ^value>>} = + Frame.encode(%{ + kind: :failure, + attempt_id: "11111111-1111-4111-8111-111111111111", + code: code + }) + end + end + + test "rejects an oversized frame before parsing" do + assert {:error, :bad_frame} = Frame.decode(:binary.copy(<<0>>, Frame.max_frame_bytes() + 1)) + end + + defp fixture_shape(%{kind: kind, attempt_id: attempt_id, route_id: route_id, payload: payload}) do + %{ + "kind" => Atom.to_string(kind), + "attemptId" => attempt_id, + "routeId" => route_id, + "opaquePayloadBase64" => Base.encode64(payload) + } + end + + defp fixture_shape(%{kind: :receipt, attempt_id: attempt_id, status: status}) do + %{ + "kind" => "receipt", + "attemptId" => attempt_id, + "status" => Atom.to_string(status) + } + end + + defp fixture_shape(%{kind: :failure, attempt_id: attempt_id, code: code}) do + %{ + "kind" => "failure", + "attemptId" => attempt_id, + "code" => Atom.to_string(code) + } + end +end diff --git a/services/relay/test/internal_contract_test.exs b/services/relay/test/internal_contract_test.exs new file mode 100644 index 00000000..8654fc6c --- /dev/null +++ b/services/relay/test/internal_contract_test.exs @@ -0,0 +1,62 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.InternalContractTest do + use ExUnit.Case, async: true + + alias AxlRelay.{Admission, HttpControlPlaneClient, RevocationHandler} + + @fixture_path Path.expand( + "../../../packages/protocol/test/fixtures/internal-relay-api-v1.json", + __DIR__ + ) + @fixtures @fixture_path |> File.read!() |> :json.decode() + + test "accepts the TypeScript ticket-consumption fixture" do + result = @fixtures["consumeTicket"]["result"] + + assert {:ok, parsed} = HttpControlPlaneClient.validate_result(result) + assert parsed.installation_id == result["installationId"] + assert parsed.device_id == result["deviceId"] + assert parsed.source_route_id == result["sourceRouteId"] + assert parsed.role == :device + assert parsed.grant_generation == result["grantGeneration"] + assert parsed.limits.max_frame_bytes == 65_535 + assert parsed.limits.max_queued_bytes == 524_288 + end + + test "accepts the role-filtered discovery fixture" do + snapshot = @fixtures["discovery"]["deviceSnapshot"] + assert snapshot["version"] == 1 + assert snapshot["type"] == "route_snapshot" + assert snapshot["sourceRoute"]["role"] == "device" + assert [%{"role" => "daemon"}] = snapshot["peers"] + refute Map.has_key?(hd(snapshot["peers"]), "deviceId") + end + + test "forms the admitted WebSocket message without relay-owned fields" do + consume = @fixtures["consumeTicket"]["request"] + + admission = + Map.take(consume, ["version", "ticket", "connectionNonce", "possessionProof"]) + |> :json.encode() + |> IO.iodata_to_binary() + + assert {:ok, parsed} = Admission.parse(admission) + assert parsed["ticket"] == consume["ticket"] + refute Map.has_key?(parsed, "relayInstanceId") + end + + test "accepts the TypeScript revocation fixture and rejects unknown fields" do + request = @fixtures["revocation"]["request"] + bytes = request |> :json.encode() |> IO.iodata_to_binary() + + assert {:ok, parsed} = RevocationHandler.parse_notification(bytes) + assert parsed.installation_id == request["installationId"] + assert parsed.device_id == request["deviceId"] + assert parsed.generation == request["generation"] + + malformed = request |> Map.put("unexpected", true) |> :json.encode() |> IO.iodata_to_binary() + assert {:error, :bad_request} = RevocationHandler.parse_notification(malformed) + end +end diff --git a/services/relay/test/route_registry_test.exs b/services/relay/test/route_registry_test.exs new file mode 100644 index 00000000..c5309ad0 --- /dev/null +++ b/services/relay/test/route_registry_test.exs @@ -0,0 +1,211 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.RouteRegistryTest do + use ExUnit.Case, async: true + + alias AxlRelay.RouteRegistry + + @installation "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + @source "11111111-1111-4111-8111-111111111111" + @destination "22222222-2222-4222-8222-222222222222" + @attempt "33333333-3333-4333-8333-333333333333" + + setup do + registry = start_supervised!({RouteRegistry, name: nil}) + parent = self() + + source = spawn_link(fn -> forward_messages(parent, :source) end) + destination = spawn_link(fn -> forward_messages(parent, :destination) end) + + limits = %{max_queued_bytes: 50} + + assert :ok = + RouteRegistry.register(registry, source, %{ + installation_id: @installation, + device_id: nil, + role: :daemon, + grant_generation: 1, + source_route_id: @source, + limits: limits + }) + + assert :ok = + RouteRegistry.register(registry, destination, %{ + installation_id: @installation, + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: :device, + grant_generation: 1, + source_route_id: @destination, + limits: limits + }) + + %{registry: registry, destination: destination, source: source} + end + + test "routes only inside one installation and bounds pending bytes", %{registry: registry} do + assert :ok = RouteRegistry.forward(registry, @source, @destination, @attempt, <<1, 2, 3>>) + + assert_receive {:destination, {:relay_delivery, @source, @attempt, <<1, 2, 3>>, 41}} + + assert {:error, :queue_full} = + RouteRegistry.forward(registry, @source, @destination, @attempt, <<1, 2, 3>>) + + RouteRegistry.delivered(registry, @destination, 41) + + assert_eventually(fn -> + RouteRegistry.snapshot(registry).routes[@destination].queued_bytes == 0 + end) + + other_route = "44444444-4444-4444-8444-444444444444" + parent = self() + other = spawn_link(fn -> forward_messages(parent, :other) end) + + assert :ok = + RouteRegistry.register(registry, other, %{ + installation_id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + device_id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + role: :device, + grant_generation: 1, + source_route_id: other_route, + limits: %{max_queued_bytes: 50} + }) + + assert {:error, :forbidden_route} = + RouteRegistry.forward(registry, @source, other_route, @attempt, <<1>>) + end + + test "rejects same-role routing and replaces an older device identity", %{registry: registry} do + parent = self() + second_device = spawn_link(fn -> forward_messages(parent, :second_device) end) + second_route = "66666666-6666-4666-8666-666666666666" + + assert :ok = + RouteRegistry.register(registry, second_device, %{ + installation_id: @installation, + device_id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + role: :device, + grant_generation: 1, + source_route_id: second_route, + limits: %{max_queued_bytes: 50} + }) + + assert {:error, :forbidden_route} = + RouteRegistry.forward(registry, @destination, second_route, @attempt, <<1>>) + + replacement = spawn_link(fn -> forward_messages(parent, :replacement) end) + replacement_route = "77777777-7777-4777-8777-777777777777" + + assert :ok = + RouteRegistry.register(registry, replacement, %{ + installation_id: @installation, + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: :device, + grant_generation: 1, + source_route_id: replacement_route, + limits: %{max_queued_bytes: 50} + }) + + assert_receive {:destination, :route_replaced} + refute Map.has_key?(RouteRegistry.snapshot(registry).routes, @destination) + assert Map.has_key?(RouteRegistry.snapshot(registry).routes, replacement_route) + end + + test "evicts a queue that remains above half after saturation" do + registry = + start_supervised!( + Supervisor.child_spec( + {RouteRegistry, name: nil, slow_consumer_grace_ms: 10}, + id: make_ref() + ) + ) + + parent = self() + daemon = spawn_link(fn -> forward_messages(parent, :slow_daemon) end) + device = spawn_link(fn -> forward_messages(parent, :slow_device) end) + + assert :ok = + RouteRegistry.register(registry, daemon, %{ + installation_id: @installation, + device_id: nil, + role: :daemon, + grant_generation: 1, + source_route_id: @source, + limits: %{max_queued_bytes: 50} + }) + + assert :ok = + RouteRegistry.register(registry, device, %{ + installation_id: @installation, + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: :device, + grant_generation: 1, + source_route_id: @destination, + limits: %{max_queued_bytes: 50} + }) + + assert :ok = RouteRegistry.forward(registry, @source, @destination, @attempt, <<1, 2, 3>>) + + assert {:error, :queue_full} = + RouteRegistry.forward(registry, @source, @destination, @attempt, <<4>>) + + assert_receive {:slow_device, :slow_consumer}, 100 + refute Map.has_key?(RouteRegistry.snapshot(registry).routes, @destination) + end + + test "revocation closes matching routes and draining rejects admission", %{registry: registry} do + assert :ok = + RouteRegistry.revoke(registry, %{ + installation_id: @installation, + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + generation: 1 + }) + + assert_receive {:destination, :route_revoked} + refute Map.has_key?(RouteRegistry.snapshot(registry).routes, @destination) + + assert {:error, :ticket_revoked} = + RouteRegistry.register(registry, self(), %{ + installation_id: @installation, + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: :device, + grant_generation: 1, + source_route_id: "88888888-8888-4888-8888-888888888888", + limits: %{max_queued_bytes: 50} + }) + + assert :ok = RouteRegistry.drain(registry) + assert_receive {:source, :relay_draining} + + assert {:error, :service_unavailable} = + RouteRegistry.register(registry, self(), %{ + installation_id: @installation, + device_id: nil, + role: :daemon, + grant_generation: 1, + source_route_id: "55555555-5555-4555-8555-555555555555", + limits: %{max_queued_bytes: 50} + }) + end + + defp forward_messages(parent, label) do + receive do + message -> + send(parent, {label, message}) + forward_messages(parent, label) + end + end + + defp assert_eventually(assertion, attempts \\ 20) + + defp assert_eventually(assertion, attempts) when attempts > 0 do + if assertion.() do + :ok + else + Process.sleep(5) + assert_eventually(assertion, attempts - 1) + end + end + + defp assert_eventually(_assertion, 0), do: flunk("condition did not become true") +end diff --git a/services/relay/test/support/hosted_path_server.exs b/services/relay/test/support/hosted_path_server.exs new file mode 100644 index 00000000..efd8ad36 --- /dev/null +++ b/services/relay/test/support/hosted_path_server.exs @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.HostedPathTestAuthenticator do + @behaviour AxlRelay.InternalAuthenticator + + @impl true + def authenticate(connection, _body, _options) do + Plug.Conn.get_req_header(connection, "authorization") == ["Bearer internal-fixture"] + end +end + +port = System.fetch_env!("AXL_RELAY_TEST_PORT") |> String.to_integer() +control_plane_origin = System.fetch_env!("AXL_CONTROL_PLANE_TEST_ORIGIN") + +{:ok, _listener} = + AxlRelay.Listener.start_link( + scheme: :http, + port: port, + ip: {127, 0, 0, 1}, + connection_options: [ + control_plane: AxlRelay.HttpControlPlaneClient, + control_plane_options: [ + origin: control_plane_origin, + headers: [{~c"authorization", ~c"Bearer internal-fixture"}], + allow_insecure_loopback_for_tests: true + ], + relay_instance_id: "hosted-path-test", + registry: AxlRelay.RouteRegistry + ], + internal_authenticator: AxlRelay.HostedPathTestAuthenticator, + registry: AxlRelay.RouteRegistry + ) + +IO.puts("AXL_RELAY_TEST_READY") +Process.sleep(:infinity) diff --git a/services/relay/test/test_helper.exs b/services/relay/test/test_helper.exs new file mode 100644 index 00000000..25e87925 --- /dev/null +++ b/services/relay/test/test_helper.exs @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +ExUnit.start() diff --git a/services/relay/test/websocket_relay_test.exs b/services/relay/test/websocket_relay_test.exs new file mode 100644 index 00000000..35ecdc21 --- /dev/null +++ b/services/relay/test/websocket_relay_test.exs @@ -0,0 +1,323 @@ +# SPDX-FileCopyrightText: 2026 Lokesh +# SPDX-License-Identifier: Apache-2.0 + +defmodule AxlRelay.WebSocketRelayTest do + use ExUnit.Case, async: false + + alias AxlRelay.{Connection, Frame, Listener, RouteRegistry} + + @daemon_route "11111111-1111-4111-8111-111111111111" + @device_route "22222222-2222-4222-8222-222222222222" + @attempt "33333333-3333-4333-8333-333333333333" + + defmodule FakeControlPlane do + @behaviour AxlRelay.ControlPlaneClient + + @impl true + def consume_ticket(%{"ticket" => "unavailable"}, _relay_instance_id, _options), + do: {:error, :service_unavailable} + + def consume_ticket(%{"ticket" => "half-open"}, _relay_instance_id, options) do + {:ok, + %{ + installation_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + source_route_id: Keyword.fetch!(options, :device), + role: :device, + grant_generation: 1, + lease_expires_at: System.system_time(:millisecond) + 60_000, + limits: %{ + max_frame_bytes: 65_535, + max_queued_bytes: 524_288, + heartbeat_interval_ms: 10, + idle_timeout_ms: 30 + } + }} + end + + def consume_ticket(%{"ticket" => ticket}, _relay_instance_id, options) + when ticket in ["daemon", "device"] do + role = if ticket == "daemon", do: :daemon, else: :device + route_id = Keyword.fetch!(options, role) + + {:ok, + %{ + installation_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + device_id: + if(ticket == "device", + do: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + else: nil + ), + source_route_id: route_id, + role: role, + grant_generation: 1, + lease_expires_at: System.system_time(:millisecond) + 60_000, + limits: %{ + max_frame_bytes: 65_535, + max_queued_bytes: 524_288, + heartbeat_interval_ms: 20_000, + idle_timeout_ms: 60_000 + } + }} + end + end + + defmodule FakeInternalAuthenticator do + @behaviour AxlRelay.InternalAuthenticator + + @impl true + def authenticate(connection, _body, _options) do + Plug.Conn.get_req_header(connection, "authorization") == ["Bearer internal-fixture"] + end + end + + setup do + registry = start_supervised!({RouteRegistry, name: nil}) + port = free_port() + + listener = + start_supervised!( + {Listener, + scheme: :http, + port: port, + ip: {127, 0, 0, 1}, + connection_options: [ + control_plane: FakeControlPlane, + control_plane_options: [daemon: @daemon_route, device: @device_route], + relay_instance_id: "relay-test", + registry: registry + ], + internal_authenticator: FakeInternalAuthenticator, + registry: registry} + ) + + %{listener: listener, registry: registry, port: port} + end + + test "admits two sockets and routes an opaque frame with distinct receipts", %{ + registry: registry, + port: port + } do + daemon = connect(port, "daemon") + device = connect(port, "device") + + assert_eventually(fn -> map_size(RouteRegistry.snapshot(registry).routes) == 2 end) + + expect_discovered_peer(daemon, @daemon_route, "device", @device_route) + expect_discovered_peer(device, @device_route, "daemon", @daemon_route) + + assert {:ok, send_frame} = + Frame.encode(%{ + kind: :send, + attempt_id: @attempt, + route_id: @daemon_route, + payload: <<0, 1, 2, 255>> + }) + + :ok = :gen_tcp.send(device, client_binary_frame(send_frame)) + + assert {:ok, admitted} = device |> receive_binary_frame() |> Frame.decode() + assert admitted == %{kind: :receipt, attempt_id: @attempt, status: :admitted} + + assert {:ok, forwarded} = device |> receive_binary_frame() |> Frame.decode() + assert forwarded == %{kind: :receipt, attempt_id: @attempt, status: :forwarded} + + assert {:ok, delivery} = daemon |> receive_binary_frame() |> Frame.decode() + + assert delivery == %{ + kind: :delivery, + attempt_id: @attempt, + route_id: @device_route, + payload: <<0, 1, 2, 255>> + } + + :gen_tcp.close(device) + :gen_tcp.close(daemon) + end + + test "closes a half-open connection after the explicit inbound idle deadline" do + registry = + start_supervised!(Supervisor.child_spec({RouteRegistry, name: nil}, id: make_ref())) + + {:ok, state} = + Connection.init( + control_plane: FakeControlPlane, + control_plane_options: [device: @device_route], + relay_instance_id: "relay-test", + registry: registry + ) + + admission = + :json.encode(%{ + "version" => 1, + "ticket" => "half-open", + "connectionNonce" => "fixture-nonce", + "possessionProof" => "AAECA/8=" + }) + |> IO.iodata_to_binary() + + assert {:ok, active} = Connection.handle_in({admission, opcode: :binary}, state) + Process.sleep(35) + + assert {:stop, :normal, {1008, "idle_timeout"}, _state} = + Connection.handle_info(:heartbeat, active) + end + + test "fails admission closed when the control plane is unavailable", %{ + registry: registry, + port: port + } do + socket = connect(port, "unavailable") + {:ok, <<0x88, _length>>} = :gen_tcp.recv(socket, 2, 2_000) + assert RouteRegistry.snapshot(registry).routes == %{} + :gen_tcp.close(socket) + end + + test "rejects unauthenticated revocation and applies an authenticated notification", %{ + registry: registry, + port: port + } do + route = "44444444-4444-4444-8444-444444444444" + + assert :ok = + RouteRegistry.register(registry, self(), %{ + installation_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + device_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + role: :device, + grant_generation: 1, + source_route_id: route, + limits: %{max_queued_bytes: 524_288} + }) + + body = + :json.encode(%{ + "version" => 1, + "installationId" => "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + "deviceId" => "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + "generation" => 1, + "effectiveAt" => 1_900_000_000_000 + }) + |> IO.iodata_to_binary() + + url = ~c"http://127.0.0.1:#{port}/internal/v1/revocations" + + assert {:ok, {{_version, 401, _reason}, _headers, _response}} = + :httpc.request(:post, {url, [], ~c"application/json", body}, [], []) + + headers = [{~c"authorization", ~c"Bearer internal-fixture"}] + + assert {:ok, {{_version, 200, _reason}, _headers, response}} = + :httpc.request(:post, {url, headers, ~c"application/json", body}, [], + body_format: :binary + ) + + assert :json.decode(response) == %{"version" => 1, "accepted" => true} + assert_receive :route_revoked + end + + defp free_port do + {:ok, socket} = :gen_tcp.listen(0, [:binary, ip: {127, 0, 0, 1}]) + {:ok, {_address, port}} = :inet.sockname(socket) + :gen_tcp.close(socket) + port + end + + defp connect(port, ticket) do + {:ok, socket} = :gen_tcp.connect({127, 0, 0, 1}, port, [:binary, active: false]) + + request = [ + "GET /v1/connect HTTP/1.1\r\n", + "Host: 127.0.0.1:", + Integer.to_string(port), + "\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n", + "Sec-WebSocket-Key: AAECAwQFBgcICQoLDA0ODw==\r\n", + "Sec-WebSocket-Version: 13\r\n\r\n" + ] + + :ok = :gen_tcp.send(socket, request) + {:ok, response} = :gen_tcp.recv(socket, 0, 2_000) + assert String.starts_with?(response, "HTTP/1.1 101") + + admission = + :json.encode(%{ + "version" => 1, + "ticket" => ticket, + "connectionNonce" => "fixture-nonce", + "possessionProof" => "AAECA/8=" + }) + |> IO.iodata_to_binary() + + :ok = :gen_tcp.send(socket, client_binary_frame(admission)) + socket + end + + defp client_binary_frame(payload) do + mask = <<1, 2, 3, 4>> + + encoded_length = + if byte_size(payload) < 126, + do: <<0x80 + byte_size(payload)>>, + else: <<0x80 + 126, byte_size(payload)::unsigned-big-16>> + + masked = + payload + |> :binary.bin_to_list() + |> Enum.with_index() + |> Enum.map(fn {byte, index} -> Bitwise.bxor(byte, :binary.at(mask, rem(index, 4))) end) + |> :binary.list_to_bin() + + <<0x82, encoded_length::binary, mask::binary, masked::binary>> + end + + defp expect_discovered_peer(socket, source_route, peer_role, peer_route) do + assert %{ + "type" => "route_snapshot", + "sourceRoute" => %{"routeId" => ^source_route}, + "peers" => peers + } = receive_json_message(socket) + + if peers == [] do + assert %{ + "type" => "route_available", + "peers" => [%{"role" => ^peer_role, "routeId" => ^peer_route}] + } = + receive_json_message(socket) + else + assert [%{"role" => ^peer_role, "routeId" => ^peer_route}] = peers + end + end + + defp receive_json_message(socket) do + socket |> receive_binary_frame() |> :json.decode() + end + + defp receive_binary_frame(socket) do + {:ok, <<0x82, length>>} = :gen_tcp.recv(socket, 2, 2_000) + + size = + case length do + value when value < 126 -> + value + + 126 -> + {:ok, <>} = :gen_tcp.recv(socket, 2, 2_000) + value + end + + {:ok, payload} = :gen_tcp.recv(socket, size, 2_000) + payload + end + + defp assert_eventually(assertion, attempts \\ 40) + + defp assert_eventually(assertion, attempts) when attempts > 0 do + if assertion.() do + :ok + else + Process.sleep(5) + assert_eventually(assertion, attempts - 1) + end + end + + defp assert_eventually(_assertion, 0), do: flunk("condition did not become true") +end diff --git a/tsconfig.base.json b/tsconfig.base.json index a1f39a3e..8b72c4bc 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -9,6 +9,7 @@ "paths": { "@axl/ai": ["./packages/ai/src/index.ts"], "@axl/ai/models": ["./packages/ai/src/models.ts"], + "@axl/control-plane": ["./services/control-plane/src/index.ts"], "@axl/daemon": ["./packages/daemon/src/index.ts"], "@axl/daemon/client": ["./packages/daemon/src/client.ts"], "@axl/extension-api": ["./packages/extensions/api/src/index.ts"], diff --git a/tsconfig.json b/tsconfig.json index e21b7a71..db27a9cb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "noEmit": true }, - "include": ["packages/**/*.ts", "scripts/**/*.ts"], + "include": ["packages/**/*.ts", "services/**/*.ts", "scripts/**/*.ts"], "exclude": ["packages/ui", "packages/web"] }