diff --git a/.github/workflows/iroh-relay-minter.yml b/.github/workflows/iroh-relay-minter.yml new file mode 100644 index 000000000000..e11f9f44f86f --- /dev/null +++ b/.github/workflows/iroh-relay-minter.yml @@ -0,0 +1,48 @@ +name: Iroh relay minter + +on: + pull_request: + paths: + - "services/iroh-relay-minter/**" + - "tests/fixtures/iroh/relay-minter-request-v1.json" + - ".github/workflows/iroh-relay-minter.yml" + push: + branches: [main] + paths: + - "services/iroh-relay-minter/**" + - "tests/fixtures/iroh/relay-minter-request-v1.json" + - ".github/workflows/iroh-relay-minter.yml" + +concurrency: + group: iroh-relay-minter-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }} + timeout-minutes: 40 + defaults: + run: + working-directory: services/iroh-relay-minter + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install pinned Rust toolchain + run: rustup toolchain install 1.91.0 --profile minimal --component clippy,rustfmt + + - name: Check formatting + run: cargo fmt --check + + - name: Lint + run: cargo clippy --all-targets --locked -- -D warnings + + - name: Test + run: cargo test --locked + + - name: Build production function + run: cargo build --release --locked diff --git a/docs/iroh-offline-pairing-v1.md b/docs/iroh-offline-pairing-v1.md new file mode 100644 index 000000000000..b18f40ff8cf1 --- /dev/null +++ b/docs/iroh-offline-pairing-v1.md @@ -0,0 +1,155 @@ +# Iroh offline same-account pairing v1 + +Offline QR pairing is authorized by two backend-signed endpoint attestations. +Possession of the QR payload is never sufficient. + +## Online preparation + +Authenticated clients fetch `GET /api/devices/iroh`. Its +`grant_verification_keys` field is a version 1 public Ed25519 key set containing +the current key and, during rotation, one previous key. The response never +contains a private key, account ID, email, or team ID. + +An authenticated client requests an attestation for its active binding with: + +```http +POST /api/devices/iroh/endpoint-attestations +Content-Type: application/json + +{"bindingId":""} +``` + +The response contains `attestation_version`, `attestation`, `expires_at`, and +the same public verification-key set. The attestation lifetime is 24 hours. +Revoked bindings and bindings owned by another account return `binding_not_found`. + +The web deployment requires these server-only values: + +- `CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64`: 32 random bytes in standard base64. +- `CMUX_IROH_GRANT_SIGNING_KEY_P8`: an Ed25519 PKCS#8 private key in PEM form. +- `CMUX_IROH_GRANT_SIGNING_KID`: the current key ID. +- `CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON`: the version 1 current plus optional + previous public-key set shown by the API. + +Generate signing material with: + +```sh +openssl genpkey -algorithm ED25519 -out iroh-grant-current.pem +openssl pkey -in iroh-grant-current.pem -pubout -outform DER | openssl base64 -A +openssl rand -base64 32 +``` + +Never put the private key or account-subject secret in a client-visible Vercel +variable. Rotate signing keys in three deployments: + +1. Keep the old signer and `current_kid`, then add the next public key as the + second verification key. +2. After clients have fetched that key set, switch the signer and `current_kid` + to the new key while retaining the old public key as the second key. +3. At least seven days after the signer switch, remove the old public key. Seven + days is the maximum online pair-grant lifetime. + +The service rejects a deployment whose private signer does not match +`current_kid`. Roll back signer, KID, and verification-key JSON as one unit. A +forced account-subject-secret rotation invalidates cached offline attestations, +so clients must refresh before pairing. + +## Signed attestation + +The attestation is a compact JWS signed with Ed25519. The protected header has +exactly these fields: + +```json +{"alg":"EdDSA","typ":"cmux-endpoint-attestation-v1+jwt","kid":""} +``` + +The payload has exactly these fields: + +```json +{ + "version": 1, + "jti": "", + "sub": "<32-byte unpadded base64url account subject>", + "bindingId": "", + "deviceId": "", + "endpointId": "<64 lowercase hex characters>", + "identityGeneration": 1, + "platform": "ios", + "iat": 1783627200, + "nbf": 1783627195, + "exp": 1783713600, + "alpn": "cmux/mobile/1", + "scope": "cmux.offline-pair.same-account" +} +``` + +`sub` is HMAC-SHA256 over the private backend account identifier with a +dedicated server secret and a versioned domain separator. It lets two devices +compare account membership without disclosing the underlying identifier. It is +stable for that account until the subject secret rotates. A peer that collects +copied attestations can therefore correlate them as belonging to the same +account during their 24-hour validity. Session-scoping `sub` would require both +devices to contact the backend for the same session, which would remove the +offline property. The one-use local invitation below limits authorization +replay, but does not remove this pseudonymous correlation. The final privacy +review must re-evaluate this tradeoff before release. + +## Offline authorization + +The Mac creates a five-minute local pairing session inside its endpoint actor. +It generates a random UUID `session_id` and 32 random bytes `proof`. The QR +contains this exact authorization object plus the Mac attestation: + +```json +{ + "version": 1, + "session_id": "", + "proof": "<32-byte unpadded base64url>", + "expires_at": 1783627500, + "acceptor_attestation": "" +} +``` + +The Mac stores only the proof hash, exact local binding/device/EndpointID/ +identity-generation tuple, expiry, and an unconsumed marker. Route hints may be +included elsewhere in the QR, but never enter the proof transcript. The iOS +initiator presents the QR authorization and its own cached attestation after +the authenticated Iroh connection is established. The Mac performs all checks +and the consume transition in one actor-isolated operation: + +1. Verify canonical JWS encoding and Ed25519 signature with a cached current or + previous public key. +2. Require the fixed version, type, ALPN, scope, and a currently valid lifetime. +3. Bind every device, binding, EndpointID, identity generation, and platform + claim to the expected local state and the authenticated Iroh peer EndpointID. +4. Require an iOS initiator, a Mac acceptor, distinct bindings, devices, and + EndpointIDs, and equal 32-byte account subjects. +5. Require the signed online pair-grant direction everywhere: iOS initiator and + Mac acceptor. +6. Require the exact unexpired local `session_id`, constant-time proof-hash + match, exact local acceptor tuple, and `consumed_at == nil`. +7. Apply local revocation and pairing-disabled state, then set `consumed_at` + before returning the accepted admission. + +A missing attestation, one attestation used for both peers, a subject mismatch, +an expired token, an EndpointID substitution, a wrong proof, or a replayed +session fails closed. A failed proof or attestation check does not consume the +session. A successful session is consumed even if later application setup +fails, so retry requires a new QR. Network addresses inside a QR remain +untrusted route hints and do not participate in authorization. + +Offline verification cannot observe a revocation made after the last refresh. +The 24-hour expiry bounds that window. A client without a fresh attestation must +go online before first-time pairing. + +## Release gate + +The TypeScript reference verifier and backend behavior tests enforce this +contract, including one-use consumption and staged key rotation. Shipping +offline QR pairing remains blocked until the Swift client has equivalent actor +tests that prove QR possession alone fails, both attestations are required, the +live Iroh EndpointIDs are bound, concurrent replay has one winner, and current +plus previous key rotation works. Release also requires a privacy decision on +the documented 24-hour pseudonymous account-correlation window. Production and +staging must use distinct account-subject secrets and Ed25519 signing key sets, +so an attestation from one environment cannot verify in another. diff --git a/services/iroh-relay-minter/.env.example b/services/iroh-relay-minter/.env.example new file mode 100644 index 000000000000..5f56d2143364 --- /dev/null +++ b/services/iroh-relay-minter/.env.example @@ -0,0 +1,5 @@ +# Configure these only on the isolated relay-minter Vercel project. +IROH_SERVICES_API_SECRET= +CMUX_IROH_MINT_HMAC_SECRET_B64= +# Optional, minter-only overlap key during a bounded HMAC rotation. +CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64= diff --git a/services/iroh-relay-minter/.gitignore b/services/iroh-relay-minter/.gitignore new file mode 100644 index 000000000000..226a93ca6de2 --- /dev/null +++ b/services/iroh-relay-minter/.gitignore @@ -0,0 +1,4 @@ +/target/ +/.vercel/ +/.env* +!/.env.example diff --git a/services/iroh-relay-minter/Cargo.lock b/services/iroh-relay-minter/Cargo.lock new file mode 100644 index 000000000000..c31436a8045b --- /dev/null +++ b/services/iroh-relay-minter/Cargo.lock @@ -0,0 +1,4590 @@ +# 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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64", + "http", + "log", + "url", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[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 = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" +dependencies = [ + "cargo-lock", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-lock" +version = "11.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63585cdf8572aa7adf0e30a253f988f2b77233bfac1973d52efb6dd53a75920e" +dependencies = [ + "semver", + "serde", + "toml", + "url", +] + +[[package]] +name = "cc" +version = "1.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[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", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cmux-iroh-relay-minter" +version = "0.1.0" +dependencies = [ + "base64", + "data-encoding", + "futures-util", + "hex", + "hmac", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "iroh", + "iroh-services", + "rcan", + "serde", + "serde_json", + "sha2 0.10.9", + "time", + "tokio", + "vercel_runtime", + "zeroize", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[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 = "5.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rand_core", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[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", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn", +] + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + +[[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", + "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", + "crypto-common 0.2.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "serdect", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2 0.11.0", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core", + "wasm-bindgen", +] + +[[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 = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + +[[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" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http", + "idna", + "ipnet", + "jni 0.22.4", + "rand", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + +[[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 = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-layer", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + +[[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 = "igd-next" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand", + "tokio", + "url", + "xmltree", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] + +[[package]] +name = "iroh" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6435544bb3a5c4e6ff7affaa0c0aa0d1bca45bd700226329d5059d3eb54f9dff" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek", + "futures-util", + "getrandom 0.4.3", + "hickory-resolver", + "http", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "portmapper", + "rand", + "reqwest", + "rustc-hash", + "rustls", + "rustls-pki-types", + "serde", + "smallvec", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + +[[package]] +name = "iroh-base" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830a582cd54410dc1aa71d4786a82c3297d7b0165accd8b6dbbb3b240b48140d" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek", + "getrandom 0.4.3", + "n0-error", + "rand", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-dns" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "portable-atomic", + "rand", + "rustls", + "simple-dns", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "iroh-relay" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.3", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand", + "reqwest", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots", + "ws_stream_wasm", +] + +[[package]] +name = "iroh-services" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a88cd95fbd20abd9eadc4df91c722915dc943412b964e915f35b0b0a46a1fd" +dependencies = [ + "anyhow", + "base64", + "built", + "bytes", + "data-encoding", + "derive_more", + "ed25519-dalek", + "futures-buffered", + "getrandom 0.4.3", + "iroh", + "iroh-metrics", + "iroh-tickets", + "irpc", + "irpc-iroh", + "n0-error", + "n0-future", + "portmapper", + "postcard", + "rand", + "rcan", + "serde", + "serde_json", + "strum", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "iroh-tickets" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da53233419ca36bf521ed45683b7748366f9b233032891eefc2d70567a84ac54" +dependencies = [ + "data-encoding", + "derive_more", + "iroh-base", + "n0-error", + "postcard", + "serde", +] + +[[package]] +name = "irpc" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3623d6ff582b415904b29bbe6ebcb4a4f9a262ccdee05a45fdd003ef0950c386" +dependencies = [ + "futures-buffered", + "futures-util", + "irpc-derive", + "n0-error", + "n0-future", + "noq", + "postcard", + "rcgen", + "rustls", + "serde", + "smallvec", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "irpc-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c254013736de16472140d26904e6ac98e8f3887284dcf4af40f88c77411b56" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "irpc-iroh" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2342daed629b312f61e57e452b0750a59da162f261b97f260a6354de61d4fb0e" +dependencies = [ + "getrandom 0.3.4", + "iroh", + "iroh-base", + "irpc", + "n0-error", + "n0-future", + "postcard", + "serde", + "tokio", + "tracing", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[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 = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "netdev" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows", + "windows-result", + "wmi", +] + +[[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 = "noq" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.3", + "identity-hash", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e" +dependencies = [ + "cfg_aliases", + "libc", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +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 = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[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 = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] + +[[package]] +name = "portmapper" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721" +dependencies = [ + "base64", + "bytes", + "derive_more", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "n0-future", + "netwatch", + "num_enum", + "rand", + "serde", + "smallvec", + "socket2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[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", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rcan" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a624a4a4742f8c6e58fba99712e606cea0491b76f5b2345f06af5802101027" +dependencies = [ + "anyhow", + "derive_more", + "ed25519-dalek", + "hex", + "n0-future", + "postcard", + "serde", + "serdect", +] + +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +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 = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[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 = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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 = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +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.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +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 = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[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.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[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.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "js-sys", + "libc", + "num-conv", + "num_threads", + "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.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.3", + "http", + "httparse", + "rand", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[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_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[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 = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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 = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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", + "serde_derive", +] + +[[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.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vercel_runtime" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f5e7942f725023f1572b7fef91b0aeddc8749a3b9b0b191f59c208c42ed8e62" +dependencies = [ + "base64", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "lazy_static", + "libc", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower", +] + +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[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 = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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 = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows", + "windows-core", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[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", + "synstructure", +] + +[[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", + "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", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/services/iroh-relay-minter/Cargo.toml b/services/iroh-relay-minter/Cargo.toml new file mode 100644 index 000000000000..f50a71e4290d --- /dev/null +++ b/services/iroh-relay-minter/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "cmux-iroh-relay-minter" +version = "0.1.0" +edition = "2024" +rust-version = "1.91" +publish = false +autobins = false + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "relay-token" +path = "api/relay-token.rs" + +[dependencies] +base64 = "0.22.1" +data-encoding = "2.9.0" +hex = "0.4.3" +hmac = "0.12.1" +http-body = "1.0.1" +http-body-util = "0.1.3" +hyper = { version = "1.7.0", features = ["http1"] } +iroh = { version = "=1.0.0", default-features = false } +iroh-services = { version = "=1.0.0", default-features = false } +rcan = "=0.4.0" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +sha2 = "0.10.9" +time = { version = "0.3.44", features = ["formatting", "parsing"] } +tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } +vercel_runtime = "=2.0.0" +zeroize = "1.8.1" + +[dev-dependencies] +futures-util = "0.3.31" +hyper-util = { version = "0.1.17", features = ["server", "http1", "tokio"] } +tokio = { version = "1.47.1", features = ["net"] } + +[profile.release] +codegen-units = 1 +lto = "fat" +opt-level = 3 +panic = "abort" +strip = true + +[lints.rust] +unsafe_code = "forbid" + +[lints.clippy] +dbg_macro = "deny" +todo = "deny" +unimplemented = "deny" diff --git a/services/iroh-relay-minter/README.md b/services/iroh-relay-minter/README.md new file mode 100644 index 000000000000..9d9858cf0684 --- /dev/null +++ b/services/iroh-relay-minter/README.md @@ -0,0 +1,98 @@ +# Iroh relay-token minter + +This Vercel Rust project is the only cmux service allowed to hold the Iroh +Services project credential. It converts a short-lived request authenticated by +the cmux web trust broker into a 24-hour, endpoint-scoped RCAN containing only +`relay:use`. + +Deploy this directory as a separate Vercel project. Set its Root Directory to +`services/iroh-relay-minter`. Do not add `IROH_SERVICES_API_SECRET` to the cmux +web project because Vercel environment variables are project-wide. + +## Environment + +The minter project requires: + +- `IROH_SERVICES_API_SECRET`: the rotated Iroh Services project secret. It is + parsed by `iroh-services` 1.0.0 and is never returned or logged. +- `CMUX_IROH_MINT_HMAC_SECRET_B64`: 32 to 256 random bytes encoded as canonical + standard base64. Generate a new 32-byte value with `openssl rand -base64 32`. +- `CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64`: optional minter-only previous key + accepted during a bounded rotation overlap. It must differ from the current + key. The web project never receives this value. + +The web project requires the same `CMUX_IROH_MINT_HMAC_SECRET_B64` value and: + +- `CMUX_IROH_MINT_URL=https:///api/relay-token` + +Rotate any Iroh Services credential previously pasted into chat or logs before +putting it in Vercel. A Services credential rotation affects only the minter. + +Rotate the HMAC without an outage in this order: + +1. Deploy the minter with the new key in `CMUX_IROH_MINT_HMAC_SECRET_B64` and + the old key in `CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64`. +2. Change the web project's `CMUX_IROH_MINT_HMAC_SECRET_B64` to the new key. +3. Keep the previous key for at least five minutes, which covers the 30-second + request timestamp window and deployment propagation. +4. Remove `CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64` from the minter. + +The overlap changes only which HMAC key authenticates the existing fixed +method, path, timestamp, and body-hash transcript. It does not expand the +minter route or RCAN capabilities. + +## Wire contract + +The only accepted route is `POST /api/relay-token` with one `Content-Type` +header whose media type is `application/json`, optionally followed by parameters +such as `charset=utf-8`, no query string, and this body: + +```json +{"endpointId":"<64 lowercase hex characters>","lifetimeSeconds":86400} +``` + +The web service sends: + +- `x-cmux-iroh-timestamp`: canonical Unix seconds, within 30 seconds of the + minter clock. +- `x-cmux-iroh-signature`: unpadded base64url HMAC-SHA256 over the transcript + below. + +```text +POST +/api/relay-token + + +``` + +The response is bounded JSON: + +```json +{"token":"","expiresAt":""} +``` + +The RCAN issuer is the Iroh Services project key, the audience is the supplied +EndpointID, the sole capability is `relay:use`, and expiry is 86,400 seconds. +The trust broker stores only issuance audit state and refreshes the relay token +after 12 hours. + +## Local verification + +No production secrets are needed for tests. + +```sh +cargo fmt --check +cargo clippy --all-targets --locked -- -D warnings +cargo test --locked +``` + +For authenticated local dogfood, the example server binds only to loopback and +uses the same request handler as the Vercel function: + +```sh +CMUX_IROH_MINT_DEV_PORT=9460 cargo run --locked --example loopback +``` + +It still requires `IROH_SERVICES_API_SECRET` and +`CMUX_IROH_MINT_HMAC_SECRET_B64` in the process environment. Do not use a +credential copied through chat for a deployed environment; rotate it first. diff --git a/services/iroh-relay-minter/api/relay-token.rs b/services/iroh-relay-minter/api/relay-token.rs new file mode 100644 index 000000000000..1c1da8d8e52a --- /dev/null +++ b/services/iroh-relay-minter/api/relay-token.rs @@ -0,0 +1,18 @@ +use std::time::SystemTime; + +use cmux_iroh_relay_minter::{MinterConfig, configuration_error_response, handle_request}; +use vercel_runtime::{Error, Request, Response, ResponseBody, run, service_fn}; + +#[tokio::main] +async fn main() -> Result<(), Error> { + run(service_fn(handler)).await +} + +async fn handler(request: Request) -> Result, Error> { + let config = match MinterConfig::from_env() { + Ok(config) => config, + Err(_) => return Ok(configuration_error_response()), + }; + + Ok(handle_request(request, &config, SystemTime::now()).await) +} diff --git a/services/iroh-relay-minter/examples/loopback.rs b/services/iroh-relay-minter/examples/loopback.rs new file mode 100644 index 000000000000..2d501715eb9d --- /dev/null +++ b/services/iroh-relay-minter/examples/loopback.rs @@ -0,0 +1,37 @@ +use std::{convert::Infallible, env, sync::Arc, time::SystemTime}; + +use cmux_iroh_relay_minter::{MinterConfig, handle_request}; +use hyper::{Request, body::Incoming, server::conn::http1, service::service_fn}; +use hyper_util::rt::TokioIo; +use tokio::net::TcpListener; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let port = env::var("CMUX_IROH_MINT_DEV_PORT") + .ok() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(9460); + let listener = TcpListener::bind(("127.0.0.1", port)).await?; + let config = Arc::new(MinterConfig::from_env()?); + eprintln!("Iroh relay minter listening on http://127.0.0.1:{port}"); + + loop { + let (stream, peer) = listener.accept().await?; + if !peer.ip().is_loopback() { + continue; + } + let config = Arc::clone(&config); + tokio::spawn(async move { + let service = service_fn(move |request: Request| { + let config = Arc::clone(&config); + async move { + Ok::<_, Infallible>(handle_request(request, &config, SystemTime::now()).await) + } + }); + let _ = http1::Builder::new() + .serve_connection(TokioIo::new(stream), service) + .await; + }); + } +} diff --git a/services/iroh-relay-minter/rust-toolchain.toml b/services/iroh-relay-minter/rust-toolchain.toml new file mode 100644 index 000000000000..0f39414be778 --- /dev/null +++ b/services/iroh-relay-minter/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.91.0" +profile = "minimal" +components = ["clippy", "rustfmt"] diff --git a/services/iroh-relay-minter/src/lib.rs b/services/iroh-relay-minter/src/lib.rs new file mode 100644 index 000000000000..2672a171f1b0 --- /dev/null +++ b/services/iroh-relay-minter/src/lib.rs @@ -0,0 +1,929 @@ +use std::{ + env, fmt, + str::FromStr, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use base64::{ + Engine as _, + engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD}, +}; +use data_encoding::BASE32_NOPAD; +use hmac::{Hmac, Mac}; +use http_body::Body; +use http_body_util::BodyExt as _; +use hyper::{ + Method, Request, Response, StatusCode, + body::Bytes, + header::{ALLOW, CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE, HeaderMap, HeaderName}, +}; +use iroh::{EndpointId, SecretKey}; +use iroh_services::{ + ApiSecret, + caps::{Cap, Caps, RelayCap, create_api_token_from_secret_key}, +}; +use rcan::Expires; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use vercel_runtime::ResponseBody; +use zeroize::Zeroizing; + +pub const MINT_PATH: &str = "/api/relay-token"; +pub const RELAY_TOKEN_LIFETIME_SECONDS: u64 = 86_400; +pub const MAX_REQUEST_BYTES: usize = 4 * 1_024; + +const MAX_RESPONSE_BYTES: usize = 32 * 1_024; +const MAX_TOKEN_BYTES: usize = 16 * 1_024; +const CLOCK_SKEW_SECONDS: u64 = 30; +const SERVICES_SECRET_ENV: &str = "IROH_SERVICES_API_SECRET"; +const HMAC_SECRET_ENV: &str = "CMUX_IROH_MINT_HMAC_SECRET_B64"; +const HMAC_PREVIOUS_SECRET_ENV: &str = "CMUX_IROH_MINT_HMAC_PREVIOUS_SECRET_B64"; +const TIMESTAMP_HEADER: HeaderName = HeaderName::from_static("x-cmux-iroh-timestamp"); +const SIGNATURE_HEADER: HeaderName = HeaderName::from_static("x-cmux-iroh-signature"); + +type HmacSha256 = Hmac; + +pub struct MinterConfig { + issuer: SecretKey, + hmac_secret: Zeroizing>, + hmac_previous_secret: Option>>, +} + +impl MinterConfig { + pub fn from_env() -> Result { + let services_secret = Zeroizing::new(bounded_env(SERVICES_SECRET_ENV, 16_384)?); + let api_secret = + ApiSecret::from_str(services_secret.as_str()).map_err(|_| ConfigurationError)?; + let hmac_secret_text = Zeroizing::new(bounded_env(HMAC_SECRET_ENV, 512)?); + let hmac_secret = Zeroizing::new(decode_hmac_secret(hmac_secret_text.as_str())?); + let hmac_previous_secret = optional_bounded_env(HMAC_PREVIOUS_SECRET_ENV, 512)? + .map(Zeroizing::new) + .map(|value| decode_hmac_secret(value.as_str())) + .transpose()? + .map(Zeroizing::new); + if hmac_previous_secret + .as_ref() + .is_some_and(|previous| previous.as_slice() == hmac_secret.as_slice()) + { + return Err(ConfigurationError); + } + + Ok(Self { + issuer: api_secret.secret, + hmac_secret, + hmac_previous_secret, + }) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct ConfigurationError; + +impl fmt::Display for ConfigurationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("relay minter configuration is unavailable") + } +} + +impl std::error::Error for ConfigurationError {} + +pub async fn handle_request( + request: Request, + config: &MinterConfig, + now: SystemTime, +) -> Response +where + B: Body + Unpin, +{ + match process_request(request, config, now).await { + Ok(response) => json_response(StatusCode::OK, &response), + Err(error) => error_response(error.status(), error.code()), + } +} + +pub fn configuration_error_response() -> Response { + error_response(StatusCode::SERVICE_UNAVAILABLE, "configuration_unavailable") +} + +async fn process_request( + request: Request, + config: &MinterConfig, + now: SystemTime, +) -> Result +where + B: Body + Unpin, +{ + if request.method() != Method::POST { + return Err(RequestFailure::Method); + } + if request.uri().path() != MINT_PATH || request.uri().query().is_some() { + return Err(RequestFailure::Path); + } + require_json_content_type(request.headers())?; + validate_content_length(request.headers())?; + + let timestamp_text = single_header(request.headers(), &TIMESTAMP_HEADER) + .ok_or(RequestFailure::Authentication)? + .to_owned(); + let timestamp = parse_timestamp(×tamp_text).ok_or(RequestFailure::Authentication)?; + let now_seconds = unix_seconds(now).ok_or(RequestFailure::Internal)?; + if now_seconds.abs_diff(timestamp) > CLOCK_SKEW_SECONDS { + return Err(RequestFailure::Authentication); + } + + let signature_text = single_header(request.headers(), &SIGNATURE_HEADER) + .ok_or(RequestFailure::Authentication)?; + let signature = decode_signature(signature_text).ok_or(RequestFailure::Authentication)?; + + let body = read_bounded_body(request.into_body()).await?; + verify_configured_hmac(config, ×tamp_text, &body, &signature)?; + + let input: MintRequest = + serde_json::from_slice(&body).map_err(|_| RequestFailure::InvalidBody)?; + if input.lifetime_seconds != RELAY_TOKEN_LIFETIME_SECONDS { + return Err(RequestFailure::InvalidLifetime); + } + let endpoint_id = parse_endpoint_id(&input.endpoint_id)?; + + mint_token(config, endpoint_id, now_seconds) +} + +async fn read_bounded_body(mut body: B) -> Result, RequestFailure> +where + B: Body + Unpin, +{ + if body + .size_hint() + .upper() + .is_some_and(|upper| upper > MAX_REQUEST_BYTES as u64) + { + return Err(RequestFailure::BodyTooLarge); + } + + let mut bytes = Vec::with_capacity( + body.size_hint() + .upper() + .unwrap_or(0) + .min(MAX_REQUEST_BYTES as u64) as usize, + ); + while let Some(frame) = body.frame().await { + let frame = frame.map_err(|_| RequestFailure::InvalidBody)?; + let Ok(data) = frame.into_data() else { + continue; + }; + let Some(next_len) = bytes.len().checked_add(data.len()) else { + return Err(RequestFailure::BodyTooLarge); + }; + if next_len > MAX_REQUEST_BYTES { + return Err(RequestFailure::BodyTooLarge); + } + bytes.extend_from_slice(&data); + } + Ok(bytes) +} + +fn mint_token( + config: &MinterConfig, + endpoint_id: EndpointId, + authenticated_at: u64, +) -> Result { + let capability = Caps::new([Cap::Relay(RelayCap::Use)]); + let rcan = create_api_token_from_secret_key( + config.issuer.clone(), + endpoint_id, + Duration::from_secs(RELAY_TOKEN_LIFETIME_SECONDS), + capability, + ) + .map_err(|_| RequestFailure::Internal)?; + + let Expires::At(expires_at) = rcan.expires() else { + return Err(RequestFailure::Internal); + }; + let expected_expiry = authenticated_at + .checked_add(RELAY_TOKEN_LIFETIME_SECONDS) + .ok_or(RequestFailure::Internal)?; + if expected_expiry.abs_diff(*expires_at) > 2 { + return Err(RequestFailure::Internal); + } + + let mut token = BASE32_NOPAD.encode(&rcan.encode()); + token.make_ascii_lowercase(); + if token.is_empty() + || token.len() > MAX_TOKEN_BYTES + || token.contains('=') + || !token + .bytes() + .all(|byte| byte.is_ascii_lowercase() || (b'2'..=b'7').contains(&byte)) + { + return Err(RequestFailure::Internal); + } + + let expires_at_i64 = i64::try_from(*expires_at).map_err(|_| RequestFailure::Internal)?; + let expires_at = OffsetDateTime::from_unix_timestamp(expires_at_i64) + .map_err(|_| RequestFailure::Internal)? + .format(&Rfc3339) + .map_err(|_| RequestFailure::Internal)?; + if expires_at.len() > 64 { + return Err(RequestFailure::Internal); + } + + Ok(MintResponse { token, expires_at }) +} + +fn verify_hmac( + secret: &[u8], + timestamp: &str, + body: &[u8], + signature: &[u8; 32], +) -> Result<(), RequestFailure> { + let body_hash = hex::encode(Sha256::digest(body)); + let transcript = format!("POST\n{MINT_PATH}\n{timestamp}\n{body_hash}"); + let mut mac = HmacSha256::new_from_slice(secret).map_err(|_| RequestFailure::Internal)?; + mac.update(transcript.as_bytes()); + mac.verify_slice(signature) + .map_err(|_| RequestFailure::Authentication) +} + +fn verify_configured_hmac( + config: &MinterConfig, + timestamp: &str, + body: &[u8], + signature: &[u8; 32], +) -> Result<(), RequestFailure> { + let current_matches = + verify_hmac(config.hmac_secret.as_slice(), timestamp, body, signature).is_ok(); + let previous_matches = config + .hmac_previous_secret + .as_ref() + .is_some_and(|secret| verify_hmac(secret.as_slice(), timestamp, body, signature).is_ok()); + if current_matches || previous_matches { + Ok(()) + } else { + Err(RequestFailure::Authentication) + } +} + +fn parse_endpoint_id(value: &str) -> Result { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(RequestFailure::InvalidEndpoint); + } + let mut bytes = [0_u8; 32]; + hex::decode_to_slice(value, &mut bytes).map_err(|_| RequestFailure::InvalidEndpoint)?; + EndpointId::from_bytes(&bytes).map_err(|_| RequestFailure::InvalidEndpoint) +} + +fn require_json_content_type(headers: &HeaderMap) -> Result<(), RequestFailure> { + let content_type = single_header(headers, &CONTENT_TYPE).ok_or(RequestFailure::ContentType)?; + let media_type = content_type + .split(';') + .next() + .map(str::trim) + .unwrap_or_default(); + if media_type.eq_ignore_ascii_case("application/json") { + Ok(()) + } else { + Err(RequestFailure::ContentType) + } +} + +fn validate_content_length(headers: &HeaderMap) -> Result<(), RequestFailure> { + let values = headers.get_all(&CONTENT_LENGTH); + let mut values = values.iter(); + let Some(value) = values.next() else { + return Ok(()); + }; + if values.next().is_some() { + return Err(RequestFailure::InvalidBody); + } + let length = value + .to_str() + .ok() + .and_then(|value| value.parse::().ok()) + .ok_or(RequestFailure::InvalidBody)?; + if length > MAX_REQUEST_BYTES { + Err(RequestFailure::BodyTooLarge) + } else { + Ok(()) + } +} + +fn single_header<'a>(headers: &'a HeaderMap, name: &HeaderName) -> Option<&'a str> { + let values = headers.get_all(name); + let mut values = values.iter(); + let value = values.next()?.to_str().ok()?; + if values.next().is_some() { + return None; + } + Some(value) +} + +fn parse_timestamp(value: &str) -> Option { + if value.is_empty() || value.len() > 20 || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let parsed: u64 = value.parse().ok()?; + (parsed.to_string() == value).then_some(parsed) +} + +fn decode_signature(value: &str) -> Option<[u8; 32]> { + if value.len() != 43 || value.contains('=') { + return None; + } + let decoded = URL_SAFE_NO_PAD.decode(value.as_bytes()).ok()?; + let signature: [u8; 32] = decoded.try_into().ok()?; + (URL_SAFE_NO_PAD.encode(signature) == value).then_some(signature) +} + +fn decode_hmac_secret(value: &str) -> Result, ConfigurationError> { + let decoded = STANDARD + .decode(value.as_bytes()) + .or_else(|_| STANDARD_NO_PAD.decode(value.as_bytes())) + .map_err(|_| ConfigurationError)?; + if decoded.len() < 32 || decoded.len() > 256 { + return Err(ConfigurationError); + } + let canonical_padded = STANDARD.encode(&decoded); + let canonical_unpadded = STANDARD_NO_PAD.encode(&decoded); + if value != canonical_padded && value != canonical_unpadded { + return Err(ConfigurationError); + } + Ok(decoded) +} + +fn bounded_env(name: &str, max_bytes: usize) -> Result { + let value = env::var(name).map_err(|_| ConfigurationError)?; + if value.is_empty() || value.len() > max_bytes { + return Err(ConfigurationError); + } + Ok(value) +} + +fn optional_bounded_env( + name: &str, + max_bytes: usize, +) -> Result, ConfigurationError> { + match env::var(name) { + Ok(value) if !value.is_empty() && value.len() <= max_bytes => Ok(Some(value)), + Ok(_) => Err(ConfigurationError), + Err(env::VarError::NotPresent) => Ok(None), + Err(env::VarError::NotUnicode(_)) => Err(ConfigurationError), + } +} + +fn unix_seconds(time: SystemTime) -> Option { + time.duration_since(UNIX_EPOCH) + .ok() + .map(|value| value.as_secs()) +} + +fn json_response(status: StatusCode, value: &impl Serialize) -> Response { + let body = serde_json::to_string(value) + .unwrap_or_else(|_| "{\"error\":\"internal_error\"}".to_owned()); + if body.len() > MAX_RESPONSE_BYTES { + return error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal_error"); + } + response(status, body) +} + +fn error_response(status: StatusCode, code: &'static str) -> Response { + let body = serde_json::to_string(&ErrorResponse { error: code }) + .unwrap_or_else(|_| "{\"error\":\"internal_error\"}".to_owned()); + let mut response = response(status, body); + if status == StatusCode::METHOD_NOT_ALLOWED { + response + .headers_mut() + .insert(ALLOW, "POST".parse().expect("static header value")); + } + response +} + +fn response(status: StatusCode, body: String) -> Response { + Response::builder() + .status(status) + .header(CONTENT_TYPE, "application/json") + .header(CACHE_CONTROL, "no-store") + .header("x-content-type-options", "nosniff") + .body(ResponseBody::from(body)) + .expect("static response metadata is valid") +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct MintRequest { + endpoint_id: String, + lifetime_seconds: u64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct MintResponse { + token: String, + expires_at: String, +} + +#[derive(Serialize)] +struct ErrorResponse { + error: &'static str, +} + +#[derive(Clone, Copy, Debug)] +enum RequestFailure { + Method, + Path, + ContentType, + Authentication, + BodyTooLarge, + InvalidBody, + InvalidEndpoint, + InvalidLifetime, + Internal, +} + +impl RequestFailure { + const fn status(self) -> StatusCode { + match self { + Self::Method => StatusCode::METHOD_NOT_ALLOWED, + Self::Path => StatusCode::NOT_FOUND, + Self::ContentType => StatusCode::UNSUPPORTED_MEDIA_TYPE, + Self::Authentication => StatusCode::UNAUTHORIZED, + Self::BodyTooLarge => StatusCode::PAYLOAD_TOO_LARGE, + Self::InvalidBody | Self::InvalidEndpoint | Self::InvalidLifetime => { + StatusCode::BAD_REQUEST + } + Self::Internal => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + const fn code(self) -> &'static str { + match self { + Self::Method => "method_not_allowed", + Self::Path => "not_found", + Self::ContentType => "unsupported_media_type", + Self::Authentication => "unauthorized", + Self::BodyTooLarge => "body_too_large", + Self::InvalidBody => "invalid_body", + Self::InvalidEndpoint => "invalid_endpoint_id", + Self::InvalidLifetime => "invalid_lifetime", + Self::Internal => "internal_error", + } + } +} + +#[cfg(test)] +mod tests { + use std::{convert::Infallible, time::SystemTime}; + + use futures_util::stream; + use http_body::Frame; + use http_body_util::{BodyExt as _, Full, StreamBody}; + use hyper::{ + Method, Request, StatusCode, + body::Bytes, + header::{ALLOW, HeaderValue}, + }; + use iroh::SecretKey; + use rcan::{CapabilityOrigin, Expires, Rcan}; + use time::OffsetDateTime; + + use super::*; + + const TEST_HMAC_SECRET: [u8; 32] = [0x42; 32]; + const PREVIOUS_HMAC_SECRET: [u8; 32] = [0x41; 32]; + + #[tokio::test] + async fn mints_lowercase_relay_only_rcan_for_the_exact_audience() { + let config = test_config(); + let endpoint = test_endpoint(); + let now = SystemTime::now(); + let now_seconds = unix_seconds(now).expect("test clock is after the Unix epoch"); + let body = valid_body(&endpoint.to_string()); + let request = signed_request(Method::POST, MINT_PATH, now_seconds, body); + + let response = handle_request(request, &config, now).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CACHE_CONTROL], "no-store"); + let response: MintResponse = response_json(response).await; + + assert!(!response.token.contains('=')); + assert!(response.token.len() <= MAX_TOKEN_BYTES); + assert!( + response + .token + .bytes() + .all(|byte| byte.is_ascii_lowercase() || (b'2'..=b'7').contains(&byte)) + ); + + let token_bytes = BASE32_NOPAD + .decode(response.token.to_ascii_uppercase().as_bytes()) + .expect("response is unpadded base32"); + let rcan = Rcan::::decode(&token_bytes).expect("response is a signed RCAN"); + assert_eq!(rcan.audience(), &endpoint.as_verifying_key()); + assert_eq!(rcan.issuer(), &config.issuer.public().as_verifying_key()); + assert_eq!(rcan.capability_origin(), &CapabilityOrigin::Issuer); + assert_eq!(rcan.capability().to_strings(), ["relay:use"]); + + let Expires::At(token_expiry) = rcan.expires() else { + panic!("relay token must expire"); + }; + assert!( + now_seconds + .checked_add(RELAY_TOKEN_LIFETIME_SECONDS) + .expect("test expiry is representable") + .abs_diff(*token_expiry) + <= 2 + ); + let response_expiry = OffsetDateTime::parse(&response.expires_at, &Rfc3339) + .expect("response expiry is RFC 3339"); + assert_eq!(response_expiry.unix_timestamp(), *token_expiry as i64); + } + + #[tokio::test] + async fn rejects_every_method_and_path_except_the_single_post_route() { + let config = test_config(); + let now = SystemTime::now(); + let timestamp = unix_seconds(now).expect("test clock is valid"); + let body = valid_body(&test_endpoint().to_string()); + + let response = handle_request( + signed_request(Method::GET, MINT_PATH, timestamp, body.clone()), + &config, + now, + ) + .await; + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(response.headers()[ALLOW], "POST"); + + for path in ["/", "/api/relay-token/", "/api/relay-token?debug=1"] { + let response = handle_request( + signed_request(Method::POST, path, timestamp, body.clone()), + &config, + now, + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND, "path {path}"); + } + } + + #[tokio::test] + async fn rejects_missing_wrong_or_body_substituted_hmac() { + let config = test_config(); + let now = SystemTime::now(); + let timestamp = unix_seconds(now).expect("test clock is valid"); + let body = valid_body(&test_endpoint().to_string()); + + let mut missing = signed_request(Method::POST, MINT_PATH, timestamp, body.clone()); + missing.headers_mut().remove(&SIGNATURE_HEADER); + assert_eq!( + handle_request(missing, &config, now).await.status(), + StatusCode::UNAUTHORIZED + ); + + let mut wrong = signed_request(Method::POST, MINT_PATH, timestamp, body.clone()); + wrong.headers_mut().insert( + &SIGNATURE_HEADER, + URL_SAFE_NO_PAD + .encode([0_u8; 32]) + .parse() + .expect("test header is valid"), + ); + assert_eq!( + handle_request(wrong, &config, now).await.status(), + StatusCode::UNAUTHORIZED + ); + + let mut substituted = signed_request(Method::POST, MINT_PATH, timestamp, body); + *substituted.body_mut() = Full::new(Bytes::from(valid_body( + &SecretKey::from_bytes(&[0x33; 32]).public().to_string(), + ))); + assert_eq!( + handle_request(substituted, &config, now).await.status(), + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn accepts_the_previous_hmac_secret_only_during_rotation_overlap() { + let mut config = test_config(); + config.hmac_previous_secret = Some(Zeroizing::new(PREVIOUS_HMAC_SECRET.to_vec())); + let now = SystemTime::now(); + let timestamp = unix_seconds(now).expect("test clock is valid"); + let body = valid_body(&test_endpoint().to_string()); + let request = signed_request_with_secret( + Method::POST, + MINT_PATH, + timestamp, + body.clone(), + &PREVIOUS_HMAC_SECRET, + ); + + assert_eq!( + handle_request(request, &config, now).await.status(), + StatusCode::OK + ); + let previous_after_overlap = signed_request_with_secret( + Method::POST, + MINT_PATH, + timestamp, + body, + &PREVIOUS_HMAC_SECRET, + ); + assert_eq!( + handle_request(previous_after_overlap, &test_config(), now) + .await + .status(), + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn enforces_the_thirty_second_timestamp_window() { + let config = test_config(); + let now = SystemTime::now(); + let now_seconds = unix_seconds(now).expect("test clock is valid"); + let body = valid_body(&test_endpoint().to_string()); + + for timestamp in [now_seconds - 31, now_seconds + 31] { + let response = handle_request( + signed_request(Method::POST, MINT_PATH, timestamp, body.clone()), + &config, + now, + ) + .await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + for timestamp in [now_seconds - 30, now_seconds + 30] { + let response = handle_request( + signed_request(Method::POST, MINT_PATH, timestamp, body.clone()), + &config, + now, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + } + + let mut malformed = signed_request(Method::POST, MINT_PATH, now_seconds, body); + malformed.headers_mut().insert( + &TIMESTAMP_HEADER, + "+123".parse().expect("test header is valid"), + ); + assert_eq!( + handle_request(malformed, &config, now).await.status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!(parse_timestamp("01"), None); + } + + #[tokio::test] + async fn bounds_declared_and_streamed_request_bodies() { + let config = test_config(); + let now = SystemTime::now(); + let timestamp = unix_seconds(now).expect("test clock is valid"); + let body = valid_body(&test_endpoint().to_string()); + + let mut declared = signed_request(Method::POST, MINT_PATH, timestamp, body); + declared.headers_mut().insert( + CONTENT_LENGTH, + (MAX_REQUEST_BYTES + 1) + .to_string() + .parse() + .expect("test header is valid"), + ); + assert_eq!( + handle_request(declared, &config, now).await.status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + + let chunk = Bytes::from(vec![b'x'; MAX_REQUEST_BYTES / 2 + 1]); + let streamed_body = [chunk.clone(), chunk]; + let joined = streamed_body.concat(); + let signature = sign_request(timestamp, &joined); + let body_stream = StreamBody::new(stream::iter( + streamed_body + .into_iter() + .map(|chunk| Ok::, Infallible>(Frame::data(chunk))), + )); + let streamed = Request::builder() + .method(Method::POST) + .uri(MINT_PATH) + .header(CONTENT_TYPE, "application/json") + .header(&TIMESTAMP_HEADER, timestamp.to_string()) + .header(&SIGNATURE_HEADER, signature) + .body(body_stream) + .expect("test request is valid"); + assert_eq!( + handle_request(streamed, &config, now).await.status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + } + + #[tokio::test] + async fn rejects_invalid_endpoint_lifetime_and_json_shape() { + let config = test_config(); + let now = SystemTime::now(); + let timestamp = unix_seconds(now).expect("test clock is valid"); + let endpoint = test_endpoint().to_string(); + let invalid_bodies = [ + format!( + "{{\"endpointId\":\"{}\",\"lifetimeSeconds\":86400}}", + endpoint.to_ascii_uppercase() + ), + format!("{{\"endpointId\":\"{endpoint}\",\"lifetimeSeconds\":86401}}"), + format!( + "{{\"endpointId\":\"{endpoint}\",\"lifetimeSeconds\":86400,\"capability\":\"all\"}}" + ), + "{}".to_owned(), + ]; + + for body in invalid_bodies { + let response = handle_request( + signed_request(Method::POST, MINT_PATH, timestamp, body), + &config, + now, + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + } + + #[tokio::test] + async fn accepts_json_content_type_parameters() { + let config = test_config(); + let now = SystemTime::now(); + let timestamp = unix_seconds(now).expect("test clock is valid"); + let body = valid_body(&test_endpoint().to_string()); + let mut request = signed_request(Method::POST, MINT_PATH, timestamp, body); + request.headers_mut().insert( + CONTENT_TYPE, + "Application/JSON; charset=utf-8" + .parse() + .expect("valid test header"), + ); + + assert_eq!( + handle_request(request, &config, now).await.status(), + StatusCode::OK + ); + } + + #[tokio::test] + async fn rejects_non_json_duplicate_and_malformed_content_type_headers() { + let config = test_config(); + let now = SystemTime::now(); + let timestamp = unix_seconds(now).expect("test clock is valid"); + let body = valid_body(&test_endpoint().to_string()); + let mut non_json = signed_request(Method::POST, MINT_PATH, timestamp, body.clone()); + non_json.headers_mut().insert( + CONTENT_TYPE, + "text/plain".parse().expect("valid test header"), + ); + let mut duplicate = signed_request(Method::POST, MINT_PATH, timestamp, body.clone()); + duplicate.headers_mut().append( + CONTENT_TYPE, + "application/json; charset=utf-8" + .parse() + .expect("valid test header"), + ); + let mut malformed = signed_request(Method::POST, MINT_PATH, timestamp, body); + malformed.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_bytes(&[0xff]).expect("opaque header bytes are representable"), + ); + + for request in [non_json, duplicate, malformed] { + assert_eq!( + handle_request(request, &config, now).await.status(), + StatusCode::UNSUPPORTED_MEDIA_TYPE + ); + } + } + + #[test] + fn matches_the_typescript_hmac_wire_fixture() { + #[derive(Deserialize)] + struct Fixture { + path: String, + timestamp: String, + body: String, + signature: String, + } + + let fixture: Fixture = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tests/fixtures/iroh/relay-minter-request-v1.json" + ))) + .expect("shared relay-minter fixture is valid"); + assert_eq!(fixture.path, MINT_PATH); + let timestamp = parse_timestamp(&fixture.timestamp).expect("fixture timestamp is valid"); + assert_eq!( + sign_request(timestamp, fixture.body.as_bytes()), + fixture.signature + ); + let signature = decode_signature(&fixture.signature).expect("fixture signature is valid"); + verify_hmac( + &TEST_HMAC_SECRET, + &fixture.timestamp, + fixture.body.as_bytes(), + &signature, + ) + .expect("fixture authenticates"); + let request: MintRequest = + serde_json::from_str(&fixture.body).expect("fixture body matches the contract"); + assert_eq!(request.lifetime_seconds, RELAY_TOKEN_LIFETIME_SECONDS); + parse_endpoint_id(&request.endpoint_id).expect("fixture endpoint is valid"); + } + + #[test] + fn accepts_only_canonical_hmac_secret_encodings_of_at_least_32_bytes() { + let padded = STANDARD.encode(TEST_HMAC_SECRET); + let unpadded = STANDARD_NO_PAD.encode(TEST_HMAC_SECRET); + assert_eq!( + decode_hmac_secret(&padded).expect("padded secret"), + TEST_HMAC_SECRET + ); + assert_eq!( + decode_hmac_secret(&unpadded).expect("unpadded secret"), + TEST_HMAC_SECRET + ); + assert!(decode_hmac_secret(&STANDARD.encode([1_u8; 31])).is_err()); + assert!(decode_hmac_secret(&format!(" {padded}")).is_err()); + assert!(decode_hmac_secret(&URL_SAFE_NO_PAD.encode([0xff_u8; 32])).is_err()); + } + + fn test_config() -> MinterConfig { + MinterConfig { + issuer: SecretKey::from_bytes(&[0x11; 32]), + hmac_secret: Zeroizing::new(TEST_HMAC_SECRET.to_vec()), + hmac_previous_secret: None, + } + } + + fn test_endpoint() -> EndpointId { + SecretKey::from_bytes(&[0x22; 32]).public() + } + + fn valid_body(endpoint_id: &str) -> String { + format!( + "{{\"endpointId\":\"{endpoint_id}\",\"lifetimeSeconds\":{RELAY_TOKEN_LIFETIME_SECONDS}}}" + ) + } + + fn signed_request( + method: Method, + path: &str, + timestamp: u64, + body: String, + ) -> Request> { + signed_request_with_secret(method, path, timestamp, body, &TEST_HMAC_SECRET) + } + + fn signed_request_with_secret( + method: Method, + path: &str, + timestamp: u64, + body: String, + secret: &[u8], + ) -> Request> { + Request::builder() + .method(method) + .uri(path) + .header(CONTENT_TYPE, "application/json") + .header(&TIMESTAMP_HEADER, timestamp.to_string()) + .header( + &SIGNATURE_HEADER, + sign_request_with_secret(secret, timestamp, body.as_bytes()), + ) + .body(Full::new(Bytes::from(body))) + .expect("test request is valid") + } + + fn sign_request(timestamp: u64, body: &[u8]) -> String { + sign_request_with_secret(&TEST_HMAC_SECRET, timestamp, body) + } + + fn sign_request_with_secret(secret: &[u8], timestamp: u64, body: &[u8]) -> String { + let body_hash = hex::encode(Sha256::digest(body)); + let transcript = format!("POST\n{MINT_PATH}\n{timestamp}\n{body_hash}"); + let mut mac = HmacSha256::new_from_slice(secret).expect("test HMAC key length is valid"); + mac.update(transcript.as_bytes()); + URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) + } + + async fn response_json(response: Response) -> T + where + T: for<'de> Deserialize<'de>, + { + let bytes = response + .into_body() + .collect() + .await + .expect("test response body is readable") + .to_bytes(); + serde_json::from_slice(&bytes).expect("test response is JSON") + } +} diff --git a/services/iroh-relay-minter/vercel.json b/services/iroh-relay-minter/vercel.json new file mode 100644 index 000000000000..20fc77e7e7cf --- /dev/null +++ b/services/iroh-relay-minter/vercel.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json" +} diff --git a/tests/fixtures/iroh/path-hint-v1.json b/tests/fixtures/iroh/path-hint-v1.json new file mode 100644 index 000000000000..be945f40aaf7 --- /dev/null +++ b/tests/fixtures/iroh/path-hint-v1.json @@ -0,0 +1,8 @@ +{ + "kind": "direct_address", + "value": "8.8.8.8:4433", + "source": "native", + "privacy_scope": "public_internet", + "observed_at": "2026-07-09T19:55:00.000Z", + "expires_at": "2026-07-09T20:45:00.000Z" +} diff --git a/tests/fixtures/iroh/relay-minter-request-v1.json b/tests/fixtures/iroh/relay-minter-request-v1.json new file mode 100644 index 000000000000..978434ba6659 --- /dev/null +++ b/tests/fixtures/iroh/relay-minter-request-v1.json @@ -0,0 +1,6 @@ +{ + "path": "/api/relay-token", + "timestamp": "1783641600", + "body": "{\"endpointId\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"lifetimeSeconds\":86400}", + "signature": "U7P9cSbpQMrtmshYTTuBALTsDhGwxWqTG4mIdlqgX4c" +} diff --git a/web/.env.example b/web/.env.example index 77b509936dd0..88d2ade3f521 100644 --- a/web/.env.example +++ b/web/.env.example @@ -39,6 +39,31 @@ NEXT_PUBLIC_STACK_PROJECT_ID= NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY= STACK_SECRET_SERVER_KEY= +# Personal-account Iroh trust broker. The Iroh Services project API key is not +# accepted by this process; it belongs only in the isolated Rust relay minter. +# Grant verification JSON maps the current and previous KIDs to Ed25519 SPKI +# PEM strings. The developer binding override remains off unless all three +# override settings explicitly match the authenticated user and deployment. +# Independent 32-byte random base64 secret used only for account-scoped LAN +# rendezvous HMAC derivation. Generate with `openssl rand -base64 32`. +CMUX_IROH_LAN_DISCOVERY_SECRET_B64= +# Independent 32-byte base64 secret used only to derive opaque account subjects +# for short-lived offline endpoint attestations. +CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64= +CMUX_IROH_GRANT_SIGNING_KEY_P8= +CMUX_IROH_GRANT_SIGNING_KID= +# Versioned public Ed25519 key set. Each key is canonical SPKI DER base64. +# {"version":1,"current_kid":"current","keys":[{"kid":"current","alg":"EdDSA","spki_der_base64":"..."}]} +CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON= +CMUX_IROH_MINT_URL= +# Current request-authentication key. The optional previous overlap key belongs +# only in the isolated minter project. +CMUX_IROH_MINT_HMAC_SECRET_B64= +CMUX_IROH_RATE_LIMIT_ID= +CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED=0 +CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS= +CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS= + # Local/Postgres database. `bun db:*` derives the local port from CMUX_PORT by default: # DATABASE_URL uses localhost:${CMUX_PORT + 10000}. CI should set explicit URLs. DATABASE_URL= diff --git a/web/app/api/account/route.ts b/web/app/api/account/route.ts index 50dc9cad36d2..d4c40271b325 100644 --- a/web/app/api/account/route.ts +++ b/web/app/api/account/route.ts @@ -18,6 +18,9 @@ import { cloudVms, deviceTokens, devices, + irohAccountSecurityStates, + irohEndpointBindings, + irohRegistrationChallenges, notificationSendEvents, stripeCustomers, stripeSubscriptions, @@ -891,6 +894,9 @@ async function deleteCmuxOwnedAccountRows(userId: string, accountTeamIds: readon await tx.delete(deviceTokens).where(eq(deviceTokens.userId, userId)); await tx.delete(notificationSendEvents).where(eq(notificationSendEvents.userId, userId)); + await tx.delete(irohRegistrationChallenges).where(eq(irohRegistrationChallenges.userId, userId)); + await tx.delete(irohEndpointBindings).where(eq(irohEndpointBindings.userId, userId)); + await tx.delete(irohAccountSecurityStates).where(eq(irohAccountSecurityStates.userId, userId)); await tx.delete(billingEmailClaims).where(or( eq(billingEmailClaims.stackUserId, userId), diff --git a/web/app/api/devices/iroh/challenge/route.ts b/web/app/api/devices/iroh/challenge/route.ts new file mode 100644 index 000000000000..1e2e21022264 --- /dev/null +++ b/web/app/api/devices/iroh/challenge/route.ts @@ -0,0 +1,8 @@ +import { handleIrohRoute } from "../../../../../services/iroh/routeHandler"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + return handleIrohRoute(request, "challenge"); +} diff --git a/web/app/api/devices/iroh/endpoint-attestations/route.ts b/web/app/api/devices/iroh/endpoint-attestations/route.ts new file mode 100644 index 000000000000..281d87768e21 --- /dev/null +++ b/web/app/api/devices/iroh/endpoint-attestations/route.ts @@ -0,0 +1,8 @@ +import { handleIrohRoute } from "../../../../../services/iroh/routeHandler"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + return handleIrohRoute(request, "endpoint_attestation"); +} diff --git a/web/app/api/devices/iroh/pair-grants/route.ts b/web/app/api/devices/iroh/pair-grants/route.ts new file mode 100644 index 000000000000..764193e35fb6 --- /dev/null +++ b/web/app/api/devices/iroh/pair-grants/route.ts @@ -0,0 +1,8 @@ +import { handleIrohRoute } from "../../../../../services/iroh/routeHandler"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + return handleIrohRoute(request, "pair_grant"); +} diff --git a/web/app/api/devices/iroh/register/route.ts b/web/app/api/devices/iroh/register/route.ts new file mode 100644 index 000000000000..02b40b335019 --- /dev/null +++ b/web/app/api/devices/iroh/register/route.ts @@ -0,0 +1,8 @@ +import { handleIrohRoute } from "../../../../../services/iroh/routeHandler"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + return handleIrohRoute(request, "register"); +} diff --git a/web/app/api/devices/iroh/relay-token/route.ts b/web/app/api/devices/iroh/relay-token/route.ts new file mode 100644 index 000000000000..ea4fe4e1ecd0 --- /dev/null +++ b/web/app/api/devices/iroh/relay-token/route.ts @@ -0,0 +1,8 @@ +import { handleIrohRoute } from "../../../../../services/iroh/routeHandler"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request): Promise { + return handleIrohRoute(request, "relay_token"); +} diff --git a/web/app/api/devices/iroh/route.ts b/web/app/api/devices/iroh/route.ts new file mode 100644 index 000000000000..1cc6225ed63c --- /dev/null +++ b/web/app/api/devices/iroh/route.ts @@ -0,0 +1,12 @@ +import { handleIrohRoute } from "../../../../services/iroh/routeHandler"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request): Promise { + return handleIrohRoute(request, "discover"); +} + +export async function DELETE(request: Request): Promise { + return handleIrohRoute(request, "revoke"); +} diff --git a/web/app/api/devices/route.ts b/web/app/api/devices/route.ts index a5f10b478b54..8bd734416868 100644 --- a/web/app/api/devices/route.ts +++ b/web/app/api/devices/route.ts @@ -28,6 +28,7 @@ import { AccountDeletionMutationBlockedError, assertAccountDeletionUserMutationAllowed, } from "../../../services/account/deletionLock"; +import { sanitizeServerPublishedRoutes } from "../../../services/iroh/publicationPolicy"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -109,11 +110,9 @@ function recordOrEmpty(value: unknown): Record { /** * Keep only structurally valid route entries (a plain object), bounded by - * `MAX_ROUTES`. Semantic validation of the `CmxAttachRoute` wire schema is left - * to the typed Mac/iOS clients, so the server stays forward-compatible with new - * route kinds; this only guarantees the stored jsonb is a bounded array of - * objects (never a string, number, or array element that would corrupt the - * column or bloat the row). + * `MAX_ROUTES`. Legacy route semantics remain client-owned so the server stays + * forward-compatible with non-Iroh route kinds. Iroh entries cross the stricter + * publication policy after this structural bound. */ function routesArray(value: unknown): unknown[] { if (!Array.isArray(value)) return []; @@ -153,7 +152,7 @@ export async function POST(request: Request): Promise { const { manual: _ignoredManualLabel, ...labels } = rawLabels; void _ignoredManualLabel; const tag = trimmedString(body.value.tag) || "default"; - const routes = routesArray(body.value.routes); + const routes = sanitizeServerPublishedRoutes(routesArray(body.value.routes)); const instanceLabels = recordOrEmpty(body.value.instanceLabels); // `manual: true` marks a user-initiated remote added through the cmux CLI // (`cmux remotes add`) rather than a Mac self-registering its own live @@ -391,7 +390,9 @@ export async function GET(request: Request): Promise { lastSeenAt: device.lastSeenAt.toISOString(), instances: (instancesByDevice.get(device.id) ?? []).map((instance) => ({ tag: instance.tag, - routes: instance.routes, + routes: sanitizeServerPublishedRoutes( + Array.isArray(instance.routes) ? instance.routes : [], + ), labels: instance.labels, lastSeenAt: instance.lastSeenAt.toISOString(), })), diff --git a/web/app/api/internal/iroh/retention/route.ts b/web/app/api/internal/iroh/retention/route.ts new file mode 100644 index 000000000000..2b41a9305fe3 --- /dev/null +++ b/web/app/api/internal/iroh/retention/route.ts @@ -0,0 +1,60 @@ +import { timingSafeEqual } from "node:crypto"; +import * as Effect from "effect/Effect"; +import { + IROH_RETENTION_MAX_DURATION_MS, + IROH_RETENTION_MAX_ROWS, + IrohRepository, + IrohRepositoryLive, +} from "../../../../../services/iroh/repository"; +import { jsonResponse } from "../../../../../services/vms/routeHelpers"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request): Promise { + return handle(request); +} + +export async function POST(request: Request): Promise { + return handle(request); +} + +async function handle(request: Request): Promise { + const secret = process.env.CRON_SECRET?.trim(); + if (!secret) return jsonResponse({ error: "service_unavailable" }, 503); + const authorization = request.headers.get("authorization")?.trim() ?? ""; + const token = authorization.toLowerCase().startsWith("bearer ") + ? authorization.slice("bearer ".length).trim() + : ""; + const tokenBytes = Buffer.from(token); + const secretBytes = Buffer.from(secret); + if (tokenBytes.length !== secretBytes.length || !timingSafeEqual(tokenBytes, secretBytes)) { + return jsonResponse({ error: "unauthorized" }, 401); + } + + try { + const startedAt = Date.now(); + const retention = await Effect.runPromise( + Effect.gen(function* () { + const repository = yield* IrohRepository; + return yield* repository.pruneExpiredStateGlobally({ + now: new Date(), + maxRows: IROH_RETENTION_MAX_ROWS, + maxDurationMs: IROH_RETENTION_MAX_DURATION_MS, + }); + }).pipe(Effect.provide(IrohRepositoryLive)), + ); + console.info("iroh retention cleanup completed", { + rows_processed: retention.rowsProcessed, + batches: retention.batches, + backlog: retention.backlog, + budget_exhausted: retention.budgetExhausted, + by_category: retention.byCategory, + duration_ms: Date.now() - startedAt, + }); + return jsonResponse({ ok: true, retention }); + } catch { + console.error("iroh retention cleanup failed", { failure: "database" }); + return jsonResponse({ error: "iroh_retention_failed" }, 500); + } +} diff --git a/web/app/env.ts b/web/app/env.ts index 2ec34008acca..927d4ed6a506 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -1,5 +1,10 @@ import { createEnv } from "@t3-oss/env-nextjs"; import { z } from "zod"; +import { + insecureLoopbackMinterAllowed, + parseIrohMinterUrl, + type IrohMinterUrlPolicy, +} from "../services/iroh/minterUrlPolicy"; // Trim at the runtimeEnv source so every consumer — including paths that // run when validation is skipped (VERCEL_ENV === "preview") — sees clean @@ -21,8 +26,18 @@ const isVercelNonPreviewDeployment = process.env.VERCEL === "1" && typeof process.env.VERCEL_ENV === "string" && process.env.VERCEL_ENV !== "preview"; -const requireVercelNonPreviewValue = (name: string): z.ZodType => - z.string().min(1).optional().superRefine((value, context) => { +const irohMinterUrlPolicy: IrohMinterUrlPolicy = { + allowInsecureLoopback: + trimEnv(process.env.CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER) === "1", + deploymentEnvironment: + process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "development", + isVercelDeployment: process.env.VERCEL === "1", +}; +const requireVercelNonPreviewValue = ( + name: string, + schema: z.ZodString = z.string().min(1), +): z.ZodType => + schema.optional().superRefine((value, context) => { if (isVercelNonPreviewDeployment && !value) { context.addIssue({ code: z.ZodIssueCode.custom, @@ -30,6 +45,32 @@ const requireVercelNonPreviewValue = (name: string): z.ZodType + z.enum(["0", "1"]).optional().superRefine((value, context) => { + if ( + value === "1" && + !insecureLoopbackMinterAllowed({ + ...irohMinterUrlPolicy, + allowInsecureLoopback: true, + }) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: `${name} is only allowed in local development`, + }); + } + }); +const irohMinterUrl = z.string().url().superRefine((value, context) => { + try { + parseIrohMinterUrl(value, irohMinterUrlPolicy); + } catch { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: + "CMUX_IROH_MINT_URL must use HTTPS, except for an opted-in local loopback development minter", + }); + } +}); const stackEnv = ( value: string | undefined, @@ -94,6 +135,41 @@ export const env = createEnv({ SUBROUTER_BASE_URL: z.string().url().optional(), SUBROUTER_ADMIN_TOKEN: z.string().min(1).optional(), SUBROUTER_TENANT_KEY_SECRET: z.string().min(1).optional(), + // Iroh trust broker. The Services API key deliberately has no TypeScript + // env entry: only the isolated Rust relay minter may hold it. These values + // are server-only and routes fail closed when an operation's key is absent. + CMUX_IROH_LAN_DISCOVERY_SECRET_B64: requireVercelNonPreviewValue( + "CMUX_IROH_LAN_DISCOVERY_SECRET_B64", + z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/), + ), + CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64: requireVercelNonPreviewValue( + "CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64", + z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/), + ), + CMUX_IROH_GRANT_SIGNING_KEY_P8: requireVercelNonPreviewValue( + "CMUX_IROH_GRANT_SIGNING_KEY_P8", + z.string().min(64).max(16_384), + ), + CMUX_IROH_GRANT_SIGNING_KID: requireVercelNonPreviewValue( + "CMUX_IROH_GRANT_SIGNING_KID", + z.string().regex(/^[A-Za-z0-9._-]{1,64}$/), + ), + CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON: requireVercelNonPreviewValue( + "CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON", + z.string().min(2).max(32_768), + ), + // Optional compatibility path for n0-hosted relay credentials. The + // self-hosted fleet mints endpoint-bound JWTs through /api/relay/token. + CMUX_IROH_MINT_URL: irohMinterUrl.optional(), + CMUX_IROH_MINT_HMAC_SECRET_B64: + z.string().max(512).regex(/^[A-Za-z0-9+/]{43,}={0,2}$/).optional(), + CMUX_IROH_RATE_LIMIT_ID: requireVercelNonPreviewValue("CMUX_IROH_RATE_LIMIT_ID"), + CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: localDevelopmentOptIn( + "CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER", + ), + CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED: z.enum(["0", "1"]).optional(), + CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS: z.string().max(8_192).optional(), + CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS: z.string().max(256).optional(), }, client: { NEXT_PUBLIC_STACK_PROJECT_ID: z.string().min(1), @@ -131,6 +207,20 @@ export const env = createEnv({ SUBROUTER_BASE_URL: trimEnv(process.env.SUBROUTER_BASE_URL) ?? defaultSubrouterBaseUrl(), SUBROUTER_ADMIN_TOKEN: trimEnv(process.env.SUBROUTER_ADMIN_TOKEN), SUBROUTER_TENANT_KEY_SECRET: trimEnv(process.env.SUBROUTER_TENANT_KEY_SECRET), + CMUX_IROH_LAN_DISCOVERY_SECRET_B64: trimEnv(process.env.CMUX_IROH_LAN_DISCOVERY_SECRET_B64), + CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64: trimEnv(process.env.CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64), + CMUX_IROH_GRANT_SIGNING_KEY_P8: trimEnv(process.env.CMUX_IROH_GRANT_SIGNING_KEY_P8), + CMUX_IROH_GRANT_SIGNING_KID: trimEnv(process.env.CMUX_IROH_GRANT_SIGNING_KID), + CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON: trimEnv(process.env.CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON), + CMUX_IROH_MINT_URL: trimEnv(process.env.CMUX_IROH_MINT_URL), + CMUX_IROH_MINT_HMAC_SECRET_B64: trimEnv(process.env.CMUX_IROH_MINT_HMAC_SECRET_B64), + CMUX_IROH_RATE_LIMIT_ID: trimEnv(process.env.CMUX_IROH_RATE_LIMIT_ID), + CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: trimEnv( + process.env.CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER, + ), + CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED: trimEnv(process.env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED), + CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS: trimEnv(process.env.CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS), + CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS: trimEnv(process.env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS), NEXT_PUBLIC_STACK_PROJECT_ID: stackEnv( process.env.NEXT_PUBLIC_STACK_PROJECT_ID, "00000000-0000-4000-8000-000000000000" diff --git a/web/db/migrations/20260709000000_iroh_trust_broker/migration.sql b/web/db/migrations/20260709000000_iroh_trust_broker/migration.sql new file mode 100644 index 000000000000..4905c409df43 --- /dev/null +++ b/web/db/migrations/20260709000000_iroh_trust_broker/migration.sql @@ -0,0 +1,157 @@ +CREATE TABLE "iroh_account_security_states" ( + "user_id" text PRIMARY KEY NOT NULL, + "lan_discovery_generation" integer DEFAULT 1 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "iroh_account_security_states_generation_check" + CHECK ("lan_discovery_generation" >= 1) +); +--> statement-breakpoint +CREATE TABLE "iroh_endpoint_bindings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "device_uuid" uuid NOT NULL, + "app_instance_id" uuid NOT NULL, + "tag" text NOT NULL, + "platform" text NOT NULL, + "display_name" text, + "endpoint_id" text NOT NULL, + "identity_generation" integer NOT NULL, + "pairing_enabled" boolean DEFAULT false NOT NULL, + "capabilities" jsonb DEFAULT '[]'::jsonb NOT NULL, + "path_hints" jsonb DEFAULT '[]'::jsonb NOT NULL, + "device_limit_override_used" boolean DEFAULT false NOT NULL, + "last_seen_at" timestamp with time zone DEFAULT now() NOT NULL, + "registered_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "revoked_at" timestamp with time zone, + "revoked_reason" text, + CONSTRAINT "iroh_endpoint_bindings_endpoint_id_check" + CHECK ("endpoint_id" ~ '^[0-9a-f]{64}$'), + CONSTRAINT "iroh_endpoint_bindings_identity_generation_check" + CHECK ("identity_generation" >= 1), + CONSTRAINT "iroh_endpoint_bindings_tag_check" + CHECK ("tag" ~ '^[A-Za-z0-9._-]{1,64}$'), + CONSTRAINT "iroh_endpoint_bindings_platform_check" + CHECK ("platform" in ('mac', 'ios')), + CONSTRAINT "iroh_endpoint_bindings_display_name_check" + CHECK ("display_name" is null or "display_name" !~ '[[:cntrl:]]'), + CONSTRAINT "iroh_endpoint_bindings_capabilities_check" + CHECK (jsonb_typeof("capabilities") = 'array' and jsonb_array_length("capabilities") <= 32), + CONSTRAINT "iroh_endpoint_bindings_path_hints_check" + CHECK (jsonb_typeof("path_hints") = 'array' and jsonb_array_length("path_hints") <= 16) +); +--> statement-breakpoint +CREATE TABLE "iroh_registration_challenges" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "device_uuid" uuid NOT NULL, + "app_instance_id" uuid NOT NULL, + "tag" text NOT NULL, + "endpoint_id" text NOT NULL, + "identity_generation" integer NOT NULL, + "payload_sha256" text NOT NULL, + "nonce_hash" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "consumed_at" timestamp with time zone, + CONSTRAINT "iroh_registration_challenges_endpoint_id_check" + CHECK ("endpoint_id" ~ '^[0-9a-f]{64}$'), + CONSTRAINT "iroh_registration_challenges_identity_generation_check" + CHECK ("identity_generation" >= 1), + CONSTRAINT "iroh_registration_challenges_tag_check" + CHECK ("tag" ~ '^[A-Za-z0-9._-]{1,64}$'), + CONSTRAINT "iroh_registration_challenges_payload_hash_check" + CHECK ("payload_sha256" ~ '^[0-9a-f]{64}$'), + CONSTRAINT "iroh_registration_challenges_nonce_hash_check" + CHECK ("nonce_hash" ~ '^[0-9a-f]{64}$') +); +--> statement-breakpoint +CREATE TABLE "iroh_pair_grant_issuances" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "jti" uuid NOT NULL, + "initiator_binding_id" uuid NOT NULL, + "acceptor_binding_id" uuid NOT NULL, + "signing_key_id" text NOT NULL, + "alpn" text DEFAULT 'cmux/mobile/1' NOT NULL, + "scope" text DEFAULT 'cmux.mobile.attach' NOT NULL, + "issued_at" timestamp with time zone NOT NULL, + "not_before" timestamp with time zone NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "revoked_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "iroh_relay_token_issuances" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "binding_id" uuid NOT NULL, + "endpoint_id_hash" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "token_hash" text, + "failure_code" text, + "requested_at" timestamp with time zone NOT NULL, + "completed_at" timestamp with time zone, + "expires_at" timestamp with time zone, + CONSTRAINT "iroh_relay_token_issuances_endpoint_hash_check" + CHECK ("endpoint_id_hash" ~ '^[0-9a-f]{64}$'), + CONSTRAINT "iroh_relay_token_issuances_status_check" + CHECK ("status" in ('pending', 'succeeded', 'failed')) +); +--> statement-breakpoint +ALTER TABLE "iroh_pair_grant_issuances" + ADD CONSTRAINT "iroh_pair_grant_issuances_initiator_binding_id_iroh_endpoint_bindings_id_fk" + FOREIGN KEY ("initiator_binding_id") REFERENCES "public"."iroh_endpoint_bindings"("id") + ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "iroh_pair_grant_issuances" + ADD CONSTRAINT "iroh_pair_grant_issuances_acceptor_binding_id_iroh_endpoint_bindings_id_fk" + FOREIGN KEY ("acceptor_binding_id") REFERENCES "public"."iroh_endpoint_bindings"("id") + ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +ALTER TABLE "iroh_relay_token_issuances" + ADD CONSTRAINT "iroh_relay_token_issuances_binding_id_iroh_endpoint_bindings_id_fk" + FOREIGN KEY ("binding_id") REFERENCES "public"."iroh_endpoint_bindings"("id") + ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE UNIQUE INDEX "iroh_endpoint_bindings_active_endpoint_unique" + ON "iroh_endpoint_bindings" USING btree ("endpoint_id") WHERE "revoked_at" is null; +--> statement-breakpoint +CREATE UNIQUE INDEX "iroh_endpoint_bindings_active_app_instance_unique" + ON "iroh_endpoint_bindings" USING btree ("app_instance_id") WHERE "revoked_at" is null; +--> statement-breakpoint +CREATE INDEX "iroh_endpoint_bindings_user_active_idx" + ON "iroh_endpoint_bindings" USING btree ("user_id", "updated_at") WHERE "revoked_at" is null; +--> statement-breakpoint +CREATE INDEX "iroh_endpoint_bindings_user_device_active_idx" + ON "iroh_endpoint_bindings" USING btree ("user_id", "device_uuid") WHERE "revoked_at" is null; +--> statement-breakpoint +CREATE INDEX "iroh_endpoint_bindings_revoked_idx" + ON "iroh_endpoint_bindings" USING btree ("revoked_at") WHERE "revoked_at" is not null; +--> statement-breakpoint +CREATE UNIQUE INDEX "iroh_registration_challenges_nonce_hash_unique" + ON "iroh_registration_challenges" USING btree ("nonce_hash"); +--> statement-breakpoint +CREATE INDEX "iroh_registration_challenges_user_created_idx" + ON "iroh_registration_challenges" USING btree ("user_id", "created_at"); +--> statement-breakpoint +CREATE INDEX "iroh_registration_challenges_user_device_created_idx" + ON "iroh_registration_challenges" USING btree ("user_id", "device_uuid", "created_at"); +--> statement-breakpoint +CREATE INDEX "iroh_registration_challenges_expires_idx" + ON "iroh_registration_challenges" USING btree ("expires_at") WHERE "consumed_at" is null; +--> statement-breakpoint +CREATE UNIQUE INDEX "iroh_pair_grant_issuances_jti_unique" + ON "iroh_pair_grant_issuances" USING btree ("jti"); +--> statement-breakpoint +CREATE INDEX "iroh_pair_grant_issuances_user_issued_idx" + ON "iroh_pair_grant_issuances" USING btree ("user_id", "issued_at"); +--> statement-breakpoint +CREATE INDEX "iroh_pair_grant_issuances_acceptor_expires_idx" + ON "iroh_pair_grant_issuances" USING btree ("acceptor_binding_id", "expires_at"); +--> statement-breakpoint +CREATE INDEX "iroh_relay_token_issuances_binding_requested_idx" + ON "iroh_relay_token_issuances" USING btree ("binding_id", "requested_at"); +--> statement-breakpoint +CREATE INDEX "iroh_relay_token_issuances_user_requested_idx" + ON "iroh_relay_token_issuances" USING btree ("user_id", "requested_at"); diff --git a/web/db/migrations/20260710000000_iroh_trust_broker_hardening/migration.sql b/web/db/migrations/20260710000000_iroh_trust_broker_hardening/migration.sql new file mode 100644 index 000000000000..c25bca7a8f3e --- /dev/null +++ b/web/db/migrations/20260710000000_iroh_trust_broker_hardening/migration.sql @@ -0,0 +1,71 @@ +ALTER TABLE "iroh_endpoint_bindings" + ADD COLUMN "path_hints_next_expiry" timestamp with time zone; +--> statement-breakpoint +ALTER TABLE "iroh_endpoint_bindings" + DROP CONSTRAINT "iroh_endpoint_bindings_identity_generation_check"; +--> statement-breakpoint +ALTER TABLE "iroh_endpoint_bindings" + ADD CONSTRAINT "iroh_endpoint_bindings_identity_generation_check" + CHECK ("identity_generation" between 1 and 2147483647); +--> statement-breakpoint +ALTER TABLE "iroh_registration_challenges" + DROP CONSTRAINT "iroh_registration_challenges_identity_generation_check"; +--> statement-breakpoint +ALTER TABLE "iroh_registration_challenges" + ADD CONSTRAINT "iroh_registration_challenges_identity_generation_check" + CHECK ("identity_generation" between 1 and 2147483647); +--> statement-breakpoint +UPDATE "iroh_endpoint_bindings" AS binding +SET "path_hints_next_expiry" = ( + SELECT min((hint ->> 'expires_at')::timestamptz) + FROM jsonb_array_elements(binding."path_hints") AS hints(hint) + WHERE jsonb_typeof(hint) = 'object' + AND jsonb_typeof(hint -> 'expires_at') = 'string' + AND (hint ->> 'expires_at') ~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$' +) +WHERE binding."revoked_at" IS NULL + AND binding."path_hints" <> '[]'::jsonb; +--> statement-breakpoint +UPDATE "iroh_endpoint_bindings" +SET "path_hints" = '[]'::jsonb, + "path_hints_next_expiry" = NULL +WHERE "revoked_at" IS NOT NULL + AND "path_hints" <> '[]'::jsonb; +--> statement-breakpoint +CREATE INDEX "iroh_endpoint_bindings_path_hints_expiry_idx" + ON "iroh_endpoint_bindings" USING btree ("path_hints_next_expiry", "id") + WHERE "revoked_at" IS NULL AND "path_hints_next_expiry" IS NOT NULL; +--> statement-breakpoint +CREATE INDEX "iroh_endpoint_bindings_revoked_hints_idx" + ON "iroh_endpoint_bindings" USING btree ("revoked_at", "id") + WHERE "revoked_at" IS NOT NULL AND "path_hints_next_expiry" IS NOT NULL; +--> statement-breakpoint +DROP INDEX "iroh_registration_challenges_expires_idx"; +--> statement-breakpoint +CREATE INDEX "iroh_registration_challenges_expires_idx" + ON "iroh_registration_challenges" USING btree ("expires_at", "id"); +--> statement-breakpoint +CREATE INDEX "iroh_registration_challenges_consumed_idx" + ON "iroh_registration_challenges" USING btree ("consumed_at", "id") + WHERE "consumed_at" IS NOT NULL; +--> statement-breakpoint +CREATE INDEX "iroh_registration_challenges_user_expires_idx" + ON "iroh_registration_challenges" USING btree ("user_id", "expires_at", "id"); +--> statement-breakpoint +CREATE INDEX "iroh_registration_challenges_user_consumed_idx" + ON "iroh_registration_challenges" USING btree ("user_id", "consumed_at", "id") + WHERE "consumed_at" IS NOT NULL; +--> statement-breakpoint +CREATE INDEX "iroh_pair_grant_issuances_expires_idx" + ON "iroh_pair_grant_issuances" USING btree ("expires_at", "id"); +--> statement-breakpoint +CREATE INDEX "iroh_pair_grant_issuances_user_expires_idx" + ON "iroh_pair_grant_issuances" USING btree ("user_id", "expires_at", "id"); +--> statement-breakpoint +CREATE INDEX "iroh_relay_token_issuances_requested_idx" + ON "iroh_relay_token_issuances" USING btree ("requested_at", "id"); +--> statement-breakpoint +DROP INDEX "iroh_relay_token_issuances_user_requested_idx"; +--> statement-breakpoint +CREATE INDEX "iroh_relay_token_issuances_user_requested_idx" + ON "iroh_relay_token_issuances" USING btree ("user_id", "requested_at", "id"); diff --git a/web/db/migrations/20260710010000_iroh_trust_broker_review_hardening/migration.sql b/web/db/migrations/20260710010000_iroh_trust_broker_review_hardening/migration.sql new file mode 100644 index 000000000000..ddda02ef8ed0 --- /dev/null +++ b/web/db/migrations/20260710010000_iroh_trust_broker_review_hardening/migration.sql @@ -0,0 +1,9 @@ +CREATE INDEX "iroh_endpoint_bindings_user_idx" + ON "iroh_endpoint_bindings" USING btree ("user_id"); +--> statement-breakpoint +CREATE INDEX "iroh_endpoint_bindings_user_revoked_idx" + ON "iroh_endpoint_bindings" USING btree ("user_id", "revoked_at", "id") + WHERE "revoked_at" IS NOT NULL; +--> statement-breakpoint +CREATE INDEX "iroh_pair_grant_issuances_initiator_idx" + ON "iroh_pair_grant_issuances" USING btree ("initiator_binding_id", "expires_at"); diff --git a/web/db/migrations/20260710113000_iroh_relay_reservation_expiry/migration.sql b/web/db/migrations/20260710113000_iroh_relay_reservation_expiry/migration.sql new file mode 100644 index 000000000000..f09a324be871 --- /dev/null +++ b/web/db/migrations/20260710113000_iroh_relay_reservation_expiry/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "iroh_relay_token_issuances" + DROP CONSTRAINT "iroh_relay_token_issuances_status_check"; +--> statement-breakpoint +ALTER TABLE "iroh_relay_token_issuances" + ADD CONSTRAINT "iroh_relay_token_issuances_status_check" + CHECK ("status" in ('pending', 'succeeded', 'failed', 'expired')) NOT VALID; diff --git a/web/db/migrations/20260710120000_iroh_server_path_privacy/migration.sql b/web/db/migrations/20260710120000_iroh_server_path_privacy/migration.sql new file mode 100644 index 000000000000..baf7f8ec947c --- /dev/null +++ b/web/db/migrations/20260710120000_iroh_server_path_privacy/migration.sql @@ -0,0 +1,30 @@ +-- Iroh is not yet a production rendezvous dependency, so remove every legacy +-- server-held path hint before the relay-only publication policy rolls out. +-- New registrations may repopulate exact managed relay URLs, never direct or +-- private addresses. +UPDATE "iroh_endpoint_bindings" +SET "path_hints" = '[]'::jsonb, + "path_hints_next_expiry" = NULL +WHERE "path_hints" <> '[]'::jsonb; +--> statement-breakpoint +-- Existing hosts will republish their EndpointID plus managed relay URL on the +-- next heartbeat. Preserve every non-Iroh route byte-for-byte so LAN, +-- Tailscale, and custom-network reconnects remain available during rollout. +UPDATE "device_app_instances" +SET "routes" = '[]'::jsonb +WHERE jsonb_typeof("routes") IS DISTINCT FROM 'array'; +--> statement-breakpoint +UPDATE "device_app_instances" AS instance +SET "routes" = COALESCE( + ( + SELECT jsonb_agg(route.value ORDER BY route.ordinality) + FROM jsonb_array_elements(instance."routes") WITH ORDINALITY AS route(value, ordinality) + WHERE route.value ->> 'kind' IS DISTINCT FROM 'iroh' + ), + '[]'::jsonb +) +WHERE EXISTS ( + SELECT 1 + FROM jsonb_array_elements(instance."routes") AS route(value) + WHERE route.value ->> 'kind' = 'iroh' +); diff --git a/web/db/schema.ts b/web/db/schema.ts index 27ee03ddfd90..6bc26f26b182 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -2,6 +2,7 @@ import { sql } from "drizzle-orm"; import { bigint, boolean, + check, index, integer, jsonb, @@ -606,6 +607,199 @@ export const deviceAppInstances = pgTable( ], ); +/** + * Personal-account Iroh trust state. The rendezvous key is derived at the + * application boundary from a server-only HMAC secret and this generation, so + * Aurora never stores the LAN discovery secret itself. Revoking an endpoint + * increments the generation and invalidates previously advertised rendezvous + * values for the account. + */ +export const irohAccountSecurityStates = pgTable( + "iroh_account_security_states", + { + userId: text("user_id").primaryKey(), + lanDiscoveryGeneration: integer("lan_discovery_generation").notNull().default(1), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + check("iroh_account_security_states_generation_check", sql`${table.lanDiscoveryGeneration} >= 1`), + ], +); + +/** + * Authenticated Iroh endpoint bindings. These rows are intentionally separate + * from the legacy team-scoped device registry: Iroh discovery and grants are + * always scoped to the exact Stack user id that registered the endpoint. + */ +export const irohEndpointBindings = pgTable( + "iroh_endpoint_bindings", + { + id: uuid("id").defaultRandom().primaryKey(), + userId: text("user_id").notNull(), + deviceUuid: uuid("device_uuid").notNull(), + appInstanceId: uuid("app_instance_id").notNull(), + tag: text("tag").notNull(), + platform: text("platform").notNull(), + displayName: text("display_name"), + endpointId: text("endpoint_id").notNull(), + identityGeneration: integer("identity_generation").notNull(), + pairingEnabled: boolean("pairing_enabled").notNull().default(false), + capabilities: jsonb("capabilities").$type().notNull().default(sql`'[]'::jsonb`), + pathHints: jsonb("path_hints").$type().notNull().default(sql`'[]'::jsonb`), + pathHintsNextExpiry: timestamp("path_hints_next_expiry", { withTimezone: true }), + deviceLimitOverrideUsed: boolean("device_limit_override_used").notNull().default(false), + lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(), + registeredAt: timestamp("registered_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + revokedReason: text("revoked_reason"), + }, + (table) => [ + check("iroh_endpoint_bindings_endpoint_id_check", sql`${table.endpointId} ~ '^[0-9a-f]{64}$'`), + check("iroh_endpoint_bindings_identity_generation_check", sql`${table.identityGeneration} between 1 and 2147483647`), + check("iroh_endpoint_bindings_tag_check", sql`${table.tag} ~ '^[A-Za-z0-9._-]{1,64}$'`), + check("iroh_endpoint_bindings_platform_check", sql`${table.platform} in ('mac', 'ios')`), + check("iroh_endpoint_bindings_display_name_check", sql`${table.displayName} is null or ${table.displayName} !~ '[[:cntrl:]]'`), + check("iroh_endpoint_bindings_capabilities_check", sql`jsonb_typeof(${table.capabilities}) = 'array' and jsonb_array_length(${table.capabilities}) <= 32`), + check("iroh_endpoint_bindings_path_hints_check", sql`jsonb_typeof(${table.pathHints}) = 'array' and jsonb_array_length(${table.pathHints}) <= 16`), + uniqueIndex("iroh_endpoint_bindings_active_endpoint_unique") + .on(table.endpointId) + .where(sql`${table.revokedAt} is null`), + uniqueIndex("iroh_endpoint_bindings_active_app_instance_unique") + .on(table.appInstanceId) + .where(sql`${table.revokedAt} is null`), + index("iroh_endpoint_bindings_user_active_idx") + .on(table.userId, table.updatedAt) + .where(sql`${table.revokedAt} is null`), + index("iroh_endpoint_bindings_user_device_active_idx") + .on(table.userId, table.deviceUuid) + .where(sql`${table.revokedAt} is null`), + index("iroh_endpoint_bindings_user_idx") + .on(table.userId), + index("iroh_endpoint_bindings_user_revoked_idx") + .on(table.userId, table.revokedAt, table.id) + .where(sql`${table.revokedAt} is not null`), + index("iroh_endpoint_bindings_revoked_idx") + .on(table.revokedAt) + .where(sql`${table.revokedAt} is not null`), + index("iroh_endpoint_bindings_path_hints_expiry_idx") + .on(table.pathHintsNextExpiry, table.id) + .where(sql`${table.revokedAt} is null and ${table.pathHintsNextExpiry} is not null`), + index("iroh_endpoint_bindings_revoked_hints_idx") + .on(table.revokedAt, table.id) + .where(sql`${table.revokedAt} is not null and ${table.pathHintsNextExpiry} is not null`), + ], +); + +/** + * One-use registration challenges. Only a SHA-256 hash of the random nonce is + * persisted. The payload hash binds all endpoint metadata before signature + * verification and the consumed timestamp provides replay protection. + */ +export const irohRegistrationChallenges = pgTable( + "iroh_registration_challenges", + { + id: uuid("id").defaultRandom().primaryKey(), + userId: text("user_id").notNull(), + deviceUuid: uuid("device_uuid").notNull(), + appInstanceId: uuid("app_instance_id").notNull(), + tag: text("tag").notNull(), + endpointId: text("endpoint_id").notNull(), + identityGeneration: integer("identity_generation").notNull(), + payloadSha256: text("payload_sha256").notNull(), + nonceHash: text("nonce_hash").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + consumedAt: timestamp("consumed_at", { withTimezone: true }), + }, + (table) => [ + check("iroh_registration_challenges_endpoint_id_check", sql`${table.endpointId} ~ '^[0-9a-f]{64}$'`), + check("iroh_registration_challenges_identity_generation_check", sql`${table.identityGeneration} between 1 and 2147483647`), + check("iroh_registration_challenges_tag_check", sql`${table.tag} ~ '^[A-Za-z0-9._-]{1,64}$'`), + check("iroh_registration_challenges_payload_hash_check", sql`${table.payloadSha256} ~ '^[0-9a-f]{64}$'`), + check("iroh_registration_challenges_nonce_hash_check", sql`${table.nonceHash} ~ '^[0-9a-f]{64}$'`), + uniqueIndex("iroh_registration_challenges_nonce_hash_unique").on(table.nonceHash), + index("iroh_registration_challenges_user_created_idx").on(table.userId, table.createdAt), + index("iroh_registration_challenges_user_device_created_idx") + .on(table.userId, table.deviceUuid, table.createdAt), + index("iroh_registration_challenges_expires_idx") + .on(table.expiresAt, table.id), + index("iroh_registration_challenges_consumed_idx") + .on(table.consumedAt, table.id) + .where(sql`${table.consumedAt} is not null`), + index("iroh_registration_challenges_user_expires_idx") + .on(table.userId, table.expiresAt, table.id), + index("iroh_registration_challenges_user_consumed_idx") + .on(table.userId, table.consumedAt, table.id) + .where(sql`${table.consumedAt} is not null`), + ], +); + +/** Audit-only record of an issued compact pair-grant JWS. The JWS is returned + * once and is never persisted. */ +export const irohPairGrantIssuances = pgTable( + "iroh_pair_grant_issuances", + { + id: uuid("id").defaultRandom().primaryKey(), + userId: text("user_id").notNull(), + jti: uuid("jti").notNull(), + initiatorBindingId: uuid("initiator_binding_id") + .notNull() + .references(() => irohEndpointBindings.id, { onDelete: "cascade" }), + acceptorBindingId: uuid("acceptor_binding_id") + .notNull() + .references(() => irohEndpointBindings.id, { onDelete: "cascade" }), + signingKeyId: text("signing_key_id").notNull(), + alpn: text("alpn").notNull().default("cmux/mobile/1"), + scope: text("scope").notNull().default("cmux.mobile.attach"), + issuedAt: timestamp("issued_at", { withTimezone: true }).notNull(), + notBefore: timestamp("not_before", { withTimezone: true }).notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + }, + (table) => [ + uniqueIndex("iroh_pair_grant_issuances_jti_unique").on(table.jti), + index("iroh_pair_grant_issuances_user_issued_idx").on(table.userId, table.issuedAt), + index("iroh_pair_grant_issuances_initiator_idx").on(table.initiatorBindingId, table.expiresAt), + index("iroh_pair_grant_issuances_acceptor_expires_idx").on(table.acceptorBindingId, table.expiresAt), + index("iroh_pair_grant_issuances_expires_idx").on(table.expiresAt, table.id), + index("iroh_pair_grant_issuances_user_expires_idx").on(table.userId, table.expiresAt, table.id), + ], +); + +/** + * DB-authoritative relay-mint quota ledger. At most a hash of a successfully + * minted token is recorded; plaintext relay credentials never enter Aurora. + */ +export const irohRelayTokenIssuances = pgTable( + "iroh_relay_token_issuances", + { + id: uuid("id").defaultRandom().primaryKey(), + userId: text("user_id").notNull(), + bindingId: uuid("binding_id") + .notNull() + .references(() => irohEndpointBindings.id, { onDelete: "cascade" }), + endpointIdHash: text("endpoint_id_hash").notNull(), + status: text("status") + .$type<"pending" | "succeeded" | "failed" | "expired">() + .notNull() + .default("pending"), + tokenHash: text("token_hash"), + failureCode: text("failure_code"), + requestedAt: timestamp("requested_at", { withTimezone: true }).notNull(), + completedAt: timestamp("completed_at", { withTimezone: true }), + expiresAt: timestamp("expires_at", { withTimezone: true }), + }, + (table) => [ + check("iroh_relay_token_issuances_endpoint_hash_check", sql`${table.endpointIdHash} ~ '^[0-9a-f]{64}$'`), + check("iroh_relay_token_issuances_status_check", sql`${table.status} in ('pending', 'succeeded', 'failed', 'expired')`), + index("iroh_relay_token_issuances_binding_requested_idx").on(table.bindingId, table.requestedAt), + index("iroh_relay_token_issuances_user_requested_idx").on(table.userId, table.requestedAt, table.id), + index("iroh_relay_token_issuances_requested_idx").on(table.requestedAt, table.id), + ], +); + export const cloudVmNotificationEvents = pgTable( "cloud_vm_notification_events", { diff --git a/web/services/iroh/README.md b/web/services/iroh/README.md new file mode 100644 index 000000000000..44050af7a18b --- /dev/null +++ b/web/services/iroh/README.md @@ -0,0 +1,89 @@ +# Iroh trust broker + +The broker is scoped to the authenticated Stack `user.id`. Team membership and +the legacy team device registry never grant Iroh discovery or pairing access. + +Registration signatures may carry the native Iroh `watch_addr` shape, but the +server strips every direct/private address before repository persistence. Direct +paths stay device-local or move endpoint-to-endpoint after admission. The broker +persists and publishes only exact managed relay URLs. Endpoint or +identity-generation replacement requires explicit revocation and reapproval; a +signature from only the proposed new key cannot replace an active binding. + +`relay_fleet` is the server-configured connection preset/allowlist. It is not a +peer address. Each peer's published `relay_url` comes from its signed +`watch_addr` payload and must match that allowlist. Discovery defensively +filters older rows to the same relay-only policy. + +Discovery runs the user-scoped retention cleanup before reading. It removes +expired hints from binding JSON, challenges more than 24 hours past expiry or +consumption, and pair/relay audit rows more than 30 days beyond their useful +window. Revocation clears hints immediately. Once a revoked binding is at least +30 days old and its pair/relay audit rows have reached their own retention +limit, cleanup deletes the binding's EndpointID, device/app UUIDs, tag, and +display name. The hourly `/api/internal/iroh/retention` cron applies the same +policy across inactive accounts; responses also filter expired hints +defensively. + +The LAN rendezvous key is HMAC-derived from an independent random server secret, +the exact Stack user id, and an account generation. Discovery reads active +bindings and that generation in one transaction under the same account lock as +registration and revocation, so it cannot mix tuples from opposite sides of a +revocation. Every successful binding revoke increments the generation in the +revocation transaction. Sign-out callers must invoke the authenticated revoke +route with their captured binding id before discarding the Stack credential. + +Postgres advisory locks make the authoritative limits concurrency-safe: six +challenges per device per ten minutes, 32 outstanding challenges per account, +32 active bindings per account, eight active bindings per physical device, 60 +pair grants per account per hour, three relay mints per endpoint per ten +minutes, 12 relay mints per endpoint per day, and 100 relay mints per account +per day. A relay reservation remains active for 60 seconds, then the next +account-scoped reservation marks it expired before applying those quotas. The +optional Vercel Firewall rule is defense in depth. A tagged-build +device-limit override requires a server flag, an exact authenticated user-id +allowlist match, and an exact deployment-environment allowlist match; it never +raises the 32-binding account limit and records an audit marker on the binding. + +Registration bootstraps a relay credential only when it creates a binding. +Signed refreshes of the same binding return `relay.status = "not_requested"`; +clients retain their existing credential or use the dedicated relay-token route +when its refresh window arrives. Platform is part of the immutable binding +identity and requires explicit revocation before it can change. + +The n0-hosted relay minter is an optional compatibility path. When +`CMUX_IROH_MINT_URL` and `CMUX_IROH_MINT_HMAC_SECRET_B64` are absent, initial +registration returns `relay.status = "unavailable"` without rolling back the +binding. Current clients obtain endpoint-bound credentials for the self-hosted +fleet from `/api/relay/token`. + +Every user-scoped mutation acquires the account-deletion advisory fence before +any Iroh lock. If the deletion tombstone wins, no challenge, binding, grant, or +relay audit state can be created. If an Iroh mutation wins, account deletion +waits for that transaction and then removes its rows. Pair grants re-read and +lock both exact signed peers at audit insertion, requiring an iOS initiator and +a pairable Mac acceptor. Relay credentials are returned only after a second +locked active-binding check following the external mint. + +Registration stores the earliest managed-relay expiry in +`path_hints_next_expiry`. The hourly cleanup uses that indexed scalar and +bounded 500-row `FOR UPDATE SKIP LOCKED` batches for relay hints, challenges, +audit rows, and revoked bindings. Concurrent cron workers can cooperate without +a full-table JSON scan. + +The `20260710120000_iroh_server_path_privacy` migration clears pre-policy broker +hints and removes existing Iroh route bodies from the legacy device registry. +Hosts republish sanitized EndpointID/managed-relay routes on their next refresh; +non-Iroh routes are preserved in order. + +The `20260710113000_iroh_relay_reservation_expiry` migration adds the +expanded status constraint with `NOT VALID` so its `ACCESS EXCLUSIVE` lock is +released without scanning existing rows. Drizzle 1.0 applies all pending +Postgres migrations in one transaction, so constraint validation must ship in +a later deployment after this migration is recorded. That follow-up migration +must run: + +```sql +ALTER TABLE "iroh_relay_token_issuances" + VALIDATE CONSTRAINT "iroh_relay_token_issuances_status_check"; +``` diff --git a/web/services/iroh/config.ts b/web/services/iroh/config.ts new file mode 100644 index 000000000000..f485effd6465 --- /dev/null +++ b/web/services/iroh/config.ts @@ -0,0 +1,65 @@ +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import { env } from "../../app/env"; + +export type IrohTrustBrokerConfigShape = { + readonly lanDiscoverySecretBase64?: string; + readonly accountSubjectSecretBase64?: string; + readonly grantSigningPrivateKeyPem?: string; + readonly grantSigningKid?: string; + readonly grantVerificationKeysJson?: string; + readonly relayMinterUrl?: string; + readonly relayMinterHmacSecretBase64?: string; + readonly relayMinterInsecureLoopbackOptIn: boolean; + readonly rateLimitId?: string; + readonly deviceLimitOverrideEnabled: boolean; + readonly deviceLimitOverrideUserIds: ReadonlySet; + readonly deviceLimitOverrideEnvironments: ReadonlySet; + readonly deploymentEnvironment: string; + readonly isVercelDeployment: boolean; +}; + +export class IrohTrustBrokerConfig extends Context.Tag("cmux/IrohTrustBrokerConfig")< + IrohTrustBrokerConfig, + IrohTrustBrokerConfigShape +>() {} + +export function irohTrustBrokerConfigFromEnv(): IrohTrustBrokerConfigShape { + return { + lanDiscoverySecretBase64: env.CMUX_IROH_LAN_DISCOVERY_SECRET_B64, + accountSubjectSecretBase64: env.CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64, + grantSigningPrivateKeyPem: env.CMUX_IROH_GRANT_SIGNING_KEY_P8, + grantSigningKid: env.CMUX_IROH_GRANT_SIGNING_KID, + grantVerificationKeysJson: env.CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON, + relayMinterUrl: env.CMUX_IROH_MINT_URL, + relayMinterHmacSecretBase64: env.CMUX_IROH_MINT_HMAC_SECRET_B64, + relayMinterInsecureLoopbackOptIn: + env.CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER === "1", + rateLimitId: env.CMUX_IROH_RATE_LIMIT_ID, + deviceLimitOverrideEnabled: env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENABLED === "1", + deviceLimitOverrideUserIds: csvSet(env.CMUX_IROH_DEV_BINDING_OVERRIDE_USER_IDS), + deviceLimitOverrideEnvironments: csvSet(env.CMUX_IROH_DEV_BINDING_OVERRIDE_ENVIRONMENTS), + deploymentEnvironment: process.env.VERCEL_ENV ?? process.env.NODE_ENV ?? "development", + isVercelDeployment: process.env.VERCEL === "1", + }; +} + +export function deviceLimitOverrideAllowed( + config: IrohTrustBrokerConfigShape, + authenticatedUserId: string, +): boolean { + if (!config.deviceLimitOverrideEnabled) return false; + return config.deviceLimitOverrideUserIds.has(authenticatedUserId) && + config.deviceLimitOverrideEnvironments.has(config.deploymentEnvironment); +} + +export const IrohTrustBrokerConfigLive = Layer.succeed( + IrohTrustBrokerConfig, + irohTrustBrokerConfigFromEnv(), +); + +function csvSet(value: string | undefined): ReadonlySet { + return new Set( + value?.split(",").map((entry) => entry.trim()).filter(Boolean) ?? [], + ); +} diff --git a/web/services/iroh/crypto.ts b/web/services/iroh/crypto.ts new file mode 100644 index 000000000000..c88bb0445ef2 --- /dev/null +++ b/web/services/iroh/crypto.ts @@ -0,0 +1,855 @@ +import { + createHmac, + createPrivateKey, + createPublicKey, + sign, + timingSafeEqual, + verify, + type KeyObject, +} from "node:crypto"; +import { + IROH_ENDPOINT_ATTESTATION_LIFETIME_SECONDS, + IROH_ENDPOINT_ATTESTATION_SCOPE, + IROH_ENDPOINT_ATTESTATION_TYP, + IROH_ENDPOINT_ATTESTATION_VERSION, + IROH_OFFLINE_PAIR_SESSION_LIFETIME_SECONDS, + IROH_OFFLINE_PAIR_SESSION_VERSION, + IROH_ALPN, + IROH_PAIR_GRANT_LIFETIME_SECONDS, + IROH_PAIR_GRANT_TYP, + IROH_PAIR_SCOPE, + endpointId, + POSTGRES_INT32_MAX, + sha256, +} from "./model"; +import { IrohConfigurationError, IrohForbiddenError, IrohInvalidInputError } from "./errors"; + +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +export type PairGrantPeer = { + readonly bindingId: string; + readonly deviceId: string; + readonly tag: string; + readonly platform: "mac" | "ios"; + readonly endpointId: string; + readonly identityGeneration: number; +}; + +export type PairGrantClaims = { + readonly jti: string; + readonly iat: number; + readonly nbf: number; + readonly exp: number; + readonly alpn: typeof IROH_ALPN; + readonly scope: typeof IROH_PAIR_SCOPE; + readonly initiator: PairGrantPeer; + readonly acceptor: PairGrantPeer; +}; + +export type PairGrantVerificationExpectation = { + readonly initiator?: PairGrantPeer; + readonly acceptor?: PairGrantPeer; + readonly nowSeconds: number; +}; + +export type PairGrantVerificationKey = { + readonly kid: string; + readonly alg: "EdDSA"; + readonly spki_der_base64: string; +}; + +export type PairGrantVerificationKeySet = { + readonly version: 1; + readonly current_kid: string; + readonly keys: readonly PairGrantVerificationKey[]; +}; + +export type ParsedPairGrantVerificationKeys = { + readonly keySet: PairGrantVerificationKeySet; + readonly publicKeys: ReadonlyMap; +}; + +export type EndpointAttestationClaims = { + readonly version: typeof IROH_ENDPOINT_ATTESTATION_VERSION; + readonly jti: string; + readonly sub: string; + readonly bindingId: string; + readonly deviceId: string; + readonly endpointId: string; + readonly identityGeneration: number; + readonly platform: "mac" | "ios"; + readonly iat: number; + readonly nbf: number; + readonly exp: number; + readonly alpn: typeof IROH_ALPN; + readonly scope: typeof IROH_ENDPOINT_ATTESTATION_SCOPE; +}; + +export type EndpointAttestationExpectation = { + readonly bindingId: string; + readonly deviceId: string; + readonly endpointId: string; + readonly identityGeneration: number; + readonly platform: "mac" | "ios"; + readonly nowSeconds: number; +}; + +export type OfflinePairVerificationExpectation = { + readonly initiator: Omit & { + readonly platform: "ios"; + }; + readonly acceptor: Omit & { + readonly platform: "mac"; + }; + readonly nowSeconds: number; +}; + +export type OfflinePairSessionRecord = { + readonly version: typeof IROH_OFFLINE_PAIR_SESSION_VERSION; + readonly sessionId: string; + readonly acceptor: OfflinePairVerificationExpectation["acceptor"]; + readonly proofHash: string; + readonly createdAtSeconds: number; + readonly expiresAtSeconds: number; + consumedAtSeconds: number | null; +}; + +export type OfflinePairInvitationProof = { + readonly version: typeof IROH_OFFLINE_PAIR_SESSION_VERSION; + readonly sessionId: string; + readonly proof: string; +}; + +export function registrationTranscript(input: { + readonly challengeId: string; + readonly nonce: string; + readonly payloadSha256: string; +}): Uint8Array { + return Buffer.from( + `cmux/iroh/device-registration/v1\n${input.challengeId}\n${input.nonce}\n${input.payloadSha256}`, + "utf8", + ); +} + +export function verifyEndpointRegistrationSignature(input: { + readonly endpointId: string; + readonly challengeId: string; + readonly nonce: string; + readonly payloadSha256: string; + readonly signature: string; +}): void { + const publicKey = endpointPublicKey(input.endpointId); + const signature = decodeCanonicalBase64url( + input.signature, + 64, + "invalid_registration_signature", + ); + const valid = verify( + null, + registrationTranscript(input), + publicKey, + signature, + ); + if (!valid) throw new IrohForbiddenError({ code: "invalid_registration_signature" }); +} + +export function nonceHash(nonce: string): string { + return sha256(Buffer.from(nonce, "base64url")); +} + +export function hashesEqual(leftHex: string, rightHex: string): boolean { + if (!/^[0-9a-f]{64}$/.test(leftHex) || !/^[0-9a-f]{64}$/.test(rightHex)) return false; + return timingSafeEqual(Buffer.from(leftHex, "hex"), Buffer.from(rightHex, "hex")); +} + +export function deriveLanRendezvousKey( + secretBase64: string | undefined, + userId: string, + generation: number, +): string { + const secret = decodeSecret(secretBase64, "lan_discovery"); + if (!Number.isSafeInteger(generation) || generation < 1 || generation > POSTGRES_INT32_MAX) { + throw new IrohConfigurationError({ component: "lan_discovery" }); + } + return createHmac("sha256", secret) + .update("cmux/iroh/lan-rendezvous/v1\0", "utf8") + .update(userId, "utf8") + .update("\0", "utf8") + .update(String(generation), "utf8") + .digest("base64url"); +} + +export function deriveAccountSubject( + secretBase64: string | undefined, + userId: string, +): string { + const secret = decodeSecret(secretBase64, "account_subject"); + if (!userId || userId.length > 1_024) { + throw new IrohConfigurationError({ component: "account_subject" }); + } + return createHmac("sha256", secret) + .update("cmux/iroh/account-subject/v1\0", "utf8") + .update(userId, "utf8") + .digest("base64url"); +} + +export function signPairGrant(input: { + readonly privateKeyPem: string | undefined; + readonly kid: string | undefined; + readonly claims: PairGrantClaims; +}): string { + const kid = validKid(input.kid, "grant_signing"); + const privateKey = signingPrivateKey(input.privateKeyPem); + validatePairGrantClaims(input.claims, { nowSeconds: input.claims.iat }); + const encodedHeader = encodeJson({ alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid }); + const encodedClaims = encodeJson(input.claims); + const signingInput = `${encodedHeader}.${encodedClaims}`; + const signature = sign(null, Buffer.from(signingInput, "ascii"), privateKey).toString("base64url"); + return `${signingInput}.${signature}`; +} + +export function signEndpointAttestation(input: { + readonly privateKeyPem: string | undefined; + readonly kid: string | undefined; + readonly claims: EndpointAttestationClaims; +}): string { + const kid = validKid(input.kid, "grant_signing"); + const privateKey = signingPrivateKey(input.privateKeyPem); + validateEndpointAttestationClaims(input.claims, { + bindingId: input.claims.bindingId, + deviceId: input.claims.deviceId, + endpointId: input.claims.endpointId, + identityGeneration: input.claims.identityGeneration, + platform: input.claims.platform, + nowSeconds: input.claims.iat, + }); + const encodedHeader = encodeJson({ + alg: "EdDSA", + typ: IROH_ENDPOINT_ATTESTATION_TYP, + kid, + }); + const encodedClaims = encodeJson(input.claims); + const signingInput = `${encodedHeader}.${encodedClaims}`; + const signature = sign(null, Buffer.from(signingInput, "ascii"), privateKey).toString("base64url"); + return `${signingInput}.${signature}`; +} + +export function verifyPairGrant( + token: string, + publicKeys: ReadonlyMap, + expected: PairGrantVerificationExpectation, +): PairGrantClaims { + if (token.length > 16_384) throw new IrohInvalidInputError({ code: "invalid_pair_grant" }); + const parts = token.split("."); + if (parts.length !== 3) throw new IrohInvalidInputError({ code: "invalid_pair_grant" }); + const header = decodeJson(parts[0], "invalid_pair_grant"); + assertExactKeys(header, ["alg", "typ", "kid"], "invalid_pair_grant_header"); + if (header.alg !== "EdDSA" || header.typ !== IROH_PAIR_GRANT_TYP || typeof header.kid !== "string") { + throw new IrohForbiddenError({ code: "invalid_pair_grant_header" }); + } + const keyDerBase64 = publicKeys.get(header.kid); + if (!keyDerBase64) throw new IrohForbiddenError({ code: "unknown_pair_grant_kid" }); + const publicKey = verificationPublicKey(keyDerBase64); + const valid = verify( + null, + Buffer.from(`${parts[0]}.${parts[1]}`, "ascii"), + publicKey, + decodeCanonicalBase64url(parts[2], 64, "invalid_pair_grant"), + ); + if (!valid) throw new IrohForbiddenError({ code: "invalid_pair_grant_signature" }); + const claims = decodeJson(parts[1], "invalid_pair_grant") as unknown as PairGrantClaims; + validatePairGrantClaims(claims, expected); + return claims; +} + +export function verifyEndpointAttestation( + token: string, + publicKeys: ReadonlyMap, + expected: EndpointAttestationExpectation, +): EndpointAttestationClaims { + if (token.length > 16_384) { + throw new IrohInvalidInputError({ code: "invalid_endpoint_attestation" }); + } + const parts = token.split("."); + if (parts.length !== 3) { + throw new IrohInvalidInputError({ code: "invalid_endpoint_attestation" }); + } + const header = decodeJson(parts[0], "invalid_endpoint_attestation"); + assertExactKeys(header, ["alg", "typ", "kid"], "invalid_endpoint_attestation_header"); + if ( + header.alg !== "EdDSA" || + header.typ !== IROH_ENDPOINT_ATTESTATION_TYP || + typeof header.kid !== "string" + ) { + throw new IrohForbiddenError({ code: "invalid_endpoint_attestation_header" }); + } + const keyDerBase64 = publicKeys.get(header.kid); + if (!keyDerBase64) { + throw new IrohForbiddenError({ code: "unknown_endpoint_attestation_kid" }); + } + const publicKey = verificationPublicKey(keyDerBase64); + const valid = verify( + null, + Buffer.from(`${parts[0]}.${parts[1]}`, "ascii"), + publicKey, + decodeCanonicalBase64url(parts[2], 64, "invalid_endpoint_attestation"), + ); + if (!valid) { + throw new IrohForbiddenError({ code: "invalid_endpoint_attestation_signature" }); + } + const claims = decodeJson( + parts[1], + "invalid_endpoint_attestation", + ) as unknown as EndpointAttestationClaims; + validateEndpointAttestationClaims(claims, expected); + return claims; +} + +function verifyOfflineSameAccountPair(input: { + readonly initiatorAttestation: string; + readonly acceptorAttestation: string; + readonly publicKeys: ReadonlyMap; + readonly expected: OfflinePairVerificationExpectation; +}): { + readonly initiator: EndpointAttestationClaims; + readonly acceptor: EndpointAttestationClaims; +} { + if ( + input.expected.initiator.platform !== "ios" || + input.expected.acceptor.platform !== "mac" + ) { + throw new IrohForbiddenError({ code: "invalid_offline_pair_platforms" }); + } + const initiator = verifyEndpointAttestation(input.initiatorAttestation, input.publicKeys, { + ...input.expected.initiator, + nowSeconds: input.expected.nowSeconds, + }); + const acceptor = verifyEndpointAttestation(input.acceptorAttestation, input.publicKeys, { + ...input.expected.acceptor, + nowSeconds: input.expected.nowSeconds, + }); + if ( + initiator.bindingId === acceptor.bindingId || + initiator.deviceId === acceptor.deviceId || + initiator.endpointId === acceptor.endpointId || + !canonicalSubjectsEqual(initiator.sub, acceptor.sub) + ) { + throw new IrohForbiddenError({ code: "offline_pair_same_account_proof_required" }); + } + return { initiator, acceptor }; +} + +export function createOfflinePairSessionRecord(input: { + readonly sessionId: string; + readonly proof: string; + readonly acceptor: OfflinePairVerificationExpectation["acceptor"]; + readonly nowSeconds: number; + readonly expiresAtSeconds: number; +}): OfflinePairSessionRecord { + validateOfflinePairSessionWindow(input.nowSeconds, input.expiresAtSeconds); + validateEndpointExpectation(input.acceptor, "mac"); + if (!UUID_PATTERN.test(input.sessionId) || input.sessionId !== input.sessionId.toLowerCase()) { + throw new IrohInvalidInputError({ code: "invalid_offline_pair_session" }); + } + const proof = decodeCanonicalBase64url(input.proof, 32, "invalid_offline_pair_proof"); + return { + version: IROH_OFFLINE_PAIR_SESSION_VERSION, + sessionId: input.sessionId.toLowerCase(), + acceptor: { ...input.acceptor }, + proofHash: offlinePairProofHash(input.sessionId.toLowerCase(), input.acceptor, proof), + createdAtSeconds: input.nowSeconds, + expiresAtSeconds: input.expiresAtSeconds, + consumedAtSeconds: null, + }; +} + +export function verifyAndConsumeOfflineSameAccountPair(input: { + readonly initiatorAttestation: string; + readonly acceptorAttestation: string; + readonly publicKeys: ReadonlyMap; + readonly expected: OfflinePairVerificationExpectation; + readonly session: OfflinePairSessionRecord; + readonly invitation: OfflinePairInvitationProof; +}): { + readonly initiator: EndpointAttestationClaims; + readonly acceptor: EndpointAttestationClaims; + readonly sessionId: string; +} { + const { session, invitation } = input; + if ( + typeof invitation.sessionId !== "string" || + !UUID_PATTERN.test(invitation.sessionId) || + invitation.sessionId !== invitation.sessionId.toLowerCase() + ) { + throw new IrohInvalidInputError({ code: "invalid_offline_pair_session" }); + } + if ( + session.version !== IROH_OFFLINE_PAIR_SESSION_VERSION || + invitation.version !== IROH_OFFLINE_PAIR_SESSION_VERSION || + session.consumedAtSeconds !== null || + session.sessionId !== invitation.sessionId || + !sameEndpointExpectation(session.acceptor, input.expected.acceptor) || + session.createdAtSeconds > input.expected.nowSeconds + 30 || + session.expiresAtSeconds <= input.expected.nowSeconds + ) { + throw new IrohForbiddenError({ code: "offline_pair_session_unavailable" }); + } + validateOfflinePairSessionWindow( + session.createdAtSeconds, + session.expiresAtSeconds, + ); + const proof = decodeCanonicalBase64url(invitation.proof, 32, "invalid_offline_pair_proof"); + const actualHash = offlinePairProofHash(session.sessionId, session.acceptor, proof); + if (!hashesEqual(session.proofHash, actualHash)) { + throw new IrohForbiddenError({ code: "invalid_offline_pair_proof" }); + } + const verified = verifyOfflineSameAccountPair(input); + session.consumedAtSeconds = input.expected.nowSeconds; + return { ...verified, sessionId: session.sessionId }; +} + +export function parseVerificationKeys( + value: string | undefined, +): ParsedPairGrantVerificationKeys { + if (!value || value.length > 32_768) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + const object = parsed as Record; + if (!hasExactKeys(object, ["version", "current_kid", "keys"])) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + if (object.version !== 1 || !Array.isArray(object.keys)) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + const currentKid = validKid( + typeof object.current_kid === "string" ? object.current_kid : undefined, + "grant_verification", + ); + if (object.keys.length < 1 || object.keys.length > 2) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + + const seen = new Set(); + const keys = object.keys.map((raw): PairGrantVerificationKey => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + const key = raw as Record; + if (!hasExactKeys(key, ["kid", "alg", "spki_der_base64"]) || key.alg !== "EdDSA") { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + const kid = validKid(typeof key.kid === "string" ? key.kid : undefined, "grant_verification"); + if (seen.has(kid)) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + seen.add(kid); + const spkiDerBase64 = typeof key.spki_der_base64 === "string" + ? canonicalVerificationKey(key.spki_der_base64) + : configurationFailure(); + return { kid, alg: "EdDSA", spki_der_base64: spkiDerBase64 }; + }); + if (!seen.has(currentKid)) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + keys.sort((left, right) => { + if (left.kid === currentKid) return -1; + if (right.kid === currentKid) return 1; + return left.kid.localeCompare(right.kid); + }); + return { + keySet: { version: 1, current_kid: currentKid, keys }, + publicKeys: new Map(keys.map((key) => [key.kid, key.spki_der_base64])), + }; +} + +export function assertCurrentSigningKey(input: { + readonly privateKeyPem: string | undefined; + readonly kid: string | undefined; + readonly verificationKeys: ParsedPairGrantVerificationKeys; +}): void { + const kid = validKid(input.kid, "grant_signing"); + if (kid !== input.verificationKeys.keySet.current_kid) { + throw new IrohConfigurationError({ component: "grant_signing" }); + } + const expectedDerBase64 = input.verificationKeys.publicKeys.get(kid); + if (!expectedDerBase64) { + throw new IrohConfigurationError({ component: "grant_signing" }); + } + const privateKey = signingPrivateKey(input.privateKeyPem); + let actualDer: Buffer; + try { + actualDer = createPublicKey(privateKey).export({ format: "der", type: "spki" }); + } catch { + throw new IrohConfigurationError({ component: "grant_signing" }); + } + const expectedDer = Buffer.from(expectedDerBase64, "base64"); + if (actualDer.byteLength !== expectedDer.byteLength || !timingSafeEqual(actualDer, expectedDer)) { + throw new IrohConfigurationError({ component: "grant_signing" }); + } +} + +function validatePairGrantClaims( + value: PairGrantClaims, + expected: PairGrantVerificationExpectation, +): void { + if (!value || typeof value !== "object") throw new IrohForbiddenError({ code: "invalid_pair_grant_claims" }); + assertExactKeys(value as unknown as Record, [ + "jti", + "iat", + "nbf", + "exp", + "alpn", + "scope", + "initiator", + "acceptor", + ], "invalid_pair_grant_claims"); + if (typeof value.jti !== "string" || !UUID_PATTERN.test(value.jti)) { + throw new IrohForbiddenError({ code: "invalid_pair_grant_claims" }); + } + if (value.alpn !== IROH_ALPN) throw new IrohForbiddenError({ code: "invalid_pair_grant_alpn" }); + if (value.scope !== IROH_PAIR_SCOPE) throw new IrohForbiddenError({ code: "invalid_pair_grant_scope" }); + if (!Number.isSafeInteger(value.iat) || !Number.isSafeInteger(value.nbf) || !Number.isSafeInteger(value.exp)) { + throw new IrohForbiddenError({ code: "invalid_pair_grant_expiry" }); + } + if ( + value.nbf > expected.nowSeconds + 30 || + value.exp <= expected.nowSeconds || + value.exp <= value.nbf || + value.exp - value.iat > IROH_PAIR_GRANT_LIFETIME_SECONDS || + value.iat > expected.nowSeconds + 30 + ) { + throw new IrohForbiddenError({ code: "invalid_pair_grant_expiry" }); + } + validatePeer(value.initiator, "initiator"); + validatePeer(value.acceptor, "acceptor"); + if (value.initiator.platform !== "ios" || value.acceptor.platform !== "mac") { + throw new IrohForbiddenError({ code: "invalid_pair_grant_platforms" }); + } + if ( + value.initiator.bindingId === value.acceptor.bindingId || + value.initiator.deviceId === value.acceptor.deviceId || + value.initiator.endpointId === value.acceptor.endpointId + ) { + throw new IrohForbiddenError({ code: "pair_grant_peers_not_distinct" }); + } + if (expected.initiator && !samePeer(value.initiator, expected.initiator)) { + throw new IrohForbiddenError({ code: "pair_grant_initiator_mismatch" }); + } + if (expected.acceptor && !samePeer(value.acceptor, expected.acceptor)) { + throw new IrohForbiddenError({ code: "pair_grant_acceptor_mismatch" }); + } +} + +function validateEndpointAttestationClaims( + value: EndpointAttestationClaims, + expected: EndpointAttestationExpectation, +): void { + if (!value || typeof value !== "object") { + throw new IrohForbiddenError({ code: "invalid_endpoint_attestation_claims" }); + } + assertExactKeys(value as unknown as Record, [ + "version", + "jti", + "sub", + "bindingId", + "deviceId", + "endpointId", + "identityGeneration", + "platform", + "iat", + "nbf", + "exp", + "alpn", + "scope", + ], "invalid_endpoint_attestation_claims"); + if ( + value.version !== IROH_ENDPOINT_ATTESTATION_VERSION || + typeof value.jti !== "string" || !UUID_PATTERN.test(value.jti) || + typeof value.bindingId !== "string" || !UUID_PATTERN.test(value.bindingId) || + typeof value.deviceId !== "string" || !UUID_PATTERN.test(value.deviceId) || + !Number.isSafeInteger(value.identityGeneration) || + value.identityGeneration < 1 || + value.identityGeneration > POSTGRES_INT32_MAX || + (value.platform !== "mac" && value.platform !== "ios") || + value.alpn !== IROH_ALPN || + value.scope !== IROH_ENDPOINT_ATTESTATION_SCOPE + ) { + throw new IrohForbiddenError({ code: "invalid_endpoint_attestation_claims" }); + } + endpointId(value.endpointId); + decodeCanonicalBase64url(value.sub, 32, "invalid_endpoint_attestation_claims"); + if ( + !Number.isSafeInteger(value.iat) || + !Number.isSafeInteger(value.nbf) || + !Number.isSafeInteger(value.exp) || + value.nbf < value.iat - 30 || + value.nbf > expected.nowSeconds + 30 || + value.exp <= expected.nowSeconds || + value.exp <= value.nbf || + value.exp - value.iat > IROH_ENDPOINT_ATTESTATION_LIFETIME_SECONDS || + value.iat > expected.nowSeconds + 30 + ) { + throw new IrohForbiddenError({ code: "invalid_endpoint_attestation_expiry" }); + } + if ( + value.bindingId !== expected.bindingId || + value.deviceId !== expected.deviceId || + value.endpointId !== expected.endpointId || + value.identityGeneration !== expected.identityGeneration || + value.platform !== expected.platform + ) { + throw new IrohForbiddenError({ code: "endpoint_attestation_identity_mismatch" }); + } +} + +function validatePeer(peer: PairGrantPeer, side: string): void { + if ( + !peer || typeof peer !== "object" || + typeof peer.bindingId !== "string" || !UUID_PATTERN.test(peer.bindingId) || + typeof peer.deviceId !== "string" || !UUID_PATTERN.test(peer.deviceId) || + typeof peer.tag !== "string" || peer.tag.length < 1 || peer.tag.length > 64 || + !Number.isSafeInteger(peer.identityGeneration) || + peer.identityGeneration < 1 || + peer.identityGeneration > POSTGRES_INT32_MAX || + (peer.platform !== "mac" && peer.platform !== "ios") + ) { + throw new IrohForbiddenError({ code: `invalid_pair_grant_${side}` }); + } + assertExactKeys(peer as unknown as Record, [ + "bindingId", + "deviceId", + "tag", + "platform", + "endpointId", + "identityGeneration", + ], `invalid_pair_grant_${side}`); + endpointId(peer.endpointId); +} + +function samePeer(left: PairGrantPeer, right: PairGrantPeer): boolean { + return left.bindingId === right.bindingId && + left.deviceId === right.deviceId && + left.tag === right.tag && + left.platform === right.platform && + left.endpointId === right.endpointId && + left.identityGeneration === right.identityGeneration; +} + +function endpointPublicKey(value: string): KeyObject { + const canonical = endpointId(value); + return createPublicKey({ + key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(canonical, "hex")]), + format: "der", + type: "spki", + }); +} + +function decodeSecret( + value: string | undefined, + component: "lan_discovery" | "account_subject", +): Buffer { + if (!value || value.length > 512) throw new IrohConfigurationError({ component }); + const decoded = Buffer.from(value, "base64"); + if ( + decoded.byteLength < 32 || + decoded.byteLength > 256 || + decoded.toString("base64").replace(/=+$/, "") !== value.replace(/=+$/, "") + ) { + throw new IrohConfigurationError({ component }); + } + return decoded; +} + +function validKid( + value: string | undefined, + component: "grant_signing" | "grant_verification", +): string { + if (!value || !/^[A-Za-z0-9._-]{1,64}$/.test(value)) { + throw new IrohConfigurationError({ component }); + } + return value; +} + +function normalizePem(value: string | undefined, component: "grant_signing"): string { + if (!value) throw new IrohConfigurationError({ component }); + const normalized = value.replaceAll("\\n", "\n").trim(); + if (normalized.length > 16_384 || !normalized.includes("-----BEGIN")) { + throw new IrohConfigurationError({ component }); + } + return normalized; +} + +function encodeJson(value: unknown): string { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); +} + +function decodeJson(encoded: string, code: string): Record { + try { + const value = JSON.parse(decodeCanonicalBase64url(encoded, undefined, code).toString("utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("not object"); + return value as Record; + } catch { + throw new IrohInvalidInputError({ code }); + } +} + +function decodeCanonicalBase64url( + encoded: string, + expectedLength: number | undefined, + code: string, +): Buffer { + if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) { + throw new IrohInvalidInputError({ code }); + } + const decoded = Buffer.from(encoded, "base64url"); + if ( + decoded.toString("base64url") !== encoded || + (expectedLength !== undefined && decoded.byteLength !== expectedLength) + ) { + throw new IrohInvalidInputError({ code }); + } + return decoded; +} + +function signingPrivateKey(value: string | undefined): KeyObject { + let privateKey: KeyObject; + try { + privateKey = createPrivateKey(normalizePem(value, "grant_signing")); + } catch { + throw new IrohConfigurationError({ component: "grant_signing" }); + } + if (privateKey.asymmetricKeyType !== "ed25519") { + throw new IrohConfigurationError({ component: "grant_signing" }); + } + return privateKey; +} + +function verificationPublicKey(value: string): KeyObject { + const canonical = canonicalVerificationKey(value); + try { + const publicKey = createPublicKey({ + key: Buffer.from(canonical, "base64"), + format: "der", + type: "spki", + }); + if (publicKey.asymmetricKeyType !== "ed25519") throw new Error("wrong key type"); + return publicKey; + } catch { + throw new IrohConfigurationError({ component: "grant_verification" }); + } +} + +function canonicalVerificationKey(value: string): string { + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + const der = Buffer.from(value, "base64"); + if ( + der.byteLength !== ED25519_SPKI_PREFIX.byteLength + 32 || + der.toString("base64") !== value || + !der.subarray(0, ED25519_SPKI_PREFIX.byteLength).equals(ED25519_SPKI_PREFIX) + ) { + throw new IrohConfigurationError({ component: "grant_verification" }); + } + verificationPublicKeyShape(der); + return value; +} + +function verificationPublicKeyShape(der: Buffer): void { + try { + const key = createPublicKey({ key: der, format: "der", type: "spki" }); + if (key.asymmetricKeyType !== "ed25519") throw new Error("wrong key type"); + } catch { + throw new IrohConfigurationError({ component: "grant_verification" }); + } +} + +function configurationFailure(): never { + throw new IrohConfigurationError({ component: "grant_verification" }); +} + +function hasExactKeys(value: Record, allowed: readonly string[]): boolean { + const keys = Object.keys(value); + return keys.length === allowed.length && keys.every((key) => allowed.includes(key)); +} + +function canonicalSubjectsEqual(left: string, right: string): boolean { + const leftBytes = decodeCanonicalBase64url(left, 32, "invalid_endpoint_attestation"); + const rightBytes = decodeCanonicalBase64url(right, 32, "invalid_endpoint_attestation"); + return timingSafeEqual(leftBytes, rightBytes); +} + +function validateOfflinePairSessionWindow(nowSeconds: number, expiresAtSeconds: number): void { + if ( + !Number.isSafeInteger(nowSeconds) || + !Number.isSafeInteger(expiresAtSeconds) || + expiresAtSeconds <= nowSeconds || + expiresAtSeconds - nowSeconds > IROH_OFFLINE_PAIR_SESSION_LIFETIME_SECONDS + ) { + throw new IrohInvalidInputError({ code: "invalid_offline_pair_session" }); + } +} + +function validateEndpointExpectation( + value: OfflinePairVerificationExpectation["acceptor"], + platform: "mac" | "ios", +): void { + if ( + !UUID_PATTERN.test(value.bindingId) || + !UUID_PATTERN.test(value.deviceId) || + value.platform !== platform || + !Number.isSafeInteger(value.identityGeneration) || + value.identityGeneration < 1 || + value.identityGeneration > POSTGRES_INT32_MAX + ) { + throw new IrohInvalidInputError({ code: "invalid_offline_pair_session" }); + } + endpointId(value.endpointId); +} + +function sameEndpointExpectation( + left: OfflinePairVerificationExpectation["acceptor"], + right: OfflinePairVerificationExpectation["acceptor"], +): boolean { + return left.bindingId === right.bindingId && + left.deviceId === right.deviceId && + left.endpointId === right.endpointId && + left.identityGeneration === right.identityGeneration && + left.platform === right.platform; +} + +function offlinePairProofHash( + sessionId: string, + acceptor: OfflinePairVerificationExpectation["acceptor"], + proof: Uint8Array, +): string { + return sha256(Buffer.concat([ + Buffer.from( + `cmux/iroh/offline-pair-session/v1\n${sessionId}\n${acceptor.bindingId}\n${acceptor.deviceId}\n${acceptor.endpointId}\n${acceptor.identityGeneration}\n${acceptor.platform}\n`, + "utf8", + ), + Buffer.from(proof), + ])); +} + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function assertExactKeys( + value: Record, + allowed: readonly string[], + code: string, +): void { + const keys = Object.keys(value); + if (keys.length !== allowed.length || keys.some((key) => !allowed.includes(key))) { + throw new IrohForbiddenError({ code }); + } +} diff --git a/web/services/iroh/errors.ts b/web/services/iroh/errors.ts new file mode 100644 index 000000000000..67d5214f3490 --- /dev/null +++ b/web/services/iroh/errors.ts @@ -0,0 +1,92 @@ +import * as Data from "effect/Data"; +import { FiberFailureCauseId } from "effect/Runtime"; + +export class IrohInvalidInputError extends Data.TaggedError("IrohInvalidInputError")<{ + readonly code: string; +}> {} + +export class IrohNotFoundError extends Data.TaggedError("IrohNotFoundError")<{ + readonly resource: "binding" | "challenge"; +}> {} + +export class IrohForbiddenError extends Data.TaggedError("IrohForbiddenError")<{ + readonly code: string; +}> {} + +export class IrohConflictError extends Data.TaggedError("IrohConflictError")<{ + readonly code: string; +}> {} + +export class IrohQuotaExceededError extends Data.TaggedError("IrohQuotaExceededError")<{ + readonly code: string; + readonly retryAfterSeconds: number; +}> {} + +export class IrohConfigurationError extends Data.TaggedError("IrohConfigurationError")<{ + readonly component: + | "grant_signing" + | "grant_verification" + | "account_subject" + | "lan_discovery" + | "relay_minter"; +}> {} + +export class IrohDatabaseError extends Data.TaggedError("IrohDatabaseError")<{ + readonly operation: string; + readonly cause: unknown; +}> {} + +export class IrohRelayMintError extends Data.TaggedError("IrohRelayMintError")<{ + readonly code: string; + readonly cause?: unknown; +}> {} + +export type IrohExpectedError = + | IrohInvalidInputError + | IrohNotFoundError + | IrohForbiddenError + | IrohConflictError + | IrohQuotaExceededError + | IrohConfigurationError + | IrohDatabaseError + | IrohRelayMintError; + +export function irohExpectedError(error: unknown): IrohExpectedError | null { + if (!error || typeof error !== "object") return null; + const tag = (error as { _tag?: unknown })._tag; + if (typeof tag === "string" && IROH_ERROR_TAGS.has(tag)) return error as IrohExpectedError; + + if (FiberFailureCauseId in error) { + return errorFromCause( + (error as Record)[FiberFailureCauseId], + ); + } + return null; +} + +function errorFromCause(cause: unknown): IrohExpectedError | null { + if (!cause || typeof cause !== "object") return null; + const tag = (cause as { _tag?: unknown })._tag; + if (tag === "Fail") { + return irohExpectedError( + (cause as { failure?: unknown; error?: unknown }).failure ?? + (cause as { error?: unknown }).error, + ); + } + if (tag === "Sequential" || tag === "Parallel") { + return errorFromCause((cause as { left?: unknown }).left) ?? + errorFromCause((cause as { right?: unknown }).right); + } + return errorFromCause((cause as { cause?: unknown }).cause); +} + +const IROH_ERROR_TAGS = new Set([ + "IrohInvalidInputError", + "IrohNotFoundError", + "IrohForbiddenError", + "IrohConflictError", + "IrohQuotaExceededError", + "IrohConfigurationError", + "IrohDatabaseError", + "IrohRelayMintError", +]); diff --git a/web/services/iroh/firewall.ts b/web/services/iroh/firewall.ts new file mode 100644 index 000000000000..1c0bbc146eaa --- /dev/null +++ b/web/services/iroh/firewall.ts @@ -0,0 +1,131 @@ +import { createHash } from "node:crypto"; + +export type IrohFirewallCheckResult = { + readonly rateLimited: boolean; + readonly error?: "not-found" | "blocked"; +}; + +export type IrohFirewallCheck = ( + rateLimitId: string, + options: { + readonly request: Request; + readonly rateLimitKey: string; + readonly signal: AbortSignal; + }, +) => Promise; + +type IrohFirewallEnvironment = Readonly<{ + readonly VERCEL_URL?: string; + readonly VERCEL_PROJECT_PRODUCTION_URL?: string; + readonly PUBLIC_VERCEL_FIREWALL_PATH_PREFIX?: string; + readonly NEXT_PUBLIC_VERCEL_FIREWALL_PATH_PREFIX?: string; + readonly VERCEL_AUTOMATION_BYPASS_SECRET?: string; + readonly RATE_LIMIT_SECRET?: string; +}>; + +type IrohFirewallDependencies = Readonly<{ + readonly environment?: IrohFirewallEnvironment; + readonly fetch?: typeof globalThis.fetch; +}>; + +// @vercel/firewall 1.2.1 does not pass an AbortSignal to its internal fetch. +// Keep the documented programmatic endpoint behavior while making cancellation +// part of this boundary's contract so a stalled check cannot outlive its slot. +export function createIrohVercelFirewallCheck( + dependencies: IrohFirewallDependencies = {}, +): IrohFirewallCheck { + const environment: IrohFirewallEnvironment = dependencies.environment ?? { + VERCEL_URL: process.env.VERCEL_URL, + VERCEL_PROJECT_PRODUCTION_URL: process.env.VERCEL_PROJECT_PRODUCTION_URL, + PUBLIC_VERCEL_FIREWALL_PATH_PREFIX: process.env.PUBLIC_VERCEL_FIREWALL_PATH_PREFIX, + NEXT_PUBLIC_VERCEL_FIREWALL_PATH_PREFIX: process.env.NEXT_PUBLIC_VERCEL_FIREWALL_PATH_PREFIX, + VERCEL_AUTOMATION_BYPASS_SECRET: process.env.VERCEL_AUTOMATION_BYPASS_SECRET, + RATE_LIMIT_SECRET: process.env.RATE_LIMIT_SECRET, + }; + const fetcher = dependencies.fetch ?? globalThis.fetch; + return async (rateLimitId, { request, rateLimitKey, signal }) => { + const host = trustedFirewallHost(request, environment); + const pathPrefix = normalizedPathPrefix( + environment.PUBLIC_VERCEL_FIREWALL_PATH_PREFIX + ?? environment.NEXT_PUBLIC_VERCEL_FIREWALL_PATH_PREFIX, + ); + const url = `https://${host}${pathPrefix}/.well-known/vercel/rate-limit-api/${encodeURIComponent(rateLimitId)}`; + const secretSuffix = `${environment.VERCEL_AUTOMATION_BYPASS_SECRET ?? ""}${environment.RATE_LIMIT_SECRET ?? ""}`; + const keyDigest = createHash("sha256") + .update(`${rateLimitKey}${rateLimitId}${secretSuffix}`) + .digest("hex"); + const headers = new Headers({ + "user-agent": "Bot/Vercel Rate Limit Checker", + "x-vercel-rate-limit-api": rateLimitId, + "x-vercel-rate-limit-key": `${rateLimitKey}-${keyDigest}`, + "x-forwarded-for": request.headers.get("x-forwarded-for") ?? "", + "x-real-ip": request.headers.get("x-real-ip") ?? "", + "x-vercel-protection-bypass": environment.VERCEL_AUTOMATION_BYPASS_SECRET ?? "", + }); + const previewJwt = cookieValue(request.headers.get("cookie"), "_vercel_jwt"); + if (previewJwt) headers.set("cookie", `_vercel_jwt=${previewJwt}`); + for (const [name, value] of request.headers.entries()) { + headers.append(`x-rr-${name}`, value); + } + + const response = await fetcher(url, { + method: "GET", + headers, + redirect: "manual", + signal, + }); + await response.body?.cancel().catch(() => {}); + switch (response.status) { + case 204: return { rateLimited: false }; + case 429: return { rateLimited: true }; + case 403: return { rateLimited: true, error: "blocked" }; + case 404: return { rateLimited: false, error: "not-found" }; + default: throw new Error("unexpected_firewall_status"); + } + }; +} + +export const checkIrohVercelFirewall = createIrohVercelFirewallCheck(); + +function trustedFirewallHost(request: Request, environment: IrohFirewallEnvironment): string { + const deploymentHost = normalizedHost(environment.VERCEL_URL); + const productionHost = normalizedHost(environment.VERCEL_PROJECT_PRODUCTION_URL); + if (!deploymentHost && !productionHost) throw new Error("firewall_host_unavailable"); + + const requestedHost = normalizedHost(request.headers.get("host") ?? new URL(request.url).host); + if (requestedHost && (requestedHost === deploymentHost || requestedHost === productionHost)) { + return requestedHost; + } + // A deployment URL can be protected by Vercel Authentication even when the + // public project URL is intentionally reachable. Both values come from + // Vercel, so prefer the stable project URL when the inbound host is not one + // of the trusted values instead of making the rate-limit check hit SSO. + return productionHost ?? deploymentHost!; +} + +function normalizedHost(value: string | null | undefined): string | undefined { + const candidate = value?.trim().toLowerCase(); + if (!candidate) return undefined; + if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?::[1-9][0-9]{0,4})?$/.test(candidate)) { + throw new Error("invalid_firewall_host"); + } + return candidate; +} + +function normalizedPathPrefix(value: string | undefined): string { + const candidate = value?.trim(); + if (!candidate) return ""; + const prefixed = candidate.startsWith("/") ? candidate : `/${candidate}`; + if (!/^\/[A-Za-z0-9/_-]*$/.test(prefixed)) throw new Error("invalid_firewall_path_prefix"); + return prefixed.replace(/\/$/, ""); +} + +function cookieValue(header: string | null, name: string): string | undefined { + for (const item of header?.split(";") ?? []) { + const separator = item.indexOf("="); + if (separator < 0 || item.slice(0, separator).trim() !== name) continue; + const value = item.slice(separator + 1).trim(); + return value || undefined; + } + return undefined; +} diff --git a/web/services/iroh/minterUrlPolicy.ts b/web/services/iroh/minterUrlPolicy.ts new file mode 100644 index 000000000000..7aac5faac229 --- /dev/null +++ b/web/services/iroh/minterUrlPolicy.ts @@ -0,0 +1,40 @@ +export const IROH_RELAY_MINTER_PATH = "/api/relay-token"; + +export type IrohMinterUrlPolicy = { + readonly allowInsecureLoopback: boolean; + readonly deploymentEnvironment: string; + readonly isVercelDeployment: boolean; +}; + +export function insecureLoopbackMinterAllowed(policy: IrohMinterUrlPolicy): boolean { + return policy.allowInsecureLoopback && + !policy.isVercelDeployment && + policy.deploymentEnvironment === "development"; +} + +export function parseIrohMinterUrl(value: string, policy: IrohMinterUrlPolicy): URL { + const url = new URL(value); + const secureTransport = url.protocol === "https:"; + const allowedDevelopmentTransport = + url.protocol === "http:" && + insecureLoopbackMinterAllowed(policy) && + isCanonicalLoopbackHost(url.hostname); + + if ( + (!secureTransport && !allowedDevelopmentTransport) || + url.username || + url.password || + url.pathname !== IROH_RELAY_MINTER_PATH || + url.search || + url.hash + ) { + throw new Error("invalid Iroh relay minter URL"); + } + return url; +} + +function isCanonicalLoopbackHost(hostname: string): boolean { + return hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "[::1]"; +} diff --git a/web/services/iroh/model.ts b/web/services/iroh/model.ts new file mode 100644 index 000000000000..21a5b54bd277 --- /dev/null +++ b/web/services/iroh/model.ts @@ -0,0 +1,569 @@ +import { createHash } from "node:crypto"; +import { isIP } from "node:net"; +import { IrohInvalidInputError } from "./errors"; +import { MANAGED_RELAY_URLS } from "./publicationPolicy"; + +export { MANAGED_RELAY_URLS } from "./publicationPolicy"; + +export const IROH_ALPN = "cmux/mobile/1"; +export const IROH_PAIR_SCOPE = "cmux.mobile.attach"; +export const IROH_PAIR_GRANT_TYP = "cmux-pair-grant+jwt"; +export const IROH_ENDPOINT_ATTESTATION_VERSION = 1; +export const IROH_ENDPOINT_ATTESTATION_TYP = "cmux-endpoint-attestation-v1+jwt"; +export const IROH_ENDPOINT_ATTESTATION_SCOPE = "cmux.offline-pair.same-account"; +export const IROH_CHALLENGE_LIFETIME_MS = 5 * 60 * 1_000; +export const IROH_PAIR_GRANT_LIFETIME_SECONDS = 7 * 24 * 60 * 60; +export const IROH_ENDPOINT_ATTESTATION_LIFETIME_SECONDS = 24 * 60 * 60; +export const IROH_OFFLINE_PAIR_SESSION_LIFETIME_SECONDS = 5 * 60; +export const IROH_OFFLINE_PAIR_SESSION_VERSION = 1; +export const IROH_RELAY_TOKEN_LIFETIME_SECONDS = 24 * 60 * 60; +export const IROH_RELAY_TOKEN_REFRESH_SECONDS = 12 * 60 * 60; +export const IROH_ROUTE_CONTRACT_VERSION = 1; +export const POSTGRES_INT32_MAX = 2_147_483_647; + +export type IrohPathHint = { + readonly kind: "direct_address" | "relay_url"; + readonly value: string; + readonly source: "native" | "lan" | "tailscale" | "custom_vpn"; + readonly privacy_scope: "public_internet" | "local_network" | "private_network"; + readonly observed_at: string; + readonly expires_at: string; + readonly network_profile?: { + readonly source: "lan" | "tailscale" | "custom_vpn"; + readonly profile_id: string; + }; +}; + +export type IrohRegistrationPayload = { + readonly route_contract_version: typeof IROH_ROUTE_CONTRACT_VERSION; + readonly deviceId: string; + readonly appInstanceId: string; + readonly tag: string; + readonly platform: "mac" | "ios"; + readonly displayName?: string; + readonly endpointId: string; + readonly identityGeneration: number; + readonly pairingEnabled: boolean; + readonly capabilities: readonly string[]; + readonly pathHints: readonly IrohPathHint[]; +}; + +export type IrohChallengeRequest = Pick< + IrohRegistrationPayload, + "deviceId" | "appInstanceId" | "tag" | "endpointId" | "identityGeneration" +> & { + readonly payloadSha256: string; +}; + +export type IrohRegisterRequest = { + readonly challengeId: string; + readonly nonce: string; + readonly payload: string; + readonly signature: string; +}; + +export function parseChallengeRequest(value: unknown): IrohChallengeRequest { + const body = record(value); + const parsed: IrohChallengeRequest = { + deviceId: uuid(body.deviceId, "invalid_device_id"), + appInstanceId: uuid(body.appInstanceId, "invalid_app_instance_id"), + tag: safeTag(body.tag), + endpointId: endpointId(body.endpointId), + identityGeneration: positiveInteger(body.identityGeneration, "invalid_identity_generation"), + payloadSha256: sha256Hex(body.payloadSha256, "invalid_payload_hash"), + }; + rejectUnknownKeys(body, [ + "deviceId", + "appInstanceId", + "tag", + "endpointId", + "identityGeneration", + "payloadSha256", + ]); + return parsed; +} + +export function parseRegisterRequest(value: unknown): IrohRegisterRequest { + const body = record(value); + const parsed = { + challengeId: uuid(body.challengeId, "invalid_challenge_id"), + nonce: base64url(body.nonce, 32, "invalid_nonce"), + payload: boundedString(body.payload, 1, 48_000, "invalid_payload"), + signature: base64url(body.signature, 64, "invalid_signature"), + }; + rejectUnknownKeys(body, ["challengeId", "nonce", "payload", "signature"]); + return parsed; +} + +export function decodeRegistrationPayload(encoded: string, now: Date): { + readonly bytes: Uint8Array; + readonly sha256: string; + readonly payload: IrohRegistrationPayload; +} { + let bytes: Uint8Array; + try { + bytes = Buffer.from(encoded, "base64url"); + } catch { + throw new IrohInvalidInputError({ code: "invalid_payload" }); + } + if (bytes.byteLength === 0 || bytes.byteLength > 32_768) { + throw new IrohInvalidInputError({ code: "invalid_payload" }); + } + let raw: unknown; + try { + raw = JSON.parse(Buffer.from(bytes).toString("utf8")); + } catch { + throw new IrohInvalidInputError({ code: "invalid_payload" }); + } + return { + bytes, + sha256: sha256(bytes), + payload: parseRegistrationPayload(raw, now), + }; +} + +export function parseRegistrationPayload(value: unknown, now: Date): IrohRegistrationPayload { + const body = record(value); + const capabilitiesRaw = Array.isArray(body.capabilities) ? body.capabilities : null; + if (!capabilitiesRaw || capabilitiesRaw.length > 32) { + throw new IrohInvalidInputError({ code: "invalid_capabilities" }); + } + const capabilities = [...new Set(capabilitiesRaw.map((item) => { + const capability = boundedString(item, 1, 64, "invalid_capabilities"); + if (!/^[A-Za-z0-9._:-]+$/.test(capability)) { + throw new IrohInvalidInputError({ code: "invalid_capabilities" }); + } + return capability; + }))]; + const hintsRaw = Array.isArray(body.pathHints) ? body.pathHints : null; + if (!hintsRaw || hintsRaw.length > 16) { + throw new IrohInvalidInputError({ code: "invalid_path_hints" }); + } + const pathHints = hintsRaw.map((hint) => parseIrohPathHint(hint, now)); + if (pathHints.filter((hint) => hint.kind === "relay_url").length > 2) { + throw new IrohInvalidInputError({ code: "too_many_relay_hints" }); + } + if (new Set(pathHints.map((hint) => JSON.stringify(hint))).size !== pathHints.length) { + throw new IrohInvalidInputError({ code: "duplicate_path_hint" }); + } + const payload: IrohRegistrationPayload = { + route_contract_version: routeContractVersion(body.route_contract_version), + deviceId: uuid(body.deviceId, "invalid_device_id"), + appInstanceId: uuid(body.appInstanceId, "invalid_app_instance_id"), + tag: safeTag(body.tag), + platform: oneOf(body.platform, ["mac", "ios"] as const, "invalid_platform"), + ...(body.displayName === undefined || body.displayName === null + ? {} + : { displayName: safeDisplayName(body.displayName) }), + endpointId: endpointId(body.endpointId), + identityGeneration: positiveInteger(body.identityGeneration, "invalid_identity_generation"), + pairingEnabled: boolean(body.pairingEnabled, "invalid_pairing_enabled"), + capabilities, + pathHints, + }; + rejectUnknownKeys(body, [ + "deviceId", + "route_contract_version", + "appInstanceId", + "tag", + "platform", + "displayName", + "endpointId", + "identityGeneration", + "pairingEnabled", + "capabilities", + "pathHints", + ]); + return payload; +} + +export function parseBindingIdBody(value: unknown): { readonly bindingId: string } { + const body = record(value); + const result = { bindingId: uuid(body.bindingId, "invalid_binding_id") }; + rejectUnknownKeys(body, ["bindingId"]); + return result; +} + +export function parsePairGrantRequest(value: unknown): { + readonly initiatorBindingId: string; + readonly acceptorBindingId: string; +} { + const body = record(value); + const result = { + initiatorBindingId: uuid(body.initiatorBindingId, "invalid_initiator_binding_id"), + acceptorBindingId: uuid(body.acceptorBindingId, "invalid_acceptor_binding_id"), + }; + if (result.initiatorBindingId === result.acceptorBindingId) { + throw new IrohInvalidInputError({ code: "grant_peers_must_differ" }); + } + rejectUnknownKeys(body, ["initiatorBindingId", "acceptorBindingId"]); + return result; +} + +export function assertChallengeMatchesPayload( + challenge: IrohChallengeIdentity, + payload: IrohRegistrationPayload, +): void { + if ( + challenge.deviceUuid !== payload.deviceId || + challenge.appInstanceId !== payload.appInstanceId || + challenge.tag !== payload.tag || + challenge.endpointId !== payload.endpointId || + challenge.identityGeneration !== payload.identityGeneration + ) { + throw new IrohInvalidInputError({ code: "challenge_payload_mismatch" }); + } +} + +export type IrohChallengeIdentity = { + readonly deviceUuid: string; + readonly appInstanceId: string; + readonly tag: string; + readonly endpointId: string; + readonly identityGeneration: number; +}; + +export function parseIrohPathHint(value: unknown, now: Date): IrohPathHint { + const hint = record(value); + const kind = oneOf(hint.kind, ["direct_address", "relay_url"] as const, "invalid_path_hint_kind"); + const source = oneOf(hint.source, ["native", "lan", "tailscale", "custom_vpn"] as const, "invalid_path_hint_source"); + const privacyScope = oneOf( + hint.privacy_scope, + ["public_internet", "local_network", "private_network"] as const, + "invalid_path_hint_scope", + ); + const expectedScope = source === "native" + ? "public_internet" + : source === "lan" + ? "local_network" + : "private_network"; + if (privacyScope !== expectedScope) throw new IrohInvalidInputError({ code: "invalid_path_hint_scope" }); + + const observedAtRaw = boundedString(hint.observed_at, 1, 40, "invalid_path_hint_observation"); + const observedAt = new Date(observedAtRaw); + if ( + !Number.isFinite(observedAt.getTime()) || + observedAt.getTime() > now.getTime() + 5 * 60 * 1_000 || + observedAt.getTime() < now.getTime() - 60 * 60 * 1_000 + ) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_observation" }); + } + const expiresAtRaw = boundedString(hint.expires_at, 1, 40, "invalid_path_hint_expiry"); + const expiresAt = new Date(expiresAtRaw); + if ( + !Number.isFinite(expiresAt.getTime()) || + expiresAt <= now || + expiresAt.getTime() > observedAt.getTime() + 60 * 60 * 1_000 + ) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_expiry" }); + } + + const networkProfile = source === "native" + ? undefined + : parseNetworkProfile(hint.network_profile, source); + if (source === "native" && hint.network_profile !== undefined) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_profile" }); + } + const hintValue = kind === "relay_url" + ? managedRelayUrl(hint.value, source) + : literalSocketAddress(hint.value, privacyScope, source); + const parsed: IrohPathHint = { + kind, + value: hintValue, + source, + privacy_scope: privacyScope, + observed_at: observedAt.toISOString(), + expires_at: expiresAt.toISOString(), + ...(networkProfile ? { network_profile: networkProfile } : {}), + }; + rejectUnknownKeys(hint, [ + "kind", + "value", + "source", + "privacy_scope", + "observed_at", + "expires_at", + "network_profile", + ]); + return parsed; +} + +export function nextPathHintExpiry(pathHints: readonly IrohPathHint[]): Date | null { + if (pathHints.length === 0) return null; + const earliest = Math.min(...pathHints.map((hint) => new Date(hint.expires_at).getTime())); + if (!Number.isFinite(earliest)) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_expiry" }); + } + return new Date(earliest); +} + +function literalSocketAddress( + value: unknown, + scope: IrohPathHint["privacy_scope"], + source: IrohPathHint["source"], +): string { + const address = boundedString(value, 3, 80, "invalid_path_hint_address"); + let host = ""; + let port = ""; + if (address.startsWith("[")) { + const close = address.indexOf("]"); + if (close <= 1 || address[close + 1] !== ":") { + throw new IrohInvalidInputError({ code: "invalid_path_hint_address" }); + } + host = address.slice(1, close); + port = address.slice(close + 2); + if (host.includes("%") || isIP(host) !== 6) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_address" }); + } + } else { + const colon = address.lastIndexOf(":"); + if (colon <= 0) throw new IrohInvalidInputError({ code: "invalid_path_hint_address" }); + host = address.slice(0, colon); + port = address.slice(colon + 1); + if (isIP(host) !== 4) throw new IrohInvalidInputError({ code: "invalid_path_hint_address" }); + } + const numericPort = Number(port); + if ( + !/^\d{1,5}$/.test(port) || + !Number.isInteger(numericPort) || + numericPort < 1 || numericPort > 65_535 || + String(numericPort) !== port + ) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_address" }); + } + assertAddressSafe(host, scope, source); + return isIP(host) === 6 + ? `[${canonicalIpv6(ipv6Bytes(host))}]:${numericPort}` + : `${host}:${numericPort}`; +} + +function parseNetworkProfile( + value: unknown, + source: Exclude, +): NonNullable { + const profile = record(value); + if (profile.source !== source) throw new IrohInvalidInputError({ code: "invalid_path_hint_profile" }); + const profileId = boundedString(profile.profile_id, 1, 96, "invalid_path_hint_profile"); + if (!/^[A-Za-z0-9._-]{1,96}$/.test(profileId)) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_profile" }); + } + rejectUnknownKeys(profile, ["source", "profile_id"]); + return { source, profile_id: profileId }; +} + +function managedRelayUrl(value: unknown, source: IrohPathHint["source"]): string { + if (source !== "native" || typeof value !== "string" || !MANAGED_RELAY_URLS.includes(value as typeof MANAGED_RELAY_URLS[number])) { + throw new IrohInvalidInputError({ code: "unmanaged_relay_hint" }); + } + return value; +} + +function assertAddressSafe( + host: string, + scope: IrohPathHint["privacy_scope"], + source: IrohPathHint["source"], +): void { + const family = isIP(host); + const classification = family === 4 ? classifyIpv4(host) : classifyIpv6(host); + if (classification === "forbidden") { + throw new IrohInvalidInputError({ code: "unsafe_path_hint_address" }); + } + if (scope === "public_internet" && classification !== "public") { + throw new IrohInvalidInputError({ code: "unsafe_public_path_hint_address" }); + } + if (scope !== "public_internet" && classification === "public" && source !== "custom_vpn") { + throw new IrohInvalidInputError({ code: "unsafe_private_path_hint_address" }); + } + if (scope === "private_network" && classification === "ipv4_link_local") { + throw new IrohInvalidInputError({ code: "unsafe_private_path_hint_address" }); + } +} + +type AddressClassification = "public" | "private" | "ipv4_link_local" | "forbidden"; + +function classifyIpv4(host: string): AddressClassification { + const rawOctets = host.split("."); + const octets = rawOctets.map(Number); + if (rawOctets.some((octet, index) => String(octets[index]) !== octet)) return "forbidden"; + const [a, b, c, d] = octets as [number, number, number, number]; + if (a === 0 || a === 127 || a >= 224) return "forbidden"; + if (a === 169 && b === 254) { + if (c === 169 && d === 254) return "forbidden"; + return "ipv4_link_local"; + } + if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) return "private"; + if (a === 100 && b >= 64 && b <= 127) return "private"; + if ( + (a === 192 && b === 0 && (c === 0 || c === 2)) || + (a === 192 && b === 88 && c === 99) || + (a === 198 && (b === 18 || b === 19)) || + (a === 198 && b === 51 && c === 100) || + (a === 203 && b === 0 && c === 113) + ) return "forbidden"; + return "public"; +} + +function classifyIpv6(host: string): AddressClassification { + const bytes = ipv6Bytes(host); + const allZero = bytes.every((byte) => byte === 0); + const loopback = bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1; + const multicast = bytes[0] === 0xff; + const linkLocal = bytes[0] === 0xfe && (bytes[1]! & 0xc0) === 0x80; + const documentation = bytes[0] === 0x20 && bytes[1] === 0x01 && bytes[2] === 0x0d && bytes[3] === 0xb8; + const mappedIpv4 = bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 0xff && bytes[11] === 0xff; + const metadata = bytesEqual(bytes, [0xfd, 0x00, 0x0e, 0xc2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x02, 0x54]); + if (allZero || loopback || multicast || linkLocal || documentation || mappedIpv4 || metadata) return "forbidden"; + if ((bytes[0]! & 0xfe) === 0xfc) return "private"; + return "public"; +} + +function ipv6Bytes(host: string): number[] { + const sides = host.toLowerCase().split("::"); + if (sides.length > 2) throw new IrohInvalidInputError({ code: "invalid_path_hint_address" }); + const left = sides[0] ? sides[0].split(":") : []; + const right = sides.length === 2 && sides[1] ? sides[1].split(":") : []; + const expand = (groups: string[]): number[] => groups.flatMap((group) => { + if (!/^[0-9a-f]{1,4}$/.test(group)) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_address" }); + } + const value = Number.parseInt(group, 16); + return [(value >> 8) & 0xff, value & 0xff]; + }); + const missingGroups = 8 - left.length - right.length; + if ((sides.length === 1 && missingGroups !== 0) || missingGroups < (sides.length === 2 ? 1 : 0)) { + throw new IrohInvalidInputError({ code: "invalid_path_hint_address" }); + } + return [ + ...expand(left), + ...Array.from({ length: missingGroups * 2 }, () => 0), + ...expand(right), + ]; +} + +function canonicalIpv6(bytes: readonly number[]): string { + const groups = Array.from({ length: 8 }, (_, index) => + ((bytes[index * 2]! << 8) | bytes[index * 2 + 1]!).toString(16)); + let bestStart = -1; + let bestLength = 0; + for (let start = 0; start < groups.length;) { + if (groups[start] !== "0") { + start += 1; + continue; + } + let end = start; + while (end < groups.length && groups[end] === "0") end += 1; + if (end - start > bestLength && end - start >= 2) { + bestStart = start; + bestLength = end - start; + } + start = end; + } + if (bestStart < 0) return groups.join(":"); + const left = groups.slice(0, bestStart).join(":"); + const right = groups.slice(bestStart + bestLength).join(":"); + if (!left && !right) return "::"; + if (!left) return `::${right}`; + if (!right) return `${left}::`; + return `${left}::${right}`; +} + +function bytesEqual(left: readonly number[], right: readonly number[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function routeContractVersion(value: unknown): typeof IROH_ROUTE_CONTRACT_VERSION { + if (value !== IROH_ROUTE_CONTRACT_VERSION) { + throw new IrohInvalidInputError({ code: "unsupported_route_contract" }); + } + return IROH_ROUTE_CONTRACT_VERSION; +} + +export function endpointId(value: unknown): string { + if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) { + throw new IrohInvalidInputError({ code: "invalid_endpoint_id" }); + } + return value; +} + +export function sha256Hex(value: unknown, code: string): string { + if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) { + throw new IrohInvalidInputError({ code }); + } + return value; +} + +export function sha256(value: Uint8Array | string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function base64url(value: unknown, decodedLength: number, code: string): string { + if (typeof value !== "string" || value.length > decodedLength * 2 || !/^[A-Za-z0-9_-]+$/.test(value)) { + throw new IrohInvalidInputError({ code }); + } + const decoded = Buffer.from(value, "base64url"); + if (decoded.byteLength !== decodedLength || decoded.toString("base64url") !== value) { + throw new IrohInvalidInputError({ code }); + } + return value; +} + +function uuid(value: unknown, code: string): string { + if (typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) { + throw new IrohInvalidInputError({ code }); + } + return value.toLowerCase(); +} + +function positiveInteger(value: unknown, code: string): number { + if ( + !Number.isSafeInteger(value) || + (value as number) < 1 || + (value as number) > POSTGRES_INT32_MAX + ) throw new IrohInvalidInputError({ code }); + return value as number; +} + +function boundedString(value: unknown, min: number, max: number, code: string): string { + if (typeof value !== "string" || value.length < min || value.length > max) { + throw new IrohInvalidInputError({ code }); + } + return value; +} + +function safeTag(value: unknown): string { + const tag = boundedString(value, 1, 64, "invalid_tag"); + if (!/^[A-Za-z0-9._-]+$/.test(tag)) throw new IrohInvalidInputError({ code: "invalid_tag" }); + return tag; +} + +function safeDisplayName(value: unknown): string { + const displayName = boundedString(value, 1, 128, "invalid_display_name"); + if (/[\u0000-\u001f\u007f]/.test(displayName)) { + throw new IrohInvalidInputError({ code: "invalid_display_name" }); + } + return displayName; +} + +function boolean(value: unknown, code: string): boolean { + if (typeof value !== "boolean") throw new IrohInvalidInputError({ code }); + return value; +} + +function record(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new IrohInvalidInputError({ code: "invalid_json" }); + } + return value as Record; +} + +function rejectUnknownKeys(value: Record, allowed: readonly string[]): void { + const allow = new Set(allowed); + if (Object.keys(value).some((key) => !allow.has(key))) { + throw new IrohInvalidInputError({ code: "unknown_field" }); + } +} + +function oneOf(value: unknown, allowed: T, code: string): T[number] { + if (typeof value !== "string" || !allowed.includes(value)) throw new IrohInvalidInputError({ code }); + return value as T[number]; +} diff --git a/web/services/iroh/publicationPolicy.ts b/web/services/iroh/publicationPolicy.ts new file mode 100644 index 000000000000..89e757264134 --- /dev/null +++ b/web/services/iroh/publicationPolicy.ts @@ -0,0 +1,90 @@ +/** + * Server publication policy for Iroh rendezvous data. + * + * Direct addresses, private-network coordinates, and opaque relay hints stay on + * the endpoint. Server surfaces may persist or distribute only a verified Iroh + * EndpointID and an exact managed relay URL. Non-Iroh routes remain opaque for + * compatibility with the existing LAN, Tailscale, and custom-network paths. + */ + +export const MANAGED_RELAY_URLS = [ + "https://usc1.relay.cmux.dev/", + "https://usw1.relay.cmux.dev/", + "https://use4.relay.cmux.dev/", + "https://euw4.relay.cmux.dev/", + "https://apne1.relay.cmux.dev/", + "https://apse1.relay.cmux.dev/", + "https://ape1.relay.cmux.dev/", +] as const; + +const MANAGED_RELAY_URL_SET: ReadonlySet = new Set(MANAGED_RELAY_URLS); +const ENDPOINT_ID_RE = /^[0-9a-f]{64}$/; +const MAX_ROUTE_ID_LENGTH = 256; + +type PathHintLike = { + readonly kind: string; + readonly value: string; +}; + +/** Keep only endpoint-reported managed relay URLs for server persistence. */ +export function serverPublishedIrohPathHints( + hints: readonly T[], +): T[] { + return hints.filter((hint) => + hint.kind === "relay_url" && MANAGED_RELAY_URL_SET.has(hint.value)); +} + +function plainRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function sanitizeIrohRoute(route: Record): Record | null { + const routeId = typeof route.id === "string" ? route.id : ""; + const endpoint = plainRecord(route.endpoint); + const endpointId = endpoint?.id; + if ( + routeId.length === 0 || + routeId.length > MAX_ROUTE_ID_LENGTH || + endpoint?.type !== "peer" || + typeof endpointId !== "string" || + !ENDPOINT_ID_RE.test(endpointId) + ) { + return null; + } + + const publicEndpoint: Record = { type: "peer", id: endpointId }; + if (typeof endpoint.relay_url === "string" && MANAGED_RELAY_URL_SET.has(endpoint.relay_url)) { + publicEndpoint.relay_url = endpoint.relay_url; + } + + const published: Record = { + id: routeId, + kind: "iroh", + endpoint: publicEndpoint, + }; + if (typeof route.priority === "number" && Number.isSafeInteger(route.priority)) { + published.priority = route.priority; + } + return published; +} + +/** + * Sanitize route bodies before persistence or publication. Invalid Iroh routes + * are dropped because their EndpointID cannot be authenticated or dialed. + */ +export function sanitizeServerPublishedRoutes(routes: readonly unknown[]): Record[] { + const published: Record[] = []; + for (const value of routes) { + const route = plainRecord(value); + if (!route) continue; + if (route.kind !== "iroh") { + published.push(route); + continue; + } + const sanitized = sanitizeIrohRoute(route); + if (sanitized) published.push(sanitized); + } + return published; +} diff --git a/web/services/iroh/relayMinter.ts b/web/services/iroh/relayMinter.ts new file mode 100644 index 000000000000..8bffeed91bf5 --- /dev/null +++ b/web/services/iroh/relayMinter.ts @@ -0,0 +1,207 @@ +import { createHash, createHmac } from "node:crypto"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { + IrohConfigurationError, + type IrohInvalidInputError, + IrohRelayMintError, +} from "./errors"; +import { IrohTrustBrokerConfig } from "./config"; +import { + IROH_RELAY_MINTER_PATH, + parseIrohMinterUrl, + type IrohMinterUrlPolicy, +} from "./minterUrlPolicy"; +import { IROH_RELAY_TOKEN_LIFETIME_SECONDS, endpointId } from "./model"; + +const MAX_MINTER_RESPONSE_BYTES = 32 * 1_024; +export { IROH_RELAY_MINTER_PATH }; + +export type IrohRelayMintResult = { + readonly token: string; + readonly expiresAt: Date; +}; + +export type IrohRelayMinterShape = { + readonly mint: (input: { + readonly endpointId: string; + readonly lifetimeSeconds: typeof IROH_RELAY_TOKEN_LIFETIME_SECONDS; + readonly now: Date; + }) => Effect.Effect< + IrohRelayMintResult, + IrohConfigurationError | IrohInvalidInputError | IrohRelayMintError + >; +}; + +export class IrohRelayMinter extends Context.Tag("cmux/IrohRelayMinter")< + IrohRelayMinter, + IrohRelayMinterShape +>() {} + +export const IrohRelayMinterLive = Layer.effect( + IrohRelayMinter, + Effect.gen(function* () { + const config = yield* IrohTrustBrokerConfig; + return { + mint: (input) => mintWithIsolatedService(config, input), + } satisfies IrohRelayMinterShape; + }), +); + +function mintWithIsolatedService( + config: typeof IrohTrustBrokerConfig.Service, + input: Parameters[0], +): Effect.Effect< + IrohRelayMintResult, + IrohConfigurationError | IrohInvalidInputError | IrohRelayMintError +> { + return Effect.tryPromise({ + try: async () => { + endpointId(input.endpointId); + const url = parseMinterUrl(config.relayMinterUrl, { + allowInsecureLoopback: config.relayMinterInsecureLoopbackOptIn, + deploymentEnvironment: config.deploymentEnvironment, + isVercelDeployment: config.isVercelDeployment, + }); + const secret = parseMinterHmacSecret(config.relayMinterHmacSecretBase64); + const body = JSON.stringify({ + endpointId: input.endpointId, + lifetimeSeconds: IROH_RELAY_TOKEN_LIFETIME_SECONDS, + }); + const timestamp = String(Math.floor(input.now.getTime() / 1_000)); + const bodyHash = createHash("sha256").update(body).digest("hex"); + const signature = createHmac("sha256", secret) + .update(`POST\n${url.pathname}\n${timestamp}\n${bodyHash}`, "utf8") + .digest("base64url"); + const response = await fetch(url, { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(10_000), + headers: { + "content-type": "application/json", + "x-cmux-iroh-timestamp": timestamp, + "x-cmux-iroh-signature": signature, + }, + body, + }); + if (!response.ok) throw new IrohRelayMintError({ code: "minter_rejected" }); + const raw = await readBoundedMinterJson(response); + if ( + typeof raw.token !== "string" || + raw.token.length < 16 || + raw.token.length > 16_384 || + !/^[a-z2-7]+$/.test(raw.token) + ) { + throw new IrohRelayMintError({ code: "invalid_minter_response" }); + } + if (typeof raw.expiresAt !== "string") throw new IrohRelayMintError({ code: "invalid_minter_response" }); + const expiresAt = new Date(raw.expiresAt); + const contractExpiry = input.now.getTime() + IROH_RELAY_TOKEN_LIFETIME_SECONDS * 1_000; + if ( + !Number.isFinite(expiresAt.getTime()) || + expiresAt <= input.now || + expiresAt.getTime() > contractExpiry + 60_000 || + expiresAt.getTime() < contractExpiry - 5 * 60_000 + ) { + throw new IrohRelayMintError({ code: "invalid_minter_expiry" }); + } + return { token: raw.token, expiresAt }; + }, + catch: (cause) => { + if ((cause as { _tag?: unknown } | null)?._tag === "IrohConfigurationError") { + return cause as IrohConfigurationError; + } + if ((cause as { _tag?: unknown } | null)?._tag === "IrohInvalidInputError") { + return cause as IrohInvalidInputError; + } + if ((cause as { _tag?: unknown } | null)?._tag === "IrohRelayMintError") { + return cause as IrohRelayMintError; + } + return new IrohRelayMintError({ code: "minter_unavailable", cause: safeCause(cause) }); + }, + }); +} + +export async function readBoundedMinterJson( + response: Response, +): Promise<{ token?: unknown; expiresAt?: unknown }> { + if ( + response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== + "application/json" + ) { + throw new IrohRelayMintError({ code: "invalid_minter_response" }); + } + const contentLength = response.headers.get("content-length"); + if (contentLength) { + const parsed = Number(contentLength); + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > MAX_MINTER_RESPONSE_BYTES) { + throw new IrohRelayMintError({ code: "minter_response_too_large" }); + } + } + const reader = response.body?.getReader(); + if (!reader) throw new IrohRelayMintError({ code: "invalid_minter_response" }); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAX_MINTER_RESPONSE_BYTES) { + await reader.cancel(); + throw new IrohRelayMintError({ code: "minter_response_too_large" }); + } + chunks.push(next.value); + } + const bytes = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total); + let parsed: unknown; + try { + parsed = JSON.parse(bytes.toString("utf8")); + } catch { + throw new IrohRelayMintError({ code: "invalid_minter_response" }); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new IrohRelayMintError({ code: "invalid_minter_response" }); + } + const object = parsed as Record; + const keys = Object.keys(object); + if (keys.length !== 2 || !keys.includes("token") || !keys.includes("expiresAt")) { + throw new IrohRelayMintError({ code: "invalid_minter_response" }); + } + return object; +} + +export function parseMinterUrl( + value: string | undefined, + policy: IrohMinterUrlPolicy = { + allowInsecureLoopback: false, + deploymentEnvironment: "production", + isVercelDeployment: true, + }, +): URL { + if (!value) throw new IrohConfigurationError({ component: "relay_minter" }); + try { + return parseIrohMinterUrl(value, policy); + } catch { + throw new IrohConfigurationError({ component: "relay_minter" }); + } +} + +export function parseMinterHmacSecret(value: string | undefined): Buffer { + if (!value || value.length > 512) throw new IrohConfigurationError({ component: "relay_minter" }); + const decoded = Buffer.from(value, "base64"); + const canonicalPadded = decoded.toString("base64"); + const canonicalUnpadded = canonicalPadded.replace(/=+$/, ""); + if ( + decoded.byteLength < 32 || + decoded.byteLength > 256 || + (value !== canonicalPadded && value !== canonicalUnpadded) + ) { + throw new IrohConfigurationError({ component: "relay_minter" }); + } + return decoded; +} + +function safeCause(cause: unknown): unknown { + return cause instanceof Error ? { name: cause.name } : { type: typeof cause }; +} diff --git a/web/services/iroh/repository.ts b/web/services/iroh/repository.ts new file mode 100644 index 000000000000..f8b9664cee0f --- /dev/null +++ b/web/services/iroh/repository.ts @@ -0,0 +1,1276 @@ +import { and, asc, count, eq, gt, inArray, isNull, lte, ne, or, sql } from "drizzle-orm"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { cloudDb } from "../../db/client"; +import { + AccountDeletionMutationBlockedError, + assertAccountDeletionUserMutationAllowed, +} from "../account/deletionLock"; +import { + irohAccountSecurityStates, + irohEndpointBindings, + irohPairGrantIssuances, + irohRegistrationChallenges, + irohRelayTokenIssuances, +} from "../../db/schema"; +import { + IrohConflictError, + IrohDatabaseError, + IrohForbiddenError, + IrohNotFoundError, + IrohQuotaExceededError, +} from "./errors"; +import type { PairGrantPeer } from "./crypto"; +import { + nextPathHintExpiry, + parseIrohPathHint, + sha256, + type IrohPathHint, + type IrohRegistrationPayload, +} from "./model"; +import { serverPublishedIrohPathHints } from "./publicationPolicy"; + +export const IROH_RETENTION_BATCH_SIZE = 500; +export const IROH_RETENTION_MAX_ROWS = 10_000; +export const IROH_RETENTION_MAX_DURATION_MS = 8_000; +export const IROH_ACCOUNT_CHALLENGE_LIMIT = 120; +export const IROH_RELAY_RESERVATION_LEASE_MS = 60 * 1_000; + +export type IrohRetentionCategory = + | "revokedHints" + | "expiredHints" + | "expiredChallenges" + | "consumedChallenges" + | "relayAudits" + | "pairGrantAudits" + | "revokedBindings"; + +export type IrohRetentionResult = { + readonly rowsProcessed: number; + readonly batches: number; + readonly backlog: boolean; + readonly budgetExhausted: "rows" | "time" | null; + readonly byCategory: Readonly>; +}; + +export type IrohBindingRecord = typeof irohEndpointBindings.$inferSelect; +export type IrohChallengeRecord = typeof irohRegistrationChallenges.$inferSelect; +export type IrohRegistrationCommit = { + readonly binding: IrohBindingRecord; + readonly created: boolean; +}; +type CloudDbTransaction = Parameters["transaction"]>[0]>[0]; + +type RepositoryError = + | IrohDatabaseError + | IrohForbiddenError + | IrohNotFoundError + | IrohConflictError + | IrohQuotaExceededError; + +export type IrohRepositoryShape = { + readonly issueChallenge: (input: { + readonly userId: string; + readonly deviceUuid: string; + readonly appInstanceId: string; + readonly tag: string; + readonly endpointId: string; + readonly identityGeneration: number; + readonly payloadSha256: string; + readonly nonceHash: string; + readonly now: Date; + readonly expiresAt: Date; + }) => Effect.Effect; + readonly findChallenge: ( + userId: string, + challengeId: string, + ) => Effect.Effect; + readonly consumeChallengeAndRegister: (input: { + readonly userId: string; + readonly challengeId: string; + readonly nonceHash: string; + readonly payload: IrohRegistrationPayload; + readonly now: Date; + readonly deviceLimitOverrideAllowed: boolean; + }) => Effect.Effect; + readonly discoverySnapshot: (input: { + readonly userId: string; + readonly now: Date; + }) => Effect.Effect<{ + readonly bindings: IrohBindingRecord[]; + readonly lanDiscoveryGeneration: number; + }, RepositoryError>; + readonly findActiveBindings: ( + userId: string, + bindingIds: readonly string[], + ) => Effect.Effect; + /** Returns true when the exact binding is owned and revoked, including retries. */ + readonly revokeBinding: (input: { + readonly userId: string; + readonly bindingId: string; + readonly now: Date; + }) => Effect.Effect; + readonly pruneExpiredState: (input: { + readonly userId: string; + readonly now: Date; + }) => Effect.Effect; + readonly pruneExpiredStateGlobally: (input: { + readonly now: Date; + readonly maxRows?: number; + readonly maxDurationMs?: number; + }) => Effect.Effect; + readonly finalizeEndpointAttestation: (input: { + readonly userId: string; + readonly bindingId: string; + readonly deviceId: string; + readonly endpointId: string; + readonly identityGeneration: number; + readonly platform: "mac" | "ios"; + }) => Effect.Effect; + readonly recordPairGrant: (input: { + readonly userId: string; + readonly jti: string; + readonly initiator: PairGrantPeer; + readonly acceptor: PairGrantPeer; + readonly signingKeyId: string; + readonly alpn: string; + readonly scope: string; + readonly issuedAt: Date; + readonly notBefore: Date; + readonly expiresAt: Date; + }) => Effect.Effect; + readonly reserveRelayIssuance: (input: { + readonly userId: string; + readonly bindingId: string; + readonly now: Date; + }) => Effect.Effect<{ + readonly issuanceId: string; + readonly binding: IrohBindingRecord; + }, RepositoryError>; + readonly completeRelayIssuance: (input: { + readonly userId: string; + readonly issuanceId: string; + readonly bindingId: string; + readonly endpointId: string; + readonly tokenHash: string; + readonly completedAt: Date; + readonly expiresAt: Date; + }) => Effect.Effect; + readonly failRelayIssuance: (input: { + readonly userId: string; + readonly issuanceId: string; + readonly completedAt: Date; + readonly failureCode: string; + }) => Effect.Effect; +}; + +export class IrohRepository extends Context.Tag("cmux/IrohRepository")< + IrohRepository, + IrohRepositoryShape +>() {} + +export const IrohRepositoryLive = Layer.succeed(IrohRepository, makeLiveRepository()); + +function makeLiveRepository(): IrohRepositoryShape { + return { + issueChallenge: (input) => repositoryEffect("issue_challenge", async () => { + const db = cloudDb(); + return await db.transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:challenge:${input.userId}`}, 0))`); + const tenMinutesAgo = new Date(input.now.getTime() - 10 * 60 * 1_000); + const [recentForAccount] = await tx + .select({ total: count() }) + .from(irohRegistrationChallenges) + .where(and( + eq(irohRegistrationChallenges.userId, input.userId), + gt(irohRegistrationChallenges.createdAt, tenMinutesAgo), + )); + if ((recentForAccount?.total ?? 0) >= IROH_ACCOUNT_CHALLENGE_LIMIT) { + throw new IrohQuotaExceededError({ + code: "challenge_account_rate_limited", + retryAfterSeconds: 600, + }); + } + const [recentForDevice] = await tx + .select({ total: count() }) + .from(irohRegistrationChallenges) + .where(and( + eq(irohRegistrationChallenges.userId, input.userId), + eq(irohRegistrationChallenges.deviceUuid, input.deviceUuid), + gt(irohRegistrationChallenges.createdAt, tenMinutesAgo), + )); + if ((recentForDevice?.total ?? 0) >= 6) { + throw new IrohQuotaExceededError({ code: "challenge_rate_limited", retryAfterSeconds: 600 }); + } + const [outstanding] = await tx + .select({ total: count() }) + .from(irohRegistrationChallenges) + .where(and( + eq(irohRegistrationChallenges.userId, input.userId), + isNull(irohRegistrationChallenges.consumedAt), + gt(irohRegistrationChallenges.expiresAt, input.now), + )); + if ((outstanding?.total ?? 0) >= 32) { + throw new IrohQuotaExceededError({ code: "too_many_outstanding_challenges", retryAfterSeconds: 300 }); + } + const [challenge] = await tx + .insert(irohRegistrationChallenges) + .values({ + userId: input.userId, + deviceUuid: input.deviceUuid, + appInstanceId: input.appInstanceId, + tag: input.tag, + endpointId: input.endpointId, + identityGeneration: input.identityGeneration, + payloadSha256: input.payloadSha256, + nonceHash: input.nonceHash, + createdAt: input.now, + expiresAt: input.expiresAt, + }) + .returning(); + if (!challenge) throw new Error("challenge insert returned no row"); + return challenge; + }); + }), + + findChallenge: (userId, challengeId) => repositoryEffect("find_challenge", async () => { + const [challenge] = await cloudDb() + .select() + .from(irohRegistrationChallenges) + .where(and( + eq(irohRegistrationChallenges.id, challengeId), + eq(irohRegistrationChallenges.userId, userId), + )) + .limit(1); + return challenge ?? null; + }), + + consumeChallengeAndRegister: (input) => repositoryEffect("register_binding", async () => { + const db = cloudDb(); + return await db.transaction(async (tx) => { + const publishedPathHints = serverPublishedIrohPathHints(input.payload.pathHints); + await assertIrohUserMutationAllowed(tx, input.userId); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:endpoint:${input.payload.endpointId}`}, 0))`); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:app:${input.payload.appInstanceId}`}, 0))`); + const [challenge] = await tx + .select() + .from(irohRegistrationChallenges) + .where(and( + eq(irohRegistrationChallenges.id, input.challengeId), + eq(irohRegistrationChallenges.userId, input.userId), + )) + .for("update") + .limit(1); + if (!challenge) throw new IrohNotFoundError({ resource: "challenge" }); + if (challenge.consumedAt) throw new IrohConflictError({ code: "challenge_replayed" }); + if (challenge.expiresAt <= input.now) throw new IrohForbiddenError({ code: "challenge_expired" }); + if (challenge.nonceHash !== input.nonceHash) throw new IrohForbiddenError({ code: "invalid_challenge_nonce" }); + + const [existingApp] = await tx + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.appInstanceId, input.payload.appInstanceId), + isNull(irohEndpointBindings.revokedAt), + )) + .for("update") + .limit(1); + + if (existingApp) { + if ( + existingApp.userId !== input.userId || + existingApp.endpointId !== input.payload.endpointId || + existingApp.identityGeneration !== input.payload.identityGeneration || + existingApp.deviceUuid !== input.payload.deviceId || + existingApp.tag !== input.payload.tag || + existingApp.platform !== input.payload.platform + ) { + throw new IrohConflictError({ code: "binding_replacement_requires_revocation" }); + } + const [updated] = await tx + .update(irohEndpointBindings) + .set({ + displayName: input.payload.displayName ?? null, + pairingEnabled: input.payload.pairingEnabled, + capabilities: [...input.payload.capabilities], + pathHints: publishedPathHints, + pathHintsNextExpiry: nextPathHintExpiry(publishedPathHints), + lastSeenAt: input.now, + updatedAt: input.now, + }) + .where(eq(irohEndpointBindings.id, existingApp.id)) + .returning(); + await tx + .update(irohRegistrationChallenges) + .set({ consumedAt: input.now }) + .where(eq(irohRegistrationChallenges.id, challenge.id)); + if (!updated) throw new Error("binding update returned no row"); + return { binding: updated, created: false }; + } + + const [endpointOwner] = await tx + .select({ id: irohEndpointBindings.id }) + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.endpointId, input.payload.endpointId), + isNull(irohEndpointBindings.revokedAt), + )) + .for("update") + .limit(1); + if (endpointOwner) throw new IrohConflictError({ code: "endpoint_already_bound" }); + + const [userTotal] = await tx + .select({ total: count() }) + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.userId, input.userId), + isNull(irohEndpointBindings.revokedAt), + )); + if ((userTotal?.total ?? 0) >= 32) { + throw new IrohQuotaExceededError({ code: "too_many_bindings", retryAfterSeconds: 86_400 }); + } + const [deviceTotal] = await tx + .select({ total: count() }) + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.userId, input.userId), + eq(irohEndpointBindings.deviceUuid, input.payload.deviceId), + isNull(irohEndpointBindings.revokedAt), + )); + const usesDeviceOverride = (deviceTotal?.total ?? 0) >= 8; + if (usesDeviceOverride && !input.deviceLimitOverrideAllowed) { + throw new IrohQuotaExceededError({ code: "too_many_device_bindings", retryAfterSeconds: 86_400 }); + } + + const [binding] = await tx + .insert(irohEndpointBindings) + .values({ + userId: input.userId, + deviceUuid: input.payload.deviceId, + appInstanceId: input.payload.appInstanceId, + tag: input.payload.tag, + platform: input.payload.platform, + displayName: input.payload.displayName ?? null, + endpointId: input.payload.endpointId, + identityGeneration: input.payload.identityGeneration, + pairingEnabled: input.payload.pairingEnabled, + capabilities: [...input.payload.capabilities], + pathHints: publishedPathHints, + pathHintsNextExpiry: nextPathHintExpiry(publishedPathHints), + deviceLimitOverrideUsed: usesDeviceOverride, + lastSeenAt: input.now, + registeredAt: input.now, + updatedAt: input.now, + }) + .returning(); + if (!binding) throw new Error("binding insert returned no row"); + await tx + .insert(irohAccountSecurityStates) + .values({ userId: input.userId, lanDiscoveryGeneration: 1, createdAt: input.now, updatedAt: input.now }) + .onConflictDoNothing({ target: irohAccountSecurityStates.userId }); + await tx + .update(irohRegistrationChallenges) + .set({ consumedAt: input.now }) + .where(and( + eq(irohRegistrationChallenges.id, challenge.id), + isNull(irohRegistrationChallenges.consumedAt), + )); + return { binding, created: true }; + }); + }), + + discoverySnapshot: (input) => repositoryEffect("discovery_snapshot", async () => { + return await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + const [state] = await tx + .insert(irohAccountSecurityStates) + .values({ userId: input.userId, lanDiscoveryGeneration: 1, createdAt: input.now, updatedAt: input.now }) + .onConflictDoUpdate({ + target: irohAccountSecurityStates.userId, + set: { updatedAt: sql`${irohAccountSecurityStates.updatedAt}` }, + }) + .returning({ generation: irohAccountSecurityStates.lanDiscoveryGeneration }); + if (!state) throw new Error("account security state returned no row"); + const bindings = await tx + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.userId, input.userId), + isNull(irohEndpointBindings.revokedAt), + )) + .orderBy(asc(irohEndpointBindings.registeredAt)); + return { + bindings, + lanDiscoveryGeneration: state.generation, + }; + }); + }), + + findActiveBindings: (userId, bindingIds) => repositoryEffect("find_bindings", async () => { + if (bindingIds.length === 0) return []; + return await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, userId); + return await tx + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.userId, userId), + inArray(irohEndpointBindings.id, [...bindingIds]), + isNull(irohEndpointBindings.revokedAt), + )); + }); + }), + + revokeBinding: (input) => repositoryEffect("revoke_binding", async () => { + return await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + const [binding] = await tx + .select({ revokedAt: irohEndpointBindings.revokedAt }) + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.id, input.bindingId), + eq(irohEndpointBindings.userId, input.userId), + )) + .for("update") + .limit(1); + if (!binding) return false; + if (binding.revokedAt) return true; + + const revoked = await tx + .update(irohEndpointBindings) + .set({ + revokedAt: input.now, + revokedReason: "user_requested", + pathHints: [], + pathHintsNextExpiry: null, + updatedAt: input.now, + }) + .where(and( + eq(irohEndpointBindings.id, input.bindingId), + eq(irohEndpointBindings.userId, input.userId), + isNull(irohEndpointBindings.revokedAt), + )) + .returning({ id: irohEndpointBindings.id }); + if (revoked.length === 0) return false; + await tx + .update(irohPairGrantIssuances) + .set({ revokedAt: input.now }) + .where(and( + isNull(irohPairGrantIssuances.revokedAt), + or( + eq(irohPairGrantIssuances.initiatorBindingId, input.bindingId), + eq(irohPairGrantIssuances.acceptorBindingId, input.bindingId), + ), + )); + await tx + .insert(irohAccountSecurityStates) + .values({ userId: input.userId, lanDiscoveryGeneration: 2, createdAt: input.now, updatedAt: input.now }) + .onConflictDoUpdate({ + target: irohAccountSecurityStates.userId, + set: { + lanDiscoveryGeneration: sql`${irohAccountSecurityStates.lanDiscoveryGeneration} + 1`, + updatedAt: input.now, + }, + }); + return true; + }); + }), + + pruneExpiredState: (input) => repositoryEffect("prune_expired_state", async () => { + await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + const bindings = await tx + .select({ + id: irohEndpointBindings.id, + pathHints: irohEndpointBindings.pathHints, + }) + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.userId, input.userId), + isNull(irohEndpointBindings.revokedAt), + lte(irohEndpointBindings.pathHintsNextExpiry, input.now), + )) + .limit(IROH_RETENTION_BATCH_SIZE) + .for("update"); + for (const binding of bindings) { + const retained = retainedStoredHints(binding.pathHints, input.now); + await tx + .update(irohEndpointBindings) + .set({ + pathHints: retained, + pathHintsNextExpiry: nextPathHintExpiry(retained), + updatedAt: input.now, + }) + .where(eq(irohEndpointBindings.id, binding.id)); + } + + const challengeRetentionCutoff = new Date(input.now.getTime() - 24 * 60 * 60 * 1_000); + const auditRetentionCutoff = new Date(input.now.getTime() - 30 * 24 * 60 * 60 * 1_000); + await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_registration_challenges + where user_id = ${input.userId} + and expires_at < ${challengeRetentionCutoff.toISOString()}::timestamptz + order by expires_at, id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ) + delete from iroh_registration_challenges as challenge + using candidates + where challenge.id = candidates.id + `); + await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_registration_challenges + where user_id = ${input.userId} + and consumed_at < ${challengeRetentionCutoff.toISOString()}::timestamptz + order by consumed_at, id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ) + delete from iroh_registration_challenges as challenge + using candidates + where challenge.id = candidates.id + `); + await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_relay_token_issuances + where user_id = ${input.userId} + and requested_at < ${auditRetentionCutoff.toISOString()}::timestamptz + order by requested_at, id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ) + delete from iroh_relay_token_issuances as issuance + using candidates + where issuance.id = candidates.id + `); + await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_pair_grant_issuances + where user_id = ${input.userId} + and expires_at < ${auditRetentionCutoff.toISOString()}::timestamptz + order by expires_at, id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ) + delete from iroh_pair_grant_issuances as issuance + using candidates + where issuance.id = candidates.id + `); + await tx.execute(sql` + with candidates as materialized ( + select binding.id + from iroh_endpoint_bindings as binding + where binding.user_id = ${input.userId} + and binding.revoked_at < ${auditRetentionCutoff.toISOString()}::timestamptz + and not exists ( + select 1 from iroh_pair_grant_issuances as pair_grant + where pair_grant.initiator_binding_id = binding.id + or pair_grant.acceptor_binding_id = binding.id + ) + and not exists ( + select 1 from iroh_relay_token_issuances as issuance + where issuance.binding_id = binding.id + ) + order by binding.revoked_at, binding.id + limit ${IROH_RETENTION_BATCH_SIZE} + for update skip locked + ) + delete from iroh_endpoint_bindings as binding + using candidates + where binding.id = candidates.id + `); + }); + }), + + pruneExpiredStateGlobally: (input) => repositoryEffect( + "prune_expired_state_globally", + () => drainIrohRetention(input), + ), + + finalizeEndpointAttestation: (input) => repositoryEffect("finalize_endpoint_attestation", async () => { + await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + const [binding] = await tx + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.id, input.bindingId), + eq(irohEndpointBindings.userId, input.userId), + isNull(irohEndpointBindings.revokedAt), + )) + .for("update") + .limit(1); + if (!binding) throw new IrohNotFoundError({ resource: "binding" }); + if ( + binding.deviceUuid !== input.deviceId || + binding.endpointId !== input.endpointId || + binding.identityGeneration !== input.identityGeneration || + binding.platform !== input.platform + ) { + throw new IrohConflictError({ code: "binding_changed_during_attestation" }); + } + }); + }), + + recordPairGrant: (input) => repositoryEffect("record_pair_grant", async () => { + await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:pair-grant:${input.userId}`}, 0))`); + const peers = await tx + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.userId, input.userId), + inArray(irohEndpointBindings.id, [input.initiator.bindingId, input.acceptor.bindingId]), + isNull(irohEndpointBindings.revokedAt), + )) + .for("update"); + const byId = new Map(peers.map((peer) => [peer.id, peer])); + const initiator = byId.get(input.initiator.bindingId); + const acceptor = byId.get(input.acceptor.bindingId); + if (!initiator || !acceptor) throw new IrohNotFoundError({ resource: "binding" }); + if ( + !bindingMatchesGrantPeer(initiator, input.initiator) || + !bindingMatchesGrantPeer(acceptor, input.acceptor) + ) { + throw new IrohConflictError({ code: "binding_changed_during_grant" }); + } + if (initiator.deviceUuid === acceptor.deviceUuid) { + throw new IrohForbiddenError({ code: "pair_grant_same_device" }); + } + if (initiator.platform !== "ios" || acceptor.platform !== "mac" || !acceptor.pairingEnabled) { + throw new IrohForbiddenError({ code: "target_not_pairable" }); + } + const hourAgo = new Date(input.issuedAt.getTime() - 60 * 60 * 1_000); + const recent = await tx + .select({ issuedAt: irohPairGrantIssuances.issuedAt }) + .from(irohPairGrantIssuances) + .where(and( + eq(irohPairGrantIssuances.userId, input.userId), + gt(irohPairGrantIssuances.issuedAt, hourAgo), + )) + .orderBy(asc(irohPairGrantIssuances.issuedAt)); + if (recent.length >= 60) { + throw quotaFromOldest( + "pair_grant_hour_quota", + recent[recent.length - 60]!.issuedAt, + 60 * 60, + input.issuedAt, + ); + } + await tx.insert(irohPairGrantIssuances).values({ + userId: input.userId, + jti: input.jti, + initiatorBindingId: input.initiator.bindingId, + acceptorBindingId: input.acceptor.bindingId, + signingKeyId: input.signingKeyId, + alpn: input.alpn, + scope: input.scope, + issuedAt: input.issuedAt, + notBefore: input.notBefore, + expiresAt: input.expiresAt, + }); + }); + }), + + reserveRelayIssuance: (input) => repositoryEffect("reserve_relay_issuance", async () => { + return await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:relay:${input.userId}`}, 0))`); + const [binding] = await tx + .select() + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.id, input.bindingId), + eq(irohEndpointBindings.userId, input.userId), + isNull(irohEndpointBindings.revokedAt), + )) + .for("update") + .limit(1); + if (!binding) throw new IrohNotFoundError({ resource: "binding" }); + + const reservationCutoff = new Date( + input.now.getTime() - IROH_RELAY_RESERVATION_LEASE_MS, + ); + await tx + .update(irohRelayTokenIssuances) + .set({ + status: "expired", + completedAt: input.now, + failureCode: "reservation_expired", + }) + .where(and( + eq(irohRelayTokenIssuances.userId, input.userId), + eq(irohRelayTokenIssuances.status, "pending"), + lte(irohRelayTokenIssuances.requestedAt, reservationCutoff), + )); + + const dayAgo = new Date(input.now.getTime() - 24 * 60 * 60 * 1_000); + const tenMinutesAgo = new Date(input.now.getTime() - 10 * 60 * 1_000); + const endpointRows = await tx + .select({ requestedAt: irohRelayTokenIssuances.requestedAt }) + .from(irohRelayTokenIssuances) + .where(and( + eq(irohRelayTokenIssuances.bindingId, binding.id), + ne(irohRelayTokenIssuances.status, "expired"), + gt(irohRelayTokenIssuances.requestedAt, dayAgo), + )) + .orderBy(asc(irohRelayTokenIssuances.requestedAt)); + const recentRows = endpointRows.filter((row) => row.requestedAt > tenMinutesAgo); + if (recentRows.length >= 3) { + throw quotaFromOldest("relay_endpoint_10m_quota", recentRows[recentRows.length - 3]!.requestedAt, 10 * 60, input.now); + } + if (endpointRows.length >= 12) { + throw quotaFromOldest("relay_endpoint_day_quota", endpointRows[endpointRows.length - 12]!.requestedAt, 24 * 60 * 60, input.now); + } + const userRows = await tx + .select({ requestedAt: irohRelayTokenIssuances.requestedAt }) + .from(irohRelayTokenIssuances) + .where(and( + eq(irohRelayTokenIssuances.userId, input.userId), + ne(irohRelayTokenIssuances.status, "expired"), + gt(irohRelayTokenIssuances.requestedAt, dayAgo), + )) + .orderBy(asc(irohRelayTokenIssuances.requestedAt)); + if (userRows.length >= 100) { + throw quotaFromOldest("relay_user_day_quota", userRows[userRows.length - 100]!.requestedAt, 24 * 60 * 60, input.now); + } + + const [issuance] = await tx + .insert(irohRelayTokenIssuances) + .values({ + userId: input.userId, + bindingId: binding.id, + endpointIdHash: sha256(binding.endpointId), + status: "pending", + requestedAt: input.now, + }) + .returning({ id: irohRelayTokenIssuances.id }); + if (!issuance) throw new Error("relay issuance insert returned no row"); + return { issuanceId: issuance.id, binding }; + }); + }), + + completeRelayIssuance: (input) => repositoryEffect("complete_relay_issuance", async () => { + return await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${input.userId}`}, 0))`); + const [issuance] = await tx + .select() + .from(irohRelayTokenIssuances) + .where(and( + eq(irohRelayTokenIssuances.id, input.issuanceId), + eq(irohRelayTokenIssuances.userId, input.userId), + eq(irohRelayTokenIssuances.bindingId, input.bindingId), + eq(irohRelayTokenIssuances.status, "pending"), + )) + .for("update") + .limit(1); + if (!issuance) return false; + const [binding] = await tx + .select({ endpointId: irohEndpointBindings.endpointId }) + .from(irohEndpointBindings) + .where(and( + eq(irohEndpointBindings.id, input.bindingId), + eq(irohEndpointBindings.userId, input.userId), + isNull(irohEndpointBindings.revokedAt), + )) + .for("update") + .limit(1); + if ( + !binding || + binding.endpointId !== input.endpointId || + issuance.endpointIdHash !== sha256(input.endpointId) + ) { + await tx + .update(irohRelayTokenIssuances) + .set({ + status: "failed", + completedAt: input.completedAt, + failureCode: "binding_inactive_after_mint", + }) + .where(eq(irohRelayTokenIssuances.id, input.issuanceId)); + return false; + } + const completed = await tx + .update(irohRelayTokenIssuances) + .set({ + status: "succeeded", + tokenHash: input.tokenHash, + completedAt: input.completedAt, + expiresAt: input.expiresAt, + failureCode: null, + }) + .where(and( + eq(irohRelayTokenIssuances.id, input.issuanceId), + eq(irohRelayTokenIssuances.status, "pending"), + )) + .returning({ id: irohRelayTokenIssuances.id }); + return completed.length === 1; + }); + }), + + failRelayIssuance: (input) => repositoryEffect("fail_relay_issuance", async () => { + await cloudDb().transaction(async (tx) => { + await assertIrohUserMutationAllowed(tx, input.userId); + await tx + .update(irohRelayTokenIssuances) + .set({ status: "failed", completedAt: input.completedAt, failureCode: input.failureCode.slice(0, 64) }) + .where(and( + eq(irohRelayTokenIssuances.id, input.issuanceId), + eq(irohRelayTokenIssuances.userId, input.userId), + eq(irohRelayTokenIssuances.status, "pending"), + )); + }); + }), + }; +} + +type RetentionBatchOperation = { + readonly category: IrohRetentionCategory; + readonly run: (limit: number) => Promise; +}; + +async function drainIrohRetention(input: { + readonly now: Date; + readonly maxRows?: number; + readonly maxDurationMs?: number; +}): Promise { + const maxRows = retentionBudget( + input.maxRows, + IROH_RETENTION_MAX_ROWS, + 100_000, + "maxRows", + ); + const maxDurationMs = retentionBudget( + input.maxDurationMs, + IROH_RETENTION_MAX_DURATION_MS, + 30_000, + "maxDurationMs", + ); + const challengeRetentionCutoff = new Date(input.now.getTime() - 24 * 60 * 60 * 1_000); + const auditRetentionCutoff = new Date(input.now.getTime() - 30 * 24 * 60 * 60 * 1_000); + const nowIso = input.now.toISOString(); + const challengeCutoffIso = challengeRetentionCutoff.toISOString(); + const auditCutoffIso = auditRetentionCutoff.toISOString(); + const operations: readonly RetentionBatchOperation[] = [ + { + category: "revokedHints", + run: (limit) => runRetentionBatch(async (tx) => await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_endpoint_bindings + where revoked_at is not null + and path_hints_next_expiry is not null + order by revoked_at, id + limit ${limit} + for update skip locked + ), changed as ( + update iroh_endpoint_bindings as binding + set path_hints = '[]'::jsonb, + path_hints_next_expiry = null, + updated_at = ${nowIso}::timestamptz + from candidates + where binding.id = candidates.id + returning binding.id + ) + select count(*)::int as affected from changed + `)), + }, + { + category: "expiredHints", + run: (limit) => runRetentionBatch(async (tx) => await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_endpoint_bindings + where revoked_at is null + and path_hints_next_expiry <= ${nowIso}::timestamptz + order by path_hints_next_expiry, id + limit ${limit} + for update skip locked + ), retained as ( + select + binding.id, + coalesce( + jsonb_agg(entry.hint order by entry.ordinality) filter ( + where case + when jsonb_typeof(entry.hint) = 'object' + and jsonb_typeof(entry.hint -> 'expires_at') = 'string' + and (entry.hint ->> 'expires_at') ~ '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$' + then (entry.hint ->> 'expires_at')::timestamptz > ${nowIso}::timestamptz + else false + end + ), + '[]'::jsonb + ) as path_hints + from candidates + join iroh_endpoint_bindings as binding on binding.id = candidates.id + left join lateral jsonb_array_elements(binding.path_hints) + with ordinality as entry(hint, ordinality) on true + group by binding.id + ), normalized as ( + select + retained.id, + retained.path_hints, + ( + select min((hint ->> 'expires_at')::timestamptz) + from jsonb_array_elements(retained.path_hints) as hints(hint) + ) as next_expiry + from retained + ), changed as ( + update iroh_endpoint_bindings as binding + set path_hints = normalized.path_hints, + path_hints_next_expiry = normalized.next_expiry, + updated_at = ${nowIso}::timestamptz + from normalized + where binding.id = normalized.id + returning binding.id + ) + select count(*)::int as affected from changed + `)), + }, + { + category: "expiredChallenges", + run: (limit) => runRetentionBatch(async (tx) => await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_registration_challenges + where expires_at < ${challengeCutoffIso}::timestamptz + order by expires_at, id + limit ${limit} + for update skip locked + ), changed as ( + delete from iroh_registration_challenges as challenge + using candidates + where challenge.id = candidates.id + returning challenge.id + ) + select count(*)::int as affected from changed + `)), + }, + { + category: "consumedChallenges", + run: (limit) => runRetentionBatch(async (tx) => await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_registration_challenges + where consumed_at < ${challengeCutoffIso}::timestamptz + order by consumed_at, id + limit ${limit} + for update skip locked + ), changed as ( + delete from iroh_registration_challenges as challenge + using candidates + where challenge.id = candidates.id + returning challenge.id + ) + select count(*)::int as affected from changed + `)), + }, + { + category: "relayAudits", + run: (limit) => runRetentionBatch(async (tx) => await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_relay_token_issuances + where requested_at < ${auditCutoffIso}::timestamptz + order by requested_at, id + limit ${limit} + for update skip locked + ), changed as ( + delete from iroh_relay_token_issuances as issuance + using candidates + where issuance.id = candidates.id + returning issuance.id + ) + select count(*)::int as affected from changed + `)), + }, + { + category: "pairGrantAudits", + run: (limit) => runRetentionBatch(async (tx) => await tx.execute(sql` + with candidates as materialized ( + select id + from iroh_pair_grant_issuances + where expires_at < ${auditCutoffIso}::timestamptz + order by expires_at, id + limit ${limit} + for update skip locked + ), changed as ( + delete from iroh_pair_grant_issuances as issuance + using candidates + where issuance.id = candidates.id + returning issuance.id + ) + select count(*)::int as affected from changed + `)), + }, + { + category: "revokedBindings", + run: (limit) => runRetentionBatch(async (tx) => await tx.execute(sql` + with candidates as materialized ( + select binding.id + from iroh_endpoint_bindings as binding + where binding.revoked_at < ${auditCutoffIso}::timestamptz + and not exists ( + select 1 from iroh_pair_grant_issuances as pair_grant + where pair_grant.initiator_binding_id = binding.id + or pair_grant.acceptor_binding_id = binding.id + ) + and not exists ( + select 1 from iroh_relay_token_issuances as issuance + where issuance.binding_id = binding.id + ) + order by binding.revoked_at, binding.id + limit ${limit} + for update skip locked + ), changed as ( + delete from iroh_endpoint_bindings as binding + using candidates + where binding.id = candidates.id + returning binding.id + ) + select count(*)::int as affected from changed + `)), + }, + ]; + const byCategory: Record = { + revokedHints: 0, + expiredHints: 0, + expiredChallenges: 0, + consumedChallenges: 0, + relayAudits: 0, + pairGrantAudits: 0, + revokedBindings: 0, + }; + const deadline = Date.now() + maxDurationMs; + const activeOperations = [...operations]; + let rowsProcessed = 0; + let batches = 0; + let operationIndex = 0; + + while (activeOperations.length > 0 && rowsProcessed < maxRows && Date.now() < deadline) { + const operation = activeOperations[operationIndex]!; + const limit = Math.min(IROH_RETENTION_BATCH_SIZE, maxRows - rowsProcessed); + const affected = await operation.run(limit); + batches += 1; + rowsProcessed += affected; + byCategory[operation.category] += affected; + if (affected < limit) { + activeOperations.splice(operationIndex, 1); + if (operationIndex >= activeOperations.length) operationIndex = 0; + } else { + operationIndex = (operationIndex + 1) % activeOperations.length; + } + } + + const budgetExhausted = rowsProcessed >= maxRows + ? "rows" + : Date.now() >= deadline + ? "time" + : null; + const backlog = budgetExhausted === "time" + ? true + : await irohRetentionBacklogExists(input.now, challengeRetentionCutoff, auditRetentionCutoff); + return { rowsProcessed, batches, backlog, budgetExhausted, byCategory }; +} + +async function runRetentionBatch( + execute: (tx: CloudDbTransaction) => Promise, +): Promise { + return await cloudDb().transaction(async (tx) => { + const result = await execute(tx); + const [row] = databaseRows(result); + const affected = Number(row?.affected ?? 0); + if (!Number.isSafeInteger(affected) || affected < 0 || affected > IROH_RETENTION_BATCH_SIZE) { + throw new Error("invalid Iroh retention batch result"); + } + return affected; + }); +} + +async function irohRetentionBacklogExists( + now: Date, + challengeRetentionCutoff: Date, + auditRetentionCutoff: Date, +): Promise { + const result = await cloudDb().execute(sql` + select ( + exists ( + select 1 from iroh_endpoint_bindings + where revoked_at is not null and path_hints_next_expiry is not null + ) or exists ( + select 1 from iroh_endpoint_bindings + where revoked_at is null and path_hints_next_expiry <= ${now.toISOString()}::timestamptz + ) or exists ( + select 1 from iroh_registration_challenges + where expires_at < ${challengeRetentionCutoff.toISOString()}::timestamptz + ) or exists ( + select 1 from iroh_registration_challenges + where consumed_at < ${challengeRetentionCutoff.toISOString()}::timestamptz + ) or exists ( + select 1 from iroh_relay_token_issuances + where requested_at < ${auditRetentionCutoff.toISOString()}::timestamptz + ) or exists ( + select 1 from iroh_pair_grant_issuances + where expires_at < ${auditRetentionCutoff.toISOString()}::timestamptz + ) or exists ( + select 1 + from iroh_endpoint_bindings as binding + where binding.revoked_at < ${auditRetentionCutoff.toISOString()}::timestamptz + and not exists ( + select 1 from iroh_pair_grant_issuances as pair_grant + where pair_grant.initiator_binding_id = binding.id + or pair_grant.acceptor_binding_id = binding.id + ) + and not exists ( + select 1 from iroh_relay_token_issuances as issuance + where issuance.binding_id = binding.id + ) + ) + ) as backlog + `); + const [row] = databaseRows(result); + return row?.backlog === true; +} + +function databaseRows(result: unknown): readonly Record[] { + if (Array.isArray(result)) return result as readonly Record[]; + const rows = (result as { readonly rows?: unknown } | null)?.rows; + return Array.isArray(rows) ? rows as readonly Record[] : []; +} + +function retentionBudget( + value: number | undefined, + fallback: number, + maximum: number, + name: string, +): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) { + throw new Error(`invalid Iroh retention ${name}`); + } + return resolved; +} + +function repositoryEffect( + operation: string, + run: () => Promise, +): Effect.Effect { + return Effect.tryPromise({ + try: run, + catch: (cause) => { + if (isDomainError(cause)) return cause; + const conflict = databaseConflict(cause); + return conflict ?? new IrohDatabaseError({ operation, cause: sanitizedDatabaseCause(cause) }); + }, + }); +} + +function isDomainError(error: unknown): error is + | IrohForbiddenError + | IrohNotFoundError + | IrohConflictError + | IrohQuotaExceededError { + const tag = (error as { _tag?: unknown } | null)?._tag; + return tag === "IrohForbiddenError" || tag === "IrohNotFoundError" || + tag === "IrohConflictError" || tag === "IrohQuotaExceededError"; +} + +function quotaFromOldest( + code: string, + oldest: Date, + windowSeconds: number, + now: Date, +): IrohQuotaExceededError { + const retryAfterSeconds = Math.max( + 1, + Math.ceil((oldest.getTime() + windowSeconds * 1_000 - now.getTime()) / 1_000), + ); + return new IrohQuotaExceededError({ code, retryAfterSeconds }); +} + +function sanitizedDatabaseCause(cause: unknown): unknown { + const candidate = databaseCause(cause); + return { + code: typeof candidate?.code === "string" ? candidate.code : undefined, + name: typeof candidate?.name === "string" ? candidate.name : undefined, + }; +} + +function databaseConflict(cause: unknown): IrohConflictError | null { + const candidate = databaseCause(cause); + if (candidate?.code !== "23505") return null; + if (candidate.constraint === "iroh_endpoint_bindings_active_endpoint_unique") { + return new IrohConflictError({ code: "endpoint_already_bound" }); + } + if (candidate.constraint === "iroh_endpoint_bindings_active_app_instance_unique") { + return new IrohConflictError({ code: "binding_replacement_requires_revocation" }); + } + return null; +} + +function databaseCause(cause: unknown): { + readonly code?: unknown; + readonly name?: unknown; + readonly constraint?: unknown; +} | null { + let current = cause; + const seen = new Set(); + for (let depth = 0; depth < 5; depth += 1) { + if (!current || typeof current !== "object" || seen.has(current)) return null; + seen.add(current); + const candidate = current as { code?: unknown; name?: unknown; constraint?: unknown; cause?: unknown }; + if (typeof candidate.code === "string") return candidate; + current = candidate.cause; + } + return null; +} + +async function assertIrohUserMutationAllowed( + tx: CloudDbTransaction, + userId: string, +): Promise { + try { + await assertAccountDeletionUserMutationAllowed(tx, userId); + } catch (error) { + if (error instanceof AccountDeletionMutationBlockedError) { + throw new IrohConflictError({ code: "account_deletion_in_progress" }); + } + throw error; + } +} + +function retainedStoredHints(pathHints: readonly unknown[], now: Date): IrohPathHint[] { + return pathHints.flatMap((hint): IrohPathHint[] => { + try { + return [parseIrohPathHint(hint, now)]; + } catch { + return []; + } + }); +} + +function bindingMatchesGrantPeer(binding: IrohBindingRecord, peer: PairGrantPeer): boolean { + return binding.id === peer.bindingId && + binding.deviceUuid === peer.deviceId && + binding.tag === peer.tag && + binding.platform === peer.platform && + binding.endpointId === peer.endpointId && + binding.identityGeneration === peer.identityGeneration; +} diff --git a/web/services/iroh/routeHandler.ts b/web/services/iroh/routeHandler.ts new file mode 100644 index 000000000000..facdd602ef64 --- /dev/null +++ b/web/services/iroh/routeHandler.ts @@ -0,0 +1,261 @@ +import { createHash } from "node:crypto"; +import * as Effect from "effect/Effect"; +import type * as Layer from "effect/Layer"; +import { env } from "../../app/env"; +import { unauthorized, verifyRequest, type AuthedUser } from "../vms/auth"; +import { enforceBrowserMutationProtection, jsonResponse } from "../vms/routeHelpers"; +import { irohExpectedError } from "./errors"; +import { + checkIrohVercelFirewall, + type IrohFirewallCheck, + type IrohFirewallCheckResult, +} from "./firewall"; +import { + IrohTrustBroker, + IrohTrustBrokerRuntime, + type IrohTrustBrokerShape, +} from "./trustBroker"; + +const MAX_BODY_BYTES = 64 * 1_024; +const FIREWALL_TIMEOUT_MS = 2_500; +const FIREWALL_MAX_IN_FLIGHT = 64; + +export class IrohFirewallAdmission { + private readonly active = new Map>(); + + constructor(private readonly maxInFlight: number) { + if (!Number.isSafeInteger(maxInFlight) || maxInFlight <= 0) { + throw new RangeError("maxInFlight must be a positive integer"); + } + } + + get activeCount(): number { + return this.active.size; + } + + run(key: string, start: () => Promise): Promise { + if (this.active.has(key) || this.active.size >= this.maxInFlight) { + throw new Error("firewall_admission_unavailable"); + } + + const work = Promise.resolve().then(start); + const tracked = work.finally(() => { + if (this.active.get(key) === tracked) this.active.delete(key); + }); + this.active.set(key, tracked); + return tracked; + } +} + +const firewallAdmission = new IrohFirewallAdmission(FIREWALL_MAX_IN_FLIGHT); + +export type IrohRouteOperation = + | "challenge" + | "register" + | "discover" + | "endpoint_attestation" + | "revoke" + | "pair_grant" + | "relay_token"; + +type RouteDependencies = { + readonly verify?: typeof verifyRequest; + readonly broker?: IrohTrustBrokerShape; + readonly runtime?: Layer.Layer; + readonly firewall?: { + readonly id: string; + readonly check: IrohFirewallCheck; + readonly timeoutMs?: number; + readonly admission?: IrohFirewallAdmission; + }; +}; + +export async function handleIrohRoute( + request: Request, + operation: IrohRouteOperation, + dependencies: RouteDependencies = {}, +): Promise { + const verify = dependencies.verify ?? verifyRequest; + let user: AuthedUser | null; + try { + user = await verify(request, { allowCookie: false }); + } catch { + return jsonResponse({ error: "unauthorized" }, 401); + } + if (!user) return unauthorized(); + + const firewall = dependencies.firewall ?? ( + process.env.VERCEL === "1" && env.CMUX_IROH_RATE_LIMIT_ID + ? { id: env.CMUX_IROH_RATE_LIMIT_ID, check: checkIrohVercelFirewall } + : undefined + ); + if (firewall) { + const rateLimitKey = createHash("sha256") + .update(`iroh-rate:${user.id}:${operation}`) + .digest("hex"); + const abortController = new AbortController(); + const timeout = setTimeout( + () => abortController.abort(new Error("firewall_timeout")), + firewall.timeoutMs ?? FIREWALL_TIMEOUT_MS, + ); + let result: IrohFirewallCheckResult; + try { + result = await (firewall.admission ?? firewallAdmission).run( + `${firewall.id}:${rateLimitKey}`, + () => firewall.check(firewall.id, { + request, + rateLimitKey, + signal: abortController.signal, + }), + ); + } catch { + console.error("iroh trust broker firewall unavailable", { + operation, + failure: "request_failed_or_timed_out", + }); + return jsonResponse({ error: "iroh_service_unavailable" }, 503); + } finally { + clearTimeout(timeout); + } + const { error, rateLimited } = result; + if (rateLimited || error === "blocked") { + return irohJsonResponse({ error: "rate_limited" }, 429, { "retry-after": "60" }); + } + if (error) { + console.error("iroh trust broker firewall unavailable", { operation, failure: error }); + return jsonResponse({ error: "iroh_service_unavailable" }, 503); + } + } + + if (operation !== "discover") { + const mutationForbidden = enforceBrowserMutationProtection(request); + if (mutationForbidden) return mutationForbidden; + } + + const bodyResult = operation === "discover" + ? { ok: true as const, value: undefined } + : await readBoundedJson(request); + if (!bodyResult.ok) return bodyResult.response; + + try { + const value = dependencies.broker + ? await Effect.runPromise(invoke(dependencies.broker, operation, user.id, bodyResult.value)) + : await Effect.runPromise( + Effect.gen(function* () { + const broker = yield* IrohTrustBroker; + return yield* invoke(broker, operation, user.id, bodyResult.value); + }).pipe(Effect.provide(dependencies.runtime ?? IrohTrustBrokerRuntime)), + ); + return irohJsonResponse(value, successStatus(operation), { + "cache-control": "no-store", + }); + } catch (error) { + const expected = irohExpectedError(error); + if (expected) return expectedErrorResponse(expected); + // Do not include EndpointIDs, hints, grants, or tokens in logs. The route + // and coarse failure class are enough for operational correlation. + console.error("iroh trust broker request failed", { operation, failure: "unexpected" }); + return jsonResponse({ error: "iroh_internal_error" }, 500); + } +} + +function invoke( + broker: IrohTrustBrokerShape, + operation: IrohRouteOperation, + userId: string, + body: unknown, +) { + switch (operation) { + case "challenge": return broker.issueChallenge(userId, body); + case "register": return broker.register(userId, body); + case "discover": return broker.discover(userId); + case "endpoint_attestation": return broker.issueEndpointAttestation(userId, body); + case "revoke": return broker.revoke(userId, body); + case "pair_grant": return broker.issuePairGrant(userId, body); + case "relay_token": return broker.issueRelayToken(userId, body); + } +} + +async function readBoundedJson(request: Request): Promise< + | { readonly ok: true; readonly value: unknown } + | { readonly ok: false; readonly response: Response } +> { + if (request.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") { + return { ok: false, response: jsonResponse({ error: "unsupported_media_type" }, 415) }; + } + const contentLength = request.headers.get("content-length"); + if (contentLength) { + const parsed = Number(contentLength); + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > MAX_BODY_BYTES) { + return { ok: false, response: jsonResponse({ error: "request_too_large" }, 413) }; + } + } + const reader = request.body?.getReader(); + if (!reader) return { ok: false, response: jsonResponse({ error: "missing_body" }, 400) }; + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > MAX_BODY_BYTES) { + await reader.cancel(); + return { ok: false, response: jsonResponse({ error: "request_too_large" }, 413) }; + } + chunks.push(next.value); + } + } catch { + return { ok: false, response: jsonResponse({ error: "invalid_body" }, 400) }; + } + if (total === 0) return { ok: false, response: jsonResponse({ error: "missing_body" }, 400) }; + const bytes = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total); + try { + return { ok: true, value: JSON.parse(bytes.toString("utf8")) }; + } catch { + return { ok: false, response: jsonResponse({ error: "invalid_json" }, 400) }; + } +} + +function successStatus(operation: IrohRouteOperation): number { + return operation === "discover" || operation === "revoke" ? 200 : 201; +} + +function expectedErrorResponse(error: ReturnType & object): Response { + const tag = (error as { _tag?: string })._tag; + if (tag === "IrohInvalidInputError") { + return jsonResponse({ error: (error as { code: string }).code }, 400); + } + if (tag === "IrohForbiddenError") { + return jsonResponse({ error: (error as { code: string }).code }, 403); + } + if (tag === "IrohNotFoundError") { + return jsonResponse({ error: `${(error as { resource: string }).resource}_not_found` }, 404); + } + if (tag === "IrohConflictError") { + return jsonResponse({ error: (error as { code: string }).code }, 409); + } + if (tag === "IrohQuotaExceededError") { + const quota = error as { code: string; retryAfterSeconds: number }; + return irohJsonResponse( + { error: quota.code, retry_after_seconds: quota.retryAfterSeconds }, + 429, + { "retry-after": String(quota.retryAfterSeconds) }, + ); + } + if (tag === "IrohConfigurationError" || tag === "IrohRelayMintError") { + return jsonResponse({ error: "iroh_service_unavailable" }, 503); + } + return jsonResponse({ error: "iroh_service_unavailable" }, 503); +} + +function irohJsonResponse( + value: unknown, + status: number, + headers: Record, +): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json", ...headers }, + }); +} diff --git a/web/services/iroh/trustBroker.ts b/web/services/iroh/trustBroker.ts new file mode 100644 index 000000000000..b210996b287d --- /dev/null +++ b/web/services/iroh/trustBroker.ts @@ -0,0 +1,460 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { + assertCurrentSigningKey, + deriveAccountSubject, + deriveLanRendezvousKey, + hashesEqual, + nonceHash, + parseVerificationKeys, + signEndpointAttestation, + signPairGrant, + verifyEndpointAttestation, + verifyEndpointRegistrationSignature, + verifyPairGrant, + type EndpointAttestationClaims, + type PairGrantClaims, + type PairGrantPeer, +} from "./crypto"; +import { + deviceLimitOverrideAllowed, + IrohTrustBrokerConfig, + IrohTrustBrokerConfigLive, + type IrohTrustBrokerConfigShape, +} from "./config"; +import { + IrohConflictError, + IrohConfigurationError, + IrohForbiddenError, + IrohInvalidInputError, + IrohNotFoundError, + type IrohExpectedError, +} from "./errors"; +import { + IROH_ALPN, + IROH_CHALLENGE_LIFETIME_MS, + IROH_ENDPOINT_ATTESTATION_LIFETIME_SECONDS, + IROH_ENDPOINT_ATTESTATION_SCOPE, + IROH_ENDPOINT_ATTESTATION_VERSION, + IROH_PAIR_GRANT_LIFETIME_SECONDS, + IROH_PAIR_SCOPE, + IROH_RELAY_TOKEN_LIFETIME_SECONDS, + IROH_RELAY_TOKEN_REFRESH_SECONDS, + MANAGED_RELAY_URLS, + assertChallengeMatchesPayload, + decodeRegistrationPayload, + parseBindingIdBody, + parseChallengeRequest, + parseIrohPathHint, + parsePairGrantRequest, + parseRegisterRequest, + sha256, + type IrohPathHint, +} from "./model"; +import { + IrohRepository, + IrohRepositoryLive, + type IrohBindingRecord, + type IrohRepositoryShape, +} from "./repository"; +import { + IrohRelayMinter, + IrohRelayMinterLive, + type IrohRelayMinterShape, +} from "./relayMinter"; +import { serverPublishedIrohPathHints } from "./publicationPolicy"; + +export type IrohTrustBrokerShape = { + readonly issueChallenge: ( + userId: string, + raw: unknown, + now?: Date, + ) => Effect.Effect; + readonly register: ( + userId: string, + raw: unknown, + now?: Date, + ) => Effect.Effect; + readonly discover: ( + userId: string, + now?: Date, + ) => Effect.Effect; + readonly revoke: ( + userId: string, + raw: unknown, + now?: Date, + ) => Effect.Effect; + readonly issuePairGrant: ( + userId: string, + raw: unknown, + now?: Date, + ) => Effect.Effect; + readonly issueEndpointAttestation: ( + userId: string, + raw: unknown, + now?: Date, + ) => Effect.Effect; + readonly issueRelayToken: ( + userId: string, + raw: unknown, + now?: Date, + ) => Effect.Effect; +}; + +export class IrohTrustBroker extends Context.Tag("cmux/IrohTrustBroker")< + IrohTrustBroker, + IrohTrustBrokerShape +>() {} + +export function makeIrohTrustBroker( + repository: IrohRepositoryShape, + relayMinter: IrohRelayMinterShape, + config: IrohTrustBrokerConfigShape, +): IrohTrustBrokerShape { + const issueRelayToken = ( + userId: string, + raw: unknown, + now = new Date(), + ): Effect.Effect => Effect.gen(function* () { + const { bindingId } = yield* parseEffect(() => parseBindingIdBody(raw)); + const reservation = yield* repository.reserveRelayIssuance({ userId, bindingId, now }); + const minted = yield* relayMinter.mint({ + endpointId: reservation.binding.endpointId, + lifetimeSeconds: IROH_RELAY_TOKEN_LIFETIME_SECONDS, + now, + }).pipe( + Effect.matchEffect({ + onFailure: (error) => repository.failRelayIssuance({ + userId, + issuanceId: reservation.issuanceId, + completedAt: new Date(), + failureCode: error._tag === "IrohRelayMintError" ? error.code : "not_configured", + }).pipe( + Effect.catchAll(() => Effect.void), + Effect.flatMap(() => Effect.fail(error)), + ), + onSuccess: Effect.succeed, + }), + ); + const completedAt = new Date(); + const completed = yield* repository.completeRelayIssuance({ + userId, + issuanceId: reservation.issuanceId, + bindingId: reservation.binding.id, + endpointId: reservation.binding.endpointId, + tokenHash: sha256(minted.token), + completedAt, + expiresAt: minted.expiresAt, + }); + if (!completed) return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); + return { + token: minted.token, + expires_at: minted.expiresAt.toISOString(), + refresh_after: new Date(now.getTime() + IROH_RELAY_TOKEN_REFRESH_SECONDS * 1_000).toISOString(), + relay_fleet: MANAGED_RELAY_URLS, + }; + }); + + return { + issueChallenge: (userId, raw, now = new Date()) => Effect.gen(function* () { + const request = yield* parseEffect(() => parseChallengeRequest(raw)); + const nonce = randomBytes(32).toString("base64url"); + const challenge = yield* repository.issueChallenge({ + userId, + deviceUuid: request.deviceId, + appInstanceId: request.appInstanceId, + tag: request.tag, + endpointId: request.endpointId, + identityGeneration: request.identityGeneration, + payloadSha256: request.payloadSha256, + nonceHash: nonceHash(nonce), + now, + expiresAt: new Date(now.getTime() + IROH_CHALLENGE_LIFETIME_MS), + }); + return { + challenge_id: challenge.id, + nonce, + expires_at: challenge.expiresAt.toISOString(), + }; + }), + + register: (userId, raw, now = new Date()) => Effect.gen(function* () { + const request = yield* parseEffect(() => parseRegisterRequest(raw)); + const decoded = yield* parseEffect(() => decodeRegistrationPayload(request.payload, now)); + const challenge = yield* repository.findChallenge(userId, request.challengeId); + if (!challenge) return yield* Effect.fail(new IrohNotFoundError({ resource: "challenge" })); + if (challenge.consumedAt) return yield* Effect.fail(new IrohConflictError({ code: "challenge_replayed" })); + if (challenge.expiresAt <= now) return yield* Effect.fail(new IrohForbiddenError({ code: "challenge_expired" })); + if (!hashesEqual(challenge.payloadSha256, decoded.sha256)) { + return yield* Effect.fail(new IrohForbiddenError({ code: "payload_hash_mismatch" })); + } + if (!hashesEqual(challenge.nonceHash, nonceHash(request.nonce))) { + return yield* Effect.fail(new IrohForbiddenError({ code: "invalid_challenge_nonce" })); + } + yield* parseEffect(() => assertChallengeMatchesPayload(challenge, decoded.payload)); + yield* parseEffect(() => verifyEndpointRegistrationSignature({ + endpointId: decoded.payload.endpointId, + challengeId: request.challengeId, + nonce: request.nonce, + payloadSha256: decoded.sha256, + signature: request.signature, + })); + const registration = yield* repository.consumeChallengeAndRegister({ + userId, + challengeId: challenge.id, + nonceHash: nonceHash(request.nonce), + payload: { + ...decoded.payload, + pathHints: serverPublishedIrohPathHints(decoded.payload.pathHints), + }, + now, + deviceLimitOverrideAllowed: deviceLimitOverrideAllowed(config, userId), + }); + + // New registration is already committed before relay minting starts. + // Refreshes keep their existing credential and use the dedicated relay + // route when it expires, so path-hint churn cannot consume mint quotas. + const relay = registration.created + ? yield* issueRelayToken(userId, { bindingId: registration.binding.id }, now).pipe( + Effect.map((value) => ({ status: "issued" as const, ...value as object })), + Effect.catchAll(() => Effect.succeed({ status: "unavailable" as const })), + ) + : { status: "not_requested" as const }; + return { binding: publicBinding(registration.binding, now), relay }; + }), + + discover: (userId, now = new Date()) => Effect.gen(function* () { + yield* repository.pruneExpiredState({ userId, now }); + const snapshot = yield* repository.discoverySnapshot({ userId, now }); + const rendezvousKey = yield* parseEffect(() => deriveLanRendezvousKey( + config.lanDiscoverySecretBase64, + userId, + snapshot.lanDiscoveryGeneration, + )); + const verificationKeys = yield* parseEffect(() => signingVerificationKeys(config)); + return { + route_contract_version: 1, + bindings: snapshot.bindings.map((binding) => publicBinding(binding, now)), + relay_fleet: MANAGED_RELAY_URLS, + lan_rendezvous: { + generation: snapshot.lanDiscoveryGeneration, + key: rendezvousKey, + }, + grant_verification_keys: verificationKeys.keySet, + }; + }), + + revoke: (userId, raw, now = new Date()) => Effect.gen(function* () { + const { bindingId } = yield* parseEffect(() => parseBindingIdBody(raw)); + const revoked = yield* repository.revokeBinding({ userId, bindingId, now }); + if (!revoked) return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); + return { revoked: true, lan_rendezvous_rotated: true }; + }), + + issuePairGrant: (userId, raw, now = new Date()) => Effect.gen(function* () { + const request = yield* parseEffect(() => parsePairGrantRequest(raw)); + const bindings = yield* repository.findActiveBindings(userId, [ + request.initiatorBindingId, + request.acceptorBindingId, + ]); + if (bindings.length !== 2) return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); + const byId = new Map(bindings.map((binding) => [binding.id, binding])); + const initiator = byId.get(request.initiatorBindingId); + const acceptor = byId.get(request.acceptorBindingId); + if (!initiator || !acceptor) return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); + if (initiator.platform !== "ios" || acceptor.platform !== "mac" || !acceptor.pairingEnabled) { + return yield* Effect.fail(new IrohForbiddenError({ code: "target_not_pairable" })); + } + if (initiator.deviceUuid === acceptor.deviceUuid) { + return yield* Effect.fail(new IrohForbiddenError({ code: "pair_grant_same_device" })); + } + const issuedAtSeconds = Math.floor(now.getTime() / 1_000); + const claims: PairGrantClaims = { + jti: randomUUID(), + iat: issuedAtSeconds, + nbf: issuedAtSeconds - 5, + exp: issuedAtSeconds + IROH_PAIR_GRANT_LIFETIME_SECONDS, + alpn: IROH_ALPN, + scope: IROH_PAIR_SCOPE, + initiator: grantPeer(initiator), + acceptor: grantPeer(acceptor), + }; + const verificationKeys = yield* parseEffect(() => signingVerificationKeys(config)); + const signingKeyId = verificationKeys.keySet.current_kid; + const token = yield* parseEffect(() => signPairGrant({ + privateKeyPem: config.grantSigningPrivateKeyPem, + kid: signingKeyId, + claims, + })); + yield* parseEffect(() => verifyPairGrant(token, verificationKeys.publicKeys, { + initiator: claims.initiator, + acceptor: claims.acceptor, + nowSeconds: issuedAtSeconds, + })); + yield* repository.recordPairGrant({ + userId, + jti: claims.jti, + initiator: claims.initiator, + acceptor: claims.acceptor, + signingKeyId, + alpn: IROH_ALPN, + scope: IROH_PAIR_SCOPE, + issuedAt: new Date(claims.iat * 1_000), + notBefore: new Date(claims.nbf * 1_000), + expiresAt: new Date(claims.exp * 1_000), + }); + return { grant: token, expires_at: new Date(claims.exp * 1_000).toISOString() }; + }), + + issueEndpointAttestation: (userId, raw, now = new Date()) => Effect.gen(function* () { + const { bindingId } = yield* parseEffect(() => parseBindingIdBody(raw)); + const bindings = yield* repository.findActiveBindings(userId, [bindingId]); + const binding = bindings.length === 1 ? bindings[0] : undefined; + if (!binding) return yield* Effect.fail(new IrohNotFoundError({ resource: "binding" })); + + const verificationKeys = yield* parseEffect(() => signingVerificationKeys(config)); + const issuedAtSeconds = Math.floor(now.getTime() / 1_000); + const platform = yield* parseEffect(() => bindingPlatform(binding)); + const claims: EndpointAttestationClaims = { + version: IROH_ENDPOINT_ATTESTATION_VERSION, + jti: randomUUID(), + sub: yield* parseEffect(() => deriveAccountSubject(config.accountSubjectSecretBase64, userId)), + bindingId: binding.id, + deviceId: binding.deviceUuid, + endpointId: binding.endpointId, + identityGeneration: binding.identityGeneration, + platform, + iat: issuedAtSeconds, + nbf: issuedAtSeconds - 5, + exp: issuedAtSeconds + IROH_ENDPOINT_ATTESTATION_LIFETIME_SECONDS, + alpn: IROH_ALPN, + scope: IROH_ENDPOINT_ATTESTATION_SCOPE, + }; + const attestation = yield* parseEffect(() => signEndpointAttestation({ + privateKeyPem: config.grantSigningPrivateKeyPem, + kid: verificationKeys.keySet.current_kid, + claims, + })); + yield* parseEffect(() => verifyEndpointAttestation( + attestation, + verificationKeys.publicKeys, + { + bindingId: binding.id, + deviceId: binding.deviceUuid, + endpointId: binding.endpointId, + identityGeneration: binding.identityGeneration, + platform, + nowSeconds: issuedAtSeconds, + }, + )); + yield* repository.finalizeEndpointAttestation({ + userId, + bindingId: binding.id, + deviceId: binding.deviceUuid, + endpointId: binding.endpointId, + identityGeneration: binding.identityGeneration, + platform, + }); + return { + attestation_version: IROH_ENDPOINT_ATTESTATION_VERSION, + attestation, + expires_at: new Date(claims.exp * 1_000).toISOString(), + grant_verification_keys: verificationKeys.keySet, + }; + }), + + issueRelayToken, + }; +} + +export const IrohTrustBrokerLive = Layer.effect( + IrohTrustBroker, + Effect.gen(function* () { + return makeIrohTrustBroker( + yield* IrohRepository, + yield* IrohRelayMinter, + yield* IrohTrustBrokerConfig, + ); + }), +); + +const IrohRelayMinterWithConfig = IrohRelayMinterLive.pipe( + Layer.provide(IrohTrustBrokerConfigLive), +); + +export const IrohTrustBrokerRuntime = IrohTrustBrokerLive.pipe( + Layer.provide(Layer.mergeAll( + IrohRepositoryLive, + IrohTrustBrokerConfigLive, + IrohRelayMinterWithConfig, + )), +); + +function parseEffect(run: () => A): Effect.Effect { + return Effect.try({ + try: run, + catch: (error) => { + const tag = (error as { _tag?: unknown } | null)?._tag; + if (typeof tag === "string" && tag.startsWith("Iroh")) return error as IrohExpectedError; + return new IrohInvalidInputError({ code: "invalid_input" }); + }, + }); +} + +function publicBinding(binding: IrohBindingRecord, now: Date): object { + return { + binding_id: binding.id, + device_id: binding.deviceUuid, + app_instance_id: binding.appInstanceId, + tag: binding.tag, + platform: binding.platform, + display_name: binding.displayName, + endpoint_id: binding.endpointId, + identity_generation: binding.identityGeneration, + pairing_enabled: binding.pairingEnabled, + capabilities: binding.capabilities, + path_hints: serverPublishedIrohPathHints(binding.pathHints.flatMap((hint): IrohPathHint[] => { + try { + return [parseIrohPathHint(hint, now)]; + } catch { + return []; + } + })), + last_seen_at: binding.lastSeenAt.toISOString(), + }; +} + +function grantPeer(binding: IrohBindingRecord): PairGrantPeer { + return { + bindingId: binding.id, + deviceId: binding.deviceUuid, + tag: binding.tag, + platform: bindingPlatform(binding), + endpointId: binding.endpointId, + identityGeneration: binding.identityGeneration, + }; +} + +function signingVerificationKeys(config: IrohTrustBrokerConfigShape) { + const verificationKeys = parseVerificationKeys(config.grantVerificationKeysJson); + assertCurrentSigningKey({ + privateKeyPem: config.grantSigningPrivateKeyPem, + kid: config.grantSigningKid, + verificationKeys, + }); + return verificationKeys; +} + +function bindingPlatform(binding: IrohBindingRecord): "mac" | "ios" { + if (binding.platform !== "mac" && binding.platform !== "ios") { + throw new IrohConfigurationError({ component: "grant_signing" }); + } + return binding.platform; +} + +// Stack bearer authentication alone is never sufficient to mutate path hints. +// Until the dedicated endpoint-signed monotonic update route lands, clients +// refresh watch_addr output only through a new signed registration challenge. +export const IROH_SIGNED_PATH_HINT_UPDATE_FOLLOWUP = "endpoint-signed-monotonic-watch-addr-update-v1"; diff --git a/web/tests/client-config-env.test.ts b/web/tests/client-config-env.test.ts index eabd98c1727e..8c6c08fed1fd 100644 --- a/web/tests/client-config-env.test.ts +++ b/web/tests/client-config-env.test.ts @@ -12,6 +12,17 @@ const requiredEnv = { NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: "stack-public", }; +const requiredIrohProductionEnv = { + CMUX_IROH_LAN_DISCOVERY_SECRET_B64: Buffer.alloc(32, 0x11).toString("base64"), + CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64: Buffer.alloc(32, 0x22).toString("base64"), + CMUX_IROH_GRANT_SIGNING_KEY_P8: `-----BEGIN PRIVATE KEY-----\n${"A".repeat(64)}\n-----END PRIVATE KEY-----`, + CMUX_IROH_GRANT_SIGNING_KID: "current", + CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON: "{}", + CMUX_IROH_MINT_URL: "https://iroh-minter.example.com/api/relay-token", + CMUX_IROH_MINT_HMAC_SECRET_B64: Buffer.alloc(32, 0x33).toString("base64"), + CMUX_IROH_RATE_LIMIT_ID: "iroh-rule", +}; + describe("client config env validation", () => { test("allows local builds with VERCEL set but no deployment environment", () => { const result = importEnv({ @@ -41,16 +52,142 @@ describe("client config env validation", () => { VERCEL: "1", VERCEL_ENV: "production", CMUX_CLIENT_CONFIG_RATE_LIMIT_ID: "client-config-rule", + ...requiredIrohProductionEnv, + }); + + expect(result.exitCode).toBe(0); + }); + + test("accepts the self-hosted relay path without the legacy hosted minter", () => { + const result = importEnv({ + ...requiredEnv, + VERCEL: "1", + VERCEL_ENV: "production", + CMUX_CLIENT_CONFIG_RATE_LIMIT_ID: "client-config-rule", + CMUX_IROH_LAN_DISCOVERY_SECRET_B64: requiredIrohProductionEnv.CMUX_IROH_LAN_DISCOVERY_SECRET_B64, + CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64: requiredIrohProductionEnv.CMUX_IROH_ACCOUNT_SUBJECT_SECRET_B64, + CMUX_IROH_GRANT_SIGNING_KEY_P8: requiredIrohProductionEnv.CMUX_IROH_GRANT_SIGNING_KEY_P8, + CMUX_IROH_GRANT_SIGNING_KID: requiredIrohProductionEnv.CMUX_IROH_GRANT_SIGNING_KID, + CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON: + requiredIrohProductionEnv.CMUX_IROH_GRANT_VERIFICATION_KEYS_JSON, + CMUX_IROH_RATE_LIMIT_ID: requiredIrohProductionEnv.CMUX_IROH_RATE_LIMIT_ID, + }); + + expect(result.exitCode).toBe(0); + }); + + test("requires the Iroh limiter id in explicit Vercel production deployments", () => { + const result = importEnv({ + ...requiredEnv, + VERCEL: "1", + VERCEL_ENV: "production", + CMUX_CLIENT_CONFIG_RATE_LIMIT_ID: "client-config-rule", + }); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("CMUX_IROH_RATE_LIMIT_ID is required"); + }); + + test("requires the complete Iroh trust-broker configuration in production", () => { + const result = importEnv({ + ...requiredEnv, + VERCEL: "1", + VERCEL_ENV: "production", + CMUX_CLIENT_CONFIG_RATE_LIMIT_ID: "client-config-rule", + CMUX_IROH_RATE_LIMIT_ID: "iroh-rule", + }); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("CMUX_IROH_GRANT_SIGNING_KEY_P8 is required"); + expect(result.stderr).not.toContain("CMUX_IROH_MINT_HMAC_SECRET_B64 is required"); + }); + + test("allows an explicitly opted-in loopback HTTP relay minter only in local development", () => { + const result = inspectIrohMinterUrl({ + ...requiredEnv, + NODE_ENV: "development", + CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: "1", + CMUX_IROH_MINT_URL: "http://localhost:49152/api/relay-token", }); expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("http://localhost:49152/api/relay-token"); + }); + + test("rejects a plaintext non-loopback relay minter in local development", () => { + const result = inspectIrohMinterUrl({ + ...requiredEnv, + NODE_ENV: "development", + CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: "1", + CMUX_IROH_MINT_URL: "http://192.168.1.10:49152/api/relay-token", + }); + + expect(result.exitCode).not.toBe(0); + }); + + test("rejects the insecure loopback opt-in in Vercel preview and production", () => { + const preview = inspectIrohMinterUrl({ + ...requiredEnv, + NODE_ENV: "production", + VERCEL: "1", + VERCEL_ENV: "preview", + CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: "1", + CMUX_IROH_MINT_URL: "http://localhost:49152/api/relay-token", + }); + expect(preview.exitCode).not.toBe(0); + + const production = inspectIrohMinterUrl({ + ...requiredEnv, + ...requiredIrohProductionEnv, + NODE_ENV: "production", + VERCEL: "1", + VERCEL_ENV: "production", + CMUX_CLIENT_CONFIG_RATE_LIMIT_ID: "client-config-rule", + CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER: "1", + CMUX_IROH_MINT_URL: "http://localhost:49152/api/relay-token", + }); + expect(production.exitCode).not.toBe(0); + expect(production.stderr).toContain( + "CMUX_IROH_DEV_ALLOW_INSECURE_LOOPBACK_MINTER is only allowed in local development", + ); }); }); function importEnv(env: Record): { exitCode: number; stderr: string } { const result = spawnSync( process.execPath, - ["-e", "await import('./app/env')"], + ["--no-env-file", "-e", "await import('./app/env')"], + { + env: env as NodeJS.ProcessEnv, + encoding: "utf8", + }, + ); + return { + exitCode: result.status ?? 1, + stderr: result.stderr, + }; +} + +function inspectIrohMinterUrl( + env: Record, +): { exitCode: number; stdout: string; stderr: string } { + const result = spawnSync( + process.execPath, + [ + "--no-env-file", + "-e", + ` + const { irohTrustBrokerConfigFromEnv } = await import('./services/iroh/config'); + const { parseMinterUrl } = await import('./services/iroh/relayMinter'); + const config = irohTrustBrokerConfigFromEnv(); + const url = parseMinterUrl(config.relayMinterUrl, { + allowInsecureLoopback: config.relayMinterInsecureLoopbackOptIn, + deploymentEnvironment: config.deploymentEnvironment, + isVercelDeployment: config.isVercelDeployment, + }); + console.log(url.href); + `, + ], { env: env as NodeJS.ProcessEnv, encoding: "utf8", @@ -58,6 +195,7 @@ function importEnv(env: Record): { exitCode: number; stderr: str ); return { exitCode: result.status ?? 1, + stdout: result.stdout.trim(), stderr: result.stderr, }; } diff --git a/web/tests/devices-route.test.ts b/web/tests/devices-route.test.ts index 59ba071e34b8..2fe5253e5b98 100644 --- a/web/tests/devices-route.test.ts +++ b/web/tests/devices-route.test.ts @@ -34,6 +34,39 @@ let sql: Sql | null = null; const DEVICE_A = "11111111-1111-4111-8111-111111111111"; const DEVICE_B = "22222222-2222-4222-8222-222222222222"; +const IROH_ENDPOINT_ID = "a".repeat(64); +const APPROVED_IROH_RELAY = "https://use4.relay.cmux.dev/"; + +const legacyTailscaleRoute = { + id: "legacy", + kind: "tailscale", + priority: 0, + endpoint: { type: "host_port", host: "100.64.1.2", port: 51001 }, +}; + +const privateIrohRoute = { + id: "iroh-private", + kind: "iroh", + priority: 1, + endpoint: { + type: "peer", + id: IROH_ENDPOINT_ID, + relay_hint: "legacy-private-relay-hint", + relay_url: APPROVED_IROH_RELAY, + direct_addrs: ["192.168.1.20:49152", "100.64.1.2:49152"], + }, +}; + +const publicIrohRoute = { + id: "iroh-private", + kind: "iroh", + priority: 1, + endpoint: { + type: "peer", + id: IROH_ENDPOINT_ID, + relay_url: APPROVED_IROH_RELAY, + }, +}; function authHeaders(teamId?: string): Record { const base: Record = { @@ -254,6 +287,70 @@ describe("device registry route", () => { expect(routes).toHaveLength(2); }); + dbTest("persists and returns only public Iroh rendezvous data while preserving legacy routes", async () => { + if (!sql) throw new Error("test database not initialized"); + + const register = await POST( + registerRequest({ + deviceId: DEVICE_A, + platform: "mac", + tag: "stable", + routes: [ + legacyTailscaleRoute, + privateIrohRoute, + { + ...privateIrohRoute, + id: "iroh-unmanaged-relay", + endpoint: { + ...privateIrohRoute.endpoint, + relay_url: "https://attacker.example/relay", + }, + }, + ], + }), + ); + expect(register.status).toBe(200); + + const [{ routes: storedRoutes }] = await sql<{ routes: unknown[] }[]>` + select routes from device_app_instances + where device_id in (select id from devices where device_uuid = ${DEVICE_A}) + and tag = 'stable' + `; + expect(storedRoutes).toEqual([ + legacyTailscaleRoute, + publicIrohRoute, + { + ...publicIrohRoute, + id: "iroh-unmanaged-relay", + endpoint: { type: "peer", id: IROH_ENDPOINT_ID }, + }, + ]); + expect(JSON.stringify(storedRoutes)).not.toContain("192.168.1.20"); + expect(JSON.stringify(storedRoutes)).not.toContain("legacy-private-relay-hint"); + expect(JSON.stringify(storedRoutes)).not.toContain("attacker.example"); + + // Simulate an unsafe Iroh route written by a pre-hardening deployment. A + // same-team member must not receive its private hints from the GET boundary. + await sql` + update device_app_instances + set routes = ${sql.json([legacyTailscaleRoute, privateIrohRoute])} + where device_id in (select id from devices where device_uuid = ${DEVICE_A}) + and tag = 'stable' + `; + currentUserId = "registry-user-2"; + const listResponse = await GET( + new Request("https://cmux.test/api/devices", { method: "GET", headers: authHeaders() }), + ); + expect(listResponse.status).toBe(200); + const list = (await listResponse.json()) as { + devices: Array<{ instances: Array<{ routes: unknown[] }> }>; + }; + const returnedRoutes = list.devices[0]?.instances[0]?.routes; + expect(returnedRoutes).toEqual([legacyTailscaleRoute, publicIrohRoute]); + expect(JSON.stringify(returnedRoutes)).not.toContain("192.168.1.20"); + expect(JSON.stringify(returnedRoutes)).not.toContain("legacy-private-relay-hint"); + }); + dbTest("registers the same device UUID in two teams the user belongs to", async () => { if (!sql) throw new Error("test database not initialized"); diff --git a/web/tests/iroh-db-behavior.test.ts b/web/tests/iroh-db-behavior.test.ts new file mode 100644 index 000000000000..8c2fad74596e --- /dev/null +++ b/web/tests/iroh-db-behavior.test.ts @@ -0,0 +1,1184 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; +import * as Effect from "effect/Effect"; +import postgres, { type Sql } from "postgres"; +import { closeCloudDbForTests } from "../db/client"; +import { + accountDeletionAdvisoryLockKey, + accountDeletionUserHash, +} from "../services/account/deletionLock"; +import type { PairGrantPeer } from "../services/iroh/crypto"; +import { + IROH_RETENTION_BATCH_SIZE, + IrohRepository, + IrohRepositoryLive, + type IrohRepositoryShape, +} from "../services/iroh/repository"; + +const runDbTests = process.env.CMUX_DB_TEST === "1"; +const dbTest = runDbTests ? test : test.skip; +const NOW = new Date("2026-07-09T20:00:00.000Z"); + +let sql: Sql | null = null; +let repository: IrohRepositoryShape | null = null; + +beforeAll(async () => { + if (!runDbTests) return; + const databaseURL = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL; + if (!databaseURL) throw new Error("DATABASE_URL is required when CMUX_DB_TEST=1"); + sql = postgres(databaseURL, { max: 8 }); + repository = await Effect.runPromise( + Effect.gen(function* () { return yield* IrohRepository; }).pipe( + Effect.provide(IrohRepositoryLive), + ), + ); +}); + +beforeEach(async () => { + if (!sql) return; + await sql` + truncate + iroh_relay_token_issuances, + iroh_pair_grant_issuances, + iroh_registration_challenges, + iroh_endpoint_bindings, + iroh_account_security_states, + account_deletion_tombstones + restart identity cascade + `; +}); + +afterAll(async () => { + await closeCloudDbForTests(); + await sql?.end(); +}); + +describe("Iroh trust broker database behavior", () => { + dbTest("blocks new trust state once account deletion wins the account fence", async () => { + const userId = "user-deleting"; + let mutation: ReturnType | undefined; + await requiredSql().begin(async (deletionSql) => { + await deletionSql` + select pg_advisory_xact_lock( + hashtextextended(${accountDeletionAdvisoryLockKey(userId)}, 0) + ) + `; + await deletionSql` + insert into account_deletion_tombstones (user_id_hash, user_id, status, updated_at) + values (${accountDeletionUserHash(userId)}, ${userId}, 'pending', now()) + `; + mutation = Effect.runPromiseExit(requiredRepository().issueChallenge({ + userId, + deviceUuid: randomUUID(), + appInstanceId: randomUUID(), + tag: "stable", + endpointId: "09".repeat(32), + identityGeneration: 1, + payloadSha256: "08".repeat(32), + nonceHash: "07".repeat(32), + now: NOW, + expiresAt: new Date(NOW.getTime() + 5 * 60 * 1_000), + })); + await waitForAdvisoryLockWaiter(); + }); + + if (!mutation) throw new Error("mutation was not started"); + const exit = await mutation; + + expect(exit._tag).toBe("Failure"); + expect(String(exit)).toContain("account_deletion_in_progress"); + const [{ total }] = await requiredSql()>` + select count(*)::text as total from iroh_registration_challenges where user_id = ${userId} + `; + expect(total).toBe("0"); + }); + + dbTest("lets an earlier trust transaction finish before deletion removes it", async () => { + const userId = "user-mutation-first"; + let deletion: Promise | undefined; + await requiredSql().begin(async (mutationSql) => { + await mutationSql` + select pg_advisory_xact_lock( + hashtextextended(${accountDeletionAdvisoryLockKey(userId)}, 0) + ) + `; + await mutationSql` + insert into iroh_registration_challenges ( + user_id, device_uuid, app_instance_id, tag, endpoint_id, + identity_generation, payload_sha256, nonce_hash, created_at, expires_at + ) values ( + ${userId}, ${randomUUID()}, ${randomUUID()}, 'stable', ${"0a".repeat(32)}, + 1, ${"0b".repeat(32)}, ${"0c".repeat(32)}, now(), now() + interval '5 minutes' + ) + `; + deletion = requiredSql().begin(async (deletionSql) => { + await deletionSql` + select pg_advisory_xact_lock( + hashtextextended(${accountDeletionAdvisoryLockKey(userId)}, 0) + ) + `; + await deletionSql` + insert into account_deletion_tombstones (user_id_hash, user_id, status, updated_at) + values (${accountDeletionUserHash(userId)}, ${userId}, 'pending', now()) + `; + await deletionSql`delete from iroh_registration_challenges where user_id = ${userId}`; + }); + await waitForAdvisoryLockWaiter(); + const [{ total }] = await mutationSql>` + select count(*)::text as total + from iroh_registration_challenges + where user_id = ${userId} + `; + expect(total).toBe("1"); + }); + if (!deletion) throw new Error("deletion was not started"); + await deletion; + const [{ total }] = await requiredSql()>` + select count(*)::text as total + from iroh_registration_challenges + where user_id = ${userId} + `; + expect(total).toBe("0"); + }); + + dbTest("fences binding authorization, grants, relay completion, and cleanup during deletion", async () => { + const userId = "user-deletion-fences"; + const iosId = await insertBinding({ + userId, + platform: "ios", + endpointId: "0d".repeat(32), + }); + const macId = await insertBinding({ + userId, + platform: "mac", + endpointId: "0e".repeat(32), + }); + const ios = await pairPeer(iosId); + const mac = await pairPeer(macId); + const [issuance] = await requiredSql()>` + insert into iroh_relay_token_issuances ( + user_id, binding_id, endpoint_id_hash, status, requested_at + ) values (${userId}, ${macId}, ${"0f".repeat(32)}, 'pending', ${NOW}) + returning id::text + `; + if (!issuance) throw new Error("issuance insert failed"); + await requiredSql()` + insert into account_deletion_tombstones (user_id_hash, user_id, status, updated_at) + values (${accountDeletionUserHash(userId)}, ${userId}, 'pending', now()) + `; + + const repository = requiredRepository(); + const operations: Array> = [ + repository.findActiveBindings(userId, [iosId, macId]), + repository.revokeBinding({ userId, bindingId: macId, now: NOW }), + repository.discoverySnapshot({ userId, now: NOW }), + repository.pruneExpiredState({ userId, now: NOW }), + repository.finalizeEndpointAttestation({ + userId, + bindingId: ios.bindingId, + deviceId: ios.deviceId, + endpointId: ios.endpointId, + identityGeneration: ios.identityGeneration, + platform: ios.platform, + }), + repository.recordPairGrant({ + userId, + jti: randomUUID(), + initiator: ios, + acceptor: mac, + signingKeyId: "current", + alpn: "cmux/mobile/1", + scope: "cmux.mobile.attach", + issuedAt: NOW, + notBefore: NOW, + expiresAt: new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1_000), + }), + repository.reserveRelayIssuance({ userId, bindingId: macId, now: NOW }), + repository.completeRelayIssuance({ + userId, + issuanceId: issuance.id, + bindingId: macId, + endpointId: mac.endpointId, + tokenHash: "10".repeat(32), + completedAt: NOW, + expiresAt: new Date(NOW.getTime() + 24 * 60 * 60 * 1_000), + }), + repository.failRelayIssuance({ + userId, + issuanceId: issuance.id, + completedAt: NOW, + failureCode: "test_failure", + }), + ]; + for (const operation of operations) { + const exit = await Effect.runPromiseExit(operation); + expect(exit._tag).toBe("Failure"); + expect(String(exit)).toContain("account_deletion_in_progress"); + } + const [state] = await requiredSql()>` + select + exists(select 1 from iroh_endpoint_bindings where id = ${macId} and revoked_at is not null) as revoked, + (select count(*)::text from iroh_pair_grant_issuances where user_id = ${userId}) as grants, + (select status from iroh_relay_token_issuances where id = ${issuance.id}) as "issuanceStatus", + (select count(*)::text from iroh_account_security_states where user_id = ${userId}) as "securityStates" + `; + expect(state).toEqual({ + revoked: false, + grants: "0", + issuanceStatus: "pending", + securityStates: "0", + }); + }); + + dbTest("atomically consumes a challenge exactly once under concurrency", async () => { + const repo = requiredRepository(); + const deviceId = randomUUID(); + const appInstanceId = randomUUID(); + const endpointId = "10".repeat(32); + const nonceHash = "20".repeat(32); + const pathHintExpiry = new Date(NOW.getTime() + 30 * 60 * 1_000); + const challenge = await Effect.runPromise(repo.issueChallenge({ + userId: "user-registration", + deviceUuid: deviceId, + appInstanceId, + tag: "stable", + endpointId, + identityGeneration: 1, + payloadSha256: "30".repeat(32), + nonceHash, + now: NOW, + expiresAt: new Date(NOW.getTime() + 5 * 60 * 1_000), + })); + const register = () => Effect.runPromise(repo.consumeChallengeAndRegister({ + userId: "user-registration", + challengeId: challenge.id, + nonceHash, + payload: { + route_contract_version: 1, + deviceId, + appInstanceId, + tag: "stable", + platform: "mac", + endpointId, + identityGeneration: 1, + pairingEnabled: true, + capabilities: [], + pathHints: [{ + kind: "direct_address", + value: "10.0.0.20:4433", + source: "lan", + privacy_scope: "local_network", + observed_at: new Date(NOW.getTime() - 5 * 60 * 1_000).toISOString(), + expires_at: pathHintExpiry.toISOString(), + network_profile: { source: "lan", profile_id: "local" }, + }], + }, + now: NOW, + deviceLimitOverrideAllowed: false, + })); + const results = await Promise.allSettled([register(), register()]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + const [{ bindings, consumed, nextExpiry, pathHints }] = await requiredSql()>` + select + (select count(*)::text from iroh_endpoint_bindings) as bindings, + (select count(*)::text from iroh_registration_challenges where consumed_at is not null) as consumed, + (select path_hints_next_expiry from iroh_endpoint_bindings limit 1) as "nextExpiry", + (select path_hints from iroh_endpoint_bindings limit 1) as "pathHints" + `; + expect({ bindings, consumed }).toEqual({ bindings: "1", consumed: "1" }); + expect(nextExpiry).toBeNull(); + expect(pathHints).toEqual([]); + }); + + dbTest("requires revocation before changing an active binding platform", async () => { + const repo = requiredRepository(); + const userId = "user-platform-change"; + const deviceId = randomUUID(); + const appInstanceId = randomUUID(); + const endpointId = "31".repeat(32); + + const register = async (platform: "mac" | "ios", suffix: string, now: Date) => { + const nonceHash = suffix.repeat(64); + const challenge = await Effect.runPromise(repo.issueChallenge({ + userId, + deviceUuid: deviceId, + appInstanceId, + tag: "stable", + endpointId, + identityGeneration: 1, + payloadSha256: `${suffix}${"0".repeat(63)}`, + nonceHash, + now, + expiresAt: new Date(now.getTime() + 5 * 60 * 1_000), + })); + return repo.consumeChallengeAndRegister({ + userId, + challengeId: challenge.id, + nonceHash, + payload: { + route_contract_version: 1, + deviceId, + appInstanceId, + tag: "stable", + platform, + endpointId, + identityGeneration: 1, + pairingEnabled: platform === "mac", + capabilities: [], + pathHints: [], + }, + now, + deviceLimitOverrideAllowed: false, + }); + }; + + await Effect.runPromise(await register("mac", "5", NOW)); + const changed = await Effect.runPromiseExit(await register( + "ios", + "6", + new Date(NOW.getTime() + 1_000), + )); + expect(changed._tag).toBe("Failure"); + const causeError = changed._tag === "Failure" + ? (changed.cause as unknown as { error?: unknown }).error + : undefined; + expect(causeError).toMatchObject({ + _tag: "IrohConflictError", + code: "binding_replacement_requires_revocation", + }); + const [{ platform }] = await requiredSql()>` + select platform from iroh_endpoint_bindings where app_instance_id = ${appInstanceId} + `; + expect(platform).toBe("mac"); + }); + + dbTest("serializes an account-wide registration challenge rate cap", async () => { + const userId = "user-challenge-flood"; + await requiredSql()` + insert into iroh_registration_challenges ( + user_id, device_uuid, app_instance_id, tag, endpoint_id, + identity_generation, payload_sha256, nonce_hash, created_at, expires_at, consumed_at + ) + select + ${userId}, gen_random_uuid(), gen_random_uuid(), 'stable', repeat('3a', 32), + 1, + md5('account-payload-a-' || value::text) || md5('account-payload-b-' || value::text), + md5('account-nonce-a-' || value::text) || md5('account-nonce-b-' || value::text), + ${new Date(NOW.getTime() - 60_000)}, ${new Date(NOW.getTime() + 60_000)}, ${NOW} + from generate_series(1, 119) as values(value) + `; + const issue = (suffix: string) => Effect.runPromiseExit(requiredRepository().issueChallenge({ + userId, + deviceUuid: randomUUID(), + appInstanceId: randomUUID(), + tag: "stable", + endpointId: suffix.repeat(64), + identityGeneration: 1, + payloadSha256: suffix.repeat(64), + nonceHash: `${suffix}${"0".repeat(63)}`, + now: NOW, + expiresAt: new Date(NOW.getTime() + 5 * 60 * 1_000), + })); + + const results = await Promise.all([issue("4"), issue("5")]); + expect(results.filter((result) => result._tag === "Success")).toHaveLength(1); + expect(results.filter((result) => result._tag === "Failure")).toHaveLength(1); + const failure = results.find((result) => result._tag === "Failure"); + const causeError = failure?._tag === "Failure" + ? (failure.cause as unknown as { error?: unknown }).error + : undefined; + expect(causeError).toMatchObject({ + _tag: "IrohQuotaExceededError", + code: "challenge_account_rate_limited", + }); + const [{ total }] = await requiredSql()>` + select count(*)::text as total + from iroh_registration_challenges + where user_id = ${userId} + `; + expect(total).toBe("120"); + }); + + dbTest("enforces globally unique active EndpointIDs and app instances", async () => { + const appInstanceId = randomUUID(); + const endpointId = "40".repeat(32); + await insertBinding({ userId: "user-a", appInstanceId, endpointId }); + await expectPostgresError(insertBinding({ userId: "user-b", endpointId }), "23505"); + await expectPostgresError(insertBinding({ userId: "user-b", appInstanceId, endpointId: "41".repeat(32) }), "23505"); + await expectPostgresError(insertBinding({ userId: "user-a", endpointId: "not-an-endpoint" }), "23514"); + await expectPostgresError(requiredSql()` + insert into iroh_endpoint_bindings ( + user_id, device_uuid, app_instance_id, tag, platform, endpoint_id, identity_generation + ) values ( + 'user-a', ${randomUUID()}, ${randomUUID()}, 'stable', 'linux', ${"42".repeat(32)}, 1 + ) + `, "23514"); + await expectPostgresError(requiredSql()` + insert into iroh_endpoint_bindings ( + user_id, device_uuid, app_instance_id, tag, platform, endpoint_id, identity_generation + ) values ( + 'user-a', ${randomUUID()}, ${randomUUID()}, 'stable', 'mac', ${"43".repeat(32)}, 2147483648 + ) + `, "22003"); + }); + + dbTest("keeps LAN discovery account-scoped and coherent across binding revocation", async () => { + const repo = requiredRepository(); + const userId = "user-lan-revoke"; + const firstBindingId = await insertBinding({ + userId, + endpointId: "44".repeat(32), + }); + const secondBindingId = await insertBinding({ + userId, + endpointId: "45".repeat(32), + }); + const otherBindingId = await insertBinding({ + userId: "user-lan-other", + endpointId: "46".repeat(32), + }); + + const initial = await Effect.runPromise(repo.discoverySnapshot({ userId, now: NOW })); + const otherInitial = await Effect.runPromise(repo.discoverySnapshot({ + userId: "user-lan-other", + now: NOW, + })); + expect(initial.lanDiscoveryGeneration).toBe(1); + expect(initial.bindings.map((binding) => binding.id).sort()).toEqual([ + firstBindingId, + secondBindingId, + ].sort()); + expect(otherInitial).toMatchObject({ + lanDiscoveryGeneration: 1, + bindings: [{ id: otherBindingId }], + }); + + expect(await Effect.runPromise(repo.revokeBinding({ + userId, + bindingId: firstBindingId, + now: NOW, + }))).toBe(true); + const afterFirstRevoke = await Effect.runPromise(repo.discoverySnapshot({ userId, now: NOW })); + expect(afterFirstRevoke.lanDiscoveryGeneration).toBe(2); + expect(afterFirstRevoke.bindings.map((binding) => binding.id)).toEqual([secondBindingId]); + expect(await Effect.runPromise(repo.revokeBinding({ + userId, + bindingId: firstBindingId, + now: new Date(NOW.getTime() + 60_000), + }))).toBe(true); + const [retriedBinding] = await requiredSql()>` + select revoked_at as "revokedAt" + from iroh_endpoint_bindings + where id = ${firstBindingId} + `; + expect(retriedBinding?.revokedAt).toEqual(NOW); + expect((await Effect.runPromise(repo.discoverySnapshot({ userId, now: NOW }))).lanDiscoveryGeneration).toBe(2); + expect(await Effect.runPromise(repo.revokeBinding({ + userId: "user-lan-other", + bindingId: firstBindingId, + now: NOW, + }))).toBe(false); + expect(await Effect.runPromise(repo.revokeBinding({ + userId, + bindingId: randomUUID(), + now: NOW, + }))).toBe(false); + + let concurrentDiscovery: ReturnType | undefined; + await requiredSql().begin(async (revocationSql) => { + await revocationSql` + select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${userId}`}, 0)) + `; + await revocationSql` + update iroh_endpoint_bindings + set revoked_at = ${NOW}, revoked_reason = 'user_requested', + path_hints = '[]'::jsonb, path_hints_next_expiry = null, updated_at = ${NOW} + where id = ${secondBindingId} and user_id = ${userId} and revoked_at is null + `; + await revocationSql` + update iroh_account_security_states + set lan_discovery_generation = lan_discovery_generation + 1, updated_at = ${NOW} + where user_id = ${userId} + `; + concurrentDiscovery = Effect.runPromise(repo.discoverySnapshot({ userId, now: NOW })); + await waitForAdvisoryLockWaiter(); + }); + if (!concurrentDiscovery) throw new Error("concurrent discovery was not started"); + const afterConcurrentRevoke = await concurrentDiscovery; + expect(afterConcurrentRevoke).toMatchObject({ + lanDiscoveryGeneration: 3, + bindings: [], + }); + const otherAfter = await Effect.runPromise(repo.discoverySnapshot({ + userId: "user-lan-other", + now: NOW, + })); + expect(otherAfter).toMatchObject({ + lanDiscoveryGeneration: 1, + bindings: [{ id: otherBindingId }], + }); + }); + + dbTest("serializes the pair-grant hourly quota", async () => { + const repo = requiredRepository(); + const initiatorId = await insertBinding({ userId: "user-pair", platform: "ios", endpointId: "50".repeat(32) }); + const acceptorId = await insertBinding({ userId: "user-pair", platform: "mac", endpointId: "51".repeat(32) }); + for (let index = 0; index < 59; index += 1) { + await requiredSql()` + insert into iroh_pair_grant_issuances ( + user_id, jti, initiator_binding_id, acceptor_binding_id, signing_key_id, + alpn, scope, issued_at, not_before, expires_at + ) values ( + 'user-pair', ${randomUUID()}, ${initiatorId}, ${acceptorId}, 'current', + 'cmux/mobile/1', 'cmux.mobile.attach', + ${new Date(NOW.getTime() - index * 1_000)}, + ${new Date(NOW.getTime() - index * 1_000)}, + ${new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1_000)} + ) + `; + } + const initiator = await pairPeer(initiatorId); + const acceptor = await pairPeer(acceptorId); + const reserve = () => Effect.runPromise(repo.recordPairGrant({ + userId: "user-pair", + jti: randomUUID(), + initiator, + acceptor, + signingKeyId: "current", + alpn: "cmux/mobile/1", + scope: "cmux.mobile.attach", + issuedAt: NOW, + notBefore: NOW, + expiresAt: new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1_000), + })); + const results = await Promise.allSettled([reserve(), reserve()]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + const [{ total }] = await requiredSql()>` + select count(*)::text as total from iroh_pair_grant_issuances where user_id = 'user-pair' + `; + expect(total).toBe("60"); + }); + + dbTest("revalidates pairability and exact signed peers inside the grant transaction", async () => { + const initiatorId = await insertBinding({ + userId: "user-pair-race", + platform: "ios", + endpointId: "52".repeat(32), + }); + const acceptorId = await insertBinding({ + userId: "user-pair-race", + platform: "mac", + endpointId: "53".repeat(32), + }); + const initiator = await pairPeer(initiatorId); + const acceptor = await pairPeer(acceptorId); + await requiredSql()` + update iroh_endpoint_bindings + set pairing_enabled = false + where id = ${acceptorId} + `; + const exit = await Effect.runPromiseExit(requiredRepository().recordPairGrant({ + userId: "user-pair-race", + jti: randomUUID(), + initiator, + acceptor, + signingKeyId: "current", + alpn: "cmux/mobile/1", + scope: "cmux.mobile.attach", + issuedAt: NOW, + notBefore: NOW, + expiresAt: new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1_000), + })); + expect(exit._tag).toBe("Failure"); + const [{ total }] = await requiredSql()>` + select count(*)::text as total + from iroh_pair_grant_issuances + where user_id = 'user-pair-race' + `; + expect(total).toBe("0"); + }); + + dbTest("rejects pair-grant peers that resolve to one physical device", async () => { + const userId = "user-pair-same-device"; + const deviceUuid = randomUUID(); + const initiatorId = await insertBinding({ + userId, + deviceUuid, + platform: "ios", + endpointId: "54".repeat(32), + }); + const acceptorId = await insertBinding({ + userId, + deviceUuid, + platform: "mac", + endpointId: "55".repeat(32), + }); + const exit = await Effect.runPromiseExit(requiredRepository().recordPairGrant({ + userId, + jti: randomUUID(), + initiator: await pairPeer(initiatorId), + acceptor: await pairPeer(acceptorId), + signingKeyId: "current", + alpn: "cmux/mobile/1", + scope: "cmux.mobile.attach", + issuedAt: NOW, + notBefore: NOW, + expiresAt: new Date(NOW.getTime() + 7 * 24 * 60 * 60 * 1_000), + })); + + expect(exit._tag).toBe("Failure"); + expect(String(exit)).toContain("pair_grant_same_device"); + }); + + dbTest("fails attestation finalization when revocation commits during signing", async () => { + const userId = "user-attestation-race"; + const bindingId = await insertBinding({ + userId, + platform: "ios", + endpointId: "56".repeat(32), + }); + const peer = await pairPeer(bindingId); + let finalization: ReturnType | undefined; + await requiredSql().begin(async (revocationSql) => { + await revocationSql` + select pg_advisory_xact_lock(hashtextextended(${`iroh:binding:${userId}`}, 0)) + `; + finalization = Effect.runPromiseExit(requiredRepository().finalizeEndpointAttestation({ + userId, + bindingId, + deviceId: peer.deviceId, + endpointId: peer.endpointId, + identityGeneration: peer.identityGeneration, + platform: peer.platform, + })); + await waitForAdvisoryLockWaiter(); + await revocationSql` + update iroh_endpoint_bindings + set revoked_at = ${NOW}, revoked_reason = 'user_requested' + where id = ${bindingId} + `; + }); + + if (!finalization) throw new Error("attestation finalization was not started"); + const exit = await finalization; + expect(exit._tag).toBe("Failure"); + expect(String(exit)).toContain("IrohNotFoundError"); + }); + + dbTest("serializes relay quota reservations before provider work", async () => { + const repo = requiredRepository(); + const bindingId = await insertBinding({ userId: "user-relay", endpointId: "60".repeat(32) }); + for (let index = 0; index < 2; index += 1) { + await requiredSql()` + insert into iroh_relay_token_issuances ( + user_id, binding_id, endpoint_id_hash, status, requested_at + ) values ( + 'user-relay', ${bindingId}, ${"70".repeat(32)}, 'failed', + ${new Date(NOW.getTime() - index * 1_000)} + ) + `; + } + const reserve = () => Effect.runPromise(repo.reserveRelayIssuance({ + userId: "user-relay", + bindingId, + now: NOW, + })); + const results = await Promise.allSettled([reserve(), reserve()]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + const [{ total }] = await requiredSql()>` + select count(*)::text as total from iroh_relay_token_issuances where binding_id = ${bindingId} + `; + expect(total).toBe("3"); + }); + + dbTest("expires abandoned relay reservations before enforcing endpoint and account quotas", async () => { + const repo = requiredRepository(); + const endpointUserId = "user-relay-abandoned-endpoint"; + const endpointBindingId = await insertBinding({ + userId: endpointUserId, + endpointId: "63".repeat(32), + }); + for (let index = 0; index < 3; index += 1) { + await requiredSql()` + insert into iroh_relay_token_issuances ( + user_id, binding_id, endpoint_id_hash, status, requested_at + ) values ( + ${endpointUserId}, ${endpointBindingId}, ${"64".repeat(32)}, 'pending', + ${new Date(NOW.getTime() - 5 * 60 * 1_000 - index * 1_000)} + ) + `; + } + + await Effect.runPromise(repo.reserveRelayIssuance({ + userId: endpointUserId, + bindingId: endpointBindingId, + now: NOW, + })); + const endpointStatuses = await requiredSql()>` + select status, count(*)::text as total + from iroh_relay_token_issuances + where user_id = ${endpointUserId} + group by status + order by status + `; + expect(endpointStatuses).toEqual([ + { status: "expired", total: "3" }, + { status: "pending", total: "1" }, + ]); + + const accountUserId = "user-relay-abandoned-account"; + const accountBindingIds: string[] = []; + for (let index = 0; index < 10; index += 1) { + const bindingId = await insertBinding({ + userId: accountUserId, + endpointId: (0xa0 + index).toString(16).repeat(32), + }); + accountBindingIds.push(bindingId); + await requiredSql()` + insert into iroh_relay_token_issuances ( + user_id, binding_id, endpoint_id_hash, status, requested_at + ) + select + ${accountUserId}, ${bindingId}, ${"65".repeat(32)}, 'pending', + ${new Date(NOW.getTime() - 15 * 60 * 1_000)} - make_interval(secs => value) + from generate_series(1, 10) as values(value) + `; + } + + await Effect.runPromise(repo.reserveRelayIssuance({ + userId: accountUserId, + bindingId: accountBindingIds[0]!, + now: NOW, + })); + const accountStatuses = await requiredSql()>` + select status, count(*)::text as total + from iroh_relay_token_issuances + where user_id = ${accountUserId} + group by status + order by status + `; + expect(accountStatuses).toEqual([ + { status: "expired", total: "100" }, + { status: "pending", total: "1" }, + ]); + }); + + dbTest("fails relay finalization when revocation commits during provider mint", async () => { + const repo = requiredRepository(); + const endpointId = "61".repeat(32); + const bindingId = await insertBinding({ userId: "user-relay-race", endpointId }); + const reservation = await Effect.runPromise(repo.reserveRelayIssuance({ + userId: "user-relay-race", + bindingId, + now: NOW, + })); + expect(await Effect.runPromise(repo.revokeBinding({ + userId: "user-relay-race", + bindingId, + now: new Date(NOW.getTime() + 1_000), + }))).toBe(true); + expect(await Effect.runPromise(repo.completeRelayIssuance({ + userId: "user-relay-race", + issuanceId: reservation.issuanceId, + bindingId, + endpointId, + tokenHash: "62".repeat(32), + completedAt: new Date(NOW.getTime() + 2_000), + expiresAt: new Date(NOW.getTime() + 24 * 60 * 60 * 1_000), + }))).toBe(false); + const [issuance] = await requiredSql()>` + select status, failure_code as "failureCode" + from iroh_relay_token_issuances + where id = ${reservation.issuanceId} + `; + expect(issuance).toEqual({ + status: "failed", + failureCode: "binding_inactive_after_mint", + }); + }); + + dbTest("global retention clears revoked hints and expired private data from Aurora", async () => { + const repo = requiredRepository(); + const activeId = await insertBinding({ + userId: "user-retention", + endpointId: "80".repeat(32), + pathHints: [ + storedLanHint("10.0.0.1:4433", "2026-07-09T18:55:00.000Z", "2026-07-09T19:00:00.000Z"), + storedLanHint("10.0.0.2:4433", "2026-07-09T19:55:00.000Z", "2026-07-09T20:30:00.000Z"), + ], + }); + const revokedId = await insertBinding({ + userId: "user-retention", + endpointId: "81".repeat(32), + pathHints: [storedLanHint("10.0.0.3:4433", "2026-07-09T19:55:00.000Z", "2026-07-09T20:30:00.000Z")], + }); + const untouchedId = await insertBinding({ + userId: "user-retention", + endpointId: "82".repeat(32), + pathHints: [storedLanHint("10.0.0.4:4433", "2026-07-09T19:55:00.000Z", "2026-07-09T20:30:00.000Z")], + }); + const oldRevokedId = await insertBinding({ + userId: "user-retention", + endpointId: "83".repeat(32), + }); + await requiredSql()` + update iroh_endpoint_bindings + set revoked_at = ${new Date(NOW.getTime() - 31 * 24 * 60 * 60 * 1_000)} + where id = ${oldRevokedId} + `; + const [untouchedBefore] = await requiredSql()>` + select updated_at as "updatedAt" from iroh_endpoint_bindings where id = ${untouchedId} + `; + await requiredSql()` + insert into iroh_pair_grant_issuances ( + user_id, jti, initiator_binding_id, acceptor_binding_id, signing_key_id, + alpn, scope, issued_at, not_before, expires_at + ) values ( + 'user-retention', ${randomUUID()}, ${activeId}, ${revokedId}, 'current', + 'cmux/mobile/1', 'cmux.mobile.attach', ${NOW}, ${NOW}, ${new Date(NOW.getTime() + 1_000)} + ) + `; + await Effect.runPromise(repo.revokeBinding({ userId: "user-retention", bindingId: revokedId, now: NOW })); + await Effect.runPromise(repo.pruneExpiredStateGlobally({ now: NOW })); + const rows = await requiredSql()>` + select id::text, path_hints as "pathHints" + from iroh_endpoint_bindings + where id in (${activeId}, ${revokedId}) + order by id + `; + expect(rows.find((row) => row.id === activeId)?.pathHints).toHaveLength(1); + expect(rows.find((row) => row.id === revokedId)?.pathHints).toEqual([]); + const [grant] = await requiredSql()>` + select revoked_at as "revokedAt" from iroh_pair_grant_issuances where acceptor_binding_id = ${revokedId} + `; + expect(grant?.revokedAt).not.toBeNull(); + const [retentionState] = await requiredSql()>` + select + exists(select 1 from iroh_endpoint_bindings where id = ${oldRevokedId}) as "oldExists", + (select updated_at from iroh_endpoint_bindings where id = ${untouchedId}) as "untouchedUpdatedAt" + `; + expect(retentionState?.oldExists).toBe(false); + expect(retentionState?.untouchedUpdatedAt.getTime()).toBe(untouchedBefore?.updatedAt.getTime()); + }); + + dbTest("retention skips locked hint rows and drains multiple indexed batches", async () => { + const lockedId = await insertBinding({ + userId: "user-retention-lock", + endpointId: "84".repeat(32), + pathHints: [storedLanHint("10.0.0.10:4433", "2026-07-09T18:55:00.000Z", "2026-07-09T19:00:00.000Z")], + }); + const unlockedId = await insertBinding({ + userId: "user-retention-lock", + endpointId: "85".repeat(32), + pathHints: [storedLanHint("10.0.0.11:4433", "2026-07-09T18:55:00.000Z", "2026-07-09T19:00:00.000Z")], + }); + await requiredSql().begin(async (lockingSql) => { + await lockingSql`select id from iroh_endpoint_bindings where id = ${lockedId} for update`; + await Effect.runPromise(requiredRepository().pruneExpiredStateGlobally({ now: NOW })); + const rows = await lockingSql>` + select id::text, jsonb_array_length(path_hints)::int as hints + from iroh_endpoint_bindings + where id in (${lockedId}, ${unlockedId}) + `; + expect(rows.find((row) => row.id === lockedId)?.hints).toBe(1); + expect(rows.find((row) => row.id === unlockedId)?.hints).toBe(0); + }); + await Effect.runPromise(requiredRepository().pruneExpiredStateGlobally({ now: NOW })); + const [locked] = await requiredSql()>` + select + jsonb_array_length(path_hints)::int as hints, + path_hints_next_expiry as "nextExpiry" + from iroh_endpoint_bindings + where id = ${lockedId} + `; + expect(locked).toEqual({ hints: 0, nextExpiry: null }); + + await requiredSql()` + insert into iroh_registration_challenges ( + user_id, device_uuid, app_instance_id, tag, endpoint_id, + identity_generation, payload_sha256, nonce_hash, created_at, expires_at + ) + select + 'user-retention-batch', gen_random_uuid(), gen_random_uuid(), 'stable', + repeat('86', 32), 1, + md5('payload-a-' || value::text) || md5('payload-b-' || value::text), + md5('nonce-a-' || value::text) || md5('nonce-b-' || value::text), + ${new Date(NOW.getTime() - 3 * 24 * 60 * 60 * 1_000)}, + ${new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1_000)} + from generate_series(1, ${IROH_RETENTION_BATCH_SIZE * 2 + 1}) as values(value) + `; + await Effect.runPromise(requiredRepository().pruneExpiredStateGlobally({ now: NOW })); + const [{ remaining }] = await requiredSql()>` + select count(*)::text as remaining + from iroh_registration_challenges + where user_id = 'user-retention-batch' + `; + expect(remaining).toBe("0"); + + await requiredSql()` + insert into iroh_registration_challenges ( + user_id, device_uuid, app_instance_id, tag, endpoint_id, + identity_generation, payload_sha256, nonce_hash, created_at, expires_at + ) + select + 'user-retention-scoped', gen_random_uuid(), gen_random_uuid(), 'stable', + repeat('87', 32), 1, + md5('scoped-payload-a-' || value::text) || md5('scoped-payload-b-' || value::text), + md5('scoped-nonce-a-' || value::text) || md5('scoped-nonce-b-' || value::text), + ${new Date(NOW.getTime() - 3 * 24 * 60 * 60 * 1_000)}, + ${new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1_000)} + from generate_series(1, ${IROH_RETENTION_BATCH_SIZE + 1}) as values(value) + `; + await Effect.runPromise(requiredRepository().pruneExpiredState({ + userId: "user-retention-scoped", + now: NOW, + })); + const [{ scopedRemaining }] = await requiredSql()>` + select count(*)::text as "scopedRemaining" + from iroh_registration_challenges + where user_id = 'user-retention-scoped' + `; + expect(scopedRemaining).toBe("1"); + }); + + dbTest("global retention reports backlog when its row budget is exhausted", async () => { + await requiredSql()` + insert into iroh_registration_challenges ( + user_id, device_uuid, app_instance_id, tag, endpoint_id, + identity_generation, payload_sha256, nonce_hash, created_at, expires_at + ) + select + 'user-retention-budget', gen_random_uuid(), gen_random_uuid(), 'stable', + repeat('88', 32), 1, + md5('budget-payload-a-' || value::text) || md5('budget-payload-b-' || value::text), + md5('budget-nonce-a-' || value::text) || md5('budget-nonce-b-' || value::text), + ${new Date(NOW.getTime() - 3 * 24 * 60 * 60 * 1_000)}, + ${new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1_000)} + from generate_series(1, ${IROH_RETENTION_BATCH_SIZE + 1}) as values(value) + `; + const cleanupInput = { + now: NOW, + maxRows: IROH_RETENTION_BATCH_SIZE, + maxDurationMs: 30_000, + }; + const result = await Effect.runPromise( + requiredRepository().pruneExpiredStateGlobally(cleanupInput), + ) as unknown as { + rowsProcessed: number; + backlog: boolean; + budgetExhausted: "rows" | "time" | null; + byCategory: { expiredChallenges: number }; + }; + + expect(result).toMatchObject({ + rowsProcessed: IROH_RETENTION_BATCH_SIZE, + backlog: true, + budgetExhausted: "rows", + byCategory: { expiredChallenges: IROH_RETENTION_BATCH_SIZE }, + }); + const [{ remaining }] = await requiredSql()>` + select count(*)::text as remaining + from iroh_registration_challenges + where user_id = 'user-retention-budget' + `; + expect(remaining).toBe("1"); + }); + + dbTest("retention and cascade lookups use their dedicated indexes", async () => { + const userId = "user-retention-index-plan"; + const initiatorId = await insertBinding({ + userId, + platform: "ios", + endpointId: "89".repeat(32), + }); + const acceptorId = await insertBinding({ + userId, + platform: "mac", + endpointId: "8a".repeat(32), + }); + await requiredSql()` + insert into iroh_pair_grant_issuances ( + user_id, jti, initiator_binding_id, acceptor_binding_id, signing_key_id, + alpn, scope, issued_at, not_before, expires_at + ) values ( + ${userId}, ${randomUUID()}, ${initiatorId}, ${acceptorId}, 'current', + 'cmux/mobile/1', 'cmux.mobile.attach', ${NOW}, ${NOW}, ${new Date(NOW.getTime() + 1_000)} + ) + `; + await requiredSql()` + update iroh_endpoint_bindings + set revoked_at = ${new Date(NOW.getTime() - 31 * 24 * 60 * 60 * 1_000)} + where id = ${initiatorId} + `; + + await requiredSql().begin(async (planSql) => { + await planSql`set local enable_seqscan = off`; + const initiatorPlan = await planSql` + explain (format json) + select id + from iroh_pair_grant_issuances + where initiator_binding_id = ${initiatorId} + `; + const fullUserPlan = await planSql` + explain (format json) + select id + from iroh_endpoint_bindings + where user_id = ${userId} + `; + const revokedCleanupPlan = await planSql` + explain (format json) + select id + from iroh_endpoint_bindings + where user_id = ${userId} + and revoked_at < ${NOW} + order by revoked_at, id + `; + + expect(JSON.stringify(initiatorPlan)).toContain("iroh_pair_grant_issuances_initiator_idx"); + expect(JSON.stringify(fullUserPlan)).toContain("iroh_endpoint_bindings_user_idx"); + expect(JSON.stringify(revokedCleanupPlan)).toContain("iroh_endpoint_bindings_user_revoked_idx"); + }); + }); + + dbTest("binding deletion cascades grant and relay audit rows", async () => { + const bindingId = await insertBinding({ userId: "user-delete", endpointId: "90".repeat(32) }); + const peerId = await insertBinding({ userId: "user-delete", endpointId: "91".repeat(32) }); + await requiredSql()` + insert into iroh_pair_grant_issuances ( + user_id, jti, initiator_binding_id, acceptor_binding_id, signing_key_id, + alpn, scope, issued_at, not_before, expires_at + ) values ( + 'user-delete', ${randomUUID()}, ${bindingId}, ${peerId}, 'current', + 'cmux/mobile/1', 'cmux.mobile.attach', ${NOW}, ${NOW}, ${new Date(NOW.getTime() + 1_000)} + ) + `; + await requiredSql()` + insert into iroh_relay_token_issuances ( + user_id, binding_id, endpoint_id_hash, status, requested_at + ) values ('user-delete', ${bindingId}, ${"92".repeat(32)}, 'pending', ${NOW}) + `; + await requiredSql()`delete from iroh_endpoint_bindings where id = ${bindingId}`; + const [{ grants, relays }] = await requiredSql()>` + select + (select count(*)::text from iroh_pair_grant_issuances) as grants, + (select count(*)::text from iroh_relay_token_issuances) as relays + `; + expect({ grants, relays }).toEqual({ grants: "0", relays: "0" }); + }); +}); + +async function insertBinding(input: { + readonly userId: string; + readonly deviceUuid?: string; + readonly appInstanceId?: string; + readonly endpointId: string; + readonly platform?: "mac" | "ios"; + readonly pathHints?: unknown[]; +}): Promise { + const [row] = await requiredSql()>` + insert into iroh_endpoint_bindings ( + user_id, device_uuid, app_instance_id, tag, platform, endpoint_id, + identity_generation, pairing_enabled, capabilities, path_hints, + path_hints_next_expiry + ) values ( + ${input.userId}, ${input.deviceUuid ?? randomUUID()}, ${input.appInstanceId ?? randomUUID()}, 'stable', + ${input.platform ?? "mac"}, ${input.endpointId}, 1, true, '[]'::jsonb, + ${requiredSql().json((input.pathHints ?? []) as never)}, + ${earliestStoredHintExpiry(input.pathHints ?? [])} + ) returning id::text + `; + if (!row) throw new Error("binding insert returned no row"); + return row.id; +} + +async function pairPeer(bindingId: string): Promise { + const [row] = await requiredSql()>` + select + id::text as "bindingId", + device_uuid::text as "deviceId", + tag, + platform, + endpoint_id as "endpointId", + identity_generation as "identityGeneration" + from iroh_endpoint_bindings + where id = ${bindingId} + `; + if (!row) throw new Error("binding not found"); + return row; +} + +function earliestStoredHintExpiry(pathHints: readonly unknown[]): Date | null { + const expiries = pathHints.flatMap((hint) => { + const value = (hint as { expires_at?: unknown } | null)?.expires_at; + return typeof value === "string" ? [new Date(value).getTime()] : []; + }); + return expiries.length > 0 ? new Date(Math.min(...expiries)) : null; +} + +function requiredSql(): Sql { + if (!sql) throw new Error("test database not initialized"); + return sql; +} + +function requiredRepository(): IrohRepositoryShape { + if (!repository) throw new Error("test repository not initialized"); + return repository; +} + +function storedLanHint(value: string, observedAt: string, expiresAt: string): Record { + return { + kind: "direct_address", + value, + source: "lan", + privacy_scope: "local_network", + observed_at: observedAt, + expires_at: expiresAt, + network_profile: { source: "lan", profile_id: "local" }, + }; +} + +async function expectPostgresError(promise: Promise, expectedCode: string): Promise { + try { + await promise; + } catch (error) { + expect((error as { code?: unknown }).code).toBe(expectedCode); + return; + } + throw new Error(`expected Postgres error ${expectedCode}`); +} + +async function waitForAdvisoryLockWaiter(): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const [row] = await requiredSql()>` + select exists ( + select 1 + from pg_stat_activity + where wait_event_type = 'Lock' + and query ilike '%pg_advisory_xact_lock%' + ) as waiting + `; + if (row?.waiting) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("timed out waiting for the Iroh mutation to reach the account deletion fence"); +} diff --git a/web/tests/iroh-firewall.test.ts b/web/tests/iroh-firewall.test.ts new file mode 100644 index 000000000000..013feebebcef --- /dev/null +++ b/web/tests/iroh-firewall.test.ts @@ -0,0 +1,90 @@ +import { createHash } from "node:crypto"; +import { describe, expect, test } from "bun:test"; +import { createIrohVercelFirewallCheck } from "../services/iroh/firewall"; + +describe("Iroh Vercel firewall client", () => { + test("uses a trusted deployment host and preserves the firewall key contract", async () => { + let capturedUrl = ""; + let capturedInit: RequestInit | undefined; + const fetcher = (async (input: RequestInfo | URL, init?: RequestInit) => { + capturedUrl = String(input); + capturedInit = init; + return new Response(null, { status: 204 }); + }) as typeof fetch; + const check = createIrohVercelFirewallCheck({ + environment: { + VERCEL_URL: "cmux-preview.vercel.app", + VERCEL_PROJECT_PRODUCTION_URL: "cmux.com", + VERCEL_AUTOMATION_BYPASS_SECRET: "bypass", + RATE_LIMIT_SECRET: "rate-secret", + }, + fetch: fetcher, + }); + const signal = new AbortController().signal; + const result = await check("iroh-rule", { + request: new Request("https://attacker.example/api/devices/iroh", { + headers: { + host: "attacker.example", + authorization: "Bearer account-token", + cookie: "other=ignored; _vercel_jwt=preview-token", + "x-real-ip": "192.0.2.4", + }, + }), + rateLimitKey: "account-hash", + signal, + }); + + expect(result).toEqual({ rateLimited: false }); + expect(capturedUrl).toBe("https://cmux.com/.well-known/vercel/rate-limit-api/iroh-rule"); + expect(capturedInit?.redirect).toBe("manual"); + expect(capturedInit?.signal).toBe(signal); + const headers = new Headers(capturedInit?.headers); + const digest = createHash("sha256") + .update("account-hashiroh-rulebypassrate-secret") + .digest("hex"); + expect(headers.get("x-vercel-rate-limit-key")).toBe(`account-hash-${digest}`); + expect(headers.get("x-vercel-protection-bypass")).toBe("bypass"); + expect(headers.get("cookie")).toBe("_vercel_jwt=preview-token"); + expect(headers.get("x-rr-authorization")).toBe("Bearer account-token"); + }); + + test("passes cancellation through to the network fetch", async () => { + let aborted = false; + const fetcher = ((_input: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) return reject(new Error("missing signal")); + signal.addEventListener("abort", () => { + aborted = true; + reject(signal.reason); + }, { once: true }); + })) as typeof fetch; + const check = createIrohVercelFirewallCheck({ + environment: { VERCEL_URL: "cmux-preview.vercel.app" }, + fetch: fetcher, + }); + const controller = new AbortController(); + const pending = check("iroh-rule", { + request: new Request("https://cmux-preview.vercel.app/api/devices/iroh"), + rateLimitKey: "account-hash", + signal: controller.signal, + }); + + controller.abort(new Error("deadline")); + await expect(pending).rejects.toThrow("deadline"); + expect(aborted).toBe(true); + }); + + test("fails closed on unexpected firewall responses", async () => { + const fetcher = (async () => new Response(null, { status: 500 })) as typeof fetch; + const check = createIrohVercelFirewallCheck({ + environment: { VERCEL_URL: "cmux-preview.vercel.app" }, + fetch: fetcher, + }); + + await expect(check("iroh-rule", { + request: new Request("https://cmux-preview.vercel.app/api/devices/iroh"), + rateLimitKey: "account-hash", + signal: new AbortController().signal, + })).rejects.toThrow("unexpected_firewall_status"); + }); +}); diff --git a/web/tests/iroh-model-crypto.test.ts b/web/tests/iroh-model-crypto.test.ts new file mode 100644 index 000000000000..ad3d6fb96da1 --- /dev/null +++ b/web/tests/iroh-model-crypto.test.ts @@ -0,0 +1,805 @@ +import { describe, expect, test } from "bun:test"; +import { createHash, createHmac, generateKeyPairSync, sign } from "node:crypto"; +import { readFileSync } from "node:fs"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { + assertCurrentSigningKey, + createOfflinePairSessionRecord, + deriveAccountSubject, + deriveLanRendezvousKey, + parseVerificationKeys, + registrationTranscript, + signEndpointAttestation, + signPairGrant, + verifyEndpointAttestation, + verifyEndpointRegistrationSignature, + verifyAndConsumeOfflineSameAccountPair, + verifyPairGrant, + type EndpointAttestationClaims, + type PairGrantClaims, + type PairGrantPeer, +} from "../services/iroh/crypto"; +import { + IrohTrustBrokerConfig, + type IrohTrustBrokerConfigShape, +} from "../services/iroh/config"; +import { + IROH_ALPN, + IROH_ENDPOINT_ATTESTATION_SCOPE, + IROH_ENDPOINT_ATTESTATION_VERSION, + IROH_PAIR_GRANT_TYP, + IROH_PAIR_SCOPE, + MANAGED_RELAY_URLS, + parseRegistrationPayload, +} from "../services/iroh/model"; +import { + parseMinterHmacSecret, + parseMinterUrl, + readBoundedMinterJson, + IrohRelayMinter, + IrohRelayMinterLive, +} from "../services/iroh/relayMinter"; + +const NOW = new Date("2026-07-09T20:00:00.000Z"); + +describe("Iroh LAN rendezvous derivation", () => { + test("derives an account-scoped generation key with a server-only HMAC secret", () => { + const secret = Buffer.alloc(32, 0x4c); + const encodedSecret = secret.toString("base64"); + const expected = createHmac("sha256", secret) + .update("cmux/iroh/lan-rendezvous/v1\0", "utf8") + .update("account-a", "utf8") + .update("\0", "utf8") + .update("1", "utf8") + .digest("base64url"); + + expect(deriveLanRendezvousKey(encodedSecret, "account-a", 1)).toBe(expected); + expect(deriveLanRendezvousKey(encodedSecret, "account-b", 1)).not.toBe(expected); + expect(deriveLanRendezvousKey(encodedSecret, "account-a", 2)).not.toBe(expected); + expect( + deriveLanRendezvousKey(Buffer.alloc(32, 0x4d).toString("base64"), "account-a", 1), + ).not.toBe(expected); + expect(() => deriveLanRendezvousKey(Buffer.alloc(31).toString("base64"), "account-a", 1)) + .toThrow(); + }); +}); + +describe("Iroh endpoint registration signatures", () => { + test("rejects a valid signature encoded with noncanonical padding", () => { + const keys = generateKeyPairSync("ed25519"); + const endpointId = keys.publicKey + .export({ format: "der", type: "spki" }) + .subarray(-32) + .toString("hex"); + const input = { + endpointId, + challengeId: "10000000-0000-4000-8000-000000000001", + nonce: Buffer.alloc(32, 0x42).toString("base64url"), + payloadSha256: "ab".repeat(32), + }; + const signature = sign( + null, + registrationTranscript(input), + keys.privateKey, + ).toString("base64url"); + + expect(() => verifyEndpointRegistrationSignature({ ...input, signature })).not.toThrow(); + expect(() => verifyEndpointRegistrationSignature({ + ...input, + signature: `${signature}=`, + })).toThrow(); + }); +}); + +function registrationPayload(pathHint: Record) { + return { + route_contract_version: 1, + deviceId: "10000000-0000-4000-8000-000000000001", + appInstanceId: "20000000-0000-4000-8000-000000000001", + tag: "stable", + platform: "mac", + endpointId: "11".repeat(32), + identityGeneration: 1, + pairingEnabled: true, + capabilities: ["terminal", "artifacts"], + pathHints: [pathHint], + }; +} + +function directHint(overrides: Record = {}) { + return { + kind: "direct_address", + value: "203.0.113.42:4433", + source: "native", + privacy_scope: "public_internet", + observed_at: "2026-07-09T19:55:00.000Z", + expires_at: "2026-07-09T20:45:00.000Z", + ...overrides, + }; +} + +describe("Iroh route wire contract", () => { + test("matches the versioned Swift Codable path-hint shape", () => { + const fixture = JSON.parse(readFileSync( + new URL("../../tests/fixtures/iroh/path-hint-v1.json", import.meta.url), + "utf8", + )) as Record; + const parsed = parseRegistrationPayload(registrationPayload(fixture), NOW); + expect(parsed.route_contract_version).toBe(1); + expect(parsed.pathHints).toEqual([fixture]); + expect(Object.keys(parsed.pathHints[0]!).sort()).toEqual([ + "expires_at", + "kind", + "observed_at", + "privacy_scope", + "source", + "value", + ]); + }); + + test("accepts provider-qualified private hints", () => { + const hint = directHint({ + value: "100.64.10.12:4433", + source: "tailscale", + privacy_scope: "private_network", + network_profile: { source: "tailscale", profile_id: "tailnet-prod" }, + }); + expect(parseRegistrationPayload(registrationPayload(hint), NOW).pathHints[0]).toEqual(hint); + }); + + test("allows globally routed address space inside an explicit custom VPN profile", () => { + const hint = directHint({ + value: "8.8.4.4:4433", + source: "custom_vpn", + privacy_scope: "private_network", + network_profile: { source: "custom_vpn", profile_id: "corp-routes" }, + }); + expect(parseRegistrationPayload(registrationPayload(hint), NOW).pathHints[0]).toEqual(hint); + }); + + test("canonicalizes accepted IPv6 spellings", () => { + const hint = directHint({ + value: "[FD00:0000:0000:0000:0000:0000:0000:0001]:4433", + source: "tailscale", + privacy_scope: "private_network", + network_profile: { source: "tailscale", profile_id: "tailnet-prod" }, + }); + expect(parseRegistrationPayload(registrationPayload(hint), NOW).pathHints[0]?.value).toBe("[fd00::1]:4433"); + }); + + test("rejects an unknown platform before it can affect Mac pairability", () => { + expect(() => parseRegistrationPayload({ + ...registrationPayload(directHint({ value: "8.8.8.8:4433" })), + platform: "linux", + }, NOW)).toThrow(); + }); + + test("rejects identity generations that overflow the Postgres integer contract", () => { + expect(() => parseRegistrationPayload({ + ...registrationPayload(directHint({ value: "8.8.8.8:4433" })), + identityGeneration: 2_147_483_648, + }, NOW)).toThrow(); + }); + + test("accepts only endpoint-reported home relays from the separate fleet allowlist", () => { + const relayHint = directHint({ kind: "relay_url", value: MANAGED_RELAY_URLS[0] }); + expect(parseRegistrationPayload(registrationPayload(relayHint), NOW).pathHints[0]?.value).toBe(MANAGED_RELAY_URLS[0]); + const payload = registrationPayload(relayHint); + payload.pathHints = MANAGED_RELAY_URLS.slice(0, 3).map((value) => directHint({ kind: "relay_url", value })); + expect(() => parseRegistrationPayload(payload, NOW)).toThrow(); + }); + + for (const [name, hint] of [ + ["RFC1918 advertised as public", directHint({ value: "10.0.0.1:4433" })], + ["ULA advertised as public", directHint({ value: "[fd00::1]:4433" })], + ["loopback", directHint({ value: "127.0.0.1:4433" })], + ["multicast", directHint({ value: "224.0.0.1:4433" })], + ["cloud metadata", directHint({ value: "169.254.169.254:4433", source: "lan", privacy_scope: "local_network", network_profile: { source: "lan", profile_id: "local" } })], + ["IPv6 link-local", directHint({ value: "[fe80::1]:4433", source: "lan", privacy_scope: "local_network", network_profile: { source: "lan", profile_id: "local" } })], + ["IPv6 remote interface scope", directHint({ value: "[2001:4860::1%en0]:4433" })], + ["alternate-spelling IPv6 documentation range", directHint({ value: "[2001:0db8::1]:4433" })], + ["IPv4 leading zero", directHint({ value: "8.8.08.8:4433" })], + ["port leading zero", directHint({ value: "8.8.8.8:04433" })], + ["LAN marked private", directHint({ value: "192.168.1.2:4433", source: "lan", privacy_scope: "private_network", network_profile: { source: "lan", profile_id: "local" } })], + ["Tailscale marked local", directHint({ value: "100.64.1.2:4433", source: "tailscale", privacy_scope: "local_network", network_profile: { source: "tailscale", profile_id: "ts" } })], + ["stale observation", directHint({ value: "8.8.8.8:4433", observed_at: "2026-07-09T18:00:00.000Z" })], + ["overlong lifetime", directHint({ value: "8.8.8.8:4433", observed_at: "2026-07-09T19:55:00.000Z", expires_at: "2026-07-09T21:00:01.000Z" })], + ["unmanaged relay", directHint({ kind: "relay_url", value: "https://example.com/" })], + ] as const) { + test(`rejects ${name}`, () => { + expect(() => parseRegistrationPayload(registrationPayload(hint), NOW)).toThrow(); + }); + } +}); + +describe("Iroh pair-grant verification", () => { + const current = generateKeyPairSync("ed25519"); + const previous = generateKeyPairSync("ed25519"); + const currentPrivate = current.privateKey.export({ format: "pem", type: "pkcs8" }).toString(); + const previousPrivate = previous.privateKey.export({ format: "pem", type: "pkcs8" }).toString(); + const currentPublic = current.publicKey.export({ format: "der", type: "spki" }).toString("base64"); + const previousPublic = previous.publicKey.export({ format: "der", type: "spki" }).toString("base64"); + const keys = new Map([ + ["current", currentPublic], + ["previous", previousPublic], + ]); + const initiator: PairGrantPeer = { + bindingId: "30000000-0000-4000-8000-000000000001", + deviceId: "10000000-0000-4000-8000-000000000001", + tag: "ios", + platform: "ios", + endpointId: "22".repeat(32), + identityGeneration: 2, + }; + const acceptor: PairGrantPeer = { + bindingId: "30000000-0000-4000-8000-000000000002", + deviceId: "10000000-0000-4000-8000-000000000002", + tag: "stable", + platform: "mac", + endpointId: "33".repeat(32), + identityGeneration: 4, + }; + const claims: PairGrantClaims = { + jti: "40000000-0000-4000-8000-000000000001", + iat: 1_783_627_200, + nbf: 1_783_627_195, + exp: 1_784_232_000, + alpn: IROH_ALPN, + scope: IROH_PAIR_SCOPE, + initiator, + acceptor, + }; + + test("accepts the current key while retaining a previous verification key", () => { + const token = signPairGrant({ privateKeyPem: currentPrivate, kid: "current", claims }); + expect(verifyPairGrant(token, keys, { initiator, acceptor, nowSeconds: claims.iat }).jti).toBe(claims.jti); + const previousToken = signPairGrant({ privateKeyPem: previousPrivate, kid: "previous", claims }); + expect(verifyPairGrant(previousToken, keys, { + initiator, + acceptor, + nowSeconds: claims.iat, + }).jti).toBe(claims.jti); + }); + + test("rejects peer and identity-generation substitution", () => { + const token = signPairGrant({ privateKeyPem: currentPrivate, kid: "current", claims }); + expect(() => verifyPairGrant(token, keys, { + initiator: { ...initiator, identityGeneration: 3 }, + acceptor, + nowSeconds: claims.iat, + })).toThrow(); + expect(() => verifyPairGrant(token, keys, { + initiator: { ...initiator, endpointId: "44".repeat(32) }, + acceptor, + nowSeconds: claims.iat, + })).toThrow(); + }); + + test("rejects grants whose peers claim the same physical device", () => { + const sameDeviceClaims = { + ...claims, + acceptor: { ...acceptor, deviceId: initiator.deviceId }, + }; + const token = manuallySignedJws( + { alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid: "current" }, + sameDeviceClaims, + current.privateKey, + ); + + expect(() => verifyPairGrant(token, keys, { + initiator, + acceptor: sameDeviceClaims.acceptor, + nowSeconds: claims.iat, + })).toThrow(); + }); + + test("rejects identity or team claims outside the fixed grant contract", () => { + const token = manuallySignedJws( + { alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid: "current" }, + { ...claims, userId: "must-not-appear" }, + current.privateKey, + ); + expect(() => verifyPairGrant(token, keys, { initiator, acceptor, nowSeconds: claims.iat })).toThrow(); + }); + + test("rejects a noncanonical signature segment", () => { + const token = signPairGrant({ privateKeyPem: currentPrivate, kid: "current", claims }); + expect(() => verifyPairGrant(`${token}!`, keys, { + initiator, + acceptor, + nowSeconds: claims.iat, + })).toThrow(); + }); + + for (const [name, header, changedClaims, nowSeconds] of [ + ["alg", { alg: "ES256", typ: IROH_PAIR_GRANT_TYP, kid: "current" }, claims, claims.iat], + ["kid", { alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid: "unknown" }, claims, claims.iat], + ["ALPN", { alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid: "current" }, { ...claims, alpn: "cmux/other/1" }, claims.iat], + ["platform direction", { alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid: "current" }, { ...claims, initiator: { ...claims.initiator, platform: "mac" } }, claims.iat], + ["expiry", { alg: "EdDSA", typ: IROH_PAIR_GRANT_TYP, kid: "current" }, claims, claims.exp], + ] as const) { + test(`rejects invalid ${name}`, () => { + const token = manuallySignedJws(header, changedClaims, current.privateKey); + expect(() => verifyPairGrant(token, keys, { initiator, acceptor, nowSeconds })).toThrow(); + }); + } +}); + +describe("Iroh grant verification keys and offline endpoint attestations", () => { + const current = generateKeyPairSync("ed25519"); + const previous = generateKeyPairSync("ed25519"); + const currentPrivate = current.privateKey.export({ format: "pem", type: "pkcs8" }).toString(); + const currentPublic = current.publicKey.export({ format: "der", type: "spki" }).toString("base64"); + const previousPublic = previous.publicKey.export({ format: "der", type: "spki" }).toString("base64"); + const parsedKeys = parseVerificationKeys(JSON.stringify({ + version: 1, + current_kid: "current", + keys: [ + { kid: "previous", alg: "EdDSA", spki_der_base64: previousPublic }, + { kid: "current", alg: "EdDSA", spki_der_base64: currentPublic }, + ], + })); + const nowSeconds = 1_783_627_200; + const subject = deriveAccountSubject(Buffer.alloc(32, 0x51).toString("base64"), "private-user-id"); + const initiator: EndpointAttestationClaims = { + version: IROH_ENDPOINT_ATTESTATION_VERSION, + jti: "40000000-0000-4000-8000-000000000011", + sub: subject, + bindingId: "30000000-0000-4000-8000-000000000011", + deviceId: "10000000-0000-4000-8000-000000000011", + endpointId: "44".repeat(32), + identityGeneration: 2, + platform: "ios", + iat: nowSeconds, + nbf: nowSeconds - 5, + exp: nowSeconds + 86_400, + alpn: IROH_ALPN, + scope: IROH_ENDPOINT_ATTESTATION_SCOPE, + }; + const acceptor: EndpointAttestationClaims = { + ...initiator, + jti: "40000000-0000-4000-8000-000000000012", + bindingId: "30000000-0000-4000-8000-000000000012", + deviceId: "10000000-0000-4000-8000-000000000012", + endpointId: "55".repeat(32), + identityGeneration: 3, + platform: "mac", + }; + + test("publishes only canonical current and previous public keys and binds the signer", () => { + expect(parsedKeys.keySet).toEqual({ + version: 1, + current_kid: "current", + keys: [ + { kid: "current", alg: "EdDSA", spki_der_base64: currentPublic }, + { kid: "previous", alg: "EdDSA", spki_der_base64: previousPublic }, + ], + }); + expect(JSON.stringify(parsedKeys.keySet)).not.toContain("PRIVATE KEY"); + expect(() => assertCurrentSigningKey({ + privateKeyPem: currentPrivate, + kid: "current", + verificationKeys: parsedKeys, + })).not.toThrow(); + expect(() => assertCurrentSigningKey({ + privateKeyPem: previous.privateKey.export({ format: "pem", type: "pkcs8" }).toString(), + kid: "current", + verificationKeys: parsedKeys, + })).toThrow(); + }); + + test("supports prepublish, signer flip, and delayed old-key retirement", () => { + const oldPrivate = previous.privateKey.export({ format: "pem", type: "pkcs8" }).toString(); + const prepublished = parseVerificationKeys(JSON.stringify({ + version: 1, + current_kid: "old", + keys: [ + { kid: "old", alg: "EdDSA", spki_der_base64: previousPublic }, + { kid: "next", alg: "EdDSA", spki_der_base64: currentPublic }, + ], + })); + expect(() => assertCurrentSigningKey({ + privateKeyPem: oldPrivate, + kid: "old", + verificationKeys: prepublished, + })).not.toThrow(); + const oldToken = signEndpointAttestation({ + privateKeyPem: oldPrivate, + kid: "old", + claims: initiator, + }); + + const flipped = parseVerificationKeys(JSON.stringify({ + version: 1, + current_kid: "next", + keys: [ + { kid: "next", alg: "EdDSA", spki_der_base64: currentPublic }, + { kid: "old", alg: "EdDSA", spki_der_base64: previousPublic }, + ], + })); + expect(() => assertCurrentSigningKey({ + privateKeyPem: currentPrivate, + kid: "next", + verificationKeys: flipped, + })).not.toThrow(); + expect(verifyEndpointAttestation( + oldToken, + flipped.publicKeys, + { ...endpointExpectation(initiator), nowSeconds }, + ).jti).toBe(initiator.jti); + + const retired = parseVerificationKeys(JSON.stringify({ + version: 1, + current_kid: "next", + keys: [{ kid: "next", alg: "EdDSA", spki_der_base64: currentPublic }], + })); + expect(() => verifyEndpointAttestation( + oldToken, + retired.publicKeys, + { ...endpointExpectation(initiator), nowSeconds }, + )).toThrow(); + }); + + test("requires two fresh endpoint-bound attestations with the same opaque account subject", () => { + const initiatorToken = signEndpointAttestation({ + privateKeyPem: currentPrivate, + kid: "current", + claims: initiator, + }); + const acceptorToken = signEndpointAttestation({ + privateKeyPem: currentPrivate, + kid: "current", + claims: acceptor, + }); + const expected = { + initiator: { ...endpointExpectation(initiator), platform: "ios" as const }, + acceptor: { ...endpointExpectation(acceptor), platform: "mac" as const }, + nowSeconds, + } as const; + const proof = Buffer.alloc(32, 0x61).toString("base64url"); + const session = createOfflinePairSessionRecord({ + sessionId: "70000000-0000-4000-8000-000000000001", + proof, + acceptor: expected.acceptor, + nowSeconds, + expiresAtSeconds: nowSeconds + 300, + }); + const invitation = { version: 1 as const, sessionId: session.sessionId, proof }; + + expect(verifyAndConsumeOfflineSameAccountPair({ + initiatorAttestation: initiatorToken, + acceptorAttestation: acceptorToken, + publicKeys: parsedKeys.publicKeys, + expected, + session, + invitation, + }).acceptor.endpointId).toBe(acceptor.endpointId); + expect(session.consumedAtSeconds).toBe(nowSeconds); + expect(() => verifyAndConsumeOfflineSameAccountPair({ + initiatorAttestation: initiatorToken, + acceptorAttestation: acceptorToken, + publicKeys: parsedKeys.publicKeys, + expected, + session, + invitation, + })).toThrow(); + + const missingSession = createOfflinePairSessionRecord({ + sessionId: "70000000-0000-4000-8000-000000000002", + proof, + acceptor: expected.acceptor, + nowSeconds, + expiresAtSeconds: nowSeconds + 300, + }); + expect(() => verifyAndConsumeOfflineSameAccountPair({ + initiatorAttestation: "", + acceptorAttestation: acceptorToken, + publicKeys: parsedKeys.publicKeys, + expected, + session: missingSession, + invitation: { ...invitation, sessionId: missingSession.sessionId }, + })).toThrow(); + expect(missingSession.consumedAtSeconds).toBeNull(); + + const wrongProofSession = createOfflinePairSessionRecord({ + sessionId: "70000000-0000-4000-8000-000000000004", + proof, + acceptor: expected.acceptor, + nowSeconds, + expiresAtSeconds: nowSeconds + 300, + }); + expect(() => verifyAndConsumeOfflineSameAccountPair({ + initiatorAttestation: initiatorToken, + acceptorAttestation: acceptorToken, + publicKeys: parsedKeys.publicKeys, + expected, + session: wrongProofSession, + invitation: { + version: 1, + sessionId: wrongProofSession.sessionId, + proof: Buffer.alloc(32, 0x62).toString("base64url"), + }, + })).toThrow(); + expect(wrongProofSession.consumedAtSeconds).toBeNull(); + + const otherAccountToken = signEndpointAttestation({ + privateKeyPem: currentPrivate, + kid: "current", + claims: { ...acceptor, sub: Buffer.alloc(32, 0x52).toString("base64url") }, + }); + const mismatchSession = createOfflinePairSessionRecord({ + sessionId: "70000000-0000-4000-8000-000000000003", + proof, + acceptor: expected.acceptor, + nowSeconds, + expiresAtSeconds: nowSeconds + 300, + }); + expect(() => verifyAndConsumeOfflineSameAccountPair({ + initiatorAttestation: initiatorToken, + acceptorAttestation: otherAccountToken, + publicKeys: parsedKeys.publicKeys, + expected, + session: mismatchSession, + invitation: { ...invitation, sessionId: mismatchSession.sessionId }, + })).toThrow(); + expect(mismatchSession.consumedAtSeconds).toBeNull(); + }); + + test("rejects endpoint substitution, expiry, extra identity claims, and noncanonical signatures", () => { + const token = signEndpointAttestation({ + privateKeyPem: currentPrivate, + kid: "current", + claims: initiator, + }); + expect(() => verifyEndpointAttestation(token, parsedKeys.publicKeys, { + ...endpointExpectation(initiator), + endpointId: "66".repeat(32), + nowSeconds, + })).toThrow(); + expect(() => verifyEndpointAttestation(token, parsedKeys.publicKeys, { + ...endpointExpectation(initiator), + nowSeconds: initiator.exp, + })).toThrow(); + expect(() => verifyEndpointAttestation(`${token}!`, parsedKeys.publicKeys, { + ...endpointExpectation(initiator), + nowSeconds, + })).toThrow(); + const tokenWithRawIdentity = manuallySignedJws( + { alg: "EdDSA", typ: "cmux-endpoint-attestation-v1+jwt", kid: "current" }, + { ...initiator, userId: "must-not-appear" }, + current.privateKey, + ); + expect(() => verifyEndpointAttestation(tokenWithRawIdentity, parsedKeys.publicKeys, { + ...endpointExpectation(initiator), + nowSeconds, + })).toThrow(); + }); + + test("rejects malformed, oversized, duplicate, and signer-misaligned key sets", () => { + expect(() => parseVerificationKeys(undefined)).toThrow(); + expect(() => parseVerificationKeys(JSON.stringify({ current: currentPublic }))).toThrow(); + expect(() => parseVerificationKeys(JSON.stringify({ + version: 1, + current_kid: "current", + keys: [ + { kid: "current", alg: "EdDSA", spki_der_base64: currentPublic }, + { kid: "current", alg: "EdDSA", spki_der_base64: currentPublic }, + ], + }))).toThrow(); + expect(() => parseVerificationKeys(JSON.stringify({ + version: 1, + current_kid: "missing", + keys: [{ kid: "current", alg: "EdDSA", spki_der_base64: currentPublic }], + }))).toThrow(); + expect(() => parseVerificationKeys("x".repeat(32_769))).toThrow(); + }); +}); + +describe("Iroh relay minter response bounds", () => { + test("the production Live layer sends the canonical fetch body and HMAC", async () => { + const secret = Buffer.alloc(32, 0x63); + const originalFetch = globalThis.fetch; + let captured: { url: string; init: RequestInit } | undefined; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + captured = { url: String(input), init: init ?? {} }; + return new Response(JSON.stringify({ + token: "a".repeat(64), + expiresAt: "2026-07-10T20:00:00.000Z", + }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + try { + const config: IrohTrustBrokerConfigShape = { + relayMinterUrl: "https://minter.cmux.test/api/relay-token", + relayMinterHmacSecretBase64: secret.toString("base64"), + relayMinterInsecureLoopbackOptIn: false, + deviceLimitOverrideEnabled: false, + deviceLimitOverrideUserIds: new Set(), + deviceLimitOverrideEnvironments: new Set(), + deploymentEnvironment: "test", + isVercelDeployment: false, + }; + const layer = IrohRelayMinterLive.pipe( + Layer.provide(Layer.succeed(IrohTrustBrokerConfig, config)), + ); + const result = await Effect.runPromise( + Effect.gen(function* () { + const minter = yield* IrohRelayMinter; + return yield* minter.mint({ + endpointId: "ab".repeat(32), + lifetimeSeconds: 86_400, + now: NOW, + }); + }).pipe(Effect.provide(layer)), + ); + expect(result.token).toBe("a".repeat(64)); + } finally { + globalThis.fetch = originalFetch; + } + + const request = captured; + expect(request?.url).toBe("https://minter.cmux.test/api/relay-token"); + expect(request?.init.method).toBe("POST"); + expect(request?.init.redirect).toBe("error"); + const body = JSON.stringify({ endpointId: "ab".repeat(32), lifetimeSeconds: 86_400 }); + expect(request?.init.body).toBe(body); + const timestamp = String(Math.floor(NOW.getTime() / 1_000)); + const expectedSignature = createHmac("sha256", secret) + .update(`POST\n/api/relay-token\n${timestamp}\n${createHash("sha256").update(body).digest("hex")}`) + .digest("base64url"); + expect(new Headers(request?.init.headers).get("x-cmux-iroh-timestamp")).toBe(timestamp); + expect(new Headers(request?.init.headers).get("x-cmux-iroh-signature")).toBe(expectedSignature); + expect(new Headers(request?.init.headers).get("content-type")).toBe("application/json"); + }); + + test("preserves an invalid EndpointID as an input error", async () => { + const config: IrohTrustBrokerConfigShape = { + relayMinterUrl: "https://minter.cmux.test/api/relay-token", + relayMinterHmacSecretBase64: Buffer.alloc(32, 0x63).toString("base64"), + relayMinterInsecureLoopbackOptIn: false, + deviceLimitOverrideEnabled: false, + deviceLimitOverrideUserIds: new Set(), + deviceLimitOverrideEnvironments: new Set(), + deploymentEnvironment: "test", + isVercelDeployment: false, + }; + const layer = IrohRelayMinterLive.pipe( + Layer.provide(Layer.succeed(IrohTrustBrokerConfig, config)), + ); + const exit = await Effect.runPromiseExit( + Effect.gen(function* () { + const minter = yield* IrohRelayMinter; + return yield* minter.mint({ + endpointId: "not-an-endpoint-id", + lifetimeSeconds: 86_400, + now: NOW, + }); + }).pipe(Effect.provide(layer)), + ); + + expect(exit._tag).toBe("Failure"); + const failure = exit._tag === "Failure" + ? Option.getOrUndefined(Cause.failureOption(exit.cause)) + : undefined; + expect((failure as { _tag?: string } | undefined)?._tag).toBe("IrohInvalidInputError"); + }); + + test("matches the Rust minter HMAC wire fixture", () => { + const fixture = JSON.parse(readFileSync( + new URL("../../tests/fixtures/iroh/relay-minter-request-v1.json", import.meta.url), + "utf8", + )) as { path: string; timestamp: string; body: string; signature: string }; + const bodyHash = createHash("sha256").update(fixture.body).digest("hex"); + const signature = createHmac("sha256", Buffer.alloc(32, 0x42)) + .update(`POST\n${fixture.path}\n${fixture.timestamp}\n${bodyHash}`, "utf8") + .digest("base64url"); + expect(fixture.path).toBe("/api/relay-token"); + expect(signature).toBe(fixture.signature); + }); + + test("requires a canonical 32-byte-or-longer HMAC secret", () => { + const valid = Buffer.alloc(32, 9).toString("base64"); + expect(parseMinterHmacSecret(valid)).toEqual(Buffer.alloc(32, 9)); + expect(() => parseMinterHmacSecret("%%%%" + valid)).toThrow(); + expect(() => parseMinterHmacSecret(Buffer.alloc(16, 9).toString("base64"))).toThrow(); + expect(() => parseMinterHmacSecret(Buffer.alloc(257, 9).toString("base64"))).toThrow(); + expect(() => parseMinterHmacSecret(Buffer.alloc(32, 0xff).toString("base64url"))).toThrow(); + }); + + test("allows plaintext only for opted-in local loopback minters", () => { + const localDevelopment = { + allowInsecureLoopback: true, + deploymentEnvironment: "development", + isVercelDeployment: false, + }; + expect(parseMinterUrl("https://minter.cmux.test/api/relay-token").pathname).toBe("/api/relay-token"); + for (const value of [ + "http://localhost:49152/api/relay-token", + "http://127.0.0.1:49152/api/relay-token", + "http://[::1]:49152/api/relay-token", + ]) { + expect(parseMinterUrl(value, localDevelopment).protocol).toBe("http:"); + } + for (const value of [ + "http://minter.cmux.test/api/relay-token", + "http://192.168.1.10:49152/api/relay-token", + "https://minter.cmux.test/api/relay-token/", + "https://minter.cmux.test/other", + "https://minter.cmux.test/api/relay-token?debug=1", + ]) { + expect(() => parseMinterUrl(value, localDevelopment)).toThrow(); + } + expect(() => parseMinterUrl("http://localhost:49152/api/relay-token", { + ...localDevelopment, + allowInsecureLoopback: false, + })).toThrow(); + expect(() => parseMinterUrl("http://localhost:49152/api/relay-token", { + ...localDevelopment, + deploymentEnvironment: "production", + })).toThrow(); + expect(() => parseMinterUrl("http://localhost:49152/api/relay-token", { + ...localDevelopment, + deploymentEnvironment: "preview", + isVercelDeployment: true, + })).toThrow(); + }); + + test("parses a bounded response", async () => { + const body = { token: "a".repeat(32), expiresAt: "2026-07-10T20:00:00.000Z" }; + expect(await readBoundedMinterJson(new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + }))).toEqual(body); + }); + + test("rejects a non-JSON or expanded minter response contract", async () => { + await expect(readBoundedMinterJson(new Response("{}"))).rejects.toThrow(); + await expect(readBoundedMinterJson(new Response(JSON.stringify({ + token: "a".repeat(32), + expiresAt: "2026-07-10T20:00:00.000Z", + servicesSecret: "must-not-appear", + }), { + headers: { "content-type": "application/json" }, + }))).rejects.toThrow(); + }); + + test("rejects oversized fixed-length and chunked responses", async () => { + await expect(readBoundedMinterJson(new Response("{}", { + headers: { "content-length": "999999", "content-type": "application/json" }, + }))).rejects.toThrow(); + + const chunk = new Uint8Array(20_000); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(chunk); + controller.enqueue(chunk); + controller.close(); + }, + }); + await expect(readBoundedMinterJson(new Response(stream, { + headers: { "content-type": "application/json" }, + }))).rejects.toThrow(); + }); +}); + +function manuallySignedJws(header: unknown, claims: unknown, privateKey: CryptoKey | import("node:crypto").KeyObject): string { + const encodedHeader = Buffer.from(JSON.stringify(header)).toString("base64url"); + const encodedClaims = Buffer.from(JSON.stringify(claims)).toString("base64url"); + const input = `${encodedHeader}.${encodedClaims}`; + const signature = sign(null, Buffer.from(input), privateKey as import("node:crypto").KeyObject).toString("base64url"); + return `${input}.${signature}`; +} + +function endpointExpectation(claims: EndpointAttestationClaims) { + return { + bindingId: claims.bindingId, + deviceId: claims.deviceId, + endpointId: claims.endpointId, + identityGeneration: claims.identityGeneration, + platform: claims.platform, + }; +} diff --git a/web/tests/iroh-route-handler.test.ts b/web/tests/iroh-route-handler.test.ts new file mode 100644 index 000000000000..f7af5ecb0a5b --- /dev/null +++ b/web/tests/iroh-route-handler.test.ts @@ -0,0 +1,342 @@ +import { describe, expect, test } from "bun:test"; +import * as Effect from "effect/Effect"; +import { IrohDatabaseError, IrohQuotaExceededError } from "../services/iroh/errors"; +import type { IrohFirewallCheck } from "../services/iroh/firewall"; +import { handleIrohRoute, IrohFirewallAdmission } from "../services/iroh/routeHandler"; +import type { IrohTrustBrokerShape } from "../services/iroh/trustBroker"; +import type { AuthedUser } from "../services/vms/auth"; +import { GET as retentionGet } from "../app/api/internal/iroh/retention/route"; + +const USER: AuthedUser = { + id: "personal-user-id", + displayName: null, + primaryEmail: null, + billingCustomerType: "team", + billingTeamId: "selected-team-id", + selectedTeamId: "selected-team-id", + teams: [{ id: "selected-team-id", displayName: null, billingPlanId: null }], + teamIds: ["selected-team-id"], + userBillingPlanId: null, + billingPlanId: null, +}; + +describe("Iroh route boundary", () => { + test("requires authentication before returning the public verification-key set", async () => { + let called = false; + const response = await handleIrohRoute(new Request("https://cmux.test/api/devices/iroh"), "discover", { + verify: async () => null, + broker: broker({ + discover: () => { + called = true; + return Effect.succeed({ grant_verification_keys: { version: 1, keys: [] } }); + }, + }), + }); + expect(response.status).toBe(401); + expect(called).toBe(false); + }); + + test("fails closed when the firewall check rejects", async () => { + let brokerCalled = false; + const dependencies = { + verify: async () => USER, + broker: broker({ + discover: () => { + brokerCalled = true; + return Effect.succeed({ bindings: [] }); + }, + }), + firewall: { + id: "iroh-test-rule", + check: async () => { + throw new Error("firewall unavailable"); + }, + }, + }; + + const response = await handleIrohRoute( + new Request("https://cmux.test/api/devices/iroh"), + "discover", + dependencies, + ); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "iroh_service_unavailable" }); + expect(brokerCalled).toBe(false); + }); + + test("bounds a firewall check that never settles", async () => { + let brokerCalled = false; + let aborted = false; + const check: IrohFirewallCheck = (_id, { signal }) => new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + aborted = true; + reject(signal.reason); + }, { once: true }); + }); + const dependencies = { + verify: async () => USER, + broker: broker({ + discover: () => { + brokerCalled = true; + return Effect.succeed({ bindings: [] }); + }, + }), + firewall: { + id: "iroh-test-rule", + timeoutMs: 10, + check, + }, + }; + + const response = await handleIrohRoute( + new Request("https://cmux.test/api/devices/iroh"), + "discover", + dependencies, + ); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "iroh_service_unavailable" }); + expect(brokerCalled).toBe(false); + expect(aborted).toBe(true); + }); + + test("caps timed-out firewall work per identity and across the worker", async () => { + const admission = new IrohFirewallAdmission(2); + let started = 0; + let aborted = 0; + const check: IrohFirewallCheck = (_id, { signal }) => { + started += 1; + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + aborted += 1; + reject(signal.reason); + }, { once: true }); + }); + }; + const firewall = { + id: "iroh-test-rule", + timeoutMs: 5, + admission, + check, + }; + const discover = (userId: string) => handleIrohRoute( + new Request("https://cmux.test/api/devices/iroh"), + "discover", + { + verify: async () => ({ ...USER, id: userId }), + broker: broker({ discover: () => Effect.succeed({ bindings: [] }) }), + firewall, + }, + ); + + const responses = await Promise.all([ + discover("user-1"), + discover("user-1"), + discover("user-2"), + discover("user-3"), + ]); + expect(responses.map((response) => response.status)).toEqual([503, 503, 503, 503]); + expect(started).toBe(2); + expect(aborted).toBe(2); + expect(admission.activeCount).toBe(0); + }); + + test("aborts timed-out firewall work and admits recovery", async () => { + const admission = new IrohFirewallAdmission(2); + let started = 0; + let aborted = false; + let brokerCalls = 0; + const firewall = { + id: "iroh-test-rule", + timeoutMs: 5, + admission, + check: (_id: string, options?: unknown) => { + started += 1; + if (started > 1) return Promise.resolve({ rateLimited: false }); + const signal = (options as { signal?: AbortSignal } | undefined)?.signal; + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => { + aborted = true; + reject(signal.reason); + }, { once: true }); + }); + }, + }; + const discover = () => handleIrohRoute( + new Request("https://cmux.test/api/devices/iroh"), + "discover", + { + verify: async () => USER, + broker: broker({ + discover: () => { + brokerCalls += 1; + return Effect.succeed({ bindings: [] }); + }, + }), + firewall, + }, + ); + + expect((await discover()).status).toBe(503); + expect(aborted).toBe(true); + expect((await discover()).status).toBe(200); + expect(started).toBe(2); + expect(brokerCalls).toBe(1); + expect(admission.activeCount).toBe(0); + }); + + test("authenticates before reading an oversized body", async () => { + let called = false; + const response = await handleIrohRoute(new Request("https://cmux.test/api/devices/iroh/challenge", { + method: "POST", + body: "x".repeat(70_000), + }), "challenge", { + verify: async () => null, + broker: broker({ issueChallenge: () => { called = true; return Effect.succeed({}); } }), + }); + expect(response.status).toBe(401); + expect(called).toBe(false); + }); + + test("caps a chunked body while streaming and rejects a missing body", async () => { + let called = false; + const chunk = new Uint8Array(40_000); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(chunk); + controller.enqueue(chunk); + controller.close(); + }, + }); + const oversizedInit: RequestInit & { duplex: "half" } = { + method: "POST", + headers: { + authorization: "Bearer test-access", + "x-stack-refresh-token": "test-refresh", + "content-type": "application/json", + }, + body: stream, + duplex: "half", + }; + const oversized = await handleIrohRoute(new Request( + "https://cmux.test/api/devices/iroh/challenge", + oversizedInit, + ), "challenge", { + verify: async () => USER, + broker: broker({ issueChallenge: () => { called = true; return Effect.succeed({}); } }), + }); + expect(oversized.status).toBe(413); + expect(called).toBe(false); + + const missing = await handleIrohRoute(new Request("https://cmux.test/api/devices/iroh/challenge", { + method: "POST", + headers: { + authorization: "Bearer test-access", + "x-stack-refresh-token": "test-refresh", + "content-type": "application/json", + }, + }), "challenge", { + verify: async () => USER, + broker: broker(), + }); + expect(missing.status).toBe(400); + expect(await missing.json()).toEqual({ error: "missing_body" }); + }); + + test("uses exact personal user id and ignores selected team membership", async () => { + let receivedUserId = ""; + const response = await handleIrohRoute(new Request("https://cmux.test/api/devices/iroh", { + method: "GET", + }), "discover", { + verify: async () => USER, + broker: broker({ + discover: (userId) => { + receivedUserId = userId; + return Effect.succeed({ bindings: [] }); + }, + }), + }); + expect(response.status).toBe(200); + expect(receivedUserId).toBe("personal-user-id"); + expect(receivedUserId).not.toBe("selected-team-id"); + }); + + test("maps DB-authoritative quota failures to typed 429 with Retry-After", async () => { + const response = await handleIrohRoute(authedPost("/api/devices/iroh/relay-token", { + bindingId: "30000000-0000-4000-8000-000000000001", + }), "relay_token", { + verify: async () => USER, + broker: broker({ + issueRelayToken: () => Effect.fail(new IrohQuotaExceededError({ + code: "relay_endpoint_10m_quota", + retryAfterSeconds: 417, + })), + }), + }); + expect(response.status).toBe(429); + expect(response.headers.get("retry-after")).toBe("417"); + expect(await response.json()).toEqual({ + error: "relay_endpoint_10m_quota", + retry_after_seconds: 417, + }); + }); + + test("does not expose database implementation details in service failures", async () => { + const response = await handleIrohRoute(authedPost("/api/devices/iroh/challenge", {}), "challenge", { + verify: async () => USER, + broker: broker({ + issueChallenge: () => Effect.fail(new IrohDatabaseError({ + operation: "issue_challenge", + cause: { category: "connection" }, + })), + }), + }); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ error: "iroh_service_unavailable" }); + }); +}); + +describe("Iroh retention route", () => { + test("fails closed without the cron secret and rejects a wrong token", async () => { + const previous = process.env.CRON_SECRET; + try { + delete process.env.CRON_SECRET; + expect((await retentionGet(new Request("https://cmux.test/api/internal/iroh/retention"))).status).toBe(503); + process.env.CRON_SECRET = "expected-secret"; + expect((await retentionGet(new Request("https://cmux.test/api/internal/iroh/retention", { + headers: { authorization: "Bearer wrong-secret" }, + }))).status).toBe(401); + } finally { + if (previous === undefined) delete process.env.CRON_SECRET; + else process.env.CRON_SECRET = previous; + } + }); +}); + +function authedPost(path: string, body: unknown): Request { + return new Request(`https://cmux.test${path}`, { + method: "POST", + headers: { + authorization: "Bearer test-access", + "x-stack-refresh-token": "test-refresh", + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); +} + +function broker(overrides: Partial = {}): IrohTrustBrokerShape { + const unavailable = () => Effect.die(new Error("unexpected broker operation")); + return { + issueChallenge: unavailable, + register: unavailable, + discover: unavailable, + issueEndpointAttestation: unavailable, + revoke: unavailable, + issuePairGrant: unavailable, + issueRelayToken: unavailable, + ...overrides, + }; +} diff --git a/web/tests/iroh-trust-broker.test.ts b/web/tests/iroh-trust-broker.test.ts new file mode 100644 index 000000000000..713102644351 --- /dev/null +++ b/web/tests/iroh-trust-broker.test.ts @@ -0,0 +1,866 @@ +import { describe, expect, test } from "bun:test"; +import { generateKeyPairSync, randomUUID, sign } from "node:crypto"; +import * as Effect from "effect/Effect"; +import { + deviceLimitOverrideAllowed, + type IrohTrustBrokerConfigShape, +} from "../services/iroh/config"; +import { + parseVerificationKeys, + registrationTranscript, + verifyEndpointAttestation, +} from "../services/iroh/crypto"; +import { + IrohConflictError, + IrohForbiddenError, + IrohNotFoundError, + IrohQuotaExceededError, + IrohRelayMintError, +} from "../services/iroh/errors"; +import { + IROH_RELAY_TOKEN_LIFETIME_SECONDS, + MANAGED_RELAY_URLS, + sha256, + type IrohRegistrationPayload, +} from "../services/iroh/model"; +import type { + IrohBindingRecord, + IrohChallengeRecord, + IrohRepositoryShape, +} from "../services/iroh/repository"; +import type { IrohRelayMinterShape } from "../services/iroh/relayMinter"; +import { makeIrohTrustBroker } from "../services/iroh/trustBroker"; + +const NOW = new Date("2026-07-09T20:00:00.000Z"); +const USER_A = "user-a"; +const USER_B = "user-b"; + +describe("Iroh trust broker registration", () => { + test("registers a valid endpoint proof and mints relay credentials after commit", async () => { + const fixture = makeFixture(); + const request = await fixture.signedRegistration(); + const result = await Effect.runPromise(fixture.broker.register(USER_A, request, NOW)) as { + binding: { endpoint_id: string }; + relay: { status: string; token: string }; + }; + expect(result.binding.endpoint_id).toBe(fixture.endpointId); + expect(result.relay.status).toBe("issued"); + expect(fixture.repository.bindings).toHaveLength(1); + expect(fixture.repository.bindings[0]?.pathHints).toEqual([]); + expect(fixture.minter.calls).toBe(1); + }); + + test("relay failure cannot roll back an authenticated registration", async () => { + const fixture = makeFixture({ minterFailure: true }); + const result = await Effect.runPromise( + fixture.broker.register(USER_A, await fixture.signedRegistration(), NOW), + ) as { relay: { status: string } }; + expect(result.relay.status).toBe("unavailable"); + expect(fixture.repository.bindings).toHaveLength(1); + }); + + test("does not mint another relay token when refreshing the same binding", async () => { + const fixture = makeFixture(); + await Effect.runPromise(fixture.broker.register( + USER_A, + await fixture.signedRegistration(), + NOW, + )); + + const refreshed = await Effect.runPromise(fixture.broker.register( + USER_A, + await fixture.signedRegistration(), + new Date(NOW.getTime() + 1_000), + )) as { relay: { status: string } }; + + expect(refreshed.relay.status).toBe("not_requested"); + expect(fixture.minter.calls).toBe(1); + }); + + test("rejects the wrong key and a changed payload", async () => { + const wrongKeyFixture = makeFixture(); + const wrongRequest = await wrongKeyFixture.signedRegistration(); + const otherKey = generateKeyPairSync("ed25519"); + wrongRequest.signature = sign( + null, + registrationTranscript({ + challengeId: wrongRequest.challengeId, + nonce: wrongRequest.nonce, + payloadSha256: sha256(Buffer.from(wrongRequest.payload, "base64url")), + }), + otherKey.privateKey, + ).toString("base64url"); + await expectEffectFailure( + wrongKeyFixture.broker.register(USER_A, wrongRequest, NOW), + "IrohForbiddenError", + ); + + const changedFixture = makeFixture(); + const changedRequest = await changedFixture.signedRegistration(); + const changed = JSON.parse(Buffer.from(changedRequest.payload, "base64url").toString()) as Record; + changed.tag = "redirected"; + changedRequest.payload = Buffer.from(JSON.stringify(changed)).toString("base64url"); + await expectEffectFailure( + changedFixture.broker.register(USER_A, changedRequest, NOW), + "IrohForbiddenError", + ); + }); + + test("rejects expired and replayed challenges", async () => { + const expired = makeFixture(); + await expectEffectFailure( + expired.broker.register( + USER_A, + await expired.signedRegistration(), + new Date(NOW.getTime() + 6 * 60 * 1_000), + ), + "IrohForbiddenError", + ); + + const replay = makeFixture(); + const request = await replay.signedRegistration(); + await Effect.runPromise(replay.broker.register(USER_A, request, NOW)); + await expectEffectFailure(replay.broker.register(USER_A, request, NOW), "IrohConflictError"); + }); + + test("requires revocation/reapproval for endpoint or generation replacement", async () => { + const fixture = makeFixture(); + await Effect.runPromise(fixture.broker.register(USER_A, await fixture.signedRegistration(), NOW)); + const replacement = makeFixture({ + repository: fixture.repository, + appInstanceId: fixture.appInstanceId, + deviceId: fixture.deviceId, + identityGeneration: 2, + }); + await expectEffectFailure( + replacement.broker.register(USER_A, await replacement.signedRegistration(), NOW), + "IrohConflictError", + ); + expect(fixture.repository.bindings).toHaveLength(1); + expect(fixture.repository.bindings[0]!.endpointId).toBe(fixture.endpointId); + }); +}); + +describe("Iroh discovery and grants", () => { + test("makes owned binding revocation retry-safe without rotating LAN state twice", async () => { + const fixture = makeFixture(); + const active = binding({ userId: USER_A }); + fixture.repository.bindings.push(active); + + const first = await Effect.runPromise(fixture.broker.revoke( + USER_A, + { bindingId: active.id }, + NOW, + )); + const firstRevokedAt = active.revokedAt; + expect(first).toEqual({ revoked: true, lan_rendezvous_rotated: true }); + expect(firstRevokedAt).toEqual(NOW); + const firstDiscovery = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + lan_rendezvous: { generation: number }; + }; + expect(firstDiscovery.lan_rendezvous.generation).toBe(2); + + const retry = await Effect.runPromise(fixture.broker.revoke( + USER_A, + { bindingId: active.id }, + new Date(NOW.getTime() + 60_000), + )); + expect(retry).toEqual(first); + expect(active.revokedAt).toEqual(firstRevokedAt); + const retryDiscovery = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + lan_rendezvous: { generation: number }; + }; + expect(retryDiscovery.lan_rendezvous.generation).toBe(2); + + await expectEffectFailure( + fixture.broker.revoke(USER_B, { bindingId: active.id }, NOW), + "IrohNotFoundError", + ); + await expectEffectFailure( + fixture.broker.revoke(USER_A, { bindingId: randomUUID() }, NOW), + "IrohNotFoundError", + ); + }); + + test("never exposes another user through shared team context", async () => { + const fixture = makeFixture(); + await Effect.runPromise(fixture.broker.register(USER_A, await fixture.signedRegistration(), NOW)); + const discovered = await Effect.runPromise(fixture.broker.discover(USER_B, NOW)) as { + bindings: unknown[]; + }; + expect(discovered.bindings).toEqual([]); + }); + + test("returns the relay fleet and authenticated current/previous public keys", async () => { + const fixture = makeFixture(); + fixture.repository.bindings.push(binding({ + userId: USER_A, + pathHints: [{ + kind: "direct_address", + value: "10.0.0.2:4433", + source: "lan", + privacy_scope: "local_network", + observed_at: "2026-07-09T18:00:00.000Z", + expires_at: "2026-07-09T19:00:00.000Z", + network_profile: { source: "lan", profile_id: "local" }, + }], + })); + const discovered = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + relay_fleet: string[]; + bindings: Array<{ path_hints: unknown[] }>; + grant_verification_keys: { + current_kid: string; + keys: Array<{ kid: string; spki_der_base64: string }>; + }; + }; + expect(discovered.relay_fleet).toEqual([...MANAGED_RELAY_URLS]); + expect(discovered.bindings[0]!.path_hints).toEqual([]); + expect(fixture.repository.bindings[0]!.pathHints).toEqual([]); + expect(discovered.grant_verification_keys.current_kid).toBe("current"); + expect(discovered.grant_verification_keys.keys.map((key) => key.kid)).toEqual([ + "current", + "previous", + ]); + expect(JSON.stringify(discovered.grant_verification_keys)).not.toContain("PRIVATE KEY"); + }); + + test("defensively withholds unexpired direct hints from discovery", async () => { + const fixture = makeFixture(); + fixture.repository.bindings.push(binding({ + userId: USER_A, + pathHints: [{ + kind: "direct_address", + value: "10.0.0.2:4433", + source: "lan", + privacy_scope: "local_network", + observed_at: "2026-07-09T19:55:00.000Z", + expires_at: "2026-07-09T20:30:00.000Z", + network_profile: { source: "lan", profile_id: "local" }, + }], + })); + + const discovered = await Effect.runPromise(fixture.broker.discover(USER_A, NOW)) as { + bindings: Array<{ path_hints: unknown[] }>; + }; + expect(discovered.bindings[0]?.path_hints).toEqual([]); + }); + + test("does not combine a pre-revocation binding with a post-revocation LAN generation", async () => { + const fixture = makeFixture(); + const active = binding({ userId: USER_A }); + fixture.repository.bindings.push(active); + let releaseSnapshot: (() => void) | undefined; + const snapshotCanRead = new Promise((resolve) => { + releaseSnapshot = resolve; + }); + let didBeginSnapshot: (() => void) | undefined; + const snapshotStarted = new Promise((resolve) => { + didBeginSnapshot = resolve; + }); + fixture.repository.beforeDiscoverySnapshot = async () => { + didBeginSnapshot?.(); + await snapshotCanRead; + }; + + const discovery = Effect.runPromise(fixture.broker.discover(USER_A, NOW)); + await snapshotStarted; + await Effect.runPromise(fixture.broker.revoke(USER_A, { bindingId: active.id }, NOW)); + releaseSnapshot?.(); + const result = await discovery as { + bindings: unknown[]; + lan_rendezvous: { generation: number }; + }; + + expect(result.bindings).toEqual([]); + expect(result.lan_rendezvous.generation).toBe(2); + }); + + test("pair grants require two same-user bindings and a pairable Mac", async () => { + const fixture = makeFixture(); + const initiator = binding({ userId: USER_A, platform: "ios", pairingEnabled: false }); + const acceptor = binding({ userId: USER_A, platform: "mac", pairingEnabled: true }); + fixture.repository.bindings.push(initiator, acceptor); + const result = await Effect.runPromise(fixture.broker.issuePairGrant(USER_A, { + initiatorBindingId: initiator.id, + acceptorBindingId: acceptor.id, + }, NOW)) as { grant: string }; + expect(result.grant.split(".")).toHaveLength(3); + expect(fixture.repository.pairGrantAudits).toHaveLength(1); + expect(JSON.stringify(fixture.repository.pairGrantAudits[0])).not.toContain(result.grant); + + acceptor.userId = USER_B; + await expectEffectFailure(fixture.broker.issuePairGrant(USER_A, { + initiatorBindingId: initiator.id, + acceptorBindingId: acceptor.id, + }, NOW), "IrohNotFoundError"); + }); + + test("pair grants require an iOS initiator and revalidate both peers at commit", async () => { + const wrongPlatform = makeFixture(); + const macInitiator = binding({ userId: USER_A, platform: "mac" }); + const macAcceptor = binding({ userId: USER_A, platform: "mac", pairingEnabled: true }); + wrongPlatform.repository.bindings.push(macInitiator, macAcceptor); + await expectEffectFailure(wrongPlatform.broker.issuePairGrant(USER_A, { + initiatorBindingId: macInitiator.id, + acceptorBindingId: macAcceptor.id, + }, NOW), "IrohForbiddenError"); + + const raced = makeFixture(); + const iosInitiator = binding({ userId: USER_A, platform: "ios" }); + const pairableMac = binding({ userId: USER_A, platform: "mac", pairingEnabled: true }); + raced.repository.bindings.push(iosInitiator, pairableMac); + raced.repository.beforeRecordPairGrant = () => { + pairableMac.revokedAt = NOW; + }; + await expectEffectFailure(raced.broker.issuePairGrant(USER_A, { + initiatorBindingId: iosInitiator.id, + acceptorBindingId: pairableMac.id, + }, NOW), "IrohNotFoundError"); + expect(raced.repository.pairGrantAudits).toHaveLength(0); + }); + + test("pair grants require two distinct physical devices", async () => { + const fixture = makeFixture(); + const deviceUuid = randomUUID(); + const iosInitiator = binding({ + userId: USER_A, + deviceUuid, + platform: "ios", + }); + const macAcceptor = binding({ + userId: USER_A, + deviceUuid, + platform: "mac", + pairingEnabled: true, + }); + fixture.repository.bindings.push(iosInitiator, macAcceptor); + + await expectEffectFailure(fixture.broker.issuePairGrant(USER_A, { + initiatorBindingId: iosInitiator.id, + acceptorBindingId: macAcceptor.id, + }, NOW), "IrohForbiddenError"); + expect(fixture.repository.pairGrantAudits).toHaveLength(0); + }); + + test("issues a short-lived opaque same-account attestation only for an owned active binding", async () => { + const fixture = makeFixture(); + const active = binding({ userId: USER_A, platform: "ios", identityGeneration: 4 }); + fixture.repository.bindings.push(active); + const result = await Effect.runPromise(fixture.broker.issueEndpointAttestation(USER_A, { + bindingId: active.id, + }, NOW)) as { + attestation_version: number; + attestation: string; + expires_at: string; + grant_verification_keys: { current_kid: string }; + }; + expect(result.attestation_version).toBe(1); + expect(result.grant_verification_keys.current_kid).toBe("current"); + expect(new Date(result.expires_at).getTime() - NOW.getTime()).toBe(24 * 60 * 60 * 1_000); + const payload = JSON.parse(Buffer.from(result.attestation.split(".")[1]!, "base64url").toString()) as { + sub: string; + }; + expect(payload.sub).toHaveLength(43); + expect(JSON.stringify(payload)).not.toContain(USER_A); + + const keys = parseVerificationKeys(fixture.config.grantVerificationKeysJson); + expect(verifyEndpointAttestation(result.attestation, keys.publicKeys, { + bindingId: active.id, + deviceId: active.deviceUuid, + endpointId: active.endpointId, + identityGeneration: active.identityGeneration, + platform: "ios", + nowSeconds: Math.floor(NOW.getTime() / 1_000), + }).sub).toBe(payload.sub); + + await expectEffectFailure(fixture.broker.issueEndpointAttestation(USER_B, { + bindingId: active.id, + }, NOW), "IrohNotFoundError"); + active.revokedAt = NOW; + await expectEffectFailure(fixture.broker.issueEndpointAttestation(USER_A, { + bindingId: active.id, + }, NOW), "IrohNotFoundError"); + }); + + test("does not return an attestation when its exact binding is revoked during signing", async () => { + const fixture = makeFixture(); + const active = binding({ userId: USER_A, platform: "ios" }); + fixture.repository.bindings.push(active); + fixture.repository.beforeFinalizeEndpointAttestation = () => { + active.revokedAt = NOW; + }; + + await expectEffectFailure(fixture.broker.issueEndpointAttestation(USER_A, { + bindingId: active.id, + }, NOW), "IrohNotFoundError"); + }); + + test("fails closed when verification or opaque-subject signing material is unavailable", async () => { + const fixture = makeFixture(); + const active = binding({ userId: USER_A, platform: "ios" }); + fixture.repository.bindings.push(active); + const noVerificationKeys = makeIrohTrustBroker(fixture.repository, fixture.minter, { + ...fixture.config, + grantVerificationKeysJson: undefined, + }); + await expectEffectFailure(noVerificationKeys.discover(USER_A, NOW), "IrohConfigurationError"); + + const noAccountSubject = makeIrohTrustBroker(fixture.repository, fixture.minter, { + ...fixture.config, + accountSubjectSecretBase64: undefined, + }); + await expectEffectFailure(noAccountSubject.issueEndpointAttestation(USER_A, { + bindingId: active.id, + }, NOW), "IrohConfigurationError"); + }); +}); + +describe("Iroh relay quotas", () => { + test("never calls the minter for an unregistered or revoked binding", async () => { + const fixture = makeFixture(); + await expectEffectFailure( + fixture.broker.issueRelayToken(USER_A, { bindingId: randomUUID() }, NOW), + "IrohNotFoundError", + ); + const revoked = binding({ userId: USER_A, revokedAt: NOW }); + fixture.repository.bindings.push(revoked); + await expectEffectFailure( + fixture.broker.issueRelayToken(USER_A, { bindingId: revoked.id }, NOW), + "IrohNotFoundError", + ); + expect(fixture.minter.calls).toBe(0); + }); + + test("enforces three endpoint mints per ten minutes before provider work", async () => { + const fixture = makeFixture(); + const active = binding({ userId: USER_A }); + fixture.repository.bindings.push(active); + for (let index = 0; index < 3; index += 1) { + await Effect.runPromise(fixture.broker.issueRelayToken( + USER_A, + { bindingId: active.id }, + new Date(NOW.getTime() + index * 1_000), + )); + } + await expectEffectFailure( + fixture.broker.issueRelayToken(USER_A, { bindingId: active.id }, new Date(NOW.getTime() + 4_000)), + "IrohQuotaExceededError", + ); + expect(fixture.minter.calls).toBe(3); + }); + + test("does not return a relay credential when the binding is revoked during mint", async () => { + const fixture = makeFixture(); + const active = binding({ userId: USER_A }); + fixture.repository.bindings.push(active); + fixture.minter.afterMint = () => { + active.revokedAt = NOW; + }; + + await expectEffectFailure( + fixture.broker.issueRelayToken(USER_A, { bindingId: active.id }, NOW), + "IrohNotFoundError", + ); + expect(fixture.repository.relayIssuances[0]?.status).toBe("failed"); + }); +}); + +describe("developer binding override", () => { + const base: IrohTrustBrokerConfigShape = { + relayMinterInsecureLoopbackOptIn: false, + deviceLimitOverrideEnabled: true, + deviceLimitOverrideUserIds: new Set([USER_A]), + deviceLimitOverrideEnvironments: new Set(["preview"]), + deploymentEnvironment: "preview", + isVercelDeployment: true, + }; + + test("requires both an explicit authenticated user and explicit environment", () => { + expect(deviceLimitOverrideAllowed(base, USER_A)).toBe(true); + expect(deviceLimitOverrideAllowed(base, USER_B)).toBe(false); + expect(deviceLimitOverrideAllowed({ ...base, deploymentEnvironment: "production" }, USER_A)).toBe(false); + expect(deviceLimitOverrideAllowed({ ...base, deviceLimitOverrideEnabled: false }, USER_A)).toBe(false); + }); +}); + +type MutableBinding = IrohBindingRecord & { userId: string }; + +class MemoryRepository implements IrohRepositoryShape { + readonly challenges: IrohChallengeRecord[] = []; + readonly bindings: MutableBinding[] = []; + readonly pairGrantAudits: unknown[] = []; + readonly relayIssuances: Array<{ + id: string; + userId: string; + bindingId: string; + requestedAt: Date; + status: string; + }> = []; + private lanGenerations = new Map(); + beforeDiscoverySnapshot: (() => Promise) | undefined; + beforeRecordPairGrant: (() => void) | undefined; + beforeFinalizeEndpointAttestation: (() => void) | undefined; + + issueChallenge(input: Parameters[0]) { + const challenge: IrohChallengeRecord = { + id: randomUUID(), + userId: input.userId, + deviceUuid: input.deviceUuid, + appInstanceId: input.appInstanceId, + tag: input.tag, + endpointId: input.endpointId, + identityGeneration: input.identityGeneration, + payloadSha256: input.payloadSha256, + nonceHash: input.nonceHash, + createdAt: input.now, + expiresAt: input.expiresAt, + consumedAt: null, + }; + this.challenges.push(challenge); + return Effect.succeed(challenge); + } + + findChallenge(userId: string, challengeId: string) { + return Effect.succeed(this.challenges.find((row) => row.userId === userId && row.id === challengeId) ?? null); + } + + consumeChallengeAndRegister(input: Parameters[0]) { + const challenge = this.challenges.find((row) => row.id === input.challengeId && row.userId === input.userId); + if (!challenge) return Effect.fail(new IrohNotFoundError({ resource: "challenge" })); + if (challenge.consumedAt) return Effect.fail(new IrohConflictError({ code: "challenge_replayed" })); + if (challenge.expiresAt <= input.now) return Effect.fail(new IrohForbiddenError({ code: "challenge_expired" })); + if (challenge.nonceHash !== input.nonceHash) return Effect.fail(new IrohForbiddenError({ code: "invalid_challenge_nonce" })); + const existing = this.bindings.find((row) => row.appInstanceId === input.payload.appInstanceId && !row.revokedAt); + if (existing) { + if ( + existing.userId !== input.userId || + existing.endpointId !== input.payload.endpointId || + existing.identityGeneration !== input.payload.identityGeneration || + existing.deviceUuid !== input.payload.deviceId || + existing.tag !== input.payload.tag || + existing.platform !== input.payload.platform + ) return Effect.fail(new IrohConflictError({ code: "binding_replacement_requires_revocation" })); + challenge.consumedAt = input.now; + existing.pathHints = [...input.payload.pathHints]; + return Effect.succeed({ binding: existing, created: false }); + } + if (this.bindings.some((row) => row.endpointId === input.payload.endpointId && !row.revokedAt)) { + return Effect.fail(new IrohConflictError({ code: "endpoint_already_bound" })); + } + const activeUser = this.bindings.filter((row) => row.userId === input.userId && !row.revokedAt); + if (activeUser.length >= 32) return Effect.fail(new IrohQuotaExceededError({ code: "too_many_bindings", retryAfterSeconds: 86_400 })); + const activeDevice = activeUser.filter((row) => row.deviceUuid === input.payload.deviceId); + if (activeDevice.length >= 8 && !input.deviceLimitOverrideAllowed) { + return Effect.fail(new IrohQuotaExceededError({ code: "too_many_device_bindings", retryAfterSeconds: 86_400 })); + } + const inserted = binding({ + userId: input.userId, + deviceUuid: input.payload.deviceId, + appInstanceId: input.payload.appInstanceId, + tag: input.payload.tag, + platform: input.payload.platform, + displayName: input.payload.displayName ?? null, + endpointId: input.payload.endpointId, + identityGeneration: input.payload.identityGeneration, + pairingEnabled: input.payload.pairingEnabled, + capabilities: [...input.payload.capabilities], + pathHints: [...input.payload.pathHints], + deviceLimitOverrideUsed: activeDevice.length >= 8, + registeredAt: input.now, + updatedAt: input.now, + lastSeenAt: input.now, + }); + challenge.consumedAt = input.now; + this.bindings.push(inserted); + return Effect.succeed({ binding: inserted, created: true }); + } + + discoverySnapshot(input: Parameters[0]) { + return Effect.promise(async () => { + await this.beforeDiscoverySnapshot?.(); + return { + bindings: this.bindings.filter((row) => row.userId === input.userId && !row.revokedAt), + lanDiscoveryGeneration: this.lanGenerations.get(input.userId) ?? 1, + }; + }); + } + + findActiveBindings(userId: string, bindingIds: readonly string[]) { + return Effect.succeed(this.bindings.filter((row) => + row.userId === userId && bindingIds.includes(row.id) && !row.revokedAt)); + } + + revokeBinding(input: Parameters[0]) { + const row = this.bindings.find((candidate) => + candidate.id === input.bindingId && candidate.userId === input.userId); + if (!row) return Effect.succeed(false); + if (row.revokedAt) return Effect.succeed(true); + row.revokedAt = input.now; + row.revokedReason = "user_requested"; + this.lanGenerations.set(input.userId, (this.lanGenerations.get(input.userId) ?? 1) + 1); + return Effect.succeed(true); + } + + pruneExpiredState(input: Parameters[0]) { + for (const row of this.bindings.filter((candidate) => candidate.userId === input.userId)) { + row.pathHints = row.pathHints.filter((hint) => { + const expiry = (hint as { expires_at?: unknown }).expires_at; + return typeof expiry === "string" && new Date(expiry) > input.now; + }); + } + return Effect.void; + } + + pruneExpiredStateGlobally(input: Parameters[0]) { + for (const row of this.bindings) { + row.pathHints = row.revokedAt + ? [] + : row.pathHints.filter((hint) => { + const expiry = (hint as { expires_at?: unknown }).expires_at; + return typeof expiry === "string" && new Date(expiry) > input.now; + }); + } + return Effect.succeed({ + rowsProcessed: 0, + batches: 0, + backlog: false, + budgetExhausted: null, + byCategory: { + revokedHints: 0, + expiredHints: 0, + expiredChallenges: 0, + consumedChallenges: 0, + relayAudits: 0, + pairGrantAudits: 0, + revokedBindings: 0, + }, + }); + } + + recordPairGrant(input: Parameters[0]) { + this.beforeRecordPairGrant?.(); + const initiator = this.bindings.find((row) => + row.id === input.initiator.bindingId && row.userId === input.userId && !row.revokedAt); + const acceptor = this.bindings.find((row) => + row.id === input.acceptor.bindingId && row.userId === input.userId && !row.revokedAt); + if (!initiator || !acceptor) return Effect.fail(new IrohNotFoundError({ resource: "binding" })); + if (initiator.platform !== "ios" || acceptor.platform !== "mac" || !acceptor.pairingEnabled) { + return Effect.fail(new IrohForbiddenError({ code: "target_not_pairable" })); + } + this.pairGrantAudits.push(input); + return Effect.void; + } + + finalizeEndpointAttestation(input: { + readonly userId: string; + readonly bindingId: string; + readonly deviceId: string; + readonly endpointId: string; + readonly identityGeneration: number; + readonly platform: "mac" | "ios"; + }) { + this.beforeFinalizeEndpointAttestation?.(); + const active = this.bindings.find((row) => + row.id === input.bindingId && + row.userId === input.userId && + !row.revokedAt); + if (!active) return Effect.fail(new IrohNotFoundError({ resource: "binding" })); + if ( + active.deviceUuid !== input.deviceId || + active.endpointId !== input.endpointId || + active.identityGeneration !== input.identityGeneration || + active.platform !== input.platform + ) { + return Effect.fail(new IrohConflictError({ code: "binding_changed_during_attestation" })); + } + return Effect.void; + } + + reserveRelayIssuance(input: Parameters[0]) { + const active = this.bindings.find((row) => + row.id === input.bindingId && row.userId === input.userId && !row.revokedAt); + if (!active) return Effect.fail(new IrohNotFoundError({ resource: "binding" })); + const recent = this.relayIssuances.filter((row) => + row.bindingId === active.id && row.requestedAt > new Date(input.now.getTime() - 10 * 60 * 1_000)); + if (recent.length >= 3) { + return Effect.fail(new IrohQuotaExceededError({ code: "relay_endpoint_10m_quota", retryAfterSeconds: 600 })); + } + const issuanceId = randomUUID(); + this.relayIssuances.push({ id: issuanceId, userId: input.userId, bindingId: active.id, requestedAt: input.now, status: "pending" }); + return Effect.succeed({ issuanceId, binding: active }); + } + + completeRelayIssuance(input: Parameters[0]) { + const row = this.relayIssuances.find((candidate) => candidate.id === input.issuanceId); + const active = this.bindings.find((candidate) => + candidate.id === input.bindingId && + candidate.userId === input.userId && + candidate.endpointId === input.endpointId && + !candidate.revokedAt); + if (!row || !active) { + if (row) row.status = "failed"; + return Effect.succeed(false); + } + row.status = "succeeded"; + return Effect.succeed(true); + } + + failRelayIssuance(input: Parameters[0]) { + const row = this.relayIssuances.find((candidate) => candidate.id === input.issuanceId); + if (row) row.status = "failed"; + return Effect.void; + } +} + +class FakeMinter implements IrohRelayMinterShape { + calls = 0; + afterMint: (() => void) | undefined; + constructor(private readonly fail: boolean) {} + + mint(input: Parameters[0]) { + this.calls += 1; + if (this.fail) return Effect.fail(new IrohRelayMintError({ code: "test_failure" })); + const result = { + token: `relay-token-${this.calls}-with-safe-length`, + expiresAt: new Date(input.now.getTime() + IROH_RELAY_TOKEN_LIFETIME_SECONDS * 1_000), + }; + this.afterMint?.(); + return Effect.succeed(result); + } +} + +function makeFixture(options: { + repository?: MemoryRepository; + minterFailure?: boolean; + appInstanceId?: string; + deviceId?: string; + identityGeneration?: number; +} = {}) { + const endpointKeys = generateKeyPairSync("ed25519"); + const grantKeys = generateKeyPairSync("ed25519"); + const previousKeys = generateKeyPairSync("ed25519"); + const endpointPublicDer = endpointKeys.publicKey.export({ format: "der", type: "spki" }); + const endpointId = Buffer.from(endpointPublicDer).subarray(-32).toString("hex"); + const repository = options.repository ?? new MemoryRepository(); + const minter = new FakeMinter(options.minterFailure ?? false); + const appInstanceId = options.appInstanceId ?? randomUUID(); + const deviceId = options.deviceId ?? randomUUID(); + const identityGeneration = options.identityGeneration ?? 1; + const config: IrohTrustBrokerConfigShape = { + lanDiscoverySecretBase64: Buffer.alloc(32, 7).toString("base64"), + accountSubjectSecretBase64: Buffer.alloc(32, 8).toString("base64"), + grantSigningPrivateKeyPem: grantKeys.privateKey.export({ format: "pem", type: "pkcs8" }).toString(), + grantSigningKid: "current", + grantVerificationKeysJson: JSON.stringify({ + version: 1, + current_kid: "current", + keys: [ + { + kid: "current", + alg: "EdDSA", + spki_der_base64: grantKeys.publicKey.export({ format: "der", type: "spki" }).toString("base64"), + }, + { + kid: "previous", + alg: "EdDSA", + spki_der_base64: previousKeys.publicKey.export({ format: "der", type: "spki" }).toString("base64"), + }, + ], + }), + deviceLimitOverrideEnabled: false, + relayMinterInsecureLoopbackOptIn: false, + deviceLimitOverrideUserIds: new Set(), + deviceLimitOverrideEnvironments: new Set(), + deploymentEnvironment: "test", + isVercelDeployment: false, + }; + const broker = makeIrohTrustBroker(repository, minter, config); + + return { + repository, + minter, + broker, + config, + endpointId, + appInstanceId, + deviceId, + identityGeneration, + async signedRegistration(platform: "mac" | "ios" = "mac") { + const payload: IrohRegistrationPayload = { + route_contract_version: 1, + deviceId, + appInstanceId, + tag: "stable", + platform, + displayName: "Test Mac", + endpointId, + identityGeneration, + pairingEnabled: true, + capabilities: ["terminal", "artifacts"], + pathHints: [{ + kind: "direct_address", + value: "8.8.8.8:4433", + source: "native", + privacy_scope: "public_internet", + observed_at: "2026-07-09T19:55:00.000Z", + expires_at: "2026-07-09T20:45:00.000Z", + }], + }; + const payloadBytes = Buffer.from(JSON.stringify(payload)); + const challenge = await Effect.runPromise(broker.issueChallenge(USER_A, { + deviceId, + appInstanceId, + tag: payload.tag, + endpointId, + identityGeneration, + payloadSha256: sha256(payloadBytes), + }, NOW)) as { challenge_id: string; nonce: string }; + return { + challengeId: challenge.challenge_id, + nonce: challenge.nonce, + payload: payloadBytes.toString("base64url"), + signature: sign(null, registrationTranscript({ + challengeId: challenge.challenge_id, + nonce: challenge.nonce, + payloadSha256: sha256(payloadBytes), + }), endpointKeys.privateKey).toString("base64url"), + }; + }, + }; +} + +function binding(overrides: Partial = {}): MutableBinding { + const now = NOW; + return { + id: randomUUID(), + userId: USER_A, + deviceUuid: randomUUID(), + appInstanceId: randomUUID(), + tag: "stable", + platform: "mac", + displayName: null, + endpointId: randomUUID().replaceAll("-", "").repeat(2), + identityGeneration: 1, + pairingEnabled: true, + capabilities: [], + pathHints: [], + pathHintsNextExpiry: null, + deviceLimitOverrideUsed: false, + lastSeenAt: now, + registeredAt: now, + updatedAt: now, + revokedAt: null, + revokedReason: null, + ...overrides, + }; +} + +async function expectEffectFailure( + effect: Effect.Effect, + expectedTag: string, +): Promise { + const exit = await Effect.runPromiseExit(effect); + expect(exit._tag).toBe("Failure"); + if (exit._tag !== "Failure") return; + expect(String(exit.cause)).toContain(expectedTag); +} diff --git a/web/vercel.json b/web/vercel.json index a10f985175f3..7f3557af1c5a 100644 --- a/web/vercel.json +++ b/web/vercel.json @@ -15,6 +15,10 @@ { "path": "/api/internal/vm/leases/revoke-expired", "schedule": "*/10 * * * *" + }, + { + "path": "/api/internal/iroh/retention", + "schedule": "43 * * * *" } ] } diff --git a/workers/presence/README.md b/workers/presence/README.md index 6fa7726edff1..455d4b78a0f5 100644 --- a/workers/presence/README.md +++ b/workers/presence/README.md @@ -24,6 +24,17 @@ The heartbeat response returns `heartbeatIntervalMs` (15s) and hardcoding it. An instance that misses heartbeats for the timeout window is flipped offline by the Durable Object alarm with `reason: "timeout"`. +Every server boundary reduces Iroh routes to the EndpointID plus an exact +managed relay URL. Direct addresses and private-network hints remain on the +device or move endpoint-to-endpoint after admission. Existing LAN, Tailscale, +and custom-network route bodies pass through unchanged for compatibility. + +Presence still sends the full sanitized route body in `online`, `routes`, and +snapshot frames. Current iOS clients write that body into their paired-Mac store +and reconnect without a registry round trip. Replacing it with a routes-changed +signal requires a new client protocol plus an authenticated fetch or local +rendezvous path, so that protocol change is intentionally separate. + Devices are owner-bound, mirroring the registry route: the first authenticated user to announce a `deviceId` owns it, and a heartbeat for that device from a different team member is rejected with `403 device_owner_mismatch`, so a diff --git a/workers/presence/src/core.ts b/workers/presence/src/core.ts index 526f66785090..83fac526dfb4 100644 --- a/workers/presence/src/core.ts +++ b/workers/presence/src/core.ts @@ -6,6 +6,8 @@ // Object alarm (an explicit event, not just absence). Everything here is pure // and synchronous so it unit-tests without Workers runtime or storage. +import { sanitizePublishedRoutes } from "./routePrivacy"; + /** How often hosts should heartbeat. Returned to clients so the cadence is * server-owned and can change without shipping new host builds. */ export const HEARTBEAT_INTERVAL_MS = 15_000; @@ -23,9 +25,8 @@ export const OFFLINE_TIMEOUT_MS = 45_000; export const PRUNE_AFTER_MS = 24 * 60 * 60 * 1000; /** One attach route as the registry stores it (`device_app_instances.routes` - * jsonb). Opaque to presence, exactly like the registry route: bounded plain - * objects whose semantic schema (`CmxAttachRoute`) is owned by the clients, so - * new route kinds flow through without a worker ship. */ + * jsonb). Legacy kinds remain opaque. Iroh routes cross a server privacy + * boundary and are reduced to EndpointID plus an approved managed relay URL. */ export type PresenceRoute = Record; export interface PresenceInstance { @@ -52,7 +53,7 @@ export interface PresenceInstance { * A live CACHE of the durable registry row (the host writes the same set to * `POST /api/devices`), kept here so subscribers get fresh routes pushed in * realtime instead of polling the registry. Reconciliation on DO cold start - * is the heartbeat itself: hosts re-announce the full set within 15s. */ + * is the heartbeat itself: hosts re-announce the sanitized set within 15s. */ routes?: PresenceRoute[]; } @@ -75,9 +76,9 @@ export type PresenceEvent = | { type: "online"; instance: PresenceInstance } | { type: "offline"; instance: PresenceInstance; reason: "timeout" | "goodbye" } | { type: "seen"; deviceId: string; tag: string; lastSeenAt: number } - /** The instance's attach routes changed while online (new port/IP). Carries - * the full updated instance so subscribers can reconnect on the fresh routes - * without a registry round trip. */ + /** The instance's attach routes changed while online. Carries the full + * sanitized instance so subscribers can reconnect without a registry round + * trip. */ | { type: "routes"; instance: PresenceInstance }; export interface HeartbeatResult { @@ -112,7 +113,9 @@ export function applyHeartbeat( const wasOnline = existing?.online === true; // Absent routes mean "unchanged": keep the previous set so a client that // omits the field (or a future slim keepalive) never wipes pushed routes. - const routes = beat.routes ?? existing?.routes; + const existingRoutes = sanitizePublishedRoutes(existing?.routes); + const beatRoutes = sanitizePublishedRoutes(beat.routes); + const routes = beatRoutes ?? existingRoutes; const instance: PresenceInstance = { deviceId: beat.deviceId, tag: beat.tag, @@ -131,7 +134,7 @@ export function applyHeartbeat( // Already online: a changed route set is the realtime "new port/IP" push; // an unchanged one is just a lightweight liveness tick. const events: PresenceEvent[] = - beat.routes !== undefined && !routesEqual(existing.routes, beat.routes) + beatRoutes !== undefined && !routesEqual(existingRoutes, beatRoutes) ? [{ type: "routes", instance }] : [{ type: "seen", deviceId: instance.deviceId, tag: instance.tag, lastSeenAt: nowMs }]; return { instance, events }; @@ -146,7 +149,7 @@ function applyGoodbye( // Keep the last known routes on the offline record: they are the // best-known rendezvous for "try waking this host", matching the registry // row that outlives the instance going offline. - const routes = beat.routes ?? existing?.routes; + const routes = sanitizePublishedRoutes(beat.routes) ?? sanitizePublishedRoutes(existing?.routes); const instance: PresenceInstance = { deviceId: beat.deviceId, tag: beat.tag, @@ -280,11 +283,13 @@ export function expireInstances( for (const instance of instances) { if (!instance.online) continue; if (nowMs - instance.lastSeenAt < timeoutMs) continue; + const routes = sanitizePublishedRoutes(instance.routes); const updated: PresenceInstance = { ...instance, online: false, onlineSince: undefined, offlineAt: nowMs, + ...(routes !== undefined ? { routes } : {}), }; expired.push(updated); events.push({ type: "offline", instance: updated, reason: "timeout" }); @@ -350,8 +355,13 @@ export function buildSnapshot( ): PresenceSnapshot { const byDevice = new Map(); for (const instance of instances) { + const routes = sanitizePublishedRoutes(instance.routes); + const publishedInstance: PresenceInstance = { + ...instance, + ...(routes !== undefined ? { routes } : {}), + }; const list = byDevice.get(instance.deviceId) ?? []; - list.push(instance); + list.push(publishedInstance); byDevice.set(instance.deviceId, list); } const devices: PresenceDevice[] = []; diff --git a/workers/presence/src/do.ts b/workers/presence/src/do.ts index 4dc5c2db7e25..4e85807876d8 100644 --- a/workers/presence/src/do.ts +++ b/workers/presence/src/do.ts @@ -50,6 +50,7 @@ import { ownersFromList, reconcileDeviceRecords, reconcileSingleDevice, + sanitizeDeviceSyncFrame, type DeviceRecord, } from "./syncDevices"; import { @@ -62,9 +63,11 @@ import { PAIRED_MACS_COLLECTION_TOMBSTONE_PREFIXES, relabelDelta, relabelSnapshot, + sanitizePairedMacSyncFrame, type PairedMacBackupOp, type PairedMacBackupRecord, } from "./syncPairedMacs"; +import { sanitizePublishedRoutes } from "./routePrivacy"; const INSTANCE_PREFIX = "inst:"; /** `owner:` -> Stack user id pinned on first heartbeat. Durable: @@ -529,9 +532,11 @@ export class TeamPresence extends DurableObject { epoch ?? 0, ); if (resolved.mode === "snapshot") { - for (const page of resolved.pages) this.sendSync(ws, page); + for (const page of resolved.pages) { + this.sendSync(ws, sanitizeDeviceSyncFrame(page)); + } } else if (resolved.delta !== null) { - this.sendSync(ws, resolved.delta); + this.sendSync(ws, sanitizeDeviceSyncFrame(resolved.delta)); } continue; } @@ -553,9 +558,11 @@ export class TeamPresence extends DurableObject { epoch ?? 0, ); if (resolved.mode === "snapshot") { - for (const page of resolved.pages) this.sendSync(ws, relabelSnapshot(page)); + for (const page of resolved.pages) { + this.sendSync(ws, relabelSnapshot(sanitizePairedMacSyncFrame(page))); + } } else if (resolved.delta !== null) { - this.sendSync(ws, relabelDelta(resolved.delta)); + this.sendSync(ws, relabelDelta(sanitizePairedMacSyncFrame(resolved.delta))); } } // Mark this socket as sync-subscribed so future delta broadcasts reach it. @@ -592,9 +599,12 @@ export class TeamPresence extends DurableObject { * client whose presence decoder throws on unknown message types. WS only; SSE * is presence-only for now. */ private broadcastSync(frame: SyncServerFrame): void { - const collection = frame.collection; + const published = frame.collection === DEVICES_COLLECTION + ? sanitizeDeviceSyncFrame(frame as SyncServerFrame) + : frame; + const collection = published.collection; const now = Date.now(); - const json = JSON.stringify(frame); + const json = JSON.stringify(published); for (const ws of this.ctx.getWebSockets()) { if (wsExpiresAt(ws) <= now) continue; if (!wsSyncCollections(ws).includes(collection)) continue; // not subscribed @@ -613,9 +623,12 @@ export class TeamPresence extends DurableObject { * another user's backup. The connection user id is pinned from the verified * `x-presence-user-id` at subscribe time, never from client input. */ private broadcastSyncToUser(userId: string, frame: SyncServerFrame): void { - const collection = frame.collection; + const published = frame.collection === PAIRED_MACS_COLLECTION + ? sanitizePairedMacSyncFrame(frame as SyncServerFrame) + : frame; + const collection = published.collection; const now = Date.now(); - const json = JSON.stringify(frame); + const json = JSON.stringify(published); for (const ws of this.ctx.getWebSockets()) { if (wsExpiresAt(ws) <= now) continue; if (wsUserId(ws) !== userId) continue; // not this user's socket @@ -693,12 +706,16 @@ export class TeamPresence extends DurableObject { // ---- Internals ---- private heartbeatOk(teamId: string, instance: PresenceInstance): HeartbeatResponse { + const routes = sanitizePublishedRoutes(instance.routes); return { ok: true, teamId, heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS, offlineTimeoutMs: OFFLINE_TIMEOUT_MS, - instance, + instance: { + ...instance, + ...(routes !== undefined ? { routes } : {}), + }, }; } diff --git a/workers/presence/src/routePrivacy.ts b/workers/presence/src/routePrivacy.ts new file mode 100644 index 000000000000..2b41aaa7663f --- /dev/null +++ b/workers/presence/src/routePrivacy.ts @@ -0,0 +1,78 @@ +// Keep this fleet in lockstep with web/services/iroh/publicationPolicy.ts. +// The Worker cannot import from `web/` because each deployment has its own +// build root. Exact URLs are intentional: arbitrary relay URLs are path hints. +export const APPROVED_IROH_RELAY_URLS = [ + "https://usc1.relay.cmux.dev/", + "https://usw1.relay.cmux.dev/", + "https://use4.relay.cmux.dev/", + "https://euw4.relay.cmux.dev/", + "https://apne1.relay.cmux.dev/", + "https://apse1.relay.cmux.dev/", + "https://ape1.relay.cmux.dev/", +] as const; + +const APPROVED_IROH_RELAY_URL_SET: ReadonlySet = new Set(APPROVED_IROH_RELAY_URLS); +const ENDPOINT_ID_RE = /^[0-9a-f]{64}$/; +const MAX_ROUTE_ID_LENGTH = 256; + +function plainRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function sanitizeIrohRoute(route: Record): Record | null { + const routeId = typeof route.id === "string" ? route.id : ""; + const endpoint = plainRecord(route.endpoint); + const endpointId = endpoint?.id; + if ( + routeId.length === 0 || + routeId.length > MAX_ROUTE_ID_LENGTH || + endpoint?.type !== "peer" || + typeof endpointId !== "string" || + !ENDPOINT_ID_RE.test(endpointId) + ) { + return null; + } + + const publicEndpoint: Record = { type: "peer", id: endpointId }; + if ( + typeof endpoint.relay_url === "string" && + APPROVED_IROH_RELAY_URL_SET.has(endpoint.relay_url) + ) { + publicEndpoint.relay_url = endpoint.relay_url; + } + + const published: Record = { + id: routeId, + kind: "iroh", + endpoint: publicEndpoint, + }; + if (typeof route.priority === "number" && Number.isSafeInteger(route.priority)) { + published.priority = route.priority; + } + return published; +} + +/** + * Server-side route publication policy. Iroh direct/private coordinates and + * opaque relay hints stay endpoint-local. Legacy route kinds pass through + * unchanged until their existing clients can migrate to a typed policy. + */ +export function sanitizePublishedRoutes( + routes: readonly unknown[] | undefined, +): Record[] | undefined { + if (routes === undefined) return undefined; + const published: Record[] = []; + for (const value of routes) { + const route = plainRecord(value); + if (!route) continue; + if (route.kind !== "iroh") { + published.push(route); + continue; + } + const sanitized = sanitizeIrohRoute(route); + if (sanitized) published.push(sanitized); + } + return published; +} diff --git a/workers/presence/src/syncDevices.ts b/workers/presence/src/syncDevices.ts index f6ec7fadd9cf..b62f7d6cd260 100644 --- a/workers/presence/src/syncDevices.ts +++ b/workers/presence/src/syncDevices.ts @@ -25,14 +25,19 @@ import { type SyncStorage, type SyncWriteResult, } from "./syncStorage"; -import { buildDelta, type SyncDeltaFrame } from "./sync"; +import { + buildDelta, + type SyncDeltaFrame, + type SyncServerFrame, +} from "./sync"; +import { sanitizePublishedRoutes } from "./routePrivacy"; export const DEVICES_COLLECTION = "devices"; /** One tagged app instance inside a device record. List-shape fields only. */ export interface DeviceInstanceRecord { tag: string; - /** Attach routes (the registry-mirrored set), opaque here as in presence. */ + /** Registry-mirrored routes after the presence publication policy. */ routes: PresenceRoute[]; /** Last-seen epoch ms AS OF the rev this record was stamped at. Not live. */ lastSeenAtAtRev: number; @@ -70,7 +75,7 @@ export function deriveDeviceRecord( lastSeenAtAtRev: newest.lastSeenAt, instances: sorted.map((i) => ({ tag: i.tag, - routes: i.routes ?? [], + routes: sanitizePublishedRoutes(i.routes) ?? [], lastSeenAtAtRev: i.lastSeenAt, })), }; @@ -124,6 +129,30 @@ export function deviceRecordEqual(stored: DeviceRecord, next: DeviceRecord): boo return !deviceShapeChanged(stored, next); } +/** Defensively scrub records read from pre-hardening sync storage. */ +export function sanitizeDeviceRecord(record: DeviceRecord): DeviceRecord { + return { + ...record, + instances: record.instances.map((instance) => ({ + ...instance, + routes: sanitizePublishedRoutes(instance.routes) ?? [], + })), + }; +} + +/** Sanitize stored device snapshots/deltas immediately before publication. */ +export function sanitizeDeviceSyncFrame>( + frame: Frame, +): Frame { + if (frame.type === "sync.tick") return frame; + return { + ...frame, + records: frame.records.map((record) => record.deleted + ? record + : { ...record, payload: sanitizeDeviceRecord(record.payload) }), + } as Frame; +} + /** Reconcile the whole `devices` collection against the DO's current presence * state in one pass. This is the single derivation hook called from BOTH DO * write paths (heartbeat and alarm), since list-shape changes happen on both diff --git a/workers/presence/src/syncPairedMacs.ts b/workers/presence/src/syncPairedMacs.ts index f16187d2f80e..d93b53d2f45d 100644 --- a/workers/presence/src/syncPairedMacs.ts +++ b/workers/presence/src/syncPairedMacs.ts @@ -22,6 +22,7 @@ // posture as sync.ts / syncStorage.ts. import { buildDelta, type SyncDeltaFrame, type SyncRecord, type SyncSnapshotFrame } from "./sync"; +import type { SyncServerFrame } from "./sync"; import { listRecords, readRecord, @@ -29,6 +30,7 @@ import { upsertRecord, type SyncStorage, } from "./syncStorage"; +import { sanitizePublishedRoutes } from "./routePrivacy"; /** Logical collection name the client subscribes to and stores under. */ export const PAIRED_MACS_COLLECTION = "pairedMacs"; @@ -85,10 +87,9 @@ export const MAX_ROUTES_TOTAL_BYTES = 2048; export const MAX_PAIRED_MAC_BACKUP_BYTES = MAX_BACKUP_OPS * (MAX_ROUTES_TOTAL_BYTES + 1024) + 2048; -/** The backup payload — mirrors the iOS `MobilePairedMac` row so a restore is - * lossless. The server bounds it but does not interpret `routes` (route - * validation stays client-owned, exactly like the heartbeat route handling), so - * new route kinds flow through without a worker ship. */ +/** The backup payload mirrors the iOS `MobilePairedMac` row. Legacy routes stay + * opaque, while Iroh routes are reduced to EndpointID plus an approved managed + * relay URL before persistence and again before restore. */ export interface PairedMacBackupRecord { macDeviceID: string; displayName?: string; @@ -302,7 +303,7 @@ export function parsePairedMacBackup(body: Record): PairedMacBa macDeviceID: id, displayName: displayName || undefined, instanceTag: instanceTag || undefined, - routes, + routes: sanitizePublishedRoutes(routes) ?? [], createdAt, lastSeenAt, isActive: r.isActive === true, @@ -339,6 +340,26 @@ export function pairedMacShapeEqual(a: PairedMacBackupRecord, b: PairedMacBackup ); } +export function sanitizePairedMacRecord(record: PairedMacBackupRecord): PairedMacBackupRecord { + return { + ...record, + routes: sanitizePublishedRoutes(record.routes) ?? [], + }; +} + +/** Sanitize pre-hardening backup snapshots/deltas immediately before sending. */ +export function sanitizePairedMacSyncFrame>( + frame: Frame, +): Frame { + if (frame.type === "sync.tick") return frame; + return { + ...frame, + records: frame.records.map((record) => record.deleted + ? record + : { ...record, payload: sanitizePairedMacRecord(record.payload) }), + } as Frame; +} + /** Relabel a frame's collection from the physical per-user name back to the * logical `pairedMacs` so the client never sees (or stores under) the user-id * suffix. The rev space is the physical collection's, and each user/scope pair @@ -423,7 +444,7 @@ export async function applyBackupOps( // and the next restore would clear them. iOS always sends all three keys (it is // authoritative, including a `null` reset-to-Auto), so its uploads keep full // control. Only meaningful when there is a live existing record to inherit from. - const record = { ...op.record }; + const record = sanitizePairedMacRecord(op.record); const provided = op.providedCustom ?? { name: true, color: true, icon: true }; if (existing !== undefined && !existing.deleted) { const prev = existing.payload; @@ -495,7 +516,7 @@ async function clearOtherActiveBackupRecords( const deltas: SyncDeltaFrame[] = []; for (const stored of records) { if (stored.deleted || stored.id === activeID || !stored.payload.isActive) continue; - const next = { ...stored.payload, isActive: false }; + const next = { ...sanitizePairedMacRecord(stored.payload), isActive: false }; const res = await upsertRecord( storage, collection, @@ -536,7 +557,7 @@ export async function listBackupSnapshot( const all = await listRecords(storage, pairedMacsCollection(userId, clientScope)); const records = all .filter((r) => !r.deleted) - .map((r) => r.payload) + .map((r) => sanitizePairedMacRecord(r.payload)) .sort((a, b) => (b?.lastSeenAt ?? 0) - (a?.lastSeenAt ?? 0)); const deletedMacDeviceIDs = all .filter((r) => r.deleted) diff --git a/workers/presence/src/validate.ts b/workers/presence/src/validate.ts index f85c7386bcc0..0433c06616da 100644 --- a/workers/presence/src/validate.ts +++ b/workers/presence/src/validate.ts @@ -4,6 +4,7 @@ // presence layer. import type { HeartbeatInput, PresenceRoute } from "./core"; +import { sanitizePublishedRoutes } from "./routePrivacy"; export const MAX_REQUEST_BYTES = 16 * 1024; export const MAX_TAG_LENGTH = 64; @@ -89,9 +90,9 @@ export function parseHeartbeat(body: Record): HeartbeatParse { // rejected rather than coerced like the registry route does, because under // presence semantics a silent coercion would either wipe pushed routes // (treat-as-empty) or mask a client bug (treat-as-absent). Entry filtering - // mirrors the registry: keep only plain objects, bounded by MAX_ROUTES; - // semantic `CmxAttachRoute` validation stays client-owned so new route kinds - // flow through without a worker ship. + // mirrors the registry: keep only plain objects, bounded by MAX_ROUTES. + // Legacy route semantics stay client-owned. Iroh routes are then reduced to + // EndpointID plus an approved managed relay URL. let routes: PresenceRoute[] | undefined; if (body.routes !== undefined) { if (!Array.isArray(body.routes)) return { ok: false, error: "invalid_routes" }; @@ -109,6 +110,7 @@ export function parseHeartbeat(body: Record): HeartbeatParse { if (routeBytes > MAX_ROUTES_TOTAL_BYTES) break; routes.push(entry as PresenceRoute); } + routes = sanitizePublishedRoutes(routes); } return { diff --git a/workers/presence/test/core.test.ts b/workers/presence/test/core.test.ts index 02820dbc2410..1c5cfec61d70 100644 --- a/workers/presence/test/core.test.ts +++ b/workers/presence/test/core.test.ts @@ -325,6 +325,28 @@ describe("heartbeat routes", () => { const routeA = { kind: "lan", host: "192.168.1.10", port: 49152 }; const routeB = { kind: "tailscale", host: "mac.tailnet.ts.net", port: 49152 }; const routeMoved = { kind: "lan", host: "192.168.1.10", port: 50000 }; + const privateIrohRoute = { + id: "iroh", + kind: "iroh", + priority: 1, + endpoint: { + type: "peer", + id: "a".repeat(64), + relay_url: "https://use4.relay.cmux.dev/", + relay_hint: "legacy-private-relay-hint", + direct_addrs: ["192.168.1.20:49152"], + }, + }; + const publicIrohRoute = { + id: "iroh", + kind: "iroh", + priority: 1, + endpoint: { + type: "peer", + id: "a".repeat(64), + relay_url: "https://use4.relay.cmux.dev/", + }, + }; it("stores routes from the first heartbeat on the online instance", () => { const { instance, events } = applyHeartbeat(undefined, beat({ routes: [routeA, routeB] }), T0); @@ -393,4 +415,28 @@ describe("heartbeat routes", () => { const snapshot = buildSnapshot("team-1", [onlineInstance({ routes: [routeA] })], T0); expect(snapshot.devices[0]?.instances[0]?.routes).toEqual([routeA]); }); + + it("scrubs pre-hardening Iroh hints from stored instances, events, and snapshots", () => { + const existing = onlineInstance({ routes: [routeA, privateIrohRoute] }); + const refreshed = applyHeartbeat(existing, beat(), T0 + HEARTBEAT_INTERVAL_MS); + expect(refreshed.instance.routes).toEqual([routeA, publicIrohRoute]); + + const expired = expireInstances( + [onlineInstance({ routes: [routeA, privateIrohRoute] })], + T0 + OFFLINE_TIMEOUT_MS, + ); + expect(expired.events[0]).toMatchObject({ + type: "offline", + instance: { routes: [routeA, publicIrohRoute] }, + }); + + const snapshot = buildSnapshot( + "team-1", + [onlineInstance({ routes: [routeA, privateIrohRoute] })], + T0, + ); + expect(snapshot.devices[0]?.instances[0]?.routes).toEqual([routeA, publicIrohRoute]); + expect(JSON.stringify(snapshot)).not.toContain("192.168.1.20"); + expect(JSON.stringify(snapshot)).not.toContain("legacy-private-relay-hint"); + }); }); diff --git a/workers/presence/test/syncPairedMacs.test.ts b/workers/presence/test/syncPairedMacs.test.ts index 7fc36b3c2826..aab0ed495a0c 100644 --- a/workers/presence/test/syncPairedMacs.test.ts +++ b/workers/presence/test/syncPairedMacs.test.ts @@ -20,6 +20,7 @@ import { PAIRED_MACS_COLLECTION, PAIRED_MACS_COLLECTION_TOMBSTONE_PREFIXES, parsePairedMacBackup, + sanitizePairedMacSyncFrame, type PairedMacBackupRecord, } from "../src/syncPairedMacs"; import { @@ -27,6 +28,7 @@ import { gcTombstones, listRecords, listTombstonedCollections, + upsertRecord, type SyncStorage, } from "../src/syncStorage"; @@ -132,6 +134,49 @@ describe("parsePairedMacBackup", () => { }], }).ok).toBe(false); }); + + it("strips private Iroh hints from backup ingestion and keeps legacy routes", () => { + const legacy = record("x", "100.64.1.2", 49152).routes[0]; + const parsed = parsePairedMacBackup({ + ops: [{ + macDeviceID: "x", + record: { + ...record("x", "100.64.1.2", 49152), + routes: [ + legacy, + { + id: "iroh", + kind: "iroh", + priority: 1, + endpoint: { + type: "peer", + id: "a".repeat(64), + direct_addrs: ["192.168.1.20:49152"], + relay_hint: "legacy-private-relay-hint", + relay_url: "https://use4.relay.cmux.dev/", + }, + }, + ], + }, + }], + }); + if (!parsed.ok) throw new Error(parsed.error); + const op = parsed.ops[0]; + if (op?.kind !== "upsert") throw new Error("expected an upsert op"); + expect(op.record.routes).toEqual([ + legacy, + { + id: "iroh", + kind: "iroh", + priority: 1, + endpoint: { + type: "peer", + id: "a".repeat(64), + relay_url: "https://use4.relay.cmux.dev/", + }, + }, + ]); + }); }); describe("applyBackupOps", () => { @@ -307,6 +352,78 @@ describe("applyBackupOps", () => { expect((await listBackupSnapshot(storage, "user-1")).records[0]).toEqual(incoming); }); + it("sanitizes direct writes, deltas, and legacy stored backup responses", async () => { + const storage = new FakeStorage(); + const unsafe = { + ...record("mac-a", "100.64.1.2", 49152), + routes: [ + record("mac-a", "100.64.1.2", 49152).routes[0], + { + id: "iroh", + kind: "iroh", + endpoint: { + type: "peer", + id: "a".repeat(64), + direct_addrs: ["192.168.1.20:49152"], + relay_hint: "legacy-private-relay-hint", + relay_url: "https://use4.relay.cmux.dev/", + }, + }, + ], + }; + const deltas = await applyBackupOps( + storage, + "user-1", + [{ kind: "upsert", id: "mac-a", record: unsafe }], + T0, + ); + expect(JSON.stringify(deltas)).not.toContain("192.168.1.20"); + expect(JSON.stringify(deltas)).not.toContain("legacy-private-relay-hint"); + const stored = await listRecords(storage, pairedMacsCollection("user-1")); + expect(JSON.stringify(stored)).not.toContain("192.168.1.20"); + + // Seed an unsafe pre-hardening record directly. Restore must scrub it even + // before the next client write migrates the stored payload. + await upsertRecord( + storage, + pairedMacsCollection("legacy-user"), + "mac-a", + unsafe, + T0, + ); + const restored = await listBackupSnapshot(storage, "legacy-user"); + expect(JSON.stringify(restored)).not.toContain("192.168.1.20"); + expect(JSON.stringify(restored)).not.toContain("legacy-private-relay-hint"); + expect(restored.records[0]?.routes).toEqual([ + unsafe.routes[0], + { + id: "iroh", + kind: "iroh", + endpoint: { + type: "peer", + id: "a".repeat(64), + relay_url: "https://use4.relay.cmux.dev/", + }, + }, + ]); + + const legacyFrame = sanitizePairedMacSyncFrame({ + type: "sync.delta", + collection: pairedMacsCollection("legacy-user"), + rev: 1, + records: [{ + id: "mac-a", + rev: 1, + updatedAt: T0, + deleted: false, + schemaVersion: 1, + payload: unsafe, + }], + }); + expect(JSON.stringify(legacyFrame)).not.toContain("192.168.1.20"); + expect(JSON.stringify(legacyFrame)).not.toContain("legacy-private-relay-hint"); + }); + it("normalizes optional client scopes into separate per-user collections", async () => { const storage = new FakeStorage(); await applyBackupOps( diff --git a/workers/presence/test/syncStorage.test.ts b/workers/presence/test/syncStorage.test.ts index 9dbbd0819289..00cdba349697 100644 --- a/workers/presence/test/syncStorage.test.ts +++ b/workers/presence/test/syncStorage.test.ts @@ -34,6 +34,7 @@ import { ownersFromList, reconcileDeviceRecords, reconcileSingleDevice, + sanitizeDeviceSyncFrame, type DeviceRecord, } from "../src/syncDevices"; import type { PresenceInstance } from "../src/core"; @@ -580,6 +581,77 @@ describe("deriveDeviceRecord edge cases", () => { expect(rec!.displayName).toBe("Studio"); expect(rec!.instances.map((i) => i.tag)).toEqual(["b", "a"]); // newest-first }); + + it("never projects private Iroh hints into the team device collection", () => { + const legacy = { kind: "tailscale", endpoint: { host: "100.64.1.2", port: 49152 } }; + const rec = deriveDeviceRecord( + "dev-A", + [ + instance({ + routes: [ + legacy, + { + id: "iroh", + kind: "iroh", + endpoint: { + type: "peer", + id: "a".repeat(64), + direct_addrs: ["192.168.1.20:49152"], + relay_hint: "legacy-private-relay-hint", + relay_url: "https://use4.relay.cmux.dev/", + }, + }, + ], + }), + ], + "user-1", + ); + expect(rec?.instances[0]?.routes).toEqual([ + legacy, + { + id: "iroh", + kind: "iroh", + endpoint: { + type: "peer", + id: "a".repeat(64), + relay_url: "https://use4.relay.cmux.dev/", + }, + }, + ]); + }); + + it("scrubs pre-hardening Iroh hints from stored team sync frames", () => { + const frame = sanitizeDeviceSyncFrame({ + type: "sync.delta", + collection: "devices", + rev: 4, + records: [{ + id: "dev-A", + rev: 4, + updatedAt: T0, + deleted: false, + schemaVersion: 1, + payload: devicePayload({ + instances: [{ + tag: "default", + lastSeenAtAtRev: T0, + routes: [{ + id: "iroh", + kind: "iroh", + endpoint: { + type: "peer", + id: "a".repeat(64), + direct_addrs: ["192.168.1.20:49152"], + relay_hint: "legacy-private-relay-hint", + }, + }], + }], + }), + }], + }); + expect(JSON.stringify(frame)).not.toContain("192.168.1.20"); + expect(JSON.stringify(frame)).not.toContain("legacy-private-relay-hint"); + }); }); describe("listRecords helper", () => { diff --git a/workers/presence/test/validate.test.ts b/workers/presence/test/validate.test.ts index 668afdd2e949..ad9ffe4d544f 100644 --- a/workers/presence/test/validate.test.ts +++ b/workers/presence/test/validate.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "bun:test"; import { MAX_REQUEST_BYTES, MAX_ROUTES, parseHeartbeat, readBoundedJson } from "../src/validate"; const DEVICE_ID = "11111111-2222-4333-8444-555555555555"; +const IROH_ENDPOINT_ID = "a".repeat(64); +const APPROVED_IROH_RELAY = "https://use4.relay.cmux.dev/"; function postRequest( body: string | ReadableStream, @@ -201,4 +203,51 @@ describe("parseHeartbeat routes", () => { if (!result.ok) throw new Error(result.error); expect(result.beat.routes).toHaveLength(MAX_ROUTES); }); + + it("strips private Iroh hints at ingestion while preserving legacy routes", () => { + const legacy = { + id: "legacy", + kind: "tailscale", + endpoint: { type: "host_port", host: "100.64.1.2", port: 49152 }, + }; + const result = parseHeartbeat(body([ + legacy, + { + id: "iroh", + kind: "iroh", + priority: 1, + endpoint: { + type: "peer", + id: IROH_ENDPOINT_ID, + direct_addrs: ["192.168.1.20:49152", "100.64.1.2:49152"], + relay_hint: "legacy-private-relay-hint", + relay_url: APPROVED_IROH_RELAY, + }, + }, + { + id: "iroh-unmanaged", + kind: "iroh", + endpoint: { + type: "peer", + id: IROH_ENDPOINT_ID, + relay_url: "https://attacker.example/relay", + }, + }, + ])); + if (!result.ok) throw new Error(result.error); + expect(result.beat.routes).toEqual([ + legacy, + { + id: "iroh", + kind: "iroh", + priority: 1, + endpoint: { type: "peer", id: IROH_ENDPOINT_ID, relay_url: APPROVED_IROH_RELAY }, + }, + { + id: "iroh-unmanaged", + kind: "iroh", + endpoint: { type: "peer", id: IROH_ENDPOINT_ID }, + }, + ]); + }); });