diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5985f6e..9eb3bc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: with: components: clippy - uses: Swatinem/rust-cache@v2 - - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings test: name: test @@ -37,7 +37,14 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - run: cargo test --workspace --all-features + # The integration tests dial macula-go's in-process stations + # (tests/teststation), built to target/teststation first. + - uses: actions/setup-go@v5 + with: + go-version-file: tests/teststation/go.mod + cache-dependency-path: tests/teststation/go.sum + - run: ./scripts/build-teststation.sh + - run: cargo test --workspace --all-features --locked doc: name: docs (deny broken links) diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 09e48de..f34d20b 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -24,18 +24,20 @@ jobs: exit 1 fi - # Rebuilds and re-runs the offline suite here rather than trusting - # an artifact from ci.yml's own run -- this workflow triggers off a - # tag push, a separate event from the branch push ci.yml already - # validated, so there's no prior run's output to reuse. Deliberately - # NOT --locked anywhere below: this repo doesn't commit Cargo.lock - # (library crate; a committed lock silently caps what a loose - # constraint resolves to on every later run -- see the workspace's - # own lockfile-hygiene convention), so a fresh checkout has no lock - # to be strict against. - - run: cargo build --workspace --all-targets - - run: cargo test --workspace --all-features - - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + # Rebuilds and re-runs the suite here rather than trusting an artifact + # from ci.yml's run: a tag push is a separate event from the branch + # push ci.yml validated, so there is no prior run to reuse. --locked: + # the committed Cargo.lock is what was tested. + # The integration tests dial macula-go's in-process stations + # (tests/teststation), built to target/teststation first. + - uses: actions/setup-go@v5 + with: + go-version-file: tests/teststation/go.mod + cache-dependency-path: tests/teststation/go.sum + - run: ./scripts/build-teststation.sh + - run: cargo build --workspace --all-targets --locked + - run: cargo test --workspace --all-features --locked + - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - run: cargo fmt --all -- --check # Dry-run needs no registry auth -- it only verifies metadata, diff --git a/.github/workflows/release-ffi.yml b/.github/workflows/release-ffi.yml index 981e81a..4758ffb 100644 --- a/.github/workflows/release-ffi.yml +++ b/.github/workflows/release-ffi.yml @@ -28,19 +28,25 @@ jobs: exit 1 fi - # Same reasoning as release-core.yml: rebuild+retest from scratch + # Same reasoning as release-core.yml: rebuild and retest from scratch # (a tag push is a separate event from the branch push ci.yml - # already validated), no --locked anywhere (no committed Cargo.lock - # in this repo). - - run: cargo build --workspace --all-targets - - run: cargo test --workspace --all-features - - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + # validated), against the committed Cargo.lock. + # The integration tests dial macula-go's in-process stations + # (tests/teststation), built to target/teststation first. + - uses: actions/setup-go@v5 + with: + go-version-file: tests/teststation/go.mod + cache-dependency-path: tests/teststation/go.sum + - run: ./scripts/build-teststation.sh + - run: cargo build --workspace --all-targets --locked + - run: cargo test --workspace --all-features --locked + - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings - run: cargo fmt --all -- --check # Dry-run needs no registry auth -- it only verifies metadata, # compresses the package, and checks the result, never uploads. # This is also the step that proves macula-rust-ffi's `macula-rust - # = { path = "..", version = "0.2" }` dependency actually resolves + # = { path = "..", version = "0.4" }` dependency actually resolves # against the REAL published macula-rust on crates.io, not just the # local workspace path -- `cargo publish` downloads and rebuilds # against the registry version during verification, confirmed diff --git a/.gitignore b/.gitignore index 60db30f..c17da7f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ /target -Cargo.lock Cargo.lock.bak diff --git a/CHANGELOG.md b/CHANGELOG.md index fc53d63..04f985c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,164 +15,64 @@ usually touches both, but their version numbers don't move in lockstep. ### [0.4.0] - Unreleased +The macula 12 wire. **Breaking throughout**: a 0.3 node cannot reach a +macula 12 station, and nothing of the 0.3 API carries over. See the README's +"Coming from 0.3 and earlier". + +#### Added + +- `node_key::NodeKey`: macula 12 identities, ML-DSA-87 (`pq_pure`) or the + LAMPS composite id-MLDSA87-RSA4096-PSS-SHA512 (`pq_hybrid`, the fleet's), + RSA-PSS-4096 from aws-lc-rs. Node and key ids, the admission puzzle + (difficulty 8), owner-only key files in the seed form, the platform secure + store through `keystore::KeyStore`, and `load_or_create`. The composite is + held to the draft's own vector, and signatures crossed both ways with macula + 12.8.0 (`scripts/cross-verify-macula.sh`). +- `cbor`: macula 12's decoding rule v1 (depth 64, 131,072 elements, integers + within ±2^63, text or integer map keys, no duplicates, finite floats, `null` + the only simple value), with its refusal reasons. +- `binding`, `signed_object`: TLS and CONNECT bindings, status statements, and + signed and held objects. +- `transport`: QUIC on macula-pqc 0.3, ML-KEM hybrid key exchange with the + AES-256 Initial suite, every station pinned by its node_id through its TLS + binding. +- `handshake`: the v4 handshake (opener, challenge, CONNECT with its member + endorsement, HELLO, status). +- `frame`: version-2 frames: signed requests, replies, relay errors, + publications and stream frames, and neighbour signatures with their seq in + pq_hybrid. +- `record`: the DHT's records and storage keys, D25 authorization (a realm's + org directory and an org's delegation) and a node's own namespace + `~/`. +- `statement_issuer`: the CONNECT key bound to the identity key, its status + statement reissued every 15 minutes, and the key rotated every 5 days. +- `station_link::Link`: one station, ported from macula-go v0.12.0's + `stationlink`. Status statements both ways, a liveness probe, calls, the + DHT, pubsub with per-node dedup, serving with request admission, and + streaming sessions released on every path. +- `pool::Pool`: a node's links, ported from macula-go v0.12.0's `pool`. Pinned + seeds redialed and given back their subscriptions and served procedures. + Calls and streams reach a provider at its own station, trusted only under + the pinned realm key. Publications are signed once, and records go through + the pool. +- Tests against macula-go's in-process stations (`tests/teststation`, built by + `scripts/build-teststation.sh`, which CI runs), and `tests/live.rs` against + one real station, ignored unless asked. +- Examples: `quickstart`, `serve`, `publish_subscribe`. + +#### Removed + +- The 10.x wire and everything on it: `identity::KeyPair` (Ed25519), + `connection::Session` and its telemetry facts, the 10.x `frame`, `dht`, + `stream` and `pool`, `direct_dial`, `cert`, `cert_chain`, `ucan`, `bolt4`, + `content` and `manifest`, and WebPki trust. +- `plans/`: the 10.x wire spec and leak survey, which describe code that is + gone. + #### Changed -- **Breaking on the wire: every dial now uses POST-QUANTUM KEY EXCHANGE, and - nothing else.** Each TLS configuration starts from - [`macula-pqc`](https://crates.io/crates/macula-pqc) 0.1's `client_builder()`: - `SecP384r1MLKEM1024`, then `SecP256r1MLKEM768`, and no classical group, - where the crate used rustls's `ring` defaults (X25519, P-256, P-384, all - classical). A station on macula 11.5.0 or earlier offers only those and - **cannot be reached**; a station on macula's `macula-pqc` QUIC NIF - negotiates `SecP384r1MLKEM1024`. Every trust mode is covered, and - `PubkeyPinVerifier` and `SkipServerVerification` verify with the same - provider. Key exchange only: certificates are still classically signed. - -- **Direct dial tries every authorized provider.** `direct_dial::call`, - `call_with_ucan`, `call_with_cert_chain`, `open_stream_direct`, - `open_stream_direct_with_cert_chain` and `get_direct` try each advertised - provider in the order the DHT returns them, instead of only the first. A - provider that can't be reached before the request is sent is skipped for - the next one, and a request that has been sent is never sent again. - `get_direct` also retries while no provider has announced the content - yet, and a DHT lookup that fails is retried within the timeout instead of - ending the call. -- **Breaking: the timeout bounds the whole call**, finding the provider - included. A timeout sized for the request alone can now run out during - resolution. `resolve` and `resolve_with_cert_chain` give up after 10 - seconds, and `put_direct`'s timeout covers the endpoint lookup and the - dial. -- **Breaking: new `GetDirectError::Timeout { last }`.** It reports a - `get_direct` whose timeout ran out during a transfer, where `last`, also - its `source()`, carries the failure before it, or before any provider - lookup was answered. An exhaustive `match` on `GetDirectError` needs the - new arm. -- **Breaking: new `ResolveError::Timeout`, and a call reports what it - observed.** At its timeout a direct-dial call returns the last candidate - failure, else why an answered DHT lookup found nothing, else a failed - lookup's error, and `ResolveError::Timeout` only when nothing was - observed at all, where it used to report `ProcedureNotAdvertised` or - `StationEndpointNotFound`. A `station_endpoint` lookup follows the same - rule, reporting `StationEndpointNotFound` only when a lookup was - answered, and retries a lookup that fails within its budget. A record - that names no dialable address is looked up again too, and when it is - the latest answer the lookup reports the new - `ResolveError::MalformedStationEndpoint`. An exhaustive `match` on - `ResolveError` needs both new arms. -- **Breaking: direct dial reuses a session this process already has open - to the provider's station under the same identity.** A station keeps one - connection per identity and closes the older one when a newer one - arrives, so a second dial used to close `resolve_via` or a `Pool` link. - `open_stream_direct`, `open_stream_direct_with_cert_chain`, `put_direct` - and `get_direct` now run on that open session, on a dedicated QUIC - stream, and leave it open. The stream functions return - `direct_dial::OpenedStream` (`stream`, `lease`) instead of a - `(Session, StreamHandle)` tuple; release its `SessionLease` once the - stream is done. `call`, `call_with_ucan` and `call_with_cert_chain` run - on an open session the same way. -- **A direct call whose CALL was not sent tries the next candidate.** A call - whose session had ended, or whose turn to write didn't come in time, moves - on to the next candidate, and its station may be tried again on a later - pass. A call that was or may have been sent is returned as before. -- **A CALL handler receives its caller.** A map payload reaches the handler - with the caller's 32-byte node id under `"caller"`, the caller the CALL's - signature was verified against, replacing any `"caller"` the sender put in - the payload. A payload that isn't a map reaches the handler unchanged and - carries no caller. -- **Breaking: `StreamHandle::accept` refuses a STREAM_OPEN not signed by its - caller.** Stream handlers previously received the STREAM_OPEN's caller - field unverified; upgrade if a stream handler relies on it. A stream whose - first frame doesn't verify against the caller it names, has no signature - or caller, is of another type, or doesn't decode is aborted in both - directions with application error code 2 (`stream::REFUSED_STREAM`), - with nothing written, and accept waits for the next stream within its - timeout. `AcceptError::Parse` is removed. A provider that accepts a - stream and won't serve it refuses it with `StreamHandle::refuse`, which - writes a STREAM_ERROR, finishes the send half and stops reading with - code 2. -- **A stream handler receives its caller.** Map args of an accepted - STREAM_OPEN carry the verified caller under `"caller"`, replacing any - `"caller"` the opener put there, as a CALL handler's payload does. -- **Drop warnings.** A session logs a dropped CALL (`dropped_call`), a - RESULT or ERROR for no pending call (`dropped_reply`) and a refused stream - (`refused_stream_open`) with its `count`, `reason`, and `procedure` or - `call_id`. The first of a kind in an interval is logged at once, and the - rest are counted into one closing line when the interval ends. - `Session::set_drop_warning_interval` sets the interval, 60 seconds by - default. -- **A frame that doesn't decode ends a session as `SessionEndReason::Malformed`**, - where it used to end as `StreamFailed`. An exhaustive `match` on - `SessionEndReason` needs the new arm. -- **A session direct dial dialed is shared until its last request is done.** - A direct-dial request that finds it open uses it too, holding a lease of - its own, and the session closes when the last lease is released instead - of when the request that dialed it finishes. It is not reused once it is - closing. -- **Breaking: a UCAN-gated procedure binds the token to its caller.** - `ucan::Policy::check` takes the CALL's `caller` as well as its token, and - a `Policy::required` procedure accepts a token only when its `aud` is - that caller's 32-byte node id as lowercase hex, with no `did:` prefix. A - token with another or no audience is refused as `unauthorized` - (`UcanError::WrongAudience`, and `UcanError::NoCaller` when `check` gets - no 32-byte caller; both new). Mint tokens for gated procedures with that - audience. -- **An inbound CALL must be signed by the caller it names.** - `Session::serve_one_call` and `serve_one_call_gated` drop a CALL whose - signature doesn't verify against its `caller` field, without a reply and - before any policy or handler runs, matching the Erlang station link. -- **Breaking: a `Session` is a cloneable handle with one reader.** Its - methods take `&self`, so calls, subscriptions, publishing and serving on - one session run at the same time. A reader task routes each RESULT or - ERROR to its call by call id, each EVENT to the subscriptions it matches, - and each inbound CALL to a queue of 64 that `serve_one_call` and - `serve_one_call_gated` take from. A slow subscriber never delays a call's - reply. The functions in `dht`, `content`, `stream` and `direct_dial` take - `&Session` instead of `&mut Session`. -- **Breaking: `Session::subscribe` returns a `Subscription`** with its own - queue of 256 events, read with `recv_event(timeout)` and ended with - `close()`. A topic matches segment by segment on `/`, where `*` is exactly - one segment, and the realm must be equal. Closing the last subscription - for a realm and topic sends UNSUBSCRIBE. A subscription that falls more - than 256 events behind returns its queued events and then - `RecvEventError::Overflow`, and stays subscribed at the station until it - is closed. `Session::recv_event`, `unsubscribe`, `recv_frame`, - `recv_frame_timeout` and `leftover_bytes` are removed, and - `run_subscriber` runs on a `Subscription`. -- **Breaking: new error types.** `Session::call` and `call_with_ucan` - return `CallError`: `Timeout { write_started }`, - `SessionEnded { reason, write_started }`, `SendTimeout`, `Encode`, - `Write` or `MalformedReply`, where `not_sent()` tells whether the CALL - can safely be sent again. `publish`, `advertise`, `unadvertise` and - `subscribe` return `SendError`, `RecvEventError` is `Timeout`, `Overflow` - or `SessionEnded`, `serve_one_call` returns `ServeCallError`, and - `FrameStream`'s call error is renamed `StreamCallError`. -- **Writes are bounded.** A caller waits for its turn to write no longer - than its deadline, a call's timeout or else 30 seconds. A write that - takes longer than 30 seconds ends the session. The reader never waits on - a write: an inbound CALL that finds the queue full is answered with - `temporary_relay_failure` through a separate queue of 64 frames, and - serving carries on. -- **How a session ends.** A GOODBYE, a HELLO or CONNECT after the handshake, - a frame that doesn't decode, a stalled write or the end of the control - stream ends the session: its pending calls fail with `SessionEnded`, its - connection closes, direct dial no longer reuses it, and the end is logged - once through the `log` crate, as a warning when the station or connection - ended it and as info when it was closed here, with both node ids. - `Session::end_reason` and `ended` report it. Frames no route claims are - counted by type in `unrouted_frame_counts`, with a log line at most once - a minute. -- **`Pool::call` publishes no RPC facts.** A pooled call goes through the - link's session without the `rpc.sent_v1` and `rpc.completed_v1` facts - that `Session::call` publishes. -- **Breaking: `Pool::call` tries another link only when the CALL was not - sent.** It moves on to the next connected link only while a call fails - before its CALL was written, so no CALL runs twice. A call that timed out - after its write started, and an ERROR reply, are returned as they are. - `PoolCallError::AllFailed` is replaced by `PoolCallError::Call`, the - failure that stopped the call. -- **A pool link is dialed again when its session ends**, instead of when a - call or publish on it fails, so a call that times out on a link that is - still up no longer drops that link. +- `rust-version` is 1.89, the least the dependencies build with. +- `Cargo.lock` is committed and CI tests with `--locked`. ### [0.3.0] - 2026-09-05 @@ -442,30 +342,35 @@ did, but the two have moved at different paces ever since). ### [ffi-0.4.0] - Unreleased +**Breaking throughout**: rewritten on `macula-rust` 0.4's pool, the macula 12 +wire. + +#### Added + +- `FfiNodeKey`: `generate`, `load`, `load_or_create`, `save`, + `load_from_keystore` and `save_to_keystore`, in `FfiProfile::PqPure` or + `PqHybrid`. +- `FfiPool`: `connect` with pinned `FfiSeed`s and `FfiPoolOptions` (realm + trust, timeouts, bounds). It offers `call`, `providers`, `publish`, + `subscribe`, `serve`, `serve_stream`, `open_stream`, the DHT's + `find_record`, `find_records`, `find_records_by_type` and `put_record`, + `status` and `close`. +- `FfiCallHandler` and `FfiStreamHandler`, implemented by the app + (`suspend fun` in Kotlin, `async throws` in Swift). A handler's thrown + `FfiError` reaches the caller as a handler_error. +- `FfiSubscription` (`next` with a timeout, `unsubscribe`), `FfiStream` on + either side, `FfiServed`, and `own_procedure`. +- `FfiError` maps the pool's errors, a provider's error with its code, and a + foreign handler's unexpected throw. + +#### Removed + +- `FfiKeyPair`, `FfiSession`, `FfiTrust`, the UCAN functions, direct-dial and + cert-chain calls, content transfer, and the live tests that used them. + #### Changed -- Builds on `macula-rust` 0.4, with the dependency requirement moved to - `"0.4"`. The direct-dial calls on `FfiSession` therefore try every - authorized provider, and their `timeout_ms` now bounds finding the - provider as well. A `get_direct` whose transfer the timeout cuts off - reports `FfiError::Content` with the earlier failure in its reason. -- `FfiSession::serve_one_call_gated` refuses a UCAN token whose `aud` isn't - the calling node's id as lowercase hex, and both serve calls drop a CALL - that isn't signed by its caller. Mint tokens for gated procedures with - `ucan_create` using that audience. -- **Breaking: `FfiOpenedDirectStream` carries a `lease` instead of a - `session`.** The direct-dial stream and content calls on `FfiSession` run - on a session this process already has open to the provider's station - under the same identity, instead of dialing a second one that would close - it. Call `FfiSessionLease::release` once the stream is done: a session - direct dial dialed closes when no other direct-dial request still uses it. -- **Breaking: `FfiSession::subscribe` returns an `FfiSubscription`**, read - with `recv_event(timeout_ms)` and ended with `close()`. Each subscription - has its own queue of 256 events and receives only the events its topic - and realm match. `FfiSession::recv_event` and `unsubscribe` are removed. -- Methods on one `FfiSession` no longer wait for each other: - `serve_one_call`, `accept_stream`, calls and subscriptions on the same - session run at the same time. +- `rust-version` is 1.91, the least the dependencies build with. ### [ffi-0.3.1] - 2026-09-05 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..27d8431 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2929 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-native-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c6349ddff23194f8fdce2ea8849380f5a4868c1648965b70e801e104cba9b3" +dependencies = [ + "base64 0.22.1", + "jni 0.21.1", + "keyring-core", + "log", + "ndk-context", + "regex", + "serde", + "serde_json", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + +[[package]] +name = "askama" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6024d73179f43f15ccd2b881bfea6fee7f3a46ec53f33b52210dea749ebebaa4" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071ee5ebf2138e3ad180e0aacf6940c2cab5e6d8333741d9925c7bee2b153f39" +dependencies = [ + "askama_parser", + "basic-toml", + "glob", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 3.0.6", +] + +[[package]] +name = "askama_macros" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643e1c7cbb6aec1d920332fe51a7c0d8219e273dcb8602db03f5263e4d16487b" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c5ae75772275d268b03ab8bdccdd12117b6169ee23256942b34e46c9f476583" +dependencies = [ + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow", +] + +[[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.21", + "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 2.0.119", + "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 2.0.119", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compat" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c97d7ff3c25d6c10d64170c12acaf5d4245e76dece3779c1d92b153a64f11df" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[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 = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[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" + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[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 = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[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 = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[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 = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastbloom" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" +dependencies = [ + "foldhash", + "libm", + "portable-atomic", + "siphasher", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + +[[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.21", + "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 2.0.119", +] + +[[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 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyring" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" +dependencies = [ + "android-native-keyring-store", + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + +[[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.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-keyutils-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39fbed79f71dc21eb21d3d07c0e908a3c58ff9a1fdbf5cf44230fb3deb6d994b" +dependencies = [ + "keyring-core", + "linux-keyutils", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "macula-keccak" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99a9ef94be3261f1debe3b04bebcafb992f7f29fb990a87e080365a810567bb" +dependencies = [ + "zeroize", +] + +[[package]] +name = "macula-mldsa" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e846a49c6be27e4e9abf33a88ac1edcfc4e0c3308e2bdb92f12e101c05856ebb" +dependencies = [ + "getrandom 0.4.3", + "macula-keccak", + "zeroize", +] + +[[package]] +name = "macula-mlkem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1dde77fd0a48f76533836a342cd4c2cf844f63e2d534330b182a52b85b3471" +dependencies = [ + "getrandom 0.4.3", + "macula-keccak", + "zeroize", +] + +[[package]] +name = "macula-pqc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd8755ca194c2d381cb15f49f0ca2022c86129e8c6b8c9bd685d3c77b728af" +dependencies = [ + "macula-mldsa", + "macula-pqc-kx", + "rcgen", + "rustls", +] + +[[package]] +name = "macula-pqc-kx" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151b72a3953fe9bfa0046421046b13b7cd360133d8cd1caf39b468ac25f98456" +dependencies = [ + "macula-mlkem", + "rustls", +] + +[[package]] +name = "macula-rust" +version = "0.4.0" +dependencies = [ + "apple-native-keyring-store", + "aws-lc-rs", + "hex", + "keyring", + "keyring-core", + "linux-keyutils-keyring-store", + "macula-mldsa", + "macula-pqc", + "quinn", + "rcgen", + "rustix", + "rustls", + "serde_json", + "sha2", + "tempfile", + "tokio", +] + +[[package]] +name = "macula-rust-ffi" +version = "0.4.0" +dependencies = [ + "async-trait", + "hex", + "macula-rust", + "serde_json", + "tempfile", + "thiserror 2.0.21", + "tokio", + "uniffi", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[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-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "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 = "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" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[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 = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[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.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "fastbloom", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +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 = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "aws-lc-rs", + "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" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "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 0.9.0", + "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 = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "secret-service" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" +dependencies = [ + "aes", + "cbc", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hybrid-array", + "num", + "once_cell", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "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 = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[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 = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +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 = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" +dependencies = [ + "smawk", + "unicode-width", +] + +[[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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[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 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "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 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "uniffi" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76407f5f396a2c949a069eff4a9fca9ce462410eb872bffef4c2b7b89804a77a" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df3ced8f0eda3de99f6d50820e8628f443da8e4f447f477d1403ceba29dacbd" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_core" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7530ae8efeaaa488622865966763fe0294832779fff80508c36d69c6a874a6a" +dependencies = [ + "anyhow", + "async-compat", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f4bad017164375d99450d70495014810d15a84ba9da6ed14b9628b1424223ba" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "uniffi_macros" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b855a58da153739ce6e863f6e296edc3716a093768c4d3d957f17f5e4317c60" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af0c88664a9cb9559856f5a250283c9708923b303170e2e5c7db8f4ad65e9459" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d00f5c044b4a3dca0b1226c92ddaeea626f337470e22180dd182160c87a06b0b" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55dfd9c778d6b39cb5acd541d9ff63431974035d2bad83c897b766a6ee3152ac" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + +[[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 = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + +[[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_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 = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "aws-lc-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.21", + "time", +] + +[[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 = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index c2f835f..2e5fb44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,14 +5,14 @@ members = ["macula-rust-ffi"] name = "macula-rust" version = "0.4.0" edition = "2021" -rust-version = "1.85" +rust-version = "1.89" authors = ["Macula "] -description = "Rust port of macula's SDK (client/leaf) wire protocol — mobile first, not mobile-only. See plans/PLAN_WIRE_PROTOCOL.md." +description = "Rust SDK for the macula 12 mesh: ML-DSA-87 and LAMPS composite node keys, a post-quantum QUIC transport — mobile first, not mobile-only." license = "Apache-2.0" repository = "https://github.com/macula-io/macula-rust" homepage = "https://github.com/macula-io/macula-rust" readme = "README.md" -keywords = ["mesh-network", "quic", "p2p", "ed25519", "decentralized"] +keywords = ["mesh-network", "quic", "p2p", "post-quantum", "decentralized"] categories = ["network-programming", "cryptography"] [lints.rust] @@ -22,37 +22,23 @@ unsafe_code = "forbid" all = { level = "deny", priority = -1 } [dependencies] -ed25519-dalek = { version = "3.0", features = ["rand_core"] } -rand = "0.10" sha2 = "0.11" quinn = "0.11" # The key exchange for every connection this crate dials: every TLS # configuration starts from macula-pqc's client_builder(), which offers -# SecP384r1MLKEM1024 then SecP256r1MLKEM768 and nothing classical. So -# rustls selects no crypto provider of its own here. See transport.rs. -macula-pqc = "0.1" -rustls = { version = "0.23", default-features = false, features = ["logging", "std", "tls12"] } -webpki-roots = "1.0" -x509-parser = "0.18" -# cert_chain.rs: pure X.509 path validation (no hostname/SAN check, unlike -# rustls's own ServerCertVerifier machinery in cert.rs) to a caller-supplied -# realm CA — already transitively pulled in by rustls, declared directly -# here since cert_chain.rs uses its EndEntityCert::verify_for_usage API. -rustls-webpki = { version = "0.103", features = ["ring"] } +# SecP384r1MLKEM1024 then SecP256r1MLKEM768 and nothing classical, and whose +# KeyPossessionVerifier accepts one ML-DSA-87 station certificate. So rustls +# selects no crypto provider of its own here. See transport.rs. +macula-pqc = "0.3" +# ML-DSA-87 for every node key signature (profile.rs, node_key.rs): the same +# implementation macula-pqc signs TLS with. +macula-mldsa = "0.3" +# The RSA-PSS-4096 half of pq_hybrid's LAMPS composite: already linked through +# rustls for the key exchange, constant-time, and with a FIPS path. +aws-lc-rs = "1" +rustls = { version = "0.23", default-features = false, features = ["logging", "std"] } tokio = { version = "1", features = ["full"] } -uuid = { version = "1", features = ["v7"] } -# control_channel.rs: a session end and dropped unrouted frames are reported -# through the `log` facade, silent unless the application installs a logger. -# Already in the graph through rustls's `logging` feature. -log = "0.4" -blake3 = "1.5" -# ucan.rs: JWT-shaped token (de)serialization, matching the reference -# macula_ucan_nif's own dependency choices exactly (its Cargo.toml has no -# UCAN-spec crate either, only these same generic primitives). -serde = { version = "1", features = ["derive"] } -serde_json = "1" -base64 = "0.23" -# keystore.rs: platform-native secure storage for a persisted seed +# keystore.rs: platform-native secure storage for a node key # (Keychain via Security.framework on macOS/iOS, Secret Service via D-Bus # on Linux, Credential Manager on Windows, Keystore via JNI on Android — # each selected automatically per target by keyring's own Cargo.toml @@ -91,25 +77,18 @@ linux-keyutils-keyring-store = "1" [target.'cfg(target_os = "ios")'.dependencies] apple-native-keyring-store = { version = "1", features = ["protected"] } +[target.'cfg(unix)'.dependencies] +# node_key/key_file.rs: a key file must belong to the effective user, and is +# opened without blocking; std has neither geteuid nor O_NONBLOCK without libc. +rustix = { version = "1", features = ["fs", "process"] } + [dev-dependencies] hex = "0.4" tempfile = "3" -# Test-only: generates synthetic Ed25519 certs to unit-test -# PubkeyPinVerifier's matching logic without needing a live station that -# happens to present that cert type — see src/cert.rs's tests. Not a -# runtime dependency of the crate itself (see src/cert.rs's module doc: -# a dialing client never generates or presents a cert of its own). - -# Test-only, cert_chain.rs's own tests: signed_by() needs a SubjectPublicKeyInfo -# constructed from a raw advertiser Ed25519 pubkey rather than a freshly -# rcgen-generated one (the leaf must bind the SAME key that signed the -# advertisement) — SubjectPublicKeyInfo::from_der needs this feature. -rcgen = { version = "0.14", default-features = false, features = ["pem", "ring", "x509-parser"] } -# Test-only, transport.rs's own tests: the stations the dialler must still -# reach, or must refuse, are built from aws-lc-rs's own groups. +serde_json = "1" +# Test-only, transport.rs's own tests: a station with a classical certificate, +# which a dial must refuse. +rcgen = { version = "0.14", default-features = false, features = ["pem", "ring"] } +# Test-only, transport.rs's own tests: the stations the dialler must refuse +# are built from aws-lc-rs's own groups. rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] } -# Test-only, cert_chain.rs's own tests: rcgen's CertificateParams -# not_before/not_after fields take this crate's OffsetDateTime directly; -# not re-exported by rcgen, so declared explicitly (already transitively -# present via rcgen itself). -time = "0.3" diff --git a/README.md b/README.md index 223ed3c..dfc866d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://img.shields.io/github/actions/workflow/status/macula-io/macula-rust/ci.yml?branch=master&label=CI)](https://github.com/macula-io/macula-rust/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](#license) -[![Rust](https://img.shields.io/badge/rust-1.85%2B-orange?logo=rust)](https://www.rust-lang.org) +[![Rust](https://img.shields.io/badge/rust-1.89%2B-orange?logo=rust)](https://www.rust-lang.org) [![memory safety](https://img.shields.io/badge/memory%20safety-100%25%20safe%20Rust-success.svg)](https://github.com/rust-secure-code/safety-dance/) [![GitHub Sponsors](https://img.shields.io/badge/GitHub%20Sponsors-support-ea4aaa.svg?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/rgfaber) @@ -14,211 +14,159 @@

- Rust port of the Macula SDK wire protocol — mobile first, not mobile-only + Rust SDK for the Macula mesh, with Kotlin and Swift bindings

--- -> **Status, 2026-08-30:** feature-complete for a leaf/edge client — -> the client/leaf side of the wire protocol is built and -> **live-verified against the production station fleet** -> (`station-de-frankfurt.macula.io`) — handshake (pinned or WebPki -> trust), unary RPC, PubSub, content transfer, and streaming RPC, every -> primitive in both caller and provider roles, plus direct-dial -> (DHT resolve/publish, both plain and cert-chain-authorized), periodic -> re-advertise, UCAN (mint/verify/introspect — policy-gated serving's -> live-network behavior needs a closer look, see Known limitations), a -> supervised PubSub pair, RPC telemetry auto-facts, and an -> overridable-per-platform `KeyStore` for identity persistence. Mobile -> bindings (Kotlin + Swift, via UniFFI) wrap almost the entire surface, -> generated and CI-checked on every push. See [Status](#status) for -> what's deliberately out of scope vs. genuinely separate future work, -> and [Known limitations](#known-limitations) for one real external bug -> this crate can't fix. +> **Status, 2026-09-26:** on the **macula 12** wire. That means post-quantum +> ML-DSA-87 identities (in pq_hybrid, the fleet's profile, the ML-DSA-87 + +> RSA-PSS-4096 composite), ML-KEM hybrid key exchange, and signed requests. +> Calls and streams by direct dial, serving (under an org or in a node's own +> namespace), publish/subscribe and the DHT are tested against in-process +> macula 12 stations on every `cargo test`, and live against the fleet. Not +> here yet: UCAN-gated calls and node-served content; see [Not yet +> implemented](#not-yet-implemented). Releases before 0.4.0 speak the retired +> 10.x wire and cannot reach the current fleet. ## What is this? -A ground-up Rust implementation of the client half of Macula's wire -protocol — the same protocol [`macula-io/macula`](https://github.com/macula-io/macula) -(the Erlang/OTP SDK) speaks, extracted directly from that source and -tracked in [`plans/PLAN_WIRE_PROTOCOL.md`](plans/PLAN_WIRE_PROTOCOL.md). -Macula is a federated mesh for sovereign, end-to-end-encrypted -application networks; a **station** is the relay/DHT node, and this crate -is what a **leaf** — a phone, a desktop app, a CLI, anything that isn't -itself a station — uses to join it. +A native Rust implementation of a Macula node: its identity key, a pool of +links to the stations it pins by node_id, calls and streams that reach a +provider at its own station, serving procedures, publish/subscribe, and the +DHT. It speaks the same wire as [macula](https://github.com/macula-io/macula) +(the Erlang/OTP reference) and [macula-go](https://github.com/macula-io/macula-go), +over QUIC ([quinn](https://github.com/quinn-rs/quinn)) with the post-quantum +TLS of [macula-pqc](https://crates.io/crates/macula-pqc), ML-DSA-87 from +[macula-mldsa](https://crates.io/crates/macula-mldsa), and RSA-PSS-4096 from +aws-lc-rs. -Mobile is the flagship consumer driving the work (hence the UniFFI -crate), not a ceiling on it: the core crate has zero UniFFI dependency -and zero FFI-shaped types, so it's exactly as usable from plain Rust, a -CLI, or WASM as any other Rust SDK. - -## Features - -| Primitive | Caller | Provider | Notes | -|---|---|---|---| -| Handshake (CONNECT/HELLO) | ✅ | — | Ed25519 identity, S/Kademlia puzzle-hardened | -| One session, many uses | ✅ | ✅ | `Session` is a cloneable handle with one reader: calls, subscriptions and serving on it run at the same time, and a slow consumer never stalls a call's reply | -| Unary RPC (CALL/RESULT/ERROR) | ✅ | ✅ | `Session::serve_one_call`, BOLT#4 error mapping live-verified; a call that times out says whether its frame was sent | -| PubSub (PUBLISH/SUBSCRIBE/EVENT) | ✅ | ✅ | `Session::subscribe` returns a `Subscription` with its own queue of 256 events; a subscriber gets its own publish, verified live | -| Content transfer (single-block + chunked) | ✅ | ✅ | Content-addressed, BLAKE3/SHA-256 | -| Streaming RPC (STREAM_OPEN/DATA/END/REPLY) | ✅ | ✅ | Both roles live-verified against the real fleet; `ClientStream` mode's reply path is SDK-correct but currently blocked by a `macula-station` bug — see [Known limitations](#known-limitations) | -| RPC advertise/unadvertise | ✅ | — | | -| Direct-dial (DHT resolve/publish) | ✅ | ✅ | `direct_dial::{resolve,call,advertise_direct}` — reaches a service without depending on advertise-gossip having propagated a route; plain + cert-chain-authorized (`*_with_cert_chain`) | -| Direct-dial streaming/content | ✅ | ✅ | `direct_dial::{open_stream_direct,put_direct,get_direct}` — runs on a session already open to the same station under the same identity instead of dialing a second one, which the station would answer by closing the first; `get_direct` is correct but currently unreachable, see [Known limitations](#known-limitations) | -| Periodic re-advertise | — | ✅ | `Session::keep_advertised` / `direct_dial::keep_advertised_direct` — a ctx-cancellable loop, since a station's registration doesn't survive the connection that sent it being replaced | -| UCAN (mint/verify/introspect) | ✅ | ✅ | `ucan::{create,verify,decode,get_*}` are pure functions; `Session::call_with_ucan`/`serve_one_call_gated` live-verified end-to-end; a gated provider accepts a token only when its `aud` is the calling node's id as lowercase hex, and drops a CALL not signed by its caller (see [`examples/ucan.rs`](examples/ucan.rs) and [Known limitations](#known-limitations) for the resolved investigation) | -| Cert-chain (org/realm authorization) | ✅ | ✅ | `cert_chain::verify_advertisement_cert_chain` + `direct_dial::*_with_cert_chain` — opt-in, the plain direct-dial path is unaffected | -| Supervised PubSub pair | ✅ | ✅ | `Session::run_publisher`/`run_subscriber` — addressable/cancellable wrappers over bare publish/subscribe, auto-publishing `pubsub.publish_*_v1` facts | -| RPC telemetry auto-facts | ✅ | ✅ | `rpc.sent_v1`/`rpc.completed_v1` (caller), `rpc.received_v1`/`rpc.replied_v1` (provider) — always-on, fire-and-forget, fired automatically by `call`/`serve_one_call_gated` | -| Overridable `KeyStore` | ✅ | — | `keystore::KeyStore` trait + `KeyringStore`/`LinuxKeyutilsStore` — `KeyPair::save_to_keystore`/`load_from_keystore`; the raw-file `KeyPair::save` stays as a testing/parity convenience | -| Mobile bindings (Kotlin, Swift) | ✅ | ✅ | Via [UniFFI](#mobile-bindings-uniffi) — provider role serves via `FfiCallHandler`, a foreign-implemented async trait (`suspend fun`/`async throws`), not a closure. Covers direct-dial, UCAN, cert-chain, content/stream direct-dial reuse, and `KeyStore`; deliberately NOT `keep_advertised`/`run_subscriber` (see the FFI crate's own module doc for why) | -| Pubkey-pinned trust | ✅ | — | `Trust::Pinned` / `FfiTrust.Pinned` — the only mode that works at all for a station without a CA-issued cert | -| Post-quantum key exchange | ✅ | — | Every dial, in every trust mode, via [`macula-pqc`](https://crates.io/crates/macula-pqc): `SecP384r1MLKEM1024`, then `SecP256r1MLKEM768`, nothing classical. A station on macula 11.5.0 or earlier offers only classical groups and cannot be reached. Key exchange only: certificates are still classically signed | - -`unsafe_code = "forbid"` at the crate level — the only unsafe in this -workspace lives inside its dependencies (`quinn`, `ring`, `aws-lc-rs`), -not here. +[`macula-rust-ffi`](#mobile-bindings-kotlin-and-swift) wraps it for Kotlin and +Swift. The core crate has no FFI dependency and no FFI-shaped types. ## Quick start -Also lives as a runnable example — `cargo run --example quickstart`. -Advertises and calls its own trivial echo procedure (two identities, a -provider and a caller, since a station kicks a connection the instant a -second one arrives under the same identity) rather than depending on any -particular procedure already being advertised on the fleet: +```toml +[dependencies] +macula-rust = "0.4" +tokio = { version = "1", features = ["full"] } +``` + +A node needs a station to link to, **pinned by its node_id**, and the key of +each realm it trusts, which the realm publishes. Its own key is created on +first use and kept in a file its owner alone can read. ```rust -use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use macula_rust::{ - cbor::Value, - connection::{self, BoxFuture, CallHandler}, - frame::AdvertiseSpec, - identity::KeyPair, - transport::Trust, -}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - // Puzzle-hardened identities — required. An unhardened identity fails - // the handshake silently (QUIC/TLS looks healthy, HELLO never accepts). - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - "station-de-frankfurt.macula.io", - 4433, - Trust::WebPki, - &provider_identity, - ) - .await?; - let caller_session = connection::connect( - "station-de-frankfurt.macula.io", - 4433, - Trust::WebPki, - &caller_identity, - ) +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use macula_rust::cbor::Value; +use macula_rust::node_key::NodeKey; +use macula_rust::pool::{Call, Opts, Pool, Seed}; +use macula_rust::profile::Profile; +use macula_rust::station_link::Publication; + +let key = NodeKey::load_or_create(Path::new("node.key"), Profile::PqHybrid)?; +let mut opts = Opts::new(Arc::new(key)); +opts.realm_trust = HashMap::from([(realm, realm_key)]); +let pool = Pool::connect( + vec![Seed { host: "station-fi-helsinki.macula.io".into(), port: 4433, node_id: station_id }], + opts, +) +.await?; + +// A call reaches a provider by direct dial: its advertisement from the DHT, +// trusted only when the realm key authorizes it, and its station dialed. +let answer = pool + .call(Call { realm, procedure: "mcl-echo/echo".into(), payload: Value::text("hello"), ..Call::default() }) .await?; - let realm = [0u8; 32]; - // Unique per run — reusing a fixed procedure name across rapid - // repeated runs can hit stale DHT routing state from the prior run's - // now-dead advertiser. - let procedure = format!( - "macula_rust.quickstart_echo.{}", - SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() - ); - - let advertise_spec = AdvertiseSpec::new(realm, procedure.clone(), provider_identity.node_id()); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await?; - tokio::time::sleep(Duration::from_millis(500)).await; // ADVERTISE is fire-and-forget; give it a moment to land - - let target_procedure = procedure.clone(); - let lookup = move |_realm: &[u8; 32], proc: &str| -> Option { - if proc != target_procedure { - return None; - } - let handler: CallHandler = std::sync::Arc::new(|payload: Value| { - Box::pin(async move { Ok(payload) }) as BoxFuture<'static, Result> - }); - Some(handler) - }; - - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call(lookup, &provider_identity, Duration::from_secs(10)) - .await; - // Close explicitly instead of letting provider_session drop when - // this task ends — see Session's own doc for why: dropping the - // last handle closes the connection at once, which gives quinn's - // send-scheduling no guarantee the RESULT just sent actually - // reached the peer first. - provider_session - .close( - "normal", - Some("quickstart provider done"), - &provider_identity, - ) - .await; - result - }); - - let now_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as i128; - let response = caller_session - .call( - &procedure, - realm, - Value::Text("hello".into()), - now_ms + 5_000, // deadline_ms - &caller_identity, - Duration::from_secs(5), - ) - .await?; - - serve_task.await??; - caller_session - .close("normal", Some("quickstart caller done"), &caller_identity) - .await; - - println!("{response:?}"); - Ok(()) -} +// Publish and subscribe; topics name a kind of fact, ids go in the payload. +let mut sub = pool.subscribe(&realm, "acme/demo/greeting_sent_v1").await?; +pool.publish(Publication { + realm, + topic: "acme/demo/greeting_sent_v1".into(), + payload: Value::Map(vec![(Value::text("text"), Value::text("hi"))]), + ttl_ms: None, +}) +.await?; +let event = sub.recv().await; + +pool.close().await; ``` -## Mobile bindings (UniFFI) - -`macula-rust-ffi` is a separate crate — not code bolted onto the -core one — wrapping every application primitive (`FfiSession::connect`/ -`call`/`serve_one_call`/`publish`/`subscribe`/`content_put`/ -`content_get`/`stream_open`/`advertise`/`accept_stream`) for Kotlin and -Swift, in the modern proc-macro UniFFI style (`#[uniffi::export]`, -native `async`/`await` and Kotlin coroutines, no `.udl` file). CI -rebuilds the `cdylib` and regenerates both language bindings on every -push as a codegen smoke test. +Serving a procedure in the node's own namespace needs no org and no realm key: -Serving an RPC from Kotlin or Swift means implementing `FfiCallHandler` -— a **foreign trait** (`#[uniffi::export(foreign)]`), not a callback -closure (UniFFI foreign traits can't carry a plain closure, so -`handle` receives the full inbound call and does its own procedure -routing if a session serves more than one): - -```kotlin -class Doubler : FfiCallHandler { - override suspend fun handle(procedure: String, realm: ByteArray, payload: FfiValue): FfiValue { - val n = (payload as FfiValue.Int).v1 - return FfiValue.Int(n * 2) - } -} +```rust +use macula_rust::pool::Offer; +use macula_rust::record::own_procedure; +use macula_rust::station_link::handler; -session.advertise("math.double", realm, identity) -session.serveOneCall(Doubler(), timeoutMs = 30_000u, identity) +let ring = own_procedure(&pool.node_id(), "ring"); // ~/ring +let served = pool + .serve(Offer::unary(realm, &ring, handler(|request| async move { Ok(request.payload) }))) + .await?; ``` -(`FfiValue` currently covers `Null`/`Int`/`Bytes`/`Text`/`Float` — see -this crate's own module doc for why `List`/`Map` aren't there yet; a -handler needing a structured payload should encode it as `Bytes` -today.) +Runnable versions are in [`examples/`](examples): `quickstart`, `serve` and +`publish_subscribe`, each reading the environment described at the top of +[`examples/common/mod.rs`](examples/common/mod.rs). + +### Coming from 0.3 and earlier + +Everything moved to the macula 12 wire, and the API with it. There is no +compatibility layer. + +- **New identities.** A macula 12 node_id derives from an ML-DSA-87 key (or + the LAMPS composite in `pq_hybrid`), so no Ed25519 identity carries over. + `NodeKey::load_or_create` makes a new key file. **Re-join your realms and + re-trust your agents**: anything that named your old node_id must be redone + with the new one. +- `identity::KeyPair` is now `node_key::NodeKey`; `connection::Session` is + `pool::Pool` (or `station_link::Link` for one station), whose seeds carry + the station's node_id and whose `realm_trust` pins realm keys; + `direct_dial::call` is simply `Pool::call`; `resolve` is `Pool::providers`; + `serve_one_call` is `Pool::serve` with a handler; `Trust::WebPki` is gone: + every station is pinned by its node_id. +- `ucan`, `cert_chain` and the content-transfer modules are gone until + macula 12's own arrive (see [Not yet implemented](#not-yet-implemented)). +- Serving an org procedure needs the realm's org directory and the org's + delegation to your node in the DHT: a realm admits orgs through a human. + +## What's implemented + +| Primitive | Caller | Provider | Notes | +|---|---|---|---| +| Node keys (`node_key::NodeKey`) | ✅ | ✅ | `pq_hybrid` (the fleet's) or `pq_pure`; key files readable by the owner only, or the platform's secure store (`keystore`); pq_hybrid checked against the LAMPS draft's own vector and cross-verified with macula 12.8.0 | +| Pool of station links (`pool::Pool`) | ✅ | ✅ | Seeds pinned by node_id; realm keys pinned; links redialed with subscriptions and served procedures replayed | +| One station link (`station_link::Link`) | ✅ | ✅ | The v4 handshake, status statements both ways, neighbour signatures in pq_hybrid, a liveness probe | +| Calls by direct dial (`call`, `providers`) | ✅ | ✅ | Candidates tried freshest first; errors arrive as `LinkError::Provider` / `LinkError::Relay` | +| A node's own namespace (`record::own_procedure`) | ✅ | ✅ | `~/`: served and called with no org and no realm key | +| Streams (`open_stream`, `Offer::stream`) | ✅ | ✅ | Server, client and bidi; a QUIC stream per session, released on every path | +| Publish/subscribe | ✅ | ✅ | Signed publications, delivered once across links | +| DHT (`find_record`, `find_records`, `find_records_by_type`, `put_record`) | ✅ | — | Records verified before they are handed on | +| Mobile bindings (Kotlin, Swift) | ✅ | ✅ | `macula-rust-ffi`, below | + +The link and the pool are ported from macula-go v0.12.0's `stationlink` and +`pool`, and every wire format is checked against macula-go's and macula's own +vectors (`tests/vectors/`). `unsafe_code = "forbid"` holds across the +workspace; the unsafe code is inside dependencies (quinn, aws-lc-rs). + +## Payloads + +A payload is what macula's wire CBOR carries: `Value::Null`, `Int`, `Float`, +`Text`, `Bytes`, `List` and `Map`. **There is no boolean**: write 1 or 0. A +decoded payload obeys macula 12's decoding rule (depth 64, 131,072 elements, +integers within ±2^63, text or integer map keys, no duplicates). + +## Mobile bindings (Kotlin and Swift) + +`macula-rust-ffi` wraps the pool with [UniFFI](https://mozilla.github.io/uniffi-rs/) +proc macros: `FfiNodeKey`, `FfiPool`, `FfiSubscription`, `FfiStream`, and two +handlers the app implements, `FfiCallHandler` and `FfiStreamHandler` +(`suspend fun` in Kotlin, `async throws` in Swift). Every 32-byte id crosses as +bytes and is checked. ```bash cargo build -p macula-rust-ffi --release @@ -227,216 +175,74 @@ cargo run -p macula-rust-ffi --release --bin uniffi-bindgen -- generate \ --language kotlin --out-dir bindings-kotlin ``` -### Connecting and a basic call - -Signatures cross-checked against real generated bindings (`uniffi-bindgen generate`, both languages), not guessed — `call` takes no separate deadline, only a timeout. Calls `math.double`, the procedure the [`Doubler`](#mobile-bindings-uniffi) example above this one advertises and serves — this SDK's own, not a fleet-wide service, so it only resolves while that example (or an equivalent provider) is actually running: - ```kotlin -val identity = FfiKeyPair.generate() -val session = FfiSession.connect("station-de-frankfurt.macula.io", 4433.toUShort(), FfiTrust.WebPki, identity) -val response = session.call("math.double", realm, FfiValue.Int(21), 5_000uL, identity) -``` +class Echo : FfiCallHandler { + override suspend fun handle(request: FfiRequest): FfiValue = request.payload +} -```swift -let identity = FfiKeyPair.generate() -let session = try await FfiSession.connect(host: "station-de-frankfurt.macula.io", port: 4433, trust: .webPki, identity: identity) -let response = try await session.call(procedure: "math.double", realm: realm, payload: .int(21), timeoutMs: 5_000, identity: identity) +val key = try { + FfiNodeKey.loadFromKeystore("io.macula.myapp", "node-identity", FfiProfile.PQ_HYBRID) +} catch (e: FfiException.KeystoreNotFound) { + FfiNodeKey.generate(FfiProfile.PQ_HYBRID).also { it.saveToKeystore("io.macula.myapp", "node-identity") } +} +val pool = FfiPool.connect(key, listOf(FfiSeed(host, 4433.toUShort(), stationId)), + FfiPoolOptions(realmTrust = listOf(FfiRealmKey(realm, realmKey)))) +pool.serve(realm, ownProcedure(pool.nodeId(), "ring"), Echo()) +val answer = pool.call(realm, "mcl-echo/echo", FfiValue.Text("hello"), null, 5_000uL) ``` -### Persisting identity via platform secure storage +On Android the platform keystore needs one call at app start, +`Keyring.initializeNdkContext(applicationContext)`; see the `keystore` +module's documentation. iOS needs nothing extra. -Real, working usage — this is `macula-apps/macula-cam2me`'s actual -Android identity persistence, not a contrived snippet. Android needs one -extra one-time call at app startup (Keystore has no NDK surface, so the -`android-native-keyring-store` crate ships its own JNI init export); iOS -needs nothing extra, since `apple-native-keyring-store` covers both -macOS and iOS as one backend. `saveToKeystore`/`loadFromKeystore` are -plain blocking calls, not `suspend`/`async` — note the `FfiError` -variant name is `KeystoreNotFound` (capitalized, mirroring the Rust -error type directly) in both languages, unlike `FfiTrust`/`FfiValue`'s -ordinary lower-camelCase Swift cases (`.webPki`, `.text`) — a real, -confirmed UniFFI codegen quirk, not a typo. - -```kotlin -// Once, in Application.onCreate or MainActivity.onCreate: -Keyring.initializeNdkContext(applicationContext) +CI generates both bindings on every push to master and every pull request; +the apps that use them compile them. The FFI crate needs Rust 1.91, the core +crate 1.89. -// Then anywhere: -val identity = try { - FfiKeyPair.loadFromKeystore("io.macula.myapp", "node-identity") -} catch (e: FfiException.KeystoreNotFound) { - FfiKeyPair.generate().also { it.saveToKeystore("io.macula.myapp", "node-identity") } -} -``` +## Not yet implemented -```swift -// No extra init needed on iOS. -let identity: FfiKeyPair -do { - identity = try FfiKeyPair.loadFromKeystore(service: "io.macula.myapp", account: "node-identity") -} catch FfiError.KeystoreNotFound { - identity = FfiKeyPair.generate() - try identity.saveToKeystore(service: "io.macula.myapp", account: "node-identity") -} -``` +- **UCAN-gated calls and serving.** macula 12 uses post-quantum UCANs; calls + carry no token yet, and a gated procedure cannot be served. +- **Node-served content** (macula 12's D27): planned for 0.5.0. +- **Station discovery beyond the seeds.** macula's discovery call is not + served by the fleet today (macula-io/macula#31); give the pool its seeds. ## Testing ```bash -cargo test --workspace --all-features +./scripts/build-teststation.sh # macula-go's in-process stations, to target/teststation +cargo test --workspace ``` -100+ tests across the workspace, plus a separate live-verification suite -(`tests/live_station.rs`) that dials the real production fleet — -`#[ignore]`d by default since it depends on infrastructure this crate -doesn't control: +The integration tests (`tests/station_link.rs`, `tests/pool.rs`, +`macula-rust-ffi/tests/pool_ffi.rs`) run against `tests/teststation`, a Go +helper around macula-go's `teststation`. It starts in-process macula 12 +stations, realms and orgs as each test asks, and reports what a station sees +(who is connected, what is advertised or subscribed, how many streams it +relays). A test fails, not skips, when the helper is missing. No network is +needed. Go ≥ 1.27 builds the helper. + +`tests/live.rs` runs against one real station and is ignored unless asked: ```bash -cargo test --test live_station -- --ignored --nocapture +MACULA_RUST_LIVE_SEED=station-fi-helsinki.macula.io:4433 \ +MACULA_RUST_LIVE_STATION_ID=<64 hex> MACULA_RUST_LIVE_REALM=<64 hex> \ +MACULA_RUST_LIVE_REALM_KEY= cargo test --test live -- --ignored ``` -## Status - -**Live-verified, 2026-08-28 — full parity, both directions:** handshake, -CALL/RESULT/ERROR as both caller (`Session::call`) and provider -(`Session::serve_one_call`, BOLT#4 error mapping — `unknown_next_peer` -on a lookup miss, `temporary_relay_failure` on a handler panic (caught -via `tokio::spawn`, one task per call, the same shape -`macula_station_link.erl`'s one-process-per-call already uses), -`unknown_error` with detail on a handler-returned error, all ported -field-for-field from that module's `handle_inbound_call/2`), PUBLISH/ -SUBSCRIBE/EVENT (a subscriber does receive its own publish), content -transfer, and streaming RPC in both the caller and provider roles — all -against `station-de-frankfurt.macula.io`, the real fleet, not a local -mock. Two independent connections to the same station (one advertising -and serving, the other calling in) is the pattern behind every -provider-role test — see `tests/live_station.rs`'s -`unary_call_provider_round_trip_against_the_real_fleet` for the unary -case. Three real protocol bugs were caught by differential-vector tests -before ever touching production. - -Unary-RPC provider dispatch was the one gap left after the streaming -and content-transfer provider roles landed — a service built on this -crate could call RPCs and serve streams, but couldn't serve a -request/response procedure at all. It's now built here and in -[`macula-go`](https://github.com/macula-io/macula-go) in the -same pass, so both SDKs serve RPCs, not just call them, and wrapped in -the FFI layer the same day: [`FfiCallHandler`](#mobile-bindings-uniffi) -is a **foreign trait** (`#[uniffi::export(foreign)]`), not a callback -closure — UniFFI doesn't support passing a bare closure across the -boundary, so `handle` receives the full inbound call and a Kotlin/Swift -implementation does its own procedure routing if a session serves more -than one. Verified past "it compiles": rebuilt the release `cdylib`, -regenerated both Kotlin and Swift, and inspected the actual generated -code — `FfiCallHandler.handle` renders as `suspend fun ... : FfiValue` -in Kotlin and `func handle(...) async throws -> FfiValue` in Swift, -`FfiSession.serveOneCall`/`serveOneCall` takes it as a parameter in -both, not just as an exit-code smoke test. - -Pubkey-pinned trust reached the FFI layer the same day too: `connect` -now takes an `FfiTrust` (`Pinned { node_id }` or `WebPki`) instead of -hardcoding WebPki. Not a nice-to-have — WebPki has no chain to validate -against a self-hosted station outside the public demo fleet, so a real -deployment off `station-de-frankfurt.macula.io` needs pinning to -connect at all. `Trust::Insecure` stays deliberately unexposed at the -FFI boundary (dev/diagnostic only in the core crate; a shipped mobile -app should never be able to select "skip TLS verification"). - -**2026-08-30: direct-dial, UCAN, cert-chain, periodic re-advertise, a -supervised PubSub pair, RPC telemetry facts, and an overridable -`KeyStore` all landed, live-verified, and FFI-wrapped the same day.** -Direct-dial exists because ordinary advertise/gossip routing depends on -a route having already propagated between the caller's and the -service's station — this fleet's gossip is best-effort and often hasn't, -so direct-dial resolves a signed DHT record naming the serving station -and dials it in one hop instead. `KeyStore` closes a real gap this -crate's own `KeyPair::save` doc comment had flagged since it was -written: raw-file persistence is fine for tests, but a real mobile app -needs Keychain/Keystore-backed storage — `KeyringStore` covers macOS, -iOS, Linux (D-Bus secret service) and Windows via one `keyring`-crate -backend (confirmed via its own `Cargo.toml`: `apple-native-keyring-store` -covers macOS *and* iOS with a single backend, no per-platform bridge -needed), `LinuxKeyutilsStore` is a second backend for sandboxes with no -secret-service daemon running. `macula-apps/macula-cam2me`'s Android app -migrated to it the same day (`NodeKeyPair.kt`), the first real consumer. - -**This crate is feature-complete for its stated purpose — a leaf -client dialing a known macula-station — in both the core crate and the -FFI layer.** What's genuinely still outstanding is a different kind of -thing entirely, not an SDK gap: -- DHT/HyParView/Plumtree gossip primitives — deliberately **not** - leaf-client scope; they're how *stations* gossip membership and - broadcast to each other (§6.5-§6.7 say so explicitly). A leaf never - needs them, so this was never a completeness gap to begin with. -- The actual Android demo app — real Kotlin/Android work outside this - crate, needing a device/emulator and toolchain this repo's own CI - doesn't have. The SDK surface it needs (`advertise`/`acceptStream`/ - `FfiStream`/`serveOneCall`, both pull and push streaming modes) is - already complete and live-verified; nothing here is blocking it. -- Additional language ports (C#, Python) — a separate initiative, not - a gap in this crate. - -See [`plans/PLAN_WIRE_PROTOCOL.md`](plans/PLAN_WIRE_PROTOCOL.md) for the -full wire-format spec this crate is built against, section by section, -traced directly to the Erlang SDK's source. - -## Known limitations - -- **`direct_dial::get_direct` can only resolve a `content_announcement` - that something has actually published** — and nothing in this - ecosystem currently does, since only a station/relay can legitimately - publish one (a `content_announcement`'s endpoint is dialed with no - relay indirection, unlike a `procedure_advertisement`, so a leaf SDK - identity can't pass its own trust check). Correct but currently - unreachable, not a bug. -- **RESOLVED**: an earlier draft of this section reported - `call_direct_with_cert_chain` timing out waiting for a reply after a - successful resolve+dial, narrowed but not root-caused across several - investigation rounds. Root-caused: the same premature-`Session`-drop - race as the `serve_one_call_gated` finding below — the FFI test's - `serve_task` dropped the provider `Session` the instant - `serve_until_procedure` returned, closing the QUIC connection before - the reply frame reached the peer. Fixed by keeping the session alive - 300ms after the last reply, matching the identical fix already applied - there. Confirmed with 5 consecutive clean passes (was failing reliably - before). No SDK defect — the cert-chain mechanism itself was never - broken. See `macula-rust-ffi/tests/live_cert_chain_direct_dial.rs`'s - own comments for the ruled-out theories from the earlier rounds. -- The demo fleet's `station_endpoint` DHT records carry a short TTL and - are not always freshly republished, so a station's record can be stale - for a while. Direct dial tries every advertised provider in turn and - keeps re-querying within the call's `timeout`; only when no provider's - station has a usable record before it runs out does the call return - `StationEndpointNotFound`. This is fleet infrastructure state, not a - code defect. -- **RESOLVED**: an earlier draft of this section reported - `serve_one_call_gated`/`call_with_ucan` failing 100% of live attempts - while `serve_one_call` succeeded reliably in the same window, and left - it as an open, unconfirmed question. Root-caused: it was a test-harness - bug, not a real difference between gated and plain serving. The failing - harness spawned the provider's `Session` into a task that dropped it - the instant `serve_one_call`/`serve_one_call_gated` returned; dropping - the last `Session` handle closes the underlying QUIC connection, which - can happen before the just-sent reply frame is flushed to the peer — the exact - same class of race already documented on [`Session::close`], just - never hit by drop instead of an explicit close before now. Confirmed - by direct A/B: 8/8 plain AND 8/8 gated calls succeeded once the - provider session was kept alive briefly after serving, interleaved on - the same station in the same window; the pre-existing - `unary_call_provider_round_trip_against_the_real_fleet` test also - passed 3/3 at the same moment, ruling out the fleet-degradation theory - entirely for this specific finding. **Practical takeaway for any - caller**: don't let a `Session` drop immediately after `serve_one_call`/ - `publish`/any send-then-return call — keep it alive briefly (or call - [`Session::close`] explicitly) so in-flight writes have time to reach - the wire. See `examples/ucan.rs` for a real, live-verified gated-serving - example built once this was root-caused. - -## Related projects - -| Project | Description | +With a key generated for the run and never saved, it reads the DHT, calls +`mcl-echo/echo` by direct dial and hears its own publication. +`scripts/cross-verify-macula.sh` renews the pq_hybrid signatures that crossed +both ways with macula (`tests/vectors/identity/macula_12_cross`). + +## Sibling SDKs + +| Repo | Approach | |---|---| -| [macula](https://github.com/macula-io/macula) | The reference SDK (Erlang/OTP) — the protocol this crate ports | +| [macula](https://github.com/macula-io/macula) | The reference SDK (Erlang/OTP) | +| [macula-go](https://github.com/macula-io/macula-go) | Go port; this crate's link and pool follow it | +| [macula-ts](https://github.com/macula-io/macula-ts) | FFI binding over macula-go, for Node.js | +| [macula-php](https://github.com/macula-io/macula-php) | FFI binding over macula-go, for PHP | | [macula-station](https://github.com/macula-io/macula-station) | The station: DHT, SWIM, routing, peering | | [macula-realm](https://github.com/macula-io/macula-realm) | Managed-realm identity + certificate authority | diff --git a/examples/common/mod.rs b/examples/common/mod.rs new file mode 100644 index 0000000..b30ad95 --- /dev/null +++ b/examples/common/mod.rs @@ -0,0 +1,89 @@ +//! What every example joins the mesh with, from the environment: +//! +//! - `MACULA_SEED`: the station, host:port (`[v6]:port` for IPv6) +//! - `MACULA_STATION_ID`: its node_id, 64 hex: the station must prove it +//! - `MACULA_REALM`: the realm id, 64 hex +//! - `MACULA_REALM_KEY`: the realm's key as carried, hex (the realm publishes it) +//! - `MACULA_KEY`: this node's key file, created on first use (`node.key`) +//! - `MACULA_PROFILE`: `pq_hybrid` (the fleet's, the default) or `pq_pure` + +#![allow(dead_code)] + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use macula_rust::node_key::NodeKey; +use macula_rust::pool::{Opts, Pool, Seed}; +use macula_rust::profile::Profile; + +pub fn env(name: &str) -> String { + match std::env::var(name) { + Ok(v) if !v.is_empty() => v, + _ => { + eprintln!("set {name} (see the top of examples/common/mod.rs)"); + std::process::exit(2); + } + } +} + +pub fn hex32(name: &str) -> [u8; 32] { + let bytes = hex_decode(&env(name)); + bytes.try_into().unwrap_or_else(|_| { + eprintln!("{name} must be 64 hex characters"); + std::process::exit(2); + }) +} + +pub fn realm() -> [u8; 32] { + hex32("MACULA_REALM") +} + +/// A pool on the seed, as the key in `key_file` (or `MACULA_KEY`, or +/// `node.key`), made on first use, trusting the realm. +pub async fn connect(key_file: Option<&str>) -> Pool { + let seed = env("MACULA_SEED"); + let Some((host, port)) = seed.rsplit_once(':') else { + eprintln!("MACULA_SEED must be host:port"); + std::process::exit(2); + }; + let profile = std::env::var("MACULA_PROFILE") + .ok() + .and_then(|p| Profile::parse(&p).ok()) + .unwrap_or(Profile::PqHybrid); + let key_file = key_file + .map(str::to_string) + .or_else(|| std::env::var("MACULA_KEY").ok()) + .unwrap_or_else(|| "node.key".into()); + let key = NodeKey::load_or_create(Path::new(&key_file), profile).expect("the node's key"); + let mut opts = Opts::new(Arc::new(key)); + opts.realm_trust = HashMap::from([(realm(), hex_decode(&env("MACULA_REALM_KEY")))]); + Pool::connect( + vec![Seed { + host: host + .trim_start_matches('[') + .trim_end_matches(']') + .to_string(), + port: port.parse().expect("MACULA_SEED's port"), + node_id: hex32("MACULA_STATION_ID"), + }], + opts, + ) + .await + .expect("a link to the seed") +} + +pub fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn hex_decode(text: &str) -> Vec { + (0..text.len()) + .step_by(2) + .map(|i| u8::from_str_radix(text.get(i..i + 2).unwrap_or("zz"), 16)) + .collect::>() + .unwrap_or_else(|_| { + eprintln!("not hex: {text}"); + std::process::exit(2); + }) +} diff --git a/examples/cross_verify_sign.rs b/examples/cross_verify_sign.rs new file mode 100644 index 0000000..851190b --- /dev/null +++ b/examples/cross_verify_sign.rs @@ -0,0 +1,29 @@ +//! This crate's half of scripts/cross-verify-macula.sh: a pq_hybrid key made +//! for the run and never saved signs a message; the message, the public key +//! as carried and the signature go to the directory named on the command line +//! for macula to verify. + +use macula_rust::node_key::{verify, NodeKey, Purpose}; +use macula_rust::profile::Profile; + +fn main() -> Result<(), Box> { + let dir = std::path::PathBuf::from( + std::env::args() + .nth(1) + .ok_or("usage: cross_verify_sign ")?, + ); + let key = NodeKey::generate(Purpose::Identity, Profile::PqHybrid)?; + let message = b"signed by macula-rust"; + let signature = key.sign(message)?; + if !verify(message, &signature, &key.public_key(), Profile::PqHybrid) { + return Err("macula-rust does not verify its own composite".into()); + } + std::fs::write(dir.join("m.bin"), message)?; + std::fs::write(dir.join("pk.bin"), key.public_key())?; + std::fs::write(dir.join("s.bin"), &signature)?; + println!( + "rust_signed: {}-byte composite by macula-rust written", + signature.len() + ); + Ok(()) +} diff --git a/examples/publish_subscribe.rs b/examples/publish_subscribe.rs new file mode 100644 index 0000000..b7f7d9b --- /dev/null +++ b/examples/publish_subscribe.rs @@ -0,0 +1,42 @@ +//! Subscribes to a topic and publishes to it. A topic names a kind of fact, +//! with a business verb, and ids go in the payload. There is no boolean on +//! the wire: write 1 or 0. +//! +//! Run: `cargo run --example publish_subscribe`, with the environment +//! examples/common/mod.rs reads. + +mod common; + +use std::time::Duration; + +use macula_rust::cbor::Value; +use macula_rust::station_link::Publication; + +const TOPIC: &str = "acme/demo/greeting_sent_v1"; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let pool = common::connect(None).await; + let mut sub = pool.subscribe(&common::realm(), TOPIC).await?; + tokio::time::sleep(Duration::from_millis(300)).await; + pool.publish(Publication { + realm: common::realm(), + topic: TOPIC.into(), + payload: Value::Map(vec![ + (Value::text("text"), Value::text("hi")), + (Value::text("urgent"), Value::Int(0)), + ]), + ttl_ms: None, + }) + .await?; + while let Ok(Some(event)) = tokio::time::timeout(Duration::from_secs(2), sub.recv()).await { + println!( + "{} published {:?}", + common::hex(&event.publisher), + event.payload + ); + } + sub.unsubscribe().await?; + pool.close().await; + Ok(()) +} diff --git a/examples/quickstart.rs b/examples/quickstart.rs index 932d3c5..aba410d 100644 --- a/examples/quickstart.rs +++ b/examples/quickstart.rs @@ -1,123 +1,35 @@ -//! Minimal end-to-end example: connect to a station, advertise a -//! trivial echo procedure, and call it. Dials the real fleet, so this -//! isn't run by CI — see README.md's "Quick start" section, which this -//! file backs (kept compiling by `cargo build --examples` in CI, run -//! manually with `cargo run --example quickstart`). +//! Connects to a macula 12 station and calls mcl-echo/echo, which runs on +//! another station: the pool finds its trusted advertisement in the DHT and +//! dials the station it serves from. //! -//! Two identities are used (a provider and a caller) because a station -//! kicks a connection the instant a second one arrives under the same -//! identity — the same reason this crate's own live tests use separate -//! identities for each role (see `tests/live_station.rs`'s -//! `unary_call_provider_round_trip_against_the_real_fleet`). The -//! procedure name is unique per run (a station's DHT can hold stale -//! routing state for a fixed name from a prior run's now-dead -//! advertiser) — and it's this crate's own procedure, not a shared -//! fleet service, so this example never depends on anything else being -//! deployed. -//! -//! The provider `Session` is moved back OUT of its `tokio::spawn` task -//! and closed explicitly, rather than let it drop when the task ends -- -//! see [`macula_rust::connection::Session`]'s own doc for why: dropping -//! the last handle closes the connection at once, which gives quinn's -//! send-scheduling no guarantee the RESULT this example just sent -//! actually reached the peer first. Confirmed live 2026-09-05: -//! under `#[tokio::main]`'s default multi-threaded runtime, a spawned -//! task with nothing after `serve_one_call().await` can complete (and -//! drop the session) within microseconds of the write, losing the reply -//! deterministically -- `tests/live_station.rs`'s own -//! `unary_call_provider_round_trip_multi_thread_runtime` reproduces this -//! and confirms the fix. -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +//! Run: `cargo run --example quickstart`, with the environment +//! examples/common/mod.rs reads. + +mod common; -use macula_rust::{ - cbor::Value, - connection::{self, BoxFuture, CallHandler}, - frame::AdvertiseSpec, - identity::KeyPair, - transport::Trust, -}; +use macula_rust::cbor::Value; +use macula_rust::pool::Call; #[tokio::main] async fn main() -> Result<(), Box> { - // Puzzle-hardened identities — required. An unhardened identity fails - // the handshake silently (QUIC/TLS looks healthy, HELLO never accepts). - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - "station-de-frankfurt.macula.io", - 4433, - Trust::WebPki, - &provider_identity, - ) - .await?; - let caller_session = connection::connect( - "station-de-frankfurt.macula.io", - 4433, - Trust::WebPki, - &caller_identity, - ) - .await?; - - let realm = [0u8; 32]; - // Unique per run — reusing a fixed procedure name across rapid - // repeated runs can hit stale DHT routing state from the prior run's - // now-dead advertiser. - let procedure = format!( - "macula_rust.quickstart_echo.{}", - SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() - ); - - let advertise_spec = AdvertiseSpec::new(realm, procedure.clone(), provider_identity.node_id()); - provider_session - .advertise(&advertise_spec, &provider_identity) + let pool = common::connect(None).await; + println!("node {}", common::hex(&pool.node_id())); + for provider in pool.providers(&common::realm(), "mcl-echo/echo").await? { + println!( + "provider {} at station {}", + common::hex(&provider.node), + common::hex(&provider.station) + ); + } + let answered = pool + .call(Call { + realm: common::realm(), + procedure: "mcl-echo/echo".into(), + payload: Value::text("hello"), + ..Call::default() + }) .await?; - tokio::time::sleep(Duration::from_millis(500)).await; // ADVERTISE is fire-and-forget; give it a moment to land - - let target_procedure = procedure.clone(); - let lookup = move |_realm: &[u8; 32], proc: &str| -> Option { - if proc != target_procedure { - return None; - } - let handler: CallHandler = std::sync::Arc::new(|payload: Value| { - Box::pin(async move { Ok(payload) }) as BoxFuture<'static, Result> - }); - Some(handler) - }; - - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call(lookup, &provider_identity, Duration::from_secs(10)) - .await; - // Close explicitly instead of letting provider_session drop when - // this task ends -- see this file's own doc comment. - provider_session - .close( - "normal", - Some("quickstart provider done"), - &provider_identity, - ) - .await; - result - }); - - let now_ms = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as i128; - let response = caller_session - .call( - &procedure, - realm, - Value::Text("hello".into()), - now_ms + 5_000, // deadline_ms - &caller_identity, - Duration::from_secs(5), - ) - .await?; - - serve_task.await??; - caller_session - .close("normal", Some("quickstart caller done"), &caller_identity) - .await; - - println!("{response:?}"); + println!("mcl-echo/echo answered {answered:?}"); + pool.close().await; Ok(()) } diff --git a/examples/serve.rs b/examples/serve.rs new file mode 100644 index 0000000..cf14600 --- /dev/null +++ b/examples/serve.rs @@ -0,0 +1,48 @@ +//! Serves a procedure in this node's own namespace, `~/ring`, which +//! needs no org and no realm key: the node's signature authorizes it. A +//! second node, with a key of its own, calls it by direct dial. +//! +//! Run: `cargo run --example serve`, with the environment +//! examples/common/mod.rs reads. The caller's key is `caller.key`. + +mod common; + +use macula_rust::cbor::Value; +use macula_rust::pool::{Call, Offer}; +use macula_rust::record; +use macula_rust::station_link::handler; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let provider = common::connect(None).await; + let ring = record::own_procedure(&provider.node_id(), "ring"); + let served = provider + .serve(Offer::unary( + common::realm(), + &ring, + handler(|request| async move { + Ok(Value::Map(vec![( + Value::text("answered"), + Value::Bytes(request.caller.to_vec()), + )])) + }), + )) + .await?; + println!("serving {ring}"); + + let caller = common::connect(Some("caller.key")).await; + let answered = caller + .call(Call { + realm: common::realm(), + procedure: ring, + payload: Value::Null, + ..Call::default() + }) + .await?; + println!("{answered:?}"); + + served.stop().await?; + caller.close().await; + provider.close().await; + Ok(()) +} diff --git a/examples/ucan.rs b/examples/ucan.rs deleted file mode 100644 index 09d0f7f..0000000 --- a/examples/ucan.rs +++ /dev/null @@ -1,113 +0,0 @@ -//! UCAN-gated serving: mint a token, gate a served procedure on it, show -//! both the rejected-without-token and accepted-with-token paths. Dials -//! the real fleet, so this isn't run by CI — see README.md's "Known -//! limitations" section for the investigation this example closes out -//! (kept compiling by `cargo build --examples` in CI, run manually with -//! `cargo run --example ucan`). -//! -//! Keeps the provider `Session` alive for a moment after -//! `serve_one_call_gated` returns before letting it drop — dropping its -//! last handle closes the QUIC connection at once, which can discard the -//! just-sent reply before it reaches the peer (the same race documented -//! on [`macula_rust::connection::Session::close`]). See this file's own -//! git history / README for the investigation that found this. -use std::sync::Arc; -use std::time::Duration; - -use macula_rust::{ - cbor::Value, connection, connection::CallHandler, identity::KeyPair, transport::Trust, ucan, -}; - -const HOST: &str = "station-de-frankfurt.macula.io"; -const PORT: u16 = 4433; - -#[tokio::main] -async fn main() -> Result<(), Box> { - let provider_id = KeyPair::generate_with_default_puzzle(); - let caller_id = KeyPair::generate_with_default_puzzle(); - let authority = KeyPair::generate_with_default_puzzle(); - - let provider = connection::connect(HOST, PORT, Trust::WebPki, &provider_id).await?; - let caller = connection::connect(HOST, PORT, Trust::WebPki, &caller_id).await?; - - let realm = [0u8; 32]; - let procedure = "macula_rust.examples.ucan_gated"; - let advertise_spec = - macula_rust::frame::AdvertiseSpec::new(realm, procedure, provider_id.node_id()); - provider.advertise(&advertise_spec, &provider_id).await?; - tokio::time::sleep(Duration::from_millis(1200)).await; - - // Only callers holding a token issued by `authority` may invoke this - // procedure. A real deployment would use a stable, pre-shared - // authority identity, not one minted fresh per run. - let issuer_pub = authority.node_id(); - let handler: CallHandler = Arc::new(|payload: Value| { - Box::pin(async move { Ok(Value::Text(format!("granted: {payload:?}"))) }) - }); - - // serve_one_call_gated answers exactly ONE inbound call, then - // returns -- this example makes two calls (rejected, then granted), - // so the provider loops twice, once per expected call. - let serve_task = tokio::spawn(async move { - for _ in 0..2 { - let handler = handler.clone(); - provider - .serve_one_call_gated( - move |_realm, proc| { - if proc == procedure { - Some(handler.clone()) - } else { - None - } - }, - move |_, _| ucan::Policy::required(issuer_pub), - &provider_id, - Duration::from_secs(15), - ) - .await?; - } - // Keep the session alive briefly after the last reply -- see - // this file's module doc for why this matters. - tokio::time::sleep(Duration::from_millis(300)).await; - Ok::<(), connection::ServeCallError>(()) - }); - - // First call: no token at all -- refused before the handler ever runs. - let rejected = caller - .call( - procedure, - realm, - Value::Null, - 0, - &caller_id, - Duration::from_secs(5), - ) - .await; - println!("call without a token: {rejected:?}"); - - // Second call: a real token minted by the required authority. It names - // this caller as its audience (the caller's node id as lowercase hex), - // the only caller a gated provider accepts it from. - let token = ucan::create( - "did:key:example-issuer", - &hex::encode(caller_id.node_id()), - vec![], - &authority, - ucan::CreateOpts::default(), - )?; - let granted = caller - .call_with_ucan( - procedure, - realm, - Value::Text("hello".into()), - 0, - &caller_id, - Duration::from_secs(5), - token, - ) - .await; - println!("call with a valid token: {granted:?}"); - - serve_task.await??; - Ok(()) -} diff --git a/macula-rust-ffi/Cargo.toml b/macula-rust-ffi/Cargo.toml index 53b9a4e..1e35d5d 100644 --- a/macula-rust-ffi/Cargo.toml +++ b/macula-rust-ffi/Cargo.toml @@ -2,7 +2,7 @@ name = "macula-rust-ffi" version = "0.4.0" edition = "2021" -rust-version = "1.85" +rust-version = "1.91" authors = ["Macula "] description = "UniFFI mobile (Kotlin/Swift) bindings for macula-rust. Wraps the core crate; adds nothing to it." license = "Apache-2.0" @@ -34,9 +34,8 @@ thiserror = "2" async-trait = "0.1" [dev-dependencies] -# tests/live_cert_chain_direct_dial.rs's self-issued realm CA/leaf fixture, -# mirroring ../tests/live_cert_chain.rs's own — versions matched to the -# core crate's own pins. -rcgen = { version = "0.14", default-features = false, features = ["pem", "ring", "x509-parser"] } -time = "0.3" -base64 = "0.23" +# tests/pool_ffi.rs drives the core crate's teststation lab +# (../tests/common), which reads the helper's JSON and hex. +hex = "0.4" +serde_json = "1" +tempfile = "3" diff --git a/macula-rust-ffi/src/lib.rs b/macula-rust-ffi/src/lib.rs index a395e86..e664fbe 100644 --- a/macula-rust-ffi/src/lib.rs +++ b/macula-rust-ffi/src/lib.rs @@ -1,55 +1,25 @@ -//! UniFFI (Kotlin/Swift) bindings for [`macula_rust`]. A thin wrapper, -//! not a reimplementation — everything here delegates straight to the -//! core crate; nothing wire-level lives in this crate at all. +//! UniFFI (Kotlin/Swift) bindings for [`macula_rust`] on the macula 12 wire. +//! A thin wrapper, not a reimplementation: everything here delegates to the +//! core crate's [`macula_rust::pool`], and nothing wire-level lives in this +//! crate. A separate crate keeps the core free of any UniFFI dependency or +//! FFI-shaped type, so it stays as usable from plain Rust or a CLI. //! -//! Structure mirrors `iroh-ffi`'s relationship to `iroh`: a separate -//! crate depending on the core one, so the core crate carries zero -//! UniFFI dependency and zero FFI-shaped types. That separation is what -//! keeps `macula-rust` itself just as usable from plain Rust, a CLI, -//! or WASM as it was before this crate existed. +//! What is wrapped: a node key ([`FfiNodeKey`]: generated in either +//! profile, kept in a key file or the platform's secure store), and a pool +//! of station links ([`FfiPool`]) with everything a node does through it: +//! calls to a provider at its own station, serving a procedure with a +//! handler the foreign side implements ([`FfiCallHandler`]), pubsub +//! ([`FfiSubscription`]), streaming sessions on either side ([`FfiStream`], +//! [`FfiStreamHandler`]), and DHT records. //! -//! Every application primitive the core crate has is wrapped: identity, -//! CONNECT/HELLO (either [`FfiTrust::Pinned`] or [`FfiTrust::WebPki`] — -//! see that type's own doc for when each applies), CALL/RESULT/ERROR as -//! both caller AND provider (`call`/[`FfiSession::serve_one_call`]), -//! UCAN-gated serving ([`FfiSession::serve_one_call_gated`]/ -//! [`FfiSession::call_with_ucan`]) and the standalone `ucan_*` mint/verify/ -//! introspect functions, PUBLISH/SUBSCRIBE/EVENT (including the supervised -//! [`FfiSession::run_publisher`]), content transfer, streaming RPC — both -//! the caller/consumer role (§13.1) and the provider role (§13.2/§6.9, -//! `advertise`/`accept_stream`), direct-dial resolution -//! ([`FfiSession::resolve_direct`]/[`call_direct`](FfiSession::call_direct)/ -//! [`advertise_direct`](FfiSession::advertise_direct)) and its cert-chain- -//! authorized variants (`*_with_cert_chain`), and direct-dial streaming/ -//! content transfer ([`FfiSession::open_stream_direct`]/ -//! [`FfiSession::put_direct`]/[`FfiSession::get_direct`]). +//! [`FfiValue`] mirrors every variant [`macula_rust::cbor::Value`] has, +//! narrowed only where the FFI boundary forces it: `Int` is `i64`, and an +//! integer outside it is [`FfiError::UnrepresentableValue`], never +//! truncated. Every 32-byte id crosses as bytes and is checked here +//! ([`FfiError::WrongByteLength`]). //! -//! Not exposed, each a real, reasoned decision rather than an oversight: -//! `Trust::Insecure` — see [`FfiTrust`]'s own doc; the core crate's -//! `keep_advertised`/`keep_advertised_direct` background-loop helpers — -//! see [`FfiSession::advertise_direct`]'s own doc for why a native -//! background timer is the wrong shape for a mobile app and what to do -//! instead; `Session::run_subscriber` — same reasoning as -//! `keep_advertised` (it takes a generic `stop: impl Future` and -//! `handler: impl FnMut`, neither of which crosses the UniFFI boundary, -//! and a native long-lived receive loop fights mobile app-lifecycle -//! management the same way a background timer does) — its full external -//! behavior (subscribe once, receive until stopped, unsubscribe when done) -//! is still achievable on the foreign side with [`FfiSession::subscribe`], -//! [`FfiSubscription::recv_event`] in a loop that carries on past a -//! timeout, and [`FfiSubscription::close`] — nothing is lost, only where -//! that loop lives. -//! -//! [`FfiValue`] mirrors every variant [`macula_rust::cbor::Value`] -//! has, including recursive list/map shapes (`Items`/`Fields`, via -//! `Vec` — see the type's own doc for why they aren't named `List`/ -//! `Map` like the core type), narrowed only where the FFI boundary -//! forces it: `Int` is `i64` not `i128` (out-of-range values round-trip -//! as an [`FfiError::UnrepresentableValue`] rather than silently -//! truncating). -//! -//! Generate the bindings with the `uniffi-bindgen` binary this crate -//! also builds, e.g.: +//! Generate the bindings with the `uniffi-bindgen` binary this crate also +//! builds, e.g.: //! ```text //! cargo build -p macula-rust-ffi --release //! cargo run -p macula-rust-ffi --bin uniffi-bindgen -- generate \ @@ -57,71 +27,151 @@ //! --language kotlin --out-dir bindings/kotlin //! ``` -uniffi::setup_scaffolding!(); +mod node_key; +mod pool; +mod pubsub; +mod serve; +mod stream; -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock after epoch") - .as_millis() as u64 -} +pub use node_key::{FfiNodeKey, FfiProfile}; +pub use pool::{ + own_procedure, FfiLinkStatus, FfiPool, FfiPoolOptions, FfiProvider, FfiRealmKey, FfiRecord, + FfiSeed, +}; +pub use pubsub::{FfiEvent, FfiSubscription}; +pub use serve::{FfiCallHandler, FfiRequest, FfiServed}; +pub use stream::{FfiStream, FfiStreamEncoding, FfiStreamEvent, FfiStreamHandler, FfiStreamMode}; + +use macula_rust::pool::PoolError; +use macula_rust::station_link::LinkError; -#[derive(Debug, thiserror::Error, uniffi::Error)] +uniffi::setup_scaffolding!(); + +/// Why an operation failed, as Kotlin and Swift see it. +#[derive(Debug, Clone, PartialEq, thiserror::Error, uniffi::Error)] pub enum FfiError { - #[error("connecting to the station: {reason}")] - Connect { reason: String }, - #[error("the call failed: {reason}")] - Call { reason: String }, - #[error("sending a frame failed: {reason}")] - Send { reason: String }, - #[error("receiving failed: {reason}")] - Recv { reason: String }, - #[error("content operation failed: {reason}")] - Content { reason: String }, - #[error("a value could not cross the FFI boundary: {reason}")] - UnrepresentableValue { reason: String }, + /// An argument outside what the operation takes. + #[error("invalid argument: {message}")] + InvalidArgument { message: String }, + /// A byte string of the wrong length, where a 32-byte id belongs. #[error("expected exactly {expected} bytes, got {actual}")] WrongByteLength { expected: u32, actual: u32 }, - #[error("this session is already closed")] - Closed, - #[error("the call handler failed: {reason}")] - CallHandlerFailed { reason: String }, - #[error("direct-dial resolution failed: {reason}")] - Resolve { reason: String }, - #[error("direct-dial trust violation: the dialed peer's proven identity did not match the resolved station (see resolved/dialed fields)")] - DirectDialTrustViolation { resolved: Vec, dialed: Vec }, - #[error("UCAN operation failed: {reason}")] - Ucan { reason: String }, - #[error("no seed is stored under this keystore identity")] + /// A value the FFI boundary cannot carry, such as an integer outside i64. + #[error("a value could not cross the FFI boundary: {message}")] + UnrepresentableValue { message: String }, + /// A node key that could not be made, saved or loaded. + #[error("node key: {message}")] + Key { message: String }, + /// Nothing is stored under this keystore identity. + #[error("no key is stored under this keystore identity")] KeystoreNotFound, - #[error("platform secure store error: {reason}")] - Keystore { reason: String }, + /// The platform's secure store failed. + #[error("platform secure store: {message}")] + Keystore { message: String }, + /// No station link came up, or none is up to carry the operation. + #[error("no station link: {message}")] + NoLink { message: String }, + /// A realm the pool pins no key for: nothing in it is served or trusted. + #[error("no realm key is pinned for the realm")] + NoRealmKey, + /// No trusted provider advertises or answered the procedure. + #[error("{message}")] + NoProvider { message: String }, + /// The provider's own ERROR: its code, detail, and who responded. + #[error("the provider answered {code}")] + Provider { + code: String, + detail: Option, + responded_by: Vec, + }, + /// The connected station could not relay the call. + #[error("the station could not relay the call: {code}")] + Relay { code: String }, + /// No answer within the timeout. + #[error("timed out")] + Timeout, + /// A record the DHT does not hold. + #[error("record not found")] + RecordNotFound, + /// A stream ended by an error: the peer's, the station's, or this side's. + #[error("stream error {code}: {message}")] + Stream { code: String, message: String }, + /// A stream that ended normally. + #[error("end of stream")] + EndOfStream, + /// A handler the foreign side implements refused, or failed. + #[error("handler: {message}")] + Handler { message: String }, + /// An operation on a closed pool, subscription, stream or serving. + #[error("closed")] + Closed, + /// Anything else the core crate reports, as its text. + #[error("{message}")] + Other { message: String }, +} + +impl From for FfiError { + /// A foreign handler that threw something other than an FfiError. + fn from(e: uniffi::UnexpectedUniFFICallbackError) -> Self { + FfiError::Handler { message: e.reason } + } } -impl From for FfiError { - fn from(e: macula_rust::ucan::UcanError) -> Self { - FfiError::Ucan { - reason: e.to_string(), +impl From for FfiError { + fn from(e: LinkError) -> Self { + match e { + LinkError::Provider { + responded_by, + code, + detail, + } => FfiError::Provider { + code, + detail, + responded_by: responded_by.to_vec(), + }, + LinkError::Relay { code, .. } => FfiError::Relay { code }, + LinkError::CallTimeout | LinkError::HandshakeTimeout => FfiError::Timeout, + LinkError::RecordNotFound => FfiError::RecordNotFound, + LinkError::Stream { code, message, .. } => FfiError::Stream { code, message }, + LinkError::EndOfStream => FfiError::EndOfStream, + LinkError::Closed | LinkError::StreamClosed | LinkError::Stopped => FfiError::Closed, + other => FfiError::Other { + message: other.to_string(), + }, } } } -impl From for FfiError { - fn from(e: macula_rust::keystore::KeyStoreError) -> Self { +impl From for FfiError { + fn from(e: PoolError) -> Self { match e { - macula_rust::keystore::KeyStoreError::NotFound => FfiError::KeystoreNotFound, - other => FfiError::Keystore { - reason: other.to_string(), + PoolError::Link(link) => link.into(), + PoolError::NoRealmKey => FfiError::NoRealmKey, + PoolError::Closed => FfiError::Closed, + e @ PoolError::NoProvider(_) => FfiError::NoProvider { + message: e.to_string(), + }, + e @ PoolError::NoLink(_) => FfiError::NoLink { + message: e.to_string(), + }, + e @ (PoolError::NoSeeds + | PoolError::SeedNotPinned(_) + | PoolError::TooManySeeds { .. } + | PoolError::RealmTrustInvalid(_) + | PoolError::InvalidOpts(_)) => FfiError::InvalidArgument { + message: e.to_string(), + }, + other => FfiError::Other { + message: other.to_string(), }, } } } -/// `Vec` -> `[u8; 32]`, with both lengths actually reported on -/// mismatch — UniFFI has no fixed-size byte array type, so every 32-byte -/// field (`realm`, node ids) crosses the boundary as `Vec` and gets -/// validated here. -fn to_32(bytes: Vec) -> Result<[u8; 32], FfiError> { +/// `Vec` to `[u8; 32]`, reporting both lengths on a mismatch: UniFFI has +/// no fixed-size byte array, so every id crosses as bytes and is checked +/// here. +pub(crate) fn to_32(bytes: Vec) -> Result<[u8; 32], FfiError> { let actual = bytes.len() as u32; bytes.try_into().map_err(|_| FfiError::WrongByteLength { expected: 32, @@ -129,15 +179,9 @@ fn to_32(bytes: Vec) -> Result<[u8; 32], FfiError> { }) } -/// `Vec` -> `[u8; 34]` — same as [`to_32`], for an MCID -/// (`<>`, `plans/PLAN_WIRE_PROTOCOL.md` -/// §12.1). -fn to_mcid(bytes: Vec) -> Result { - let actual = bytes.len() as u32; - bytes.try_into().map_err(|_| FfiError::WrongByteLength { - expected: 34, - actual, - }) +/// A timeout in milliseconds, zero for the core crate's default. +pub(crate) fn millis(ms: u64) -> std::time::Duration { + std::time::Duration::from_millis(ms) } /// A mirror of [`macula_rust::cbor::Value`], narrowed only where the @@ -220,7 +264,7 @@ impl TryFrom for FfiValue { i64::try_from(n) .map(FfiValue::Int) .map_err(|_| FfiError::UnrepresentableValue { - reason: format!("integer {n} is outside i64 range"), + message: format!("integer {n} is outside i64 range"), }) } Value::Bytes(b) => Ok(FfiValue::Bytes(b)), @@ -244,1660 +288,3 @@ impl TryFrom for FfiValue { } } } - -/// The result of a CALL: a mirror of -/// [`macula_rust::frame::CallResponse`]. -#[derive(uniffi::Enum, Debug, Clone)] -pub enum FfiCallResponse { - Result { - payload: FfiValue, - responded_by: Vec, - }, - Error { - code: u8, - name: String, - reported_by: Vec, - detail: Option, - }, -} - -impl TryFrom for FfiCallResponse { - type Error = FfiError; - - fn try_from(r: macula_rust::frame::CallResponse) -> Result { - use macula_rust::frame::CallResponse; - match r { - CallResponse::Result { - payload, - responded_by, - } => Ok(FfiCallResponse::Result { - payload: FfiValue::try_from(payload)?, - responded_by: responded_by.to_vec(), - }), - CallResponse::Error { - code, - name, - reported_by, - detail, - } => Ok(FfiCallResponse::Error { - code, - name, - reported_by: reported_by.to_vec(), - detail, - }), - } - } -} - -/// A resolved direct-dial target — a mirror of -/// [`macula_rust::direct_dial::Resolved`]: the station's own node id -/// (32 bytes) plus its dialable host/port. Returned by -/// [`FfiSession::resolve_direct`]; [`FfiSession::call_direct`] does this -/// same resolution internally, so most callers never need this type -/// directly — it's exposed for a caller that wants to resolve once and -/// decide what to do with the target itself (e.g. displaying it, or -/// dialing via a mechanism this crate doesn't cover). -#[derive(uniffi::Record, Debug, Clone)] -pub struct FfiResolved { - pub station: Vec, - pub host: String, - pub port: u16, -} - -impl From for FfiResolved { - fn from(r: macula_rust::direct_dial::Resolved) -> Self { - FfiResolved { - station: r.station.to_vec(), - host: r.host, - port: r.port, - } - } -} - -impl From for FfiError { - fn from(e: macula_rust::direct_dial::ResolveError) -> Self { - FfiError::Resolve { - reason: e.to_string(), - } - } -} - -impl From for FfiError { - fn from(e: macula_rust::direct_dial::CallError) -> Self { - use macula_rust::direct_dial::CallError; - match e { - CallError::Resolve(re) => re.into(), - CallError::TrustViolation { resolved, dialed } => FfiError::DirectDialTrustViolation { - resolved: resolved.to_vec(), - dialed: dialed.to_vec(), - }, - other => FfiError::Call { - reason: other.to_string(), - }, - } - } -} - -impl From for FfiError { - fn from(e: macula_rust::direct_dial::AdvertiseDirectError) -> Self { - FfiError::Send { - reason: e.to_string(), - } - } -} - -/// One entry in a UCAN token's capability list — mirrors -/// [`macula_rust::ucan::Capability`]. -#[derive(uniffi::Record, Debug, Clone, PartialEq)] -pub struct FfiCapability { - pub with: String, - pub can: String, -} - -impl From for FfiCapability { - fn from(c: macula_rust::ucan::Capability) -> Self { - FfiCapability { - with: c.with, - can: c.can, - } - } -} - -impl From for macula_rust::ucan::Capability { - fn from(c: FfiCapability) -> Self { - macula_rust::ucan::Capability { - with: c.with, - can: c.can, - } - } -} - -/// A UCAN token's decoded claims — a mirror of -/// [`macula_rust::ucan::Payload`], minus `facts`: the core type's -/// `facts` field is an arbitrary `serde_json::Value` map, which has no -/// UniFFI-representable shape (unlike [`FfiValue`], which exists -/// specifically to give CBOR values one) — the same class of narrowing -/// [`FfiValue::Int`] already documents for `i128`. A caller needing the -/// raw `fct` claim can decode the token bytes on the foreign side with any -/// JSON library. -#[derive(uniffi::Record, Debug, Clone)] -pub struct FfiUcanPayload { - pub issuer: String, - pub audience: String, - pub capabilities: Vec, - pub expires_at: Option, - pub not_before: Option, - pub nonce: String, - pub proofs: Vec, -} - -impl From for FfiUcanPayload { - fn from(p: macula_rust::ucan::Payload) -> Self { - FfiUcanPayload { - issuer: p.issuer, - audience: p.audience, - capabilities: p.capabilities.into_iter().map(Into::into).collect(), - expires_at: p.expires_at, - not_before: p.not_before, - nonce: p.nonce, - proofs: p.proofs, - } - } -} - -/// Mints a new UCAN token, self-issued and signed by `identity` — see -/// [`macula_rust::ucan::create`]'s own doc for the full contract -/// (`issuer`/`audience` are opaque strings, not validated here). A token -/// for a UCAN-gated procedure must name the calling node as its `audience`: -/// that node's id as lowercase hex. -#[uniffi::export] -pub fn ucan_create( - issuer: String, - audience: String, - capabilities: Vec, - identity: &FfiKeyPair, - expires_at: Option, - not_before: Option, -) -> Result, FfiError> { - let opts = macula_rust::ucan::CreateOpts { - expires_at, - not_before, - ..Default::default() - }; - macula_rust::ucan::create( - &issuer, - &audience, - capabilities.into_iter().map(Into::into).collect(), - &identity.0, - opts, - ) - .map_err(FfiError::from) -} - -/// Verifies `token`'s signature against `public_key` (32 bytes) and its -/// `exp`/`nbf` claims against the current time — see -/// [`macula_rust::ucan::verify`]'s own doc, including its check order. -/// Only a successful [`ucan_verify`] result should ever back an -/// authorization decision — [`ucan_decode`] and the `ucan_get_*` getters -/// below never check the signature. -#[uniffi::export] -pub fn ucan_verify(token: Vec, public_key: Vec) -> Result { - let key = to_32(public_key)?; - macula_rust::ucan::verify(&token, &key) - .map(FfiUcanPayload::from) - .map_err(FfiError::from) -} - -/// Parses `token`'s payload WITHOUT verifying its signature or checking -/// expiration — see [`ucan_verify`]'s doc for why that distinction matters. -#[uniffi::export] -pub fn ucan_decode(token: Vec) -> Result { - macula_rust::ucan::decode(&token) - .map(FfiUcanPayload::from) - .map_err(FfiError::from) -} - -/// `token`'s `iss` claim, unverified — see [`ucan_verify`]'s doc. -#[uniffi::export] -pub fn ucan_get_issuer(token: Vec) -> Result { - macula_rust::ucan::get_issuer(&token).map_err(FfiError::from) -} - -/// `token`'s `aud` claim, unverified — see [`ucan_verify`]'s doc. -#[uniffi::export] -pub fn ucan_get_audience(token: Vec) -> Result { - macula_rust::ucan::get_audience(&token).map_err(FfiError::from) -} - -/// `token`'s `cap` claim, unverified — see [`ucan_verify`]'s doc. -#[uniffi::export] -pub fn ucan_get_capabilities(token: Vec) -> Result, FfiError> { - macula_rust::ucan::get_capabilities(&token) - .map(|caps| caps.into_iter().map(Into::into).collect()) - .map_err(FfiError::from) -} - -/// `token`'s `exp` claim, unverified — see [`ucan_verify`]'s doc. -#[uniffi::export] -pub fn ucan_get_expiration(token: Vec) -> Result, FfiError> { - macula_rust::ucan::get_expiration(&token).map_err(FfiError::from) -} - -/// `token`'s `prf` claim, unverified — see [`ucan_verify`]'s doc. -#[uniffi::export] -pub fn ucan_get_proofs(token: Vec) -> Result, FfiError> { - macula_rust::ucan::get_proofs(&token).map_err(FfiError::from) -} - -/// Whether `token`'s `exp` claim is in the past, unverified — see -/// [`ucan_verify`]'s doc. A token with no `exp` claim is never expired. -#[uniffi::export] -pub fn ucan_is_expired(token: Vec) -> Result { - macula_rust::ucan::is_expired(&token).map_err(FfiError::from) -} - -/// `token`'s content identifier (SHA-256, base64url-no-pad) — used only -/// for proof-chain references between UCANs. See -/// [`macula_rust::ucan::compute_cid`]'s own doc. -#[uniffi::export] -pub fn ucan_compute_cid(token: Vec) -> String { - macula_rust::ucan::compute_cid(&token) -} - -/// What a provider requires to answer one inbound CALL — a mirror of -/// [`macula_rust::ucan::Policy`], passed to -/// [`FfiSession::serve_one_call_gated`]. `Open` is what -/// [`FfiSession::serve_one_call`] uses internally. -#[derive(uniffi::Enum, Debug, Clone)] -pub enum FfiPolicy { - Open, - Required { issuer: Vec }, -} - -impl TryFrom for macula_rust::ucan::Policy { - type Error = FfiError; - - fn try_from(p: FfiPolicy) -> Result { - Ok(match p { - FfiPolicy::Open => macula_rust::ucan::Policy::open(), - FfiPolicy::Required { issuer } => macula_rust::ucan::Policy::required(to_32(issuer)?), - }) - } -} - -/// Provider role: implement this trait on the foreign side (Kotlin, -/// Swift) to serve inbound unary CALLs — see -/// [`FfiSession::serve_one_call`]. Adapts -/// [`macula_rust::connection::CallHandler`] for the FFI boundary: -/// `handle` receives the full inbound call (`procedure`/`realm`/ -/// `payload`) rather than being looked up from a table first, since a -/// UniFFI foreign trait can't be handed a plain Rust closure the way -/// the core crate's `CallLookup` is — do your own procedure routing -/// inside `handle` if a single session serves more than one procedure. -/// -/// An `Err` reply is always sent as a BOLT#4 `unknown_error` (0x0F) -/// with `reason` as its `detail` — this trait has no way to -/// distinguish "unknown procedure" from any other application-level -/// failure the way the core crate's `CallLookup` can (a synchronous, -/// local table lookup that either finds a handler or doesn't, checked -/// *before* any handler runs): that distinction would need the foreign -/// side to answer a synchronous "do I handle this?" question ahead of -/// the necessarily-async `handle` call, which UniFFI foreign traits -/// don't support today. Nothing behavioral is lost either way — BOLT#4 -/// `unknown_next_peer` and `unknown_error` carry the identical retry -/// classification (`plans/PLAN_WIRE_PROTOCOL.md` §9) — only diagnostic -/// precision. -/// -/// A panic inside `handle` is caught the same way the core crate's own -/// `serve_one_call` catches one (via `tokio::spawn` + -/// `JoinError::is_panic()`) and reported to the caller as BOLT#4 -/// `temporary_relay_failure`, not propagated across the FFI boundary as -/// a Rust panic. -#[uniffi::export(foreign)] -#[async_trait::async_trait] -pub trait FfiCallHandler: Send + Sync { - async fn handle( - &self, - procedure: String, - realm: Vec, - payload: FfiValue, - ) -> Result; -} - -/// What a subscriber receives: a mirror of -/// [`macula_rust::frame::EventInfo`]. -#[derive(uniffi::Record, Debug, Clone)] -pub struct FfiEvent { - pub topic: String, - pub realm: Vec, - pub publisher: Vec, - pub seq: u64, - pub payload: FfiValue, - pub delivered_via: String, -} - -impl TryFrom for FfiEvent { - type Error = FfiError; - - fn try_from(e: macula_rust::frame::EventInfo) -> Result { - Ok(FfiEvent { - topic: e.topic, - realm: e.realm.to_vec(), - publisher: e.publisher.to_vec(), - seq: e.seq, - payload: FfiValue::try_from(e.payload)?, - delivered_via: e.delivered_via, - }) - } -} - -/// `mode` on a stream — mirrors [`macula_rust::frame::StreamMode`]. -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] -pub enum FfiStreamMode { - ServerStream, - ClientStream, - Bidi, -} - -impl From for macula_rust::frame::StreamMode { - fn from(m: FfiStreamMode) -> Self { - match m { - FfiStreamMode::ServerStream => macula_rust::frame::StreamMode::ServerStream, - FfiStreamMode::ClientStream => macula_rust::frame::StreamMode::ClientStream, - FfiStreamMode::Bidi => macula_rust::frame::StreamMode::Bidi, - } - } -} - -impl From for FfiStreamMode { - fn from(m: macula_rust::frame::StreamMode) -> Self { - match m { - macula_rust::frame::StreamMode::ServerStream => FfiStreamMode::ServerStream, - macula_rust::frame::StreamMode::ClientStream => FfiStreamMode::ClientStream, - macula_rust::frame::StreamMode::Bidi => FfiStreamMode::Bidi, - } - } -} - -/// `encoding` on a stream chunk — mirrors -/// [`macula_rust::frame::StreamEncoding`]. A semantic hint, not a -/// second wire codec — see that type's own doc. -#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] -pub enum FfiStreamEncoding { - Raw, - Msgpack, -} - -impl From for macula_rust::frame::StreamEncoding { - fn from(e: FfiStreamEncoding) -> Self { - match e { - FfiStreamEncoding::Raw => macula_rust::frame::StreamEncoding::Raw, - FfiStreamEncoding::Msgpack => macula_rust::frame::StreamEncoding::Msgpack, - } - } -} - -impl From for FfiStreamEncoding { - fn from(e: macula_rust::frame::StreamEncoding) -> Self { - match e { - macula_rust::frame::StreamEncoding::Raw => FfiStreamEncoding::Raw, - macula_rust::frame::StreamEncoding::Msgpack => FfiStreamEncoding::Msgpack, - } - } -} - -/// One item received from a stream: a chunk, or a clean end-of-stream. -/// Mirrors [`macula_rust::stream::StreamItem`]. -#[derive(uniffi::Enum, Debug, Clone)] -pub enum FfiStreamItem { - Data { - seq: u64, - encoding: FfiStreamEncoding, - body: FfiValue, - }, - Eof, -} - -/// The terminal result of a `client_stream`/`bidi` exchange — the pair -/// [`macula_rust::stream::StreamHandle::await_reply`] returns. -#[derive(uniffi::Record, Debug, Clone)] -pub struct FfiStreamReply { - pub payload: FfiValue, - pub responded_by: Vec, -} - -/// Provider role: the fields of an inbound STREAM_OPEN needed to decide -/// how to handle it (which procedure, whose call, what arguments) — -/// mirrors [`macula_rust::frame::StreamOpenInfo`]. -#[derive(uniffi::Record, Debug, Clone)] -pub struct FfiStreamOpenInfo { - pub stream_id: Vec, - pub procedure: String, - pub realm: Vec, - pub mode: FfiStreamMode, - pub args: FfiValue, - pub deadline_ms: i64, - pub caller: Vec, -} - -impl TryFrom for FfiStreamOpenInfo { - type Error = FfiError; - - fn try_from(o: macula_rust::frame::StreamOpenInfo) -> Result { - Ok(FfiStreamOpenInfo { - stream_id: o.stream_id.to_vec(), - procedure: o.procedure, - realm: o.realm.to_vec(), - mode: o.mode.into(), - args: FfiValue::try_from(o.args)?, - deadline_ms: o.deadline_ms as i64, - caller: o.caller.to_vec(), - }) - } -} - -/// What [`FfiSession::accept_stream`] hands back: a ready-to-use -/// [`FfiStream`] plus the STREAM_OPEN info that came with it. -#[derive(uniffi::Record)] -pub struct FfiAcceptedStream { - pub stream: std::sync::Arc, - pub info: FfiStreamOpenInfo, -} - -/// What [`FfiSession::open_stream_direct`]/ -/// [`FfiSession::open_stream_direct_with_cert_chain`] hand back: the -/// [`FfiStream`], and its [`FfiSessionLease`] on the session it runs on. -/// Release the lease once the stream is done. -#[derive(uniffi::Record)] -pub struct FfiOpenedDirectStream { - pub stream: std::sync::Arc, - pub lease: std::sync::Arc, -} - -impl From for FfiOpenedDirectStream { - fn from(opened: macula_rust::direct_dial::OpenedStream) -> Self { - Self { - stream: std::sync::Arc::new(FfiStream(tokio::sync::Mutex::new(Some(opened.stream)))), - lease: std::sync::Arc::new(FfiSessionLease(tokio::sync::Mutex::new(Some( - opened.lease, - )))), - } - } -} - -/// A direct-dial stream's use of the session it runs on, wrapping -/// [`macula_rust::direct_dial::SessionLease`]. A session direct dial dialed -/// closes once no direct-dial request still uses it; a session this process -/// already had open under its owner stays open. -#[derive(uniffi::Object)] -pub struct FfiSessionLease(tokio::sync::Mutex>); - -#[uniffi::export(async_runtime = "tokio")] -impl FfiSessionLease { - /// Gives back this use of the session once the stream is done. A no-op - /// once released. - pub async fn release(&self, identity: &FfiKeyPair) { - let lease = self.0.lock().await.take(); - if let Some(lease) = lease { - lease.release(&identity.0).await; - } - } -} - -/// How to trust whatever certificate the station presents — mirrors -/// [`macula_rust::transport::Trust`], minus `Insecure`. -/// -/// `Insecure` (skip TLS verification entirely) is deliberately NOT -/// exposed here: it's a development/diagnostic escape hatch in the core -/// crate, never something a shipped mobile app should be able to -/// select — a stray debug flag left on in production would silently -/// disable all transport security. Reach into the core crate directly -/// (outside this FFI boundary) for that one, if a test harness genuinely -/// needs it. -#[derive(uniffi::Enum, Debug, Clone)] -pub enum FfiTrust { - /// Pin the station's known Ed25519 pubkey (its macula node_id, 32 - /// bytes) — the right mode once a station's identity is known - /// (DHT-resolved, or configured directly), and the ONLY mode that - /// works at all for a station without a CA-issued cert, e.g. a - /// self-hosted/home station outside the public demo fleet — WebPki - /// has no chain to validate there. - Pinned { node_id: Vec }, - /// Standard CA-bundle + hostname validation, for a station whose - /// TLS is terminated by real PKI (e.g. Let's Encrypt) — what the - /// public `station-de-frankfurt.macula.io` demo fleet presents. - WebPki, -} - -impl TryFrom for macula_rust::transport::Trust { - type Error = FfiError; - - fn try_from(t: FfiTrust) -> Result { - match t { - FfiTrust::Pinned { node_id } => { - Ok(macula_rust::transport::Trust::Pinned(to_32(node_id)?)) - } - FfiTrust::WebPki => Ok(macula_rust::transport::Trust::WebPki), - } - } -} - -/// An Ed25519 identity, puzzle-hardened by construction — see -/// [`macula_rust::identity::KeyPair::generate_with_default_puzzle`]'s -/// own doc for why this is always the right default despite its (small, -/// one-time) CPU cost. -#[derive(uniffi::Object)] -pub struct FfiKeyPair(macula_rust::identity::KeyPair); - -#[uniffi::export] -impl FfiKeyPair { - #[uniffi::constructor] - pub fn generate() -> Self { - Self(macula_rust::identity::KeyPair::generate_with_default_puzzle()) - } - - /// Reconstruct a keypair from its 32-byte seed (see - /// [`FfiKeyPair::private_bytes`]) — deterministic, the same seed - /// always yields the same node_id. The seed came from a - /// puzzle-hardened [`generate`](Self::generate) call, so - /// reconstructing from it stays puzzle-valid too; puzzle validity is - /// a property of the public key this seed determines, not something - /// re-checked at reconstruction time. - #[uniffi::constructor] - pub fn from_seed_bytes(seed: Vec) -> Result { - Ok(Self(macula_rust::identity::KeyPair::from_seed_bytes( - to_32(seed)?, - ))) - } - - /// This identity's node_id (its Ed25519 public key), 32 bytes. - pub fn node_id(&self) -> Vec { - self.0.node_id().to_vec() - } - - /// This identity's 32-byte seed. Persist it to restore the SAME - /// identity (same node_id) across restarts via - /// [`FfiKeyPair::from_seed_bytes`] — treat it like a private key, - /// since it deterministically reconstructs this keypair. - pub fn private_bytes(&self) -> Vec { - self.0.private_bytes().to_vec() - } - - /// Persist this identity's seed to the platform's native secure store - /// — Keychain on macOS/iOS, Secret Service on Linux, Credential - /// Manager on Windows, Keystore on Android — instead of handling the - /// raw bytes from [`private_bytes`](Self::private_bytes) yourself. See - /// `macula_rust::keystore`'s module doc for the full platform - /// story, including Android's one-time `initializeNdkContext` setup - /// requirement (unrelated to this method itself — a property of that - /// platform's Keystore, not something this crate can do for you). - /// - /// `service`/`account` address the credential the same way every - /// `keyring` consumer does — e.g. `("com.example.myapp", - /// "macula-identity")` — pick values scoped to your application, since - /// the underlying store is a shared OS-wide facility, not sandboxed to - /// this crate. - pub fn save_to_keystore(&self, service: String, account: String) -> Result<(), FfiError> { - let store = macula_rust::keystore::KeyringStore::new(&service, &account)?; - self.0.save_to_keystore(&store)?; - Ok(()) - } - - /// Reconstruct a keypair previously persisted with - /// [`save_to_keystore`](Self::save_to_keystore). Fails with - /// [`FfiError::KeystoreNotFound`] if nothing has been stored yet under - /// this `service`/`account` pair. - #[uniffi::constructor] - pub fn load_from_keystore(service: String, account: String) -> Result { - let store = macula_rust::keystore::KeyringStore::new(&service, &account)?; - Ok(Self(macula_rust::identity::KeyPair::load_from_keystore( - &store, - )?)) - } -} - -/// A handshaked connection to a macula-station. Wraps -/// [`macula_rust::connection::Session`], a handle, behind a mutex that -/// holds it until [`close`](Self::close). Each method works on a handle -/// cloned out of it, so calls, subscriptions and serving on one session run -/// at the same time. -#[derive(uniffi::Object)] -pub struct FfiSession(tokio::sync::Mutex>); - -impl FfiSession { - /// A handle to the session, cloned out so no lock is held while it works. - async fn session(&self) -> Result { - self.0.lock().await.clone().ok_or(FfiError::Closed) - } -} - -#[uniffi::export(async_runtime = "tokio")] -impl FfiSession { - /// Dial `host:port` and complete the CONNECT/HELLO handshake, using - /// `trust` to validate the station's TLS certificate — see - /// [`FfiTrust`]'s own doc for which mode fits which station. - #[uniffi::constructor] - pub async fn connect( - host: String, - port: u16, - trust: FfiTrust, - identity: &FfiKeyPair, - ) -> Result { - let session = macula_rust::connection::connect(&host, port, trust.try_into()?, &identity.0) - .await - .map_err(|e| FfiError::Connect { - reason: e.to_string(), - })?; - Ok(Self(tokio::sync::Mutex::new(Some(session)))) - } - - /// This session's own connected station's node id (32 bytes), as - /// proven by the HELLO frame's own signature during the handshake. - /// Needed to call [`put_direct`](Self::put_direct) against "whatever - /// station this session is already on" — the common case, and - /// otherwise unreachable through this FFI surface without a - /// [`resolve_direct`](Self::resolve_direct) result to read a station - /// id from instead. - pub async fn station_id(&self) -> Vec { - let guard = self.0.lock().await; - guard - .as_ref() - .map(|s| s.station.node_id.to_vec()) - .unwrap_or_default() - } - - /// Send a signed CALL and wait for the matching RESULT or ERROR. - /// `realm` must be exactly 32 bytes. `timeout_ms` bounds both the - /// wait for a response and the frame's own `deadline_ms` field - /// (`now + timeout_ms`). - pub async fn call( - &self, - procedure: String, - realm: Vec, - payload: FfiValue, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result { - let realm = to_32(realm)?; - let deadline_ms = (now_ms() + timeout_ms) as i128; - - let session = self.session().await?; - let session = &session; - let response = session - .call( - &procedure, - realm, - payload.into(), - deadline_ms, - &identity.0, - std::time::Duration::from_millis(timeout_ms), - ) - .await - .map_err(|e| FfiError::Call { - reason: e.to_string(), - })?; - FfiCallResponse::try_from(response) - } - - /// The provider role's counterpart to [`call`](Self::call): wait for - /// the next inbound CALL, bounded by `timeout_ms`, and dispatch it to - /// `handler` — see [`FfiCallHandler`]. Calls, subscriptions and other - /// serving on this session carry on meanwhile. - pub async fn serve_one_call( - &self, - handler: std::sync::Arc, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - self.serve_one_call_gated(handler, FfiPolicy::Open, timeout_ms, identity) - .await - } - - /// [`serve_one_call`](Self::serve_one_call), additionally gating the - /// inbound CALL through `policy` BEFORE `handler` ever runs — see - /// [`FfiPolicy`]. A rejected caller gets a BOLT#4 `unauthorized` error - /// and never reaches `handler`; `handler` itself never sees the raw - /// UCAN token either way, matching - /// [`macula_rust::connection::Session::serve_one_call_gated`]'s own - /// contract exactly. - pub async fn serve_one_call_gated( - &self, - handler: std::sync::Arc, - policy: FfiPolicy, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let policy: macula_rust::ucan::Policy = policy.try_into()?; - let session = self.session().await?; - let session = &session; - - let lookup = move |realm: &[u8; 32], procedure: &str| { - let handler = handler.clone(); - let realm = realm.to_vec(); - let procedure = procedure.to_string(); - let core_handler: macula_rust::connection::CallHandler = - std::sync::Arc::new(move |payload: macula_rust::cbor::Value| { - let handler = handler.clone(); - let realm = realm.clone(); - let procedure = procedure.clone(); - Box::pin(async move { - let ffi_payload = FfiValue::try_from(payload).map_err(|e| e.to_string())?; - let reply = handler - .handle(procedure, realm, ffi_payload) - .await - .map_err(|e| e.to_string())?; - Ok(macula_rust::cbor::Value::from(reply)) - }) - as macula_rust::connection::BoxFuture< - 'static, - Result, - > - }); - Some(core_handler) - }; - - session - .serve_one_call_gated( - lookup, - move |_, _| policy.clone(), - &identity.0, - std::time::Duration::from_millis(timeout_ms), - ) - .await - .map_err(|e| FfiError::Recv { - reason: e.to_string(), - }) - } - - /// Send a signed PUBLISH. Fire-and-forget — no reply is expected on - /// the wire; a subscriber (this session included, if subscribed to - /// the same topic/realm) receives it asynchronously via - /// [`FfiSubscription::recv_event`]. - /// - /// `seq` and `published_at_ms` are caller-supplied rather than - /// tracked internally — unlike streaming RPC's per-stream counter, - /// PUBLISH's `seq` is a per-publisher, per-topic sequence the mesh - /// uses for gap detection, and a client publishing to several topics - /// has to own that bookkeeping itself; this crate doesn't - /// second-guess it. - pub async fn publish( - &self, - topic: String, - realm: Vec, - seq: u64, - payload: FfiValue, - published_at_ms: u64, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let realm = to_32(realm)?; - let spec = macula_rust::frame::PublishSpec::new( - topic, - realm, - identity.0.node_id(), - seq, - payload.into(), - published_at_ms, - ); - let session = self.session().await?; - let session = &session; - session - .publish(&spec, &identity.0) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - }) - } - - /// Starts a subscription with its own queue of 256 events — see - /// [`macula_rust::connection::Session::subscribe`] for the topic rule. - /// Receive with [`FfiSubscription::recv_event`], and close it when done: - /// the session sends UNSUBSCRIBE once no other subscription on it holds - /// that realm and topic. - pub async fn subscribe( - &self, - topic: String, - realm: Vec, - identity: &FfiKeyPair, - ) -> Result, FfiError> { - let realm = to_32(realm)?; - let spec = macula_rust::frame::SubscribeSpec::new(topic, realm, identity.0.node_id()); - let session = self.session().await?; - let subscription = - session - .subscribe(&spec, &identity.0) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - })?; - Ok(std::sync::Arc::new(FfiSubscription( - tokio::sync::Mutex::new(Some(subscription)), - ))) - } - - /// Send a signed ADVERTISE (§6.9) — registers this session as the - /// handler for `procedure` under `realm`. Fire-and-forget; the - /// station then routes inbound STREAM_OPENs for it back to us as a - /// fresh dedicated stream — see - /// [`accept_stream`](Self::accept_stream). - pub async fn advertise( - &self, - procedure: String, - realm: Vec, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let realm = to_32(realm)?; - let spec = macula_rust::frame::AdvertiseSpec::new(realm, procedure, identity.0.node_id()); - let session = self.session().await?; - let session = &session; - session - .advertise(&spec, &identity.0) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - }) - } - - /// Send a signed UNADVERTISE. Fire-and-forget. - pub async fn unadvertise( - &self, - procedure: String, - realm: Vec, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let realm = to_32(realm)?; - let spec = macula_rust::frame::UnadvertiseSpec::new(realm, procedure, identity.0.node_id()); - let session = self.session().await?; - let session = &session; - session - .unadvertise(&spec, &identity.0) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - }) - } - - /// Direct-dial resolution: finds `procedure`'s currently-advertised - /// serving station and its dialable host/port via the mesh DHT, - /// through this session (used only to query the DHT — it does not - /// need to be connected to the station that will end up serving the - /// call). The provider must have advertised via - /// [`advertise_direct`](Self::advertise_direct) — a plain - /// [`advertise`](Self::advertise) publishes no discoverable record. - /// Most callers want [`call_direct`](Self::call_direct) instead, - /// which does this resolution internally; this is exposed separately - /// for a caller that wants the resolved target itself. - pub async fn resolve_direct( - &self, - procedure: String, - realm: Vec, - identity: &FfiKeyPair, - ) -> Result { - let realm = to_32(realm)?; - let session = self.session().await?; - let session = &session; - let resolved = - macula_rust::direct_dial::resolve(session, &identity.0, realm, &procedure).await?; - Ok(resolved.into()) - } - - /// Resolves `procedure`'s provider via direct-dial (through this - /// session, used only to query the DHT) and calls it there, in one - /// hop, on a session already open to the provider's station under - /// `identity` when there is one — see - /// [`macula_rust::direct_dial::call`]'s own doc for the full trust - /// model. Use this instead of [`call`](Self::call) when the provider - /// is reachable only via [`advertise_direct`](Self::advertise_direct) - /// (e.g. no ordinary advertise-gossip route has propagated between - /// the two stations involved). - pub async fn call_direct( - &self, - procedure: String, - realm: Vec, - payload: FfiValue, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result { - let realm = to_32(realm)?; - let session = self.session().await?; - let session = &session; - let response = macula_rust::direct_dial::call( - session, - &identity.0, - realm, - &procedure, - payload.into(), - std::time::Duration::from_millis(timeout_ms), - ) - .await?; - FfiCallResponse::try_from(response) - } - - /// Publishes a signed direct-dial advertisement for `procedure` naming - /// this session's own currently-connected station — sends the - /// ordinary ADVERTISE frame first (so an inbound CALL routed here the - /// normal way still works, matching - /// [`advertise`](Self::advertise)'s own effect), then publishes a - /// signed `procedure_advertisement` DHT record so a caller on a - /// different station can [`resolve_direct`](Self::resolve_direct)/ - /// [`call_direct`](Self::call_direct) here directly, skipping - /// inter-station gossip propagation. `ttl_ms` is the DHT record's - /// lifetime — this call does not repeat itself; a long-lived provider - /// must call it again on its own schedule before `ttl_ms` elapses - /// (deliberately not wrapped in a background loop here — see this - /// crate's own module doc for why: unlike the core crate's - /// `keep_advertised_direct`, a native background timer inside a - /// mobile app fights the OS's own app-lifecycle/background-execution - /// model; the foreign side should drive its own periodic re-advertise - /// using whatever scheduling mechanism its platform provides, calling - /// this method each time). - pub async fn advertise_direct( - &self, - procedure: String, - realm: Vec, - ttl_ms: u64, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let realm = to_32(realm)?; - let session = self.session().await?; - let session = &session; - macula_rust::direct_dial::advertise_direct( - session, - &identity.0, - realm, - &procedure, - std::time::Duration::from_millis(ttl_ms), - ) - .await - .map_err(FfiError::from) - } - - /// As [`call`](Self::call), attaching `ucan_token` (e.g. from - /// [`ucan_create`]) to the outgoing CALL — for invoking a procedure - /// gated by a [`FfiPolicy::Required`] policy on the provider side. - pub async fn call_with_ucan( - &self, - procedure: String, - realm: Vec, - payload: FfiValue, - timeout_ms: u64, - identity: &FfiKeyPair, - ucan_token: Vec, - ) -> Result { - let realm = to_32(realm)?; - let deadline_ms = (now_ms() + timeout_ms) as i128; - let session = self.session().await?; - let session = &session; - let response = session - .call_with_ucan( - &procedure, - realm, - payload.into(), - deadline_ms, - &identity.0, - std::time::Duration::from_millis(timeout_ms), - ucan_token, - ) - .await - .map_err(|e| FfiError::Call { - reason: e.to_string(), - })?; - FfiCallResponse::try_from(response) - } - - /// The supervised counterpart to the bare [`publish`](Self::publish) - /// primitive — see - /// [`macula_rust::connection::Session::run_publisher`]'s own doc. - /// `announce` controls whether `pubsub.publish_started_v1`/ - /// `pubsub.publish_completed_v1` facts are published around this - /// publish (a fact-publish failure never fails the underlying publish - /// either way). - #[allow(clippy::too_many_arguments)] - pub async fn run_publisher( - &self, - topic: String, - realm: Vec, - seq: u64, - payload: FfiValue, - published_at_ms: u64, - announce: bool, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let realm = to_32(realm)?; - let spec = macula_rust::frame::PublishSpec::new( - topic, - realm, - identity.0.node_id(), - seq, - payload.into(), - published_at_ms, - ); - let session = self.session().await?; - let session = &session; - session - .run_publisher(&spec, &identity.0, announce) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - }) - } - - /// [`resolve_direct`](Self::resolve_direct) plus Slice 7c Direction B - /// managed-realm authorization: only an advertisement whose embedded - /// cert chain validates to `realm_ca_pem` and names `expected_org` is - /// trusted. Opt-in — [`resolve_direct`](Self::resolve_direct) itself is - /// unaffected. - pub async fn resolve_direct_with_cert_chain( - &self, - procedure: String, - realm: Vec, - realm_ca_pem: Vec, - expected_org: String, - identity: &FfiKeyPair, - ) -> Result { - let realm = to_32(realm)?; - let session = self.session().await?; - let session = &session; - let resolved = macula_rust::direct_dial::resolve_with_cert_chain( - session, - &identity.0, - realm, - &procedure, - &realm_ca_pem, - &expected_org, - ) - .await?; - Ok(resolved.into()) - } - - /// [`call_direct`](Self::call_direct), resolved via - /// [`resolve_direct_with_cert_chain`](Self::resolve_direct_with_cert_chain) - /// instead of [`resolve_direct`](Self::resolve_direct) — see both for - /// the full contract. Opt-in managed-realm authorization; - /// [`call_direct`](Self::call_direct) itself is unaffected. - #[allow(clippy::too_many_arguments)] - pub async fn call_direct_with_cert_chain( - &self, - procedure: String, - realm: Vec, - realm_ca_pem: Vec, - expected_org: String, - payload: FfiValue, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result { - let realm = to_32(realm)?; - let session = self.session().await?; - let session = &session; - let response = macula_rust::direct_dial::call_with_cert_chain( - session, - &identity.0, - realm, - &procedure, - &realm_ca_pem, - &expected_org, - payload.into(), - std::time::Duration::from_millis(timeout_ms), - ) - .await?; - FfiCallResponse::try_from(response) - } - - /// [`advertise_direct`](Self::advertise_direct) plus an embedded X.509 - /// service-cert chain, for Slice 7c Direction B managed-realm - /// authorization — see - /// [`resolve_direct_with_cert_chain`](Self::resolve_direct_with_cert_chain)/ - /// [`call_direct_with_cert_chain`](Self::call_direct_with_cert_chain) - /// for the corresponding checks. Opt-in: - /// [`advertise_direct`](Self::advertise_direct) itself is unaffected. - pub async fn advertise_direct_with_cert_chain( - &self, - procedure: String, - realm: Vec, - ttl_ms: u64, - cert_chain_pem: Vec, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let realm = to_32(realm)?; - let session = self.session().await?; - let session = &session; - macula_rust::direct_dial::advertise_direct_with_cert_chain( - session, - &identity.0, - realm, - &procedure, - std::time::Duration::from_millis(ttl_ms), - cert_chain_pem, - ) - .await - .map_err(FfiError::from) - } - - /// Resolves `procedure`'s provider via direct-dial (through this - /// session, used only to query the DHT) and opens a stream to it - /// there, in one hop — mirrors - /// [`call_direct`](Self::call_direct)'s own resolve-then-dial shape for - /// [`stream_open`](Self::stream_open) instead of - /// [`call`](Self::call). The stream runs on a session this process - /// already has open to the provider's station under `identity` when - /// there is one, and otherwise on a new session direct dial opens for - /// it. Release [`FfiOpenedDirectStream::lease`] once the stream is done. - pub async fn open_stream_direct( - &self, - procedure: String, - realm: Vec, - mode: FfiStreamMode, - args: FfiValue, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result { - let realm = to_32(realm)?; - let deadline_ms = (now_ms() + timeout_ms) as i128; - let session = self.session().await?; - let session = &session; - let opened = macula_rust::direct_dial::open_stream_direct( - session, - &identity.0, - realm, - &procedure, - mode.into(), - args.into(), - deadline_ms, - std::time::Duration::from_millis(timeout_ms), - ) - .await - .map_err(|e| FfiError::Resolve { - reason: e.to_string(), - })?; - Ok(FfiOpenedDirectStream::from(opened)) - } - - /// [`open_stream_direct`](Self::open_stream_direct), resolved via - /// [`resolve_direct_with_cert_chain`](Self::resolve_direct_with_cert_chain) - /// instead of [`resolve_direct`](Self::resolve_direct) — see both for - /// the full contract. Opt-in managed-realm authorization; - /// [`open_stream_direct`](Self::open_stream_direct) itself is - /// unaffected. - #[allow(clippy::too_many_arguments)] - pub async fn open_stream_direct_with_cert_chain( - &self, - procedure: String, - realm: Vec, - realm_ca_pem: Vec, - expected_org: String, - mode: FfiStreamMode, - args: FfiValue, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result { - let realm = to_32(realm)?; - let deadline_ms = (now_ms() + timeout_ms) as i128; - let session = self.session().await?; - let session = &session; - let opened = macula_rust::direct_dial::open_stream_direct_with_cert_chain( - session, - &identity.0, - realm, - &procedure, - &realm_ca_pem, - &expected_org, - mode.into(), - args.into(), - deadline_ms, - std::time::Duration::from_millis(timeout_ms), - ) - .await - .map_err(|e| FfiError::Resolve { - reason: e.to_string(), - })?; - Ok(FfiOpenedDirectStream::from(opened)) - } - - /// Stores `data` at a KNOWN `station` (32 bytes) directly, in one hop, - /// instead of going through whatever station this session happens to - /// be connected to — see - /// [`macula_rust::direct_dial::put_direct`]'s own doc. When this - /// process already has a session open to `station` under `identity`, - /// such as this one, the upload runs on that session and leaves it - /// open instead of dialing. - pub async fn put_direct( - &self, - station: Vec, - data: Vec, - name: String, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result, FfiError> { - let station = to_32(station)?; - let session = self.session().await?; - let session = &session; - let mcid = macula_rust::direct_dial::put_direct( - session, - &identity.0, - station, - &data, - name, - std::time::Duration::from_millis(timeout_ms), - ) - .await - .map_err(|e| FfiError::Content { - reason: e.to_string(), - })?; - Ok(mcid.to_vec()) - } - - /// Fetches the content addressed by `mcid` (34 bytes) directly from - /// whichever station announced it, resolved via this session's DHT - /// query — see [`macula_rust::direct_dial::get_direct`]'s own doc. - /// Unlike [`put_direct`](Self::put_direct), no `station` is needed: a - /// `content_announcement` names its own announcer. - pub async fn get_direct( - &self, - mcid: Vec, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result, FfiError> { - let mcid = to_mcid(mcid)?; - let session = self.session().await?; - let session = &session; - macula_rust::direct_dial::get_direct( - session, - &identity.0, - mcid, - std::time::Duration::from_millis(timeout_ms), - ) - .await - .map_err(|e| FfiError::Content { - reason: e.to_string(), - }) - } - - /// Provider role: block for the next inbound STREAM_OPEN, bounded by - /// `timeout_ms`. Only ever succeeds after - /// [`advertise`](Self::advertise) has registered at least one - /// procedure — otherwise the station has nothing to route here. Other - /// methods on this `FfiSession` carry on while it waits. - /// - /// The app decides whether to serve a stream it accepts. One it refuses - /// should get a STREAM_ERROR with macula's codes, `unauthorized` when the - /// caller may not use the procedure and `not_found` for a procedure it - /// doesn't serve, sent with [`FfiStream::refuse`], so a caller sees the - /// same refusal from every stack. - pub async fn accept_stream(&self, timeout_ms: u64) -> Result { - let session = self.session().await?; - let session = &session; - let (handle, info) = macula_rust::stream::StreamHandle::accept( - session, - std::time::Duration::from_millis(timeout_ms), - ) - .await - .map_err(|e| FfiError::Recv { - reason: e.to_string(), - })?; - Ok(FfiAcceptedStream { - stream: std::sync::Arc::new(FfiStream(tokio::sync::Mutex::new(Some(handle)))), - info: FfiStreamOpenInfo::try_from(info)?, - }) - } - - /// Store `data` under a content-address, returning its MCID (34 - /// bytes). `name` is attached to the manifest when `data` is large - /// enough to be chunked; silently unused for a single block, which - /// is addressed purely by content hash — see - /// [`macula_rust::content::put`]'s own doc. - pub async fn content_put( - &self, - data: Vec, - name: String, - identity: &FfiKeyPair, - ) -> Result, FfiError> { - let session = self.session().await?; - let session = &session; - let mcid = macula_rust::content::put(session, &data, name, &identity.0) - .await - .map_err(|e| FfiError::Content { - reason: e.to_string(), - })?; - Ok(mcid.to_vec()) - } - - /// Fetch and verify the content addressed by `mcid` (34 bytes). - pub async fn content_get( - &self, - mcid: Vec, - identity: &FfiKeyPair, - ) -> Result, FfiError> { - let mcid = to_mcid(mcid)?; - let session = self.session().await?; - let session = &session; - macula_rust::content::get(session, mcid, &identity.0) - .await - .map_err(|e| FfiError::Content { - reason: e.to_string(), - }) - } - - /// Open a dedicated stream and send a signed STREAM_OPEN. `realm` - /// must be exactly 32 bytes. `timeout_ms` bounds the frame's own - /// `deadline_ms` field (`now + timeout_ms`); there's no open-time - /// acknowledgement to wait for on the wire — the provider starts - /// reacting to it directly. - pub async fn stream_open( - &self, - procedure: String, - realm: Vec, - mode: FfiStreamMode, - args: FfiValue, - timeout_ms: u64, - identity: &FfiKeyPair, - ) -> Result { - let realm = to_32(realm)?; - let deadline_ms = (now_ms() + timeout_ms) as i128; - let session = self.session().await?; - let session = &session; - let handle = macula_rust::stream::StreamHandle::open( - session, - &procedure, - realm, - mode.into(), - args.into(), - deadline_ms, - &identity.0, - ) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - })?; - Ok(FfiStream(tokio::sync::Mutex::new(Some(handle)))) - } - - /// Close the session with a GOODBYE frame. A no-op if already closed. - pub async fn close(&self, identity: &FfiKeyPair) { - let mut guard = self.0.lock().await; - if let Some(session) = guard.take() { - session.close("normal", None, &identity.0).await; - } - } -} - -/// One subscription on an [`FfiSession`] — wraps -/// [`macula_rust::connection::Subscription`] behind a mutex, the way -/// [`FfiStream`] wraps a stream. Created by [`FfiSession::subscribe`]. -#[derive(uniffi::Object)] -pub struct FfiSubscription(tokio::sync::Mutex>); - -#[uniffi::export(async_runtime = "tokio")] -impl FfiSubscription { - /// Waits up to `timeout_ms` for the next event on this subscription. - /// Fails with [`FfiError::Recv`] when none arrives in time, once the - /// subscription fell more than 256 events behind (after its queued - /// events), and once the session ended. - pub async fn recv_event(&self, timeout_ms: u64) -> Result { - let mut guard = self.0.lock().await; - let subscription = guard.as_mut().ok_or(FfiError::Closed)?; - let event = subscription - .recv_event(std::time::Duration::from_millis(timeout_ms)) - .await - .map_err(|e| FfiError::Recv { - reason: e.to_string(), - })?; - FfiEvent::try_from(event) - } - - /// Ends the subscription, sending UNSUBSCRIBE when no other subscription - /// on the session holds its realm and topic. A no-op if already closed. - pub async fn close(&self) { - let subscription = self.0.lock().await.take(); - if let Some(subscription) = subscription { - subscription.close().await; - } - } -} - -/// A streaming RPC exchange, caller/consumer role — wraps -/// [`macula_rust::stream::StreamHandle`] the same way [`FfiSession`] -/// wraps [`macula_rust::connection::Session`]: a mutex bridges -/// UniFFI's `&self` methods to the core type's `&mut self` ones. Created -/// via [`FfiSession::stream_open`]. -#[derive(uniffi::Object)] -pub struct FfiStream(tokio::sync::Mutex>); - -#[uniffi::export(async_runtime = "tokio")] -impl FfiStream { - /// Send one chunk. - pub async fn send_data( - &self, - encoding: FfiStreamEncoding, - body: FfiValue, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let mut guard = self.0.lock().await; - let handle = guard.as_mut().ok_or(FfiError::Closed)?; - handle - .send_data(encoding.into(), body.into(), &identity.0) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - }) - } - - /// Half-close: signal this side is done sending. For - /// `client_stream`/`bidi` modes, follow with - /// [`await_reply`](Self::await_reply). - pub async fn close_send(&self, identity: &FfiKeyPair) -> Result<(), FfiError> { - let mut guard = self.0.lock().await; - let handle = guard.as_mut().ok_or(FfiError::Closed)?; - handle - .close_send(&identity.0) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - }) - } - - /// Receive the next chunk or end-of-stream, bounded by `timeout_ms`. - pub async fn recv(&self, timeout_ms: u64) -> Result { - let mut guard = self.0.lock().await; - let handle = guard.as_mut().ok_or(FfiError::Closed)?; - let item = handle - .recv(std::time::Duration::from_millis(timeout_ms)) - .await - .map_err(|e| FfiError::Recv { - reason: e.to_string(), - })?; - Ok(match item { - macula_rust::stream::StreamItem::Data { - seq, - encoding, - body, - } => FfiStreamItem::Data { - seq, - encoding: encoding.into(), - body: FfiValue::try_from(body)?, - }, - macula_rust::stream::StreamItem::Eof => FfiStreamItem::Eof, - }) - } - - /// Block for the provider's terminal STREAM_REPLY (`client_stream`/ - /// `bidi` modes only) — call after [`close_send`](Self::close_send). - pub async fn await_reply(&self, timeout_ms: u64) -> Result { - let mut guard = self.0.lock().await; - let handle = guard.as_mut().ok_or(FfiError::Closed)?; - let (payload, responded_by) = handle - .await_reply(std::time::Duration::from_millis(timeout_ms)) - .await - .map_err(|e| FfiError::Recv { - reason: e.to_string(), - })?; - Ok(FfiStreamReply { - payload: FfiValue::try_from(payload)?, - responded_by: responded_by.to_vec(), - }) - } - - /// Provider role: send the terminal STREAM_REPLY a `client_stream`/ - /// `bidi` caller's own `await_reply` is waiting on, once this side - /// has fully consumed and verified whatever the caller streamed. - pub async fn send_reply( - &self, - payload: FfiValue, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let mut guard = self.0.lock().await; - let handle = guard.as_mut().ok_or(FfiError::Closed)?; - handle - .send_reply(payload.into(), &identity.0) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - }) - } - - /// Non-normal termination: send an explicit STREAM_ERROR abort, - /// rather than just dropping the stream — the peer's only signal to - /// tell a cancellation/failure apart from a dropped connection - /// (`plans/PLAN_WIRE_PROTOCOL.md` §13.1, point 4). A no-op if - /// already closed/aborted. - pub async fn abort(&self, code: String, message: String, identity: &FfiKeyPair) { - let mut guard = self.0.lock().await; - if let Some(handle) = guard.take() { - handle.abort(code, message, &identity.0).await; - } - } - - /// Refuses a stream accepted and not served: writes a STREAM_ERROR with - /// `code` and `message`, then finishes sending and stops reading — see - /// [`macula_rust::stream::StreamHandle::refuse`]. A no-op if already - /// closed, aborted or refused. - pub async fn refuse( - &self, - code: String, - message: String, - identity: &FfiKeyPair, - ) -> Result<(), FfiError> { - let handle = self.0.lock().await.take(); - match handle { - Some(handle) => handle - .refuse(code, message, &identity.0) - .await - .map_err(|e| FfiError::Send { - reason: e.to_string(), - }), - None => Ok(()), - } - } -} - -#[cfg(test)] -mod ffi_value_tests { - use super::{FfiError, FfiMapEntry, FfiValue}; - use macula_rust::cbor::Value; - - fn round_trip(v: FfiValue) -> Result { - let core: Value = v.into(); - FfiValue::try_from(core) - } - - #[test] - fn scalars_still_round_trip() { - for v in [ - FfiValue::Null, - FfiValue::Int(-7), - FfiValue::Bytes(vec![1, 2, 3]), - FfiValue::Text("station".to_string()), - FfiValue::Float(1.5), - ] { - assert_eq!(round_trip(v.clone()).unwrap(), v); - } - } - - #[test] - fn empty_list_and_map_round_trip() { - assert_eq!( - round_trip(FfiValue::Items(vec![])).unwrap(), - FfiValue::Items(vec![]) - ); - assert_eq!( - round_trip(FfiValue::Fields(vec![])).unwrap(), - FfiValue::Fields(vec![]) - ); - } - - #[test] - fn flat_list_round_trips() { - let v = FfiValue::Items(vec![ - FfiValue::Int(1), - FfiValue::Text("two".to_string()), - FfiValue::Null, - ]); - assert_eq!(round_trip(v.clone()).unwrap(), v); - } - - #[test] - fn flat_map_round_trips() { - let v = FfiValue::Fields(vec![ - FfiMapEntry { - key: FfiValue::Text("city".to_string()), - value: FfiValue::Text("Milan".to_string()), - }, - FfiMapEntry { - key: FfiValue::Text("lat".to_string()), - value: FfiValue::Float(45.4642), - }, - ]); - assert_eq!(round_trip(v.clone()).unwrap(), v); - } - - /// The actual shape `hecate_stations.list_stations` returns: - /// `#{stations => [#{city => ..., lat => ..., ...}, ...]}` — a map - /// containing a list of maps. This is the case that motivated the - /// fix; a shallow test alone wouldn't have caught a bug in either - /// recursive call. - #[test] - fn map_containing_list_of_maps_round_trips() { - let station = |city: &str| { - FfiValue::Fields(vec![ - FfiMapEntry { - key: FfiValue::Text("city".to_string()), - value: FfiValue::Text(city.to_string()), - }, - FfiMapEntry { - key: FfiValue::Text("capabilities".to_string()), - value: FfiValue::Int(0), - }, - ]) - }; - let v = FfiValue::Fields(vec![FfiMapEntry { - key: FfiValue::Text("stations".to_string()), - value: FfiValue::Items(vec![station("Milan"), station("Paris")]), - }]); - assert_eq!(round_trip(v.clone()).unwrap(), v); - } - - /// A non-text map key must survive too — `Value::Map`'s keys are - /// arbitrary values, not just text (see `FfiValue::Fields`'s own doc). - #[test] - fn integer_keyed_map_round_trips() { - let v = FfiValue::Fields(vec![ - FfiMapEntry { - key: FfiValue::Int(0), - value: FfiValue::Text("a".to_string()), - }, - FfiMapEntry { - key: FfiValue::Int(1), - value: FfiValue::Text("b".to_string()), - }, - ]); - assert_eq!(round_trip(v.clone()).unwrap(), v); - } - - /// `i128` values outside `i64` range must still fail cleanly even - /// nested inside a list -- the recursive `?`/`map_err` chain must - /// propagate the error rather than swallowing or panicking. - #[test] - fn out_of_range_int_inside_list_errors_not_panics() { - let too_big = Value::List(vec![Value::Int(i128::MAX)]); - let err = FfiValue::try_from(too_big).unwrap_err(); - assert!(matches!(err, FfiError::UnrepresentableValue { .. })); - } -} diff --git a/macula-rust-ffi/src/node_key.rs b/macula-rust-ffi/src/node_key.rs new file mode 100644 index 0000000..c210e56 --- /dev/null +++ b/macula-rust-ffi/src/node_key.rs @@ -0,0 +1,139 @@ +//! A node's identity key: made in either profile with the admission puzzle +//! solved, and kept in an owner-only key file or the platform's secure +//! store (Keychain on iOS, the Android Keystore; see +//! [`macula_rust::keystore`] for the one-time Android setup). + +use std::path::Path; +use std::sync::Arc; + +use macula_rust::keystore::{KeyStoreError, KeyringStore}; +use macula_rust::node_key::{KeyFileError, NodeKey, Purpose, PUZZLE_DIFFICULTY}; +use macula_rust::profile::Profile; + +use crate::FfiError; + +/// macula 12's two profiles: ML-DSA-87 alone, or ML-DSA-87 with RSA-PSS-4096 +/// as a LAMPS composite. +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum FfiProfile { + PqPure, + PqHybrid, +} + +impl From for Profile { + fn from(p: FfiProfile) -> Self { + match p { + FfiProfile::PqPure => Profile::PqPure, + FfiProfile::PqHybrid => Profile::PqHybrid, + } + } +} + +impl From for FfiProfile { + fn from(p: Profile) -> Self { + match p { + Profile::PqPure => FfiProfile::PqPure, + Profile::PqHybrid => FfiProfile::PqHybrid, + } + } +} + +/// A node's identity key. +#[derive(uniffi::Object)] +pub struct FfiNodeKey(pub(crate) Arc); + +impl From for FfiError { + fn from(e: KeyFileError) -> Self { + match e { + KeyFileError::KeyStore(KeyStoreError::NotFound) => FfiError::KeystoreNotFound, + KeyFileError::KeyStore(other) => FfiError::Keystore { + message: other.to_string(), + }, + other => FfiError::Key { + message: other.to_string(), + }, + } + } +} + +impl From for FfiError { + fn from(e: KeyStoreError) -> Self { + KeyFileError::KeyStore(e).into() + } +} + +#[uniffi::export] +impl FfiNodeKey { + /// A new identity key in `profile` whose node_id solves the admission + /// puzzle stations require. A pq_hybrid key takes a few seconds. + #[uniffi::constructor] + pub fn generate(profile: FfiProfile) -> Result, FfiError> { + let key = NodeKey::generate_identity(profile.into(), PUZZLE_DIFFICULTY).map_err(|e| { + FfiError::Key { + message: e.to_string(), + } + })?; + Ok(Arc::new(FfiNodeKey(Arc::new(key)))) + } + + /// The identity key in the key file at `path`, which must be owner-only + /// and hold a key of `profile`. + #[uniffi::constructor] + pub fn load(path: String, profile: FfiProfile) -> Result, FfiError> { + let key = NodeKey::load(Path::new(&path), Purpose::Identity, profile.into())?; + Ok(Arc::new(FfiNodeKey(Arc::new(key)))) + } + + /// The identity key in the key file at `path`, or, when nothing is + /// there, a new one saved there first. A file that does not load as a + /// key of `profile` is refused and left as it is. + #[uniffi::constructor] + pub fn load_or_create(path: String, profile: FfiProfile) -> Result, FfiError> { + let key = NodeKey::load_or_create(Path::new(&path), profile.into())?; + Ok(Arc::new(FfiNodeKey(Arc::new(key)))) + } + + /// The identity key the platform's secure store holds under `service` + /// and `account`, as [`save_to_keystore`](Self::save_to_keystore) put it. + #[uniffi::constructor] + pub fn load_from_keystore( + service: String, + account: String, + profile: FfiProfile, + ) -> Result, FfiError> { + let store = KeyringStore::new(&service, &account)?; + let key = NodeKey::load_from_keystore(&store, Purpose::Identity, profile.into())?; + Ok(Arc::new(FfiNodeKey(Arc::new(key)))) + } + + /// Writes the key to an owner-only key file at `path`. + pub fn save(&self, path: String) -> Result<(), FfiError> { + Ok(self.0.save(Path::new(&path))?) + } + + /// Keeps the key in the platform's secure store under `service` and + /// `account`, e.g. ("com.example.app", "macula-identity"): the store is + /// shared by the whole device, so scope them to the app. + pub fn save_to_keystore(&self, service: String, account: String) -> Result<(), FfiError> { + let store = KeyringStore::new(&service, &account)?; + Ok(self.0.save_to_keystore(&store)?) + } + + /// The node_id the key proves, 32 bytes. + pub fn node_id(&self) -> Vec { + self.0 + .node_id() + .expect("an identity key always has a node_id") + .to_vec() + } + + /// The key's profile. + pub fn profile(&self) -> FfiProfile { + self.0.profile().into() + } + + /// The public key as carried on the wire. + pub fn public_key(&self) -> Vec { + self.0.public_key() + } +} diff --git a/macula-rust-ffi/src/pool.rs b/macula-rust-ffi/src/pool.rs new file mode 100644 index 0000000..d84a497 --- /dev/null +++ b/macula-rust-ffi/src/pool.rs @@ -0,0 +1,237 @@ +//! A node's pool of station links: one link per pinned seed, redialed when it +//! drops; calls to a provider at its own station, trusted only under the +//! realm keys the pool pins; and DHT records. + +use std::collections::HashMap; +use std::sync::Arc; + +use macula_rust::pool::{Call, Opts, Pool, Seed}; +use macula_rust::record::{self, RecordType, Verified}; + +use crate::node_key::FfiNodeKey; +use crate::{millis, to_32, FfiError, FfiValue}; + +/// A station to link to: where it is dialed and the node_id it must prove. +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct FfiSeed { + pub host: String, + pub port: u16, + pub node_id: Vec, +} + +/// A realm's key as carried, the key its members pin. +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct FfiRealmKey { + pub realm: Vec, + pub key: Vec, +} + +/// A pool's options. Zero for a number is macula's default. +#[derive(uniffi::Record, Debug, Clone, PartialEq, Default)] +pub struct FfiPoolOptions { + /// The realms whose org procedures the pool trusts and serves. + #[uniffi(default = [])] + pub realm_trust: Vec, + #[uniffi(default = 0)] + pub connect_timeout_ms: u64, + #[uniffi(default = 0)] + pub respawn_delay_ms: u64, + #[uniffi(default = 0)] + pub replication_factor: u32, + #[uniffi(default = 0)] + pub max_seeds: u32, + #[uniffi(default = 0)] + pub max_direct_links: u32, + /// Try the links in a fresh random order each time, not seed order. + #[uniffi(default = false)] + pub random_link_order: bool, +} + +/// One of the pool's links. +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct FfiLinkStatus { + pub station: Vec, + pub host: String, + pub port: u16, + pub direct: bool, + pub up: bool, +} + +/// A node serving a procedure, and the station it serves from. +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct FfiProvider { + pub node: Vec, + pub station: Vec, +} + +/// A DHT record that verified: its type, its signer's key id, its times, +/// its payload, and its wire bytes. +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct FfiRecord { + pub record_type: u8, + pub signer: Vec, + pub created_at: u64, + pub expires_at: u64, + pub payload: FfiValue, + pub wire: Vec, +} + +impl TryFrom for FfiRecord { + type Error = FfiError; + + fn try_from(v: Verified) -> Result { + let r = v.into_record(); + let wire = record::encode(&r).map_err(|e| FfiError::Other { + message: e.to_string(), + })?; + Ok(FfiRecord { + record_type: r.record_type.0, + signer: r + .signed + .as_ref() + .map(|s| s.key_id.to_vec()) + .unwrap_or_default(), + created_at: r.created_at, + expires_at: r.expires_at, + payload: r.payload.try_into()?, + wire, + }) + } +} + +/// `name` in the own namespace of `node`, `~/name`: a procedure +/// its node serves with no realm key, authorized by its signature alone. +#[uniffi::export] +pub fn own_procedure(node: Vec, name: String) -> Result { + Ok(record::own_procedure(&to_32(node)?, &name)) +} + +/// A node's station links. +#[derive(uniffi::Object)] +pub struct FfiPool(pub(crate) Pool); + +#[uniffi::export(async_runtime = "tokio")] +impl FfiPool { + /// Links to every seed as `key`, and returns once one link is up. + #[uniffi::constructor] + pub async fn connect( + key: Arc, + seeds: Vec, + options: FfiPoolOptions, + ) -> Result, FfiError> { + let mut opts = Opts::new(key.0.clone()); + let mut trust = HashMap::new(); + for r in options.realm_trust { + trust.insert(to_32(r.realm)?, r.key); + } + opts.realm_trust = trust; + opts.connect_timeout = millis(options.connect_timeout_ms); + opts.respawn_delay = millis(options.respawn_delay_ms); + opts.replication_factor = options.replication_factor as usize; + opts.max_seeds = options.max_seeds as usize; + opts.max_direct_links = options.max_direct_links as usize; + if options.random_link_order { + opts.link_selection = macula_rust::pool::LinkSelection::Random; + } + let seeds = seeds + .into_iter() + .map(|s| { + Ok(Seed { + host: s.host, + port: s.port, + node_id: to_32(s.node_id)?, + }) + }) + .collect::, FfiError>>()?; + Ok(Arc::new(FfiPool(Pool::connect(seeds, opts).await?))) + } + + /// The node_id the pool links as. + pub fn node_id(&self) -> Vec { + self.0.node_id().to_vec() + } + + /// Every link the pool holds, seeds first. + pub fn status(&self) -> Vec { + self.0 + .status() + .into_iter() + .map(|l| FfiLinkStatus { + station: l.station.to_vec(), + host: l.host, + port: l.port, + direct: l.direct, + up: l.up, + }) + .collect() + } + + /// Ends every link with a GOODBYE, and every subscription. + pub async fn close(&self) { + self.0.close().await; + } + + /// Calls `procedure` in `realm` at a provider that serves it (`provider`, + /// or any trusted one when `None`), waiting up to `timeout_ms` (0 for + /// macula's 5 seconds). A provider's ERROR is [`FfiError::Provider`]. + pub async fn call( + &self, + realm: Vec, + procedure: String, + payload: FfiValue, + provider: Option>, + timeout_ms: u64, + ) -> Result { + let answered = self + .0 + .call(Call { + realm: to_32(realm)?, + procedure, + provider: provider.map(to_32).transpose()?.unwrap_or([0; 32]), + payload: payload.into(), + timeout: millis(timeout_ms), + ..Call::default() + }) + .await?; + answered.try_into() + } + + /// Every provider of `procedure` in `realm` the pinned realm key + /// authorizes, freshest first. + pub async fn providers( + &self, + realm: Vec, + procedure: String, + ) -> Result, FfiError> { + let found = self.0.providers(&to_32(realm)?, &procedure).await?; + Ok(found + .into_iter() + .map(|p| FfiProvider { + node: p.node.to_vec(), + station: p.station.to_vec(), + }) + .collect()) + } + + /// The record under `key`, verified. + pub async fn find_record(&self, key: Vec) -> Result { + self.0.find_record(&to_32(key)?).await?.try_into() + } + + /// The records under `key` that verify. + pub async fn find_records(&self, key: Vec) -> Result, FfiError> { + let (found, _) = self.0.find_records(&to_32(key)?).await?; + found.into_iter().map(FfiRecord::try_from).collect() + } + + /// The records of `record_type` the station holds that verify. + pub async fn find_records_by_type(&self, record_type: u8) -> Result, FfiError> { + let (found, _) = self.0.find_records_by_type(RecordType(record_type)).await?; + found.into_iter().map(FfiRecord::try_from).collect() + } + + /// Puts a signed record, as its wire bytes, in the DHT. + pub async fn put_record(&self, wire: Vec) -> Result<(), FfiError> { + Ok(self.0.put_record(&wire).await?) + } +} diff --git a/macula-rust-ffi/src/pubsub.rs b/macula-rust-ffi/src/pubsub.rs new file mode 100644 index 0000000..af0b82d --- /dev/null +++ b/macula-rust-ffi/src/pubsub.rs @@ -0,0 +1,117 @@ +//! PubSub through the pool: a publication signed once and sent on the +//! pool's links, and a subscription on every link, each event delivered +//! once. The foreign side reads a subscription with +//! [`FfiSubscription::next`] in a loop of its own, which fits a mobile app's +//! lifecycle better than a native background loop would. + +use std::sync::Arc; + +use macula_rust::pool::Subscription; +use macula_rust::station_link::{Event, Publication}; +use tokio::sync::Mutex; + +use crate::pool::FfiPool; +use crate::{millis, to_32, FfiError, FfiValue}; + +/// A publication a subscription heard, verified: who published it, where, +/// its seq and time, the payload, and how it arrived. +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct FfiEvent { + pub publisher: Vec, + pub realm: Vec, + pub topic: String, + pub seq: u64, + pub published_at: u64, + pub payload: FfiValue, + pub delivered_via: String, +} + +impl TryFrom for FfiEvent { + type Error = FfiError; + + fn try_from(e: Event) -> Result { + Ok(FfiEvent { + publisher: e.publisher.to_vec(), + realm: e.realm.to_vec(), + topic: e.topic, + seq: e.seq, + published_at: e.published_at, + payload: e.payload.try_into()?, + delivered_via: e.delivered_via, + }) + } +} + +/// The node's subscription to a realm and topic, until +/// [`unsubscribe`](Self::unsubscribe). +#[derive(uniffi::Object)] +pub struct FfiSubscription(Mutex>); + +#[uniffi::export(async_runtime = "tokio")] +impl FfiPool { + /// Signs a publication once and sends it on the pool's links. `ttl_ms` + /// of `None` is macula's 10 minutes. + pub async fn publish( + &self, + realm: Vec, + topic: String, + payload: FfiValue, + ttl_ms: Option, + ) -> Result<(), FfiError> { + Ok(self + .0 + .publish(Publication { + realm: to_32(realm)?, + topic, + payload: payload.into(), + ttl_ms, + }) + .await?) + } + + /// Subscribes the node to `topic` in `realm` on every link. + pub async fn subscribe( + &self, + realm: Vec, + topic: String, + ) -> Result, FfiError> { + let sub = self.0.subscribe(&to_32(realm)?, &topic).await?; + Ok(Arc::new(FfiSubscription(Mutex::new(Some(sub))))) + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl FfiSubscription { + /// The next event, or `None` when none arrives within `timeout_ms`. + /// [`FfiError::Closed`] once the subscription or its pool has ended. + pub async fn next(&self, timeout_ms: u64) -> Result, FfiError> { + let mut held = self.0.lock().await; + let sub = held.as_mut().ok_or(FfiError::Closed)?; + match tokio::time::timeout(millis(timeout_ms), sub.recv()).await { + Err(_) => Ok(None), + Ok(None) => { + *held = None; + Err(FfiError::Closed) + } + Ok(Some(event)) => Ok(Some(event.try_into()?)), + } + } + + /// How many events arrived while the subscription was full. + pub async fn dropped(&self) -> u64 { + self.0 + .lock() + .await + .as_ref() + .map(|s| s.dropped()) + .unwrap_or(0) + } + + /// Ends the subscription on every link. + pub async fn unsubscribe(&self) -> Result<(), FfiError> { + let Some(mut sub) = self.0.lock().await.take() else { + return Ok(()); + }; + Ok(sub.unsubscribe().await?) + } +} diff --git a/macula-rust-ffi/src/serve.rs b/macula-rust-ffi/src/serve.rs new file mode 100644 index 0000000..3b30832 --- /dev/null +++ b/macula-rust-ffi/src/serve.rs @@ -0,0 +1,121 @@ +//! Serving a procedure with a handler the foreign side implements, on every +//! link the pool holds and every link it dials later, until stopped. A +//! handler's [`FfiError`] reaches the caller as a handler_error with the +//! error's text; a handler that throws anything else, or panics, is answered +//! as macula answers a crashed handler. + +use std::sync::Arc; + +use macula_rust::pool::{Offer, Served}; +use macula_rust::station_link::{handler, stream_handler, Request}; + +use crate::pool::FfiPool; +use crate::stream::{FfiStream, FfiStreamHandler, FfiStreamMode}; +use crate::{to_32, FfiError, FfiValue}; + +/// A CALL a served procedure answers: the caller's node_id (the key its +/// signature verified under), what it asked for, and its deadline in unix +/// milliseconds. +#[derive(uniffi::Record, Debug, Clone, PartialEq)] +pub struct FfiRequest { + pub caller: Vec, + pub realm: Vec, + pub procedure: String, + pub payload: FfiValue, + pub deadline_ms: u64, +} + +impl TryFrom for FfiRequest { + type Error = FfiError; + + fn try_from(r: Request) -> Result { + Ok(FfiRequest { + caller: r.caller.to_vec(), + realm: r.realm.to_vec(), + procedure: r.procedure, + payload: r.payload.try_into()?, + deadline_ms: r.deadline_ms, + }) + } +} + +/// Answers the CALLs of a procedure the node serves, implemented in Kotlin +/// or Swift. +#[uniffi::export(with_foreign)] +#[async_trait::async_trait] +pub trait FfiCallHandler: Send + Sync { + async fn handle(&self, request: FfiRequest) -> Result; +} + +/// A procedure the node serves, until [`stop`](Self::stop). +#[derive(uniffi::Object)] +pub struct FfiServed(Served); + +#[uniffi::export(async_runtime = "tokio")] +impl FfiPool { + /// Serves `procedure` in `realm` with `handler` on every link. An org + /// procedure needs its realm's key pinned; one in the node's own + /// namespace (see [`own_procedure`](crate::own_procedure)) needs none. + pub async fn serve( + &self, + realm: Vec, + procedure: String, + handler_impl: Arc, + ) -> Result, FfiError> { + let answer = handler(move |r: Request| { + let handler_impl = handler_impl.clone(); + async move { + let request = FfiRequest::try_from(r).map_err(|e| e.to_string())?; + let answered = handler_impl + .handle(request) + .await + .map_err(|e| e.to_string())?; + Ok(answered.into()) + } + }); + let served = self + .0 + .serve(Offer::unary(to_32(realm)?, &procedure, answer)) + .await?; + Ok(Arc::new(FfiServed(served))) + } + + /// Serves `procedure` in `realm` as a streaming procedure of `mode`, + /// each session handed to `handler_impl`: a session it returns from + /// without ending is closed, and one it fails is aborted. + pub async fn serve_stream( + &self, + realm: Vec, + procedure: String, + mode: FfiStreamMode, + handler_impl: Arc, + ) -> Result, FfiError> { + let session = stream_handler(move |s| { + let handler_impl = handler_impl.clone(); + async move { + handler_impl + .handle(Arc::new(FfiStream::new(s))) + .await + .map_err(|e| e.to_string()) + } + }); + let served = self + .0 + .serve(Offer::stream( + to_32(realm)?, + &procedure, + mode.into(), + session, + )) + .await?; + Ok(Arc::new(FfiServed(served))) + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl FfiServed { + /// Withdraws the procedure on every link. + pub async fn stop(&self) -> Result<(), FfiError> { + Ok(self.0.stop().await?) + } +} diff --git a/macula-rust-ffi/src/stream.rs b/macula-rust-ffi/src/stream.rs new file mode 100644 index 0000000..4b6cd0a --- /dev/null +++ b/macula-rust-ffi/src/stream.rs @@ -0,0 +1,181 @@ +//! Streaming sessions, on either side: opened at a provider through the +//! pool, or handed to a [`FfiStreamHandler`] the node serves with. Each side +//! sends chunks and ends its sending; a client_stream or bidi provider ends +//! the session with a reply. + +use std::sync::Arc; + +use macula_rust::frame::{StreamEncoding, StreamMode, StreamRole}; +use macula_rust::pool::StreamCall; +use macula_rust::station_link::{Stream, StreamEvent}; + +use crate::pool::FfiPool; +use crate::{millis, to_32, FfiError, FfiValue}; + +/// Who pushes data on a stream: the provider, the caller, or both. +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum FfiStreamMode { + ServerStream, + ClientStream, + Bidi, +} + +impl From for StreamMode { + fn from(m: FfiStreamMode) -> Self { + match m { + FfiStreamMode::ServerStream => StreamMode::ServerStream, + FfiStreamMode::ClientStream => StreamMode::ClientStream, + FfiStreamMode::Bidi => StreamMode::Bidi, + } + } +} + +/// How a chunk's body reads: raw bytes, or a structured value. +#[derive(uniffi::Enum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum FfiStreamEncoding { + Raw, + Structured, +} + +/// One frame the peer sent, verified: a chunk, the peer's end (`both` when +/// it ends the whole session, not just the peer's sending), or the +/// provider's terminal reply. +#[derive(uniffi::Enum, Debug, Clone, PartialEq)] +pub enum FfiStreamEvent { + Data { + encoding: FfiStreamEncoding, + body: FfiValue, + }, + End { + both: bool, + }, + Reply { + payload: FfiValue, + }, +} + +impl TryFrom for FfiStreamEvent { + type Error = FfiError; + + fn try_from(e: StreamEvent) -> Result { + Ok(match e { + StreamEvent::Data { encoding, body } => FfiStreamEvent::Data { + encoding: match encoding { + StreamEncoding::Raw => FfiStreamEncoding::Raw, + StreamEncoding::Msgpack => FfiStreamEncoding::Structured, + }, + body: body.try_into()?, + }, + StreamEvent::End { role } => FfiStreamEvent::End { + both: role == StreamRole::Both, + }, + StreamEvent::Reply { payload } => FfiStreamEvent::Reply { + payload: payload.try_into()?, + }, + }) + } +} + +/// Serves one streaming session, implemented in Kotlin or Swift. A session +/// it returns from without ending is closed on both sides; one it fails is +/// aborted with code `error` and the error's text. +#[uniffi::export(with_foreign)] +#[async_trait::async_trait] +pub trait FfiStreamHandler: Send + Sync { + async fn handle(&self, stream: Arc) -> Result<(), FfiError>; +} + +/// One streaming session, on either side. +#[derive(uniffi::Object)] +pub struct FfiStream(Stream); + +impl FfiStream { + pub(crate) fn new(s: Stream) -> FfiStream { + FfiStream(s) + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl FfiPool { + /// Opens a streaming session of `mode` at a provider of `procedure` in + /// `realm` (`provider`, or any trusted one when `None`), its deadline + /// `deadline_ms` ahead (0 for macula's 30 seconds). A refusal arrives on + /// the first [`FfiStream::recv`]. + pub async fn open_stream( + &self, + realm: Vec, + procedure: String, + mode: FfiStreamMode, + payload: FfiValue, + provider: Option>, + deadline_ms: u64, + ) -> Result, FfiError> { + let stream = self + .0 + .open_stream(StreamCall { + realm: to_32(realm)?, + procedure, + provider: provider.map(to_32).transpose()?.unwrap_or([0; 32]), + mode: mode.into(), + payload: payload.into(), + deadline: millis(deadline_ms), + ..StreamCall::default() + }) + .await?; + Ok(Arc::new(FfiStream(stream))) + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl FfiStream { + /// The session's caller, its node_id. + pub fn caller(&self) -> Vec { + self.0.request().caller.to_vec() + } + + /// The payload the session was opened with. + pub fn open_payload(&self) -> Result { + self.0.request().payload.clone().try_into() + } + + /// Sends a raw chunk. + pub async fn send(&self, body: Vec) -> Result<(), FfiError> { + Ok(self.0.send(&body).await?) + } + + /// Sends a structured chunk. + pub async fn send_value(&self, value: FfiValue) -> Result<(), FfiError> { + Ok(self.0.send_value(value.into()).await?) + } + + /// Ends this side's sending; the peer may still send. + pub async fn close_send(&self) -> Result<(), FfiError> { + Ok(self.0.close_send().await?) + } + + /// Ends the session on both sides. + pub async fn close(&self) -> Result<(), FfiError> { + Ok(self.0.close().await?) + } + + /// Sends the provider's terminal value and ends the session. + pub async fn reply(&self, payload: FfiValue) -> Result<(), FfiError> { + Ok(self.0.reply(payload.into()).await?) + } + + /// Ends the session with an error of `code` and `message`. + pub async fn abort(&self, code: String, message: String) -> Result<(), FfiError> { + Ok(self.0.abort(&code, &message).await?) + } + + /// The next frame the peer sent, waiting up to `timeout_ms` + /// ([`FfiError::Timeout`] past it). Once the session has ended and every + /// frame before is read: [`FfiError::EndOfStream`] for a normal end, + /// [`FfiError::Stream`] for an error. + pub async fn recv(&self, timeout_ms: u64) -> Result { + match tokio::time::timeout(millis(timeout_ms), self.0.recv()).await { + Err(_) => Err(FfiError::Timeout), + Ok(event) => event?.try_into(), + } + } +} diff --git a/macula-rust-ffi/tests/live_cert_chain_direct_dial.rs b/macula-rust-ffi/tests/live_cert_chain_direct_dial.rs deleted file mode 100644 index d6404a4..0000000 --- a/macula-rust-ffi/tests/live_cert_chain_direct_dial.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! Proves `resolve_direct_with_cert_chain`/`call_direct_with_cert_chain`/ -//! `advertise_direct_with_cert_chain` work end-to-end THROUGH the FFI -//! surface, mirroring `../../tests/live_cert_chain.rs`'s own self-issued -//! trust anchor (cert-chain authorization is a client-side check on an -//! opaque DHT payload the station itself never inspects, so no fleet -//! provisioning is needed). -//! -//! Not run by default CI — `#[ignore]`d, matching this crate's other live -//! tests. Run explicitly with: -//! `cargo test -p macula-rust-ffi --test live_cert_chain_direct_dial -- --ignored --nocapture` - -use macula_rust_ffi::{ - FfiCallHandler, FfiCallResponse, FfiError, FfiKeyPair, FfiSession, FfiTrust, FfiValue, -}; -use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair as RcgenKeyPair}; -use std::sync::Arc; -use std::time::Duration; - -const STATION_HOST: &str = "station-de-frankfurt.macula.io"; -const STATION_PORT: u16 = 4433; - -fn short_hex(bytes: &[u8]) -> String { - bytes.iter().take(8).map(|b| format!("{b:02x}")).collect() -} - -/// Records the procedure of every CALL it answers -- see -/// `live_ffi.rs`'s identical `RecordingEchoHandler` for the full -/// reasoning (a one-shot `serve_one_call` answering SOMETHING is not -/// proof it answered THIS test's own call, on a shared public fleet). -struct RecordingEchoHandler { - served: Arc>>, -} - -#[async_trait::async_trait] -impl FfiCallHandler for RecordingEchoHandler { - async fn handle( - &self, - procedure: String, - _realm: Vec, - payload: FfiValue, - ) -> Result { - self.served.lock().await.push(procedure); - Ok(payload) - } -} - -/// `serve_one_call` accepts the next inbound CALL unconditionally -- it -/// doesn't filter by procedure. On this shared public fleet a stray -/// unrelated CALL can arrive first; loop past it via a recording handler -/// rather than assuming the first one-shot serve answers this test's own -/// call. A first draft here returned `Ok` on ANY successful serve -/// regardless of which procedure it answered -- real bug, reproduced -/// live (a stray call satisfied the loop while this test's own call went -/// unanswered until it timed out) -- fixed to match `live_ffi.rs`'s -/// already-correct `serve_until_procedure`. -async fn serve_until_procedure( - session: &FfiSession, - procedure: &str, - per_attempt_timeout_ms: u64, - max_attempts: u32, - identity: &FfiKeyPair, -) -> Result<(), FfiError> { - let served = Arc::new(tokio::sync::Mutex::new(Vec::new())); - for _ in 0..max_attempts { - let handler = Arc::new(RecordingEchoHandler { - served: Arc::clone(&served), - }); - match session - .serve_one_call(handler, per_attempt_timeout_ms, identity) - .await - { - Ok(()) => { - if served.lock().await.iter().any(|p| p == procedure) { - return Ok(()); - } - } - Err(FfiError::Recv { .. }) => {} - Err(other) => return Err(other), - } - } - Ok(()) -} - -fn self_issued_realm_ca() -> (Vec, rcgen::Issuer<'static, RcgenKeyPair>) { - let key_pair = RcgenKeyPair::generate_for(&rcgen::PKCS_ED25519).expect("ca keygen"); - let mut params = CertificateParams::new(Vec::::new()).expect("ca params"); - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "Live FFI Test Realm CA"); - dn.push(DnType::OrganizationName, "Live FFI Test Realm CA"); - params.distinguished_name = dn; - params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); - params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1); - params.not_after = time::OffsetDateTime::now_utc() + time::Duration::hours(1); - let cert = params.self_signed(&key_pair).expect("ca self-sign"); - let pem = cert.pem().into_bytes(); - (pem, rcgen::Issuer::new(params, key_pair)) -} - -/// RFC 8410 SubjectPublicKeyInfo DER for a raw 32-byte Ed25519 pubkey — -/// duplicated from the core crate's own `tests/live_cert_chain.rs`, which -/// duplicates it from `src/cert_chain.rs`'s own `#[cfg(test)]` helper for -/// the identical reason (not reachable across crate/module boundaries). -fn ed25519_spki_der(pubkey: [u8; 32]) -> Vec { - let mut der = vec![ - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ]; - der.extend_from_slice(&pubkey); - der -} - -fn issue_leaf( - ca_issuer: &rcgen::Issuer<'static, RcgenKeyPair>, - advertiser_pub: [u8; 32], - org: &str, -) -> Vec { - let subject_spki = - rcgen::SubjectPublicKeyInfo::from_der(&ed25519_spki_der(advertiser_pub)).expect("spki"); - let mut params = CertificateParams::new(Vec::::new()).expect("leaf params"); - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "live-ffi-cert-chain-test-service"); - dn.push(DnType::OrganizationName, org); - params.distinguished_name = dn; - params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1); - params.not_after = time::OffsetDateTime::now_utc() + time::Duration::hours(1); - let cert = params - .signed_by(&subject_spki, ca_issuer) - .expect("leaf signed_by"); - cert.der().to_vec() -} - -fn pem_bundle(ders: &[Vec]) -> Vec { - use base64::Engine; - let mut out = Vec::new(); - for der in ders { - let b64 = base64::engine::general_purpose::STANDARD.encode(der); - out.extend_from_slice(b"-----BEGIN CERTIFICATE-----\n"); - for chunk in b64.as_bytes().chunks(64) { - out.extend_from_slice(chunk); - out.push(b'\n'); - } - out.extend_from_slice(b"-----END CERTIFICATE-----\n"); - } - out -} - -/// Publishes a `cert_chain`-bearing advertisement via -/// `advertise_direct_with_cert_chain`, serves through it, and calls it via -/// `call_direct_with_cert_chain` from a separate session/identity — a real -/// RESULT, not just a reached-the-call-stage outcome (see this session's -/// own history for why that weaker bar already hid a real bug once). -/// Includes the negative control: the SAME chain correctly fails -/// authorization when a caller expects the wrong org. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn cert_chain_direct_dial_round_trip_through_the_ffi_surface() { - let (ca_pem, ca_issuer) = self_issued_realm_ca(); - - let provider_id = FfiKeyPair::generate(); - // Distinct from caller_id -- call_direct_with_cert_chain dials the - // resolved station in a FRESH connection using this identity while - // caller_id's own resolver session stays open throughout; reusing one - // identity for both was a real bug (this fleet kicks whichever - // connection reuses an identity second), already found and fixed in - // live_ffi.rs's streaming test after hitting the identical symptom - // ("timed out waiting for a frame" regardless of how long the - // timeout was) there first. - let dial_id = FfiKeyPair::generate(); - let caller_id = FfiKeyPair::generate(); - let procedure = format!( - "macula_rust_ffi.live_test.cert_chain.{}", - short_hex(&provider_id.node_id()) - ); - let realm = vec![0u8; 32]; - let org = "live-ffi-test-org"; - - let leaf_der = issue_leaf(&ca_issuer, provider_id.node_id().try_into().unwrap(), org); - let cert_chain_pem = pem_bundle(&[leaf_der]); - - let provider = FfiSession::connect( - STATION_HOST.to_string(), - STATION_PORT, - FfiTrust::WebPki, - &provider_id, - ) - .await - .expect("provider connect"); - provider - .advertise_direct_with_cert_chain( - procedure.clone(), - realm.clone(), - 60_000, - cert_chain_pem, - &provider_id, - ) - .await - .expect("advertise_direct_with_cert_chain"); - - let serve_procedure = procedure.clone(); - let serve_task = tokio::spawn(async move { - let result = - serve_until_procedure(&provider, &serve_procedure, 10_000, 10, &provider_id).await; - // Keep the session alive briefly after the last reply -- Session - // has no Drop impl, so dropping it immediately on return can - // close the underlying QUIC connection before the just-sent - // reply frame actually reaches the peer. Same race already - // documented on Session::close, same fix already confirmed live - // for the identical symptom in serve_one_call_gated (see 986b981). - tokio::time::sleep(Duration::from_millis(300)).await; - result - }); - - let caller = FfiSession::connect( - STATION_HOST.to_string(), - STATION_PORT, - FfiTrust::WebPki, - &caller_id, - ) - .await - .expect("caller connect"); - - // Positive: right org, chain validates, real RESULT comes back. - let response = match caller - .call_direct_with_cert_chain( - procedure.clone(), - realm.clone(), - ca_pem.clone(), - org.to_string(), - FfiValue::Text("authorized via cert chain".to_string()), - 30_000, - &dial_id, - ) - .await - { - Ok(r) => r, - // KNOWN EXTERNAL BLOCKER, not a defect here -- same - // already-documented gap as live_ffi.rs's streaming/content test - // and the core crate's own tests/live_direct_dial_extensions.rs: - // the demo fleet's station_endpoint records expire (5min TTL) - // faster than they're republished. - Err(FfiError::Resolve { reason }) if reason.contains("no reachable station_endpoint") => { - eprintln!( - "SKIP: resolved station published no reachable station_endpoint -- known \ - external fleet staleness, not a defect here: {reason}" - ); - serve_task.abort(); - return; - } - // RESOLVED 2026-08-30: the "provider answers correctly, caller - // never gets the reply" symptom that took 3 rounds of theories to - // narrow (see git history on this file for the ruled-out ones -- - // not cert-chain-specific, not station-specific, not a - // timeout-cancellation artifact) turned out to be the exact same - // premature-Session-drop race confirmed and fixed for - // serve_one_call_gated in 986b981: `serve_task`'s async block - // dropped `provider` the instant `serve_until_procedure` returned, - // and Session has no Drop impl, so the just-sent reply frame could - // be torn down before it reached the peer. Fixed above by keeping - // the session alive 300ms after the last reply. Verified with 5 - // consecutive clean passes (was failing reliably before). - Err(e) => panic!("call_direct_with_cert_chain: {e}"), - }; - match response { - FfiCallResponse::Result { payload, .. } => { - assert_eq!( - payload, - FfiValue::Text("authorized via cert chain".to_string()) - ); - } - FfiCallResponse::Error { - code, name, detail, .. - } => { - panic!("expected a real RESULT, got ERROR code={code} name={name} detail={detail:?}"); - } - } - serve_task - .await - .expect("serve task should not panic") - .expect("serve_one_call should have answered the call cleanly"); - - // Negative control: same chain, wrong expected org -> resolve itself - // must fail (nothing to dial), not silently succeed. - let resolver = FfiSession::connect( - STATION_HOST.to_string(), - STATION_PORT, - FfiTrust::WebPki, - &caller_id, - ) - .await - .expect("resolver connect for negative control"); - let err = resolver - .resolve_direct_with_cert_chain( - procedure, - realm, - ca_pem, - "some-other-org".to_string(), - &caller_id, - ) - .await - .expect_err("resolve_direct_with_cert_chain should reject the wrong org"); - match err { - FfiError::Resolve { reason } => { - assert!( - reason.contains("Authorized") || reason.contains("authoriz"), - "expected an authorization-shaped rejection reason, got: {reason}" - ); - } - other => panic!("expected FfiError::Resolve, got {other:?}"), - } -} diff --git a/macula-rust-ffi/tests/live_ffi.rs b/macula-rust-ffi/tests/live_ffi.rs deleted file mode 100644 index 82ba57b..0000000 --- a/macula-rust-ffi/tests/live_ffi.rs +++ /dev/null @@ -1,758 +0,0 @@ -//! Integration tests exercising the actual UniFFI-exported surface -//! (`FfiSession`/`FfiKeyPair`/`FfiValue`/`FfiCallHandler`), not the core -//! crate directly — this is what a generated Kotlin/Swift binding would -//! actually call through. `macula-rust-ffi` had no test harness at -//! all beyond `FfiValue`'s own pure conversion tests before this file; -//! testing only the core crate (already covered by `../tests/live_station.rs`) -//! would never catch a bug introduced in THIS crate's own wrapping — -//! wrong argument order, a broken error conversion, a type that doesn't -//! actually cross the boundary the way it's assumed to. -//! -//! **Not run by default CI** — every test here is `#[ignore]`d, matching -//! `../tests/live_station.rs`'s own convention: -//! -//! ```text -//! cargo test -p macula-rust-ffi --test live_ffi -- --ignored --nocapture -//! ``` -//! -//! No mobile toolchain (Kotlin/Swift compiler + runtime) is available in -//! this environment, so this cannot exercise the generated bindings -//! themselves end-to-end — only the Rust-side glue every generated binding -//! calls into. `cargo run -p macula-rust-ffi --bin uniffi-bindgen -- -//! generate --library --language kotlin --out-dir ` -//! succeeding without error (checked separately, not in this file) is the -//! remaining piece of confidence that the newly-added types/methods are -//! actually representable in the generated bindings at all. - -use macula_rust_ffi::{ - ucan_create, FfiCallHandler, FfiCallResponse, FfiCapability, FfiError, FfiKeyPair, FfiPolicy, - FfiSession, FfiStreamMode, FfiTrust, FfiValue, -}; -use std::sync::Arc; - -const MILAN_HOST: &str = "station-it-milan.macula.io"; -const MILAN_PORT: u16 = 4433; - -/// A short, unique-enough procedure-name suffix from an identity's node -/// id, without pulling in a `hex` crate dependency just for test naming. -fn short_hex(bytes: &[u8]) -> String { - bytes.iter().take(8).map(|b| format!("{b:02x}")).collect() -} - -struct EchoHandler; - -#[async_trait::async_trait] -impl FfiCallHandler for EchoHandler { - async fn handle( - &self, - _procedure: String, - _realm: Vec, - payload: FfiValue, - ) -> Result { - Ok(payload) - } -} - -/// Records the procedure of every CALL it answers, so a test can confirm -/// `serve_one_call`/`serve_one_call_gated` actually answered ITS intended -/// call and not some other inbound CALL that happened to route to this -/// connection first — this fleet is a real, shared, multi-tenant public -/// station, and `serve_one_call`'s own doc is explicit that ANY inbound -/// CALL frame that arrives is served (the FFI `FfiCallHandler` trait -/// receives every procedure unconditionally, doing its own routing inside -/// `handle` — see this crate's module doc) — a one-shot serve is not -/// guaranteed to be answering the call a test is waiting on. -struct RecordingEchoHandler { - served: Arc>>, -} - -#[async_trait::async_trait] -impl FfiCallHandler for RecordingEchoHandler { - async fn handle( - &self, - procedure: String, - _realm: Vec, - payload: FfiValue, - ) -> Result { - self.served.lock().await.push(procedure); - Ok(payload) - } -} - -/// Loops `serve_one_call_gated` until it answers a CALL for `procedure` -/// specifically (see [`RecordingEchoHandler`]'s own doc for why a single -/// call to `serve_one_call_gated` isn't sufficient on this shared fleet), -/// or `max_attempts` attempts are exhausted. A per-attempt timeout -/// (`FfiError::Recv`, covering both `ServeCallError::Timeout` and a -/// generic recv failure) is NOT fatal here — it just means nothing arrived -/// that round, so the loop tries again; only a non-`Recv` error (e.g. a -/// reply actually failed to SEND) aborts early, since that indicates a -/// real problem rather than "nothing showed up yet." -async fn serve_until_procedure( - session: &FfiSession, - procedure: &str, - policy: FfiPolicy, - per_attempt_timeout_ms: u64, - max_attempts: u32, - identity: &FfiKeyPair, -) -> Result<(), FfiError> { - let served = Arc::new(tokio::sync::Mutex::new(Vec::new())); - for attempt in 0..max_attempts { - let handler = Arc::new(RecordingEchoHandler { - served: Arc::clone(&served), - }); - match session - .serve_one_call_gated(handler, policy.clone(), per_attempt_timeout_ms, identity) - .await - { - Ok(()) => { - if served.lock().await.iter().any(|p| p == procedure) { - return Ok(()); - } - } - Err(FfiError::Recv { reason }) => { - eprintln!( - "serve_until_procedure: attempt {attempt} got no CALL ({reason}), retrying" - ); - } - Err(other) => return Err(other), - } - } - Ok(()) -} - -/// Proves the newly-exposed `resolve_direct`/`call_direct`/`advertise_direct` -/// work end-to-end THROUGH the FFI types: a provider session advertises -/// direct-dial reachability and serves one call via the exported -/// `FfiCallHandler` trait; a separate session/identity resolves and calls -/// it, and gets back a real RESULT it can inspect via `FfiValue`/ -/// `FfiCallResponse` — not just "reached the call stage" (see this -/// session's own `macula-go`/`macula-rust` history for why that -/// weaker bar isn't good enough: it already hid a real -/// missing-plain-ADVERTISE bug in `advertise_direct` once). -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn direct_dial_round_trip_through_the_ffi_surface() { - let provider_id = FfiKeyPair::generate(); - let caller_id = FfiKeyPair::generate(); - let procedure = format!( - "macula_rust_ffi.live_test.echo.{}", - short_hex(&provider_id.node_id()) - ); - let realm = vec![0u8; 32]; - - let provider = Arc::new( - FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &provider_id, - ) - .await - .expect("provider connect"), - ); - - provider - .advertise_direct(procedure.clone(), realm.clone(), 60_000, &provider_id) - .await - .expect("advertise_direct"); - - let serve_provider = Arc::clone(&provider); - let serve_task = tokio::spawn(async move { - serve_provider - .serve_one_call(Arc::new(EchoHandler), 20_000, &provider_id) - .await - }); - - let caller = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &caller_id, - ) - .await - .expect("caller connect"); - - let response = caller - .call_direct( - procedure, - realm, - FfiValue::Text("hello via ffi direct-dial".to_string()), - 15_000, - &caller_id, - ) - .await; - // KNOWN EXTERNAL BLOCKER, not a defect here -- the demo fleet's - // station_endpoint records expire (5min TTL) faster than they're - // republished; confirmed repeatedly this session across go-sdk and - // rust-sdk, core crate and FFI alike. - let response = match response { - Ok(r) => r, - Err(FfiError::Resolve { reason }) if reason.contains("no reachable station_endpoint") => { - eprintln!( - "SKIP: resolved station published no reachable station_endpoint -- known \ - external fleet staleness, not a defect here: {reason}" - ); - serve_task.abort(); - return; - } - Err(e) => panic!("call_direct should succeed through a live provider: {e}"), - }; - - match response { - FfiCallResponse::Result { payload, .. } => { - assert_eq!( - payload, - FfiValue::Text("hello via ffi direct-dial".to_string()), - "echoed payload should round-trip through FfiValue unchanged" - ); - } - FfiCallResponse::Error { - code, name, detail, .. - } => { - panic!("expected a real RESULT, got a bolt4 ERROR frame instead: code={code} name={name} detail={detail:?}"); - } - } - - serve_task - .await - .expect("serve task should not panic") - .expect("serve_one_call should have answered the call cleanly"); -} - -/// `resolve_direct` alone, without a live call — proves the DHT -/// publish/resolve round trip through the FFI's `FfiResolved` type -/// specifically (the round trip through `FfiCallResponse`/`FfiValue` is -/// already covered above). -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn resolve_direct_through_the_ffi_surface() { - let id = FfiKeyPair::generate(); - let procedure = format!( - "macula_rust_ffi.live_test.resolve_only.{}", - short_hex(&id.node_id()) - ); - let realm = vec![0u8; 32]; - - let session = FfiSession::connect(MILAN_HOST.to_string(), MILAN_PORT, FfiTrust::WebPki, &id) - .await - .expect("connect"); - - session - .advertise_direct(procedure.clone(), realm.clone(), 60_000, &id) - .await - .expect("advertise_direct"); - - let resolved = match session.resolve_direct(procedure, realm, &id).await { - Ok(r) => r, - // KNOWN EXTERNAL BLOCKER, not a defect here -- see the identical - // handling and comment in direct_dial_round_trip_through_the_ffi_surface - // above. - Err(FfiError::Resolve { reason }) if reason.contains("no reachable station_endpoint") => { - eprintln!( - "SKIP: resolved station published no reachable station_endpoint -- known \ - external fleet staleness, not a defect here: {reason}" - ); - return; - } - Err(e) => panic!("resolve_direct should find what was just advertised: {e}"), - }; - - // `resolved.station` is the STATION's own node id (Milan's, here) -- - // the DHT record's `serving_station` field, per `advertise_direct`'s - // own design -- NOT this identity's node id. `FfiSession` exposes no - // accessor for "which station am I connected to" to compare against - // directly, so a 32-byte sanity check is the honest bound here; the - // full round trip above already proves resolution correctly finds a - // station that actually routes the call, which is the real property - // under test. - assert_eq!(resolved.station.len(), 32, "station id should be 32 bytes"); - assert!( - !resolved.host.is_empty(), - "resolved host should be non-empty" - ); - assert_eq!(resolved.port, 4433); -} - -/// Proves `serve_one_call_gated`/`FfiPolicy`/`call_with_ucan` work -/// end-to-end THROUGH the FFI surface: a caller with no token is refused -/// (`Unauthorized`) before the handler ever runs; the same caller with a -/// valid token, minted via the exported `ucan_create`, reaches it and gets -/// a real RESULT. No live UCAN-gated procedure exists anywhere in this -/// workspace to test against (confirmed this session, both languages) — -/// this test proves the MECHANISM works for real, with its own throwaway -/// procedure and issuer, the same way `live_cert_chain.rs`'s self-issued -/// trust anchor proves cert-chain verification without needing fleet -/// provisioning. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn ucan_gated_serve_one_call_through_the_ffi_surface() { - let issuer_id = FfiKeyPair::generate(); - - // 1. Unauthorized: no token at all. Independent identities/procedure - // from case 2 below -- nothing needs to be shared between the two - // cases, and keeping them separate avoids needing to clone an - // FfiKeyPair (an opaque uniffi::Object, not exposed as Clone). - { - let provider_id = FfiKeyPair::generate(); - let caller_id = FfiKeyPair::generate(); - let procedure = format!( - "macula_rust_ffi.live_test.ucan_gated.unauthorized.{}", - short_hex(&provider_id.node_id()) - ); - let realm = vec![0u8; 32]; - let policy = FfiPolicy::Required { - issuer: issuer_id.node_id(), - }; - - let provider = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &provider_id, - ) - .await - .expect("provider connect (unauthorized case)"); - provider - .advertise(procedure.clone(), realm.clone(), &provider_id) - .await - .expect("advertise (unauthorized case)"); - - let serve_procedure = procedure.clone(); - let serve = tokio::spawn(async move { - serve_until_procedure(&provider, &serve_procedure, policy, 10_000, 5, &provider_id) - .await - }); - - let caller = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &caller_id, - ) - .await - .expect("caller connect (unauthorized case)"); - let call_result = caller - .call(procedure, realm, FfiValue::Null, 25_000, &caller_id) - .await; - serve - .await - .expect("serve task should not panic") - .expect("serve_until_procedure should not itself error"); - match call_result.expect("call should complete at the wire level even when refused") { - FfiCallResponse::Error { code, .. } => { - assert_eq!(code, 0x10, "expected BOLT#4 unauthorized (0x10)"); - } - other => panic!("expected an Unauthorized ERROR frame, got {other:?}"), - } - } - - // 2. Authorized: a valid token from the required issuer reaches the handler. - let provider_id = FfiKeyPair::generate(); - let caller_id = FfiKeyPair::generate(); - let procedure = format!( - "macula_rust_ffi.live_test.ucan_gated.authorized.{}", - short_hex(&provider_id.node_id()) - ); - let realm = vec![0u8; 32]; - let policy = FfiPolicy::Required { - issuer: issuer_id.node_id(), - }; - - let provider = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &provider_id, - ) - .await - .expect("provider connect (authorized case)"); - provider - .advertise(procedure.clone(), realm.clone(), &provider_id) - .await - .expect("advertise (authorized case)"); - - let serve_procedure = procedure.clone(); - let serve_task = tokio::spawn(async move { - serve_until_procedure(&provider, &serve_procedure, policy, 10_000, 5, &provider_id).await - }); - - // A gated provider accepts a token only from the caller it names as its - // audience: that caller's node id as lowercase hex. - let caller_audience: String = caller_id - .node_id() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect(); - let token = ucan_create( - "did:macula:live-test-issuer".to_string(), - caller_audience, - vec![FfiCapability { - with: "mri:test".to_string(), - can: "invoke".to_string(), - }], - &issuer_id, - None, - None, - ) - .expect("ucan_create"); - - let caller = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &caller_id, - ) - .await - .expect("caller connect (authorized case)"); - let call_result = caller - .call_with_ucan( - procedure, - realm, - FfiValue::Text("authorized via ucan".to_string()), - 25_000, - &caller_id, - token, - ) - .await; - - // KNOWN EXTERNAL BLOCKER, confirmed 2026-08-30, not a defect in this - // crate or its FFI wrapper: isolated via a core-crate-only diagnostic - // (bypassing this FFI entirely) that reproduces the identical failure - // -- the REAL, currently-deployed macula-station actively closes the - // connection the instant it receives a CALL frame carrying a - // non-empty `ucan_token` field (case 1 above, an EMPTY token, works - // fine; this is specifically about a real, non-empty one). The client - // side (this crate, and macula-go's equivalent) sends exactly - // what the wire protocol plan documents; the station itself was never - // updated to tolerate the field. UCAN support was built and - // unit-tested in both SDKs this session but this is the first attempt - // to exercise it against the real fleet, and it surfaced a real, - // previously-unknown cross-cutting gap at the STATION layer (a - // separate repo) -- out of scope to fix from here. Treated as a soft - // pass with a loud message rather than a hard failure so this test - // keeps proving the CLIENT-side mechanism is correct (case 1 above) - // while staying an honest regression guard for the day the station - // side is fixed -- flip this back to a hard `.expect()` once that - // lands, the same way other known-external-blocker tests in this - // codebase are written to be re-tightened later. - let response = match call_result { - Ok(r) => r, - Err(e) => { - eprintln!( - "SKIPPING assertion: call_with_ucan failed, which matches a KNOWN external \ - station-side gap (real macula-station closes the connection on a non-empty \ - ucan_token field -- confirmed via a core-crate-only diagnostic, not a bug in \ - this crate): {e}" - ); - serve_task.await.ok(); - return; - } - }; - match response { - FfiCallResponse::Result { payload, .. } => { - assert_eq!(payload, FfiValue::Text("authorized via ucan".to_string())); - } - FfiCallResponse::Error { - code, name, detail, .. - } => panic!("expected a real RESULT, got ERROR code={code} name={name} detail={detail:?}"), - } - serve_task - .await - .expect("serve task should not panic") - .expect("serve_one_call_gated should have answered the authorized call"); -} - -/// Proves `run_publisher` genuinely publishes `pubsub.publish_started_v1`/ -/// `pubsub.publish_completed_v1` around a real publish — confirmed by an -/// INDEPENDENT third session/identity subscribed before the publish -/// happens, not the publisher's own bookkeeping, the same discipline the -/// core crate's own `run_subscriber_and_run_publisher_against_the_real_fleet` -/// test already established. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn run_publisher_facts_through_the_ffi_surface() { - let publisher_id = FfiKeyPair::generate(); - let watcher_id = FfiKeyPair::generate(); - let topic = format!( - "macula_rust_ffi.live_test.pubsub.{}", - short_hex(&publisher_id.node_id()) - ); - let realm = vec![0u8; 32]; - - let watcher = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &watcher_id, - ) - .await - .expect("watcher connect"); - // run_publisher's auto-facts go to FIXED topic names - // (pubsub.publish_started_v1/pubsub.publish_completed_v1), not the - // per-publish `topic` itself -- subscribe to those, not `topic`. These - // are global on this shared public fleet (nothing in the exposed FFI - // surface returns run_publisher's internal publish_id to correlate - // against), so this test can only confirm AT LEAST ONE of each - // landed, not that it was specifically ours -- an honest limit of - // what's observable through the exposed API, not a gap in the test. - let started = watcher - .subscribe( - "pubsub.publish_started_v1".to_string(), - realm.clone(), - &watcher_id, - ) - .await - .expect("watcher subscribe (started)"); - let completed = watcher - .subscribe( - "pubsub.publish_completed_v1".to_string(), - realm.clone(), - &watcher_id, - ) - .await - .expect("watcher subscribe (completed)"); - - let publisher = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &publisher_id, - ) - .await - .expect("publisher connect"); - publisher - .run_publisher( - topic, - realm, - 1, - FfiValue::Text("payload".to_string()), - 0, - true, - &publisher_id, - ) - .await - .expect("run_publisher"); - - // Each subscription receives only its own fact topic, so the first event - // on each is a fact of that kind. These topics are global on this shared - // public fleet, so the test confirms one of each landed, as noted above. - let saw_started = started - .recv_event(10_000) - .await - .is_ok_and(|event| event.topic.ends_with("pubsub.publish_started_v1")); - let saw_completed = completed - .recv_event(10_000) - .await - .is_ok_and(|event| event.topic.ends_with("pubsub.publish_completed_v1")); - started.close().await; - completed.close().await; - assert!(saw_started, "pubsub.publish_started_v1 should have landed"); - assert!( - saw_completed, - "pubsub.publish_completed_v1 should have landed" - ); -} - -/// Proves `open_stream_direct` and `put_direct`/`get_direct` all work -/// end-to-end THROUGH the FFI surface. Streaming: a real chunk sent -/// through a direct-dial-opened stream arrives at the other end. Content: -/// a real byte-exact put/get round trip through a resolved direct-dial -/// connection. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn streaming_and_content_direct_dial_through_the_ffi_surface() { - // --- streaming --- - let provider_id = FfiKeyPair::generate(); - // Two DISTINCT identities on the caller side: `resolver_id` for the - // `caller` session (used only to query the DHT and stays open the - // whole test), `dial_id` for open_stream_direct's own dial. Under one - // identity for both, open_stream_direct would run the stream on the - // caller session instead of dialing, and this test covers the dial. - let resolver_id = FfiKeyPair::generate(); - let dial_id = FfiKeyPair::generate(); - let procedure = format!( - "macula_rust_ffi.live_test.stream_direct.{}", - short_hex(&provider_id.node_id()) - ); - let realm = vec![0u8; 32]; - - // Arc, not a bare value moved into the spawned task below: the task - // does ONLY accept() (returning the accepted stream, not consuming - // it), so `provider` stays alive here in the outer scope too, past - // send_data, until an explicit close at the very end. An earlier - // draft did accept+send_data both inside the spawned task and let - // `provider` drop implicitly the moment that task returned -- real - // bug, reproduced live (3/3 on one station): the caller saw - // Recv("peer closed the stream") instead of the pushed data, because - // `send_data` succeeding only means the data was handed to quinn's - // send-scheduling machinery, not that it reached the peer -- the - // implicit drop tore the connection down before delivery was actually - // confirmed. This is the EXACT gotcha already documented in the core - // crate's own tests/live_direct_dial_extensions.rs (which structures - // this correctly) and in Session::close's own doc -- read both, and - // still wrote the bug into this FFI-level test's first draft, so - // spelling it out here for the next person editing this file. - let provider = Arc::new( - FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &provider_id, - ) - .await - .expect("provider connect"), - ); - provider - .advertise_direct(procedure.clone(), realm.clone(), 60_000, &provider_id) - .await - .expect("advertise_direct"); - - // accept_stream, like serve_one_call, accepts the NEXT inbound - // dedicated stream unconditionally -- it doesn't filter by procedure - // (see RecordingEchoHandler's own doc for the identical reasoning on - // the unary-call side). On this shared public fleet a stray unrelated - // stream-open can arrive first; loop past it via `info.procedure` - // rather than assuming the first accept is this test's own. - let accept_procedure = procedure.clone(); - let accept_provider = Arc::clone(&provider); - let accept_task = tokio::spawn(async move { - for _ in 0..20 { - let accepted = accept_provider.accept_stream(20_000).await?; - if accepted.info.procedure == accept_procedure { - return Ok(accepted); - } - eprintln!( - "accept_stream: got a stream for {:?}, not ours ({accept_procedure:?}); retrying", - accepted.info.procedure - ); - } - Err(FfiError::Closed) - }); - - let caller = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &resolver_id, - ) - .await - .expect("caller (resolver) connect"); - let opened = match caller - .open_stream_direct( - procedure, - realm, - FfiStreamMode::ServerStream, - FfiValue::Null, - 15_000, - &dial_id, - ) - .await - { - Ok(o) => o, - // KNOWN EXTERNAL BLOCKER, not a defect here -- matches the core - // crate's own tests/live_direct_dial_extensions.rs handling of - // the identical ResolveError::StationEndpointNotFound: the - // demo fleet's station_endpoint records expire (5min TTL) faster - // than they're republished, confirmed repeatedly this session - // across both go-sdk and rust-sdk, core crate and FFI alike. - Err(FfiError::Resolve { reason }) if reason.contains("no reachable station_endpoint") => { - eprintln!( - "SKIP: resolved station published no reachable station_endpoint -- known \ - external fleet staleness, not a defect here: {reason}" - ); - accept_task.abort(); - return; - } - Err(e) => panic!("open_stream_direct: {e}"), - }; - let accepted = accept_task - .await - .expect("accept task should not panic") - .expect("provider should accept the direct-dial-opened stream"); - accepted - .stream - .send_data( - macula_rust_ffi::FfiStreamEncoding::Raw, - FfiValue::Text("direct stream data".to_string()), - &provider_id, - ) - .await - .expect("provider should push the chunk"); - let recv_result = opened.stream.recv(15_000).await; - let item = recv_result.expect("recv on direct-dial-opened stream"); - match item { - macula_rust_ffi::FfiStreamItem::Data { body, .. } => { - assert_eq!(body, FfiValue::Text("direct stream data".to_string())); - } - other => panic!("expected Data, got {other:?}"), - } - opened.lease.release(&dial_id).await; - - // --- content --- - let resolve_via_id = FfiKeyPair::generate(); - // A SEPARATE identity from resolve_via_id for the put/get calls - // themselves -- put_direct's own doc warns that reusing resolve_via's - // identity risks this fleet's one-connection-per-identity guard - // kicking resolve_via's own session out from under the caller, since - // put_direct's internal dial would otherwise reuse the identity that's - // ALSO holding resolve_via's connection open (the same identity- - // collision class already found and fixed elsewhere this session). - let content_id = FfiKeyPair::generate(); - let session = FfiSession::connect( - MILAN_HOST.to_string(), - MILAN_PORT, - FfiTrust::WebPki, - &resolve_via_id, - ) - .await - .expect("content session connect"); - let station = session.station_id().await; - let data = b"direct-dial content transfer test payload".to_vec(); - let mcid = session - .put_direct( - station, - data.clone(), - "live-test-blob".to_string(), - 15_000, - &content_id, - ) - .await - .expect("put_direct"); - // KNOWN REAL GAP, discovered live 2026-08-30, NOT fixed here - // (deliberately -- this is a core-crate architectural question, out - // of scope for an FFI-exposure pass): `get_direct` resolves via a - // `content_announcement` DHT record that `macula.erl`'s own doc says - // the STATION publishes automatically on receiving content -- but - // `dht::new_content_announcement` (the only code in this crate that - // COULD build one) has zero callers anywhere in `src/`, confirmed by - // grep, and no client-facing "announce content direct" is exposed on - // purpose (a leaf identity architecturally can't pass the trust check - // for one, per this crate's own doc on that decision). Whether the - // REAL deployed station actually performs this auto-announcement at - // all, or does so on a much longer timescale than tested here, is - // unconfirmed -- 10 retries x 500ms found nothing. Practical effect: - // `get_direct` currently cannot succeed for content stored via - // `put_direct`, in this environment, within this window. Retry a - // bounded number of times (in case it's genuinely just slow) and - // treat a persistent miss as a documented gap, not a hard test - // failure, so this test still proves `put_direct` itself works - // (confirmed above) without permanently blocking on an - // architectural question this pass isn't scoped to resolve. - for _ in 0..10 { - match session.get_direct(mcid.clone(), 15_000, &content_id).await { - Ok(bytes) => { - assert_eq!(bytes, data, "content should round-trip byte-exact"); - return; - } - Err(FfiError::Content { reason }) if reason.contains("no verifiable announcement") => { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - } - Err(e) => panic!("get_direct: {e}"), - } - } - eprintln!( - "SKIP: get_direct found no content_announcement for this mcid after 10 retries -- \ - known real gap (see comment above), not a defect in this FFI pass" - ); -} diff --git a/macula-rust-ffi/tests/pool_ffi.rs b/macula-rust-ffi/tests/pool_ffi.rs new file mode 100644 index 0000000..de7c43e --- /dev/null +++ b/macula-rust-ffi/tests/pool_ffi.rs @@ -0,0 +1,364 @@ +//! The mobile bindings over the pool, as Kotlin and Swift reach them, +//! against macula-go's in-process stations (the core crate's +//! tests/common/lab.rs): node keys, a pool, calls to a handler the foreign +//! side implements, pubsub, streams, and records. + +#[path = "../../tests/common/mod.rs"] +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::lab::{Lab, LabStation}; +use macula_rust::profile::Profile; +use macula_rust_ffi::{ + own_procedure, FfiCallHandler, FfiError, FfiNodeKey, FfiPool, FfiPoolOptions, FfiProfile, + FfiRealmKey, FfiRequest, FfiSeed, FfiStream, FfiStreamEvent, FfiStreamHandler, FfiStreamMode, + FfiValue, +}; + +const ORG_PROCEDURE: &str = "mcl-echo/echo"; +const TOPIC: &str = "mcl-rust/ffi/greeting_sent_v1"; + +fn seed(s: &LabStation) -> FfiSeed { + FfiSeed { + host: s.host.clone(), + port: s.port, + node_id: s.node_id.to_vec(), + } +} + +fn options() -> FfiPoolOptions { + FfiPoolOptions { + respawn_delay_ms: 100, + connect_timeout_ms: 15_000, + ..FfiPoolOptions::default() + } +} + +async fn pool(key: &Arc, s: &LabStation, opts: FfiPoolOptions) -> Arc { + FfiPool::connect(key.clone(), vec![seed(s)], opts) + .await + .unwrap() +} + +/// A foreign handler: echoes, or refuses "fail". +struct Echo; + +#[async_trait::async_trait] +impl FfiCallHandler for Echo { + async fn handle(&self, request: FfiRequest) -> Result { + if request.payload == FfiValue::Text("fail".into()) { + return Err(FfiError::Handler { + message: "refused by the handler".into(), + }); + } + Ok(FfiValue::Fields(vec![ + macula_rust_ffi::FfiMapEntry { + key: FfiValue::Text("echo".into()), + value: request.payload, + }, + macula_rust_ffi::FfiMapEntry { + key: FfiValue::Text("caller".into()), + value: FfiValue::Bytes(request.caller), + }, + ])) + } +} + +/// A foreign stream handler: sends "a" then "b". +struct Chunks; + +#[async_trait::async_trait] +impl FfiStreamHandler for Chunks { + async fn handle(&self, stream: Arc) -> Result<(), FfiError> { + for chunk in ["a", "b"] { + stream.send(chunk.as_bytes().to_vec()).await?; + } + Ok(()) + } +} + +#[test] +fn a_node_key_is_made_in_either_profile_and_survives_its_key_file() { + let dir = tempfile::tempdir().unwrap(); + for profile in [FfiProfile::PqPure, FfiProfile::PqHybrid] { + let key = FfiNodeKey::generate(profile).unwrap(); + assert_eq!(key.profile(), profile); + assert_eq!(key.node_id().len(), 32); + assert_eq!(key.node_id()[0], 0, "the puzzle's leading zero bits"); + let path = dir.path().join(format!("{profile:?}.key")); + key.save(path.to_string_lossy().into()).unwrap(); + let loaded = FfiNodeKey::load(path.to_string_lossy().into(), profile).unwrap(); + assert_eq!(loaded.node_id(), key.node_id()); + } + let fresh = dir.path().join("fresh.key").to_string_lossy().into_owned(); + let made = FfiNodeKey::load_or_create(fresh.clone(), FfiProfile::PqPure).unwrap(); + let again = FfiNodeKey::load_or_create(fresh, FfiProfile::PqPure).unwrap(); + assert_eq!(again.node_id(), made.node_id()); + let missing = FfiNodeKey::load( + dir.path().join("none").to_string_lossy().into(), + FfiProfile::PqPure, + ); + assert!(matches!(missing, Err(FfiError::Key { .. }))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_call_reaches_a_foreign_handler_in_the_provider_s_own_namespace() { + let lab = Lab::start(Profile::PqPure); + let (serving, callers) = (lab.station("ffi serving"), lab.station("ffi callers")); + lab.share(&[&serving, &callers]); + let realm = vec![0x42; 32]; + let provider_key = FfiNodeKey::generate(FfiProfile::PqPure).unwrap(); + let provider = pool(&provider_key, &serving, options()).await; + let ring = own_procedure(provider.node_id(), "ring".into()).unwrap(); + let served = provider + .serve(realm.clone(), ring.clone(), Arc::new(Echo)) + .await + .unwrap(); + + let caller_key = FfiNodeKey::generate(FfiProfile::PqPure).unwrap(); + let caller = pool(&caller_key, &callers, options()).await; + let answered = caller + .call( + realm.clone(), + ring.clone(), + FfiValue::Text("hi".into()), + None, + 5_000, + ) + .await + .unwrap(); + let FfiValue::Fields(fields) = answered else { + panic!("{answered:?}") + }; + assert_eq!(fields[0].value, FfiValue::Text("hi".into())); + assert_eq!(fields[1].value, FfiValue::Bytes(caller.node_id())); + + match caller + .call( + realm.clone(), + ring.clone(), + FfiValue::Text("fail".into()), + None, + 5_000, + ) + .await + { + Err(FfiError::Provider { code, detail, .. }) => { + assert_eq!(code, "handler_error"); + assert!(detail + .unwrap_or_default() + .contains("refused by the handler")); + } + other => panic!("{other:?}"), + } + let providers = caller.providers(realm.clone(), ring.clone()).await.unwrap(); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].node, provider.node_id()); + assert_eq!(providers[0].station, serving.node_id.to_vec()); + + served.stop().await.unwrap(); + // An org procedure in a realm the pool pins no key for is refused. + let unpinned = caller + .call(realm, ORG_PROCEDURE.into(), FfiValue::Null, None, 2_000) + .await; + assert!( + matches!(unpinned, Err(FfiError::NoRealmKey)), + "{unpinned:?}" + ); + caller.close().await; + provider.close().await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_org_procedure_is_served_under_the_pinned_realm_key() { + let lab = Lab::start(Profile::PqHybrid); + let s = lab.station("ffi org"); + let realm = lab.realm("ffi-org", "mcl-echo"); + let trust = FfiPoolOptions { + realm_trust: vec![FfiRealmKey { + realm: realm.id.to_vec(), + key: realm.key.clone(), + }], + ..options() + }; + let provider_key = FfiNodeKey::generate(FfiProfile::PqHybrid).unwrap(); + let provider_id: [u8; 32] = provider_key.node_id().try_into().unwrap(); + lab.admit(&realm, &s, &[provider_id]); + let provider = pool(&provider_key, &s, trust.clone()).await; + provider + .serve(realm.id.to_vec(), ORG_PROCEDURE.into(), Arc::new(Echo)) + .await + .unwrap(); + let caller = pool( + &FfiNodeKey::generate(FfiProfile::PqHybrid).unwrap(), + &s, + trust, + ) + .await; + let answered = caller + .call( + realm.id.to_vec(), + ORG_PROCEDURE.into(), + FfiValue::Int(7), + None, + 5_000, + ) + .await + .unwrap(); + let FfiValue::Fields(fields) = answered else { + panic!("{answered:?}") + }; + assert_eq!(fields[0].value, FfiValue::Int(7)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_subscription_hears_a_publication_once() { + let lab = Lab::start(Profile::PqPure); + let s = lab.station("ffi pubsub"); + let realm = vec![5; 32]; + let listener = pool( + &FfiNodeKey::generate(FfiProfile::PqPure).unwrap(), + &s, + options(), + ) + .await; + let publisher = pool( + &FfiNodeKey::generate(FfiProfile::PqPure).unwrap(), + &s, + options(), + ) + .await; + let sub = listener + .subscribe(realm.clone(), TOPIC.into()) + .await + .unwrap(); + let me: [u8; 32] = listener.node_id().try_into().unwrap(); + let r: [u8; 32] = realm.clone().try_into().unwrap(); + for _ in 0..200 { + if lab.subscribed(&s, &me, &r, TOPIC) { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + publisher + .publish( + realm.clone(), + TOPIC.into(), + FfiValue::Text("hello".into()), + None, + ) + .await + .unwrap(); + let event = sub.next(5_000).await.unwrap().expect("the publication"); + assert_eq!(event.payload, FfiValue::Text("hello".into())); + assert_eq!(event.publisher, publisher.node_id()); + assert_eq!(event.topic, TOPIC); + assert!(sub.next(300).await.unwrap().is_none(), "heard once"); + sub.unsubscribe().await.unwrap(); + assert!(matches!(sub.next(100).await, Err(FfiError::Closed))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_stream_from_a_foreign_handler_is_heard_and_released() { + let lab = Lab::start(Profile::PqPure); + let (serving, callers) = ( + lab.station("ffi stream serving"), + lab.station("ffi stream callers"), + ); + lab.share(&[&serving, &callers]); + let realm = vec![6; 32]; + let provider = pool( + &FfiNodeKey::generate(FfiProfile::PqPure).unwrap(), + &serving, + options(), + ) + .await; + let watch = own_procedure(provider.node_id(), "watch".into()).unwrap(); + provider + .serve_stream( + realm.clone(), + watch.clone(), + FfiStreamMode::ServerStream, + Arc::new(Chunks), + ) + .await + .unwrap(); + let caller = pool( + &FfiNodeKey::generate(FfiProfile::PqPure).unwrap(), + &callers, + options(), + ) + .await; + let stream = caller + .open_stream( + realm, + watch, + FfiStreamMode::ServerStream, + FfiValue::Null, + None, + 0, + ) + .await + .unwrap(); + let mut got = Vec::new(); + loop { + match stream.recv(5_000).await.unwrap() { + FfiStreamEvent::Data { + body: FfiValue::Bytes(b), + .. + } => got.push(b), + FfiStreamEvent::End { .. } => break, + other => panic!("{other:?}"), + } + } + assert_eq!(got, vec![b"a".to_vec(), b"b".to_vec()]); + assert!(matches!( + stream.recv(1_000).await, + Err(FfiError::EndOfStream) + )); + for _ in 0..100 { + if lab.relayed(&serving) == 0 && lab.relayed(&callers) == 0 { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("the stream was not released within 2 s"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn records_go_through_the_pool() { + let lab = Lab::start(Profile::PqPure); + let s = lab.station("ffi records"); + let p = pool( + &FfiNodeKey::generate(FfiProfile::PqPure).unwrap(), + &s, + options(), + ) + .await; + let endpoints = p + .find_records_by_type(macula_rust::record::RecordType::STATION_ENDPOINT.0) + .await + .unwrap(); + assert_eq!(endpoints.len(), 1); + assert_eq!(endpoints[0].signer, s.node_id.to_vec()); + let missing = p.find_record(vec![0x22; 32]).await; + assert!( + matches!(missing, Err(FfiError::RecordNotFound)), + "{missing:?}" + ); + let refused = p.put_record(b"not a record".to_vec()).await; + assert!(refused.is_err()); + let bad_id = p.find_record(vec![1; 3]).await; + assert!( + matches!( + bad_id, + Err(FfiError::WrongByteLength { + expected: 32, + actual: 3 + }) + ), + "{bad_id:?}" + ); +} diff --git a/plans/PLAN_RESOURCE_LEAK_HARDENING.md b/plans/PLAN_RESOURCE_LEAK_HARDENING.md deleted file mode 100644 index 70e4265..0000000 --- a/plans/PLAN_RESOURCE_LEAK_HARDENING.md +++ /dev/null @@ -1,293 +0,0 @@ -# PLAN_RESOURCE_LEAK_HARDENING.md - -**Status:** Survey complete — hardening not started -**Created:** 2026-09-12 -**Last Updated:** 2026-09-12 - -## Overview - -Read-only survey of `src/` (all 19 modules), `macula-rust-ffi/src/lib.rs`, -and the tests/examples that reveal usage patterns, for potential memory and -resource leaks — run as a cross-check against the just-completed survey of -the C# sibling (`macula-dotnet/plans/PLAN_RESOURCE_LEAK_HARDENING.md`). No -code was changed; every finding below is verified against the current tree -with file:line references, and quinn 0.11.11's own Drop semantics were -checked in the vendored source (`send_stream.rs:344-362`, -`recv_stream.rs:509-531`, `endpoint.rs:718-728`, `connection.rs:929-948`) -before anything was claimed released or retained. - -The headline result: **the C# survey's CRITICAL stream-slot leaks do not -exist in Rust.** quinn's own Drop impls release every dedicated stream -(dropped `SendStream` → FIN, dropped `RecvStream` → STOP_SENDING(0), last -`ConnectionRef` → implicit close), `OpenSessions` holds `Weak` handles -instead of strong ones, the inbound CALL queue is bounded at 64, both -crates forbid `unsafe` (`unsafe_code = "forbid"` in each Cargo.toml), and -UniFFI scaffolding owns all raw-pointer marshalling — there is no -hand-written `Box::into_raw`/`from_raw` anywhere. What remains is a set of -Rust-shaped resource-lifetime issues: abandoned detached handler tasks, -unobserved panics in fire-and-forget tasks, missing app-level stream -teardown on error paths, and FFI objects with no Drop-side protocol -signaling. - -General hygiene found GOOD and not repeated here: `Pool::close` aborts and -awaits every background task via `JoinSet::shutdown` before draining links -(pool.rs:551-562); `PooledLink` state is never held across a round trip; -`Waiting` (control_channel.rs:827-836) removes a call's reply slot on every -exit path; `transport::connect`'s local `Endpoint` drop is safe (quinn's -`EndpointRef` keeps the driver alive while connections exist); the CBOR -decoder caps nesting (`MAX_NESTING_DEPTH = 128`), validates declared -lengths before slicing, and caps list preallocation (cbor.rs:317, 366-411, -491); `frame::MAX_FRAME_BYTES` (16 MiB) caps every frame decode -(frame.rs:41, 1087-1138); direct-dial lease accounting releases on every -request outcome (direct_dial.rs:1437-1454). - ---- - -## Findings (ranked) - -### CRITICAL - -None. The three C# CRITICALs (F1-F3: dedicated streams never released, -consuming outbound stream slots until the connection dies) are **not -present**: every dedicated stream in this crate is owned by a `FrameStream` -whose quinn halves release the QUIC stream slot on drop. Verified against -quinn 0.11.11 source, not assumed. The residual gap is that this teardown -is bare (FIN + STOP_SENDING code 0) rather than an app-level -finish/abort — see F3, ranked MEDIUM for that reason. - -### HIGH - -#### F1. `serve_one_call_gated` timeout abandons the running handler task, detached -`connection.rs:978-993` (the `tokio::time::timeout(...).unwrap_or(...)` -wrap) + `connection.rs:1346` (`tokio::spawn(async move { handler(payload).await })`). - -When the serve timeout fires (or the caller cancels the future), the -in-flight `build_call_reply` future is dropped, which drops the -`JoinHandle` of the spawned handler task **without aborting it**. The -handler keeps running to completion in the background, detached, holding -whatever it captured (its `Arc` / `FfiCallHandler`, the -payload, any app state it moved in). A handler that hangs (waiting on a -network call, a lock, a blocking API) leaks its task forever; every -timed-out serve of a hung handler accumulates one detached task. An app -looping `serve_one_call` with short timeouts against a slow handler grows -background work without bound. The reply the detached handler eventually -produces is dropped as an unrouted frame, which is fine for the caller's -timeout contract — the leak is the task itself. - -Fix direction: on timeout, abort the handler task (`JoinHandle::abort()` -inside `build_call_reply`'s own timeout/guard) or run handlers under an -explicit watchdog, so a timed-out serve leaves no live task; add a test -with a hung handler asserting the task count returns to baseline. - -#### F2. Background tasks spawned without JoinHandle observation — a panic is unobserved, and a dead reader leaves a session that still reports live -`control_channel.rs:381` (reader task), `:382` (hand-off writer task), -`:960-967` (`Subscription::drop` → `runtime.spawn(remove_subscription)`), -`control_channel/drop_warning.rs:188-194` (interval closer), -`connection.rs:1346` (per-call handler). - -Every one of these spawns discards its `JoinHandle`, so a panic inside is -observed by nothing (the default panic hook prints; the `JoinError` is -never awaited). The reader task is the sharpest case: `SessionInner::is_live` -(connection.rs:351-353) checks only `end_reason` and `close_reason` — a -reader that panicked leaves both `None`, so the session still reports -live, still sits in `open_sessions::live()` (as the registry's `Weak` is -upgradable through the app's own still-held handles), and is handed out -for reuse while it can never route a frame again. Retained resources: the -whole `Channel` (writer mutex, pending-calls map, subscriptions) plus a -QUIC connection, for as long as any `Session` handle exists. - -Fix direction: retain and observe the reader/writer `JoinHandle`s (e.g. -store them in `Channel`, `end()` on a reader `Err(join)`), attach -observation continuations to the fire-and-forget spawns (`Subscription::drop`, -`drop_warning::record`), and treat a panic in `read` as a session end -(`SessionEndReason::StreamFailed`-shaped), which also drives the -`on_ended` unregister. - -### MEDIUM - -#### F3. Dedicated streams never receive an app-level finish/abort on any path -`content.rs:144-149` (`put`), `:180-185` (`get`), `:225-322` -(`put_block`/`put_manifest`/`get_block`/`get_manifest` error paths); -`stream.rs:202-229` (`open_on`, STREAM_OPEN write failure path); -`connection.rs:154-165` (the `abort_both`/`finish_and_stop_reading` -helpers that these callers never call). - -`content::put`/`get` and `StreamHandle::open_on` open a dedicated stream -and simply drop the `FrameStream` on success and every failure path — -never `finish_and_stop_reading` (success) or `abort_both` (failure). The -stream is released by quinn's Drop (FIN + STOP_SENDING(0)), so this is -**not** the C# slot-exhaustion leak — but the peer's only signal is a bare -code-0 teardown: a mid-transfer failure (hash mismatch, remote error, -timeout) is indistinguishable from a clean end of transfer, and the -protocol's own refusal code (`stream::REFUSED_STREAM = 2`) is used only on -the accept/refuse paths, never here. Consequence is protocol-level -mis-signaling and harder debugging on the station side, not local memory -growth. - -Fix direction: give `put_on`/`get_on`/`open_on` a teardown guard — -finish-and-stop on success, abort with an appropriate code on every error -return; same for `FrameStream::call`'s send-failure path. - -#### F4. `fetch_content` timeout drops a leased, dialed session mid-transfer without release or GOODBYE -`direct_dial.rs:601-614`: on the `Err(_)` branch of the fetch timeout the -`fetch(target, ...)` future — which owns the `StationTarget`, the lease, -and the in-flight `FrameStream` — is dropped wholesale. The dialed -session's lease is never released through `Leases::release`, and the -session is closed only when the last handle drops, i.e. abruptly via -`SessionInner::drop` (connection.rs:341-348): no GOODBYE, no bounded drain, -the station sees a bare connection close. Released, not leaked — but the -lease accounting is bypassed, and a request that the app still holds a -handle to (through a clone) survives past its deadline with the -connection torn out from under it. - -Fix direction: make the timeout branch release the target through the same -`run_then_release`/`close_last` path the non-timeout outcomes use, and -have dropped leases close leased sessions explicitly (GOODBYE) where a -runtime is available. - -#### F5. FFI wrapper objects have no Drop that performs protocol teardown -`macula-rust-ffi/src/lib.rs:1626-1654` (`FfiSubscription`), `:1662-1789` -(`FfiStream`), `:733` (`FfiSessionLease`), `:865` (`FfiSession`). - -UniFFI cannot await in `Drop`, so all four objects delegate teardown to -explicit methods (`close`, `abort`, `refuse`, `release`). A foreign -(Kotlin/Swift) caller that lets an object be GC'd without calling those -gets quinn's bare teardown (F3) plus: an **accepted** inbound stream -(`accept_stream`, lib.rs:1525-1540) that is never served and never -`refuse`d leaves the peer's `await_reply`/`recv` hanging until its own -timeout (no error, no refusal frame ever arrives), while the local stream -slot stays consumed for that whole window; a `FfiSession` GC'd without -`close` tears the connection down without GOODBYE (documented, but -every Ffi object makes it easier to hit); a `FfiSessionLease` GC'd without -`release` skips lease accounting exactly as in F4. Resources are -ultimately freed by Rust drops — this is protocol-lifetime hardening, the -FFI-shaped analog of the C# F6 finding. - -Fix direction: where a runtime handle is available, mirror -`Subscription::drop`'s pattern (spawn a teardown task from `Drop`); at -minimum add `close`/`abort` destructor guidance and an accepted-stream -watchdog that refuses never-served streams after a bound. - -### LOW - -#### F6. Frame/reader buffers grow to the frame cap and never shrink -`connection.rs:72-78` (`FrameStream.buf`), `connection.rs:178-196` -(`recv_frame`), `control_channel.rs:838-884` (reader `buf`). Each buffer -grows to at most `frame::MAX_FRAME_BYTES` (~16 MiB) and retains that -capacity for the rest of the session's life; the inbound CALL queue -(control_channel.rs:51) can simultaneously hold 64 payloads of up to -16 MiB each worst case. Bounded — the C# equivalent of `Envelope.MaxFrameBytes` -is present and respected — but a memory-pressure knob under large-frame -traffic. Consider releasing/shrinking `FrameStream.buf` on finish and -capping aggregate queued payload bytes. - -#### F7. Stale weak entries in `OpenSessions` when a session dies without `end()` -`open_sessions.rs:89-131`. Unregister happens through the `on_ended` -callback (connection.rs:515); if the reader task panics before `end()` -runs (F2's scenario), the weak entry survives until the next -`find`/`unregister` for that (identity, station) pair. Harmless (weak, ~80 -bytes), bounded by pairs ever used — but prune-on-find is the only -recovery, and it should also run when `SessionInner::drop` fires outside -`end`. - -#### F8. `KeyPair::save` leaves a `.tmp` file on failure before rename -`identity.rs:160-163` — same shape as the C# survey's -`KeyPair.Save` LOW: a write that fails at `fs::write`/`set_permissions`/ -`rename` leaves the sibling `.tmp` behind. Delete on error. - -#### F9. `StreamHandle::accept`'s shared-deadline loop can be monopolized by refused stream-open floods -`stream.rs:244-265` — the same note as the C# survey's LOW for -`AcceptAsync`: a peer flooding STREAM_OPENs that all get refused keeps the -loop consuming the one total deadline. Refused streams are properly -aborted (`refuse` → `abort_both(REFUSED_STREAM)`, stream.rs:109-112), so -this is a liveness/budget concern, not a leak. - -#### F10. `Subscription::drop` spawn can silently fail outside a runtime -`control_channel.rs:954-969` — `try_current()` returning `Err` falls back -to the retain path, which is correct and documented; the `spawn` path's -fire-and-forget JoinHandle is part of F2. No action beyond F2's. - ---- - -## Cross-check against the dotnet survey (record for completeness) - -| dotnet finding | Rust status | -|----------------|-------------| -| F1 `ContentTransfer` never releases its stream | **Not present as a leak** — quinn Drop releases the QUIC stream (FIN + STOP_SENDING(0), verified in quinn 0.11.11 `send_stream.rs:344-362`, `recv_stream.rs:509-531`). Residual app-level teardown gap: F3 here. | -| F2 `StreamHandle.AcceptAsync` abandons stream on non-refusal failures | **Not present as a leak** — `open_inbound`'s `Inbound::Failed` and `accept`'s timeout both drop the `FrameStream`, which quinn releases (stream.rs:272-303, 250-263). Refusal paths use the proper `REFUSED_STREAM` abort. | -| F3 `StreamHandle.OpenAsync` leaks on STREAM_OPEN write failure | **Not present as a leak** — same quinn Drop release on the `stream.rs:222` error path. | -| F4 `OperationCanceledException` misclassified as timeout | **Not present** — Rust's typed errors keep session-end (`SessionEnded`) and timeout (`Timeout`) distinct; `tokio::time::timeout` only errors on its own timer. The timeout-drop *consequence* (abandoned work) surfaces as F1 here. | -| F5 Untrusted `manifest.Size` drives unbounded allocation | **Not present — deliberately addressed**: `get_on` grows the buffer only from individually hash-verified chunks (content.rs:202-219), and `from_wire` rejects `chunk_size: 0` and mismatched `chunk_count` (manifest.rs:352-368, 408-413). | -| F6 No `IDisposable` safety net on handles | **Resource side not present** — quinn's Drop *is* the safety net; the app-code side (no abort codes on drop) is F3/F5 here. | -| F7 Unbounded task fan-out per inbound CALL | **Mostly not present** — inbound queue bounded at 64 (control_channel.rs:51); the per-call handler task is awaited inline (connection.rs:1346), not fire-and-forget. The abandoned-on-timeout case is F1. | -| F8 `EventDedup` growth between sweeps | **Not present** — no event-dedup map exists in this crate (verified by search). | -| F9 Fire-and-forget close with unobserved fault | **Present, Rust-shaped** — F2 here (reader/writer/subscription-removal/drop-warning spawns, all JoinHandle-discarded). | -| F10 Publisher CTS ownership / unobserved callbacks | **Not present** — no CTS pattern; `run_publisher` (connection.rs:1050-1095) is fully awaited; fact-publish failures are deliberately discarded as in the reference. | -| F11 Dead subscriptions retained by ended channel | **Not present materially** — `end()` drops every entry's event sender (control_channel.rs:626-628); remaining `Entry` strings are bounded by session lifetime. | -| F12 Static `OpenSessions` registry retains undisposed sessions | **Not present** — registry holds `Weak` (open_sessions.rs:77) and unregisters via `on_ended` (connection.rs:515); dropped sessions are unreachable by `find`. Residual hygiene: F7. | -| LOW `.tmp` file after failed `KeyPair.Save` | **Present** — F8. | -| LOW accept-loop budget under refusal floods | **Present** — F9. | - -**Rust-specific additions not in the dotnet survey:** F1 (detached handler -tasks — no equivalent "abandoned task on timeout" shape in the C# list), -F2's reader-panic-leaves-session-"live" consequence, F4 (lease bypass on -fetch timeout), F5 (UniFFI objects without Drop-side protocol teardown), -F6 (buffer retention). Also verified clean: `unsafe` is forbidden in both -crates, so the "unsafe code in ffi leaking across the boundary" item from -the survey list has no hand-written counterpart — UniFFI's generated -scaffolding owns all marshalling. - ---- - -## Phases - -- [ ] Phase 1 — Detached-task closure (F1, F2): abort handler tasks on - serve timeout; retain and observe the reader/writer JoinHandles; - panic in the reader ends the session (drives `on_ended` unregister). - Test: N timed-out serves of a hung handler leave zero live tasks. -- [ ] Phase 2 — Dedicated-stream teardown (F3, F4): finish/abort guards on - `content::put_on`/`get_on` and `StreamHandle::open_on`; lease-aware - release on the `fetch_content` timeout branch. Test: repeated - put/get and open-failure cycles show no station-side open-stream - growth and correct RESET codes on failure. -- [ ] Phase 3 — FFI lifetime (F5): Drop-side teardown where a runtime is - available (spawn-on-drop like `Subscription::drop`), destructor - guidance, and a bounded watchdog refusing never-served accepted - streams. -- [ ] Phase 4 — Hygiene (F6-F10): buffer release/caps, stale-weak prune - on drop, `.tmp` cleanup, accept-loop budget. - -## Files to Create/Modify - -| File | Purpose | Status | -|------|---------|--------| -| `src/connection.rs` | F1 handler-task abort on timeout, F2 reader/writer JoinHandle observation, F6 buffer release | Not started | -| `src/content.rs` | F3 finish/abort teardown on `put_on`/`get_on` | Not started | -| `src/stream.rs` | F3 teardown on `open_on`/accept error paths, F9 accept budget | Not started | -| `src/direct_dial.rs` | F4 lease release + close on fetch-timeout drop | Not started | -| `src/control_channel.rs` | F2 spawned-task observation, F6 queue byte cap, F10 | Not started | -| `src/control_channel/drop_warning.rs` | F2 observation of the interval-closer spawn | Not started | -| `src/open_sessions.rs` | F7 prune on `SessionInner` drop | Not started | -| `src/identity.rs` | F8 `.tmp` cleanup on save failure | Not started | -| `macula-rust-ffi/src/lib.rs` | F5 Drop-side teardown, accepted-stream watchdog | Not started | -| `tests/` (new leak-regression tests) | Prove F1-F4 fixes: task-count stability, stream-count stability, correct abort codes | Not started | - -## Success Criteria - -- [ ] A live-session stress test (N sequential `content::put`/`get` calls, - interleaved with induced failures) shows no growth in station-side - open-stream count, and failure teardown carries a non-zero - RESET_STREAM/STOP_SENDING code. -- [ ] `serve_one_call` with a hung handler, timed out 1000x, leaves zero - additional live tasks (task count stable after each cycle). -- [ ] A panicking reader task ends the session (`end_reason` set, - `on_ended` ran, `open_sessions` no longer finds it) instead of a - live-reporting zombie. -- [ ] `fetch_content` timeout on a dialed session releases the lease and - closes the session with GOODBYE, not a bare drop-close. -- [ ] A foreign-side GC of `FfiStream`/`FfiSubscription`/`FfiSessionLease` - produces the same teardown (abort frame / UNSUBSCRIBE / lease - release) as the explicit close/abort/refuse methods. -- [ ] All tests green (`cargo test` in workspace root and - `macula-rust-ffi`), clippy clean under the repo's deny config, and - `unsafe_code = "forbid"` still holds in both crates. diff --git a/plans/PLAN_WIRE_PROTOCOL.md b/plans/PLAN_WIRE_PROTOCOL.md deleted file mode 100644 index 0b6b93a..0000000 --- a/plans/PLAN_WIRE_PROTOCOL.md +++ /dev/null @@ -1,1099 +0,0 @@ -# Macula Wire Protocol — Spec Extracted for a Rust SDK Port - -**Status:** Reference spec, extracted from source. Not a build plan yet. -**Created:** 2026-08-28 -**Repo renamed 2026-08-28:** `macula-mobile` → `macula-rust`. This is a -Rust port of macula's *SDK* half (the client/leaf side — see -`macula/CLAUDE.md`'s own SDK-vs-Relay split), not the relay/station. Mobile -(iOS/Android via UniFFI) is the flagship, driving consumer and the reason -this work started, not the ceiling on it — the same core is equally usable -from a future WASM build, CLI tool, or any other non-BEAM Rust consumer, -with no code shaped specifically around "mobile" below the UniFFI binding -layer itself. -**Scope constraint:** macula-station cannot change. Everything below describes -the wire contract as it exists today so a client can be built against it -unmodified. - -**Why this exists:** so a non-BEAM Rust consumer — a phone first — can hold -a QUIC session with an unmodified macula-station and speak its real -application primitives (pubsub, RPC, capability advertise), without -macula's own Erlang code changing at all. - -This is a BUILD artifact (a wire-format spec extracted from existing, -shipped, tested source), not a CLAIM about the world — nothing here needs -an adversarial gate. It needs to be *correct against the source*, which is -why every section below is traced to specific files and line ranges in -`macula-io/macula` at v10.10.0 rather than reconstructed from memory. - -Source files read in full for this spec: `native/macula_quic/{Cargo.toml, -src/cert.rs, src/config.rs}`, `native/macula_cbor_nif/src/deterministic.rs`, -`src/identity/macula_identity.erl`, `src/peering/{macula_protocol_types.erl, -macula_frame.erl, macula_peering_conn.erl, macula_bolt4.erl, -macula_source_route.erl}`, `src/content/macula_manifest.erl`, -`src/macula_content_transfer.erl`, `src/macula_upload.erl`, -`src/macula_pusher.erl`, `src/macula_download.erl`, `src/macula_stream.erl`, -`src/macula_streamer.erl`, `src/macula_stream_sink.erl`, plus lines 890-964 -of `src/client/macula_client.erl` (identity-resolution/puzzle-lifecycle -context). Skimmed for scope only (not needed for a client, station/config-side): -`macula_tls.erl`, `macula_peering.erl`, `macula_quic.erl`. Not read, role -inferred from siblings (§12.3): `macula_feeder.erl`, -`macula_content_transfer_registry.erl`, `macula_stream_local.erl`, -`macula_streamer_sup.erl`, `macula_feeder_sup.erl`, `macula_download_sup.erl`. -Not yet read: the rest of `macula_client.erl` (1376 lines total — only the -identity-resolution section was needed so far), `macula_record_cbor.erl` -(corroborating source for the CBOR codec, not primary), `macula_crypto_nif`'s -`grind_puzzle` implementation (not needed — algorithm fully specified from -the Erlang side). Confirmed dead: -`macula_protocol_types.erl`, `macula_protocol_encoder.erl`, -`macula_protocol_decoder.erl` — an unreferenced legacy (V1, msgpack/byte-tag) -scheme, superseded entirely by `macula_frame.erl` ("Macula V2"). Ignore all -three; nothing in the live peering connection state machine calls them. - ---- - -## 1. Transport layer - -- **Engine:** `quinn` 0.11 + `rustls` 0.23 (`ring` backend), driven from - Erlang via a `rustler` NIF (`native/macula_quic`). Despite the "HTTP/3 - mesh" branding elsewhere in the docs, this is **raw QUIC**, not real - HTTP/3 — there is no `h3` crate dependency anywhere in `macula_quic`'s - `Cargo.toml`. -- **ALPN:** `"macula"` (single string, `native/macula_quic/src/config.rs:13` - default). A client MUST negotiate this ALPN, not `h3` or anything else. -- **Framing on top of QUIC:** one long-lived bidirectional "control stream" - per connection carries the handshake and all application frames after - it. Separate QUIC streams ("dedicated streams") are opened per streaming - RPC session or per content-transfer session — see §7. - -A Rust client depending on the same `quinn`/`rustls` combination macula -already trusts should be wire-compatible at the transport level with zero -station-side changes. Dial directly on `quinn` — see §11.4 for why `iroh` -was considered and dropped: macula's edges are dial-out only, and macula -already owns discovery/gossip/pubsub/identity, so Iroh's actual -distinguishing features (NAT traversal, its own discovery, its own -gossip/doc-sync) would compete with macula's stack rather than fill a -gap in it. - -## 2. Identity and trust model - -Every peer's identity **is** an Ed25519 keypair. There is no separate -account system at the transport layer. - -- **Certs:** self-signed Ed25519 leaf certs generated per node - (`native/macula_quic/src/cert.rs`, using `rcgen`). No CA chain, no - DNS-anchored trust, by design — the doc comment says so explicitly. -- **TLS-layer verification**, chosen per dial (`macula_peering_conn.erl` - `dial_trust_opts/1`, lines 785-812): - - `verify_pubkey => NodeId` (32-byte Ed25519 pubkey): pins the server - cert's SubjectPublicKeyInfo to that exact key via a custom - `rustls::client::danger::ServerCertVerifier` - (`cert.rs::PubkeyPinVerifier`, ~line 140). Used when the dialer already - knows the peer's identity (DHT records, pre-shared relay identities). - - `verify => webpki` (default since macula 5.0.0): standard CA-bundle + - hostname validation (`webpki-roots` crate). Used for bootstrap-style - dials by hostname where the peer's Ed25519 identity isn't known yet. - - `verify => none`: skips TLS verification entirely — dev/lab only, logs - a warning. -- **Application-layer verification, independent of the above:** the - CONNECT/HELLO handshake frame itself carries the peer's self-claimed - `node_id` and is Ed25519-signed. `macula_frame:verify/2` checks the - signature proves the sender holds the private key for that `node_id` — - this is checked **regardless of which TLS trust mode was used**. If the - dialer additionally set `expected_node_id`, `bind_peer_identity/2` - (`macula_peering_conn.erl:481`) rejects the handshake unless the - verified frame identity matches, closing the gap where TLS-layer trust - and application-layer identity could otherwise diverge (e.g. under - `pin_tls_cert => false`, where a peer's TLS is terminated by an - unrelated PKI). - -**For a mobile client dialing a known macula-station:** use -`verify_pubkey` with the station's known Ed25519 identity, matching what -DHT-resolved or pre-configured station records already give you, rather -than `webpki`. - -**Empirical finding, 2026-08-28 — confirmed against a live production -station, not assumed:** `macula-station-frankfurt` (`macula.io`, part of -the 7-box demo fleet) presents a **3-certificate RSA chain** (SPKI OID -`1.2.840.113549.1.1.1`), not a self-signed Ed25519 identity cert. That's -macula's *other* documented trust mode (`verify => webpki`, "public-IP -path with Let's Encrypt-anchored certs"), confirmed working end-to-end -from `macula-rust` (`tests/live_station.rs`): full QUIC/TLS handshake -completes, ALPN negotiates as `"macula"` exactly per spec, CA-chain -validation against `webpki-roots` succeeds. Pubkey-pinned trust is fully -implemented and unit-tested (`src/cert.rs`, against a synthetic cert — -see its test module), but **no box in the currently-reachable demo fleet -happens to be configured that way**, so it hasn't been exercised live. -Whoever configures the *target* station for a real deployment decides -which trust mode applies — this crate needs to support both regardless, -which it does. - -**Operational note for reaching this fleet specifically:** the bare -`macula.io` hostname has an A record but genuinely no AAAA record, while -the station's actual QUIC listener is bound to a specific IPv6 address -with no relationship to that A record — dialing `macula.io:4433` directly -resolves to a real, reachable IPv4 address with nothing listening, and -every packet vanishes silently (indistinguishable from a firewalled port -from the client side alone; confirmed via `ss -ulnp` on the box itself, -not guessed). `station-de-frankfurt.macula.io` is the name that actually -resolves to the listener. Matches the DNS-repoint gotcha already on file -in project memory (`reference_demo_fleet_boxes`) — confirmed still true. - -## 3. Connection lifecycle (state machine) - -From `macula_peering_conn.erl` (`gen_statem`), module doc lines 1-11: - -``` -client: connecting → handshaking → connected → draining → (terminate) -server: awaiting_start → handshaking → connected → draining → (terminate) -``` - -Client-side flow a Rust implementation needs to reproduce: - -1. Dial QUIC to `(host, port)` with ALPN `["macula"]` and the trust mode - from §2. (`do_connect/1`, line 785.) -2. Open one bidirectional stream on the connection (the control stream). -3. Send a **signed CONNECT frame** (§5) on that stream. -4. Start a 30-second handshake timeout (`HANDSHAKE_TIMEOUT_MS`, line 181). - Its most common real-world trigger, per the code comment, is a protocol - version mismatch (bytes accumulate but never form a valid frame) — a - Rust client that gets this wrong will just silently time out, not get - an explicit error frame. -5. Receive and verify the peer's **signed HELLO frame**. If - `accepted := true`, absorb peer info (`node_id`, `station_id`, - `realms`, `capabilities`) and transition to `connected`. If - `accepted := false`, the connection is refused (`refusal_code` present) - — terminate. -6. In `connected`, every frame arriving on the control stream is - length-prefixed CBOR (§4) parsed via `parse_stream/1` and dispatched by - `frame_type`. Every outbound application frame is auto-signed if not - already signed (`ensure_signed/2`, line 771) and written to the same - control stream. Frames may be batched into one write (up to 64 queued - sends coalesced, line 757) — purely a sender-side optimization, no - wire implication. -7. `GOODBYE` (§5) + close, or the peer's own stream/connection closure, - moves to `draining` (5-second grace timeout) then terminates. - -Note for implementers: the actual QUIC "closed" event the Rust NIF *could* -send is never wired up on the Erlang side (dead code, confirmed by -comment at `macula_peering_conn.erl:329`) — what a real disconnect looks -like on the wire is a `stream_closed` or `peer_send_shutdown` condition at -the QUIC-stream level, not a distinct "connection closed" frame. A Rust -client should treat stream-level close/reset the same way. - -## 4. Wire frame codec - -From `macula_frame.erl`, module doc lines 1-28. - -``` -<> -``` - -`Cbor` is the **RFC 8949 §4.2.1 deterministic encoding** of a single CBOR -map. `Length` is the byte length of `Cbor` alone (not including itself). -`MAX_FRAME_BYTES = 0xFFFFFF` (16 MiB) — a frame at or under 4 bytes header -is either read whole or the caller is told how many more bytes are needed -(`decode/1`, lines 1546-1557; three-way return: `{ok, Frame, Rest}`, -`{more, N}`, `{error, Reason}`). - -**⚠ Deterministic/canonical CBOR is load-bearing for correctness, not -just a style choice.** Every frame's Ed25519 signature is computed over -the canonical CBOR bytes of the unsigned frame (§5). If a Rust CBOR -encoder produces different bytes for the same logical map (different key -ordering, non-minimal integer encoding, etc.), signatures will not -verify against station-produced frames and vice versa. - -**RESOLVED — `ciborium` is not involved at all, and that's good news, not -a gap.** `macula_cbor_nif` has two separate code paths -(`native/macula_cbor_nif/src/`): `nif_pack`/`nif_unpack` go through -`ciborium::value::Value` and are genuinely non-deterministic (not what -the wire uses). `pack_deterministic`/`unpack_deterministic` — what -`macula_frame.erl` actually calls — live in a **separate, hand-rolled -encoder** (`deterministic.rs`, 410 lines) that bypasses `ciborium` -entirely and operates directly on `rustler::Term`. Its own doc comment -says it "mirrors `macula_record_cbor.erl` byte-for-byte" and the two are -kept in sync by a differential test -(`test/macula_cbor_deterministic_diff_tests.erl`). This means the exact -canonical algorithm is fully known, small, and directly portable — -nothing to "verify against the RFC," just a mechanical Rust-to-Rust -transcription of an already-correct 200-line core: - -- **Integers:** non-negative → major 0; negative → major 1, encoded value - `-1 - N`. Both use **minimal-length encoding**: inline if ≤23, else the - smallest of 1/2/4/8 extra bytes that fits (AI 24/25/26/27). Range: - positive up to `u64::MAX`; negative down to `-(2^64)` (via `i128` - internally, since plain `i64::MIN` is one bit short). Anything outside - that range is a hard encode error, not silent bignum handling. -- **Binary** → major 2 (byte string), raw bytes, unchanged. -- **`{text, Binary}`** → major 3 (text string), bytes used **as-is, no - UTF-8 validation** (matches the Erlang encoder's own leniency — don't - add validation a Rust port that isn't there in the source). -- **Atom (not `null`)** → major 3, via the atom's own UTF-8 name. This - NIF encodes atoms directly to text on the way out, but **on decode - every major-3 value always comes back as `{text, Binary}`, never a bare - atom** — atom reconstitution (`binary_to_existing_atom`) happens one - layer up, in `macula_frame.erl`'s own `from_wire_envelope/1` (§ above). - A Rust port has no atom-table-exhaustion risk to defend against, so - this two-layer split collapses to nothing: just decode major-3 as a - `String`/`&str` and match it against the fixed vocabulary in §6 - directly. -- **List** → major 4 (array). -- **Tuple**: the **only** encodable tuple shape is `{text, Binary}` — - anything else is a hard encode error. There is no general tuple - encoding. -- **Map** → major 5. Keys are sorted by the **bytewise lexicographic - order of their own already-encoded bytes** (encode each key - independently first, then sort the `(key_bytes, value_bytes)` pairs by - `key_bytes` using plain byte-vector `Ord`, then concatenate). This is - the one rule a naive implementation is most likely to get wrong — - sorting by the *original* key representation instead of its *encoded* - bytes will diverge from station output for keys of different CBOR - major types. -- **`null` (Erlang `undefined`)** → major 7, AI 22 (`0xF6`). -- **Float → ALWAYS binary64** (major 7, AI 27, `0xFB` prefix) on encode, - regardless of whether the value would round-trip in fewer bits. This is - a **deliberate divergence from RFC 8949's own canonical-form - recommendation** (which prefers the shortest float width that - round-trips) — done so the byte derivation is independent of platform - float encoding. A generic "canonical CBOR" crate that follows the RFC's - shortest-float rule instead of this will silently produce - non-matching, non-verifying bytes. Decode accepts binary16/32/64 for - interop, converting all to `f64`. -- **Decode rejects major type 6 (tags) outright** — not supported at all. - Major 7 only supports `null` and the three float widths; no booleans, - no "undefined" simple value, nothing else. Duplicate map keys on decode - are last-write-wins, not an error. -- Every decode path is panic-free by construction (explicit bounds checks - throughout, no `unwrap`/`expect`/panicking slice index) — worth - matching in a Rust port that will also be parsing untrusted - network input. - -Net effect: this open item is closed. Define a small Rust `Value` enum -mirroring these variants (`UInt`, `NegInt`, `Bytes`, `Text`, `List`, -`Map`, `Null`, `Float`) and transcribe `encode_value`/`decode_one` from -`deterministic.rs` directly — no external crate needed for this part at -all. - -**Atom ↔ wire-string mapping** (`to_wire/1` / `from_wire_envelope/1`, -lines 1855-1909): every Erlang atom (frame type names, field names like -`frame_type`, `capabilities`, enum values like `alive`/`suspect`) encodes -as a CBOR text string (major type 3) on the wire — there is no compact -integer tag scheme in the *live* protocol (that was the legacy -`macula_protocol_types.erl` design; dead, see header). A Rust -implementation needs a fixed table mapping each known atom name to/from -its literal string spelling — every such name is enumerated in §6 below. -Erlang's `undefined` maps to CBOR `null` and back. Plain binaries -(signatures, node IDs, payloads, nonces) stay as CBOR byte strings (major -type 2), never text. - -**Signing domains** (Ed25519, `macula_identity:sign/2`): -| Domain separator | Covers | -|---|---| -| `"macula-v2-frame\0"` | Every frame's own `signature` field, over the canonical CBOR of the frame with `signature` and `publisher_sig` stripped (`canonical_unsigned/1`, line 1633). | -| `"macula-v2-swim-update\0"` | Each individual SWIM piggyback update's own `signature`, over the update map minus `signature` (`canonical_swim_update/1`, line 807). | -| `"macula-v2-event-pub\0"` | `publisher_sig` on PUBLISH/EVENT frames — a **separate**, end-to-end signature over just `(topic, realm, publisher, seq, payload)`, independent of frame type, so it survives PUBLISH→EVENT conversion across relay hops (§6.6). | - -Domain separation is deliberate and enforced by construction: a signature -valid under one domain must never be replayable as a signature under -another. - -## 5. Handshake frames (CONNECT / HELLO / GOODBYE) - -`connect_spec()` (`macula_frame.erl:180`): - -| Field | Type | Notes | -|---|---|---| -| `node_id` | 32 bytes | Ed25519 pubkey, the connecting identity | -| `station_id` | 32 bytes | for a plain peer/daemon dial, `send_connect/2` sets this equal to `node_id` | -| `realms` | list of 32-byte pubkeys | realms this identity claims membership in | -| `capabilities` | non-neg integer | bitmask, negotiated in HELLO | -| `puzzle_evidence` | 32 bytes | `SHA-256(node_id)` — see the dedicated callout below. Applies to **every** CONNECT, edge clients included, not just station-to-station peering. | -| `addresses` | optional list of maps | | -| `site` | optional map | | -| `endorsements` | optional list | realm-membership endorsement records (ties into HyParView admission, §6.3) | - -**⚠ The puzzle is an identity property, not a per-connection cost — and -skipping it fails silently.** From `macula_identity.erl` (177 lines) and -`macula_client.erl` (lines 928-952): - -- `puzzle_evidence(Pub)` is just `crypto:hash(sha256, Pub)` — a plain, - deterministic hash of the node's own 32-byte pubkey. No nonce, no - per-connection computation. -- The actual proof-of-work happens **once, at identity creation**: - `macula_identity:generate(#{puzzle => true})` grinds fresh Ed25519 - keypairs (via the `macula_crypto_nif:grind_puzzle/1` NIF) until one's - pubkey hash has at least `N` leading zero bits (S/Kademlia Sybil - defense — mints an identity expensively, not a connection). Default - `N = 8` (`?DEFAULT_PUZZLE_DIFFICULTY`), configurable via - `application:get_env(macula_identity, puzzle_difficulty, 8)`. The code - comment states grinding at the default is sub-millisecond. -- `puzzle_valid(Pub, N)` — the check any station runs — is just - "hash + leading-zero-bit check," equally trivial. -- **Every station checks this on every CONNECT/HELLO, for every kind of - dialer, not only station-to-station peering.** Confirmed directly: - `macula_client.erl` — the ordinary leaf SDK any daemon uses — defaults - to a puzzle-hardened identity specifically because, quoting the source - comment, "this identity is exactly what every station's - `puzzle_enforcement_mode/0` checks on CONNECT/HELLO." -- **Real incident, cited in the source (2026-08-21):** a client connected - with an *unhardened* identity. The QUIC/TLS connection reported - healthy, `subscribe/5` returned `{ok, _}` — and the station silently - rejected the HELLO at the application layer. Result: a link that looked - fully healthy delivered zero events for over an hour before the missing - puzzle was identified as the cause. **A mobile client that skips this - will exhibit exactly that failure mode**, and will be far harder to - diagnose without Erlang-side introspection. Do not skip it, and don't - bury the identity-generation step where a future implementer might - reach for the cheap `generate()` instead of `generate(#{puzzle=>true})`. -- **Empirical caveat, 2026-08-28 — tested directly, not assumed.** Against - the live `macula-station-frankfurt` (`macula-rust`'s - `tests/live_station.rs`), an **unhardened** identity was accepted - (`accepted = true`), not rejected — contradicting the incident above. - `macula-rust`'s own puzzle-evidence computation is independently - verified byte-for-byte against real Erlang `crypto:hash/2` output, so - this isn't a client-side computation bug; it means either this - particular dev-fleet station has enforcement disabled/lenient (it's - documented elsewhere as throwaway dev infra, not production), the - deployed image predates the enforcement described above, or - enforcement is scoped to a condition a plain CONNECT doesn't trigger. - Which one is true is a `macula-station`-side question, not chased here. - **Grind the puzzle regardless** — the cost is negligible and it's - unambiguously the documented, intended behavior; this caveat is a fact - about one dev station's current configuration, not license to skip it. - -**Lifecycle for the mobile port:** grind once — at first run/onboarding, -not per connection — persist the resulting keypair in secure device -storage (Keychain on iOS, Keystore on Android; the Erlang side's analog -is an atomic, 0600-permission local file write via `macula_identity:save/2`), -and reuse that same identity for every subsequent CONNECT. Never re-grind -per connection; `resolve_identity/1` in `macula_client.erl` is written -specifically to avoid that (`maps:get/3`'s default argument evaluates -unconditionally, so a naive lookup-with-default would grind on every -call even when an identity already exists — the source works around this -with an explicit `maps:find/2` check first). - -`hello_spec()` mirrors `connect_spec()` plus `accepted` (bool), -`negotiated_capabilities`, optional `refusal_code`. - -`goodbye(Reason, Detail)`: `reason` (atom, e.g. `normal`/`error`/`timeout`) -+ optional `detail` (binary). - -Every frame carries a common envelope from `base/2` (line 1621): -`version` (currently `2`), `frame_type` (atom), `frame_id` (UUIDv7), -`sent_at_ms`, `capabilities`, plus `realm`/`call_id`/`source_route` set to -`null` unless the specific frame type populates them. - -## 6. Full frame-type catalogue (the "application primitives") - -All from `macula_frame.erl`'s `frame_type()` union (lines 155-174) — -this is authoritative; ignore the differently-named, differently-scoped -message list in the dead `macula_protocol_types.erl`. - -### 6.1 Control -`connect`, `hello`, `goodbye` — §5. - -### 6.2 SWIM membership (`swim_ping`, `swim_ack`, `swim_suspect`, -`swim_confirm`) -Ping/Ack carry `round`, `incarnation`, optional `piggyback` (list of -signed `swim_update` maps: `target`, `state` ∈ -`alive|suspect|confirmed_failed`, `incarnation`, `observed_at`, `by`, -`signature`). Ack additionally carries `responder`. Suspect/Confirm carry -`target`, `target_incarnation`, `suspected_by`, `ttl` (decremented per -rebroadcast). No `swim_ping_req` in the live protocol (present in the -dead legacy catalogue only). - -### 6.3 Kademlia DHT (`ping`, `pong`, `find_node`, `nodes`, `find_value`, -`value`, `store`, `store_ack`, `replicate`, `replicate_ack`) -`ping`/`pong` carry a 16-byte `nonce`. `find_node` carries `key` (32 -bytes), `origin` (32-byte pubkey), `depth`. `nodes` returns a list of -`station_ref()`: `node_id`, `station_id`, `addresses`, `tier` (0-4), -`asn` (optional), `country` (2-byte ISO code), `last_seen_at`. `store`/ -`replicate` carry an opaque `macula_record:m_record()` (encoded -separately by `macula_record:encode/1`, not covered in this pass — needed -before DHT put/get can be ported). - -### 6.4 RPC (`call`, `result`, `error`) -This is the primitive a mobile client most needs early. `call_spec()` -(line 322): `call_id` (16 bytes), `procedure` (binary, e.g. -`"my.app.get_user"`), `realm` (32 bytes), `payload` (arbitrary CBOR-able -term), `deadline_ms`, `caller` (32-byte pubkey), optional -`source_route` (opaque binary, §8), optional `retry_budget`, optional -`ucan_token` (capability token for gated procedures). `result_spec()`: -`call_id`, `payload`, `responded_by`, optional -`source_route_reverse`. `call_error` uses the BOLT#4 taxonomy (§9): -`call_id`, `code` (0-255), `reported_by`, optional `detail`, -`offending_hop`, `source_route_partial`. - -**⚠ `procedure` (and `topic` in §6.8, and `detail` on ERROR/GOODBYE) are -`binary()` on the wire — a raw byte string (CBOR major 2), NOT text -(major 3).** Easy to get backwards, since most other string-ish fields -(`frame_type`, `reason`, `delivered_via`) really are atoms and do encode -as text. Caught by `macula-rust`'s own differential-vector tests: a -hand-built CALL frame using text encoding for `procedure` produced a -completely different (still validly-formed, silently wrong) signature -from the reference — see that crate's `src/frame.rs` for the fix and the -byte-level trace that found it. - -**Live-verified, 2026-08-28** (`macula-rust`'s -`tests/live_station.rs`): a full CALL/RESULT-or-ERROR round trip against -`macula-station-frankfurt` — signed CALL out, signed ERROR back -(`unknown_next_peer`, correctly correlated by `call_id`) for a -deliberately-nonexistent procedure name. - -### 6.5 HyParView membership overlay (`hyparview_join`, -`hyparview_forward_join`, `hyparview_neighbor`, `hyparview_disconnect`, -`hyparview_shuffle`, `hyparview_shuffle_reply`) -JOIN/FORWARD_JOIN/NEIGHBOR each optionally carry a signed -`macula_record:m_record()` admission endorsement — a realm requiring -admission-gated JOIN must reject a JOIN missing it. Likely not needed for -a leaf mobile client connecting to one known station; relevant mainly for -station-to-station overlay membership. - -### 6.6 Plumtree gossip (`plumtree_gossip`, `plumtree_ihave`, -`plumtree_graft`, `plumtree_prune`) -Epidemic broadcast tree primitives, keyed by `realm` + 16-byte `msg_id` + -`round`. Also station-to-station territory, not a leaf client concern for -v1. - -### 6.7 Overlay relay (`overlay_relay`) -Envelope wrapping an already-encoded HyParView/Plumtree frame with an -explicit target `peer`, so a station forwards it to whichever other -connection authenticates as that peer. Opaque `payload`; the relaying -station never decodes it. Not a leaf client concern. - -### 6.8 PubSub (`publish`, `subscribe`, `unsubscribe`, `event`) -The other primitive a mobile client needs early. `publish_spec()`: -`topic`, `realm` (32 bytes), `publisher` (32-byte pubkey), `seq`, -`payload`, `published_at_ms`, optional `ttl_ms`, optional -`publisher_sig` (the separate end-to-end signature, §4). `subscribe`: -`topic`, `realm`, `subscriber`, optional `filter`, optional `options`. -`event` is what a subscriber actually receives: same shape as `publish` -plus `delivered_via` ∈ `plumtree|dht|direct`. A relay station copies -`publisher_sig` verbatim from PUBLISH onto the EVENT(s) it fans out, so a -receiving mobile client can verify authenticity against the *original -publisher*, independent of which station relayed it. - -**Live-verified, 2026-08-28** (`macula-rust`'s -`tests/live_station.rs`): SUBSCRIBE → PUBLISH → EVENT against -`macula-station-frankfurt`, answering a question the spec had left open -— **yes, a subscriber receives its own publish** (`delivered_via = -"direct"`), essentially instantly on this fleet. Not guaranteed to -generalize to every delivery path (`plumtree`/`dht` weren't exercised), -but confirms the direct case works end-to-end, wire format included. - -### 6.9 RPC advertise (`advertise`, `unadvertise`) — frame types BUILT 2026-08-28 -A peer registers itself as the handler for `procedure` under `realm` on -its own connection; the station routes inbound CALLs for that procedure -back over that connection. Tombstoned on UNADVERTISE or disconnect. -Needed if a mobile client wants to *expose* an RPC procedure, not just -call one. Frame construction (`src/frame.rs`'s `AdvertiseSpec`/ -`UnadvertiseSpec`) is built and byte-verified; `Session::advertise`/ -`unadvertise` (`src/connection.rs`) send them. Consumed today by the -streaming provider role (§13.2, live-verified) — unary CALL routing to -an advertised procedure (accepting an inbound CALL on the control stream -and replying with RESULT/ERROR, mirroring -`macula_station_link.erl`'s `handle_inbound_call`) is not built yet; -nothing in this crate has needed to serve unary RPC so far, only -streams. - -### 6.10 Streaming RPC (`stream_open`, `stream_data`, `stream_end`, -`stream_error`, `stream_reply`) -`stream_open` mirrors `call`'s auth/routing shape (`deadline_ms`, -`caller`, `source_route`) plus `stream_id` (16 bytes) and `mode` ∈ -`server_stream|client_stream|bidi`. Runs on its own dedicated QUIC -stream, not the control stream — see §7. `stream_data` carries `seq` + -`body` with `encoding` ∈ `raw|msgpack`. `stream_end`'s `role` ∈ -`send|both` (half-close vs full close). Non-OPEN stream frames may carry -an optional `signer` pubkey so a relaying station (not just the -originating daemon) can be authenticated per-hop. See §13 for the full -client-side usage pattern (caller and provider roles) on top of these -frames. - -**Correction, 2026-08-29 — this crate didn't actually stamp `signer` -until today, despite this section documenting it correctly all along.** -`src/frame.rs`'s `StreamDataSpec`/`StreamEndSpec`/`StreamErrorSpec` -never carried the field, and every real call site -(`StreamHandle::send_data`/`close_send`/`abort`) never supplied it — -found live: a cross-station stream (provider on -`station-de-frankfurt.macula.io`, caller on `station-it-milan.macula.io`) -opened correctly but silently never delivered a single DATA frame, -because `macula_station_peer_observer.erl`'s multi-hop verify falls back -to "the connection this frame arrived on" when `signer` is absent — fine -for the direct client→first-station edge, wrong at any station→station -hop after that. Fixed: the three specs gained `signer: Option<[u8; 32]>` -and every real call site now always supplies -`Some(identity.public_bytes())`. Confirmed live, both directions, after -the fix (`tests/live_station.rs`'s -`cross_station_streaming_round_trip_frankfurt_provider_milan_caller`). - -**Correction, 2026-08-28 — the `msgpack` encoding is not a second wire -codec.** An earlier draft of this section (quoted above in the original -form for the record) read `encoding`'s `msgpack` value as meaning -`stream_data`'s `body` is pre-serialized through a real MessagePack -codec, distinct from the frame envelope's own CBOR — implying a Rust -port would need an `rmp-serde` dependency. Verified directly against -`macula-io/macula` v10.10.0 and it's wrong: `msgpack` was **removed from -macula's own dependencies in v3.0.0** (`rebar.config`'s own comment: -"wire protocol switched to CBOR"); the one remaining `msgpack:pack` call -in the entire repo is in an unrelated legacy DHT test, never on the -`stream_data` path. Built a real `stream_data` frame with -`encoding = msgpack` and a structured Erlang map as `body` in a live -`rebar3 shell`, round-tripped it through `macula_frame:encode/1` + -`decode/1`, and got the map straight back — `body` is embedded as an -ordinary nested value in the frame's own canonical-CBOR envelope, same -as CALL's `payload`. `encoding` is purely a semantic hint for the -receiver; **no second codec, no `rmp-serde` dependency needed.** -Confirmed at the crate level too: -`stream_data_msgpack_frame_matches_the_reference_byte_for_byte` (Rust -crate `macula-rust`, `src/frame.rs`) matches the reference's -signature byte-for-byte with exactly this shape. - -### 6.11 Content transfer (`want`, `have`, `block`, `manifest_req`, -`manifest_res`, `cancel`) -Bitswap-style block exchange keyed by 34-byte MCID -(`<>`). - -**Correction, 2026-08-28: these frame types are very likely -station-to-station only, not something a mobile client needs at all.** -A full read of the client-side content-sharing stack -(`macula_content_transfer.erl`, `macula_upload.erl`, `macula_download.erl`, -`macula_pusher.erl`, `macula_manifest.erl`) found **zero references** to -`want`/`have`/`block`/`manifest_req`/`manifest_res`/`cancel` anywhere in -the client SDK. The client-facing content API uses ordinary `call`/ -`result` (§6.4) against well-known `_content.*` procedure names instead -— see §12. These frame types are plausible station-to-station DHT -replication/gossip primitives (matching `macula/CLAUDE.md`'s listing of -"content" as a Relay, not SDK, concern), not part of what a client speaks. -Not fully confirmed (would need to read macula-station's own source to -be certain), but strong enough evidence to deprioritize this section -entirely for a mobile client — see §12 for the actual mechanism to build. - -## 7. Stream model - -- **Control stream:** one bidirectional QUIC stream, opened by the client - right after the handshake starts, carries CONNECT/HELLO/GOODBYE and - every non-streaming application frame in both directions for the life - of the connection. -- **Dedicated streams:** opened per streaming-RPC session - (`stream_open`/…) or per content-transfer session. Either side can open - one; the receiving side has no advance notice of *why* — it reads the - new stream's own first frame to learn its purpose - (`macula_peering_conn.erl:565`, comment block explains a real - production race here: the peer must be notified of the new stream - *before* the NIF is told to start delivering data on it, or fast/local - peers can lose the opening bytes — worth replicating this ordering - exactly in a Rust client that also accepts inbound dedicated streams). - -## 8. Source-route header (binary, not CBOR) - -Used inside the opaque `source_route`/`source_route_reverse`/ -`source_route_partial` fields of `call`/`stream_open`/`result`/`error`. -Fully specified, fixed binary layout -(`macula_source_route.erl`, doc lines 1-37): - -``` -offset size field -0 1 version (currently 1) -1 1 total_hops (1..8) -2 1 current_hop -3 8 deadline (unsigned, big-endian, absolute Unix ms) -11 16 path_hash — first 16 bytes of SHA-256(concat(hops)) -27 16×N hops[0..N-1] — each the first 16 bytes of the hop's NodeId -``` - -Fixed overhead 27 bytes; max size (8 hops) 155 bytes. `path_hash` is -verified on every decode — a mismatch is a hard reject -(`path_hash_mismatch`), not a warning. For a mobile client making a -*direct* call to one known station (no multi-hop routing requested), -this field is simply empty/absent; it only needs implementing when the -client wants to request or interpret explicit multi-hop routing. - -## 9. BOLT#4 error taxonomy - -`macula_bolt4.erl`, 17 entries (0x00-0x10), adapted from Lightning -Network's onion-failure codes. Each `call_error` frame carries one of -these as `code`, plus the reporting station's own frame signature so a -downstream hop can't forge "not my fault." Full table (code, name, advisory -retry policy): - -| Code | Name | Retry policy | -|---|---|---| -| 0x00 | `ok` | none | -| 0x01 | `unknown_next_peer` | different_path | -| 0x02 | `temporary_relay_failure` | same_path_after_backoff | -| 0x03 | `relay_disabled` | different_path | -| 0x04 | `node_not_found_at_target_relay` | caller_recompute_with_lookup | -| 0x05 | `target_realm_refused` | application | -| 0x06 | `loop_detected` | caller_recompute | -| 0x07 | `expiry_too_soon` | caller_extends_deadline | -| 0x08 | `upstream_congestion` | exponential_backoff | -| 0x09 | `invalid_path_header` | caller_recompute | -| 0x0A | `crypto_puzzle_invalid` | crypto_drop | -| 0x0B | `realm_not_authoritative_here` | caller_recompute_with_lookup | -| 0x0C | `tombstoned` | application | -| 0x0D | `payload_too_large` | application | -| 0x0E | `signature_invalid` | crypto_drop | -| 0x0F | `unknown_error` | log_and_caution | -| 0x10 | `unauthorized` | application (missing/invalid UCAN on a gated procedure) | - -`none`, `application`, and `crypto_drop` are the three non-retryable -policies; everything else means "retry, differently." - -## 10. Rust crate reuse — what's already there vs. what needs writing - -Confirmed from the NIF `Cargo.toml`s in `native/*`, as observed at this -project's inception (2026-08-28) — this table describes `macula-io/macula`'s -own native NIFs, not this crate. `macula-rust`'s own `Cargo.toml` is the only -authoritative source for its current dependency versions, and has since -diverged from some of the rows below via its own dependency-refresh passes -(e.g. `ed25519-dalek` 3.0, `rcgen` 0.14 as a dev-only dependency here vs. -`native/macula_quic`'s still-current production `rcgen 0.13` pin) — nothing -wrong with that divergence (this crate's `rcgen` only ever generates -synthetic test certs, never anything the Erlang side parses or verifies), -just don't read this table as this crate's current manifest. - -| Concern | Existing Rust crate (already a macula dependency) | Reuse directly? | -|---|---|---| -| QUIC engine | `quinn` 0.11 | Yes | -| TLS | `rustls` 0.23 (`ring` backend) | Yes | -| Self-signed cert generation | `rcgen` 0.13 | Yes | -| Pubkey-pin verifier | (hand-written, ~150 lines in `cert.rs`) | Port near-verbatim | -| Ed25519 sign/verify | `ed25519-dalek` 2.1 | Yes | -| Puzzle grind/verify | (hand-written, `macula_crypto_nif::grind_puzzle`) | Trivial to reimplement: generate Ed25519 keypairs, SHA-256 the pubkey, check leading zero bits, repeat. No need to read the NIF source — the algorithm is fully specified from the Erlang side (§5). | -| Hashing | `blake3` 1.5 (where BLAKE3 is used; source-route hop-hash uses SHA-256 via Erlang's `crypto:hash/2`, a separate primitive — confirm which Rust crate covers that path, likely `sha2`) | Yes for BLAKE3 uses | -| CBOR (deterministic wire codec) | none — hand-rolled in `native/macula_cbor_nif/src/deterministic.rs` (410 lines) | Transcribe directly, algorithm fully known (§4). `ciborium` is a *different*, non-deterministic code path in the same NIF crate and is irrelevant to the wire format. | -| MRI parsing | (custom, `macula_mri_nif`) | Study before porting | -| DID/UCAN | `ed25519-dalek`-based (`macula_did_nif`/`macula_ucan_nif`) | Study before porting | - -What still needs writing in Rust, with no existing crate to lean on: the -frame envelope + atom-vocabulary table (§4, §6), the connection state -machine (§3), the source-route codec (§8, trivial — ~60 lines given the -fixed layout above), and the puzzle-evidence handshake field (unresolved, -§5). - -## 11. Open items before any Rust code gets written - -1. ~~Canonical CBOR verification.~~ **RESOLVED, 2026-08-28** — see §4. - `ciborium` was never the actual wire codec; the real algorithm is - hand-rolled, fully traced, and directly portable. -2. ~~`puzzle_evidence`.~~ **RESOLVED, 2026-08-28** — see §5's callout. - One-time keypair grinding at identity creation (sub-millisecond at - default difficulty), a trivial deterministic hash on every CONNECT - after that, and confirmed to apply to every dialer, edge clients - included — not a station-to-station-only concern. -3. **`macula_record:encode/1`.** Needed for DHT `store`/`replicate`/ - `value` frames (record payloads are encoded by a separate codec, not - `macula_frame`'s own `to_wire/1`). Not needed for CALL/PUBLISH-only v1. -4. ~~Iroh raw-dial capability.~~ **DECIDED, 2026-08-28 — not pursuing - Iroh.** Reassessed against what's now confirmed about macula's actual - architecture: edges are dial-out only (no NAT-traversal/relay gap for - Iroh to fill), and macula already owns its own discovery (Kademlia - DHT), gossip (Plumtree/HyParView), pubsub, and pubkey-pinned identity - — all things Iroh would otherwise bring, and all things that would - *compete* with macula's own stack rather than complement it if - adopted. The one real remaining candidate, QUIC connection migration - for WiFi↔cellular handover, is a baseline feature of QUIC itself - (RFC 9000 Connection IDs) that `quinn` — already a macula dependency — - should already support; any extra mobile-specific network-change - detection glue can be hand-written directly against `quinn` later if - real-world testing shows it's needed, without adopting Iroh's whole - addressing/discovery/gossip stack to get it. The one genuinely useful - thing Iroh demonstrated — shipping a Rust core to iOS/Android via - UniFFI — doesn't require Iroh either: UniFFI is a separate, - general-purpose Mozilla tool this crate can depend on directly. -5. **v1 scope decision — superseded, 2026-08-28.** The original cut - deferred streaming RPC and content transfer wholesale. Both have now - been fully traced (§12-§13) and turn out to be cheap additions, not - separate protocols: content sharing's core path reuses `call`/`result` - (§6.4) verbatim, and push-upload reuses streaming RPC (§6.10) - verbatim. Revised v1 cut: transport + handshake (§2-§5) + - `call`/`result`/`error` (§6.4) + `publish`/`subscribe`/`event` (§6.8) - + `advertise`/`unadvertise` (§6.9) + streaming RPC caller role (§13.1) - + content get/put, single-block and chunked (§12), still deferring - DHT, HyParView, Plumtree, and the streaming/content *provider* roles - (§13.2) as v2. See §12-§13 for what's now specced and what's still - open within them. - -## 12. Content sharing (upload/download) — client-side mechanism - -Traced in full from `macula_content_transfer.erl` (799 lines — the core), -`macula_manifest.erl` (268 — chunking/hashing, §4's "Rust crate reuse" -table already covers reuse), `macula_upload.erl` (299), `macula_pusher.erl` -(308), `macula_download.erl` (270). `macula_feeder.erl` (278, -`macula_content_transfer_registry.erl`, 81, and the trivial `*_sup.erl` -files) were not read in full — their role is inferable from what their -callers/siblings already show (see §12.3), and none of it is wire-level. - -**Headline finding: this is not a separate wire protocol.** Content -put/get is ordinary `call`/`result` (§6.4) against four well-known -procedure names, sent over a *dedicated* QUIC stream (§7) instead of the -control stream. Push-upload (§12.3) is ordinary streaming RPC (§6.10), -full stop. Nothing here needs new frame types — §6.11's `want`/`have`/ -`block` frames appear to be unused by the client entirely (see the -correction there). - -### 12.1 The four procedures - -All calls use realm `<<0:256>>` (32 zero bytes — a reserved sentinel for -content operations, distinct from any real realm), and run over a -dedicated stream opened via the same "dedicated stream" mechanism as -streaming RPC (§7), not the control stream. - -| Procedure | Payload (call) | Reply (result) | -|---|---|---| -| `_content.put_block` | `#{mcid, payload}` — `payload` is the raw chunk bytes | `ok` \| `hash_mismatch` | -| `_content.get_block` | `#{mcid}` | raw binary (the block) \| `not_found` | -| `_content.put_manifest` | `#{manifest}` — the full manifest map (§4's crate-reuse table, chunking algorithm) | `ok` | -| `_content.get_manifest` | `#{mcid}` | the manifest map \| `not_found` | - -**Implemented + live-verified 2026-08-28** (`src/manifest.rs`, `src/content.rs`, -Rust crate `macula-rust`). Two things worth recording that weren't obvious -from reading the Erlang alone: - -- **`name`'s wire representation depends on which computation you're in.** - `macula_manifest`'s canonical MCID hash input wraps `name` as CBOR *text* - (`compute_mcid`'s own narrow special case), but the manifest map as actually - sent in a `_content.put_manifest` call payload (`to_wire`) encodes `name` as - a raw *byte string* — its real `binary()` type. Confirmed by encoding a real - manifest through the general deterministic-CBOR codec and inspecting the - bytes, not inferred from the type spec (the CALL `procedure`/PUBLISH `topic` - lesson elsewhere in this doc was exactly this kind of inference trap). -- **v1 client implementation is deliberately sequential, not multi-lane.** The - Rust crate opens exactly one dedicated stream per `put`/`get` call and runs - every `_content.*` call on it in order — no round-robin lanes. This is a - documented simplification, not a wire deviation: every call, the MCID - scheme, and the manifest format are identical either way, so a sequential - client interoperates fully with a station built to serve a parallel-lane - peer. Multi-lane parallelism is purely a throughput optimization, addable - later with zero wire change. Confirmed live: both a 4096-byte single-block - round trip and a ~536KB (3-chunk) round trip succeeded first try against - `station-de-frankfurt.macula.io`, including a `not_found` probe against a - made-up MCID. - -**MCID for a single block** is computed client-side before the call: -`<<1, 0x55, blake3(bytes)>>` (`macula_content_transfer.erl:put_single_block/3`). -**Always re-verify a fetched block's hash client-side against its MCID**, -even though the station verified it at put time — you may be fetching -from a station that only relayed it, not the one that stored it, so its -answer isn't inherently trustworthy. `verify_block_hash/2` is the -reference: recompute `blake3(bytes)`, compare to the MCID's embedded -hash. - -### 12.2 Single-block vs. chunked - -Determined without any network round trip: -- **Put:** chunked iff `byte_size(Bytes) > 262144` (256 KiB, `macula_manifest:default_chunk_size/0`). -- **Get:** chunked iff the MCID's codec byte is `0x56` (`CODEC_MANIFEST`); single-block iff `0x55` (`CODEC_RAW`). - -**Single-block** is one dedicated stream, one CALL/RESULT round trip. -Nothing more to it. - -**Chunked** runs a "multi-stream lanes" algorithm -(`macula_content_transfer.erl` lines 539-765): -- **Put:** the manifest is computed entirely locally - (`macula_manifest:create/1`, pure, no network) — chunks and their MCIDs - are all known upfront. Chunks are distributed round-robin - (`index rem stream_count`) across up to `stream_count` dedicated - streams (default 4, capped at the actual chunk count — a 2-chunk - transfer never opens more than 2). Each stream ("lane") runs its own - independent sequential queue: one `_content.put_block` in flight at a - time per lane, next chunk starts only once the current one's - CALL/RESULT completes. Once every lane's queue is empty, fire one - final `_content.put_manifest` on the primal stream (the first stream - opened) to register it. -- **Get:** the manifest is unknown upfront, so it's fetched first — one - `_content.get_manifest` call on the single stream the initial connect - opened. Once it's back (with `chunk_count`), lanes are set up the same - way, this time distributing chunk *indices* rather than bytes. Each - lane sequentially `_content.get_block`s its assigned indices, - accumulating results into a map keyed by index (lanes finish in - whatever order their own network calls complete, not necessarily - index order). Once every lane is done, reassemble bytes in index order - and verify against the manifest's root hash via `macula_manifest:verify/2` - — this final step is pure, no network. -- **Extra streams are cheap to open** (a local QUIC operation on an - already-live connection — allocate a stream id, no peer round trip) - and opening one is allowed to fail without failing the transfer: it - just degrades to fewer lanes. -- **Retry:** each `_content.*` call is retried up to 3 times (200 ms - backoff) if the BOLT#4 error code it failed with is itself flagged - retryable (§9's table) — directly reuses the taxonomy already specced, - no separate retry policy to invent. -- **Cancel is QUIC-level, not application-level:** resets every - currently-open lane stream via QUIC `RESET_STREAM` - (`macula_station_link:abort_content_stream/4`), **not** a - `stream_error` application frame — a genuinely different abort - mechanism from general streaming RPC (§13), because a content-transfer - stream is a raw dedicated QUIC stream, not a `macula_stream`-managed - one. Don't conflate the two when porting. -- **Pause/resume** (chunked only): gates whether a lane starts its *next* - queued item; whatever's already in flight always finishes; resume - continues each lane from wherever it left off. A reasonable thing to - skip for a first mobile port — v1 can always run to completion or - cancel outright. - -### 12.3 Two ways content moves: pull vs. push - -**Pull (`macula_download`/`macula_feeder`):** fetch by an already-known -MCID, or announce content into the mesh for others to discover and pull -later (`macula_feeder`, not read in full — inferred role from -`macula_download`'s module doc: a provider's station auto-publishes a -signed `content_announcement` DHT record on receipt, so there's nothing -to explicitly advertise on the feeder side, unlike RPC procedures). -Trust model is deliberately lighter than RPC's direct-dial path — content -is self-verifying by hash, so `macula_download`'s direct-dial fetch can -use `pin_tls_cert => false, verify => none` for the QUIC dial itself and -still be safe, because §12.1's client-side hash re-verification is what -actually protects the caller, not the station's identity. - -**Push (`macula_pusher`/`macula_upload`):** actively sends bytes AT a -specific, already-known recipient advertising an upload procedure — -**and this path uses zero content-transfer machinery at all.** It's -`client_stream`-mode streaming RPC (§6.10/§13), full stop: the manifest -(`macula_manifest:create/2`) rides as `stream_open`'s `args`, each chunk -is one `stream_data` frame sent in order over the ONE stream (no -multi-lane parallelism — that's explicitly a content-transfer-only -mechanism, per `macula_pusher.erl`'s own doc comment correcting an -earlier draft of the plan that claimed otherwise), `close_send` half-closes, -and the terminal `stream_reply` (§6.10) carries the receiver's verified -`{ok, Mcid}` or `{error, Reason}` — the receiver (`macula_upload`) -reassembles and verifies against the manifest before ever setting that -reply, so a caller blocking on it knows the bytes actually arrived -intact, not merely that local `send/2,3` calls returned `ok`. - -**For a mobile client:** push is the better fit for "upload a photo to a -known destination" (simpler, reuses §13's already-specced streaming -primitive, no new mechanism). Pull is the better fit for "fetch a piece -of content by its content-address" (§12.1-§12.2). Both are worth having; -neither requires touching §6.11's frames. - -## 13. General-purpose streaming RPC — client-side mechanism - -Traced in full from `macula_stream.erl` (581 lines — the per-stream wire -state machine), `macula_streamer.erl` (452 — provider/server role), -`macula_stream_sink.erl` (253 — caller/consumer role). Not read: -`macula_stream_local.erl` (195, an in-process test-only carrier, not -wire-relevant) and `macula_streamer_sup.erl` (33, trivial supervisor -boilerplate). - -### 13.1 Caller (consumer) role — the one a mobile client mostly wants - -Pattern, from `macula_stream_sink.erl`: - -1. `call_stream(Pool, Realm, Procedure, Args, Opts)` sends `stream_open` - (§6.10) and returns a stream handle once opened. `Opts` selects - `mode` (`server_stream` for "the provider pushes chunks at me," - `client_stream` for "I push chunks at the provider" — see §12.3's push - path for that mode in practice, `bidi` for both directions). -2. Drive a receive loop: `recv/2` blocks for the next `stream_data` - frame, decoded per its own `encoding` field (`raw` → bytes, `msgpack` - → a structured value — **not** a second wire codec, see §6.10/§13.3's - correction: `body` is an ordinary nested value in the frame's own - CBOR envelope either way). Loop until `eof` (peer sent `stream_end`) - or `{error, Reason}` (peer sent `stream_error`, or the underlying - connection died). -3. For `client_stream`/`bidi` modes wanting a result: `send/2,3` each - chunk in order (`stream_data`), `close_send/1` when done - (`stream_end` with `role => send`), then `await_reply/1,2` blocks for - the provider's terminal `stream_reply`. -4. **Non-normal termination must send an explicit abort, not just drop - the connection.** `macula_stream_sink.erl`'s own rule: a clean stop - (eof reached, or the consumer's own callback choosing to stop - cleanly) closes both sides normally; anything else (a `recv` error, a - crash, a non-normal stop) sends the peer an explicit `stream_error` - abort — so the other side learns this was a cancellation/failure - rather than mistaking a dropped connection for a clean end-of-stream. - Worth replicating exactly: the distinction is the only signal the - peer gets. - -### 13.2 Provider (server) role — BUILT + LIVE-VERIFIED 2026-08-28 - -Pattern, from `macula_streamer.erl` and `macula_station_link.erl` -(`handle_inbound_stream_open`, `dispatch_dedicated_frame`, -`macula_peering_conn.erl`'s inbound-`new_dedicated_stream` handoff): -`advertise_stream/5` registers a handler invoked per inbound -`stream_open`; the module drives `recv/2` on the provider's own stream -for `client_stream`-mode procedures (mirroring §13.1's loop, just on the -other end) and exposes `send/2,3`/`close/1` for `server_stream`-mode -ones to push with. Same non-normal-termination → explicit abort rule as -§13.1, symmetric. **Wire mechanics, confirmed from source before any -Rust was written:** an inbound `stream_open` for an advertised procedure -arrives as the first frame on a *fresh dedicated QUIC stream the station -opens toward the advertiser* — the advertiser has no other notice it's -coming; ADVERTISE itself (§6.9) flows on the shared control stream and -is the SAME wire frame whether registering for unary CALL routing or -streaming. - -**Rust port (`src/frame.rs`'s `parse_stream_open`, `src/connection.rs`'s -`Session::advertise`/`accept_dedicated_stream`, `src/stream.rs`'s -`StreamHandle::accept`/`send_reply`) built and live-verified same day** -against `station-de-frankfurt.macula.io`: two independent connections, -one advertises and accepts an inbound stream, the other dials in and -pushes/pulls data — the station really does open a fresh dedicated -stream toward the advertiser and route the caller's `stream_open` onto -it, exactly as the Erlang source says. First time this crate has been on -the *receiving* end of a mesh interaction it didn't initiate. - -The Erlang reference's own inbound-stream handoff has a documented race -(`macula_peering_conn.erl`'s "notify before enabling active mode" — a -fast/local peer's first bytes can arrive before the owning Erlang process -even knows the stream exists, because the `quicer` NIF's stream -resources start passive). **Confirmed this doesn't apply to the Rust -port:** `quinn`/QUIC buffers inbound stream data at the transport layer -regardless of when the application calls `accept_bi()`/starts reading, -so there's no analogous "arm before read" step needed here — the race is -specific to macula-station's own NIF architecture, not a general QUIC -property. - -### 13.3 Wire-level notes that apply to both roles - -- One dedicated QUIC stream per streaming-RPC session (§7), never the - control stream. -- `stream_data`'s `encoding` field: `raw` (bytes as-is) or `msgpack` - (a structured value). **Corrected 2026-08-28, see §6.10 above: this is - NOT a second serialization format.** msgpack was removed from macula's - own dependencies in v3.0.0; `body` for `encoding = msgpack` is embedded - directly as an ordinary nested value in the frame's own deterministic - CBOR envelope (§4), verified by round-tripping a real frame through - `macula_frame:encode/1`/`decode/1`. No msgpack codec (`rmp-serde` or - otherwise) is needed in a Rust port — a plain `Value` covers both - `encoding` variants. -- Sequencing: `seq_out`/`seq_in` counters per direction, tracked - independently — not used for reordering (frames arrive in order on a - single QUIC stream by construction) but as a sanity/debugging signal. -- `handle_down`/owner-death semantics (`macula_stream.erl`) matter less - for a Rust port — that's Erlang-process-monitor plumbing with no wire - equivalent; the wire-relevant rule is just "stream owner gone ⇒ close - or abort the stream," which any reasonable async-Rust structured- - concurrency approach gets for free. - -### 13.4 Forward-compatibility note: live/unbounded streaming (2026-08-28) - -**Confirmed gap, out of scope here, but worth designing around.** A -concrete real-world case (`hecate-tube` / macula-portal's "Macula TV") was -checked directly: its ingest path is plain HTTP upload to a conventional -web server (mesh not involved at all — confirmed in -`maybe_upload_video_clip.erl`), and its *playback* path is `server_stream` -streaming RPC reading an **already-complete file** off local disk -(`stream_video_clip_by_id.erl`, whose own comment states "the mesh -Content primitive is never the video-bytes path"). Neither is "capture -device pushes a live, unbounded feed into the mesh as it happens." Nothing -in the ecosystem does that today, on either the client or the receiving -side. - -The wire primitive is not the gap: `client_stream`-mode `stream_open` with -no manifest, followed by `stream_data` frames pushed continuously with no -predetermined end, is already valid against everything in §13.1 — a -producer just never knows total length upfront and that's fine, nothing -in the frame format requires it. The gap is entirely a missing *receiving -service* (something implementing §13.2's provider role in `client_stream` -mode, doing something useful with each chunk as it arrives — re-publish -live, buffer into a rolling window, hand off to a segmenter) — that's SDK/ -station-side application design, explicitly out of scope for this repo and -not something to build now. - -**What this means for this crate's design, without building the receiving -side:** don't route a live/unbounded producer through the same API shape -as §12.3's bounded push-upload (which computes a manifest from the full -byte count upfront — structurally wrong for "still recording, unknown -duration"). Expose live `client_stream` publishing as its own API surface -— open, push chunks as captured, close when done — separate from the -manifest-based upload path, so the day a receiving procedure exists on the -SDK/station side, this crate points at a new procedure name with no -protocol-level rework. A seam, not a feature. - -## 14. UniFFI mobile bindings — crate architecture, started 2026-08-28 - -**Every application primitive wrapped, same day.** A separate crate, `macula-rust-ffi`, -depending on the core `macula-rust` crate via a path dependency — -structurally identical to `iroh-ffi`'s relationship to `iroh`, confirmed -by reading the live `n0-computer/iroh-ffi` repo directly rather than -assuming: same crate separation, same modern UniFFI proc-macro style -(`uniffi::setup_scaffolding!()`, `#[uniffi::export]`, `#[derive(uniffi:: -Object/Enum/Error)]`) instead of the older `.udl`-file approach, same -`tokio` async-runtime feature (native async support, no callback/blocking -rewrite needed since this crate is already tokio-based throughout), same -`crate-type = ["staticlib", "cdylib"]` plus a `uniffi-bindgen` binary -target for codegen. - -**Why a separate crate, not code inside the core one:** this is what -keeps `macula-rust` itself exactly as usable from plain Rust, a CLI, -or WASM as it was before — zero UniFFI dependency, zero FFI-shaped types, -in the core crate. The doc comment at the top of `src/lib.rs` -("Mobile... is the flagship consumer driving this work, not the ceiling -on it") is enforced structurally by this separation, not just stated. - -**What's exposed — every application primitive the core crate has:** -- `FfiKeyPair` — identity generation, `node_id()`. -- `FfiSession` — `connect` (CONNECT/HELLO), `call` (CALL/RESULT/ERROR), - `publish`/`subscribe`/`unsubscribe`/`recv_event` (§6.8), `content_put`/ - `content_get` (§12), `stream_open` (§13.1, returns an `FfiStream`), - `advertise`/`unadvertise` (§6.9), `accept_stream` (§13.2, blocks for - the next inbound STREAM_OPEN, returns an `FfiAcceptedStream`), `close`. -- `FfiStream` — `send_data`/`close_send`/`recv`/`await_reply`/`abort` - (caller role, §13.1) plus `send_reply` (provider role, §13.2) — the - same object serves either role, since a stream's wire vocabulary is - symmetric regardless of which side opened it (mirrors `StreamHandle` - exactly). -- `FfiValue` (0.2.0) — a mirror of `cbor::Value`: `Null`/`Int`/`Bytes`/ - `Text`/`Float`/`Items`/`Fields`, the last two recursing through `Vec` - for `cbor::Value`'s own `List`/`Map`. Named `Items`/`Fields` rather - than `List`/`Map`: UniFFI's Kotlin codegen emits an unqualified - `List`/`Map` field type for a `Vec`/dictionary-shaped variant, - which resolves to the sibling variant class of the same name inside - `FfiValue`'s own sealed class body, not `kotlin.collections.List` — - confirmed by compiling the generated bindings before the rename. - `Fields` uses a dedicated `FfiMapEntry{key, value}` record rather than - `HashMap`, since `cbor::Value::Map`'s own keys are - arbitrary values, not just text. `Int` is narrowed from `i128` to - `i64` (UniFFI has no 128-bit integer type; an out-of-range value - returns an explicit `FfiError::UnrepresentableValue` rather than - silently truncating). -- `FfiCallResponse`, `FfiEvent`, `FfiStreamItem`, `FfiStreamReply`, - `FfiStreamOpenInfo`, `FfiAcceptedStream` — mirror - `frame::CallResponse`/`frame::EventInfo`/`stream::StreamItem`/the - `(payload, responded_by)` pair `StreamHandle::await_reply` returns/ - `frame::StreamOpenInfo`/the `(StreamHandle, StreamOpenInfo)` pair - `StreamHandle::accept` returns. `FfiAcceptedStream` embeds an - `Arc` directly as a record field — confirmed UniFFI 0.32 - supports an Object handle inside a Record, generating correctly in - both languages (Kotlin's version even picks up `Disposable` - automatically). `publish`'s `seq`/`published_at_ms` stay - caller-supplied rather than tracked internally by `FfiSession` (unlike - streaming RPC's per-stream `seq_out` counter): PUBLISH's `seq` is a - per-publisher, per-topic gap-detection sequence, and a client - publishing to several topics has to own that bookkeeping itself. - -**`accept_stream` holds the session's lock for as long as it waits** — -no other `FfiSession` method can run concurrently during that wait. Not -an FFI-layer restriction: the core crate's own `Session` has the same -property, since its control stream is single-owner by construction. - -**Not wrapped, and won't be until the core crate has it:** unary-RPC -provider dispatch (accepting an inbound CALL on the control stream and -replying — the core crate doesn't implement that role either, only -streaming's provider side needed it so far) and pubkey-pinned trust -(`connect` always uses WebPki — the core crate's `Trust::Pinned` exists -but isn't surfaced here yet). - -**Verified past "it compiles":** built the release `cdylib` and actually -ran `uniffi-bindgen generate` for both Kotlin and Swift, then inspected -the *generated source* — not just the build exit code — for the expected -async surface: Kotlin's `suspend fun connect(...)`/`suspend fun call(...)` -(proper coroutine integration, `AutoCloseable` object handles), Swift's -`static func connect(...) async throws -> FfiSession`/`func call(...) -async throws -> FfiCallResponse` (`Sendable` conformance, `Data` for byte -arrays). CI gained a `ffi-bindings` job that rebuilds the `cdylib` and -regenerates both languages on every push, as a codegen smoke test — it -doesn't (and, without a macOS/Android runner, can't) compile the -generated Kotlin/Swift against the real platform SDKs; that's the next -gap once actual mobile app integration starts. - -**Also fixed while wiring this up:** the existing CI workflow's -`clippy`/`test`/`doc` jobs were missing `--workspace` — Cargo's default -behavior for a workspace root that is *also* a package member is to -operate on just that root package unless `--workspace` is passed -explicitly, so before this fix those three jobs were silently never -touching the new crate at all (only `fmt --all` already covered it, -since `--all` is fmt's own workspace flag, spelled differently from the -others for historical reasons). Caught by directly comparing `cargo test` -vs `cargo test --workspace`'s own `Running` output, not assumed. diff --git a/scripts/build-teststation.sh b/scripts/build-teststation.sh new file mode 100755 index 0000000..e7a0a4d --- /dev/null +++ b/scripts/build-teststation.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Builds the in-process macula 12 test stations the integration tests dial +# (tests/teststation, macula-go's teststation) to target/teststation. cargo +# test does not build it: run this first, as CI does. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +export ASDF_GOLANG_VERSION="${ASDF_GOLANG_VERSION:-1.27.0}" +mkdir -p "$ROOT/target" +cd "$ROOT/tests/teststation" +go build -trimpath -o "$ROOT/target/teststation" . +echo "built $ROOT/target/teststation" diff --git a/scripts/cross-verify-macula.sh b/scripts/cross-verify-macula.sh new file mode 100755 index 0000000..192ec52 --- /dev/null +++ b/scripts/cross-verify-macula.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Cross-verifies pq_hybrid composites (the LAMPS id-MLDSA87-RSA4096-PSS-SHA512) +# both ways between this SDK and macula 12.x, and writes what crossed to +# tests/vectors/identity/macula_12_cross for tests/identity_cross_verify.rs to +# hold: +# +# 1. this SDK makes a pq_hybrid key and signs a message (rust_signed/); +# 2. macula, from hex, in the image macula's own CI runs in, verifies that +# signature (and refuses it altered), then makes a pq_hybrid key of its +# own and signs a message (macula_signed/); +# 3. this SDK verifies macula's signature: cargo test --test +# identity_cross_verify. +# +# Needs cargo and podman. MACULA_CI_IMAGE +# overrides the image; the default is the one macula v12.7.0's test job pins. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +IMAGE="${MACULA_CI_IMAGE:-ghcr.io/macula-io/macula-ci-otp@sha256:aff1d39bc4aa29d13044b90b38e9b7f4b757d50818cc11c5bb7e84cdbf82ac70}" +OUT="$ROOT/tests/vectors/identity/macula_12_cross" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/cross-verify-macula.XXXXXX")" +trap 'podman unshare rm -rf "$WORK" 2>/dev/null || rm -rf "$WORK"' EXIT + +mkdir -p "$OUT/rust_signed" "$OUT/macula_signed" +(cd "$ROOT" && cargo run --quiet --example cross_verify_sign -- "$OUT/rust_signed") + +cp -r "$ROOT/scripts/cross-verify-macula/." "$WORK/project" +cp -r "$OUT" "$WORK/cross" +podman run --rm --cpus=4 --memory=8g --user root \ + -v "$WORK:/w:Z" -w /w/project "$IMAGE" sh -euc ' + rebar3 compile >/dev/null + erl -noshell -pa _build/default/lib/*/ebin \ + -eval "cross_verify_macula:main(\"/w/cross\"), halt()."' +cp "$WORK/cross/macula_signed/"*.bin "$OUT/macula_signed/" + +cd "$ROOT" && cargo test --test identity_cross_verify diff --git a/scripts/cross-verify-macula/rebar.config b/scripts/cross-verify-macula/rebar.config new file mode 100644 index 0000000..29d4a77 --- /dev/null +++ b/scripts/cross-verify-macula/rebar.config @@ -0,0 +1,2 @@ +{erl_opts, [debug_info]}. +{deps, [{macula, "~> 12.7"}]}. diff --git a/scripts/cross-verify-macula/src/cross_verify_macula.app.src b/scripts/cross-verify-macula/src/cross_verify_macula.app.src new file mode 100644 index 0000000..25d6b59 --- /dev/null +++ b/scripts/cross-verify-macula/src/cross_verify_macula.app.src @@ -0,0 +1,6 @@ +{application, cross_verify_macula, [ + {description, "macula 12.x's half of macula-rust's LAMPS composite cross-verification"}, + {vsn, "1"}, + {applications, [kernel, stdlib, crypto, public_key]}, + {env, []} +]}. diff --git a/scripts/cross-verify-macula/src/cross_verify_macula.erl b/scripts/cross-verify-macula/src/cross_verify_macula.erl new file mode 100644 index 0000000..c697971 --- /dev/null +++ b/scripts/cross-verify-macula/src/cross_verify_macula.erl @@ -0,0 +1,39 @@ +%% macula 12.x's half of scripts/cross-verify-macula.sh: verifies the pq_hybrid +%% composite macula-rust signed, refuses it altered, then signs a message with a +%% pq_hybrid key of its own for macula-rust to verify. The key is generated here +%% and never saved; no macula application is started, so no key file is read. +-module(cross_verify_macula). +-export([main/1]). + +main(Dir) -> + {ok, _} = application:ensure_all_started(crypto), + ok = application:load(macula), + {ok, Vsn} = application:get_key(macula, vsn), + io:format("macula ~s on OTP ~s~n", [Vsn, otp_version()]), + [Message, Public, Signature] = [read(Dir, "rust_signed", N) || N <- ["m.bin", "pk.bin", "s.bin"]], + true = macula_node_keys:verify(Message, Signature, Public, pq_hybrid), + false = macula_node_keys:verify(Message, flipped(Signature, 4627 + 10), Public, pq_hybrid), + false = macula_node_keys:verify(<>, Signature, Public, pq_hybrid), + io:format("rust_signed: verified by macula ~s, refused altered~n", [Vsn]), + {ok, Key} = macula_node_keys:generate(identity, pq_hybrid), + Ours = iolist_to_binary(["signed by macula ", Vsn]), + OurPublic = macula_node_keys:public_key(Key), + OurSignature = macula_node_keys:sign(Ours, Key), + true = macula_node_keys:verify(Ours, OurSignature, OurPublic, pq_hybrid), + ok = filelib:ensure_path(filename:join(Dir, "macula_signed")), + [ok = file:write_file(filename:join([Dir, "macula_signed", N]), B) + || {N, B} <- [{"m.bin", Ours}, {"pk.bin", OurPublic}, {"s.bin", OurSignature}]], + io:format("macula_signed: ~b-byte composite by macula ~s written~n", [byte_size(OurSignature), Vsn]). + +read(Dir, Signer, Name) -> + {ok, Bin} = file:read_file(filename:join([Dir, Signer, Name])), + Bin. + +flipped(Bin, At) -> + <> = Bin, + <>. + +otp_version() -> + {ok, V} = file:read_file(filename:join([code:root_dir(), "releases", erlang:system_info(otp_release), + "OTP_VERSION"])), + string:trim(V). diff --git a/src/binding.rs b/src/binding.rs new file mode 100644 index 0000000..655ad76 --- /dev/null +++ b/src/binding.rs @@ -0,0 +1,521 @@ +//! TLS and CONNECT bindings and status statements, as macula_key_bindings and +//! macula-go make and check them. A binding ties a station's TLS leaf, or a +//! node's CONNECT key, to an identity key for up to 7 days; a status +//! statement keeps a binding in force for up to an hour. Each travels as +//! `{tbs, signature}`, the signature over its label, a zero byte and the tbs +//! bytes; a verifier checks the signature over the bytes it received first, +//! and only then decodes them. + +use std::fmt; + +use sha2::{Digest, Sha384}; + +use crate::cbor::{self, Value}; +use crate::node_key::{node_id_of, verify, KeyError, NodeKey}; +use crate::profile::Profile; + +const LABEL_BINDING_TLS: &str = "MACULA-PQ-BINDING-TLS-V1"; +const LABEL_BINDING_CONNECT: &str = "MACULA-PQ-BINDING-CONNECT-V1"; +const LABEL_STATUS: &str = "MACULA-PQ-STATUS-V1"; +const MAX_BINDING_MS: i64 = 7 * 24 * 60 * 60 * 1000; +const MAX_STATUS_MS: i64 = 60 * 60 * 1000; +const TOLERANCE_MS: i64 = 5 * 60 * 1000; +const MAX_PROTOCOL_INT: i64 = 1 << 53; + +const BINDING_FIELDS: [&str; 9] = [ + "label", + "node_id", + "use", + "subject_hash", + "binding_id", + "not_before", + "not_after", + "hash_alg", + "sig_alg", +]; +const STATUS_FIELDS: [&str; 6] = [ + "label", + "node_id", + "binding_hash", + "issued_at", + "expires_at", + "sig_alg", +]; + +/// What a binding binds to the identity key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BindingUse { + /// A station's TLS key, by the leaf certificate it presents. + Tls, + /// A CONNECT key. + Connect, +} + +impl BindingUse { + fn name(self) -> &'static str { + match self { + BindingUse::Tls => "tls", + BindingUse::Connect => "connect", + } + } + + fn label(self) -> &'static str { + match self { + BindingUse::Tls => LABEL_BINDING_TLS, + BindingUse::Connect => LABEL_BINDING_CONNECT, + } + } +} + +/// The refusals of the binding and status checks, named as macula names them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BindingError { + /// A signed structure of the wrong shape: not exactly `{tbs, signature}`, + /// or a tbs that the decoding rule refuses, with a key its structure does + /// not define, or a field of the wrong type, length or range. + Malformed, + /// A binding whose signature does not verify under its use's label. + BindingSignatureInvalid, + /// A binding whose label or use is another use's. + WrongUse, + /// A binding whose subject is not the leaf or CONNECT key it came with. + KeyMismatch, + /// A binding more than 5 minutes past its not_after. + Expired, + /// A binding more than 5 minutes before its not_before. + NotYetValid, + /// A binding or statement naming a node_id other than the identity key's. + NodeIdMismatch, + /// A status statement whose signature does not verify. + StatusSignatureInvalid, + /// A status statement for another binding. + StatusBindingMismatch, + /// A status statement more than 5 minutes past its expiry. + StatusExpired, + /// A status statement issued more than 5 minutes ahead. + StatusFutureDated, + /// A window a verifier would refuse: negative, backwards, at 2^53 or later, + /// or longer than 7 days for a binding and an hour for a statement. + ValidityWindow, + /// The signing key could not sign, or is not an identity key. + Key(KeyError), +} + +impl fmt::Display for BindingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BindingError::Malformed => f.write_str("malformed signed structure"), + BindingError::BindingSignatureInvalid => { + f.write_str("the binding's signature does not verify") + } + BindingError::WrongUse => f.write_str("the binding is for another use"), + BindingError::KeyMismatch => f.write_str("the binding binds another key"), + BindingError::Expired => f.write_str("the binding has expired"), + BindingError::NotYetValid => f.write_str("the binding is not valid yet"), + BindingError::NodeIdMismatch => f.write_str("the structure names another node_id"), + BindingError::StatusSignatureInvalid => { + f.write_str("the status statement's signature does not verify") + } + BindingError::StatusBindingMismatch => { + f.write_str("the status statement is for another binding") + } + BindingError::StatusExpired => f.write_str("the status statement has expired"), + BindingError::StatusFutureDated => { + f.write_str("the status statement is dated in the future") + } + BindingError::ValidityWindow => { + f.write_str("a validity period outside what a verifier accepts") + } + BindingError::Key(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for BindingError {} + +impl From for BindingError { + fn from(e: KeyError) -> Self { + BindingError::Key(e) + } +} + +/// A signed structure as it travels: tbs, the deterministic CBOR of its +/// fields, and a signature over its label, a zero byte and tbs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignedTbs { + pub tbs: Vec, + pub signature: Vec, +} + +impl SignedTbs { + /// The structure as the map `{tbs, signature}`. + pub fn to_value(&self) -> Value { + Value::Map(vec![ + (Value::text("tbs"), Value::Bytes(self.tbs.clone())), + ( + Value::text("signature"), + Value::Bytes(self.signature.clone()), + ), + ]) + } + + /// The structure in `value`, which must be a map of exactly `tbs` and + /// `signature`, both byte strings. + pub fn from_value(value: &Value) -> Result { + match value { + Value::Map(pairs) if pairs.len() == 2 => { + match (value.get("tbs"), value.get("signature")) { + (Some(Value::Bytes(tbs)), Some(Value::Bytes(signature))) => Ok(SignedTbs { + tbs: tbs.clone(), + signature: signature.clone(), + }), + _ => Err(BindingError::Malformed), + } + } + _ => Err(BindingError::Malformed), + } + } +} + +/// What a verified binding says. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BindingInfo { + pub use_: BindingUse, + pub node_id: [u8; 32], + pub not_after: i64, +} + +/// Binds the TLS key of the leaf certificate a listener presents to +/// `identity_key`, by the SHA-384 of the leaf's DER, from `not_before` to +/// `not_after` in milliseconds. +pub fn tls_binding( + identity_key: &NodeKey, + leaf_der: &[u8], + not_before: i64, + not_after: i64, +) -> Result { + issue_binding( + identity_key, + BindingUse::Tls, + &sha384(leaf_der), + not_before, + not_after, + ) +} + +/// Binds a CONNECT key, by the SHA-384 of the key as carried, to +/// `identity_key`, from `not_before` to `not_after` in milliseconds. +pub fn connect_binding( + identity_key: &NodeKey, + connect_key: &[u8], + not_before: i64, + not_after: i64, +) -> Result { + issue_binding( + identity_key, + BindingUse::Connect, + &sha384(connect_key), + not_before, + not_after, + ) +} + +fn issue_binding( + identity_key: &NodeKey, + use_: BindingUse, + subject_hash: &[u8; 48], + not_before: i64, + not_after: i64, +) -> Result { + let node_id = identity_key.node_id()?; + if !within_window(not_before, not_after, MAX_BINDING_MS) { + return Err(BindingError::ValidityWindow); + } + let mut binding_id = [0u8; 16]; + aws_lc_rs::rand::fill(&mut binding_id) + .map_err(|_| BindingError::Key(KeyError::RandomnessUnavailable))?; + let tbs = encode(Value::Map(vec![ + text("label", use_.label()), + bytes("node_id", &node_id), + text("use", use_.name()), + bytes("subject_hash", subject_hash), + bytes("binding_id", &binding_id), + int("not_before", not_before), + int("not_after", not_after), + text("hash_alg", "SHA-384"), + text("sig_alg", identity_key.profile().sig_alg()), + ])); + sign_tbs(identity_key, use_.label(), tbs) +} + +/// Keeps `binding` in force from `issued_at` to `expires_at` in milliseconds, +/// at most an hour, signed by `identity_key`. +pub fn status_statement( + identity_key: &NodeKey, + binding: &SignedTbs, + issued_at: i64, + expires_at: i64, +) -> Result { + let node_id = identity_key.node_id()?; + if !within_window(issued_at, expires_at, MAX_STATUS_MS) { + return Err(BindingError::ValidityWindow); + } + let tbs = encode(Value::Map(vec![ + text("label", LABEL_STATUS), + bytes("node_id", &node_id), + bytes("binding_hash", &sha384(&binding.tbs)), + int("issued_at", issued_at), + int("expires_at", expires_at), + text("sig_alg", identity_key.profile().sig_alg()), + ])); + sign_tbs(identity_key, LABEL_STATUS, tbs) +} + +/// Checks `binding` against the identity key as carried, under `profile`, and +/// the leaf certificate this connection presented, at `now_ms` with 5 minutes +/// of tolerance. +pub fn verify_tls_binding( + binding: &SignedTbs, + identity_key: &[u8], + profile: Profile, + leaf_der: &[u8], + now_ms: i64, +) -> Result { + verify_binding( + binding, + identity_key, + profile, + BindingUse::Tls, + &sha384(leaf_der), + now_ms, + ) +} + +/// Checks `binding` against the identity key as carried, under `profile`, and +/// the CONNECT key as carried, at `now_ms` with 5 minutes of tolerance. +pub fn verify_connect_binding( + binding: &SignedTbs, + identity_key: &[u8], + profile: Profile, + connect_key: &[u8], + now_ms: i64, +) -> Result { + verify_binding( + binding, + identity_key, + profile, + BindingUse::Connect, + &sha384(connect_key), + now_ms, + ) +} + +/// The signature over the tbs bytes as received first, then the tbs decoded, +/// then its fields in macula's order: shape, use, node_id, subject, +/// not_before, not_after. +fn verify_binding( + binding: &SignedTbs, + identity_key: &[u8], + profile: Profile, + use_: BindingUse, + subject_hash: &[u8; 48], + now_ms: i64, +) -> Result { + if !verify( + &labelled(use_.label(), &binding.tbs), + &binding.signature, + identity_key, + profile, + ) { + return Err(BindingError::BindingSignatureInvalid); + } + let fields = decode_tbs(&binding.tbs, &BINDING_FIELDS).ok_or(BindingError::Malformed)?; + let parsed = well_formed_binding(&fields, profile).ok_or(BindingError::Malformed)?; + if parsed.label != use_.label() || parsed.use_ != use_.name() { + return Err(BindingError::WrongUse); + } + if parsed.node_id != node_id_of(identity_key, profile) { + return Err(BindingError::NodeIdMismatch); + } + if &parsed.subject_hash != subject_hash { + return Err(BindingError::KeyMismatch); + } + if now_ms + TOLERANCE_MS < parsed.not_before { + return Err(BindingError::NotYetValid); + } + if now_ms - TOLERANCE_MS > parsed.not_after { + return Err(BindingError::Expired); + } + Ok(BindingInfo { + use_, + node_id: parsed.node_id, + not_after: parsed.not_after, + }) +} + +struct BindingTbs<'a> { + label: &'a str, + use_: &'a str, + node_id: [u8; 32], + subject_hash: [u8; 48], + not_before: i64, + not_after: i64, +} + +fn well_formed_binding<'a>(f: &'a Fields, profile: Profile) -> Option> { + let binding_id = field_bytes(f, "binding_id")?; + let not_before = protocol_int(f, "not_before")?; + let not_after = protocol_int(f, "not_after")?; + let well_formed = binding_id.len() == 16 + && within_window(not_before, not_after, MAX_BINDING_MS) + && field_text(f, "hash_alg")? == "SHA-384" + && field_text(f, "sig_alg")? == profile.sig_alg(); + well_formed.then_some(BindingTbs { + label: field_text(f, "label")?, + use_: field_text(f, "use")?, + node_id: field_array(f, "node_id")?, + subject_hash: field_array(f, "subject_hash")?, + not_before, + not_after, + }) +} + +/// Checks a status statement for the binding it came with, against the +/// identity key as carried, under `profile`, at `now_ms` with 5 minutes of +/// tolerance, and returns when the statement expires. It checks that the +/// statement names that binding, not the binding itself: a caller verifies +/// the binding too. +pub fn verify_status( + statement: &SignedTbs, + binding: &SignedTbs, + identity_key: &[u8], + profile: Profile, + now_ms: i64, +) -> Result { + if !verify( + &labelled(LABEL_STATUS, &statement.tbs), + &statement.signature, + identity_key, + profile, + ) { + return Err(BindingError::StatusSignatureInvalid); + } + let fields = decode_tbs(&statement.tbs, &STATUS_FIELDS).ok_or(BindingError::Malformed)?; + let issued_at = protocol_int(&fields, "issued_at").ok_or(BindingError::Malformed)?; + let expires_at = protocol_int(&fields, "expires_at").ok_or(BindingError::Malformed)?; + let node_id: [u8; 32] = field_array(&fields, "node_id").ok_or(BindingError::Malformed)?; + let binding_hash: [u8; 48] = + field_array(&fields, "binding_hash").ok_or(BindingError::Malformed)?; + let well_formed = field_text(&fields, "label") == Some(LABEL_STATUS) + && within_window(issued_at, expires_at, MAX_STATUS_MS) + && field_text(&fields, "sig_alg") == Some(profile.sig_alg()); + if !well_formed { + return Err(BindingError::Malformed); + } + if node_id != node_id_of(identity_key, profile) { + return Err(BindingError::NodeIdMismatch); + } + if binding_hash != sha384(&binding.tbs) { + return Err(BindingError::StatusBindingMismatch); + } + if issued_at > now_ms + TOLERANCE_MS { + return Err(BindingError::StatusFutureDated); + } + if now_ms - TOLERANCE_MS > expires_at { + return Err(BindingError::StatusExpired); + } + Ok(expires_at) +} + +/// A tbs's fields by name. +type Fields = std::collections::HashMap; + +/// `tbs` decoded under the decoding rule, when it is a map whose keys are +/// exactly `names`, all text. +fn decode_tbs(tbs: &[u8], names: &[&str]) -> Option { + let Value::Map(pairs) = cbor::decode(tbs).ok()? else { + return None; + }; + if pairs.len() != names.len() { + return None; + } + let mut fields = Fields::with_capacity(pairs.len()); + for (key, value) in pairs { + let Value::Text(name) = key else { + return None; + }; + fields.insert(name, value); + } + names + .iter() + .all(|n| fields.contains_key(*n)) + .then_some(fields) +} + +/// Whether `from` and `to` are a validity period a verifier accepts: `from` +/// at least 0, `to` no earlier than `from` and below 2^53, and at most `max` +/// apart. +fn within_window(from: i64, to: i64, max: i64) -> bool { + from >= 0 && from <= to && to < MAX_PROTOCOL_INT && to - from <= max +} + +fn protocol_int(f: &Fields, name: &str) -> Option { + match f.get(name)? { + Value::Int(n) => i64::try_from(*n).ok(), + _ => None, + } +} + +fn field_text<'a>(f: &'a Fields, name: &str) -> Option<&'a str> { + match f.get(name)? { + Value::Text(t) => Some(t), + _ => None, + } +} + +fn field_bytes<'a>(f: &'a Fields, name: &str) -> Option<&'a [u8]> { + match f.get(name)? { + Value::Bytes(b) => Some(b), + _ => None, + } +} + +fn field_array(f: &Fields, name: &str) -> Option<[u8; N]> { + field_bytes(f, name)?.try_into().ok() +} + +fn sign_tbs(key: &NodeKey, label: &str, tbs: Vec) -> Result { + let signature = key.sign(&labelled(label, &tbs))?; + Ok(SignedTbs { tbs, signature }) +} + +/// `label`, a zero byte and `tbs`: what a binding or status statement signs. +fn labelled(label: &str, tbs: &[u8]) -> Vec { + let mut out = Vec::with_capacity(label.len() + 1 + tbs.len()); + out.extend_from_slice(label.as_bytes()); + out.push(0); + out.extend_from_slice(tbs); + out +} + +fn sha384(bytes: &[u8]) -> [u8; 48] { + Sha384::digest(bytes).into() +} + +/// A map of text keys and protocol values always encodes: every integer here +/// is an i64. +fn encode(value: Value) -> Vec { + cbor::encode(&value).expect("text-keyed fields of i64 integers always encode") +} + +fn text(name: &str, value: &str) -> (Value, Value) { + (Value::text(name), Value::text(value)) +} + +fn bytes(name: &str, value: &[u8]) -> (Value, Value) { + (Value::text(name), Value::Bytes(value.to_vec())) +} + +fn int(name: &str, value: i64) -> (Value, Value) { + (Value::text(name), Value::Int(i128::from(value))) +} diff --git a/src/bolt4.rs b/src/bolt4.rs deleted file mode 100644 index d545a64..0000000 --- a/src/bolt4.rs +++ /dev/null @@ -1,154 +0,0 @@ -//! BOLT#4-style error taxonomy for CALL failures, ported from -//! `src/peering/macula_bolt4.erl` (`macula-io/macula`) — see -//! `plans/PLAN_WIRE_PROTOCOL.md` §9. Adapted from Lightning Network's -//! BOLT#4 onion-failure codes: a small, specific taxonomy that prevents -//! retry loops and enables post-mortem, rather than an open-ended error -//! string. Codes are stable across V2 minor versions; new codes append -//! at the next free integer. -//! -//! The retry policy is advisory — a caller's own CALL state machine is -//! the actual decision point (not implemented by this module). - -/// The 17 codes macula's own `table/0` defines, in order. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Code { - Ok = 0x00, - UnknownNextPeer = 0x01, - TemporaryRelayFailure = 0x02, - RelayDisabled = 0x03, - NodeNotFoundAtTargetRelay = 0x04, - TargetRealmRefused = 0x05, - LoopDetected = 0x06, - ExpiryTooSoon = 0x07, - UpstreamCongestion = 0x08, - InvalidPathHeader = 0x09, - CryptoPuzzleInvalid = 0x0A, - RealmNotAuthoritativeHere = 0x0B, - Tombstoned = 0x0C, - PayloadTooLarge = 0x0D, - SignatureInvalid = 0x0E, - UnknownError = 0x0F, - /// Direct-dial dual-trust: the caller lacked a valid UCAN capability - /// for a gated procedure. - Unauthorized = 0x10, -} - -/// Whether the retry policy for a code permits retrying at all. `none` -/// (success), `application` (handler-level remedy), and `crypto_drop` -/// (security-critical) are all non-retryable — everything else means -/// "retry, differently." -impl Code { - pub fn as_u8(self) -> u8 { - self as u8 - } - - pub fn name(self) -> &'static str { - match self { - Code::Ok => "ok", - Code::UnknownNextPeer => "unknown_next_peer", - Code::TemporaryRelayFailure => "temporary_relay_failure", - Code::RelayDisabled => "relay_disabled", - Code::NodeNotFoundAtTargetRelay => "node_not_found_at_target_relay", - Code::TargetRealmRefused => "target_realm_refused", - Code::LoopDetected => "loop_detected", - Code::ExpiryTooSoon => "expiry_too_soon", - Code::UpstreamCongestion => "upstream_congestion", - Code::InvalidPathHeader => "invalid_path_header", - Code::CryptoPuzzleInvalid => "crypto_puzzle_invalid", - Code::RealmNotAuthoritativeHere => "realm_not_authoritative_here", - Code::Tombstoned => "tombstoned", - Code::PayloadTooLarge => "payload_too_large", - Code::SignatureInvalid => "signature_invalid", - Code::UnknownError => "unknown_error", - Code::Unauthorized => "unauthorized", - } - } - - pub fn is_retryable(self) -> bool { - !matches!( - self, - Code::Ok - | Code::TargetRealmRefused - | Code::Tombstoned - | Code::PayloadTooLarge - | Code::Unauthorized - | Code::CryptoPuzzleInvalid - | Code::SignatureInvalid - ) - } - - pub fn from_u8(code: u8) -> Option { - Some(match code { - 0x00 => Code::Ok, - 0x01 => Code::UnknownNextPeer, - 0x02 => Code::TemporaryRelayFailure, - 0x03 => Code::RelayDisabled, - 0x04 => Code::NodeNotFoundAtTargetRelay, - 0x05 => Code::TargetRealmRefused, - 0x06 => Code::LoopDetected, - 0x07 => Code::ExpiryTooSoon, - 0x08 => Code::UpstreamCongestion, - 0x09 => Code::InvalidPathHeader, - 0x0A => Code::CryptoPuzzleInvalid, - 0x0B => Code::RealmNotAuthoritativeHere, - 0x0C => Code::Tombstoned, - 0x0D => Code::PayloadTooLarge, - 0x0E => Code::SignatureInvalid, - 0x0F => Code::UnknownError, - 0x10 => Code::Unauthorized, - _ => return None, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn round_trips_every_defined_code() { - for code in 0x00u8..=0x10 { - let parsed = - Code::from_u8(code).unwrap_or_else(|| panic!("code {code:#x} should be defined")); - assert_eq!(parsed.as_u8(), code); - } - } - - #[test] - fn unknown_code_is_none() { - assert_eq!(Code::from_u8(0x11), None); - assert_eq!(Code::from_u8(0xFF), None); - } - - #[test] - fn non_retryable_codes_match_the_reference_table() { - // none | application | crypto_drop, per macula_bolt4.erl's table/0. - assert!(!Code::Ok.is_retryable()); - assert!(!Code::TargetRealmRefused.is_retryable()); - assert!(!Code::Tombstoned.is_retryable()); - assert!(!Code::PayloadTooLarge.is_retryable()); - assert!(!Code::Unauthorized.is_retryable()); - assert!(!Code::CryptoPuzzleInvalid.is_retryable()); - assert!(!Code::SignatureInvalid.is_retryable()); - } - - #[test] - fn retryable_codes_match_the_reference_table() { - assert!(Code::UnknownNextPeer.is_retryable()); - assert!(Code::TemporaryRelayFailure.is_retryable()); - assert!(Code::RelayDisabled.is_retryable()); - assert!(Code::NodeNotFoundAtTargetRelay.is_retryable()); - assert!(Code::LoopDetected.is_retryable()); - assert!(Code::ExpiryTooSoon.is_retryable()); - assert!(Code::UpstreamCongestion.is_retryable()); - assert!(Code::InvalidPathHeader.is_retryable()); - assert!(Code::RealmNotAuthoritativeHere.is_retryable()); - assert!(Code::UnknownError.is_retryable()); - } - - #[test] - fn names_match_the_reference_spelling() { - assert_eq!(Code::UnknownNextPeer.name(), "unknown_next_peer"); - assert_eq!(Code::Unauthorized.name(), "unauthorized"); - } -} diff --git a/src/cbor.rs b/src/cbor.rs index 0de5390..5cdcf69 100644 --- a/src/cbor.rs +++ b/src/cbor.rs @@ -5,12 +5,11 @@ //! a direct Rust transcription of the hand-rolled canonical encoder macula //! actually ships in `native/macula_cbor_nif/src/deterministic.rs` //! (`macula-io/macula`), which `macula_frame.erl`'s wire codec calls as -//! `pack_deterministic/1` / `unpack_deterministic/1`. Every frame's -//! Ed25519 signature is computed over these exact bytes, so a divergence +//! `pack_deterministic/1` / `unpack_deterministic/1`. Every signed frame, +//! record and binding is signed over these exact bytes, so a divergence //! here silently breaks signature verification against real stations — //! this module's tests include fixtures captured directly from the real -//! NIF (`rebar3 shell` against `macula-io/macula` at v10.10.0), not just -//! hand-derived expectations. +//! NIF, not just hand-derived expectations. //! //! Encoding rules (all verified against the reference, see `tests` below): //! - Integers: minimal-length encoding (inline for 0..=23, else the @@ -40,12 +39,16 @@ //! "canonical CBOR" crate that follows the RFC's shortest-float rule //! would silently produce non-matching, non-verifying bytes here. //! -//! Decode is deliberately narrow to match the reference: major type 6 -//! (tags) is rejected outright, and major 7 only supports `null` and the -//! three float widths (binary16/32/64, all promoted to `f64`) — no -//! booleans, no "undefined" simple value. Every read is bounds-checked; -//! nothing in this module panics on malformed or truncated input, since -//! decode exists specifically to parse untrusted, network-received bytes. +//! Decode applies macula 12's decoding rule, the rule every stack applies to +//! what a peer sends (`tests/cbor_decoding_rule.rs` holds it to the shared +//! vectors and to the reason macula's reference decoder gives for each +//! refusal): lengths in any width, map keys in any order but only text or +//! integers and never twice, integers within -2^63..=2^63-1, `null` and +//! finite half, single and double floats, at most [`MAX_NESTING_DEPTH`] +//! levels and [`MAX_ELEMENTS`] items. Tags, booleans and every other simple +//! value are refused. Every read is bounds-checked; nothing in this module +//! panics on malformed or truncated input, since decode exists specifically +//! to parse untrusted, network-received bytes. use std::fmt; @@ -62,8 +65,7 @@ pub enum Value { Text(String), List(Vec), /// Insertion order on construction; canonical key sort happens at - /// encode time, not here. Decode preserves last-write-wins on - /// duplicate keys, matching the reference decoder exactly. + /// encode time, not here. Decode refuses a duplicate key. Map(Vec<(Value, Value)>), Null, /// Always round-trips through binary64 — see the module doc's note @@ -137,71 +139,44 @@ impl fmt::Display for IntOutOfRange { impl std::error::Error for IntOutOfRange {} +/// Why [`decode`] refused an input: one variant for each reason macula's +/// reference decoder (`macula_record_cbor:decode_strict/1`) gives, so an input +/// is refused for the same reason in every stack. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DecodeError { - /// The buffer ended before a complete value could be read. - Truncated, - /// Major type 6 (tags) — not part of macula's wire format. - UnsupportedMajorType(u8), - /// A major-7 additional-info value with no meaning here (only 22 - /// \[null\] and 25/26/27 \[floats\] are supported). - UnsupportedAdditionalInfo(u8), - /// Additional-info 28-31 on any major type — reserved, unused. - UnsupportedAdditionalInfoEncoding(u8), - /// A single top-level value didn't consume the whole buffer. + /// Bytes after the top-level value. TrailingBytes, - /// A major-3 (text) value's bytes were not valid UTF-8. The reference - /// Erlang/Rust codec does not validate this on decode (it stores - /// whatever bytes arrived); this port deliberately diverges and - /// treats it as an error instead of losslessly carrying invalid - /// UTF-8, since every real macula text value is ASCII/UTF-8 by - /// construction and failing closed on malformed input from a peer is - /// the safer default. Documented, not accidental. - InvalidUtf8, - /// A half-float (binary16) with exponent 31 — NaN or infinity, which - /// has no representation as an ordinary `f64` value here (matches - /// the reference decoder's own behavior: no clause for it). - UnrepresentableFloat, - /// Lists/maps nested more than [`MAX_NESTING_DEPTH`] levels deep. - /// Not part of the wire format's own semantics — a defense against a - /// maliciously crafted frame: a list-of-one-list-of-one-list... can - /// encode extreme nesting in very few bytes (one byte per level), - /// and this decoder is plain recursive descent, so without a limit - /// a peer could crash the process with a stack overflow (not a - /// catchable panic) from a single frame well under - /// `frame::MAX_FRAME_BYTES`. No real macula wire value nests anywhere - /// close to this deep. + /// A map key that is neither text nor an integer. + BadKey, + /// A map key equal to an earlier key of its map: text with the same + /// bytes, or an integer of the same value, in any width. + DuplicateKey, + /// Text that is not valid UTF-8. + InvalidText, + /// Arrays and maps nested more than [`MAX_NESTING_DEPTH`] levels. NestingTooDeep, + /// An integer below -2^63 or above 2^63-1. + IntegerOutOfRange, + /// Input that holds more than [`MAX_ELEMENTS`] items. + TooManyElements, + /// Input that is not one complete item of what the rule allows: truncated + /// input, an indefinite length, a tag, a simple value other than null, or + /// a float that is NaN or infinite. + Malformed, } impl fmt::Display for DecodeError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - DecodeError::Truncated => write!(f, "truncated input"), - DecodeError::UnsupportedMajorType(m) => { - write!( - f, - "unsupported major type {m} (only 0-5 and 7 are valid here)" - ) - } - DecodeError::UnsupportedAdditionalInfo(ai) => { - write!(f, "unsupported major-7 additional info {ai}") - } - DecodeError::UnsupportedAdditionalInfoEncoding(ai) => { - write!( - f, - "unsupported additional-info encoding {ai} (28-31 are reserved)" - ) - } - DecodeError::TrailingBytes => write!(f, "trailing bytes after the top-level value"), - DecodeError::InvalidUtf8 => write!(f, "text value was not valid UTF-8"), - DecodeError::UnrepresentableFloat => { - write!(f, "half-float NaN/infinity has no f64 representation here") - } - DecodeError::NestingTooDeep => { - write!(f, "list/map nesting exceeds {MAX_NESTING_DEPTH} levels") - } - } + f.write_str(match self { + DecodeError::TrailingBytes => "bytes after the top-level value", + DecodeError::BadKey => "a map key that is neither text nor an integer", + DecodeError::DuplicateKey => "a duplicate map key", + DecodeError::InvalidText => "text that is not valid UTF-8", + DecodeError::NestingTooDeep => "arrays and maps nested more than 64 levels", + DecodeError::IntegerOutOfRange => "an integer below -2^63 or above 2^63-1", + DecodeError::TooManyElements => "more than 131072 items", + DecodeError::Malformed => "malformed", + }) } } @@ -311,325 +286,234 @@ fn encode_head(major: u8, n: u64, out: &mut Vec) { } } -/// Recursive-descent nesting limit — see [`DecodeError::NestingTooDeep`] -/// for why this exists. No real macula wire value nests remotely this -/// deep; this only ever rejects an adversarial input. -pub const MAX_NESTING_DEPTH: usize = 128; - -/// Decode a single deterministic-CBOR value from `bytes`. The whole -/// buffer must be consumed by exactly one top-level value — trailing -/// bytes are an error, matching the reference decoder's own contract. +/// How many arrays and maps may nest inside each other, the outermost +/// counted: 64 levels decode, and a 65th is refused, as in macula's decoding +/// rule. +pub const MAX_NESTING_DEPTH: usize = 64; + +/// How many CBOR items one [`decode`] may read: every item counts once, the +/// top-level value, array elements, map keys and map values included. It is +/// macula's element budget, so an input macula refuses for the items it holds +/// is refused here too. +pub const MAX_ELEMENTS: usize = 131_072; + +/// Decode `bytes` as exactly one value under macula's post-quantum decoding +/// rule, the rule every stack applies to what a peer sends. Lengths are +/// accepted in any width, map keys in any order, and half, single and double +/// floats; everything else the rule refuses is refused with the reason +/// macula's reference decoder gives. Every path returns an error rather than +/// panicking, since the input is untrusted. pub fn decode(bytes: &[u8]) -> Result { - // Nobody consumes the top-level value's canonical bytes — don't - // build them (see `decode_one`'s `need_canon` param). - let (value, _canonical_bytes, pos) = decode_one(bytes, 0, 0, false)?; - if pos != bytes.len() { + let mut decoder = Decoder { + data: bytes, + pos: 0, + budget: MAX_ELEMENTS, + }; + let value = decoder.item(0)?; + if decoder.pos != bytes.len() { return Err(DecodeError::TrailingBytes); } Ok(value) } -fn need(buf: &[u8], pos: usize, n: usize) -> Result<(), DecodeError> { - match pos.checked_add(n) { - Some(end) if end <= buf.len() => Ok(()), - _ => Err(DecodeError::Truncated), - } -} - -/// Decodes one value, and — only when `need_canon` is true — its own -/// canonical (deterministic-CBOR) bytes, built bottom-up as decoding -/// proceeds rather than re-derived by a separate encode pass afterward. -/// See `decode_map`'s doc for why the bytes are needed at all (a map -/// using another map as a key needs its key's canonical bytes to -/// dedupe/sort by, and re-encoding a key from scratch at every ancestor -/// level is itself an unbounded-work trap on nested input) and why -/// `need_canon` exists (computing them for every value regardless of -/// whether anything ever reads them — the common case, since most -/// decoded values are never used as a map key at any level — turned out -/// to be its own real cost: a value nested `depth` levels inside a -/// value that never touches a map key at all still doesn't need canon -/// bytes, but always building them anyway meant a large nested -/// non-map-keyed value paid full canon-construction cost with nothing -/// to show for it, confirmed to regress both time and peak memory on -/// large deep lists/values with no map keys anywhere in them). -/// `decode_map` is the only caller that ever passes different values -/// for its two child calls: always `true` for a key (dedup needs it -/// unconditionally, regardless of whether the map's OWN canon bytes are -/// wanted) and its own `need_canon` for a value (only needed if this -/// whole map is itself nested inside some ancestor's key). -fn decode_one( - buf: &[u8], +/// Reads one value from `data`: `pos` is how far it has read, and `budget` +/// how many more items it may read. +struct Decoder<'a> { + data: &'a [u8], pos: usize, - depth: usize, - need_canon: bool, -) -> Result<(Value, Vec, usize), DecodeError> { - if depth > MAX_NESTING_DEPTH { - return Err(DecodeError::NestingTooDeep); - } - need(buf, pos, 1)?; - let byte0 = buf[pos]; - let major = byte0 >> 5; - let ai = byte0 & 0x1F; - - if major == 7 { - let (value, next) = decode_major7(buf, pos, ai)?; - return Ok(scalar_canonical_bytes(value, next, need_canon)); - } - - let (n, next) = decode_count(buf, pos + 1, ai)?; - match major { - 0 => Ok(scalar_canonical_bytes( - Value::Int(n as i128), - next, - need_canon, - )), - 1 => Ok(scalar_canonical_bytes( - Value::Int(-1i128 - n as i128), - next, - need_canon, - )), - 2 => { - let len = n as usize; - need(buf, next, len)?; - let value = Value::Bytes(buf[next..next + len].to_vec()); - Ok(scalar_canonical_bytes(value, next + len, need_canon)) - } - 3 => { - let len = n as usize; - need(buf, next, len)?; - let text = String::from_utf8(buf[next..next + len].to_vec()) - .map_err(|_| DecodeError::InvalidUtf8)?; - Ok(scalar_canonical_bytes( - Value::Text(text), - next + len, - need_canon, - )) - } - 4 => decode_list(buf, next, n, depth + 1, need_canon), - 5 => decode_map(buf, next, n, depth + 1, need_canon), - _ => Err(DecodeError::UnsupportedMajorType(major)), - } + budget: usize, } -/// `with_canonical_bytes`, but skipped (an empty `Vec` instead) when -/// nothing will ever read it — see `decode_one`'s `need_canon` doc. -fn scalar_canonical_bytes(value: Value, next: usize, need_canon: bool) -> (Value, Vec, usize) { - if need_canon { - with_canonical_bytes(value, next) - } else { - (value, Vec::new(), next) - } -} - -/// Computes a scalar (non-list/map) value's own canonical bytes via a -/// plain, non-recursive `encode_value` call — cheap regardless of where -/// in a nested structure it's called from, unlike `List`/`Map`, which -/// build their canonical bytes by concatenating their CHILDREN's -/// already-computed bytes (see `decode_list`/`decode_map`) instead of -/// calling `encode_value` on themselves. -fn with_canonical_bytes(value: Value, next: usize) -> (Value, Vec, usize) { - let mut canon = Vec::new(); - encode_value(&value, &mut canon).expect("a value produced by this decoder is always encodable"); - (value, canon, next) +/// A map key's identity under the rule: its text, or an integer's value. +#[derive(PartialEq, Eq, Hash)] +enum KeyId { + Text(String), + Int(i128), } -fn decode_count(buf: &[u8], pos: usize, ai: u8) -> Result<(u64, usize), DecodeError> { - match ai { - 0..=23 => Ok((ai as u64, pos)), - 24 => { - need(buf, pos, 1)?; - Ok((buf[pos] as u64, pos + 1)) +/// The room a list or map is given before its elements decode: a declared +/// count is not checked against the input, so it is never trusted as an +/// allocation size. +const MAX_SIZE_HINT: usize = 4; + +impl Decoder<'_> { + /// The item at `pos`, which sits inside `depth` arrays and maps. As in + /// macula's decoder, an item is counted against the budget once its head + /// and argument have been read, and before its own checks. + fn item(&mut self, depth: usize) -> Result { + let head = self.take(1)?[0]; + let (major, ai) = (head >> 5, head & 0x1F); + if major == 7 { + return self.simple_or_float(ai); } - 25 => { - need(buf, pos, 2)?; - Ok((u16::from_be_bytes([buf[pos], buf[pos + 1]]) as u64, pos + 2)) - } - 26 => { - need(buf, pos, 4)?; - let b: [u8; 4] = buf[pos..pos + 4].try_into().expect("checked len"); - Ok((u32::from_be_bytes(b) as u64, pos + 4)) - } - 27 => { - need(buf, pos, 8)?; - let b: [u8; 8] = buf[pos..pos + 8].try_into().expect("checked len"); - Ok((u64::from_be_bytes(b), pos + 8)) + let arg = self.argument(ai)?; + self.count()?; + match major { + 0 => integer(i128::from(arg), arg), + 1 => integer(-1 - i128::from(arg), arg), + 2 => Ok(Value::Bytes(self.take(arg)?.to_vec())), + 3 => { + let bytes = self.take(arg)?; + std::str::from_utf8(bytes) + .map(|text| Value::Text(text.to_owned())) + .map_err(|_| DecodeError::InvalidText) + } + 4 => self.list(arg, depth), + 5 => self.map(arg, depth), + _ => Err(DecodeError::Malformed), } - 28..=31 => Err(DecodeError::UnsupportedAdditionalInfoEncoding(ai)), - _ => unreachable!("additional info is a 5-bit field, 0..=31"), } -} -fn decode_major7(buf: &[u8], pos: usize, ai: u8) -> Result<(Value, usize), DecodeError> { - match ai { - 22 => Ok((Value::Null, pos + 1)), - 25 => { - need(buf, pos + 1, 2)?; - let half = u16::from_be_bytes([buf[pos + 1], buf[pos + 2]]); - Ok((Value::Float(half_to_f64(half)?), pos + 3)) + /// Takes one item from the budget. + fn count(&mut self) -> Result<(), DecodeError> { + if self.budget == 0 { + return Err(DecodeError::TooManyElements); } - 26 => { - need(buf, pos + 1, 4)?; - let b: [u8; 4] = buf[pos + 1..pos + 5].try_into().expect("checked len"); - Ok((Value::Float(f32::from_be_bytes(b) as f64), pos + 5)) + self.budget -= 1; + Ok(()) + } + + /// The next `n` bytes of the input, moving past them. + fn take(&mut self, n: u64) -> Result<&[u8], DecodeError> { + let remaining = (self.data.len() - self.pos) as u64; + if n > remaining { + return Err(DecodeError::Malformed); } - 27 => { - need(buf, pos + 1, 8)?; - let b: [u8; 8] = buf[pos + 1..pos + 9].try_into().expect("checked len"); - Ok((Value::Float(f64::from_be_bytes(b)), pos + 9)) + let start = self.pos; + self.pos += n as usize; + Ok(&self.data[start..self.pos]) + } + + /// A head's argument, a value or a length: its additional information + /// itself up to 23, or the 1, 2, 4 or 8 bytes after the head, in whichever + /// width the sender chose. 28 to 31, every indefinite length among them, + /// is malformed. + fn argument(&mut self, ai: u8) -> Result { + let width = match ai { + 0..=23 => return Ok(u64::from(ai)), + 24 => 1, + 25 => 2, + 26 => 4, + 27 => 8, + _ => return Err(DecodeError::Malformed), + }; + Ok(self + .take(width)? + .iter() + .fold(0u64, |arg, &b| (arg << 8) | u64::from(b))) + } + + /// Major type 7, counted once its bytes have been read: null, or a finite + /// half, single or double float. Every other simple value, a boolean + /// among them, is malformed. + fn simple_or_float(&mut self, ai: u8) -> Result { + match ai { + 22 => { + self.count()?; + Ok(Value::Null) + } + 25..=27 => { + let width = match ai { + 25 => 2, + 26 => 4, + _ => 8, + }; + let bytes = self.take(width)?; + let value = match bytes.len() { + 2 => half_to_f64(u16::from_be_bytes([bytes[0], bytes[1]])), + 4 => f64::from(f32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])), + _ => f64::from_be_bytes(bytes.try_into().map_err(|_| DecodeError::Malformed)?), + }; + self.count()?; + if value.is_finite() { + Ok(Value::Float(value)) + } else { + Err(DecodeError::Malformed) + } + } + 0..=24 => { + self.argument(ai)?; + self.count()?; + Err(DecodeError::Malformed) + } + _ => Err(DecodeError::Malformed), } - _ => Err(DecodeError::UnsupportedAdditionalInfo(ai)), } -} -fn decode_list( - buf: &[u8], - mut pos: usize, - count: u64, - depth: usize, - need_canon: bool, -) -> Result<(Value, Vec, usize), DecodeError> { - let mut items = Vec::with_capacity(count.min(1024) as usize); - let mut canon = Vec::new(); - if need_canon { - encode_head(4, count, &mut canon); + /// The room to give a list or map that declares `count` elements of at + /// least `items_per_element` items and bytes each: at most the count, + /// what the bytes and budget left could hold, and [`MAX_SIZE_HINT`], so + /// what decoding allocates follows the bytes present. + fn size_hint(&self, count: u64, items_per_element: usize) -> usize { + let bytes_left = (self.data.len() - self.pos) / items_per_element; + let budget_left = self.budget / items_per_element; + count + .min(bytes_left as u64) + .min(budget_left as u64) + .min(MAX_SIZE_HINT as u64) as usize } - for _ in 0..count { - let (item, item_canon, next) = decode_one(buf, pos, depth, need_canon)?; - if need_canon { - canon.extend_from_slice(&item_canon); + + fn list(&mut self, count: u64, depth: usize) -> Result { + if depth >= MAX_NESTING_DEPTH { + return Err(DecodeError::NestingTooDeep); } - items.push(item); - pos = next; + let mut items = Vec::with_capacity(self.size_hint(count, 1)); + for _ in 0..count { + items.push(self.item(depth + 1)?); + } + Ok(Value::List(items)) } - Ok((Value::List(items), canon, pos)) -} -/// Duplicate keys overwrite (last write wins), matching the reference -/// decoder exactly — not treated as an error. -/// -/// Looks up each key's slot by its own canonical bytes (from -/// `decode_one`'s bottom-up construction — see that function's doc) in -/// a `HashMap`, rather than a `Value`-equality linear scan over -/// everything decoded so far: the scan made this function O(n²) on a -/// map with many distinct keys — a single ~350 KB crafted frame (well -/// under `frame::MAX_FRAME_BYTES`) pegged a CPU core for 50+ seconds -/// decoding it, and the cost scaled quadratically toward the -/// frame-size cap, all of it running before any signature check on the -/// frame. -/// -/// An earlier version of this fix looked up each key by calling -/// `encode(&k)` fresh, per entry, instead of reusing the bytes -/// `decode_one` already built while decoding that same key — that's -/// sound for a FLAT map (fixed the 350 KB/50 s case, confirmed -/// empirically), but reintroduced unbounded work for a map whose KEY is -/// itself a large nested structure: re-encoding a key from scratch at -/// every ancestor level costs O(depth × key size), and a 128-level -/// chain of single-entry maps (`MAX_NESTING_DEPTH`) each keyed by a -/// large blob turned back into tens of seconds of pre-auth CPU on a -/// frame still under the size cap — confirmed empirically. Building -/// canonical bytes bottom-up (each value's bytes computed exactly once, -/// when it's decoded, then only ever concatenated/sorted by its -/// ancestors — never re-derived) fixed that: the same 128-deep/15 MB -/// case dropped from ~31 s to ~1 s. This is O(depth × size), the same -/// bound `MAX_NESTING_DEPTH` already exists to enforce — NOT O(total -/// input size) regardless of nesting shape, since a key containing a -/// key still gets its bytes copied once per level it's nested under. -/// It just can no longer exceed the depth cap's own bound, the same -/// guarantee `NestingTooDeep` already gives the rest of this decoder. -/// -/// Computing canon bytes unconditionally for every value (not just -/// values that end up under a map key somewhere) was ALSO measured to -/// be a real, separate cost — a large nested value that never touches -/// a map key still paid full canon-construction cost for nothing; -/// `need_canon` (threaded through `decode_one`/`decode_list`/this -/// function) skips it. A key's canon is always needed, unconditionally -/// (dedup requires it); a value's is only needed if this whole map is -/// itself nested inside some ancestor's key, i.e. this map's OWN -/// `need_canon`. -/// -/// The still-remaining, deliberate, narrow divergences from a literal -/// `Value`-equality scan, fuzzed against 500k adversarial inputs -/// against both this and the pre-fix decoder: nested-map keys that -/// differ only in wire insertion order now merge (the old scan kept -/// both — wrong, since Erlang maps/this format's own key-sort are both -/// unordered); `+0.0`/`-0.0` keys no longer merge (the old scan merged -/// them via `PartialEq` — wrong, since neither Erlang's `=:=` nor the -/// reference NIF's own byte-dedup merge them); bit-identical `NaN` keys -/// now merge (the old scan never did, since `NaN != NaN` under -/// `PartialEq` — matches the reference). All three move this decoder -/// TOWARD the reference decoder's actual behavior, not away from it, -/// and none of the three is reachable in practice: no real macula map -/// key is ever a float or a nested map. -fn decode_map( - buf: &[u8], - mut pos: usize, - count: u64, - depth: usize, - need_canon: bool, -) -> Result<(Value, Vec, usize), DecodeError> { - let capacity = count.min(1024) as usize; - let mut pairs: Vec<(Value, Value)> = Vec::with_capacity(capacity); - // Owns each distinct key's canonical bytes (moved in on first sight, - // never cloned) -> slot index into `pairs`/`vals_canon`. - let mut index_of_key: std::collections::HashMap, usize> = - std::collections::HashMap::with_capacity(capacity); - // Per-slot VALUE canon, kept in step with `pairs` (same index, same - // last-write-wins updates) -- only populated when `need_canon`, since - // a key's canon (owned by `index_of_key` above) is the only one ever - // needed just to make dedup itself work. - let mut vals_canon: Vec> = Vec::with_capacity(if need_canon { capacity } else { 0 }); - for _ in 0..count { - // A key ALWAYS needs its canon bytes -- that's the dedup - // identity itself, independent of whether this map's OWN canon - // bytes (built below) are ever going to be read by anything. - let (k, key_canon, next1) = decode_one(buf, pos, depth, true)?; - let (v, val_canon, next2) = decode_one(buf, next1, depth, need_canon)?; - pos = next2; - use std::collections::hash_map::Entry; - match index_of_key.entry(key_canon) { - Entry::Occupied(e) => { - let i = *e.get(); - pairs[i].1 = v; - if need_canon { - vals_canon[i] = val_canon; - } - } - Entry::Vacant(e) => { - e.insert(pairs.len()); - pairs.push((k, v)); - if need_canon { - vals_canon.push(val_canon); - } + /// A map of `count` entries. Each entry's value decodes before its key is + /// judged, as in the reference decoder, so an input that breaks two + /// checks is refused for the same one in every stack. Duplicates are found + /// through a hash set, so the work grows with the number of keys, not its + /// square. + fn map(&mut self, count: u64, depth: usize) -> Result { + if depth >= MAX_NESTING_DEPTH { + return Err(DecodeError::NestingTooDeep); + } + let hint = self.size_hint(count, 2); + let mut pairs = Vec::with_capacity(hint); + let mut seen = std::collections::HashSet::with_capacity(hint); + for _ in 0..count { + let key = self.item(depth + 1)?; + let value = self.item(depth + 1)?; + let id = match &key { + Value::Text(text) => KeyId::Text(text.clone()), + Value::Int(n) => KeyId::Int(*n), + _ => return Err(DecodeError::BadKey), + }; + if !seen.insert(id) { + return Err(DecodeError::DuplicateKey); } + pairs.push((key, value)); } + Ok(Value::Map(pairs)) } - if !need_canon { - return Ok((Value::Map(pairs), Vec::new(), pos)); - } - // Matches `encode_map`'s own rule exactly: sort entries by the - // key's encoded bytes, plain lexicographic `Ord` on `Vec`. - let mut order: Vec<(&Vec, usize)> = index_of_key.iter().map(|(k, &i)| (k, i)).collect(); - order.sort_by(|a, b| a.0.cmp(b.0)); - let mut canon = Vec::new(); - encode_head(5, order.len() as u64, &mut canon); - for (k, i) in order { - canon.extend_from_slice(k); - canon.extend_from_slice(&vals_canon[i]); +} + +/// An integer head's value, refused when its argument puts it outside -2^63 +/// to 2^63-1: an unsigned argument of 2^63 or more is above 2^63-1, and a +/// negative one of 2^63 or more is below -2^63. +fn integer(value: i128, arg: u64) -> Result { + if arg >= 1 << 63 { + return Err(DecodeError::IntegerOutOfRange); } - Ok((Value::Map(pairs), canon, pos)) + Ok(Value::Int(value)) } -/// IEEE 754 binary16 → f64. Subnormals (exp=0) and normals (1..=30) use -/// the standard formula; exp=31 (NaN/infinity) has no representation here -/// — matches the reference decoder, which has no clause for it either. -fn half_to_f64(half: u16) -> Result { - let sign: f64 = if (half >> 15) & 1 == 1 { -1.0 } else { 1.0 }; +/// IEEE 754 binary16 to f64, infinities and NaN included; the caller refuses +/// what is not finite. +fn half_to_f64(half: u16) -> f64 { + let sign = if half >> 15 == 1 { -1.0 } else { 1.0 }; let exp = (half >> 10) & 0x1F; - let frac = (half & 0x3FF) as f64; + let frac = f64::from(half & 0x3FF); match exp { - 0 => Ok(sign * 2f64.powi(-14) * (frac / 1024.0)), - 1..=30 => Ok(sign * 2f64.powi(exp as i32 - 15) * (1.0 + frac / 1024.0)), - _ => Err(DecodeError::UnrepresentableFloat), + 0 => sign * 2f64.powi(-24) * frac, + 31 if frac == 0.0 => sign * f64::INFINITY, + 31 => f64::NAN, + _ => sign * 2f64.powi(i32::from(exp) - 15) * (1.0 + frac / 1024.0), } } @@ -814,117 +698,12 @@ mod tests { ); } - #[test] - fn decode_rejects_tags() { - // Major type 6, additional info 0 — a tag, not part of this wire - // format. - assert_eq!(decode(&[0xC0]), Err(DecodeError::UnsupportedMajorType(6))); - } - #[test] fn decode_rejects_trailing_bytes() { // A valid `0` (0x00) followed by a stray byte. assert_eq!(decode(&[0x00, 0xFF]), Err(DecodeError::TrailingBytes)); } - #[test] - fn decode_rejects_truncated_input() { - // Major 0, AI 24 (one more byte expected) but the buffer ends. - assert_eq!(decode(&[0x18]), Err(DecodeError::Truncated)); - } - - /// Builds a payload of `depth` one-element-list wrappers (major 4, - /// AI 1 — a single byte, `0x81`, per level) around one terminal - /// scalar (`0x00`, the integer 0). Before `MAX_NESTING_DEPTH` existed, - /// decoding this crashed the whole process with a real stack - /// overflow (verified against this exact decoder pre-fix, on a - /// realistic 2 MiB worker-thread stack, at a nesting depth of only - /// 100_000 -- well under 1% of what a single 16 MiB wire frame could - /// carry) rather than returning a decode error. A stack overflow - /// aborts the process; it is not a `panic!` `#[should_panic]` can - /// catch, so the tests below only exercise the now-clean error path. - fn nested_list_payload(depth: usize) -> Vec { - let mut buf = vec![0x81u8; depth]; - buf.push(0x00); - buf - } - - #[test] - fn decode_accepts_nesting_at_the_depth_limit() { - let bytes = nested_list_payload(MAX_NESTING_DEPTH); - assert!(decode(&bytes).is_ok()); - } - - #[test] - fn decode_rejects_nesting_one_past_the_depth_limit() { - let bytes = nested_list_payload(MAX_NESTING_DEPTH + 1); - assert_eq!(decode(&bytes), Err(DecodeError::NestingTooDeep)); - } - - #[test] - fn decode_rejects_extreme_nesting_without_crashing() { - // Far beyond the limit, and far beyond what actually crashed the - // pre-fix decoder -- this is the direct regression test for the - // stack-overflow finding. If this test process crashes instead of - // completing, the depth guard has regressed. - let bytes = nested_list_payload(100_000); - assert_eq!(decode(&bytes), Err(DecodeError::NestingTooDeep)); - } - - #[test] - fn decode_duplicate_map_keys_last_write_wins() { - // Two entries both keyed "a" (0x61 0x61), values 1 then 2. - let bytes = hex("A2616101616102"); - let decoded = decode(&bytes).expect("valid map"); - match decoded { - Value::Map(pairs) => { - assert_eq!(pairs.len(), 1); - assert_eq!(pairs[0], (Value::text("a"), Value::Int(2))); - } - other => panic!("expected a map, got {other:?}"), - } - } - - /// A duplicate key in the middle of several distinct ones overwrites - /// in place — the duplicate's ORIGINAL insertion slot, not a new one - /// appended at the end — and every other key's position is - /// undisturbed. Guards `decode_map`'s HashMap-indexed dedup: it would - /// be easy for a faster implementation to accidentally reorder - /// entries or dedupe the wrong slot. - #[test] - fn decode_duplicate_map_key_overwrites_its_original_slot_not_the_end() { - let map = Value::Map(vec![ - (Value::text("a"), Value::Int(1)), - (Value::text("b"), Value::Int(2)), - (Value::text("c"), Value::Int(3)), - ]); - let mut bytes = encode(&map).expect("encodable"); - // Append one more entry, "b" -> 99, so the wire form has 4 - // entries with "b" duplicated -- can't build this through - // `encode` directly since it only ever emits already-deduped - // maps; construct the extra entry's bytes by hand and bump the - // map's own entry count (the map header's low nibble, byte 0). - assert_eq!(bytes[0] & 0x1F, 3, "expected a 3-entry map header"); - bytes[0] = (bytes[0] & 0xE0) | 4; - bytes.extend_from_slice(&encode(&Value::text("b")).unwrap()); - bytes.extend_from_slice(&encode(&Value::Int(99)).unwrap()); - - let decoded = decode(&bytes).expect("valid map"); - match decoded { - Value::Map(pairs) => { - assert_eq!( - pairs, - vec![ - (Value::text("a"), Value::Int(1)), - (Value::text("b"), Value::Int(99)), - (Value::text("c"), Value::Int(3)), - ] - ); - } - other => panic!("expected a map, got {other:?}"), - } - } - /// Regression guard for a real bug: `decode_map`'s duplicate-key /// check used to be a `Value`-equality linear scan over every entry /// decoded so far, making decode O(n^2) in entry count. A single @@ -959,59 +738,6 @@ mod tests { ); } - /// A second, narrower regression this same bug had once already: - /// the first attempt at fixing the flat-map O(n^2) case above - /// re-encoded each key fresh (`encode(&k)`) to find its slot, which - /// fixed the flat case but reintroduced unbounded work for a map - /// whose KEY is itself a large nested structure -- re-encoding a - /// key from scratch at every ancestor level costs O(depth × key - /// size), and a `MAX_NESTING_DEPTH`-deep chain of single-entry maps - /// keyed by a large blob took real, measured tens of seconds even - /// though it's well under `frame::MAX_FRAME_BYTES`. This decodes a - /// nesting-depth-limit-deep chain wrapping a multi-megabyte blob key - /// in well under a second; if key canonicalization regresses to - /// re-deriving a key's bytes at every ancestor level instead of - /// reusing what decoding that key already computed, this test will - /// time out long before it fails its assertions. - #[test] - fn decode_map_with_a_large_deeply_nested_key_is_not_quadratic_in_depth() { - // `MAX_NESTING_DEPTH` copies of "a 1-entry map wrapping...", - // around one 4 MiB byte-string key, each level's own map then - // valued at `Int(0)` (innermost first). - let blob_len = 512 * 1024; - let mut bytes = vec![0xA1u8; MAX_NESTING_DEPTH]; - bytes.push(0x5A); // major 2 (bytes), AI 26 -> 4-byte length follows - bytes.extend_from_slice(&(blob_len as u32).to_be_bytes()); - bytes.extend(std::iter::repeat_n(0x41u8, blob_len)); - bytes.extend(std::iter::repeat_n(0x00u8, MAX_NESTING_DEPTH)); - - let start = std::time::Instant::now(); - let decoded = decode(&bytes).expect("valid, maximally-nested map-key chain"); - let elapsed = start.elapsed(); - - // Sanity: really did decode the full nested-map chain down to - // the 4 MiB blob at its center, not bail out early on a - // malformed payload. `0xA1` nests a 1-entry map as each level's - // KEY, so the blob is `MAX_NESTING_DEPTH` levels of `Map` down. - let mut cursor = &decoded; - for _ in 0..MAX_NESTING_DEPTH { - match cursor { - Value::Map(pairs) if pairs.len() == 1 => cursor = &pairs[0].0, - other => panic!("expected a 1-entry map at this nesting level, got {other:?}"), - } - } - match cursor { - Value::Bytes(b) => assert_eq!(b.len(), blob_len), - other => panic!("expected the innermost key to be Bytes, got {other:?}"), - } - assert!( - elapsed < std::time::Duration::from_secs(5), - "decoding a {MAX_NESTING_DEPTH}-deep map-key chain around a {blob_len}-byte blob \ - took {elapsed:?} -- looks like key canonicalization regressed to re-deriving a \ - key's bytes at every ancestor level instead of reusing decode_one's own" - ); - } - #[test] fn get_finds_a_field_by_text_key() { let map = Value::Map(vec![(Value::text("a"), Value::Int(1))]); diff --git a/src/cert.rs b/src/cert.rs deleted file mode 100644 index 54c9d13..0000000 --- a/src/cert.rs +++ /dev/null @@ -1,287 +0,0 @@ -//! TLS trust for dialing a macula-station: pubkey-pin verification, -//! ported from `native/macula_quic/src/cert.rs` (`macula-io/macula`). -//! -//! A station presents a self-signed X.509 cert wrapping its macula -//! identity's Ed25519 public key. A client that already knows which -//! station it's dialing (from DHT records, pre-shared relay identities, -//! or — for a mobile client — configuration) verifies by comparing the -//! cert's SubjectPublicKeyInfo to that known pubkey directly. No CA -//! chain, no DNS-anchored trust: the pubkey **is** the identity. -//! -//! Unlike the Erlang SDK's own `native/macula_quic`, this crate never -//! needs to *generate* a cert — macula uses `with_no_client_auth()` -//! throughout, so a dialing client presents no TLS certificate of its -//! own at all. Identity/authentication happens entirely at the -//! application layer (the CONNECT/HELLO frame's Ed25519 signature — see -//! `plans/PLAN_WIRE_PROTOCOL.md` §2, §5), not via mutual TLS. This -//! module is verification-only. - -use std::sync::Arc; - -use rustls::pki_types::CertificateDer; - -/// Extract the 32-byte Ed25519 public key from a DER-encoded X.509 -/// certificate's SubjectPublicKeyInfo. Errors if the SPKI algorithm -/// isn't Ed25519 (OID `1.3.101.112`) or the key isn't 32 bytes. -pub fn ed25519_pubkey_from_cert(der: &[u8]) -> Result<[u8; 32], String> { - let (_, cert) = - x509_parser::parse_x509_certificate(der).map_err(|e| format!("parse cert: {e}"))?; - let spki = cert.public_key(); - let alg_oid = &spki.algorithm.algorithm; - if alg_oid.to_id_string() != "1.3.101.112" { - return Err(format!( - "expected Ed25519 SPKI, got OID {}", - alg_oid.to_id_string() - )); - } - let pk = spki.subject_public_key.data.as_ref(); - pk.try_into() - .map_err(|_| format!("Ed25519 pubkey must be 32 bytes, got {}", pk.len())) -} - -/// Custom rustls `ServerCertVerifier` that pins on the leaf cert's -/// SubjectPublicKeyInfo Ed25519 pubkey rather than walking a CA chain — -/// the pragmatic equivalent of TLS raw-public-key (RFC 7250) without -/// changing the wire protocol. No expiry check, no SAN check, no CA -/// chain: matches the reference verifier exactly, including its -/// intentional narrowness. -#[derive(Debug)] -pub struct PubkeyPinVerifier { - pinned: [u8; 32], - crypto: Arc, -} - -impl PubkeyPinVerifier { - /// Verifies handshake signatures with `macula-pqc`'s provider, the one - /// every connection this crate dials runs on. - pub fn new(pinned_pubkey: [u8; 32]) -> Self { - Self { - pinned: pinned_pubkey, - crypto: macula_pqc::client_builder().crypto_provider().clone(), - } - } -} - -impl rustls::client::danger::ServerCertVerifier for PubkeyPinVerifier { - fn verify_server_cert( - &self, - end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &rustls::pki_types::ServerName<'_>, - _ocsp_response: &[u8], - _now: rustls::pki_types::UnixTime, - ) -> Result { - let presented = ed25519_pubkey_from_cert(end_entity.as_ref()) - .map_err(|e| rustls::Error::General(format!("pubkey extract: {e}")))?; - - if presented == self.pinned { - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } else { - Err(rustls::Error::General(format!( - "pubkey mismatch: pinned={} presented={}", - hex(&self.pinned), - hex(&presented) - ))) - } - } - - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, - ) -> Result { - // Ed25519 leaves are TLS 1.3 only; if a 1.2 path somehow - // triggers this, accept — the cert match above is what anchors - // trust, matching the reference verifier's own reasoning. - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - message: &[u8], - cert: &CertificateDer<'_>, - dss: &rustls::DigitallySignedStruct, - ) -> Result { - // Defer to the crypto provider: this verifies `dss` was really - // produced by the leaf cert's own private key, i.e. that the - // peer we pinned by pubkey is the one actually completing this - // handshake, not just quoting someone else's cert. - rustls::crypto::verify_tls13_signature( - message, - cert, - dss, - &self.crypto.signature_verification_algorithms, - ) - } - - fn supported_verify_schemes(&self) -> Vec { - self.crypto - .signature_verification_algorithms - .supported_schemes() - } -} - -/// Skips all server certificate verification. **Development/diagnostic -/// use only** — establishes transport connectivity with no identity -/// guarantee whatsoever. Never use this to dial a station you intend to -/// trust; use [`PubkeyPinVerifier`] once the station's identity is -/// known, matching the reference's own `verify => none` mode. -#[derive(Debug)] -pub struct SkipServerVerification(Arc); - -impl SkipServerVerification { - /// States the signature schemes of `macula-pqc`'s provider, the one every - /// connection this crate dials runs on. - pub fn new() -> Self { - Self(macula_pqc::client_builder().crypto_provider().clone()) - } -} - -impl Default for SkipServerVerification { - fn default() -> Self { - Self::new() - } -} - -impl rustls::client::danger::ServerCertVerifier for SkipServerVerification { - fn verify_server_cert( - &self, - _end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &rustls::pki_types::ServerName<'_>, - _ocsp_response: &[u8], - _now: rustls::pki_types::UnixTime, - ) -> Result { - Ok(rustls::client::danger::ServerCertVerified::assertion()) - } - - fn verify_tls12_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, - ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn verify_tls13_signature( - &self, - _message: &[u8], - _cert: &CertificateDer<'_>, - _dss: &rustls::DigitallySignedStruct, - ) -> Result { - Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) - } - - fn supported_verify_schemes(&self) -> Vec { - self.0.signature_verification_algorithms.supported_schemes() - } -} - -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{b:02x}")).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use rustls::client::danger::ServerCertVerifier; - - #[test] - fn ed25519_pubkey_extraction_rejects_garbage_der() { - assert!(ed25519_pubkey_from_cert(b"not a certificate").is_err()); - } - - #[test] - fn pinned_verifier_stores_the_exact_bytes_given() { - let pin = [0x42u8; 32]; - let verifier = PubkeyPinVerifier::new(pin); - assert_eq!(verifier.pinned, pin); - } - - /// A synthetic self-signed Ed25519 cert, shaped like what a station - /// running pubkey-pinned trust actually presents — generated with - /// `rcgen` purely for this test (not a runtime dependency, see the - /// module doc). No live station reachable from this crate's test - /// suite happens to be configured this way (the reachable demo - /// fleet all uses `Trust::WebPki` — see `tests/live_station.rs`), so - /// this is the only way to exercise `PubkeyPinVerifier`'s actual - /// matching logic end-to-end without one. - fn synthetic_ed25519_cert() -> (rustls::pki_types::CertificateDer<'static>, [u8; 32]) { - let key_pair = rcgen::KeyPair::generate_for(&rcgen::PKCS_ED25519).expect("keygen"); - let params = rcgen::CertificateParams::new(Vec::::new()).expect("params"); - let cert = params.self_signed(&key_pair).expect("self-sign"); - let der = rustls::pki_types::CertificateDer::from(cert.der().to_vec()); - let pubkey = - ed25519_pubkey_from_cert(der.as_ref()).expect("our own synthetic cert must parse"); - (der, pubkey) - } - - fn fake_server_name() -> rustls::pki_types::ServerName<'static> { - rustls::pki_types::ServerName::try_from("station.example").expect("valid server name") - } - - #[test] - fn extracts_the_real_pubkey_from_a_synthetic_cert() { - // Not asserting a specific value — proves the extraction path - // works end-to-end against a real (if synthetic) cert, distinct - // from `ed25519_pubkey_extraction_rejects_garbage_der`'s - // negative case. - let (_der, pubkey) = synthetic_ed25519_cert(); - assert_ne!( - pubkey, [0u8; 32], - "a real generated key should not be all-zero" - ); - } - - #[test] - fn verify_server_cert_accepts_the_pinned_key() { - let (der, pubkey) = synthetic_ed25519_cert(); - let verifier = PubkeyPinVerifier::new(pubkey); - let result = verifier.verify_server_cert( - &der, - &[], - &fake_server_name(), - &[], - rustls::pki_types::UnixTime::now(), - ); - assert!( - result.is_ok(), - "pinning the cert's real key must succeed: {result:?}" - ); - } - - #[test] - fn verify_server_cert_rejects_a_mismatched_key() { - let (der, pubkey) = synthetic_ed25519_cert(); - let mut wrong = pubkey; - wrong[0] ^= 0xFF; - let verifier = PubkeyPinVerifier::new(wrong); - let result = verifier.verify_server_cert( - &der, - &[], - &fake_server_name(), - &[], - rustls::pki_types::UnixTime::now(), - ); - assert!(result.is_err(), "pinning the WRONG key must fail closed"); - } - - #[test] - fn skip_verification_accepts_anything() { - let (der, _pubkey) = synthetic_ed25519_cert(); - let verifier = SkipServerVerification::new(); - let result = verifier.verify_server_cert( - &der, - &[], - &fake_server_name(), - &[], - rustls::pki_types::UnixTime::now(), - ); - assert!( - result.is_ok(), - "insecure mode must accept any cert, by design" - ); - } -} diff --git a/src/cert_chain.rs b/src/cert_chain.rs deleted file mode 100644 index cb9097f..0000000 --- a/src/cert_chain.rs +++ /dev/null @@ -1,479 +0,0 @@ -//! Direct-dial dual-trust (Slice 7c Direction B) — X.509 cert chain. -//! -//! Managed realms root trust in the realm CA, not in the (keyless) realm -//! tag. A provider embeds its own service-cert chain (leaf ++ org CA, PEM) -//! in its `procedure_advertisement`; a verifying consumer chains it to the -//! realm CA it received at its own issuance. No publisher records, no live -//! authority — the trust material already travels with the advertisement. -//! -//! Ported from `macula_record.erl`'s `verify_advertisement_cert_chain/3` -//! (and its `cert_chain_step_*`/`pem_cert_ders`/`cert_subject_pubkey`/ -//! `validate_path`/`cert_org` helpers, in `src/record/macula_record.erl`) -//! — same algorithm, using `rustls-webpki`'s native path validation instead -//! of hand-rolling `pkix_path_validation`, and `x509-parser` (already used -//! by [`crate::cert`] for the unrelated TLS pubkey-pinning tier) for field -//! extraction. Cross-checked against `macula-go`'s own port -//! (`dht/cert_chain.go`), which uses `crypto/x509`'s native path validation -//! the same way. Opt-in: this has no effect on plain (non-cert-chain) -//! direct-dial, which remains exactly as it was. -//! -//! **Not the same trust tier as [`crate::cert`].** That module verifies a -//! TLS pubkey pin for dialing a KNOWN station — no CA chain, no org -//! semantics, the pubkey IS the identity. This module verifies a resolved -//! advertisement's embedded X.509 chain proves its signer belongs to a -//! specific org, authorized by a realm CA the caller already trusts — a -//! higher, separate, opt-in tier that only matters once direct-dial itself -//! exists. - -use rustls::pki_types::{CertificateDer, UnixTime}; - -use crate::dht::{self, Record}; - -/// Mirrors `macula_record.erl`'s six `cert_chain_step_*` failure atoms -/// (`advertisement_bad_signature`, `no_cert_chain`, `cert_chain_undecodable`, -/// `cert_key_mismatch`, `cert_chain_untrusted`/`{bad_cert, _}`, -/// `cert_org_mismatch`) as distinguishable variants (test with -/// `matches!`/`==`) — never silently treat an unauthorized advertisement as -/// trusted. -#[derive(Debug, PartialEq, Eq)] -pub enum CertChainError { - /// The advertisement's own Ed25519 envelope signature does not verify — - /// checked BEFORE the cert chain is even examined, since nothing in an - /// unverified record can be trusted. Also covers the (practically - /// unreachable once the envelope verifies) case of a structurally - /// malformed `procedure_advertisement` payload — `macula_record.erl` - /// itself has no distinct atom for that case either, since it can't - /// occur without the signer having signed garbage in the first place. - BadSignature, - /// `cert_chain` is absent — the common, unmanaged-realm case. Not - /// itself a sign of tampering; callers that require managed-realm - /// authorization should treat this as "not authorized," not as - /// evidence of an attack. - Absent, - /// `cert_chain` is present but not a decodable PEM bundle containing at - /// least one certificate. - Undecodable, - /// The leaf certificate's Ed25519 subject public key does not match the - /// advertisement's own signing key — the chain does not actually belong - /// to whoever signed this record. - KeyMismatch, - /// The chain does not validate to the given realm CA (expired, wrong - /// issuer, broken path, etc.). - Untrusted, - /// The chain validates, but the leaf certificate's Organization (O) - /// does not match the procedure's expected org segment — a - /// validly-signed cert for the WRONG org, i.e. a squat. - OrgMismatch, -} - -impl std::fmt::Display for CertChainError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let msg = match self { - CertChainError::BadSignature => "advertisement signature does not verify", - CertChainError::Absent => "advertisement carries no cert_chain", - CertChainError::Undecodable => "cert_chain is not a decodable PEM certificate bundle", - CertChainError::KeyMismatch => { - "leaf cert public key does not match the advertisement's signer" - } - CertChainError::Untrusted => "cert chain does not validate to the trusted realm CA", - CertChainError::OrgMismatch => "leaf cert organization does not match the expected org", - }; - write!(f, "cert_chain: {msg}") - } -} - -impl std::error::Error for CertChainError {} - -/// Verifies a resolved `procedure_advertisement` record's embedded X.509 -/// service-cert chain against a trusted realm CA, for Slice 7c Direction B -/// managed-realm authorization. -/// -/// `realm_ca_pem` is the realm CA the caller already trusts (obtained at -/// its own issuance, out of band — never resolved from the mesh itself). -/// `rec` is a resolved `procedure_advertisement`. `expected_org` is the -/// `` segment of the procedure URI the caller intended to reach. -/// -/// Passes (returns `Ok(())`) only when: `rec`'s own envelope signature -/// verifies; `rec` carries a `cert_chain`; the chain decodes to at least -/// one certificate; the leaf certificate's Ed25519 subject public key -/// equals `rec`'s signing key (`rec.key`); the leaf chains to -/// `realm_ca_pem`; and the leaf's Organization RDN equals `expected_org`. -pub fn verify_advertisement_cert_chain( - realm_ca_pem: &[u8], - rec: &Record, - expected_org: &str, -) -> Result<(), CertChainError> { - dht::verify(rec).map_err(|_| CertChainError::BadSignature)?; - let adv = dht::read_procedure_advertisement(rec).map_err(|_| CertChainError::BadSignature)?; - let Some(chain_pem) = adv.cert_chain else { - return Err(CertChainError::Absent); - }; - - let chain_der = decode_cert_chain(&chain_pem)?; - let leaf_der = &chain_der[0]; - - let leaf_key = - crate::cert::ed25519_pubkey_from_cert(leaf_der).map_err(|_| CertChainError::KeyMismatch)?; - if leaf_key != rec.key { - return Err(CertChainError::KeyMismatch); - } - - validate_cert_path(realm_ca_pem, &chain_der)?; - - let leaf_org = leaf_organization(leaf_der).ok_or(CertChainError::OrgMismatch)?; - if leaf_org != expected_org { - return Err(CertChainError::OrgMismatch); - } - Ok(()) -} - -/// Decodes a leaf-first PEM bundle (as embedded: leaf ++ org CA ++ ...) -/// into DER certificates, leaf-first, matching `macula_record`'s -/// `pem_cert_ders/1`. -fn decode_cert_chain(cert_chain_pem: &[u8]) -> Result>, CertChainError> { - let ders: Vec> = x509_parser::pem::Pem::iter_from_buffer(cert_chain_pem) - .filter_map(Result::ok) - .filter(|pem| pem.label == "CERTIFICATE") - .map(|pem| pem.contents) - .collect(); - if ders.is_empty() { - return Err(CertChainError::Undecodable); - } - Ok(ders) -} - -/// The Organization (O) RDN of a leaf cert's Subject, or `None` if absent -/// or unreadable as a string. -fn leaf_organization(der: &[u8]) -> Option { - let (_, cert) = x509_parser::parse_x509_certificate(der).ok()?; - let org = cert - .subject() - .iter_organization() - .next() - .and_then(|attr| attr.as_str().ok()) - .map(str::to_owned); - org -} - -/// Validates `chain` (leaf-first: `[leaf, org_ca, ...]`) to `realm_ca_pem` -/// as trust anchor, with no hostname/SAN check (unlike `crate::cert`'s -/// `ServerCertVerifier` machinery) — matches `macula_record`'s -/// `validate_path/2`, which hands Erlang's `pkix_path_validation` the same -/// leaf..anchor chain and no name constraint of its own either. -fn validate_cert_path(realm_ca_pem: &[u8], chain: &[Vec]) -> Result<(), CertChainError> { - let anchor_ders = decode_cert_chain(realm_ca_pem).map_err(|_| CertChainError::Untrusted)?; - let anchor_der = CertificateDer::from(anchor_ders[0].clone()); - let anchor = - webpki::anchor_from_trusted_cert(&anchor_der).map_err(|_| CertChainError::Untrusted)?; - - let leaf_der = CertificateDer::from(chain[0].clone()); - let end_entity = - webpki::EndEntityCert::try_from(&leaf_der).map_err(|_| CertChainError::Untrusted)?; - let intermediates: Vec = chain[1..] - .iter() - .map(|der| CertificateDer::from(der.clone())) - .collect(); - - // KeyUsage::server_auth() is `required_if_present` — it does not - // require the leaf to carry an EKU extension at all (macula's - // self-issued service certs typically don't), it only rejects a leaf - // that declares an EKU set excluding server_auth. - end_entity - .verify_for_usage( - &[webpki::ring::ED25519], - std::slice::from_ref(&anchor), - &intermediates, - UnixTime::now(), - webpki::KeyUsage::server_auth(), - None, - None, - ) - .map_err(|_| CertChainError::Untrusted)?; - Ok(()) -} - -/// Test certificates for cert-chain authorization: a realm CA, a leaf it -/// issues for an advertiser key and org, and a leaf-first PEM bundle. -/// Shared by this module's tests and `direct_dial`'s. -#[cfg(test)] -pub(crate) mod fixtures { - use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair as RcgenKeyPair}; - - pub(crate) fn test_ca() -> (Vec, rcgen::Issuer<'static, RcgenKeyPair>) { - let key_pair = RcgenKeyPair::generate_for(&rcgen::PKCS_ED25519).expect("ca keygen"); - let mut params = CertificateParams::new(Vec::::new()).expect("ca params"); - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "Test Realm CA"); - dn.push(DnType::OrganizationName, "Test Realm CA"); - params.distinguished_name = dn; - params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); - params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1); - params.not_after = time::OffsetDateTime::now_utc() + time::Duration::hours(24); - let cert = params.self_signed(&key_pair).expect("ca self-sign"); - let pem = cert.pem().into_bytes(); - (pem, rcgen::Issuer::new(params, key_pair)) - } - - pub(crate) fn test_leaf( - ca_issuer: &rcgen::Issuer<'static, RcgenKeyPair>, - advertiser_pub: [u8; 32], - org: &str, - not_after: time::OffsetDateTime, - ) -> Vec { - let subject_spki = rcgen::SubjectPublicKeyInfo::from_der(&ed25519_spki_der(advertiser_pub)) - .expect("advertiser SPKI"); - let mut params = CertificateParams::new(Vec::::new()).expect("leaf params"); - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "test-service"); - dn.push(DnType::OrganizationName, org); - params.distinguished_name = dn; - params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1); - params.not_after = not_after; - let cert = params - .signed_by(&subject_spki, ca_issuer) - .expect("leaf signed_by"); - cert.der().to_vec() - } - - /// DER-encodes a raw 32-byte Ed25519 public key as a SubjectPublicKeyInfo - /// structure (RFC 8410): `SEQUENCE { SEQUENCE { OID 1.3.101.112 }, - /// BIT STRING key }`. rcgen needs this to build a leaf cert whose - /// subject key is a SPECIFIC pre-existing key (the advertiser's own - /// node identity), not a freshly rcgen-generated one. - fn ed25519_spki_der(pubkey: [u8; 32]) -> Vec { - let mut der = vec![ - 0x30, 0x2a, // SEQUENCE, 42 bytes - 0x30, 0x05, // SEQUENCE, 5 bytes (AlgorithmIdentifier) - 0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112 (Ed25519) - 0x03, 0x21, 0x00, // BIT STRING, 33 bytes, 0 unused bits - ]; - der.extend_from_slice(&pubkey); - der - } - - pub(crate) fn pem_bundle(ders: &[Vec]) -> Vec { - let mut out = Vec::new(); - for der in ders { - let b64 = base64_std_encode(der); - out.extend_from_slice(b"-----BEGIN CERTIFICATE-----\n"); - for chunk in b64.as_bytes().chunks(64) { - out.extend_from_slice(chunk); - out.push(b'\n'); - } - out.extend_from_slice(b"-----END CERTIFICATE-----\n"); - } - out - } - - fn base64_std_encode(data: &[u8]) -> String { - use base64::Engine; - base64::engine::general_purpose::STANDARD.encode(data) - } -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use super::fixtures::{pem_bundle, test_ca, test_leaf}; - use super::*; - use crate::dht; - use crate::identity::KeyPair; - - fn advertiser_and_station() -> (KeyPair, KeyPair) { - (KeyPair::generate(), KeyPair::generate()) - } - - #[test] - fn valid_chain_verifies_and_authorizes() { - let (ca_pem, ca_issuer) = test_ca(); - let (advertiser, station) = advertiser_and_station(); - let leaf_der = test_leaf( - &ca_issuer, - advertiser.node_id(), - "acme-corp", - time::OffsetDateTime::now_utc() + time::Duration::hours(1), - ); - - let rec = dht::new_procedure_advertisement_with_cert_chain( - advertiser.node_id(), - "0000/acme-corp/widget.build_v1", - station.node_id(), - Duration::from_secs(3600), - pem_bundle(&[leaf_der]), - ); - let rec = dht::sign(rec, &advertiser); - - assert_eq!( - verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"), - Ok(()) - ); - } - - #[test] - fn absent_chain_is_reported_distinctly() { - let (advertiser, station) = advertiser_and_station(); - let rec = dht::new_procedure_advertisement( - advertiser.node_id(), - "0000/acme-corp/widget.build_v1", - station.node_id(), - Duration::from_secs(3600), - ); - let rec = dht::sign(rec, &advertiser); - let (ca_pem, _) = test_ca(); - - assert_eq!( - verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"), - Err(CertChainError::Absent) - ); - } - - #[test] - fn bad_envelope_signature_is_checked_before_the_chain() { - let (ca_pem, ca_issuer) = test_ca(); - let (advertiser, station) = advertiser_and_station(); - let leaf_der = test_leaf( - &ca_issuer, - advertiser.node_id(), - "acme-corp", - time::OffsetDateTime::now_utc() + time::Duration::hours(1), - ); - let rec = dht::new_procedure_advertisement_with_cert_chain( - advertiser.node_id(), - "0000/acme-corp/widget.build_v1", - station.node_id(), - Duration::from_secs(3600), - pem_bundle(&[leaf_der]), - ); - let mut rec = dht::sign(rec, &advertiser); - rec.signature[0] ^= 0xFF; - - assert_eq!( - verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"), - Err(CertChainError::BadSignature) - ); - } - - #[test] - fn leaf_key_not_matching_the_signer_is_rejected() { - let (ca_pem, ca_issuer) = test_ca(); - let (advertiser, station) = advertiser_and_station(); - let other = KeyPair::generate(); - // Leaf binds `other`'s key, but the advertisement is signed by - // `advertiser` -- the chain does not belong to this record's signer. - let leaf_der = test_leaf( - &ca_issuer, - other.node_id(), - "acme-corp", - time::OffsetDateTime::now_utc() + time::Duration::hours(1), - ); - let rec = dht::new_procedure_advertisement_with_cert_chain( - advertiser.node_id(), - "0000/acme-corp/widget.build_v1", - station.node_id(), - Duration::from_secs(3600), - pem_bundle(&[leaf_der]), - ); - let rec = dht::sign(rec, &advertiser); - - assert_eq!( - verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"), - Err(CertChainError::KeyMismatch) - ); - } - - #[test] - fn wrong_org_is_rejected_after_a_valid_chain() { - let (ca_pem, ca_issuer) = test_ca(); - let (advertiser, station) = advertiser_and_station(); - let leaf_der = test_leaf( - &ca_issuer, - advertiser.node_id(), - "acme-corp", - time::OffsetDateTime::now_utc() + time::Duration::hours(1), - ); - let rec = dht::new_procedure_advertisement_with_cert_chain( - advertiser.node_id(), - "0000/other-org/widget.build_v1", - station.node_id(), - Duration::from_secs(3600), - pem_bundle(&[leaf_der]), - ); - let rec = dht::sign(rec, &advertiser); - - assert_eq!( - verify_advertisement_cert_chain(&ca_pem, &rec, "other-org"), - Err(CertChainError::OrgMismatch) - ); - } - - #[test] - fn expired_leaf_is_untrusted() { - let (ca_pem, ca_issuer) = test_ca(); - let (advertiser, station) = advertiser_and_station(); - let leaf_der = test_leaf( - &ca_issuer, - advertiser.node_id(), - "acme-corp", - time::OffsetDateTime::now_utc() - time::Duration::hours(1), - ); - let rec = dht::new_procedure_advertisement_with_cert_chain( - advertiser.node_id(), - "0000/acme-corp/widget.build_v1", - station.node_id(), - Duration::from_secs(3600), - pem_bundle(&[leaf_der]), - ); - let rec = dht::sign(rec, &advertiser); - - assert_eq!( - verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"), - Err(CertChainError::Untrusted) - ); - } - - #[test] - fn chain_signed_by_a_different_ca_is_untrusted() { - let (_, ca_issuer) = test_ca(); - let (other_ca_pem, _) = test_ca(); - let (advertiser, station) = advertiser_and_station(); - let leaf_der = test_leaf( - &ca_issuer, - advertiser.node_id(), - "acme-corp", - time::OffsetDateTime::now_utc() + time::Duration::hours(1), - ); - let rec = dht::new_procedure_advertisement_with_cert_chain( - advertiser.node_id(), - "0000/acme-corp/widget.build_v1", - station.node_id(), - Duration::from_secs(3600), - pem_bundle(&[leaf_der]), - ); - let rec = dht::sign(rec, &advertiser); - - assert_eq!( - verify_advertisement_cert_chain(&other_ca_pem, &rec, "acme-corp"), - Err(CertChainError::Untrusted) - ); - } - - #[test] - fn undecodable_chain_is_reported_distinctly() { - let (ca_pem, _) = test_ca(); - let (advertiser, station) = advertiser_and_station(); - let rec = dht::new_procedure_advertisement_with_cert_chain( - advertiser.node_id(), - "0000/acme-corp/widget.build_v1", - station.node_id(), - Duration::from_secs(3600), - b"not a pem cert bundle".to_vec(), - ); - let rec = dht::sign(rec, &advertiser); - - assert_eq!( - verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"), - Err(CertChainError::Undecodable) - ); - } -} diff --git a/src/connection.rs b/src/connection.rs deleted file mode 100644 index 3ffd3e4..0000000 --- a/src/connection.rs +++ /dev/null @@ -1,1666 +0,0 @@ -//! The CONNECT/HELLO handshake and the application-frame stream -//! abstraction, ported from `src/peering/macula_peering_conn.erl` -//! (`macula-io/macula`) — see `plans/PLAN_WIRE_PROTOCOL.md` §3. -//! -//! Only the client role's `connecting -> handshaking -> connected` path -//! is implemented. [`Session`] is the handshaked connection: one reader -//! routes every frame on its control stream, so calls, subscriptions and -//! serving run on it at the same time. [`FrameStream`] is the "send/receive -//! signed application frames on one QUIC stream" primitive that -//! [`Session::open_dedicated_stream`] hands out for content transfer (§12) -//! and streaming RPC (§13), which both run on dedicated streams rather than -//! the control stream. - -use std::collections::HashMap; -use std::sync::{Arc, Weak}; -use std::time::Duration; - -use crate::bolt4; -use crate::cbor::Value; -use crate::control_channel::{self, Channel}; -use crate::frame::{self, Decoded, HelloInfo}; -use crate::identity::KeyPair; -use crate::transport::{self, ConnectError, Trust}; - -pub use crate::control_channel::{ - CallError, RecvEventError, SendError, SessionEndReason, Subscription, -}; - -/// A boxed, `'static` future — hand-rolled rather than pulling in the -/// `futures` crate for one type alias. -pub type BoxFuture<'a, T> = std::pin::Pin + Send + 'a>>; - -/// Answers one inbound CALL. `Ok(payload)` sends a RESULT; `Err(reason)` -/// sends an ERROR (BOLT#4 `unknown_error`, `detail = reason`); a panic -/// inside the handler (caught via [`tokio::spawn`], the same "one -/// transient task per call" shape `macula_station_link.erl` uses one -/// process per call for) is sent as ERROR `temporary_relay_failure` — -/// matching that module's own `safe_invoke_handler/4` mapping exactly -/// (including sending no `detail` on a crash, since the reference -/// doesn't either — it only logs locally). -/// -/// A map payload arrives with the caller's 32-byte node id under -/// `"caller"`: the caller the CALL's signature was verified against, -/// replacing any `"caller"` the sender put in the payload. A payload that -/// isn't a map arrives unchanged and carries no caller. -pub type CallHandler = - Arc BoxFuture<'static, Result> + Send + Sync>; - -/// Matches `HANDSHAKE_TIMEOUT_MS` in `macula_peering_conn.erl`: CONNECT -/// -> HELLO is sub-second on a healthy peer; this is generous. The most -/// common real-world trigger for hitting it is a protocol version -/// mismatch — bytes accumulate but never form a valid frame, so the -/// station-side symptom and this crate's symptom are the same shape. -pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); - -/// Default timeout for a single CALL awaiting its RESULT/ERROR. Not from -/// the reference source (macula's own CALL timeout is caller-supplied -/// per-call via `deadline_ms` inside the frame itself, not a transport- -/// level default) — a reasonable local default for this crate's API. -pub const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(30); - -/// Bound on a single read from a QUIC stream while accumulating a frame. -/// Not a protocol limit — just how much to ask the stream for at once; -/// `frame::decode`'s own `MAX_FRAME_BYTES` is the real cap. -const READ_CHUNK: usize = 64 * 1024; - -// --------------------------------------------------------------------- -// FrameStream — send/receive signed application frames on one dedicated -// QUIC stream (content transfer, streaming RPC). -// --------------------------------------------------------------------- - -pub struct FrameStream { - send: quinn::SendStream, - recv: quinn::RecvStream, - /// Bytes read but not yet consumed by a decoded frame — carried - /// over between reads so nothing is ever dropped. - buf: Vec, -} - -#[derive(Debug)] -pub enum SendFrameError { - Encode(frame::EncodeFrameError), - Write(quinn::WriteError), -} - -impl std::fmt::Display for SendFrameError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SendFrameError::Encode(e) => write!(f, "encoding frame: {e}"), - SendFrameError::Write(e) => write!(f, "writing to stream: {e}"), - } - } -} - -impl std::error::Error for SendFrameError {} - -#[derive(Debug)] -pub enum RecvFrameError { - Read(quinn::ReadError), - StreamClosed, - Decode(frame::DecodeFrameError), - Timeout, -} - -impl std::fmt::Display for RecvFrameError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - RecvFrameError::Read(e) => write!(f, "reading from stream: {e}"), - RecvFrameError::StreamClosed => write!(f, "peer closed the stream"), - RecvFrameError::Decode(e) => write!(f, "decoding a frame: {e}"), - RecvFrameError::Timeout => write!(f, "timed out waiting for a frame"), - } - } -} - -impl std::error::Error for RecvFrameError {} - -/// Why a CALL on a dedicated stream got no reply. -#[derive(Debug)] -pub enum StreamCallError { - Send(SendFrameError), - Recv(RecvFrameError), -} - -impl std::fmt::Display for StreamCallError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - StreamCallError::Send(e) => write!(f, "sending CALL: {e}"), - StreamCallError::Recv(e) => write!(f, "awaiting RESULT/ERROR: {e}"), - } - } -} - -impl std::error::Error for StreamCallError {} - -impl FrameStream { - pub(crate) fn new(send: quinn::SendStream, recv: quinn::RecvStream) -> Self { - Self { - send, - recv, - buf: Vec::new(), - } - } - - /// Any bytes already read past the last decoded frame. - pub fn leftover_bytes(&self) -> &[u8] { - &self.buf - } - - /// Aborts both halves with `code`, writing nothing: RESET_STREAM on the - /// send half and STOP_SENDING on the receive half. Stopping has to be - /// explicit, since a receive half dropped unread sends STOP_SENDING with - /// code 0. - pub(crate) fn abort_both(mut self, code: u32) { - let code = quinn::VarInt::from_u32(code); - let _ = self.send.reset(code); - let _ = self.recv.stop(code); - } - - /// Finishes the send half, so what was written still reaches the peer, - /// and stops the receive half with `code`. - pub(crate) fn finish_and_stop_reading(mut self, code: u32) { - let _ = self.send.finish(); - let _ = self.recv.stop(quinn::VarInt::from_u32(code)); - } - - pub async fn send_frame(&mut self, frame: Value) -> Result<(), SendFrameError> { - let encoded = frame::encode(&frame).map_err(SendFrameError::Encode)?; - self.send - .write_all(&encoded) - .await - .map_err(SendFrameError::Write) - } - - /// Read the next complete application frame, using (and updating) - /// any bytes already buffered. - pub async fn recv_frame(&mut self) -> Result { - let mut chunk = vec![0u8; READ_CHUNK]; - loop { - match frame::decode(&self.buf) { - Ok(Decoded::Frame(value, consumed)) => { - self.buf.drain(..consumed); - return Ok(value); - } - Ok(Decoded::More(_)) => {} - Err(e) => return Err(RecvFrameError::Decode(e)), - } - let n = self - .recv - .read(&mut chunk) - .await - .map_err(RecvFrameError::Read)? - .ok_or(RecvFrameError::StreamClosed)?; - self.buf.extend_from_slice(&chunk[..n]); - } - } - - /// As [`recv_frame`](Self::recv_frame), bounded by `timeout`. - pub async fn recv_frame_timeout(&mut self, timeout: Duration) -> Result { - tokio::time::timeout(timeout, self.recv_frame()) - .await - .unwrap_or(Err(RecvFrameError::Timeout)) - } - - /// Send a signed CALL for `procedure` and wait for the matching - /// RESULT or ERROR, correlated by `call_id`. A dedicated stream carries - /// only its own exchange, so a frame with another `call_id` is skipped. - pub async fn call( - &mut self, - procedure: &str, - realm: [u8; 32], - payload: Value, - deadline_ms: i128, - identity: &KeyPair, - timeout: Duration, - ) -> Result { - let call_id: [u8; 16] = rand::random(); - let spec = frame::CallSpec::new( - call_id, - procedure, - realm, - payload, - deadline_ms, - identity.node_id(), - ); - let signed = frame::sign(frame::call(&spec), identity); - self.send_frame(signed) - .await - .map_err(StreamCallError::Send)?; - - tokio::time::timeout(timeout, self.await_call_response(call_id)) - .await - .unwrap_or(Err(StreamCallError::Recv(RecvFrameError::Timeout))) - } - - /// As [`call`](Self::call), additionally attaching `ucan_token` to the - /// outgoing CALL frame — for invoking a procedure gated by a - /// [`crate::ucan::Policy::required`] policy. A procedure that isn't - /// gated ignores the token; one that is checks it (see - /// [`Session::serve_one_call_gated`]) before ever running its - /// handler, so an invalid/missing token comes back as a BOLT#4 - /// `unauthorized` error frame, not a Rust error from this call. - /// - /// One parameter over [`call`](Self::call)'s own count, for the one - /// new thing this adds — same reasoning - /// [`crate::direct_dial::keep_advertised_direct`] already gives for - /// its own allow. - #[allow(clippy::too_many_arguments)] - pub async fn call_with_ucan( - &mut self, - procedure: &str, - realm: [u8; 32], - payload: Value, - deadline_ms: i128, - identity: &KeyPair, - timeout: Duration, - ucan_token: Vec, - ) -> Result { - let call_id: [u8; 16] = rand::random(); - let mut spec = frame::CallSpec::new( - call_id, - procedure, - realm, - payload, - deadline_ms, - identity.node_id(), - ); - spec.ucan_token = ucan_token; - let signed = frame::sign(frame::call(&spec), identity); - self.send_frame(signed) - .await - .map_err(StreamCallError::Send)?; - - tokio::time::timeout(timeout, self.await_call_response(call_id)) - .await - .unwrap_or(Err(StreamCallError::Recv(RecvFrameError::Timeout))) - } - - async fn await_call_response( - &mut self, - call_id: [u8; 16], - ) -> Result { - loop { - let value = self.recv_frame().await.map_err(StreamCallError::Recv)?; - if frame::frame_call_id(&value) != Some(call_id) { - continue; - } - if let Ok(response) = frame::parse_call_response(&value) { - return Ok(response); - } - // Matching call_id but not a result/error shape: keep - // waiting rather than erroring, since nothing else in the - // protocol is expected to carry this call's id. - } - } -} - -// --------------------------------------------------------------------- -// Session — the handshaked connection. One reader routes every frame on -// its control stream, and writers take turns: see control_channel.rs. -// --------------------------------------------------------------------- - -/// A completed, handshaked connection to a macula-station, and a handle to -/// it: clones share the one connection. Calls, subscriptions and serving -/// run on it at the same time, because one reader routes every frame on its -/// control stream to whatever waits for it: a RESULT or ERROR to its call, -/// an EVENT to each matching [`Subscription`], and an inbound CALL signed by -/// its caller to a queue of 64. GOODBYE, HELLO or CONNECT after the -/// handshake, a frame that can't be decoded, or a write that stalls past -/// the 30 second send timeout ends the session; every later operation then -/// reports [`SessionEndReason`], the connection closes, and the end is -/// logged once through the `log` facade. Other frames nothing waits for are -/// counted ([`unrouted_frame_counts`](Self::unrouted_frame_counts)). -/// -/// **Always call [`close`](Self::close) before the last handle goes out of -/// scope, not just after your own logic is done with it -- especially right -/// after a send-then-return call like [`publish`](Self::publish) or -/// [`serve_one_call`](Self::serve_one_call).** Dropping the last handle -/// ends the session and tears the connection down at once (flushing -/// outstanding QUIC stream data needs `.await`, which `Drop` can't do), with -/// no guarantee the last write actually reached the peer -- see -/// [`close`](Self::close)'s own doc for the mechanism, and -/// [`serve_one_call`](Self::serve_one_call)'s for the specific, -/// confirmed-live way this bites a spawned provider task. -#[derive(Clone)] -pub struct Session { - inner: Arc, - pub station: HelloInfo, -} - -/// What every handle of one session shares. -pub(crate) struct SessionInner { - /// Direct dial opens dedicated streams on it when it reuses this session. - connection: Arc, - channel: Arc, - hello: HelloInfo, - /// The requests using this session, when direct dial dialed it for them. - leases: Option, -} - -impl Drop for SessionInner { - fn drop(&mut self) { - // The last handle went without close: end the session, which stops - // its reader and writers, and close the connection at once. - self.channel.end(SessionEndReason::Closed, true); - self.connection.close(0u32.into(), b"dropped"); - } -} - -impl crate::open_sessions::Live for SessionInner { - fn is_live(&self) -> bool { - self.channel.end_reason().is_none() && self.connection.close_reason().is_none() - } -} - -impl crate::open_sessions::Leased for Session { - fn leases(&self) -> Option<&crate::open_sessions::Leases> { - self.inner.leases.as_ref() - } -} - -#[derive(Debug)] -pub enum HandshakeError { - Transport(ConnectError), - OpenStream(quinn::ConnectionError), - Write(quinn::WriteError), - Read(quinn::ReadError), - /// The peer closed the stream before a complete frame arrived. - StreamClosed, - Timeout, - Encode(frame::EncodeFrameError), - Decode(frame::DecodeFrameError), - /// Received a frame, but it wasn't a HELLO — a station is never - /// expected to send anything else at this point in the handshake. - UnexpectedFrameType(frame::ParseHelloError), - /// The HELLO frame's own signature didn't verify against the - /// node_id it claims — proves nothing about who actually sent it. - SignatureInvalid(frame::VerifyError), - /// The station completed the handshake but refused the connection - /// (`accepted = false`), e.g. a puzzle-invalid or unrecognized - /// identity. - Refused { - refusal_code: Option, - }, -} - -impl std::fmt::Display for HandshakeError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - HandshakeError::Transport(e) => write!(f, "transport: {e}"), - HandshakeError::OpenStream(e) => write!(f, "opening control stream: {e}"), - HandshakeError::Write(e) => write!(f, "sending CONNECT: {e}"), - HandshakeError::Read(e) => write!(f, "reading from control stream: {e}"), - HandshakeError::StreamClosed => { - write!(f, "station closed the stream before HELLO arrived") - } - HandshakeError::Timeout => write!( - f, - "no HELLO within {HANDSHAKE_TIMEOUT:?} (likely a protocol mismatch)" - ), - HandshakeError::Encode(e) => write!(f, "encoding CONNECT: {e}"), - HandshakeError::Decode(e) => write!(f, "decoding the station's response: {e}"), - HandshakeError::UnexpectedFrameType(e) => write!(f, "expected a HELLO frame: {e}"), - HandshakeError::SignatureInvalid(e) => write!(f, "HELLO signature check failed: {e}"), - HandshakeError::Refused { refusal_code } => { - write!( - f, - "station refused the connection (refusal_code = {refusal_code:?})" - ) - } - } - } -} - -impl std::error::Error for HandshakeError {} - -/// Dial `host:port` and complete the full CONNECT/HELLO handshake: -/// open a QUIC connection, open the control stream, send a signed -/// CONNECT built from `identity`, and wait for a HELLO whose own -/// signature verifies against the node_id it claims. The session's reader -/// starts right after. -/// -/// `identity` **must** be puzzle-hardened -/// ([`KeyPair::generate_with_puzzle`](crate::identity::KeyPair::generate_with_puzzle)) -/// — see that function's own doc and `plans/PLAN_WIRE_PROTOCOL.md` §5's -/// callout: an unhardened identity fails this handshake silently (the -/// QUIC/TLS layer looks healthy right up until the HELLO never accepts). -pub async fn connect( - host: &str, - port: u16, - trust: Trust, - identity: &KeyPair, -) -> Result { - tokio::time::timeout( - HANDSHAKE_TIMEOUT, - connect_inner(host, port, trust, identity, None), - ) - .await - .unwrap_or(Err(HandshakeError::Timeout)) -} - -/// [`connect`], for a session direct dial dials for its own requests. The -/// session counts the requests using it, starting with the one that dialed -/// it ([`Leases`](crate::open_sessions::Leases)), so a request that finds it -/// open shares it, and it closes when the last one is done. -pub(crate) async fn connect_leased( - host: &str, - port: u16, - trust: Trust, - identity: &KeyPair, -) -> Result { - let leases = Some(crate::open_sessions::Leases::new()); - tokio::time::timeout( - HANDSHAKE_TIMEOUT, - connect_inner(host, port, trust, identity, leases), - ) - .await - .unwrap_or(Err(HandshakeError::Timeout)) -} - -async fn connect_inner( - host: &str, - port: u16, - trust: Trust, - identity: &KeyPair, - leases: Option, -) -> Result { - let connection = transport::connect(host, port, trust) - .await - .map_err(HandshakeError::Transport)?; - - let (mut send, mut recv) = connection - .open_bi() - .await - .map_err(HandshakeError::OpenStream)?; - - let connect_spec = - crate::frame::ConnectSpec::new(identity.node_id(), identity.puzzle_evidence()); - let connect_frame = frame::sign(frame::connect(&connect_spec), identity); - let encoded = frame::encode(&connect_frame).map_err(HandshakeError::Encode)?; - send.write_all(&encoded) - .await - .map_err(HandshakeError::Write)?; - - let (hello_value, buf) = read_one_frame(&mut recv).await?; - - let station = frame::parse_hello(&hello_value).map_err(HandshakeError::UnexpectedFrameType)?; - frame::verify(&hello_value, &station.node_id).map_err(HandshakeError::SignatureInvalid)?; - - if !station.accepted { - return Err(HandshakeError::Refused { - refusal_code: station.refusal_code, - }); - } - - let connection = Arc::new(connection); - let (identity_node, station_node) = (identity.node_id(), station.node_id); - // The session signs the frames it sends on its own account (replies its - // reader makes, UNSUBSCRIBE on a dropped subscription) with its own copy - // of the identity it connected under. - let own_identity = KeyPair::from_seed_bytes(identity.private_bytes()); - let hello = station.clone(); - let inner = Arc::new_cyclic(|this: &Weak| { - let (this, ended_connection) = (this.clone(), connection.clone()); - let channel = Channel::start( - Box::new(recv), - buf, - Box::new(send), - own_identity, - station_node, - control_channel::SEND_TIMEOUT, - Box::new(move |reason: &SessionEndReason, closed_here: bool| { - // A session whose control stream ended is no longer offered - // for reuse, and its connection closes. - crate::open_sessions::live().unregister_weak(identity_node, station_node, &this); - if !closed_here { - ended_connection.close(0u32.into(), reason.to_string().as_bytes()); - } - }), - ); - SessionInner { - connection, - channel, - hello, - leases, - } - }); - crate::open_sessions::live().register(identity_node, station_node, &inner); - Ok(Session { inner, station }) -} - -/// Read from `recv` until one complete frame has been decoded, returning -/// it along with any leftover bytes already read that belong to the -/// *next* frame, which the session's reader starts from. -async fn read_one_frame(recv: &mut quinn::RecvStream) -> Result<(Value, Vec), HandshakeError> { - let mut buf = Vec::new(); - let mut chunk = vec![0u8; READ_CHUNK]; - loop { - match frame::decode(&buf) { - Ok(Decoded::Frame(value, consumed)) => { - buf.drain(..consumed); - return Ok((value, buf)); - } - Ok(Decoded::More(_)) => {} - Err(e) => return Err(HandshakeError::Decode(e)), - } - let n = recv - .read(&mut chunk) - .await - .map_err(HandshakeError::Read)? - .ok_or(HandshakeError::StreamClosed)?; - buf.extend_from_slice(&chunk[..n]); - } -} - -/// Errors from [`Session::serve_one_call`]. -#[derive(Debug)] -pub enum ServeCallError { - /// No inbound CALL arrived within the requested timeout. - Timeout, - /// The session ended. - SessionEnded(SessionEndReason), - /// Sending the reply failed. - Send(SendError), -} - -impl std::fmt::Display for ServeCallError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ServeCallError::Timeout => write!(f, "timed out waiting for an inbound CALL"), - ServeCallError::SessionEnded(reason) => write!(f, "the session has ended: {reason}"), - ServeCallError::Send(e) => write!(f, "sending the reply: {e}"), - } - } -} - -impl std::error::Error for ServeCallError {} - -/// Errors from [`Session::run_subscriber`]. -#[derive(Debug)] -pub enum RunSubscriberError { - Subscribe(SendError), - Recv(RecvEventError), -} - -impl std::fmt::Display for RunSubscriberError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - RunSubscriberError::Subscribe(e) => write!(f, "subscribing: {e}"), - RunSubscriberError::Recv(e) => write!(f, "{e}"), - } - } -} - -impl std::error::Error for RunSubscriberError {} - -impl Session { - /// A handle to a session found open in this process's open sessions. - pub(crate) fn from_open(inner: Arc) -> Session { - Session { - station: inner.hello.clone(), - inner, - } - } - - /// Whether `other` is a handle to this same session. - pub(crate) fn is_same_session(&self, other: &Session) -> bool { - Arc::ptr_eq(&self.inner, &other.inner) - } - - /// The remote address this session's connection is with. - pub fn remote_address(&self) -> std::net::SocketAddr { - self.inner.connection.remote_address() - } - - /// Why this session ended, once it has. - pub fn end_reason(&self) -> Option { - self.inner.channel.end_reason() - } - - /// Resolves once this session has ended, with why. - pub async fn ended(&self) -> SessionEndReason { - self.inner.channel.ended().await - } - - /// How many frames of each type the station sent that nothing on this - /// session was waiting for, such as the station's own advertise - /// broadcasts. They are dropped, and at most one log line per type per - /// minute reports them, except a dropped CALL, RESULT or ERROR, which gets - /// a drop warning instead (see - /// [`drop_warning_interval`](Self::drop_warning_interval)). - pub fn unrouted_frame_counts(&self) -> HashMap { - self.inner.channel.unrouted_frame_counts() - } - - /// How long this session's drop warning intervals last. The first inbound - /// CALL or reply of an interval this session drops, and the first stream - /// it refuses, is logged at once with the reason; the rest of the same - /// kind in that interval are counted into one closing line when it ends. - /// Defaults to 60 seconds. - pub fn drop_warning_interval(&self) -> Duration { - self.inner.channel.drop_warnings().interval() - } - - /// Sets [`drop_warning_interval`](Self::drop_warning_interval), for the - /// intervals that start after this. - pub fn set_drop_warning_interval(&self, interval: Duration) { - self.inner.channel.drop_warnings().set_interval(interval); - } - - pub(crate) fn drop_warnings(&self) -> &crate::control_channel::drop_warning::DropWarnings { - self.inner.channel.drop_warnings() - } - - /// Open a new dedicated QUIC stream on this same connection, separate - /// from the control stream — the mechanism content transfer (§12) - /// and streaming RPC (§13), both use instead of the control stream. - pub async fn open_dedicated_stream(&self) -> Result { - let (send, recv) = self.inner.connection.open_bi().await?; - Ok(FrameStream::new(send, recv)) - } - - /// Accept the next dedicated stream the *peer* opens toward us — - /// e.g. the station routing an inbound STREAM_OPEN for a procedure - /// this session has [`advertise`](Self::advertise)d (§13.2). Blocks - /// until one arrives. - /// - /// The receiving side has no advance notice of why a new stream - /// arrived; §7 of `plans/PLAN_WIRE_PROTOCOL.md` says to read the - /// stream's own first frame to learn its purpose, which is exactly - /// what a caller of this method does next via the returned - /// `FrameStream`'s own `recv_frame`. The reference (`quicer`-backed - /// Erlang) has a documented race here — the peer's first bytes can - /// arrive before the owning process is notified the stream exists at - /// all, because its NIF stream resources start passive and only - /// begin delivering once explicitly armed *after* the notification. - /// That race doesn't apply here: `quinn`/QUIC buffers inbound stream - /// data at the transport layer regardless of whether or when the - /// application starts reading, so nothing analogous to arm before - /// read is needed on this side. - pub async fn accept_dedicated_stream(&self) -> Result { - let (send, recv) = self.inner.connection.accept_bi().await?; - Ok(FrameStream::new(send, recv)) - } - - /// Send a signed CALL on the control stream and wait for the matching - /// RESULT or ERROR, correlated by `call_id`. Other calls, subscriptions - /// and serving on this session carry on meanwhile. `timeout` covers the - /// whole call, its turn to write included; when it runs out the call - /// returns [`CallError::Timeout`], and when the session ends first - /// [`CallError::SessionEnded`]. Both say whether the CALL may have - /// reached the station. Announces `rpc.sent_v1` once the CALL is written - /// and `rpc.completed_v1` when the call returns, through this session's - /// own writer, so the facts never cost the call time — see - /// `RPC_SENT_TOPIC` for why these are always on. - pub async fn call( - &self, - procedure: &str, - realm: [u8; 32], - payload: Value, - deadline_ms: i128, - identity: &KeyPair, - timeout: Duration, - ) -> Result { - let spec = frame::CallSpec::new( - rand::random(), - procedure, - realm, - payload, - deadline_ms, - identity.node_id(), - ); - self.announced_call(&spec, identity, timeout).await - } - - /// As [`call`](Self::call), attaching `ucan_token` (e.g. from - /// [`crate::ucan::create`]) to the outgoing CALL — for invoking a - /// procedure gated by a [`crate::ucan::Policy::required`] policy on - /// the provider side. A procedure that isn't gated ignores the token; - /// one that is checks it before ever running its handler, so an - /// invalid or missing token comes back as a BOLT#4 `unauthorized` ERROR - /// frame, not a Rust error from this call. Announces - /// `rpc.sent_v1`/`rpc.completed_v1` the same way [`call`](Self::call) - /// does. - #[allow(clippy::too_many_arguments)] - pub async fn call_with_ucan( - &self, - procedure: &str, - realm: [u8; 32], - payload: Value, - deadline_ms: i128, - identity: &KeyPair, - timeout: Duration, - ucan_token: Vec, - ) -> Result { - let mut spec = frame::CallSpec::new( - rand::random(), - procedure, - realm, - payload, - deadline_ms, - identity.node_id(), - ); - spec.ucan_token = ucan_token; - self.announced_call(&spec, identity, timeout).await - } - - async fn announced_call( - &self, - spec: &frame::CallSpec, - identity: &KeyPair, - timeout: Duration, - ) -> Result { - let request_id: [u8; 16] = rand::random(); - let sent = rpc_fact( - RPC_SENT_TOPIC, - spec.realm, - identity, - request_id_payload(request_id), - ); - let result = self - .inner - .channel - .call(spec, identity, timeout, Some(sent)) - .await; - self.inner - .channel - .hand_off(rpc_completed(spec.realm, identity, request_id, &result)); - result - } - - /// A CALL on this session without RPC telemetry facts, for a pool - /// calling on its links, as macula's pool calls through - /// `macula_station_link:call`. - pub(crate) async fn link_call( - &self, - spec: &frame::CallSpec, - identity: &KeyPair, - timeout: Duration, - ) -> Result { - self.inner.channel.call(spec, identity, timeout, None).await - } - - /// Send a signed PUBLISH, carrying the end-to-end `publisher_sig` - /// (over topic/realm/publisher/seq/payload, independent of frame - /// type) so the resulting EVENT survives being relayed beyond one - /// hop — a station verifies an EVENT's per-hop `signature` against - /// whichever station forwarded it, which only matches on hop 1; - /// every hop after that needs `publisher_sig` instead. Matches the - /// Erlang reference SDK's own default (`pubsub_emit_publisher_sig`, - /// true since macula 4.6.0). Fire-and-forget — no reply is expected - /// on the wire; a subscriber (this session included, if subscribed - /// to the same topic/realm) receives an EVENT asynchronously, through - /// its [`Subscription`]. - pub async fn publish( - &self, - spec: &frame::PublishSpec, - identity: &KeyPair, - ) -> Result<(), SendError> { - let unsigned = frame::publish(spec); - let with_publisher_sig = frame::sign_publisher(unsigned, identity); - let signed = frame::sign(with_publisher_sig, identity); - self.inner.channel.send(&signed).await - } - - /// Starts a subscription with its own queue of 256 events. It receives - /// every EVENT whose realm is `spec`'s and whose topic matches `spec`'s - /// topic by the station's rule: both split on "/", equal segment counts, - /// and each segment equal or "*", which matches exactly one whole - /// segment. SUBSCRIBE goes to the station unless another subscription on - /// this session already holds that realm and topic, and closing or - /// dropping the last one sends UNSUBSCRIBE. - pub async fn subscribe( - &self, - spec: &frame::SubscribeSpec, - identity: &KeyPair, - ) -> Result { - self.inner.channel.subscribe(spec, identity).await - } - - /// Send a signed ADVERTISE (§6.9) — registers this connection as the - /// handler for `spec`'s `(realm, procedure)`. Fire-and-forget on the - /// wire; the station then routes inbound CALLs (control stream) and - /// STREAM_OPENs (a fresh dedicated stream — see - /// [`accept_dedicated_stream`](Self::accept_dedicated_stream)) for - /// that procedure back to this connection. - pub async fn advertise( - &self, - spec: &frame::AdvertiseSpec, - identity: &KeyPair, - ) -> Result<(), SendError> { - let signed = frame::sign(frame::advertise(spec), identity); - self.inner.channel.send(&signed).await - } - - /// Send a signed UNADVERTISE. Fire-and-forget. - pub async fn unadvertise( - &self, - spec: &frame::UnadvertiseSpec, - identity: &KeyPair, - ) -> Result<(), SendError> { - let signed = frame::sign(frame::unadvertise(spec), identity); - self.inner.channel.send(&signed).await - } - - /// Sends an ADVERTISE for `spec` immediately, then again every - /// `interval`, until `stop` resolves. [`advertise`](Self::advertise)'s - /// own doc notes the station's registration is tied to the connection - /// that sent it — a long-lived server needs to keep re-asserting it. - /// [`advertise`](Self::advertise) is a stateless, side-effect-free-on- - /// repeat wire send (unlike the Erlang reference's `advertise/5`, which - /// spawns a real per-call OTP supervisor and so needs a `reuse_sup` - /// option to avoid leaking one per tick), so there is nothing - /// equivalent to worry about leaking here — same reasoning - /// `macula-go`'s `KeepAdvertised` already applied and verified - /// live. - /// - /// A failed tick is reported via `on_error` but does not stop the - /// loop — it tries again at the next interval regardless. This cannot - /// repair a dead session on its own; if the session has ended, every - /// tick will keep failing until `stop` resolves. See - /// [`crate::direct_dial::keep_advertised_direct`] for the direct-dial - /// equivalent (same shape, same reasoning). - pub async fn keep_advertised( - &self, - spec: &frame::AdvertiseSpec, - identity: &KeyPair, - interval: Duration, - stop: F, - on_error: impl Fn(SendError), - ) where - F: std::future::Future, - { - tokio::pin!(stop); - let mut ticker = tokio::time::interval(interval); - loop { - tokio::select! { - _ = &mut stop => return, - _ = ticker.tick() => { - if let Err(e) = self.advertise(spec, identity).await { - on_error(e); - } - } - } - } - } - - /// The provider role's counterpart to [`call`](Self::call): wait for - /// the next inbound CALL, bounded by `timeout`, look it up via - /// `lookup`, invoke the matching handler, and send the resulting RESULT - /// or ERROR back over this same connection — see - /// `plans/PLAN_WIRE_PROTOCOL.md` §6.9's routing description and - /// `macula_station_link.erl`'s `handle_inbound_call/2`, which this - /// mirrors field for field, including its BOLT#4 error-code mapping. - /// - /// Inbound CALLs wait in this session's queue of 64 until served, while - /// calls and subscriptions on the same session carry on. A CALL that - /// doesn't fit gets `temporary_relay_failure` at once, and serving - /// carries on with the calls already queued. The reader queues only a - /// CALL whose signature verifies against the `caller` it names. - /// - /// A caller wanting a long-lived server loops on this: - /// - /// ```no_run - /// # use std::time::Duration; - /// # async fn example(session: &macula_rust::connection::Session, identity: &macula_rust::identity::KeyPair, lookup: impl Fn(&[u8; 32], &str) -> Option) { - /// loop { - /// if let Err(e) = session.serve_one_call(&lookup, identity, Duration::from_secs(30)).await { - /// // ServeCallError::Timeout just means nothing arrived -- keep looping. - /// eprintln!("{e}"); - /// } - /// } - /// # } - /// ``` - /// - /// **Do not let the last handle to this `Session` drop right after this - /// call returns -- call [`close`](Self::close) on it explicitly first.** - /// This is the single most common way to lose the RESULT/ERROR you just - /// sent: `serve_one_call` returning `Ok(())` only means the reply was - /// handed to quinn's own send-scheduling machinery, exactly like - /// [`close`](Self::close)'s own doc explains for `write_all`/`finish` - /// -- dropping the last handle does nothing to wait for that to reach - /// the peer before the connection is torn down, while `close` has a - /// deliberate bounded drain for precisely this. Confirmed live - /// 2026-09-05 with the single most natural-looking way to hit it: - /// spawning the only handle into its own `tokio::spawn` task with - /// nothing following the `.await` -- the task can complete and drop it - /// within microseconds of the write, deterministically under a - /// multi-threaded runtime, losing the reply every time. Keep a handle - /// outside the task and close it explicitly instead, same as this - /// crate's own `tests/live_station.rs` does for every spawned provider - /// role. - pub async fn serve_one_call( - &self, - lookup: L, - identity: &KeyPair, - timeout: Duration, - ) -> Result<(), ServeCallError> - where - L: Fn(&[u8; 32], &str) -> Option, - { - self.serve_one_call_gated( - lookup, - |_, _| crate::ucan::Policy::open(), - identity, - timeout, - ) - .await - } - - /// [`serve_one_call`](Self::serve_one_call), additionally gating each - /// inbound CALL through `policy` BEFORE `lookup` runs — mirrors - /// `macula_station_link.erl`'s `handle_inbound_call/2` exactly: an - /// open policy (the default [`serve_one_call`](Self::serve_one_call) - /// uses) behaves identically; a [`crate::ucan::Policy::required`] - /// policy demands a CALL's `ucan_token` verify against the required - /// issuer and name the CALL's `caller` as its audience, and refuses - /// with BOLT#4 `unauthorized` WITHOUT ever invoking `lookup` or a - /// handler if it doesn't — a [`CallHandler`] never sees the raw token - /// either way, matching the reference's own handler contract (payload - /// only). - /// - /// Before any policy runs, the CALL's signature must verify against the - /// `caller` it names; the session's reader drops a CALL that doesn't, - /// with no reply, as `macula_station_link.erl`'s `on_inbound_call/3` - /// does. - pub async fn serve_one_call_gated( - &self, - lookup: L, - policy: P, - identity: &KeyPair, - timeout: Duration, - ) -> Result<(), ServeCallError> - where - L: Fn(&[u8; 32], &str) -> Option, - P: Fn(&[u8; 32], &str) -> crate::ucan::Policy, - { - tokio::time::timeout(timeout, async { - let call = self - .inner - .channel - .next_inbound_call() - .await - .map_err(ServeCallError::SessionEnded)?; - let reply = build_call_reply(call, &lookup, &policy, identity, Some(self)).await; - self.inner - .channel - .send(&frame::sign(reply, identity)) - .await - .map_err(ServeCallError::Send) - }) - .await - .unwrap_or(Err(ServeCallError::Timeout)) - } - - /// Bounds how long [`close`](Self::close) waits after its last write - /// before hard-closing the connection -- see that method's own doc - /// for why this exists at all. Short relative to the Erlang - /// reference's own 5s draining-state upper bound - /// (`macula_peering.erl`, `?DRAIN_TIMEOUT_MS`): this side only needs - /// to cover quinn's own internal send-scheduling latency, not a full - /// round trip's worth of protocol drain. - const CLOSE_DRAIN: Duration = Duration::from_millis(250); - - /// Close the control stream and connection gracefully with a GOODBYE - /// frame, matching `macula_peering_conn.erl`'s `connected -> draining` - /// transition (minus the full drain-timeout bookkeeping, since this - /// crate isn't holding a supervisor to clean up). Every handle to this - /// session sees it end. - /// - /// `write_all(...).await` and `finish()` both only guarantee the data - /// was handed to quinn's own send-scheduling machinery, not that it - /// reached the peer -- `Connection::close` is abrupt and does not - /// wait for outstanding stream data to be delivered. Found live - /// 2026-08-29 in the Go port of this exact pattern - /// (macula-go's connection.Session.Close): a PUBLISH sent - /// immediately before Close intermittently never reached the peer, - /// root-caused to this race. Fixed proactively here before it was - /// independently rediscovered against this crate -- same doc - /// comment ("minus the drain-timeout bookkeeping"), same - /// write-then-immediately-abort-connection shape, so the same race - /// applies. Closing the stream via `finish()` first, then giving the - /// background sender a bounded window before hard-closing the - /// connection, mirrors the Erlang reference's own bounded-drain - /// approach. - pub async fn close(&self, reason: &str, detail: Option<&str>, identity: &KeyPair) { - let goodbye = frame::sign(frame::goodbye(reason, detail), identity); - self.inner.channel.close(&goodbye).await; - tokio::time::sleep(Self::CLOSE_DRAIN).await; - self.inner.connection.close(0u32.into(), reason.as_bytes()); - } - - /// The supervised counterpart to the bare [`publish`](Self::publish) - /// primitive, matching `macula_publisher.erl` in spirit: publishes - /// `pubsub.publish_started_v1` before the publish and - /// `pubsub.publish_completed_v1` after, both under `spec`'s own realm. - /// Fact-publish failures are silently discarded — matching - /// `macula_publisher.erl`'s own `publish/5` helper, which throws away - /// its result unconditionally (`_ = macula:publish(...), ok`). - /// - /// Unlike Erlang's version — a supervised worker process a caller can - /// kill mid-flight — this crate's bare `publish` is already a - /// synchronous, near-instant frame send (no ack on this wire, no - /// network round-trip to await), so there is no meaningful "cancel - /// before it starts" window worth a dedicated mechanism. Await this - /// directly, or wrap it in `tokio::select!`/`tokio::time::timeout` - /// yourself if you need to abandon it early — dropping a `Future` IS - /// real cancellation in Rust; Erlang has to simulate that by killing a - /// worker process. - pub async fn run_publisher( - &self, - spec: &frame::PublishSpec, - identity: &KeyPair, - announce: bool, - ) -> Result<(), SendError> { - let publish_id: [u8; 16] = rand::random(); - if announce { - let payload = Value::Map(vec![]) - .with_field("publish_id", Value::Bytes(publish_id.to_vec())) - .with_field("topic", Value::Bytes(spec.topic.as_bytes().to_vec())); - let fact = frame::PublishSpec::new( - "pubsub.publish_started_v1", - spec.realm, - identity.node_id(), - rand::random(), - payload, - now_ms(), - ); - let _ = self.publish(&fact, identity).await; - } - - let result = self.publish(spec, identity).await; - - if announce { - let payload = - Value::Map(vec![]).with_field("publish_id", Value::Bytes(publish_id.to_vec())); - let payload = match &result { - Ok(()) => payload.with_field("outcome", Value::text("completed")), - Err(e) => payload - .with_field("outcome", Value::text("failed")) - .with_field("reason", Value::text(e.to_string())), - }; - let fact = frame::PublishSpec::new( - "pubsub.publish_completed_v1", - spec.realm, - identity.node_id(), - rand::random(), - payload, - now_ms(), - ); - let _ = self.publish(&fact, identity).await; - } - - result - } - - /// The supervised counterpart to a bare [`Subscription`], matching - /// `macula_subscriber.erl` in spirit: subscribes once, then hands every - /// matching EVENT to `handler` until `stop` resolves, instead of - /// requiring the caller to hand-roll a receive loop. Closes the - /// subscription on return, including on cancellation, which sends - /// UNSUBSCRIBE when no other subscription on the session holds that - /// realm and topic. Other frames on the session never reach this loop: - /// the session's reader routes each one to whatever waits for it. - /// - /// Returns an error when the subscription falls behind - /// ([`RecvEventError::Overflow`]) or the session ends. - /// - /// No OTP pid to address a running subscriber by; `stop` plays that - /// role — matches [`keep_advertised`](Self::keep_advertised)'s own - /// cancellation shape exactly, not a new one. `handler` cannot itself - /// stop the loop (no return value) — by the same design `keep_advertised` - /// already established, where `on_error` can only report, not halt; - /// stopping is always external, via `stop`. - pub async fn run_subscriber( - &self, - spec: &frame::SubscribeSpec, - identity: &KeyPair, - stop: F, - mut handler: impl FnMut(frame::EventInfo), - ) -> Result<(), RunSubscriberError> - where - F: std::future::Future, - { - let mut subscription = self - .subscribe(spec, identity) - .await - .map_err(RunSubscriberError::Subscribe)?; - - tokio::pin!(stop); - let result = loop { - tokio::select! { - _ = &mut stop => break Ok(()), - received = subscription.recv_event(SUBSCRIBER_POLL_INTERVAL) => match received { - Ok(event) => handler(event), - Err(RecvEventError::Timeout) => {} - Err(e) => break Err(RunSubscriberError::Recv(e)), - }, - } - }; - - subscription.close().await; - result - } -} - -/// How long [`Session::run_subscriber`] waits on its subscription at a -/// time. Not a wire timeout: nothing is sent when it runs out, the loop just -/// waits again. -const SUBSCRIBER_POLL_INTERVAL: Duration = Duration::from_secs(3600); - -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock before 1970") - .as_millis() as u64 -} - -// RPC telemetry auto-facts, matching `macula_request.erl` (caller side: -// rpc.sent_v1/rpc.completed_v1) and `macula_response.erl` (provider side: -// rpc.received_v1/rpc.replied_v1) exactly -- same topic names, same -// `request_id` field (16 fresh random bytes per call, independent of the -// wire CALL frame's own `call_id` -- the reference tracks its own request -// lifecycle separately from the wire frame, and this does too), same realm -// as the call itself, fire-and-forget: each fact goes to the session's own -// writer, so it never costs the call or serve it describes any time and -// never fails it, matching `macula_response.erl`'s own -// `_ = macula:publish(...), ok` and `macula_request.erl`'s identical -// `publish/5` helper. A fact is dropped when 64 frames already wait for that -// writer. -// -// Always on, matching the reference's ACTUAL behavior on each side, not -// just a blanket claim -- checked directly rather than assumed: -// `macula_request.erl`'s `start_link/7` and `start_link_direct/8` both -// hardcode `true` literally at the tuple-construction call site; there is -// no `Opts` key or parameter that reaches it at all on the caller side. -// `macula_response.erl`'s `advertise/6` DOES read `announce` from its -// `Opts` map with a `true` default (`maps:get(announce, Opts, true)`) -- -// technically overridable -- but the one real caller in this workspace -// (`hecate_om_capabilities.erl`'s `advertise_opts/1`) never sets it to -// `false`. Matching Go's `macula-go` decision here: no toggle exposed -// on either side, since exposing one on `call`/`serve_one_call_gated` -- -// this crate's two most heavily used functions -- for an option nothing -// in the reference ecosystem actually flips would be a real-blast-radius -// signature change for no practical benefit. -const RPC_SENT_TOPIC: &str = "rpc.sent_v1"; -const RPC_COMPLETED_TOPIC: &str = "rpc.completed_v1"; -const RPC_RECEIVED_TOPIC: &str = "rpc.received_v1"; -const RPC_REPLIED_TOPIC: &str = "rpc.replied_v1"; - -fn request_id_payload(request_id: [u8; 16]) -> Value { - Value::Map(vec![]).with_field("request_id", Value::Bytes(request_id.to_vec())) -} - -/// A fact's PUBLISH, carrying its `publisher_sig`; the session's own writer -/// signs the envelope when it sends it. -fn rpc_fact(topic: &str, realm: [u8; 32], identity: &KeyPair, payload: Value) -> Value { - let spec = frame::PublishSpec::new( - topic, - realm, - identity.node_id(), - rand::random(), - payload, - now_ms(), - ); - frame::sign_publisher(frame::publish(&spec), identity) -} - -/// Matches `macula_request.erl`'s `outcome_fields/2`: `completed` (no Rust -/// error, not a bolt4 ERROR frame) or `failed` (either). Erlang -/// additionally has a `cancelled` outcome from its own -/// gen_server-cancellable `macula_request:cancel/1` -- this crate's plain -/// `call` has no cancellation concept independent of an ordinary -/// error/timeout at this layer, so that outcome is not reachable here and -/// is not fabricated (same reasoning Go's port already documented). -fn rpc_completed( - realm: [u8; 32], - identity: &KeyPair, - request_id: [u8; 16], - result: &Result, -) -> Value { - let payload = request_id_payload(request_id); - let payload = match result { - Err(e) => payload - .with_field("outcome", Value::text("failed")) - .with_field("reason", Value::text(e.to_string())), - Ok(frame::CallResponse::Error { name, .. }) => payload - .with_field("outcome", Value::text("failed")) - .with_field("reason", Value::text(name.clone())), - Ok(frame::CallResponse::Result { .. }) => { - payload.with_field("outcome", Value::text("completed")) - } - }; - rpc_fact(RPC_COMPLETED_TOPIC, realm, identity, payload) -} - -/// Matches `macula_response.erl`'s `outcome_fields/2`: `replied` (`{ok, -/// _}`) or `failed` (`{error, Reason}`). A handler panic is deliberately -/// NOT announced here at all -- matching the reference exactly, where a -/// crashing `Module:handle_request/2` crashes the whole per-request child -/// before its own `publish_replied/2` call is ever reached, so -/// `REQUEST_REPLIED` is never published for a crash there either. -fn rpc_replied( - realm: [u8; 32], - identity: &KeyPair, - request_id: [u8; 16], - handler_err: Option<&str>, -) -> Value { - let payload = request_id_payload(request_id); - let payload = match handler_err { - Some(reason) => payload - .with_field("outcome", Value::text("failed")) - .with_field("reason", Value::text(reason)), - None => payload.with_field("outcome", Value::text("replied")), - }; - rpc_fact(RPC_REPLIED_TOPIC, realm, identity, payload) -} - -/// Build the RESULT/ERROR reply for one inbound CALL — mirrors -/// `macula_station_link.erl`'s `handle_inbound_call/2` + -/// `safe_invoke_handler/4` exactly: `policy` is checked FIRST (a -/// rejection is BOLT#4 `unauthorized`, and `lookup`/a handler never run -/// at all); then a lookup miss is `unknown_next_peer`; the handler -/// running to completion produces a RESULT (`Ok`) or `unknown_error` with -/// `detail` (`Err`); a handler panic — caught via `tokio::spawn`, the -/// same "one transient task per call" shape the reference's own "one -/// process per call" uses — is `temporary_relay_failure`, with no -/// `detail`, matching the reference not sending one on a crash either. -/// -/// Announces `rpc.received_v1`/`rpc.replied_v1` around dispatch through -/// `session`'s own writer when `session` is `Some` -- `None` for the pure -/// dispatch-logic unit tests below, which deliberately exercise this -/// function with no network at all (mirrors `macula-go`'s identical -/// nil-session-safe `announceFact`). `rpc.received_v1` fires only after -/// `policy` and `lookup` both pass, matching `macula_response.erl`'s own -/// per-request child only starting once the raw advertise mechanism -/// already decided to dispatch to a real handler -- a UCAN-rejected or -/// unadvertised-procedure CALL announces neither fact. -/// The payload a CALL's handler receives: a map payload with `caller`, the -/// node id the CALL's signature was verified against, under `"caller"`, -/// replacing a `"caller"` the sender put there under a text or byte-string -/// key; any other payload unchanged. Mirrors -/// `macula_station_link:with_caller/2`. -pub(crate) fn with_caller(payload: Value, caller: [u8; 32]) -> Value { - match payload { - Value::Map(mut fields) => { - fields.retain(|(key, _)| !is_caller_key(key)); - fields.push((Value::text("caller"), Value::Bytes(caller.to_vec()))); - Value::Map(fields) - } - other => other, - } -} - -fn is_caller_key(key: &Value) -> bool { - match key { - Value::Text(text) => text == "caller", - Value::Bytes(bytes) => bytes.as_slice() == b"caller", - _ => false, - } -} - -pub(crate) async fn build_call_reply( - call_info: frame::CallInfo, - lookup: &L, - policy: &P, - identity: &KeyPair, - session: Option<&Session>, -) -> Value -where - L: Fn(&[u8; 32], &str) -> Option, - P: Fn(&[u8; 32], &str) -> crate::ucan::Policy, -{ - let self_pub = identity.node_id(); - - if policy(&call_info.realm, &call_info.procedure) - .check(&call_info.ucan_token, &call_info.caller) - .is_err() - { - return frame::call_error(&frame::CallErrorSpec::new( - call_info.call_id, - bolt4::Code::Unauthorized, - self_pub, - )); - } - - let Some(handler) = lookup(&call_info.realm, &call_info.procedure) else { - return frame::call_error(&frame::CallErrorSpec::new( - call_info.call_id, - bolt4::Code::UnknownNextPeer, - self_pub, - )); - }; - - let request_id: [u8; 16] = rand::random(); - if let Some(session) = session { - session.inner.channel.hand_off(rpc_fact( - RPC_RECEIVED_TOPIC, - call_info.realm, - identity, - request_id_payload(request_id), - )); - } - - let payload = with_caller(call_info.payload, call_info.caller); - let outcome = tokio::spawn(async move { handler(payload).await }).await; - match outcome { - Ok(Ok(value)) => { - if let Some(session) = session { - session.inner.channel.hand_off(rpc_replied( - call_info.realm, - identity, - request_id, - None, - )); - } - frame::result(&frame::ResultSpec::new(call_info.call_id, value, self_pub)) - } - Ok(Err(reason)) => { - if let Some(session) = session { - session.inner.channel.hand_off(rpc_replied( - call_info.realm, - identity, - request_id, - Some(&reason), - )); - } - let mut spec = - frame::CallErrorSpec::new(call_info.call_id, bolt4::Code::UnknownError, self_pub); - spec.detail = Some(reason); - frame::call_error(&spec) - } - Err(_join_error) => frame::call_error(&frame::CallErrorSpec::new( - call_info.call_id, - bolt4::Code::TemporaryRelayFailure, - self_pub, - )), - } -} - -#[cfg(test)] -mod ucan_gating_tests { - //! Proves `serve_one_call_gated`'s policy wiring end-to-end WITHOUT a - //! network — `build_call_reply` is a plain async function of - //! `(CallInfo, lookup, policy, self_pub)`, so its dispatch/reply logic - //! is fully testable in isolation. Mirrors `macula-go`'s own 4 - //! connection-level UCAN-gating unit tests (`serve_ucan_test.go`). - use super::*; - use crate::identity::KeyPair; - use crate::ucan::{self, Policy}; - - fn call_info(ucan_token: Vec) -> frame::CallInfo { - frame::CallInfo { - call_id: [1; 16], - procedure: "test.proc".into(), - realm: [0; 32], - payload: Value::Null, - deadline_ms: 0, - caller: [2; 32], - ucan_token, - } - } - - fn never_called_lookup() -> impl Fn(&[u8; 32], &str) -> Option { - |_, _| panic!("handler lookup must not run when policy rejects the call") - } - - fn echo_lookup() -> impl Fn(&[u8; 32], &str) -> Option { - |_, _| { - Some(Arc::new(|payload: Value| { - Box::pin(async move { Ok(payload) }) - })) - } - } - - #[tokio::test] - async fn open_policy_never_gates_dispatch() { - let identity = KeyPair::generate(); - let reply = build_call_reply( - call_info(Vec::new()), - &echo_lookup(), - &|_, _| Policy::open(), - &identity, - None, - ) - .await; - assert!(matches!( - frame::parse_call_response(&reply), - Ok(frame::CallResponse::Result { .. }) - )); - } - - #[tokio::test] - async fn required_policy_refuses_a_call_with_no_token_before_lookup_runs() { - let id = KeyPair::generate(); - let identity = KeyPair::generate(); - let reply = build_call_reply( - call_info(Vec::new()), - &never_called_lookup(), - &move |_, _| Policy::required(id.node_id()), - &identity, - None, - ) - .await; - match frame::parse_call_response(&reply) { - Ok(frame::CallResponse::Error { code, .. }) => { - assert_eq!(code, bolt4::Code::Unauthorized as u8) - } - other => panic!("expected an Unauthorized ERROR frame, got {other:?}"), - } - } - - #[tokio::test] - async fn required_policy_refuses_a_token_from_the_wrong_issuer_before_lookup_runs() { - let required_issuer = KeyPair::generate(); - let impostor = KeyPair::generate(); - let bad_token = ucan::create( - "did:iss", - "did:aud", - vec![], - &impostor, - ucan::CreateOpts::default(), - ) - .unwrap(); - let identity = KeyPair::generate(); - let reply = build_call_reply( - call_info(bad_token), - &never_called_lookup(), - &move |_, _| Policy::required(required_issuer.node_id()), - &identity, - None, - ) - .await; - match frame::parse_call_response(&reply) { - Ok(frame::CallResponse::Error { code, .. }) => { - assert_eq!(code, bolt4::Code::Unauthorized as u8) - } - other => panic!("expected an Unauthorized ERROR frame, got {other:?}"), - } - } - - #[tokio::test] - async fn required_policy_lets_a_valid_token_reach_the_handler() { - let id = KeyPair::generate(); - let good_token = ucan::create( - "did:iss", - &hex::encode(call_info(Vec::new()).caller), - vec![], - &id, - ucan::CreateOpts::default(), - ) - .unwrap(); - let identity = KeyPair::generate(); - let reply = build_call_reply( - call_info(good_token), - &echo_lookup(), - &move |_, _| Policy::required(id.node_id()), - &identity, - None, - ) - .await; - assert!(matches!( - frame::parse_call_response(&reply), - Ok(frame::CallResponse::Result { .. }) - )); - } - - // A handler receives the verified caller in a map payload. The names - // match macula-go's. - - async fn payload_seen_by_the_handler(payload: Value, caller: [u8; 32]) -> Value { - let info = frame::CallInfo { - payload, - caller, - ..call_info(Vec::new()) - }; - let reply = build_call_reply( - info, - &echo_lookup(), - &|_, _| Policy::open(), - &KeyPair::generate(), - None, - ) - .await; - match frame::parse_call_response(&reply) { - Ok(frame::CallResponse::Result { payload, .. }) => payload, - other => panic!("expected a RESULT, got {other:?}"), - } - } - - #[tokio::test] - async fn an_inbound_call_threads_its_caller_into_the_payload() { - let caller = KeyPair::generate().node_id(); - let payload = Value::Map(vec![(Value::text("n"), Value::Int(21))]); - - let seen = payload_seen_by_the_handler(payload, caller).await; - - assert_eq!(seen.get("caller"), Some(&Value::Bytes(caller.to_vec()))); - assert_eq!(seen.get("n"), Some(&Value::Int(21))); - } - - #[tokio::test] - async fn a_caller_the_sender_put_in_the_payload_is_replaced_by_the_verified_caller() { - let (caller, claimed) = (KeyPair::generate().node_id(), KeyPair::generate().node_id()); - let payload = Value::Map(vec![ - (Value::text("caller"), Value::Bytes(claimed.to_vec())), - ( - Value::Bytes(b"caller".to_vec()), - Value::Bytes(claimed.to_vec()), - ), - ]); - - let seen = payload_seen_by_the_handler(payload, caller).await; - - assert_eq!( - seen, - Value::Map(vec![(Value::text("caller"), Value::Bytes(caller.to_vec()))]) - ); - } - - #[tokio::test] - async fn a_non_map_payload_carries_no_caller() { - let caller = KeyPair::generate().node_id(); - - let seen = payload_seen_by_the_handler(Value::text("hello"), caller).await; - - assert_eq!(seen, Value::text("hello")); - } - - // The provider side of an inbound CALL: a gated policy accepts a token - // only from the caller it was minted for. The names match macula-go's - // connection/serve_caller_test.go. That a CALL reaches serving only when - // its signature verifies against the caller it names is checked by the - // session's reader; see control_channel.rs. - - fn call_info_from(caller: [u8; 32], ucan_token: Vec) -> frame::CallInfo { - frame::CallInfo { - caller, - ..call_info(ucan_token) - } - } - - fn token_for(issuer: &KeyPair, audience: &str) -> Vec { - ucan::create( - "did:iss", - audience, - vec![], - issuer, - ucan::CreateOpts::default(), - ) - .unwrap() - } - - fn is_unauthorized(reply: &Value) -> bool { - matches!( - frame::parse_call_response(reply), - Ok(frame::CallResponse::Error { code, .. }) if code == bolt4::Code::Unauthorized as u8 - ) - } - - #[tokio::test] - async fn build_call_reply_gated_policy_refuses_a_token_presented_by_another_caller() { - let (issuer, audience, presenter) = ( - KeyPair::generate(), - KeyPair::generate(), - KeyPair::generate(), - ); - let token = token_for(&issuer, &hex::encode(audience.node_id())); - - let reply = build_call_reply( - call_info_from(presenter.node_id(), token), - &never_called_lookup(), - &move |_, _| Policy::required(issuer.node_id()), - &KeyPair::generate(), - None, - ) - .await; - - assert!( - is_unauthorized(&reply), - "{:?}", - frame::parse_call_response(&reply) - ); - } - - #[tokio::test] - async fn build_call_reply_gated_policy_accepts_a_token_from_its_audience() { - let (issuer, caller) = (KeyPair::generate(), KeyPair::generate()); - let token = token_for(&issuer, &hex::encode(caller.node_id())); - - let reply = build_call_reply( - call_info_from(caller.node_id(), token), - &echo_lookup(), - &move |_, _| Policy::required(issuer.node_id()), - &KeyPair::generate(), - None, - ) - .await; - - assert!(matches!( - frame::parse_call_response(&reply), - Ok(frame::CallResponse::Result { .. }) - )); - } - - #[tokio::test] - async fn build_call_reply_gated_policy_refuses_a_token_without_audience() { - let (issuer, caller) = (KeyPair::generate(), KeyPair::generate()); - let token = token_for(&issuer, ""); - - let reply = build_call_reply( - call_info_from(caller.node_id(), token), - &never_called_lookup(), - &move |_, _| Policy::required(issuer.node_id()), - &KeyPair::generate(), - None, - ) - .await; - - assert!( - is_unauthorized(&reply), - "{:?}", - frame::parse_call_response(&reply) - ); - } -} diff --git a/src/content.rs b/src/content.rs deleted file mode 100644 index 35c09da..0000000 --- a/src/content.rs +++ /dev/null @@ -1,398 +0,0 @@ -//! Content sharing (§12 of `plans/PLAN_WIRE_PROTOCOL.md`): put/get by -//! content-address, over a dedicated QUIC stream — ordinary CALL/RESULT -//! (§6.4) against four well-known `_content.*` procedures, ported from -//! `macula_content_transfer.erl`. Not a separate wire protocol: nothing -//! here is new frame types, just calls a normal [`crate::connection::Session`] -//! could already make, sent on a stream opened via -//! [`Session::open_dedicated_stream`](crate::connection::Session::open_dedicated_stream) -//! instead of the control stream. -//! -//! **Deliberate v1 simplification (documented per spec §12.2):** chunked -//! transfers here run strictly sequentially, one `_content.put_block` / -//! `_content.get_block` in flight at a time on the single dedicated -//! stream this module opens — not the reference's parallel multi-lane -//! algorithm (round-robin chunks across up to 4 concurrent streams). -//! Multi-lane parallelism is a throughput optimization, not a -//! correctness requirement: every `_content.*` call, the MCID scheme, -//! and the manifest wire format are identical either way, so this v1 -//! client interoperates fully with a station built to serve a -//! parallel-lane peer, and lanes can be added later purely as a -//! performance improvement with no wire change. -//! -//! Sequential retrieval has one incidental upside over the reference: -//! chunks arrive and get appended in index order for free, so there's no -//! need for the reference's "accumulate into a map keyed by index, then -//! reassemble" step. - -use std::time::Duration; - -use crate::bolt4; -use crate::cbor::Value; -use crate::connection::{FrameStream, Session, StreamCallError}; -use crate::identity::KeyPair; -use crate::manifest::{self, Manifest, Mcid}; - -/// Reserved realm sentinel for all `_content.*` calls — 32 zero bytes, -/// distinct from any real realm (`plans/PLAN_WIRE_PROTOCOL.md` §12.1). -pub const CONTENT_REALM: [u8; 32] = [0u8; 32]; - -const PUT_BLOCK_PROC: &str = "_content.put_block"; -const GET_BLOCK_PROC: &str = "_content.get_block"; -const PUT_MANIFEST_PROC: &str = "_content.put_manifest"; -const GET_MANIFEST_PROC: &str = "_content.get_manifest"; - -/// Matches `CONTENT_BLOCK_TIMEOUT_MS` in `macula_content_transfer.erl`. -const BLOCK_TIMEOUT: Duration = Duration::from_secs(15); -/// Matches `CONTENT_MANIFEST_TIMEOUT_MS`. -const MANIFEST_TIMEOUT: Duration = Duration::from_secs(5); - -/// Matches §12.2's retry policy: up to 3 attempts total, 200ms backoff -/// between them, only for a BOLT#4 code flagged retryable (§9). -const MAX_ATTEMPTS: u32 = 3; -const RETRY_BACKOFF: Duration = Duration::from_millis(200); - -#[derive(Debug)] -pub enum PutError { - /// Opening the dedicated stream itself failed (e.g. the connection - /// is already dead) — never got as far as making a call. - OpenStream(quinn::ConnectionError), - Call(StreamCallError), - /// The station rejected the call with a BOLT#4 ERROR. - Remote { - code: u8, - name: String, - detail: Option, - }, - /// A RESULT arrived but its payload wasn't one of the shapes this - /// procedure is documented to return. - UnexpectedReply(Value), - /// The station recomputed the block's hash and it didn't match the - /// MCID the caller sent — the block was not stored. - HashMismatch, -} - -impl std::fmt::Display for PutError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PutError::OpenStream(e) => write!(f, "opening a dedicated stream: {e}"), - PutError::Call(e) => write!(f, "{e}"), - PutError::Remote { code, name, detail } => { - write!(f, "station returned error {code} ({name}): {detail:?}") - } - PutError::UnexpectedReply(v) => write!(f, "unexpected reply shape: {v:?}"), - PutError::HashMismatch => write!(f, "station reported hash_mismatch"), - } - } -} - -impl std::error::Error for PutError {} - -#[derive(Debug)] -pub enum GetError { - /// Opening the dedicated stream itself failed (e.g. the connection - /// is already dead) — never got as far as making a call. - OpenStream(quinn::ConnectionError), - Call(StreamCallError), - Remote { - code: u8, - name: String, - detail: Option, - }, - UnexpectedReply(Value), - NotFound, - ManifestDecode(manifest::FromWireError), - /// A fetched block or reassembled blob didn't hash to the MCID it - /// was fetched under — see the module-level note in §12.1: a - /// station may only be relaying content it doesn't itself store, so - /// its answer is never trusted without this client-side check. - HashMismatch, - Verify(manifest::VerifyError), -} - -impl std::fmt::Display for GetError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - GetError::OpenStream(e) => write!(f, "opening a dedicated stream: {e}"), - GetError::Call(e) => write!(f, "{e}"), - GetError::Remote { code, name, detail } => { - write!(f, "station returned error {code} ({name}): {detail:?}") - } - GetError::UnexpectedReply(v) => write!(f, "unexpected reply shape: {v:?}"), - GetError::NotFound => write!(f, "station reported not_found"), - GetError::ManifestDecode(e) => write!(f, "decoding the fetched manifest: {e}"), - GetError::HashMismatch => write!(f, "fetched content does not hash to its MCID"), - GetError::Verify(e) => write!(f, "reassembled content failed verification: {e}"), - } - } -} - -impl std::error::Error for GetError {} - -/// Store `data`, returning the MCID it's now addressable by. -/// -/// `name` is attached to the manifest when `data` is large enough to be -/// chunked; a single block (`data.len() <= manifest::DEFAULT_CHUNK_SIZE`) -/// is addressed purely by content hash and carries no name at all, -/// matching `macula_content_transfer:put_single_block/3` — `name` is -/// silently unused on that path, not an oversight. -pub async fn put( - session: &Session, - data: &[u8], - name: impl Into, - identity: &KeyPair, -) -> Result { - let stream = session - .open_dedicated_stream() - .await - .map_err(PutError::OpenStream)?; - put_on(stream, data, name, identity).await -} - -/// [`put`], over a dedicated stream already open to the station. -pub(crate) async fn put_on( - mut stream: FrameStream, - data: &[u8], - name: impl Into, - identity: &KeyPair, -) -> Result { - if data.len() <= manifest::DEFAULT_CHUNK_SIZE { - let mcid = manifest::block_mcid(data); - put_block(&mut stream, &mcid, data, identity).await?; - return Ok(mcid); - } - - let opts = manifest::CreateOptions { - name: name.into(), - ..manifest::CreateOptions::default() - }; - let (manifest, chunks) = manifest::create(data, &opts); - for (index, chunk) in chunks.iter().enumerate() { - let chunk_mcid = manifest::chunk_mcid(&manifest, index) - .expect("index is in range: it came from iterating manifest.create's own chunks"); - put_block(&mut stream, &chunk_mcid, chunk, identity).await?; - } - put_manifest(&mut stream, &manifest, identity).await?; - Ok(manifest.mcid) -} - -/// Fetch and verify the content addressed by `mcid`. -pub async fn get(session: &Session, mcid: Mcid, identity: &KeyPair) -> Result, GetError> { - let stream = session - .open_dedicated_stream() - .await - .map_err(GetError::OpenStream)?; - get_on(stream, mcid, identity).await -} - -/// [`get`], over a dedicated stream already open to the station. -pub(crate) async fn get_on( - mut stream: FrameStream, - mcid: Mcid, - identity: &KeyPair, -) -> Result, GetError> { - if !manifest::mcid_is_chunked(&mcid) { - let data = get_block(&mut stream, &mcid, identity).await?; - if manifest::block_mcid(&data) != mcid { - return Err(GetError::HashMismatch); - } - return Ok(data); - } - - let manifest = get_manifest(&mut stream, &mcid, identity).await?; - // Deliberately NOT `Vec::with_capacity(manifest.size as usize)`: - // `manifest.size` is a bare claim from whichever peer served this - // manifest, unverified until every chunk is in hand and - // `manifest::verify` runs below — a single small malicious manifest - // could otherwise claim an enormous size and trigger an immediate, - // unbounded allocation attempt before a single byte of real content - // has been fetched. Growing the buffer as genuinely-received, - // individually-hash-verified chunks arrive bounds memory use to - // what has actually, legitimately come off the wire. - let mut data = Vec::new(); - for index in 0..manifest.chunk_count { - let chunk_mcid = manifest::chunk_mcid(&manifest, index) - .expect("index < manifest.chunk_count, so manifest.chunks[index] exists"); - let chunk = get_block(&mut stream, &chunk_mcid, identity).await?; - if manifest::block_mcid(&chunk) != chunk_mcid { - return Err(GetError::HashMismatch); - } - data.extend_from_slice(&chunk); - } - manifest::verify(&manifest, &data).map_err(GetError::Verify)?; - Ok(data) -} - -async fn put_block( - stream: &mut FrameStream, - mcid: &Mcid, - bytes: &[u8], - identity: &KeyPair, -) -> Result<(), PutError> { - let payload = Value::Map(vec![ - (Value::text("mcid"), Value::Bytes(mcid.to_vec())), - (Value::text("payload"), Value::Bytes(bytes.to_vec())), - ]); - let response = call_with_retry(stream, PUT_BLOCK_PROC, payload, BLOCK_TIMEOUT, identity) - .await - .map_err(PutError::Call)?; - match response { - crate::frame::CallResponse::Result { payload, .. } => match payload { - Value::Text(t) if t == "ok" => Ok(()), - Value::Text(t) if t == "hash_mismatch" => Err(PutError::HashMismatch), - other => Err(PutError::UnexpectedReply(other)), - }, - crate::frame::CallResponse::Error { - code, name, detail, .. - } => Err(PutError::Remote { code, name, detail }), - } -} - -async fn put_manifest( - stream: &mut FrameStream, - manifest: &Manifest, - identity: &KeyPair, -) -> Result<(), PutError> { - let payload = Value::Map(vec![(Value::text("manifest"), manifest::to_wire(manifest))]); - let response = call_with_retry( - stream, - PUT_MANIFEST_PROC, - payload, - MANIFEST_TIMEOUT, - identity, - ) - .await - .map_err(PutError::Call)?; - match response { - crate::frame::CallResponse::Result { payload, .. } => match payload { - Value::Text(t) if t == "ok" => Ok(()), - other => Err(PutError::UnexpectedReply(other)), - }, - crate::frame::CallResponse::Error { - code, name, detail, .. - } => Err(PutError::Remote { code, name, detail }), - } -} - -async fn get_block( - stream: &mut FrameStream, - mcid: &Mcid, - identity: &KeyPair, -) -> Result, GetError> { - let payload = Value::Map(vec![(Value::text("mcid"), Value::Bytes(mcid.to_vec()))]); - let response = call_with_retry(stream, GET_BLOCK_PROC, payload, BLOCK_TIMEOUT, identity) - .await - .map_err(GetError::Call)?; - match response { - crate::frame::CallResponse::Result { payload, .. } => match payload { - Value::Bytes(b) => Ok(b), - Value::Text(t) if t == "not_found" => Err(GetError::NotFound), - other => Err(GetError::UnexpectedReply(other)), - }, - crate::frame::CallResponse::Error { - code, name, detail, .. - } => Err(GetError::Remote { code, name, detail }), - } -} - -async fn get_manifest( - stream: &mut FrameStream, - mcid: &Mcid, - identity: &KeyPair, -) -> Result { - let payload = Value::Map(vec![(Value::text("mcid"), Value::Bytes(mcid.to_vec()))]); - let response = call_with_retry( - stream, - GET_MANIFEST_PROC, - payload, - MANIFEST_TIMEOUT, - identity, - ) - .await - .map_err(GetError::Call)?; - match response { - crate::frame::CallResponse::Result { payload, .. } => match payload { - Value::Map(_) => manifest::from_wire(&payload).map_err(GetError::ManifestDecode), - Value::Text(t) if t == "not_found" => Err(GetError::NotFound), - other => Err(GetError::UnexpectedReply(other)), - }, - crate::frame::CallResponse::Error { - code, name, detail, .. - } => Err(GetError::Remote { code, name, detail }), - } -} - -/// Send one `_content.*` CALL, retrying per §12.2's policy: up to -/// [`MAX_ATTEMPTS`] total, [`RETRY_BACKOFF`] between them, only when the -/// prior attempt's ERROR carries a BOLT#4 code flagged -/// [retryable](bolt4::Code::is_retryable). A non-retryable ERROR, or a -/// RESULT (whatever its payload turns out to mean to the caller), both -/// return on the first attempt. -async fn call_with_retry( - stream: &mut FrameStream, - procedure: &str, - payload: Value, - timeout: Duration, - identity: &KeyPair, -) -> Result { - let mut attempt = 0; - loop { - attempt += 1; - let deadline_ms = (now_ms() + timeout.as_millis() as u64) as i128; - let outcome = stream - .call( - procedure, - CONTENT_REALM, - payload.clone(), - deadline_ms, - identity, - timeout, - ) - .await; - - let should_retry = attempt < MAX_ATTEMPTS - && matches!( - &outcome, - Ok(crate::frame::CallResponse::Error { code, .. }) - if bolt4::Code::from_u8(*code).is_some_and(bolt4::Code::is_retryable) - ); - if !should_retry { - return outcome; - } - tokio::time::sleep(RETRY_BACKOFF).await; - } -} - -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock after epoch") - .as_millis() as u64 -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn small_data_addresses_as_a_single_block() { - let data = vec![7u8; 100]; - assert!(data.len() <= manifest::DEFAULT_CHUNK_SIZE); - let mcid = manifest::block_mcid(&data); - assert!(!manifest::mcid_is_chunked(&mcid)); - } - - #[test] - fn large_data_would_address_as_a_manifest() { - let data = vec![7u8; manifest::DEFAULT_CHUNK_SIZE + 1]; - let opts = manifest::CreateOptions::default(); - let (manifest, chunks) = manifest::create(&data, &opts); - assert!(chunks.len() > 1); - assert!(manifest::mcid_is_chunked(&manifest.mcid)); - } - - #[test] - fn call_with_retry_backoff_matches_the_spec() { - assert_eq!(MAX_ATTEMPTS, 3); - assert_eq!(RETRY_BACKOFF, Duration::from_millis(200)); - } -} diff --git a/src/control_channel.rs b/src/control_channel.rs deleted file mode 100644 index 6bb89ad..0000000 --- a/src/control_channel.rs +++ /dev/null @@ -1,1668 +0,0 @@ -//! One session's control stream: a single reader that routes every frame, -//! and writers that take turns. [`Session`](crate::connection::Session) is -//! the public face of this; the machinery lives here. -//! -//! The reader hands a RESULT or ERROR to the call waiting on its `call_id`, -//! an EVENT to every subscription whose realm is the event's and whose topic -//! pattern matches it (the station's rule: both split on "/", equal segment -//! counts, and "*" matches exactly one whole segment), and a CALL signed by -//! the caller it names to the inbound call queue. GOODBYE, HELLO or CONNECT -//! after the handshake, and a frame that can't be decoded end the session. -//! Any other frame is dropped and counted by type, with at most one log line -//! per type per minute. -//! -//! Writers take turns. Waiting for a turn is bounded by the caller's own -//! deadline: a call's timeout, and the send timeout for every other frame. -//! A write in progress is bounded by the send timeout, and one that stalls -//! past it ends the session. The reader never waits on a write: the frames a -//! session sends on its own account, the replies the reader makes and the -//! RPC telemetry facts, go to a writer of their own, and one is dropped when -//! 64 already wait there. -//! -//! When the session ends, every waiting call, every subscription, the -//! inbound call queue and every later operation report why, the end is -//! logged once with that reason and both node ids, and the session's -//! `on_ended` runs once. - -use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError}; -use std::time::Duration; - -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; -use tokio::sync::{mpsc, oneshot, watch}; -use tokio::task::JoinHandle; - -use crate::bolt4; -use crate::cbor::Value; -use crate::frame::{self, CallInfo, CallResponse, CallSpec, Decoded, EventInfo, SubscribeSpec}; -use crate::identity::KeyPair; -use drop_warning::{DropWarnings, Kind, Reason, Subject}; - -pub(crate) type BoxRead = Box; -pub(crate) type BoxWrite = Box; - -/// Runs once when a session ends, with why and whether it was closed here. -pub(crate) type OnEnded = Box; - -/// How many events one subscription holds before it falls behind. -pub(crate) const EVENT_QUEUE_CAPACITY: usize = 256; -/// How many inbound CALLs wait to be served before an extra one is refused. -pub(crate) const CALL_QUEUE_CAPACITY: usize = 64; -/// How many frames a session's own writer holds before it drops one. -pub(crate) const HAND_OFF_CAPACITY: usize = 64; -/// How long a send waits for its turn to write, and how long any write may -/// take before it ends the session. -pub(crate) const SEND_TIMEOUT: Duration = Duration::from_secs(30); - -const LOG_INTERVAL: Duration = Duration::from_secs(60); -const READ_CHUNK: usize = 64 * 1024; - -/// Why a session ended. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SessionEndReason { - /// The station sent GOODBYE. - Goodbye { - reason: String, - detail: Option, - }, - /// The station sent HELLO or CONNECT, which only belong to the - /// handshake, after it. - ProtocolViolation { frame_type: String }, - /// A write on the control stream stalled for longer than the send - /// timeout, so a frame may be half written. - SendTimeout, - /// The station sent a frame that couldn't be decoded, so nothing after it - /// could be read in step. - Malformed(String), - /// The control stream ended or failed. - StreamFailed(String), - /// The session was closed or dropped here. - Closed, -} - -impl std::fmt::Display for SessionEndReason { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SessionEndReason::Goodbye { - reason, - detail: None, - } => write!(f, "the station said goodbye: {reason}"), - SessionEndReason::Goodbye { - reason, - detail: Some(detail), - } => write!(f, "the station said goodbye: {reason} ({detail})"), - SessionEndReason::ProtocolViolation { frame_type } => { - write!(f, "the station sent {frame_type} after the handshake") - } - SessionEndReason::SendTimeout => write!( - f, - "a write on the control stream stalled past the send timeout" - ), - SessionEndReason::Malformed(why) => { - write!( - f, - "the station sent a frame that could not be decoded: {why}" - ) - } - SessionEndReason::StreamFailed(why) => write!(f, "the control stream failed: {why}"), - SessionEndReason::Closed => write!(f, "the session was closed"), - } - } -} - -/// Why a frame couldn't be sent on a session. -#[derive(Debug)] -pub enum SendError { - /// No turn to write came within the send timeout. The frame was not - /// sent, and the session carries on. - Timeout, - /// This frame's own write stalled past the send timeout, and the session - /// ended. - SendTimeout, - /// The session had ended, or ended while the frame waited for its turn. - SessionEnded(SessionEndReason), - Encode(frame::EncodeFrameError), - /// Writing to the control stream failed. - Write(std::io::Error), -} - -impl std::fmt::Display for SendError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SendError::Timeout => write!( - f, - "no turn to write came within the send timeout, so the frame was not sent" - ), - SendError::SendTimeout => write!( - f, - "the write stalled past the send timeout, and the session ended" - ), - SendError::SessionEnded(reason) => write!(f, "the session has ended: {reason}"), - SendError::Encode(e) => write!(f, "encoding the frame: {e}"), - SendError::Write(e) => write!(f, "writing to the control stream: {e}"), - } - } -} - -impl std::error::Error for SendError {} - -/// Why a call on a session got no reply. -#[derive(Debug)] -pub enum CallError { - /// The call's own timeout ran out. `write_started` is false when the - /// CALL was still waiting for its turn to write, so it was never sent, - /// and true once its write had started, so the station may have it. A - /// reply that arrives later is counted as unrouted. - Timeout { - write_started: bool, - }, - /// The session ended before a reply came. `write_started` means the same - /// as for [`CallError::Timeout`]. - SessionEnded { - reason: SessionEndReason, - write_started: bool, - }, - /// This call's own write stalled past the send timeout, and the session - /// ended. The station may have part of the CALL. - SendTimeout, - Encode(frame::EncodeFrameError), - /// Writing the CALL failed. - Write(std::io::Error), - /// A reply carried this call's `call_id` but wasn't a RESULT or ERROR. - MalformedReply(frame::ParseCallResponseError), -} - -impl CallError { - /// Whether the CALL was never sent, so trying it elsewhere can't run it - /// twice. - pub fn not_sent(&self) -> bool { - matches!( - self, - CallError::Timeout { - write_started: false - } | CallError::SessionEnded { - write_started: false, - .. - } | CallError::Encode(_) - ) - } -} - -impl std::fmt::Display for CallError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - CallError::Timeout { - write_started: false, - } => write!( - f, - "no turn to write the CALL came in time, so it was not sent" - ), - CallError::Timeout { - write_started: true, - } => write!(f, "timed out waiting for a RESULT or ERROR"), - CallError::SessionEnded { - reason, - write_started: false, - } => write!(f, "the session ended before the CALL was sent: {reason}"), - CallError::SessionEnded { - reason, - write_started: true, - } => write!(f, "the session ended while awaiting a reply: {reason}"), - CallError::SendTimeout => write!( - f, - "the CALL's write stalled past the send timeout, and the session ended" - ), - CallError::Encode(e) => write!(f, "encoding the CALL: {e}"), - CallError::Write(e) => write!(f, "writing the CALL: {e}"), - CallError::MalformedReply(e) => write!(f, "the reply was malformed: {e}"), - } - } -} - -impl std::error::Error for CallError {} - -/// Why a subscription produced no event. -#[derive(Debug)] -pub enum RecvEventError { - /// No event arrived in time. - Timeout, - /// This subscription fell more than 256 events behind. The events it had - /// queued were read first; it receives nothing more, but keeps the - /// station subscribed until it is closed, so a replacement subscribed - /// first takes over without a gap. The session carries on. - Overflow, - /// The session ended. - SessionEnded(SessionEndReason), -} - -impl std::fmt::Display for RecvEventError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - RecvEventError::Timeout => write!(f, "timed out waiting for an event"), - RecvEventError::Overflow => write!( - f, - "the subscription fell more than {EVENT_QUEUE_CAPACITY} events behind" - ), - RecvEventError::SessionEnded(reason) => write!(f, "the session has ended: {reason}"), - } - } -} - -impl std::error::Error for RecvEventError {} - -/// The calls waiting for their reply, by call id. -type PendingCalls = - HashMap<[u8; 16], oneshot::Sender>>; - -/// The routing and writing state of one session's control stream, shared by -/// the session's handles, its reader task and its hand-off writer. -pub(crate) struct Channel { - identity: KeyPair, - station: [u8; 32], - send_timeout: Duration, - /// Holding this lock is the turn to write. - writer: Arc>, - calls: Mutex, - subscriptions: Mutex>, - /// Subscribing and closing take turns across the count change and the - /// SUBSCRIBE or UNSUBSCRIBE it sends, so the frames reach the station in - /// the order the counts changed. - subscription_turn: tokio::sync::Mutex<()>, - next_subscription: AtomicU64, - inbound_tx: Mutex>>, - inbound_rx: tokio::sync::Mutex>, - hand_off: Mutex>>, - unrouted: Mutex>, - reported: Mutex)>>, - /// The bounded warnings for dropped CALLs and replies and refused streams. - drop_warnings: DropWarnings, - ended: OnceLock, - ended_tx: watch::Sender, - on_ended: Mutex>, -} - -/// One subscription as the reader sees it. `events` is dropped when the -/// subscription overflows or the session ends, which is how its receiver -/// learns it gets nothing more. -struct Entry { - id: u64, - realm: [u8; 32], - topic: String, - subscriber: [u8; 32], - events: Option>, - overflowed: Arc, -} - -impl Entry { - fn holds(&self, realm: &[u8; 32], topic: &str) -> bool { - self.realm == *realm && self.topic == topic - } -} - -/// Why a writer got no turn. -enum Refused { - Timeout, - Ended(SessionEndReason), -} - -/// How a write that got its turn went. -enum Written { - Whole, - Stalled, - Failed(std::io::Error), -} - -// A panic elsewhere while a lock was held leaves the data itself intact. -fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { - mutex.lock().unwrap_or_else(PoisonError::into_inner) -} - -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -fn text_field(frame: &Value, key: &str) -> Option { - match frame.get(key)? { - Value::Text(text) => Some(text.clone()), - Value::Bytes(bytes) => String::from_utf8(bytes.clone()).ok(), - _ => None, - } -} - -/// The station's topic rule: both split on "/", equal segment counts, and -/// each pattern segment equal to the topic's or "*", which matches exactly -/// one whole segment. -pub(crate) fn topic_matches(pattern: &str, topic: &str) -> bool { - let pattern: Vec<&str> = pattern.split('/').collect(); - let topic: Vec<&str> = topic.split('/').collect(); - pattern.len() == topic.len() - && pattern - .iter() - .zip(&topic) - .all(|(segment, actual)| *segment == "*" || segment == actual) -} - -impl Channel { - /// Starts the reader and the hand-off writer on a control stream whose - /// handshake is done. `leftover` is what the handshake read past HELLO. - /// `identity` signs the frames the session sends on its own account. - pub(crate) fn start( - reader: BoxRead, - leftover: Vec, - writer: BoxWrite, - identity: KeyPair, - station: [u8; 32], - send_timeout: Duration, - on_ended: OnEnded, - ) -> Arc { - let (inbound_tx, inbound_rx) = mpsc::channel(CALL_QUEUE_CAPACITY); - let (hand_off_tx, hand_off_rx) = mpsc::channel(HAND_OFF_CAPACITY); - let (ended_tx, _) = watch::channel(false); - let channel = Arc::new(Channel { - identity, - station, - send_timeout, - writer: Arc::new(tokio::sync::Mutex::new(writer)), - calls: Mutex::new(HashMap::new()), - subscriptions: Mutex::new(Vec::new()), - subscription_turn: tokio::sync::Mutex::new(()), - next_subscription: AtomicU64::new(0), - inbound_tx: Mutex::new(Some(inbound_tx)), - inbound_rx: tokio::sync::Mutex::new(inbound_rx), - hand_off: Mutex::new(Some(hand_off_tx)), - unrouted: Mutex::new(HashMap::new()), - reported: Mutex::new(HashMap::new()), - drop_warnings: DropWarnings::new(station), - ended: OnceLock::new(), - ended_tx, - on_ended: Mutex::new(Some(on_ended)), - }); - tokio::spawn(read(channel.clone(), reader, leftover)); - tokio::spawn(write_handed_off(channel.clone(), hand_off_rx)); - channel - } - - /// Why the session ended, once it has. - pub(crate) fn end_reason(&self) -> Option { - self.ended.get().cloned() - } - - /// Resolves once the session has ended, with why. - pub(crate) async fn ended(&self) -> SessionEndReason { - let mut ended = self.ended_tx.subscribe(); - let _ = ended.wait_for(|ended| *ended).await; - self.end_reason().unwrap_or(SessionEndReason::Closed) - } - - /// How many frames of each type arrived with nothing to route them to. - pub(crate) fn unrouted_frame_counts(&self) -> HashMap { - lock(&self.unrouted).clone() - } - - /// The bounded warnings for frames this session drops and streams it - /// refuses. - pub(crate) fn drop_warnings(&self) -> &DropWarnings { - &self.drop_warnings - } - - /// Sends an already signed frame whole. Waiting for the turn to write is - /// bounded by the send timeout; the write itself runs in a task of its - /// own, so a caller that stops waiting never leaves a frame half written. - pub(crate) async fn send(self: &Arc, signed: &Value) -> Result<(), SendError> { - let bytes = frame::encode(signed).map_err(SendError::Encode)?; - let write = self - .start_write(bytes, self.send_timeout, None) - .await - .map_err(|refused| match refused { - Refused::Timeout => SendError::Timeout, - Refused::Ended(reason) => SendError::SessionEnded(reason), - })?; - match write.await { - Ok(Written::Whole) => Ok(()), - Ok(Written::Stalled) => Err(SendError::SendTimeout), - Ok(Written::Failed(e)) => Err(SendError::Write(e)), - Err(join) => Err(SendError::Write(std::io::Error::other(join))), - } - } - - /// Hands an unsigned frame to the writer of the frames the session sends - /// on its own account, without waiting. It is dropped when 64 frames - /// already wait there, or the session ended. - pub(crate) fn hand_off(&self, frame: Value) { - if let Some(frames) = lock(&self.hand_off).as_ref() { - let _ = frames.try_send(frame); - } - } - - /// Sends `spec` as a CALL signed by `identity` and waits for its reply, - /// all within `timeout`, its turn to write included. `after_written`, an - /// unsigned frame, is handed off once the CALL is written. - pub(crate) async fn call( - self: &Arc, - spec: &CallSpec, - identity: &KeyPair, - timeout: Duration, - after_written: Option, - ) -> Result { - let deadline = tokio::time::Instant::now() + timeout; - let bytes = - frame::encode(&frame::sign(frame::call(spec), identity)).map_err(CallError::Encode)?; - let (reply_tx, reply_rx) = oneshot::channel(); - lock(&self.calls).insert(spec.call_id, reply_tx); - let _waiting = Waiting { - channel: self, - call_id: spec.call_id, - }; - let write = match self.start_write(bytes, timeout, after_written).await { - Ok(write) => write, - Err(Refused::Timeout) => { - return Err(CallError::Timeout { - write_started: false, - }) - } - Err(Refused::Ended(reason)) => { - return Err(CallError::SessionEnded { - reason, - write_started: false, - }) - } - }; - match tokio::time::timeout_at(deadline, write).await { - Err(_) => { - return Err(CallError::Timeout { - write_started: true, - }) - } - Ok(Ok(Written::Whole)) => {} - Ok(Ok(Written::Stalled)) => return Err(CallError::SendTimeout), - Ok(Ok(Written::Failed(e))) => { - return Err(match self.end_reason() { - Some(reason) => CallError::SessionEnded { - reason, - write_started: true, - }, - None => CallError::Write(e), - }) - } - Ok(Err(join)) => return Err(CallError::Write(std::io::Error::other(join))), - } - match tokio::time::timeout_at(deadline, reply_rx).await { - Err(_) => Err(CallError::Timeout { - write_started: true, - }), - Ok(Ok(Ok(response))) => Ok(response), - Ok(Ok(Err(malformed))) => Err(CallError::MalformedReply(malformed)), - Ok(Err(_)) => Err(CallError::SessionEnded { - reason: self.end_reason().unwrap_or(SessionEndReason::Closed), - write_started: true, - }), - } - } - - /// The next inbound CALL signed by its caller. Once the session ended and - /// the queued calls are served, why it ended. - pub(crate) async fn next_inbound_call(&self) -> Result { - let mut calls = self.inbound_rx.lock().await; - calls - .recv() - .await - .ok_or_else(|| self.end_reason().unwrap_or(SessionEndReason::Closed)) - } - - /// Starts a subscription, sending SUBSCRIBE signed by `identity` unless - /// another subscription on this session already holds that realm and - /// topic at the station. - pub(crate) async fn subscribe( - self: &Arc, - spec: &SubscribeSpec, - identity: &KeyPair, - ) -> Result { - let _turn = self.subscription_turn.lock().await; - if let Some(reason) = self.end_reason() { - return Err(SendError::SessionEnded(reason)); - } - let (events_tx, events_rx) = mpsc::channel(EVENT_QUEUE_CAPACITY); - let overflowed = Arc::new(AtomicBool::new(false)); - let id = self.next_subscription.fetch_add(1, Ordering::Relaxed); - let first = { - let mut entries = lock(&self.subscriptions); - let first = !entries - .iter() - .any(|entry| entry.holds(&spec.realm, &spec.topic)); - entries.push(Entry { - id, - realm: spec.realm, - topic: spec.topic.clone(), - subscriber: spec.subscriber, - events: Some(events_tx), - overflowed: overflowed.clone(), - }); - first - }; - if first { - if let Err(e) = self - .send(&frame::sign(frame::subscribe(spec), identity)) - .await - { - lock(&self.subscriptions).retain(|entry| entry.id != id); - return Err(e); - } - } - Ok(Subscription { - channel: self.clone(), - id, - realm: spec.realm, - topic: spec.topic.clone(), - events: events_rx, - overflowed, - closed: false, - }) - } - - /// Ends a subscription, and sends UNSUBSCRIBE when no other subscription - /// on this session still holds its realm and topic. - async fn remove_subscription(self: &Arc, id: u64) { - let _turn = self.subscription_turn.lock().await; - let removed = { - let mut entries = lock(&self.subscriptions); - entries - .iter() - .position(|entry| entry.id == id) - .map(|index| { - let entry = entries.remove(index); - let last = !entries - .iter() - .any(|other| other.holds(&entry.realm, &entry.topic)); - (entry, last) - }) - }; - let Some((entry, true)) = removed else { - return; - }; - if self.end_reason().is_some() { - return; - } - let spec = frame::UnsubscribeSpec::new(entry.topic, entry.realm, entry.subscriber); - // Not sent in time, or the session ended; a station drops a - // connection's subscriptions together with the connection. - let _ = self - .send(&frame::sign(frame::unsubscribe(&spec), &self.identity)) - .await; - } - - /// Closes the session here: GOODBYE, as best it can within the send - /// timeout, then the stream's sending side, then the end itself. - pub(crate) async fn close(self: &Arc, goodbye: &Value) { - let _ = self.send(goodbye).await; - if let Ok(mut writer) = tokio::time::timeout(self.send_timeout, self.writer.lock()).await { - let _ = tokio::time::timeout(self.send_timeout, writer.shutdown()).await; - } - self.end(SessionEndReason::Closed, true); - } - - /// Ends the session with `reason`, once: logs it, fails every waiting - /// call, ends every subscription and the inbound call queue, stops the - /// reader and the hand-off writer, and runs `on_ended`. - pub(crate) fn end(&self, reason: SessionEndReason, closed_here: bool) { - if self.ended.set(reason.clone()).is_err() { - return; - } - // Every end is reported once, so why a session went away can be found - // afterwards: a warning when the station or the connection ended it, - // information when it was closed here. - let report = format!( - "macula: session {} to station {} ended: {reason}", - hex(&self.identity.node_id()), - hex(&self.station) - ); - if closed_here { - log::info!("{report}"); - } else { - log::warn!("{report}"); - } - self.ended_tx.send_replace(true); - lock(&self.calls).clear(); - for entry in lock(&self.subscriptions).iter_mut() { - entry.events = None; - } - lock(&self.inbound_tx).take(); - lock(&self.hand_off).take(); - if let Some(on_ended) = lock(&self.on_ended).take() { - on_ended(&reason, closed_here); - } - } - - /// Waits up to `wait` for the turn to write, then writes `bytes` in a task - /// of its own that gives the turn back when done, bounded by the send - /// timeout. A write that stalls past it ends the session before the turn - /// is given back, so nothing is written after a half-written frame. - async fn start_write( - self: &Arc, - bytes: Vec, - wait: Duration, - after_written: Option, - ) -> Result, Refused> { - if let Some(reason) = self.end_reason() { - return Err(Refused::Ended(reason)); - } - let mut ended = self.ended_tx.subscribe(); - let turn = tokio::select! { - biased; - _ = ended.wait_for(|ended| *ended) => { - return Err(Refused::Ended(self.end_reason().unwrap_or(SessionEndReason::Closed))); - } - turn = tokio::time::timeout(wait, self.writer.clone().lock_owned()) => { - turn.map_err(|_| Refused::Timeout)? - } - }; - if let Some(reason) = self.end_reason() { - return Err(Refused::Ended(reason)); - } - let channel = self.clone(); - Ok(tokio::spawn(async move { - let mut writer = turn; - let written = tokio::time::timeout(channel.send_timeout, async { - writer.write_all(&bytes).await?; - writer.flush().await - }) - .await; - let outcome = match written { - Ok(Ok(())) => Written::Whole, - Ok(Err(e)) => Written::Failed(e), - Err(_) => { - channel.end(SessionEndReason::SendTimeout, false); - Written::Stalled - } - }; - drop(writer); - if let (Written::Whole, Some(frame)) = (&outcome, after_written) { - channel.hand_off(frame); - } - outcome - })) - } - - // Routes one frame, returning why when the frame ends the session. - fn route(&self, frame: Value) -> Option { - let frame_type = text_field(&frame, "frame_type").unwrap_or_else(|| "unknown".to_string()); - match frame_type.as_str() { - "result" | "error" => { - self.complete_call(&frame_type, &frame); - None - } - "event" => { - self.deliver_event(&frame); - None - } - "call" => { - self.queue_inbound_call(&frame); - None - } - "goodbye" => Some(SessionEndReason::Goodbye { - reason: text_field(&frame, "reason") - .unwrap_or_else(|| "no reason given".to_string()), - detail: text_field(&frame, "detail"), - }), - "hello" | "connect" => Some(SessionEndReason::ProtocolViolation { frame_type }), - _ => { - self.drop_unrouted(&frame_type); - None - } - } - } - - fn complete_call(&self, frame_type: &str, frame: &Value) { - let call_id = frame::frame_call_id(frame); - let waiting = call_id.and_then(|call_id| lock(&self.calls).remove(&call_id)); - match (waiting, call_id) { - (Some(reply), _) => { - let _ = reply.send(frame::parse_call_response(frame)); - } - (None, Some(call_id)) => { - self.drop_reply(frame_type, Reason::UnknownCallId, Subject::CallId(call_id)) - } - (None, None) => self.drop_reply(frame_type, Reason::Malformed, Subject::Nothing), - } - } - - /// Counts a RESULT or ERROR no call waits for, with a bounded warning. - fn drop_reply(&self, frame_type: &str, reason: Reason, subject: Subject) { - self.count_unrouted(frame_type); - self.drop_warnings - .record(Kind::DroppedReply, reason, subject); - } - - fn deliver_event(&self, frame: &Value) { - let Ok(event) = frame::parse_event(frame) else { - self.drop_unrouted("event"); - return; - }; - let mut matched = false; - for entry in lock(&self.subscriptions).iter_mut() { - let Some(events) = entry.events.as_ref() else { - continue; - }; - if entry.realm != event.realm || !topic_matches(&entry.topic, &event.topic) { - continue; - } - matched = true; - if let Err(mpsc::error::TrySendError::Full(_)) = events.try_send(event.clone()) { - entry.overflowed.store(true, Ordering::Release); - entry.events = None; - } - } - if !matched { - self.drop_unrouted("event"); - } - } - - fn queue_inbound_call(&self, frame: &Value) { - // A CALL that isn't signed by the caller it names gets no reply, and - // nothing else looks at it first, as in macula_station_link.erl's - // on_inbound_call/3. - if let Err(reason) = drop_warning::signed_caller(frame) { - self.drop_call(reason, frame); - return; - } - let Ok(call) = frame::parse_call(frame) else { - self.drop_call(Reason::Malformed, frame); - return; - }; - let refused = match lock(&self.inbound_tx).as_ref() { - Some(calls) => match calls.try_send(call) { - Err(mpsc::error::TrySendError::Full(call)) => Some(call.call_id), - _ => None, - }, - None => None, - }; - // A CALL that doesn't fit gets temporary_relay_failure, as a handler - // crash does: the handler never ran, so the caller may try again - // instead of waiting out its deadline. Serving carries on with the - // calls queued, and when the hand-off is full too, the refusal is - // dropped and the caller's deadline covers it. - if let Some(call_id) = refused { - self.hand_off(frame::call_error(&frame::CallErrorSpec::new( - call_id, - bolt4::Code::TemporaryRelayFailure, - self.identity.node_id(), - ))); - } - } - - /// Counts a dropped inbound CALL, with a bounded warning. - fn drop_call(&self, reason: Reason, frame: &Value) { - self.count_unrouted("call"); - self.drop_warnings - .record(Kind::DroppedCall, reason, drop_warning::procedure_of(frame)); - } - - fn count_unrouted(&self, frame_type: &str) { - *lock(&self.unrouted) - .entry(frame_type.to_string()) - .or_default() += 1; - } - - /// Counts a frame nothing routes, with at most one warning line per frame - /// type a minute. - fn drop_unrouted(&self, frame_type: &str) { - self.count_unrouted(frame_type); - let mut reported = lock(&self.reported); - let (dropped, last) = reported.entry(frame_type.to_string()).or_insert((0, None)); - *dropped += 1; - let now = std::time::Instant::now(); - if last.is_none_or(|last| now.duration_since(last) >= LOG_INTERVAL) { - log::warn!( - "macula: dropped {dropped} unrouted {frame_type} frame(s) in the last minute (station {})", - hex(&self.station) - ); - *dropped = 0; - *last = Some(now); - } - } -} - -/// Removes a call's reply slot however the call ends, so a late reply is -/// counted as unrouted. -struct Waiting<'a> { - channel: &'a Channel, - call_id: [u8; 16], -} - -impl Drop for Waiting<'_> { - fn drop(&mut self) { - lock(&self.channel.calls).remove(&self.call_id); - } -} - -async fn read(channel: Arc, mut reader: BoxRead, mut buf: Vec) { - let mut chunk = vec![0u8; READ_CHUNK]; - let mut ended = channel.ended_tx.subscribe(); - loop { - loop { - match frame::decode(&buf) { - Ok(Decoded::Frame(value, consumed)) => { - buf.drain(..consumed); - if let Some(reason) = channel.route(value) { - channel.end(reason, false); - return; - } - } - Ok(Decoded::More(_)) => break, - Err(e) => { - // Nothing after a frame that can't be decoded can be read - // in step. - channel.end(SessionEndReason::Malformed(e.to_string()), false); - return; - } - } - } - let read = tokio::select! { - _ = ended.wait_for(|ended| *ended) => return, - read = reader.read(&mut chunk) => read, - }; - match read { - Ok(0) => { - channel.end( - SessionEndReason::StreamFailed( - "the station closed the control stream".to_string(), - ), - false, - ); - return; - } - Ok(n) => buf.extend_from_slice(&chunk[..n]), - Err(e) => { - channel.end( - SessionEndReason::StreamFailed(format!("reading: {e}")), - false, - ); - return; - } - } - } -} - -// Sends the frames handed off, one at a time, until the session ends. -async fn write_handed_off(channel: Arc, mut frames: mpsc::Receiver) { - while let Some(frame) = frames.recv().await { - // Not sent in time, or the session ended. Nothing waits on these - // frames: a caller's own deadline covers a missing reply, and a fact - // is best effort. - let _ = channel.send(&frame::sign(frame, &channel.identity)).await; - } -} - -/// One subscription on a [`Session`](crate::connection::Session): every -/// EVENT whose realm is this subscription's and whose topic matches its -/// pattern is copied into a queue of its own of 256 events. Several -/// subscriptions on one session each get their own copy. -/// -/// A subscription that falls more than 256 events behind gets its queued -/// events, then [`RecvEventError::Overflow`], and nothing more, while the -/// session and its other subscriptions carry on. It keeps the station -/// subscribed until it is closed. Close it, or drop it, to stop: the session -/// sends UNSUBSCRIBE once no other subscription on it holds that realm and -/// topic. -pub struct Subscription { - channel: Arc, - id: u64, - realm: [u8; 32], - topic: String, - events: mpsc::Receiver, - overflowed: Arc, - closed: bool, -} - -impl Subscription { - pub fn realm(&self) -> [u8; 32] { - self.realm - } - - /// The topic pattern this subscription matches. - pub fn topic(&self) -> &str { - &self.topic - } - - /// Whether this subscription fell behind and receives nothing more. - pub fn is_overflowed(&self) -> bool { - self.overflowed.load(Ordering::Acquire) - } - - /// Waits up to `timeout` for the next event. - pub async fn recv_event(&mut self, timeout: Duration) -> Result { - match tokio::time::timeout(timeout, self.events.recv()).await { - Err(_) => Err(RecvEventError::Timeout), - Ok(Some(event)) => Ok(event), - Ok(None) if self.is_overflowed() => Err(RecvEventError::Overflow), - Ok(None) => Err(RecvEventError::SessionEnded( - self.channel - .end_reason() - .unwrap_or(SessionEndReason::Closed), - )), - } - } - - /// Ends this subscription, and sends UNSUBSCRIBE when no other - /// subscription on the session holds its realm and topic. - pub async fn close(mut self) { - self.closed = true; - self.channel.remove_subscription(self.id).await; - } -} - -impl Drop for Subscription { - fn drop(&mut self) { - if self.closed { - return; - } - let (channel, id) = (self.channel.clone(), self.id); - match tokio::runtime::Handle::try_current() { - Ok(runtime) => { - runtime.spawn(async move { channel.remove_subscription(id).await }); - } - // Outside a runtime nothing can be sent; the station drops the - // subscription with the connection. - Err(_) => lock(&channel.subscriptions).retain(|entry| entry.id != id), - } - } -} - -pub(crate) mod drop_warning; - -#[cfg(test)] -pub(crate) mod fake_station; - -#[cfg(test)] -mod tests { - //! The session reader, over in-memory pipes. The names match the Go and - //! .NET tests. - use super::fake_station::*; - use super::*; - - #[tokio::test] - async fn concurrent_calls_on_one_session_each_get_their_own_reply() { - let (channel, mut station, _ended) = connect(); - let procedures: Vec = (0..10).map(|i| format!("app/echo_{i}")).collect(); - - let calls: Vec<_> = procedures - .iter() - .map(|procedure| spawn_call(&channel, call(procedure), WAIT)) - .collect(); - let mut sent = Vec::new(); - for _ in &procedures { - sent.push(station.next("call").await); - } - for frame in sent.iter().rev() { - let procedure = text_field(frame, "procedure").expect("a CALL names its procedure"); - station.reply(frame, &procedure).await; - } - - for (procedure, call) in procedures.iter().zip(calls) { - assert_eq!(reply_text(call.await.unwrap().unwrap()), *procedure); - } - } - - #[tokio::test] - async fn an_event_arriving_during_a_call_reaches_its_subscriber() { - let (channel, mut station, _ended) = connect(); - let mut subscription = channel - .subscribe(&subscribe("app/orders/placed"), &KeyPair::generate()) - .await - .unwrap(); - - let pending = spawn_call(&channel, call("app/echo"), WAIT); - let sent = station.next("call").await; - station.send_event("app/orders/placed", "order 1").await; - station.reply(&sent, "echoed").await; - - assert_eq!(reply_text(pending.await.unwrap().unwrap()), "echoed"); - assert_eq!( - event_text(subscription.recv_event(WAIT).await.unwrap()), - "order 1" - ); - } - - #[tokio::test] - async fn serving_a_call_while_calling_on_the_same_session() { - let (channel, mut station, _ended) = connect(); - - let served = { - let channel = channel.clone(); - tokio::spawn(async move { channel.next_inbound_call().await }) - }; - let pending = spawn_call(&channel, call("app/echo"), WAIT); - let sent = station.next("call").await; - station.send_inbound_call("app/greet", Signer::Caller).await; - station.reply(&sent, "echoed").await; - - assert_eq!(served.await.unwrap().unwrap().procedure, "app/greet"); - assert_eq!(reply_text(pending.await.unwrap().unwrap()), "echoed"); - } - - #[tokio::test] - async fn a_queued_call_past_its_deadline_is_still_served() { - let (channel, mut station, _ended) = connect(); - - station - .send_inbound_call_due("app/echo", Signer::Caller, now_ms() - 1_000) - .await; - - let call = tokio::time::timeout(WAIT, channel.next_inbound_call()) - .await - .unwrap() - .unwrap(); - let echo: crate::connection::CallHandler = Arc::new(|payload: Value| { - Box::pin(async move { Ok(payload) }) - as crate::connection::BoxFuture<'static, Result> - }); - let lookup = - move |_: &[u8; 32], procedure: &str| (procedure == "app/echo").then(|| echo.clone()); - let reply = crate::connection::build_call_reply( - call, - &lookup, - &|_: &[u8; 32], _: &str| crate::ucan::Policy::open(), - &KeyPair::generate(), - None, - ) - .await; - assert!(matches!( - frame::parse_call_response(&reply), - Ok(CallResponse::Result { .. }) - )); - } - - #[tokio::test] - async fn two_subscribers_with_different_topics_each_get_only_their_events() { - let (channel, mut station, _ended) = connect(); - let id = KeyPair::generate(); - let mut orders = channel - .subscribe(&subscribe("app/orders"), &id) - .await - .unwrap(); - let mut invoices = channel - .subscribe(&subscribe("app/invoices"), &id) - .await - .unwrap(); - - station.send_event("app/orders", "order 1").await; - station.send_event("app/invoices", "invoice 1").await; - - assert_eq!( - event_text(orders.recv_event(WAIT).await.unwrap()), - "order 1" - ); - assert_eq!( - event_text(invoices.recv_event(WAIT).await.unwrap()), - "invoice 1" - ); - let short = Duration::from_millis(200); - assert!(matches!( - orders.recv_event(short).await, - Err(RecvEventError::Timeout) - )); - assert!(matches!( - invoices.recv_event(short).await, - Err(RecvEventError::Timeout) - )); - } - - #[tokio::test] - async fn a_wildcard_subscription_matches_exactly_one_segment() { - let (channel, mut station, _ended) = connect(); - let mut placed = channel - .subscribe(&subscribe("app/*/placed"), &KeyPair::generate()) - .await - .unwrap(); - - station - .send_event("app/orders/eu/placed", "two segments") - .await; - station.send_event("app/placed", "no segment").await; - station.send_event("app/orders/placed", "one segment").await; - - assert_eq!( - event_text(placed.recv_event(WAIT).await.unwrap()), - "one segment" - ); - assert!(matches!( - placed.recv_event(Duration::from_millis(200)).await, - Err(RecvEventError::Timeout) - )); - } - - #[tokio::test] - async fn closing_the_last_subscription_for_a_topic_unsubscribes() { - let (channel, mut station, _ended) = connect(); - let id = KeyPair::generate(); - let first = channel - .subscribe(&subscribe("app/orders"), &id) - .await - .unwrap(); - let second = channel - .subscribe(&subscribe("app/orders"), &id) - .await - .unwrap(); - assert_eq!(frame_type(&station.next_frame().await), "subscribe"); - - first.close().await; - // A call right after shows what the session sent in between: nothing. - let pending = spawn_call(&channel, call("app/echo"), WAIT); - let sent = station.next_frame().await; - assert_eq!(frame_type(&sent), "call"); - station.reply(&sent, "echoed").await; - pending.await.unwrap().unwrap(); - - second.close().await; - assert_eq!(frame_type(&station.next_frame().await), "unsubscribe"); - } - - #[tokio::test] - async fn an_overflowed_subscription_keeps_the_station_subscribed_until_it_is_closed() { - let (channel, mut station, _ended) = connect(); - let behind = channel - .subscribe(&subscribe("app/ticks"), &KeyPair::generate()) - .await - .unwrap(); - assert_eq!(frame_type(&station.next_frame().await), "subscribe"); - - // The call's reply comes after every event, so by the time it - // arrives the reader has routed all of them. - let pending = spawn_call(&channel, call("app/echo"), WAIT); - let sent = station.next_frame().await; - for i in 0..=EVENT_QUEUE_CAPACITY { - station.send_event("app/ticks", &format!("tick {i}")).await; - } - station.reply(&sent, "echoed").await; - pending.await.unwrap().unwrap(); - assert!(behind.is_overflowed()); - - // A call right after shows what the session sent since: nothing. - let probe = spawn_call(&channel, call("app/echo"), WAIT); - let next = station.next_frame().await; - assert_eq!(frame_type(&next), "call"); - station.reply(&next, "echoed").await; - probe.await.unwrap().unwrap(); - - behind.close().await; - assert_eq!(frame_type(&station.next_frame().await), "unsubscribe"); - } - - #[tokio::test] - async fn a_stalled_event_consumer_does_not_stall_call_replies() { - let (channel, mut station, _ended) = connect(); - let _stalled = channel - .subscribe(&subscribe("app/ticks"), &KeyPair::generate()) - .await - .unwrap(); - - let pending = spawn_call(&channel, call("app/echo"), WAIT); - let sent = station.next("call").await; - for i in 0..EVENT_QUEUE_CAPACITY + 44 { - station.send_event("app/ticks", &format!("tick {i}")).await; - } - station.reply(&sent, "echoed").await; - - assert_eq!(reply_text(pending.await.unwrap().unwrap()), "echoed"); - } - - #[tokio::test] - async fn an_overflowing_event_consumer_ends_with_an_overflow_error_and_the_session_stays_up() { - let (channel, mut station, _ended) = connect(); - let id = KeyPair::generate(); - let mut behind = channel - .subscribe(&subscribe("app/ticks"), &id) - .await - .unwrap(); - - let pending = spawn_call(&channel, call("app/echo"), WAIT); - let sent = station.next("call").await; - for i in 0..=EVENT_QUEUE_CAPACITY { - station.send_event("app/ticks", &format!("tick {i}")).await; - } - station.reply(&sent, "echoed").await; - pending.await.unwrap().unwrap(); - - for i in 0..EVENT_QUEUE_CAPACITY { - assert_eq!( - event_text(behind.recv_event(WAIT).await.unwrap()), - format!("tick {i}") - ); - } - assert!(matches!( - behind.recv_event(WAIT).await, - Err(RecvEventError::Overflow) - )); - - let mut fresh = channel - .subscribe(&subscribe("app/ticks"), &id) - .await - .unwrap(); - station.send_event("app/ticks", "after the overflow").await; - assert_eq!( - event_text(fresh.recv_event(WAIT).await.unwrap()), - "after the overflow" - ); - } - - #[tokio::test] - async fn an_overflowing_call_queue_answers_the_extra_call_and_keeps_serving() { - let (channel, mut station, _ended) = connect(); - - let mut inbound = Vec::new(); - for i in 0..=CALL_QUEUE_CAPACITY { - inbound.push( - station - .send_inbound_call(&format!("app/job_{i}"), Signer::Caller) - .await, - ); - } - - let refusal = station.next("error").await; - assert_eq!(frame::frame_call_id(&refusal), inbound.last().copied()); - match frame::parse_call_response(&refusal) { - Ok(CallResponse::Error { name, .. }) => assert_eq!(name, "temporary_relay_failure"), - other => panic!("expected an ERROR, got {other:?}"), - } - - // Serving carries on: the queued calls, then the next one to arrive. - for i in 0..CALL_QUEUE_CAPACITY { - let served = tokio::time::timeout(WAIT, channel.next_inbound_call()) - .await - .unwrap() - .unwrap(); - assert_eq!(served.procedure, format!("app/job_{i}")); - } - station - .send_inbound_call("app/after_the_overflow", Signer::Caller) - .await; - let served = tokio::time::timeout(WAIT, channel.next_inbound_call()) - .await - .unwrap() - .unwrap(); - assert_eq!(served.procedure, "app/after_the_overflow"); - } - - #[tokio::test] - async fn a_call_that_cannot_get_the_write_lock_in_time_times_out_and_the_session_stays_up() { - let (channel, mut station, mut ended) = connect(); - station.stall_session_writes(); - let publishing = { - let channel = channel.clone(); - tokio::spawn(async move { channel.send(&publish("app/ticks")).await }) - }; - turn_taken(&channel).await; - - let timed_out = channel - .call( - &call("app/echo"), - &KeyPair::generate(), - Duration::from_millis(100), - None, - ) - .await; - assert!( - matches!( - timed_out, - Err(CallError::Timeout { - write_started: false - }) - ), - "{timed_out:?}" - ); - assert!(ended.try_recv().is_err(), "the session stays up"); - - station.resume_session_writes(); - publishing.await.unwrap().unwrap(); - assert_eq!(frame_type(&station.next_frame().await), "publish"); - let next = call("app/echo"); - let call_id = next.call_id; - let pending = spawn_call(&channel, next, WAIT); - let sent = station.next_frame().await; - assert_eq!(frame::frame_call_id(&sent), Some(call_id)); - station.reply(&sent, "echoed").await; - assert_eq!(reply_text(pending.await.unwrap().unwrap()), "echoed"); - } - - #[tokio::test] - async fn a_write_stalled_past_the_send_timeout_ends_the_session() { - let (channel, station, ended) = connect_with(Duration::from_millis(200)); - station.stall_session_writes(); - - let published = tokio::time::timeout(WAIT, channel.send(&publish("app/ticks"))) - .await - .unwrap(); - assert!( - matches!(published, Err(SendError::SendTimeout)), - "{published:?}" - ); - let (reason, _) = tokio::time::timeout(WAIT, ended).await.unwrap().unwrap(); - assert_eq!(reason, SessionEndReason::SendTimeout); - let called = channel - .call(&call("app/echo"), &KeyPair::generate(), WAIT, None) - .await; - assert!( - matches!( - called, - Err(CallError::SessionEnded { - reason: SessionEndReason::SendTimeout, - write_started: false - }) - ), - "{called:?}" - ); - } - - #[tokio::test] - async fn the_reader_keeps_delivering_while_a_write_is_stalled() { - let (channel, mut station, _ended) = connect(); - let mut ticks = channel - .subscribe(&subscribe("app/ticks"), &KeyPair::generate()) - .await - .unwrap(); - station.next("subscribe").await; - station.stall_session_writes(); - - // The extra calls' refusals can't go out while writes are stalled. - for i in 0..CALL_QUEUE_CAPACITY + 4 { - station - .send_inbound_call(&format!("app/job_{i}"), Signer::Caller) - .await; - } - station.send_event("app/ticks", "tick 1").await; - - assert_eq!(event_text(ticks.recv_event(WAIT).await.unwrap()), "tick 1"); - station.resume_session_writes(); - } - - #[tokio::test] - async fn a_call_timing_out_while_its_frame_is_being_written_reports_it_may_have_been_sent() { - let (channel, station, mut ended) = connect(); - station.stall_session_writes(); - - let called = channel - .call( - &call("app/echo"), - &KeyPair::generate(), - Duration::from_millis(100), - None, - ) - .await; - - assert!( - matches!( - called, - Err(CallError::Timeout { - write_started: true - }) - ), - "{called:?}" - ); - assert!(ended.try_recv().is_err(), "the session stays up"); - station.resume_session_writes(); - } - - #[tokio::test] - async fn a_frame_that_cannot_be_decoded_ends_the_session() { - let (channel, mut station, ended) = connect(); - let pending = spawn_call(&channel, call("app/echo"), Duration::from_secs(10)); - station.next("call").await; - - // A one-byte frame whose CBOR initial byte uses a reserved value. - station.send_raw(&[0, 0, 0, 1, 0x1C]).await; - - let (reason, closed_here) = tokio::time::timeout(WAIT, ended).await.unwrap().unwrap(); - assert!( - matches!(reason, SessionEndReason::Malformed(_)), - "{reason:?}" - ); - assert!(!closed_here); - let called = pending.await.unwrap(); - assert!( - matches!( - called, - Err(CallError::SessionEnded { - write_started: true, - .. - }) - ), - "{called:?}" - ); - } - - #[tokio::test] - async fn a_call_on_a_session_that_has_ended_reports_it_was_not_sent() { - let (channel, station, ended) = connect(); - drop(station); - tokio::time::timeout(WAIT, ended).await.unwrap().unwrap(); - - let called = channel - .call(&call("app/echo"), &KeyPair::generate(), WAIT, None) - .await; - - assert!( - matches!( - called, - Err(CallError::SessionEnded { - write_started: false, - .. - }) - ), - "{called:?}" - ); - } - - #[tokio::test] - async fn a_call_waiting_for_the_write_lock_when_the_session_ends_reports_it_was_not_sent() { - let (channel, mut station, _ended) = connect(); - station.stall_session_writes(); - let _publishing = { - let channel = channel.clone(); - tokio::spawn(async move { channel.send(&publish("app/ticks")).await }) - }; - turn_taken(&channel).await; - let pending = spawn_call(&channel, call("app/echo"), Duration::from_secs(30)); - - station.send(&frame::goodbye("maintenance", None)).await; - - // Well before the call's own 30 second deadline. - let called = tokio::time::timeout(WAIT, pending).await.unwrap().unwrap(); - assert!( - matches!( - called, - Err(CallError::SessionEnded { - write_started: false, - .. - }) - ), - "{called:?}" - ); - station.resume_session_writes(); - } - - #[tokio::test] - async fn a_goodbye_from_the_station_fails_pending_calls_and_ends_the_session() { - let (channel, mut station, ended) = connect(); - let pending = spawn_call(&channel, call("app/echo"), Duration::from_secs(10)); - station.next("call").await; - - station.send(&frame::goodbye("maintenance", None)).await; - - match pending.await.unwrap() { - Err(CallError::SessionEnded { - reason: SessionEndReason::Goodbye { reason, .. }, - write_started: true, - }) => assert_eq!(reason, "maintenance"), - other => panic!("expected the goodbye to end the call, got {other:?}"), - } - let (reason, _) = tokio::time::timeout(WAIT, ended).await.unwrap().unwrap(); - assert!( - matches!(reason, SessionEndReason::Goodbye { ref reason, .. } if reason == "maintenance"), - "{reason:?}" - ); - } - - #[tokio::test] - async fn a_hello_after_the_handshake_ends_the_session() { - let (channel, mut station, ended) = connect(); - let pending = spawn_call(&channel, call("app/echo"), Duration::from_secs(10)); - station.next("call").await; - - station - .send(&Value::Map(vec![( - Value::text("frame_type"), - Value::text("hello"), - )])) - .await; - - assert!( - matches!( - pending.await.unwrap(), - Err(CallError::SessionEnded { - reason: SessionEndReason::ProtocolViolation { .. }, - .. - }) - ), - "a HELLO after the handshake ends the call" - ); - let (reason, _) = tokio::time::timeout(WAIT, ended).await.unwrap().unwrap(); - assert!( - matches!(reason, SessionEndReason::ProtocolViolation { .. }), - "{reason:?}" - ); - } - - #[tokio::test] - async fn a_session_end_is_logged_once_with_its_reason() { - capture_logs(); - - let (ended_by_station, mut station, ended) = connect(); - let station_id = station.identity.node_id(); - station.send(&frame::goodbye("maintenance", None)).await; - tokio::time::timeout(WAIT, ended).await.unwrap().unwrap(); - ended_by_station.end(SessionEndReason::Closed, true); - - let (ended_here, _other_station, _) = connect(); - ended_here.end(SessionEndReason::Closed, true); - - let by_station = logged_about(&ended_by_station.identity.node_id()); - assert_eq!(by_station.len(), 1, "{by_station:?}"); - assert_eq!(by_station[0].0, log::Level::Warn); - assert!(by_station[0].1.contains("maintenance"), "{by_station:?}"); - assert!( - by_station[0].1.contains(&hex(&station_id)), - "{by_station:?}" - ); - let by_us = logged_about(&ended_here.identity.node_id()); - assert_eq!(by_us.len(), 1, "{by_us:?}"); - assert_eq!(by_us[0].0, log::Level::Info); - } - - #[tokio::test] - async fn an_unrouted_frame_is_counted_by_type() { - let (channel, mut station, _ended) = connect(); - - let pending = spawn_call(&channel, call("app/echo"), WAIT); - let sent = station.next("call").await; - let advertise = Value::Map(vec![(Value::text("frame_type"), Value::text("advertise"))]); - station.send(&advertise).await; - station.send(&advertise).await; - let stray = frame::result(&frame::ResultSpec::new( - rand::random(), - Value::Null, - station.identity.node_id(), - )); - station.send(&stray).await; - station.reply(&sent, "echoed").await; - pending.await.unwrap().unwrap(); - - let counts = channel.unrouted_frame_counts(); - assert_eq!(counts.get("advertise"), Some(&2)); - assert_eq!(counts.get("result"), Some(&1)); - } - - struct OpenSession; - - impl crate::open_sessions::Live for OpenSession { - fn is_live(&self) -> bool { - true - } - } - - #[tokio::test] - async fn a_session_whose_connection_ends_is_no_longer_found_for_reuse() { - let (identity, station_id): ([u8; 32], [u8; 32]) = (rand::random(), rand::random()); - let open = Arc::new(crate::open_sessions::OpenSessions::::default()); - let session = Arc::new(OpenSession); - open.register(identity, station_id, &session); - let (ended_tx, ended_rx) = oneshot::channel(); - let (registry, registered) = (open.clone(), session.clone()); - let (_channel, station) = connect_ending( - SEND_TIMEOUT, - Box::new(move |reason, _| { - registry.unregister(identity, station_id, ®istered); - let _ = ended_tx.send(reason.clone()); - }), - ); - - drop(station); - - let reason = tokio::time::timeout(WAIT, ended_rx).await.unwrap().unwrap(); - assert!( - matches!(reason, SessionEndReason::StreamFailed(_)), - "{reason:?}" - ); - assert!(open.find(identity, station_id).is_none()); - } - - // Guards moved here from the serve tests with the signature check itself: - // a CALL that isn't signed by the caller it names never reaches serving - // and gets no reply, as in macula_station_link.erl's on_inbound_call/3. - - #[tokio::test] - async fn an_inbound_call_not_signed_by_its_caller_is_dropped() { - let (channel, mut station, _ended) = connect(); - - station - .send_inbound_call("app/forged", Signer::Other(Box::new(KeyPair::generate()))) - .await; - station - .send_inbound_call("app/genuine", Signer::Caller) - .await; - - let served = tokio::time::timeout(WAIT, channel.next_inbound_call()) - .await - .unwrap() - .unwrap(); - assert_eq!(served.procedure, "app/genuine"); - assert_eq!(channel.unrouted_frame_counts().get("call"), Some(&1)); - } - - #[tokio::test] - async fn an_unsigned_inbound_call_is_dropped() { - let (channel, mut station, _ended) = connect(); - - station - .send_inbound_call("app/unsigned", Signer::Nobody) - .await; - station - .send_inbound_call("app/genuine", Signer::Caller) - .await; - - let served = tokio::time::timeout(WAIT, channel.next_inbound_call()) - .await - .unwrap() - .unwrap(); - assert_eq!(served.procedure, "app/genuine"); - assert_eq!(channel.unrouted_frame_counts().get("call"), Some(&1)); - } - - #[test] - fn a_topic_pattern_matches_by_whole_segments() { - assert!(topic_matches("app/orders", "app/orders")); - assert!(topic_matches("app/*/placed", "app/orders/placed")); - assert!(!topic_matches("app/*/placed", "app/orders/eu/placed")); - assert!(!topic_matches("app/*", "app")); - assert!(!topic_matches("app/orders", "app/order")); - } -} diff --git a/src/control_channel/drop_warning.rs b/src/control_channel/drop_warning.rs deleted file mode 100644 index 2b65fc8..0000000 --- a/src/control_channel/drop_warning.rs +++ /dev/null @@ -1,332 +0,0 @@ -//! The warnings a session logs when it drops an inbound frame or refuses a -//! stream, bounded per kind: the first drop in an interval is logged at once, -//! and the rest in that interval are counted into one closing line when it -//! ends, so a flood of bad frames can't flood the log. The kinds, reasons and -//! fields are the same in every Macula stack. - -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use std::time::Duration; - -use crate::cbor::Value; -use crate::frame; - -/// The interval a session starts with. -pub(crate) const DEFAULT_INTERVAL: Duration = Duration::from_secs(60); - -/// How many bytes of a procedure a warning line carries. -const PROCEDURE_LIMIT: usize = 256; - -/// What was dropped or refused. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Kind { - RefusedStreamOpen, - DroppedCall, - DroppedReply, -} - -impl Kind { - fn name(self) -> &'static str { - match self { - Kind::RefusedStreamOpen => "refused_stream_open", - Kind::DroppedCall => "dropped_call", - Kind::DroppedReply => "dropped_reply", - } - } -} - -/// Why it was dropped or refused. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum Reason { - /// A well-formed signature that doesn't verify against the caller the - /// frame names. - InvalidSignature, - /// A signature that's missing or isn't 64 bytes, or a caller that isn't a - /// 32-byte key. - Unsigned, - /// A frame that doesn't parse. - Malformed, - /// A dedicated stream whose first frame is of another type. - NotAStreamOpen, - /// A RESULT or ERROR for no pending call. - UnknownCallId, -} - -impl Reason { - fn name(self) -> &'static str { - match self { - Reason::InvalidSignature => "invalid_signature", - Reason::Unsigned => "unsigned", - Reason::Malformed => "malformed", - Reason::NotAStreamOpen => "not_a_stream_open", - Reason::UnknownCallId => "unknown_call_id", - } - } -} - -/// What a warning line names besides its kind and reason. -#[derive(Debug)] -pub(crate) enum Subject { - Procedure(String), - CallId([u8; 16]), - Nothing, -} - -impl Subject { - fn field(&self) -> String { - match self { - Subject::Procedure(procedure) => { - format!(" procedure={}", printable(truncated(procedure))) - } - Subject::CallId(call_id) => format!(" call_id={}", super::hex(&call_id[..4])), - Subject::Nothing => String::new(), - } - } -} - -/// The caller `frame` names, when the frame is signed by it: the check macula -/// runs on an inbound CALL or STREAM_OPEN before anything else looks at it. -pub(crate) fn signed_caller(frame: &Value) -> Result<[u8; 32], Reason> { - let caller = match frame.get("caller") { - Some(Value::Bytes(bytes)) => { - <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| Reason::Unsigned)? - } - _ => return Err(Reason::Unsigned), - }; - match frame::verify(frame, &caller) { - Ok(()) => Ok(caller), - Err(frame::VerifyError::MissingSignature | frame::VerifyError::BadSignature) => { - Err(Reason::Unsigned) - } - Err(frame::VerifyError::SignatureInvalid) => Err(Reason::InvalidSignature), - } -} - -/// The procedure `frame` names, as a warning line carries it. -pub(crate) fn procedure_of(frame: &Value) -> Subject { - match frame.get("procedure") { - Some(Value::Bytes(bytes)) => { - Subject::Procedure(String::from_utf8_lossy(bytes).into_owned()) - } - Some(Value::Text(text)) => Subject::Procedure(text.clone()), - _ => Subject::Nothing, - } -} - -/// A session's drop warnings, one limiter per kind. -pub(crate) struct DropWarnings { - station: [u8; 32], - interval: Mutex, - refused_stream_open: Limiter, - dropped_call: Limiter, - dropped_reply: Limiter, -} - -#[derive(Default)] -struct Limiter(Arc>); - -#[derive(Default)] -struct Window { - /// An interval is running: its first drop was logged at once. - open: bool, - /// The drops after that first one, and the latest one's reason and - /// subject field. - later: u64, - latest: Option<(Reason, String)>, -} - -impl DropWarnings { - pub(crate) fn new(station: [u8; 32]) -> Self { - Self { - station, - interval: Mutex::new(DEFAULT_INTERVAL), - refused_stream_open: Limiter::default(), - dropped_call: Limiter::default(), - dropped_reply: Limiter::default(), - } - } - - pub(crate) fn interval(&self) -> Duration { - *lock(&self.interval) - } - - pub(crate) fn set_interval(&self, interval: Duration) { - *lock(&self.interval) = interval; - } - - /// Records one drop of `kind`. The first in an interval is logged at once; - /// later ones are counted and reported in one line when the interval ends, - /// and not at all when none came. - pub(crate) fn record(&self, kind: Kind, reason: Reason, subject: Subject) { - let limiter = match kind { - Kind::RefusedStreamOpen => &self.refused_stream_open, - Kind::DroppedCall => &self.dropped_call, - Kind::DroppedReply => &self.dropped_reply, - }; - let field = subject.field(); - { - let mut window = lock(&limiter.0); - if window.open { - window.later += 1; - window.latest = Some((reason, field)); - return; - } - window.open = true; - } - log_line(self.station, kind, 1, reason, &field); - let (window, interval, station) = (limiter.0.clone(), self.interval(), self.station); - let close = async move { - tokio::time::sleep(interval).await; - let (later, latest) = { - let mut window = lock(&window); - window.open = false; - (std::mem::take(&mut window.later), window.latest.take()) - }; - if let Some((reason, field)) = latest.filter(|_| later > 0) { - log_line(station, kind, later, reason, &field); - } - }; - match tokio::runtime::Handle::try_current() { - Ok(runtime) => { - runtime.spawn(close); - } - // With no runtime to end the interval, every drop gets its own line. - Err(_) => lock(&limiter.0).open = false, - } - } -} - -fn log_line(station: [u8; 32], kind: Kind, count: u64, reason: Reason, field: &str) { - log::warn!( - "macula: kind={} count={count} reason={}{field} (station {})", - kind.name(), - reason.name(), - super::hex(&station) - ); -} - -/// `procedure` cut to [`PROCEDURE_LIMIT`] bytes, on a character boundary. -fn truncated(procedure: &str) -> &str { - let mut end = procedure.len().min(PROCEDURE_LIMIT); - while !procedure.is_char_boundary(end) { - end -= 1; - } - &procedure[..end] -} - -/// `text` with its control characters escaped, `\n`, `\r` and `\t` by name -/// and any other as `\u{..}`, so a warning line never breaks. -fn printable(text: &str) -> String { - let mut out = String::with_capacity(text.len()); - for c in text.chars() { - match c { - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", u32::from(c))), - c => out.push(c), - } - } - out -} - -// A panic elsewhere while a lock was held leaves the counts usable. -fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { - mutex.lock().unwrap_or_else(PoisonError::into_inner) -} - -#[cfg(test)] -mod tests { - //! The shared drop warning tests. The names match the Go, .NET and - //! Erlang tests. - use super::*; - use crate::control_channel::fake_station::{capture_logs, logged_about}; - - const SHORT: Duration = Duration::from_millis(100); - - fn warnings() -> DropWarnings { - let warnings = DropWarnings::new(rand::random()); - warnings.set_interval(SHORT); - warnings - } - - #[tokio::test] - async fn a_drop_burst_logs_one_immediate_line_and_one_closing_line_with_the_rest() { - capture_logs(); - let warnings = warnings(); - - for n in 0..5 { - warnings.record( - Kind::RefusedStreamOpen, - Reason::InvalidSignature, - Subject::Procedure(format!("app/stream_{n}")), - ); - } - let immediate = logged_about(&warnings.station); - tokio::time::sleep(SHORT * 3).await; - let lines = logged_about(&warnings.station); - - assert_eq!(immediate.len(), 1, "{immediate:?}"); - assert!( - immediate[0].1.contains( - "kind=refused_stream_open count=1 reason=invalid_signature procedure=app/stream_0" - ), - "{immediate:?}" - ); - assert_eq!(lines.len(), 2, "{lines:?}"); - assert!( - lines[1].1.contains( - "kind=refused_stream_open count=4 reason=invalid_signature procedure=app/stream_4" - ), - "{lines:?}" - ); - } - - #[tokio::test] - async fn a_single_drop_logs_only_the_immediate_line() { - capture_logs(); - let warnings = warnings(); - - warnings.record( - Kind::DroppedReply, - Reason::UnknownCallId, - Subject::CallId([0xab; 16]), - ); - tokio::time::sleep(SHORT * 3).await; - let lines = logged_about(&warnings.station); - - assert_eq!(lines.len(), 1, "{lines:?}"); - assert!( - lines[0] - .1 - .contains("kind=dropped_reply count=1 reason=unknown_call_id call_id=abababab"), - "{lines:?}" - ); - } - - #[tokio::test] - async fn a_procedure_with_a_newline_stays_on_one_log_line() { - capture_logs(); - let warnings = warnings(); - - warnings.record( - Kind::DroppedCall, - Reason::InvalidSignature, - Subject::Procedure("app/a\nkind=forged\u{1b}".to_string()), - ); - let lines = logged_about(&warnings.station); - - assert_eq!(lines.len(), 1, "{lines:?}"); - assert!(!lines[0].1.contains('\n'), "{lines:?}"); - assert!( - lines[0].1.contains("procedure=app/a\\nkind=forged\\u{1b}"), - "{lines:?}" - ); - } - - #[test] - fn a_procedure_is_cut_on_a_character_boundary() { - let procedure = format!("{}é", "a".repeat(PROCEDURE_LIMIT - 1)); - - assert_eq!(truncated(&procedure), "a".repeat(PROCEDURE_LIMIT - 1)); - } -} diff --git a/src/control_channel/fake_station.rs b/src/control_channel/fake_station.rs deleted file mode 100644 index c0e18cb..0000000 --- a/src/control_channel/fake_station.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! An in-memory station on the other end of a session's control stream, for -//! tests of the session reader and of what runs on sessions, such as the -//! pool. The names match the Go and .NET test helpers. - -use super::*; -use tokio::io::DuplexStream; - -pub(crate) const WAIT: Duration = Duration::from_secs(2); -pub(crate) const REALM: [u8; 32] = [7; 32]; - -/// The station end of a session's control stream. What the session -/// writes reaches the station through a pipe that holds one byte, so a -/// station that stops reading stalls the session's write mid-frame, as a -/// station withholding flow-control credit does. -pub(crate) struct FakeStation { - pub(crate) identity: KeyPair, - to_session: DuplexStream, - frames: mpsc::UnboundedReceiver, - reading: watch::Sender, -} - -pub(crate) type Ended = oneshot::Receiver<(SessionEndReason, bool)>; - -pub(crate) fn connect() -> (Arc, FakeStation, Ended) { - connect_with(SEND_TIMEOUT) -} - -pub(crate) fn connect_with(send_timeout: Duration) -> (Arc, FakeStation, Ended) { - let (ended_tx, ended_rx) = oneshot::channel(); - let (channel, station) = connect_ending( - send_timeout, - Box::new(move |reason, closed_here| { - let _ = ended_tx.send((reason.clone(), closed_here)); - }), - ); - (channel, station, ended_rx) -} - -pub(crate) fn connect_ending( - send_timeout: Duration, - on_ended: OnEnded, -) -> (Arc, FakeStation) { - let (session_writes, station_reads) = tokio::io::duplex(1); - let (station_writes, session_reads) = tokio::io::duplex(READ_CHUNK); - let identity = KeyPair::generate(); - let channel = Channel::start( - Box::new(session_reads), - Vec::new(), - Box::new(session_writes), - KeyPair::generate(), - identity.node_id(), - send_timeout, - on_ended, - ); - let (frames_tx, frames) = mpsc::unbounded_channel(); - let (reading, reading_rx) = watch::channel(true); - tokio::spawn(read_session_frames(station_reads, frames_tx, reading_rx)); - ( - channel, - FakeStation { - identity, - to_session: station_writes, - frames, - reading, - }, - ) -} - -async fn read_session_frames( - mut pipe: DuplexStream, - frames: mpsc::UnboundedSender, - mut reading: watch::Receiver, -) { - let mut buf = Vec::new(); - let mut chunk = [0u8; 4096]; - loop { - if reading.wait_for(|reading| *reading).await.is_err() { - return; - } - let Ok(n) = pipe.read(&mut chunk).await else { - return; - }; - if n == 0 { - return; - } - buf.extend_from_slice(&chunk[..n]); - while let Ok(Decoded::Frame(value, consumed)) = frame::decode(&buf) { - buf.drain(..consumed); - if frames.send(value).is_err() { - return; - } - } - } -} - -pub(crate) enum Signer { - Caller, - Other(Box), - Nobody, -} - -impl FakeStation { - pub(crate) async fn send(&mut self, frame: &Value) { - self.send_raw(&frame::encode(frame).expect("the test frame encodes")) - .await; - } - - pub(crate) async fn send_raw(&mut self, bytes: &[u8]) { - self.to_session - .write_all(bytes) - .await - .expect("the session's pipe is open"); - } - - pub(crate) async fn next_frame(&mut self) -> Value { - tokio::time::timeout(WAIT, self.frames.recv()) - .await - .expect("the session sent a frame in time") - .expect("the session's pipe is open") - } - - pub(crate) async fn next(&mut self, frame_type: &str) -> Value { - loop { - let frame = self.next_frame().await; - if text_field(&frame, "frame_type").as_deref() == Some(frame_type) { - return frame; - } - } - } - - pub(crate) async fn reply(&mut self, call: &Value, text: &str) { - let call_id = frame::frame_call_id(call).expect("a CALL carries its call_id"); - let result = frame::result(&frame::ResultSpec::new( - call_id, - Value::text(text), - self.identity.node_id(), - )); - self.send(&result).await; - } - - pub(crate) async fn send_event(&mut self, topic: &str, payload: &str) { - let event = Value::Map(vec![ - (Value::text("frame_type"), Value::text("event")), - (Value::text("realm"), Value::Bytes(REALM.to_vec())), - ( - Value::text("topic"), - Value::Bytes(topic.as_bytes().to_vec()), - ), - ( - Value::text("publisher"), - Value::Bytes(self.identity.node_id().to_vec()), - ), - (Value::text("seq"), Value::Int(1)), - (Value::text("payload"), Value::text(payload)), - (Value::text("delivered_via"), Value::text("direct")), - ]); - self.send(&event).await; - } - - /// An inbound CALL as a station relays one. Returns its call_id. - pub(crate) async fn send_inbound_call(&mut self, procedure: &str, signer: Signer) -> [u8; 16] { - self.send_inbound_call_due(procedure, signer, now_ms() + 5_000) - .await - } - - /// [`send_inbound_call`](Self::send_inbound_call), due at `deadline_ms`. - pub(crate) async fn send_inbound_call_due( - &mut self, - procedure: &str, - signer: Signer, - deadline_ms: i128, - ) -> [u8; 16] { - let caller = KeyPair::generate(); - let spec = CallSpec::new( - rand::random(), - procedure, - REALM, - Value::Null, - deadline_ms, - caller.node_id(), - ); - let unsigned = frame::call(&spec); - let call = match signer { - Signer::Caller => frame::sign(unsigned, &caller), - Signer::Other(other) => frame::sign(unsigned, &other), - Signer::Nobody => unsigned, - }; - self.send(&call).await; - spec.call_id - } - - pub(crate) fn stall_session_writes(&self) { - self.reading.send_replace(false); - } - - pub(crate) fn resume_session_writes(&self) { - self.reading.send_replace(true); - } - - /// Whether the session sends no frame for `wait`. - pub(crate) async fn nothing_sent_within(&mut self, wait: Duration) -> bool { - tokio::time::timeout(wait, self.frames.recv()) - .await - .is_err() - } -} - -pub(crate) fn now_ms() -> i128 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock after 1970") - .as_millis() as i128 -} - -pub(crate) fn call(procedure: &str) -> CallSpec { - CallSpec::new( - rand::random(), - procedure, - REALM, - Value::Null, - now_ms() + 5_000, - KeyPair::generate().node_id(), - ) -} - -pub(crate) fn subscribe(topic: &str) -> SubscribeSpec { - SubscribeSpec::new(topic, REALM, KeyPair::generate().node_id()) -} - -pub(crate) fn publish(topic: &str) -> Value { - let publisher = KeyPair::generate(); - frame::sign( - frame::publish(&frame::PublishSpec::new( - topic, - REALM, - publisher.node_id(), - 1, - Value::text("tick"), - now_ms() as u64, - )), - &publisher, - ) -} - -pub(crate) fn spawn_call( - channel: &Arc, - spec: CallSpec, - timeout: Duration, -) -> JoinHandle> { - let channel = channel.clone(); - tokio::spawn(async move { - channel - .call(&spec, &KeyPair::generate(), timeout, None) - .await - }) -} - -pub(crate) fn reply_text(response: CallResponse) -> String { - match response { - CallResponse::Result { - payload: Value::Text(text), - .. - } => text, - other => panic!("expected a text RESULT, got {other:?}"), - } -} - -pub(crate) fn event_text(event: EventInfo) -> String { - match event.payload { - Value::Text(text) => text, - other => panic!("expected a text payload, got {other:?}"), - } -} - -pub(crate) fn frame_type(frame: &Value) -> String { - text_field(frame, "frame_type").expect("a frame carries its frame_type") -} - -/// Resolves once some other writer holds the turn to write. -pub(crate) async fn turn_taken(channel: &Channel) { - tokio::time::timeout(WAIT, async { - while channel.writer.try_lock().is_ok() { - tokio::task::yield_now().await; - } - }) - .await - .expect("a writer took the turn in time"); -} - -/// Keeps every log line a test run writes, for tests that check what was -/// logged. The log crate takes one logger for the whole process, so every -/// such test installs this same one and picks its own lines by a node id. -struct CapturingLogger; - -static LOGGED: Mutex> = Mutex::new(Vec::new()); - -impl log::Log for CapturingLogger { - fn enabled(&self, _metadata: &log::Metadata<'_>) -> bool { - true - } - - fn log(&self, record: &log::Record<'_>) { - lock(&LOGGED).push((record.level(), record.args().to_string())); - } - - fn flush(&self) {} -} - -pub(crate) fn capture_logs() { - static INSTALLED: OnceLock<()> = OnceLock::new(); - INSTALLED.get_or_init(|| { - let _ = log::set_logger(&CapturingLogger); - log::set_max_level(log::LevelFilter::Info); - }); -} - -/// The captured lines that name `node_id`, in the order they were logged. -pub(crate) fn logged_about(node_id: &[u8; 32]) -> Vec<(log::Level, String)> { - let node_id = hex(node_id); - lock(&LOGGED) - .iter() - .filter(|(_, line)| line.contains(&node_id)) - .cloned() - .collect() -} diff --git a/src/dht.rs b/src/dht.rs deleted file mode 100644 index 8bb36a9..0000000 --- a/src/dht.rs +++ /dev/null @@ -1,829 +0,0 @@ -//! The subset of Macula's signed DHT records that direct-dial resolution -//! needs: `procedure_advertisement` and `station_endpoint` construction, -//! signing, verification, and storage-key derivation, plus thin wrappers -//! around the mesh's `_dht.*` RPC procedures. -//! -//! Ported from `macula-io/macula`'s `src/record/macula_record.erl` and -//! `src/macula.erl` (the `put_record`/`find_record`/`find_records` facade), -//! cross-checked against `macula-go`'s own port of the same reference -//! (`dht/record.go`, `dht/client.go`) — see those files' doc comments for -//! the fuller reasoning behind each field. Only the two record types -//! direct-dial needs are ported; add more constructors here as other -//! direct-dial consumers (streaming, content) are built. -//! -//! **This is a thin RPC client, not a DHT participant.** Every function -//! here just issues an ordinary signed CALL (`_dht.put_record` etc.) to -//! whichever station the given [`Session`] is -//! already connected to — real Kademlia routing, replication, and k-bucket -//! maintenance stay entirely on the relay side (`macula-station`). Nothing -//! in this module talks DHT protocol directly. - -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use crate::cbor::Value; -use crate::connection::{CallError, Session}; -use crate::frame::CallResponse; -use crate::identity::KeyPair; - -/// Record type tags — `macula_record.erl`'s `?TYPE_*` constants. -pub const TYPE_PROCEDURE_ADVERTISEMENT: u8 = 0x06; -pub const TYPE_STATION_ENDPOINT: u8 = 0x12; -pub const TYPE_CONTENT_ANNOUNCEMENT: u8 = 0x11; - -/// Matches `macula_record`'s `?DEFAULT_TTL_MS` (48h) — the TTL a -/// `procedure_advertisement` gets when the caller doesn't specify one. -pub const DEFAULT_TTL: Duration = Duration::from_secs(48 * 60 * 60); - -/// The Ed25519 signature domain separator — `macula_record`'s -/// `?SIG_DOMAIN`. 17 bytes: "macula-v2-record" (16 ASCII) plus a trailing -/// NUL. -const SIG_DOMAIN: &[u8] = b"macula-v2-record\0"; - -/// Mirrors `macula_record.erl`'s envelope map (type/key/version/ -/// created_at/expires_at/payload/signature). `subject_id` is not carried — -/// neither record type this module builds uses it. -#[derive(Debug, Clone)] -pub struct Record { - pub record_type: u8, - /// 32B: envelope signer's Ed25519 pubkey. - pub key: [u8; 32], - /// 16B: UUIDv7. - pub version: [u8; 16], - /// ms since epoch. - pub created_at: i128, - /// ms since epoch. - pub expires_at: i128, - pub payload: Value, - /// 64B once [`sign`] has been called; empty beforehand. - pub signature: Vec, -} - -fn now_ms() -> i128 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock before 1970") - .as_millis() as i128 -} - -fn new_envelope(record_type: u8, key: [u8; 32], payload: Value, ttl: Duration) -> Record { - let created_at = now_ms(); - Record { - record_type, - key, - version: *uuid::Uuid::now_v7().as_bytes(), - created_at, - expires_at: created_at + ttl.as_millis() as i128, - payload, - signature: Vec::new(), - } -} - -/// Builds an UNSIGNED `procedure_advertisement` record naming -/// `serving_station` as `procedure_uri`'s current handler. `procedure_uri` -/// should be the realm-qualified discovery URI (see [`discovery_uri`]), -/// matching `macula_direct_dial`'s own convention — the advertiser and the -/// resolver must derive the identical URI or the DHT storage key -/// ([`procedure_key`]) will not agree. Sign before [`put_record`]. -/// -/// Mirrors `macula_record:procedure_advertisement/3,4`. See -/// [`new_procedure_advertisement_with_cert_chain`] for the `cert_chain` -/// variant. -pub fn new_procedure_advertisement( - advertiser_node: [u8; 32], - procedure_uri: impl Into, - serving_station: [u8; 32], - ttl: Duration, -) -> Record { - let ttl = if ttl.is_zero() { DEFAULT_TTL } else { ttl }; - let payload = Value::Map(vec![ - (Value::text("procedure_uri"), Value::text(procedure_uri)), - ( - Value::text("advertiser_node"), - Value::Bytes(advertiser_node.to_vec()), - ), - ( - Value::text("serving_station"), - Value::Bytes(serving_station.to_vec()), - ), - ]); - new_envelope(TYPE_PROCEDURE_ADVERTISEMENT, advertiser_node, payload, ttl) -} - -/// [`new_procedure_advertisement`] plus an embedded X.509 service-cert -/// chain (leaf-first PEM: leaf ++ org CA), for Slice 7c Direction B -/// managed-realm authorization — see -/// [`cert_chain::verify_advertisement_cert_chain`](crate::cert_chain::verify_advertisement_cert_chain) -/// for the corresponding check. Opt-in: plain [`new_procedure_advertisement`] -/// is unaffected and remains the right choice for unmanaged realms. -pub fn new_procedure_advertisement_with_cert_chain( - advertiser_node: [u8; 32], - procedure_uri: impl Into, - serving_station: [u8; 32], - ttl: Duration, - cert_chain_pem: Vec, -) -> Record { - let mut rec = new_procedure_advertisement(advertiser_node, procedure_uri, serving_station, ttl); - let Value::Map(mut entries) = rec.payload else { - unreachable!("new_procedure_advertisement always returns a Map payload"); - }; - entries.push((Value::text("cert_chain"), Value::Bytes(cert_chain_pem))); - rec.payload = Value::Map(entries); - rec -} - -/// Builds an UNSIGNED `content_announcement` record naming -/// `announcer_node` as reachable at `endpoint` for `mcid`. Sign before -/// [`put_record`]. Mirrors `macula_record:content_announcement/3,4` — see -/// [`ContentAnnouncement`] for which optional metadata fields are not -/// ported. -pub fn new_content_announcement( - announcer_node: [u8; 32], - mcid: crate::manifest::Mcid, - endpoint: impl Into, - ttl: Duration, -) -> Record { - let payload = Value::Map(vec![ - ( - Value::text("announcer_node"), - Value::Bytes(announcer_node.to_vec()), - ), - (Value::text("mcid"), Value::Bytes(mcid.to_vec())), - (Value::text("endpoint"), Value::text(endpoint)), - ]); - new_envelope(TYPE_CONTENT_ANNOUNCEMENT, announcer_node, payload, ttl) -} - -/// Extracts a `content_announcement` record's typed fields, or an error if -/// `r` isn't one or is malformed. Mirrors -/// `macula_record:read_content_announcement/1`. -pub fn read_content_announcement(r: &Record) -> Result { - if r.record_type != TYPE_CONTENT_ANNOUNCEMENT { - return Err(ReadRecordError::WrongRecordType); - } - let announcer_node = bytes32_field(&r.payload, "announcer_node")?; - let mcid: crate::manifest::Mcid = match r.payload.get("mcid") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ReadRecordError::WrongFieldType("mcid"))?, - Some(_) => return Err(ReadRecordError::WrongFieldType("mcid")), - None => return Err(ReadRecordError::MissingField("mcid")), - }; - let endpoint = match r.payload.get("endpoint") { - Some(Value::Text(t)) => t.clone(), - Some(_) => return Err(ReadRecordError::WrongFieldType("endpoint")), - None => return Err(ReadRecordError::MissingField("endpoint")), - }; - Ok(ContentAnnouncement { - announcer_node, - mcid, - endpoint, - }) -} - -/// The exact bytes `macula_record:canonical_unsigned/1` signs and -/// verifies: deterministic CBOR of the envelope map using the COMPACT -/// single-letter keys (t/k/v/c/x/p), signature excluded. This is a -/// DIFFERENT representation from the full-field-name map [`to_rpc_value`] -/// sends as RPC args — the compact form exists only to be signed/verified, -/// never sent on the wire as such. -fn canonical_unsigned(r: &Record) -> Vec { - let entries = Value::Map(vec![ - (Value::text("t"), Value::Int(r.record_type as i128)), - (Value::text("k"), Value::Bytes(r.key.to_vec())), - (Value::text("v"), Value::Bytes(r.version.to_vec())), - (Value::text("c"), Value::Int(r.created_at)), - (Value::text("x"), Value::Int(r.expires_at)), - (Value::text("p"), r.payload.clone()), - ]); - // Signing bytes are protocol-internal and always within the - // deterministic encoder's supported range — an encode failure here - // would mean a payload this module itself built is malformed, which - // is a bug in this module, not a runtime condition to recover from. - crate::cbor::encode(&entries).expect("dht record payload must be encodable") -} - -/// Sets `r.signature` to the Ed25519 signature over -/// `SIG_DOMAIN || canonical_unsigned(r)`, matching `macula_record:sign/2`. -pub fn sign(mut r: Record, id: &KeyPair) -> Record { - let mut msg = SIG_DOMAIN.to_vec(); - msg.extend_from_slice(&canonical_unsigned(&r)); - r.signature = id.sign(&msg).to_vec(); - r -} - -#[derive(Debug, PartialEq, Eq)] -pub enum VerifyError { - InvalidSignature, - Expired, -} - -impl std::fmt::Display for VerifyError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - VerifyError::InvalidSignature => write!(f, "dht: signature invalid"), - VerifyError::Expired => write!(f, "dht: record expired"), - } - } -} - -impl std::error::Error for VerifyError {} - -/// Checks `r`'s Ed25519 signature against its own `key`, then its expiry. -/// Matches `macula_record:verify/1`. Distinguishes [`VerifyError::Expired`] -/// from [`VerifyError::InvalidSignature`] because a caller resolving a -/// record (e.g. `direct_dial`'s retry loop) should retry past a -/// stale-but-once-valid replica, never past a forged one — see -/// `macula_direct_dial.erl`'s `on_endpoint_verified/3` doing exactly this -/// branch. -pub fn verify(r: &Record) -> Result<(), VerifyError> { - let sig: [u8; 64] = r - .signature - .as_slice() - .try_into() - .map_err(|_| VerifyError::InvalidSignature)?; - let mut msg = SIG_DOMAIN.to_vec(); - msg.extend_from_slice(&canonical_unsigned(r)); - if !crate::identity::verify(&msg, &sig, &r.key) { - return Err(VerifyError::InvalidSignature); - } - if r.expires_at > 0 && now_ms() >= r.expires_at { - return Err(VerifyError::Expired); - } - Ok(()) -} - -/// Namespaces `station_endpoint` storage keys so they don't collide with -/// `node_record`, which keys on the same pubkey — `macula_record`'s -/// `?STORAGE_DOMAIN_STATION_ENDPOINT`. -const STORAGE_DOMAIN_STATION_ENDPOINT: &[u8] = b"station_endpoint"; - -/// The DHT storage key for a `procedure_advertisement` by its (already -/// realm-qualified — see [`discovery_uri`]) URI: `SHA-256(uri)`. Matches -/// `macula_record:procedure_key/1`. -pub fn procedure_key(procedure_uri: &str) -> [u8; 32] { - use sha2::{Digest, Sha256}; - Sha256::digest(procedure_uri.as_bytes()).into() -} - -/// The DHT storage key for a station's own `station_endpoint` record: -/// `SHA-256("station_endpoint" || pubkey)`. Matches -/// `macula_record:station_endpoint_key/1`. -pub fn station_endpoint_key(station_pubkey: [u8; 32]) -> [u8; 32] { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(STORAGE_DOMAIN_STATION_ENDPOINT); - hasher.update(station_pubkey); - hasher.finalize().into() -} - -/// The DHT storage key for every `content_announcement` naming `mcid`: -/// `SHA-256(mcid)`. Matches `macula_record:content_key/1`. Consumers use -/// this with [`find_records`] (there may be more than one announcer) -/// before holding any record. -pub fn content_key(mcid: crate::manifest::Mcid) -> [u8; 32] { - use sha2::{Digest, Sha256}; - Sha256::digest(mcid).into() -} - -/// Matches `macula_direct_dial`'s `discovery_uri/2`: the DHT -/// lookup/advertisement key input is `hex(realm) + "/" + procedure`, so the -/// same procedure name under different realms doesn't collide in the DHT. -/// The advertiser and every resolver must derive this identically. -pub fn discovery_uri(realm: [u8; 32], procedure: &str) -> String { - let mut hex_realm = String::with_capacity(64); - for b in realm { - hex_realm.push_str(&format!("{b:02X}")); - } - format!("{hex_realm}/{procedure}") -} - -/// A `procedure_advertisement` record's fields, read out of its payload — -/// mirrors `macula_record:read_procedure_advertisement/1`. `cert_chain` is -/// `None` when the advertisement carries no `cert_chain` field (the common, -/// unmanaged-realm case); see -/// [`cert_chain::verify_advertisement_cert_chain`](crate::cert_chain::verify_advertisement_cert_chain). -#[derive(Debug, Clone)] -pub struct ProcedureAdvertisement { - pub procedure_uri: String, - pub advertiser_node: [u8; 32], - pub serving_station: [u8; 32], - /// Optional: leaf-first PEM bundle, leaf ++ org CA. - pub cert_chain: Option>, -} - -/// A `station_endpoint` record's fields, read out of its payload — mirrors -/// `macula_record:read_station_endpoint/1`. -#[derive(Debug, Clone)] -pub struct StationEndpoint { - pub quic_port: u16, - pub host_advertised: Vec, -} - -/// A `content_announcement` record's fields, read out of its payload — -/// mirrors `macula_record:read_content_announcement/1`. The optional -/// `name`/`size`/`chunk_count` metadata fields -/// (`content_announcement_opts()`) are not ported — direct-dial content -/// fetch doesn't need them to resolve and dial; add them if a future -/// caller needs to prioritize candidates without fetching the manifest. -#[derive(Debug, Clone)] -pub struct ContentAnnouncement { - pub announcer_node: [u8; 32], - pub mcid: crate::manifest::Mcid, - /// A dialable seed URL, e.g. `"https://host:4433"` — matches - /// `macula_client:seed()`'s own format, NOT a `station_endpoint`'s - /// split host/port. - pub endpoint: String, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ReadRecordError { - WrongRecordType, - MissingField(&'static str), - WrongFieldType(&'static str), -} - -impl std::fmt::Display for ReadRecordError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ReadRecordError::WrongRecordType => write!(f, "dht: unexpected record type"), - ReadRecordError::MissingField(name) => write!(f, "dht: missing field {name:?}"), - ReadRecordError::WrongFieldType(name) => { - write!(f, "dht: field {name:?} has the wrong type") - } - } - } -} - -impl std::error::Error for ReadRecordError {} - -/// Extracts a `procedure_advertisement` record's typed fields, or an error -/// if `r` isn't one or is malformed. -pub fn read_procedure_advertisement(r: &Record) -> Result { - if r.record_type != TYPE_PROCEDURE_ADVERTISEMENT { - return Err(ReadRecordError::WrongRecordType); - } - let procedure_uri = match r.payload.get("procedure_uri") { - Some(Value::Text(t)) => t.clone(), - Some(_) => return Err(ReadRecordError::WrongFieldType("procedure_uri")), - None => return Err(ReadRecordError::MissingField("procedure_uri")), - }; - let advertiser_node = bytes32_field(&r.payload, "advertiser_node")?; - let serving_station = bytes32_field(&r.payload, "serving_station")?; - // Absent is valid, not an error — the common, unmanaged-realm case. - let cert_chain = match r.payload.get("cert_chain") { - Some(Value::Bytes(b)) => Some(b.clone()), - _ => None, - }; - Ok(ProcedureAdvertisement { - procedure_uri, - advertiser_node, - serving_station, - cert_chain, - }) -} - -/// Extracts a `station_endpoint` record's typed fields, or an error if `r` -/// isn't one or is malformed. -pub fn read_station_endpoint(r: &Record) -> Result { - if r.record_type != TYPE_STATION_ENDPOINT { - return Err(ReadRecordError::WrongRecordType); - } - let quic_port = match r.payload.get("quic_port") { - Some(Value::Int(n)) if (1..=65535).contains(n) => *n as u16, - Some(_) => return Err(ReadRecordError::WrongFieldType("quic_port")), - None => return Err(ReadRecordError::MissingField("quic_port")), - }; - // `macula_record.erl`'s `with_host_list/2` puts each host in as a bare - // Erlang binary, unlike every other string field in this record (which - // wraps with `{text, Bin}`) — so on the wire these are CBOR BYTE - // strings (major type 2), not text strings, confirmed against a real - // station's own published record while building `macula-go`'s - // equivalent. Try bytes first, text as a fallback in case a future - // publisher wraps these properly. - let host_advertised = match r.payload.get("host_advertised") { - Some(Value::List(items)) => items - .iter() - .filter_map(|item| match item { - Value::Bytes(b) => String::from_utf8(b.clone()).ok(), - Value::Text(t) => Some(t.clone()), - _ => None, - }) - .collect(), - _ => Vec::new(), - }; - Ok(StationEndpoint { - quic_port, - host_advertised, - }) -} - -fn bytes32_field(v: &Value, name: &'static str) -> Result<[u8; 32], ReadRecordError> { - match v.get(name) { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ReadRecordError::WrongFieldType(name)), - Some(_) => Err(ReadRecordError::WrongFieldType(name)), - None => Err(ReadRecordError::MissingField(name)), - } -} - -// --------------------------------------------------------------------- -// Thin RPC wrappers over the mesh's `_dht.*` procedures. -// --------------------------------------------------------------------- - -/// The all-zero 32-byte realm DHT traffic travels under, protocol-internal -/// infrastructure — matches `macula.erl`'s `?DHT_REALM`. -const DHT_REALM: [u8; 32] = [0u8; 32]; - -/// Matches `macula.erl`'s `?DHT_RECORD_TIMEOUT_MS`. -const DHT_TIMEOUT: Duration = Duration::from_secs(5); - -const PUT_RECORD_PROC: &str = "_dht.put_record"; -const FIND_RECORD_PROC: &str = "_dht.find_record"; -const FIND_RECORDS_PROC: &str = "_dht.find_records"; -const FIND_RECORDS_BY_TYPE_PROC: &str = "_dht.find_records_by_type"; - -/// The FULL-field-name map `macula.erl`'s `put_record/2` sends as a CALL's -/// args (and `find_record`/`find_records` return as a RESULT) — distinct -/// from [`canonical_unsigned`]'s compact single-letter envelope, which -/// exists only to be signed/verified, never sent as such. -fn to_rpc_value(r: &Record) -> Value { - let mut entries = vec![ - (Value::text("type"), Value::Int(r.record_type as i128)), - (Value::text("key"), Value::Bytes(r.key.to_vec())), - (Value::text("version"), Value::Bytes(r.version.to_vec())), - (Value::text("created_at"), Value::Int(r.created_at)), - (Value::text("expires_at"), Value::Int(r.expires_at)), - (Value::text("payload"), r.payload.clone()), - ]; - if r.signature.len() == 64 { - entries.push((Value::text("signature"), Value::Bytes(r.signature.clone()))); - } - Value::Map(entries) -} - -#[derive(Debug, PartialEq, Eq)] -pub enum RecordFromRpcError { - MissingField(&'static str), - WrongFieldType(&'static str), -} - -impl std::fmt::Display for RecordFromRpcError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - RecordFromRpcError::MissingField(name) => write!(f, "dht: missing field {name:?}"), - RecordFromRpcError::WrongFieldType(name) => { - write!(f, "dht: field {name:?} has the wrong type") - } - } - } -} - -impl std::error::Error for RecordFromRpcError {} - -fn record_from_rpc_value(v: &Value) -> Result { - let record_type = match v.get("type") { - Some(Value::Int(n)) if (0..=255).contains(n) => *n as u8, - Some(_) => return Err(RecordFromRpcError::WrongFieldType("type")), - None => return Err(RecordFromRpcError::MissingField("type")), - }; - let key = match v.get("key") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| RecordFromRpcError::WrongFieldType("key"))?, - Some(_) => return Err(RecordFromRpcError::WrongFieldType("key")), - None => return Err(RecordFromRpcError::MissingField("key")), - }; - let version = match v.get("version") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| RecordFromRpcError::WrongFieldType("version"))?, - Some(_) => return Err(RecordFromRpcError::WrongFieldType("version")), - None => return Err(RecordFromRpcError::MissingField("version")), - }; - let created_at = match v.get("created_at") { - Some(Value::Int(n)) => *n, - Some(_) => return Err(RecordFromRpcError::WrongFieldType("created_at")), - None => return Err(RecordFromRpcError::MissingField("created_at")), - }; - let expires_at = match v.get("expires_at") { - Some(Value::Int(n)) => *n, - Some(_) => return Err(RecordFromRpcError::WrongFieldType("expires_at")), - None => return Err(RecordFromRpcError::MissingField("expires_at")), - }; - let payload = v - .get("payload") - .cloned() - .ok_or(RecordFromRpcError::MissingField("payload"))?; - let signature = match v.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - _ => Vec::new(), - }; - Ok(Record { - record_type, - key, - version, - created_at, - expires_at, - payload, - signature, - }) -} - -#[derive(Debug)] -pub enum DhtError { - Call(CallError), - /// The station answered with an ERROR frame — carries its `name`. - Remote(String), - NotFound, - Malformed(RecordFromRpcError), - /// The RESULT payload wasn't the list shape `find_records`/ - /// `find_records_by_type` are expected to return. - ExpectedList, -} - -impl std::fmt::Display for DhtError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DhtError::Call(e) => write!(f, "dht: {e}"), - DhtError::Remote(name) => write!(f, "dht: station reported {name}"), - DhtError::NotFound => write!(f, "dht: record not found"), - DhtError::Malformed(e) => write!(f, "dht: {e}"), - DhtError::ExpectedList => write!(f, "dht: expected a list reply"), - } - } -} - -impl std::error::Error for DhtError {} - -fn deadline_ms(timeout: Duration) -> i128 { - now_ms() + timeout.as_millis() as i128 -} - -/// Stores a signed record in the mesh DHT. Mirrors `macula:put_record/2` — -/// the relay validates the signature on receipt. -pub async fn put_record(session: &Session, id: &KeyPair, rec: &Record) -> Result<(), DhtError> { - let resp = session - .call( - PUT_RECORD_PROC, - DHT_REALM, - to_rpc_value(rec), - deadline_ms(DHT_TIMEOUT), - id, - DHT_TIMEOUT, - ) - .await - .map_err(DhtError::Call)?; - match resp { - CallResponse::Result { .. } => Ok(()), - CallResponse::Error { name, .. } => Err(DhtError::Remote(name)), - } -} - -/// Fetches one record by its storage key (see [`procedure_key`] / -/// [`station_endpoint_key`]). Returns [`DhtError::NotFound`] if none -/// exists — the caller's signature should still be checked via [`verify`] -/// before the payload is trusted; this function does not verify on the -/// caller's behalf. -pub async fn find_record( - session: &Session, - id: &KeyPair, - key: [u8; 32], -) -> Result { - let args = Value::Map(vec![(Value::text("key"), Value::Bytes(key.to_vec()))]); - let resp = session - .call( - FIND_RECORD_PROC, - DHT_REALM, - args, - deadline_ms(DHT_TIMEOUT), - id, - DHT_TIMEOUT, - ) - .await - .map_err(DhtError::Call)?; - match resp { - CallResponse::Result { payload, .. } => { - if matches!(&payload, Value::Text(t) if t == "not_found") { - return Err(DhtError::NotFound); - } - record_from_rpc_value(&payload).map_err(DhtError::Malformed) - } - CallResponse::Error { name, .. } => Err(DhtError::Remote(name)), - } -} - -/// Fetches every record stored at `key` — the full signer-deduped multiset -/// (e.g. every `procedure_advertisement` for one procedure). Each record's -/// signature should be verified via [`verify`] before its payload is -/// trusted; this function does not verify on the caller's behalf. -pub async fn find_records( - session: &Session, - id: &KeyPair, - key: [u8; 32], -) -> Result, DhtError> { - let args = Value::Map(vec![(Value::text("key"), Value::Bytes(key.to_vec()))]); - let resp = session - .call( - FIND_RECORDS_PROC, - DHT_REALM, - args, - deadline_ms(DHT_TIMEOUT), - id, - DHT_TIMEOUT, - ) - .await - .map_err(DhtError::Call)?; - records_list_from_response(resp) -} - -/// Returns every record of `typ` currently visible from the station this -/// session is connected to. Coverage depends on that station's own view of -/// the DHT. Mirrors `macula:find_records_by_type/2`. -pub async fn find_records_by_type( - session: &Session, - id: &KeyPair, - typ: u8, -) -> Result, DhtError> { - let args = Value::Map(vec![(Value::text("type"), Value::Int(typ as i128))]); - let resp = session - .call( - FIND_RECORDS_BY_TYPE_PROC, - DHT_REALM, - args, - deadline_ms(DHT_TIMEOUT), - id, - DHT_TIMEOUT, - ) - .await - .map_err(DhtError::Call)?; - records_list_from_response(resp) -} - -fn records_list_from_response(resp: CallResponse) -> Result, DhtError> { - match resp { - CallResponse::Result { payload, .. } => match payload { - Value::List(items) => Ok(items - .iter() - .filter_map(|item| record_from_rpc_value(item).ok()) - .collect()), - _ => Err(DhtError::ExpectedList), - }, - CallResponse::Error { name, .. } => Err(DhtError::Remote(name)), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn sample_advertisement(id: &KeyPair) -> Record { - let station: [u8; 32] = [7u8; 32]; - let uri = discovery_uri([0u8; 32], "test.procedure"); - let rec = new_procedure_advertisement(id.node_id(), uri, station, DEFAULT_TTL); - sign(rec, id) - } - - #[test] - fn sign_then_verify_round_trips() { - let id = KeyPair::generate(); - let rec = sample_advertisement(&id); - assert_eq!(rec.signature.len(), 64); - assert!(verify(&rec).is_ok()); - } - - #[test] - fn verify_rejects_a_tampered_payload() { - let id = KeyPair::generate(); - let mut rec = sample_advertisement(&id); - // Flip the record's advertised type after signing -- the signature - // covers record_type, so this must invalidate it. - rec.record_type = TYPE_STATION_ENDPOINT; - assert_eq!(verify(&rec), Err(VerifyError::InvalidSignature)); - } - - #[test] - fn verify_rejects_a_signature_from_the_wrong_signer() { - let signer = KeyPair::generate(); - let mut rec = sample_advertisement(&signer); - // The envelope's own `key` field claims a DIFFERENT signer than - // the one that actually produced `signature` -- verify checks the - // signature against `key`, so this must fail. - rec.key = KeyPair::generate().public_bytes(); - assert_eq!(verify(&rec), Err(VerifyError::InvalidSignature)); - } - - #[test] - fn verify_rejects_an_expired_record() { - let id = KeyPair::generate(); - let station: [u8; 32] = [7u8; 32]; - let uri = discovery_uri([0u8; 32], "test.procedure"); - // A TTL that has already elapsed by the time verify() runs. - let rec = new_procedure_advertisement(id.node_id(), uri, station, Duration::from_millis(1)); - std::thread::sleep(Duration::from_millis(20)); - let rec = sign(rec, &id); - assert_eq!(verify(&rec), Err(VerifyError::Expired)); - } - - #[test] - fn canonical_unsigned_is_deterministic() { - let id = KeyPair::generate(); - let rec = sample_advertisement(&id); - // Re-deriving the same bytes from the same (already-built) record - // must always agree -- this is exactly what a verifier on the - // other end of the wire independently recomputes. - assert_eq!(canonical_unsigned(&rec), canonical_unsigned(&rec)); - } - - #[test] - fn procedure_key_differs_by_realm() { - let a = procedure_key(&discovery_uri([0u8; 32], "same.name")); - let b = procedure_key(&discovery_uri([1u8; 32], "same.name")); - assert_ne!( - a, b, - "the same bare procedure name under different realms must not collide" - ); - } - - #[test] - fn discovery_uri_matches_expected_hex_format() { - let uri = discovery_uri([0u8; 32], "hecate_mail.initiate_mailbox"); - assert_eq!( - uri, - format!("{}/hecate_mail.initiate_mailbox", "00".repeat(32)) - ); - } - - #[test] - fn read_procedure_advertisement_round_trips_the_payload() { - let id = KeyPair::generate(); - let station: [u8; 32] = [9u8; 32]; - let uri = "0".repeat(64) + "/some.procedure"; - let rec = new_procedure_advertisement(id.node_id(), uri.clone(), station, DEFAULT_TTL); - let read = read_procedure_advertisement(&rec).expect("should read back cleanly"); - assert_eq!(read.procedure_uri, uri); - assert_eq!(read.advertiser_node, id.node_id()); - assert_eq!(read.serving_station, station); - } - - #[test] - fn read_procedure_advertisement_rejects_the_wrong_record_type() { - let id = KeyPair::generate(); - let station: [u8; 32] = [9u8; 32]; - let mut rec = new_procedure_advertisement(id.node_id(), "x/y", station, DEFAULT_TTL); - rec.record_type = TYPE_STATION_ENDPOINT; - assert!(matches!( - read_procedure_advertisement(&rec), - Err(ReadRecordError::WrongRecordType) - )); - } - - #[test] - fn station_endpoint_host_advertised_reads_byte_string_entries() { - // macula_record.erl's with_host_list/2 puts each host in as a bare - // Erlang binary -- on the wire these decode as CBOR byte strings - // (major type 2), not text, confirmed against a real station's own - // published record while building macula-go's equivalent. This - // guards that this crate reads that shape too, not just a - // hypothetical text-wrapped one. - let rec = Record { - record_type: TYPE_STATION_ENDPOINT, - key: [1u8; 32], - version: [0u8; 16], - created_at: 0, - expires_at: 0, - payload: Value::Map(vec![ - (Value::text("quic_port"), Value::Int(4433)), - ( - Value::text("host_advertised"), - Value::List(vec![Value::Bytes(b"203.0.113.5".to_vec())]), - ), - ]), - signature: Vec::new(), - }; - let ep = read_station_endpoint(&rec).expect("should read the byte-string host"); - assert_eq!(ep.quic_port, 4433); - assert_eq!(ep.host_advertised, vec!["203.0.113.5".to_string()]); - } - - #[test] - fn to_rpc_value_and_record_from_rpc_value_round_trip() { - let id = KeyPair::generate(); - let rec = sample_advertisement(&id); - let rpc_value = to_rpc_value(&rec); - let back = record_from_rpc_value(&rpc_value).expect("should decode cleanly"); - assert_eq!(back.record_type, rec.record_type); - assert_eq!(back.key, rec.key); - assert_eq!(back.version, rec.version); - assert_eq!(back.created_at, rec.created_at); - assert_eq!(back.expires_at, rec.expires_at); - assert_eq!(back.signature, rec.signature); - // The payload survives the RPC round trip byte-for-byte-equivalent - // even though it isn't compared via canonical_unsigned here. - assert!(verify(&back).is_ok()); - } -} diff --git a/src/direct_dial.rs b/src/direct_dial.rs deleted file mode 100644 index 8fb998b..0000000 --- a/src/direct_dial.rs +++ /dev/null @@ -1,3786 +0,0 @@ -//! Direct-dial resolve-and-call: resolving a signed `procedure_advertisement` -//! DHT record and its serving station's own signed `station_endpoint`, then -//! dialing that station in one hop — instead of depending on ordinary -//! advertise-gossip having propagated a route between whichever two -//! stations happen to be involved. -//! -//! Ported from `macula-io/macula`'s `macula_direct_dial.erl`, cross-checked -//! against `macula-go`'s own port of the same reference -//! (`directdial/directdial.go`) — see that file's doc for the fuller -//! reasoning behind each design choice made here. -//! -//! **Trust model** (see `macula_direct_dial.erl`'s module doc for the full -//! reasoning): every candidate `procedure_advertisement` must carry a valid -//! Ed25519 signature before its `serving_station` is trusted at all, and -//! the resolved `station_endpoint` must be signed by the station itself. -//! The actual QUIC dial trusts neither the TLS certificate (a production -//! station's TLS is terminated by an unrelated PKI) nor nothing — trust is -//! enforced at the application layer, by checking the freshly dialed -//! session's own signature-verified HELLO identity against the exact -//! pubkey the signed DHT chain resolved. -//! -//! **Candidates:** every advertisement (or content announcement) that -//! verifies is a candidate, tried in the order the DHT returned them. A -//! candidate whose endpoint record doesn't resolve, whose dial fails, or -//! whose dialed identity doesn't match is skipped for the next one, because -//! nothing has reached the provider yet. So is a CALL that failed before it -//! was sent, because its session had ended or its turn to write didn't come -//! in time; its station may be tried again on a later pass. Once a CALL or -//! STREAM_OPEN has gone out, its result is the call's result and it is never -//! sent again. When a -//! query fails, no candidate qualifies, or every one failed before sending, -//! the DHT is queried again with a backoff of 100 ms doubling to 1 s; within -//! one call a -//! station that already failed is dialed again only once its advertisement -//! or endpoint record has changed. The call's `timeout` bounds all of it, -//! and each candidate gets a share of what remains for its endpoint lookup -//! and dial. At the deadline, the most recent candidate failure is returned -//! as it was raised; a later query that finds nothing, or fails, never -//! replaces it. When no candidate was ever tried, the call returns why the -//! latest answered query found none, else the latest failed query's error, -//! else a timeout: an absence nobody observed is never reported. -//! -//! **Reuse:** a station keeps one connection per identity and closes the -//! older one when a newer one arrives. So when this process already has a -//! session open to the provider's station under the same identity -//! (`resolve_via` itself, or a [`Pool`](crate::pool::Pool) link), [`call`] -//! and its variants run on that session, and [`open_stream_direct`], -//! [`put_direct`] and [`get_direct`] run on it on a dedicated QUIC stream of -//! their own, instead of dialing, and never close it. They need no -//! `station_endpoint` lookup either. A session direct dial dialed is shared -//! the same way: each request using it holds a lease, and it closes when the -//! last one is released (see [`SessionLease`]). -//! -//! `cert_chain`-based org/realm authorization (Slice 7c Direction B, -//! `macula_record:verify_advertisement_cert_chain/3` on the Erlang side) is -//! opt-in here too, matching the reference and `macula-go`'s own port — -//! see [`resolve_with_cert_chain`]/[`call_with_cert_chain`]/ -//! [`advertise_direct_with_cert_chain`]. Plain [`resolve`]/[`call`]/ -//! [`advertise_direct`] are completely unaffected. - -use std::collections::HashMap; -use std::convert::Infallible; -use std::future::{ready, Future}; -use std::time::Duration; - -use tokio::time::Instant; - -use crate::cbor::Value; -use crate::cert_chain::{self, CertChainError}; -use crate::connection::{self, FrameStream, Session}; -use crate::content; -use crate::dht::{self, DhtError, Record}; -use crate::frame::{CallResponse, StreamMode}; -use crate::identity::KeyPair; -use crate::manifest::Mcid; -use crate::open_sessions::{self, Leased, Leases}; -use crate::stream::{self, StreamHandle}; -use crate::transport::Trust; - -fn now_ms() -> i128 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock before 1970") - .as_millis() as i128 -} - -/// Matches `macula_direct_dial.erl`'s `?RESOLVE_RETRY_MS` — a record just -/// published on the provider's station has not necessarily replicated to -/// the resolving station yet, so the first miss is not treated as failure. -/// The endpoint lookup retries at this cadence within a candidate's share, -/// and re-queries start at it. -const RESOLVE_RETRY_DELAY: Duration = Duration::from_millis(100); - -/// Re-queries back off from [`RESOLVE_RETRY_DELAY`], doubling up to this -/// cap, so a call waiting out a missing or refusing provider doesn't keep -/// loading the DHT. -const MAX_REQUERY_PAUSE: Duration = Duration::from_secs(1); - -/// Every candidate gets at least this much of the remaining time for its -/// endpoint lookup and dial, or all of it when less than this remains. -const MIN_CANDIDATE_SHARE: Duration = Duration::from_secs(1); - -/// The time budget [`resolve`] and [`resolve_with_cert_chain`] get, since -/// neither takes a timeout of its own. Matches `macula-go`'s -/// `DefaultResolveTimeout`. -const DEFAULT_RESOLVE_TIMEOUT: Duration = Duration::from_secs(10); - -#[derive(Debug)] -pub enum ResolveError { - /// Every `find_records` attempt came back empty after retrying past - /// DHT propagation lag. - ProcedureNotAdvertised, - /// Records were found, but none had a valid signature. - NoTrustedAdvertisement, - /// A resolved station published no reachable (or no longer valid) - /// `station_endpoint` after retrying. - StationEndpointNotFound, - /// A `station_endpoint` record was found under the right key, but its - /// signer didn't match the station it's supposed to describe. - StationEndpointSignerMismatch, - /// The station's `station_endpoint` record verified but named no - /// dialable address, and it was the latest answer before the deadline. - /// Matches `macula_direct_dial`'s `malformed_station_endpoint`. - MalformedStationEndpoint, - Dht(DhtError), - /// [`resolve_with_cert_chain`] only: at least one candidate - /// advertisement's envelope signature verified (otherwise - /// [`ResolveError::NoTrustedAdvertisement`] would apply instead), but - /// none passed cert-chain authorization for the expected org — carries - /// the specific [`CertChainError`] from the LAST candidate tried - /// (absent chain, wrong org, untrusted chain, etc.). - NoAuthorizedAdvertisement(CertChainError), - /// The timeout ran out before any DHT lookup was answered or failed, or - /// before any candidate could be tried. - Timeout, -} - -impl std::fmt::Display for ResolveError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ResolveError::ProcedureNotAdvertised => { - write!( - f, - "direct_dial: procedure has no direct-dial advertisement in the DHT" - ) - } - ResolveError::NoTrustedAdvertisement => write!( - f, - "direct_dial: every candidate advertisement failed signature verification" - ), - ResolveError::StationEndpointNotFound => write!( - f, - "direct_dial: resolved station published no reachable station_endpoint" - ), - ResolveError::StationEndpointSignerMismatch => { - write!(f, "direct_dial: station_endpoint signer mismatch") - } - ResolveError::MalformedStationEndpoint => write!( - f, - "direct_dial: the station_endpoint record names no dialable address" - ), - ResolveError::Dht(e) => write!(f, "direct_dial: {e}"), - ResolveError::NoAuthorizedAdvertisement(e) => write!( - f, - "direct_dial: no candidate advertisement is cert-chain-authorized for the expected org: {e}" - ), - ResolveError::Timeout => write!( - f, - "direct_dial: the timeout ran out before a provider was resolved" - ), - } - } -} - -impl std::error::Error for ResolveError {} - -/// One resolved direct-dial target: the station's own node id plus a -/// dialable host/port. -#[derive(Debug, Clone)] -pub struct Resolved { - pub station: [u8; 32], - pub host: String, - pub port: u16, -} - -/// The two DHT lookups direct-dial resolution makes, behind a trait so the -/// resolution logic runs unchanged against a fake DHT in tests. -pub(crate) trait DhtLookups { - async fn find_records(&mut self, key: [u8; 32]) -> Result, DhtError>; - async fn find_record(&mut self, key: [u8; 32]) -> Result; -} - -/// The real lookups: DHT queries over `session`. -struct Via<'a> { - session: &'a Session, - id: &'a KeyPair, -} - -impl DhtLookups for Via<'_> { - async fn find_records(&mut self, key: [u8; 32]) -> Result, DhtError> { - dht::find_records(self.session, self.id, key).await - } - - async fn find_record(&mut self, key: [u8; 32]) -> Result { - dht::find_record(self.session, self.id, key).await - } -} - -/// The realm CA and org an advertisement's cert chain must satisfy, on the -/// `*_with_cert_chain` paths. -#[derive(Clone, Copy)] -pub(crate) struct CertChainCheck<'a> { - pub(crate) realm_ca_pem: &'a [u8], - pub(crate) expected_org: &'a str, -} - -/// Why a resolving call failed: resolution itself, the dial of the last -/// candidate tried, or the request once it was sent. Each public function -/// maps this to its own error type. -#[derive(Debug)] -pub(crate) enum Failure { - Resolve(ResolveError), - Dial(DE), - Request(RE), -} - -/// Why a direct content fetch failed; mapped to [`GetDirectError`]. -#[derive(Debug)] -pub(crate) enum ContentFailure { - Dht(DhtError), - NotAnnounced, - EndpointParse(String), - Dial(DE), - Fetch(RE), - /// The deadline cut a transfer off; carries the failure before it. - Timeout(Option>>), -} - -/// One call's time budget. It covers resolution, every endpoint lookup, -/// every dial and the request itself. -#[derive(Clone, Copy, Debug)] -pub(crate) struct CallDeadline { - at: Instant, -} - -impl CallDeadline { - pub(crate) fn after(budget: Duration) -> Self { - Self { - at: Instant::now() + budget, - } - } - - /// Less than a millisecond left counts as none, so a pause at the - /// deadline edge never shrinks to a zero-length sleep that lets the - /// re-query loop spin. - pub(crate) fn remaining(&self) -> Duration { - let left = self.at.saturating_duration_since(Instant::now()); - if left >= Duration::from_millis(1) { - left - } else { - Duration::ZERO - } - } - - pub(crate) fn passed(&self) -> bool { - self.remaining().is_zero() - } - - /// The next candidate's share of what remains, for its endpoint lookup - /// and dial: an even split across the candidates not yet tried, but - /// never less than [`MIN_CANDIDATE_SHARE`] unless less than that - /// remains. - pub(crate) fn share_for(&self, untried: usize) -> CallDeadline { - let remaining = self.remaining(); - let even = remaining / u32::try_from(untried.max(1)).unwrap_or(u32::MAX); - CallDeadline::after(even.max(remaining.min(MIN_CANDIDATE_SHARE))) - } -} - -/// A qualified advertisement: its signer and record version identify the -/// candidate, and `station` serves it. -struct ProcedureCandidate { - signer: [u8; 32], - version: [u8; 16], - station: [u8; 32], -} - -/// A qualified content announcement: its announcer and record version -/// identify the candidate, and `endpoint` is where to dial it. -struct ContentCandidate { - announcer: [u8; 32], - version: [u8; 16], - endpoint: String, -} - -/// A candidate that failed before sending, within one call: the version of -/// the record that made it a candidate, the endpoint record version it -/// failed on (`None` when none was found), and the error. -struct Remembered { - record_version: [u8; 16], - endpoint_version: Option<[u8; 16]>, - error: F, -} - -/// The failure a call reports at its deadline, the first of: the remembered -/// failure of the last candidate tried, why the latest answered query found -/// no candidate, and the latest failed query's error. With none of them the -/// call reports a timeout. -enum Last { - Candidate([u8; 32]), - /// A request that failed before it was sent, which is not remembered. - NotSent(F), - NoneQualified(F), - LookupFailed(F), -} - -fn take_last( - last: Option>, - failures: &mut HashMap<[u8; 32], Remembered>, -) -> Option { - match last? { - Last::Candidate(key) => failures.remove(&key).map(|remembered| remembered.error), - Last::NotSent(failure) | Last::NoneQualified(failure) | Last::LookupFailed(failure) => { - Some(failure) - } - } -} - -/// Records why an answered query found no candidate: over a failed query's -/// error, never over a candidate's failure. -fn record_none_qualified(last: &mut Option>, reason: F) { - if !matches!(last, Some(Last::Candidate(_) | Last::NotSent(_))) { - *last = Some(Last::NoneQualified(reason)); - } -} - -/// Records a failed query's error, only while no candidate has failed and no -/// query was answered. -fn record_lookup_failure(last: &mut Option>, error: F) { - if matches!(last, None | Some(Last::LookupFailed(_))) { - *last = Some(Last::LookupFailed(error)); - } -} - -/// What one DHT query came to. -enum Lookup { - Answered(Vec), - Failed(DhtError), - /// Cut off by the deadline: it learned nothing. - CutOff, -} - -async fn query(dht: &mut D, key: [u8; 32], deadline: CallDeadline) -> Lookup { - match tokio::time::timeout(deadline.remaining(), dht.find_records(key)).await { - Ok(Ok(recs)) => Lookup::Answered(recs), - Ok(Err(e)) => Lookup::Failed(e), - Err(_) => Lookup::CutOff, - } -} - -/// Whether a request that failed was never sent, so another candidate may -/// take it without the provider running it twice. -pub(crate) trait NotSent { - fn not_sent(&self) -> bool; -} - -impl NotSent for connection::CallError { - fn not_sent(&self) -> bool { - connection::CallError::not_sent(self) - } -} - -/// A stream is never opened elsewhere: its STREAM_OPEN may already be out. -impl NotSent for stream::OpenError { - fn not_sent(&self) -> bool { - false - } -} - -impl NotSent for Infallible { - fn not_sent(&self) -> bool { - match *self {} - } -} - -/// Resolves `procedure`'s provider, dials it with `dial`, and sends it one -/// request with `request`, all within `timeout` — see the module doc's -/// "Candidates" for how providers are tried in turn. -/// -/// A candidate whose station `already_open` has a session for gets the -/// request on that session, with no endpoint lookup and no dial. A request -/// that fails before it was sent ([`NotSent`]) lets the next candidate take -/// it and is not remembered, so its station may be tried again on a later -/// pass; any other outcome of a request ends the call. -/// -/// `dial` and `request` are plain closures returning futures (not async -/// closures) so the public functions built on this keep `Send` futures. -#[allow(clippy::too_many_arguments)] -pub(crate) async fn reach_procedure( - dht: &mut D, - realm: [u8; 32], - procedure: &str, - cert_chain: Option>, - mut already_open: impl FnMut(&[u8; 32]) -> Option, - mut dial: impl FnMut(Resolved, Duration) -> DF, - mut request: impl FnMut(S, Duration) -> RF, - timeout: Duration, -) -> Result> -where - D: DhtLookups, - RE: NotSent, - DF: Future>, - RF: Future>, -{ - let deadline = CallDeadline::after(timeout); - let key = dht::procedure_key(&dht::discovery_uri(realm, procedure)); - let mut failures: HashMap<[u8; 32], Remembered>> = HashMap::new(); - let mut last: Option>> = None; - let mut pause = RESOLVE_RETRY_DELAY; - loop { - let candidates = match query(dht, key, deadline).await { - Lookup::Answered(recs) => { - let (candidates, unresolved) = if recs.is_empty() { - (Vec::new(), ResolveError::ProcedureNotAdvertised) - } else if let Some(check) = cert_chain { - authorized_advertisements(&recs, check) - } else { - trusted_advertisements(&recs) - }; - if candidates.is_empty() { - record_none_qualified(&mut last, Failure::Resolve(unresolved)); - } - candidates - } - // A failed query teaches nothing, and is retried like one that - // found no candidate. - Lookup::Failed(e) => { - record_lookup_failure(&mut last, Failure::Resolve(ResolveError::Dht(e))); - Vec::new() - } - Lookup::CutOff => Vec::new(), - }; - for (tried, candidate) in candidates.iter().enumerate() { - if deadline.passed() { - break; - } - if let Some(target) = reused(&mut already_open, &candidate.station) { - match request(target, deadline.remaining()).await { - Err(e) if e.not_sent() => { - last = Some(Last::NotSent(Failure::Request(e))); - continue; - } - result => return result.map_err(Failure::Request), - } - } - let share = deadline.share_for(candidates.len() - tried); - // An unchanged advertisement that already failed gets a single - // endpoint lookup, and its station is dialed again only if that - // lookup shows a different endpoint version. A lookup that got - // no answer teaches nothing. - let failed_on = failures - .get(&candidate.signer) - .filter(|remembered| remembered.record_version == candidate.version) - .map(|remembered| remembered.endpoint_version); - let lookup = - lookup_station_endpoint(dht, candidate.station, share, failed_on.is_none()).await; - if failed_on - .is_some_and(|failed_on| !lookup.answered || lookup.seen_version == failed_on) - { - last = Some(Last::Candidate(candidate.signer)); - continue; - } - let failure = match lookup.outcome { - Ok(resolved) => match dial(resolved, share.remaining()).await { - Ok(target) => match request(target, deadline.remaining()).await { - Err(e) if e.not_sent() => { - last = Some(Last::NotSent(Failure::Request(e))); - continue; - } - result => return result.map_err(Failure::Request), - }, - Err(e) => Failure::Dial(e), - }, - Err(e) => Failure::Resolve(e), - }; - failures.insert( - candidate.signer, - Remembered { - record_version: candidate.version, - endpoint_version: lookup.seen_version, - error: failure, - }, - ); - last = Some(Last::Candidate(candidate.signer)); - } - if deadline.passed() { - break; - } - tokio::time::sleep(pause.min(deadline.remaining())).await; - pause = (pause * 2).min(MAX_REQUERY_PAUSE); - if deadline.passed() { - break; - } - } - // With nothing observed at all, the deadline ran out before any query - // was answered or any candidate could be tried. - Err(take_last(last, &mut failures).unwrap_or(Failure::Resolve(ResolveError::Timeout))) -} - -/// Resolves a known station's endpoint, dials it with `dial`, and sends it -/// one request with `request`. `timeout` bounds the endpoint lookup and the -/// dial; the request gets whatever remains and may ignore it. When -/// `already_open` has a session for the station, the request runs on it -/// instead, with no endpoint lookup and no dial. -pub(crate) async fn reach_station( - dht: &mut D, - station: [u8; 32], - already_open: impl FnOnce(&[u8; 32]) -> Option, - dial: impl FnOnce(Resolved, Duration) -> DF, - request: impl FnOnce(S, Duration) -> RF, - timeout: Duration, -) -> Result> -where - D: DhtLookups, - DF: Future>, - RF: Future>, -{ - let deadline = CallDeadline::after(timeout); - if let Some(target) = reused(already_open, &station) { - return request(target, deadline.remaining()) - .await - .map_err(Failure::Request); - } - let resolved = lookup_station_endpoint(dht, station, deadline, true) - .await - .outcome - .map_err(Failure::Resolve)?; - let target = dial(resolved, deadline.remaining()) - .await - .map_err(Failure::Dial)?; - request(target, deadline.remaining()) - .await - .map_err(Failure::Request) -} - -/// Finds `mcid`'s announced providers and fetches the content from the -/// first one that serves it, dialing each with `dial` and fetching with -/// `fetch`, all within `timeout`. Any failure moves on to the next -/// provider, since a fetch is verified against its MCID and safe to repeat -/// elsewhere; a provider that failed is skipped on later passes unless its -/// announcement changed. A provider whose station `already_open` has a -/// session for is fetched from on that session, with no dial. -pub(crate) async fn fetch_content( - dht: &mut D, - mcid: Mcid, - mut already_open: impl FnMut(&[u8; 32]) -> Option, - mut dial: impl FnMut(Resolved, Duration) -> DF, - mut fetch: impl FnMut(S, Duration) -> FF, - timeout: Duration, -) -> Result> -where - D: DhtLookups, - DF: Future>, - FF: Future>, -{ - let deadline = CallDeadline::after(timeout); - let key = dht::content_key(mcid); - let mut failures: HashMap<[u8; 32], Remembered>> = HashMap::new(); - let mut last: Option>> = None; - let mut pause = RESOLVE_RETRY_DELAY; - loop { - let providers = match query(dht, key, deadline).await { - Lookup::Answered(recs) => { - let providers = trusted_content_providers(&recs); - if providers.is_empty() { - record_none_qualified(&mut last, ContentFailure::NotAnnounced); - } - providers - } - // A failed query teaches nothing, and is retried like one that - // found no provider. - Lookup::Failed(e) => { - record_lookup_failure(&mut last, ContentFailure::Dht(e)); - Vec::new() - } - Lookup::CutOff => Vec::new(), - }; - for (tried, provider) in providers.iter().enumerate() { - if deadline.passed() { - break; - } - let share = deadline.share_for(providers.len() - tried); - let already_failed = failures - .get(&provider.announcer) - .is_some_and(|remembered| remembered.record_version == provider.version); - if !already_failed { - let failure = - match reach_provider(provider, &mut already_open, &mut dial, share).await { - Err(failure) => failure, - Ok(target) => match tokio::time::timeout( - deadline.remaining(), - fetch(target, deadline.remaining()), - ) - .await - { - Ok(Ok(content)) => return Ok(content), - Ok(Err(e)) => ContentFailure::Fetch(e), - Err(_) => { - return Err(ContentFailure::Timeout( - take_last(last, &mut failures).map(Box::new), - )) - } - }, - }; - failures.insert( - provider.announcer, - Remembered { - record_version: provider.version, - endpoint_version: None, - error: failure, - }, - ); - } - last = Some(Last::Candidate(provider.announcer)); - } - if deadline.passed() { - break; - } - tokio::time::sleep(pause.min(deadline.remaining())).await; - pause = (pause * 2).min(MAX_REQUERY_PAUSE); - if deadline.passed() { - break; - } - } - // With nothing observed at all, the deadline ran out before any query - // was answered or any provider could be tried. - Err(take_last(last, &mut failures).unwrap_or(ContentFailure::Timeout(None))) -} - -/// Reaches one content provider: on the session `already_open` has for its -/// station when there is one, otherwise by dialing its announced endpoint -/// within `share`. -async fn reach_provider( - provider: &ContentCandidate, - already_open: impl FnOnce(&[u8; 32]) -> Option, - dial: impl FnOnce(Resolved, Duration) -> DF, - share: CallDeadline, -) -> Result> -where - DF: Future>, -{ - if let Some(target) = reused(already_open, &provider.announcer) { - return Ok(target); - } - let (host, port) = parse_seed_url(&provider.endpoint) - .ok_or_else(|| ContentFailure::EndpointParse(provider.endpoint.clone()))?; - let resolved = Resolved { - station: provider.announcer, - host, - port, - }; - dial(resolved, share.remaining()) - .await - .map_err(ContentFailure::Dial) -} - -/// The session already open to `station` that a request runs on instead of -/// dialing, if any. -fn reused(already_open: impl FnOnce(&[u8; 32]) -> Option, station: &[u8; 32]) -> Option { - already_open(station) -} - -/// No session to reuse, for resolution alone. -fn no_open_session(_station: &[u8; 32]) -> Option { - None -} - -/// Resolution alone: the "dial" and the "request" hand the resolved -/// endpoint straight back. -pub(crate) async fn resolve_within( - dht: &mut D, - realm: [u8; 32], - procedure: &str, - cert_chain: Option>, - timeout: Duration, -) -> Result { - reach_procedure( - dht, - realm, - procedure, - cert_chain, - no_open_session::, - |resolved: Resolved, _share: Duration| ready(Ok::<_, Infallible>(resolved)), - |resolved: Resolved, _remaining: Duration| ready(Ok::<_, Infallible>(resolved)), - timeout, - ) - .await - .map_err(|failure| match failure { - Failure::Resolve(e) => e, - Failure::Dial(never) | Failure::Request(never) => match never {}, - }) -} - -/// Finds `procedure`'s currently-advertised serving station and its -/// dialable host/port, retrying past DHT propagation lag for up to 10 -/// seconds. `realm` and `procedure` must match exactly what the provider -/// passed to [`advertise_direct`] (or the Erlang equivalent) — the -/// discovery URI they derive must agree. `session` is used only to query -/// the DHT; it does not need to be connected to the same station that will -/// end up serving the call. The first candidate whose station endpoint -/// resolves is returned. -pub async fn resolve( - session: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, -) -> Result { - resolve_within( - &mut Via { session, id }, - realm, - procedure, - None, - DEFAULT_RESOLVE_TIMEOUT, - ) - .await -} - -/// Every advertisement whose signature and expiry verify and whose payload -/// parses, in DHT order, and the error to report if none does. -fn trusted_advertisements(recs: &[Record]) -> (Vec, ResolveError) { - let candidates = recs - .iter() - .filter_map(|rec| { - dht::verify(rec).ok()?; - let adv = dht::read_procedure_advertisement(rec).ok()?; - Some(ProcedureCandidate { - signer: rec.key, - version: rec.version, - station: adv.serving_station, - }) - }) - .collect(); - (candidates, ResolveError::NoTrustedAdvertisement) -} - -/// [`resolve`] plus Slice 7c Direction B managed-realm authorization: only -/// an advertisement whose embedded cert chain validates to `realm_ca_pem` -/// and names `expected_org` is trusted. Opt-in — [`resolve`] itself is -/// unaffected and remains the right choice for unmanaged realms. -pub async fn resolve_with_cert_chain( - session: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - realm_ca_pem: &[u8], - expected_org: &str, -) -> Result { - resolve_within( - &mut Via { session, id }, - realm, - procedure, - Some(CertChainCheck { - realm_ca_pem, - expected_org, - }), - DEFAULT_RESOLVE_TIMEOUT, - ) - .await -} - -/// [`trusted_advertisements`] plus the cert-chain check. Matches Go's -/// `firstAuthorizedAdvertisement`: if every candidate fails even the plain -/// envelope-signature check, report [`ResolveError::NoTrustedAdvertisement`] -/// (same as the plain path); only report -/// [`ResolveError::NoAuthorizedAdvertisement`] once at least one candidate's -/// signature verified but none passed cert-chain authorization. -fn authorized_advertisements( - recs: &[Record], - check: CertChainCheck<'_>, -) -> (Vec, ResolveError) { - let mut candidates = Vec::new(); - let mut last_cert_err: Option = None; - for rec in recs { - if dht::verify(rec).is_err() { - continue; - } - match cert_chain::verify_advertisement_cert_chain( - check.realm_ca_pem, - rec, - check.expected_org, - ) { - Ok(()) => { - if let Ok(adv) = dht::read_procedure_advertisement(rec) { - candidates.push(ProcedureCandidate { - signer: rec.key, - version: rec.version, - station: adv.serving_station, - }); - } - } - Err(e) => last_cert_err = Some(e), - } - } - let unresolved = match last_cert_err { - Some(e) => ResolveError::NoAuthorizedAdvertisement(e), - None => ResolveError::NoTrustedAdvertisement, - }; - (candidates, unresolved) -} - -/// One `station_endpoint` lookup within a budget: the resolved endpoint or -/// why it failed, the version of the last record the DHT returned, and -/// whether the DHT answered at all (a record or not_found) rather than -/// failing or being cut off. -struct EndpointLookup { - outcome: Result, - seen_version: Option<[u8; 16]>, - answered: bool, -} - -/// What the latest answered `station_endpoint` lookup found instead of a -/// usable record. -#[derive(Clone, Copy)] -enum Answered { - NotFound, - Malformed, -} - -/// With `retry_within_budget`, a lookup that found no usable record (absent, -/// expired, or naming no dialable address) or that failed is looked up again -/// every [`RESOLVE_RETRY_DELAY`] until `budget` runs out — the DHT can hand -/// back a replica that hasn't been evicted yet even though the station's own -/// current publish is live. A record that doesn't verify ends the lookup. -/// When no usable record turns up, the lookup reports what it observed, as -/// `macula_direct_dial`'s `endpoint_recorded/3` does: the latest answered -/// lookup (not found, or the malformed record), else the latest failed -/// lookup's error, else a timeout. -async fn lookup_station_endpoint( - dht: &mut D, - station: [u8; 32], - budget: CallDeadline, - retry_within_budget: bool, -) -> EndpointLookup { - let key = dht::station_endpoint_key(station); - let mut seen_version = None; - let mut answered = None; - let mut failed = None; - loop { - match tokio::time::timeout(budget.remaining(), dht.find_record(key)).await { - Ok(Ok(rec)) => { - seen_version = Some(rec.version); - // The station_endpoint record for `station` must be SIGNED BY - // `station` itself — checking the signature and that the - // signer is exactly `station`, not just any valid signature, - // is what makes pinning the dial's expected identity - // meaningful. - if rec.key != station { - return EndpointLookup { - outcome: Err(ResolveError::StationEndpointSignerMismatch), - seen_version, - answered: true, - }; - } - match dht::verify(&rec) { - Ok(()) => match read_endpoint(station, &rec) { - Some(resolved) => { - return EndpointLookup { - outcome: Ok(resolved), - seen_version, - answered: true, - } - } - None => answered = Some(Answered::Malformed), - }, - Err(dht::VerifyError::Expired) => answered = Some(Answered::NotFound), - Err(_) => { - return EndpointLookup { - outcome: Err(ResolveError::NoTrustedAdvertisement), - seen_version, - answered: true, - } - } - } - } - Ok(Err(DhtError::NotFound)) => answered = Some(Answered::NotFound), - // A failed lookup teaches nothing: looked up again like an absent - // record. - Ok(Err(e)) => failed = Some(e), - // Cut off by the budget: nothing learned. - Err(_) => {} - } - if !retry_within_budget || budget.passed() { - let unresolved = match (answered, failed) { - (Some(Answered::NotFound), _) => ResolveError::StationEndpointNotFound, - (Some(Answered::Malformed), _) => ResolveError::MalformedStationEndpoint, - (None, Some(e)) => ResolveError::Dht(e), - (None, None) => ResolveError::Timeout, - }; - return EndpointLookup { - outcome: Err(unresolved), - seen_version, - answered: answered.is_some(), - }; - } - tokio::time::sleep(RESOLVE_RETRY_DELAY.min(budget.remaining())).await; - } -} - -/// The dialable address a verified `station_endpoint` record names, or -/// `None` when it names none. -fn read_endpoint(station: [u8; 32], rec: &Record) -> Option { - let ep = dht::read_station_endpoint(rec).ok()?; - let host = ep.host_advertised.into_iter().next()?; - Some(Resolved { - station, - host, - port: ep.quic_port, - }) -} - -#[derive(Debug)] -pub enum CallError { - Resolve(ResolveError), - Dial(connection::HandshakeError), - /// The dialed peer's own signature-verified HELLO identity didn't - /// match the pubkey the signed DHT chain resolved — a trust violation, - /// not a retryable error. - TrustViolation { - resolved: [u8; 32], - dialed: [u8; 32], - }, - Call(connection::CallError), -} - -impl std::fmt::Display for CallError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - CallError::Resolve(e) => write!(f, "{e}"), - CallError::Dial(e) => write!(f, "direct_dial: dialing resolved station: {e}"), - CallError::TrustViolation { resolved, dialed } => write!( - f, - "direct_dial: trust violation -- resolved station {} but the dialed peer proved identity {}", - hex_of(resolved), - hex_of(dialed) - ), - CallError::Call(e) => write!(f, "direct_dial: {e}"), - } - } -} - -impl std::error::Error for CallError {} - -fn hex_of(b: &[u8; 32]) -> String { - b.iter().map(|byte| format!("{byte:02x}")).collect() -} - -fn call_failure(failure: Failure) -> CallError { - match failure { - Failure::Resolve(e) => CallError::Resolve(e), - Failure::Dial(DialAndVerifyError::Dial(e)) => CallError::Dial(e), - Failure::Dial(DialAndVerifyError::TrustViolation { resolved, dialed }) => { - CallError::TrustViolation { resolved, dialed } - } - Failure::Request(e) => CallError::Call(e), - } -} - -/// The live dial every direct-dial shape uses: [`dial_and_verify`] against -/// a resolved endpoint within `timeout`. -async fn dial_verified( - resolved: Resolved, - id: &KeyPair, - timeout: Duration, -) -> Result { - dial_and_verify(&resolved.host, resolved.port, resolved.station, id, timeout).await -} - -/// Sends one CALL on the target with whatever remains of the deadline, then -/// gives back the target's lease, closing a dialed session when no other -/// direct-dial request still uses it. -async fn call_then_release( - target: StationTarget, - remaining: Duration, - id: &KeyPair, - procedure: &str, - realm: [u8; 32], - payload: Value, - ucan_token: Option>, -) -> Result { - let deadline_ms = now_ms() + remaining.as_millis() as i128; - let (result, last) = run_then_release(target, move |session: Session| async move { - match ucan_token { - None => { - session - .call(procedure, realm, payload, deadline_ms, id, remaining) - .await - } - Some(token) => { - session - .call_with_ucan(procedure, realm, payload, deadline_ms, id, remaining, token) - .await - } - } - }) - .await; - close_last(last, id).await; - result -} - -/// Resolves `procedure`'s provider via direct-dial (through `resolve_via`, -/// used only to query the DHT) and calls it there, in one hop. The provider -/// must have advertised via [`advertise_direct`] (or the Erlang -/// `macula_response:advertise_direct/6,7`) — a plain `advertise` publishes -/// no discoverable record and the call returns -/// [`ResolveError::ProcedureNotAdvertised`]. -/// -/// `timeout` bounds the whole call: finding the provider, each candidate's -/// endpoint lookup and dial, and the CALL itself. See the module doc's -/// "Candidates" for how providers are tried in turn. -/// -/// The call runs on a session this process already has open to the -/// provider's station under `id` when there is one (`resolve_via`, a -/// [`Pool`](crate::pool::Pool) link, or a session direct dial dialed for -/// another request), and otherwise dials one, which closes once no -/// direct-dial request still uses it. A CALL that fails before it was sent -/// is tried on the next candidate; one that was or may have been sent is -/// returned. -/// -/// The dial itself uses [`Trust::Insecure`] (no TLS verification) because -/// trust is enforced at the application layer instead — see the module -/// doc's "Trust model". After the dial, the freshly connected session's own -/// signature-verified HELLO identity is checked against the exact pubkey -/// the signed DHT chain resolved; a mismatch is -/// [`CallError::TrustViolation`], and that candidate is skipped. -pub async fn call( - resolve_via: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - payload: Value, - timeout: Duration, -) -> Result { - reach_procedure( - &mut Via { - session: resolve_via, - id, - }, - realm, - procedure, - None, - move |station: &[u8; 32]| open_session_to(id, station), - move |resolved: Resolved, share: Duration| dial_target(resolved, id, share), - move |target: StationTarget, remaining: Duration| { - call_then_release( - target, - remaining, - id, - procedure, - realm, - payload.clone(), - None, - ) - }, - timeout, - ) - .await - .map_err(call_failure) -} - -/// [`call`], presenting `ucan_token` to a provider gated with -/// `{ucan_required, Issuer}`. Every hecate-om capability is advertised via -/// [`advertise_direct`], so this is the only way a UCAN-gated capability -/// is reachable through this crate at all -- [`call`] itself has no token -/// parameter, and [`Session::call_with_ucan`] is the plain, non-direct -/// path, which cannot resolve a direct-dial-only advertisement to begin -/// with. -pub async fn call_with_ucan( - resolve_via: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - payload: Value, - timeout: Duration, - ucan_token: Vec, -) -> Result { - reach_procedure( - &mut Via { - session: resolve_via, - id, - }, - realm, - procedure, - None, - move |station: &[u8; 32]| open_session_to(id, station), - move |resolved: Resolved, share: Duration| dial_target(resolved, id, share), - move |target: StationTarget, remaining: Duration| { - call_then_release( - target, - remaining, - id, - procedure, - realm, - payload.clone(), - Some(ucan_token.clone()), - ) - }, - timeout, - ) - .await - .map_err(call_failure) -} - -/// [`call`], resolved via [`resolve_with_cert_chain`] instead of -/// [`resolve`] — see both for the full contract. Opt-in managed-realm -/// authorization; [`call`] itself is unaffected. -#[allow(clippy::too_many_arguments)] -pub async fn call_with_cert_chain( - resolve_via: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - realm_ca_pem: &[u8], - expected_org: &str, - payload: Value, - timeout: Duration, -) -> Result { - reach_procedure( - &mut Via { - session: resolve_via, - id, - }, - realm, - procedure, - Some(CertChainCheck { - realm_ca_pem, - expected_org, - }), - move |station: &[u8; 32]| open_session_to(id, station), - move |resolved: Resolved, share: Duration| dial_target(resolved, id, share), - move |target: StationTarget, remaining: Duration| { - call_then_release( - target, - remaining, - id, - procedure, - realm, - payload.clone(), - None, - ) - }, - timeout, - ) - .await - .map_err(call_failure) -} - -/// Publishes a signed `procedure_advertisement` naming `session`'s own -/// currently-connected station (`session.station.node_id`) as `procedure`'s -/// server, discoverable by any caller's [`resolve`]/[`call`]. Mirrors -/// `macula_response:advertise_direct/6,7` + -/// `macula_direct_dial:publish_advertisement/4,5` — unlike the Erlang -/// reference's pool (many links, one chosen by `connected_station/1`), a -/// [`Session`] is always exactly one connection, so there is no -/// link-selection step: the session's own verified HELLO identity IS the -/// serving station. -/// -/// **Sends the ordinary ADVERTISE frame first, then publishes the DHT -/// record** — matching `macula_response:advertise_direct/7`'s own body -/// exactly (`case advertise(Pool, Realm, Procedure, Module, Args, Opts) of -/// {ok, Sup} -> ... macula_direct_dial:publish_advertisement(...)`). The -/// DHT record is an ADDITIONAL discovery path for a caller on a different -/// station to skip inter-station gossip propagation — it is not a -/// substitute for the station actually knowing to route inbound CALLs -/// here. **Found live, 2026-08-30**: an earlier version of this function -/// (and its `macula-go` port, same gap, not yet fixed there as of this -/// writing) published only the DHT record — a direct-dial caller could -/// resolve and dial the right station, but the station itself had never -/// been told to route the call anywhere, so every call still failed with -/// `unknown_next_peer` despite a perfectly valid, resolvable, trusted -/// advertisement. Caught by a live test that, unlike the earlier -/// direct-dial verification, actually tried to get a real RESULT back -/// instead of accepting `unknown_next_peer` as the expected terminal state. -/// -/// Unlike the Erlang SDK's supervised `macula_response`, this does not -/// itself keep anything alive — it does not spawn a responder process, so -/// a caller still needs its own [`Session::serve_one_call`](crate::connection::Session::serve_one_call) -/// loop to actually answer what gets routed here. A station's registration -/// for a procedure does not survive the connection that sent it being -/// replaced, so a long-lived server needs to call this again on its own -/// schedule; see [`keep_advertised_direct`] for that loop. -pub async fn advertise_direct( - session: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - ttl: Duration, -) -> Result<(), AdvertiseDirectError> { - let advertise_spec = crate::frame::AdvertiseSpec::new(realm, procedure, id.node_id()); - session - .advertise(&advertise_spec, id) - .await - .map_err(AdvertiseDirectError::Advertise)?; - - let uri = dht::discovery_uri(realm, procedure); - let rec = dht::new_procedure_advertisement(id.node_id(), uri, session.station.node_id, ttl); - let rec = dht::sign(rec, id); - dht::put_record(session, id, &rec) - .await - .map_err(AdvertiseDirectError::Dht) -} - -/// [`advertise_direct`] plus an embedded X.509 service-cert chain, for -/// Slice 7c Direction B managed-realm authorization — see -/// [`resolve_with_cert_chain`]/[`call_with_cert_chain`] for the -/// corresponding checks. Opt-in: plain [`advertise_direct`] is unaffected. -pub async fn advertise_direct_with_cert_chain( - session: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - ttl: Duration, - cert_chain_pem: Vec, -) -> Result<(), AdvertiseDirectError> { - let advertise_spec = crate::frame::AdvertiseSpec::new(realm, procedure, id.node_id()); - session - .advertise(&advertise_spec, id) - .await - .map_err(AdvertiseDirectError::Advertise)?; - - let uri = dht::discovery_uri(realm, procedure); - let rec = dht::new_procedure_advertisement_with_cert_chain( - id.node_id(), - uri, - session.station.node_id, - ttl, - cert_chain_pem, - ); - let rec = dht::sign(rec, id); - dht::put_record(session, id, &rec) - .await - .map_err(AdvertiseDirectError::Dht) -} - -#[derive(Debug)] -pub enum AdvertiseDirectError { - /// The ordinary station-side ADVERTISE frame failed to send. - Advertise(connection::SendError), - /// The ordinary ADVERTISE succeeded, but publishing the direct-dial - /// DHT record failed — the procedure IS now reachable via ordinary - /// advertise-gossip, just not via direct-dial resolution. - Dht(DhtError), -} - -impl std::fmt::Display for AdvertiseDirectError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - AdvertiseDirectError::Advertise(e) => write!(f, "direct_dial: sending ADVERTISE: {e}"), - AdvertiseDirectError::Dht(e) => write!(f, "direct_dial: {e}"), - } - } -} - -impl std::error::Error for AdvertiseDirectError {} - -/// Calls [`advertise_direct`] immediately, then again every `interval`, -/// until `stop` resolves. Rust has nothing equivalent to -/// `macula_response`'s `reuse_sup` to worry about here, because -/// [`advertise_direct`] (unlike Erlang's `advertise/5`, which spawns a real -/// per-call OTP supervisor) is already a stateless, side-effect-free-on- -/// repeat async function: nothing is created per tick that could leak — -/// same reasoning `macula-go`'s `KeepAdvertisedDirect` already applied -/// and verified live. -/// -/// `interval` should leave real margin before `ttl` expires — production -/// practice in `hecate-om`'s own capability re-advertise loop (the actual -/// consumer of `advertise_direct`'s `reuse_sup` option on the Erlang side) -/// uses a 4x margin: a 30s republish interval against a 120s record TTL. -/// -/// A failed tick (network blip, connection genuinely dead, etc.) is -/// reported via `on_error` but does NOT stop the loop; it tries again at -/// the next interval regardless, matching `hecate-om`'s own log-and-continue -/// practice around every DHT publish. This loop cannot detect or repair a -/// dead `session` on its own — if its underlying connection has actually -/// gone down, every tick will keep failing the same way until `stop` -/// resolves; reconnecting a dead session is a separate, larger concern this -/// does not attempt to solve. -/// -/// 8 parameters: a target (`session`/`realm`/`procedure`), a re-advertise -/// schedule (`id`/`ttl`/`interval`), and two independent callbacks -/// (`stop`/`on_error`) with no natural sub-grouping — folding any of them -/// into a synthetic struct would relocate the count, not reduce it. -#[allow(clippy::too_many_arguments)] -pub async fn keep_advertised_direct( - session: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - ttl: Duration, - interval: Duration, - stop: F, - on_error: impl Fn(AdvertiseDirectError), -) where - F: Future, -{ - tokio::pin!(stop); - let mut ticker = tokio::time::interval(interval); - loop { - tokio::select! { - _ = &mut stop => return, - _ = ticker.tick() => { - if let Err(e) = advertise_direct(session, id, realm, procedure, ttl).await { - on_error(e); - } - } - } - } -} - -/// The dial-then-pin sequence every direct-dial call shape needs after -/// resolving: dial `resolved`'s host:port, then check the freshly -/// connected session's own signature-verified HELLO identity against -/// `resolved.station` — factored out here because [`call`]/ -/// [`open_stream_direct`]/[`put_direct`]/[`get_direct`] all need the -/// identical sequence against a station identity that isn't necessarily -/// reached via [`resolve`]. -#[derive(Debug)] -pub enum DialAndVerifyError { - Dial(connection::HandshakeError), - /// The dialed peer's own signature-verified HELLO identity didn't - /// match the pubkey the signed DHT chain resolved — a trust - /// violation, not a retryable error. - TrustViolation { - resolved: [u8; 32], - dialed: [u8; 32], - }, -} - -impl std::fmt::Display for DialAndVerifyError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DialAndVerifyError::Dial(e) => write!(f, "direct_dial: dialing resolved station: {e}"), - DialAndVerifyError::TrustViolation { resolved, dialed } => write!( - f, - "direct_dial: trust violation -- resolved station {} but the dialed peer proved identity {}", - hex_of(resolved), - hex_of(dialed) - ), - } - } -} - -impl std::error::Error for DialAndVerifyError {} - -async fn dial_and_verify( - host: &str, - port: u16, - station: [u8; 32], - id: &KeyPair, - timeout: Duration, -) -> Result { - let target = tokio::time::timeout( - timeout, - connection::connect_leased(host, port, Trust::Insecure, id), - ) - .await - .unwrap_or(Err(connection::HandshakeError::Timeout)) - .map_err(DialAndVerifyError::Dial)?; - - if target.station.node_id != station { - let dialed = target.station.node_id; - target.close("trust_violation", None, id).await; - return Err(DialAndVerifyError::TrustViolation { - resolved: station, - dialed, - }); - } - Ok(target) -} - -/// The session a request runs on, and whether the request holds a lease on -/// it. A session direct dial dialed comes with the request's lease; a -/// session this process already had open under its owner, such as -/// `resolve_via` or a pool link, comes with none, so the request never -/// releases or closes it. -struct StationTarget { - session: S, - leased: bool, -} - -impl StationTarget { - /// The target for a session direct dial just dialed, holding the lease - /// the dial took. - fn dialed(session: S) -> Self { - let leased = session.leases().is_some(); - Self { session, leased } - } - - /// The target for reusing a session found open to a station: without a - /// lease when its owner opened it, with a new lease when direct dial - /// dialed it, and none at all when its last lease was already released. - fn reuse(found: S) -> Option { - let leased = match found.leases() { - None => false, - Some(leases) if leases.try_lease() => true, - Some(_) => return None, - }; - Some(Self { - session: found, - leased, - }) - } - - /// Gives back the request's lease, if it holds one. Returns the session - /// when that was its last lease, for the caller to close. - fn release_lease(self) -> Option { - let last = self.leased && self.session.leases().is_some_and(Leases::release); - last.then_some(self.session) - } -} - -impl StationTarget { - async fn open_dedicated_stream(&self) -> Result { - self.session.open_dedicated_stream().await - } - - /// Gives back the request's lease, closing a session direct dial dialed - /// when no other direct-dial request still uses it. - async fn release(self, id: &KeyPair) { - close_last(self.release_lease(), id).await; - } -} - -/// Runs `request` on the target's session, then gives the target's lease -/// back whatever the outcome. Returns the request's result, and the session -/// when that was its last lease, for the caller to close. -async fn run_then_release( - target: StationTarget, - request: impl FnOnce(S) -> Fut, -) -> (T, Option) -where - S: Leased + Clone, - Fut: Future, -{ - let result = request(target.session.clone()).await; - (result, target.release_lease()) -} - -/// Closes `last`, a session whose last lease was just given back. -async fn close_last(last: Option, id: &KeyPair) { - if let Some(session) = last { - session.close("normal", None, id).await; - } -} - -/// Dials a resolved station for a request that could have reused an open -/// session instead. -async fn dial_target( - resolved: Resolved, - id: &KeyPair, - timeout: Duration, -) -> Result { - dial_verified(resolved, id, timeout) - .await - .map(StationTarget::dialed) -} - -/// The connection of a session this process already has open to `station` -/// under `id`, such as `resolve_via` or a pool link, reused instead of -/// dialing: a second connection under the same identity would make the -/// station close that session. A session direct dial dialed for another -/// request is reused with a lease of its own, and not at all once it is -/// closing. -fn open_session_to(id: &KeyPair, station: &[u8; 32]) -> Option { - open_sessions::live() - .find(id.node_id(), *station) - .and_then(|open| StationTarget::reuse(Session::from_open(open))) -} - -/// A stream [`open_stream_direct`] opened, and its use of the session it -/// runs on. -pub struct OpenedStream { - pub stream: StreamHandle, - /// Release it once the stream is done. - pub lease: SessionLease, -} - -/// A direct-dial stream's use of the session it runs on. A session direct -/// dial dialed stays open while any direct-dial request still uses it and -/// closes when the last one is released; a session this process already had -/// open under its owner, such as `resolve_via` or a -/// [`Pool`](crate::pool::Pool) link, stays open. A lease dropped without -/// being released leaves a dialed session open until its last handle is -/// gone, which then closes it without a GOODBYE. -pub struct SessionLease { - target: StationTarget, -} - -impl SessionLease { - /// The session the stream runs on. - pub fn session(&self) -> &Session { - &self.target.session - } - - /// Gives back this use of the session, closing a session direct dial - /// dialed when no other direct-dial request still uses it. - pub async fn release(self, id: &KeyPair) { - self.target.release(id).await; - } -} - -#[derive(Debug)] -pub enum OpenStreamDirectError { - Resolve(ResolveError), - Dial(DialAndVerifyError), - Open(stream::OpenError), -} - -impl std::fmt::Display for OpenStreamDirectError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - OpenStreamDirectError::Resolve(e) => write!(f, "{e}"), - OpenStreamDirectError::Dial(e) => write!(f, "{e}"), - OpenStreamDirectError::Open(e) => write!(f, "direct_dial: open stream: {e}"), - } - } -} - -impl std::error::Error for OpenStreamDirectError {} - -fn open_stream_failure( - failure: Failure, -) -> OpenStreamDirectError { - match failure { - Failure::Resolve(e) => OpenStreamDirectError::Resolve(e), - Failure::Dial(e) => OpenStreamDirectError::Dial(e), - Failure::Request(e) => OpenStreamDirectError::Open(e), - } -} - -/// Opens the stream on the target and hands it to the caller with the -/// target's lease. Gives the lease back only if the open itself fails. -async fn open_stream_on( - target: StationTarget, - id: &KeyPair, - procedure: &str, - realm: [u8; 32], - mode: StreamMode, - args: Value, - deadline_ms: i128, -) -> Result { - let opened = match target.open_dedicated_stream().await { - Ok(dedicated) => { - StreamHandle::open_on(dedicated, procedure, realm, mode, args, deadline_ms, id).await - } - Err(e) => Err(stream::OpenError::OpenStream(e)), - }; - match opened { - Ok(stream) => Ok(OpenedStream { - stream, - lease: SessionLease { target }, - }), - Err(e) => { - target.release(id).await; - Err(e) - } - } -} - -/// Resolves `procedure`'s provider via direct-dial (through `resolve_via`, -/// used only to query the DHT) and opens a stream there, in one hop — the -/// streaming-RPC counterpart to [`call`]. The provider must have advertised -/// via [`advertise_direct`]: -/// streaming's provider side (`macula_streamer.erl`) shares the identical -/// `procedure_advertisement` mechanism RPC uses (confirmed against -/// `macula_streamer.erl`/`macula_stream_sink.erl`'s own `advertise_direct`/ -/// `start_link_direct` — both are `macula_response:advertise_direct`/ -/// `macula_direct_dial:call_stream` under the hood, nothing stream-specific -/// added), so no separate stream-shaped advertise function exists or is -/// needed. -/// -/// `timeout` bounds finding the provider, each candidate's endpoint lookup -/// and dial, and opening the stream; `deadline_ms` is the stream's own -/// deadline, sent to the provider. A failure once the station is reached is -/// never retried elsewhere, because STREAM_OPEN may already be out. -/// -/// The stream runs on a session this process already has open to the -/// provider's station under `id` when there is one (`resolve_via`, a -/// [`Pool`](crate::pool::Pool) link, or a session direct dial dialed for -/// another request), on a dedicated QUIC stream of its own; otherwise direct -/// dial dials a session for it. Release [`OpenedStream::lease`] once the -/// stream is done. -#[allow(clippy::too_many_arguments)] -pub async fn open_stream_direct( - resolve_via: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - mode: StreamMode, - args: Value, - deadline_ms: i128, - timeout: Duration, -) -> Result { - reach_procedure( - &mut Via { - session: resolve_via, - id, - }, - realm, - procedure, - None, - move |station: &[u8; 32]| open_session_to(id, station), - move |resolved: Resolved, share: Duration| dial_target(resolved, id, share), - move |target: StationTarget, _remaining: Duration| { - open_stream_on( - target, - id, - procedure, - realm, - mode, - args.clone(), - deadline_ms, - ) - }, - timeout, - ) - .await - .map_err(open_stream_failure) -} - -/// [`open_stream_direct`], resolved via [`resolve_with_cert_chain`] -/// instead of [`resolve`] — see both for the full contract. Opt-in -/// managed-realm authorization; [`open_stream_direct`] itself is -/// unaffected. -#[allow(clippy::too_many_arguments)] -pub async fn open_stream_direct_with_cert_chain( - resolve_via: &Session, - id: &KeyPair, - realm: [u8; 32], - procedure: &str, - realm_ca_pem: &[u8], - expected_org: &str, - mode: StreamMode, - args: Value, - deadline_ms: i128, - timeout: Duration, -) -> Result { - reach_procedure( - &mut Via { - session: resolve_via, - id, - }, - realm, - procedure, - Some(CertChainCheck { - realm_ca_pem, - expected_org, - }), - move |station: &[u8; 32]| open_session_to(id, station), - move |resolved: Resolved, share: Duration| dial_target(resolved, id, share), - move |target: StationTarget, _remaining: Duration| { - open_stream_on( - target, - id, - procedure, - realm, - mode, - args.clone(), - deadline_ms, - ) - }, - timeout, - ) - .await - .map_err(open_stream_failure) -} - -#[derive(Debug)] -pub enum PutDirectError { - Resolve(ResolveError), - Dial(DialAndVerifyError), - Put(content::PutError), -} - -impl std::fmt::Display for PutDirectError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PutDirectError::Resolve(e) => write!(f, "{e}"), - PutDirectError::Dial(e) => write!(f, "{e}"), - PutDirectError::Put(e) => write!(f, "direct_dial: {e}"), - } - } -} - -impl std::error::Error for PutDirectError {} - -/// Stores `data` on the target, then gives back the target's lease, closing -/// a dialed session when no other direct-dial request still uses it. -async fn put_then_release( - target: StationTarget, - id: &KeyPair, - data: &[u8], - name: String, -) -> Result { - let (result, last) = run_then_release(target, move |session: Session| async move { - match session.open_dedicated_stream().await { - Ok(dedicated) => content::put_on(dedicated, data, name, id).await, - Err(e) => Err(content::PutError::OpenStream(e)), - } - }) - .await; - close_last(last, id).await; - result -} - -/// Stores `data` at a KNOWN `station` directly, in one hop, instead of -/// going through whatever station `resolve_via` happens to be connected -/// to. Mirrors `macula_feeder:start_link_direct/5,6`, which — unlike -/// procedure/stream direct-dial — takes the target station's pubkey -/// directly rather than resolving one via a `procedure_advertisement`: -/// content has no "procedure" to advertise, so there is nothing to -/// resolve here beyond the station's own `station_endpoint`. -/// `resolve_via` is used only to query the DHT for `station`'s -/// `station_endpoint`; it does not need to already be connected to -/// `station`. -/// -/// `timeout` bounds the station's endpoint lookup and the dial; the upload -/// itself runs without one, as before. -/// -/// When this process already has a session open to `station` under `id` -/// (`resolve_via` itself, or a [`Pool`](crate::pool::Pool) link), the -/// upload runs on that session, on a dedicated QUIC stream, with no -/// endpoint lookup or dial, and the session stays open. Otherwise direct -/// dial dials a session for the upload and closes it afterwards, unless -/// another direct-dial request still uses it. -pub async fn put_direct( - resolve_via: &Session, - id: &KeyPair, - station: [u8; 32], - data: &[u8], - name: impl Into, - timeout: Duration, -) -> Result { - let name = name.into(); - reach_station( - &mut Via { - session: resolve_via, - id, - }, - station, - move |station: &[u8; 32]| open_session_to(id, station), - move |resolved: Resolved, remaining: Duration| dial_target(resolved, id, remaining), - move |target: StationTarget, _remaining: Duration| put_then_release(target, id, data, name), - timeout, - ) - .await - .map_err(|failure| match failure { - Failure::Resolve(e) => PutDirectError::Resolve(e), - Failure::Dial(e) => PutDirectError::Dial(e), - Failure::Request(e) => PutDirectError::Put(e), - }) -} - -/// `mcid` has no live, verifiable `content_announcement` in the DHT — -/// either nobody announced it (common: a single-block content put alone is -/// never announced, matching `macula_content_transfer:put_single_block/3`), -/// or every candidate found failed signature/self-consistency -/// verification. -#[derive(Debug)] -pub struct ContentNotAnnounced; - -impl std::fmt::Display for ContentNotAnnounced { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "direct_dial: content has no verifiable announcement in the DHT" - ) - } -} - -impl std::error::Error for ContentNotAnnounced {} - -#[derive(Debug)] -pub enum GetDirectError { - Dht(DhtError), - NotAnnounced(ContentNotAnnounced), - /// A `content_announcement`'s `endpoint` field wasn't a dialable - /// `host:port` or URL. - EndpointParse(String), - Dial(DialAndVerifyError), - Get(content::GetError), - /// The timeout ran out before the content arrived: during a content - /// transfer, or before any provider lookup was answered. `last` is the - /// failure before a cut-off transfer, if any — for example the provider - /// tried first serving content that didn't verify — and is also this - /// error's [`source`](std::error::Error::source). - Timeout { - last: Option>, - }, -} - -impl std::fmt::Display for GetDirectError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - GetDirectError::Dht(e) => write!(f, "direct_dial: find content providers: {e}"), - GetDirectError::NotAnnounced(e) => write!(f, "{e}"), - GetDirectError::EndpointParse(endpoint) => { - write!( - f, - "direct_dial: content provider endpoint {endpoint:?}: not a URL or host:port" - ) - } - GetDirectError::Dial(e) => write!(f, "{e}"), - GetDirectError::Get(e) => write!(f, "direct_dial: {e}"), - GetDirectError::Timeout { last: None } => { - write!( - f, - "direct_dial: the timeout ran out before the content arrived" - ) - } - GetDirectError::Timeout { last: Some(last) } => write!( - f, - "direct_dial: the timeout ran out during the content transfer, after: {last}" - ), - } - } -} - -impl std::error::Error for GetDirectError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - GetDirectError::Timeout { last: Some(last) } => Some(last.as_ref()), - _ => None, - } - } -} - -fn get_direct_failure( - failure: ContentFailure, -) -> GetDirectError { - match failure { - ContentFailure::Dht(e) => GetDirectError::Dht(e), - ContentFailure::NotAnnounced => GetDirectError::NotAnnounced(ContentNotAnnounced), - ContentFailure::EndpointParse(endpoint) => GetDirectError::EndpointParse(endpoint), - ContentFailure::Dial(e) => GetDirectError::Dial(e), - ContentFailure::Fetch(e) => GetDirectError::Get(e), - ContentFailure::Timeout(last) => GetDirectError::Timeout { - last: last.map(|failure| Box::new(get_direct_failure(*failure))), - }, - } -} - -/// Fetches `mcid` on the target, then gives back the target's lease, closing -/// a dialed session when no other direct-dial request still uses it. -async fn get_then_release( - target: StationTarget, - id: &KeyPair, - mcid: Mcid, -) -> Result, content::GetError> { - let (result, last) = run_then_release(target, move |session: Session| async move { - match session.open_dedicated_stream().await { - Ok(dedicated) => content::get_on(dedicated, mcid, id).await, - Err(e) => Err(content::GetError::OpenStream(e)), - } - }) - .await; - close_last(last, id).await; - result -} - -/// Fetches and verifies the content addressed by `mcid` from whichever -/// station a signed `content_announcement` names as its host, dialing -/// that station in one hop instead of relaying through `resolve_via`'s own -/// station. Mirrors `macula_direct_dial:get_content/3`. -/// -/// `timeout` bounds the whole fetch: finding providers, each dial and each -/// transfer. A provider whose dial or transfer fails, including content -/// that doesn't verify against `mcid`, is skipped for the next one. When -/// the timeout cuts a transfer off, [`GetDirectError::Timeout`] carries the -/// previous failure, and a session dialed for that transfer is dropped -/// without a GOODBYE. A provider whose station this process already has a -/// session open to under `id` is fetched from on that session, which stays -/// open. -/// -/// **Architectural note this module's other direct-dial functions don't -/// need**: a `content_announcement`'s `endpoint` is the FINAL dial target -/// directly (see `macula_record:read_content_announcement/1`'s `endpoint` -/// field and `macula:get_content_station/5`'s use of it as-is) — unlike -/// `procedure_advertisement`, there is no station-relay indirection, so -/// the announcer must genuinely BE independently dialable there. A plain -/// outbound-only leaf (everything this SDK's own identity/session model -/// supports) cannot legitimately publish one of these about itself — only -/// something with its own listening identity (`macula-station`, or a -/// dedicated content-serving relay) can; confirmed directly against -/// `macula.erl`, which states a `content_announcement` is made -/// "automatically by the station on receipt," not by an arbitrary -/// publisher. This crate therefore does not expose a client-facing -/// "announce content direct": [`dht::new_content_announcement`] stays a -/// low-level primitive (mirroring `macula_record.erl`'s own export) for -/// that kind of infrastructure-tier code, not ordinary leaf use. -/// [`get_direct`] itself has no such limitation — resolving and fetching -/// FROM an already-announced provider is a perfectly ordinary leaf -/// operation. -pub async fn get_direct( - resolve_via: &Session, - id: &KeyPair, - mcid: Mcid, - timeout: Duration, -) -> Result, GetDirectError> { - fetch_content( - &mut Via { - session: resolve_via, - id, - }, - mcid, - move |station: &[u8; 32]| open_session_to(id, station), - move |resolved: Resolved, share: Duration| dial_target(resolved, id, share), - move |target: StationTarget, _remaining: Duration| get_then_release(target, id, mcid), - timeout, - ) - .await - .map_err(get_direct_failure) -} - -/// Every content announcement that verifies, in DHT order. Mirrors -/// `macula.erl`'s `decode_provider/1`: the record's OWN signature must -/// verify, AND the payload's claimed `announcer_node` must equal the -/// record's own envelope key — a record merely stored under the right key -/// but self-signed by a different identity would otherwise still be -/// trusted. -fn trusted_content_providers(recs: &[Record]) -> Vec { - recs.iter() - .filter_map(|rec| { - dht::verify(rec).ok()?; - let adv = dht::read_content_announcement(rec).ok()?; - (adv.announcer_node == rec.key).then_some(ContentCandidate { - announcer: adv.announcer_node, - version: rec.version, - endpoint: adv.endpoint, - }) - }) - .collect() -} - -/// Splits a `content_announcement`'s `endpoint` (a dialable seed URL, e.g. -/// `"https://host:4433"` — `macula_client:seed()`'s own format) into the -/// host/port pair [`connection::connect`] wants. Distinct from -/// `station_endpoint`'s already-split `host_advertised`/`quic_port` -/// fields — `content_announcement` embeds a single ready-to-dial URL -/// instead. Tolerates a bare `host:port` with no scheme too, matching this -/// crate's own tolerance elsewhere for a station config given without one. -fn parse_seed_url(seed: &str) -> Option<(String, u16)> { - if let Some(rest) = seed - .strip_prefix("https://") - .or_else(|| seed.strip_prefix("http://")) - { - let hostport = rest.split('/').next().unwrap_or(rest); - let (host, port_str) = hostport.rsplit_once(':')?; - return Some((host.to_string(), port_str.parse().ok()?)); - } - let (host, port_str) = seed.rsplit_once(':')?; - Some((host.to_string(), port_str.parse().ok()?)) -} - -/// Direct-dial candidate selection with no network: a fake DHT answers with -/// real signed records, and fake dials and requests remember which stations -/// were reached. The cases and names match `macula-go`'s -/// `directdial/candidates_test.go` and `macula-dotnet`'s -/// `DirectDialCandidatesTests`, so every SDK in the family is held to the -/// same behaviour. -#[cfg(test)] -mod tests { - use std::cell::Cell; - use std::collections::{HashMap, HashSet}; - use std::sync::{Arc, Mutex}; - use std::time::Instant; - - use super::*; - use crate::cert_chain::fixtures::{pem_bundle, test_ca, test_leaf}; - - const REALM: [u8; 32] = [0; 32]; - const PROCEDURE: &str = "macula_rust.candidates_test.echo"; - const ORG: &str = "acme-corp"; - /// Leaves time for every candidate. - const ROOMY: Duration = Duration::from_secs(3); - /// The deadline of the timeout-bound cases. - const SHORT: Duration = Duration::from_millis(300); - /// How long the timeout-bound cases may take to return. - const SHORT_BOUND: Duration = Duration::from_secs(1); - const CONTENT: &[u8] = b"the content"; - - // The fakes' request failures are strings, which count as sent. - impl NotSent for String { - fn not_sent(&self) -> bool { - false - } - } - - fn procedure_key() -> [u8; 32] { - dht::procedure_key(&dht::discovery_uri(REALM, PROCEDURE)) - } - - /// A provider: its own advertisement signer (the DHT keeps one record - /// per signer) and the station serving it at `host`. - struct Provider { - host: String, - advertiser: KeyPair, - station: KeyPair, - } - - impl Provider { - fn new(host: &str) -> Self { - Self { - host: host.to_string(), - advertiser: KeyPair::generate(), - station: KeyPair::generate(), - } - } - } - - fn advertisement(p: &Provider) -> Record { - advertisement_expiring_in(p, 120_000) - } - - fn advertisement_expiring_in(p: &Provider, expires_in_ms: i128) -> Record { - let mut rec = dht::new_procedure_advertisement( - p.advertiser.node_id(), - dht::discovery_uri(REALM, PROCEDURE), - p.station.node_id(), - Duration::from_secs(120), - ); - rec.expires_at = now_ms() + expires_in_ms; - dht::sign(rec, &p.advertiser) - } - - fn authorized_advertisement( - p: &Provider, - ca: &rcgen::Issuer<'static, rcgen::KeyPair>, - org: &str, - ) -> Record { - let leaf = test_leaf( - ca, - p.advertiser.node_id(), - org, - time::OffsetDateTime::now_utc() + time::Duration::hours(1), - ); - let rec = dht::new_procedure_advertisement_with_cert_chain( - p.advertiser.node_id(), - dht::discovery_uri(REALM, PROCEDURE), - p.station.node_id(), - Duration::from_secs(120), - pem_bundle(&[leaf]), - ); - dht::sign(rec, &p.advertiser) - } - - /// A `station_endpoint` advertising `host`, signed by `signer` — - /// normally the station itself. Each call is a new record version. - fn station_endpoint(signer: &KeyPair, host: &str) -> Record { - let created_at = now_ms(); - dht::sign( - Record { - record_type: dht::TYPE_STATION_ENDPOINT, - key: signer.node_id(), - version: *uuid::Uuid::now_v7().as_bytes(), - created_at, - expires_at: created_at + 600_000, - payload: Value::Map(vec![ - (Value::text("quic_port"), Value::Int(4433)), - ( - Value::text("host_advertised"), - Value::List(vec![Value::Bytes(host.as_bytes().to_vec())]), - ), - ]), - signature: Vec::new(), - }, - signer, - ) - } - - /// A `station_endpoint` signed by `signer` that advertises no host, so it - /// names no dialable address. - fn malformed_station_endpoint(signer: &KeyPair) -> Record { - let created_at = now_ms(); - dht::sign( - Record { - record_type: dht::TYPE_STATION_ENDPOINT, - key: signer.node_id(), - version: *uuid::Uuid::now_v7().as_bytes(), - created_at, - expires_at: created_at + 600_000, - payload: Value::Map(vec![ - (Value::text("quic_port"), Value::Int(4433)), - (Value::text("host_advertised"), Value::List(vec![])), - ]), - signature: Vec::new(), - }, - signer, - ) - } - - fn announcement(p: &Provider, mcid: Mcid) -> Record { - dht::sign( - dht::new_content_announcement( - p.station.node_id(), - mcid, - format!("https://{}:4433", p.host), - Duration::from_secs(120), - ), - &p.station, - ) - } - - fn new_mcid() -> Mcid { - std::array::from_fn(|_| rand::random()) - } - - /// This process has no session open to any station. - fn nothing_open(_station: &[u8; 32]) -> Option { - None - } - - #[tokio::test] - async fn call_reports_the_last_candidate_failure_when_a_later_pass_finds_none() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)], vec![]]); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - let stations = FakeStations::default(); - stations.refuse(&a.host); - - let result = call(&dht, &stations, Duration::from_secs(1)).await; - - assert!( - matches!(&result, Err(Failure::Dial(e)) if e.contains("refused")), - "{result:?}" - ); - } - - #[tokio::test] - async fn call_reports_the_last_candidate_failure_when_a_later_lookup_fails() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - dht.fail_lookups_after(procedure_key(), 1); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - let stations = FakeStations::default(); - stations.refuse(&a.host); - - let result = call(&dht, &stations, Duration::from_secs(1)).await; - - assert!( - matches!(&result, Err(Failure::Dial(e)) if e.contains("refused")), - "{result:?}" - ); - } - - #[tokio::test] - async fn get_direct_reports_the_last_candidate_failure_when_a_later_lookup_fails() { - let p = Provider::new("p.test"); - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.answer(dht::content_key(mcid), vec![vec![announcement(&p, mcid)]]); - dht.fail_lookups_after(dht::content_key(mcid), 1); - let stations = FakeStations::default(); - stations.refuse(&p.host); - - let result = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |_host: String, _remaining: Duration| ready(Ok::<_, String>(CONTENT.to_vec())), - Duration::from_secs(1), - ) - .await; - - assert!( - matches!(&result, Err(ContentFailure::Dial(e)) if e.contains("refused")), - "{result:?}" - ); - } - - #[tokio::test] - async fn call_retries_after_a_lookup_fails() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - dht.fail_first_lookups(procedure_key(), 1); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - let stations = FakeStations::default(); - - let reply = call(&dht, &stations, ROOMY) - .await - .expect("a answers once a lookup is answered"); - - assert_eq!(reply, "reply from a.test"); - assert_eq!(dht.asked_at(procedure_key()).len(), 2); - } - - #[tokio::test] - async fn get_direct_retries_after_a_lookup_fails() { - let p = Provider::new("p.test"); - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.answer(dht::content_key(mcid), vec![vec![announcement(&p, mcid)]]); - dht.fail_first_lookups(dht::content_key(mcid), 1); - let stations = FakeStations::default(); - - let content = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |_host: String, _remaining: Duration| ready(Ok::<_, String>(CONTENT.to_vec())), - ROOMY, - ) - .await - .expect("p serves the content once a lookup is answered"); - - assert_eq!(content, CONTENT); - assert_eq!(stations.reached(), ["p.test"]); - } - - #[tokio::test] - async fn call_reports_a_failed_lookup_at_its_deadline_when_no_candidate_was_tried() { - let dht = FakeDht::new(); - dht.fail_first_lookups(procedure_key(), usize::MAX); - let started = Instant::now(); - - let result = call(&dht, &FakeStations::default(), SHORT).await; - - assert!( - matches!(result, Err(Failure::Resolve(ResolveError::Dht(_)))), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - assert!( - dht.asked_at(procedure_key()).len() > 1, - "a failed lookup is retried until the deadline" - ); - } - - #[tokio::test] - async fn get_direct_reports_a_failed_lookup_at_its_deadline_when_no_provider_was_tried() { - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.fail_first_lookups(dht::content_key(mcid), usize::MAX); - let stations = FakeStations::default(); - let started = Instant::now(); - - let result = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |_host: String, _remaining: Duration| ready(Ok::<_, String>(CONTENT.to_vec())), - SHORT, - ) - .await; - - assert!(matches!(result, Err(ContentFailure::Dht(_))), "{result:?}"); - assert_returned_within(SHORT_BOUND, started); - assert!( - dht.asked_at(dht::content_key(mcid)).len() > 1, - "a failed lookup is retried until the deadline" - ); - } - - #[tokio::test] - async fn call_reports_not_advertised_when_a_lookup_answered_before_later_ones_failed() { - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![]]); - dht.fail_lookups_after(procedure_key(), 1); - - let result = call(&dht, &FakeStations::default(), SHORT).await; - - assert!( - matches!( - result, - Err(Failure::Resolve(ResolveError::ProcedureNotAdvertised)) - ), - "{result:?}" - ); - } - - #[tokio::test] - async fn call_reports_a_timeout_when_no_lookup_was_answered_in_time() { - let dht = FakeDht::new(); - dht.never_answer(procedure_key()); - let started = Instant::now(); - - let result = call(&dht, &FakeStations::default(), SHORT).await; - - assert!( - matches!(result, Err(Failure::Resolve(ResolveError::Timeout))), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - } - - #[tokio::test] - async fn call_keeps_a_lookup_error_when_a_later_lookup_is_cut_off_by_the_deadline() { - let dht = FakeDht::new(); - dht.fail_first_lookups(procedure_key(), 1); - dht.never_answer(procedure_key()); - - let result = call(&dht, &FakeStations::default(), SHORT).await; - - assert!( - matches!(result, Err(Failure::Resolve(ResolveError::Dht(_)))), - "{result:?}" - ); - } - - #[tokio::test] - async fn get_direct_reports_not_announced_when_a_lookup_answered_before_later_ones_failed() { - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.answer(dht::content_key(mcid), vec![vec![]]); - dht.fail_lookups_after(dht::content_key(mcid), 1); - let stations = FakeStations::default(); - - let result = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |_host: String, _remaining: Duration| ready(Ok::<_, String>(CONTENT.to_vec())), - SHORT, - ) - .await; - - assert!( - matches!(result, Err(ContentFailure::NotAnnounced)), - "{result:?}" - ); - } - - #[tokio::test] - async fn get_direct_reports_a_timeout_when_no_lookup_was_answered_in_time() { - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.never_answer(dht::content_key(mcid)); - let stations = FakeStations::default(); - let started = Instant::now(); - - let result = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |_host: String, _remaining: Duration| ready(Ok::<_, String>(CONTENT.to_vec())), - SHORT, - ) - .await; - - assert!( - matches!(result, Err(ContentFailure::Timeout(None))), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - } - - /// A DHT that answers `find_records` on a key with successive replies, - /// repeating the last, and `find_record` with the `station_endpoint` - /// published under that key (not_found otherwise). Clones share state, - /// so a test can publish while a call is running. - #[derive(Clone)] - struct FakeDht(Arc>); - - struct DhtState { - started: Instant, - replies: HashMap<[u8; 32], Vec>>, - asked: HashMap<[u8; 32], Vec>, - fail_after: HashMap<[u8; 32], usize>, - fail_first: HashMap<[u8; 32], usize>, - never_answered: HashSet<[u8; 32]>, - endpoint_asked: HashMap<[u8; 32], usize>, - endpoints: HashMap<[u8; 32], Record>, - endpoints_in_turn: HashMap<[u8; 32], Vec>, - } - - impl FakeDht { - fn new() -> Self { - Self(Arc::new(Mutex::new(DhtState { - started: Instant::now(), - replies: HashMap::new(), - asked: HashMap::new(), - fail_after: HashMap::new(), - fail_first: HashMap::new(), - never_answered: HashSet::new(), - endpoint_asked: HashMap::new(), - endpoints: HashMap::new(), - endpoints_in_turn: HashMap::new(), - }))) - } - - fn answer(&self, key: [u8; 32], replies: Vec>) { - self.0.lock().unwrap().replies.insert(key, replies); - } - - fn publish_endpoint(&self, station: &KeyPair, endpoint: Record) { - self.0 - .lock() - .unwrap() - .endpoints - .insert(dht::station_endpoint_key(station.node_id()), endpoint); - } - - /// `find_record` for `station`'s `station_endpoint` answers with - /// `endpoints` in turn, repeating the last. - fn publish_endpoints_in_turn(&self, station: &KeyPair, endpoints: Vec) { - self.0 - .lock() - .unwrap() - .endpoints_in_turn - .insert(dht::station_endpoint_key(station.node_id()), endpoints); - } - - /// When `find_records` was asked for `key`, since this DHT was - /// created. - fn asked_at(&self, key: [u8; 32]) -> Vec { - self.0 - .lock() - .unwrap() - .asked - .get(&key) - .cloned() - .unwrap_or_default() - } - - /// After `answered_lookups` lookups, `find_records` on `key` fails, - /// the way a query over a resolver session that has dropped does. - fn fail_lookups_after(&self, key: [u8; 32], answered_lookups: usize) { - self.0 - .lock() - .unwrap() - .fail_after - .insert(key, answered_lookups); - } - - /// The first `failed_lookups` lookups of `key` fail, the way a query - /// the station doesn't answer in time does; later ones are answered. - fn fail_first_lookups(&self, key: [u8; 32], failed_lookups: usize) { - self.0 - .lock() - .unwrap() - .fail_first - .insert(key, failed_lookups); - } - - /// Lookups of `key` never get an answer once any - /// `fail_first_lookups` have failed; only the caller's deadline ends - /// them. - fn never_answer(&self, key: [u8; 32]) { - self.0.lock().unwrap().never_answered.insert(key); - } - - /// Answers one `find_records` lookup of `key` at once, or `None` when - /// `key` is never answered. - fn lookup_now(&self, key: [u8; 32]) -> Option, DhtError>> { - let mut state = self.0.lock().unwrap(); - let elapsed = state.started.elapsed(); - let asked = state.asked.entry(key).or_default(); - asked.push(elapsed); - let turn = asked.len() - 1; - if state - .fail_first - .get(&key) - .is_some_and(|failed| turn < *failed) - { - return Some(Err(DhtError::Remote( - "the station did not answer the lookup".to_string(), - ))); - } - if state.never_answered.contains(&key) { - return None; - } - if state - .fail_after - .get(&key) - .is_some_and(|answered| turn >= *answered) - { - return Some(Err(DhtError::Remote( - "the resolver session is gone".to_string(), - ))); - } - Some(Ok(state - .replies - .get(&key) - .map(|replies| replies[turn.min(replies.len() - 1)].clone()) - .unwrap_or_default())) - } - - /// How many times `find_record` was asked for `station`'s - /// `station_endpoint`. - fn endpoint_lookups_of(&self, station: &KeyPair) -> usize { - self.0 - .lock() - .unwrap() - .endpoint_asked - .get(&dht::station_endpoint_key(station.node_id())) - .copied() - .unwrap_or(0) - } - - /// Answers one `find_record` lookup of `key` at once, or `None` when - /// `key` is never answered. `fail_first_lookups` and `never_answer` - /// apply to `station_endpoint` keys too. - fn endpoint_lookup_now(&self, key: [u8; 32]) -> Option> { - let mut state = self.0.lock().unwrap(); - let asked = state.endpoint_asked.entry(key).or_default(); - *asked += 1; - let turn = *asked - 1; - if state - .fail_first - .get(&key) - .is_some_and(|failed| turn < *failed) - { - return Some(Err(DhtError::Remote( - "the station did not answer the lookup".to_string(), - ))); - } - if state.never_answered.contains(&key) { - return None; - } - if let Some(in_turn) = state.endpoints_in_turn.get(&key) { - return Some(Ok(in_turn[turn.min(in_turn.len() - 1)].clone())); - } - Some(state.endpoints.get(&key).cloned().ok_or(DhtError::NotFound)) - } - } - - impl DhtLookups for FakeDht { - async fn find_records(&mut self, key: [u8; 32]) -> Result, DhtError> { - match self.lookup_now(key) { - Some(answer) => answer, - None => std::future::pending().await, - } - } - - async fn find_record(&mut self, key: [u8; 32]) -> Result { - match self.endpoint_lookup_now(key) { - Some(answer) => answer, - None => std::future::pending().await, - } - } - } - - /// Fake dials: remembers every host it is asked to reach, in order, and - /// refuses the hosts marked as refusing before anything is sent. - #[derive(Clone, Default)] - struct FakeStations(Arc>); - - #[derive(Default)] - struct StationsState { - reached: Vec, - refusing: HashSet, - } - - impl FakeStations { - fn refuse(&self, host: &str) { - self.0.lock().unwrap().refusing.insert(host.to_string()); - } - - fn reached(&self) -> Vec { - self.0.lock().unwrap().reached.clone() - } - - fn dial(&self, station: &Resolved) -> Result { - let mut state = self.0.lock().unwrap(); - state.reached.push(station.host.clone()); - if state.refusing.contains(&station.host) { - Err(format!("{} refused the connection", station.host)) - } else { - Ok(station.host.clone()) - } - } - } - - async fn call( - dht: &FakeDht, - stations: &FakeStations, - timeout: Duration, - ) -> Result> { - reach_procedure( - &mut dht.clone(), - REALM, - PROCEDURE, - None, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| ready(Ok(format!("reply from {host}"))), - timeout, - ) - .await - } - - async fn call_with_cert_chain( - dht: &FakeDht, - stations: &FakeStations, - realm_ca_pem: &[u8], - timeout: Duration, - ) -> Result> { - reach_procedure( - &mut dht.clone(), - REALM, - PROCEDURE, - Some(CertChainCheck { - realm_ca_pem, - expected_org: ORG, - }), - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| ready(Ok(format!("reply from {host}"))), - timeout, - ) - .await - } - - fn two_providers_with_endpoints() -> (FakeDht, Provider, Provider) { - let (a, b) = (Provider::new("a.test"), Provider::new("b.test")); - let dht = FakeDht::new(); - dht.answer( - procedure_key(), - vec![vec![advertisement(&a), advertisement(&b)]], - ); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - dht.publish_endpoint(&b.station, station_endpoint(&b.station, &b.host)); - (dht, a, b) - } - - fn assert_returned_within(bound: Duration, started: Instant) { - let took = started.elapsed(); - assert!( - took < bound, - "expected to return within {bound:?}, took {took:?}" - ); - } - - #[tokio::test] - async fn call_tries_the_next_advertisement_when_a_station_has_no_endpoint() { - let (a, b) = (Provider::new("a.test"), Provider::new("b.test")); - let dht = FakeDht::new(); - dht.answer( - procedure_key(), - vec![vec![advertisement(&a), advertisement(&b)]], - ); - dht.publish_endpoint(&b.station, station_endpoint(&b.station, &b.host)); - let stations = FakeStations::default(); - - let reply = call(&dht, &stations, ROOMY).await.expect("b answers"); - - assert_eq!(reply, "reply from b.test"); - assert_eq!(stations.reached(), ["b.test"]); - } - - #[tokio::test] - async fn call_retries_when_no_advertisement_qualifies() { - let (a, b) = (Provider::new("a.test"), Provider::new("b.test")); - let dht = FakeDht::new(); - dht.answer( - procedure_key(), - vec![ - vec![advertisement_expiring_in(&a, -1_000)], - vec![advertisement(&b)], - ], - ); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - dht.publish_endpoint(&b.station, station_endpoint(&b.station, &b.host)); - let stations = FakeStations::default(); - - let reply = call(&dht, &stations, ROOMY).await.expect("b answers"); - - assert_eq!(reply, "reply from b.test"); - assert_eq!(stations.reached(), ["b.test"]); - } - - #[tokio::test] - async fn call_tries_the_next_station_when_a_dial_fails() { - let (dht, a, _b) = two_providers_with_endpoints(); - let stations = FakeStations::default(); - stations.refuse(&a.host); - - let reply = call(&dht, &stations, ROOMY).await.expect("b answers"); - - assert_eq!(reply, "reply from b.test"); - assert_eq!(stations.reached(), ["a.test", "b.test"]); - } - - /// A guard, green before and after the fix: once the CALL has gone out, - /// its failure is the call's result. - #[tokio::test] - async fn call_never_sends_the_request_twice() { - let (dht, _a, _b) = two_providers_with_endpoints(); - let stations = FakeStations::default(); - - let result = reach_procedure( - &mut dht.clone(), - REALM, - PROCEDURE, - None, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| { - ready(Err::(format!( - "{host} reset the stream after the CALL went out" - ))) - }, - ROOMY, - ) - .await; - - assert!( - matches!(&result, Err(Failure::Request(e)) if e.starts_with("a.test")), - "{result:?}" - ); - assert_eq!(stations.reached(), ["a.test"]); - } - - #[tokio::test] - async fn call_timeout_bounds_resolution() { - let started = Instant::now(); - - let result = call(&FakeDht::new(), &FakeStations::default(), SHORT).await; - - assert!( - matches!( - result, - Err(Failure::Resolve(ResolveError::ProcedureNotAdvertised)) - ), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - } - - #[tokio::test] - async fn call_timeout_bounds_the_endpoint_lookup() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - let stations = FakeStations::default(); - let started = Instant::now(); - - let result = call(&dht, &stations, SHORT).await; - - assert!( - matches!( - result, - Err(Failure::Resolve(ResolveError::StationEndpointNotFound)) - ), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - assert!(stations.reached().is_empty()); - } - - #[tokio::test] - async fn open_stream_direct_tries_the_next_station_when_a_dial_fails() { - let (dht, a, _b) = two_providers_with_endpoints(); - let stations = FakeStations::default(); - stations.refuse(&a.host); - - let stream = reach_procedure( - &mut dht.clone(), - REALM, - PROCEDURE, - None, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| { - ready(Ok::<_, String>(format!("stream at {host}"))) - }, - ROOMY, - ) - .await - .expect("b opens"); - - assert_eq!(stream, "stream at b.test"); - assert_eq!(stations.reached(), ["a.test", "b.test"]); - } - - /// A guard, green before and after the fix: STREAM_OPEN may already be - /// out once the station is dialed, so a failure there is never retried. - #[tokio::test] - async fn open_stream_direct_never_opens_the_stream_twice() { - let (dht, _a, _b) = two_providers_with_endpoints(); - let stations = FakeStations::default(); - - let result = reach_procedure( - &mut dht.clone(), - REALM, - PROCEDURE, - None, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| { - ready(Err::(format!( - "{host} failed after STREAM_OPEN may have gone out" - ))) - }, - ROOMY, - ) - .await; - - assert!( - matches!(&result, Err(Failure::Request(e)) if e.starts_with("a.test")), - "{result:?}" - ); - assert_eq!(stations.reached(), ["a.test"]); - } - - #[tokio::test] - async fn get_direct_retries_when_no_provider_qualifies() { - let p = Provider::new("p.test"); - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.answer( - dht::content_key(mcid), - vec![vec![], vec![announcement(&p, mcid)]], - ); - let stations = FakeStations::default(); - - let content = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |_host: String, _remaining: Duration| ready(Ok::<_, String>(CONTENT.to_vec())), - ROOMY, - ) - .await - .expect("p serves the content"); - - assert_eq!(content, CONTENT); - assert_eq!(stations.reached(), ["p.test"]); - } - - #[tokio::test] - async fn get_direct_tries_the_next_provider_after_a_failed_fetch() { - let (a, b) = (Provider::new("a.test"), Provider::new("b.test")); - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.answer( - dht::content_key(mcid), - vec![vec![announcement(&a, mcid), announcement(&b, mcid)]], - ); - let stations = FakeStations::default(); - - let content = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| { - ready(if host == "a.test" { - Err("fetched content does not hash to its MCID".to_string()) - } else { - Ok(CONTENT.to_vec()) - }) - }, - ROOMY, - ) - .await - .expect("b serves the content"); - - assert_eq!(content, CONTENT); - assert_eq!(stations.reached(), ["a.test", "b.test"]); - } - - #[tokio::test] - async fn get_direct_timeout_bounds_resolution() { - let stations = FakeStations::default(); - let started = Instant::now(); - - let result = fetch_content( - &mut FakeDht::new(), - new_mcid(), - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |_host: String, _remaining: Duration| ready(Ok::<_, String>(CONTENT.to_vec())), - SHORT, - ) - .await; - - assert!( - matches!(result, Err(ContentFailure::NotAnnounced)), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - } - - async fn put( - dht: &FakeDht, - stations: &FakeStations, - station: &KeyPair, - timeout: Duration, - ) -> Result> { - reach_station( - &mut dht.clone(), - station.node_id(), - nothing_open, - |r: Resolved, _remaining: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| { - ready(Ok::<_, String>(format!("stored on {host}"))) - }, - timeout, - ) - .await - } - - #[tokio::test] - async fn put_direct_reports_no_station_endpoint_when_a_lookup_answered_not_found() { - let result = put( - &FakeDht::new(), - &FakeStations::default(), - &KeyPair::generate(), - SHORT, - ) - .await; - - assert!( - matches!( - result, - Err(Failure::Resolve(ResolveError::StationEndpointNotFound)) - ), - "{result:?}" - ); - } - - #[tokio::test] - async fn put_direct_retries_an_endpoint_lookup_that_fails() { - let station = KeyPair::generate(); - let dht = FakeDht::new(); - dht.publish_endpoint(&station, station_endpoint(&station, "s.test")); - dht.fail_first_lookups(dht::station_endpoint_key(station.node_id()), 1); - - let stored = put(&dht, &FakeStations::default(), &station, ROOMY) - .await - .expect("s stores once its endpoint lookup is answered"); - - assert_eq!(stored, "stored on s.test"); - } - - #[tokio::test] - async fn put_direct_reports_a_failed_endpoint_lookup_when_every_lookup_failed() { - let station = KeyPair::generate(); - let dht = FakeDht::new(); - dht.fail_first_lookups(dht::station_endpoint_key(station.node_id()), usize::MAX); - let started = Instant::now(); - - let result = put(&dht, &FakeStations::default(), &station, SHORT).await; - - assert!( - matches!(result, Err(Failure::Resolve(ResolveError::Dht(_)))), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - assert!( - dht.endpoint_lookups_of(&station) > 1, - "a failed endpoint lookup is retried within the budget" - ); - } - - #[tokio::test] - async fn put_direct_reports_a_timeout_when_no_endpoint_lookup_was_answered_in_time() { - let station = KeyPair::generate(); - let dht = FakeDht::new(); - dht.never_answer(dht::station_endpoint_key(station.node_id())); - let started = Instant::now(); - - let result = put(&dht, &FakeStations::default(), &station, SHORT).await; - - assert!( - matches!(result, Err(Failure::Resolve(ResolveError::Timeout))), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - } - - #[tokio::test] - async fn put_direct_keeps_a_lookup_error_when_a_later_endpoint_lookup_is_cut_off_by_the_deadline( - ) { - let station = KeyPair::generate(); - let dht = FakeDht::new(); - dht.fail_first_lookups(dht::station_endpoint_key(station.node_id()), 1); - dht.never_answer(dht::station_endpoint_key(station.node_id())); - - let result = put(&dht, &FakeStations::default(), &station, SHORT).await; - - assert!( - matches!(result, Err(Failure::Resolve(ResolveError::Dht(_)))), - "{result:?}" - ); - } - - #[tokio::test] - async fn put_direct_asks_again_past_a_malformed_endpoint_record() { - let station = KeyPair::generate(); - let dht = FakeDht::new(); - dht.publish_endpoints_in_turn( - &station, - vec![ - malformed_station_endpoint(&station), - station_endpoint(&station, "s.test"), - ], - ); - - let stored = put(&dht, &FakeStations::default(), &station, ROOMY) - .await - .expect("s stores once a lookup finds its good endpoint record"); - - assert_eq!(stored, "stored on s.test"); - } - - #[tokio::test] - async fn put_direct_reports_a_malformed_endpoint_record_at_its_deadline() { - let station = KeyPair::generate(); - let dht = FakeDht::new(); - dht.publish_endpoint(&station, malformed_station_endpoint(&station)); - let started = Instant::now(); - - let result = put(&dht, &FakeStations::default(), &station, SHORT).await; - - assert!( - matches!( - result, - Err(Failure::Resolve(ResolveError::MalformedStationEndpoint)) - ), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - assert!( - dht.endpoint_lookups_of(&station) > 1, - "a malformed endpoint record is asked again within the budget" - ); - } - - #[tokio::test] - async fn call_reports_a_timeout_when_no_endpoint_lookup_was_answered_in_time() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - dht.never_answer(dht::station_endpoint_key(a.station.node_id())); - let stations = FakeStations::default(); - let started = Instant::now(); - - let result = call(&dht, &stations, SHORT).await; - - assert!( - matches!(result, Err(Failure::Resolve(ResolveError::Timeout))), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - assert!(stations.reached().is_empty()); - } - - #[tokio::test] - async fn put_direct_timeout_bounds_the_endpoint_lookup() { - let station = KeyPair::generate(); - let stations = FakeStations::default(); - let started = Instant::now(); - - let result = reach_station( - &mut FakeDht::new(), - station.node_id(), - nothing_open, - |r: Resolved, _remaining: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| ready(Ok::<_, String>(host)), - SHORT, - ) - .await; - - assert!( - matches!( - result, - Err(Failure::Resolve(ResolveError::StationEndpointNotFound)) - ), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - assert!(stations.reached().is_empty()); - } - - #[tokio::test] - async fn open_stream_direct_reuses_an_open_session_to_the_provider_station() { - let a = Provider::new("a.test"); - let a_station = a.station.node_id(); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - let stations = FakeStations::default(); - - let stream = reach_procedure( - &mut dht.clone(), - REALM, - PROCEDURE, - None, - |station: &[u8; 32]| { - (*station == a_station).then(|| "the caller's session to a".to_string()) - }, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |session: String, _remaining: Duration| { - ready(Ok::<_, String>(format!("stream on {session}"))) - }, - Duration::from_secs(1), - ) - .await - .expect("the stream opens on the open session"); - - assert_eq!(stream, "stream on the caller's session to a"); - assert!(stations.reached().is_empty()); - } - - #[tokio::test] - async fn get_direct_reuses_an_open_session_to_the_provider_station() { - let p = Provider::new("p.test"); - let p_station = p.station.node_id(); - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.answer(dht::content_key(mcid), vec![vec![announcement(&p, mcid)]]); - let stations = FakeStations::default(); - stations.refuse(&p.host); - - let content = fetch_content( - &mut dht.clone(), - mcid, - |station: &[u8; 32]| { - (*station == p_station).then(|| "the caller's session to p".to_string()) - }, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |session: String, _remaining: Duration| { - ready(if session == "the caller's session to p" { - Ok(CONTENT.to_vec()) - } else { - Err(format!("{session} does not serve the content")) - }) - }, - Duration::from_secs(1), - ) - .await - .expect("p serves the content on the open session"); - - assert_eq!(content, CONTENT); - assert!(stations.reached().is_empty()); - } - - #[tokio::test] - async fn put_direct_reuses_an_open_session_to_the_station() { - let station = KeyPair::generate().node_id(); - let stations = FakeStations::default(); - - let stored = reach_station( - &mut FakeDht::new(), - station, - |open: &[u8; 32]| { - (*open == station).then(|| "the caller's session to the station".to_string()) - }, - |r: Resolved, _remaining: Duration| ready(stations.dial(&r)), - |session: String, _remaining: Duration| { - ready(Ok::<_, String>(format!("stored on {session}"))) - }, - SHORT, - ) - .await - .expect("stored on the open session"); - - assert_eq!(stored, "stored on the caller's session to the station"); - assert!(stations.reached().is_empty()); - } - - // Sharing a session direct dial dialed between the requests that reuse - // it. The names match the .NET and Go tests. - - #[derive(Clone)] - struct FakeSession { - leases: Option>, - } - - impl Leased for FakeSession { - fn leases(&self) -> Option<&Leases> { - self.leases.as_deref() - } - } - - fn dialed_session() -> FakeSession { - FakeSession { - leases: Some(std::sync::Arc::new(Leases::new())), - } - } - - #[tokio::test] - async fn a_dialed_session_is_closed_after_the_request_even_when_it_fails() { - let target = StationTarget::dialed(dialed_session()); - - let (result, closed) = run_then_release(target, |_session| { - ready(Err::<(), _>("reset after the request went out")) - }) - .await; - - assert!(result.is_err()); - assert!(closed.is_some(), "the dialed session is closed"); - } - - #[tokio::test] - async fn a_reused_session_is_never_closed_by_the_request() { - let owned = FakeSession { leases: None }; - let target = StationTarget::reuse(owned).expect("an owner's open session is reused"); - - let (_, closed) = run_then_release(target, |_session| ready("answered")).await; - - assert!(closed.is_none()); - } - - #[test] - fn a_dialed_session_stays_open_until_its_last_lease_is_released() { - let leases = Leases::new(); - assert!(leases.try_lease()); - - assert!(!leases.release(), "one lease is still out"); - assert!(leases.release(), "the last lease closes the session"); - } - - #[test] - fn a_session_that_is_closing_is_not_reused() { - let session = dialed_session(); - assert!(StationTarget::dialed(session.clone()) - .release_lease() - .is_some()); - - assert!(StationTarget::reuse(session).is_none()); - } - - #[tokio::test] - async fn two_concurrent_transfers_to_one_station_share_the_dialed_session_until_both_finish() { - let session = dialed_session(); - let first = StationTarget::dialed(session.clone()); - let second = StationTarget::reuse(session).expect("the dialed session is reused"); - - let (_, closed) = run_then_release(first, |_session| ready("first stored")).await; - assert!( - closed.is_none(), - "the second transfer still uses the session" - ); - - let (_, closed) = run_then_release(second, |_session| ready("second stored")).await; - assert!(closed.is_some(), "the last transfer closes the session"); - } - - #[tokio::test] - async fn a_dialed_session_shared_by_a_stream_and_a_call_closes_only_when_both_release() { - let session = dialed_session(); - let stream = StationTarget::dialed(session.clone()); - let call = StationTarget::reuse(session).expect("the dialed session is reused"); - - let (_, closed) = run_then_release(call, |_session| ready("answered")).await; - assert!(closed.is_none(), "the stream still uses the session"); - - assert!(stream.release_lease().is_some()); - } - - // A direct call reuses an open session, and after its request failed goes - // on to the next candidate only when its CALL was never sent. The names - // match the .NET and Go tests. - - /// A call on the fakes whose request is `request`. - async fn call_requesting( - dht: &FakeDht, - stations: &FakeStations, - already_open: impl FnMut(&[u8; 32]) -> Option, - request: impl FnMut(String, Duration) -> RF, - ) -> Result> - where - RF: Future>, - { - reach_procedure( - &mut dht.clone(), - REALM, - PROCEDURE, - None, - already_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - request, - ROOMY, - ) - .await - } - - fn answer_from(session: String) -> std::future::Ready> { - ready(Ok(format!("reply from {session}"))) - } - - #[tokio::test] - async fn call_reuses_an_open_session_to_the_provider_station() { - // No endpoint is published for a, so only reuse can reach it. - let a = Provider::new("a.test"); - let a_station = a.station.node_id(); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - let stations = FakeStations::default(); - - let reply = call_requesting( - &dht, - &stations, - |station: &[u8; 32]| { - (*station == a_station).then(|| "the caller's session to a".to_string()) - }, - |session: String, _remaining: Duration| answer_from(session), - ) - .await - .expect("the call is answered on the open session"); - - assert_eq!(reply, "reply from the caller's session to a"); - assert!(stations.reached().is_empty()); - } - - #[tokio::test] - async fn a_direct_call_whose_reused_session_has_ended_dials_the_station_fresh() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - let stations = FakeStations::default(); - // The caller's session to a is open until the call finds it has ended, - // and a session that ends leaves the open set. - let open = Cell::new(true); - - let reply = call_requesting( - &dht, - &stations, - |_station: &[u8; 32]| open.get().then(|| "the caller's session to a".to_string()), - |session: String, _remaining: Duration| { - if session != "the caller's session to a" { - return answer_from(session); - } - open.set(false); - ready(Err(connection::CallError::SessionEnded { - reason: connection::SessionEndReason::StreamFailed( - "the station went away".to_string(), - ), - write_started: false, - })) - }, - ) - .await - .expect("the call is answered on a fresh session"); - - assert_eq!(reply, format!("reply from {}", a.host)); - assert_eq!(stations.reached(), vec![a.host.clone()]); - } - - #[tokio::test] - async fn a_direct_call_that_was_not_sent_is_tried_again_on_the_next_pass() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - let stations = FakeStations::default(); - let attempts = Cell::new(0); - - let reply = call_requesting( - &dht, - &stations, - nothing_open, - |host: String, _remaining: Duration| { - attempts.set(attempts.get() + 1); - if attempts.get() == 1 { - ready(Err(connection::CallError::Timeout { - write_started: false, - })) - } else { - answer_from(host) - } - }, - ) - .await - .expect("the next pass answers"); - - assert_eq!(reply, format!("reply from {}", a.host)); - assert_eq!(stations.reached(), vec![a.host.clone(), a.host.clone()]); - } - - #[tokio::test] - async fn a_direct_call_that_timed_out_waiting_for_the_write_lock_may_try_the_next_candidate() { - let (dht, a, b) = two_providers_with_endpoints(); - let stations = FakeStations::default(); - - let reply = call_requesting( - &dht, - &stations, - nothing_open, - |host: String, _remaining: Duration| { - if host == a.host { - ready(Err(connection::CallError::Timeout { - write_started: false, - })) - } else { - answer_from(host) - } - }, - ) - .await - .expect("b answers"); - - assert_eq!(reply, format!("reply from {}", b.host)); - assert_eq!(stations.reached(), vec![a.host.clone(), b.host.clone()]); - } - - #[tokio::test] - async fn a_direct_call_that_timed_out_after_its_write_started_is_not_tried_on_another_candidate( - ) { - let (dht, a, _b) = two_providers_with_endpoints(); - let stations = FakeStations::default(); - - let result = call_requesting( - &dht, - &stations, - nothing_open, - |host: String, _remaining: Duration| { - assert_eq!(host, a.host, "the CALL was sent a second time"); - ready(Err(connection::CallError::Timeout { - write_started: true, - })) - }, - ) - .await; - - assert!( - matches!( - result, - Err(Failure::Request(connection::CallError::Timeout { - write_started: true - })) - ), - "{result:?}" - ); - assert_eq!(stations.reached(), vec![a.host.clone()]); - } - - #[tokio::test] - async fn call_with_cert_chain_tries_the_next_advertisement_when_a_station_has_no_endpoint() { - let (ca_pem, ca) = test_ca(); - let (a, b) = (Provider::new("a.test"), Provider::new("b.test")); - let dht = FakeDht::new(); - dht.answer( - procedure_key(), - vec![vec![ - authorized_advertisement(&a, &ca, ORG), - authorized_advertisement(&b, &ca, ORG), - ]], - ); - dht.publish_endpoint(&b.station, station_endpoint(&b.station, &b.host)); - let stations = FakeStations::default(); - - let reply = call_with_cert_chain(&dht, &stations, &ca_pem, ROOMY) - .await - .expect("b answers"); - - assert_eq!(reply, "reply from b.test"); - assert_eq!(stations.reached(), ["b.test"]); - } - - #[tokio::test] - async fn call_with_cert_chain_reports_the_authorization_failure_at_its_deadline() { - let (ca_pem, ca) = test_ca(); - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer( - procedure_key(), - vec![vec![authorized_advertisement(&a, &ca, "other-org")]], - ); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - let stations = FakeStations::default(); - let started = Instant::now(); - - let result = call_with_cert_chain(&dht, &stations, &ca_pem, SHORT).await; - - assert!( - matches!( - result, - Err(Failure::Resolve(ResolveError::NoAuthorizedAdvertisement(_))) - ), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - assert!(stations.reached().is_empty()); - } - - #[tokio::test] - async fn call_skips_a_station_whose_endpoint_is_signed_by_another_key() { - let (a, b) = (Provider::new("a.test"), Provider::new("b.test")); - let dht = FakeDht::new(); - dht.answer( - procedure_key(), - vec![vec![advertisement(&a), advertisement(&b)]], - ); - dht.publish_endpoint(&a.station, station_endpoint(&KeyPair::generate(), &a.host)); - dht.publish_endpoint(&b.station, station_endpoint(&b.station, &b.host)); - let stations = FakeStations::default(); - - let reply = call(&dht, &stations, ROOMY).await.expect("b answers"); - - assert_eq!(reply, "reply from b.test"); - assert_eq!(stations.reached(), ["b.test"]); - } - - #[tokio::test] - async fn call_dials_a_refusing_station_once_per_endpoint_version() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - let stations = FakeStations::default(); - stations.refuse(&a.host); - - let (result, ()) = tokio::join!(call(&dht, &stations, ROOMY), async { - tokio::time::sleep(Duration::from_secs(1)).await; - assert_eq!(stations.reached(), ["a.test"]); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - }); - - assert!( - matches!(&result, Err(Failure::Dial(e)) if e.contains("refused")), - "{result:?}" - ); - assert_eq!(stations.reached(), ["a.test", "a.test"]); - } - - #[tokio::test] - async fn call_tries_an_advertisement_that_appears_on_a_later_pass() { - let (a, b) = (Provider::new("a.test"), Provider::new("b.test")); - let ad_a = advertisement(&a); - let dht = FakeDht::new(); - dht.answer( - procedure_key(), - vec![vec![ad_a.clone()], vec![ad_a, advertisement(&b)]], - ); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - dht.publish_endpoint(&b.station, station_endpoint(&b.station, &b.host)); - let stations = FakeStations::default(); - stations.refuse(&a.host); - - let reply = call(&dht, &stations, ROOMY).await.expect("b answers"); - - assert_eq!(reply, "reply from b.test"); - assert_eq!(stations.reached(), ["a.test", "b.test"]); - } - - #[tokio::test] - async fn resolution_backs_off_between_passes() { - let dht = FakeDht::new(); - - let result = call(&dht, &FakeStations::default(), ROOMY).await; - - assert!( - matches!( - result, - Err(Failure::Resolve(ResolveError::ProcedureNotAdvertised)) - ), - "{result:?}" - ); - let asked = dht.asked_at(procedure_key()); - assert!( - (5..=8).contains(&asked.len()), - "expected 5 to 8 lookups, got {} at {asked:?}", - asked.len() - ); - } - - #[tokio::test] - async fn get_direct_fetches_from_a_failing_provider_once_per_announcement() { - let p = Provider::new("p.test"); - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.answer(dht::content_key(mcid), vec![vec![announcement(&p, mcid)]]); - let stations = FakeStations::default(); - let fetches = Cell::new(0); - - let result = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |_host: String, _remaining: Duration| { - fetches.set(fetches.get() + 1); - ready(Err::, _>( - "fetched content does not hash to its MCID".to_string(), - )) - }, - Duration::from_secs(2), - ) - .await; - - assert!( - matches!(result, Err(ContentFailure::Fetch(_))), - "{result:?}" - ); - assert_eq!(fetches.get(), 1); - } - - #[tokio::test] - async fn call_picks_up_an_endpoint_record_that_changes_mid_deadline() { - let a = Provider::new("a.test"); - let dht = FakeDht::new(); - dht.answer(procedure_key(), vec![vec![advertisement(&a)]]); - dht.publish_endpoint(&a.station, station_endpoint(&a.station, "a-old.test")); - let stations = FakeStations::default(); - stations.refuse("a-old.test"); - - let (result, ()) = tokio::join!(call(&dht, &stations, ROOMY), async { - tokio::time::sleep(Duration::from_millis(500)).await; - dht.publish_endpoint(&a.station, station_endpoint(&a.station, &a.host)); - }); - - assert_eq!( - result.expect("a answers at its new endpoint"), - "reply from a.test" - ); - assert_eq!(stations.reached(), ["a-old.test", "a.test"]); - } - - #[tokio::test] - async fn resolve_timeout_bounds_resolution() { - let started = Instant::now(); - - let result = resolve_within(&mut FakeDht::new(), REALM, PROCEDURE, None, SHORT).await; - - assert!( - matches!(result, Err(ResolveError::ProcedureNotAdvertised)), - "{result:?}" - ); - assert_returned_within(SHORT_BOUND, started); - } - - #[tokio::test] - async fn resolve_station_endpoint_timeout_bounds_the_lookup() { - let started = Instant::now(); - - let lookup = lookup_station_endpoint( - &mut FakeDht::new(), - KeyPair::generate().node_id(), - CallDeadline::after(SHORT), - true, - ) - .await; - - assert!( - matches!(lookup.outcome, Err(ResolveError::StationEndpointNotFound)), - "{:?}", - lookup.outcome - ); - assert_returned_within(SHORT_BOUND, started); - } - - #[test] - fn a_candidate_share_splits_what_remains_evenly_with_a_one_second_floor() { - let slack = Duration::from_millis(100); - let about = |share: CallDeadline, expected: Duration| { - let remaining = share.remaining(); - assert!( - remaining <= expected && remaining + slack >= expected, - "expected about {expected:?}, got {remaining:?}" - ); - }; - let roomy = CallDeadline::after(Duration::from_secs(3)); - let tight = CallDeadline::after(Duration::from_millis(300)); - - about(roomy.share_for(2), Duration::from_millis(1500)); - about(roomy.share_for(10), Duration::from_secs(1)); - about(tight.share_for(3), Duration::from_millis(300)); - } - - #[tokio::test] - async fn get_direct_timeout_during_a_transfer_carries_the_last_failure() { - let (a, b) = (Provider::new("a.test"), Provider::new("b.test")); - let mcid = new_mcid(); - let dht = FakeDht::new(); - dht.answer( - dht::content_key(mcid), - vec![vec![announcement(&a, mcid), announcement(&b, mcid)]], - ); - let stations = FakeStations::default(); - - let result = fetch_content( - &mut dht.clone(), - mcid, - nothing_open, - |r: Resolved, _share: Duration| ready(stations.dial(&r)), - |host: String, _remaining: Duration| async move { - if host == "a.test" { - return Err("fetched content does not hash to its MCID".to_string()); - } - std::future::pending::<()>().await; - Ok(CONTENT.to_vec()) - }, - Duration::from_secs(1), - ) - .await; - - assert!( - matches!( - &result, - Err(ContentFailure::Timeout(Some(last))) - if matches!(last.as_ref(), ContentFailure::Fetch(e) if e.contains("MCID")) - ), - "{result:?}" - ); - } - - /// The public direct-dial futures must stay `Send`, so a caller can - /// spawn them and the FFI crate, which requires it, keeps building. The - /// check happens at compile time: the closure below is type-checked but - /// never run, so it needs no live session. - #[test] - fn public_direct_dial_futures_are_send() { - fn assert_send(_: &T) {} - let _type_check_only = |session: &Session, id: &KeyPair, mode: StreamMode| { - assert_send(&super::resolve(session, id, REALM, PROCEDURE)); - assert_send(&super::resolve_with_cert_chain( - session, id, REALM, PROCEDURE, b"", ORG, - )); - assert_send(&super::call( - session, - id, - REALM, - PROCEDURE, - Value::Null, - ROOMY, - )); - assert_send(&super::call_with_ucan( - session, - id, - REALM, - PROCEDURE, - Value::Null, - ROOMY, - Vec::new(), - )); - assert_send(&super::call_with_cert_chain( - session, - id, - REALM, - PROCEDURE, - b"", - ORG, - Value::Null, - ROOMY, - )); - assert_send(&super::open_stream_direct( - session, - id, - REALM, - PROCEDURE, - mode, - Value::Null, - 0, - ROOMY, - )); - assert_send(&super::open_stream_direct_with_cert_chain( - session, - id, - REALM, - PROCEDURE, - b"", - ORG, - mode, - Value::Null, - 0, - ROOMY, - )); - assert_send(&super::put_direct(session, id, [0; 32], b"", "name", ROOMY)); - assert_send(&super::get_direct(session, id, [0; 34], ROOMY)); - assert_send(&super::advertise_direct( - session, id, REALM, PROCEDURE, ROOMY, - )); - assert_send(&super::advertise_direct_with_cert_chain( - session, - id, - REALM, - PROCEDURE, - ROOMY, - Vec::new(), - )); - }; - } -} diff --git a/src/frame.rs b/src/frame.rs index b9f775c..8ba2262 100644 --- a/src/frame.rs +++ b/src/frame.rs @@ -1,2806 +1,399 @@ -//! The macula application-frame envelope: construction, Ed25519 -//! signing/verification, and the length-prefixed wire codec. Ported from -//! `src/peering/macula_frame.erl` (`macula-io/macula`). +//! macula 12's frames, as macula_frame and macula-go build and read them: the +//! requests, replies, relay errors, publications and stream frames that carry +//! signed objects, the control frames a pq_hybrid link neighbour-signs, the +//! decoding rule's payload bounds, and the length-prefixed wire codec. //! -//! A wire frame is `<>` where `Cbor` is the -//! deterministic encoding of a single map (see [`crate::cbor`]). Every -//! frame carries a common envelope — `version`, `frame_type`, `frame_id` -//! (UUIDv7), `sent_at_ms`, `capabilities`, plus `realm`/`call_id`/ -//! `source_route` set to `null` unless the specific frame type populates -//! them — and every frame is Ed25519-signed over its own canonical bytes -//! with `signature`/`publisher_sig` stripped first. -//! -//! This module's correctness is checked against a real reference frame: -//! `tests::connect_frame_matches_the_reference_byte_for_byte` builds -//! the exact same CONNECT frame `macula_frame:connect/1` + -//! `macula_frame:sign/2` produced in a live `rebar3 shell` — same -//! identity, same fixed `frame_id`/`sent_at_ms` (injected explicitly, -//! since the reference randomizes both per call and non-determinism -//! would make an exact byte comparison meaningless) — and asserts the -//! encoded bytes, **including the Ed25519 signature itself**, match -//! exactly. That's the strongest test available short of dialing a real -//! station: it proves the canonical-CBOR encoding, the field set, and -//! the signing domain are all bit-for-bit compatible at once. - -use std::time::{SystemTime, UNIX_EPOCH}; +//! A wire frame is ``, the deterministic +//! encoding of one map with `version` and `frame_type`. No frame carries a +//! frame-level signature: what is signed is the signed object a frame holds, +//! and, in pq_hybrid, a control frame's neighbour signature. + +mod check_payload; +mod neighbour; +mod publication; +mod reply; +mod request; +mod stream; + +pub use check_payload::{ + check_frame, check_payload, FRAME_RESERVED_ELEMENTS, MAX_PAYLOAD_ELEMENTS, MAX_PAYLOAD_NESTING, +}; +pub use neighbour::{ + advertise_frame, goodbye_frame, neighbour_signed, sign_neighbour, subscribe_frame, + unadvertise_frame, unsubscribe_frame, verify_neighbour, NeighbourLink, NeighbourPeer, +}; +pub use publication::{sign_publish, verify_publication, PublicationSpec, VerifiedPublication}; +pub use reply::{ + claimed_reply_ids, sign_provider_error, sign_relay_error, sign_result, verify_relay_error, + verify_reply, RelayErrorSpec, RelayErrorType, ReplyType, VerifiedRelayError, VerifiedReply, +}; +pub use request::{ + request_fields_accepted, sign_call, sign_stream_open, verify_request, RequestSpec, RequestType, + VerifiedRequest, MAX_PROOFS, MAX_PROOFS_BYTES, +}; +pub use stream::{ + open_stream, sign_caller_stream, sign_provider_stream, verify_caller_stream, + verify_provider_stream, StreamEncoding, StreamFields, StreamMode, StreamRole, StreamState, + VerifiedStreamFrame, +}; + +use std::fmt; use crate::cbor::{self, Value}; -use crate::identity::KeyPair; +use crate::node_key::{KeyError, NodeKey, Purpose}; +use crate::signed_object::ObjectError; -/// Domain separator for the per-frame Ed25519 signature (every frame's -/// own `signature` field). Distinct from the SWIM-update and -/// publisher-end-to-end domains documented in -/// `plans/PLAN_WIRE_PROTOCOL.md` §4 — neither of those is implemented -/// here yet. -pub const SIG_DOMAIN: &[u8] = b"macula-v2-frame\0"; +/// The version field every frame carries. +pub const PROTOCOL_VERSION: i64 = 2; -pub const PROTOCOL_VERSION: i128 = 2; - -/// 16 MiB minus one byte — matches `?MAX_FRAME_BYTES` (`16#FFFFFF`) -/// exactly. +/// The CBOR payload size cap: 16 MiB minus one byte, as macula's. pub const MAX_FRAME_BYTES: usize = 0x00FF_FFFF; -fn current_millis() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock is after the Unix epoch") - .as_millis() as u64 -} - -fn fresh_frame_id() -> [u8; 16] { - *uuid::Uuid::now_v7().as_bytes() -} - -/// The common envelope every frame carries, matching `base/2`. Field -/// order doesn't matter — canonical CBOR re-sorts by encoded key bytes -/// at encode time regardless (see `crate::cbor`). -fn base( - frame_type: &str, - capabilities: u64, - frame_id: [u8; 16], - sent_at_ms: u64, -) -> Vec<(Value, Value)> { - vec![ - (Value::text("version"), Value::Int(PROTOCOL_VERSION)), - (Value::text("frame_type"), Value::text(frame_type)), - (Value::text("frame_id"), Value::Bytes(frame_id.to_vec())), - (Value::text("sent_at_ms"), Value::Int(sent_at_ms as i128)), - ( - Value::text("capabilities"), - Value::Int(capabilities as i128), - ), - (Value::text("realm"), Value::Null), - (Value::text("call_id"), Value::Null), - (Value::text("source_route"), Value::Null), - ] -} - -fn bytes32_list(items: &[[u8; 32]]) -> Value { - Value::List(items.iter().map(|b| Value::Bytes(b.to_vec())).collect()) -} - -// --------------------------------------------------------------------- -// CONNECT -// --------------------------------------------------------------------- - -/// Fields for a CONNECT frame — see `plans/PLAN_WIRE_PROTOCOL.md` §5. -#[derive(Debug, Clone)] -pub struct ConnectSpec { - pub node_id: [u8; 32], - pub station_id: [u8; 32], - pub realms: Vec<[u8; 32]>, - pub capabilities: u64, - pub puzzle_evidence: [u8; 32], - pub addresses: Vec, - pub site: Option, - pub endorsements: Vec, -} - -impl ConnectSpec { - /// A CONNECT with no realm memberships claimed and no advertised - /// addresses — the shape a dial-out-only leaf client uses (see the - /// spec's §11 discussion of why edge clients never need reachable - /// addresses of their own). - pub fn new(node_id: [u8; 32], puzzle_evidence: [u8; 32]) -> Self { - Self { - node_id, - // `send_connect/2`'s own convention: a plain peer/daemon - // dial sets station_id equal to node_id. - station_id: node_id, - realms: Vec::new(), - capabilities: 0, - puzzle_evidence, - addresses: Vec::new(), - site: None, - endorsements: Vec::new(), - } - } -} - -fn connect_value(spec: &ConnectSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - let mut fields = base("connect", spec.capabilities, frame_id, sent_at_ms); - fields.push((Value::text("node_id"), Value::Bytes(spec.node_id.to_vec()))); - fields.push(( - Value::text("station_id"), - Value::Bytes(spec.station_id.to_vec()), - )); - fields.push((Value::text("realms"), bytes32_list(&spec.realms))); - fields.push(( - Value::text("addresses"), - Value::List(spec.addresses.clone()), - )); - fields.push(( - Value::text("site"), - spec.site.clone().unwrap_or(Value::Null), - )); - fields.push(( - Value::text("puzzle_evidence"), - Value::Bytes(spec.puzzle_evidence.to_vec()), - )); - fields.push(( - Value::text("endorsements"), - Value::List(spec.endorsements.clone()), - )); - Value::Map(fields) -} - -/// Build a CONNECT frame with a fresh `frame_id`/`sent_at_ms`. Unsigned — -/// pass the result to [`sign`] before sending. -pub fn connect(spec: &ConnectSpec) -> Value { - connect_value(spec, fresh_frame_id(), current_millis()) -} - -// --------------------------------------------------------------------- -// GOODBYE -// --------------------------------------------------------------------- - -fn goodbye_value(reason: &str, detail: Option<&str>, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - let mut fields = base("goodbye", 0, frame_id, sent_at_ms); - // `reason` is an Erlang atom() -> text (major 3). `detail` is - // `binary() | undefined` -> a raw byte string (major 2), NOT text — - // caught by the CALL/PUBLISH/etc. differential vectors failing on - // this exact mistake for their own binary()-typed fields (procedure, - // topic). Fixed here too even though no direct GOODBYE vector was - // captured, since it's the identical type. - fields.push((Value::text("reason"), Value::text(reason))); - fields.push(( - Value::text("detail"), - detail - .map(|d| Value::Bytes(d.as_bytes().to_vec())) - .unwrap_or(Value::Null), - )); - Value::Map(fields) -} - -/// Build a GOODBYE frame. `reason` is a short machine-readable code -/// (e.g. `"normal"`); `detail` is an optional human-readable string. -pub fn goodbye(reason: &str, detail: Option<&str>) -> Value { - goodbye_value(reason, detail, fresh_frame_id(), current_millis()) -} - -// --------------------------------------------------------------------- -// CALL / RESULT / ERROR -// -// ⚠ Overriding a base-envelope sentinel field (`realm`, `call_id`, -// `source_route` — all `Null` by default from `base()`) MUST use -// `Value::with_field`, never a raw push onto the field vec. `Value::Map` -// is a plain `Vec<(Value, Value)>`, not a real map — it has none of -// Erlang's automatic key-uniqueness, so appending a second `call_id` -// entry on top of `base()`'s `call_id => Null` would silently produce a -// wire-invalid map with two `call_id` keys instead of overriding it. -// Caught during differential-vector generation against the real -// reference (a hand-built CONNECT test frame subtly differed from -// `macula_frame:call/1`'s own output the same way, before this was -// fixed) — see this crate's own commit history, not hypothetical. -// --------------------------------------------------------------------- - -/// Fields for a CALL frame — see `plans/PLAN_WIRE_PROTOCOL.md` §6.4. -#[derive(Debug, Clone)] -pub struct CallSpec { - pub call_id: [u8; 16], - pub procedure: String, - pub realm: [u8; 32], - pub payload: Value, - pub deadline_ms: i128, - pub caller: [u8; 32], - /// Opaque source-route header bytes (`plans/PLAN_WIRE_PROTOCOL.md` - /// §8) — empty for a direct call to one known station, which is the - /// only shape this crate builds so far. - pub source_route: Vec, - pub retry_budget: u64, - pub ucan_token: Vec, -} - -impl CallSpec { - pub fn new( - call_id: [u8; 16], - procedure: impl Into, - realm: [u8; 32], - payload: Value, - deadline_ms: i128, - caller: [u8; 32], - ) -> Self { - Self { - call_id, - procedure: procedure.into(), - realm, - payload, - deadline_ms, - caller, - source_route: Vec::new(), - retry_budget: 0, - ucan_token: Vec::new(), - } - } -} - -fn call_value(spec: &CallSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - Value::Map(base("call", 0, frame_id, sent_at_ms)) - .with_field("realm", Value::Bytes(spec.realm.to_vec())) - .with_field("call_id", Value::Bytes(spec.call_id.to_vec())) - // `procedure := binary()` in the Erlang spec — a raw byte - // string (major 2), not text (major 3). Confirmed the hard way: - // this was `Value::text(...)` originally and the differential - // vector test caught the resulting signature mismatch. - .with_field( - "procedure", - Value::Bytes(spec.procedure.as_bytes().to_vec()), - ) - .with_field("payload", spec.payload.clone()) - .with_field("deadline_ms", Value::Int(spec.deadline_ms)) - .with_field("caller", Value::Bytes(spec.caller.to_vec())) - .with_field("source_route", Value::Bytes(spec.source_route.clone())) - .with_field("retry_budget", Value::Int(spec.retry_budget as i128)) - .with_field("ucan_token", Value::Bytes(spec.ucan_token.clone())) -} - -/// Build a CALL frame with a fresh `frame_id`/`sent_at_ms`. Unsigned — -/// pass the result to [`sign`] before sending. -pub fn call(spec: &CallSpec) -> Value { - call_value(spec, fresh_frame_id(), current_millis()) -} - -/// Fields for a RESULT frame. -#[derive(Debug, Clone)] -pub struct ResultSpec { - pub call_id: [u8; 16], - pub payload: Value, - pub responded_by: [u8; 32], - pub source_route_reverse: Vec, -} - -impl ResultSpec { - pub fn new(call_id: [u8; 16], payload: Value, responded_by: [u8; 32]) -> Self { - Self { - call_id, - payload, - responded_by, - source_route_reverse: Vec::new(), - } - } -} - -fn result_value(spec: &ResultSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - // NOTE: RESULT does not touch the base envelope's `realm` or - // `source_route` fields at all — they stay `Null`, matching the - // reference exactly (confirmed by inspecting `macula_frame:result/1`'s - // own output directly, not assumed from the CALL pattern above). - // `source_route_reverse` is a distinct field, not a rename. - Value::Map(base("result", 0, frame_id, sent_at_ms)) - .with_field("call_id", Value::Bytes(spec.call_id.to_vec())) - .with_field("payload", spec.payload.clone()) - .with_field("responded_by", Value::Bytes(spec.responded_by.to_vec())) - .with_field( - "source_route_reverse", - Value::Bytes(spec.source_route_reverse.clone()), - ) -} - -/// Build a RESULT frame with a fresh `frame_id`/`sent_at_ms`. -pub fn result(spec: &ResultSpec) -> Value { - result_value(spec, fresh_frame_id(), current_millis()) -} - -/// Fields for an ERROR frame. `name` is derived from `code` automatically -/// (matching `macula_frame:call_error/1`'s own `macula_bolt4:name/1` -/// lookup), not a caller-supplied field. -#[derive(Debug, Clone)] -pub struct CallErrorSpec { - pub call_id: [u8; 16], - pub code: crate::bolt4::Code, - pub reported_by: [u8; 32], - pub detail: Option, - pub offending_hop: Option<[u8; 32]>, - pub source_route_partial: Vec, -} - -impl CallErrorSpec { - pub fn new(call_id: [u8; 16], code: crate::bolt4::Code, reported_by: [u8; 32]) -> Self { - Self { - call_id, - code, - reported_by, - detail: None, - offending_hop: None, - source_route_partial: Vec::new(), - } - } -} - -fn call_error_value(spec: &CallErrorSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - Value::Map(base("error", 0, frame_id, sent_at_ms)) - .with_field("call_id", Value::Bytes(spec.call_id.to_vec())) - .with_field("code", Value::Int(spec.code.as_u8() as i128)) - .with_field("name", Value::text(spec.code.name())) - .with_field("reported_by", Value::Bytes(spec.reported_by.to_vec())) - .with_field( - // `detail => binary() | undefined` — bytes, not text. Same - // fix as CALL's `procedure` and GOODBYE's `detail`. - "detail", - spec.detail - .as_ref() - .map(|d| Value::Bytes(d.as_bytes().to_vec())) - .unwrap_or(Value::Null), - ) - .with_field( - "offending_hop", - spec.offending_hop - .map(|h| Value::Bytes(h.to_vec())) - .unwrap_or(Value::Null), - ) - .with_field( - "source_route_partial", - Value::Bytes(spec.source_route_partial.clone()), - ) -} - -/// Build an ERROR frame with a fresh `frame_id`/`sent_at_ms`. -pub fn call_error(spec: &CallErrorSpec) -> Value { - call_error_value(spec, fresh_frame_id(), current_millis()) -} - -/// The fields a provider needs from an *inbound* CALL — the -/// counterpart to [`CallResponse`] for the receiving side. Still doesn't -/// carry `source_route`/`retry_budget`: nothing in the provider role -/// built so far acts on either. `ucan_token` IS carried — added for -/// [`crate::connection::Session::serve_one_call_gated`]'s policy check, -/// which runs before a handler ever sees the call. -#[derive(Debug, Clone)] -pub struct CallInfo { - pub call_id: [u8; 16], - pub procedure: String, - pub realm: [u8; 32], - pub payload: Value, - pub deadline_ms: i128, - pub caller: [u8; 32], - /// Empty if the caller attached none — matches [`CallSpec::new`]'s - /// own default and `macula_station_link.erl`'s "absent token" case. - pub ucan_token: Vec, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ParseCallError { - NotACallFrame, - MissingField(&'static str), - WrongFieldType(&'static str), -} - -impl std::fmt::Display for ParseCallError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ParseCallError::NotACallFrame => write!(f, "frame_type is not \"call\""), - ParseCallError::MissingField(name) => write!(f, "missing required field {name:?}"), - ParseCallError::WrongFieldType(name) => write!(f, "field {name:?} has the wrong type"), - } - } -} - -impl std::error::Error for ParseCallError {} - -/// Parse a decoded frame as a CALL — the provider-side counterpart to -/// [`parse_call_response`]. -pub fn parse_call(frame: &Value) -> Result { - match frame.get("frame_type") { - Some(Value::Text(t)) if t == "call" => {} - _ => return Err(ParseCallError::NotACallFrame), - } - let call_id = match frame.get("call_id") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseCallError::WrongFieldType("call_id"))?, - Some(_) => return Err(ParseCallError::WrongFieldType("call_id")), - None => return Err(ParseCallError::MissingField("call_id")), - }; - // `procedure := binary()` on the wire -- bytes, not text. - let procedure = match frame.get("procedure") { - Some(Value::Bytes(b)) => { - String::from_utf8(b.clone()).map_err(|_| ParseCallError::WrongFieldType("procedure"))? - } - Some(_) => return Err(ParseCallError::WrongFieldType("procedure")), - None => return Err(ParseCallError::MissingField("procedure")), - }; - let realm = match frame.get("realm") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseCallError::WrongFieldType("realm"))?, - Some(_) => return Err(ParseCallError::WrongFieldType("realm")), - None => return Err(ParseCallError::MissingField("realm")), - }; - let payload = frame - .get("payload") - .cloned() - .ok_or(ParseCallError::MissingField("payload"))?; - let deadline_ms = match frame.get("deadline_ms") { - Some(Value::Int(n)) => *n, - Some(_) => return Err(ParseCallError::WrongFieldType("deadline_ms")), - None => return Err(ParseCallError::MissingField("deadline_ms")), - }; - let caller = match frame.get("caller") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseCallError::WrongFieldType("caller"))?, - Some(_) => return Err(ParseCallError::WrongFieldType("caller")), - None => return Err(ParseCallError::MissingField("caller")), - }; - let ucan_token = match frame.get("ucan_token") { - Some(Value::Bytes(b)) => b.clone(), - _ => Vec::new(), - }; - Ok(CallInfo { - call_id, - procedure, - realm, - payload, - deadline_ms, - caller, - ucan_token, - }) -} - -/// Parsed fields of a RESULT or ERROR response to a CALL, correlated by -/// `call_id`. Returned by [`crate::connection::Session::call`]. -#[derive(Debug, Clone)] -pub enum CallResponse { - Result { - payload: Value, - responded_by: [u8; 32], - }, - Error { - code: u8, - name: String, - reported_by: [u8; 32], - detail: Option, - }, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ParseCallResponseError { - NotAResultOrError, - MissingField(&'static str), - WrongFieldType(&'static str), +/// The labels of the signed objects frames carry (D25, D17). +const REQUEST_LABEL: &str = "MACULA-PQ-REQUEST-V1"; +const REPLY_LABEL: &str = "MACULA-PQ-REPLY-V1"; +const RELAY_ERROR_LABEL: &str = "MACULA-PQ-RELAY-ERROR-V1"; +const STREAM_LABEL: &str = "MACULA-PQ-STREAM-V1"; +const CALLER_STREAM_LABEL: &str = "MACULA-PQ-CALLER-STREAM-V1"; +const PUBLICATION_LABEL: &str = "MACULA-PQ-PUBLICATION-V1"; + +/// A protocol integer stays below 2^53; a procedure name is at most 512 +/// bytes, an error code at most 64, and an error's text at most 256. +const MAX_PROTOCOL_INT: u64 = 1 << 53; +const MAX_PROCEDURE_BYTES: usize = 512; +const MAX_ERROR_CODE_BYTES: usize = 64; +const MAX_ERROR_TEXT_BYTES: usize = 256; +const MAX_TOPIC_BYTES: usize = 512; + +/// The refusals of a frame, named as macula_frame names them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrameError { + /// An encoding longer than [`MAX_FRAME_BYTES`], or a header claiming one. + TooLarge(usize), + /// A frame, or the signed object it carries, without exactly the shape and + /// fields of its type. + Malformed, + /// A payload the decoding rule would refuse where it arrives, and where. + Payload(String), + /// A whole frame the decoding rule would refuse, and where. + BreaksDecodingRule(String), + /// A request's delegation chain proofs outside their bound. + ProofsOutOfBound, + /// A signed object whose signer is not the key it verified with. + KeyIdMismatch, + /// A reply, relay error or stream frame naming another request. + RequestMismatch, + /// A reply, or a provider's first stream frame, from a node other than its + /// request's target. + NotTheTarget, + /// A relay error from another station than the connection's. + NotTheConnection, + /// A key that cannot sign this frame. + Unsignable, + /// A text longer than its bound, naming the field. + TextTooLong(String), + /// A text that is not valid UTF-8, naming the field. + InvalidText(String), + /// A relay error code outside its closed set. + RelayCodeOutsideItsSet, + /// A field outside its range, and which. + OutOfRange(String), + /// A stream frame its side does not send, and which. + NotAllowed(String), + /// A stream frame out of its side's order. + SeqMismatch, + /// A stream frame after its side's STREAM_END. + StreamEnded, + /// A frame given to sign that already carries a neighbour signature. + NeighbourSigned, + /// A publication published too far ahead, by how many milliseconds. + NotYetValid(i64), + /// A publication past its expiry, by how many milliseconds. + Expired(i64), + /// A signed object's signature that does not verify. + SignatureInvalid, + /// A key that could not sign. + Key(KeyError), } -impl std::fmt::Display for ParseCallResponseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for FrameError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - ParseCallResponseError::NotAResultOrError => { - write!(f, "frame_type is neither \"result\" nor \"error\"") + FrameError::TooLarge(n) => write!( + f, + "a frame of {n} bytes, over the {MAX_FRAME_BYTES}-byte cap" + ), + FrameError::Malformed => f.write_str("malformed frame"), + FrameError::Payload(why) | FrameError::BreaksDecodingRule(why) => f.write_str(why), + FrameError::ProofsOutOfBound => { + f.write_str("the request's proofs are outside the bound") } - ParseCallResponseError::MissingField(name) => { - write!(f, "missing required field {name:?}") + FrameError::KeyIdMismatch => { + f.write_str("the signer the frame names is not the key it verified with") } - ParseCallResponseError::WrongFieldType(name) => { - write!(f, "field {name:?} has the wrong type") + FrameError::RequestMismatch => f.write_str("the frame names another request"), + FrameError::NotTheTarget => f.write_str("the frame is not from the request's target"), + FrameError::NotTheConnection => { + f.write_str("the relay error is not from the connection's station") } + FrameError::Unsignable => f.write_str("the key cannot sign this frame"), + FrameError::TextTooLong(what) => write!(f, "text longer than its bound: {what}"), + FrameError::InvalidText(what) => write!(f, "text that is not valid UTF-8: {what}"), + FrameError::RelayCodeOutsideItsSet => { + f.write_str("a relay error code outside its closed set") + } + FrameError::OutOfRange(what) => write!(f, "a field outside its range: {what}"), + FrameError::NotAllowed(what) => { + write!(f, "a stream frame its side does not send: {what}") + } + FrameError::SeqMismatch => f.write_str("a stream frame out of its side's order"), + FrameError::StreamEnded => f.write_str("a stream frame after its side's STREAM_END"), + FrameError::NeighbourSigned => { + f.write_str("the frame already carries a neighbour signature") + } + FrameError::NotYetValid(ms) => write!(f, "a publication not yet valid, by {ms} ms"), + FrameError::Expired(ms) => write!(f, "a publication past its expiry, by {ms} ms"), + FrameError::SignatureInvalid => { + f.write_str("the signed object's signature does not verify") + } + FrameError::Key(e) => write!(f, "{e}"), } } } -impl std::error::Error for ParseCallResponseError {} - -/// Extract this frame's `call_id`, regardless of frame type — used to -/// correlate a RESULT/ERROR back to the CALL that requested it. 16 -/// bytes, matching `call_id() :: <<_:128>>` — NOT 32; caught only by -/// re-checking against the spec, since the original test for this -/// function made the identical size mistake and so didn't catch it. -pub fn frame_call_id(frame: &Value) -> Option<[u8; 16]> { - match frame.get("call_id") { - Some(Value::Bytes(b)) => b.as_slice().try_into().ok(), - _ => None, - } -} - -/// Parse a decoded frame as a RESULT or ERROR response to a CALL. -pub fn parse_call_response(frame: &Value) -> Result { - match frame.get("frame_type") { - Some(Value::Text(t)) if t == "result" => { - let payload = frame - .get("payload") - .cloned() - .ok_or(ParseCallResponseError::MissingField("payload"))?; - let responded_by = get_bytes32_generic(frame, "responded_by")?; - Ok(CallResponse::Result { - payload, - responded_by, - }) - } - Some(Value::Text(t)) if t == "error" => { - let code = match frame.get("code") { - Some(Value::Int(n)) if (0..=255).contains(n) => *n as u8, - Some(_) => return Err(ParseCallResponseError::WrongFieldType("code")), - None => return Err(ParseCallResponseError::MissingField("code")), - }; - let name = match frame.get("name") { - Some(Value::Text(t)) => t.clone(), - Some(_) => return Err(ParseCallResponseError::WrongFieldType("name")), - None => return Err(ParseCallResponseError::MissingField("name")), - }; - let reported_by = get_bytes32_generic(frame, "reported_by")?; - // `detail` is `binary() | undefined` on the wire (bytes), - // not text -- see call_error_value's own comment. - let detail = match frame.get("detail") { - None | Some(Value::Null) => None, - Some(Value::Bytes(b)) => Some( - String::from_utf8(b.clone()) - .map_err(|_| ParseCallResponseError::WrongFieldType("detail"))?, - ), - Some(_) => return Err(ParseCallResponseError::WrongFieldType("detail")), - }; - Ok(CallResponse::Error { - code, - name, - reported_by, - detail, - }) - } - _ => Err(ParseCallResponseError::NotAResultOrError), - } -} - -fn get_bytes32_generic( - frame: &Value, - field: &'static str, -) -> Result<[u8; 32], ParseCallResponseError> { - match frame.get(field) { - None => Err(ParseCallResponseError::MissingField(field)), - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseCallResponseError::WrongFieldType(field)), - Some(_) => Err(ParseCallResponseError::WrongFieldType(field)), - } -} - -// --------------------------------------------------------------------- -// PUBLISH / SUBSCRIBE / UNSUBSCRIBE / EVENT -// --------------------------------------------------------------------- - -/// Fields for a PUBLISH frame. -#[derive(Debug, Clone)] -pub struct PublishSpec { - pub topic: String, - pub realm: [u8; 32], - pub publisher: [u8; 32], - pub seq: u64, - pub payload: Value, - pub published_at_ms: u64, - pub ttl_ms: Option, -} - -impl PublishSpec { - pub fn new( - topic: impl Into, - realm: [u8; 32], - publisher: [u8; 32], - seq: u64, - payload: Value, - published_at_ms: u64, - ) -> Self { - Self { - topic: topic.into(), - realm, - publisher, - seq, - payload, - published_at_ms, - ttl_ms: None, - } - } -} - -fn publish_value(spec: &PublishSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - Value::Map(base("publish", 0, frame_id, sent_at_ms)) - .with_field("realm", Value::Bytes(spec.realm.to_vec())) - // `topic := binary()` -- bytes, not text. Same fix as CALL's - // `procedure`. - .with_field("topic", Value::Bytes(spec.topic.as_bytes().to_vec())) - .with_field("publisher", Value::Bytes(spec.publisher.to_vec())) - .with_field("seq", Value::Int(spec.seq as i128)) - .with_field("payload", spec.payload.clone()) - .with_field("published_at_ms", Value::Int(spec.published_at_ms as i128)) - .with_field( - "ttl_ms", - spec.ttl_ms - .map(|t| Value::Int(t as i128)) - .unwrap_or(Value::Null), - ) -} - -/// Build a PUBLISH frame with a fresh `frame_id`/`sent_at_ms`. Does not -/// set `publisher_sig` (the separate end-to-end publisher signature, -/// §4/§6.8 of the spec) — not implemented by this crate yet. -pub fn publish(spec: &PublishSpec) -> Value { - publish_value(spec, fresh_frame_id(), current_millis()) -} - -/// Fields for a SUBSCRIBE frame. -#[derive(Debug, Clone)] -pub struct SubscribeSpec { - pub topic: String, - pub realm: [u8; 32], - pub subscriber: [u8; 32], -} +impl std::error::Error for FrameError {} -impl SubscribeSpec { - pub fn new(topic: impl Into, realm: [u8; 32], subscriber: [u8; 32]) -> Self { - Self { - topic: topic.into(), - realm, - subscriber, - } +/// The refusal of a frame whose signed object did not verify: a signature +/// that does not verify as it is, anything else as [`FrameError::Malformed`]. +fn object_refusal(e: ObjectError) -> FrameError { + match e { + ObjectError::SignatureInvalid => FrameError::SignatureInvalid, + ObjectError::Key(k) => FrameError::Key(k), + _ => FrameError::Malformed, } } -fn subscribe_value(spec: &SubscribeSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - Value::Map(base("subscribe", 0, frame_id, sent_at_ms)) - .with_field("realm", Value::Bytes(spec.realm.to_vec())) - // `topic := binary()` -- bytes, not text. Same fix as CALL's - // `procedure`. - .with_field("topic", Value::Bytes(spec.topic.as_bytes().to_vec())) - .with_field("subscriber", Value::Bytes(spec.subscriber.to_vec())) - .with_field("filter", Value::Null) - .with_field("options", Value::Map(vec![])) -} - -/// Build a SUBSCRIBE frame with a fresh `frame_id`/`sent_at_ms`. No -/// filter, no options — the plainest possible subscription. -pub fn subscribe(spec: &SubscribeSpec) -> Value { - subscribe_value(spec, fresh_frame_id(), current_millis()) -} - -/// Fields for an UNSUBSCRIBE frame. -#[derive(Debug, Clone)] -pub struct UnsubscribeSpec { - pub topic: String, - pub realm: [u8; 32], - pub subscriber: [u8; 32], -} - -impl UnsubscribeSpec { - pub fn new(topic: impl Into, realm: [u8; 32], subscriber: [u8; 32]) -> Self { - Self { - topic: topic.into(), - realm, - subscriber, - } +/// Wraps `frame` as ``, refusing one over +/// the frame cap. +pub fn encode(frame: &Value) -> Result, FrameError> { + let payload = cbor::encode(frame).map_err(|e| FrameError::Payload(e.to_string()))?; + if payload.len() > MAX_FRAME_BYTES { + return Err(FrameError::TooLarge(payload.len())); } + let mut out = Vec::with_capacity(4 + payload.len()); + out.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + out.extend_from_slice(&payload); + Ok(out) } -fn unsubscribe_value(spec: &UnsubscribeSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - Value::Map(base("unsubscribe", 0, frame_id, sent_at_ms)) - .with_field("realm", Value::Bytes(spec.realm.to_vec())) - // `topic := binary()` -- bytes, not text. Same fix as CALL's - // `procedure`. - .with_field("topic", Value::Bytes(spec.topic.as_bytes().to_vec())) - .with_field("subscriber", Value::Bytes(spec.subscriber.to_vec())) -} - -/// Build an UNSUBSCRIBE frame with a fresh `frame_id`/`sent_at_ms`. -pub fn unsubscribe(spec: &UnsubscribeSpec) -> Value { - unsubscribe_value(spec, fresh_frame_id(), current_millis()) -} - -/// What a subscriber actually receives — parsed fields of an EVENT frame. -#[derive(Debug, Clone)] -pub struct EventInfo { - pub topic: String, - pub realm: [u8; 32], - pub publisher: [u8; 32], - pub seq: u64, - pub payload: Value, - pub delivered_via: String, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ParseEventError { - NotAnEventFrame, - MissingField(&'static str), - WrongFieldType(&'static str), -} - -impl std::fmt::Display for ParseEventError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ParseEventError::NotAnEventFrame => write!(f, "frame_type is not \"event\""), - ParseEventError::MissingField(name) => write!(f, "missing required field {name:?}"), - ParseEventError::WrongFieldType(name) => write!(f, "field {name:?} has the wrong type"), - } - } +/// What decoding the head of a buffer found. +#[derive(Debug, Clone, PartialEq)] +pub enum Decoded { + /// A whole frame, and how many bytes of the buffer it took. + Complete { frame: Value, consumed: usize }, + /// At least this many more bytes are needed before trying again. + NeedMore(usize), } -impl std::error::Error for ParseEventError {} - -/// Parse a decoded frame as an EVENT. -pub fn parse_event(frame: &Value) -> Result { - match frame.get("frame_type") { - Some(Value::Text(t)) if t == "event" => {} - _ => return Err(ParseEventError::NotAnEventFrame), - } - // `topic := binary()` on the wire -- bytes, not text. - let topic = match frame.get("topic") { - Some(Value::Bytes(b)) => { - String::from_utf8(b.clone()).map_err(|_| ParseEventError::WrongFieldType("topic"))? - } - Some(_) => return Err(ParseEventError::WrongFieldType("topic")), - None => return Err(ParseEventError::MissingField("topic")), - }; - let realm = match frame.get("realm") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseEventError::WrongFieldType("realm"))?, - Some(_) => return Err(ParseEventError::WrongFieldType("realm")), - None => return Err(ParseEventError::MissingField("realm")), - }; - let publisher = match frame.get("publisher") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseEventError::WrongFieldType("publisher"))?, - Some(_) => return Err(ParseEventError::WrongFieldType("publisher")), - None => return Err(ParseEventError::MissingField("publisher")), +/// Decodes one length-prefixed frame from the head of `buf`, under the +/// decoding rule. +pub fn decode(buf: &[u8]) -> Result { + let Some((header, rest)) = buf.split_first_chunk::<4>() else { + return Ok(Decoded::NeedMore(4 - buf.len())); }; - let seq = match frame.get("seq") { - Some(Value::Int(n)) if *n >= 0 => *n as u64, - Some(_) => return Err(ParseEventError::WrongFieldType("seq")), - None => return Err(ParseEventError::MissingField("seq")), - }; - let payload = frame - .get("payload") - .cloned() - .ok_or(ParseEventError::MissingField("payload"))?; - let delivered_via = match frame.get("delivered_via") { - Some(Value::Text(t)) => t.clone(), - Some(_) => return Err(ParseEventError::WrongFieldType("delivered_via")), - None => return Err(ParseEventError::MissingField("delivered_via")), - }; - Ok(EventInfo { - topic, - realm, - publisher, - seq, - payload, - delivered_via, - }) -} - -// --------------------------------------------------------------------- -// HELLO (parse only — a client receives these, it doesn't construct them) -// --------------------------------------------------------------------- - -/// The fields of a HELLO frame actually needed to drive the handshake -/// state machine (`plans/PLAN_WIRE_PROTOCOL.md` §3). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HelloInfo { - pub node_id: [u8; 32], - pub station_id: [u8; 32], - pub realms: Vec<[u8; 32]>, - pub capabilities: u64, - pub accepted: bool, - pub negotiated_capabilities: u64, - pub refusal_code: Option, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ParseHelloError { - NotAHelloFrame, - MissingField(&'static str), - WrongFieldType(&'static str), -} - -impl std::fmt::Display for ParseHelloError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ParseHelloError::NotAHelloFrame => write!(f, "frame_type is not \"hello\""), - ParseHelloError::MissingField(name) => write!(f, "missing required field {name:?}"), - ParseHelloError::WrongFieldType(name) => write!(f, "field {name:?} has the wrong type"), - } - } -} - -impl std::error::Error for ParseHelloError {} - -fn get_bytes32(frame: &Value, field: &'static str) -> Result<[u8; 32], ParseHelloError> { - match frame.get(field) { - None => Err(ParseHelloError::MissingField(field)), - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseHelloError::WrongFieldType(field)), - Some(_) => Err(ParseHelloError::WrongFieldType(field)), - } -} - -fn get_bytes32_list(frame: &Value, field: &'static str) -> Result, ParseHelloError> { - match frame.get(field) { - None => Err(ParseHelloError::MissingField(field)), - Some(Value::List(items)) => items - .iter() - .map(|v| match v { - Value::Bytes(b) => b - .as_slice() - .try_into() - .map_err(|_| ParseHelloError::WrongFieldType(field)), - _ => Err(ParseHelloError::WrongFieldType(field)), - }) - .collect(), - Some(_) => Err(ParseHelloError::WrongFieldType(field)), + let length = u32::from_be_bytes(*header) as usize; + if length > MAX_FRAME_BYTES { + return Err(FrameError::TooLarge(length)); } -} - -fn get_uint(frame: &Value, field: &'static str) -> Result { - match frame.get(field) { - None => Err(ParseHelloError::MissingField(field)), - Some(Value::Int(n)) if *n >= 0 => Ok(*n as u64), - Some(_) => Err(ParseHelloError::WrongFieldType(field)), - } -} - -fn get_bool(frame: &Value, field: &'static str) -> Result { - match frame.get(field) { - None => Err(ParseHelloError::MissingField(field)), - Some(Value::Text(t)) if t == "true" => Ok(true), - Some(Value::Text(t)) if t == "false" => Ok(false), - Some(_) => Err(ParseHelloError::WrongFieldType(field)), - } -} - -/// Parse a decoded frame as a HELLO, checking `frame_type` first. -pub fn parse_hello(frame: &Value) -> Result { - match frame.get("frame_type") { - Some(Value::Text(t)) if t == "hello" => {} - _ => return Err(ParseHelloError::NotAHelloFrame), + if rest.len() < length { + return Ok(Decoded::NeedMore(length - rest.len())); } - let refusal_code = match frame.get("refusal_code") { - None | Some(Value::Null) => None, - Some(Value::Int(n)) => Some(*n), - Some(_) => return Err(ParseHelloError::WrongFieldType("refusal_code")), - }; - Ok(HelloInfo { - node_id: get_bytes32(frame, "node_id")?, - station_id: get_bytes32(frame, "station_id")?, - realms: get_bytes32_list(frame, "realms")?, - capabilities: get_uint(frame, "capabilities")?, - accepted: get_bool(frame, "accepted")?, - negotiated_capabilities: get_uint(frame, "negotiated_capabilities")?, - refusal_code, + let frame = cbor::decode(&rest[..length]).map_err(|_| FrameError::Malformed)?; + Ok(Decoded::Complete { + frame, + consumed: 4 + length, }) } -// --------------------------------------------------------------------- -// Sign / verify -// --------------------------------------------------------------------- - -/// Sign `frame` with `identity`, over `SIG_DOMAIN || canonical_cbor(frame -/// minus signature/publisher_sig)`, and return the frame with its -/// `signature` field set (64 bytes). -pub fn sign(frame: Value, identity: &KeyPair) -> Value { - let signable = signable_bytes(&frame); - let sig = identity.sign(&signable); - frame.with_field("signature", Value::Bytes(sig.to_vec())) -} - -fn signable_bytes(frame: &Value) -> Vec { - let unsigned = frame.without(&["signature", "publisher_sig"]); - let canonical = - cbor::encode(&unsigned).expect("a frame built by this module is always encodable"); - let mut out = Vec::with_capacity(SIG_DOMAIN.len() + canonical.len()); - out.extend_from_slice(SIG_DOMAIN); - out.extend_from_slice(&canonical); - out -} - -#[derive(Debug, PartialEq, Eq)] -pub enum VerifyError { - MissingSignature, - BadSignature, - SignatureInvalid, -} - -impl std::fmt::Display for VerifyError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +/// A field's rule, as a frame's field table names it. +#[derive(Debug, Clone, Copy)] +enum Rule { + Any, + AnyBytes, + BytesOf(usize), + TextWithin(usize), + TextIn(&'static [&'static str]), + ProtocolUint, + ProtocolVersion, + CarriedObject, + HeldObject, + StreamObject, + Proofs, +} + +impl Rule { + fn accepts(self, v: &Value) -> bool { match self { - VerifyError::MissingSignature => write!(f, "frame has no signature field"), - VerifyError::BadSignature => write!(f, "signature field is not 64 bytes"), - VerifyError::SignatureInvalid => write!(f, "signature does not verify against pubkey"), + Rule::Any => true, + Rule::AnyBytes => matches!(v, Value::Bytes(_)), + Rule::BytesOf(n) => matches!(v, Value::Bytes(b) if b.len() == n), + Rule::TextWithin(n) => matches!(v, Value::Text(t) if t.len() <= n), + Rule::TextIn(names) => matches!(v, Value::Text(t) if names.contains(&t.as_str())), + Rule::ProtocolUint => protocol_uint(v).is_some(), + Rule::ProtocolVersion => { + matches!(v, Value::Int(n) if *n == i128::from(PROTOCOL_VERSION)) + } + Rule::CarriedObject => crate::signed_object::Object::from_value(v).is_ok(), + Rule::HeldObject => crate::signed_object::HeldObject::from_value(v).is_ok(), + Rule::StreamObject => Rule::CarriedObject.accepts(v) || Rule::HeldObject.accepts(v), + Rule::Proofs => request::proofs_within_bound(v), } } } -impl std::error::Error for VerifyError {} +/// A frame's or signed object's fields by name. +type Fields = std::collections::HashMap; -/// Verify `frame`'s `signature` field against `pubkey`, over the same -/// domain-separated bytes [`sign`] produces. -pub fn verify(frame: &Value, pubkey: &[u8; 32]) -> Result<(), VerifyError> { - let sig: [u8; 64] = match frame.get("signature") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| VerifyError::BadSignature)?, - _ => return Err(VerifyError::MissingSignature), +/// A map read through its table, as macula_frame's read_fields does: every +/// key text, named in the table and there once, with a value its rule +/// accepts. +fn read_fields(v: &Value, table: &[(&str, Rule)]) -> Option { + let Value::Map(pairs) = v else { + return None; }; - let signable = signable_bytes(frame); - if crate::identity::verify(&signable, &sig, pubkey) { - Ok(()) - } else { - Err(VerifyError::SignatureInvalid) + let mut fields = Fields::with_capacity(pairs.len()); + for (key, value) in pairs { + let Value::Text(name) = key else { + return None; + }; + let rule = table.iter().find(|(n, _)| *n == name)?.1; + if fields.contains_key(name) || !rule.accepts(value) { + return None; + } + fields.insert(name.clone(), value.clone()); } + Some(fields) } -// --------------------------------------------------------------------- -// publisher_sig: the separate end-to-end signature on PUBLISH/EVENT -// frames (§4/§6.6, §6.8). `sign`/`verify` above cover a frame's own -// per-hop `signature`, which is checked against whichever connection -// the frame arrived on -- correct for the frame's origin (hop 1), but -// wrong for any further relay hop, since a relayed frame's signature -// still belongs to the ORIGINAL sender, not whichever station forwarded -// it. `publisher_sig` covers just (topic, realm, publisher, seq, -// payload), independent of frame type, so it survives PUBLISH -> EVENT -// conversion and every relay hop -- a receiving station or client can -// verify authenticity against the ORIGINAL publisher no matter how many -// stations forwarded it. Ported from the Erlang reference -// (macula_frame.erl:sign_publisher/2, ?EVENT_PUBLISHER_DOMAIN) and -// checked byte-for-byte against a signature generated live from that -// same code (frame::tests::publisher_sig_matches_the_erlang_reference). -// --------------------------------------------------------------------- - -pub const EVENT_PUBLISHER_DOMAIN: &[u8] = b"macula-v2-event-pub\0"; - -/// Add `publisher_sig` to a PUBLISH or EVENT frame: `identity`'s Ed25519 -/// signature over `(topic, realm, publisher, seq, payload)`. `identity` -/// must be the key pair for the pubkey already in the frame's -/// `publisher` field -- this is not checked here (callers build frames -/// with their own identity's pubkey as `publisher` by construction). -pub fn sign_publisher(frame: Value, identity: &KeyPair) -> Value { - let signable = publisher_signing_bytes(&frame); - let sig = identity.sign(&signable); - frame.with_field("publisher_sig", Value::Bytes(sig.to_vec())) -} - -#[derive(Debug, PartialEq, Eq)] -pub enum VerifyPublisherError { - MissingPublisherSig, - BadPublisherSig, - PublisherSigInvalid, +fn has_fields(fields: &Fields, names: &[&str]) -> bool { + names.iter().all(|n| fields.contains_key(*n)) } -impl std::fmt::Display for VerifyPublisherError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - VerifyPublisherError::MissingPublisherSig => { - write!(f, "frame has no publisher_sig field") - } - VerifyPublisherError::BadPublisherSig => { - write!(f, "publisher_sig field is not 64 bytes") - } - VerifyPublisherError::PublisherSigInvalid => write!( - f, - "publisher_sig does not verify against the frame's publisher field" - ), - } +/// A received frame that carries its fields in one signed object: exactly +/// version, frame_type, the object under `object_name` and the routing fields +/// `routes` names; the protocol's version; a frame type of `types`; the object +/// in a shape `object_rule` accepts; and each routing field of its rule. +fn received_frame( + v: &Value, + object_name: &str, + object_rule: Rule, + routes: &[(&str, Rule)], + types: &'static [&'static str], +) -> Option<(String, Value)> { + let mut table = vec![ + ("version", Rule::ProtocolVersion), + ("frame_type", Rule::TextIn(types)), + (object_name, object_rule), + ]; + table.extend_from_slice(routes); + let fields = read_fields(v, &table)?; + if !has_fields(&fields, &["version", "frame_type", object_name]) { + return None; } + Some((text_of(&fields["frame_type"]), fields[object_name].clone())) } -impl std::error::Error for VerifyPublisherError {} - -/// Verify `frame`'s `publisher_sig` against its OWN `publisher` field -- -/// unlike [`verify`] (the per-hop signature), there is no separate -/// pubkey parameter: `publisher_sig`'s whole point is proving "the -/// pubkey named in this frame produced it", independent of which -/// connection it arrived on. -pub fn verify_publisher(frame: &Value) -> Result<(), VerifyPublisherError> { - let sig: [u8; 64] = match frame.get("publisher_sig") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| VerifyPublisherError::BadPublisherSig)?, - _ => return Err(VerifyPublisherError::MissingPublisherSig), - }; - let pubkey: [u8; 32] = match frame.get("publisher") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| VerifyPublisherError::BadPublisherSig)?, - _ => return Err(VerifyPublisherError::BadPublisherSig), - }; - let signable = publisher_signing_bytes(frame); - if crate::identity::verify(&signable, &sig, &pubkey) { - Ok(()) - } else { - Err(VerifyPublisherError::PublisherSigInvalid) +/// Refuses text longer than `max` bytes, judged first, then text that is not +/// UTF-8, naming the field. +fn bounded_text(field: &str, text: &[u8], max: usize) -> Result<(), FrameError> { + if text.len() > max { + return Err(FrameError::TextTooLong(format!( + "a {field} of {} bytes, over {max}", + text.len() + ))); } -} - -/// The canonical bytes a publisher signs: a fixed 5-field tuple, -/// independent of frame type, header fields, `delivered_via`, or -/// `ttl_ms`, so the same signature is valid on the PUBLISH the -/// publisher sent and on every EVENT a relay derives from it. -fn publisher_signing_bytes(frame: &Value) -> Vec { - let fields = ["topic", "realm", "publisher", "seq", "payload"]; - let pairs: Vec<(Value, Value)> = fields - .iter() - .map(|f| { - let v = frame.get(f).cloned().unwrap_or(Value::Null); - (Value::text(*f), v) - }) - .collect(); - let canonical = - cbor::encode(&Value::Map(pairs)).expect("a frame built by this module is always encodable"); - let mut out = Vec::with_capacity(EVENT_PUBLISHER_DOMAIN.len() + canonical.len()); - out.extend_from_slice(EVENT_PUBLISHER_DOMAIN); - out.extend_from_slice(&canonical); - out -} - -// --------------------------------------------------------------------- -// Wire codec: length-prefixed CBOR -// --------------------------------------------------------------------- - -#[derive(Debug, PartialEq, Eq)] -pub enum EncodeFrameError { - TooLarge(usize), - Cbor(cbor::IntOutOfRange), -} - -impl std::fmt::Display for EncodeFrameError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - EncodeFrameError::TooLarge(n) => { - write!( - f, - "frame is {n} bytes, exceeding the {MAX_FRAME_BYTES}-byte cap" - ) - } - EncodeFrameError::Cbor(e) => write!(f, "{e}"), - } + if std::str::from_utf8(text).is_err() { + return Err(FrameError::InvalidText(format!("the {field}"))); } + Ok(()) } -impl std::error::Error for EncodeFrameError {} - -/// Encode `frame` as `<>`. -pub fn encode(frame: &Value) -> Result, EncodeFrameError> { - let payload = cbor::encode(frame).map_err(EncodeFrameError::Cbor)?; - if payload.len() > MAX_FRAME_BYTES { - return Err(EncodeFrameError::TooLarge(payload.len())); +/// Refuses a key that is not an identity key. +fn identity_signer(key: &NodeKey) -> Result<(), FrameError> { + if key.purpose() != Purpose::Identity { + return Err(FrameError::Unsignable); } - let mut out = Vec::with_capacity(4 + payload.len()); - out.extend_from_slice(&(payload.len() as u32).to_be_bytes()); - out.extend_from_slice(&payload); - Ok(out) -} - -/// Result of attempting to decode one frame from the head of a buffer — -/// mirrors the reference decoder's three-way `{ok,_,_}` / `{more,_}` / -/// `{error,_}` contract, adapted to return a consumed-byte count instead -/// of a remainder slice (equally usable, more idiomatic here). -#[derive(Debug)] -pub enum Decoded { - /// A complete frame was decoded, consuming this many bytes from the - /// front of the buffer. - Frame(Value, usize), - /// The buffer doesn't yet hold a complete frame; at least this many - /// more bytes are needed before trying again. - More(usize), -} - -#[derive(Debug)] -pub enum DecodeFrameError { - TooLarge(usize), - Cbor(cbor::DecodeError), + Ok(()) } -impl std::fmt::Display for DecodeFrameError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DecodeFrameError::TooLarge(n) => { - write!( - f, - "claimed frame length {n} exceeds the {MAX_FRAME_BYTES}-byte cap" - ) - } - DecodeFrameError::Cbor(e) => write!(f, "{e}"), - } +fn protocol_uint(v: &Value) -> Option { + match v { + Value::Int(n) if *n >= 0 && *n < i128::from(MAX_PROTOCOL_INT) => Some(*n as u64), + _ => None, } } -impl std::error::Error for DecodeFrameError {} - -/// Decode one length-prefixed frame from the head of `buf`. -pub fn decode(buf: &[u8]) -> Result { - if buf.len() < 4 { - return Ok(Decoded::More(4 - buf.len())); - } - let len = u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]]) as usize; - if len > MAX_FRAME_BYTES { - return Err(DecodeFrameError::TooLarge(len)); +fn text_of(v: &Value) -> String { + match v { + Value::Text(t) => t.clone(), + _ => String::new(), } - if buf.len() < 4 + len { - return Ok(Decoded::More(4 + len - buf.len())); - } - let value = cbor::decode(&buf[4..4 + len]).map_err(DecodeFrameError::Cbor)?; - Ok(Decoded::Frame(value, 4 + len)) -} - -// --------------------------------------------------------------------- -// RPC advertise (§6.9 of `plans/PLAN_WIRE_PROTOCOL.md`): ADVERTISE, -// UNADVERTISE. The provider-role building block — registers this -// connection as the handler for `procedure` under `realm`; the station -// then routes inbound CALLs (control stream) and STREAM_OPENs (a fresh -// dedicated stream it opens toward us) for that procedure back to us. -// See `src/provider.rs` for the dispatch side of that, once built. -// --------------------------------------------------------------------- - -/// Fields for an ADVERTISE frame. -#[derive(Debug, Clone)] -pub struct AdvertiseSpec { - pub realm: [u8; 32], - pub procedure: String, - pub advertiser: [u8; 32], } -impl AdvertiseSpec { - pub fn new(realm: [u8; 32], procedure: impl Into, advertiser: [u8; 32]) -> Self { - Self { - realm, - procedure: procedure.into(), - advertiser, - } +fn bytes_of(v: &Value) -> Vec { + match v { + Value::Bytes(b) => b.clone(), + _ => Vec::new(), } } -fn advertise_value(spec: &AdvertiseSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - // NOTE: `source_route` stays untouched (`Null`) — confirmed directly - // against the reference, not assumed from CALL/STREAM_OPEN's pattern - // (which DO override it). `realm` IS overridden here, unlike RESULT/ - // STREAM_DATA/etc. - Value::Map(base("advertise", 0, frame_id, sent_at_ms)) - .with_field("realm", Value::Bytes(spec.realm.to_vec())) - // `procedure := binary()` -- bytes, not text. Same fix as CALL's - // `procedure`. - .with_field( - "procedure", - Value::Bytes(spec.procedure.as_bytes().to_vec()), - ) - .with_field("advertiser", Value::Bytes(spec.advertiser.to_vec())) - // `options` has no known use case yet -- always the reference's - // own default, an empty map. - .with_field("options", Value::Map(vec![])) -} - -/// Build an ADVERTISE frame with a fresh `frame_id`/`sent_at_ms`. -pub fn advertise(spec: &AdvertiseSpec) -> Value { - advertise_value(spec, fresh_frame_id(), current_millis()) -} - -/// Fields for an UNADVERTISE frame. -#[derive(Debug, Clone)] -pub struct UnadvertiseSpec { - pub realm: [u8; 32], - pub procedure: String, - pub advertiser: [u8; 32], -} - -impl UnadvertiseSpec { - pub fn new(realm: [u8; 32], procedure: impl Into, advertiser: [u8; 32]) -> Self { - Self { - realm, - procedure: procedure.into(), - advertiser, +fn fixed(v: &Value) -> [u8; N] { + let mut out = [0u8; N]; + if let Value::Bytes(b) = v { + if b.len() == N { + out.copy_from_slice(b); } } + out } -fn unadvertise_value(spec: &UnadvertiseSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - Value::Map(base("unadvertise", 0, frame_id, sent_at_ms)) - .with_field("realm", Value::Bytes(spec.realm.to_vec())) - .with_field( - "procedure", - Value::Bytes(spec.procedure.as_bytes().to_vec()), - ) - .with_field("advertiser", Value::Bytes(spec.advertiser.to_vec())) -} - -/// Build an UNADVERTISE frame with a fresh `frame_id`/`sent_at_ms`. -pub fn unadvertise(spec: &UnadvertiseSpec) -> Value { - unadvertise_value(spec, fresh_frame_id(), current_millis()) +fn entry(name: &str, value: Value) -> (Value, Value) { + (Value::text(name), value) } -// --------------------------------------------------------------------- -// Streaming RPC (§13 of `plans/PLAN_WIRE_PROTOCOL.md`): STREAM_OPEN, -// STREAM_DATA, STREAM_END, STREAM_ERROR, STREAM_REPLY. Ported from -// `macula_frame.erl`'s streaming constructors, verified against real -// `rebar3` output the same way as every other frame type. -// -// **Real finding, empirically verified (2026-08-28), correcting an -// assumption in an earlier draft of the wire-protocol spec:** despite -// `encoding`'s `msgpack` value name, there is no second wire codec. -// `msgpack` was removed from macula's own dependencies in v3.0.0 -// (`rebar.config`'s own comment: "wire protocol switched to CBOR"); the -// one remaining `msgpack:pack` call in the whole macula repo is in an -// unrelated legacy DHT test, never on the `stream_data` path. Confirmed -// directly: building a `stream_data` frame with `encoding = msgpack` and -// an arbitrary Erlang map as `body`, then round-tripping it through -// `macula_frame:encode/1` + `decode/1`, hands the map straight back — -// `body` is embedded as an ordinary nested value in the frame's own -// canonical-CBOR envelope, exactly like CALL's `payload` or -// `stream_reply`'s `payload`. So here, `encoding` is purely a semantic -// hint for the receiver ("treat `body` as raw bytes" vs "treat it as a -// structured value") — `StreamDataSpec::body` is just a [`Value`] either -// way, and no `rmp-serde`/msgpack dependency is needed in this crate. -// -// **v1 scope, matching this crate's existing priority (also documented -// in the plan): the caller/consumer role (§13.1) only.** These -// constructors are enough to open a stream, send/receive chunks, close -// or abort — the shape a mobile client actually needs. The provider -// role (§13.2, exposing a streaming procedure *to* the mesh) isn't -// built — nothing in this crate needs to *serve* RPCs yet. -// -// **Correction, 2026-08-29 — the assumption below was wrong, found live -// against the real fleet.** `signer` (an optional field on -// STREAM_DATA/STREAM_END/STREAM_ERROR, mirrors the reference's -// `maybe_add_signer/2`) IS now stamped by every real call site in this -// crate (`stream::StreamHandle::send_data`/`close_send`/`abort`, which -// always pass `Some(identity.public_bytes())`). The original reasoning — -// "a direct-dial client talking to one station has no relay hop to -// authenticate across" — assumed the client's own single hop is the -// only hop that matters. It isn't: the STATION side can still relay the -// stream on to a SECOND station if the advertised provider lives -// elsewhere (`macula_station_peer_observer.erl`'s dedicated-stream -// dispatch is built to do exactly this). Without `signer`, the second -// hop's verify falls back to the inbound connection's NodeId — which at -// that hop is the relaying station's own identity, not the original -// caller's — and the reference's own comment on `maybe_add_signer/2` -// says as much: "fine for the direct edge... fails on every subsequent -// station-to-station hop". Confirmed live: `tests/live_station.rs`'s -// `cross_station_streaming_round_trip_frankfurt_provider_milan_caller` -// found exactly this failure mode (STREAM_OPEN routes cross-station, -// STREAM_DATA silently never arrives) before this field was wired up. -// CALL/PUBLISH don't need this because they're signed end-to-end by a -// REQUIRED field (`caller`/`responded_by`) present on every frame -// regardless of hop count — `signer` gives STREAM_DATA/END/ERROR the -// same property, just as an optional field instead of a required one, -// matching the reference's own design exactly. - -/// `mode` on a STREAM_OPEN — who's expected to push data. Matches -/// `macula_stream:mode()`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StreamMode { - /// The provider pushes chunks at the caller. - ServerStream, - /// The caller pushes chunks at the provider (§12.3's push-upload - /// path is exactly this mode). - ClientStream, - /// Both directions. - Bidi, +fn uint(n: u64) -> Value { + Value::Int(i128::from(n)) } -impl StreamMode { - pub fn name(self) -> &'static str { - match self { - StreamMode::ServerStream => "server_stream", - StreamMode::ClientStream => "client_stream", - StreamMode::Bidi => "bidi", - } - } - - fn from_name(name: &str) -> Option { - match name { - "server_stream" => Some(StreamMode::ServerStream), - "client_stream" => Some(StreamMode::ClientStream), - "bidi" => Some(StreamMode::Bidi), - _ => None, - } - } +/// Whether a reply's, relay error's or stream frame's request_id and +/// request_hash are `request`'s. +fn names_request(fields: &Fields, request: &VerifiedRequest) -> bool { + fields.get("request_id") == Some(&Value::Bytes(request.request_id.to_vec())) + && fields.get("request_hash") == Some(&Value::Bytes(request.request_hash.to_vec())) } -/// `encoding` on a STREAM_DATA — a hint for how to interpret `body`, not -/// a second wire codec. See this section's module-level note. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StreamEncoding { - /// `body` is opaque bytes. - Raw, - /// `body` is a structured [`Value`] (despite the name — no msgpack - /// byte-level encoding actually happens; see the note above). - Msgpack, +/// The envelope a control frame carries, as macula_frame's base/2: version, +/// frame_type, a fresh frame_id (UUID v7), sent_at_ms, capabilities, and the +/// null realm, call_id and source_route. +fn base(frame_type: &str) -> Vec<(Value, Value)> { + vec![ + entry("version", Value::Int(i128::from(PROTOCOL_VERSION))), + entry("frame_type", Value::text(frame_type)), + entry("frame_id", Value::Bytes(crate::uuid_v7::new().to_vec())), + entry("sent_at_ms", uint(crate::uuid_v7::now_ms())), + entry("capabilities", uint(0)), + entry("realm", Value::Null), + entry("call_id", Value::Null), + entry("source_route", Value::Null), + ] } -impl StreamEncoding { - pub fn name(self) -> &'static str { - match self { - StreamEncoding::Raw => "raw", - StreamEncoding::Msgpack => "msgpack", - } - } - - fn from_name(name: &str) -> Option { - match name { - "raw" => Some(StreamEncoding::Raw), - "msgpack" => Some(StreamEncoding::Msgpack), - _ => None, - } - } -} - -/// `role` on a STREAM_END — which direction(s) are closing. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StreamRole { - /// Half-close: this side is done sending, still willing to receive. - Send, - /// Full close: this side is done in both directions. - Both, -} - -impl StreamRole { - pub fn name(self) -> &'static str { - match self { - StreamRole::Send => "send", - StreamRole::Both => "both", - } - } - - fn from_name(name: &str) -> Option { - match name { - "send" => Some(StreamRole::Send), - "both" => Some(StreamRole::Both), - _ => None, - } - } -} - -/// Fields for a STREAM_OPEN frame. Mirrors CALL's auth/routing shape — -/// `deadline_ms`/`caller`/`source_route`/`retry_budget` — plus the -/// stream-specific `stream_id`/`mode`/`args`. -#[derive(Debug, Clone)] -pub struct StreamOpenSpec { - pub stream_id: [u8; 16], - pub procedure: String, - pub realm: [u8; 32], - pub mode: StreamMode, - pub args: Value, - pub deadline_ms: i128, - pub caller: [u8; 32], - pub source_route: Vec, - pub retry_budget: u64, -} - -impl StreamOpenSpec { - pub fn new( - stream_id: [u8; 16], - procedure: impl Into, - realm: [u8; 32], - mode: StreamMode, - args: Value, - deadline_ms: i128, - caller: [u8; 32], - ) -> Self { - Self { - stream_id, - procedure: procedure.into(), - realm, - mode, - args, - deadline_ms, - caller, - source_route: Vec::new(), - retry_budget: 0, - } - } -} - -fn stream_open_value(spec: &StreamOpenSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - Value::Map(base("stream_open", 0, frame_id, sent_at_ms)) - .with_field("stream_id", Value::Bytes(spec.stream_id.to_vec())) - // `procedure := binary()` -- bytes, not text. Same fix as CALL's - // `procedure`. - .with_field( - "procedure", - Value::Bytes(spec.procedure.as_bytes().to_vec()), - ) - .with_field("realm", Value::Bytes(spec.realm.to_vec())) - .with_field("mode", Value::text(spec.mode.name())) - .with_field("args", spec.args.clone()) - .with_field("deadline_ms", Value::Int(spec.deadline_ms)) - .with_field("caller", Value::Bytes(spec.caller.to_vec())) - .with_field("source_route", Value::Bytes(spec.source_route.clone())) - .with_field("retry_budget", Value::Int(spec.retry_budget as i128)) -} - -/// Build a STREAM_OPEN frame with a fresh `frame_id`/`sent_at_ms`. -/// Unsigned — pass the result to [`sign`] before sending. -pub fn stream_open(spec: &StreamOpenSpec) -> Value { - stream_open_value(spec, fresh_frame_id(), current_millis()) -} - -/// The fields a provider needs from an *inbound* STREAM_OPEN — the -/// first frame on a freshly-accepted dedicated stream (§13.2). Doesn't -/// carry `source_route`/`retry_budget`: nothing in the provider role -/// built so far acts on either. -#[derive(Debug, Clone)] -pub struct StreamOpenInfo { - pub stream_id: [u8; 16], - pub procedure: String, - pub realm: [u8; 32], - pub mode: StreamMode, - pub args: Value, - pub deadline_ms: i128, - pub caller: [u8; 32], -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ParseStreamOpenError { - NotAStreamOpenFrame, - MissingField(&'static str), - WrongFieldType(&'static str), -} - -impl std::fmt::Display for ParseStreamOpenError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ParseStreamOpenError::NotAStreamOpenFrame => { - write!(f, "frame_type is not \"stream_open\"") - } - ParseStreamOpenError::MissingField(name) => { - write!(f, "missing required field {name:?}") - } - ParseStreamOpenError::WrongFieldType(name) => { - write!(f, "field {name:?} has the wrong type") - } - } - } -} - -impl std::error::Error for ParseStreamOpenError {} - -/// Parse a decoded frame as a STREAM_OPEN. -pub fn parse_stream_open(frame: &Value) -> Result { - match frame.get("frame_type") { - Some(Value::Text(t)) if t == "stream_open" => {} - _ => return Err(ParseStreamOpenError::NotAStreamOpenFrame), - } - let stream_id = match frame.get("stream_id") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseStreamOpenError::WrongFieldType("stream_id"))?, - Some(_) => return Err(ParseStreamOpenError::WrongFieldType("stream_id")), - None => return Err(ParseStreamOpenError::MissingField("stream_id")), - }; - // `procedure := binary()` on the wire -- bytes, not text. - let procedure = match frame.get("procedure") { - Some(Value::Bytes(b)) => String::from_utf8(b.clone()) - .map_err(|_| ParseStreamOpenError::WrongFieldType("procedure"))?, - Some(_) => return Err(ParseStreamOpenError::WrongFieldType("procedure")), - None => return Err(ParseStreamOpenError::MissingField("procedure")), - }; - let realm = match frame.get("realm") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseStreamOpenError::WrongFieldType("realm"))?, - Some(_) => return Err(ParseStreamOpenError::WrongFieldType("realm")), - None => return Err(ParseStreamOpenError::MissingField("realm")), - }; - let mode = match frame.get("mode") { - Some(Value::Text(t)) => { - StreamMode::from_name(t).ok_or(ParseStreamOpenError::WrongFieldType("mode"))? - } - Some(_) => return Err(ParseStreamOpenError::WrongFieldType("mode")), - None => return Err(ParseStreamOpenError::MissingField("mode")), - }; - let args = frame - .get("args") - .cloned() - .ok_or(ParseStreamOpenError::MissingField("args"))?; - let deadline_ms = match frame.get("deadline_ms") { - Some(Value::Int(n)) => *n, - Some(_) => return Err(ParseStreamOpenError::WrongFieldType("deadline_ms")), - None => return Err(ParseStreamOpenError::MissingField("deadline_ms")), - }; - let caller = match frame.get("caller") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseStreamOpenError::WrongFieldType("caller"))?, - Some(_) => return Err(ParseStreamOpenError::WrongFieldType("caller")), - None => return Err(ParseStreamOpenError::MissingField("caller")), - }; - Ok(StreamOpenInfo { - stream_id, - procedure, - realm, - mode, - args, - deadline_ms, - caller, - }) -} - -/// Fields for a STREAM_DATA frame — one chunk. `body`'s shape follows -/// `encoding`: [`Value::Bytes`] for [`StreamEncoding::Raw`], any -/// structured [`Value`] for [`StreamEncoding::Msgpack`] (see this -/// section's module-level note on why that's still a plain CBOR value, -/// not a second codec). -/// -/// `signer`: see this section's module doc for why every real call site -/// in this crate ([`crate::stream::StreamHandle`]) always supplies -/// `Some(identity.public_bytes())` — `None` exists only because the -/// reference's own `maybe_add_signer/2` treats it as optional, and two -/// of this module's own differential vectors deliberately exercise that -/// branch (signature bytes captured before this crate carried `signer` -/// at all, still valid against the reference today). -#[derive(Debug, Clone)] -pub struct StreamDataSpec { - pub stream_id: [u8; 16], - pub seq: u64, - pub encoding: StreamEncoding, - pub body: Value, - pub signer: Option<[u8; 32]>, -} - -impl StreamDataSpec { - pub fn new( - stream_id: [u8; 16], - seq: u64, - encoding: StreamEncoding, - body: Value, - signer: Option<[u8; 32]>, - ) -> Self { - Self { - stream_id, - seq, - encoding, - body, - signer, - } - } -} - -fn stream_data_value(spec: &StreamDataSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - // NOTE: like RESULT, STREAM_DATA does not touch the base envelope's - // `realm`/`call_id`/`source_route` — they stay `Null`, confirmed - // directly against the reference's own output, not assumed from - // STREAM_OPEN's pattern. - let value = Value::Map(base("stream_data", 0, frame_id, sent_at_ms)) - .with_field("stream_id", Value::Bytes(spec.stream_id.to_vec())) - .with_field("seq", Value::Int(spec.seq as i128)) - .with_field("encoding", Value::text(spec.encoding.name())) - .with_field("body", spec.body.clone()); - with_optional_signer(value, spec.signer) -} - -/// Build a STREAM_DATA frame with a fresh `frame_id`/`sent_at_ms`. -pub fn stream_data(spec: &StreamDataSpec) -> Value { - stream_data_value(spec, fresh_frame_id(), current_millis()) -} - -/// Mirrors the reference's `maybe_add_signer/2` exactly: stamp `signer` -/// onto the frame when present, leave the frame untouched otherwise — -/// see [`StreamDataSpec::signer`]'s doc for why this exists at all. -fn with_optional_signer(value: Value, signer: Option<[u8; 32]>) -> Value { - match signer { - Some(pub_key) => value.with_field("signer", Value::Bytes(pub_key.to_vec())), - None => value, - } -} - -/// Fields for a STREAM_END frame — a half-close (`role: Send`) or full -/// close (`role: Both`) of one direction. See [`StreamDataSpec::signer`]'s -/// doc — same field, same reasoning. -#[derive(Debug, Clone)] -pub struct StreamEndSpec { - pub stream_id: [u8; 16], - pub role: StreamRole, - pub signer: Option<[u8; 32]>, -} - -impl StreamEndSpec { - pub fn new(stream_id: [u8; 16], role: StreamRole, signer: Option<[u8; 32]>) -> Self { - Self { - stream_id, - role, - signer, - } - } -} - -fn stream_end_value(spec: &StreamEndSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - let value = Value::Map(base("stream_end", 0, frame_id, sent_at_ms)) - .with_field("stream_id", Value::Bytes(spec.stream_id.to_vec())) - .with_field("role", Value::text(spec.role.name())); - with_optional_signer(value, spec.signer) -} - -/// Build a STREAM_END frame with a fresh `frame_id`/`sent_at_ms`. -pub fn stream_end(spec: &StreamEndSpec) -> Value { - stream_end_value(spec, fresh_frame_id(), current_millis()) -} - -/// Fields for a STREAM_ERROR frame — the explicit abort a well-behaved -/// peer sends instead of just dropping the stream on any non-normal -/// termination (`plans/PLAN_WIRE_PROTOCOL.md` §13.1, point 4). `code` -/// here is a free-form label (`is_binary(Code)` in the reference), NOT -/// a BOLT#4 numeric code like an ERROR (§6.4) frame's `code` — streaming -/// aborts and unary-call errors use unrelated error vocabularies. -/// `signer`: see [`StreamDataSpec::signer`]'s doc — same field, same -/// reasoning. -#[derive(Debug, Clone)] -pub struct StreamErrorSpec { - pub stream_id: [u8; 16], - pub code: String, - pub message: String, - pub signer: Option<[u8; 32]>, -} - -impl StreamErrorSpec { - pub fn new( - stream_id: [u8; 16], - code: impl Into, - message: impl Into, - signer: Option<[u8; 32]>, - ) -> Self { - Self { - stream_id, - code: code.into(), - message: message.into(), - signer, - } - } -} - -fn stream_error_value(spec: &StreamErrorSpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - let value = Value::Map(base("stream_error", 0, frame_id, sent_at_ms)) - .with_field("stream_id", Value::Bytes(spec.stream_id.to_vec())) - .with_field("code", Value::Bytes(spec.code.as_bytes().to_vec())) - .with_field("message", Value::Bytes(spec.message.as_bytes().to_vec())); - with_optional_signer(value, spec.signer) -} - -/// Build a STREAM_ERROR frame with a fresh `frame_id`/`sent_at_ms`. -pub fn stream_error(spec: &StreamErrorSpec) -> Value { - stream_error_value(spec, fresh_frame_id(), current_millis()) -} - -/// Fields for a STREAM_REPLY frame — the terminal result of a -/// `client_stream`/`bidi` exchange, sent once by the provider after it -/// has fully consumed and verified whatever the caller streamed. -#[derive(Debug, Clone)] -pub struct StreamReplySpec { - pub stream_id: [u8; 16], - pub payload: Value, - pub responded_by: [u8; 32], -} - -impl StreamReplySpec { - pub fn new(stream_id: [u8; 16], payload: Value, responded_by: [u8; 32]) -> Self { - Self { - stream_id, - payload, - responded_by, - } - } -} - -fn stream_reply_value(spec: &StreamReplySpec, frame_id: [u8; 16], sent_at_ms: u64) -> Value { - Value::Map(base("stream_reply", 0, frame_id, sent_at_ms)) - .with_field("stream_id", Value::Bytes(spec.stream_id.to_vec())) - .with_field("payload", spec.payload.clone()) - .with_field("responded_by", Value::Bytes(spec.responded_by.to_vec())) -} - -/// Build a STREAM_REPLY frame with a fresh `frame_id`/`sent_at_ms`. -pub fn stream_reply(spec: &StreamReplySpec) -> Value { - stream_reply_value(spec, fresh_frame_id(), current_millis()) -} - -/// Extract this frame's `stream_id`, regardless of frame type — used to -/// correlate STREAM_DATA/STREAM_END/STREAM_ERROR/STREAM_REPLY frames -/// back to the STREAM_OPEN that started the exchange. 16 bytes, matching -/// `stream_id() :: <<_:128>>`. -pub fn frame_stream_id(frame: &Value) -> Option<[u8; 16]> { - match frame.get("stream_id") { - Some(Value::Bytes(b)) => b.as_slice().try_into().ok(), - _ => None, - } -} - -/// What a stream consumer actually receives — one parsed -/// STREAM_DATA/STREAM_END/STREAM_ERROR/STREAM_REPLY frame. -#[derive(Debug, Clone)] -pub enum StreamEvent { - Data { - stream_id: [u8; 16], - seq: u64, - encoding: StreamEncoding, - body: Value, - }, - End { - stream_id: [u8; 16], - role: StreamRole, - }, - Error { - stream_id: [u8; 16], - code: String, - message: String, - }, - Reply { - stream_id: [u8; 16], - payload: Value, - responded_by: [u8; 32], - }, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum ParseStreamEventError { - NotAStreamFrame, - MissingField(&'static str), - WrongFieldType(&'static str), -} - -impl std::fmt::Display for ParseStreamEventError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ParseStreamEventError::NotAStreamFrame => write!( - f, - "frame_type is none of stream_data/stream_end/stream_error/stream_reply" - ), - ParseStreamEventError::MissingField(name) => { - write!(f, "missing required field {name:?}") - } - ParseStreamEventError::WrongFieldType(name) => { - write!(f, "field {name:?} has the wrong type") - } - } - } -} - -impl std::error::Error for ParseStreamEventError {} - -/// Parse a decoded frame as one of STREAM_DATA/STREAM_END/STREAM_ERROR/ -/// STREAM_REPLY. -pub fn parse_stream_event(frame: &Value) -> Result { - let stream_id = match frame.get("stream_id") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseStreamEventError::WrongFieldType("stream_id"))?, - Some(_) => return Err(ParseStreamEventError::WrongFieldType("stream_id")), - None => return Err(ParseStreamEventError::MissingField("stream_id")), - }; - match frame.get("frame_type") { - Some(Value::Text(t)) if t == "stream_data" => { - let seq = match frame.get("seq") { - Some(Value::Int(n)) if *n >= 0 => *n as u64, - Some(_) => return Err(ParseStreamEventError::WrongFieldType("seq")), - None => return Err(ParseStreamEventError::MissingField("seq")), - }; - let encoding = match frame.get("encoding") { - Some(Value::Text(t)) => StreamEncoding::from_name(t) - .ok_or(ParseStreamEventError::WrongFieldType("encoding"))?, - Some(_) => return Err(ParseStreamEventError::WrongFieldType("encoding")), - None => return Err(ParseStreamEventError::MissingField("encoding")), - }; - let body = frame - .get("body") - .cloned() - .ok_or(ParseStreamEventError::MissingField("body"))?; - Ok(StreamEvent::Data { - stream_id, - seq, - encoding, - body, - }) - } - Some(Value::Text(t)) if t == "stream_end" => { - let role = match frame.get("role") { - Some(Value::Text(t)) => { - StreamRole::from_name(t).ok_or(ParseStreamEventError::WrongFieldType("role"))? - } - Some(_) => return Err(ParseStreamEventError::WrongFieldType("role")), - None => return Err(ParseStreamEventError::MissingField("role")), - }; - Ok(StreamEvent::End { stream_id, role }) - } - Some(Value::Text(t)) if t == "stream_error" => { - let code = match frame.get("code") { - Some(Value::Bytes(b)) => String::from_utf8(b.clone()) - .map_err(|_| ParseStreamEventError::WrongFieldType("code"))?, - Some(_) => return Err(ParseStreamEventError::WrongFieldType("code")), - None => return Err(ParseStreamEventError::MissingField("code")), - }; - let message = match frame.get("message") { - Some(Value::Bytes(b)) => String::from_utf8(b.clone()) - .map_err(|_| ParseStreamEventError::WrongFieldType("message"))?, - Some(_) => return Err(ParseStreamEventError::WrongFieldType("message")), - None => return Err(ParseStreamEventError::MissingField("message")), - }; - Ok(StreamEvent::Error { - stream_id, - code, - message, - }) - } - Some(Value::Text(t)) if t == "stream_reply" => { - let payload = frame - .get("payload") - .cloned() - .ok_or(ParseStreamEventError::MissingField("payload"))?; - let responded_by = match frame.get("responded_by") { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| ParseStreamEventError::WrongFieldType("responded_by"))?, - Some(_) => return Err(ParseStreamEventError::WrongFieldType("responded_by")), - None => return Err(ParseStreamEventError::MissingField("responded_by")), - }; - Ok(StreamEvent::Reply { - stream_id, - payload, - responded_by, - }) - } - Some(_) | None => Err(ParseStreamEventError::NotAStreamFrame), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn hex_bytes(s: &str) -> Vec { - ::hex::decode(s).expect("valid hex fixture") - } - - fn fixed_array(hex_str: &str) -> [u8; 32] { - hex_bytes(hex_str).try_into().expect("32-byte fixture") - } - - // Same identity/evidence vectors as src/identity.rs's tests — - // captured from the same real `rebar3 shell` session. - const VECTOR_PUB: &str = "B966A9812649C3D5542FF54954FE090C43FDA6574FE48A0DD326626CFAD29A83"; - const VECTOR_PRIV: &str = "457F45FF5A09E172ED15CB20D6CB26B51AD15ED7308C12D478E8631F9CA03D4F"; - const VECTOR_PUZZLE_EVIDENCE: &str = - "09D48C91CB46513ED2580BDCEA87C40DA508D4E50EC3DF2F701AFC55D1C5C0B2"; - const VECTOR_FRAME_ID: &str = "0192E8B0F1A47000A1B2C3D4E5F60718"; - const VECTOR_SENT_AT_MS: u64 = 1_700_000_000_000; - const VECTOR_SIGNATURE: &str = "CF6959A61A2F4D2046F0124C1DD56A6541265F36A24CB18CA8C45C95031854D6AECE5FB93E2AE7BA6C444A09C7C5DED195B6EB0D1CC8E487CCF6E4F0D903B409"; - const VECTOR_ENCODED_LEN: usize = 375; - - /// The single strongest test in this crate so far: builds the exact - /// same CONNECT frame `macula_frame:connect/1` + `sign/2` produced - /// in a real, live `rebar3 shell` (same identity, fixed - /// `frame_id`/`sent_at_ms` injected explicitly since the reference - /// randomizes both per call), and checks the encoded bytes — - /// including the Ed25519 signature — match exactly. See this - /// module's doc comment. - #[test] - fn connect_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = KeyPair::from_seed_bytes(fixed_array(VECTOR_PRIV)); - let puzzle_evidence = fixed_array(VECTOR_PUZZLE_EVIDENCE); - let frame_id: [u8; 16] = hex_bytes(VECTOR_FRAME_ID).try_into().expect("16 bytes"); - - let spec = ConnectSpec::new(pub_bytes, puzzle_evidence); - let unsigned = connect_value(&spec, frame_id, VECTOR_SENT_AT_MS); - let signed = sign(unsigned, &identity); - - let sig_field = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig_field), - VECTOR_SIGNATURE, - "signature diverged from the reference — canonical CBOR encoding \ - or the signing domain/bytes must differ somewhere" - ); - - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), VECTOR_ENCODED_LEN); - - // Round-trip: decode what we just built and verify it against - // the known pubkey, exactly like a receiving station would. - let decoded = match decode(&encoded).expect("valid frame") { - Decoded::Frame(value, consumed) => { - assert_eq!(consumed, encoded.len()); - value - } - Decoded::More(n) => panic!("unexpectedly needed {n} more bytes"), - }; - verify(&decoded, &pub_bytes).expect("our own signature must verify"); - } - - #[test] - fn verify_rejects_a_tampered_field() { - let identity = KeyPair::from_seed_bytes(fixed_array(VECTOR_PRIV)); - let pub_bytes = identity.public_bytes(); - let spec = ConnectSpec::new(pub_bytes, fixed_array(VECTOR_PUZZLE_EVIDENCE)); - let signed = sign(connect(&spec), &identity); - - // Flip the capabilities field after signing. - let tampered = signed.with_field("capabilities", Value::Int(999)); - assert_eq!( - verify(&tampered, &pub_bytes), - Err(VerifyError::SignatureInvalid) - ); - } - - #[test] - fn verify_rejects_a_missing_signature() { - let frame = Value::Map(vec![(Value::text("frame_type"), Value::text("connect"))]); - let pubkey = [0u8; 32]; - assert_eq!(verify(&frame, &pubkey), Err(VerifyError::MissingSignature)); - } - - #[test] - fn decode_reports_more_for_a_short_buffer() { - assert!(matches!(decode(&[0, 0]), Ok(Decoded::More(2)))); - // A 4-byte length prefix claiming 10 bytes of payload, but only - // 2 are present. - let mut buf = 10u32.to_be_bytes().to_vec(); - buf.extend_from_slice(&[0, 0]); - assert!(matches!(decode(&buf), Ok(Decoded::More(8)))); - } - - #[test] - fn decode_rejects_a_length_over_the_cap() { - let buf = ((MAX_FRAME_BYTES as u32) + 1).to_be_bytes(); - assert!(matches!( - decode(&buf), - Err(DecodeFrameError::TooLarge(n)) if n == MAX_FRAME_BYTES + 1 - )); - } - - #[test] - fn goodbye_frame_round_trips() { - let frame = goodbye("normal", Some("bye")); - assert_eq!(frame.get("frame_type"), Some(&Value::text("goodbye"))); - assert_eq!(frame.get("reason"), Some(&Value::text("normal"))); - assert_eq!(frame.get("detail"), Some(&Value::Bytes(b"bye".to_vec()))); - } - - #[test] - fn goodbye_without_detail_is_null() { - let frame = goodbye("timeout", None); - assert_eq!(frame.get("detail"), Some(&Value::Null)); - } - - #[test] - fn parse_hello_reads_a_well_formed_frame() { - let node_id = [7u8; 32]; - let station_id = [8u8; 32]; - let realm = [9u8; 32]; - let hello = Value::Map(vec![ - (Value::text("frame_type"), Value::text("hello")), - (Value::text("node_id"), Value::Bytes(node_id.to_vec())), - (Value::text("station_id"), Value::Bytes(station_id.to_vec())), - ( - Value::text("realms"), - Value::List(vec![Value::Bytes(realm.to_vec())]), - ), - (Value::text("capabilities"), Value::Int(0)), - (Value::text("accepted"), Value::text("true")), - (Value::text("negotiated_capabilities"), Value::Int(3)), - ]); - let info = parse_hello(&hello).expect("well-formed hello"); - assert_eq!(info.node_id, node_id); - assert_eq!(info.station_id, station_id); - assert_eq!(info.realms, vec![realm]); - assert!(info.accepted); - assert_eq!(info.negotiated_capabilities, 3); - assert_eq!(info.refusal_code, None); - } - - #[test] - fn parse_hello_rejects_the_wrong_frame_type() { - let frame = Value::Map(vec![(Value::text("frame_type"), Value::text("connect"))]); - assert_eq!(parse_hello(&frame), Err(ParseHelloError::NotAHelloFrame)); - } - - #[test] - fn parse_hello_reports_a_missing_field() { - let frame = Value::Map(vec![(Value::text("frame_type"), Value::text("hello"))]); - assert_eq!( - parse_hello(&frame), - Err(ParseHelloError::MissingField("node_id")) - ); - } - - // ------------------------------------------------------------- - // Differential vectors for CALL/RESULT/ERROR/PUBLISH/SUBSCRIBE/ - // UNSUBSCRIBE/EVENT — same method and same identity as the CONNECT - // vector above: built with fixed frame_id/sent_at_ms in a real - // `rebar3 shell`, exact encoded bytes (including the Ed25519 - // signature) asserted to match. The CALL vector specifically caught - // a real discrepancy on the first attempt — a hand-built test frame - // that assumed `source_route` stayed `null` like other optional - // fields, when the real constructor always sets it to an empty - // binary — fixed before this test was written, not after. - // ------------------------------------------------------------- - - const VECTOR_CALL_ID: &str = "AABBCCDDEEFF00112233445566778899"; - const VECTOR_ZERO_REALM: [u8; 32] = [0u8; 32]; - - fn vector_identity() -> KeyPair { - KeyPair::from_seed_bytes(fixed_array(VECTOR_PRIV)) - } - - fn vector_call_id() -> [u8; 16] { - hex_bytes(VECTOR_CALL_ID).try_into().expect("16 bytes") - } - - fn vector_frame_id() -> [u8; 16] { - hex_bytes(VECTOR_FRAME_ID).try_into().expect("16 bytes") - } - - #[test] - fn call_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = CallSpec::new( - vector_call_id(), - "_content.get_manifest", - VECTOR_ZERO_REALM, - Value::Map(vec![(Value::text("hello"), Value::text("world"))]), - 1_700_000_030_000, - pub_bytes, - ); - let signed = sign( - call_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig), - "A6BC174F0241E644F634702C08781C8FC8BD3CDE3CA9650DE8A731A01203D9B9403A2CAD75800F7B8C9AAE16FA146B1195FF03F0E6DC4595A652D7F29BFE350A" - ); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 386); - } - - #[test] - fn result_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = ResultSpec::new(vector_call_id(), Value::text("ok-result"), pub_bytes); - let signed = sign( - result_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig), - "03E8F72D51D958C318B7F1C25D78408408317DEAB23434D6EA32F211CADEA1C62900DA15AFF603E795B19A388D382BDB10E65AEFC6F0CE551270AB172A88E50B" - ); - assert_eq!(encode(&signed).expect("encodable").len(), 301); - } - - #[test] - fn error_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = CallErrorSpec::new( - vector_call_id(), - crate::bolt4::Code::UnknownNextPeer, - pub_bytes, - ); - let signed = sign( - call_error_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig), - "182ECD5217CE378F576635B23CC8C9F265555142845D6CBA033A282BAED97966C23FBE91D08507FB8E840375AA17665763804F40F89102F8D3EDAD4DA98FC20D" - ); - assert_eq!(encode(&signed).expect("encodable").len(), 333); - } - - #[test] - fn publish_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = PublishSpec::new( - "test.topic", - VECTOR_ZERO_REALM, - pub_bytes, - 42, - Value::text("published-data"), - VECTOR_SENT_AT_MS, - ); - let signed = sign( - publish_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig), - "DD49D10EFA9F2EED0A393DC02DC5BBAC25D6731562EA39F5AB2E5337824527AFFBC7D917AF4DE5EFDBE5BC41E58659E05EC6FDE4E91FB1A32CC9C211456DF10C" - ); - assert_eq!(encode(&signed).expect("encodable").len(), 355); - } - - // Reference vector generated directly from the Erlang implementation - // (macula-io/macula, src/peering/macula_frame.erl:sign_publisher/2), - // live in a rebar3 shell against the same fixed identity every other - // vector test in this file uses. First publisher_sig implementation - // in any repo as of 2026-08-29 (macula-go, macula-rust, - // macula-dotnet all lacked it) -- no prior port existed to - // cross-check against instead, so this is checked straight against - // the Erlang source of truth. - #[test] - fn publisher_sig_matches_the_erlang_reference() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = PublishSpec::new( - "acme/svc.do", - VECTOR_ZERO_REALM, - pub_bytes, - 42, - Value::Bytes(b"hello".to_vec()), - VECTOR_SENT_AT_MS, - ); - let unsigned = publish_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS); - let with_pub_sig = sign_publisher(unsigned, &identity); - - let sig = match with_pub_sig.get("publisher_sig") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a publisher_sig field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig), - "C11BEB676A590FD1BA86F0B77E377B4582AA461DB1283F64E57224E920A7BD0A2C7D36271B795FFC3CB4F2C7BB8925B034431AA6425E25B2AEEFAC026883BB0C" - ); - - verify_publisher(&with_pub_sig).expect("our own freshly-signed frame must verify"); - - // Tamper check: changing payload after signing must invalidate it. - let tampered = with_pub_sig - .clone() - .with_field("payload", Value::Bytes(b"world".to_vec())); - assert!( - verify_publisher(&tampered).is_err(), - "verify_publisher accepted a frame with a tampered payload" - ); - - // Absence must be a verification failure, not "trusted". - assert_eq!( - verify_publisher(&unsigned_publish_for_tamper_check(&spec)), - Err(VerifyPublisherError::MissingPublisherSig) - ); - } - - fn unsigned_publish_for_tamper_check(spec: &PublishSpec) -> Value { - publish_value(spec, vector_frame_id(), VECTOR_SENT_AT_MS) - } - - // Full encode/decode round trip with BOTH publisher_sig and the - // per-hop signature present, mirroring exactly what a real caller - // (macula-go's connection.Session.Publish does this already; - // this crate's own connection layer should too) would build. - #[test] - fn publish_frame_with_both_signatures_round_trips() { - let identity = KeyPair::generate(); - let pub_bytes = identity.node_id(); - let spec = PublishSpec::new( - "acme/svc.do", - VECTOR_ZERO_REALM, - pub_bytes, - 1, - Value::Bytes(b"hello".to_vec()), - VECTOR_SENT_AT_MS, - ); - let unsigned = publish(&spec); - let with_pub_sig = sign_publisher(unsigned, &identity); - let fully_signed = sign(with_pub_sig, &identity); - - let encoded = encode(&fully_signed).expect("encodable"); - let decoded = match decode(&encoded).expect("decodable") { - Decoded::Frame(value, consumed) => { - assert_eq!(consumed, encoded.len()); - value - } - Decoded::More(n) => panic!("unexpectedly needed {n} more bytes"), - }; - - verify(&decoded, &pub_bytes).expect("per-hop verify on decoded frame"); - verify_publisher(&decoded).expect("verify_publisher on decoded frame"); - - assert!( - decoded.get("publisher_sig").is_some(), - "decoded frame lost publisher_sig" - ); - assert!( - decoded.get("signature").is_some(), - "decoded frame lost signature" - ); - } - - #[test] - fn subscribe_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = SubscribeSpec::new("test.topic", VECTOR_ZERO_REALM, pub_bytes); - let signed = sign( - subscribe_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig), - "ABDD7304B887A53B149CE4D4C62F1AFD20AE07D8612B76F22006FA6676B8DDB37C1D5106358D32080246BA4355A9E04BF49F73600E752F5F9037D7A93A47020A" - ); - assert_eq!(encode(&signed).expect("encodable").len(), 313); - } - - #[test] - fn unsubscribe_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = UnsubscribeSpec::new("test.topic", VECTOR_ZERO_REALM, pub_bytes); - let signed = sign( - unsubscribe_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig), - "C917068BE4E1C5A3C753F249037DD8F44293D888BB252BF1E828671969547969982160C91A0E3CA1C31DE29ED39E3677E7F20F4BDE61539D4618B3703018E403" - ); - assert_eq!(encode(&signed).expect("encodable").len(), 298); - } - - #[test] - fn event_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let fields = base("event", 0, vector_frame_id(), VECTOR_SENT_AT_MS); - let unsigned = Value::Map(fields) - .with_field("realm", Value::Bytes(VECTOR_ZERO_REALM.to_vec())) - .with_field("topic", Value::Bytes(b"test.topic".to_vec())) - .with_field("publisher", Value::Bytes(pub_bytes.to_vec())) - .with_field("seq", Value::Int(42)) - .with_field("payload", Value::text("published-data")) - .with_field("delivered_via", Value::text("direct")); - let signed = sign(unsigned, &identity); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!( - hex::encode_upper(&sig), - "9B9EE4EAC375FBD0C9B5A5BC6D82E35739F8ECBF594979891BF35E5BDB53A148B3936AF99217C3D8C12E2EEA0686F68D5FE63284BE6B142F87BFF319DDDB780F" - ); - assert_eq!(encode(&signed).expect("encodable").len(), 341); - - // Round-trip through parse_event too, since EVENT (unlike the - // others above) has a real parser a receiving client uses. - let decoded = decode(&encode(&signed).unwrap()).unwrap(); - let Decoded::Frame(value, _) = decoded else { - panic!("expected a complete frame") - }; - let info = parse_event(&value).expect("well-formed event"); - assert_eq!(info.topic, "test.topic"); - assert_eq!(info.seq, 42); - assert_eq!(info.delivered_via, "direct"); - } - - #[test] - fn parse_call_response_reads_a_result() { - let frame = Value::Map(vec![ - (Value::text("frame_type"), Value::text("result")), - (Value::text("call_id"), Value::Bytes(vec![1; 16])), - (Value::text("payload"), Value::text("ok")), - (Value::text("responded_by"), Value::Bytes(vec![2; 32])), - ]); - match parse_call_response(&frame).expect("well-formed result") { - CallResponse::Result { - payload, - responded_by, - } => { - assert_eq!(payload, Value::text("ok")); - assert_eq!(responded_by, [2u8; 32]); - } - other => panic!("expected Result, got {other:?}"), - } - } - - #[test] - fn parse_call_response_reads_an_error() { - let frame = Value::Map(vec![ - (Value::text("frame_type"), Value::text("error")), - (Value::text("call_id"), Value::Bytes(vec![1; 16])), - (Value::text("code"), Value::Int(1)), - (Value::text("name"), Value::text("unknown_next_peer")), - (Value::text("reported_by"), Value::Bytes(vec![2; 32])), - (Value::text("detail"), Value::Null), - ]); - match parse_call_response(&frame).expect("well-formed error") { - CallResponse::Error { - code, - name, - reported_by, - detail, - } => { - assert_eq!(code, 1); - assert_eq!(name, "unknown_next_peer"); - assert_eq!(reported_by, [2u8; 32]); - assert_eq!(detail, None); - } - other => panic!("expected Error, got {other:?}"), - } - } - - #[test] - fn frame_call_id_reads_from_any_frame_type() { - let frame = Value::Map(vec![(Value::text("call_id"), Value::Bytes(vec![9; 16]))]); - assert_eq!(frame_call_id(&frame), Some([9u8; 16])); - // A 32-byte value (e.g. a pubkey accidentally in this field) must - // NOT be accepted as a 16-byte call_id. - let wrong_size = Value::Map(vec![(Value::text("call_id"), Value::Bytes(vec![9; 32]))]); - assert_eq!(frame_call_id(&wrong_size), None); - } - - // ------------------------------------------------------------- - // Streaming RPC (§13) — same differential method as CALL above, - // vectors captured from a real `macula_frame:stream_open/1` + - // `stream_data/1` + `stream_end/1` + `stream_error/1` + - // `stream_reply/1` + `sign/2` in a live `rebar3 shell` session - // against the same identity/frame_id/sent_at_ms fixtures already - // defined above. - // ------------------------------------------------------------- - - const VECTOR_STREAM_ID: &str = "0102030405060708090A0B0C0D0E0F10"; - - fn vector_stream_id() -> [u8; 16] { - hex_bytes(VECTOR_STREAM_ID).try_into().expect("16 bytes") - } - - #[test] - fn stream_open_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = StreamOpenSpec::new( - vector_stream_id(), - "macula_rust_sdk.test_stream", - VECTOR_ZERO_REALM, - StreamMode::ClientStream, - Value::Map(vec![(Value::text("hello"), Value::text("world"))]), - 1_700_000_030_000, - pub_bytes, - ); - let signed = sign( - stream_open_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "6070D8AB71F837591AC2C803C04F9E1D3FA01C9310D33C96A90434820C5E50550F9DEA8A764247EB49AF63447C037E192B7892A365C1A4ACB9BC46B98AA5670F"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 415); - } - - /// `parse_stream_open` round-tripped against the SAME - /// already-byte-verified construction above: since - /// `stream_open_frame_matches_the_reference_byte_for_byte` already - /// proves the constructor's encoding is bit-for-bit correct, - /// getting the same field values back out here proves the parser - /// inverts it correctly too, without needing a second live vector. - #[test] - fn parse_stream_open_round_trips_a_well_formed_frame() { - let pub_bytes = fixed_array(VECTOR_PUB); - let spec = StreamOpenSpec::new( - vector_stream_id(), - "macula_rust_sdk.test_stream", - VECTOR_ZERO_REALM, - StreamMode::ClientStream, - Value::Map(vec![(Value::text("hello"), Value::text("world"))]), - 1_700_000_030_000, - pub_bytes, - ); - let frame = stream_open_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS); - let info = parse_stream_open(&frame).expect("well-formed stream_open"); - assert_eq!(info.stream_id, vector_stream_id()); - assert_eq!(info.procedure, "macula_rust_sdk.test_stream"); - assert_eq!(info.realm, VECTOR_ZERO_REALM); - assert_eq!(info.mode, StreamMode::ClientStream); - assert_eq!( - info.args, - Value::Map(vec![(Value::text("hello"), Value::text("world"))]) - ); - assert_eq!(info.deadline_ms, 1_700_000_030_000); - assert_eq!(info.caller, pub_bytes); - } - - #[test] - fn parse_stream_open_rejects_the_wrong_frame_type() { - let frame = Value::Map(vec![( - Value::text("frame_type"), - Value::text("stream_data"), - )]); - assert_eq!( - parse_stream_open(&frame).unwrap_err(), - ParseStreamOpenError::NotAStreamOpenFrame - ); - } - - #[test] - fn stream_data_raw_frame_matches_the_reference_byte_for_byte() { - let identity = vector_identity(); - let spec = StreamDataSpec::new( - vector_stream_id(), - 0, - StreamEncoding::Raw, - Value::Bytes(b"raw chunk bytes".to_vec()), - None, - ); - let signed = sign( - stream_data_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "35770744FE5BD01B86DDA01AB4EF855E4E4FE0EDFEDC89FF690728C585C60A5CB035717E3EA9133C4AD833E226F4DB95E9A5AF9AC59E7BACBB8BDF72611F8003"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 269); - } - - /// The vector this crate was missing until 2026-08-29: `signer` - /// present, matching what every real `StreamHandle` call site now - /// sends. Generated live against `macula_frame:stream_data/1` with - /// `signer => Pub` in the spec map (`rebar3 shell`, same identity/ - /// frame_id/stream_id/sent_at_ms fixture as every other vector in - /// this module) — not guessed from the field's shape. - #[test] - fn stream_data_with_signer_matches_the_reference_byte_for_byte() { - let identity = vector_identity(); - let spec = StreamDataSpec::new( - vector_stream_id(), - 0, - StreamEncoding::Raw, - Value::Bytes(b"raw chunk bytes".to_vec()), - Some(fixed_array(VECTOR_PUB)), - ); - let signed = sign( - stream_data_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "3EA0B6B6DB1549D2EA42AF015A477FCD6D00B11F48F9CC07AF0914CAC18F22B5C12E5EE446811388F207D688960B67D9BEE7B4D998BE02F2B1426B6C4A06D307"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 310); - } - - /// The real point of this vector: `encoding = msgpack` with a - /// structured `body` (`{a: 1, greeting: "hi"}`, mirroring the - /// reference's `#{a => 1, greeting => <<"hi">>}`) still matches the - /// reference's signature byte-for-byte — proving `body` is encoded - /// as an ordinary nested CBOR value in the frame's own envelope, not - /// pre-serialized through a separate msgpack codec this crate would - /// otherwise need to implement. See this section's module doc. - #[test] - fn stream_data_msgpack_frame_matches_the_reference_byte_for_byte() { - let identity = vector_identity(); - let spec = StreamDataSpec::new( - vector_stream_id(), - 1, - StreamEncoding::Msgpack, - Value::Map(vec![ - (Value::text("a"), Value::Int(1)), - // `greeting`'s VALUE is a binary (`<<"hi">>`) in the - // reference, not an atom -- bytes, not text, unlike its - // (atom) key. - (Value::text("greeting"), Value::Bytes(b"hi".to_vec())), - ]), - None, - ); - let signed = sign( - stream_data_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "99CA90B0C01FD349DBAF317D03872E5F460426789874D79B6FBE37F4AC92C2AD690A00CDB3734F262D5C58C8F3BFD06F8AE892A8B5655274718A283ABA1D4D08"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 273); - } - - #[test] - fn stream_end_frame_matches_the_reference_byte_for_byte() { - let identity = vector_identity(); - let spec = StreamEndSpec::new(vector_stream_id(), StreamRole::Send, None); - let signed = sign( - stream_end_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "78F2B94BD5AC70901EABB31D8B17C89B58A88942300C6232545899AFB933B2C4B7399BB183A5660671981B6346DA27033C8F93A99E7EBA96F0F689B03D4F940A"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 239); - } - - /// See `stream_data_with_signer_matches_the_reference_byte_for_byte`'s - /// doc — same fixture, same 2026-08-29 gap, this crate's STREAM_END. - #[test] - fn stream_end_with_signer_matches_the_reference_byte_for_byte() { - let identity = vector_identity(); - let spec = StreamEndSpec::new( - vector_stream_id(), - StreamRole::Send, - Some(fixed_array(VECTOR_PUB)), - ); - let signed = sign( - stream_end_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "CC316B0A1C1AD4701AD16D8A140ED62D5DEEFD721C1CEB574CC8755C645CA27413EF9C6A6A9C4768564524C412515C14637A9D6BD4CCB8CD1ADD44F2A240C70C"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 280); - } - - #[test] - fn stream_error_frame_matches_the_reference_byte_for_byte() { - let identity = vector_identity(); - let spec = StreamErrorSpec::new(vector_stream_id(), "cancelled", "boom", None); - let signed = sign( - stream_error_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "119F379518EC17C603ED5466A57D7AE53198A8AC4D5CA9849934A78994428CB3DAD40BC0EFECE1A0C8EEB0ACC28973C0F7E55DE6444827091814AF0715D9FF0B"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 259); - } - - /// See `stream_data_with_signer_matches_the_reference_byte_for_byte`'s - /// doc — same fixture, same 2026-08-29 gap, this crate's STREAM_ERROR. - #[test] - fn stream_error_with_signer_matches_the_reference_byte_for_byte() { - let identity = vector_identity(); - let spec = StreamErrorSpec::new( - vector_stream_id(), - "cancelled", - "boom", - Some(fixed_array(VECTOR_PUB)), - ); - let signed = sign( - stream_error_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "223062E2816C5E6DABCF08A0A4FD01F477F2D1D933F2F1FDC971CAB570003DDE8192CC2F8811CE4A2D180B6781AFA64EB4057947E25CF121F745A9654DC23D0A"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 300); - } - - #[test] - fn stream_reply_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = StreamReplySpec::new( - vector_stream_id(), - Value::Map(vec![(Value::text("ok"), Value::text("true"))]), - pub_bytes, - ); - let signed = sign( - stream_reply_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "ADF57AD58B253F175ADF72E4717E078C62F3E22CBDDBF8DDC0DD8A47CAAA061E8A37C73BAAB91E450D1D8472021B6A0161169D77E9D186C436D3E6580D48C703"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 295); - } - - #[test] - fn frame_stream_id_reads_from_any_frame_type() { - let frame = Value::Map(vec![(Value::text("stream_id"), Value::Bytes(vec![9; 16]))]); - assert_eq!(frame_stream_id(&frame), Some([9u8; 16])); - let wrong_size = Value::Map(vec![(Value::text("stream_id"), Value::Bytes(vec![9; 32]))]); - assert_eq!(frame_stream_id(&wrong_size), None); - } - - #[test] - fn parse_stream_event_reads_data_end_error_and_reply() { - let data = Value::Map(vec![ - (Value::text("frame_type"), Value::text("stream_data")), - (Value::text("stream_id"), Value::Bytes(vec![1; 16])), - (Value::text("seq"), Value::Int(3)), - (Value::text("encoding"), Value::text("raw")), - (Value::text("body"), Value::Bytes(b"hi".to_vec())), - ]); - match parse_stream_event(&data).expect("well-formed stream_data") { - StreamEvent::Data { - stream_id, - seq, - encoding, - body, - } => { - assert_eq!(stream_id, [1u8; 16]); - assert_eq!(seq, 3); - assert_eq!(encoding, StreamEncoding::Raw); - assert_eq!(body, Value::Bytes(b"hi".to_vec())); - } - other => panic!("expected Data, got {other:?}"), - } - - let end = Value::Map(vec![ - (Value::text("frame_type"), Value::text("stream_end")), - (Value::text("stream_id"), Value::Bytes(vec![1; 16])), - (Value::text("role"), Value::text("both")), - ]); - match parse_stream_event(&end).expect("well-formed stream_end") { - StreamEvent::End { stream_id, role } => { - assert_eq!(stream_id, [1u8; 16]); - assert_eq!(role, StreamRole::Both); - } - other => panic!("expected End, got {other:?}"), - } - - let error = Value::Map(vec![ - (Value::text("frame_type"), Value::text("stream_error")), - (Value::text("stream_id"), Value::Bytes(vec![1; 16])), - (Value::text("code"), Value::Bytes(b"cancelled".to_vec())), - (Value::text("message"), Value::Bytes(b"boom".to_vec())), - ]); - match parse_stream_event(&error).expect("well-formed stream_error") { - StreamEvent::Error { - stream_id, - code, - message, - } => { - assert_eq!(stream_id, [1u8; 16]); - assert_eq!(code, "cancelled"); - assert_eq!(message, "boom"); - } - other => panic!("expected Error, got {other:?}"), - } - - let reply = Value::Map(vec![ - (Value::text("frame_type"), Value::text("stream_reply")), - (Value::text("stream_id"), Value::Bytes(vec![1; 16])), - (Value::text("payload"), Value::text("done")), - (Value::text("responded_by"), Value::Bytes(vec![2; 32])), - ]); - match parse_stream_event(&reply).expect("well-formed stream_reply") { - StreamEvent::Reply { - stream_id, - payload, - responded_by, - } => { - assert_eq!(stream_id, [1u8; 16]); - assert_eq!(payload, Value::text("done")); - assert_eq!(responded_by, [2u8; 32]); - } - other => panic!("expected Reply, got {other:?}"), - } - } - - #[test] - fn parse_stream_event_rejects_a_non_stream_frame() { - let frame = Value::Map(vec![ - (Value::text("frame_type"), Value::text("call")), - (Value::text("stream_id"), Value::Bytes(vec![1; 16])), - ]); - assert_eq!( - parse_stream_event(&frame).unwrap_err(), - ParseStreamEventError::NotAStreamFrame - ); - } - - // ------------------------------------------------------------- - // RPC advertise (§6.9) — same differential method, vectors - // captured from a real `macula_frame:advertise/1` + - // `unadvertise/1` + `sign/2` in a live `rebar3 shell`. - // ------------------------------------------------------------- - - #[test] - fn advertise_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = AdvertiseSpec::new( - VECTOR_ZERO_REALM, - "macula_rust_sdk.test_procedure", - pub_bytes, - ); - let signed = sign( - advertise_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "22AE051A542289279A56FB9C8587341232EF48208F9A8641C77F37E1B5D3D26A4B7C30CDCA4AE6E851FEB4E2FBF9C5B2469AFCC7317D59F5D775A05C99E99C0A"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 330); - } - - #[test] - fn unadvertise_frame_matches_the_reference_byte_for_byte() { - let pub_bytes = fixed_array(VECTOR_PUB); - let identity = vector_identity(); - let spec = UnadvertiseSpec::new( - VECTOR_ZERO_REALM, - "macula_rust_sdk.test_procedure", - pub_bytes, - ); - let signed = sign( - unadvertise_value(&spec, vector_frame_id(), VECTOR_SENT_AT_MS), - &identity, - ); - let sig = match signed.get("signature") { - Some(Value::Bytes(b)) => b.clone(), - other => panic!("expected a signature field, got {other:?}"), - }; - assert_eq!(hex::encode_upper(&sig), "C4111E5C2685DCDDB035B9DA29AD2A30D90BC7CAC09620A675D9A3DB480508FDAD7DCDD145B77607395DBF6195643BBA60C2C6D29E2DCFE5F70F20CF15DA2600"); - let encoded = encode(&signed).expect("encodable frame"); - assert_eq!(encoded.len(), 323); +/// Replaces `fields`' entry for `key`, or appends one: a raw push over a base +/// field would put two entries under one key. +fn with_field(mut fields: Vec<(Value, Value)>, key: &str, value: Value) -> Vec<(Value, Value)> { + match fields.iter_mut().find(|(k, _)| *k == Value::text(key)) { + Some(slot) => slot.1 = value, + None => fields.push(entry(key, value)), } + fields } diff --git a/src/frame/check_payload.rs b/src/frame/check_payload.rs new file mode 100644 index 0000000..0e8be81 --- /dev/null +++ b/src/frame/check_payload.rs @@ -0,0 +1,146 @@ +//! The checks a sender runs so that nothing the decoding rule refuses on +//! arrival leaves this node, as macula_frame's check_payload/1 and +//! check_frame/1: a map key that is not text or an integer, two keys of one +//! map that encode alike, an integer outside -2^63 to 2^63-1, a float that is +//! NaN or infinite, too deep a nesting, and too many items. Text is valid +//! UTF-8 by construction here. + +use crate::cbor::{self, Value, MAX_ELEMENTS, MAX_NESTING_DEPTH}; + +use super::{FrameError, MAX_FRAME_BYTES}; + +/// How many lists and maps a payload may nest, the outermost counted: a +/// payload travels inside a frame's map, which takes one level. +pub const MAX_PAYLOAD_NESTING: usize = MAX_NESTING_DEPTH - 1; + +/// How many of the decoding rule's items a payload leaves for the frame +/// around it. +pub const FRAME_RESERVED_ELEMENTS: usize = 64; + +/// How many CBOR items a payload may hold, itself included. +pub const MAX_PAYLOAD_ELEMENTS: usize = MAX_ELEMENTS - FRAME_RESERVED_ELEMENTS; + +/// Whether `payload` is admissible as a frame payload. It also refuses a +/// payload whose own encoding is over the frame cap. +pub fn check_payload(payload: &Value) -> Result<(), FrameError> { + let mut check = RuleCheck { + subject: "payload", + max_items: MAX_PAYLOAD_ELEMENTS, + max_nesting: MAX_PAYLOAD_NESTING, + items: 0, + }; + check + .value(payload, &mut Vec::new()) + .map_err(FrameError::Payload)?; + let encoded = cbor::encode(payload).map_err(|e| FrameError::Payload(e.to_string()))?; + if encoded.len() > MAX_FRAME_BYTES { + return Err(FrameError::Payload(format!( + "the payload encodes to {} bytes, over the {MAX_FRAME_BYTES}-byte frame cap", + encoded.len() + ))); + } + Ok(()) +} + +/// Whether the whole `frame` is one the decoding rule accepts where it +/// arrives, the check macula runs on every frame before it is sent. +pub fn check_frame(frame: &Value) -> Result<(), FrameError> { + let mut check = RuleCheck { + subject: "frame", + max_items: MAX_ELEMENTS, + max_nesting: MAX_NESTING_DEPTH, + items: 0, + }; + check + .value(frame, &mut Vec::new()) + .map_err(FrameError::BreaksDecodingRule) +} + +/// A walk of a payload or a whole frame, its subject, under the decoding +/// rule's limits for it, counting its items. +struct RuleCheck { + subject: &'static str, + max_items: usize, + max_nesting: usize, + items: usize, +} + +impl RuleCheck { + fn value(&mut self, v: &Value, path: &mut Vec) -> Result<(), String> { + self.items += 1; + if self.items > self.max_items { + return Err(format!( + "the {} holds more than {} items, at {}", + self.subject, + self.max_items, + self.at(path) + )); + } + match v { + Value::Float(f) if !f.is_finite() => { + Err(format!("a float that is not finite at {}", self.at(path))) + } + Value::Int(n) if i64::try_from(*n).is_err() => Err(format!( + "an integer outside -2^63 to 2^63-1 at {}", + self.at(path) + )), + Value::List(items) => { + self.nesting(path)?; + for (i, item) in items.iter().enumerate() { + path.push(i.to_string()); + self.value(item, path)?; + path.pop(); + } + Ok(()) + } + Value::Map(pairs) => { + self.nesting(path)?; + let mut seen = std::collections::HashSet::with_capacity(pairs.len()); + for (key, value) in pairs { + if !matches!(key, Value::Text(_) | Value::Int(_)) { + return Err(format!( + "a map key that is not text or an integer at {}", + self.at(path) + )); + } + self.value(key, path)?; + let encoded = cbor::encode(key).map_err(|e| e.to_string())?; + if !seen.insert(encoded) { + return Err(format!( + "two keys of the map at {} encode alike", + self.at(path) + )); + } + path.push(match key { + Value::Text(t) => t.clone(), + other => format!("{other:?}"), + }); + self.value(value, path)?; + path.pop(); + } + Ok(()) + } + _ => Ok(()), + } + } + + /// Refuses a list or map at `path` that would nest more than the limit. + fn nesting(&self, path: &[String]) -> Result<(), String> { + if path.len() >= self.max_nesting { + return Err(format!( + "lists and maps at {} nest more than {} levels", + self.at(path), + self.max_nesting + )); + } + Ok(()) + } + + fn at(&self, path: &[String]) -> String { + if path.is_empty() { + format!("the {} root", self.subject) + } else { + path.join(".") + } + } +} diff --git a/src/frame/neighbour.rs b/src/frame/neighbour.rs new file mode 100644 index 0000000..b7f5a70 --- /dev/null +++ b/src/frame/neighbour.rs @@ -0,0 +1,298 @@ +//! Neighbour signatures (D17). In pq_hybrid a control frame travels as +//! `{version, frame_type, neighbour}`: `neighbour` is a held signed object +//! under MACULA-PQ-NEIGHBOUR-V1 by the sender's identity key, which the +//! receiver holds from the handshake. Its tbs holds the frame's fields +//! (without version), alg, the connection hash (the SHA-384 of the CHALLENGE +//! frame's bytes) and seq: 0 on the first neighbour-signed frame in each +//! direction, one more on each after. In pq_pure no frame carries one. +//! +//! The builders here are the control frames a client link sends: ADVERTISE +//! and UNADVERTISE carry a signed record, SUBSCRIBE and UNSUBSCRIBE a topic, +//! and GOODBYE a reason. + +use crate::cbor::Value; +use crate::node_key::NodeKey; +use crate::profile::Profile; +use crate::signed_object::{sign_held_object, verify_held_object}; + +use super::{ + base, bounded_text, entry, has_fields, object_refusal, protocol_uint, read_fields, uint, + with_field, FrameError, Rule, MAX_TOPIC_BYTES, PROTOCOL_VERSION, +}; + +const NEIGHBOUR_LABEL: &str = "MACULA-PQ-NEIGHBOUR-V1"; +const MAX_GOODBYE_REASON_BYTES: usize = 256; +const MAX_GOODBYE_DETAIL_BYTES: usize = 256; + +/// The control frames pq_hybrid neighbour-signs, as macula_frame lists them. +/// Data frames carry their own end-to-end signatures. +const NEIGHBOUR_SIGNED_TYPES: &[&str] = &[ + "swim_ping", + "swim_ack", + "swim_suspect", + "swim_confirm", + "ping", + "pong", + "find_node", + "nodes", + "find_value", + "value", + "store", + "store_ack", + "advertise", + "unadvertise", + "subscribe", + "unsubscribe", + "overlay_relay", + "hyparview_join", + "hyparview_forward_join", + "hyparview_neighbor", + "hyparview_disconnect", + "hyparview_shuffle", + "hyparview_shuffle_reply", + "plumtree_ihave", + "plumtree_graft", + "plumtree_prune", + "goodbye", +]; + +/// Whether `profile` neighbour-signs frames of `frame_type`: every control +/// frame in pq_hybrid, none in pq_pure. +pub fn neighbour_signed(profile: Profile, frame_type: &str) -> bool { + profile == Profile::PqHybrid && NEIGHBOUR_SIGNED_TYPES.contains(&frame_type) +} + +/// Where a sender neighbour-signs a frame: the connection hash and the seq of +/// this frame in the sender's direction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NeighbourLink { + pub connection: [u8; 48], + pub seq: u64, +} + +/// What a receiver checks a frame against: the connection's profile, the +/// peer's identity key as carried, the connection hash, and the seq it +/// expects next from that peer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NeighbourPeer { + pub profile: Profile, + pub peer_key: Vec, + pub connection: [u8; 48], + pub seq: u64, +} + +/// Neighbour-signs `frame` with the sender's identity key, for one connection +/// and one seq, when the key's profile signs its type; otherwise `frame` goes +/// as it is. A frame that already carries a neighbour signature is refused. +pub fn sign_neighbour( + frame: &Value, + key: &NodeKey, + link: &NeighbourLink, +) -> Result { + let Value::Map(pairs) = frame else { + return Err(FrameError::Malformed); + }; + let (frame_type, version, has_neighbour) = control_header(pairs); + if has_neighbour { + return Err(FrameError::NeighbourSigned); + } + if !neighbour_signed(key.profile(), &frame_type) { + return Ok(frame.clone()); + } + let mut fields: Vec<(Value, Value)> = pairs + .iter() + .filter(|(k, _)| *k != Value::text("version")) + .cloned() + .collect(); + fields.push(entry("connection", Value::Bytes(link.connection.to_vec()))); + fields.push(entry("seq", uint(link.seq))); + let held = sign_held_object(NEIGHBOUR_LABEL, &fields, key).map_err(object_refusal)?; + Ok(Value::Map(vec![ + entry("version", version), + entry("frame_type", Value::text(frame_type)), + entry("neighbour", held.to_value()), + ])) +} + +/// Reads a received frame under the connection's profile. A frame type the +/// profile signs must be exactly `{version, frame_type, neighbour}`, signed by +/// the peer's identity key for this connection and this seq, and comes back as +/// the frame its tbs holds. Any other frame must not carry `neighbour` and +/// comes back as it is. +pub fn verify_neighbour(frame: &Value, peer: &NeighbourPeer) -> Result { + let Value::Map(pairs) = frame else { + return Err(FrameError::Malformed); + }; + let (frame_type, version, has_neighbour) = control_header(pairs); + if !neighbour_signed(peer.profile, &frame_type) { + return if has_neighbour { + Err(FrameError::Malformed) + } else { + Ok(frame.clone()) + }; + } + if pairs.len() != 3 || !has_neighbour { + return Err(FrameError::Malformed); + } + let neighbour = frame.get("neighbour").ok_or(FrameError::Malformed)?; + let verified = verify_held_object(NEIGHBOUR_LABEL, neighbour, &peer.peer_key, peer.profile) + .map_err(object_refusal)?; + opened_control_frame(&verified.fields, &frame_type, version, peer) +} + +/// The frame a neighbour tbs holds: read under its type's table with alg, +/// connection and seq, which must name this connection and this seq, and +/// returned without them and with version back. +fn opened_control_frame( + tbs: &Value, + frame_type: &str, + version: Value, + peer: &NeighbourPeer, +) -> Result { + let mut table = control_table(frame_type).ok_or(FrameError::Malformed)?; + table.extend([ + ("alg", Rule::Any), + ("connection", Rule::BytesOf(48)), + ("seq", Rule::ProtocolUint), + ]); + let fields = read_fields(tbs, &table).ok_or(FrameError::Malformed)?; + if !has_fields(&fields, &["frame_type", "alg", "connection", "seq"]) + || fields["frame_type"] != Value::text(frame_type) + || fields["connection"] != Value::Bytes(peer.connection.to_vec()) + || protocol_uint(&fields["seq"]) != Some(peer.seq) + { + return Err(FrameError::Malformed); + } + let Value::Map(pairs) = tbs else { + return Err(FrameError::Malformed); + }; + let mut opened = vec![entry("version", version)]; + opened.extend( + pairs + .iter() + .filter(|(k, _)| !matches!(k, Value::Text(t) if t == "alg" || t == "connection" || t == "seq")) + .cloned(), + ); + Ok(Value::Map(opened)) +} + +/// The field table of a control frame a client link exchanges, without +/// version and neighbour: the base every frame carries and the type's own. +fn control_table(frame_type: &str) -> Option> { + let own: &[(&'static str, Rule)] = match frame_type { + "advertise" => &[("advertisement", Rule::AnyBytes)], + "unadvertise" => &[("withdrawal", Rule::AnyBytes)], + "subscribe" => &[ + ("topic", Rule::Any), + ("subscriber", Rule::BytesOf(32)), + ("options", Rule::Any), + ], + "unsubscribe" => &[("topic", Rule::Any), ("subscriber", Rule::BytesOf(32))], + "goodbye" => &[ + ("reason", Rule::TextWithin(MAX_GOODBYE_REASON_BYTES)), + ("detail", Rule::Any), + ], + _ => return None, + }; + let types: &'static [&'static str] = match frame_type { + "advertise" => &["advertise"], + "unadvertise" => &["unadvertise"], + "subscribe" => &["subscribe"], + "unsubscribe" => &["unsubscribe"], + _ => &["goodbye"], + }; + let mut table = vec![ + ("frame_type", Rule::TextIn(types)), + ("frame_id", Rule::Any), + ("sent_at_ms", Rule::ProtocolUint), + ("capabilities", Rule::ProtocolUint), + ("realm", Rule::Any), + ("call_id", Rule::Any), + ("source_route", Rule::Any), + ]; + table.extend_from_slice(own); + Some(table) +} + +/// A frame map's frame_type, version, and whether it carries neighbour. +fn control_header(pairs: &[(Value, Value)]) -> (String, Value, bool) { + let mut frame_type = String::new(); + let mut version = Value::Int(i128::from(PROTOCOL_VERSION)); + let mut has_neighbour = false; + for (k, v) in pairs { + match (k, v) { + (Value::Text(n), Value::Text(t)) if n == "frame_type" => frame_type = t.clone(), + (Value::Text(n), _) if n == "version" => version = v.clone(), + (Value::Text(n), _) if n == "neighbour" => has_neighbour = true, + _ => {} + } + } + (frame_type, version, has_neighbour) +} + +/// macula 12's ADVERTISE: the signed procedure_advertisement record, as +/// encoded bytes. +pub fn advertise_frame(advertisement: &[u8]) -> Value { + let mut fields = base("advertise"); + fields.push(entry("advertisement", Value::Bytes(advertisement.to_vec()))); + Value::Map(fields) +} + +/// macula 12's UNADVERTISE: the signed withdrawal record, as encoded bytes. +pub fn unadvertise_frame(withdrawal: &[u8]) -> Value { + let mut fields = base("unadvertise"); + fields.push(entry("withdrawal", Value::Bytes(withdrawal.to_vec()))); + Value::Map(fields) +} + +/// macula 12's SUBSCRIBE of `subscriber` to `topic` in `realm`, with no +/// options. A topic over 512 bytes or not UTF-8 is refused. +pub fn subscribe_frame( + topic: &[u8], + realm: &[u8; 32], + subscriber: &[u8; 32], +) -> Result { + let mut fields = topic_frame("subscribe", topic, realm, subscriber)?; + fields.push(entry("options", Value::Map(Vec::new()))); + Ok(Value::Map(fields)) +} + +/// macula 12's UNSUBSCRIBE of `subscriber` from `topic` in `realm`, with +/// [`subscribe_frame`]'s bound on the topic. +pub fn unsubscribe_frame( + topic: &[u8], + realm: &[u8; 32], + subscriber: &[u8; 32], +) -> Result { + topic_frame("unsubscribe", topic, realm, subscriber).map(Value::Map) +} + +fn topic_frame( + frame_type: &str, + topic: &[u8], + realm: &[u8; 32], + subscriber: &[u8; 32], +) -> Result, FrameError> { + bounded_text("topic", topic, MAX_TOPIC_BYTES)?; + let mut fields = with_field(base(frame_type), "realm", Value::Bytes(realm.to_vec())); + fields.push(entry("topic", Value::Bytes(topic.to_vec()))); + fields.push(entry("subscriber", Value::Bytes(subscriber.to_vec()))); + Ok(fields) +} + +/// macula 12's GOODBYE: a reason of at most 256 bytes, and a detail of at +/// most 256 bytes of UTF-8, or none. +pub fn goodbye_frame(reason: &str, detail: Option<&[u8]>) -> Result { + bounded_text("reason", reason.as_bytes(), MAX_GOODBYE_REASON_BYTES)?; + let detail = match detail { + Some(d) => { + bounded_text("detail", d, MAX_GOODBYE_DETAIL_BYTES)?; + Value::Bytes(d.to_vec()) + } + None => Value::Null, + }; + let mut fields = base("goodbye"); + fields.push(entry("reason", Value::text(reason))); + fields.push(entry("detail", detail)); + Ok(Value::Map(fields)) +} diff --git a/src/frame/publication.rs b/src/frame/publication.rs new file mode 100644 index 0000000..839eb42 --- /dev/null +++ b/src/frame/publication.rs @@ -0,0 +1,173 @@ +//! Publications (D17): signed under MACULA-PQ-PUBLICATION-V1 by the +//! publisher's identity key, with no frame_type in the tbs, since the same +//! bytes ride in every EVENT and GOSSIP made from a PUBLISH. A verifier +//! accepts one published up to 5 minutes ahead of its clock, until its +//! ttl_ms, or 10 minutes without one, and 5 minutes more; a ttl_ms is at most +//! one hour. + +use sha2::{Digest, Sha384}; + +use crate::cbor::Value; +use crate::node_key::{node_id_of, NodeKey}; +use crate::profile::Profile; +use crate::signed_object::{sign_object, verify_object}; + +use super::{ + bounded_text, check_payload, entry, fixed, has_fields, identity_signer, object_refusal, + protocol_uint, read_fields, received_frame, text_of, uint, FrameError, Rule, MAX_PROTOCOL_INT, + MAX_TOPIC_BYTES, PROTOCOL_VERSION, PUBLICATION_LABEL, +}; + +const PUBLISH: &str = "publish"; +const EVENT: &str = "event"; +const PLUMTREE_GOSSIP: &str = "plumtree_gossip"; +const TOLERANCE_MS: u64 = 5 * 60_000; +const DEFAULT_TTL_MS: u64 = 10 * 60_000; +const MAX_TTL_MS: u64 = 60 * 60_000; + +/// A publication as its publisher gives it: a realm, a topic, the publisher's +/// own seq, when it was published in Unix milliseconds, a payload, and a +/// ttl_ms, `None` for the 10 minutes a publication lives without one. +#[derive(Debug, Clone, PartialEq)] +pub struct PublicationSpec { + pub realm: [u8; 32], + pub topic: String, + pub seq: u64, + pub published_at: u64, + pub payload: Value, + pub ttl_ms: Option, +} + +/// A publication that verified: its fields, the publisher's key as carried, +/// `publication_hash`, the SHA-384 of its tbs, which deduplication keys on, +/// and `expires_at`, the last moment a verifier accepts it. +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedPublication { + pub publisher: [u8; 32], + pub realm: [u8; 32], + pub topic: String, + pub seq: u64, + pub published_at: u64, + pub ttl_ms: Option, + pub payload: Value, + pub key: Vec, + pub publication_hash: [u8; 48], + pub expires_at: u64, +} + +/// Signs a publication as a PUBLISH with the publisher's identity key. +/// Refused, in macula's order: a key that is not an identity key; a seq or +/// published_at of 2^53 or more; a topic over 512 bytes; a payload the wire +/// cannot carry; a ttl_ms over one hour. +pub fn sign_publish(spec: &PublicationSpec, key: &NodeKey) -> Result { + identity_signer(key)?; + if spec.seq >= MAX_PROTOCOL_INT || spec.published_at >= MAX_PROTOCOL_INT { + return Err(FrameError::OutOfRange( + "a seq or published_at of 2^53 or more".into(), + )); + } + bounded_text("topic", spec.topic.as_bytes(), MAX_TOPIC_BYTES)?; + check_payload(&spec.payload)?; + if spec.ttl_ms.is_some_and(|t| t > MAX_TTL_MS) { + return Err(FrameError::OutOfRange("a ttl_ms over one hour".into())); + } + let mut fields = vec![ + entry("publisher", Value::Bytes(key.key_id().to_vec())), + entry("realm", Value::Bytes(spec.realm.to_vec())), + entry("topic", Value::text(spec.topic.clone())), + entry("seq", uint(spec.seq)), + entry("published_at", uint(spec.published_at)), + entry("payload", spec.payload.clone()), + ]; + if let Some(ttl) = spec.ttl_ms { + fields.push(entry("ttl_ms", uint(ttl))); + } + let publication = sign_object(PUBLICATION_LABEL, &fields, key).map_err(object_refusal)?; + Ok(Value::Map(vec![ + entry("version", Value::Int(i128::from(PROTOCOL_VERSION))), + entry("frame_type", Value::text(PUBLISH)), + entry("publication", publication.to_value()), + ])) +} + +const PUBLICATION_TABLE: &[(&str, Rule)] = &[ + ("alg", Rule::Any), + ("publisher", Rule::BytesOf(32)), + ("realm", Rule::BytesOf(32)), + ("topic", Rule::TextWithin(MAX_TOPIC_BYTES)), + ("seq", Rule::ProtocolUint), + ("published_at", Rule::ProtocolUint), + ("ttl_ms", Rule::ProtocolUint), + ("payload", Rule::Any), +]; + +/// Verifies the publication a received PUBLISH, EVENT or GOSSIP carries, +/// under the connection's `profile` and the verifier's clock `now_ms`: the +/// frame is exactly version, frame_type and publication, with an EVENT's +/// delivered_via or a GOSSIP's round; then the publication's signature and +/// fields, a ttl_ms of at most an hour, publisher as the key id of its key, +/// and its time. +pub fn verify_publication( + frame: &Value, + profile: Profile, + now_ms: i64, +) -> Result { + let frame_type = frame.get("frame_type").map(text_of).unwrap_or_default(); + let (extra, types): (&[(&str, Rule)], &'static [&'static str]) = match frame_type.as_str() { + PUBLISH => (&[], &[PUBLISH]), + EVENT => ( + &[("delivered_via", Rule::TextIn(&["plumtree", "direct"]))], + &[EVENT], + ), + PLUMTREE_GOSSIP => (&[("round", Rule::ProtocolUint)], &[PLUMTREE_GOSSIP]), + _ => return Err(FrameError::Malformed), + }; + let (_, object) = received_frame(frame, "publication", Rule::CarriedObject, extra, types) + .ok_or(FrameError::Malformed)?; + if extra.iter().any(|(name, _)| frame.get(name).is_none()) { + return Err(FrameError::Malformed); + } + let verified = verify_object(PUBLICATION_LABEL, &object, profile).map_err(object_refusal)?; + let fields = read_fields(&verified.fields, PUBLICATION_TABLE).ok_or(FrameError::Malformed)?; + if !has_fields( + &fields, + &[ + "publisher", + "realm", + "topic", + "seq", + "published_at", + "payload", + ], + ) { + return Err(FrameError::Malformed); + } + let ttl_ms = fields.get("ttl_ms").and_then(protocol_uint); + let published_at = protocol_uint(&fields["published_at"]).unwrap_or(0); + let publication = VerifiedPublication { + publisher: fixed(&fields["publisher"]), + realm: fixed(&fields["realm"]), + topic: text_of(&fields["topic"]), + seq: protocol_uint(&fields["seq"]).unwrap_or(0), + published_at, + ttl_ms, + payload: fields["payload"].clone(), + publication_hash: Sha384::digest(&verified.tbs).into(), + expires_at: published_at + ttl_ms.unwrap_or(DEFAULT_TTL_MS) + TOLERANCE_MS, + key: verified.key, + }; + let valid_from = published_at as i64 - TOLERANCE_MS as i64; + if ttl_ms.is_some_and(|t| t > MAX_TTL_MS) { + return Err(FrameError::Malformed); + } + if publication.publisher != node_id_of(&publication.key, profile) { + return Err(FrameError::KeyIdMismatch); + } + if valid_from > now_ms { + return Err(FrameError::NotYetValid(valid_from - now_ms)); + } + if now_ms > publication.expires_at as i64 { + return Err(FrameError::Expired(now_ms - publication.expires_at as i64)); + } + Ok(publication) +} diff --git a/src/frame/reply.rs b/src/frame/reply.rs new file mode 100644 index 0000000..1b7584c --- /dev/null +++ b/src/frame/reply.rs @@ -0,0 +1,393 @@ +//! Replies and relay errors (D25): a provider's RESULT or ERROR, signed under +//! MACULA-PQ-REPLY-V1 by the request's target, and a station's relay ERROR or +//! STREAM_ERROR, signed under MACULA-PQ-RELAY-ERROR-V1 with a code from a +//! closed set and no free text. + +use crate::cbor::{self, Value}; +use crate::node_key::{node_id_of, NodeKey}; +use crate::profile::Profile; +use crate::signed_object::{sign_object, verify_object, Object}; + +use super::{ + bounded_text, check_payload, entry, fixed, has_fields, identity_signer, names_request, + object_refusal, read_fields, received_frame, text_of, FrameError, Rule, VerifiedRequest, + MAX_ERROR_CODE_BYTES, MAX_ERROR_TEXT_BYTES, PROTOCOL_VERSION, RELAY_ERROR_LABEL, REPLY_LABEL, +}; + +const RESULT: &str = "result"; +const ERROR: &str = "error"; +const STREAM_ERROR: &str = "stream_error"; + +/// The closed set of relay error codes, disjoint from every provider code. +const RELAY_CODES: &[&str] = &["unknown_next_peer"]; + +/// A reply's frame type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplyType { + Result, + Error, +} + +/// A relay error's frame type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RelayErrorType { + Error, + StreamError, +} + +impl RelayErrorType { + fn name(self) -> &'static str { + match self { + RelayErrorType::Error => ERROR, + RelayErrorType::StreamError => STREAM_ERROR, + } + } +} + +/// A provider's RESULT or ERROR that verified for its request: the node that +/// responded, a RESULT's payload, and an ERROR's code and detail. +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedReply { + pub frame_type: ReplyType, + pub responded_by: [u8; 32], + pub payload: Option, + pub code: Option, + pub detail: Option, +} + +/// A station's relay error as it gives it: for a pending verified request, a +/// code from the closed set, the hop that failed, and a routing field outside +/// the signature. +#[derive(Debug, Clone, PartialEq)] +pub struct RelayErrorSpec { + pub frame_type: RelayErrorType, + pub request: VerifiedRequest, + pub code: String, + pub offending_hop: Option<[u8; 32]>, + pub source_route_partial: Option>, +} + +/// A relay error that verified for its request: the station that reported +/// it, its code, and the hop that failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedRelayError { + pub frame_type: RelayErrorType, + pub reported_by: [u8; 32], + pub code: String, + pub offending_hop: Option<[u8; 32]>, +} + +/// Signs a provider's RESULT for a verified request: responded_by is the +/// key's key id, which must be the request's target, and the payload one the +/// wire carries. `source_route_reverse` rides outside the signature. +pub fn sign_result( + request: &VerifiedRequest, + payload: &Value, + source_route_reverse: Option>, + key: &NodeKey, +) -> Result { + reply_signer(request, key)?; + check_payload(payload)?; + sign_reply( + ReplyType::Result, + request, + vec![entry("payload", payload.clone())], + source_route_reverse, + key, + ) +} + +/// Signs a provider's ERROR for a verified request, with [`sign_result`]'s +/// key check: a code of at most 64 bytes and a detail of at most 256. +pub fn sign_provider_error( + request: &VerifiedRequest, + code: &str, + detail: Option<&str>, + source_route_reverse: Option>, + key: &NodeKey, +) -> Result { + reply_signer(request, key)?; + bounded_text("code", code.as_bytes(), MAX_ERROR_CODE_BYTES)?; + let mut fields = vec![entry("code", Value::text(code))]; + if let Some(detail) = detail { + bounded_text("detail", detail.as_bytes(), MAX_ERROR_TEXT_BYTES)?; + fields.push(entry("detail", Value::text(detail))); + } + sign_reply(ReplyType::Error, request, fields, source_route_reverse, key) +} + +fn reply_signer(request: &VerifiedRequest, key: &NodeKey) -> Result<(), FrameError> { + identity_signer(key)?; + if key.key_id() != request.target { + return Err(FrameError::Unsignable); + } + Ok(()) +} + +fn sign_reply( + frame_type: ReplyType, + request: &VerifiedRequest, + mut fields: Vec<(Value, Value)>, + source_route_reverse: Option>, + key: &NodeKey, +) -> Result { + let name = match frame_type { + ReplyType::Result => RESULT, + ReplyType::Error => ERROR, + }; + fields.extend([ + entry("frame_type", Value::text(name)), + entry("request_id", Value::Bytes(request.request_id.to_vec())), + entry("request_hash", Value::Bytes(request.request_hash.to_vec())), + entry("responded_by", Value::Bytes(key.key_id().to_vec())), + ]); + let reply = sign_object(REPLY_LABEL, &fields, key).map_err(object_refusal)?; + Ok(routed_frame( + name, + "reply", + &reply, + "source_route_reverse", + source_route_reverse, + )) +} + +const REPLY_ROUTES: &[(&str, Rule)] = &[("source_route_reverse", Rule::AnyBytes)]; +const RELAY_ERROR_ROUTES: &[(&str, Rule)] = &[("source_route_partial", Rule::AnyBytes)]; + +/// Verifies a received RESULT or provider ERROR for the request it answers: +/// the frame's shape, the reply's signature and fields, responded_by as the +/// key id of its key, the request's request_id and request_hash, and +/// responded_by as the request's target. +pub fn verify_reply( + frame: &Value, + request: &VerifiedRequest, + profile: Profile, +) -> Result { + let (frame_type, object) = received_frame( + frame, + "reply", + Rule::CarriedObject, + REPLY_ROUTES, + &[RESULT, ERROR], + ) + .ok_or(FrameError::Malformed)?; + let verified = verify_object(REPLY_LABEL, &object, profile).map_err(object_refusal)?; + let fields = + read_fields(&verified.fields, &reply_table(&frame_type)).ok_or(FrameError::Malformed)?; + let (has_payload, has_code, has_detail) = ( + fields.contains_key("payload"), + fields.contains_key("code"), + fields.contains_key("detail"), + ); + let shaped = if frame_type == RESULT { + has_payload && !has_code && !has_detail + } else { + has_code && !has_payload + }; + if !has_fields( + &fields, + &["frame_type", "request_id", "request_hash", "responded_by"], + ) || !shaped + { + return Err(FrameError::Malformed); + } + let reply = VerifiedReply { + frame_type: if frame_type == RESULT { + ReplyType::Result + } else { + ReplyType::Error + }, + responded_by: fixed(&fields["responded_by"]), + payload: fields.get("payload").cloned(), + code: fields.get("code").map(text_of), + detail: fields.get("detail").map(text_of), + }; + if reply.responded_by != node_id_of(&verified.key, profile) { + return Err(FrameError::KeyIdMismatch); + } + if !names_request(&fields, request) { + return Err(FrameError::RequestMismatch); + } + if reply.responded_by != request.target { + return Err(FrameError::NotTheTarget); + } + Ok(reply) +} + +fn reply_table(frame_type: &str) -> Vec<(&'static str, Rule)> { + vec![ + ( + "frame_type", + Rule::TextIn(if frame_type == RESULT { + &[RESULT] + } else { + &[ERROR] + }), + ), + ("alg", Rule::Any), + ("request_id", Rule::BytesOf(16)), + ("request_hash", Rule::BytesOf(48)), + ("responded_by", Rule::BytesOf(32)), + ("payload", Rule::Any), + ("code", Rule::TextWithin(MAX_ERROR_CODE_BYTES)), + ("detail", Rule::TextWithin(MAX_ERROR_TEXT_BYTES)), + ] +} + +/// Signs a station's relay error with its identity key: reported_by is the +/// key's key id. Refused, in this order: a key that is not an identity key, a +/// code outside the closed set. +pub fn sign_relay_error(spec: &RelayErrorSpec, key: &NodeKey) -> Result { + identity_signer(key)?; + if !RELAY_CODES.contains(&spec.code.as_str()) { + return Err(FrameError::RelayCodeOutsideItsSet); + } + let name = spec.frame_type.name(); + let mut fields = vec![ + entry("frame_type", Value::text(name)), + entry("request_id", Value::Bytes(spec.request.request_id.to_vec())), + entry( + "request_hash", + Value::Bytes(spec.request.request_hash.to_vec()), + ), + entry("reported_by", Value::Bytes(key.key_id().to_vec())), + entry("code", Value::text(spec.code.clone())), + ]; + if let Some(hop) = spec.offending_hop { + fields.push(entry("offending_hop", Value::Bytes(hop.to_vec()))); + } + let relay_error = sign_object(RELAY_ERROR_LABEL, &fields, key).map_err(object_refusal)?; + Ok(routed_frame( + name, + "relay_error", + &relay_error, + "source_route_partial", + spec.source_route_partial.clone(), + )) +} + +/// Verifies a received relay error for the pending request it names, from +/// the station the connection authenticated, `expected_reporter`. +pub fn verify_relay_error( + frame: &Value, + request: &VerifiedRequest, + profile: Profile, + expected_reporter: &[u8; 32], +) -> Result { + let (frame_type, object) = received_frame( + frame, + "relay_error", + Rule::CarriedObject, + RELAY_ERROR_ROUTES, + &[ERROR, STREAM_ERROR], + ) + .ok_or(FrameError::Malformed)?; + let verified = verify_object(RELAY_ERROR_LABEL, &object, profile).map_err(object_refusal)?; + let fields = read_fields(&verified.fields, &relay_error_table(&frame_type)) + .ok_or(FrameError::Malformed)?; + if !has_fields( + &fields, + &[ + "frame_type", + "request_id", + "request_hash", + "reported_by", + "code", + ], + ) { + return Err(FrameError::Malformed); + } + let relay_error = VerifiedRelayError { + frame_type: if frame_type == ERROR { + RelayErrorType::Error + } else { + RelayErrorType::StreamError + }, + reported_by: fixed(&fields["reported_by"]), + code: text_of(&fields["code"]), + offending_hop: fields.get("offending_hop").map(fixed), + }; + if relay_error.reported_by != node_id_of(&verified.key, profile) { + return Err(FrameError::KeyIdMismatch); + } + if !names_request(&fields, request) { + return Err(FrameError::RequestMismatch); + } + if &relay_error.reported_by != expected_reporter { + return Err(FrameError::NotTheConnection); + } + Ok(relay_error) +} + +fn relay_error_table(frame_type: &str) -> Vec<(&'static str, Rule)> { + vec![ + ( + "frame_type", + Rule::TextIn(if frame_type == ERROR { + &[ERROR] + } else { + &[STREAM_ERROR] + }), + ), + ("alg", Rule::Any), + ("request_id", Rule::BytesOf(16)), + ("request_hash", Rule::BytesOf(48)), + ("reported_by", Rule::BytesOf(32)), + ("code", Rule::TextIn(RELAY_CODES)), + ("offending_hop", Rule::BytesOf(32)), + ] +} + +/// The request_id and request_hash a received reply or relay error names, +/// read without verifying it: a key for finding the pending request and +/// nothing more. The frame's fields and the signed object's shape are checked +/// as the verifiers check them, so ids of another length or shape never come +/// back. +pub fn claimed_reply_ids(frame: &Value) -> Result<([u8; 16], [u8; 48]), FrameError> { + let frame_type = frame.get("frame_type").map(text_of).unwrap_or_default(); + let (object_name, routes, table) = match (frame.get("reply"), frame.get("relay_error")) { + (Some(_), _) if frame_type == RESULT || frame_type == ERROR => { + ("reply", REPLY_ROUTES, reply_table(&frame_type)) + } + (_, Some(_)) if frame_type == ERROR || frame_type == STREAM_ERROR => ( + "relay_error", + RELAY_ERROR_ROUTES, + relay_error_table(&frame_type), + ), + _ => return Err(FrameError::Malformed), + }; + let types: &'static [&'static str] = match frame_type.as_str() { + RESULT => &[RESULT], + ERROR => &[ERROR], + _ => &[STREAM_ERROR], + }; + let (_, object) = received_frame(frame, object_name, Rule::CarriedObject, routes, types) + .ok_or(FrameError::Malformed)?; + let parsed = Object::from_value(&object).map_err(|_| FrameError::Malformed)?; + let tbs = cbor::decode(&parsed.tbs).map_err(|_| FrameError::Malformed)?; + let fields = read_fields(&tbs, &table).ok_or(FrameError::Malformed)?; + if !has_fields(&fields, &["frame_type", "request_id", "request_hash"]) { + return Err(FrameError::Malformed); + } + Ok((fixed(&fields["request_id"]), fixed(&fields["request_hash"]))) +} + +/// A frame of `frame_type` carrying `object` under `object_name`, with the +/// routing field `route_name` when there is one. +fn routed_frame( + frame_type: &str, + object_name: &str, + object: &Object, + route_name: &str, + route: Option>, +) -> Value { + let mut entries = vec![ + entry("version", Value::Int(i128::from(PROTOCOL_VERSION))), + entry("frame_type", Value::text(frame_type)), + entry(object_name, object.to_value()), + ]; + if let Some(route) = route { + entries.push(entry(route_name, Value::Bytes(route))); + } + Value::Map(entries) +} diff --git a/src/frame/request.rs b/src/frame/request.rs new file mode 100644 index 0000000..8a77475 --- /dev/null +++ b/src/frame/request.rs @@ -0,0 +1,290 @@ +//! Requests (D25): a CALL or STREAM_OPEN, a signed object under +//! MACULA-PQ-REQUEST-V1 by the caller's identity key, with routing fields +//! outside the signature. + +use sha2::{Digest, Sha384}; + +use crate::cbor::Value; +use crate::node_key::{node_id_of, NodeKey}; +use crate::profile::Profile; +use crate::signed_object::{sign_object, verify_object}; + +use super::{ + bounded_text, check_payload, entry, fixed, has_fields, identity_signer, object_refusal, + protocol_uint, read_fields, received_frame, text_of, uint, FrameError, Rule, StreamMode, + MAX_PROCEDURE_BYTES, MAX_PROTOCOL_INT, PROTOCOL_VERSION, REQUEST_LABEL, +}; + +/// The bound on a request's proofs (D7, chain transport): eight tokens, 256 +/// KiB in all, none repeated. +pub const MAX_PROOFS: usize = 8; +pub const MAX_PROOFS_BYTES: usize = 256 * 1024; + +const CALL: &str = "call"; +const STREAM_OPEN: &str = "stream_open"; + +/// A request's frame type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestType { + Call, + StreamOpen, +} + +impl RequestType { + fn name(self) -> &'static str { + match self { + RequestType::Call => CALL, + RequestType::StreamOpen => STREAM_OPEN, + } + } +} + +/// A request as its caller gives it: `mode` is a STREAM_OPEN's and `None` for +/// a CALL; `token` is `None` when the request carries none; `proofs` are the +/// tokens of the delegation chain the token rests on, empty for none; +/// `source_route` and `retry_budget` are routing fields outside the +/// signature. +#[derive(Debug, Clone, PartialEq)] +pub struct RequestSpec { + pub request_id: [u8; 16], + pub realm: [u8; 32], + pub procedure: String, + pub target: [u8; 32], + pub deadline: u64, + pub payload: Value, + pub mode: Option, + pub token: Option>, + pub proofs: Vec>, + pub source_route: Option>, + pub retry_budget: Option, +} + +/// A CALL or STREAM_OPEN whose request verified: its fields, the caller's key +/// as carried, and `request_hash`, the SHA-384 of its tbs, which replies and +/// stream frames name. +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedRequest { + pub frame_type: RequestType, + pub key: Vec, + pub request_hash: [u8; 48], + pub caller: [u8; 32], + pub request_id: [u8; 16], + pub realm: [u8; 32], + pub procedure: String, + pub target: [u8; 32], + pub deadline: u64, + pub payload: Value, + pub mode: Option, + pub token: Option>, + pub proofs: Option>>, +} + +/// Signs a CALL with the caller's identity key: caller is the key's key id. +/// Refused, in this order: a key that is not an identity key, a procedure over +/// 512 bytes, a payload the wire cannot carry, a deadline or retry budget of +/// 2^53 or more, or a stream mode, which a CALL does not carry; then proofs +/// outside their bound. +pub fn sign_call(spec: &RequestSpec, key: &NodeKey) -> Result { + sign_request(RequestType::Call, spec, key) +} + +/// Signs a STREAM_OPEN, which carries `spec.mode`, with [`sign_call`]'s +/// checks, the last of them refusing no mode. +pub fn sign_stream_open(spec: &RequestSpec, key: &NodeKey) -> Result { + sign_request(RequestType::StreamOpen, spec, key) +} + +fn sign_request( + frame_type: RequestType, + spec: &RequestSpec, + key: &NodeKey, +) -> Result { + identity_signer(key)?; + bounded_text("procedure", spec.procedure.as_bytes(), MAX_PROCEDURE_BYTES)?; + check_payload(&spec.payload)?; + if spec.deadline >= MAX_PROTOCOL_INT || spec.retry_budget.is_some_and(|b| b >= MAX_PROTOCOL_INT) + { + return Err(FrameError::OutOfRange( + "a deadline or retry budget of 2^53 or more".into(), + )); + } + match (frame_type, spec.mode) { + (RequestType::Call, Some(_)) => { + return Err(FrameError::OutOfRange( + "a CALL carries no stream mode".into(), + )) + } + (RequestType::StreamOpen, None) => { + return Err(FrameError::OutOfRange( + "a STREAM_OPEN carries one of the three stream modes".into(), + )) + } + _ => {} + } + let proofs = proofs_value(&spec.proofs); + if !proofs_within_bound(&proofs) { + return Err(FrameError::ProofsOutOfBound); + } + let mut fields = vec![ + entry("frame_type", Value::text(frame_type.name())), + entry("caller", Value::Bytes(key.key_id().to_vec())), + entry("request_id", Value::Bytes(spec.request_id.to_vec())), + entry("realm", Value::Bytes(spec.realm.to_vec())), + entry("procedure", Value::text(spec.procedure.clone())), + entry("target", Value::Bytes(spec.target.to_vec())), + entry("deadline", uint(spec.deadline)), + entry("payload", spec.payload.clone()), + ]; + if let Some(mode) = spec.mode { + fields.push(entry("mode", Value::text(mode.name()))); + } + if let Some(token) = &spec.token { + fields.push(entry("token", Value::Bytes(token.clone()))); + } + if !spec.proofs.is_empty() { + fields.push(entry("proofs", proofs)); + } + let request = sign_object(REQUEST_LABEL, &fields, key).map_err(object_refusal)?; + let mut frame = vec![ + entry("version", Value::Int(i128::from(PROTOCOL_VERSION))), + entry("frame_type", Value::text(frame_type.name())), + entry("request", request.to_value()), + ]; + if let Some(route) = &spec.source_route { + frame.push(entry("source_route", Value::Bytes(route.clone()))); + } + if let Some(budget) = spec.retry_budget { + frame.push(entry("retry_budget", uint(budget))); + } + Ok(Value::Map(frame)) +} + +const REQUEST_ROUTES: &[(&str, Rule)] = &[ + ("source_route", Rule::AnyBytes), + ("retry_budget", Rule::ProtocolUint), +]; + +/// Verifies a received CALL or STREAM_OPEN under the connection's `profile`: +/// the frame's shape, the request's signature and fields, and caller as the +/// key id of its key. A station checks this before it routes, and a provider +/// before its own checks, which stay with the caller: its node_id as target, +/// the deadline window, replays and tokens. +pub fn verify_request(frame: &Value, profile: Profile) -> Result { + let (frame_type, object) = received_frame( + frame, + "request", + Rule::CarriedObject, + REQUEST_ROUTES, + &[CALL, STREAM_OPEN], + ) + .ok_or(FrameError::Malformed)?; + let frame_type = if frame_type == CALL { + RequestType::Call + } else { + RequestType::StreamOpen + }; + let verified = verify_object(REQUEST_LABEL, &object, profile).map_err(object_refusal)?; + let fields = + read_fields(&verified.fields, &request_table(frame_type)).ok_or(FrameError::Malformed)?; + let has_mode = fields.contains_key("mode"); + if !has_fields( + &fields, + &[ + "frame_type", + "caller", + "request_id", + "realm", + "procedure", + "target", + "deadline", + "payload", + ], + ) || has_mode != (frame_type == RequestType::StreamOpen) + { + return Err(FrameError::Malformed); + } + let request = VerifiedRequest { + frame_type, + request_hash: Sha384::digest(&verified.tbs).into(), + caller: fixed(&fields["caller"]), + request_id: fixed(&fields["request_id"]), + realm: fixed(&fields["realm"]), + procedure: text_of(&fields["procedure"]), + target: fixed(&fields["target"]), + deadline: protocol_uint(&fields["deadline"]).unwrap_or(0), + payload: fields["payload"].clone(), + mode: fields + .get("mode") + .and_then(|m| StreamMode::parse(&text_of(m))), + token: fields.get("token").map(super::bytes_of), + proofs: fields.get("proofs").map(|p| match p { + Value::List(items) => items.iter().map(super::bytes_of).collect(), + _ => Vec::new(), + }), + key: verified.key, + }; + if request.caller != node_id_of(&request.key, profile) { + return Err(FrameError::KeyIdMismatch); + } + Ok(request) +} + +fn request_table(frame_type: RequestType) -> Vec<(&'static str, Rule)> { + vec![ + ( + "frame_type", + Rule::TextIn(match frame_type { + RequestType::Call => &[CALL], + RequestType::StreamOpen => &[STREAM_OPEN], + }), + ), + ("alg", Rule::Any), + ("caller", Rule::BytesOf(32)), + ("request_id", Rule::BytesOf(16)), + ("realm", Rule::BytesOf(32)), + ("procedure", Rule::TextWithin(MAX_PROCEDURE_BYTES)), + ("target", Rule::BytesOf(32)), + ("deadline", Rule::ProtocolUint), + ("payload", Rule::Any), + ( + "mode", + Rule::TextIn(&["server_stream", "client_stream", "bidi"]), + ), + ("token", Rule::AnyBytes), + ("proofs", Rule::Proofs), + ] +} + +/// Whether `fields` read as a CALL's under the request table, where a +/// delegation chain's proofs are bounded: the reading the shared decoding +/// rule vectors name `request_fields`. +pub fn request_fields_accepted(fields: &Value) -> bool { + read_fields(fields, &request_table(RequestType::Call)).is_some() +} + +fn proofs_value(proofs: &[Vec]) -> Value { + Value::List(proofs.iter().map(|p| Value::Bytes(p.clone())).collect()) +} + +/// macula's bytes_set rule for proofs: a list of at most [`MAX_PROOFS`] byte +/// strings, [`MAX_PROOFS_BYTES`] in all, none repeated. +pub(super) fn proofs_within_bound(v: &Value) -> bool { + let Value::List(items) = v else { + return false; + }; + if items.len() > MAX_PROOFS { + return false; + } + let mut seen = std::collections::HashSet::with_capacity(items.len()); + let mut total = 0; + for item in items { + let Value::Bytes(b) = item else { + return false; + }; + if !seen.insert(b.as_slice()) { + return false; + } + total += b.len(); + } + total <= MAX_PROOFS_BYTES +} diff --git a/src/frame/stream.rs b/src/frame/stream.rs new file mode 100644 index 0000000..e8da719 --- /dev/null +++ b/src/frame/stream.rs @@ -0,0 +1,494 @@ +//! Stream frames (D25 item 5): STREAM_DATA, STREAM_END, STREAM_ERROR and +//! STREAM_REPLY. A provider's are signed objects under MACULA-PQ-STREAM-V1 +//! that carry the provider's key on the first frame, seq 0, and leave it out +//! after; a caller's are held objects under MACULA-PQ-CALLER-STREAM-V1, +//! verified with the key its STREAM_OPEN carried. Each side's seq runs from 0 +//! without a gap, and nothing follows a side's STREAM_END. + +use crate::cbor::Value; +use crate::node_key::{node_id_of, NodeKey}; +use crate::profile::Profile; +use crate::signed_object::{ + sign_held_object, sign_object, verify_held_object, verify_object, VerifiedObject, +}; + +use super::{ + bounded_text, check_payload, entry, fixed, has_fields, identity_signer, names_request, + object_refusal, protocol_uint, read_fields, received_frame, text_of, uint, FrameError, + RequestType, Rule, VerifiedRequest, CALLER_STREAM_LABEL, MAX_ERROR_CODE_BYTES, + MAX_ERROR_TEXT_BYTES, MAX_PROTOCOL_INT, PROTOCOL_VERSION, STREAM_LABEL, +}; + +const STREAM_DATA: &str = "stream_data"; +const STREAM_END: &str = "stream_end"; +const STREAM_ERROR: &str = "stream_error"; +const STREAM_REPLY: &str = "stream_reply"; + +/// Who pushes data on a stream: the provider (ServerStream), the caller +/// (ClientStream), or both (Bidi). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamMode { + ServerStream, + ClientStream, + Bidi, +} + +impl StreamMode { + pub fn name(self) -> &'static str { + match self { + StreamMode::ServerStream => "server_stream", + StreamMode::ClientStream => "client_stream", + StreamMode::Bidi => "bidi", + } + } + + pub fn parse(name: &str) -> Option { + match name { + "server_stream" => Some(StreamMode::ServerStream), + "client_stream" => Some(StreamMode::ClientStream), + "bidi" => Some(StreamMode::Bidi), + _ => None, + } + } +} + +/// How a STREAM_DATA's body reads: raw bytes, or a structured value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamEncoding { + Raw, + Msgpack, +} + +impl StreamEncoding { + fn name(self) -> &'static str { + match self { + StreamEncoding::Raw => "raw", + StreamEncoding::Msgpack => "msgpack", + } + } +} + +/// Which directions a STREAM_END closes: this side's sending, or both. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StreamRole { + Send, + Both, +} + +impl StreamRole { + fn name(self) -> &'static str { + match self { + StreamRole::Send => "send", + StreamRole::Both => "both", + } + } +} + +/// A stream frame's own fields, each with its sender's seq on the stream. +#[derive(Debug, Clone, PartialEq)] +pub enum StreamFields { + /// One chunk: a raw body is a byte string, a msgpack body any value the + /// wire carries. + Data { + seq: u64, + encoding: StreamEncoding, + body: Value, + }, + /// The last frame of its sender's side. + End { seq: u64, role: StreamRole }, + /// An error: a code of at most 64 bytes and a message of at most 256. + Error { + seq: u64, + code: String, + message: String, + }, + /// A provider's terminal value for a client_stream or bidi stream. + Reply { seq: u64, payload: Value }, +} + +impl StreamFields { + fn seq(&self) -> u64 { + match self { + StreamFields::Data { seq, .. } + | StreamFields::End { seq, .. } + | StreamFields::Error { seq, .. } + | StreamFields::Reply { seq, .. } => *seq, + } + } + + fn frame_type(&self) -> &'static str { + match self { + StreamFields::Data { .. } => STREAM_DATA, + StreamFields::End { .. } => STREAM_END, + StreamFields::Error { .. } => STREAM_ERROR, + StreamFields::Reply { .. } => STREAM_REPLY, + } + } +} + +/// A stream frame that verified against its stream: its signer's key id and +/// its fields. +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedStreamFrame { + pub signer: [u8; 32], + pub fields: StreamFields, +} + +/// What a verifier holds for one stream: the verified STREAM_OPEN and, for +/// each side, the next seq and whether it has ended, and for the provider the +/// key and signer its first frame carried. Each verification returns the +/// next state, which replaces this one: a state has one owner. +#[derive(Debug, Clone, PartialEq)] +pub struct StreamState { + open: VerifiedRequest, + mode: StreamMode, + provider: Side, + caller: Side, +} + +#[derive(Debug, Clone, PartialEq, Default)] +struct Side { + next: u64, + ended: bool, + key: Option>, + signer: [u8; 32], +} + +/// The state a verifier starts a stream with: nothing seen from either side +/// yet. `open` must be a verified STREAM_OPEN. +pub fn open_stream(open: &VerifiedRequest) -> Result { + match (open.frame_type, open.mode) { + (RequestType::StreamOpen, Some(mode)) => Ok(StreamState { + open: open.clone(), + mode, + provider: Side::default(), + caller: Side::default(), + }), + _ => Err(FrameError::OutOfRange( + "a stream opens on a STREAM_OPEN".into(), + )), + } +} + +/// Signs a provider's stream frame for a verified STREAM_OPEN with the +/// provider's identity key, whose key id must be the STREAM_OPEN's target. +/// The first frame, seq 0, carries the key; the later ones leave it out. +pub fn sign_provider_stream( + fields: &StreamFields, + open: &VerifiedRequest, + key: &NodeKey, +) -> Result { + let tbs = stream_build(fields, open, key, false)?; + let object = if fields.seq() == 0 { + sign_object(STREAM_LABEL, &tbs, key) + .map_err(object_refusal)? + .to_value() + } else { + sign_held_object(STREAM_LABEL, &tbs, key) + .map_err(object_refusal)? + .to_value() + }; + Ok(stream_frame(fields.frame_type(), "stream", object)) +} + +/// Signs a caller's stream frame for a verified STREAM_OPEN with the caller's +/// identity key, whose key id must be the STREAM_OPEN's caller. A caller sends +/// no STREAM_REPLY, and no STREAM_DATA in a server_stream. +pub fn sign_caller_stream( + fields: &StreamFields, + open: &VerifiedRequest, + key: &NodeKey, +) -> Result { + let tbs = stream_build(fields, open, key, true)?; + let object = sign_held_object(CALLER_STREAM_LABEL, &tbs, key).map_err(object_refusal)?; + Ok(stream_frame( + fields.frame_type(), + "caller_stream", + object.to_value(), + )) +} + +/// A stream frame build's checks in macula's order: the key against its +/// side's sender, the frame types its side sends, the text, the body or +/// payload, then the ranges. Returns the signed fields. +fn stream_build( + fields: &StreamFields, + open: &VerifiedRequest, + key: &NodeKey, + caller: bool, +) -> Result, FrameError> { + identity_signer(key)?; + let sender = if caller { open.caller } else { open.target }; + if open.frame_type != RequestType::StreamOpen || open.mode.is_none() || key.key_id() != sender { + return Err(FrameError::Unsignable); + } + if caller { + match fields { + StreamFields::Reply { .. } => { + return Err(FrameError::NotAllowed("a caller's STREAM_REPLY".into())) + } + StreamFields::Data { .. } if open.mode == Some(StreamMode::ServerStream) => { + return Err(FrameError::NotAllowed( + "a caller's STREAM_DATA in a server_stream".into(), + )) + } + _ => {} + } + } + if let StreamFields::Error { code, message, .. } = fields { + bounded_text("code", code.as_bytes(), MAX_ERROR_CODE_BYTES)?; + bounded_text("message", message.as_bytes(), MAX_ERROR_TEXT_BYTES)?; + } + match fields { + StreamFields::Data { + encoding: StreamEncoding::Msgpack, + body, + .. + } => check_payload(body)?, + StreamFields::Reply { payload, .. } => check_payload(payload)?, + _ => {} + } + let mut tbs = match fields { + StreamFields::Data { encoding, body, .. } => { + if *encoding == StreamEncoding::Raw && !matches!(body, Value::Bytes(_)) { + return Err(FrameError::OutOfRange( + "a raw body that is not a byte string".into(), + )); + } + vec![ + entry("encoding", Value::text(encoding.name())), + entry("body", body.clone()), + ] + } + StreamFields::End { role, .. } => vec![entry("role", Value::text(role.name()))], + StreamFields::Error { code, message, .. } => { + vec![ + entry("code", Value::text(code.clone())), + entry("message", Value::text(message.clone())), + ] + } + StreamFields::Reply { payload, .. } => vec![entry("payload", payload.clone())], + }; + if fields.seq() >= MAX_PROTOCOL_INT { + return Err(FrameError::OutOfRange("a seq of 2^53 or more".into())); + } + tbs.extend([ + entry("frame_type", Value::text(fields.frame_type())), + entry("request_id", Value::Bytes(open.request_id.to_vec())), + entry("request_hash", Value::Bytes(open.request_hash.to_vec())), + entry("signer", Value::Bytes(key.key_id().to_vec())), + entry("seq", uint(fields.seq())), + ]); + Ok(tbs) +} + +fn stream_frame(frame_type: &str, object_name: &str, object: Value) -> Value { + Value::Map(vec![ + entry("version", Value::Int(i128::from(PROTOCOL_VERSION))), + entry("frame_type", Value::text(frame_type)), + entry(object_name, object), + ]) +} + +const PROVIDER_TYPES: &[&str] = &[STREAM_DATA, STREAM_END, STREAM_ERROR, STREAM_REPLY]; +const CALLER_TYPES: &[&str] = &[STREAM_DATA, STREAM_END, STREAM_ERROR]; + +/// Verifies a provider's received stream frame against its stream's state, +/// and returns the frame and the stream's next state. Before the provider's +/// first frame the state holds no provider key, so a frame without one is out +/// of order. The first frame's signer is the key id of the key it carries and +/// the STREAM_OPEN's target, with seq 0; later frames verify with that key, +/// name that signer and carry no key. +pub fn verify_provider_stream( + frame: &Value, + state: &StreamState, + profile: Profile, +) -> Result<(VerifiedStreamFrame, StreamState), FrameError> { + let (frame_type, object) = + received_frame(frame, "stream", Rule::StreamObject, &[], PROVIDER_TYPES) + .ok_or(FrameError::Malformed)?; + if state.provider.ended { + return Err(FrameError::StreamEnded); + } + let carries_key = object.get("key").is_some(); + let Some(held_key) = &state.provider.key else { + return provider_first(&frame_type, &object, carries_key, state, profile); + }; + let verified = if carries_key { + verify_object(STREAM_LABEL, &object, profile) + } else { + verify_held_object(STREAM_LABEL, &object, held_key, profile) + } + .map_err(object_refusal)?; + if &verified.key != held_key { + return Err(FrameError::KeyIdMismatch); + } + let (signer, fields, read) = stream_read(&frame_type, &verified)?; + if signer != state.provider.signer { + return Err(FrameError::KeyIdMismatch); + } + if !names_request(&read, &state.open) { + return Err(FrameError::RequestMismatch); + } + if fields.seq() != state.provider.next { + return Err(FrameError::SeqMismatch); + } + if carries_key { + return Err(FrameError::Malformed); + } + let mut next = state.clone(); + next.provider.next = fields.seq() + 1; + next.provider.ended = frame_type == STREAM_END; + Ok((VerifiedStreamFrame { signer, fields }, next)) +} + +fn provider_first( + frame_type: &str, + object: &Value, + carries_key: bool, + state: &StreamState, + profile: Profile, +) -> Result<(VerifiedStreamFrame, StreamState), FrameError> { + if !carries_key { + return Err(FrameError::SeqMismatch); + } + let verified = verify_object(STREAM_LABEL, object, profile).map_err(object_refusal)?; + let (signer, fields, read) = stream_read(frame_type, &verified)?; + if signer != node_id_of(&verified.key, profile) { + return Err(FrameError::KeyIdMismatch); + } + if !names_request(&read, &state.open) { + return Err(FrameError::RequestMismatch); + } + if signer != state.open.target { + return Err(FrameError::NotTheTarget); + } + if fields.seq() != 0 { + return Err(FrameError::SeqMismatch); + } + let mut next = state.clone(); + next.provider = Side { + next: 1, + ended: frame_type == STREAM_END, + key: Some(verified.key), + signer, + }; + Ok((VerifiedStreamFrame { signer, fields }, next)) +} + +/// Verifies a caller's received stream frame against its stream's state, +/// with the STREAM_OPEN's key, and returns the frame and the next state. A +/// caller sends no STREAM_DATA in a server_stream. +pub fn verify_caller_stream( + frame: &Value, + state: &StreamState, + profile: Profile, +) -> Result<(VerifiedStreamFrame, StreamState), FrameError> { + let (frame_type, object) = + received_frame(frame, "caller_stream", Rule::HeldObject, &[], CALLER_TYPES) + .ok_or(FrameError::Malformed)?; + if state.caller.ended { + return Err(FrameError::StreamEnded); + } + let verified = verify_held_object(CALLER_STREAM_LABEL, &object, &state.open.key, profile) + .map_err(object_refusal)?; + let (signer, fields, read) = stream_read(&frame_type, &verified)?; + if frame_type == STREAM_DATA && state.mode == StreamMode::ServerStream { + return Err(FrameError::Malformed); + } + if signer != state.open.caller { + return Err(FrameError::KeyIdMismatch); + } + if !names_request(&read, &state.open) { + return Err(FrameError::RequestMismatch); + } + if fields.seq() != state.caller.next { + return Err(FrameError::SeqMismatch); + } + let mut next = state.clone(); + next.caller.next = fields.seq() + 1; + next.caller.ended = frame_type == STREAM_END; + Ok((VerifiedStreamFrame { signer, fields }, next)) +} + +/// A stream frame's signed fields read through its type's table: frame_type, +/// request_id, request_hash, signer and seq, and exactly the fields of its +/// type, a raw body a byte string. +fn stream_read( + frame_type: &str, + verified: &VerifiedObject, +) -> Result<([u8; 32], StreamFields, super::Fields), FrameError> { + let types: &'static [&'static str] = match frame_type { + STREAM_DATA => &[STREAM_DATA], + STREAM_END => &[STREAM_END], + STREAM_ERROR => &[STREAM_ERROR], + _ => &[STREAM_REPLY], + }; + let table = [ + ("frame_type", Rule::TextIn(types)), + ("alg", Rule::Any), + ("request_id", Rule::BytesOf(16)), + ("request_hash", Rule::BytesOf(48)), + ("signer", Rule::BytesOf(32)), + ("seq", Rule::ProtocolUint), + ("encoding", Rule::TextIn(&["raw", "msgpack"])), + ("body", Rule::Any), + ("role", Rule::TextIn(&["send", "both"])), + ("code", Rule::TextWithin(MAX_ERROR_CODE_BYTES)), + ("message", Rule::TextWithin(MAX_ERROR_TEXT_BYTES)), + ("payload", Rule::Any), + ]; + let fields = read_fields(&verified.fields, &table).ok_or(FrameError::Malformed)?; + if !has_fields( + &fields, + &["frame_type", "request_id", "request_hash", "signer", "seq"], + ) { + return Err(FrameError::Malformed); + } + let own: &[&str] = match frame_type { + STREAM_DATA => &["encoding", "body"], + STREAM_END => &["role"], + STREAM_ERROR => &["code", "message"], + _ => &["payload"], + }; + let carried = 5 + own.len() + usize::from(fields.contains_key("alg")); + if !has_fields(&fields, own) || fields.len() != carried { + return Err(FrameError::Malformed); + } + let seq = protocol_uint(&fields["seq"]).unwrap_or(0); + let parsed = match frame_type { + STREAM_DATA => { + let encoding = if text_of(&fields["encoding"]) == "raw" { + StreamEncoding::Raw + } else { + StreamEncoding::Msgpack + }; + if encoding == StreamEncoding::Raw && !matches!(fields["body"], Value::Bytes(_)) { + return Err(FrameError::Malformed); + } + StreamFields::Data { + seq, + encoding, + body: fields["body"].clone(), + } + } + STREAM_END => StreamFields::End { + seq, + role: if text_of(&fields["role"]) == "send" { + StreamRole::Send + } else { + StreamRole::Both + }, + }, + STREAM_ERROR => StreamFields::Error { + seq, + code: text_of(&fields["code"]), + message: text_of(&fields["message"]), + }, + _ => StreamFields::Reply { + seq, + payload: fields["payload"].clone(), + }, + }; + Ok((fixed(&fields["signer"]), parsed, fields)) +} diff --git a/src/handshake.rs b/src/handshake.rs new file mode 100644 index 0000000..9723f9e --- /dev/null +++ b/src/handshake.rs @@ -0,0 +1,677 @@ +//! macula 12's post-quantum connection handshake, as macula_handshake and +//! macula-go build and check it: the opener, challenge, CONNECT, HELLO and +//! status frames (D16, D22), as CBOR bytes without the length prefix. +//! +//! The client opens with an opener. The station answers with a challenge: its +//! carried identity key, its TLS binding and status statement, and a fresh +//! nonce. The client checks the challenge against the node_id it dialed and +//! the leaf it received, before it signs anything, and answers with CONNECT: +//! its identity and CONNECT keys, the CONNECT binding and status statement, +//! and a proof by the CONNECT key. The station checks CONNECT, the puzzle +//! before any signature, and answers with HELLO. Status frames renew a peer's +//! statement on the open connection. +//! +//! Every frame decodes under the decoding rule and must hold exactly the keys +//! of its type, each of its type and length. Close reasons are local: a +//! refusing station sends only a HELLO with one coarse refusal code. + +use std::fmt; + +use sha2::{Digest, Sha384}; + +use crate::binding::{ + verify_connect_binding, verify_status, verify_tls_binding, BindingError, SignedTbs, +}; +use crate::cbor::{self, Value}; +use crate::node_key::{ + carried_key_well_formed, node_id_of, puzzle_solved, signature_size, verify, KeyError, NodeKey, +}; +use crate::profile::Profile; + +/// The handshake's frame version: 4, as macula 12's. A peer on another version +/// hears `unsupported_version`. +pub const VERSION: i64 = 4; + +const NONCE_SIZE: usize = 32; +const MAX_PROTOCOL_INT: i64 = 1 << 53; +const MLDSA_KEY_SIZE: usize = 2592; +const CONNECT_PROOF_LABEL: &[u8] = b"MACULA-PQ-CONNECT-PROOF-V1"; + +const OPENER_KEYS: &[&str] = &["frame_type", "version"]; +const CHALLENGE_KEYS: &[&str] = &[ + "frame_type", + "identity_key", + "nonce", + "profile", + "tls_binding", + "tls_status", + "version", +]; +/// CONNECT always holds member_endorsement, empty when the node has none, as +/// macula 12's: one layout, so the wire does not tell whether a node holds an +/// endorsement or a station asks for one. +const CONNECT_KEYS: &[&str] = &[ + "capabilities", + "connect_binding", + "connect_key", + "connect_status", + "frame_type", + "identity_key", + "member_endorsement", + "proof", + "version", +]; +const HELLO_ACCEPTED_KEYS: &[&str] = &["accepted", "capabilities", "frame_type", "version"]; +const HELLO_REFUSED_KEYS: &[&str] = &[ + "accepted", + "capabilities", + "frame_type", + "refusal_code", + "version", +]; +const STATUS_KEYS: &[&str] = &["frame_type", "statement", "version"]; + +/// The one coarse reason a refusing HELLO carries. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefusalCode { + /// Frames that are not version 4. + UnsupportedVersion, + /// A node_id that misses the puzzle, which the client can check itself. + PuzzleInvalid, + /// A CONNECT that failed any other check. + NotAccepted, +} + +impl RefusalCode { + fn name(self) -> &'static str { + match self { + RefusalCode::UnsupportedVersion => "unsupported_version", + RefusalCode::PuzzleInvalid => "puzzle_invalid", + RefusalCode::NotAccepted => "not_accepted", + } + } + + fn parse(name: &str) -> Option { + match name { + "unsupported_version" => Some(RefusalCode::UnsupportedVersion), + "puzzle_invalid" => Some(RefusalCode::PuzzleInvalid), + "not_accepted" => Some(RefusalCode::NotAccepted), + _ => None, + } + } +} + +/// The handshake's close reasons, named as macula names them. A binding or +/// status statement that fails its check closes with its [`BindingError`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HandshakeError { + /// A frame of another type than the one expected next. + UnexpectedFrame, + /// A frame of another version than 4. + UnsupportedVersion, + /// A frame that does not decode exactly, or a carried key or proof of the + /// wrong form. + Malformed, + /// A challenge that names another profile. + ProfileMismatch, + /// A key that would serve two purposes: a CONNECT key that shares a half + /// with its identity key, or a key found in the leaf. + KeyPurposeReuse, + /// A station whose node_id is not the one dialed. + PeerIdentityMismatch { + expected: [u8; 32], + derived: [u8; 32], + }, + /// A client whose node_id does not meet the puzzle. + PuzzleInvalid, + /// A CONNECT proof that does not verify. + ProofInvalid, + /// A HELLO that refuses the connection, with its code. + Refused(RefusalCode), + /// A station session with a puzzle difficulty the design does not have. + InvalidStationSession, + /// A binding or status statement that did not verify. + Binding(BindingError), + /// A key that could not sign, or randomness that could not be drawn. + Key(KeyError), +} + +impl fmt::Display for HandshakeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + HandshakeError::UnexpectedFrame => f.write_str("unexpected frame"), + HandshakeError::UnsupportedVersion => f.write_str("unsupported frame version"), + HandshakeError::Malformed => f.write_str("malformed frame"), + HandshakeError::ProfileMismatch => f.write_str("the peer names another profile"), + HandshakeError::KeyPurposeReuse => f.write_str("a key would serve two purposes"), + HandshakeError::PeerIdentityMismatch { expected, derived } => write!( + f, + "dialed node_id {}, but the station's key derives {}", + hex_of(expected), + hex_of(derived) + ), + HandshakeError::PuzzleInvalid => f.write_str("the node_id does not meet the puzzle"), + HandshakeError::ProofInvalid => f.write_str("the CONNECT proof does not verify"), + HandshakeError::Refused(code) => { + write!(f, "the station refused the connection: {}", code.name()) + } + HandshakeError::InvalidStationSession => { + f.write_str("the station session has an unknown puzzle difficulty") + } + HandshakeError::Binding(e) => write!(f, "{e}"), + HandshakeError::Key(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for HandshakeError {} + +impl From for HandshakeError { + fn from(e: BindingError) -> Self { + HandshakeError::Binding(e) + } +} + +/// How a station treats a client's node_id puzzle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PuzzleMode { + /// The puzzle is not checked. + Off, + /// An unsolved puzzle is accepted and reported. + LogOnly, + /// An unsolved puzzle is refused. + Enforce, +} + +/// What a station found of a client's puzzle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PuzzleResult { + Solved, + Unsolved, + NotChecked, +} + +/// The client's first frame on the control stream. It carries nothing that +/// relates to identity. +pub fn opener() -> Vec { + encode_frame("opener", Vec::new()) +} + +/// The station's check of the first frame. +pub fn read_opener(frame: &[u8]) -> Result<(), HandshakeError> { + decode(frame, "opener", &[OPENER_KEYS]).map(|_| ()) +} + +/// What a station precomputes for its challenges: its carried identity key, +/// and the TLS binding and status statement for the leaf it presents. +#[derive(Debug, Clone)] +pub struct StationMaterial { + pub profile: Profile, + pub identity_key: Vec, + pub tls_binding: SignedTbs, + pub tls_status: SignedTbs, +} + +/// A station's challenge, with a fresh nonce. The station keeps the bytes it +/// sends, for the proof check. +pub fn challenge(m: &StationMaterial) -> Result, HandshakeError> { + let mut nonce = [0u8; NONCE_SIZE]; + aws_lc_rs::rand::fill(&mut nonce) + .map_err(|_| HandshakeError::Key(KeyError::RandomnessUnavailable))?; + Ok(encode_frame( + "challenge", + vec![ + entry("nonce", Value::Bytes(nonce.to_vec())), + entry("profile", Value::text(m.profile.name())), + entry("identity_key", Value::Bytes(m.identity_key.clone())), + entry("tls_binding", m.tls_binding.to_value()), + entry("tls_status", m.tls_status.to_value()), + ], + )) +} + +/// What a client brings to a handshake: its profile, the node_id it dialed, +/// the leaf DER it received in this TLS handshake, its carried identity key, +/// its CONNECT key with binding and status statement, its capability bits, +/// the time in milliseconds, and the realm membership endorsement CONNECT +/// carries, empty for a node that holds none. +pub struct ClientSession<'a> { + pub profile: Profile, + pub expected_node_id: [u8; 32], + pub leaf: &'a [u8], + pub identity_key: Vec, + pub connect_key: &'a NodeKey, + pub connect_binding: &'a SignedTbs, + pub connect_status: &'a SignedTbs, + pub capabilities: u64, + pub now_ms: i64, + pub member_endorsement: Vec, +} + +/// What a client knows of the station once it has checked the challenge. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Station { + pub node_id: [u8; 32], + pub identity_key: Vec, + pub tls_binding: SignedTbs, + pub status_expires_at: i64, + pub binding_not_after: i64, +} + +/// Checks a challenge and, when every check passes, returns the CONNECT to +/// send. It checks, in macula's order: the frame, the profile, the station's +/// carried key, that each key in view serves one purpose, the station's +/// node_id against the one dialed, the TLS binding against the leaf received, +/// and the status statement. It signs nothing before all of them pass. +pub fn answer_challenge( + challenge: &[u8], + s: &ClientSession<'_>, +) -> Result<(Vec, Station), HandshakeError> { + let f = decode(challenge, "challenge", &[CHALLENGE_KEYS])?; + let station_key = f.bytes("identity_key"); + let connect_key = s.connect_key.public_key(); + if f.text("profile") != s.profile.name() { + return Err(HandshakeError::ProfileMismatch); + } + if !carried_key_well_formed(station_key, s.profile) { + return Err(HandshakeError::Malformed); + } + if shares_a_half(&s.identity_key, &connect_key) + || in_leaf(station_key, s.leaf) + || in_leaf(&connect_key, s.leaf) + { + return Err(HandshakeError::KeyPurposeReuse); + } + let station_node_id = node_id_of(station_key, s.profile); + if station_node_id != s.expected_node_id { + return Err(HandshakeError::PeerIdentityMismatch { + expected: s.expected_node_id, + derived: station_node_id, + }); + } + let tls_binding = f.signed("tls_binding"); + let binding = verify_tls_binding(&tls_binding, station_key, s.profile, s.leaf, s.now_ms)?; + let expires_at = verify_status( + &f.signed("tls_status"), + &tls_binding, + station_key, + s.profile, + s.now_ms, + )?; + let client_node_id = node_id_of(&s.identity_key, s.profile); + let proof = s + .connect_key + .sign(&proof_message( + f.bytes("nonce"), + &station_node_id, + &client_node_id, + s.leaf, + challenge, + )) + .map_err(HandshakeError::Key)?; + let connect = encode_frame( + "connect", + vec![ + entry("identity_key", Value::Bytes(s.identity_key.clone())), + entry("connect_key", Value::Bytes(connect_key)), + entry("connect_binding", s.connect_binding.to_value()), + entry("connect_status", s.connect_status.to_value()), + entry("proof", Value::Bytes(proof)), + entry("capabilities", Value::Int(i128::from(s.capabilities))), + entry( + "member_endorsement", + Value::Bytes(s.member_endorsement.clone()), + ), + ], + ); + Ok(( + connect, + Station { + node_id: station_node_id, + identity_key: station_key.to_vec(), + tls_binding, + status_expires_at: expires_at, + binding_not_after: binding.not_after, + }, + )) +} + +/// What a station brings to a CONNECT check: its profile, the challenge bytes +/// it sent, the leaf DER this connection presented, its puzzle difficulty and +/// mode, its capability bits, and the time in milliseconds. +#[derive(Debug, Clone)] +pub struct StationSession { + pub profile: Profile, + pub challenge: Vec, + pub leaf: Vec, + pub puzzle_difficulty: u32, + pub puzzle_mode: PuzzleMode, + pub capabilities: u64, + pub now_ms: i64, +} + +/// What a station knows of an accepted client. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Client { + pub node_id: [u8; 32], + pub identity_key: Vec, + pub connect_key: Vec, + pub connect_binding: SignedTbs, + pub capabilities: u64, + pub status_expires_at: i64, + pub binding_not_after: i64, + pub puzzle: PuzzleResult, + /// The endorsement the CONNECT carried, empty when the client holds none. + /// Nothing here checks it: that is the station's policy. + pub member_endorsement: Vec, +} + +/// Checks a CONNECT, and returns the verdict with the HELLO to send. It +/// checks, in macula's order: the frame, the carried keys and the proof's +/// length, that each key serves one purpose, the puzzle on the derived node_id +/// before any signature, the CONNECT binding and status statement, and the +/// proof against the challenge this station sent and the leaf it presented. A +/// refusal is the local close reason, and the HELLO refuses with one coarse +/// code. +pub fn accept_connect( + connect: &[u8], + s: &StationSession, +) -> (Result, Vec) { + match check_connect(connect, s) { + Ok(client) => (Ok(client), hello(None, s.capabilities)), + Err(e) => { + let code = match e { + HandshakeError::UnsupportedVersion => RefusalCode::UnsupportedVersion, + HandshakeError::PuzzleInvalid => RefusalCode::PuzzleInvalid, + _ => RefusalCode::NotAccepted, + }; + (Err(e), hello(Some(code), s.capabilities)) + } + } +} + +fn check_connect(connect: &[u8], s: &StationSession) -> Result { + if s.puzzle_difficulty > 256 { + return Err(HandshakeError::InvalidStationSession); + } + let f = decode(connect, "connect", &[CONNECT_KEYS])?; + let (identity_key, connect_key, proof) = ( + f.bytes("identity_key"), + f.bytes("connect_key"), + f.bytes("proof"), + ); + if !carried_key_well_formed(identity_key, s.profile) + || !carried_key_well_formed(connect_key, s.profile) + || proof.len() != signature_size(s.profile) + { + return Err(HandshakeError::Malformed); + } + if shares_a_half(identity_key, connect_key) || in_leaf(connect_key, &s.leaf) { + return Err(HandshakeError::KeyPurposeReuse); + } + let node_id = node_id_of(identity_key, s.profile); + let puzzle = match s.puzzle_mode { + PuzzleMode::Off => PuzzleResult::NotChecked, + _ if puzzle_solved(&node_id, s.puzzle_difficulty) => PuzzleResult::Solved, + _ => PuzzleResult::Unsolved, + }; + if puzzle == PuzzleResult::Unsolved && s.puzzle_mode == PuzzleMode::Enforce { + return Err(HandshakeError::PuzzleInvalid); + } + let connect_binding = f.signed("connect_binding"); + let binding = verify_connect_binding( + &connect_binding, + identity_key, + s.profile, + connect_key, + s.now_ms, + )?; + let expires_at = verify_status( + &f.signed("connect_status"), + &connect_binding, + identity_key, + s.profile, + s.now_ms, + )?; + if !proof_verifies(s, &node_id, connect_key, proof) { + return Err(HandshakeError::ProofInvalid); + } + Ok(Client { + node_id, + identity_key: identity_key.to_vec(), + connect_key: connect_key.to_vec(), + connect_binding, + capabilities: f.uint("capabilities"), + status_expires_at: expires_at, + binding_not_after: binding.not_after, + puzzle, + member_endorsement: f.bytes("member_endorsement").to_vec(), + }) +} + +/// A CONNECT proof checked against the challenge the station sent and the +/// leaf it presented. The station's own challenge decodes: it built it. +fn proof_verifies( + s: &StationSession, + client_node_id: &[u8; 32], + connect_key: &[u8], + proof: &[u8], +) -> bool { + let Ok(challenge) = decode(&s.challenge, "challenge", &[CHALLENGE_KEYS]) else { + return false; + }; + let station_node_id = node_id_of(challenge.bytes("identity_key"), s.profile); + let message = proof_message( + challenge.bytes("nonce"), + &station_node_id, + client_node_id, + &s.leaf, + &s.challenge, + ); + verify(&message, proof, connect_key, s.profile) +} + +/// What the CONNECT proof signs: the label, a zero byte, the nonce, the +/// station's and the client's node_ids, the SHA-384 of the leaf DER and the +/// SHA-384 of the challenge bytes as received. +fn proof_message( + nonce: &[u8], + station_node_id: &[u8; 32], + client_node_id: &[u8; 32], + leaf: &[u8], + challenge: &[u8], +) -> Vec { + let mut out = Vec::with_capacity(CONNECT_PROOF_LABEL.len() + 1 + nonce.len() + 64 + 96); + out.extend_from_slice(CONNECT_PROOF_LABEL); + out.push(0); + out.extend_from_slice(nonce); + out.extend_from_slice(station_node_id); + out.extend_from_slice(client_node_id); + out.extend_from_slice(&Sha384::digest(leaf)); + out.extend_from_slice(&Sha384::digest(challenge)); + out +} + +fn hello(refusal: Option, capabilities: u64) -> Vec { + let mut entries = vec![ + entry("accepted", Value::Int(i128::from(refusal.is_none()))), + entry("capabilities", Value::Int(i128::from(capabilities))), + ]; + if let Some(code) = refusal { + entries.push(entry("refusal_code", Value::text(code.name()))); + } + encode_frame("hello", entries) +} + +/// The client's reading of HELLO: the station's capability bits, or +/// [`HandshakeError::Refused`] with its refusal code. +pub fn read_hello(frame: &[u8]) -> Result { + let f = decode(frame, "hello", &[HELLO_ACCEPTED_KEYS, HELLO_REFUSED_KEYS])?; + let accepted = f.int("accepted"); + match (accepted, f.0.get("refusal_code")) { + (1, None) => Ok(f.uint("capabilities")), + (0, Some(Value::Text(code))) => Err(HandshakeError::Refused( + RefusalCode::parse(code).ok_or(HandshakeError::Malformed)?, + )), + _ => Err(HandshakeError::Malformed), + } +} + +/// A status frame carrying a fresh status statement, sent at every reissue. +pub fn status_frame(statement: &SignedTbs) -> Vec { + encode_frame("status", vec![entry("statement", statement.to_value())]) +} + +/// What a connection checks a peer's status frames against: the profile, the +/// identity key and binding the handshake verified, and the time in +/// milliseconds. +#[derive(Debug, Clone)] +pub struct Peer { + pub profile: Profile, + pub identity_key: Vec, + pub binding: SignedTbs, + pub now_ms: i64, +} + +/// Checks a peer's status frame, and returns when its statement expires. +pub fn read_status(frame: &[u8], p: &Peer) -> Result { + let f = decode(frame, "status", &[STATUS_KEYS])?; + Ok(verify_status( + &f.signed("statement"), + &p.binding, + &p.identity_key, + p.profile, + p.now_ms, + )?) +} + +/// A decoded handshake frame's values by their keys. +struct Fields(std::collections::HashMap); + +impl Fields { + fn bytes(&self, key: &str) -> &[u8] { + match self.0.get(key) { + Some(Value::Bytes(b)) => b, + _ => &[], + } + } + + fn text(&self, key: &str) -> &str { + match self.0.get(key) { + Some(Value::Text(t)) => t, + _ => "", + } + } + + fn int(&self, key: &str) -> i128 { + match self.0.get(key) { + Some(Value::Int(n)) => *n, + _ => -1, + } + } + + fn uint(&self, key: &str) -> u64 { + u64::try_from(self.int(key)).unwrap_or(0) + } + + fn signed(&self, key: &str) -> SignedTbs { + self.0 + .get(key) + .and_then(|v| SignedTbs::from_value(v).ok()) + .unwrap_or(SignedTbs { + tbs: Vec::new(), + signature: Vec::new(), + }) + } +} + +/// A handshake frame read strictly, in macula's order: the decoding rule, the +/// version, the frame type, exactly the keys of one of the layouts, then the +/// type and length of every field. +fn decode(frame: &[u8], frame_type: &str, layouts: &[&[&str]]) -> Result { + let Ok(Value::Map(pairs)) = cbor::decode(frame) else { + return Err(HandshakeError::Malformed); + }; + let mut fields = std::collections::HashMap::with_capacity(pairs.len()); + let mut non_text = 0; + for (key, value) in pairs { + match key { + Value::Text(name) => { + fields.insert(name, value); + } + _ => non_text += 1, + } + } + match fields.get("version") { + Some(Value::Int(v)) if *v == i128::from(VERSION) => {} + Some(Value::Int(_)) => return Err(HandshakeError::UnsupportedVersion), + _ => return Err(HandshakeError::Malformed), + } + match fields.get("frame_type") { + Some(Value::Text(t)) if t == frame_type => {} + Some(Value::Text(_)) => return Err(HandshakeError::UnexpectedFrame), + _ => return Err(HandshakeError::Malformed), + } + let mut keys: Vec<&str> = fields.keys().map(String::as_str).collect(); + keys.sort_unstable(); + let has_layout = layouts.contains(&keys.as_slice()); + if non_text > 0 || !has_layout || !fields.iter().all(|(k, v)| field_typed(k, v)) { + return Err(HandshakeError::Malformed); + } + Ok(Fields(fields)) +} + +fn field_typed(key: &str, v: &Value) -> bool { + match key { + "version" | "frame_type" => true, + "profile" => matches!(v, Value::Text(_)), + "nonce" => matches!(v, Value::Bytes(b) if b.len() == NONCE_SIZE), + "identity_key" | "connect_key" | "proof" | "member_endorsement" => { + matches!(v, Value::Bytes(_)) + } + "tls_binding" | "tls_status" | "connect_binding" | "connect_status" | "statement" => { + SignedTbs::from_value(v).is_ok() + } + "capabilities" => { + matches!(v, Value::Int(n) if *n >= 0 && *n < i128::from(MAX_PROTOCOL_INT)) + } + "accepted" => matches!(v, Value::Int(0 | 1)), + "refusal_code" => matches!(v, Value::Text(t) if RefusalCode::parse(t).is_some()), + _ => false, + } +} + +/// Whether `leaf` holds `key`'s ML-DSA-87 half. +fn in_leaf(key: &[u8], leaf: &[u8]) -> bool { + key.len() >= MLDSA_KEY_SIZE + && leaf + .windows(MLDSA_KEY_SIZE) + .any(|w| w == &key[..MLDSA_KEY_SIZE]) +} + +/// Whether two carried keys share their ML-DSA-87 half, or a classical half. +fn shares_a_half(a: &[u8], b: &[u8]) -> bool { + if a.len() < MLDSA_KEY_SIZE || b.len() < MLDSA_KEY_SIZE { + return a == b; + } + let (classical_a, classical_b) = (&a[MLDSA_KEY_SIZE..], &b[MLDSA_KEY_SIZE..]); + a[..MLDSA_KEY_SIZE] == b[..MLDSA_KEY_SIZE] + || (!classical_a.is_empty() && classical_a == classical_b) +} + +fn encode_frame(frame_type: &str, entries: Vec<(Value, Value)>) -> Vec { + let mut all = vec![ + entry("version", Value::Int(i128::from(VERSION))), + entry("frame_type", Value::text(frame_type)), + ]; + all.extend(entries); + cbor::encode(&Value::Map(all)).expect("a handshake frame's integers are all below 2^53") +} + +fn entry(key: &str, value: Value) -> (Value, Value) { + (Value::text(key), value) +} + +fn hex_of(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} diff --git a/src/identity.rs b/src/identity.rs deleted file mode 100644 index 9a3d792..0000000 --- a/src/identity.rs +++ /dev/null @@ -1,451 +0,0 @@ -//! Ed25519 identity and the S/Kademlia crypto puzzle, matching macula's -//! own `macula_identity.erl` (`macula-io/macula`). -//! -//! Uses `ed25519-dalek` (with the `rand_core` feature) — the same crate -//! macula's own `macula_crypto_nif` Rust NIF already wraps in production, -//! not a separate crypto implementation, though the two are not required -//! to track the same `ed25519-dalek` version: Ed25519 signing is -//! deterministic per RFC 8032, so a byte-identical seed/message pair must -//! produce a byte-identical signature across any correct implementation, -//! any version. Every keypair/sign/verify test in this module is checked -//! against fixtures captured directly from the real `crypto:generate_key/2` -//! and `crypto:sign/4` in `macula-io/macula`'s own `rebar3 shell`, not just -//! hand-derived expectations — which is exactly what lets this crate move -//! ahead of the NIF's own `ed25519-dalek` pin without losing that proof. -//! -//! A macula NodeId **is** an Ed25519 public key (32 bytes) — there is no -//! separate account/identity layer underneath it. Identities are -//! optionally "puzzle-hardened": ground until `SHA-256(pubkey)` has at -//! least `N` leading zero bits (S/Kademlia Sybil defense — this raises -//! the cost of *minting* identities in bulk, not of connecting with one -//! that already exists). Grinding is a one-time cost paid once per -//! identity, not per connection: `puzzle_evidence` is a cheap, -//! deterministic hash computed fresh on every `CONNECT` frame, and -//! `puzzle_valid` is a cheap check, not a proof-of-work re-verification. -//! -//! **Every station checks this on every CONNECT/HELLO, for every kind of -//! dialer — this is not a station-to-station-only concern.** Skipping it -//! produces a real, previously-observed failure mode: the QUIC/TLS -//! connection reports healthy, but the station silently rejects the -//! application-layer HELLO, so the link looks connected while delivering -//! nothing. Always use [`KeyPair::generate_with_puzzle`], never -//! [`KeyPair::generate`], for any identity that will actually dial a -//! station. - -use std::fmt; -use std::fs; -use std::io; -use std::path::Path; - -use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey}; -use sha2::{Digest, Sha256}; - -use crate::keystore::{KeyStore, KeyStoreError}; - -/// Matches `?DEFAULT_PUZZLE_DIFFICULTY` in `macula_identity.erl`. Grinding -/// at this difficulty is sub-millisecond — see the module doc. -pub const DEFAULT_PUZZLE_DIFFICULTY: u32 = 8; - -const KEY_FILE_MAGIC: &[u8] = b"macula-v2-key\0"; - -/// An Ed25519 keypair. The public half **is** the macula NodeId. -pub struct KeyPair { - signing_key: SigningKey, -} - -impl KeyPair { - /// Generate a fresh keypair. Does **not** grind a puzzle — the - /// resulting identity will be silently rejected by any station that - /// enforces puzzle admission (which is every station in practice). - /// Prefer [`generate_with_puzzle`](Self::generate_with_puzzle) unless - /// you specifically need an unhardened identity (e.g. a unit test - /// that never dials a real station). - /// - /// Seeded directly from the OS RNG (`rand::rngs::SysRng`), unwrapped - /// via [`rand::rand_core::UnwrapErr`] to make it panic rather than - /// return a `Result` on the rare case the OS entropy syscall itself - /// fails — the exact same fail-fast behavior `rand` 0.8's `OsRng` had - /// implicitly, since `rand` 0.9 split `SysRng` into a fallible-only - /// type that no longer satisfies `SigningKey::generate`'s infallible - /// `CryptoRng` bound on its own. This is the pattern - /// `ed25519-dalek` 3.0's own docs use for this exact call - /// (`ed25519_dalek::SigningKey::generate`'s doc example), not - /// `rand::rng()`/`ThreadRng` — a userspace CSPRNG that, since rand - /// 0.9, is explicitly documented as **not** reseeding on `fork()`, - /// which would be a real (if narrow) identity-collision risk for a - /// long-lived process that forks after generating a key. Direct OS - /// randomness has no such state to reuse across a fork. - pub fn generate() -> Self { - let signing_key = SigningKey::generate(&mut rand::rand_core::UnwrapErr(rand::rngs::SysRng)); - Self { signing_key } - } - - /// Generate a keypair, grinding fresh candidates until - /// `puzzle_valid(pubkey, difficulty)` holds. This is the one-time - /// cost described in the module doc — not something to redo per - /// connection. - pub fn generate_with_puzzle(difficulty: u32) -> Self { - loop { - let candidate = Self::generate(); - if puzzle_valid(&candidate.public_bytes(), difficulty) { - return candidate; - } - } - } - - /// As [`generate_with_puzzle`](Self::generate_with_puzzle), at - /// [`DEFAULT_PUZZLE_DIFFICULTY`]. - pub fn generate_with_default_puzzle() -> Self { - Self::generate_with_puzzle(DEFAULT_PUZZLE_DIFFICULTY) - } - - /// Reconstruct a keypair from its 32-byte seed. Deterministic — the - /// same seed always yields the same public key and, for a given - /// message, the same signature (Ed25519 per RFC 8032 has no signing - /// randomness). - pub fn from_seed_bytes(seed: [u8; 32]) -> Self { - Self { - signing_key: SigningKey::from_bytes(&seed), - } - } - - /// The public key — also this identity's macula NodeId. - pub fn public_bytes(&self) -> [u8; 32] { - self.signing_key.verifying_key().to_bytes() - } - - /// The 32-byte seed. Matches `macula_identity:private/1`. - pub fn private_bytes(&self) -> [u8; 32] { - self.signing_key.to_bytes() - } - - /// Alias for [`public_bytes`](Self::public_bytes) — NodeId == public - /// key, matching `macula_identity:node_id/1`'s own doc ("Phase 1: - /// NodeId == public key"). - pub fn node_id(&self) -> [u8; 32] { - self.public_bytes() - } - - /// Sign `msg` with this identity. Callers add their own domain - /// separation by prefixing `msg` (see the frame-signing domains in - /// `plans/PLAN_WIRE_PROTOCOL.md` §4) — this function itself is raw - /// Ed25519, matching `macula_identity:sign/2` exactly. - pub fn sign(&self, msg: &[u8]) -> [u8; 64] { - self.signing_key.sign(msg).to_bytes() - } - - /// This identity's puzzle evidence — see [`puzzle_evidence`]. - pub fn puzzle_evidence(&self) -> [u8; 32] { - puzzle_evidence(&self.public_bytes()) - } - - /// Save this keypair to `path`, atomically (write to a `.tmp` - /// sibling, then rename) with `0600` permissions on Unix — matching - /// `macula_identity:save/2`'s own file format and discipline exactly: - /// a 14-byte magic header (`"macula-v2-key\0"`), then the 32-byte - /// public key, then the 32-byte private seed. - /// - /// This raw-file format is a testing/parity convenience, matching the - /// Erlang reference. A real mobile binding should use platform - /// secure storage (Keychain on iOS, Keystore on Android) instead of - /// this file format directly — see - /// `plans/PLAN_WIRE_PROTOCOL.md`'s puzzle_evidence lifecycle note. - pub fn save(&self, path: impl AsRef) -> io::Result<()> { - let path = path.as_ref(); - let mut blob = Vec::with_capacity(KEY_FILE_MAGIC.len() + 64); - blob.extend_from_slice(KEY_FILE_MAGIC); - blob.extend_from_slice(&self.public_bytes()); - blob.extend_from_slice(&self.private_bytes()); - - let tmp_path = path.with_extension("tmp"); - fs::write(&tmp_path, &blob)?; - set_owner_only_permissions(&tmp_path)?; - fs::rename(&tmp_path, path) - } - - /// Load a keypair previously written by [`save`](Self::save). - /// Returns [`LoadKeyError::PubkeyMismatch`] if the file's stored - /// public key doesn't match the one derived from its stored private - /// key — a corrupted or hand-edited key file would otherwise - /// silently produce a keypair that can never complete a real - /// handshake, which is a much harder failure to diagnose than a - /// load-time error. - pub fn load(path: impl AsRef) -> Result { - let blob = fs::read(path.as_ref())?; - let expected_len = KEY_FILE_MAGIC.len() + 64; - if blob.len() != expected_len || !blob.starts_with(KEY_FILE_MAGIC) { - return Err(LoadKeyError::BadKeyFile); - } - let rest = &blob[KEY_FILE_MAGIC.len()..]; - let stored_pub: [u8; 32] = rest[..32].try_into().expect("checked length"); - let stored_priv: [u8; 32] = rest[32..64].try_into().expect("checked length"); - - let keypair = Self::from_seed_bytes(stored_priv); - if keypair.public_bytes() != stored_pub { - return Err(LoadKeyError::PubkeyMismatch); - } - Ok(keypair) - } - - /// Persist this keypair's seed to `store` — see `crate::keystore`'s - /// module doc for why this, not [`save`](Self::save), is what a real - /// mobile (or otherwise security-sensitive) binding should use. - pub fn save_to_keystore(&self, store: &dyn KeyStore) -> Result<(), KeyStoreError> { - store.save_seed(&self.private_bytes()) - } - - /// Reconstruct a keypair from a seed previously written by - /// [`save_to_keystore`](Self::save_to_keystore). Unlike - /// [`load`](Self::load), there is no separately-stored public key to - /// cross-check — a keystore-backed secret is either exactly the seed - /// this method wrote or [`KeyStoreError::InvalidSeedLength`], and the - /// public key a seed derives is always internally consistent by - /// construction (see [`from_seed_bytes`](Self::from_seed_bytes)). - pub fn load_from_keystore(store: &dyn KeyStore) -> Result { - Ok(Self::from_seed_bytes(store.load_seed()?)) - } -} - -#[cfg(unix)] -fn set_owner_only_permissions(path: &Path) -> io::Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o600)) -} - -#[cfg(not(unix))] -fn set_owner_only_permissions(_path: &Path) -> io::Result<()> { - // No POSIX permission bits off Unix; the platform's own file ACLs - // apply. Real mobile builds should not be using this raw-file format - // at all — see `KeyPair::save`'s doc. - Ok(()) -} - -#[derive(Debug)] -pub enum LoadKeyError { - Io(io::Error), - BadKeyFile, - PubkeyMismatch, -} - -impl fmt::Display for LoadKeyError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - LoadKeyError::Io(e) => write!(f, "I/O error reading key file: {e}"), - LoadKeyError::BadKeyFile => write!(f, "key file has the wrong magic header or length"), - LoadKeyError::PubkeyMismatch => { - write!( - f, - "stored public key does not match the one derived from the stored private key" - ) - } - } - } -} - -impl std::error::Error for LoadKeyError {} - -impl From for LoadKeyError { - fn from(e: io::Error) -> Self { - LoadKeyError::Io(e) - } -} - -/// Verify `sig` over `msg` against `pubkey`. Matches -/// `macula_identity:verify/3`'s contract exactly: a structurally invalid -/// public key (not a valid Ed25519 point) is treated as "verification -/// failed" (`false`), not a separate error — it could not have produced -/// a valid signature either way. -pub fn verify(msg: &[u8], sig: &[u8; 64], pubkey: &[u8; 32]) -> bool { - let Ok(verifying_key) = VerifyingKey::from_bytes(pubkey) else { - return false; - }; - let signature = ed25519_dalek::Signature::from_bytes(sig); - verifying_key.verify(msg, &signature).is_ok() -} - -/// `SHA-256(pubkey)` — the proof-of-work output measured by the puzzle. -/// Cheap; not itself the expensive step (see the module doc). -pub fn puzzle_evidence(pubkey: &[u8; 32]) -> [u8; 32] { - Sha256::digest(pubkey).into() -} - -/// Whether `pubkey` satisfies the puzzle at `difficulty` (leading zero -/// bits of its [`puzzle_evidence`]). -pub fn puzzle_valid(pubkey: &[u8; 32], difficulty: u32) -> bool { - count_leading_zero_bits(&puzzle_evidence(pubkey)) >= difficulty -} - -fn count_leading_zero_bits(bytes: &[u8]) -> u32 { - let mut count = 0u32; - for &b in bytes { - if b == 0 { - count += 8; - } else { - count += b.leading_zeros(); - break; - } - } - count -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Captured directly from a real, random `crypto:generate_key(eddsa, - /// ed25519)` / `crypto:sign/4` / `crypto:hash(sha256, Pub)` in - /// `macula-io/macula`'s own `rebar3 shell` — see this module's doc - /// comment. Not a synthetic fixture. - const VECTOR_PUB: &str = "B966A9812649C3D5542FF54954FE090C43FDA6574FE48A0DD326626CFAD29A83"; - const VECTOR_PRIV: &str = "457F45FF5A09E172ED15CB20D6CB26B51AD15ED7308C12D478E8631F9CA03D4F"; - const VECTOR_MSG: &str = "6D6163756C612D76322D6672616D650068656C6C6F20776F726C64"; - const VECTOR_SIG: &str = "E8605CF0387CDFCDD88308A0E40A1DCB83402864C335A64D44431DC8ABC5E7E4FF16CA0C56231B32EEB312C4F89F20B6BA76280AFD622983E9D8BC5F4456AC0B"; - const VECTOR_PUZZLE_EVIDENCE: &str = - "09D48C91CB46513ED2580BDCEA87C40DA508D4E50EC3DF2F701AFC55D1C5C0B2"; - const VECTOR_LEADING_ZERO_BITS: u32 = 4; - - fn fixed_array(hex_str: &str) -> [u8; 32] { - hex::decode(hex_str) - .expect("valid hex fixture") - .try_into() - .expect("32-byte fixture") - } - - fn fixed_array64(hex_str: &str) -> [u8; 64] { - hex::decode(hex_str) - .expect("valid hex fixture") - .try_into() - .expect("64-byte fixture") - } - - #[test] - fn seed_derives_the_reference_pubkey() { - let kp = KeyPair::from_seed_bytes(fixed_array(VECTOR_PRIV)); - assert_eq!( - kp.public_bytes(), - fixed_array(VECTOR_PUB), - "ed25519-dalek's public-key derivation diverged from Erlang's crypto module" - ); - } - - #[test] - fn signature_matches_the_reference_byte_for_byte() { - let kp = KeyPair::from_seed_bytes(fixed_array(VECTOR_PRIV)); - let msg = hex::decode(VECTOR_MSG).unwrap(); - let sig = kp.sign(&msg); - assert_eq!( - sig, - fixed_array64(VECTOR_SIG), - "Ed25519 is deterministic (RFC 8032) — a mismatch here means \ - the two implementations disagree on the signing algorithm \ - itself, not just on random input" - ); - } - - #[test] - fn verify_accepts_the_reference_signature() { - let pubkey = fixed_array(VECTOR_PUB); - let msg = hex::decode(VECTOR_MSG).unwrap(); - let sig = fixed_array64(VECTOR_SIG); - assert!(verify(&msg, &sig, &pubkey)); - } - - #[test] - fn verify_rejects_a_tampered_message() { - let pubkey = fixed_array(VECTOR_PUB); - let sig = fixed_array64(VECTOR_SIG); - assert!(!verify(b"not the original message", &sig, &pubkey)); - } - - #[test] - fn verify_rejects_a_structurally_invalid_pubkey_without_panicking() { - // All-0xFF is not a valid Ed25519 point. - let bogus_pubkey = [0xFFu8; 32]; - let msg = hex::decode(VECTOR_MSG).unwrap(); - let sig = fixed_array64(VECTOR_SIG); - assert!(!verify(&msg, &sig, &bogus_pubkey)); - } - - #[test] - fn puzzle_evidence_matches_the_reference() { - let pubkey = fixed_array(VECTOR_PUB); - assert_eq!( - puzzle_evidence(&pubkey), - fixed_array(VECTOR_PUZZLE_EVIDENCE) - ); - } - - #[test] - fn puzzle_valid_matches_the_reference_leading_zero_count() { - let pubkey = fixed_array(VECTOR_PUB); - assert!(puzzle_valid(&pubkey, VECTOR_LEADING_ZERO_BITS)); - assert!(!puzzle_valid(&pubkey, VECTOR_LEADING_ZERO_BITS + 1)); - assert!(puzzle_valid(&pubkey, 0)); // 0 is always satisfied - } - - #[test] - fn generate_with_default_puzzle_produces_a_valid_identity() { - // A real grind, not a fixture — proves the loop terminates and - // its result actually satisfies the check it's grinding for. - // Sub-millisecond at the default difficulty per the Erlang - // reference's own comment; this test should be fast. - let kp = KeyPair::generate_with_default_puzzle(); - assert!(puzzle_valid(&kp.public_bytes(), DEFAULT_PUZZLE_DIFFICULTY)); - } - - #[test] - fn save_and_load_roundtrip() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("identity.key"); - - let original = KeyPair::from_seed_bytes(fixed_array(VECTOR_PRIV)); - original.save(&path).expect("save"); - - let loaded = KeyPair::load(&path).expect("load"); - assert_eq!(loaded.public_bytes(), original.public_bytes()); - assert_eq!(loaded.private_bytes(), original.private_bytes()); - } - - #[cfg(unix)] - #[test] - fn saved_key_file_is_owner_only() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("identity.key"); - KeyPair::generate().save(&path).expect("save"); - - let mode = fs::metadata(&path).expect("metadata").permissions().mode(); - assert_eq!(mode & 0o777, 0o600); - } - - #[test] - fn load_rejects_a_corrupted_file() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("identity.key"); - fs::write(&path, b"not a key file").expect("write"); - - assert!(matches!( - KeyPair::load(&path), - Err(LoadKeyError::BadKeyFile) - )); - } - - #[test] - fn load_rejects_a_tampered_pubkey() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("identity.key"); - KeyPair::generate().save(&path).expect("save"); - - // Flip a byte inside the stored public key. - let mut blob = fs::read(&path).expect("read"); - let pub_offset = KEY_FILE_MAGIC.len(); - blob[pub_offset] ^= 0xFF; - fs::write(&path, &blob).expect("write tampered"); - - assert!(matches!( - KeyPair::load(&path), - Err(LoadKeyError::PubkeyMismatch) - )); - } -} diff --git a/src/keystore.rs b/src/keystore.rs index 89839f7..d2c90e9 100644 --- a/src/keystore.rs +++ b/src/keystore.rs @@ -1,9 +1,9 @@ -//! Overridable, per-platform secure storage for a persisted identity seed. +//! Overridable, per-platform secure storage for a node key. //! -//! [`KeyPair::save`](crate::identity::KeyPair::save)/[`load`](crate::identity::KeyPair::load) -//! write a raw file — explicitly documented there as "a testing/parity -//! convenience," not what a real mobile binding should use. This module is -//! the real answer: a small [`KeyStore`] trait plus [`KeyringStore`], a +//! [`NodeKey::save`](crate::node_key::NodeKey::save)/[`load`](crate::node_key::NodeKey::load) +//! write an owner-only key file, which suits a server or a desktop. A mobile +//! app keeps its key in the platform's secure store instead: this module is +//! a small [`KeyStore`] trait plus [`KeyringStore`], a //! default implementation backed by the `keyring` crate, which selects the //! actual native secure store per target automatically — //! Keychain (`Security.framework`) on macOS and iOS, Secret Service (D-Bus) @@ -16,8 +16,8 @@ //! [`KeyStore`] itself is deliberately not tied to `keyring` at all — a //! caller with a different secure-storage requirement (a hardware security //! module, a different vault) can implement the trait directly and hand it -//! to [`KeyPair::save_to_keystore`](crate::identity::KeyPair::save_to_keystore)/ -//! [`load_from_keystore`](crate::identity::KeyPair::load_from_keystore) — +//! to [`NodeKey::save_to_keystore`](crate::node_key::NodeKey::save_to_keystore)/ +//! [`load_from_keystore`](crate::node_key::NodeKey::load_from_keystore) — //! "overridable per target platform" is a property of the trait boundary, //! not something wired into this crate's own logic. //! @@ -64,24 +64,27 @@ //! real save/load/delete round trip in this environment. use keyring::Entry; +use macula_mldsa::Zeroizing; -/// Secure storage for a 32-byte identity seed. Implement this directly for -/// a backend other than [`KeyringStore`] (a hardware security module, a -/// different vault) — this is the override point "per target platform" -/// hangs off, not a platform enum this crate switches on internally. +/// Secure storage for one node key, as the bytes of its key file (the seed +/// form: the ML-DSA-87 seed, and in pq_hybrid the RSA-PSS key too, a few KiB). +/// Implement this directly for a backend other than [`KeyringStore`] (a +/// hardware security module, a different vault) — this is the override point +/// "per target platform" hangs off, not a platform enum this crate switches on +/// internally. pub trait KeyStore { - /// Persist `seed`, overwriting any value already stored under this + /// Persist `key`, overwriting any value already stored under this /// store's identity. - fn save_seed(&self, seed: &[u8; 32]) -> Result<(), KeyStoreError>; + fn save_key(&self, key: &[u8]) -> Result<(), KeyStoreError>; - /// Retrieve a previously-[`save_seed`](Self::save_seed)d seed. + /// Retrieve a previously-[`save_key`](Self::save_key)d key. /// [`KeyStoreError::NotFound`] if nothing has been stored yet. - fn load_seed(&self) -> Result<[u8; 32], KeyStoreError>; + fn load_key(&self) -> Result>, KeyStoreError>; - /// Remove a previously-stored seed, if any. Not required before a - /// [`save_seed`](Self::save_seed) (which overwrites), only for + /// Remove a previously-stored key, if any. Not required before a + /// [`save_key`](Self::save_key) (which overwrites), only for /// deliberately forgetting an identity. - fn delete_seed(&self) -> Result<(), KeyStoreError>; + fn delete_key(&self) -> Result<(), KeyStoreError>; } /// The default [`KeyStore`]: the platform-native secure store `keyring` @@ -104,24 +107,20 @@ impl KeyringStore { } impl KeyStore for KeyringStore { - fn save_seed(&self, seed: &[u8; 32]) -> Result<(), KeyStoreError> { - self.entry.set_secret(seed)?; + fn save_key(&self, key: &[u8]) -> Result<(), KeyStoreError> { + self.entry.set_secret(key)?; Ok(()) } - fn load_seed(&self) -> Result<[u8; 32], KeyStoreError> { - let secret = match self.entry.get_secret() { - Ok(secret) => secret, - Err(keyring::Error::NoEntry) => return Err(KeyStoreError::NotFound), - Err(e) => return Err(e.into()), - }; - let actual = secret.len(); - secret - .try_into() - .map_err(|_| KeyStoreError::InvalidSeedLength { actual }) + fn load_key(&self) -> Result>, KeyStoreError> { + match self.entry.get_secret() { + Ok(secret) => Ok(Zeroizing::new(secret)), + Err(keyring::Error::NoEntry) => Err(KeyStoreError::NotFound), + Err(e) => Err(e.into()), + } } - fn delete_seed(&self) -> Result<(), KeyStoreError> { + fn delete_key(&self) -> Result<(), KeyStoreError> { match self.entry.delete_credential() { Ok(()) => Ok(()), Err(keyring::Error::NoEntry) => Ok(()), @@ -164,24 +163,20 @@ impl LinuxKeyutilsStore { #[cfg(target_os = "linux")] impl KeyStore for LinuxKeyutilsStore { - fn save_seed(&self, seed: &[u8; 32]) -> Result<(), KeyStoreError> { - self.entry.set_secret(seed)?; + fn save_key(&self, key: &[u8]) -> Result<(), KeyStoreError> { + self.entry.set_secret(key)?; Ok(()) } - fn load_seed(&self) -> Result<[u8; 32], KeyStoreError> { - let secret = match self.entry.get_secret() { - Ok(secret) => secret, - Err(keyring_core::Error::NoEntry) => return Err(KeyStoreError::NotFound), - Err(e) => return Err(e.into()), - }; - let actual = secret.len(); - secret - .try_into() - .map_err(|_| KeyStoreError::InvalidSeedLength { actual }) + fn load_key(&self) -> Result>, KeyStoreError> { + match self.entry.get_secret() { + Ok(secret) => Ok(Zeroizing::new(secret)), + Err(keyring_core::Error::NoEntry) => Err(KeyStoreError::NotFound), + Err(e) => Err(e.into()), + } } - fn delete_seed(&self) -> Result<(), KeyStoreError> { + fn delete_key(&self) -> Result<(), KeyStoreError> { match self.entry.delete_credential() { Ok(()) => Ok(()), Err(keyring_core::Error::NoEntry) => Ok(()), @@ -192,11 +187,8 @@ impl KeyStore for LinuxKeyutilsStore { #[derive(Debug)] pub enum KeyStoreError { - /// No seed has been stored yet under this store's identity. + /// No key has been stored yet under this store's identity. NotFound, - /// A stored secret existed but wasn't 32 bytes — corrupted, or written - /// by something other than [`KeyStore::save_seed`]. - InvalidSeedLength { actual: usize }, /// The underlying platform secure store rejected the operation. Backend(keyring::Error), } @@ -204,10 +196,7 @@ pub enum KeyStoreError { impl std::fmt::Display for KeyStoreError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - KeyStoreError::NotFound => write!(f, "no seed stored under this identity"), - KeyStoreError::InvalidSeedLength { actual } => { - write!(f, "stored secret is {actual} bytes, expected 32") - } + KeyStoreError::NotFound => write!(f, "no key stored under this identity"), KeyStoreError::Backend(e) => write!(f, "platform secure store error: {e}"), } } @@ -268,19 +257,19 @@ mod tests { #[cfg(target_os = "linux")] #[test] - fn save_then_load_returns_the_same_seed() { + fn save_then_load_returns_the_same_key() { let _guard = KEYRING_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); let store = test_store(); - let seed = [0x42u8; 32]; + let key = vec![0x42u8; 2400]; let result = (|| -> Result<(), KeyStoreError> { - store.save_seed(&seed)?; - let loaded = store.load_seed()?; - assert_eq!(loaded, seed); + store.save_key(&key)?; + let loaded = store.load_key()?; + assert_eq!(*loaded, key); Ok(()) })(); - store.delete_seed().expect("cleanup delete should succeed"); + store.delete_key().expect("cleanup delete should succeed"); result.expect("save/load round trip should succeed"); } @@ -291,9 +280,9 @@ mod tests { let store = test_store(); // Guard against a leftover entry from a prior failed run on this // machine before asserting NotFound. - let _ = store.delete_seed(); + let _ = store.delete_key(); - assert!(matches!(store.load_seed(), Err(KeyStoreError::NotFound))); + assert!(matches!(store.load_key(), Err(KeyStoreError::NotFound))); } #[cfg(target_os = "linux")] @@ -301,12 +290,12 @@ mod tests { fn delete_is_idempotent() { let _guard = KEYRING_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); let store = test_store(); - store.save_seed(&[0x7Fu8; 32]).expect("save"); - store.delete_seed().expect("first delete"); + store.save_key(&[0x7Fu8; 32]).expect("save"); + store.delete_key().expect("first delete"); // A second delete of an already-absent entry must not error -- - // KeyStore::delete_seed's own doc promises this. + // KeyStore::delete_key's own doc promises this. store - .delete_seed() + .delete_key() .expect("second delete on an absent entry"); } } diff --git a/src/lib.rs b/src/lib.rs index fb1cd02..4b458fc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,27 +1,22 @@ -//! Rust port of macula's SDK (client/leaf) protocol — see -//! `plans/PLAN_WIRE_PROTOCOL.md` for the full wire-format spec this crate -//! is built against, traced directly to `macula-io/macula` source. +//! Rust SDK for the macula 12 mesh: node keys (ML-DSA-87, and the LAMPS +//! composite ML-DSA-87 + RSA-PSS-4096 in pq_hybrid), bindings and signed +//! objects, and a station dialed over QUIC with a post-quantum key exchange. //! -//! Mobile (iOS/Android via UniFFI) is the flagship consumer driving this -//! work, not the ceiling on it — nothing below the eventual FFI binding -//! layer is mobile-specific. +//! Mobile (iOS/Android via UniFFI, `macula-rust-ffi`) is the flagship +//! consumer, not the ceiling: nothing below the FFI layer is mobile-specific. -pub mod bolt4; +pub mod binding; pub mod cbor; -pub mod cert; -pub mod cert_chain; -pub mod connection; -pub mod content; -mod control_channel; -pub mod dht; -pub mod direct_dial; pub mod frame; -pub mod identity; +pub mod handshake; pub mod keystore; -pub mod manifest; -mod open_sessions; +pub mod node_key; pub mod petname; pub mod pool; -pub mod stream; +pub mod profile; +pub mod record; +pub mod signed_object; +pub mod statement_issuer; +pub mod station_link; pub mod transport; -pub mod ucan; +mod uuid_v7; diff --git a/src/manifest.rs b/src/manifest.rs deleted file mode 100644 index 5a29131..0000000 --- a/src/manifest.rs +++ /dev/null @@ -1,663 +0,0 @@ -//! Fixed-size chunking, Merkle-root computation, and manifest -//! construction for content larger than one storage block. Ported from -//! macula's own `macula_manifest` (SDK) — see -//! `plans/PLAN_WIRE_PROTOCOL.md` §12.2. -//! -//! Mirrors the reference byte-for-byte: same MCID format, same default -//! chunk size (256 KiB), same Merkle fold (including the odd-leaf-count -//! rule — pair the last hash with itself), same canonical-CBOR MCID -//! derivation. Verified against real `macula_manifest:create/2` / -//! `chunk_mcid/3` / `verify/2` output (even *and* odd chunk counts, to -//! exercise both branches of the Merkle fold) — see this module's tests. -//! -//! **Two different wire representations of `name`, both verified -//! separately, not confused with each other:** `compute_mcid`'s -//! canonical hash input wraps `name` as CBOR *text* (a deliberate, -//! narrow special case in the reference, just for that hash -//! computation), while [`to_wire`] — the actual manifest map as sent in -//! a `_content.put_manifest` CALL payload — encodes `name` as a raw -//! *byte string*, matching its `binary()` type. Confirmed directly by -//! encoding a real manifest through the general deterministic-CBOR -//! codec and inspecting the bytes, not inferred from the type spec -//! alone — see the CALL/PUBLISH `procedure`/`topic` lesson in -//! `src/frame.rs` for why that inference alone wasn't trusted here. - -use crate::cbor::Value; - -/// 256 KiB — matches `macula_manifest:default_chunk_size/0`. -pub const DEFAULT_CHUNK_SIZE: usize = 262_144; - -const VERSION: u8 = 1; -const CODEC_RAW: u8 = 0x55; -const CODEC_MANIFEST: u8 = 0x56; - -/// `<>` — 34 bytes. -pub type Mcid = [u8; 34]; - -fn make_mcid(codec: u8, hash: [u8; 32]) -> Mcid { - let mut out = [0u8; 34]; - out[0] = VERSION; - out[1] = codec; - out[2..].copy_from_slice(&hash); - out -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Algorithm { - Blake3, - Sha256, -} - -impl Algorithm { - fn hash(self, data: &[u8]) -> [u8; 32] { - match self { - Algorithm::Blake3 => *blake3::hash(data).as_bytes(), - Algorithm::Sha256 => { - use sha2::{Digest, Sha256}; - Sha256::digest(data).into() - } - } - } - - pub fn name(self) -> &'static str { - match self { - Algorithm::Blake3 => "blake3", - Algorithm::Sha256 => "sha256", - } - } - - /// Matches `to_algorithm/1`'s own fallback: anything unrecognized - /// defaults to `blake3`, it doesn't error. - pub fn from_name(name: &str) -> Algorithm { - match name { - "sha256" => Algorithm::Sha256, - _ => Algorithm::Blake3, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ChunkInfo { - pub index: usize, - pub offset: usize, - pub size: usize, - pub hash: [u8; 32], -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Manifest { - pub mcid: Mcid, - pub version: u32, - pub name: String, - pub size: u64, - pub created: u64, - pub chunk_size: usize, - pub chunk_count: usize, - pub hash_algorithm: Algorithm, - pub root_hash: [u8; 32], - pub chunks: Vec, -} - -#[derive(Debug, Clone)] -pub struct CreateOptions { - pub name: String, - pub chunk_size: usize, - pub hash_algorithm: Algorithm, -} - -impl Default for CreateOptions { - fn default() -> Self { - Self { - name: "unnamed".to_string(), - chunk_size: DEFAULT_CHUNK_SIZE, - hash_algorithm: Algorithm::Blake3, - } - } -} - -/// Split `data` into fixed-size chunks and build its manifest. Returns -/// the manifest and the chunk bytes in order (index 0 first) — a caller -/// uploads each chunk (`_content.put_block`) then the manifest itself -/// (`_content.put_manifest`), per §12.2. -/// -/// `opts.chunk_size` must be non-zero (matches the reference: it never -/// guards against zero either, and a zero chunk size is a caller bug, -/// not a case worth silently tolerating). -pub fn create(data: &[u8], opts: &CreateOptions) -> (Manifest, Vec>) { - create_with_created(data, opts, current_unix_secs()) -} - -fn create_with_created( - data: &[u8], - opts: &CreateOptions, - created: u64, -) -> (Manifest, Vec>) { - let chunks = do_chunk(data, opts.chunk_size); - let chunk_infos = chunk_infos(&chunks, opts.hash_algorithm); - let root_hash = root_hash_for(&chunk_infos, opts.hash_algorithm); - let chunk_count = chunk_infos.len(); - let mcid = compute_mcid( - &opts.name, - data.len() as u64, - opts.chunk_size, - chunk_count, - opts.hash_algorithm, - &root_hash, - ); - let manifest = Manifest { - mcid, - version: 1, - name: opts.name.clone(), - size: data.len() as u64, - created, - chunk_size: opts.chunk_size, - chunk_count, - hash_algorithm: opts.hash_algorithm, - root_hash, - chunks: chunk_infos, - }; - (manifest, chunks) -} - -/// The MCID a chunk at `index` is stored/fetched under — the station -/// derives this same value independently when serving the chunk, so -/// both sides agree on its address without exchanging it. -pub fn chunk_mcid(manifest: &Manifest, index: usize) -> Option { - manifest - .chunks - .get(index) - .map(|c| make_mcid(CODEC_RAW, c.hash)) -} - -/// The MCID a whole blob is stored/fetched under when it's small enough -/// to be a single block (no manifest at all). Matches -/// `macula_content_transfer:put_single_block/3` exactly: **always** -/// BLAKE3, regardless of any algorithm preference — single-block content -/// has no algorithm choice, only chunked/manifest content does. -pub fn block_mcid(data: &[u8]) -> Mcid { - make_mcid(CODEC_RAW, Algorithm::Blake3.hash(data)) -} - -/// Whether `mcid` addresses a manifest (chunked content) rather than a -/// single raw block — determined from its own codec byte, no network -/// round trip needed. Matches `macula_content_transfer:is_chunked/2`'s -/// get-side check. -pub fn mcid_is_chunked(mcid: &Mcid) -> bool { - mcid[1] == CODEC_MANIFEST -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum VerifyError { - SizeMismatch, - RootHashMismatch, -} - -impl std::fmt::Display for VerifyError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - VerifyError::SizeMismatch => write!(f, "data size does not match the manifest"), - VerifyError::RootHashMismatch => { - write!(f, "re-chunked root hash does not match the manifest") - } - } - } -} - -impl std::error::Error for VerifyError {} - -/// Verify reassembled `data` against `manifest`: size, then a fresh -/// Merkle root over `data` re-chunked the same way. -pub fn verify(manifest: &Manifest, data: &[u8]) -> Result<(), VerifyError> { - if data.len() as u64 != manifest.size { - return Err(VerifyError::SizeMismatch); - } - let chunks = do_chunk(data, manifest.chunk_size); - let infos = chunk_infos(&chunks, manifest.hash_algorithm); - let actual_root = root_hash_for(&infos, manifest.hash_algorithm); - if actual_root == manifest.root_hash { - Ok(()) - } else { - Err(VerifyError::RootHashMismatch) - } -} - -fn do_chunk(data: &[u8], chunk_size: usize) -> Vec> { - // `[T]::chunks(n)` is exactly equivalent to the reference's - // recursive `do_chunk/3` for every case checked, including exact - // multiples of chunk_size (no trailing empty chunk) and empty input - // (zero chunks, not one empty chunk). - data.chunks(chunk_size).map(<[u8]>::to_vec).collect() -} - -fn chunk_infos(chunks: &[Vec], algorithm: Algorithm) -> Vec { - let mut offset = 0usize; - chunks - .iter() - .enumerate() - .map(|(index, chunk)| { - let info = ChunkInfo { - index, - offset, - size: chunk.len(), - hash: algorithm.hash(chunk), - }; - offset += chunk.len(); - info - }) - .collect() -} - -fn root_hash_for(infos: &[ChunkInfo], algorithm: Algorithm) -> [u8; 32] { - if infos.is_empty() { - return algorithm.hash(&[]); - } - let mut hashes: Vec<[u8; 32]> = infos.iter().map(|i| i.hash).collect(); - while hashes.len() > 1 { - hashes = combine(&hashes, algorithm); - } - hashes[0] -} - -/// One Merkle-fold pass: pairs from the front, `hash(L || R)`. An odd -/// leftover at the end is paired with itself, `hash(Last || Last)` — the -/// rule most likely to be implemented wrong; verified against an -/// odd-chunk-count reference vector specifically (see this module's -/// tests), not just even counts. -fn combine(hashes: &[[u8; 32]], algorithm: Algorithm) -> Vec<[u8; 32]> { - hashes - .chunks(2) - .map(|pair| { - let mut buf = Vec::with_capacity(64); - buf.extend_from_slice(&pair[0]); - buf.extend_from_slice(pair.get(1).unwrap_or(&pair[0])); - algorithm.hash(&buf) - }) - .collect() -} - -/// The canonical hash input for a manifest's own MCID — deliberately -/// excludes `created` (timestamp) and `chunks` (already rolled up into -/// `root_hash`). `name` and `hash_algorithm` are wrapped as CBOR text -/// here specifically, matching the reference's own special-cased -/// `compute_mcid/2` — see this module's doc comment for why that's -/// *not* the same encoding [`to_wire`] uses for `name`. -fn compute_mcid( - name: &str, - size: u64, - chunk_size: usize, - chunk_count: usize, - algorithm: Algorithm, - root_hash: &[u8; 32], -) -> Mcid { - let canonical = Value::Map(vec![ - (Value::text("name"), Value::text(name)), - (Value::text("size"), Value::Int(size as i128)), - (Value::text("chunk_size"), Value::Int(chunk_size as i128)), - (Value::text("chunk_count"), Value::Int(chunk_count as i128)), - (Value::text("hash_algorithm"), Value::text(algorithm.name())), - (Value::text("root_hash"), Value::Bytes(root_hash.to_vec())), - ]); - let bytes = crate::cbor::encode(&canonical).expect("manifest MCID fields are always encodable"); - let hash = algorithm.hash(&bytes); - make_mcid(CODEC_MANIFEST, hash) -} - -/// Encode `manifest` as it's actually sent in a `_content.put_manifest` -/// CALL payload — `name` as bytes (its real `binary()` type), NOT the -/// text-wrapped form `compute_mcid` uses internally. See this module's -/// doc comment. -pub fn to_wire(manifest: &Manifest) -> Value { - Value::Map(vec![ - (Value::text("mcid"), Value::Bytes(manifest.mcid.to_vec())), - (Value::text("version"), Value::Int(manifest.version as i128)), - ( - Value::text("name"), - Value::Bytes(manifest.name.as_bytes().to_vec()), - ), - (Value::text("size"), Value::Int(manifest.size as i128)), - (Value::text("created"), Value::Int(manifest.created as i128)), - ( - Value::text("chunk_size"), - Value::Int(manifest.chunk_size as i128), - ), - ( - Value::text("chunk_count"), - Value::Int(manifest.chunk_count as i128), - ), - ( - Value::text("hash_algorithm"), - Value::text(manifest.hash_algorithm.name()), - ), - ( - Value::text("root_hash"), - Value::Bytes(manifest.root_hash.to_vec()), - ), - ( - Value::text("chunks"), - Value::List(manifest.chunks.iter().map(chunk_info_to_wire).collect()), - ), - ]) -} - -fn chunk_info_to_wire(info: &ChunkInfo) -> Value { - Value::Map(vec![ - (Value::text("index"), Value::Int(info.index as i128)), - (Value::text("offset"), Value::Int(info.offset as i128)), - (Value::text("size"), Value::Int(info.size as i128)), - (Value::text("hash"), Value::Bytes(info.hash.to_vec())), - ]) -} - -#[derive(Debug, PartialEq, Eq)] -pub enum FromWireError { - MissingField(&'static str), - WrongFieldType(&'static str), - /// `chunk_size` is 0. Never produced by [`create`] (its own doc - /// comment already requires a non-zero `chunk_size`), so this only - /// ever rejects a malicious/malformed wire manifest — accepting it - /// would panic downstream: `[T]::chunks(0)` (called from - /// [`verify`]'s own `do_chunk`) panics unconditionally on a zero - /// chunk size, even for empty content. - ZeroChunkSize, - /// `chunk_count` doesn't match the actual number of entries in the - /// `chunks` list. Never produced by [`create`] (`chunk_count` is - /// always `chunk_infos.len()`), so this only ever rejects a - /// malicious/malformed wire manifest — accepting it would let a - /// caller iterate `0..chunk_count` past the real end of `chunks` - /// and panic on the resulting `None` from [`chunk_mcid`]. - InconsistentChunkCount, -} - -impl std::fmt::Display for FromWireError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - FromWireError::MissingField(name) => write!(f, "missing required field {name:?}"), - FromWireError::WrongFieldType(name) => write!(f, "field {name:?} has the wrong type"), - FromWireError::ZeroChunkSize => write!(f, "chunk_size is 0"), - FromWireError::InconsistentChunkCount => { - write!( - f, - "chunk_count does not match the number of entries in chunks" - ) - } - } - } -} - -impl std::error::Error for FromWireError {} - -/// Parse a manifest as received from a `_content.get_manifest` RESULT. -pub fn from_wire(value: &Value) -> Result { - let mcid: Mcid = get_bytes_exact(value, "mcid")?; - let version = get_uint(value, "version")? as u32; - let name = get_string_bytes(value, "name")?; - let size = get_uint(value, "size")?; - let created = get_uint(value, "created")?; - let chunk_size = get_uint(value, "chunk_size")? as usize; - let chunk_count = get_uint(value, "chunk_count")? as usize; - let hash_algorithm = Algorithm::from_name(&get_text(value, "hash_algorithm")?); - let root_hash: [u8; 32] = get_bytes_exact(value, "root_hash")?; - let chunks = match value.get("chunks") { - Some(Value::List(items)) => items - .iter() - .map(chunk_info_from_wire) - .collect::, _>>()?, - Some(_) => return Err(FromWireError::WrongFieldType("chunks")), - None => return Err(FromWireError::MissingField("chunks")), - }; - if chunk_size == 0 { - return Err(FromWireError::ZeroChunkSize); - } - if chunks.len() != chunk_count { - return Err(FromWireError::InconsistentChunkCount); - } - Ok(Manifest { - mcid, - version, - name, - size, - created, - chunk_size, - chunk_count, - hash_algorithm, - root_hash, - chunks, - }) -} - -fn chunk_info_from_wire(value: &Value) -> Result { - Ok(ChunkInfo { - index: get_uint(value, "index")? as usize, - offset: get_uint(value, "offset")? as usize, - size: get_uint(value, "size")? as usize, - hash: get_bytes_exact(value, "hash")?, - }) -} - -fn get_uint(value: &Value, field: &'static str) -> Result { - match value.get(field) { - Some(Value::Int(n)) if *n >= 0 => Ok(*n as u64), - Some(_) => Err(FromWireError::WrongFieldType(field)), - None => Err(FromWireError::MissingField(field)), - } -} - -fn get_text(value: &Value, field: &'static str) -> Result { - match value.get(field) { - Some(Value::Text(t)) => Ok(t.clone()), - Some(_) => Err(FromWireError::WrongFieldType(field)), - None => Err(FromWireError::MissingField(field)), - } -} - -fn get_string_bytes(value: &Value, field: &'static str) -> Result { - match value.get(field) { - Some(Value::Bytes(b)) => { - String::from_utf8(b.clone()).map_err(|_| FromWireError::WrongFieldType(field)) - } - Some(_) => Err(FromWireError::WrongFieldType(field)), - None => Err(FromWireError::MissingField(field)), - } -} - -fn get_bytes_exact( - value: &Value, - field: &'static str, -) -> Result<[u8; N], FromWireError> { - match value.get(field) { - Some(Value::Bytes(b)) => b - .as_slice() - .try_into() - .map_err(|_| FromWireError::WrongFieldType(field)), - Some(_) => Err(FromWireError::WrongFieldType(field)), - None => Err(FromWireError::MissingField(field)), - } -} - -fn current_unix_secs() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock is after the Unix epoch") - .as_secs() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn hex_bytes(s: &str) -> Vec { - ::hex::decode(s).expect("valid hex fixture") - } - - /// Even chunk count (4) — captured from a real `macula_manifest:create/2` - /// via `rebar3 shell` against `macula-io/macula`. - #[test] - fn even_chunk_count_matches_the_reference() { - let data = b"AAAABBBBCCCCD"; // 13 bytes - let opts = CreateOptions { - name: "test-file".to_string(), - chunk_size: 4, - hash_algorithm: Algorithm::Blake3, - }; - let (manifest, chunks) = create_with_created(data, &opts, 0); - - assert_eq!(chunks.len(), 4); - assert_eq!(chunks[0], b"AAAA"); - assert_eq!(chunks[3], b"D"); - - assert_eq!( - hex::encode_upper(manifest.root_hash), - "784F87CDC9C180A21C878FC26703F9E4782F2FD2E6235048299811675E36EAC4" - ); - assert_eq!( - hex::encode_upper(manifest.mcid), - "01564CC855EF538530393E36DBD4CCD216558B60F87498889890247EEB9B52B8FED7" - ); - - // Per-chunk hashes and offsets, spot-checked against the same run. - assert_eq!(manifest.chunks[0].offset, 0); - assert_eq!( - hex::encode_upper(manifest.chunks[0].hash), - "26C7BB3DAAAA0439EB3E5C5270E7C4DB05218D8892A0258FBD4911CEF5006D23" - ); - assert_eq!(manifest.chunks[3].offset, 12); - assert_eq!(manifest.chunks[3].size, 1); - - assert_eq!( - chunk_mcid(&manifest, 0).map(hex::encode_upper), - Some( - "015526C7BB3DAAAA0439EB3E5C5270E7C4DB05218D8892A0258FBD4911CEF5006D23".to_string() - ) - ); - - assert_eq!(verify(&manifest, data), Ok(())); - } - - /// Odd chunk count (3) — exercises the Merkle fold's "pair the last - /// hash with itself" branch, which the even-count test above never - /// touches. - #[test] - fn odd_chunk_count_matches_the_reference() { - let data = b"AAAABBBBCCCC"; // 12 bytes, chunk_size 4 -> exactly 3 chunks - let opts = CreateOptions { - name: "odd-test".to_string(), - chunk_size: 4, - hash_algorithm: Algorithm::Blake3, - }; - let (manifest, chunks) = create_with_created(data, &opts, 0); - - assert_eq!(chunks.len(), 3); - assert_eq!( - hex::encode_upper(manifest.root_hash), - "50FE839CCDE80B13D7531A9C34FD856DBCBBB87D8FBD241DE6AFF2C86909CD54" - ); - assert_eq!( - hex::encode_upper(manifest.mcid), - "0156589728C90DB0138CA87E4E500A61812C64D30C3BE325184A761F20CA04BC86FB" - ); - - assert_eq!(verify(&manifest, data), Ok(())); - assert_eq!( - verify(&manifest, b"AAAABBBBWRONG"), - Err(VerifyError::SizeMismatch) - ); - } - - #[test] - fn verify_rejects_tampered_content_of_the_same_size() { - let data = b"AAAABBBBCCCC"; - let opts = CreateOptions { - chunk_size: 4, - ..Default::default() - }; - let (manifest, _) = create_with_created(data, &opts, 0); - assert_eq!( - verify(&manifest, b"AAAABBBBCCCX"), - Err(VerifyError::RootHashMismatch) - ); - } - - /// The full manifest map as it's actually sent in a - /// `_content.put_manifest` CALL payload — captured by encoding a - /// real manifest through `macula_cbor_nif:pack_deterministic/1` - /// directly (the same general codec CALL payloads go through), not - /// through `compute_mcid`'s special canonical path. This is what - /// proves `name` really is bytes on the wire, not text. - #[test] - fn to_wire_matches_the_reference_byte_for_byte() { - let data = b"AAAABBBBCCCC"; - let opts = CreateOptions { - name: "odd-test".to_string(), - chunk_size: 4, - hash_algorithm: Algorithm::Blake3, - }; - let (manifest, _) = create_with_created(data, &opts, 1_787_892_082); // 0x6A911172 - - let wire = to_wire(&manifest); - let encoded = crate::cbor::encode(&wire).expect("encodable manifest"); - assert_eq!( - encoded, - hex_bytes( - "AA646D63696458220156589728C90DB0138CA87E4E500A61812C64D30C3BE325184A761F20CA04BC86FB646E616D65486F64642D746573746473697A650C666368756E6B7383A46468617368582026C7BB3DAAAA0439EB3E5C5270E7C4DB05218D8892A0258FBD4911CEF5006D236473697A650465696E64657800666F666673657400A464686173685820255EC90F561EDA98B1E5E3EFA56B7B477086E273CD07CC4F780A646D052726446473697A650465696E64657801666F666673657404A464686173685820A83CE6EC6760EB7F66D3D7BBC84D1AAC3BEF0948074F8ED21423D825AE8821726473697A650465696E64657802666F66667365740867637265617465641A6A9111726776657273696F6E0169726F6F745F68617368582050FE839CCDE80B13D7531A9C34FD856DBCBBB87D8FBD241DE6AFF2C86909CD546A6368756E6B5F73697A65046B6368756E6B5F636F756E74036E686173685F616C676F726974686D66626C616B6533" - ) - ); - - // Round-trip through from_wire. - let decoded = crate::cbor::decode(&encoded).expect("valid CBOR"); - let parsed = from_wire(&decoded).expect("well-formed manifest"); - assert_eq!(parsed, manifest); - } - - #[test] - fn from_wire_rejects_a_missing_field() { - let value = Value::Map(vec![(Value::text("mcid"), Value::Bytes(vec![0; 34]))]); - assert_eq!( - from_wire(&value), - Err(FromWireError::MissingField("version")) - ); - } - - /// A malicious/malformed manifest claiming `chunk_size: 0` must be - /// rejected at parse time, not accepted and left to panic later: - /// `verify`'s own `do_chunk` calls `[T]::chunks(manifest.chunk_size)`, - /// which panics unconditionally when its argument is 0. - #[test] - fn from_wire_rejects_zero_chunk_size() { - let (manifest, _) = create_with_created(b"AAAABBBBCCCC", &CreateOptions::default(), 0); - let tampered = to_wire(&manifest).with_field("chunk_size", Value::Int(0)); - assert_eq!(from_wire(&tampered), Err(FromWireError::ZeroChunkSize)); - } - - /// A malicious/malformed manifest whose `chunk_count` doesn't match - /// the actual number of entries in `chunks` must be rejected at - /// parse time: a caller iterating `0..chunk_count` (see - /// `content::get`) would otherwise index past the real end of - /// `chunks` and panic on the resulting `None`. - #[test] - fn from_wire_rejects_inconsistent_chunk_count() { - let opts = CreateOptions { - chunk_size: 4, - ..CreateOptions::default() - }; - let (manifest, _) = create_with_created(b"AAAABBBBCCCC", &opts, 0); - let tampered = to_wire(&manifest).with_field("chunk_count", Value::Int(1000)); - assert_eq!( - from_wire(&tampered), - Err(FromWireError::InconsistentChunkCount) - ); - } - - #[test] - fn algorithm_from_name_defaults_to_blake3() { - assert_eq!(Algorithm::from_name("blake3"), Algorithm::Blake3); - assert_eq!(Algorithm::from_name("sha256"), Algorithm::Sha256); - assert_eq!(Algorithm::from_name("something-unknown"), Algorithm::Blake3); - } - - #[test] - fn empty_data_produces_zero_chunks() { - let (manifest, chunks) = create_with_created(b"", &CreateOptions::default(), 0); - assert_eq!(chunks.len(), 0); - assert_eq!(manifest.chunk_count, 0); - } -} diff --git a/src/node_key.rs b/src/node_key.rs new file mode 100644 index 0000000..f850f76 --- /dev/null +++ b/src/node_key.rs @@ -0,0 +1,349 @@ +//! A macula 12 node's keys, as macula and macula-go hold them: ML-DSA-87 in +//! pq_pure, and in pq_hybrid the LAMPS composite id-MLDSA87-RSA4096-PSS-SHA512 +//! (draft-ietf-lamps-pq-composite-sigs), which signs with both halves and is +//! valid only when both verify. An identity key's node_id (D5) solves the +//! admission puzzle; a CONNECT key is bound to it (see `crate::binding`). +//! Keys are stored in macula's seed form, readable by their owner only (see +//! [`NodeKey::save`] and [`NodeKey::load`]). +//! +//! The ML-DSA-87 half is macula-mldsa, the implementation macula-pqc signs +//! TLS with, kept as its 32-byte seed. The RSA-PSS-4096 half is aws-lc-rs, +//! already linked through rustls: constant-time, with a FIPS path. + +mod der; +mod key_file; + +pub use key_file::KeyFileError; + +use std::fmt; + +use aws_lc_rs::rand::SystemRandom; +use aws_lc_rs::rsa::{KeyPair as RsaKeyPair, KeySize}; +use aws_lc_rs::signature::{ + KeyPair as _, UnparsedPublicKey, RSA_PSS_2048_8192_SHA384, RSA_PSS_SHA384, +}; +use macula_mldsa::{PrivateKey, Zeroizing, ML_DSA_87}; +use sha2::{Digest, Sha256, Sha512}; + +use crate::profile::Profile; + +/// How many leading zero bits an identity key's node_id has: a node generates +/// its identity key for it ([`NodeKey::generate_identity`]), and stations +/// check it. +pub const PUZZLE_DIFFICULTY: u32 = 8; + +const MLDSA_PUBLIC_KEY_SIZE: usize = 2592; +const MLDSA_SIGNATURE_SIZE: usize = 4627; +const RSA_MODULUS_BYTES: usize = 512; +const COMPOSITE_PREFIX: &[u8] = b"CompositeAlgorithmSignatures2025"; +const COMPOSITE_LABEL: &[u8] = b"COMPSIG-MLDSA87-RSA4096-PSS-SHA512"; +const NODE_ID_LABEL: &[u8] = b"MACULA-NODE-ID-V1"; +const KEY_ID_LABEL: &[u8] = b"MACULA-KEY-ID-V1"; + +/// What a node key is for. Each key serves exactly one purpose. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Purpose { + /// A node's identity key, the key its node_id derives from and that signs + /// its bindings, status statements and signed objects. + Identity, + /// A node's CONNECT key, bound to its identity key, which signs the proof + /// of each connection it makes. + Connect, +} + +impl Purpose { + /// The purpose's name. + pub fn name(self) -> &'static str { + match self { + Purpose::Identity => "identity", + Purpose::Connect => "connect", + } + } +} + +impl fmt::Display for Purpose { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// Why a key operation refused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeyError { + /// A node_id asked of a key that is not an identity key. + NotAnIdentityKey, + /// A puzzle difficulty outside 0 to 256. + DifficultyOutOfRange(u32), + /// The operating system gave no randomness. + RandomnessUnavailable, + /// Generating a half failed. + Generate(&'static str), + /// Signing with a half failed. + Sign(&'static str), +} + +impl fmt::Display for KeyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + KeyError::NotAnIdentityKey => f.write_str("not an identity key"), + KeyError::DifficultyOutOfRange(d) => { + write!(f, "puzzle difficulty {d} is outside 0 to 256") + } + KeyError::RandomnessUnavailable => { + f.write_str("the operating system gave no randomness") + } + KeyError::Generate(half) => write!(f, "could not generate the {half} half"), + KeyError::Sign(half) => write!(f, "could not sign with the {half} half"), + } + } +} + +impl std::error::Error for KeyError {} + +/// The RSA-PSS-4096 half of a pq_hybrid key. +struct RsaHalf { + pair: RsaKeyPair, + /// The DER `RSAPublicKey`, as carried after the ML-DSA-87 key. + public_der: Vec, +} + +/// One of a node's keys in its profile: the ML-DSA-87 half, and in pq_hybrid +/// the RSA-PSS-4096 half. It signs as a whole, never with one half on its own. +/// Showing it gives its purpose, profile and key id, never a private half. +pub struct NodeKey { + purpose: Purpose, + profile: Profile, + mldsa_seed: Zeroizing<[u8; 32]>, + mldsa_public: Vec, + rsa: Option, +} + +impl NodeKey { + /// A new key for `purpose` in `profile`. + pub fn generate(purpose: Purpose, profile: Profile) -> Result { + let (mldsa_public, mldsa_seed) = + macula_mldsa::key_gen_seed(ML_DSA_87).map_err(|_| KeyError::RandomnessUnavailable)?; + let rsa = if profile.hybrid() { + let pair = RsaKeyPair::generate(KeySize::Rsa4096) + .map_err(|_| KeyError::Generate("RSA-4096"))?; + let public_der = pair.public_key().as_ref().to_vec(); + Some(RsaHalf { pair, public_der }) + } else { + None + }; + Ok(NodeKey { + purpose, + profile, + mldsa_seed, + mldsa_public, + rsa, + }) + } + + /// A new identity key in `profile` whose node_id starts with `difficulty` + /// zero bits, found in about 2^difficulty tries. Each try makes a new + /// ML-DSA-87 half; a pq_hybrid key keeps its RSA-PSS half, since the + /// node_id covers both. + pub fn generate_identity(profile: Profile, difficulty: u32) -> Result { + if difficulty > 256 { + return Err(KeyError::DifficultyOutOfRange(difficulty)); + } + let mut key = NodeKey::generate(Purpose::Identity, profile)?; + while !puzzle_solved(&node_id_of(&key.public_key(), profile), difficulty) { + let (public, seed) = macula_mldsa::key_gen_seed(ML_DSA_87) + .map_err(|_| KeyError::RandomnessUnavailable)?; + key.mldsa_public = public; + key.mldsa_seed = seed; + } + Ok(key) + } + + /// What the key is for. + pub fn purpose(&self) -> Purpose { + self.purpose + } + + /// The profile the key belongs to. + pub fn profile(&self) -> Profile { + self.profile + } + + /// The key as carried (D13): the 2,592-byte ML-DSA-87 key, followed in + /// pq_hybrid by the DER `RSAPublicKey`. + pub fn public_key(&self) -> Vec { + let mut carried = self.mldsa_public.clone(); + if let Some(rsa) = &self.rsa { + carried.extend_from_slice(&rsa.public_der); + } + carried + } + + /// The node_id of an identity key (D5). + pub fn node_id(&self) -> Result<[u8; 32], KeyError> { + match self.purpose { + Purpose::Identity => Ok(node_id_of(&self.public_key(), self.profile)), + Purpose::Connect => Err(KeyError::NotAnIdentityKey), + } + } + + /// The id that names the key in signed objects: an identity key's + /// node_id, and the key id of any other key. + pub fn key_id(&self) -> [u8; 32] { + match self.purpose { + Purpose::Identity => node_id_of(&self.public_key(), self.profile), + Purpose::Connect => key_id_of(&self.public_key(), self.profile), + } + } + + /// Signs `message`: with ML-DSA-87 alone in pq_pure, and in pq_hybrid with + /// the composite, where both halves sign the message representative, the + /// ML-DSA-87 half with the composite label as its context, and the + /// signature is the ML-DSA-87 signature followed by the RSA-PSS one. + /// ML-DSA-87 signs hedged and RSA-PSS salted, so each signature is new. + pub fn sign(&self, message: &[u8]) -> Result, KeyError> { + let seed = PrivateKey::Seed(&self.mldsa_seed); + let Some(rsa) = &self.rsa else { + return macula_mldsa::sign(ML_DSA_87, seed, message, &[]) + .map_err(|_| KeyError::Sign("ML-DSA-87")); + }; + let representative = composite_representative(message); + let mut signature = macula_mldsa::sign(ML_DSA_87, seed, &representative, COMPOSITE_LABEL) + .map_err(|_| KeyError::Sign("ML-DSA-87"))?; + let mut rsa_signature = vec![0u8; rsa.pair.public_modulus_len()]; + rsa.pair + .sign( + &RSA_PSS_SHA384, + &SystemRandom::new(), + &representative, + &mut rsa_signature, + ) + .map_err(|_| KeyError::Sign("RSA-PSS"))?; + signature.extend_from_slice(&rsa_signature); + Ok(signature) + } +} + +impl fmt::Display for NodeKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} {} key {}", + self.purpose, + self.profile, + hex_of(&self.key_id()) + ) + } +} + +impl fmt::Debug for NodeKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +/// Whether `signature` is valid over `message` for a key as carried, under +/// `profile`: an ML-DSA-87 signature in pq_pure, and in pq_hybrid a composite +/// whose two halves both verify, with the key in its one carried form. +/// Malformed input is refused, never panicked on. +pub fn verify(message: &[u8], signature: &[u8], carried_key: &[u8], profile: Profile) -> bool { + if !profile.hybrid() { + return signature.len() == MLDSA_SIGNATURE_SIZE + && carried_key.len() == MLDSA_PUBLIC_KEY_SIZE + && macula_mldsa::verify(ML_DSA_87, carried_key, message, signature, &[]) == Ok(true); + } + if signature.len() != signature_size(profile) || !carried_key_well_formed(carried_key, profile) + { + return false; + } + let representative = composite_representative(message); + let (mldsa_public, rsa_public) = carried_key.split_at(MLDSA_PUBLIC_KEY_SIZE); + let (mldsa_signature, rsa_signature) = signature.split_at(MLDSA_SIGNATURE_SIZE); + let mldsa_valid = macula_mldsa::verify( + ML_DSA_87, + mldsa_public, + &representative, + mldsa_signature, + COMPOSITE_LABEL, + ) == Ok(true); + let rsa_valid = UnparsedPublicKey::new(&RSA_PSS_2048_8192_SHA384, rsa_public) + .verify(&representative, rsa_signature) + .is_ok(); + mldsa_valid && rsa_valid +} + +/// Whether `key` is a key in its one carried form for `profile` (D13): exactly +/// 2,592 bytes in pq_pure, and in pq_hybrid the ML-DSA-87 key followed by a DER +/// `RSAPublicKey` that encodes back to the same bytes, with a 4,096-bit modulus +/// and exponent 65537. It says nothing about who holds the key. +pub fn carried_key_well_formed(key: &[u8], profile: Profile) -> bool { + if !profile.hybrid() { + return key.len() == MLDSA_PUBLIC_KEY_SIZE; + } + if key.len() <= MLDSA_PUBLIC_KEY_SIZE { + return false; + } + let der = &key[MLDSA_PUBLIC_KEY_SIZE..]; + der::rsa_public_key_is_4096_f4(der) + && aws_lc_rs::rsa::PublicKey::from_der(der).is_ok_and(|parsed| parsed.as_ref() == der) +} + +/// The size of a signature by a node key in `profile`: the ML-DSA-87 +/// signature, followed in pq_hybrid by an RSA-PSS signature as long as the +/// modulus. +pub fn signature_size(profile: Profile) -> usize { + if profile.hybrid() { + MLDSA_SIGNATURE_SIZE + RSA_MODULUS_BYTES + } else { + MLDSA_SIGNATURE_SIZE + } +} + +/// The node_id of an identity key as carried, under `profile` (D5). A node_id +/// earns no trust on its own: rely on it only after a signature by the same +/// carried key has verified. +pub fn node_id_of(carried_key: &[u8], profile: Profile) -> [u8; 32] { + labelled_id(NODE_ID_LABEL, carried_key, profile) +} + +/// The key id of a key as carried that is not an identity key. Like a +/// node_id, it earns no trust on its own. +pub fn key_id_of(carried_key: &[u8], profile: Profile) -> [u8; 32] { + labelled_id(KEY_ID_LABEL, carried_key, profile) +} + +/// SHA-256 over `label`, a zero byte, the length and ASCII name of +/// `profile`, and a key as carried. +fn labelled_id(label: &[u8], carried_key: &[u8], profile: Profile) -> [u8; 32] { + let name = profile.name(); + let mut h = Sha256::new(); + h.update(label); + h.update([0, name.len() as u8]); + h.update(name.as_bytes()); + h.update(carried_key); + h.finalize().into() +} + +/// Whether `node_id` starts with `difficulty` zero bits. A difficulty above +/// 256 is never solved. +pub fn puzzle_solved(node_id: &[u8; 32], difficulty: u32) -> bool { + if difficulty > 256 { + return false; + } + let (whole, rest) = ((difficulty / 8) as usize, difficulty % 8); + node_id[..whole].iter().all(|&b| b == 0) && (rest == 0 || node_id[whole] >> (8 - rest) == 0) +} + +/// The message both halves of a composite sign: the prefix, the label, a zero +/// byte for the empty application context, and the SHA-512 of the message. +fn composite_representative(message: &[u8]) -> Vec { + let mut out = Vec::with_capacity(COMPOSITE_PREFIX.len() + COMPOSITE_LABEL.len() + 1 + 64); + out.extend_from_slice(COMPOSITE_PREFIX); + out.extend_from_slice(COMPOSITE_LABEL); + out.push(0); + out.extend_from_slice(&Sha512::digest(message)); + out +} + +fn hex_of(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} diff --git a/src/node_key/der.rs b/src/node_key/der.rs new file mode 100644 index 0000000..45d8813 --- /dev/null +++ b/src/node_key/der.rs @@ -0,0 +1,66 @@ +//! The little DER a node key reads: an `RSAPublicKey`'s modulus and exponent, +//! and the PKCS #1 key inside a PKCS #8 `PrivateKeyInfo`. Definite lengths in +//! their shortest form only, as DER has them; anything else is refused. + +const SEQUENCE: u8 = 0x30; +const INTEGER: u8 = 0x02; +const OCTET_STRING: u8 = 0x04; + +/// The element at the start of `input` with `tag`: its content, and what +/// follows it. +fn element(input: &[u8], tag: u8) -> Option<(&[u8], &[u8])> { + let (&first, rest) = input.split_first()?; + if first != tag { + return None; + } + let (&len0, rest) = rest.split_first()?; + let (len, rest) = if len0 < 0x80 { + (len0 as usize, rest) + } else { + let count = (len0 & 0x7f) as usize; + if count == 0 || count > 4 || rest.len() < count || rest[0] == 0 { + return None; + } + let len = rest[..count] + .iter() + .fold(0usize, |n, &b| (n << 8) | b as usize); + if len < 0x80 { + return None; + } + (len, &rest[count..]) + }; + if rest.len() < len { + return None; + } + Some(rest.split_at(len)) +} + +/// Whether `der` is exactly an `RSAPublicKey` with a 4,096-bit modulus and the +/// exponent 65537, each a minimal positive INTEGER. +pub(super) fn rsa_public_key_is_4096_f4(der: &[u8]) -> bool { + let Some((body, [])) = element(der, SEQUENCE) else { + return false; + }; + let Some((modulus, rest)) = element(body, INTEGER) else { + return false; + }; + let Some((exponent, [])) = element(rest, INTEGER) else { + return false; + }; + modulus.len() == 513 + && modulus[0] == 0 + && modulus[1] & 0x80 != 0 + && exponent == [0x01, 0x00, 0x01] +} + +/// The PKCS #1 `RSAPrivateKey` a PKCS #8 `PrivateKeyInfo` holds. +pub(super) fn pkcs1_of_pkcs8(pkcs8: &[u8]) -> Option> { + let (body, rest) = element(pkcs8, SEQUENCE)?; + if !rest.is_empty() { + return None; + } + let (_version, rest) = element(body, INTEGER)?; + let (_algorithm, rest) = element(rest, SEQUENCE)?; + let (key, _attributes) = element(rest, OCTET_STRING)?; + Some(key.to_vec()) +} diff --git a/src/node_key/key_file.rs b/src/node_key/key_file.rs new file mode 100644 index 0000000..692ff54 --- /dev/null +++ b/src/node_key/key_file.rs @@ -0,0 +1,445 @@ +//! Key files in macula's seed form, as macula-go writes them: the magic, the +//! purpose, profile and half count, then each half as its algorithm tag and its +//! public and private keys, each length-prefixed in four big-endian bytes. An +//! ML-DSA-87 half keeps its 32-byte seed, and an RSA-PSS half its PKCS #1 key. +//! A key file is readable by its owner only. + +use std::fmt; +use std::io::{Read, Write}; +use std::path::Path; + +use aws_lc_rs::encoding::AsDer; +use aws_lc_rs::rsa::KeyPair as RsaKeyPair; +use aws_lc_rs::signature::KeyPair as _; +use macula_mldsa::{PrivateKey, Zeroizing, ML_DSA_87}; + +use super::{der, verify, KeyError, NodeKey, Purpose, RsaHalf}; +use crate::keystore::{KeyStore, KeyStoreError}; +use crate::profile::Profile; + +/// Opens every key file this crate writes. macula's own key files hold the +/// expanded ML-DSA-87 key and open with "macula-node-key-v1", so one is never +/// taken for the other. +const MAGIC: &[u8] = b"macula-node-key-seed-v1\0"; + +/// The most a load reads: a key file is a few KiB. +const MAX_KEY_FILE_BYTES: u64 = 64 * 1024; + +const TAG_MLDSA_SEED: u8 = 1; +const TAG_RSA_PSS: u8 = 2; + +/// Why a key file was not saved or loaded. +#[derive(Debug)] +pub enum KeyFileError { + /// Reading or writing the file failed. + Io(std::io::Error), + /// The path names something other than a regular file, directly or + /// through a symlink. + NotRegular, + /// Another user than the effective user owns the file. + Owner, + /// The file's group or others can read it. + Permissions, + /// The file is longer than 64 KiB, which no key file is. + TooLarge, + /// The file is not a key file in the seed form. + BadKeyFile, + /// The file holds a key for another purpose, named here. + WrongPurpose(Purpose), + /// The file holds a key for another profile, named here. + WrongProfile(Profile), + /// The key's halves do not fit its profile. + WrongAlgorithms, + /// The RSA-PSS half is not a 4,096-bit key with exponent 65537. + WrongKeySize, + /// A private key does not decode. + PrivateKeyInvalid, + /// A stored public key is not the one its private key derives. + PublicKeyMismatch, + /// The key does not sign and verify as a whole. + RoundTripFailed, + /// The key store could not save or load the key. + KeyStore(KeyStoreError), + /// A new key could not be made. + Generate(KeyError), +} + +impl fmt::Display for KeyFileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + KeyFileError::Io(e) => write!(f, "key file: {e}"), + KeyFileError::NotRegular => f.write_str("the key file is not a regular file"), + KeyFileError::Owner => f.write_str("the key file is owned by another user"), + KeyFileError::Permissions => { + f.write_str("the key file can be read by its group or others") + } + KeyFileError::TooLarge => f.write_str("the key file is longer than 64 KiB"), + KeyFileError::BadKeyFile => f.write_str("not a key file in the seed form"), + KeyFileError::WrongPurpose(p) => write!(f, "the key file holds a key for {p}"), + KeyFileError::WrongProfile(p) => write!(f, "the key file holds a key for {p}"), + KeyFileError::WrongAlgorithms => f.write_str("the key's halves do not fit its profile"), + KeyFileError::WrongKeySize => { + f.write_str("the RSA-PSS half is not a 4096-bit key with exponent 65537") + } + KeyFileError::PrivateKeyInvalid => { + f.write_str("the key file's private key is not valid") + } + KeyFileError::PublicKeyMismatch => { + f.write_str("the stored public key is not the one its private key derives") + } + KeyFileError::RoundTripFailed => f.write_str("the key does not sign and verify"), + KeyFileError::KeyStore(e) => write!(f, "key store: {e}"), + KeyFileError::Generate(e) => write!(f, "a new key: {e}"), + } + } +} + +impl std::error::Error for KeyFileError {} + +impl From for KeyFileError { + fn from(e: std::io::Error) -> Self { + KeyFileError::Io(e) + } +} + +impl NodeKey { + /// Writes the key to `path` in the seed form, readable by its owner only. + /// The file is created in a new owner-only directory beside `path`, + /// written, synced and renamed over any file at `path`; then `path`'s + /// directory is synced and the new one removed. Nothing else in the + /// directory is read, written or removed. + pub fn save(&self, path: &Path) -> Result<(), KeyFileError> { + let dir = match path.parent() { + Some(d) if !d.as_os_str().is_empty() => d, + _ => Path::new("."), + }; + create_dir_owner_only(dir, true)?; + let base = path + .file_name() + .ok_or(KeyFileError::NotRegular)? + .to_string_lossy(); + let staging = dir.join(format!(".{base}.saving-{}", random_suffix()?)); + create_dir_owner_only(&staging, false)?; + let result = write_staged(&staging, path, &self.file_bytes()?).and_then(|()| sync_dir(dir)); + let removed = std::fs::remove_dir_all(&staging); + result?; + removed.map_err(KeyFileError::from) + } + + /// The key saved at `path` for `purpose` in `profile`, checked before it + /// is returned. A path that names anything but a regular file, directly + /// or through a symlink, is refused before it is opened, and the opened + /// file is checked again: a regular file, owned by the effective user, + /// that its group and others cannot read, of at most 64 KiB. Then a key + /// for another purpose or profile, halves that do not fit the profile, a + /// stored public key its private key does not derive, and a key that + /// fails a sign-and-verify round trip are refused. + pub fn load(path: &Path, purpose: Purpose, profile: Profile) -> Result { + let contents = read_key_file(path)?; + let key = parse(&contents, purpose, profile)?; + round_trip(&key)?; + Ok(key) + } + + /// The identity key at `path` in `profile`, or, when nothing is there, a + /// new one with the admission puzzle solved, saved there first. Anything + /// at `path` that does not load as such a key is refused and left as it + /// is, never replaced. + pub fn load_or_create(path: &Path, profile: Profile) -> Result { + match std::fs::symlink_metadata(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let key = NodeKey::generate_identity(profile, super::PUZZLE_DIFFICULTY) + .map_err(KeyFileError::Generate)?; + key.save(path)?; + Ok(key) + } + _ => NodeKey::load(path, Purpose::Identity, profile), + } + } + + /// Keeps the key in `store`, as the bytes of its key file: the platform + /// secure store a mobile app keeps its key in (see `crate::keystore`). + pub fn save_to_keystore(&self, store: &dyn KeyStore) -> Result<(), KeyFileError> { + store + .save_key(&self.file_bytes()?) + .map_err(KeyFileError::KeyStore) + } + + /// The key kept in `store` for `purpose` in `profile`, checked as a key + /// file's is, but for the file's owner and permissions, which the store + /// keeps. + pub fn load_from_keystore( + store: &dyn KeyStore, + purpose: Purpose, + profile: Profile, + ) -> Result { + let contents = store.load_key().map_err(KeyFileError::KeyStore)?; + let key = parse(&contents, purpose, profile)?; + round_trip(&key)?; + Ok(key) + } + + /// The key laid out as a key file. + fn file_bytes(&self) -> Result, KeyFileError> { + let mut out = MAGIC.to_vec(); + out.extend([ + purpose_tag(self.purpose), + profile_tag(self.profile), + if self.rsa.is_some() { 2 } else { 1 }, + ]); + append_half( + &mut out, + TAG_MLDSA_SEED, + &self.mldsa_public, + &self.mldsa_seed[..], + ); + if let Some(rsa) = &self.rsa { + let private = rsa_private_pkcs1(&rsa.pair)?; + append_half(&mut out, TAG_RSA_PSS, &rsa.public_der, &private); + } + Ok(out) + } +} + +fn purpose_tag(purpose: Purpose) -> u8 { + match purpose { + Purpose::Identity => 1, + Purpose::Connect => 2, + } +} + +fn profile_tag(profile: Profile) -> u8 { + match profile { + Profile::PqPure => 1, + Profile::PqHybrid => 2, + } +} + +fn append_half(out: &mut Vec, tag: u8, public: &[u8], private: &[u8]) { + out.push(tag); + out.extend((public.len() as u32).to_be_bytes()); + out.extend_from_slice(public); + out.extend((private.len() as u32).to_be_bytes()); + out.extend_from_slice(private); +} + +/// The RSA half's private key as PKCS #1, which aws-lc-rs hands out inside a +/// PKCS #8 `PrivateKeyInfo`. +fn rsa_private_pkcs1(pair: &RsaKeyPair) -> Result>, KeyFileError> { + let pkcs8 = pair.as_der().map_err(|_| KeyFileError::PrivateKeyInvalid)?; + der::pkcs1_of_pkcs8(pkcs8.as_ref()) + .map(Zeroizing::new) + .ok_or(KeyFileError::PrivateKeyInvalid) +} + +/// One half as a key file holds it. +struct StoredHalf<'a> { + tag: u8, + public: &'a [u8], + private: &'a [u8], +} + +/// A key file's key, checked for `purpose` and `profile`. +fn parse(bytes: &[u8], purpose: Purpose, profile: Profile) -> Result { + let rest = bytes.strip_prefix(MAGIC).ok_or(KeyFileError::BadKeyFile)?; + let [purpose_byte, profile_byte, count, halves_bytes @ ..] = rest else { + return Err(KeyFileError::BadKeyFile); + }; + let stored_purpose = match purpose_byte { + 1 => Purpose::Identity, + 2 => Purpose::Connect, + _ => return Err(KeyFileError::BadKeyFile), + }; + let stored_profile = match profile_byte { + 1 => Profile::PqPure, + 2 => Profile::PqHybrid, + _ => return Err(KeyFileError::BadKeyFile), + }; + let halves = parse_halves(halves_bytes)?; + if halves.len() != *count as usize { + return Err(KeyFileError::BadKeyFile); + } + if stored_purpose != purpose { + return Err(KeyFileError::WrongPurpose(stored_purpose)); + } + if stored_profile != profile { + return Err(KeyFileError::WrongProfile(stored_profile)); + } + let fits = match profile { + Profile::PqPure => halves.len() == 1 && halves[0].tag == TAG_MLDSA_SEED, + Profile::PqHybrid => { + halves.len() == 2 && halves[0].tag == TAG_MLDSA_SEED && halves[1].tag == TAG_RSA_PSS + } + }; + if !fits { + return Err(KeyFileError::WrongAlgorithms); + } + let (mldsa_seed, mldsa_public) = mldsa_from_half(&halves[0])?; + let rsa = if profile.hybrid() { + Some(rsa_from_half(&halves[1])?) + } else { + None + }; + Ok(NodeKey { + purpose, + profile, + mldsa_seed, + mldsa_public, + rsa, + }) +} + +fn parse_halves(mut bytes: &[u8]) -> Result>, KeyFileError> { + let mut halves = Vec::new(); + while let Some((&tag, rest)) = bytes.split_first() { + if tag != TAG_MLDSA_SEED && tag != TAG_RSA_PSS { + return Err(KeyFileError::BadKeyFile); + } + let (public, rest) = length_prefixed(rest)?; + let (private, rest) = length_prefixed(rest)?; + halves.push(StoredHalf { + tag, + public, + private, + }); + bytes = rest; + } + Ok(halves) +} + +fn length_prefixed(bytes: &[u8]) -> Result<(&[u8], &[u8]), KeyFileError> { + let (len, rest) = bytes + .split_first_chunk::<4>() + .ok_or(KeyFileError::BadKeyFile)?; + let len = u32::from_be_bytes(*len) as usize; + if len > rest.len() { + return Err(KeyFileError::BadKeyFile); + } + Ok(rest.split_at(len)) +} + +fn mldsa_from_half(half: &StoredHalf<'_>) -> Result<(Zeroizing<[u8; 32]>, Vec), KeyFileError> { + let seed: [u8; 32] = half + .private + .try_into() + .map_err(|_| KeyFileError::PrivateKeyInvalid)?; + let seed = Zeroizing::new(seed); + let derived = macula_mldsa::public_key(ML_DSA_87, PrivateKey::Seed(&seed)) + .map_err(|_| KeyFileError::PrivateKeyInvalid)?; + if derived != half.public { + return Err(KeyFileError::PublicKeyMismatch); + } + Ok((seed, derived)) +} + +fn rsa_from_half(half: &StoredHalf<'_>) -> Result { + let pair = RsaKeyPair::from_der(half.private).map_err(|_| KeyFileError::PrivateKeyInvalid)?; + if pair.public_key().as_ref() != half.public { + return Err(KeyFileError::PublicKeyMismatch); + } + if !der::rsa_public_key_is_4096_f4(half.public) { + return Err(KeyFileError::WrongKeySize); + } + Ok(RsaHalf { + pair, + public_der: half.public.to_vec(), + }) +} + +/// Signs a random message with the whole key and verifies it. A hybrid key +/// signs its composite, never one half on its own. +fn round_trip(key: &NodeKey) -> Result<(), KeyFileError> { + let mut message = [0u8; 32]; + aws_lc_rs::rand::fill(&mut message).map_err(|_| KeyFileError::RoundTripFailed)?; + let signature = key + .sign(&message) + .map_err(|_| KeyFileError::RoundTripFailed)?; + if verify(&message, &signature, &key.public_key(), key.profile) { + Ok(()) + } else { + Err(KeyFileError::RoundTripFailed) + } +} + +fn random_suffix() -> Result { + let mut bytes = [0u8; 8]; + aws_lc_rs::rand::fill(&mut bytes) + .map_err(|_| KeyFileError::Io(std::io::Error::other("no randomness")))?; + Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) +} + +fn write_staged(staging: &Path, path: &Path, contents: &[u8]) -> Result<(), KeyFileError> { + let staged = staging.join("key"); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let mut file = options.open(&staged)?; + file.write_all(contents)?; + file.sync_all()?; + drop(file); + std::fs::rename(&staged, path)?; + Ok(()) +} + +fn create_dir_owner_only(dir: &Path, recursive: bool) -> Result<(), KeyFileError> { + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(recursive); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder.create(dir)?; + Ok(()) +} + +fn sync_dir(dir: &Path) -> Result<(), KeyFileError> { + #[cfg(unix)] + std::fs::File::open(dir)?.sync_all()?; + #[cfg(not(unix))] + let _ = dir; + Ok(()) +} + +/// The contents of the key file at `path`, read only once the path names a +/// regular file and the opened file passes [`owner_only`]. +fn read_key_file(path: &Path) -> Result, KeyFileError> { + if !std::fs::metadata(path)?.is_file() { + return Err(KeyFileError::NotRegular); + } + let mut options = std::fs::OpenOptions::new(); + options.read(true); + // Without waiting, should the path have become a FIFO since it was + // checked. + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::custom_flags( + &mut options, + rustix::fs::OFlags::NONBLOCK.bits() as i32, + ); + let file = options.open(path)?; + owner_only(&file.metadata()?)?; + let mut contents = Vec::new(); + file.take(MAX_KEY_FILE_BYTES + 1) + .read_to_end(&mut contents)?; + if contents.len() as u64 > MAX_KEY_FILE_BYTES { + return Err(KeyFileError::TooLarge); + } + Ok(contents) +} + +/// Refuses an opened key file that is not a regular file, not the effective +/// user's, or readable by its group or others. +fn owner_only(metadata: &std::fs::Metadata) -> Result<(), KeyFileError> { + if !metadata.is_file() { + return Err(KeyFileError::NotRegular); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.uid() != rustix::process::geteuid().as_raw() { + return Err(KeyFileError::Owner); + } + if metadata.mode() & 0o077 != 0 { + return Err(KeyFileError::Permissions); + } + } + Ok(()) +} diff --git a/src/open_sessions.rs b/src/open_sessions.rs deleted file mode 100644 index 2039eb4..0000000 --- a/src/open_sessions.rs +++ /dev/null @@ -1,243 +0,0 @@ -//! The sessions this process has open, at most one per identity and -//! station, so direct dial can reuse one instead of dialing again. -//! -//! A station keeps one connection per identity: when a newer connection -//! under an identity completes its handshake, the station closes the older -//! one (`macula_station_listener.erl`, `replaced_by_newer_handshake`). Code -//! that needs a station under an identity this process already holds a -//! session to must therefore reuse that session, or it closes its own -//! session by dialing. Direct dial looks here before it dials. -//! -//! A session registers once its HELLO is accepted and unregisters when it -//! closes. The newest registration for a pair wins, matching the station, -//! and unregistering removes an entry only if it still holds that same -//! session, so closing an older session never drops a newer one. Entries -//! are weak: a session dropped without closing, or whose connection has -//! ended, is not found. -//! -//! A session direct dial dialed for its own requests carries [`Leases`]: -//! every request using it holds one, so it closes when the last request is -//! done, and it is not reused once it is closing. A session its owner opened -//! carries none, and direct dial never closes it. - -use std::collections::HashMap; -use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError, Weak}; - -/// Whether an open session's connection can still carry a request. -pub(crate) trait Live { - fn is_live(&self) -> bool; -} - -/// A session that may carry [`Leases`]: one direct dial dialed does, one its -/// owner opened doesn't. -pub(crate) trait Leased { - fn leases(&self) -> Option<&Leases>; -} - -/// The requests using a session direct dial dialed, counted so the session -/// closes when the last one is done and is not reused once it is closing. -/// The request that dialed the session holds the first lease. -pub(crate) struct Leases { - count: Mutex, -} - -impl Leases { - pub(crate) fn new() -> Self { - Self { - count: Mutex::new(1), - } - } - - /// Takes one more lease, unless the last one was already released. - pub(crate) fn try_lease(&self) -> bool { - let mut count = self.count.lock().unwrap_or_else(PoisonError::into_inner); - if *count == 0 { - return false; - } - *count += 1; - true - } - - /// Gives one lease back, and says whether it was the last, so the session - /// is to be closed. A release after the last one does nothing. - pub(crate) fn release(&self) -> bool { - let mut count = self.count.lock().unwrap_or_else(PoisonError::into_inner); - if *count == 0 { - return false; - } - *count -= 1; - *count == 0 - } -} - -/// An identity's node id and a station's node id. -type Pair = ([u8; 32], [u8; 32]); - -pub(crate) struct OpenSessions { - open: Mutex>>, -} - -impl Default for OpenSessions { - fn default() -> Self { - Self { - open: Mutex::new(HashMap::new()), - } - } -} - -impl OpenSessions { - pub(crate) fn register(&self, identity: [u8; 32], station: [u8; 32], session: &Arc) { - self.lock() - .insert((identity, station), Arc::downgrade(session)); - } - - #[cfg(test)] - pub(crate) fn unregister(&self, identity: [u8; 32], station: [u8; 32], session: &Arc) { - self.unregister_pointer(identity, station, Arc::as_ptr(session)); - } - - /// [`unregister`](Self::unregister), for a session only a weak reference - /// is left to, such as one whose last handle is being dropped. - pub(crate) fn unregister_weak(&self, identity: [u8; 32], station: [u8; 32], session: &Weak) { - self.unregister_pointer(identity, station, session.as_ptr()); - } - - fn unregister_pointer(&self, identity: [u8; 32], station: [u8; 32], session: *const C) { - let mut open = self.lock(); - if open - .get(&(identity, station)) - .is_some_and(|held| std::ptr::eq(held.as_ptr(), session)) - { - open.remove(&(identity, station)); - } - } - - pub(crate) fn find(&self, identity: [u8; 32], station: [u8; 32]) -> Option> { - let mut open = self.lock(); - let found = open - .get(&(identity, station)) - .and_then(Weak::upgrade) - .filter(|session| session.is_live()); - if found.is_none() { - open.remove(&(identity, station)); - } - found - } - - // A panic elsewhere while the lock was held leaves the map itself intact. - fn lock(&self) -> MutexGuard<'_, HashMap>> { - self.open.lock().unwrap_or_else(PoisonError::into_inner) - } -} - -/// This process's open sessions, which every -/// [`Session`](crate::connection::Session) registers with. -pub(crate) fn live() -> &'static OpenSessions { - static LIVE: OnceLock> = OnceLock::new(); - LIVE.get_or_init(OpenSessions::default) -} - -#[cfg(test)] -mod tests { - use std::sync::atomic::{AtomicBool, Ordering}; - - use super::*; - - struct FakeSession { - live: AtomicBool, - } - - impl Live for FakeSession { - fn is_live(&self) -> bool { - self.live.load(Ordering::Relaxed) - } - } - - fn open_session() -> Arc { - Arc::new(FakeSession { - live: AtomicBool::new(true), - }) - } - - fn node_id() -> [u8; 32] { - rand::random() - } - - fn holds(found: Option>, session: &Arc) -> bool { - found.is_some_and(|found| Arc::ptr_eq(&found, session)) - } - - #[test] - fn a_session_is_found_by_its_identity_and_station() { - let (identity, station) = (node_id(), node_id()); - let open = OpenSessions::default(); - let session = open_session(); - - open.register(identity, station, &session); - - assert!(holds(open.find(identity, station), &session)); - assert!(open.find(node_id(), station).is_none()); - assert!(open.find(identity, node_id()).is_none()); - } - - #[test] - fn the_newest_session_per_identity_and_station_wins() { - let (identity, station) = (node_id(), node_id()); - let open = OpenSessions::default(); - let (older, newer) = (open_session(), open_session()); - - open.register(identity, station, &older); - open.register(identity, station, &newer); - - assert!(holds(open.find(identity, station), &newer)); - } - - #[test] - fn closing_an_older_session_leaves_the_newer_one_registered() { - let (identity, station) = (node_id(), node_id()); - let open = OpenSessions::default(); - let (older, newer) = (open_session(), open_session()); - open.register(identity, station, &older); - open.register(identity, station, &newer); - - open.unregister(identity, station, &older); - - assert!(holds(open.find(identity, station), &newer)); - } - - #[test] - fn a_closed_session_is_no_longer_found() { - let (identity, station) = (node_id(), node_id()); - let open = OpenSessions::default(); - let session = open_session(); - open.register(identity, station, &session); - - open.unregister(identity, station, &session); - - assert!(open.find(identity, station).is_none()); - } - - #[test] - fn a_dropped_session_is_no_longer_found() { - let (identity, station) = (node_id(), node_id()); - let open = OpenSessions::default(); - let session = open_session(); - open.register(identity, station, &session); - - drop(session); - - assert!(open.find(identity, station).is_none()); - } - - #[test] - fn a_session_whose_connection_ended_is_no_longer_found() { - let (identity, station) = (node_id(), node_id()); - let open = OpenSessions::default(); - let session = open_session(); - open.register(identity, station, &session); - - session.live.store(false, Ordering::Relaxed); - - assert!(open.find(identity, station).is_none()); - } -} diff --git a/src/pool.rs b/src/pool.rs index 5e0ba02..f39f524 100644 --- a/src/pool.rs +++ b/src/pool.rs @@ -1,1204 +1,482 @@ -//! A multi-station connection pool: dials several [`Session`]s concurrently -//! (bootstrap seeds, optionally grown by discovering more via -//! `hecate_stations.list_stations`) and gives [`Pool::call`]/ -//! [`Pool::publish`] a choice of which connected one to use, instead of a -//! caller managing a single [`Session`] by hand. +//! A macula 12 node's set of station links, as macula's client pool keeps +//! them: one link to each seed station, every seed pinned by its node_id, +//! all links of one node sharing its identity key, statement issuer, request +//! admission, publication seq and event dedup. A link that ends is dialed +//! again after the respawn delay and given back the node's subscriptions and +//! served procedures. //! -//! **Call/Publish only — no pooled Subscribe.** A caller that needs -//! Subscribe uses [`Session::subscribe`]/[`Session::run_subscriber`] on a -//! session directly, unpooled. Each link's [`Session`] runs its own reader, -//! so calls and publishes on one link run at the same time. +//! Calls reach providers directly, as macula 12 calls them: the procedure's +//! advertisements are resolved from the DHT and checked against the realm key +//! the pool pins for the realm, the serving station an advertisement names is +//! dialed (pinned by its node_id, from its own station_endpoint record) and +//! the provider called there. Station procedures (`_dht.*`) go to the pool's +//! links. //! -//! Ported from the same station-discovery/link-rotation design already -//! shipped in `macula-go` (`pool/discovery.go`, v0.7.0) and `macula-dotnet` -//! (`StationDiscovery.cs`, v0.4.0) — see those crates' own doc comments for -//! the fuller cross-language history. This is a from-scratch build, not a -//! port of an existing pool: this crate had no `Seed`/multi-link concept at -//! all before this module. -//! -//! **Per-link trust, not one fixed mode for the whole pool** — this is the -//! one deliberate design difference from the go/dotnet ports, made possible -//! by building from scratch rather than extending an existing single-Trust -//! pool: a discovered station whose directory row has NO `hostname` (only a -//! bare-IP `host_advertised`) but DOES carry a `node_id` dials under -//! `Trust::Pinned(node_id)` instead of being skipped outright the way -//! go/dotnet's ports skip every hostname-less row under `Trust::WebPki` -//! (which can never validate a bare IP with no IP SANs). The underlying -//! MECHANISM mirrors a shipped, live-verified precedent in a completely -//! different codebase: `macula-apps/macula-cam2me`'s Android client -//! (`reachability/StationDiscovery.kt`), confirmed against the real fleet -//! by 34 (`macula`'s own reference-implementation session) independently -//! reaching the identical conclusion this module reaches, including a -//! live TLS-layer `verify=none` warning from `macula_quic` when dialing a -//! hostname'd station by IP+Pinned instead of its usual WebPki path. -//! -//! **This module's PRIORITY ORDER deliberately differs from cam2me's own, -//! in the safer direction** — cam2me picks Pinned(node_id) whenever a row -//! carries a node_id at all (true of essentially every row), falling back -//! to hostname/WebPki only when `host_advertised` itself is missing; that -//! trades away TLS-layer MITM resistance for the common case, not just the -//! no-DNS one. Here, `dial_target_from_station_row` prefers `hostname` -//! unconditionally — `Trust::Pinned` is chosen only when a row has NO -//! usable hostname at all, so a normal Let's-Encrypt-backed station still -//! dials WebPki exactly as it always has; only a genuine no-DNS station -//! (`stations-linode-toronto` — see `tests/live_station.rs`'s own -//! `pinned_trust_full_handshake_succeeds_against_toronto`) ever falls to -//! Pinned. Bootstrap seeds are entirely unaffected either way — they -//! always dial under the pool's own configured `Trust`. - -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::Arc; +//! Station discovery beyond the seeds is not here: macula's discovery calls +//! hecate_stations.list_stations, which the fleet no longer serves +//! (macula-io/macula#31). + +mod call; +mod member; +mod pubsub; +mod serve; + +pub use call::{Call, Provider, StreamCall}; +pub use pubsub::Subscription; +pub use serve::{Offer, Served}; + +use std::collections::HashMap; +use std::fmt; +use std::sync::{Arc, Mutex, MutexGuard}; use std::time::Duration; -use tokio::sync::{watch, Mutex, RwLock}; -use tokio::task::JoinSet; - -use crate::connection::{self, CallError, Session}; -use crate::dht; -use crate::frame::{CallResponse, CallSpec, PublishSpec}; -use crate::identity::KeyPair; -use crate::transport::Trust; +use crate::node_key::{carried_key_well_formed, NodeKey, Purpose}; +use crate::statement_issuer::{IssuerError, StatementIssuer}; +use crate::station_link::{ + Admission, AdmissionLimits, EventDedup, Link, LinkError, PublicationSeq, +}; +use crate::transport::Target; -/// A dial target: host+port only, no identity attached (every link in a -/// pool shares the pool's one identity). Mirrors macula-go's -/// `connection.Seed` / macula-dotnet's `Seed` record — this crate had no -/// equivalent type before this pool. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct Seed { - pub host: String, - pub port: u16, -} +use member::Member; -impl Seed { - pub fn new(host: impl Into, port: u16) -> Self { - Self { - host: host.into(), - port, +/// macula's defaults and caps for a pool's bounds. +pub const DEFAULT_REPLICATION_FACTOR: usize = 2; +pub const DEFAULT_RESPAWN_DELAY: Duration = Duration::from_secs(1); +pub const DEFAULT_MAX_SEEDS: usize = 16; +pub const DEFAULT_MAX_DIRECT_LINKS: usize = 8; +pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_LINK_LIMIT: usize = 64; + +/// Why a pool, or one of its operations, failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PoolError { + /// A pool given no seed station. + NoSeeds, + /// A seed without the station's node_id, as host:port: a pool dials + /// only stations it can check. + SeedNotPinned(String), + /// More seeds than `max_seeds`. + TooManySeeds { given: usize, max: usize }, + /// A realm key that is not a well-formed key of the pool's profile, and + /// its realm. + RealmTrustInvalid([u8; 32]), + /// An option out of its range, or a key that is no identity key. + InvalidOpts(String), + /// No link came up within the connect timeout, or none is up to carry an + /// operation; each link's last error. + NoLink(Vec), + /// An operation on a closed pool. + Closed, + /// A realm the pool pins no key for: nothing in it is served or trusted. + NoRealmKey, + /// No provider the pinned realm key authorizes answered: each candidate + /// tried and why it failed; none when there was no candidate at all. + NoProvider(Vec<(Provider, PoolError)>), + /// A serving station with no endpoint record it signed itself. + NoStationEndpoint(Option), + /// A serving station not yet linked while `max_direct_links` direct + /// links are held. + DirectLinksFull, + /// A station that could not be linked, and the last dial's error. + StationNotReached { + station: [u8; 32], + cause: Option, + }, + /// A procedure no link would serve, and each link's error. + NotServed(Vec), + /// A link's own failure, or a provider's or station's answer. + Link(LinkError), +} + +impl fmt::Display for PoolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + PoolError::Link(e) => write!(f, "{e}"), + PoolError::NoProvider(tried) if tried.is_empty() => { + f.write_str("no trusted provider advertises the procedure") + } + PoolError::NoProvider(tried) => { + f.write_str("no trusted provider answered:")?; + for (p, e) in tried { + write!(f, " [{} at {}: {e}]", short(&p.node), short(&p.station))?; + } + Ok(()) + } + other => write!(f, "{other:?}"), } } } -/// How [`Pool::call`]/[`Pool::publish`] order the pool's currently-connected -/// links before applying their own existing first-match/`replication_factor` -/// logic — changes ORDER only, never how many links get used. Matches -/// macula_client.erl's own `link_selection` option (`first_success`/ -/// `random`) and macula-go/macula-dotnet's identically-named enum, so -/// config ported from any of those doesn't need re-learning. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum LinkSelection { - /// (The default.) Derives the actual policy from - /// [`StationDiscoveryOptions::enabled`]: [`FirstSuccess`](Self::FirstSuccess) - /// if discovery is off (this pool's original, only behavior, unchanged), - /// [`Random`](Self::Random) if it's on. - #[default] - Auto, - /// Tries links in Seed-list order (bootstrap seeds in the order the - /// caller gave them, then discovered links in discovery order) — this - /// pool's baseline behavior, since `links` is a plain append-only - /// `Vec` (see [`Pool`]'s own doc on why that, not a `HashMap`, is the - /// backing store — insertion order is the ordering, nothing extra to - /// track). - FirstSuccess, - /// Uniformly shuffles the connected-links list before the same - /// first-match (Call) or take-first-N (Publish) logic runs. Composes - /// safely with a small `replication_factor`: shuffling ahead of a - /// 1-element slice is a no-op. - Random, -} +impl std::error::Error for PoolError {} -fn resolve_link_selection( - configured: LinkSelection, - station_discovery_enabled: bool, -) -> LinkSelection { - match configured { - LinkSelection::Auto if station_discovery_enabled => LinkSelection::Random, - LinkSelection::Auto => LinkSelection::FirstSuccess, - other => other, +impl From for PoolError { + fn from(e: LinkError) -> Self { + PoolError::Link(e) } } -fn select_links( - mut connected: Vec>, - resolved: LinkSelection, -) -> Vec> { - if resolved != LinkSelection::Random || connected.len() <= 1 { - return connected; - } - use rand::seq::SliceRandom; - connected.shuffle(&mut rand::rng()); - connected +fn short(id: &[u8; 32]) -> String { + id[..4].iter().map(|b| format!("{b:02x}")).collect() } -/// Configures opt-in discovery of additional stations via -/// `hecate_stations.list_stations`, layered on top of the caller-supplied -/// bootstrap [`Seed`]s. Default (`enabled == false`) is a complete no-op. -/// -/// Bootstrap seeds keep their exact meaning: dialed first, permanent -/// fallback if discovery never succeeds, retried forever on failure, never -/// replaced. Discovery only ADDS links — a station missing from a later -/// refresh does NOT tear down an existing link. A DISCOVERY-added link that -/// fails to dial `DISCOVERY_LINK_MAX_RESPAWN_ATTEMPTS` (5) times in a row -/// gives up and frees its slot (so a future refresh can try a different -/// station instead) — unlike a bootstrap seed, which never gives up. Go's -/// and dotnet's ports of this same feature have no such give-up mechanism -/// (a permanently-unreachable discovered station wastes a slot forever, -/// redialing at the flat respawn delay indefinitely); building this pool -/// from scratch, with 34's parallel design work on the Erlang reference -/// specifically adding this exception, was reason enough to include it here -/// too rather than carry the narrower behavior forward by default. -#[derive(Debug, Clone)] -pub struct StationDiscoveryOptions { - pub enabled: bool, - /// Interval between discovery attempts once at least one bootstrap - /// link is up. Default 30 minutes. - pub refresh_interval: Duration, - /// Bounds discovery's OWN adds only, not the pool's total link count — - /// see macula-dotnet's identical `StationDiscoveryOptions.MaxLinks` doc - /// for the exact accounting rules this mirrors. - pub max_links: usize, +/// A station to link to: where it is dialed and the node_id it must prove. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Seed { + pub host: String, + pub port: u16, + pub node_id: [u8; 32], } -impl Default for StationDiscoveryOptions { - fn default() -> Self { - Self { - enabled: false, - refresh_interval: Duration::from_secs(30 * 60), - max_links: 5, - } - } +/// The order calls, publications and DHT operations try the pool's links in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LinkSelection { + /// The links in seed order. + #[default] + FirstSuccess, + /// A fresh random order each time. + Random, } -/// Tunables for [`Pool`]. Defaults match every other port of this feature. -#[derive(Debug, Clone)] -pub struct PoolOptions { - pub link_selection: LinkSelection, - pub station_discovery: StationDiscoveryOptions, - /// Flat delay before redialing a link after it dies (or fails to dial - /// in the first place). Default 1s — flat, not exponential, matching - /// the reference and every other port in this SDK family except ts. - pub respawn_delay: Duration, - /// Per-CALL timeout, passed through to [`Session::call`]. Default - /// matches [`connection::DEFAULT_CALL_TIMEOUT`]. - pub call_timeout: Duration, - /// How many currently-connected links a single publish fans out to. - /// Partial success counts as success. Default 1, matching macula-ts - /// and macula-dotnet's own `ReplicationFactor` default. +/// A link coming up, or ending or failing to dial with its error. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkEvent { + pub station: [u8; 32], + pub direct: bool, + pub up: bool, + pub error: Option, +} + +/// A pool's configuration. [`Opts::new`] gives macula's defaults; a zero +/// bound also means its default. +#[derive(Clone)] +pub struct Opts { + /// The node's identity key; its profile is the pool's. + pub identity: Arc, + /// Each realm's key as carried: an advertisement in a realm is trusted + /// only when its authorization verifies against it, and an org + /// procedure is served only in a realm it names. + pub realm_trust: HashMap<[u8; 32], Vec>, pub replication_factor: usize, -} - -impl Default for PoolOptions { - fn default() -> Self { - Self { - link_selection: LinkSelection::default(), - station_discovery: StationDiscoveryOptions::default(), + pub respawn_delay: Duration, + pub max_seeds: usize, + pub max_direct_links: usize, + /// How long [`Pool::connect`] waits for a first link. + pub connect_timeout: Duration, + /// Bounds on the requests the node's served procedures take; `None` is + /// macula's defaults, with the cap one share per link the pool may hold. + pub admission: Option, + pub link_selection: LinkSelection, + /// Hears every link coming up and going down, on a task of its own. + pub on_link_event: Option>, + /// Hears each failure to reissue the node's status statements or rotate + /// its CONNECT key; `None` writes it to stderr. Left failing, the links + /// end when their statements lapse. + pub on_issuer_error: Option>, +} + +impl Opts { + /// macula's defaults for `identity`, trusting no realm. + pub fn new(identity: Arc) -> Opts { + Opts { + identity, + realm_trust: HashMap::new(), + replication_factor: DEFAULT_REPLICATION_FACTOR, respawn_delay: DEFAULT_RESPAWN_DELAY, - call_timeout: connection::DEFAULT_CALL_TIMEOUT, - replication_factor: 1, + max_seeds: DEFAULT_MAX_SEEDS, + max_direct_links: DEFAULT_MAX_DIRECT_LINKS, + connect_timeout: DEFAULT_CONNECT_TIMEOUT, + admission: None, + link_selection: LinkSelection::FirstSuccess, + on_link_event: None, + on_issuer_error: None, } } } -pub const DEFAULT_RESPAWN_DELAY: Duration = Duration::from_secs(1); - -/// Discovery-added links only (never bootstrap seeds) give up redialing -/// after this many consecutive failures — see -/// [`StationDiscoveryOptions`]'s own doc for why. -const DISCOVERY_LINK_MAX_RESPAWN_ATTEMPTS: u32 = 5; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LinkOrigin { - Bootstrap, - Discovered, -} - -/// A bootstrap link never gives up — matches every other port of this -/// pool shape, and macula_client.erl's own reference behavior for -/// caller-supplied seeds. Only a discovery-added link, after -/// [`DISCOVERY_LINK_MAX_RESPAWN_ATTEMPTS`] straight failures, gives up — -/// see [`StationDiscoveryOptions`]'s own doc for why this exception -/// exists at all. -fn should_give_up(origin: LinkOrigin, consecutive_failures: u32) -> bool { - origin == LinkOrigin::Discovered && consecutive_failures >= DISCOVERY_LINK_MAX_RESPAWN_ATTEMPTS -} - -struct LinkState { - session: Option, - peer_node_id: Option<[u8; 32]>, -} - -/// One configured link's current state. Never removed from [`Pool`]'s own -/// `links` list once added (see that field's own doc) — `connected` simply -/// goes false when the link is down or still dialing. -pub struct PooledLink { - pub seed: Seed, - origin: LinkOrigin, - /// This link's OWN trust — usually the pool's configured `Trust`, but - /// see [`Pool`]'s module-level doc for the one case (a discovered, - /// hostname-less, node_id-bearing row) where it differs per link. - trust: Trust, - /// Held only to read or swap the link's session, never across a round - /// trip: a caller clones the session handle out first. Only the link's - /// own lifecycle ([`run_link_lifecycle`]) installs a session and clears - /// it when it ends; [`Pool::close`] takes it to close it. - state: Mutex, - connected: AtomicBool, - /// Discovery-added links only: consecutive failed dial attempts, reset - /// on a successful connect. Bootstrap links never read this — they - /// retry forever regardless. - consecutive_failures: AtomicU32, - gave_up: AtomicBool, -} - -impl PooledLink { - fn new(seed: Seed, origin: LinkOrigin, trust: Trust) -> Arc { - Arc::new(Self { - seed, - origin, - trust, - state: Mutex::new(LinkState { - session: None, - peer_node_id: None, - }), - connected: AtomicBool::new(false), - consecutive_failures: AtomicU32::new(0), - gave_up: AtomicBool::new(false), - }) - } - - pub fn is_connected(&self) -> bool { - self.connected.load(Ordering::Acquire) - } - - /// A discovery-added link that has permanently given up redialing — see - /// [`StationDiscoveryOptions`]'s own doc. Always `false` for a - /// bootstrap link, which never gives up. - fn has_given_up(&self) -> bool { - self.gave_up.load(Ordering::Acquire) - } - - async fn peer_node_id(&self) -> Option<[u8; 32]> { - self.state.lock().await.peer_node_id - } -} - -/// Snapshot of one link, for health/introspection. -#[derive(Debug, Clone)] -pub struct LinkInfo { - pub seed: Seed, - pub connected: bool, - pub node_id: Option<[u8; 32]>, -} - -/// Aggregate health snapshot. Lock-free best-effort read. -#[derive(Debug, Clone, Copy)] -pub struct PoolStatus { - pub healthy_links: usize, - pub total_links: usize, -} - -impl PoolStatus { - /// At least one link has completed its CONNECT/HELLO handshake. - pub fn is_healthy(&self) -> bool { - self.healthy_links > 0 - } -} - -#[derive(Debug)] -pub enum PoolCallError { - /// No link in the pool has completed its CONNECT/HELLO handshake. - NoHealthyStation, - /// The failure that stopped the call, on the last link it was tried on. - /// The pool moves on to the next link only while a call fails before its - /// CALL was sent ([`CallError::not_sent`]), so no CALL runs twice. - Call(CallError), -} - -impl std::fmt::Display for PoolCallError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PoolCallError::NoHealthyStation => { - write!(f, "pool: no link has completed its CONNECT/HELLO handshake") - } - PoolCallError::Call(e) => write!(f, "pool: call failed: {e}"), - } - } +/// A node's station links. Cloning it shares the pool; the pool closes when +/// [`Pool::close`] is called or its last handle is dropped. +#[derive(Clone)] +pub struct Pool { + inner: Arc, } -impl std::error::Error for PoolCallError {} - -#[derive(Debug)] -pub enum PoolPublishError { - NoHealthyStation, - /// Every link the publish was routed to failed — carries the count. - AllFailed(usize), +pub(crate) struct PoolInner { + opts: Opts, + self_id: [u8; 32], + issuer: StatementIssuer, + publication_seq: Arc, + admission: Arc, + dedup: Arc, + state: Mutex, + ticks: tokio::task::JoinHandle<()>, } -impl std::fmt::Display for PoolPublishError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PoolPublishError::NoHealthyStation => { - write!(f, "pool: no link has completed its CONNECT/HELLO handshake") - } - PoolPublishError::AllFailed(n) => { - write!(f, "pool: publish failed on all {n} targeted link(s)") - } - } - } +struct State { + members: Vec>, + subs: HashMap>, + served: HashMap>, + remember: HashMap, + closed: bool, } -impl std::error::Error for PoolPublishError {} - -/// A multi-station connection pool — see this module's own doc for the -/// full design. -/// -/// `links` is a plain append-only `Vec>` behind an async -/// `RwLock`, deliberately NOT a `HashMap`/`HashSet` — this is the one -/// change made in direct response to the SAME class of bug found (twice, -/// independently) porting this feature to macula-go (`map[string]*link` -/// randomizing iteration order) and macula-dotnet (migrating to -/// `ConcurrentDictionary` for concurrent-add safety silently broke -/// `FirstSuccess`'s reliance on insertion order). A `Vec`, appended to -/// under the same lock that's ALSO taken to read it, has no separate -/// enumeration-order concept to accidentally break — insertion order IS -/// the order, by construction, with nothing to track alongside it the way -/// go's fix (tracking iteration order separately) or dotnet's fix (an -/// explicit `Ordinal` field) both had to. -pub struct Pool { - identity: Arc, - trust: Trust, - options: PoolOptions, - links: RwLock>>, - stop_tx: watch::Sender, - /// Every background task this pool has spawned (a link's respawn - /// lifecycle, the discovery loop). `Pool::close` hard-aborts and - /// awaits every one of these BEFORE draining/closing `links` — found - /// necessary by adversarial review, 2026-09-05: without this, a task - /// mid-dial (`connection::connect` has no internal cooperation point - /// with the pool's own stop signal) or mid-discovery-push could - /// complete AFTER `close` returns and resurrect a link, or a live, - /// connected `Session`, that nothing will ever close again. Sequencing - /// `shutdown()` strictly before the drain guarantees any task that - /// manages to finish its own critical section either finishes before - /// `close` starts draining (so the drain sees and closes it) or gets - /// aborted before it can (so nothing is left to see). - tasks: Mutex>, +/// One of the pool's links. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkStatus { + pub station: [u8; 32], + pub host: String, + pub port: u16, + pub direct: bool, + pub up: bool, } impl Pool { - /// Spawn a pool with one link per seed. Returns as soon as every link's - /// dial has STARTED, not once any is connected — handshakes complete - /// asynchronously, matching macula_client:connect/2 and every other - /// port of this pool shape. - pub fn connect( - seeds: Vec, - trust: Trust, - identity: KeyPair, - options: PoolOptions, - ) -> Arc { - assert!(!seeds.is_empty(), "at least one seed is required"); - let (stop_tx, _stop_rx) = watch::channel(false); - let identity = Arc::new(identity); - let pool = Arc::new(Pool { - identity: identity.clone(), - trust, - options, - links: RwLock::new(Vec::new()), - stop_tx, - tasks: Mutex::new(JoinSet::new()), + /// Checks `seeds` and `opts`, dials every seed, and returns once one link + /// is up, or [`PoolError::NoLink`] with each link's last error when the + /// connect timeout passes first. Links not yet up keep dialing. + pub async fn connect(seeds: Vec, opts: Opts) -> Result { + let opts = checked(&seeds, opts)?; + let self_id = opts + .identity + .node_id() + .map_err(|e| PoolError::InvalidOpts(e.to_string()))?; + let issuer = StatementIssuer::with_wall_clock(opts.identity.clone()) + .map_err(|e| PoolError::InvalidOpts(e.to_string()))?; + let on_error = opts.on_issuer_error.clone(); + let ticks = issuer.spawn_ticks(move |e| match &on_error { + Some(f) => f(e), + None => eprintln!("macula-rust pool: the statement issuer failed: {e}"), }); - - let bootstrap_links: Vec> = seeds - .into_iter() - .map(|seed| PooledLink::new(seed, LinkOrigin::Bootstrap, trust)) - .collect(); - - { - // Bootstrap links are known synchronously at construction, so - // this can be a blocking write via try_write rather than - // spawning a task just to populate the initial Vec -- no - // other task holds the lock yet. - let mut links = pool - .links - .try_write() - .expect("no other task can hold this lock before Pool::connect returns"); - links.extend(bootstrap_links.iter().cloned()); - } - - { - // Same reasoning as the links lock above: nothing else can - // hold this lock yet either. - let mut tasks = pool - .tasks - .try_lock() - .expect("no other task can hold this lock before Pool::connect returns"); - for link in bootstrap_links { - tasks.spawn(run_link_lifecycle(pool.clone(), link)); - } - if pool.options.station_discovery.enabled { - tasks.spawn(discover_stations_loop(pool.clone())); - } - } - - pool - } - - /// Send a signed CALL on the currently-connected links, in the order - /// [`PoolOptions::link_selection`] gives them. The pool moves on to the - /// next link only when a call failed before its CALL was sent - /// ([`CallError::not_sent`]), so no CALL runs twice: a call that timed out - /// after its write started is returned as it is, and so is a BOLT#4 ERROR - /// reply, exactly like a bare [`Session::call`]. A link whose session - /// ends is dialed again by the pool. Pool calls publish no RPC telemetry - /// facts, as macula's pool doesn't. - pub async fn call( - &self, - procedure: &str, - realm: [u8; 32], - payload: crate::cbor::Value, - deadline_ms: i128, - ) -> Result { - let calls = self.select_connected_links().await.into_iter().map(|link| { - let spec = CallSpec::new( - rand::random(), - procedure, - realm, - payload.clone(), - deadline_ms, - self.identity.node_id(), - ); - async move { - // A handle, cloned out, so no lock is held across the round trip. - let session = link.state.lock().await.session.clone(); - let Some(session) = session else { - return Err(link_not_connected(&link)); - }; - session - .link_call(&spec, &self.identity, self.options.call_timeout) - .await - } + let admission = opts.admission.expect("checked fills the admission limits"); + let inner = Arc::new(PoolInner { + self_id, + issuer, + publication_seq: Arc::default(), + admission: Arc::new(Admission::new(admission)), + dedup: Arc::default(), + state: Mutex::new(State { + members: Vec::new(), + subs: HashMap::new(), + served: HashMap::new(), + remember: HashMap::new(), + closed: false, + }), + ticks, + opts, }); - call_until_sent(calls).await - } - - /// Send a signed PUBLISH, fanning out to up to - /// [`PoolOptions::replication_factor`] currently-connected links - /// (ordered by [`PoolOptions::link_selection`]). Partial success counts - /// as success, matching macula-ts/macula-dotnet's own publish-fanout - /// contract. - pub async fn publish(&self, spec: &PublishSpec) -> Result<(), PoolPublishError> { - let candidates = self.select_connected_links().await; - if candidates.is_empty() { - return Err(PoolPublishError::NoHealthyStation); + let pool = Pool { inner }; + for seed in &seeds { + pool.inner.start_member(pool.target(seed), false); } - let targets: Vec<_> = candidates - .into_iter() - .take(self.options.replication_factor.max(1)) - .collect(); - let attempted = targets.len(); - let mut successes = 0usize; - for link in targets { - let Some(session) = link.state.lock().await.session.clone() else { - continue; - }; - if session.publish(spec, &self.identity).await.is_ok() { - successes += 1; - } - } - if successes > 0 { - Ok(()) - } else { - Err(PoolPublishError::AllFailed(attempted)) + let deadline = tokio::time::Instant::now() + pool.inner.opts.connect_timeout; + if let Err(e) = pool.inner.await_up(deadline).await { + pool.close().await; + return Err(e); } + Ok(pool) } - /// Aggregate health snapshot. - pub async fn status(&self) -> PoolStatus { - let links = self.links.read().await; - let healthy_links = links.iter().filter(|l| l.is_connected()).count(); - PoolStatus { - healthy_links, - total_links: links.len(), + fn target(&self, seed: &Seed) -> Target { + Target { + host: seed.host.clone(), + port: seed.port, + profile: self.inner.opts.identity.profile(), + expected_node_id: seed.node_id, } } - /// Per-link snapshot, in seed-list/discovery order — see [`Pool`]'s own - /// doc on why a plain `Vec` already guarantees this without any extra - /// bookkeeping. - pub async fn links(&self) -> Vec { - let links = self.links.read().await; - let mut out = Vec::with_capacity(links.len()); - for link in links.iter() { - out.push(LinkInfo { - seed: link.seed.clone(), - connected: link.is_connected(), - node_id: if link.is_connected() { - link.peer_node_id().await - } else { - None - }, - }); - } - out + /// The node_id the pool links as. + pub fn node_id(&self) -> [u8; 32] { + self.inner.self_id } - /// Sends GOODBYE on every currently-connected link and stops all - /// background dial/discovery tasks. Waits for every background task - /// (respawn lifecycles, the discovery loop) to actually be gone - /// BEFORE draining/closing `links` — see [`Pool`]'s own field doc on - /// `tasks` for why this ordering, specifically, is load-bearing. - /// Does not wait for the GOODBYE writes themselves to finish being - /// scheduled beyond [`Session::close`]'s own bounded drain. - pub async fn close(&self, reason: &str, detail: Option<&str>) { - let _ = self.stop_tx.send(true); - self.tasks.lock().await.shutdown().await; - let mut links = self.links.write().await; - for link in links.drain(..) { - let mut state = link.state.lock().await; - if let Some(session) = state.session.take() { - session.close(reason, detail, &self.identity).await; + /// Every link the pool holds, seeds first. + pub fn status(&self) -> Vec { + let members = self.inner.lock().members.clone(); + members + .iter() + .map(|m| LinkStatus { + station: m.target.expected_node_id, + host: m.target.host.clone(), + port: m.target.port, + direct: m.direct, + up: m.current().is_some(), + }) + .collect() + } + + /// Ends every link with a GOODBYE and every subscription. It withdraws + /// nothing: an advertisement lapses with its link. + pub async fn close(&self) { + let (members, subs) = { + let mut state = self.inner.lock(); + if state.closed { + return; } - link.connected.store(false, Ordering::Release); + state.closed = true; + state.served.clear(); + ( + std::mem::take(&mut state.members), + std::mem::take(&mut state.subs), + ) + }; + self.inner.ticks.abort(); + for m in &members { + m.retire(); } - } - - async fn select_connected_links(&self) -> Vec> { - let links = self.links.read().await; - let connected: Vec> = - links.iter().filter(|l| l.is_connected()).cloned().collect(); - drop(links); - let resolved = resolve_link_selection( - self.options.link_selection, - self.options.station_discovery.enabled, - ); - select_links(connected, resolved) - } -} - -/// Runs `calls` in turn and moves on to the next only when a call failed -/// before its CALL was sent ([`CallError::not_sent`]), so no CALL runs -/// twice. Returns the first reply, a BOLT#4 ERROR reply included, else the -/// failure that stopped it: a call that was or may have been sent, or the -/// last one. -async fn call_until_sent( - calls: impl IntoIterator, -) -> Result -where - F: std::future::Future>, -{ - let mut calls = calls.into_iter().peekable(); - while let Some(call) = calls.next() { - match call.await { - // Never sent on this link, so the next one can't run it twice. - Err(e) if e.not_sent() && calls.peek().is_some() => {} - done => return done.map_err(PoolCallError::Call), + for m in &members { + m.stopped().await; + } + for sub in subs.into_values() { + let _ = sub.end().await; } } - Err(PoolCallError::NoHealthyStation) } -/// The failure of a call on a link that lost its session after it was -/// selected, which never sent anything. -fn link_not_connected(link: &PooledLink) -> CallError { - CallError::SessionEnded { - reason: crate::connection::SessionEndReason::StreamFailed(format!( - "link to {}:{} is not connected", - link.seed.host, link.seed.port - )), - write_started: false, +impl fmt::Debug for Pool { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Pool") + .field("node_id", &short(&self.inner.self_id)) + .field("links", &self.status()) + .finish() } } -/// Marks `link` disconnected if it still holds `ended`: [`Pool::close`] may -/// have taken the session already. -async fn mark_disconnected(link: &PooledLink, ended: &Session) { - let mut state = link.state.lock().await; - if state - .session - .as_ref() - .is_some_and(|session| session.is_same_session(ended)) - { - state.session = None; - link.connected.store(false, Ordering::Release); +impl PoolInner { + fn lock(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(|p| p.into_inner()) } -} -/// Resolves once the pool is stopping, or gone. -async fn pool_stopping(stop_rx: &mut watch::Receiver) { - let _ = stop_rx.wait_for(|stopped| *stopped).await; -} - -/// Runs `link` for as long as the pool does: dials it, keeps it connected -/// until its session ends, and dials it again after -/// [`PoolOptions::respawn_delay`]. A failed dial is retried after the same -/// delay, and a discovery-added link whose dial fails -/// [`DISCOVERY_LINK_MAX_RESPAWN_ATTEMPTS`] times in a row gives up. Each link -/// has exactly one of these, spawned when the link is added, so a link is -/// never dialed twice at once. -async fn run_link_lifecycle(pool: Arc, link: Arc) { - let mut stop_rx = pool.stop_tx.subscribe(); - loop { - if *stop_rx.borrow() { - return; - } - match connection::connect(&link.seed.host, link.seed.port, link.trust, &pool.identity).await - { - Ok(session) => { - let node_id = session.station.node_id; - let mut state = link.state.lock().await; - state.session = Some(session.clone()); - state.peer_node_id = Some(node_id); - drop(state); - link.connected.store(true, Ordering::Release); - link.consecutive_failures.store(0, Ordering::Release); - // Pool::close closes the session itself. - tokio::select! { - _ = session.ended() => {} - () = pool_stopping(&mut stop_rx) => return, - } - mark_disconnected(&link, &session).await; - } - Err(_) => { - let failures = link.consecutive_failures.fetch_add(1, Ordering::AcqRel) + 1; - if should_give_up(link.origin, failures) { - link.gave_up.store(true, Ordering::Release); - return; - } - } + /// The links up now, in the pool's selection order. + fn links(&self) -> Vec { + let members = self.lock().members.clone(); + let mut up: Vec = members.iter().filter_map(|m| m.current()).collect(); + if self.opts.link_selection == LinkSelection::Random { + shuffle(&mut up); } - tokio::select! { - () = tokio::time::sleep(pool.options.respawn_delay) => {} - () = pool_stopping(&mut stop_rx) => return, - } - } -} - -// --------------------------------------------------------------------- -// Station discovery — resolves hecate_stations.list_stations' realm via -// the DHT, calls it through the pool's own `call` (never a raw Session), -// and additively adds links for whatever it finds. -// --------------------------------------------------------------------- - -const LIST_STATIONS_PROCEDURE: &str = "hecate_stations.list_stations"; -/// `hecate_stations.list_stations` itself is called under its OWN resolved -/// realm (whatever [`resolve_list_stations_realm`] found), NOT the DHT's -/// own all-zero realm — that's `dht::find_records_by_type`'s realm -/// (`dht.rs`'s own private `DHT_REALM` constant), a separate, unrelated -/// realm this module never needs to name directly since it only ever -/// reaches the DHT through `dht::find_records_by_type` itself. -const DISCOVERY_CALL_DEADLINE: Duration = Duration::from_secs(5); - -async fn discover_stations_loop(pool: Arc) { - // Trust::Pinned can never validate a SECOND station's identity -- - // running discovery at all under it is unconditionally pointless, not - // just risky (it would otherwise dial-storm forever against stations - // it can never actually trust). Matches the identical guard in - // macula-go's and macula-dotnet's own ports of this feature. - if matches!(pool.trust, Trust::Pinned(_)) { - return; - } - - let mut stop_rx = pool.stop_tx.subscribe(); - if !wait_for_any_healthy_link(&pool, &mut stop_rx).await { - return; + up } - loop { - if *stop_rx.borrow() { - return; - } - discover_once(&pool).await; - tokio::select! { - _ = tokio::time::sleep(pool.options.station_discovery.refresh_interval) => {} - _ = stop_rx.changed() => { - if *stop_rx.borrow() { - return; - } + /// Waits until a link is up, or `deadline` passes. + async fn await_up(self: &Arc, deadline: tokio::time::Instant) -> Result<(), PoolError> { + loop { + if !self.links().is_empty() { + return Ok(()); } + if tokio::time::Instant::now() >= deadline { + let members = self.lock().members.clone(); + return Err(PoolError::NoLink( + members.iter().filter_map(|m| m.last_error()).collect(), + )); + } + tokio::time::sleep(Duration::from_millis(10)).await; } } -} -async fn wait_for_any_healthy_link(pool: &Arc, stop_rx: &mut watch::Receiver) -> bool { - loop { - if *stop_rx.borrow() { - return false; - } - if pool.status().await.healthy_links > 0 { - return true; - } - tokio::select! { - _ = tokio::time::sleep(Duration::from_millis(200)) => {} - _ = stop_rx.changed() => { - if *stop_rx.borrow() { - return false; - } - } + fn event(&self, e: LinkEvent) { + if let Some(f) = self.opts.on_link_event.clone() { + tokio::spawn(async move { f(e) }); } } -} -async fn discover_once(pool: &Arc) { - let Some(realm) = resolve_list_stations_realm(pool).await else { - return; - }; - - let deadline = now_ms() + DISCOVERY_CALL_DEADLINE.as_millis() as i128; - let Ok(CallResponse::Result { payload, .. }) = pool - .call( - LIST_STATIONS_PROCEDURE, - realm, - crate::cbor::Value::Map(vec![]), - deadline, - ) - .await - else { - return; - }; - let crate::cbor::Value::Map(fields) = &payload else { - return; - }; - let Some(crate::cbor::Value::List(stations)) = fields - .iter() - .find(|(k, _)| matches!(k, crate::cbor::Value::Text(t) if t == "stations")) - .map(|(_, v)| v.clone()) - else { - return; - }; - - add_discovered_links(pool, &stations).await; -} - -/// Resolves `hecate_stations.list_stations`' own realm by scanning every -/// `procedure_advertisement` DHT record visible from the pool's current -/// bootstrap connection, matching a `procedure_uri` of the shape -/// `hex(realm) + "/hecate_stations.list_stations"` — mirrors -/// `dht::discovery_uri`'s own format and macula-go/macula-dotnet's -/// identical resolution step. Returns `None` on any failure (no -/// advertisement found yet, none verify, no healthy link to ask with) — -/// discovery just tries again at the next refresh tick, same as a bare DHT -/// lookup miss anywhere else in this crate. -async fn resolve_list_stations_realm(pool: &Arc) -> Option<[u8; 32]> { - let links = pool.links.read().await; - let link = links.iter().find(|l| l.is_connected())?.clone(); - drop(links); - - let session = link.state.lock().await.session.clone()?; - let records = - dht::find_records_by_type(&session, &pool.identity, dht::TYPE_PROCEDURE_ADVERTISEMENT) - .await - .ok()?; - - for record in records { - if dht::verify(&record).is_err() { - continue; + /// The key a procedure's authorization is checked against: the realm's + /// pinned key, or none for a procedure in a node's own namespace, which + /// its advertisement's signature alone authorizes. + fn realm_key_for( + &self, + realm: &[u8; 32], + procedure: &str, + ) -> Result>, PoolError> { + if crate::record::in_own_namespace(procedure) { + return Ok(None); } - let Ok(advertisement) = dht::read_procedure_advertisement(&record) else { - continue; - }; - if let Some(realm) = try_match_list_stations_realm(&advertisement.procedure_uri) { - return Some(realm); + self.opts + .realm_trust + .get(realm) + .cloned() + .map(Some) + .ok_or(PoolError::NoRealmKey) + } +} + +impl Drop for PoolInner { + /// A pool whose last handle is dropped stops dialing and closes its links. + fn drop(&mut self) { + self.ticks.abort(); + let state = self.state.get_mut().unwrap_or_else(|p| p.into_inner()); + for m in &state.members { + m.retire(); } } - None -} - -fn try_match_list_stations_realm(procedure_uri: &str) -> Option<[u8; 32]> { - let suffix = format!("/{LIST_STATIONS_PROCEDURE}"); - let hex_realm = procedure_uri.strip_suffix(&suffix)?; - if hex_realm.len() != 64 { - return None; - } - let bytes = hex_decode(hex_realm)?; - bytes.try_into().ok() } -fn hex_decode(s: &str) -> Option> { - if s.len() % 2 != 0 { - return None; +/// Refuses, before anything is dialed, what macula's pool refuses, and fills +/// in the defaults. +fn checked(seeds: &[Seed], mut opts: Opts) -> Result { + if opts.identity.purpose() != Purpose::Identity { + return Err(PoolError::InvalidOpts("an identity key is required".into())); } - (0..s.len()) - .step_by(2) - .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) - .collect() -} - -/// How many of `links` currently count against -/// [`StationDiscoveryOptions::max_links`] — MaxLinks bounds discovery's OWN -/// adds only, not the pool's total link count (see that field's own doc), -/// so a bootstrap seed must NEVER count against this budget, however many -/// were configured, or a pool started with >= max_links bootstrap seeds (a -/// realistic deployment) would have discovery silently do nothing, forever, -/// from its very first refresh — caught by adversarial review, 2026-09-05, -/// after an earlier version of this count included every link regardless -/// of origin. A given-up discovery link ALSO stays in the pool's `links` -/// forever (no removal path — see [`Pool`]'s own doc on why the backing -/// store is a plain `Vec`) but must NOT keep occupying its own slot either, -/// or the whole point of giving up (freeing room for a different -/// candidate) is defeated. -fn count_occupied_discovery_slots(links: &[Arc]) -> usize { - links - .iter() - .filter(|l| l.origin == LinkOrigin::Discovered && !l.has_given_up()) - .count() -} - -async fn add_discovered_links(pool: &Arc, stations: &[crate::cbor::Value]) { - let is_web_pki = matches!(pool.trust, Trust::WebPki); - for station in stations { - let links = pool.links.read().await; - let occupied_slots = count_occupied_discovery_slots(&links); - drop(links); - if occupied_slots >= pool.options.station_discovery.max_links { - break; + let profile = opts.identity.profile(); + for (realm, key) in &opts.realm_trust { + if !carried_key_well_formed(key, profile) { + return Err(PoolError::RealmTrustInvalid(*realm)); } - let Some((host, port, node_id)) = dial_target_from_station_row(station) else { - continue; - }; - let is_bare_ip = host.parse::().is_ok(); - - // Prefer a real hostname under the pool's own configured trust; - // fall back to Trust::Pinned(node_id) for a bare-IP-only row when - // a node_id is available -- see this module's own doc for why, - // and cam2me's StationDiscovery.kt for the shipped precedent. - let link_trust = if is_bare_ip && is_web_pki { - match node_id { - Some(id) => Trust::Pinned(id), - None => continue, // no way to validate this station at all - } - } else { - pool.trust - }; - - if let Some(id) = node_id { - if has_link_for_node_id(pool, id).await { - continue; - } - } - - let seed = Seed::new(host, port); - spawn_seed_link_if_absent(pool, seed, link_trust).await; } -} - -/// Extracts a dialable `(host, port, node_id)` from one -/// `hecate_stations.list_stations` response row. Prefers `hostname` over -/// `host_advertised[0]` when both are present — every station on the real -/// fleet advertises `host_advertised` as a bare IP literal, never a DNS -/// name (confirmed live, same finding independently hit by macula-go's and -/// macula-dotnet's own ports of this feature), so `hostname` is the only -/// field a WebPki dial can ever succeed against. `host_advertised` is -/// still read out (and returned) even when a `hostname` is present, since -/// callers that end up needing `Trust::Pinned` (see -/// [`add_discovered_links`]) dial by IP regardless of whether a hostname -/// also exists. -pub(crate) fn dial_target_from_station_row( - row: &crate::cbor::Value, -) -> Option<(String, u16, Option<[u8; 32]>)> { - let crate::cbor::Value::Map(fields) = row else { - return None; - }; - let get = |name: &str| { - fields - .iter() - .find(|(k, _)| matches!(k, crate::cbor::Value::Text(t) if t == name)) - .map(|(_, v)| v) - }; - - let port = match get("quic_port") { - Some(crate::cbor::Value::Int(n)) if (1..=65535).contains(n) => *n as u16, - _ => return None, - }; - - let hostname = match get("hostname") { - Some(crate::cbor::Value::Text(t)) if !t.is_empty() => Some(t.clone()), - Some(crate::cbor::Value::Bytes(b)) if !b.is_empty() => String::from_utf8(b.clone()).ok(), - _ => None, - }; - let host_advertised = match get("host_advertised") { - Some(crate::cbor::Value::List(items)) => items.iter().find_map(|item| match item { - crate::cbor::Value::Bytes(b) => String::from_utf8(b.clone()).ok(), - crate::cbor::Value::Text(t) => Some(t.clone()), - _ => None, - }), - _ => None, - }; - - let host = hostname.or(host_advertised)?; - let node_id = match get("node_id") { - Some(crate::cbor::Value::Bytes(b)) => b.as_slice().try_into().ok(), - _ => None, - }; - Some((host, port, node_id)) -} - -async fn has_link_for_node_id(pool: &Arc, node_id: [u8; 32]) -> bool { - let links = pool.links.read().await; - for link in links.iter() { - if link.is_connected() { - if let Some(known) = link.peer_node_id().await { - if known == node_id { - return true; - } - } + for (name, limit) in [ + ("max_seeds", opts.max_seeds), + ("max_direct_links", opts.max_direct_links), + ("replication_factor", opts.replication_factor), + ] { + if limit > MAX_LINK_LIMIT { + return Err(PoolError::InvalidOpts(format!( + "{name} of {limit}, outside 1 to {MAX_LINK_LIMIT}" + ))); } } - false -} - -async fn spawn_seed_link_if_absent(pool: &Arc, seed: Seed, trust: Trust) { - let mut links = pool.links.write().await; - if links.iter().any(|l| l.seed == seed) { - return; - } - let link = PooledLink::new(seed, LinkOrigin::Discovered, trust); - links.push(link.clone()); - drop(links); - pool.tasks - .lock() - .await - .spawn(run_link_lifecycle(pool.clone(), link)); -} - -fn now_ms() -> i128 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock before 1970") - .as_millis() as i128 -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::control_channel::fake_station::{call, connect, reply_text, WAIT}; - - fn row(fields: Vec<(&str, crate::cbor::Value)>) -> crate::cbor::Value { - crate::cbor::Value::Map( - fields - .into_iter() - .map(|(k, v)| (crate::cbor::Value::Text(k.to_string()), v)) - .collect(), - ) - } - - #[test] - fn resolve_link_selection_auto_pairs_with_discovery() { - assert_eq!( - resolve_link_selection(LinkSelection::Auto, false), - LinkSelection::FirstSuccess - ); - assert_eq!( - resolve_link_selection(LinkSelection::Auto, true), - LinkSelection::Random - ); - } - - #[test] - fn resolve_link_selection_explicit_survives_either_way() { - assert_eq!( - resolve_link_selection(LinkSelection::FirstSuccess, true), - LinkSelection::FirstSuccess - ); - assert_eq!( - resolve_link_selection(LinkSelection::Random, false), - LinkSelection::Random - ); - } - - #[test] - fn dial_target_prefers_hostname_over_bare_ip() { - let r = row(vec![ - ( - "hostname", - crate::cbor::Value::Text("station-de-frankfurt.macula.io".into()), - ), - ( - "host_advertised", - crate::cbor::Value::List(vec![crate::cbor::Value::Bytes( - b"2a01:7e01::f03c:94ff:fe22:719e".to_vec(), - )]), - ), - ("quic_port", crate::cbor::Value::Int(4433)), - ]); - let (host, port, _) = dial_target_from_station_row(&r).expect("should parse"); - assert_eq!(host, "station-de-frankfurt.macula.io"); - assert_eq!(port, 4433); - } - - #[test] - fn dial_target_falls_back_to_host_advertised_when_hostname_absent() { - let r = row(vec![ - ( - "host_advertised", - crate::cbor::Value::List(vec![crate::cbor::Value::Bytes( - b"2600:3c0b::2000:1fff:fe35:416b".to_vec(), - )]), - ), - ("quic_port", crate::cbor::Value::Int(4433)), - ]); - let (host, _, _) = dial_target_from_station_row(&r).expect("should parse"); - assert_eq!(host, "2600:3c0b::2000:1fff:fe35:416b"); - } - - #[test] - fn dial_target_rejects_missing_port() { - let r = row(vec![("hostname", crate::cbor::Value::Text("x".into()))]); - assert!(dial_target_from_station_row(&r).is_none()); - } - - #[test] - fn dial_target_extracts_node_id() { - let node_id = [0xABu8; 32]; - let r = row(vec![ - ("hostname", crate::cbor::Value::Text("x".into())), - ("quic_port", crate::cbor::Value::Int(4433)), - ("node_id", crate::cbor::Value::Bytes(node_id.to_vec())), - ]); - let (_, _, id) = dial_target_from_station_row(&r).expect("should parse"); - assert_eq!(id, Some(node_id)); - } - - #[test] - fn should_give_up_never_applies_to_a_bootstrap_link() { - assert!(!should_give_up( - LinkOrigin::Bootstrap, - DISCOVERY_LINK_MAX_RESPAWN_ATTEMPTS - )); - assert!(!should_give_up(LinkOrigin::Bootstrap, 1_000_000)); - } - - #[test] - fn should_give_up_applies_to_a_discovered_link_at_the_threshold() { - assert!(!should_give_up( - LinkOrigin::Discovered, - DISCOVERY_LINK_MAX_RESPAWN_ATTEMPTS - 1 - )); - assert!(should_give_up( - LinkOrigin::Discovered, - DISCOVERY_LINK_MAX_RESPAWN_ATTEMPTS - )); - } - - fn synthetic_link(origin: LinkOrigin, gave_up: bool) -> Arc { - let link = PooledLink::new(Seed::new("x.example", 4433), origin, Trust::WebPki); - link.gave_up.store(gave_up, Ordering::Relaxed); - link - } - - /// Regression test for the exact bug adversarial review caught: an - /// earlier version of this count included bootstrap links, which can - /// never give up, so a pool started with `bootstrap.len() >= - /// max_links` would have discovery silently do nothing forever. - #[test] - fn discovery_slot_count_excludes_bootstrap_links_entirely() { - let links = vec![ - synthetic_link(LinkOrigin::Bootstrap, false), - synthetic_link(LinkOrigin::Bootstrap, false), - synthetic_link(LinkOrigin::Bootstrap, false), - ]; - assert_eq!(count_occupied_discovery_slots(&links), 0); - } - - #[test] - fn discovery_slot_count_excludes_a_given_up_discovered_link() { - let links = vec![ - synthetic_link(LinkOrigin::Discovered, false), - synthetic_link(LinkOrigin::Discovered, true), // gave up -- slot freed - ]; - assert_eq!(count_occupied_discovery_slots(&links), 1); - } - - #[test] - fn discovery_slot_count_mixed_origins() { - let links = vec![ - synthetic_link(LinkOrigin::Bootstrap, false), - synthetic_link(LinkOrigin::Bootstrap, false), - synthetic_link(LinkOrigin::Discovered, false), - synthetic_link(LinkOrigin::Discovered, true), - ]; - assert_eq!(count_occupied_discovery_slots(&links), 1); - } - - #[tokio::test] - async fn call_falls_through_to_next_connected_link() { - let (gone, gone_station, gone_ended) = connect(); - drop(gone_station); - tokio::time::timeout(WAIT, gone_ended) - .await - .unwrap() - .unwrap(); - let (live, mut station, _ended) = connect(); - let identity = KeyPair::generate(); - let (first, second) = (call("app/echo"), call("app/echo")); - - let (called, ()) = tokio::join!( - call_until_sent([ - gone.call(&first, &identity, WAIT, None), - live.call(&second, &identity, WAIT, None), - ]), - async { - let sent = station.next("call").await; - station.reply(&sent, "echoed").await; - } - ); - - assert_eq!(reply_text(called.unwrap()), "echoed"); - } - - #[tokio::test] - async fn a_pool_call_that_timed_out_after_its_write_started_is_not_tried_on_another_link() { - let (stalled, stalled_station, _stalled_ended) = connect(); - let (other, mut other_station, _other_ended) = connect(); - stalled_station.stall_session_writes(); - let identity = KeyPair::generate(); - let (first, second) = (call("app/echo"), call("app/echo")); - - let called = call_until_sent([ - stalled.call(&first, &identity, Duration::from_millis(100), None), - other.call(&second, &identity, WAIT, None), - ]) - .await; - - assert!( - matches!( - called, - Err(PoolCallError::Call(CallError::Timeout { - write_started: true - })) - ), - "{called:?}" - ); - assert!( - other_station - .nothing_sent_within(Duration::from_millis(300)) - .await - ); - stalled_station.resume_session_writes(); - } - - #[test] - fn try_match_list_stations_realm_matches_expected_format() { - let hex_realm = "0".repeat(64); - let uri = format!("{hex_realm}/hecate_stations.list_stations"); - assert_eq!(try_match_list_stations_realm(&uri), Some([0u8; 32])); + let or_default = |v: usize, d: usize| if v == 0 { d } else { v }; + opts.max_seeds = or_default(opts.max_seeds, DEFAULT_MAX_SEEDS); + opts.max_direct_links = or_default(opts.max_direct_links, DEFAULT_MAX_DIRECT_LINKS); + opts.replication_factor = or_default(opts.replication_factor, DEFAULT_REPLICATION_FACTOR); + if opts.respawn_delay.is_zero() { + opts.respawn_delay = DEFAULT_RESPAWN_DELAY; + } + if opts.connect_timeout.is_zero() { + opts.connect_timeout = DEFAULT_CONNECT_TIMEOUT; + } + let admission = opts.admission.unwrap_or_else(|| { + let mut limits = AdmissionLimits::default(); + limits.cap = limits.share * (opts.max_seeds + opts.max_direct_links); + limits + }); + admission + .validate() + .map_err(|e| PoolError::InvalidOpts(e.to_string()))?; + opts.admission = Some(admission); + if seeds.is_empty() { + return Err(PoolError::NoSeeds); + } + if seeds.len() > opts.max_seeds { + return Err(PoolError::TooManySeeds { + given: seeds.len(), + max: opts.max_seeds, + }); } - - #[test] - fn try_match_list_stations_realm_rejects_a_different_procedure() { - let hex_realm = "0".repeat(64); - let uri = format!("{hex_realm}/some.other_procedure"); - assert_eq!(try_match_list_stations_realm(&uri), None); + if let Some(unpinned) = seeds.iter().find(|s| s.node_id == [0; 32]) { + return Err(PoolError::SeedNotPinned(format!( + "{}:{}", + unpinned.host, unpinned.port + ))); } + Ok(opts) +} - #[test] - fn select_links_first_success_returns_input_unshuffled() { - // Empty/zero-length input is the simplest observable proof this - // is a passthrough -- Vec equality on non-empty synthetic - // PooledLinks would need constructing real Arcs with - // no real Session, which is exactly the "no fake-dialer seam" - // situation macula-dotnet's own tests document; a live pool test - // covers the real end-to-end ordering instead. - let empty: Vec> = Vec::new(); - assert_eq!(select_links(empty, LinkSelection::FirstSuccess).len(), 0); +/// Fisher-Yates over the system's randomness. +fn shuffle(items: &mut [T]) { + for i in (1..items.len()).rev() { + let mut r = [0u8; 8]; + if aws_lc_rs::rand::fill(&mut r).is_err() { + return; + } + let j = (u64::from_le_bytes(r) % (i as u64 + 1)) as usize; + items.swap(i, j); } } diff --git a/src/pool/call.rs b/src/pool/call.rs new file mode 100644 index 0000000..aee751b --- /dev/null +++ b/src/pool/call.rs @@ -0,0 +1,506 @@ +//! Calls and streams that reach a provider at its own station, and the DHT +//! through the pool's links. +//! +//! A call resolves the procedure's advertisements from the DHT, keeps those +//! the realm's pinned key authorizes (or, in a node's own namespace, those +//! that node signed), and tries the freshest first: it dials the serving +//! station the advertisement names, pinned by its node_id from the station's +//! own station_endpoint record, and calls the provider there. It moves on to +//! the next candidate when a station cannot be reached or reports it cannot +//! relay the call, and returns a provider's own answer or error as it is. A +//! candidate that answered is remembered until its advertisement expires. + +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use tokio::time::Instant; + +use crate::cbor::Value; +use crate::frame::StreamMode; +use crate::record::{self, RecordType, Trust, Verified}; +use crate::station_link::{self, Link, LinkError, Stream, DEFAULT_CALL_TIMEOUT}; +use crate::transport::Target; + +use super::{Pool, PoolError, PoolInner}; + +/// No candidate gets less than a second of a call's time. +const MIN_CANDIDATE_SHARE: Duration = Duration::from_secs(1); + +/// A call to a procedure: its realm and name, the provider to call (any +/// trusted one when zero), the payload, how long to wait +/// ([`DEFAULT_CALL_TIMEOUT`] when zero), and a UCAN and its proofs for a +/// gated procedure. +#[derive(Debug, Clone, PartialEq)] +pub struct Call { + pub realm: [u8; 32], + pub procedure: String, + pub provider: [u8; 32], + pub payload: Value, + pub timeout: Duration, + pub token: Option>, + pub proofs: Vec>, +} + +impl Default for Call { + fn default() -> Self { + Call { + realm: [0; 32], + procedure: String::new(), + provider: [0; 32], + payload: Value::Map(Vec::new()), + timeout: Duration::ZERO, + token: None, + proofs: Vec::new(), + } + } +} + +/// A streaming session to open: its realm and name, the provider (any +/// trusted one when zero), the mode, the open's payload, its deadline (the +/// link's default when zero), and a UCAN and its proofs for a gated +/// procedure. Its default mode is server_stream. +#[derive(Debug, Clone, PartialEq)] +pub struct StreamCall { + pub realm: [u8; 32], + pub procedure: String, + pub provider: [u8; 32], + pub mode: StreamMode, + pub payload: Value, + pub deadline: Duration, + pub token: Option>, + pub proofs: Vec>, +} + +impl Default for StreamCall { + fn default() -> Self { + StreamCall { + realm: [0; 32], + procedure: String::new(), + provider: [0; 32], + mode: StreamMode::ServerStream, + payload: Value::Map(Vec::new()), + deadline: Duration::ZERO, + token: None, + proofs: Vec::new(), + } + } +} + +/// A node serving a procedure, and the station it serves from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Provider { + pub node: [u8; 32], + pub station: [u8; 32], +} + +/// A trusted advertisement: its provider, serving station and times. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct Candidate { + provider: Provider, + expires_at: u64, + created_at: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(super) struct ResolvedKey { + realm: [u8; 32], + procedure: String, + provider: [u8; 32], +} + +impl Pool { + /// Calls a procedure at a provider that serves it, as macula 12 calls + /// one. A provider's ERROR comes back as + /// `PoolError::Link(LinkError::Provider { .. })`; when no candidate + /// answers, [`PoolError::NoProvider`] names each one tried. + pub async fn call(&self, c: Call) -> Result { + let inner = &self.inner; + let realm_key = inner.realm_key_for(&c.realm, &c.procedure)?; + let timeout = if c.timeout.is_zero() { + DEFAULT_CALL_TIMEOUT + } else { + c.timeout + }; + let deadline = Instant::now() + timeout; + let key = ResolvedKey { + realm: c.realm, + procedure: c.procedure.clone(), + provider: c.provider, + }; + let candidates = bounded(deadline, inner.candidates(&key, realm_key)).await?; + let mut tried = Vec::new(); + let count = candidates.len(); + for (i, cand) in candidates.into_iter().enumerate() { + let share = candidate_share(deadline, count - i); + let outcome = bounded(share, inner.call_at(&cand, &c, share)).await; + match outcome { + Ok(v) => { + inner.remember(key, cand); + return Ok(v); + } + Err(e @ PoolError::Link(LinkError::Provider { .. })) => { + inner.remember(key, cand); + return Err(e); + } + Err(e) => { + inner.forget(&key); + tried.push((cand.provider, e)); + if Instant::now() >= deadline { + break; + } + } + } + } + Err(PoolError::NoProvider(tried)) + } + + /// Every provider whose advertisement of `procedure` in `realm` the + /// realm's pinned key authorizes, with the station each serves from, + /// freshest first. + pub async fn providers( + &self, + realm: &[u8; 32], + procedure: &str, + ) -> Result, PoolError> { + let realm_key = self.inner.realm_key_for(realm, procedure)?; + let key = ResolvedKey { + realm: *realm, + procedure: procedure.to_string(), + provider: [0; 32], + }; + let found = self.inner.resolve(&key, realm_key).await?; + Ok(found.into_iter().map(|c| c.provider).collect()) + } + + /// Opens a streaming session at a provider of the procedure, reached as + /// [`Pool::call`] reaches one. The stream is open once its STREAM_OPEN + /// is sent; a provider's or station's refusal arrives on its first recv. + pub async fn open_stream(&self, c: StreamCall) -> Result { + let inner = &self.inner; + let realm_key = inner.realm_key_for(&c.realm, &c.procedure)?; + let deadline = Instant::now() + DEFAULT_CALL_TIMEOUT; + let key = ResolvedKey { + realm: c.realm, + procedure: c.procedure.clone(), + provider: c.provider, + }; + let candidates = bounded(deadline, inner.candidates(&key, realm_key)).await?; + let mut tried = Vec::new(); + let count = candidates.len(); + for (i, cand) in candidates.into_iter().enumerate() { + let share = candidate_share(deadline, count - i); + match bounded(share, inner.open_at(&cand, &c, share)).await { + Ok(stream) => { + inner.remember(key, cand); + return Ok(stream); + } + Err(e) => { + inner.forget(&key); + tried.push((cand.provider, e)); + if Instant::now() >= deadline { + break; + } + } + } + } + Err(PoolError::NoProvider(tried)) + } + + /// Where `station` is dialed, from the station_endpoint record the + /// station signed itself; one another key signed is refused. + pub async fn station_target(&self, station: &[u8; 32]) -> Result { + self.inner.station_target(station).await + } + + /// A link to `station`: one the pool holds, or a direct link it dials to + /// the address in the station's own endpoint record, pinned by its + /// node_id, within the default call timeout. A direct link that does not + /// come up on its first dial is not kept. + pub async fn link_to(&self, station: &[u8; 32]) -> Result { + let deadline = Instant::now() + DEFAULT_CALL_TIMEOUT; + self.inner.link_to(station, deadline).await + } + + /// The record under `key`, verified, from the first link that answers. + pub async fn find_record(&self, key: &[u8; 32]) -> Result { + self.inner + .first_answer(|l| async move { l.find_record(key).await }) + .await + } + + /// The records under `key` that verify, and how many did not, from the + /// first link that answers. + pub async fn find_records(&self, key: &[u8; 32]) -> Result<(Vec, usize), PoolError> { + self.inner + .first_answer(|l| async move { l.find_records(key).await }) + .await + } + + /// The records of type `t` that verify, and how many did not, from the + /// first link that answers. + pub async fn find_records_by_type( + &self, + t: RecordType, + ) -> Result<(Vec, usize), PoolError> { + self.inner + .first_answer(|l| async move { l.find_records_by_type(t).await }) + .await + } + + /// Puts a signed record through the first link whose station takes it. + pub async fn put_record(&self, wire: &[u8]) -> Result<(), PoolError> { + self.inner + .first_answer(|l| async move { l.put_record(wire).await }) + .await + } +} + +impl PoolInner { + fn remember(&self, key: ResolvedKey, cand: Candidate) { + self.lock().remember.insert(key, cand); + } + + fn forget(&self, key: &ResolvedKey) { + self.lock().remember.remove(key); + } + + /// The remembered candidate while it lives and its station is linked, + /// else the procedure's trusted advertisements from the DHT. + async fn candidates( + &self, + key: &ResolvedKey, + realm_key: Option>, + ) -> Result, PoolError> { + let remembered = self.lock().remember.get(key).copied(); + if let Some(cand) = remembered { + if cand.expires_at as i64 > now_ms() && self.linked_to(&cand.provider.station).is_some() + { + return Ok(vec![cand]); + } + } + self.resolve(key, realm_key).await + } + + /// The procedure's advertisements the realm key authorizes, by + /// `key.provider` when it is set, freshest first. + async fn resolve( + &self, + key: &ResolvedKey, + realm_key: Option>, + ) -> Result, PoolError> { + let slot = record::procedure_key(&key.realm, &key.procedure); + let (found, _) = self + .first_answer(|l| async move { l.find_records(&slot).await }) + .await?; + let now = now_ms(); + let trust = Trust { + profile: self.opts.identity.profile(), + realm_key, + }; + let mut out: Vec = found + .iter() + .filter(|v| v.record().record_type == RecordType::PROCEDURE_ADVERTISEMENT) + .filter_map(|v| { + let ad = record::read_procedure_advertisement(v.record()).ok()?; + let wanted = ad.realm_id == key.realm + && ad.procedure == key.procedure + && (key.provider == [0; 32] || ad.advertiser_node == key.provider); + if !wanted || record::verify_authorization(v, &trust, now).is_err() { + return None; + } + Some(Candidate { + provider: Provider { + node: ad.advertiser_node, + station: ad.serving_station, + }, + expires_at: v.record().expires_at, + created_at: v.record().created_at, + }) + }) + .collect(); + if out.is_empty() { + return Err(PoolError::NoProvider(Vec::new())); + } + out.sort_by_key(|c| std::cmp::Reverse(c.created_at)); + Ok(out) + } + + async fn call_at( + self: &Arc, + cand: &Candidate, + c: &Call, + share: Instant, + ) -> Result { + let link = self.link_to(&cand.provider.station, share).await?; + let left = share.saturating_duration_since(Instant::now()); + Ok(link + .call(station_link::Call { + realm: c.realm, + procedure: c.procedure.clone(), + target: cand.provider.node, + payload: c.payload.clone(), + timeout: left.max(Duration::from_millis(1)), + token: c.token.clone(), + proofs: c.proofs.clone(), + }) + .await?) + } + + async fn open_at( + self: &Arc, + cand: &Candidate, + c: &StreamCall, + share: Instant, + ) -> Result { + let link = self.link_to(&cand.provider.station, share).await?; + Ok(link + .open_stream(station_link::StreamCall { + realm: c.realm, + procedure: c.procedure.clone(), + target: cand.provider.node, + mode: c.mode, + payload: c.payload.clone(), + deadline: c.deadline, + token: c.token.clone(), + proofs: c.proofs.clone(), + }) + .await?) + } + + /// The link up now to `station`, if any. + fn linked_to(&self, station: &[u8; 32]) -> Option { + self.links() + .into_iter() + .find(|l| l.station_node_id() == *station) + } + + async fn link_to( + self: &Arc, + station: &[u8; 32], + deadline: Instant, + ) -> Result { + if let Some(link) = self.linked_to(station) { + return Ok(link); + } + let (existing, direct) = { + let state = self.lock(); + if state.closed { + return Err(PoolError::Closed); + } + let existing = state + .members + .iter() + .find(|m| m.target.expected_node_id == *station) + .cloned(); + (existing, state.members.iter().filter(|m| m.direct).count()) + }; + let fresh = existing.is_none(); + let member = match existing { + Some(m) => m, + None => { + if direct >= self.opts.max_direct_links { + return Err(PoolError::DirectLinksFull); + } + let target = bounded(deadline, self.station_target(station)).await?; + self.start_member(target, true) + } + }; + if let Some(link) = member.await_up(deadline, fresh).await { + return Ok(link); + } + if fresh { + self.drop_member(&member); + } + Err(PoolError::StationNotReached { + station: *station, + cause: member.last_error(), + }) + } + + async fn station_target(&self, station: &[u8; 32]) -> Result { + let slot = record::station_endpoint_key(station); + let verified = self + .first_answer(|l| async move { l.find_record(&slot).await }) + .await + .map_err(|e| match e { + PoolError::Link(link) => PoolError::NoStationEndpoint(Some(link)), + other => other, + })?; + let r = verified.record(); + let signer = r.signed.as_ref().map(|s| s.key_id); + let endpoint = record::read_station_endpoint(r) + .map_err(|e| PoolError::NoStationEndpoint(Some(e.into())))?; + match (signer, endpoint.host_advertised.first()) { + (Some(signer), Some(host)) if signer == *station && endpoint.quic_port != 0 => { + Ok(Target { + host: host.clone(), + port: endpoint.quic_port, + profile: self.opts.identity.profile(), + expected_node_id: *station, + }) + } + _ => Err(PoolError::NoStationEndpoint(None)), + } + } + + /// Runs `ask` on the links in selection order and returns the first + /// answer, moving on only when a link could not carry the request: a + /// station's own answer, not_found included, is final. + async fn first_answer<'a, T, F, Fut>(&self, ask: F) -> Result + where + F: Fn(Link) -> Fut, + Fut: Future> + 'a, + { + let links = self.links(); + if links.is_empty() { + return Err(PoolError::NoLink(Vec::new())); + } + let mut errors = Vec::new(); + for link in links { + match ask(link).await { + Err(e) if unreachable(&e) => errors.push(e), + answered => return answered.map_err(PoolError::Link), + } + } + Err(PoolError::NoLink(errors)) + } +} + +/// A failure of the link to carry a request, as opposed to the station's +/// answer: no reply in time, or the link ended. +fn unreachable(e: &LinkError) -> bool { + matches!( + e, + LinkError::CallTimeout + | LinkError::Closed + | LinkError::LivenessLost + | LinkError::Io(_) + | LinkError::Goodbye(_) + | LinkError::StatusExpired + | LinkError::BindingExpired + ) +} + +/// One candidate's part of what is left before `deadline` with `left` +/// candidates to try, at least a second, as macula shares it, and never past +/// `deadline` itself. +fn candidate_share(deadline: Instant, left: usize) -> Instant { + let now = Instant::now(); + let remaining = deadline.saturating_duration_since(now); + (now + (remaining / left.max(1) as u32).max(MIN_CANDIDATE_SHARE)).min(deadline) +} + +/// `work` bounded by `deadline`: past it, the call timed out. +async fn bounded( + deadline: Instant, + work: impl Future>, +) -> Result { + tokio::time::timeout_at(deadline, work) + .await + .unwrap_or(Err(PoolError::Link(LinkError::CallTimeout))) +} + +fn now_ms() -> i64 { + crate::uuid_v7::now_ms() as i64 +} diff --git a/src/pool/member.rs b/src/pool/member.rs new file mode 100644 index 0000000..a44d66c --- /dev/null +++ b/src/pool/member.rs @@ -0,0 +1,173 @@ +//! One station the pool links to, a seed or a station dialed directly for a +//! call: it dials the station, and when the link ends dials it again after +//! the respawn delay, until it is retired. + +use std::sync::{Arc, Weak}; + +use tokio::sync::watch; + +use crate::station_link::{Config, Link, LinkError}; +use crate::transport::Target; + +use super::{LinkEvent, PoolInner}; + +pub(super) struct Member { + pub(super) target: Target, + pub(super) direct: bool, + state: watch::Sender, + retired: watch::Sender, + stopped: watch::Sender, +} + +/// What a member holds now: its link while one is up, the last error, and +/// how many dials have failed. +#[derive(Clone, Default)] +struct Held { + link: Option, + error: Option, + failed_dials: u64, +} + +impl PoolInner { + pub(super) fn start_member(self: &Arc, target: Target, direct: bool) -> Arc { + let m = Arc::new(Member { + target, + direct, + state: watch::channel(Held::default()).0, + retired: watch::channel(false).0, + stopped: watch::channel(false).0, + }); + self.lock().members.push(m.clone()); + tokio::spawn(supervise(Arc::downgrade(self), m.clone())); + m + } + + /// Retires a member the pool no longer keeps: its link closes, it is not + /// dialed again, and it leaves the pool's members. + pub(super) fn drop_member(&self, m: &Arc) { + m.retire(); + self.lock().members.retain(|held| !Arc::ptr_eq(held, m)); + } +} + +impl Member { + pub(super) fn current(&self) -> Option { + self.state.borrow().link.clone() + } + + pub(super) fn last_error(&self) -> Option { + self.state.borrow().error.clone() + } + + pub(super) fn retire(&self) { + let _ = self.retired.send_replace(true); + } + + /// Waits until the member's link has closed after it was retired. + pub(super) async fn stopped(&self) { + let mut stopped = self.stopped.subscribe(); + let _ = stopped.wait_for(|s| *s).await; + } + + /// The member's link once it is up, or `None` when `deadline` passes, the + /// member is retired, or, with `fail_fast`, when its next dial fails. + pub(super) async fn await_up( + &self, + deadline: tokio::time::Instant, + fail_fast: bool, + ) -> Option { + let mut state = self.state.subscribe(); + let mut retired = self.retired.subscribe(); + let failures_at_start = state.borrow().failed_dials; + loop { + { + let held = state.borrow_and_update(); + if let Some(link) = &held.link { + return Some(link.clone()); + } + if fail_fast && held.failed_dials > failures_at_start { + return None; + } + } + tokio::select! { + changed = state.changed() => if changed.is_err() { return None }, + _ = retired.wait_for(|r| *r) => return None, + _ = tokio::time::sleep_until(deadline) => return None, + } + } + } +} + +/// Dials the member's station, holds the link until it ends or the member is +/// retired, and dials again after the respawn delay. +async fn supervise(pool: Weak, m: Arc) { + let mut retired = m.retired.subscribe(); + loop { + let Some(inner) = pool.upgrade() else { break }; + let respawn = inner.opts.respawn_delay; + let mut cfg = Config::new( + m.target.clone(), + inner.opts.identity.clone(), + inner.issuer.clone(), + ); + cfg.publication_seq = Some(inner.publication_seq.clone()); + cfg.admission = Some(inner.admission.clone()); + cfg.dedup = Some(inner.dedup.clone()); + drop(inner); + let dialed = tokio::select! { + dialed = Link::dial(cfg) => dialed, + _ = retired.wait_for(|r| *r) => break, + }; + match dialed { + Err(e) => { + m.state.send_modify(|h| { + h.error = Some(e.clone()); + h.failed_dials += 1; + }); + event(&pool, &m, false, Some(e)); + } + Ok(link) => { + m.state.send_modify(|h| { + h.link = Some(link.clone()); + h.error = None; + }); + event(&pool, &m, true, None); + if let Some(inner) = pool.upgrade() { + inner.replay(&link).await; + } + let retiring = tokio::select! { + _ = link.done() => false, + _ = retired.wait_for(|r| *r) => true, + }; + if retiring { + let _ = link.close("client_stop").await; + } + let ended = link.error(); + m.state.send_modify(|h| { + h.link = None; + h.error = ended.clone(); + }); + event(&pool, &m, false, ended); + } + } + if *retired.borrow() { + break; + } + tokio::select! { + _ = retired.wait_for(|r| *r) => break, + _ = tokio::time::sleep(respawn) => {} + } + } + let _ = m.stopped.send_replace(true); +} + +fn event(pool: &Weak, m: &Member, up: bool, error: Option) { + if let Some(inner) = pool.upgrade() { + inner.event(LinkEvent { + station: m.target.expected_node_id, + direct: m.direct, + up, + error, + }); + } +} diff --git a/src/pool/pubsub.rs b/src/pool/pubsub.rs new file mode 100644 index 0000000..892021f --- /dev/null +++ b/src/pool/pubsub.rs @@ -0,0 +1,245 @@ +//! PubSub through the pool: a subscription on every link the pool holds and +//! every link it dials later, each event delivered once whichever links hear +//! it; a publication signed once and sent on the first replication_factor +//! links. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; + +use tokio::sync::{mpsc, oneshot}; + +use crate::station_link::{self, Event, Link, LinkError, Publication, SignedPublication}; + +use super::{Pool, PoolError, PoolInner}; + +/// How many events a subscription holds that its reader has not taken; one +/// arriving at a full subscription is dropped and counted. +const SUBSCRIPTION_BUFFER: usize = 256; + +static NEXT_SUBSCRIPTION: AtomicU64 = AtomicU64::new(1); + +/// The node's subscription to a realm and topic, on every link the pool +/// holds and every link it dials later, until [`Subscription::unsubscribe`] +/// or the pool closes. +pub struct Subscription { + inner: Arc, + events: mpsc::Receiver, + unsubscribed: bool, +} + +pub(super) struct SubInner { + id: u64, + pool: Weak, + realm: [u8; 32], + topic: String, + held: Mutex, + dropped: AtomicU64, +} + +struct Held { + /// `None` once the subscription ended. + events: Option>, + /// Each link's forwarder, by link serial: told to stop, it unsubscribes + /// on its link and says how that went. + on_links: HashMap, +} + +struct Forwarder { + stop: oneshot::Sender<()>, + unsubscribed: oneshot::Receiver>, +} + +impl Pool { + /// Subscribes the node to `topic` in `realm` on every link. + pub async fn subscribe( + &self, + realm: &[u8; 32], + topic: &str, + ) -> Result { + let (events_tx, events) = mpsc::channel(SUBSCRIPTION_BUFFER); + let sub = Arc::new(SubInner { + id: NEXT_SUBSCRIPTION.fetch_add(1, Ordering::Relaxed), + pool: Arc::downgrade(&self.inner), + realm: *realm, + topic: topic.to_string(), + held: Mutex::new(Held { + events: Some(events_tx), + on_links: HashMap::new(), + }), + dropped: AtomicU64::new(0), + }); + { + let mut state = self.inner.lock(); + if state.closed { + return Err(PoolError::Closed); + } + state.subs.insert(sub.id, sub.clone()); + } + for link in self.inner.links() { + sub.attach(&link).await; + } + Ok(Subscription { + inner: sub, + events, + unsubscribed: false, + }) + } + + /// Signs `p` once and sends it on the first replication_factor links, in + /// the pool's selection order, succeeding when one of them takes it. + /// Every copy is the same publication, so a subscriber delivers it once. + pub async fn publish(&self, p: Publication) -> Result<(), PoolError> { + let links = self.inner.links(); + if links.is_empty() { + return Err(PoolError::NoLink(Vec::new())); + } + let signed = + SignedPublication::sign(&self.inner.opts.identity, &self.inner.publication_seq, p)?; + let mut errors = Vec::new(); + let mut sent = 0; + for link in links.iter().take(self.inner.opts.replication_factor) { + match link.publish_signed(&signed).await { + Ok(()) => sent += 1, + Err(e) => errors.push(e), + } + } + if sent == 0 { + return Err(PoolError::NoLink(errors)); + } + Ok(()) + } +} + +impl Subscription { + /// The next event, once, whichever links heard it; `None` once the + /// subscription or the pool has ended. + pub async fn recv(&mut self) -> Option { + self.events.recv().await + } + + /// How many events arrived while the subscription was full. + pub fn dropped(&self) -> u64 { + self.inner.dropped.load(Ordering::Relaxed) + } + + /// Ends the subscription on every link. + pub async fn unsubscribe(&mut self) -> Result<(), LinkError> { + self.unsubscribed = true; + if let Some(pool) = self.inner.pool.upgrade() { + pool.lock().subs.remove(&self.inner.id); + } + self.inner.end().await + } +} + +impl Drop for Subscription { + /// A subscription dropped without unsubscribing unsubscribes as it goes. + fn drop(&mut self) { + if self.unsubscribed { + return; + } + if let Some(pool) = self.inner.pool.upgrade() { + pool.lock().subs.remove(&self.inner.id); + } + let inner = self.inner.clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + let _ = inner.end().await; + }); + } + } +} + +impl SubInner { + fn lock(&self) -> MutexGuard<'_, Held> { + self.held.lock().unwrap_or_else(|p| p.into_inner()) + } + + /// Ends the subscription once: its events end, and every link + /// unsubscribes. + pub(super) async fn end(&self) -> Result<(), LinkError> { + let forwarders = { + let mut held = self.lock(); + if held.events.take().is_none() { + return Ok(()); + } + std::mem::take(&mut held.on_links) + }; + let mut result = Ok(()); + for (_, f) in forwarders { + let _ = f.stop.send(()); + if let Ok(Err(e)) = f.unsubscribed.await { + result = Err(e); + } + } + result + } + + /// Subscribes on `link`, once, and forwards what it hears until the + /// link's subscription ends. + pub(super) async fn attach(self: &Arc, link: &Link) { + let events = { + let held = self.lock(); + match &held.events { + Some(events) if !held.on_links.contains_key(&link.serial()) => events.clone(), + _ => return, + } + }; + let Ok(on_link) = link.subscribe(&self.realm, &self.topic).await else { + return; + }; + let (stop, stopped) = oneshot::channel(); + let (unsubscribed_tx, unsubscribed) = oneshot::channel(); + let kept = { + let mut held = self.lock(); + let wanted = held.events.is_some() && !held.on_links.contains_key(&link.serial()); + if wanted { + held.on_links + .insert(link.serial(), Forwarder { stop, unsubscribed }); + } + wanted + }; + if !kept { + let _ = on_link.unsubscribe().await; + return; + } + tokio::spawn(forward( + self.clone(), + link.serial(), + on_link, + events, + stopped, + unsubscribed_tx, + )); + } +} + +async fn forward( + sub: Arc, + serial: u64, + mut on_link: station_link::Subscription, + events: mpsc::Sender, + mut stop: oneshot::Receiver<()>, + unsubscribed: oneshot::Sender>, +) { + loop { + tokio::select! { + _ = &mut stop => { + let _ = unsubscribed.send(on_link.unsubscribe().await); + return; + } + event = on_link.recv() => match event { + Some(event) => { + if events.try_send(event).is_err() { + sub.dropped.fetch_add(1, Ordering::Relaxed); + } + } + None => break, + }, + } + } + // The link ended: a link dialed again gets the subscription back. + sub.lock().on_links.remove(&serial); + let _ = unsubscribed.send(Ok(())); +} diff --git a/src/pool/serve.rs b/src/pool/serve.rs new file mode 100644 index 0000000..1f67955 --- /dev/null +++ b/src/pool/serve.rs @@ -0,0 +1,244 @@ +//! Serving through the pool: a procedure served on every link the pool +//! holds and every link it dials later, each advertising it naming its own +//! station, until stopped. An org procedure is served only in a realm the +//! pool pins a key for; one in the node's own namespace needs none. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; +use std::time::Duration; + +use crate::frame::StreamMode; +use crate::station_link::{ + self, Handler, Link, LinkError, StreamHandler, StreamOffer, DEFAULT_CALL_TIMEOUT, +}; + +use super::{Pool, PoolError, PoolInner}; + +static NEXT_SERVED: AtomicU64 = AtomicU64::new(1); + +/// A procedure the node serves: its realm, which the pool must pin a key for +/// unless the procedure is in the node's own namespace, its name, and +/// exactly one of a unary handler and a stream offer. +#[derive(Clone)] +pub struct Offer { + pub realm: [u8; 32], + pub procedure: String, + pub handler: Option, + pub stream: Option, +} + +impl Offer { + /// A unary procedure. + pub fn unary(realm: [u8; 32], procedure: &str, handler: Handler) -> Offer { + Offer { + realm, + procedure: procedure.to_string(), + handler: Some(handler), + stream: None, + } + } + + /// A streaming procedure of `mode`. + pub fn stream( + realm: [u8; 32], + procedure: &str, + mode: StreamMode, + handler: StreamHandler, + ) -> Offer { + Offer { + realm, + procedure: procedure.to_string(), + handler: None, + stream: Some(StreamOffer { mode, handler }), + } + } +} + +/// A procedure the node serves on every link the pool holds, and on every +/// link it dials later, until [`Served::stop`]. +pub struct Served { + inner: Arc, +} + +pub(super) struct ServedInner { + id: u64, + pool: Weak, + offer: station_link::Offer, + held: Mutex, +} + +struct Held { + stopped: bool, + on_links: HashMap, +} + +impl Pool { + /// Serves `o` on every link that is up, and on every link that comes up + /// after. It succeeds when one link serves it; each link advertises it + /// naming its own station, and renews it and puts it in the DHT as + /// [`Link::serve`] does. + pub async fn serve(&self, o: Offer) -> Result { + let realm_key = self.inner.realm_key_for(&o.realm, &o.procedure)?; + let served = Arc::new(ServedInner { + id: NEXT_SERVED.fetch_add(1, Ordering::Relaxed), + pool: Arc::downgrade(&self.inner), + offer: station_link::Offer { + realm: o.realm, + procedure: o.procedure, + handler: o.handler, + stream: o.stream, + realm_key, + }, + held: Mutex::new(Held { + stopped: false, + on_links: HashMap::new(), + }), + }); + let links = self.inner.links(); + if links.is_empty() { + return Err(PoolError::NoLink(Vec::new())); + } + let mut errors = Vec::new(); + for link in &links { + if let Err(e) = served.serve_on(link).await { + errors.push(e); + } + } + if errors.len() == links.len() { + return Err(PoolError::NotServed(errors)); + } + { + let mut state = self.inner.lock(); + if state.closed { + return Err(PoolError::Closed); + } + state.served.insert(served.id, served.clone()); + } + for link in self.inner.links() { + served.attach(link); + } + Ok(Served { inner: served }) + } +} + +impl PoolInner { + /// Gives a new link the node's subscriptions, then its served + /// procedures, as macula's pool replays them on a respawned link. + pub(super) async fn replay(&self, link: &Link) { + let (subs, served) = { + let state = self.lock(); + ( + state.subs.values().cloned().collect::>(), + state.served.values().cloned().collect::>(), + ) + }; + for sub in subs { + sub.attach(link).await; + } + for s in served { + s.attach(link.clone()); + } + } +} + +impl Served { + /// Withdraws the procedure on every link. + pub async fn stop(&self) -> Result<(), LinkError> { + if let Some(pool) = self.inner.pool.upgrade() { + pool.lock().served.remove(&self.inner.id); + } + let on_links = { + let mut held = self.inner.lock(); + if held.stopped { + return Ok(()); + } + held.stopped = true; + std::mem::take(&mut held.on_links) + }; + let mut result = Ok(()); + for on_link in on_links.into_values() { + if let Err(e) = on_link.stop().await { + result = Err(e); + } + } + result + } +} + +impl ServedInner { + fn lock(&self) -> MutexGuard<'_, Held> { + self.held.lock().unwrap_or_else(|p| p.into_inner()) + } + + fn respawn_delay(&self) -> Option { + self.pool.upgrade().map(|p| p.opts.respawn_delay) + } + + /// Serves the offer on `link`, once, and watches it there. + async fn serve_on(self: &Arc, link: &Link) -> Result<(), LinkError> { + { + let held = self.lock(); + if held.stopped || held.on_links.contains_key(&link.serial()) { + return Ok(()); + } + } + let on_link = match link.serve(self.offer.clone()).await { + Err(LinkError::AlreadyServed) => return Ok(()), + other => other?, + }; + { + let mut held = self.lock(); + if !held.stopped { + held.on_links.insert(link.serial(), on_link.clone()); + drop(held); + tokio::spawn(watch(self.clone(), link.clone(), on_link)); + return Ok(()); + } + } + on_link.stop().await + } + + /// Serves the offer on a link the pool dialed, trying again every + /// respawn delay while the link lives and the offer is not served there. + pub(super) fn attach(self: &Arc, link: Link) { + let served = self.clone(); + tokio::spawn(async move { + loop { + let outcome = + tokio::time::timeout(DEFAULT_CALL_TIMEOUT, served.serve_on(&link)).await; + if matches!(outcome, Ok(Ok(()))) { + return; + } + let Some(delay) = served.respawn_delay() else { + return; + }; + tokio::select! { + _ = link.done() => return, + _ = tokio::time::sleep(delay) => {} + } + } + }); + } +} + +/// Forgets a link's serving when it ends, and serves the offer there again +/// when it lapsed while the link lives. +async fn watch(served: Arc, link: Link, on_link: station_link::Served) { + let why = on_link.done().await; + let stopped = { + let mut held = served.lock(); + held.on_links.remove(&link.serial()); + held.stopped + }; + if stopped || why == LinkError::Stopped || link.error().is_some() { + return; + } + let Some(delay) = served.respawn_delay() else { + return; + }; + tokio::select! { + _ = link.done() => {} + _ = tokio::time::sleep(delay) => served.attach(link.clone()), + } +} diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 0000000..68a698d --- /dev/null +++ b/src/profile.rs @@ -0,0 +1,79 @@ +//! The post-quantum crypto profile a node runs, the counterpart of macula's +//! `macula_crypto_profile` and macula-go's `profile`. A realm runs one profile +//! and every node in it is configured with that one: there is no default, no +//! negotiation and no classical fallback. + +use std::fmt; + +/// A crypto profile, by the name a node is configured with. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Profile { + /// The CNSA 2.0 profile: ML-DSA-87 signatures, with no classical half. + PqPure, + /// The hybrid profile, the fleet's: ML-DSA-87 alone in TLS, and every + /// other signature the LAMPS composite id-MLDSA87-RSA4096-PSS-SHA512, + /// valid only if both halves verify. + PqHybrid, +} + +/// A configured value that names no profile: empty, or not exactly one of +/// `pq_pure` and `pq_hybrid`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProfileError { + /// No profile is configured. + Missing, + /// The value is not exactly one known profile. + Unknown(String), +} + +impl fmt::Display for ProfileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ProfileError::Missing => f.write_str("no crypto profile is configured"), + ProfileError::Unknown(value) => write!(f, "not a known crypto profile: {value:?}"), + } + } +} + +impl std::error::Error for ProfileError {} + +impl Profile { + /// The profile `value` names, exactly. + pub fn parse(value: &str) -> Result { + match value { + "" => Err(ProfileError::Missing), + "pq_pure" => Ok(Profile::PqPure), + "pq_hybrid" => Ok(Profile::PqHybrid), + other => Err(ProfileError::Unknown(other.to_owned())), + } + } + + /// The name a node is configured with, and that node_ids are derived + /// over. + pub fn name(self) -> &'static str { + match self { + Profile::PqPure => "pq_pure", + Profile::PqHybrid => "pq_hybrid", + } + } + + /// Whether identity, CONNECT and status signatures pair ML-DSA-87 with + /// RSA-PSS-4096. + pub fn hybrid(self) -> bool { + self == Profile::PqHybrid + } + + /// The signature algorithm's name, as signed structures carry it. + pub fn sig_alg(self) -> &'static str { + match self { + Profile::PqPure => "ML-DSA-87", + Profile::PqHybrid => "ML-DSA-87-PS384", + } + } +} + +impl fmt::Display for Profile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} diff --git a/src/record.rs b/src/record.rs new file mode 100644 index 0000000..65b6ce2 --- /dev/null +++ b/src/record.rs @@ -0,0 +1,638 @@ +//! macula 12's DHT records, as macula_record and macula-go sign and verify +//! them. A record is the signed object `{key, tbs, signature}` under +//! MACULA-PQ-RECORD-V1; its tbs holds type, alg, version, created_at, +//! expires_at and payload, and subject only on a domain type (tags 0x20 to +//! 0xFF). [`sign`] refuses a key whose purpose does not fit the type; [`verify`] +//! reads a record's wire form in the design's order and keeps its tbs bytes, +//! so [`encode`] sends them unchanged. +//! +//! A record is named by the key id of its key: the node_id for node records, +//! procedure advertisements, content announcements and station endpoints, and +//! the key id for every other type. A tombstone is named as the type it +//! withdraws. + +mod authorization; +mod content_announcement; +mod node_record; +mod payload; +mod procedure_advertisement; +mod station_endpoint; +mod storage_key; +mod tombstone; + +pub use authorization::{ + in_own_namespace, namespace_node, own_namespace, own_procedure, procedure_org, + read_org_directory, read_procedure_delegation, verify_authorization, OrgDirectory, + ProcedureDelegation, Trust, OWN_NAMESPACE_PREFIX, +}; +pub use content_announcement::{ + new_content_announcement, read_content_announcement, ContentAnnouncement, + ContentAnnouncementOptions, +}; +pub use node_record::{new_node_record, read_node_record, NodeRecord, NodeRecordOptions}; +pub use procedure_advertisement::{ + new_procedure_advertisement, read_procedure_advertisement, Authorization, + ProcedureAdvertisement, ProcedureAdvertisementOptions, +}; +pub use station_endpoint::{ + new_station_endpoint, read_station_endpoint, StationEndpoint, StationEndpointOptions, +}; +pub use storage_key::{ + content_key, org_directory_key, procedure_delegation_key, procedure_key, station_endpoint_key, + storage_key, +}; +pub use tombstone::{new_tombstone, read_tombstone, Reason, Tombstone, TombstoneOptions}; + +use std::fmt; + +use crate::cbor::{self, Value}; +use crate::node_key::{key_id_of, node_id_of, signature_size, KeyError, NodeKey, Purpose}; +use crate::profile::Profile; +use crate::signed_object::{sign_object, verify_object, Object, ObjectError}; + +const LABEL: &str = "MACULA-PQ-RECORD-V1"; + +/// The longest wire form a record may have, 256 KiB. +pub const MAX_RECORD_BYTES: usize = 256 * 1024; + +/// How far a verifier's clock may be from a record's created_at and +/// expires_at, 5 minutes. +pub const CLOCK_TOLERANCE_MS: u64 = 5 * MINUTE_MS; + +/// The longest a realm member endorsement admits its member, 30 days. +pub const MAX_ENDORSEMENT_WINDOW_MS: u64 = 30 * DAY_MS; + +const MAX_PROTOCOL_INT: u64 = 1 << 53; +const MAX_PAYLOAD_NESTING: usize = 63; +const MINUTE_MS: u64 = 60 * 1000; +const HOUR_MS: u64 = 60 * MINUTE_MS; +const DAY_MS: u64 = 24 * HOUR_MS; + +/// The longest a record of a type lives (D28). +const NODE_RECORD_MAX_LIFETIME_MS: u64 = 48 * HOUR_MS; +const CONTENT_ANNOUNCEMENT_MAX_LIFETIME_MS: u64 = 48 * HOUR_MS; +const PROCEDURE_ADVERTISEMENT_MAX_LIFETIME_MS: u64 = 5 * MINUTE_MS; +const STATION_ENDPOINT_TTL_MS: u64 = 5 * MINUTE_MS; +const REALM_AND_ORG_MAX_LIFETIME_MS: u64 = 6 * HOUR_MS; +const DOMAIN_RECORD_MAX_LIFETIME_MS: u64 = 7 * DAY_MS; +const DEFAULT_MAX_LIFETIME_MS: u64 = 30 * DAY_MS; +const DEFAULT_TTL_MS: u64 = 48 * HOUR_MS; + +/// A record's type tag. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct RecordType(pub u8); + +impl RecordType { + pub const NODE_RECORD: RecordType = RecordType(0x01); + pub const REALM_DIRECTORY: RecordType = RecordType(0x03); + pub const REALM_STATIONS: RecordType = RecordType(0x04); + pub const REALM_MEMBER_ENDORSEMENT: RecordType = RecordType(0x05); + pub const PROCEDURE_ADVERTISEMENT: RecordType = RecordType(0x06); + pub const TOMBSTONE: RecordType = RecordType(0x0C); + pub const FOUNDATION_SEED_LIST: RecordType = RecordType(0x0D); + pub const FOUNDATION_PARAMETER: RecordType = RecordType(0x0E); + pub const FOUNDATION_REALM_TRUST_LIST: RecordType = RecordType(0x0F); + pub const FOUNDATION_T3_ATTESTATION: RecordType = RecordType(0x10); + pub const CONTENT_ANNOUNCEMENT: RecordType = RecordType(0x11); + pub const STATION_ENDPOINT: RecordType = RecordType(0x12); + pub const ORG_DIRECTORY: RecordType = RecordType(0x15); + pub const PROCEDURE_DELEGATION: RecordType = RecordType(0x16); + /// Tags from here to 0xFF are domain types, whose owners set their + /// payload rules. + pub const DOMAIN_MIN: RecordType = RecordType(0x20); + + fn is_domain(self) -> bool { + self >= RecordType::DOMAIN_MIN + } +} + +/// The refusals of a record, named as macula_record names them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecordError { + /// A wire form over 256 KiB. + TooLarge, + /// A record without exactly the shape, fields and payload of its type, + /// and what. + Malformed(String), + /// Created more than 5 minutes ahead of the verifier's clock. + NotYetValid, + /// Expired more than 5 minutes before the verifier's clock. + Expired, + /// A payload that names a signer other than the key id of its key. + KeyIdMismatch, + /// A lifetime over its type's maximum. + LifetimeTooLong, + /// Expires no later than it is created. + LifetimeReversed, + /// A key whose purpose does not fit the type it would sign. + KeyPurposeMismatch, + /// A record with no key, tbs or signature to encode. + Unsigned, + /// A domain envelope for a built-in type. + NotADomainType, + /// A domain record's subject that is empty. + InvalidSubject, + /// A signature that does not verify. + SignatureInvalid, + /// A signed object whose alg names another profile's algorithm. + AlgMismatch, + /// A node record coordinate that is not a number within its range. + InvalidCoordinate(String), + /// A station endpoint's QUIC port of 0. + InvalidPort, + /// Not a tag 2 content id of 50 bytes. + NotAContentId, + /// A tombstone built to withdraw a tombstone. + TombstoneOfATombstone, + /// An org procedure's advertisement that carries no authorization. + NoAuthorization, + /// An authorization on a procedure whose namespace takes none. + AuthorizationNotAllowed, + /// An authorization in a form macula 12 does not have, a certificate + /// chain among them. + AuthorizationFormUnsupported, + /// An advertisement that is not in its advertiser's own namespace. + NotOwnNamespace, + /// An authorization checked without a trusted realm key. + NoRealmKey, + /// An org directory that does not verify as one, and why. + OrgDirectoryInvalid(String), + /// An org directory the trusted realm key did not sign, or for another + /// realm. + OrgDirectoryWrongRealm, + /// An org directory for another org. + OrgDirectoryWrongOrg, + /// A procedure delegation that does not verify as one, and why. + DelegationInvalid(String), + /// A delegation the org key did not sign, or for another advertiser. + DelegationMismatch, + /// An advertisement that expires after its org directory or delegation. + AuthorizationOutlived, + /// The key could not sign. + Key(KeyError), +} + +impl fmt::Display for RecordError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RecordError::Malformed(why) => write!(f, "malformed record: {why}"), + RecordError::InvalidCoordinate(what) => { + write!(f, "a coordinate outside its range: {what}") + } + RecordError::OrgDirectoryInvalid(why) => { + write!(f, "the org directory does not verify: {why}") + } + RecordError::DelegationInvalid(why) => { + write!(f, "the procedure delegation does not verify: {why}") + } + RecordError::Key(e) => write!(f, "{e}"), + other => write!(f, "{other:?}"), + } + } +} + +impl std::error::Error for RecordError {} + +fn malformed(why: impl Into) -> RecordError { + RecordError::Malformed(why.into()) +} + +/// What signing or verifying gave a record: the key as carried, its key id, +/// the alg, the tbs bytes, and the signature. +#[derive(Debug, Clone, PartialEq)] +pub struct Signed { + pub key: Vec, + pub key_id: [u8; 32], + pub alg: String, + pub tbs: Vec, + pub signature: Vec, +} + +/// A record: unsigned as a builder returns it, or signed or verified, when +/// `signed` holds what signing or verifying gave. The payload is a map. +/// `subject` names a domain record's subject, `None` for none and on every +/// other type. +#[derive(Debug, Clone, PartialEq)] +pub struct Record { + pub record_type: RecordType, + pub version: [u8; 16], + pub created_at: u64, + pub expires_at: u64, + pub payload: Value, + pub subject: Option>, + pub signed: Option, +} + +/// A record of `record_type`, created now with a UUID v7 version, living +/// `ttl_ms`, or its type's default when `ttl_ms` is 0. +fn unsigned(record_type: RecordType, payload: Value, ttl_ms: u64) -> Record { + let ttl_ms = if ttl_ms == 0 { + default_ttl(record_type) + } else { + ttl_ms + }; + let now = crate::uuid_v7::now_ms(); + Record { + record_type, + version: crate::uuid_v7::new(), + created_at: now, + expires_at: now + ttl_ms, + payload, + subject: None, + signed: None, + } +} + +/// An unsigned record of a domain type, 0x20 to 0xFF, with `subject`, living +/// `ttl_ms`, or 48 hours when 0. An empty subject would name a slot apart +/// from no subject, so it is refused. +pub fn envelope( + record_type: u8, + payload: Value, + subject: Option>, + ttl_ms: u64, +) -> Result { + let t = RecordType(record_type); + if !t.is_domain() { + return Err(RecordError::NotADomainType); + } + if subject.as_ref().is_some_and(|s| s.is_empty()) { + return Err(RecordError::InvalidSubject); + } + let mut r = unsigned(t, payload, ttl_ms); + r.subject = subject; + Ok(r) +} + +/// Signs `r` with `key`, as macula_record's sign/2 does. Refused, in +/// macula's order: a key whose purpose does not fit the type; a lifetime that +/// runs backwards or past its type's maximum; a payload that names a signer +/// other than the key; a tbs or payload a verifier would refuse; and a record +/// over 256 KiB. +pub fn sign(r: &Record, key: &NodeKey) -> Result { + let profile = key.profile(); + if !signer_may_sign(r.record_type.0, &r.payload, key.purpose()) { + return Err(RecordError::KeyPurposeMismatch); + } + lifetime(r)?; + let carried = key.public_key(); + let key_id = key_id_by_kind(r.record_type.0, &r.payload, &carried, profile); + if !named_signer(r.record_type, &r.payload, &key_id) { + return Err(RecordError::KeyIdMismatch); + } + let fields = tbs_fields(r); + let mut with_alg = fields.clone(); + with_alg.push((Value::text("alg"), Value::text(profile.sig_alg()))); + let encoded = cbor::encode(&Value::Map(with_alg)).map_err(|e| malformed(e.to_string()))?; + let tbs = cbor::decode(&encoded) + .map_err(|e| malformed(format!("a tbs the decoding rule refuses: {e}")))?; + match read_tbs(&tbs) { + Some(read) if payload::payload_ok(read.record_type, &read.payload) => {} + _ => return Err(malformed("a record a verifier would refuse")), + } + let unsigned_size = cbor::encode(&Value::Map(fields.clone())) + .map_err(|e| malformed(e.to_string()))? + .len() + + carried.len() + + signature_size(profile); + if unsigned_size > MAX_RECORD_BYTES { + return Err(RecordError::TooLarge); + } + let object = sign_object(LABEL, &fields, key).map_err(|e| match e { + ObjectError::Key(k) => RecordError::Key(k), + other => malformed(other.to_string()), + })?; + let wire = cbor::encode(&object.to_value()).map_err(|e| malformed(e.to_string()))?; + if wire.len() > MAX_RECORD_BYTES { + return Err(RecordError::TooLarge); + } + let mut out = r.clone(); + out.signed = Some(Signed { + key: object.key, + key_id, + alg: profile.sig_alg().to_string(), + tbs: object.tbs, + signature: object.signature, + }); + Ok(out) +} + +/// `r` with a new version, created now, with the same lifetime, signed again +/// with `key`. +pub fn refresh(r: &Record, key: &NodeKey) -> Result { + let now = crate::uuid_v7::now_ms(); + let fresh = Record { + version: crate::uuid_v7::new(), + created_at: now, + expires_at: now + r.expires_at.saturating_sub(r.created_at), + signed: None, + ..r.clone() + }; + sign(&fresh, key) +} + +/// The wire form of a signed or verified record: its `{key, tbs, signature}` +/// map, tbs unchanged. +pub fn encode(r: &Record) -> Result, RecordError> { + let signed = r.signed.as_ref().ok_or(RecordError::Unsigned)?; + let object = Object { + key: signed.key.clone(), + tbs: signed.tbs.clone(), + signature: signed.signature.clone(), + }; + cbor::encode(&object.to_value()).map_err(|e| malformed(e.to_string())) +} + +/// A record [`verify`] returned. Only `verify` makes one. +#[derive(Debug, Clone, PartialEq)] +pub struct Verified(Record); + +impl Verified { + /// The verified record. + pub fn record(&self) -> &Record { + &self.0 + } + + /// The verified record, taken. + pub fn into_record(self) -> Record { + self.0 + } +} + +/// Reads a record's wire form under the verifier's `profile` and clock +/// `now_ms`, as macula_record's verify/3 does, in this order: a wire form +/// over 256 KiB, before anything is decoded; a signed object that carries its +/// key; its signature and alg; a tbs of exactly its fields; created_at no more +/// than 5 minutes ahead and expires_at no more than 5 minutes behind; a +/// lifetime within its type's maximum; its type's payload rules; and a payload +/// that names its signer by the key's id. +pub fn verify(wire: &[u8], profile: Profile, now_ms: i64) -> Result { + if wire.len() > MAX_RECORD_BYTES { + return Err(RecordError::TooLarge); + } + let value = cbor::decode(wire).map_err(|e| malformed(e.to_string()))?; + let object = Object::from_value(&value).map_err(|e| malformed(e.to_string()))?; + let verified = verify_object(LABEL, &value, profile).map_err(|e| match e { + ObjectError::SignatureInvalid => RecordError::SignatureInvalid, + ObjectError::AlgMismatch => RecordError::AlgMismatch, + other => malformed(other.to_string()), + })?; + let mut r = read_tbs(&verified.fields).ok_or_else(|| malformed("a tbs of another shape"))?; + let created = r.created_at as i64; + let expires = r.expires_at as i64; + let tolerance = CLOCK_TOLERANCE_MS as i64; + if created > now_ms + tolerance { + return Err(RecordError::NotYetValid); + } + if expires + tolerance < now_ms { + return Err(RecordError::Expired); + } + lifetime(&r)?; + if !payload::payload_ok(r.record_type, &r.payload) { + return Err(malformed("a payload its type's rules refuse")); + } + let key_id = key_id_by_kind(r.record_type.0, &r.payload, &verified.key, profile); + if !named_signer(r.record_type, &r.payload, &key_id) { + return Err(RecordError::KeyIdMismatch); + } + r.signed = Some(Signed { + key: verified.key, + key_id, + alg: profile.sig_alg().to_string(), + tbs: verified.tbs, + signature: object.signature, + }); + Ok(Verified(r)) +} + +/// Checks a payload before anything is signed: its encoding is at most 256 +/// KiB, and it nests at most 63 levels, which a record's tbs leaves it under +/// the decoding rule's 64. +pub fn payload_bounded(payload: &Value) -> Result<(), RecordError> { + let size = cbor::encode(payload) + .map_err(|e| malformed(e.to_string()))? + .len(); + if size > MAX_RECORD_BYTES { + return Err(RecordError::TooLarge); + } + if nesting(payload, 0) > MAX_PAYLOAD_NESTING { + return Err(malformed("a payload nested past 63 levels")); + } + Ok(()) +} + +fn nesting(v: &Value, depth: usize) -> usize { + let children: Vec<&Value> = match v { + Value::List(items) => items.iter().collect(), + Value::Map(pairs) => pairs.iter().flat_map(|(k, v)| [k, v]).collect(), + _ => return depth, + }; + let mut deepest = depth + 1; + for child in children { + if deepest > MAX_PAYLOAD_NESTING { + break; + } + deepest = deepest.max(nesting(child, depth + 1)); + } + deepest +} + +fn tbs_fields(r: &Record) -> Vec<(Value, Value)> { + let mut fields = vec![ + (Value::text("type"), Value::Int(i128::from(r.record_type.0))), + (Value::text("version"), Value::Bytes(r.version.to_vec())), + ( + Value::text("created_at"), + Value::Int(i128::from(r.created_at)), + ), + ( + Value::text("expires_at"), + Value::Int(i128::from(r.expires_at)), + ), + (Value::text("payload"), r.payload.clone()), + ]; + if let Some(subject) = &r.subject { + fields.push((Value::text("subject"), Value::Bytes(subject.clone()))); + } + fields +} + +/// A verified record's tbs, as macula_record's read_tbs does: exactly type +/// (1 to 255), alg, version (16 bytes), created_at and expires_at (below +/// 2^53), payload (a map), and subject (bytes, not empty) only on a domain +/// type. +fn read_tbs(tbs: &Value) -> Option { + let Value::Map(pairs) = tbs else { + return None; + }; + let field = |name: &str| tbs.get(name); + let record_type = match field("type")? { + Value::Int(n) if (1..=255).contains(n) => RecordType(*n as u8), + _ => return None, + }; + let Value::Text(_) = field("alg")? else { + return None; + }; + let version: [u8; 16] = match field("version")? { + Value::Bytes(b) => b.as_slice().try_into().ok()?, + _ => return None, + }; + let created_at = protocol_uint(field("created_at")?)?; + let expires_at = protocol_uint(field("expires_at")?)?; + let payload = field("payload")?; + if !matches!(payload, Value::Map(_)) { + return None; + } + let mut r = Record { + record_type, + version, + created_at, + expires_at, + payload: payload.clone(), + subject: None, + signed: None, + }; + match (pairs.len(), field("subject")) { + (6, None) => Some(r), + (7, Some(Value::Bytes(subject))) if !subject.is_empty() && record_type.is_domain() => { + r.subject = Some(subject.clone()); + Some(r) + } + _ => None, + } +} + +fn protocol_uint(v: &Value) -> Option { + match v { + Value::Int(n) if *n >= 0 && *n < i128::from(MAX_PROTOCOL_INT) => Some(*n as u64), + _ => None, + } +} + +/// Refuses a record whose lifetime does not run forward, or is longer than +/// its type's maximum. +fn lifetime(r: &Record) -> Result<(), RecordError> { + let lived = r.expires_at as i128 - r.created_at as i128; + if lived <= 0 { + return Err(RecordError::LifetimeReversed); + } + if lived > i128::from(max_lifetime(i128::from(r.record_type.0), &r.payload)) { + return Err(RecordError::LifetimeTooLong); + } + Ok(()) +} + +/// The withdrawn_type a tombstone's payload names, when it is an integer. +fn withdrawn_type(payload: &Value) -> Option> { + match payload.get("withdrawn_type") { + None => None, + Some(Value::Int(n)) => Some(Some(*n)), + Some(_) => Some(None), + } +} + +/// The longest a record of type `t` lives, as macula_record's max_lifetime/2 +/// has it. A tombstone's follows the type it withdraws, plus twice the clock +/// tolerance. +fn max_lifetime(t: i128, payload: &Value) -> u64 { + match t { + 0x01 => NODE_RECORD_MAX_LIFETIME_MS, + 0x11 => CONTENT_ANNOUNCEMENT_MAX_LIFETIME_MS, + 0x06 => PROCEDURE_ADVERTISEMENT_MAX_LIFETIME_MS, + 0x12 => STATION_ENDPOINT_TTL_MS, + 0x04 | 0x15 | 0x16 => REALM_AND_ORG_MAX_LIFETIME_MS, + 0x05 => MAX_ENDORSEMENT_WINDOW_MS, + 0x0C => match withdrawn_type(payload) { + None | Some(Some(0x0C)) => DEFAULT_MAX_LIFETIME_MS, + Some(None) => DEFAULT_MAX_LIFETIME_MS + 2 * CLOCK_TOLERANCE_MS, + Some(Some(w)) => max_lifetime(w, &Value::Map(Vec::new())) + 2 * CLOCK_TOLERANCE_MS, + }, + t if t >= 0x20 => DOMAIN_RECORD_MAX_LIFETIME_MS, + _ => DEFAULT_MAX_LIFETIME_MS, + } +} + +/// The lifetime a builder given no ttl takes: 48 hours, or the type's +/// maximum when shorter. +fn default_ttl(t: RecordType) -> u64 { + DEFAULT_TTL_MS.min(max_lifetime(i128::from(t.0), &Value::Map(Vec::new()))) +} + +/// Whether a key of `purpose` may sign a record of type `t`, as +/// macula_record's signer_purposes/2 has it: an identity key signs node +/// records, procedure advertisements, content announcements, station +/// endpoints and domain records; the realm, org and foundation types are +/// their keys'; a tombstone is signed like the type it withdraws. +fn signer_may_sign(t: u8, payload: &Value, purpose: Purpose) -> bool { + match t { + 0x0C => match withdrawn_type(payload) { + Some(Some(w)) if (0..=255).contains(&w) && w != 0x0C => { + signer_may_sign(w as u8, &Value::Map(Vec::new()), purpose) + } + _ => false, + }, + 0x01 | 0x06 | 0x11 | 0x12 => purpose == Purpose::Identity, + t if t >= 0x20 => purpose == Purpose::Identity, + _ => false, + } +} + +/// Whether a type is signed by some key at all, which a withdrawable type +/// must be. +fn signed_by_some_key(t: i128) -> bool { + matches!(t, 0x01 | 0x03..=0x06 | 0x0D..=0x12 | 0x15 | 0x16) || t >= 0x20 +} + +/// Whether a record of type `t` names its signer by node_id rather than key +/// id. A tombstone is named as the type it withdraws. +fn named_by_node_id(t: u8, payload: &Value) -> bool { + let t = if t == 0x0C { + match withdrawn_type(payload) { + Some(Some(w)) => w, + _ => return false, + } + } else { + i128::from(t) + }; + matches!(t, 0x01 | 0x06 | 0x11 | 0x12) +} + +fn key_id_by_kind(t: u8, payload: &Value, carried: &[u8], profile: Profile) -> [u8; 32] { + if named_by_node_id(t, payload) { + node_id_of(carried, profile) + } else { + key_id_of(carried, profile) + } +} + +/// Whether the payload field that names a type's signer, where the type has +/// one, holds `key_id`. +fn named_signer(t: RecordType, payload: &Value, key_id: &[u8; 32]) -> bool { + let name = match t { + RecordType::NODE_RECORD => "node_id", + RecordType::PROCEDURE_ADVERTISEMENT => "advertiser_node", + RecordType::CONTENT_ANNOUNCEMENT => "announcer_node", + RecordType::PROCEDURE_DELEGATION => "org_key", + _ => return true, + }; + matches!(payload.get(name), Some(Value::Bytes(b)) if b.as_slice() == key_id) +} + +/// A 32-byte id field of `payload`, or zeros. +fn id_field(payload: &Value, name: &str) -> [u8; 32] { + match payload.get(name) { + Some(Value::Bytes(b)) if b.len() == 32 => b.as_slice().try_into().unwrap_or([0; 32]), + _ => [0; 32], + } +} + +fn text_field(payload: &Value, name: &str) -> String { + match payload.get(name) { + Some(Value::Text(t)) => t.clone(), + _ => String::new(), + } +} + +fn entry(name: &str, value: Value) -> (Value, Value) { + (Value::text(name), value) +} diff --git a/src/record/authorization.rs b/src/record/authorization.rs new file mode 100644 index 0000000..4b960c1 --- /dev/null +++ b/src/record/authorization.rs @@ -0,0 +1,220 @@ +//! A procedure advertisement's provider authorization (D25), as +//! macula_record's verify_authorization/3 and own_namespace/1 decide it. A +//! procedure `org/name` is authorized by the realm's org directory, which +//! names the org's key, and the org's procedure delegation to the +//! advertiser, both carried in the advertisement. A procedure in a node's own +//! namespace, `~/name`, is authorized by the advertisement's +//! signature alone, and needs no realm key. A procedure without a namespace +//! carries no authorization. + +use crate::profile::Profile; + +use super::{ + id_field, malformed, read_procedure_advertisement, text_field, verify, Authorization, + ProcedureAdvertisement, Record, RecordError, RecordType, Verified, +}; + +/// Starts the namespace of a node's own procedures, `~/`. +pub const OWN_NAMESPACE_PREFIX: &str = "~"; + +/// What a caller trusts for its realm: the verifier's profile and the realm +/// key as carried, `None` when none is pinned. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Trust { + pub profile: Profile, + pub realm_key: Option>, +} + +/// A procedure's org namespace: the text before the first `/` of its name. +/// A name without a slash, or with `_` before it, has none; a name starting +/// with a slash is malformed. +pub fn procedure_org(procedure: &str) -> Result, RecordError> { + match procedure.split_once('/') { + None => Ok(None), + Some(("_", _)) => Ok(None), + Some(("", _)) => Err(malformed("a procedure name starting with a slash")), + Some((org, _)) => Ok(Some(org)), + } +} + +/// Whether `procedure` names a node's own namespace, `~` before its first +/// slash, spelled well or not. +pub fn in_own_namespace(procedure: &str) -> bool { + matches!(procedure_org(procedure), Ok(Some(org)) if org.starts_with(OWN_NAMESPACE_PREFIX)) +} + +/// `name` in the own namespace of `node`: `~/name`. +pub fn own_procedure(node: &[u8; 32], name: &str) -> String { + let hex: String = node.iter().map(|b| format!("{b:02x}")).collect(); + format!("{OWN_NAMESPACE_PREFIX}{hex}/{name}") +} + +/// The node_id a `~` namespace names: exactly 64 lowercase hex characters, +/// the one spelling of a node_id in a namespace. +pub fn namespace_node(hex_node: &str) -> Result<[u8; 32], RecordError> { + let bad = || malformed("a ~ namespace is 64 lowercase hex characters"); + if hex_node.len() != 64 + || !hex_node + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(bad()); + } + let mut node = [0u8; 32]; + for (i, byte) in node.iter_mut().enumerate() { + *byte = u8::from_str_radix(&hex_node[2 * i..2 * i + 2], 16).map_err(|_| bad())?; + } + Ok(node) +} + +/// Whether a verified procedure advertisement is in its advertiser's own +/// namespace and admissible there: `~/` where node_id is the +/// advertiser_node verifying bound to its signer, with no authorization. +pub fn own_namespace(advertisement: &Verified) -> Result<(), RecordError> { + let r = advertisement.record(); + if r.record_type != RecordType::PROCEDURE_ADVERTISEMENT { + return Err(RecordError::NotOwnNamespace); + } + let read = read_procedure_advertisement(r)?; + match procedure_org(&read.procedure) { + Ok(Some(org)) if org.starts_with(OWN_NAMESPACE_PREFIX) => { + own_node(&org[OWN_NAMESPACE_PREFIX.len()..], &read) + } + _ => Err(RecordError::NotOwnNamespace), + } +} + +fn own_node(hex_node: &str, read: &ProcedureAdvertisement) -> Result<(), RecordError> { + let node = namespace_node(hex_node)?; + if node != read.advertiser_node { + return Err(RecordError::NotOwnNamespace); + } + if read.authorization != Authorization::None { + return Err(RecordError::AuthorizationNotAllowed); + } + Ok(()) +} + +/// A caller's check of a verified procedure advertisement's provider +/// authorization against the realm it trusts, at `now_ms`. An org procedure +/// needs an org directory and a procedure delegation, and the realm key: the +/// directory must verify, carry the realm key and name the advertisement's +/// realm and the procedure's org; the delegation must verify, signed by the +/// org key the directory names, for the advertiser; and the advertisement +/// expires no later than either. +pub fn verify_authorization( + advertisement: &Verified, + trust: &Trust, + now_ms: i64, +) -> Result<(), RecordError> { + let r = advertisement.record(); + if r.record_type != RecordType::PROCEDURE_ADVERTISEMENT { + return Err(malformed("not a procedure advertisement")); + } + let read = read_procedure_advertisement(r)?; + let org = procedure_org(&read.procedure)?; + if let Some(org) = org.filter(|o| o.starts_with(OWN_NAMESPACE_PREFIX)) { + return own_node(&org[OWN_NAMESPACE_PREFIX.len()..], &read); + } + match (org, &read.authorization) { + (None, Authorization::None) => Ok(()), + (None, _) => Err(RecordError::AuthorizationNotAllowed), + (Some(_), Authorization::None) => Err(RecordError::NoAuthorization), + ( + Some(org), + Authorization::Delegation { + org_directory, + procedure_delegation, + }, + ) => delegation_path( + r, + &read, + org, + org_directory, + procedure_delegation, + trust, + now_ms, + ), + (Some(_), Authorization::Unsupported) => Err(RecordError::AuthorizationFormUnsupported), + (Some(_), Authorization::Malformed) => Err(malformed( + "an org directory and a procedure delegation that are not both byte strings", + )), + } +} + +fn delegation_path( + advertisement: &Record, + read: &ProcedureAdvertisement, + org: &str, + directory_wire: &[u8], + delegation_wire: &[u8], + trust: &Trust, + now_ms: i64, +) -> Result<(), RecordError> { + let realm_key = trust.realm_key.as_ref().ok_or(RecordError::NoRealmKey)?; + let directory = verify(directory_wire, trust.profile, now_ms) + .map_err(|e| RecordError::OrgDirectoryInvalid(e.to_string()))? + .into_record(); + let named = read_org_directory(&directory) + .map_err(|e| RecordError::OrgDirectoryInvalid(e.to_string()))?; + let directory_key = directory.signed.as_ref().map(|s| &s.key); + if directory_key != Some(realm_key) || named.realm_id != read.realm_id { + return Err(RecordError::OrgDirectoryWrongRealm); + } + if named.org_name != org { + return Err(RecordError::OrgDirectoryWrongOrg); + } + let delegation = verify(delegation_wire, trust.profile, now_ms) + .map_err(|e| RecordError::DelegationInvalid(e.to_string()))? + .into_record(); + let granted = read_procedure_delegation(&delegation) + .map_err(|e| RecordError::DelegationInvalid(e.to_string()))?; + let delegation_key_id = delegation.signed.as_ref().map(|s| s.key_id); + if delegation_key_id != Some(named.org_key) || granted.advertiser != read.advertiser_node { + return Err(RecordError::DelegationMismatch); + } + if advertisement.expires_at > directory.expires_at.min(delegation.expires_at) { + return Err(RecordError::AuthorizationOutlived); + } + Ok(()) +} + +/// An org directory's payload: a realm's statement that the org `org_name` +/// is held by the key with key id `org_key`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct OrgDirectory { + pub realm_id: [u8; 32], + pub org_name: String, + pub org_key: [u8; 32], +} + +/// Reads an org directory's payload. +pub fn read_org_directory(r: &Record) -> Result { + if r.record_type != RecordType::ORG_DIRECTORY { + return Err(malformed("not an org directory")); + } + Ok(OrgDirectory { + realm_id: id_field(&r.payload, "realm_id"), + org_name: text_field(&r.payload, "org_name"), + org_key: id_field(&r.payload, "org_key"), + }) +} + +/// A procedure delegation's payload: an org's grant, signed by its org key, +/// that the node `advertiser` may serve procedures under the org. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProcedureDelegation { + pub org_key: [u8; 32], + pub advertiser: [u8; 32], +} + +/// Reads a procedure delegation's payload. +pub fn read_procedure_delegation(r: &Record) -> Result { + if r.record_type != RecordType::PROCEDURE_DELEGATION { + return Err(malformed("not a procedure delegation")); + } + Ok(ProcedureDelegation { + org_key: id_field(&r.payload, "org_key"), + advertiser: id_field(&r.payload, "advertiser"), + }) +} diff --git a/src/record/content_announcement.rs b/src/record/content_announcement.rs new file mode 100644 index 0000000..c6735ef --- /dev/null +++ b/src/record/content_announcement.rs @@ -0,0 +1,102 @@ +//! Content announcements (macula 12.6.0, D27): a node's statement that it +//! shares the content with a tag 2 content id, served on its content +//! procedure in a realm and reachable through a station. + +use crate::cbor::Value; + +use super::{ + entry, id_field, malformed, payload::is_content_id, text_field, unsigned, Record, RecordError, + RecordType, +}; + +/// Where the content is served, then its name, size and chunk count, each +/// left out when empty or `None`; `ttl_ms`, 0 for the default, 48 hours. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ContentAnnouncementOptions { + pub realm_id: [u8; 32], + pub serving_station: [u8; 32], + pub procedure: String, + pub name: String, + pub size: Option, + pub chunk_count: Option, + pub ttl_ms: u64, +} + +/// An unsigned announcement by `announcer_node`, which signs it. A content id +/// of another size or tag, or an empty procedure, is refused. +pub fn new_content_announcement( + announcer_node: &[u8; 32], + mcid: &[u8], + opts: &ContentAnnouncementOptions, +) -> Result { + if !is_content_id(Some(&Value::Bytes(mcid.to_vec()))) { + return Err(RecordError::NotAContentId); + } + if opts.procedure.is_empty() { + return Err(malformed( + "a content announcement names its content procedure", + )); + } + let mut entries = vec![ + entry("announcer_node", Value::Bytes(announcer_node.to_vec())), + entry("mcid", Value::Bytes(mcid.to_vec())), + entry("realm_id", Value::Bytes(opts.realm_id.to_vec())), + entry( + "serving_station", + Value::Bytes(opts.serving_station.to_vec()), + ), + entry("procedure", Value::text(opts.procedure.clone())), + ]; + if !opts.name.is_empty() { + entries.push(entry("name", Value::text(opts.name.clone()))); + } + if let Some(size) = opts.size { + entries.push(entry("size", Value::Int(i128::from(size)))); + } + if let Some(chunks) = opts.chunk_count { + entries.push(entry("chunk_count", Value::Int(i128::from(chunks)))); + } + Ok(unsigned( + RecordType::CONTENT_ANNOUNCEMENT, + Value::Map(entries), + opts.ttl_ms, + )) +} + +/// A content announcement's payload. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ContentAnnouncement { + pub announcer_node: [u8; 32], + pub mcid: Vec, + pub realm_id: [u8; 32], + pub serving_station: [u8; 32], + pub procedure: String, + pub name: String, + pub size: Option, + pub chunk_count: Option, +} + +/// Reads a content announcement's payload. +pub fn read_content_announcement(r: &Record) -> Result { + if r.record_type != RecordType::CONTENT_ANNOUNCEMENT { + return Err(malformed("not a content announcement")); + } + let p = &r.payload; + let optional = |name: &str| match p.get(name) { + Some(Value::Int(n)) if *n >= 0 => u64::try_from(*n).ok(), + _ => None, + }; + Ok(ContentAnnouncement { + announcer_node: id_field(p, "announcer_node"), + mcid: match p.get("mcid") { + Some(Value::Bytes(b)) => b.clone(), + _ => Vec::new(), + }, + realm_id: id_field(p, "realm_id"), + serving_station: id_field(p, "serving_station"), + procedure: text_field(p, "procedure"), + name: text_field(p, "name"), + size: optional("size"), + chunk_count: optional("chunk_count"), + }) +} diff --git a/src/record/node_record.rs b/src/record/node_record.rs new file mode 100644 index 0000000..e3c6d47 --- /dev/null +++ b/src/record/node_record.rs @@ -0,0 +1,187 @@ +//! Node records: a node's statement of the realms it serves, its +//! capabilities and where it is, signed by the node and stored under its +//! node_id. Coordinates travel as text with at most 6 decimals and no +//! trailing zeros, stable across stacks where float encodings are not. + +use crate::cbor::Value; + +use super::{entry, id_field, malformed, text_field, unsigned, Record, RecordError, RecordType}; + +const MAX_GEO_TEXT_BYTES: usize = 32; +const LAT_BOUND: f64 = 90.0; +const LNG_BOUND: f64 = 180.0; + +/// A node record's optional fields: `station_id` is `None` for the node +/// itself; empty text and `None` coordinates are left out; `kind` is +/// "station" or "daemon"; `peers` are kept sorted and once each; `ttl_ms` is +/// 0 for the default, 48 hours. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct NodeRecordOptions { + pub station_id: Option<[u8; 32]>, + pub caps_hint: String, + pub display_name: String, + pub hostname: String, + pub endpoint: String, + pub city: String, + pub country: String, + pub lat: Option, + pub lng: Option, + pub kind: String, + pub peers: Vec<[u8; 32]>, + pub ttl_ms: u64, +} + +/// An unsigned node record about `node_id`, which signs it. A coordinate out +/// of its range, or NaN, is refused. +pub fn new_node_record( + node_id: &[u8; 32], + realms: &[[u8; 32]], + capabilities: u64, + opts: &NodeRecordOptions, +) -> Result { + let mut entries = vec![ + entry("node_id", Value::Bytes(node_id.to_vec())), + entry( + "station_id", + Value::Bytes(opts.station_id.unwrap_or(*node_id).to_vec()), + ), + entry("realms", id_list(realms)), + entry("capabilities", Value::Int(i128::from(capabilities))), + ]; + for (name, value) in [ + ("caps_hint", &opts.caps_hint), + ("display_name", &opts.display_name), + ("hostname", &opts.hostname), + ("endpoint", &opts.endpoint), + ("city", &opts.city), + ("country", &opts.country), + ("kind", &opts.kind), + ] { + if !value.is_empty() { + entries.push(entry(name, Value::text(value.clone()))); + } + } + for (name, value, bound) in [("lat", opts.lat, LAT_BOUND), ("lng", opts.lng, LNG_BOUND)] { + let Some(v) = value else { continue }; + if v.is_nan() || v.abs() > bound { + return Err(RecordError::InvalidCoordinate(format!("{name} {v}"))); + } + entries.push(entry(name, Value::text(geo_text(v)))); + } + let mut peers = opts.peers.clone(); + peers.sort_unstable(); + peers.dedup(); + if !peers.is_empty() { + entries.push(entry("peers", id_list(&peers))); + } + Ok(unsigned( + RecordType::NODE_RECORD, + Value::Map(entries), + opts.ttl_ms, + )) +} + +/// A node record's payload, as macula_record reads it. A field left out, or +/// of another kind, is zero, empty or `None`. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct NodeRecord { + pub node_id: [u8; 32], + pub station_id: [u8; 32], + pub realms: Vec<[u8; 32]>, + pub capabilities: u64, + pub kind: String, + pub hostname: String, + pub endpoint: String, + pub city: String, + pub country: String, + pub lat: Option, + pub lng: Option, + pub display_name: String, + pub caps_hint: String, + pub peers: Vec<[u8; 32]>, + pub version: String, +} + +/// Reads a node record's payload. +pub fn read_node_record(r: &Record) -> Result { + if r.record_type != RecordType::NODE_RECORD { + return Err(malformed("not a node record")); + } + let p = &r.payload; + Ok(NodeRecord { + node_id: id_field(p, "node_id"), + station_id: id_field(p, "station_id"), + realms: read_ids(p.get("realms")), + capabilities: match p.get("capabilities") { + Some(Value::Int(n)) if *n >= 0 => u64::try_from(*n).unwrap_or(0), + _ => 0, + }, + kind: text_field(p, "kind"), + hostname: text_field(p, "hostname"), + endpoint: text_field(p, "endpoint"), + city: text_field(p, "city"), + country: text_field(p, "country"), + lat: parse_geo(&text_field(p, "lat"), LAT_BOUND), + lng: parse_geo(&text_field(p, "lng"), LNG_BOUND), + display_name: text_field(p, "display_name"), + caps_hint: text_field(p, "caps_hint"), + peers: read_ids(p.get("peers")), + version: text_field(p, "version"), + }) +} + +/// A coordinate as macula renders one: 6 decimals, trailing zeros cut, +/// keeping one digit after the point. +fn geo_text(v: f64) -> String { + let s = format!("{v:.6}"); + let s = s.trim_end_matches('0'); + if s.ends_with('.') { + format!("{s}0") + } else { + s.to_string() + } +} + +/// A coordinate's text as macula_record's parse_geo/2 reads it: at most 32 +/// bytes, an optional leading minus, digits, then optionally a dot and +/// digits, within `bound` of zero. +fn parse_geo(s: &str, bound: f64) -> Option { + if s.len() > MAX_GEO_TEXT_BYTES { + return None; + } + let unsigned = s.strip_prefix('-').unwrap_or(s); + let (whole, fraction) = match unsigned.split_once('.') { + Some((w, f)) => (w, Some(f)), + None => (unsigned, None), + }; + let digits = |d: &str| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()); + if !digits(whole) || fraction.is_some_and(|f| !digits(f)) { + return None; + } + let v: f64 = s.parse().ok()?; + if v.abs() > bound { + return None; + } + Some(if v == 0.0 && fraction.is_none() { + 0.0 + } else { + v + }) +} + +fn id_list(ids: &[[u8; 32]]) -> Value { + Value::List(ids.iter().map(|id| Value::Bytes(id.to_vec())).collect()) +} + +fn read_ids(v: Option<&Value>) -> Vec<[u8; 32]> { + match v { + Some(Value::List(items)) => items + .iter() + .filter_map(|item| match item { + Value::Bytes(b) => b.as_slice().try_into().ok(), + _ => None, + }) + .collect(), + _ => Vec::new(), + } +} diff --git a/src/record/payload.rs b/src/record/payload.rs new file mode 100644 index 0000000..d56ac6c --- /dev/null +++ b/src/record/payload.rs @@ -0,0 +1,152 @@ +//! Whether a record's payload holds what its type's rules require, as +//! macula_record's payload_ok/2: every field a storage key or a signer check +//! reads is present, of its kind, and the payloads the design pins hold +//! exactly their keys. A domain type's owner sets its rules. + +use crate::cbor::Value; + +use super::{signed_by_some_key, RecordType}; + +/// A tombstone's own payload fields; the rest are the withdrawn record's slot +/// fields. +const TOMBSTONE_FIELDS: &[&str] = &["withdrawn_type", "withdrawn_version", "reason", "detail"]; + +/// The reasons a tombstone may give. +const TOMBSTONE_REASONS: &[&str] = &["shutdown", "moved", "revoked"]; + +pub(super) fn payload_ok(t: RecordType, payload: &Value) -> bool { + let Value::Map(pairs) = payload else { + return false; + }; + let field = |name: &str| payload.get(name); + match t { + RecordType::NODE_RECORD => is_id(field("node_id")), + RecordType::REALM_DIRECTORY | RecordType::REALM_STATIONS => is_id(field("realm_id")), + RecordType::REALM_MEMBER_ENDORSEMENT => { + is_id(field("realm_id")) && is_id(field("member_node")) + } + RecordType::PROCEDURE_ADVERTISEMENT => advertisement_ok(payload, pairs.len()), + RecordType::TOMBSTONE => tombstone_ok(payload, pairs), + RecordType::FOUNDATION_SEED_LIST + | RecordType::FOUNDATION_REALM_TRUST_LIST + | RecordType::STATION_ENDPOINT => true, + RecordType::FOUNDATION_PARAMETER => is_text(field("param_name")), + RecordType::FOUNDATION_T3_ATTESTATION => is_id(field("station_id")), + RecordType::CONTENT_ANNOUNCEMENT => { + is_id(field("announcer_node")) + && is_content_id(field("mcid")) + && is_id(field("realm_id")) + && is_id(field("serving_station")) + && matches!(field("procedure"), Some(Value::Text(p)) if !p.is_empty()) + } + RecordType::ORG_DIRECTORY => { + is_id(field("realm_id")) && is_text(field("org_name")) && is_id(field("org_key")) + } + RecordType::PROCEDURE_DELEGATION => is_id(field("org_key")) && is_id(field("advertiser")), + t => t >= RecordType::DOMAIN_MIN, + } +} + +/// Exactly realm_id, procedure, advertiser_node and serving_station, with an +/// authorization map when it carries one. +fn advertisement_ok(payload: &Value, size: usize) -> bool { + if !is_id(payload.get("realm_id")) + || !is_text(payload.get("procedure")) + || !is_id(payload.get("advertiser_node")) + || !is_id(payload.get("serving_station")) + { + return false; + } + match payload.get("authorization") { + None => size == 4, + Some(Value::Map(_)) => size == 5, + Some(_) => false, + } +} + +fn tombstone_ok(payload: &Value, pairs: &[(Value, Value)]) -> bool { + let (Some(Value::Int(withdrawn)), Some(Value::Bytes(version)), Some(Value::Text(reason))) = ( + payload.get("withdrawn_type"), + payload.get("withdrawn_version"), + payload.get("reason"), + ) else { + return false; + }; + if version.len() != 16 { + return false; + } + let slot: Vec<&(Value, Value)> = pairs + .iter() + .filter(|(k, _)| !matches!(k, Value::Text(n) if TOMBSTONE_FIELDS.contains(&n.as_str()))) + .collect(); + TOMBSTONE_REASONS.contains(&reason.as_str()) + && withdrawable(*withdrawn) + && payload + .get("detail") + .is_none_or(|d| matches!(d, Value::Text(_))) + && slot_ok(*withdrawn, &slot) +} + +/// Whether a record of type `t` can be withdrawn: a domain type, or a +/// built-in type other than a tombstone that some key signs. +fn withdrawable(t: i128) -> bool { + match t { + 0x20..=0xFF => true, + 1..=0x1F => t != 0x0C && signed_by_some_key(t), + _ => false, + } +} + +/// Whether a tombstone's slot fields are exactly the withdrawn type's, each +/// of its kind; for a domain type, none or a subject that is not empty. +fn slot_ok(t: i128, slot: &[&(Value, Value)]) -> bool { + if t >= 0x20 { + return match slot { + [] => true, + [(Value::Text(name), Value::Bytes(subject))] => { + name == "subject" && !subject.is_empty() + } + _ => false, + }; + } + let names = slot_field_names(t); + slot.len() == names.len() + && slot.iter().all(|(k, v)| matches!(k, Value::Text(n) if names.contains(&n.as_str()) && slot_value_ok(n, v))) +} + +/// The payload fields a type's storage key derives from, besides its +/// signer's key id. +pub(super) fn slot_field_names(t: i128) -> &'static [&'static str] { + match t { + 0x03 | 0x04 => &["realm_id"], + 0x05 => &["realm_id", "member_node"], + 0x06 => &["realm_id", "procedure"], + 0x0E => &["param_name"], + 0x10 => &["station_id"], + 0x11 => &["mcid"], + 0x15 => &["realm_id", "org_name"], + 0x16 => &["advertiser"], + _ => &[], + } +} + +fn slot_value_ok(name: &str, v: &Value) -> bool { + match name { + "procedure" | "param_name" | "org_name" => matches!(v, Value::Text(_)), + "mcid" => is_content_id(Some(v)), + _ => is_id(Some(v)), + } +} + +pub(super) fn is_id(v: Option<&Value>) -> bool { + matches!(v, Some(Value::Bytes(b)) if b.len() == 32) +} + +fn is_text(v: Option<&Value>) -> bool { + matches!(v, Some(Value::Text(_))) +} + +/// An MCID: 50 bytes, tag 2 for SHA-384, a codec byte and the hash (D24). +pub(super) fn is_content_id(v: Option<&Value>) -> bool { + matches!(v, Some(Value::Bytes(b)) if b.len() == 50 && b[0] == 2) +} diff --git a/src/record/procedure_advertisement.rs b/src/record/procedure_advertisement.rs new file mode 100644 index 0000000..44eb4e8 --- /dev/null +++ b/src/record/procedure_advertisement.rs @@ -0,0 +1,128 @@ +//! Procedure advertisements: a node's statement that it serves a procedure in +//! a realm, through a station, signed by the node. A procedure with an org +//! namespace carries its provider authorization inside the payload: the +//! realm's org directory and the org's procedure delegation, as their wire +//! forms (see `authorization`). + +use crate::cbor::Value; + +use super::{entry, id_field, malformed, text_field, unsigned, Record, RecordError, RecordType}; + +/// A procedure advertisement's provider authorization, as macula_record's +/// read_authorization/1 reads it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum Authorization { + /// The advertisement carries none. + #[default] + None, + /// The realm's org directory and the org's procedure delegation, the one + /// form macula 12 has, as the records' wire forms. + Delegation { + org_directory: Vec, + procedure_delegation: Vec, + }, + /// A map of any other fields, a certificate chain among them. + Unsupported, + /// Not a map, or an org directory and a delegation that are not both + /// byte strings. + Malformed, +} + +/// A procedure advertisement's optional fields: its authorization, and +/// `ttl_ms`, 0 for the default and maximum, 5 minutes. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProcedureAdvertisementOptions { + pub authorization: Authorization, + pub ttl_ms: u64, +} + +/// An unsigned advertisement, by `advertiser_node`, which signs it, of +/// `procedure` in `realm_id`, served through `serving_station`. It builds no +/// authorization but an org directory and a procedure delegation. +pub fn new_procedure_advertisement( + advertiser_node: &[u8; 32], + realm_id: &[u8; 32], + procedure: &str, + serving_station: &[u8; 32], + opts: &ProcedureAdvertisementOptions, +) -> Result { + let mut entries = vec![ + entry("realm_id", Value::Bytes(realm_id.to_vec())), + entry("procedure", Value::text(procedure)), + entry("advertiser_node", Value::Bytes(advertiser_node.to_vec())), + entry("serving_station", Value::Bytes(serving_station.to_vec())), + ]; + match &opts.authorization { + Authorization::None => {} + Authorization::Delegation { + org_directory, + procedure_delegation, + } => entries.push(entry( + "authorization", + Value::Map(vec![ + entry("org_directory", Value::Bytes(org_directory.clone())), + entry( + "procedure_delegation", + Value::Bytes(procedure_delegation.clone()), + ), + ]), + )), + Authorization::Unsupported => return Err(RecordError::AuthorizationFormUnsupported), + Authorization::Malformed => return Err(malformed("an authorization in no form")), + } + Ok(unsigned( + RecordType::PROCEDURE_ADVERTISEMENT, + Value::Map(entries), + opts.ttl_ms, + )) +} + +/// A procedure advertisement's payload. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProcedureAdvertisement { + pub realm_id: [u8; 32], + pub procedure: String, + pub advertiser_node: [u8; 32], + pub serving_station: [u8; 32], + pub authorization: Authorization, +} + +/// Reads a procedure advertisement's payload. +pub fn read_procedure_advertisement(r: &Record) -> Result { + if r.record_type != RecordType::PROCEDURE_ADVERTISEMENT { + return Err(malformed("not a procedure advertisement")); + } + let p = &r.payload; + Ok(ProcedureAdvertisement { + realm_id: id_field(p, "realm_id"), + procedure: text_field(p, "procedure"), + advertiser_node: id_field(p, "advertiser_node"), + serving_station: id_field(p, "serving_station"), + authorization: read_authorization(p), + }) +} + +fn read_authorization(payload: &Value) -> Authorization { + let Some(value) = payload.get("authorization") else { + return Authorization::None; + }; + let Value::Map(pairs) = value else { + return Authorization::Malformed; + }; + let (Some(directory), Some(delegation)) = ( + value.get("org_directory"), + value.get("procedure_delegation"), + ) else { + return Authorization::Unsupported; + }; + if pairs.len() != 2 { + return Authorization::Unsupported; + } + match (directory, delegation) { + (Value::Bytes(d), Value::Bytes(g)) => Authorization::Delegation { + org_directory: d.clone(), + procedure_delegation: g.clone(), + }, + _ => Authorization::Malformed, + } +} diff --git a/src/record/station_endpoint.rs b/src/record/station_endpoint.rs new file mode 100644 index 0000000..ae371c3 --- /dev/null +++ b/src/record/station_endpoint.rs @@ -0,0 +1,88 @@ +//! Station endpoints: where a station is dialled, signed by the station and +//! stored under its node_id's station endpoint key. + +use crate::cbor::Value; + +use super::{entry, malformed, unsigned, Record, RecordError, RecordType}; + +/// A station endpoint's optional fields: the hosts it is dialled at, left +/// out when none; its ALPN, left out when empty; `ttl_ms`, 0 for the default +/// and maximum, 5 minutes. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StationEndpointOptions { + pub host_advertised: Vec, + pub alpn: String, + pub ttl_ms: u64, +} + +/// An unsigned record of a station's dialable endpoint. A QUIC port of 0 is +/// refused. +pub fn new_station_endpoint( + quic_port: u16, + opts: &StationEndpointOptions, +) -> Result { + if quic_port == 0 { + return Err(RecordError::InvalidPort); + } + let mut entries = vec![entry("quic_port", Value::Int(i128::from(quic_port)))]; + if !opts.host_advertised.is_empty() { + entries.push(entry( + "host_advertised", + Value::List( + opts.host_advertised + .iter() + .map(|h| Value::Bytes(h.as_bytes().to_vec())) + .collect(), + ), + )); + } + if !opts.alpn.is_empty() { + entries.push(entry("alpn", Value::text(opts.alpn.clone()))); + } + Ok(unsigned( + RecordType::STATION_ENDPOINT, + Value::Map(entries), + opts.ttl_ms, + )) +} + +/// A station endpoint's payload: its QUIC port, 0 when it carries none from +/// 1 to 65535, and the hosts it is dialled at. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct StationEndpoint { + pub quic_port: u16, + pub host_advertised: Vec, +} + +/// Reads a station endpoint's payload. +pub fn read_station_endpoint(r: &Record) -> Result { + if r.record_type != RecordType::STATION_ENDPOINT { + return Err(malformed("not a station endpoint")); + } + let quic_port = match r.payload.get("quic_port") { + Some(Value::Int(n)) if (1..=65535).contains(n) => *n as u16, + _ => 0, + }; + Ok(StationEndpoint { + quic_port, + host_advertised: host_list(r.payload.get("host_advertised")), + }) +} + +/// host_advertised as macula_record's host_list/1 reads it: a list of hosts +/// or a single host, each as bytes or text. +fn host_list(v: Option<&Value>) -> Vec { + let items: Vec<&Value> = match v { + None => return Vec::new(), + Some(Value::List(items)) => items.iter().collect(), + Some(single) => vec![single], + }; + items + .into_iter() + .filter_map(|item| match item { + Value::Bytes(b) => Some(String::from_utf8_lossy(b).into_owned()), + Value::Text(t) => Some(t.clone()), + _ => None, + }) + .collect() +} diff --git a/src/record/storage_key.rs b/src/record/storage_key.rs new file mode 100644 index 0000000..cfc5c17 --- /dev/null +++ b/src/record/storage_key.rs @@ -0,0 +1,134 @@ +//! A record's 32-byte DHT storage key, as macula_record's storage_key/1 +//! derives it. A node record is stored under its node_id, and every other +//! record under SHA-256 over MACULA-PQ-STORAGE-KEY-V1, a zero byte, its type +//! and the fields of its slot, a 32-byte id as it is and any other field +//! length-prefixed. A tombstone takes the key of the record it withdraws. + +use sha2::{Digest, Sha256}; + +use crate::cbor::Value; + +use super::{malformed, payload::is_content_id, Record, RecordError, RecordType}; + +const STORAGE_KEY_LABEL: &[u8] = b"MACULA-PQ-STORAGE-KEY-V1"; + +/// The storage key of `r`. A record stored under its signer must be signed or +/// verified. +pub fn storage_key(r: &Record) -> Result<[u8; 32], RecordError> { + if r.record_type != RecordType::TOMBSTONE { + return slot_key( + i128::from(r.record_type.0), + &r.payload, + r.subject.as_deref(), + r, + ); + } + let Some(Value::Int(withdrawn)) = r.payload.get("withdrawn_type") else { + return Err(malformed("a tombstone without an integer withdrawn_type")); + }; + let subject = match r.payload.get("subject") { + None => None, + Some(Value::Bytes(s)) => Some(s.as_slice()), + Some(_) => return Err(malformed("a tombstone's subject that is not bytes")), + }; + slot_key(*withdrawn, &r.payload, subject, r) +} + +/// The storage key of a procedure's advertisements. +pub fn procedure_key(realm_id: &[u8; 32], procedure: &str) -> [u8; 32] { + derived(0x06, &[realm_id, &length_prefixed(procedure.as_bytes())]) +} + +/// The storage key every announcement of a content id shares. +pub fn content_key(mcid: &[u8]) -> Result<[u8; 32], RecordError> { + if !is_content_id(Some(&Value::Bytes(mcid.to_vec()))) { + return Err(RecordError::NotAContentId); + } + Ok(derived(0x11, &[&length_prefixed(mcid)])) +} + +/// The storage key of a station's endpoint record. +pub fn station_endpoint_key(node_id: &[u8; 32]) -> [u8; 32] { + derived(0x12, &[node_id]) +} + +/// The storage key of an org directory record. +pub fn org_directory_key(realm_id: &[u8; 32], org_name: &str) -> [u8; 32] { + derived(0x15, &[realm_id, &length_prefixed(org_name.as_bytes())]) +} + +/// The storage key of a procedure delegation. +pub fn procedure_delegation_key(org_key_id: &[u8; 32], advertiser: &[u8; 32]) -> [u8; 32] { + derived(0x16, &[org_key_id, advertiser]) +} + +fn slot_key( + t: i128, + payload: &Value, + subject: Option<&[u8]>, + r: &Record, +) -> Result<[u8; 32], RecordError> { + let id = |name: &str| -> Result, RecordError> { + match payload.get(name) { + Some(Value::Bytes(b)) if b.len() == 32 => Ok(b.clone()), + _ => Err(malformed(format!("a storage key needs a 32-byte {name}"))), + } + }; + let text = |name: &str| -> Result, RecordError> { + match payload.get(name) { + Some(Value::Text(t)) => Ok(length_prefixed(t.as_bytes())), + _ => Err(malformed(format!("a storage key needs {name} as text"))), + } + }; + let signer = || -> Result, RecordError> { + r.signed + .as_ref() + .map(|s| s.key_id.to_vec()) + .ok_or(RecordError::Unsigned) + }; + let key = match t { + 0x01 => { + let signer = signer()?; + let mut key = [0u8; 32]; + key.copy_from_slice(&signer); + key + } + 0x03 | 0x04 => derived(t as u8, &[&id("realm_id")?]), + 0x05 => derived(t as u8, &[&id("realm_id")?, &id("member_node")?]), + 0x06 => derived(t as u8, &[&id("realm_id")?, &text("procedure")?]), + 0x0D | 0x0F => derived(t as u8, &[&signer()?]), + 0x0E => derived(t as u8, &[&signer()?, &text("param_name")?]), + 0x10 => derived(t as u8, &[&id("station_id")?]), + 0x11 => match payload.get("mcid") { + Some(Value::Bytes(m)) if is_content_id(Some(&Value::Bytes(m.clone()))) => { + derived(0x11, &[&length_prefixed(m)]) + } + _ => return Err(RecordError::NotAContentId), + }, + 0x12 => derived(t as u8, &[&signer()?]), + 0x15 => derived(t as u8, &[&id("realm_id")?, &text("org_name")?]), + 0x16 => derived(t as u8, &[&signer()?, &id("advertiser")?]), + 0x20..=0xFF => match subject { + Some(s) => derived(t as u8, &[&signer()?, &length_prefixed(s)]), + None => derived(t as u8, &[&signer()?]), + }, + _ => return Err(malformed(format!("no storage key for type {t:#04x}"))), + }; + Ok(key) +} + +fn derived(t: u8, fields: &[&[u8]]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(STORAGE_KEY_LABEL); + h.update([0, t]); + for field in fields { + h.update(field); + } + h.finalize().into() +} + +fn length_prefixed(b: &[u8]) -> Vec { + let mut out = (b.len() as u32).to_be_bytes().to_vec(); + out.extend_from_slice(b); + out +} diff --git a/src/record/tombstone.rs b/src/record/tombstone.rs new file mode 100644 index 0000000..8aa73fd --- /dev/null +++ b/src/record/tombstone.rs @@ -0,0 +1,147 @@ +//! Tombstones: a signer's withdrawal of one of its records, stored in the +//! withdrawn record's slot and signed with the key that signed it. + +use crate::cbor::Value; + +use super::payload::slot_field_names; +use super::{ + entry, id_field, malformed, text_field, unsigned, Record, RecordError, RecordType, + CLOCK_TOLERANCE_MS, +}; + +/// Why a tombstone withdraws a record. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Reason { + Shutdown, + Moved, + Revoked, +} + +impl Reason { + fn name(self) -> &'static str { + match self { + Reason::Shutdown => "shutdown", + Reason::Moved => "moved", + Reason::Revoked => "revoked", + } + } +} + +/// A tombstone's optional fields: `detail`, left out when empty, and +/// `ttl_ms`, 0 for the clock tolerance, 5 minutes. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TombstoneOptions { + pub detail: String, + pub ttl_ms: u64, +} + +/// An unsigned tombstone that withdraws `withdrawn`: it names the record's +/// type, version and slot fields, takes its slot, and lives until the record +/// has expired plus the clock tolerance, or `ttl_ms` past its own creation +/// when later, so no replica serves the record again after it lapses. +pub fn new_tombstone( + withdrawn: &Record, + reason: Reason, + opts: &TombstoneOptions, +) -> Result { + if withdrawn.record_type == RecordType::TOMBSTONE { + return Err(RecordError::TombstoneOfATombstone); + } + let mut entries = vec![ + entry( + "withdrawn_type", + Value::Int(i128::from(withdrawn.record_type.0)), + ), + entry( + "withdrawn_version", + Value::Bytes(withdrawn.version.to_vec()), + ), + entry("reason", Value::text(reason.name())), + ]; + entries.extend(slot_fields(withdrawn)?); + if !opts.detail.is_empty() { + entries.push(entry("detail", Value::text(opts.detail.clone()))); + } + let ttl_ms = if opts.ttl_ms == 0 { + CLOCK_TOLERANCE_MS + } else { + opts.ttl_ms + }; + let mut r = unsigned(RecordType::TOMBSTONE, Value::Map(entries), ttl_ms); + r.expires_at = (r.created_at + ttl_ms).max(withdrawn.expires_at + CLOCK_TOLERANCE_MS); + Ok(r) +} + +fn slot_fields(withdrawn: &Record) -> Result, RecordError> { + if withdrawn.record_type >= RecordType::DOMAIN_MIN { + return Ok(withdrawn + .subject + .as_ref() + .map(|s| vec![entry("subject", Value::Bytes(s.clone()))]) + .unwrap_or_default()); + } + slot_field_names(i128::from(withdrawn.record_type.0)) + .iter() + .map(|name| { + withdrawn + .payload + .get(name) + .map(|v| entry(name, v.clone())) + .ok_or_else(|| malformed(format!("the withdrawn record has no {name}"))) + }) + .collect() +} + +/// A tombstone's payload: what it withdraws, why, and the withdrawn record's +/// slot fields. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Tombstone { + pub withdrawn_type: RecordType, + pub withdrawn_version: [u8; 16], + pub reason: String, + pub detail: String, + pub realm_id: [u8; 32], + pub member_node: [u8; 32], + pub procedure: String, + pub param_name: String, + pub station_id: [u8; 32], + pub mcid: Vec, + pub org_name: String, + pub advertiser: [u8; 32], + pub subject: Vec, +} + +/// Reads a tombstone's payload. +pub fn read_tombstone(r: &Record) -> Result { + if r.record_type != RecordType::TOMBSTONE { + return Err(malformed("not a tombstone")); + } + let p = &r.payload; + let withdrawn = match p.get("withdrawn_type") { + Some(Value::Int(n)) if (1..=255).contains(n) => RecordType(*n as u8), + _ => { + return Err(malformed( + "a withdrawn_type that is not an integer from 1 to 255", + )) + } + }; + let bytes = |name: &str| match p.get(name) { + Some(Value::Bytes(b)) => b.clone(), + _ => Vec::new(), + }; + Ok(Tombstone { + withdrawn_type: withdrawn, + withdrawn_version: bytes("withdrawn_version").try_into().unwrap_or([0; 16]), + reason: text_field(p, "reason"), + detail: text_field(p, "detail"), + realm_id: id_field(p, "realm_id"), + member_node: id_field(p, "member_node"), + procedure: text_field(p, "procedure"), + param_name: text_field(p, "param_name"), + station_id: id_field(p, "station_id"), + mcid: bytes("mcid"), + org_name: text_field(p, "org_name"), + advertiser: id_field(p, "advertiser"), + subject: bytes("subject"), + }) +} diff --git a/src/signed_object.rs b/src/signed_object.rs new file mode 100644 index 0000000..3af3f94 --- /dev/null +++ b/src/signed_object.rs @@ -0,0 +1,265 @@ +//! Signed objects, as macula_signed_object and macula-go sign and verify them: +//! a record, a request, a reply, a relay error, a publication, or a stream +//! frame. The fields gain `alg`, the signer's profile algorithm, and are +//! encoded as tbs in the deterministic form; the signature covers the label, a +//! zero byte, the SHA-384 of the signer's key as carried, and tbs. An +//! [`Object`] carries its key; a [`HeldObject`] leaves it out for a verifier +//! that already holds it, and still signs its hash. + +use std::fmt; + +use sha2::{Digest, Sha384}; + +use crate::cbor::{self, Value}; +use crate::node_key::{carried_key_well_formed, verify, KeyError, NodeKey}; +use crate::profile::Profile; + +/// The refusals of a signed object, named as macula_signed_object names them, +/// and the ones signing gives. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ObjectError { + /// Not exactly the keys of its shape, each a byte string; a carried key + /// not in the verifier's profile's carried form; or a tbs the decoding + /// rule refuses, or that is not a map naming alg as text. + Malformed, + /// A signature that does not verify over the label, the key's hash and + /// the tbs as received. + SignatureInvalid, + /// An alg that names another profile's algorithm than the verifier's. + AlgMismatch, + /// A field to sign whose key is not text. + FieldKeyNotText, + /// Two fields to sign with one key, named here. + DuplicateField(String), + /// The key could not sign. + Key(KeyError), +} + +impl fmt::Display for ObjectError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ObjectError::Malformed => f.write_str("malformed signed object"), + ObjectError::SignatureInvalid => { + f.write_str("the signed object's signature does not verify") + } + ObjectError::AlgMismatch => { + f.write_str("the signed object names another profile's algorithm") + } + ObjectError::FieldKeyNotText => f.write_str("a field key that is not text"), + ObjectError::DuplicateField(name) => write!(f, "two fields named {name:?}"), + ObjectError::Key(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for ObjectError {} + +/// A signed object that carries its signer's key as carried. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Object { + pub key: Vec, + pub tbs: Vec, + pub signature: Vec, +} + +/// A signed object whose verifier already holds the signer's key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HeldObject { + pub tbs: Vec, + pub signature: Vec, +} + +/// A signed object that verified: the key it verified with, its tbs bytes as +/// received, and the map they decode to. +#[derive(Debug, Clone, PartialEq)] +pub struct VerifiedObject { + pub key: Vec, + pub tbs: Vec, + pub fields: Value, +} + +impl Object { + /// The object as the map `{key, tbs, signature}`. + pub fn to_value(&self) -> Value { + Value::Map(vec![ + (Value::text("key"), Value::Bytes(self.key.clone())), + (Value::text("tbs"), Value::Bytes(self.tbs.clone())), + ( + Value::text("signature"), + Value::Bytes(self.signature.clone()), + ), + ]) + } + + /// The object in `value`, a map of exactly `key`, `tbs` and `signature`, + /// each a byte string. + pub fn from_value(value: &Value) -> Result { + let [key, tbs, signature] = exact_byte_fields(value, ["key", "tbs", "signature"])?; + Ok(Object { + key, + tbs, + signature, + }) + } +} + +impl HeldObject { + /// The object as the map `{tbs, signature}`. + pub fn to_value(&self) -> Value { + Value::Map(vec![ + (Value::text("tbs"), Value::Bytes(self.tbs.clone())), + ( + Value::text("signature"), + Value::Bytes(self.signature.clone()), + ), + ]) + } + + /// The object in `value`, a map of exactly `tbs` and `signature`, each a + /// byte string. + pub fn from_value(value: &Value) -> Result { + let [tbs, signature] = exact_byte_fields(value, ["tbs", "signature"])?; + Ok(HeldObject { tbs, signature }) + } +} + +/// Signs `fields` under `label` with `key`. The fields gain alg, replacing any +/// alg they held; every field needs a text key of its own. +pub fn sign_object( + label: &str, + fields: &[(Value, Value)], + key: &NodeKey, +) -> Result { + let carried = key.public_key(); + let tbs = object_tbs(fields, key.profile())?; + let signature = key + .sign(&object_signed_bytes(label, &carried, &tbs)) + .map_err(ObjectError::Key)?; + Ok(Object { + key: carried, + tbs, + signature, + }) +} + +/// [`sign_object`] for a verifier that already holds `key`. +pub fn sign_held_object( + label: &str, + fields: &[(Value, Value)], + key: &NodeKey, +) -> Result { + let object = sign_object(label, fields, key)?; + Ok(HeldObject { + tbs: object.tbs, + signature: object.signature, + }) +} + +/// Verifies an object that carries its key, under `label` and the verifier's +/// `profile`, in macula's order: exactly key, tbs and signature, each a byte +/// string; key in the profile's carried form; the signature over tbs as +/// received; only then tbs under the decoding rule, a map whose alg names the +/// profile's algorithm. alg is checked and never selects an algorithm. +pub fn verify_object( + label: &str, + value: &Value, + profile: Profile, +) -> Result { + let object = Object::from_value(value)?; + if !carried_key_well_formed(&object.key, profile) { + return Err(ObjectError::Malformed); + } + verified(label, object.key, object.tbs, &object.signature, profile) +} + +/// Verifies an object whose key the verifier holds, as carried, under `label` +/// and the verifier's `profile`. +pub fn verify_held_object( + label: &str, + value: &Value, + key: &[u8], + profile: Profile, +) -> Result { + let held = HeldObject::from_value(value)?; + verified(label, key.to_vec(), held.tbs, &held.signature, profile) +} + +fn verified( + label: &str, + key: Vec, + tbs: Vec, + signature: &[u8], + profile: Profile, +) -> Result { + if !verify( + &object_signed_bytes(label, &key, &tbs), + signature, + &key, + profile, + ) { + return Err(ObjectError::SignatureInvalid); + } + let fields = cbor::decode(&tbs).map_err(|_| ObjectError::Malformed)?; + let alg = match (&fields, fields.get("alg")) { + (Value::Map(_), Some(Value::Text(alg))) => alg.clone(), + _ => return Err(ObjectError::Malformed), + }; + if alg != profile.sig_alg() { + return Err(ObjectError::AlgMismatch); + } + Ok(VerifiedObject { key, tbs, fields }) +} + +/// The fields with alg for `profile`, deterministically encoded. +fn object_tbs(fields: &[(Value, Value)], profile: Profile) -> Result, ObjectError> { + let mut seen = std::collections::HashSet::with_capacity(fields.len()); + let mut with_alg = Vec::with_capacity(fields.len() + 1); + for (key, value) in fields { + let Value::Text(name) = key else { + return Err(ObjectError::FieldKeyNotText); + }; + if seen.contains(name.as_str()) { + return Err(ObjectError::DuplicateField(name.clone())); + } + if name == "alg" { + continue; + } + seen.insert(name.as_str()); + with_alg.push((key.clone(), value.clone())); + } + with_alg.push((Value::text("alg"), Value::text(profile.sig_alg()))); + cbor::encode(&Value::Map(with_alg)).map_err(|_| ObjectError::Malformed) +} + +/// What a signed object's signature covers: label, a zero byte, the SHA-384 +/// of the key as carried, and tbs. +fn object_signed_bytes(label: &str, key: &[u8], tbs: &[u8]) -> Vec { + let mut out = Vec::with_capacity(label.len() + 1 + 48 + tbs.len()); + out.extend_from_slice(label.as_bytes()); + out.push(0); + out.extend_from_slice(&Sha384::digest(key)); + out.extend_from_slice(tbs); + out +} + +/// The byte strings under `names` in `value`, when it is a map of exactly +/// those text keys, each holding a byte string. +fn exact_byte_fields( + value: &Value, + names: [&str; N], +) -> Result<[Vec; N], ObjectError> { + let Value::Map(pairs) = value else { + return Err(ObjectError::Malformed); + }; + if pairs.len() != N { + return Err(ObjectError::Malformed); + } + let mut out: [Vec; N] = std::array::from_fn(|_| Vec::new()); + for (slot, name) in out.iter_mut().zip(names) { + match value.get(name) { + Some(Value::Bytes(b)) => *slot = b.clone(), + _ => return Err(ObjectError::Malformed), + } + } + Ok(out) +} diff --git a/src/statement_issuer.rs b/src/statement_issuer.rs new file mode 100644 index 0000000..0ba1545 --- /dev/null +++ b/src/statement_issuer.rs @@ -0,0 +1,440 @@ +//! A client's status statement issuer, the client side of macula's +//! macula_statement_issuer (D22), as macula-go's StatementIssuer. It holds the +//! identity key, the node's CONNECT bindings with the newest status statement +//! for each, and the current CONNECT key. Each tick issues a statement valid +//! for an hour for each binding whose not_after has not passed, and hands it +//! to that binding's subscribers, the links that connected with it. Every 5 +//! days it rotates the CONNECT key: the new key's binding and statement exist +//! before [`StatementIssuer::connect_material`] hands the key out, and the +//! rotated-out binding keeps its statements until its not_after. +//! `connect_material` does work that is due itself, so a dial after missed +//! ticks, a sleep or a clock step still carries material in force. Nothing is +//! written to disk, so a new issuer starts with a new CONNECT key. + +use std::collections::HashMap; +use std::fmt; +use std::sync::{Arc, Mutex, Weak}; + +use sha2::{Digest, Sha384}; +use tokio::sync::Notify; + +use crate::binding::{connect_binding, status_statement, BindingError, SignedTbs}; +use crate::node_key::{KeyError, NodeKey, Purpose}; + +/// How often statements are reissued. +pub const STATEMENT_EVERY_MS: i64 = 15 * 60 * 1000; +/// How long a status statement is valid. +pub const STATEMENT_VALID_MS: i64 = 60 * 60 * 1000; +/// How long a CONNECT binding is valid. +pub const CONNECT_BINDING_VALID_MS: i64 = 7 * 24 * 60 * 60 * 1000; +/// How often the CONNECT key rotates. +pub const CONNECT_ROTATE_EVERY_MS: i64 = 5 * 24 * 60 * 60 * 1000; +/// How long before the current binding's not_after a failed rotation is +/// reported as overdue. +pub const ROTATION_MARGIN_MS: i64 = 24 * 60 * 60 * 1000; + +const TOLERANCE_MS: i64 = 5 * 60 * 1000; + +/// Why the issuer could not do what was asked. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IssuerError { + /// The key given is not an identity key. + NotAnIdentityKey, + /// A subscription to a binding the issuer does not hold, or whose + /// not_after has passed. + UnknownBinding, + /// No CONNECT binding and status statement in force, because the work + /// that renews them failed. + NoConnectMaterial(String), + /// A rotation failed while the current binding expires within the + /// rotation margin. + RotationOverdue { failures: u64, left_ms: i64 }, + /// A key could not be made or could not sign. + Key(KeyError), + /// A binding or statement could not be issued. + Binding(BindingError), +} + +impl fmt::Display for IssuerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + IssuerError::NotAnIdentityKey => f.write_str("the issuer needs an identity key"), + IssuerError::UnknownBinding => f.write_str("no binding in force has that hash"), + IssuerError::NoConnectMaterial(why) => write!(f, "no CONNECT binding and status statement in force: {why}"), + IssuerError::RotationOverdue { failures, left_ms } => write!( + f, + "the CONNECT key has not rotated: {failures} failed rotations, {left_ms} ms left on its binding" + ), + IssuerError::Key(e) => write!(f, "{e}"), + IssuerError::Binding(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for IssuerError {} + +impl From for IssuerError { + fn from(e: KeyError) -> Self { + IssuerError::Key(e) + } +} + +impl From for IssuerError { + fn from(e: BindingError) -> Self { + IssuerError::Binding(e) + } +} + +/// What a new dial carries: the CONNECT key, its binding, and a status +/// statement for that binding. +#[derive(Debug, Clone)] +pub struct ConnectMaterial { + pub key: Arc, + pub binding: SignedTbs, + pub status: SignedTbs, +} + +/// A clock in Unix milliseconds. +pub type Clock = Box i64 + Send + Sync>; + +/// A binding the issuer holds, with its newest statement, and its key while +/// it is the current binding. +struct StatedBinding { + key: Option>, + binding: SignedTbs, + statement: SignedTbs, + bound_at: i64, + stated_at: i64, + not_after: i64, +} + +impl StatedBinding { + fn rotation_due(&self, now: i64) -> bool { + now < self.bound_at - TOLERANCE_MS || now >= self.bound_at + CONNECT_ROTATE_EVERY_MS + } + + fn restatement_due(&self, now: i64) -> bool { + now < self.stated_at - TOLERANCE_MS || now >= self.stated_at + STATEMENT_EVERY_MS + } + + fn in_force(&self, now: i64) -> bool { + self.bound_at - TOLERANCE_MS <= now + && now <= self.not_after + && self.stated_at - TOLERANCE_MS <= now + && now < self.stated_at + STATEMENT_VALID_MS + } +} + +/// A subscription's slot: at most the newest statement, whether it closed, +/// and the waker of a reader waiting for one. +struct Slot { + newest: Mutex<(Option, bool)>, + notify: Notify, +} + +struct State { + identity: Arc, + clock: Clock, + current: [u8; 48], + bindings: HashMap<[u8; 48], StatedBinding>, + subscribers: HashMap<[u8; 48], Vec>>, + rotation_failures: u64, +} + +/// A client's statement issuer, shared by every link of one node. +#[derive(Clone)] +pub struct StatementIssuer { + state: Arc>, +} + +impl StatementIssuer { + /// An issuer for `identity`, reading the time from `clock`. It starts with + /// a new CONNECT key, bound and stated. + pub fn new(identity: Arc, clock: Clock) -> Result { + if identity.purpose() != Purpose::Identity { + return Err(IssuerError::NotAnIdentityKey); + } + let now = clock(); + let mut state = State { + identity, + clock, + current: [0; 48], + bindings: HashMap::new(), + subscribers: HashMap::new(), + rotation_failures: 0, + }; + state.rotate_connect(now)?; + Ok(StatementIssuer { + state: Arc::new(Mutex::new(state)), + }) + } + + /// An issuer on the wall clock. + pub fn with_wall_clock(identity: Arc) -> Result { + StatementIssuer::new(identity, Box::new(|| crate::uuid_v7::now_ms() as i64)) + } + + /// The current CONNECT key with its binding and a statement for it, both + /// in force at the clock's time. Work that is due is done first. + pub fn connect_material(&self) -> Result { + let mut state = self.lock(); + let now = (state.clock)(); + let mut work = Ok(()); + let due = state + .bindings + .get(&state.current) + .is_some_and(|b| b.rotation_due(now) || b.restatement_due(now)); + if due { + work = state.tick(now); + } + let current = &state.bindings[&state.current]; + match ¤t.key { + Some(key) if current.in_force(now) => Ok(ConnectMaterial { + key: key.clone(), + binding: current.binding.clone(), + status: current.statement.clone(), + }), + _ => Err(IssuerError::NoConnectMaterial(match work { + Err(e) => e.to_string(), + Ok(()) => "the current binding is out of force".into(), + })), + } + } + + /// How many rotations have failed since the last that succeeded. + pub fn rotation_failures(&self) -> u64 { + self.lock().rotation_failures + } + + /// Hands over the newest statement for `binding` at every reissue. The + /// subscription closes once a tick finds the binding's not_after passed, + /// and unsubscribes when dropped. + pub fn subscribe(&self, binding: &SignedTbs) -> Result { + let hash: [u8; 48] = Sha384::digest(&binding.tbs).into(); + let mut state = self.lock(); + let now = (state.clock)(); + match state.bindings.get(&hash) { + Some(held) if held.not_after >= now => {} + _ => return Err(IssuerError::UnknownBinding), + } + let slot = Arc::new(Slot { + newest: Mutex::new((None, false)), + notify: Notify::new(), + }); + state + .subscribers + .entry(hash) + .or_default() + .push(slot.clone()); + Ok(StatementSubscription { + slot, + hash, + issuer: Arc::downgrade(&self.state), + }) + } + + /// The periodic work at the clock's time: a statement for each binding in + /// force, a rotation when one is due, and the expired bindings let go. + pub fn tick(&self) -> Result<(), IssuerError> { + let mut state = self.lock(); + let now = (state.clock)(); + state.tick(now) + } + + /// Ticks every 15 minutes until every handle to the issuer is dropped, + /// handing a failed tick's error to `on_error`. + pub fn spawn_ticks( + &self, + on_error: impl Fn(IssuerError) + Send + 'static, + ) -> tokio::task::JoinHandle<()> { + let weak = Arc::downgrade(&self.state); + tokio::spawn(async move { + let mut ticks = + tokio::time::interval(std::time::Duration::from_millis(STATEMENT_EVERY_MS as u64)); + ticks.tick().await; + loop { + ticks.tick().await; + let Some(state) = weak.upgrade() else { return }; + let issuer = StatementIssuer { state }; + if let Err(e) = issuer.tick() { + on_error(e); + } + } + }) + } + + fn lock(&self) -> std::sync::MutexGuard<'_, State> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +impl State { + fn tick(&mut self, now: i64) -> Result<(), IssuerError> { + let reissued = self.reissue(now); + let mut rotated = Ok(()); + if self + .bindings + .get(&self.current) + .is_some_and(|b| b.rotation_due(now)) + { + rotated = self.rotate(now); + } + self.drop_expired(now); + reissued.and(rotated) + } + + fn rotate(&mut self, now: i64) -> Result<(), IssuerError> { + match self.rotate_connect(now) { + Ok(()) => { + self.rotation_failures = 0; + Ok(()) + } + Err(e) => { + self.rotation_failures += 1; + let left = self + .bindings + .get(&self.current) + .map_or(0, |b| b.not_after - now); + if left < ROTATION_MARGIN_MS { + return Err(IssuerError::RotationOverdue { + failures: self.rotation_failures, + left_ms: left, + }); + } + Err(e) + } + } + } + + fn reissue(&mut self, now: i64) -> Result<(), IssuerError> { + let mut first_error = Ok(()); + for (hash, held) in self.bindings.iter_mut() { + if held.not_after < now { + continue; + } + match status_statement(&self.identity, &held.binding, now, now + STATEMENT_VALID_MS) { + Ok(statement) => { + held.statement = statement.clone(); + held.stated_at = now; + for slot in self.subscribers.get(hash).into_iter().flatten() { + deliver(slot, statement.clone()); + } + } + Err(e) => { + if first_error.is_ok() { + first_error = Err(e.into()); + } + } + } + } + first_error + } + + fn drop_expired(&mut self, now: i64) { + let expired: Vec<[u8; 48]> = self + .bindings + .iter() + .filter(|(_, b)| b.not_after < now) + .map(|(h, _)| *h) + .collect(); + for hash in expired { + for slot in self.subscribers.remove(&hash).into_iter().flatten() { + close(&slot); + } + if hash != self.current { + self.bindings.remove(&hash); + } + } + } + + fn rotate_connect(&mut self, now: i64) -> Result<(), IssuerError> { + let key = NodeKey::generate(Purpose::Connect, self.identity.profile())?; + let not_after = now + CONNECT_BINDING_VALID_MS; + let binding = connect_binding(&self.identity, &key.public_key(), now, not_after)?; + let statement = status_statement(&self.identity, &binding, now, now + STATEMENT_VALID_MS)?; + if let Some(previous) = self.bindings.get_mut(&self.current) { + previous.key = None; + } + let hash: [u8; 48] = Sha384::digest(&binding.tbs).into(); + self.bindings.insert( + hash, + StatedBinding { + key: Some(Arc::new(key)), + binding, + statement, + bound_at: now, + stated_at: now, + not_after, + }, + ); + self.current = hash; + Ok(()) + } +} + +fn deliver(slot: &Slot, statement: SignedTbs) { + let mut newest = slot.newest.lock().unwrap_or_else(|p| p.into_inner()); + newest.0 = Some(statement); + drop(newest); + slot.notify.notify_one(); +} + +fn close(slot: &Slot) { + slot.newest.lock().unwrap_or_else(|p| p.into_inner()).1 = true; + slot.notify.notify_one(); +} + +/// Why a subscription handed over no statement. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SubscriptionEmpty { + /// None has been issued since the last taken. + Empty, + /// The subscription has closed. + Closed, +} + +/// A subscription to one binding's statements, holding at most the newest. +pub struct StatementSubscription { + slot: Arc, + hash: [u8; 48], + issuer: Weak>, +} + +impl StatementSubscription { + /// The newest statement not yet taken. + pub fn try_recv(&mut self) -> Result { + let mut newest = self.slot.newest.lock().unwrap_or_else(|p| p.into_inner()); + match newest.0.take() { + Some(statement) => Ok(statement), + None if newest.1 => Err(SubscriptionEmpty::Closed), + None => Err(SubscriptionEmpty::Empty), + } + } + + /// The next statement, or `None` once the subscription has closed. + pub async fn recv(&mut self) -> Option { + let slot = self.slot.clone(); + loop { + let notified = slot.notify.notified(); + match self.try_recv() { + Ok(statement) => return Some(statement), + Err(SubscriptionEmpty::Closed) => return None, + Err(SubscriptionEmpty::Empty) => notified.await, + } + } + } +} + +impl Drop for StatementSubscription { + fn drop(&mut self) { + let Some(state) = self.issuer.upgrade() else { + return; + }; + let mut state = state.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(slots) = state.subscribers.get_mut(&self.hash) { + slots.retain(|s| !Arc::ptr_eq(s, &self.slot)); + } + } +} diff --git a/src/station_link.rs b/src/station_link.rs new file mode 100644 index 0000000..5505a20 --- /dev/null +++ b/src/station_link.rs @@ -0,0 +1,642 @@ +//! A client's link to one macula 12 station, as macula_station_link and +//! macula-go's stationlink are: a QUIC connection dialed to the station its +//! target pins, one bidirectional control stream, the v4 handshake on it, then +//! status statements both ways and every frame of the session. +//! +//! After HELLO the link sends its own status statement at every reissue of +//! the node's statement issuer, and ends when the station's statement is five +//! minutes past its expiry or the station's TLS binding reaches its +//! not_after. In pq_hybrid every control frame is neighbour-signed with a +//! sequence number per direction, from 0 after HELLO; a frame out of sequence +//! ends the link. A liveness probe, a `_macula.ping` call every 30 seconds, +//! ends the link after two misses in a row. + +mod admission; +mod call; +mod dht; +mod framing; +mod pubsub; +mod serve; +mod stream; + +pub use admission::{Admission, AdmissionLimits}; +pub use call::{Call, DEFAULT_CALL_TIMEOUT, MAX_CALL_TIMEOUT}; +pub use pubsub::{Event, EventDedup, Publication, PublicationSeq, SignedPublication, Subscription}; +pub use serve::{handler, BoxFuture, Handler, Offer, Request, Served, StreamOffer}; +pub use stream::{ + stream_handler, Stream, StreamCall, StreamEvent, StreamHandler, DEFAULT_STREAM_DEADLINE, +}; + +use std::collections::HashMap; +use std::fmt; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Duration; + +use sha2::{Digest, Sha384}; +use tokio::sync::watch; + +use crate::cbor::{self, Value}; +use crate::frame::{self, FrameError, NeighbourLink, NeighbourPeer}; +use crate::handshake::{self, ClientSession, HandshakeError, Peer, Station}; +use crate::node_key::NodeKey; +use crate::profile::Profile; +use crate::record::RecordError; +use crate::statement_issuer::{IssuerError, StatementIssuer, StatementSubscription}; +use crate::transport::{self, DialError, Target}; + +use framing::{read_frame, FrameWriter, HANDSHAKE_FRAME_BYTES, MAX_FRAME_BYTES}; + +/// How long the handshake may take, as macula's 30 seconds. +pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); + +/// How long Close waits, after its GOODBYE, for the station to close the +/// connection before closing it itself. +const CLOSE_LINGER: Duration = Duration::from_secs(1); + +/// How long past a statement's expiry the link keeps a station whose next +/// statement has not arrived: macula's five minutes. +const STATUS_GRACE_MS: i64 = 5 * 60 * 1000; + +/// Why a link or one of its operations failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LinkError { + /// The configuration cannot be dialed: a key of another profile than the + /// target's, or admission limits macula would not start with. + InvalidConfig(String), + /// The dial failed. + Dial(String), + /// The handshake refused, or was refused. + Handshake(HandshakeError), + /// The handshake did not finish within [`HANDSHAKE_TIMEOUT`]. + HandshakeTimeout, + /// The issuer had no CONNECT material in force. + Issuer(IssuerError), + /// The QUIC connection or a stream failed. + Io(String), + /// A frame longer than the cap in force. + FrameTooLarge(usize), + /// A frame that could not be built, or one received that does not + /// verify. + Frame(FrameError), + /// A record that could not be built, or one found that does not verify. + Record(RecordError), + /// The station's status statement lapsed past the grace. + StatusExpired, + /// The station's TLS binding reached its not_after. + BindingExpired, + /// The link was closed by its owner. + Closed, + /// The station ended the link with a GOODBYE, for this reason. + Goodbye(String), + /// The station missed two liveness probes in a row. + LivenessLost, + /// No verified reply answered the call within its timeout. + CallTimeout, + /// A provider's ERROR for the call. + Provider { + responded_by: [u8; 32], + code: String, + detail: Option, + }, + /// A relay error the connected station reported for the call. + Relay { reported_by: [u8; 32], code: String }, + /// A find_record the station answered not_found. + RecordNotFound, + /// A station reply to a DHT call in a shape that call never answers with. + UnexpectedReply(String), + /// An offer without exactly one handler, or an org procedure's without + /// the realm key. + InvalidOffer, + /// A procedure without a namespace, which nobody can authorize. + NoOrg, + /// A procedure already served on this link. + AlreadyServed, + /// A served procedure withdrawn by its owner. + Stopped, + /// A stream ended by a STREAM_ERROR: the peer's, the station's relay + /// error (`relay`), or this side's own abort. + Stream { + code: String, + message: String, + relay: bool, + }, + /// A stream that ended normally. + EndOfStream, + /// A send on a stream this side has ended. + StreamClosed, + /// A STREAM_OPEN over the 1 MiB a peer reads of one. + StreamOpenTooLarge(usize), +} + +impl fmt::Display for LinkError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LinkError::Provider { + code, + detail: Some(d), + .. + } => write!(f, "the provider answered {code}: {d}"), + LinkError::Provider { code, .. } => write!(f, "the provider answered {code}"), + LinkError::Relay { code, .. } => { + write!(f, "the station could not relay the call: {code}") + } + LinkError::Stream { code, message, .. } if !message.is_empty() => { + write!(f, "stream error {code}: {message}") + } + LinkError::Stream { code, .. } => write!(f, "stream error {code}"), + LinkError::Handshake(e) => write!(f, "handshake: {e}"), + LinkError::Frame(e) => write!(f, "frame: {e}"), + LinkError::Record(e) => write!(f, "record: {e}"), + LinkError::Issuer(e) => write!(f, "{e}"), + LinkError::Goodbye(reason) => write!(f, "the station said goodbye: {reason}"), + other => write!(f, "{other:?}"), + } + } +} + +impl std::error::Error for LinkError {} + +impl From for LinkError { + fn from(e: FrameError) -> Self { + LinkError::Frame(e) + } +} + +impl From for LinkError { + fn from(e: RecordError) -> Self { + LinkError::Record(e) + } +} + +impl From for LinkError { + fn from(e: HandshakeError) -> Self { + LinkError::Handshake(e) + } +} + +impl From for LinkError { + fn from(e: DialError) -> Self { + LinkError::Dial(e.to_string()) + } +} + +/// What a link is dialed with: the station to reach, the node's identity +/// key, the statement issuer that holds its CONNECT key and statements, and +/// the realm membership endorsement to present, empty for none. Links of one +/// node share their publication seq, admission and dedup; a link given none +/// makes its own. +pub struct Config { + pub target: Target, + pub identity: Arc, + pub issuer: StatementIssuer, + pub member_endorsement: Vec, + pub publication_seq: Option>, + pub admission: Option>, + pub dedup: Option>, + /// This link's place in the admission: the station it dialed, host:port, + /// when `None`. + pub share: Option, +} + +impl Config { + /// A link's configuration with none of the shared parts. + pub fn new(target: Target, identity: Arc, issuer: StatementIssuer) -> Config { + Config { + target, + identity, + issuer, + member_endorsement: Vec::new(), + publication_seq: None, + admission: None, + dedup: None, + share: None, + } + } +} + +/// A handshaked link to one station. Cloning it shares the link. +#[derive(Clone)] +pub struct Link { + inner: Arc, +} + +struct Inner { + /// Distinct for every link a process dials. + serial: u64, + connection: quinn::Connection, + _endpoint: quinn::Endpoint, + control: FrameWriter, + profile: Profile, + key: Arc, + self_id: [u8; 32], + station: Station, + station_capabilities: u64, + connection_hash: [u8; 48], + /// Orders a neighbour signature's seq with its write. + send_seq: tokio::sync::Mutex, + status_deadline: AtomicI64, + publication_seq: Arc, + admission: Arc, + dedup: Arc, + share: String, + state: Mutex, + done_tx: watch::Sender, + done_rx: watch::Receiver, +} + +struct State { + ended: Option, + /// The owner is closing the link: however it then ends, it ends + /// [`LinkError::Closed`]. + closing: bool, + unrouted: HashMap, + pending: HashMap<[u8; 16], call::Pending>, + subs: HashMap<([u8; 32], String), Vec>, + served: HashMap<([u8; 32], String), serve::ServedEntry>, + streams: Vec>, +} + +impl Link { + /// Dials `cfg.target`, runs the v4 handshake as a client, and returns the + /// link once the station's HELLO accepts it, within + /// [`HANDSHAKE_TIMEOUT`]. + pub async fn dial(cfg: Config) -> Result { + if cfg.identity.profile() != cfg.target.profile { + return Err(LinkError::InvalidConfig( + "the identity key is of another profile than the target's".into(), + )); + } + if let Some(admission) = &cfg.admission { + admission.limits().validate()?; + } + tokio::time::timeout(HANDSHAKE_TIMEOUT, handshaken(cfg)) + .await + .map_err(|_| LinkError::HandshakeTimeout) + .and_then(|linked| linked) + } + + /// The node_id of the station the link reached. + pub fn station_node_id(&self) -> [u8; 32] { + self.inner.station.node_id + } + + /// A number distinct for every link this process dials. + pub fn serial(&self) -> u64 { + self.inner.serial + } + + /// The node_id this link connected as. + pub fn node_id(&self) -> [u8; 32] { + self.inner.self_id + } + + /// The capability bits the station's HELLO announced. + pub fn station_capabilities(&self) -> u64 { + self.inner.station_capabilities + } + + /// The profile the link runs. + pub fn profile(&self) -> Profile { + self.inner.profile + } + + /// Why the link ended, or `None` while it runs. + pub fn error(&self) -> Option { + self.inner.lock().ended.clone() + } + + /// Waits until the link has ended, and says why. + pub async fn done(&self) -> LinkError { + let mut done = self.inner.done_rx.clone(); + let _ = done.wait_for(|ended| *ended).await; + self.error().unwrap_or(LinkError::Closed) + } + + /// The frames received that nothing on this link handles, by what they + /// were. + pub fn unrouted(&self) -> HashMap { + self.inner.lock().unrouted.clone() + } + + /// Sends a GOODBYE with `reason`, closes the control stream's sending + /// side, waits up to a second for the station to close the connection, + /// and ends the link. Closing an ended link does nothing. + pub async fn close(&self, reason: &str) -> Result<(), LinkError> { + { + let mut state = self.inner.lock(); + if state.ended.is_some() { + return Ok(()); + } + state.closing = true; + } + let goodbye = frame::goodbye_frame(reason, None)?; + let sent = self.inner.send_control(&goodbye).await; + if sent.is_ok() { + self.inner.control.finish().await; + let _ = tokio::time::timeout(CLOSE_LINGER, self.inner.connection.closed()).await; + } + self.inner.end(LinkError::Closed); + sent + } +} + +async fn handshaken(cfg: Config) -> Result { + let dialed = transport::dial_target(&cfg.target).await?; + let (send, mut recv) = dialed + .connection + .open_bi() + .await + .map_err(|e| LinkError::Io(format!("open the control stream: {e}")))?; + let control = FrameWriter::new(send); + control + .write(&handshake::opener(), HANDSHAKE_FRAME_BYTES) + .await?; + let challenge = read_frame(&mut recv, HANDSHAKE_FRAME_BYTES).await?; + let material = cfg.issuer.connect_material().map_err(LinkError::Issuer)?; + let (connect, station) = handshake::answer_challenge( + &challenge, + &ClientSession { + profile: cfg.target.profile, + expected_node_id: cfg.target.expected_node_id, + leaf: &dialed.leaf, + identity_key: cfg.identity.public_key(), + connect_key: &material.key, + connect_binding: &material.binding, + connect_status: &material.status, + capabilities: 0, + now_ms: now_ms(), + member_endorsement: cfg.member_endorsement.clone(), + }, + )?; + control.write(&connect, HANDSHAKE_FRAME_BYTES).await?; + let hello = read_frame(&mut recv, HANDSHAKE_FRAME_BYTES).await?; + let capabilities = handshake::read_hello(&hello)?; + let self_id = cfg + .identity + .node_id() + .map_err(|e| LinkError::InvalidConfig(e.to_string()))?; + let statements = cfg + .issuer + .subscribe(&material.binding) + .map_err(LinkError::Issuer)?; + let (done_tx, done_rx) = watch::channel(false); + let share = cfg + .share + .unwrap_or_else(|| format!("{}:{}", cfg.target.host, cfg.target.port)); + static SERIALS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let inner = Arc::new(Inner { + serial: SERIALS.fetch_add(1, Ordering::Relaxed), + connection: dialed.connection, + _endpoint: dialed.endpoint, + control, + profile: cfg.target.profile, + key: cfg.identity, + self_id, + status_deadline: AtomicI64::new(station.status_expires_at + STATUS_GRACE_MS), + station_capabilities: capabilities, + connection_hash: Sha384::digest(&challenge).into(), + station, + send_seq: tokio::sync::Mutex::new(0), + publication_seq: cfg.publication_seq.unwrap_or_default(), + admission: cfg + .admission + .unwrap_or_else(|| Arc::new(Admission::new(AdmissionLimits::default()))), + dedup: cfg.dedup.unwrap_or_default(), + share, + state: Mutex::new(State { + ended: None, + closing: false, + unrouted: HashMap::new(), + pending: HashMap::new(), + subs: HashMap::new(), + served: HashMap::new(), + streams: Vec::new(), + }), + done_tx, + done_rx, + }); + tokio::spawn(send_statements(Arc::downgrade(&inner), statements)); + tokio::spawn(read_control(inner.clone(), recv)); + tokio::spawn(stream::accept_streams(Arc::downgrade(&inner))); + tokio::spawn(call::probe(Arc::downgrade(&inner))); + tokio::spawn(watch_expiries(Arc::downgrade(&inner))); + tokio::spawn(watch_connection(Arc::downgrade(&inner))); + Ok(Link { inner }) +} + +impl Inner { + fn lock(&self) -> MutexGuard<'_, State> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn count(&self, what: &str) { + *self.lock().unrouted.entry(what.to_string()).or_default() += 1; + } + + /// A version-2 frame on the control stream, neighbour-signed with the + /// next seq when the profile signs its type. + async fn send_control(&self, v: &Value) -> Result<(), LinkError> { + let mut seq = self.send_seq.lock().await; + let signed = frame::sign_neighbour( + v, + &self.key, + &NeighbourLink { + connection: self.connection_hash, + seq: *seq, + }, + )?; + if frame::neighbour_signed(self.profile, &frame_type_of(v)) { + *seq += 1; + } + self.write_control(&signed).await + } + + /// A frame on the control stream as it is: a data frame, which carries + /// its own end-to-end signature and no neighbour signature. + async fn write_control(&self, v: &Value) -> Result<(), LinkError> { + let encoded = + cbor::encode(v).map_err(|e| LinkError::Frame(FrameError::Payload(e.to_string())))?; + self.control.write(&encoded, MAX_FRAME_BYTES).await + } + + /// Ends the link once, with `err`: pending calls, subscriptions, served + /// procedures and streams end with it, and the connection closes. + fn end(&self, err: LinkError) { + let (err, pending, subs, served, streams) = { + let mut state = self.lock(); + if state.ended.is_some() { + return; + } + let err = if state.closing { + LinkError::Closed + } else { + err + }; + state.ended = Some(err.clone()); + ( + err, + std::mem::take(&mut state.pending), + std::mem::take(&mut state.subs), + std::mem::take(&mut state.served), + std::mem::take(&mut state.streams), + ) + }; + for (_, p) in pending { + let _ = p.outcome.send(Err(err.clone())); + } + drop(subs); + for s in served.into_values() { + s.end(err.clone()); + } + for s in streams.into_iter().filter_map(|w| w.upgrade()) { + stream::StreamInner::end(&s, Some(err.clone())); + } + self.connection.close(0u32.into(), b"link ended"); + let _ = self.done_tx.send_replace(true); + } +} + +/// Sends each statement the issuer reissues as a STATUS, until the link +/// ends. A STATUS carries no neighbour signature and takes no seq. +async fn send_statements(link: std::sync::Weak, mut statements: StatementSubscription) { + loop { + let Some(done) = link.upgrade().map(|l| l.done_rx.clone()) else { + return; + }; + let mut done = done; + let statement = tokio::select! { + _ = done.wait_for(|ended| *ended) => return, + statement = statements.recv() => statement, + }; + let (Some(statement), Some(inner)) = (statement, link.upgrade()) else { + return; + }; + if let Err(e) = inner + .control + .write(&handshake::status_frame(&statement), MAX_FRAME_BYTES) + .await + { + inner.end(e); + return; + } + } +} + +/// Reads the station's frames until the link ends. +async fn read_control(inner: Arc, mut recv: quinn::RecvStream) { + let mut recv_seq = 0u64; + let mut done = inner.done_rx.clone(); + loop { + let payload = tokio::select! { + _ = done.wait_for(|ended| *ended) => return, + payload = read_frame(&mut recv, MAX_FRAME_BYTES) => payload, + }; + let outcome = match payload { + Ok(payload) => received(&inner, &payload, &mut recv_seq), + Err(e) => Err(e), + }; + if let Err(e) = outcome { + inner.end(e); + return; + } + } +} + +/// One frame from the station: a STATUS renews the station's statement, +/// every other frame is opened from its neighbour signature at the next seq, +/// and a GOODBYE ends the link. +fn received(inner: &Arc, payload: &[u8], recv_seq: &mut u64) -> Result<(), LinkError> { + let v = cbor::decode(payload).map_err(|_| LinkError::Frame(FrameError::Malformed))?; + let frame_type = frame_type_of(&v); + if frame_type == "status" { + let expires_at = handshake::read_status( + payload, + &Peer { + profile: inner.profile, + identity_key: inner.station.identity_key.clone(), + binding: inner.station.tls_binding.clone(), + now_ms: now_ms(), + }, + )?; + inner + .status_deadline + .store(expires_at + STATUS_GRACE_MS, Ordering::SeqCst); + return Ok(()); + } + let opened = frame::verify_neighbour( + &v, + &NeighbourPeer { + profile: inner.profile, + peer_key: inner.station.identity_key.clone(), + connection: inner.connection_hash, + seq: *recv_seq, + }, + )?; + if frame::neighbour_signed(inner.profile, &frame_type) { + *recv_seq += 1; + } + match frame_type.as_str() { + "event" => pubsub::evented(inner, &opened), + "result" | "error" => call::replied(inner, &opened), + "call" => serve::called(inner, &opened), + "goodbye" => { + let reason = match opened.get("reason") { + Some(Value::Text(r)) => r.clone(), + _ => String::new(), + }; + return Err(LinkError::Goodbye(reason)); + } + _ => inner.count(&frame_type), + } + Ok(()) +} + +/// Ends the link when the station's statement lapses past the grace, or its +/// TLS binding reaches its not_after. +async fn watch_expiries(link: std::sync::Weak) { + loop { + let Some(inner) = link.upgrade() else { return }; + let now = now_ms(); + if now >= inner.station.binding_not_after { + inner.end(LinkError::BindingExpired); + return; + } + let status_deadline = inner.status_deadline.load(Ordering::SeqCst); + if now >= status_deadline { + inner.end(LinkError::StatusExpired); + return; + } + let wait = (status_deadline.min(inner.station.binding_not_after) - now).clamp(1, 60_000); + let mut done = inner.done_rx.clone(); + drop(inner); + tokio::select! { + _ = done.wait_for(|ended| *ended) => return, + _ = tokio::time::sleep(Duration::from_millis(wait as u64)) => {} + } + } +} + +/// Ends the link when its QUIC connection closes. +async fn watch_connection(link: std::sync::Weak) { + let Some(connection) = link.upgrade().map(|l| l.connection.clone()) else { + return; + }; + let cause = connection.closed().await; + if let Some(inner) = link.upgrade() { + inner.end(LinkError::Io(cause.to_string())); + } +} + +fn frame_type_of(v: &Value) -> String { + match v.get("frame_type") { + Some(Value::Text(t)) => t.clone(), + _ => String::new(), + } +} + +fn now_ms() -> i64 { + crate::uuid_v7::now_ms() as i64 +} diff --git a/src/station_link/admission.rs b/src/station_link/admission.rs new file mode 100644 index 0000000..f061fb6 --- /dev/null +++ b/src/station_link/admission.rs @@ -0,0 +1,298 @@ +//! Admission of the CALLs a provider node receives, as +//! macula_request_admission judges them: a request runs once, whichever of +//! the node's links it arrives on. Its deadline must lie between the +//! provider's clock minus 5 minutes and plus 10 minutes, and (caller, +//! request_id) must be new; the entry is kept until the deadline plus 5 +//! minutes. A copy with the same request hash gets the stored reply, or +//! request_copy while the first still runs; one with another hash is refused. +//! The entries are bounded, and a full bound refuses rather than evicts, in +//! this order: each caller holds at most `caller_quota` entries, each share +//! (one link's place: the station it dialed) at most `share`, and the +//! admission at most `cap`; stored replies take at most `reply_bytes` per +//! caller and `reply_bytes_total` in all. A reply past either is not kept, and +//! a copy of its request is refused reply_not_kept. + +use std::collections::HashMap; +use std::hash::Hash; +use std::sync::{Arc, Mutex, MutexGuard}; + +use crate::frame::VerifiedRequest; + +use super::LinkError; + +const DEADLINE_PAST_TOLERANCE_MS: i64 = 5 * 60_000; +const DEADLINE_AHEAD_MAX_MS: i64 = 10 * 60_000; +const KEPT_PAST_DEADLINE_MS: i64 = 5 * 60_000; + +/// An admission's bounds. The last four bound the streaming sessions a node +/// serves, as macula_stream_sessions does: sessions at once per caller and in +/// all, and the bytes their inboxes hold per caller and in all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AdmissionLimits { + pub caller_quota: usize, + pub share: usize, + pub cap: usize, + pub reply_bytes: usize, + pub reply_bytes_total: usize, + pub sessions_per_caller: usize, + pub sessions: usize, + pub inbox_bytes_per_caller: usize, + pub inbox_bytes: usize, +} + +impl Default for AdmissionLimits { + /// macula's defaults, with `cap` one share's worth: the bound of an + /// admission a single link holds. A pool sets `cap` to `share` times the + /// most links it holds. + fn default() -> Self { + AdmissionLimits { + caller_quota: 256, + share: 1024, + cap: 1024, + reply_bytes: 256 * 1024, + reply_bytes_total: 16 * 1024 * 1024, + sessions_per_caller: 16, + sessions: 1000, + inbox_bytes_per_caller: 16 * 1024 * 1024, + inbox_bytes: 256 * 1024 * 1024, + } + } +} + +impl AdmissionLimits { + /// Whether macula would start with these limits: every bound positive, + /// and each per-caller bound within its total. + pub fn validate(&self) -> Result<(), LinkError> { + let l = self; + let valid = l.caller_quota > 0 + && l.share > 0 + && l.cap > 0 + && l.reply_bytes > 0 + && l.reply_bytes_total > 0 + && l.caller_quota <= l.share + && l.reply_bytes <= l.reply_bytes_total + && l.sessions_per_caller > 0 + && l.sessions >= l.sessions_per_caller + && l.inbox_bytes_per_caller > 0 + && l.inbox_bytes >= l.inbox_bytes_per_caller; + if valid { + Ok(()) + } else { + Err(LinkError::InvalidConfig( + "admission limits must be positive, each per-caller bound within its total".into(), + )) + } + } +} + +/// The admission's judgement of a request: refused with a code, a copy with +/// its stored reply (`None` while the first still runs), or new. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Verdict { + Refused(&'static str), + Copy(Option>), + New, +} + +struct Entry { + hash: [u8; 48], + expires_at: i64, + share: String, + answered: bool, + /// `None` when answered but not kept. + reply: Option>, +} + +#[derive(Default)] +struct Held { + entries: HashMap<([u8; 32], [u8; 16]), Entry>, + callers: HashMap<[u8; 32], usize>, + shares: HashMap, + reply_bytes: HashMap<[u8; 32], usize>, + reply_total: usize, + sessions: HashMap<[u8; 32], usize>, + sessions_total: usize, + inbox: HashMap<[u8; 32], usize>, + inbox_total: usize, +} + +/// One provider node's request admission, shared by all its links. +pub struct Admission { + limits: AdmissionLimits, + held: Mutex, +} + +impl Admission { + /// An empty admission with `limits`, which must validate: a link given + /// limits that do not is refused at dial. + pub fn new(limits: AdmissionLimits) -> Admission { + Admission { + limits, + held: Mutex::new(Held::default()), + } + } + + /// The admission's bounds. + pub fn limits(&self) -> AdmissionLimits { + self.limits + } + + fn lock(&self) -> MutexGuard<'_, Held> { + self.held.lock().unwrap_or_else(|p| p.into_inner()) + } + + /// Judges `request` arriving on `share` at `now_ms`, sweeping the entries + /// that expired first. + pub(super) fn admit(&self, request: &VerifiedRequest, share: &str, now_ms: i64) -> Verdict { + let deadline = request.deadline as i64; + if deadline < now_ms - DEADLINE_PAST_TOLERANCE_MS { + return Verdict::Refused("expired"); + } + if deadline > now_ms + DEADLINE_AHEAD_MAX_MS { + return Verdict::Refused("not_yet_valid"); + } + let mut held = self.lock(); + held.sweep(now_ms); + let key = (request.caller, request.request_id); + if let Some(entry) = held.entries.get(&key) { + if entry.hash != request.request_hash { + return Verdict::Refused("request_id_reused"); + } + if entry.answered && entry.reply.is_none() { + return Verdict::Refused("reply_not_kept"); + } + return Verdict::Copy(entry.reply.clone()); + } + if held.callers.get(&request.caller).copied().unwrap_or(0) >= self.limits.caller_quota { + return Verdict::Refused("caller_quota"); + } + if held.shares.get(share).copied().unwrap_or(0) >= self.limits.share { + return Verdict::Refused("share_full"); + } + if held.entries.len() >= self.limits.cap { + return Verdict::Refused("admission_full"); + } + held.entries.insert( + key, + Entry { + hash: request.request_hash, + expires_at: deadline + KEPT_PAST_DEADLINE_MS, + share: share.to_string(), + answered: false, + reply: None, + }, + ); + *held.callers.entry(request.caller).or_default() += 1; + *held.shares.entry(share.to_string()).or_default() += 1; + Verdict::New + } + + /// Keeps the encoded reply of an admitted request for its copies, when + /// the byte bounds leave room for it. + pub(super) fn store(&self, request: &VerifiedRequest, reply: Vec) { + let mut held = self.lock(); + let held = &mut *held; + let Some(entry) = held.entries.get_mut(&(request.caller, request.request_id)) else { + return; + }; + if entry.answered || entry.hash != request.request_hash { + return; + } + entry.answered = true; + let caller_bytes = held.reply_bytes.get(&request.caller).copied().unwrap_or(0); + if caller_bytes + reply.len() > self.limits.reply_bytes + || held.reply_total + reply.len() > self.limits.reply_bytes_total + { + return; + } + *held.reply_bytes.entry(request.caller).or_default() += reply.len(); + held.reply_total += reply.len(); + entry.reply = Some(reply); + } + + /// Takes a streaming session's place for `caller`, or `None` when the + /// per-caller or node bound is full. Dropping the place gives it back. + pub(super) fn open_session(self: &Arc, caller: [u8; 32]) -> Option { + let mut held = self.lock(); + if held.sessions.get(&caller).copied().unwrap_or(0) >= self.limits.sessions_per_caller + || held.sessions_total >= self.limits.sessions + { + return None; + } + *held.sessions.entry(caller).or_default() += 1; + held.sessions_total += 1; + Some(SessionPlace { + admission: self.clone(), + caller, + }) + } + + /// Counts `n` more bytes that `caller`'s served streams hold unread, + /// refusing a charge past either bound. + pub(super) fn charge_inbox(&self, caller: [u8; 32], n: usize) -> bool { + let mut held = self.lock(); + if held.inbox.get(&caller).copied().unwrap_or(0) + n > self.limits.inbox_bytes_per_caller + || held.inbox_total + n > self.limits.inbox_bytes + { + return false; + } + *held.inbox.entry(caller).or_default() += n; + held.inbox_total += n; + true + } + + /// Gives back `n` bytes `caller`'s served streams no longer hold. + pub(super) fn release_inbox(&self, caller: [u8; 32], n: usize) { + let mut held = self.lock(); + decrement(&mut held.inbox, caller, n); + held.inbox_total = held.inbox_total.saturating_sub(n); + } +} + +/// A streaming session's place in the admission, given back when dropped. +pub(super) struct SessionPlace { + admission: Arc, + caller: [u8; 32], +} + +impl Drop for SessionPlace { + fn drop(&mut self) { + let mut held = self.admission.lock(); + decrement(&mut held.sessions, self.caller, 1); + held.sessions_total = held.sessions_total.saturating_sub(1); + } +} + +impl Held { + /// Removes the entries whose deadline plus 5 minutes passed before + /// `now_ms`. + fn sweep(&mut self, now_ms: i64) { + let expired: Vec<_> = self + .entries + .iter() + .filter(|(_, e)| e.expires_at < now_ms) + .map(|(k, _)| *k) + .collect(); + for key in expired { + let Some(entry) = self.entries.remove(&key) else { + continue; + }; + decrement(&mut self.callers, key.0, 1); + decrement(&mut self.shares, entry.share, 1); + if let Some(reply) = entry.reply { + decrement(&mut self.reply_bytes, key.0, reply.len()); + self.reply_total = self.reply_total.saturating_sub(reply.len()); + } + } + } +} + +/// Takes `n` from `m[k]`, dropping the key at zero. +fn decrement(m: &mut HashMap, k: K, n: usize) { + if let Some(v) = m.get_mut(&k) { + *v = v.saturating_sub(n); + if *v == 0 { + m.remove(&k); + } + } +} diff --git a/src/station_link/call.rs b/src/station_link/call.rs new file mode 100644 index 0000000..1c0e710 --- /dev/null +++ b/src/station_link/call.rs @@ -0,0 +1,208 @@ +//! Calls on a link: a CALL signed with the link's identity key, answered by +//! the RESULT or ERROR that verifies for it: a provider reply signed by its +//! target, or a relay error the connected station signed. A reply that does +//! not verify is counted and ignored, and the call keeps waiting, as macula's +//! link does. The liveness probe is a call too. + +use std::sync::{Arc, Weak}; +use std::time::Duration; + +use tokio::sync::oneshot; + +use crate::cbor::Value; +use crate::frame::{self, ReplyType, RequestSpec, VerifiedRequest}; + +use super::{now_ms, Inner, Link, LinkError}; + +/// macula's default timeout for a call. +pub const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(5); +/// The longest a call waits: the far edge of a provider's deadline window. +pub const MAX_CALL_TIMEOUT: Duration = Duration::from_secs(10 * 60); + +const LIVENESS_EVERY: Duration = Duration::from_secs(30); +const LIVENESS_TIMEOUT: Duration = Duration::from_secs(30); +const LIVENESS_PROCEDURE: &str = "_macula.ping"; + +/// One request: the realm and procedure, the node it targets (the station +/// itself when zero, as for `_dht.*`; the provider's node_id otherwise), its +/// payload, how long to wait (the default when zero), and a UCAN token and +/// its delegation chain's proofs for a gated procedure. +#[derive(Debug, Clone, PartialEq)] +pub struct Call { + pub realm: [u8; 32], + pub procedure: String, + pub target: [u8; 32], + pub payload: Value, + pub timeout: Duration, + pub token: Option>, + pub proofs: Vec>, +} + +impl Default for Call { + fn default() -> Self { + Call { + realm: [0; 32], + procedure: String::new(), + target: [0; 32], + payload: Value::Map(Vec::new()), + timeout: Duration::ZERO, + token: None, + proofs: Vec::new(), + } + } +} + +/// A call waiting for its reply. +pub(super) struct Pending { + pub(super) request: VerifiedRequest, + pub(super) outcome: oneshot::Sender>, +} + +impl Link { + /// Signs `c` as a CALL, sends it, and waits for the RESULT or ERROR that + /// verifies for it. + pub async fn call(&self, c: Call) -> Result { + call(&self.inner, c).await + } +} + +pub(super) async fn call(inner: &Arc, c: Call) -> Result { + let timeout = if c.timeout.is_zero() { + DEFAULT_CALL_TIMEOUT + } else { + c.timeout.min(MAX_CALL_TIMEOUT) + }; + let target = if c.target == [0; 32] { + inner.station.node_id + } else { + c.target + }; + let mut request_id = [0u8; 16]; + aws_lc_rs::rand::fill(&mut request_id).map_err(|_| LinkError::Io("no randomness".into()))?; + let signed = frame::sign_call( + &RequestSpec { + request_id, + realm: c.realm, + procedure: c.procedure, + target, + deadline: (now_ms() + timeout.as_millis() as i64) as u64, + payload: c.payload, + mode: None, + token: c.token, + proofs: c.proofs, + source_route: None, + retry_budget: None, + }, + &inner.key, + )?; + let request = frame::verify_request(&signed, inner.profile)?; + let (outcome_tx, outcome) = oneshot::channel(); + { + let mut state = inner.lock(); + if let Some(e) = &state.ended { + return Err(e.clone()); + } + state.pending.insert( + request_id, + Pending { + request, + outcome: outcome_tx, + }, + ); + } + let forget = || { + inner.lock().pending.remove(&request_id); + }; + if let Err(e) = inner.write_control(&signed).await { + forget(); + return Err(e); + } + let answered = tokio::time::timeout(timeout, outcome).await; + forget(); + match answered { + Ok(Ok(outcome)) => outcome, + Ok(Err(_)) => Err(inner.lock().ended.clone().unwrap_or(LinkError::Closed)), + Err(_) => Err(LinkError::CallTimeout), + } +} + +/// A RESULT or ERROR matched to its pending call by the ids it claims, and +/// handed on only once it verifies for that call's request. +pub(super) fn replied(inner: &Arc, v: &Value) { + let Ok((request_id, _)) = frame::claimed_reply_ids(v) else { + inner.count("malformed_reply"); + return; + }; + let request = match inner.lock().pending.get(&request_id) { + Some(p) => p.request.clone(), + None => { + inner.count("unmatched_reply"); + return; + } + }; + let Some(outcome) = verified_outcome(inner, v, &request) else { + inner.count("unverified_reply"); + return; + }; + if let Some(p) = inner.lock().pending.remove(&request_id) { + let _ = p.outcome.send(outcome); + } +} + +fn verified_outcome( + inner: &Inner, + v: &Value, + request: &VerifiedRequest, +) -> Option> { + if v.get("reply").is_some() { + let reply = frame::verify_reply(v, request, inner.profile).ok()?; + return Some(match reply.frame_type { + ReplyType::Result => Ok(reply.payload.unwrap_or(Value::Null)), + ReplyType::Error => Err(LinkError::Provider { + responded_by: reply.responded_by, + code: reply.code.unwrap_or_default(), + detail: reply.detail, + }), + }); + } + let relayed = + frame::verify_relay_error(v, request, inner.profile, &inner.station.node_id).ok()?; + Some(Err(LinkError::Relay { + reported_by: relayed.reported_by, + code: relayed.code, + })) +} + +/// A liveness probe every 30 seconds; two misses in a row end the link. Any +/// verified answer counts: it proves the station is there. +pub(super) async fn probe(link: Weak) { + let mut misses = 0; + loop { + let Some(mut done) = link.upgrade().map(|l| l.done_rx.clone()) else { + return; + }; + tokio::select! { + _ = done.wait_for(|ended| *ended) => return, + _ = tokio::time::sleep(LIVENESS_EVERY) => {} + } + let Some(inner) = link.upgrade() else { return }; + let outcome = call( + &inner, + Call { + procedure: LIVENESS_PROCEDURE.to_string(), + timeout: LIVENESS_TIMEOUT, + ..Call::default() + }, + ) + .await; + misses = if matches!(outcome, Err(LinkError::CallTimeout)) { + misses + 1 + } else { + 0 + }; + if misses >= 2 { + inner.end(LinkError::LivenessLost); + return; + } + } +} diff --git a/src/station_link/dht.rs b/src/station_link/dht.rs new file mode 100644 index 0000000..65868cc --- /dev/null +++ b/src/station_link/dht.rs @@ -0,0 +1,91 @@ +//! The DHT, as macula 12's facade reaches it: station procedures on the zero +//! realm, targeting the connected station, carrying record wire bytes. Every +//! record found is verified here before it is handed on, and one that does +//! not verify is dropped and counted. + +use crate::cbor::Value; +use crate::record::{self, RecordType, Verified}; + +use super::{now_ms, Call, Link, LinkError}; + +impl Link { + /// Stores a signed record, as its wire bytes, in the station's DHT. + pub async fn put_record(&self, wire: &[u8]) -> Result<(), LinkError> { + let result = self + .dht_call("_dht.put_record", Value::Bytes(wire.to_vec())) + .await?; + match result { + Value::Text(t) if t == "ok" => Ok(()), + other => Err(LinkError::UnexpectedReply(format!( + "put_record answered {other:?}" + ))), + } + } + + /// The record stored under `key`, verified. + pub async fn find_record(&self, key: &[u8; 32]) -> Result { + match self.dht_call("_dht.find_record", key_payload(key)).await? { + Value::Text(t) if t == "not_found" => Err(LinkError::RecordNotFound), + Value::Bytes(wire) => Ok(record::verify(&wire, self.profile(), now_ms())?), + other => Err(LinkError::UnexpectedReply(format!( + "find_record answered {other:?}" + ))), + } + } + + /// Every record stored under `key` that verifies, and how many the + /// station returned that did not. + pub async fn find_records(&self, key: &[u8; 32]) -> Result<(Vec, usize), LinkError> { + self.verified_list("_dht.find_records", key_payload(key)) + .await + } + + /// Every record of type `t` the station holds that verifies, and how many + /// it returned that did not. + pub async fn find_records_by_type( + &self, + t: RecordType, + ) -> Result<(Vec, usize), LinkError> { + let payload = Value::Map(vec![(Value::text("type"), Value::Int(i128::from(t.0)))]); + self.verified_list("_dht.find_records_by_type", payload) + .await + } + + async fn verified_list( + &self, + procedure: &str, + payload: Value, + ) -> Result<(Vec, usize), LinkError> { + let Value::List(items) = self.dht_call(procedure, payload).await? else { + return Err(LinkError::UnexpectedReply(format!( + "{procedure} answered no list" + ))); + }; + let now = now_ms(); + let mut verified = Vec::with_capacity(items.len()); + let mut dropped = 0; + for item in items { + match item { + Value::Bytes(wire) => match record::verify(&wire, self.profile(), now) { + Ok(r) => verified.push(r), + Err(_) => dropped += 1, + }, + _ => dropped += 1, + } + } + Ok((verified, dropped)) + } + + async fn dht_call(&self, procedure: &str, payload: Value) -> Result { + self.call(Call { + procedure: procedure.to_string(), + payload, + ..Call::default() + }) + .await + } +} + +fn key_payload(key: &[u8; 32]) -> Value { + Value::Map(vec![(Value::text("key"), Value::Bytes(key.to_vec()))]) +} diff --git a/src/station_link/framing.rs b/src/station_link/framing.rs new file mode 100644 index 0000000..e592fda --- /dev/null +++ b/src/station_link/framing.rs @@ -0,0 +1,70 @@ +//! A control stream's or a session stream's frames: ``, +//! with the caps macula 12 holds them to. A length header over the cap is +//! refused as soon as it arrives, before its body is read. + +use super::LinkError; + +/// A handshake frame (OPENER, CHALLENGE, CONNECT, HELLO) is at most 64 KiB. +pub(super) const HANDSHAKE_FRAME_BYTES: usize = 64 * 1024; + +/// Every frame after HELLO is at most 16 MiB. +pub(super) const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; + +/// The next frame's CBOR bytes, refusing a length header over `max`. +pub(super) async fn read_frame( + recv: &mut quinn::RecvStream, + max: usize, +) -> Result, LinkError> { + let mut header = [0u8; 4]; + recv.read_exact(&mut header) + .await + .map_err(|e| LinkError::Io(e.to_string()))?; + let length = u32::from_be_bytes(header) as usize; + if length > max { + return Err(LinkError::FrameTooLarge(length)); + } + let mut payload = vec![0u8; length]; + recv.read_exact(&mut payload) + .await + .map_err(|e| LinkError::Io(e.to_string()))?; + Ok(payload) +} + +/// A stream's sending side, writing one frame at a time. +pub(super) struct FrameWriter { + send: tokio::sync::Mutex, +} + +impl FrameWriter { + pub(super) fn new(send: quinn::SendStream) -> FrameWriter { + FrameWriter { + send: tokio::sync::Mutex::new(send), + } + } + + /// Sends `payload` as one frame, refusing one over `max`. + pub(super) async fn write(&self, payload: &[u8], max: usize) -> Result<(), LinkError> { + if payload.len() > max { + return Err(LinkError::FrameTooLarge(payload.len())); + } + let mut framed = Vec::with_capacity(4 + payload.len()); + framed.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + framed.extend_from_slice(payload); + self.send + .lock() + .await + .write_all(&framed) + .await + .map_err(|e| LinkError::Io(e.to_string())) + } + + /// Finishes the sending side gracefully, after what was written. + pub(super) async fn finish(&self) { + let _ = self.send.lock().await.finish(); + } + + /// Resets the sending side, dropping what it still holds. + pub(super) async fn reset(&self) { + let _ = self.send.lock().await.reset(0u32.into()); + } +} diff --git a/src/station_link/pubsub.rs b/src/station_link/pubsub.rs new file mode 100644 index 0000000..9c7fe59 --- /dev/null +++ b/src/station_link/pubsub.rs @@ -0,0 +1,301 @@ +//! PubSub, as macula 12's link does it. A PUBLISH carries a publication +//! signed with the link's identity key; SUBSCRIBE and UNSUBSCRIBE are control +//! frames naming the realm, the topic and this node, with no +//! acknowledgement; an EVENT carries a publication that is verified before it +//! is delivered, and delivered once however many copies arrive, until it +//! expires. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, Weak}; + +use tokio::sync::mpsc; + +use crate::cbor::Value; +use crate::frame::{self, PublicationSpec}; +use crate::node_key::NodeKey; + +use super::{now_ms, Inner, Link, LinkError}; + +/// How many events a subscription holds that its reader has not taken; an +/// event arriving at a full subscription is dropped and counted. +const EVENT_BUFFER: usize = 64; + +/// What [`Link::publish`] sends: the realm and topic, the payload, and its +/// time to live, `None` for macula's 10 minutes. +#[derive(Debug, Clone, PartialEq)] +pub struct Publication { + pub realm: [u8; 32], + pub topic: String, + pub payload: Value, + pub ttl_ms: Option, +} + +/// A publication a subscription heard, verified: who published it (the key +/// id its signature verified under), where, its seq and time, the payload, +/// and how it arrived (direct or plumtree). +#[derive(Debug, Clone, PartialEq)] +pub struct Event { + pub publisher: [u8; 32], + pub realm: [u8; 32], + pub topic: String, + pub seq: u64, + pub published_at: u64, + pub payload: Value, + pub delivered_via: String, +} + +/// Numbers one publisher's publications: the first is the wall clock in +/// microseconds, and each after is one more than the last, or the clock, +/// whichever is later, as macula_publication_seq does. Links of one identity +/// key share one, so their seqs never repeat. +#[derive(Debug, Default)] +pub struct PublicationSeq { + last: Mutex, +} + +impl PublicationSeq { + /// The seq for the next publication. + pub fn next(&self) -> u64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_micros() as u64) + .unwrap_or(0); + let mut last = self.last.lock().unwrap_or_else(|p| p.into_inner()); + *last = now.max(*last + 1); + *last + } +} + +/// Remembers the publications a node delivered, by publication hash, until +/// each expires, so an event heard on several links, or twice on one, is +/// delivered once. The links of one node share one. +#[derive(Debug, Default)] +pub struct EventDedup { + seen: Mutex>, +} + +impl EventDedup { + /// Whether `hash` is new at `now_ms`, remembering it until `expires_at`. + fn first(&self, hash: [u8; 48], expires_at: u64, now_ms: i64) -> bool { + let mut seen = self.seen.lock().unwrap_or_else(|p| p.into_inner()); + if seen.contains_key(&hash) { + return false; + } + seen.retain(|_, until| *until >= now_ms as u64); + seen.insert(hash, expires_at); + true + } +} + +/// A PUBLISH signed once, to be sent on several links of one node: every copy +/// is the same publication, so a subscriber hearing it on several links +/// delivers it once. +#[derive(Debug, Clone, PartialEq)] +pub struct SignedPublication { + frame: Value, +} + +impl SignedPublication { + /// Signs `p` with `key` at the next seq of `seq`. + pub fn sign( + key: &NodeKey, + seq: &PublicationSeq, + p: Publication, + ) -> Result { + let frame = frame::sign_publish( + &PublicationSpec { + realm: p.realm, + topic: p.topic, + seq: seq.next(), + published_at: now_ms() as u64, + payload: p.payload, + ttl_ms: p.ttl_ms, + }, + key, + )?; + Ok(SignedPublication { frame }) + } +} + +static NEXT_SUBSCRIBER: AtomicU64 = AtomicU64::new(1); + +/// One subscriber of a realm and topic on a link. +pub(super) struct SubscriberSlot { + id: u64, + events: mpsc::Sender, +} + +/// One subscription to a realm and topic on a link, until +/// [`Subscription::unsubscribe`] or the link ends. +pub struct Subscription { + link: Weak, + key: ([u8; 32], String), + id: u64, + events: mpsc::Receiver, + unsubscribed: bool, +} + +impl Subscription { + /// The next event, or `None` once the subscription has ended. + pub async fn recv(&mut self) -> Option { + self.events.recv().await + } + + /// Ends the subscription, and sends UNSUBSCRIBE once no other + /// subscription on the link holds its realm and topic. + pub async fn unsubscribe(mut self) -> Result<(), LinkError> { + self.unsubscribed = true; + let Some(inner) = self.link.upgrade() else { + return Ok(()); + }; + if drop_subscriber(&inner, &self.key, self.id) { + let frame = + frame::unsubscribe_frame(self.key.1.as_bytes(), &self.key.0, &inner.self_id)?; + inner.send_control(&frame).await?; + } + Ok(()) + } +} + +impl Drop for Subscription { + /// A subscription dropped without unsubscribing unsubscribes as it goes. + fn drop(&mut self) { + if self.unsubscribed { + return; + } + let Some(inner) = self.link.upgrade() else { + return; + }; + if !drop_subscriber(&inner, &self.key, self.id) { + return; + } + let key = self.key.clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + if let Ok(frame) = + frame::unsubscribe_frame(key.1.as_bytes(), &key.0, &inner.self_id) + { + let _ = inner.send_control(&frame).await; + } + }); + } + } +} + +/// Removes a subscriber, and whether it was the last on its realm and topic. +fn drop_subscriber(inner: &Inner, key: &([u8; 32], String), id: u64) -> bool { + let mut state = inner.lock(); + let Some(slots) = state.subs.get_mut(key) else { + return false; + }; + slots.retain(|s| s.id != id); + if slots.is_empty() { + state.subs.remove(key); + return true; + } + false +} + +impl Link { + /// Signs `p` as a PUBLISH and sends it. + pub async fn publish(&self, p: Publication) -> Result<(), LinkError> { + let signed = SignedPublication::sign(&self.inner.key, &self.inner.publication_seq, p)?; + self.publish_signed(&signed).await + } + + /// Sends a publication signed once for several links, as it is. + pub async fn publish_signed(&self, p: &SignedPublication) -> Result<(), LinkError> { + self.inner.write_control(&p.frame).await + } + + /// Subscribes to `topic` in `realm`: the first subscription to a realm + /// and topic on the link sends SUBSCRIBE. + pub async fn subscribe( + &self, + realm: &[u8; 32], + topic: &str, + ) -> Result { + let key = (*realm, topic.to_string()); + let (events_tx, events) = mpsc::channel(EVENT_BUFFER); + let id = NEXT_SUBSCRIBER.fetch_add(1, Ordering::Relaxed); + let first = { + let mut state = self.inner.lock(); + if let Some(e) = &state.ended { + return Err(e.clone()); + } + let slots = state.subs.entry(key.clone()).or_default(); + slots.push(SubscriberSlot { + id, + events: events_tx, + }); + slots.len() == 1 + }; + let subscription = Subscription { + link: Arc::downgrade(&self.inner), + key: key.clone(), + id, + events, + unsubscribed: false, + }; + if first { + let frame = frame::subscribe_frame(topic.as_bytes(), realm, &self.inner.self_id); + let sent = match frame { + Ok(frame) => self.inner.send_control(&frame).await, + Err(e) => Err(e.into()), + }; + if let Err(e) = sent { + drop_subscriber(&self.inner, &key, id); + let mut subscription = subscription; + subscription.unsubscribed = true; + return Err(e); + } + } + Ok(subscription) + } +} + +/// An EVENT's publication, once it verifies, delivered to every subscription +/// on its realm and topic, unless it was delivered before. +pub(super) fn evented(inner: &Arc, v: &Value) { + let now = now_ms(); + let Ok(publication) = frame::verify_publication(v, inner.profile, now) else { + inner.count("event_unverified"); + return; + }; + let delivered_via = match v.get("delivered_via") { + Some(Value::Text(t)) => t.clone(), + _ => String::new(), + }; + if !inner + .dedup + .first(publication.publication_hash, publication.expires_at, now) + { + inner.count("event_duplicate"); + return; + } + let event = Event { + publisher: publication.publisher, + realm: publication.realm, + topic: publication.topic.clone(), + seq: publication.seq, + published_at: publication.published_at, + payload: publication.payload, + delivered_via, + }; + let mut state = inner.lock(); + let Some(slots) = state.subs.get(&(publication.realm, publication.topic)) else { + *state + .unrouted + .entry("event_unsubscribed".into()) + .or_default() += 1; + return; + }; + let overflowed = slots + .iter() + .filter(|s| s.events.try_send(event.clone()).is_err()) + .count(); + if overflowed > 0 { + *state.unrouted.entry("event_overflow".into()).or_default() += overflowed as u64; + } +} diff --git a/src/station_link/serve.rs b/src/station_link/serve.rs new file mode 100644 index 0000000..ed8a845 --- /dev/null +++ b/src/station_link/serve.rs @@ -0,0 +1,540 @@ +//! Serving, as macula 12's provider does it. A procedure is served under an +//! org namespace: the realm's org directory names the org's key, the org's +//! procedure delegation names this node, both are found in the DHT, and the +//! signed procedure_advertisement carries them to the station in an +//! ADVERTISE, checked against the realm key before it goes out. Or it is +//! served in this node's own namespace, `~/` (D25 item 6): its +//! advertisement carries no authorization, its signature alone authorizes it, +//! and no realm key or record in the DHT is needed. The station routes CALLs +//! for the procedure to this link; each is admitted once per (caller, +//! request_id) and answered with a RESULT or ERROR signed by this node. +//! UNADVERTISE carries a tombstone of the advertisement. +//! +//! Only open procedures are served: a gated one needs a post-quantum UCAN +//! verifier, which this crate does not have. + +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex, Weak}; +use std::time::Duration; + +use tokio::sync::watch; + +use crate::cbor::{self, Value}; +use crate::frame::{self, StreamMode, VerifiedRequest}; +use crate::record::{ + self, Authorization, ProcedureAdvertisementOptions, Reason, Record, RecordError, + TombstoneOptions, Trust, +}; + +use super::admission::Verdict; +use super::framing::MAX_FRAME_BYTES; +use super::stream::StreamHandler; +use super::{now_ms, Inner, Link, LinkError}; + +/// The provider codes of a served procedure's ERRORs: a handler's own +/// refusal, a handler that panicked, a procedure this link does not serve, a +/// copy of a request still running, and a result the wire cannot carry. +const CODE_HANDLER_ERROR: &str = "handler_error"; +const CODE_HANDLER_CRASHED: &str = "temporary_relay_failure"; +const CODE_UNKNOWN_PROCEDURE: &str = "unknown_next_peer"; +pub(super) const CODE_REQUEST_COPY: &str = "request_copy"; +const CODE_PAYLOAD_TOO_LARGE: &str = "payload_too_large"; +const CODE_UNSENDABLE: &str = "unknown_error"; + +/// An ERROR's detail is at most 256 bytes, cut on a character boundary. +const MAX_DETAIL_BYTES: usize = 256; + +/// macula's default and longest advertisement lifetime. +const MAX_ADVERTISEMENT_TTL: Duration = Duration::from_secs(5 * 60); +/// How soon a failed renewal is tried again, while the advertisement it +/// replaces still lives. +const REFRESH_RETRY: Duration = Duration::from_secs(10); + +/// A future a handler returns. +pub type BoxFuture = Pin + Send + 'static>>; + +/// Answers a [`Request`] with a result payload, or an error whose text the +/// caller receives as a handler_error's detail. A handler still running at +/// the request's deadline is dropped and answered handler_error. +pub type Handler = Arc BoxFuture> + Send + Sync>; + +/// A [`Handler`] from an async closure. +pub fn handler(f: F) -> Handler +where + F: Fn(Request) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, +{ + Arc::new(move |r| Box::pin(f(r))) +} + +/// A CALL a served procedure answers: the caller (the key id its signature +/// verified under), what it asked for, and its deadline in unix +/// milliseconds. +#[derive(Debug, Clone, PartialEq)] +pub struct Request { + pub caller: [u8; 32], + pub realm: [u8; 32], + pub procedure: String, + pub payload: Value, + pub token: Option>, + pub proofs: Option>>, + pub deadline_ms: u64, +} + +/// A procedure to serve: its realm and name, exactly one of a unary handler +/// and a stream offer, and, for an org procedure, the realm key the org +/// directory must be signed with, as the realm's members pin it; a procedure +/// in this node's own namespace needs none. +#[derive(Clone)] +pub struct Offer { + pub realm: [u8; 32], + pub procedure: String, + pub handler: Option, + pub stream: Option, + pub realm_key: Option>, +} + +impl Offer { + /// A unary procedure, with no realm key. + pub fn unary(realm: [u8; 32], procedure: &str, handler: Handler) -> Offer { + Offer { + realm, + procedure: procedure.to_string(), + handler: Some(handler), + stream: None, + realm_key: None, + } + } + + /// A streaming procedure of `mode`, with no realm key. + pub fn stream( + realm: [u8; 32], + procedure: &str, + mode: StreamMode, + handler: StreamHandler, + ) -> Offer { + Offer { + realm, + procedure: procedure.to_string(), + handler: None, + stream: Some(StreamOffer { mode, handler }), + realm_key: None, + } + } +} + +/// A streaming procedure's mode and handler. The advertisement is the one a +/// unary procedure sends, which names no mode: a STREAM_OPEN of another mode +/// is refused mode_mismatch. +#[derive(Clone)] +pub struct StreamOffer { + pub mode: StreamMode, + pub handler: StreamHandler, +} + +/// A procedure a link serves, as the link holds it. +pub(super) type ServedEntry = Arc; + +pub(super) struct ServedInner { + link: Weak, + key: ([u8; 32], String), + pub(super) offer: Offer, + latest: Mutex, + err: Mutex>, + done_tx: watch::Sender, +} + +/// A procedure this link serves, until [`Served::stop`] or the link ends, or +/// until its advertisement lapses because its authorization could not be +/// found again. Cloning it shares the serving. +#[derive(Clone)] +pub struct Served { + inner: Arc, +} + +impl Link { + /// Advertises `o`'s procedure on the link and answers its CALLs until + /// stopped. For an org procedure it resolves the org directory and this + /// node's procedure delegation from the DHT; a procedure in this node's + /// own namespace needs neither, and another node's namespace is refused. + /// It signs the advertisement with this node's identity key, naming the + /// connected station as the serving station and living no longer than + /// the records it carries nor 5 minutes, and checks its authorization + /// (against the realm key for an org procedure) before sending it in an + /// ADVERTISE and putting it in the DHT. The advertisement is renewed at + /// half its lifetime. + pub async fn serve(&self, o: Offer) -> Result { + let own = record::in_own_namespace(&o.procedure); + if o.handler.is_some() == o.stream.is_some() || (!own && o.realm_key.is_none()) { + return Err(LinkError::InvalidOffer); + } + if !matches!(record::procedure_org(&o.procedure), Ok(Some(_))) { + return Err(LinkError::NoOrg); + } + let (advertisement, wire) = self.advertisement(&o, MAX_ADVERTISEMENT_TTL).await?; + let key = (o.realm, o.procedure.clone()); + let (done_tx, _) = watch::channel(false); + let served = Arc::new(ServedInner { + link: Arc::downgrade(&self.inner), + key: key.clone(), + offer: o, + latest: Mutex::new(advertisement), + err: Mutex::new(None), + done_tx, + }); + { + let mut state = self.inner.lock(); + if let Some(e) = &state.ended { + return Err(e.clone()); + } + if state.served.contains_key(&key) { + return Err(LinkError::AlreadyServed); + } + state.served.insert(key, served.clone()); + } + if let Err(e) = self.announce(&wire).await { + served.end(e.clone()); + return Err(e); + } + tokio::spawn(renew(served.clone())); + Ok(Served { inner: served }) + } + + /// `o`'s signed procedure_advertisement and its wire form, living at most + /// `max_ttl`: with the authorization resolved from the DHT for an org + /// procedure, with none in this node's own namespace. + async fn advertisement( + &self, + o: &Offer, + max_ttl: Duration, + ) -> Result<(Record, Vec), LinkError> { + let inner = &self.inner; + let max_ttl_ms = max_ttl.as_millis() as u64; + let opts = if record::in_own_namespace(&o.procedure) { + ProcedureAdvertisementOptions { + authorization: Authorization::None, + ttl_ms: max_ttl_ms, + } + } else { + let org = record::procedure_org(&o.procedure)?.ok_or(LinkError::NoOrg)?; + let directory = self + .find_record(&record::org_directory_key(&o.realm, org)) + .await?; + let named = record::read_org_directory(directory.record())?; + let delegation = self + .find_record(&record::procedure_delegation_key( + &named.org_key, + &inner.self_id, + )) + .await?; + let now = now_ms(); + let ttl = (max_ttl_ms as i64) + .min(directory.record().expires_at as i64 - now) + .min(delegation.record().expires_at as i64 - now); + if ttl <= 0 { + return Err(RecordError::AuthorizationOutlived.into()); + } + ProcedureAdvertisementOptions { + authorization: Authorization::Delegation { + org_directory: record::encode(directory.record())?, + procedure_delegation: record::encode(delegation.record())?, + }, + ttl_ms: ttl as u64, + } + }; + let unsigned = record::new_procedure_advertisement( + &inner.self_id, + &o.realm, + &o.procedure, + &inner.station.node_id, + &opts, + )?; + // Signed, then checked as a caller will check it before it is sent + // anywhere. + let signed = record::sign(&unsigned, &inner.key)?; + let wire = record::encode(&signed)?; + let now = now_ms(); + let verified = record::verify(&wire, inner.profile, now)?; + record::verify_authorization( + &verified, + &Trust { + profile: inner.profile, + realm_key: o.realm_key.clone(), + }, + now, + )?; + Ok((signed, wire)) + } + + /// Sends an advertisement to the station in an ADVERTISE, which routes + /// CALLs through it, and puts it in the DHT, where a caller resolving + /// the procedure finds it, as macula's advertise_direct does both. + async fn announce(&self, wire: &[u8]) -> Result<(), LinkError> { + self.inner + .send_control(&frame::advertise_frame(wire)) + .await?; + self.put_record(wire).await + } +} + +impl Served { + /// Withdraws the advertisement with an UNADVERTISE carrying its + /// tombstone, signed by this node, puts the tombstone in the + /// advertisement's DHT slot, and stops answering the procedure's CALLs. + /// Stopping a procedure no longer served does nothing. + pub async fn stop(&self) -> Result<(), LinkError> { + if self.inner.is_done() { + return Ok(()); + } + self.inner.end(LinkError::Stopped); + let Some(inner) = self.inner.link.upgrade() else { + return Ok(()); + }; + let latest = self + .inner + .latest + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone(); + let tombstone = + record::new_tombstone(&latest, Reason::Shutdown, &TombstoneOptions::default())?; + let wire = record::encode(&record::sign(&tombstone, &inner.key)?)?; + inner.send_control(&frame::unadvertise_frame(&wire)).await?; + Link { inner }.put_record(&wire).await + } + + /// Waits until the procedure is no longer served, and says why. + pub async fn done(&self) -> LinkError { + let mut done = self.inner.done_tx.subscribe(); + let _ = done.wait_for(|ended| *ended).await; + self.error().unwrap_or(LinkError::Stopped) + } + + /// Why the procedure is no longer served: [`LinkError::Stopped`] after + /// stop, the link's error when it ended, or the renewal's failure; `None` + /// while served. + pub fn error(&self) -> Option { + self.inner + .err + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone() + } +} + +impl ServedInner { + fn is_done(&self) -> bool { + *self.done_tx.borrow() + } + + /// Ends the serving once, with `err`: the link no longer routes the + /// procedure's CALLs to its handler. + pub(super) fn end(self: &Arc, err: LinkError) { + { + let mut held = self.err.lock().unwrap_or_else(|p| p.into_inner()); + if held.is_some() { + return; + } + *held = Some(err); + } + if let Some(inner) = self.link.upgrade() { + let mut state = inner.lock(); + if state + .served + .get(&self.key) + .is_some_and(|s| Arc::ptr_eq(s, self)) + { + state.served.remove(&self.key); + } + } + let _ = self.done_tx.send_replace(true); + } +} + +/// Sends a fresh advertisement at half the current one's lifetime. A renewal +/// that fails is tried again every [`REFRESH_RETRY`] while the current one +/// lives; when it lapses unrenewed, the procedure is no longer served and its +/// error says why. +async fn renew(served: Arc) { + let mut current = served + .latest + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clone(); + let mut wait = half_life(¤t); + let mut last_err: Option = None; + let mut stopped = served.done_tx.subscribe(); + loop { + let Some(mut link_done) = served.link.upgrade().map(|l| l.done_rx.clone()) else { + return; + }; + tokio::select! { + _ = stopped.wait_for(|ended| *ended) => return, + _ = link_done.wait_for(|ended| *ended) => { + let err = served.link.upgrade().and_then(|l| l.lock().ended.clone()).unwrap_or(LinkError::Closed); + served.end(err); + return; + } + _ = tokio::time::sleep(wait) => {} + } + if now_ms() >= current.expires_at as i64 { + served.end(last_err.unwrap_or(LinkError::Stopped)); + return; + } + let Some(inner) = served.link.upgrade() else { + return; + }; + let link = Link { inner }; + let renewed = async { + let (advertisement, wire) = link + .advertisement(&served.offer, MAX_ADVERTISEMENT_TTL) + .await?; + link.announce(&wire).await?; + Ok::<_, LinkError>(advertisement) + } + .await; + match renewed { + Ok(advertisement) => { + *served.latest.lock().unwrap_or_else(|p| p.into_inner()) = advertisement.clone(); + wait = half_life(&advertisement); + current = advertisement; + } + Err(e) => { + last_err = Some(e); + let left = (current.expires_at as i64 - now_ms()).max(0) as u64; + wait = REFRESH_RETRY.min(Duration::from_millis(left)); + } + } + } +} + +fn half_life(r: &Record) -> Duration { + Duration::from_millis(r.expires_at.saturating_sub(r.created_at) / 2) +} + +/// Answers a CALL the station routed to this link. A request that does not +/// verify, or targets another node, gets no reply and is counted. One that +/// verifies is judged by the admission, then answered by its handler, or by +/// an ERROR naming why not. +pub(super) fn called(inner: &Arc, v: &Value) { + let Ok(request) = frame::verify_request(v, inner.profile) else { + inner.count("unverified_call"); + return; + }; + if request.target != inner.self_id { + inner.count("call_for_another_node"); + return; + } + let inner = inner.clone(); + match inner.admission.admit(&request, &inner.share, now_ms()) { + Verdict::Refused(code) => { + let reply = provider_error(&inner, &request, code, None); + tokio::spawn(async move { send_reply(&inner, reply).await }); + } + Verdict::Copy(None) => { + let reply = provider_error(&inner, &request, CODE_REQUEST_COPY, None); + tokio::spawn(async move { send_reply(&inner, reply).await }); + } + Verdict::Copy(Some(stored)) => { + tokio::spawn(async move { + let _ = inner.control.write(&stored, MAX_FRAME_BYTES).await; + }); + } + Verdict::New => { + tokio::spawn(answer(inner, request)); + } + } +} + +/// Runs the request's handler and sends its signed reply, storing it for the +/// request's copies. +async fn answer(inner: Arc, request: VerifiedRequest) { + let handler = inner + .lock() + .served + .get(&(request.realm, request.procedure.clone())) + .and_then(|s| s.offer.handler.clone()); + let reply = match handler { + None => provider_error(&inner, &request, CODE_UNKNOWN_PROCEDURE, None), + Some(handler) => handled(&inner, handler, &request).await, + }; + let Ok(encoded) = cbor::encode(&reply) else { + inner.count("unencodable_reply"); + return; + }; + inner.admission.store(&request, encoded.clone()); + let _ = inner.control.write(&encoded, MAX_FRAME_BYTES).await; +} + +/// The handler's answer to `request` as a signed reply: its result, its +/// refusal as handler_error, a panic as temporary_relay_failure, or a result +/// the wire cannot carry as payload_too_large or unknown_error. +async fn handled(inner: &Inner, handler: Handler, request: &VerifiedRequest) -> Value { + let running = tokio::spawn(handler(Request { + caller: request.caller, + realm: request.realm, + procedure: request.procedure.clone(), + payload: request.payload.clone(), + token: request.token.clone(), + proofs: request.proofs.clone(), + deadline_ms: request.deadline, + })); + let abort = running.abort_handle(); + let left = (request.deadline as i64 - now_ms()).max(0) as u64; + let outcome = tokio::time::timeout(Duration::from_millis(left), running).await; + match outcome { + Err(_) => { + abort.abort(); + provider_error( + inner, + request, + CODE_HANDLER_ERROR, + Some("the request's deadline passed"), + ) + } + Ok(Err(_panicked)) => provider_error(inner, request, CODE_HANDLER_CRASHED, None), + Ok(Ok(Err(refusal))) => provider_error( + inner, + request, + CODE_HANDLER_ERROR, + Some(bounded_detail(&refusal)), + ), + Ok(Ok(Ok(payload))) => match frame::sign_result(request, &payload, None, &inner.key) { + Ok(signed) => signed, + Err(_) if cbor::encode(&payload).is_ok_and(|e| e.len() > frame::MAX_FRAME_BYTES) => { + provider_error(inner, request, CODE_PAYLOAD_TOO_LARGE, None) + } + Err(_) => provider_error(inner, request, CODE_UNSENDABLE, None), + }, + } +} + +/// This node's signed ERROR for `request`. The request verified with this +/// node as its target, so signing cannot fail on the key; a code or detail +/// out of bounds is a bug in this module. +fn provider_error( + inner: &Inner, + request: &VerifiedRequest, + code: &str, + detail: Option<&str>, +) -> Value { + frame::sign_provider_error(request, code, detail, None, &inner.key) + .unwrap_or_else(|e| panic!("station_link: a provider error that does not sign: {e}")) +} + +async fn send_reply(inner: &Inner, reply: Value) { + let _ = inner.write_control(&reply).await; +} + +/// `text` cut to 256 bytes on a character boundary. +pub(super) fn bounded_detail(text: &str) -> &str { + if text.len() <= MAX_DETAIL_BYTES { + return text; + } + let mut cut = MAX_DETAIL_BYTES; + while !text.is_char_boundary(cut) { + cut -= 1; + } + &text[..cut] +} diff --git a/src/station_link/stream.rs b/src/station_link/stream.rs new file mode 100644 index 0000000..3f05f24 --- /dev/null +++ b/src/station_link/stream.rs @@ -0,0 +1,760 @@ +//! Streaming RPC, as macula 12's link does it. Each session has a QUIC stream +//! of its own: the caller opens it with a signed STREAM_OPEN naming its mode, +//! the station opens one of its own to the provider and relays between them. +//! After the open, each side sends frames signed by its own key (the +//! provider's under MACULA-PQ-STREAM-V1, the caller's under +//! MACULA-PQ-CALLER-STREAM-V1), each numbered from 0 on its side and bound to +//! the open's request hash. +//! +//! A stream is released, both its QUIC directions finished, on every path: +//! when it ends normally, when either side aborts or refuses it, when its +//! inbox is over its bound, when its link ends, and when an open or an +//! accepted stream fails before a session exists. The bounds are macula +//! 12.3.0's: an open of at most 1 MiB, read within 10 seconds of a stream +//! being opened to the provider, and at most 16 MiB of a stream's frames +//! received and not yet read. + +use std::collections::VecDeque; +use std::future::Future; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; +use std::time::Duration; + +use tokio::sync::{watch, Notify}; + +use crate::cbor::{self, Value}; +use crate::frame::{ + self, RequestSpec, StreamEncoding, StreamFields, StreamMode, StreamRole, StreamState, + VerifiedRequest, +}; + +use super::admission::{Admission, SessionPlace, Verdict}; +use super::framing::{read_frame, FrameWriter, MAX_FRAME_BYTES}; +use super::serve::{bounded_detail, BoxFuture, StreamOffer, CODE_REQUEST_COPY}; +use super::{frame_type_of, now_ms, Inner, Link, LinkError}; + +const STREAM_OPEN_BYTES: usize = 1024 * 1024; +const STREAM_OPEN_WAIT: Duration = Duration::from_secs(10); +const STREAM_INBOX: usize = 16 * 1024 * 1024; + +/// How far ahead a STREAM_OPEN's deadline lies when its [`StreamCall`] names +/// none, as macula's default. +pub const DEFAULT_STREAM_DEADLINE: Duration = Duration::from_secs(30); + +/// The refusal codes of a STREAM_OPEN, besides the admission's own, as +/// macula's refuse_open sends them, and the code of a failed handler. +const CODE_STREAM_NOT_FOUND: &str = "not_found"; +const CODE_MODE_MISMATCH: &str = "mode_mismatch"; +const CODE_TOO_MANY_SESSIONS: &str = "too_many_sessions"; +const CODE_STREAM_HANDLER_ERROR: &str = "error"; + +/// Serves one streaming session. When it returns `Ok` and has not ended the +/// stream, the stream is closed on both sides; an `Err` or a panic aborts it +/// with code `error` and the error's text, as macula aborts a stream whose +/// handler failed. A handler still running when its stream ends is dropped. +pub type StreamHandler = Arc BoxFuture> + Send + Sync>; + +/// A [`StreamHandler`] from an async closure. +pub fn stream_handler(f: F) -> StreamHandler +where + F: Fn(Stream) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, +{ + Arc::new(move |s| Box::pin(f(s))) +} + +/// A streaming session to open: the realm and procedure, the provider it +/// targets, the mode, the open's payload, how far ahead its deadline lies +/// ([`DEFAULT_STREAM_DEADLINE`] when zero), and a UCAN and its proofs for a +/// gated procedure. Its default mode is server_stream. +#[derive(Debug, Clone, PartialEq)] +pub struct StreamCall { + pub realm: [u8; 32], + pub procedure: String, + pub target: [u8; 32], + pub mode: StreamMode, + pub payload: Value, + pub deadline: Duration, + pub token: Option>, + pub proofs: Vec>, +} + +impl Default for StreamCall { + fn default() -> Self { + StreamCall { + realm: [0; 32], + procedure: String::new(), + target: [0; 32], + mode: StreamMode::ServerStream, + payload: Value::Map(Vec::new()), + deadline: Duration::ZERO, + token: None, + proofs: Vec::new(), + } + } +} + +/// One frame the peer sent, verified: a chunk, the peer's end (role `Send` +/// ends its sending only, `Both` the stream), or the provider's terminal +/// value. +#[derive(Debug, Clone, PartialEq)] +pub enum StreamEvent { + Data { + encoding: StreamEncoding, + body: Value, + }, + End { + role: StreamRole, + }, + Reply { + payload: Value, + }, +} + +/// One streaming session, on either side. Cloning it shares the session. +#[derive(Clone)] +pub struct Stream { + inner: Arc, +} + +/// What a served stream's inbox holds is charged to its caller's budget in +/// the node's admission, and the session holds its place there. +struct Budget { + admission: Arc, + caller: [u8; 32], + place: Option, +} + +pub(super) struct StreamInner { + link: Arc, + writer: FrameWriter, + open: VerifiedRequest, + caller: bool, + /// Orders a frame's seq with its write. + send_seq: tokio::sync::Mutex, + state: Mutex, + budget: Mutex>, + notify: Notify, + done_tx: watch::Sender, +} + +#[derive(Default)] +struct StreamSide { + /// This side sent its last frame, or will send no more. + sent_end: bool, + /// The peer sent its last frame. + peer_ended: bool, + inbox: VecDeque<(StreamEvent, usize)>, + held: usize, + ended: bool, + err: Option, +} + +impl Stream { + /// The stream's verified STREAM_OPEN: its caller, procedure, mode and + /// payload. + pub fn request(&self) -> &VerifiedRequest { + &self.inner.open + } + + /// Sends a raw chunk. + pub async fn send(&self, body: &[u8]) -> Result<(), LinkError> { + self.inner + .send( + |seq| StreamFields::Data { + seq, + encoding: StreamEncoding::Raw, + body: Value::Bytes(body.to_vec()), + }, + false, + ) + .await + } + + /// Sends a structured chunk. + pub async fn send_value(&self, v: Value) -> Result<(), LinkError> { + self.inner + .send( + |seq| StreamFields::Data { + seq, + encoding: StreamEncoding::Msgpack, + body: v.clone(), + }, + false, + ) + .await + } + + /// Ends this side's sending; the peer may still send. + pub async fn close_send(&self) -> Result<(), LinkError> { + self.inner + .send( + |seq| StreamFields::End { + seq, + role: StreamRole::Send, + }, + true, + ) + .await + } + + /// Ends the stream on both sides. + pub async fn close(&self) -> Result<(), LinkError> { + let sent = self + .inner + .send( + |seq| StreamFields::End { + seq, + role: StreamRole::Both, + }, + true, + ) + .await; + StreamInner::end(&self.inner, None); + sent + } + + /// Sends the provider's terminal value and ends the stream. + pub async fn reply(&self, payload: Value) -> Result<(), LinkError> { + let sent = self + .inner + .send( + |seq| StreamFields::Reply { + seq, + payload: payload.clone(), + }, + true, + ) + .await; + StreamInner::end(&self.inner, None); + sent + } + + /// Ends the stream with a STREAM_ERROR of `code` and `message`. + pub async fn abort(&self, code: &str, message: &str) -> Result<(), LinkError> { + self.inner.abort(code, message).await + } + + /// The next frame the peer sent. After the stream ends, once every event + /// before it is read, it returns [`LinkError::EndOfStream`] for a normal + /// end and the error that ended it otherwise. + pub async fn recv(&self) -> Result { + loop { + let notified = self.inner.notify.notified(); + { + let mut side = self.inner.side(); + if let Some((event, size)) = side.inbox.pop_front() { + side.held -= size; + drop(side); + self.inner.release_inbox(size); + return Ok(event); + } + if side.ended { + return Err(side.err.clone().unwrap_or(LinkError::EndOfStream)); + } + } + notified.await; + } + } + + /// Waits until the stream has ended and been released, and says why: + /// `None` for a normal end. + pub async fn done(&self) -> Option { + let mut done = self.inner.done_tx.subscribe(); + let _ = done.wait_for(|ended| *ended).await; + self.inner.side().err.clone() + } +} + +impl StreamInner { + fn new( + link: Arc, + send: quinn::SendStream, + open: VerifiedRequest, + caller: bool, + ) -> Arc { + Arc::new(StreamInner { + link, + writer: FrameWriter::new(send), + open, + caller, + send_seq: tokio::sync::Mutex::new(0), + state: Mutex::new(StreamSide::default()), + budget: Mutex::new(None), + notify: Notify::new(), + done_tx: watch::channel(false).0, + }) + } + + fn side(&self) -> MutexGuard<'_, StreamSide> { + self.state.lock().unwrap_or_else(|p| p.into_inner()) + } + + fn budget(&self) -> MutexGuard<'_, Option> { + self.budget.lock().unwrap_or_else(|p| p.into_inner()) + } + + /// Signs the fields `at(seq)` builds at this side's next seq and writes + /// them; `last` marks this side's last frame, after which its QUIC + /// direction is finished. + async fn send( + self: &Arc, + at: impl FnOnce(u64) -> StreamFields, + last: bool, + ) -> Result<(), LinkError> { + let mut seq = self.send_seq.lock().await; + if self.side().sent_end { + return Err(LinkError::StreamClosed); + } + let fields = at(*seq); + let signed = if self.caller { + frame::sign_caller_stream(&fields, &self.open, &self.link.key)? + } else { + frame::sign_provider_stream(&fields, &self.open, &self.link.key)? + }; + let encoded = cbor::encode(&signed) + .map_err(|e| LinkError::Frame(frame::FrameError::Payload(e.to_string())))?; + if let Err(e) = self.writer.write(&encoded, MAX_FRAME_BYTES).await { + self.side().sent_end = true; + return Err(e); + } + *seq += 1; + if last { + let peer_ended = { + let mut side = self.side(); + side.sent_end = true; + side.peer_ended + }; + self.writer.finish().await; + if peer_ended { + StreamInner::end(self, None); + } + } + Ok(()) + } + + async fn abort(self: &Arc, code: &str, message: &str) -> Result<(), LinkError> { + let sent = self + .send( + |seq| StreamFields::Error { + seq, + code: code.to_string(), + message: message.to_string(), + }, + true, + ) + .await; + StreamInner::end( + self, + Some(LinkError::Stream { + code: code.to_string(), + message: message.to_string(), + relay: false, + }), + ); + sent + } + + /// Queues `event` for recv, refusing it when it would take the inbox, or + /// the node's budget for served streams, past its bound. + fn deliver(&self, event: StreamEvent, size: usize) -> bool { + { + let mut side = self.side(); + if side.held + size > STREAM_INBOX { + return false; + } + if let Some(budget) = &*self.budget() { + if !budget.admission.charge_inbox(budget.caller, size) { + return false; + } + } + side.inbox.push_back((event, size)); + side.held += size; + } + self.notify.notify_one(); + true + } + + fn release_inbox(&self, size: usize) { + if let Some(budget) = &*self.budget() { + budget.admission.release_inbox(budget.caller, size); + } + } + + /// Ends the stream after the peer's last frame: this side sends no more, + /// and `err` is why it ended, `None` for a normal end. + fn peer_finished(self: &Arc, err: Option) { + self.side().peer_ended = true; + StreamInner::end(self, err); + } + + /// Aborts the stream from this side for a fault it found in what the + /// peer sent, or an inbox over its bound, telling the peer when it still + /// can. + async fn fail(self: &Arc, code: &str, cause: Option) { + let message = cause + .as_deref() + .map(bounded_detail) + .unwrap_or("") + .to_string(); + let _ = self.abort(code, &message).await; + } + + /// Releases the stream once: its sending side finished after this side's + /// last frame and reset otherwise, its reader stopped, what its inbox + /// held and its session's place given back. + pub(super) fn end(this: &Arc, err: Option) { + let graceful = { + let mut side = this.side(); + if side.ended { + return; + } + side.ended = true; + if side.err.is_none() { + side.err = err; + } + let graceful = side.sent_end; + side.sent_end = true; + graceful + }; + if !graceful { + let released = this.clone(); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { released.writer.reset().await }); + } + } + if let Some(budget) = this.budget().take() { + let held = std::mem::take(&mut this.side().held); + budget.admission.release_inbox(budget.caller, held); + drop(budget.place); + } + this.link + .lock() + .streams + .retain(|w| w.strong_count() > 0 && !std::ptr::eq(w.as_ptr(), Arc::as_ptr(this))); + let _ = this.done_tx.send_replace(true); + this.notify.notify_waiters(); + this.notify.notify_one(); + } +} + +/// Keeps `s` among the link's streams, ended with the link; false once the +/// link has ended. +fn hold_stream(inner: &Inner, s: &Arc) -> bool { + let mut state = inner.lock(); + if state.ended.is_some() { + return false; + } + state.streams.push(Arc::downgrade(s)); + true +} + +/// Releases a QUIC stream no session holds, in both directions. +fn abandon(mut send: quinn::SendStream, mut recv: quinn::RecvStream) { + let _ = send.reset(0u32.into()); + let _ = recv.stop(0u32.into()); +} + +impl Link { + /// Opens a streaming session: a QUIC stream of its own, on which it + /// writes the signed STREAM_OPEN. A stream it opens but cannot write the + /// open on is released before the error returns. + pub async fn open_stream(&self, c: StreamCall) -> Result { + let inner = &self.inner; + let deadline = if c.deadline.is_zero() { + DEFAULT_STREAM_DEADLINE + } else { + c.deadline + }; + let mut request_id = [0u8; 16]; + aws_lc_rs::rand::fill(&mut request_id) + .map_err(|_| LinkError::Io("no randomness".into()))?; + let signed = frame::sign_stream_open( + &RequestSpec { + request_id, + realm: c.realm, + procedure: c.procedure, + target: c.target, + deadline: (now_ms() + deadline.as_millis() as i64) as u64, + payload: c.payload, + mode: Some(c.mode), + token: c.token, + proofs: c.proofs, + source_route: None, + retry_budget: None, + }, + &inner.key, + )?; + let encoded = cbor::encode(&signed) + .map_err(|e| LinkError::Frame(frame::FrameError::Payload(e.to_string())))?; + if encoded.len() > STREAM_OPEN_BYTES { + return Err(LinkError::StreamOpenTooLarge(encoded.len())); + } + let open = frame::verify_request(&signed, inner.profile)?; + let state = frame::open_stream(&open)?; + let (send, recv) = inner + .connection + .open_bi() + .await + .map_err(|e| LinkError::Io(format!("open a stream: {e}")))?; + let s = StreamInner::new(inner.clone(), send, open, true); + let held = hold_stream(inner, &s); + let written = match held { + true => s.writer.write(&encoded, STREAM_OPEN_BYTES).await, + false => Err(inner.lock().ended.clone().unwrap_or(LinkError::Closed)), + }; + if let Err(e) = written { + StreamInner::end(&s, Some(e.clone())); + let mut recv = recv; + let _ = recv.stop(0u32.into()); + return Err(e); + } + tokio::spawn(read(s.clone(), recv, state)); + Ok(Stream { inner: s }) + } +} + +/// Takes each stream the station opens to this link, until the link ends. +pub(super) async fn accept_streams(link: Weak) { + let Some(connection) = link.upgrade().map(|l| l.connection.clone()) else { + return; + }; + while let Ok((send, recv)) = connection.accept_bi().await { + tokio::spawn(incoming(link.clone(), send, recv)); + } +} + +/// Reads a stream's first frame within 10 seconds and starts the session it +/// opens, or refuses it. A stream that fails before a session exists is +/// released: one that does not deliver a STREAM_OPEN in time, whose first +/// frame is not one, that does not verify or targets another node is dropped +/// without a word, and one the provider refuses is told why at seq 0. +async fn incoming(link: Weak, send: quinn::SendStream, mut recv: quinn::RecvStream) { + let Some(inner) = link.upgrade() else { return }; + let payload = match tokio::time::timeout( + STREAM_OPEN_WAIT, + read_frame(&mut recv, STREAM_OPEN_BYTES), + ) + .await + { + Ok(Ok(payload)) => payload, + _ => { + inner.count("stream_open_unread"); + abandon(send, recv); + return; + } + }; + let v = match cbor::decode(&payload) { + Ok(v) if frame_type_of(&v) == "stream_open" => v, + _ => { + inner.count("stream_open_malformed"); + abandon(send, recv); + return; + } + }; + let Ok(open) = frame::verify_request(&v, inner.profile) else { + inner.count("stream_open_unverified"); + abandon(send, recv); + return; + }; + if open.target != inner.self_id { + inner.count("stream_for_another_node"); + abandon(send, recv); + return; + } + let Ok(state) = frame::open_stream(&open) else { + abandon(send, recv); + return; + }; + let s = StreamInner::new(inner.clone(), send, open.clone(), false); + let offer = match admit_stream(&inner, &open) { + Ok(offer) => offer, + Err(code) => return refuse(&s, code, recv).await, + }; + let Some(place) = inner.admission.open_session(open.caller) else { + return refuse(&s, CODE_TOO_MANY_SESSIONS, recv).await; + }; + *s.budget() = Some(Budget { + admission: inner.admission.clone(), + caller: open.caller, + place: Some(place), + }); + if !hold_stream(&inner, &s) { + StreamInner::end(&s, Some(LinkError::Closed)); + let _ = recv.stop(0u32.into()); + return; + } + tokio::spawn(read(s.clone(), recv, state)); + tokio::spawn(serve(s, offer)); +} + +/// Judges an open as macula's link does, in its order: the admission (one +/// run per request, the deadline window, its bounds), the procedure served +/// here as a stream, and its mode. The offer, or the code to refuse with. +fn admit_stream(inner: &Inner, open: &VerifiedRequest) -> Result { + match inner.admission.admit(open, &inner.share, now_ms()) { + Verdict::Refused(code) => return Err(code), + Verdict::Copy(_) => return Err(CODE_REQUEST_COPY), + Verdict::New => {} + } + let state = inner.lock(); + let offer = state + .served + .get(&(open.realm, open.procedure.clone())) + .and_then(|s| s.offer.stream.clone()) + .ok_or(CODE_STREAM_NOT_FOUND)?; + if Some(offer.mode) != open.mode { + return Err(CODE_MODE_MISMATCH); + } + Ok(offer) +} + +/// Answers an open with a STREAM_ERROR of `code` at seq 0 and releases the +/// stream. +async fn refuse(s: &Arc, code: &str, mut recv: quinn::RecvStream) { + s.link.count(&format!("stream_refused_{code}")); + let _ = s.abort(code, "").await; + let _ = recv.stop(0u32.into()); +} + +/// Runs the handler for the session, and ends the stream as the handler +/// leaves it: closed when it returns `Ok` without ending it, aborted with its +/// error or panic. A handler still running when the stream ends is dropped. +async fn serve(s: Arc, offer: StreamOffer) { + let stream = Stream { inner: s.clone() }; + let mut running = tokio::spawn((offer.handler)(stream.clone())); + let mut done = s.done_tx.subscribe(); + let outcome = tokio::select! { + outcome = &mut running => outcome, + _ = done.wait_for(|ended| *ended) => { + running.abort(); + return; + } + }; + match outcome { + Ok(Ok(())) => { + let _ = stream.close().await; + } + Ok(Err(e)) => { + let _ = stream + .abort(CODE_STREAM_HANDLER_ERROR, bounded_detail(&e)) + .await; + } + Err(panicked) => { + let _ = stream + .abort( + CODE_STREAM_HANDLER_ERROR, + bounded_detail(&panicked.to_string()), + ) + .await; + } + } +} + +/// Verifies the peer's frames until the stream ends. It is the stream's one +/// reader, the only holder of its verifier state; when it returns, its +/// receiving side is dropped, which stops it. +async fn read(s: Arc, mut recv: quinn::RecvStream, mut state: StreamState) { + let mut done = s.done_tx.subscribe(); + loop { + let payload = tokio::select! { + _ = done.wait_for(|ended| *ended) => return, + payload = read_frame(&mut recv, MAX_FRAME_BYTES) => payload, + }; + let payload = match payload { + Ok(payload) => payload, + Err(e) => return read_ended(&s, e), + }; + match received(&s, &payload, &state).await { + Some(next) => state = next, + None => return, + } + } +} + +/// Ends a stream whose peer direction finished: after the peer's last frame +/// that is expected, and before it the stream was lost. +fn read_ended(s: &Arc, e: LinkError) { + if s.side().peer_ended { + return; + } + let err = s.link.lock().ended.clone().unwrap_or(e); + StreamInner::end(s, Some(err)); +} + +/// Handles one frame from the peer; the next verifier state, or `None` when +/// reading stops. +async fn received( + s: &Arc, + payload: &[u8], + state: &StreamState, +) -> Option { + let v = match cbor::decode(payload) { + Ok(v) => v, + Err(e) => { + s.fail("malformed_frame", Some(e.to_string())).await; + return None; + } + }; + if s.caller && v.get("relay_error").is_some() { + match frame::verify_relay_error(&v, &s.open, s.link.profile, &s.link.station.node_id) { + Ok(relayed) => s.peer_finished(Some(LinkError::Stream { + code: relayed.code, + message: String::new(), + relay: true, + })), + Err(e) => s.fail("malformed_frame", Some(e.to_string())).await, + } + return None; + } + let verified = if s.caller { + frame::verify_provider_stream(&v, state, s.link.profile) + } else { + frame::verify_caller_stream(&v, state, s.link.profile) + }; + let (verified, next) = match verified { + Ok(verified) => verified, + Err(e) => { + s.fail("malformed_frame", Some(e.to_string())).await; + return None; + } + }; + let size = payload.len(); + match verified.fields { + StreamFields::Error { code, message, .. } => { + s.peer_finished(Some(LinkError::Stream { + code, + message, + relay: false, + })); + None + } + StreamFields::Reply { payload, .. } => { + s.deliver(StreamEvent::Reply { payload }, size); + s.peer_finished(None); + None + } + StreamFields::End { role, .. } => { + s.deliver(StreamEvent::End { role }, size); + if role == StreamRole::Both { + s.peer_finished(None); + return None; + } + let mine = { + let mut side = s.side(); + side.peer_ended = true; + side.sent_end + }; + if mine { + StreamInner::end(s, None); + } + None + } + StreamFields::Data { encoding, body, .. } => { + if !s.deliver(StreamEvent::Data { encoding, body }, size) { + s.fail("resource_exhausted", None).await; + return None; + } + Some(next) + } + } +} diff --git a/src/stream.rs b/src/stream.rs deleted file mode 100644 index d77cf70..0000000 --- a/src/stream.rs +++ /dev/null @@ -1,676 +0,0 @@ -//! General-purpose streaming RPC, caller/consumer role (§13.1 of -//! `plans/PLAN_WIRE_PROTOCOL.md`), ported from `macula_stream_sink.erl`. -//! Like content transfer (`src/content.rs`), this is not a separate wire -//! mechanism: it runs the frame types built in `src/frame.rs` §13 over a -//! dedicated QUIC stream, opened via -//! [`Session::open_dedicated_stream`](crate::connection::Session::open_dedicated_stream) -//! rather than the control stream. -//! -//! **Both roles are built.** Caller/consumer (§13.1) opens a stream and -//! is the natural fit for pulling/pushing against a procedure that -//! already exists somewhere. Provider (§13.2) advertises a procedure -//! (§6.9, [`Session::advertise`](crate::connection::Session::advertise)) -//! and answers inbound STREAM_OPENs the station routes back — -//! [`Session::accept_dedicated_stream`](crate::connection::Session::accept_dedicated_stream) -//! accepts the fresh dedicated stream the station opens toward us, -//! [`StreamHandle::accept`] reads and parses the STREAM_OPEN that's -//! always its first frame. Both roles end up holding the same -//! [`StreamHandle`] afterward — a stream's wire vocabulary -//! (STREAM_DATA/END/ERROR/REPLY) is symmetric regardless of which side -//! opened it, so `send_data`/`recv`/`close_send`/`abort` all mean the -//! same thing either way. [`StreamHandle::send_reply`] is the one -//! provider-only addition: sending the terminal STREAM_REPLY a -//! `client_stream`/`bidi` caller's own -//! [`await_reply`](StreamHandle::await_reply) is waiting on. -//! -//! Caller/consumer usage, matching the reference's own pattern: -//! 1. [`StreamHandle::open`] sends STREAM_OPEN and returns a handle once -//! the frame is on the wire — there's no open-time acknowledgement to -//! wait for; the provider starts reacting to it directly. -//! 2. Drive a receive loop with [`StreamHandle::recv`] until -//! [`StreamItem::Eof`] or an error. -//! 3. For `client_stream`/`bidi` modes wanting a result: -//! [`StreamHandle::send_data`] each chunk in order, -//! [`StreamHandle::close_send`] when done, then -//! [`StreamHandle::await_reply`]. -//! 4. **Non-normal termination must call [`StreamHandle::abort`], not -//! just drop the handle** — the peer's only signal to tell a -//! cancellation/failure apart from a dropped connection -//! (`plans/PLAN_WIRE_PROTOCOL.md` §13.1, point 4). -//! -//! Provider usage: -//! 1. [`Session::advertise`](crate::connection::Session::advertise) once -//! per procedure this session will answer. -//! 2. Loop on [`StreamHandle::accept`], which blocks for the next -//! inbound STREAM_OPEN and hands back a ready-to-use handle plus the -//! parsed [`frame::StreamOpenInfo`] (check its `procedure` — a single -//! connection's dedicated streams aren't partitioned by which -//! procedure they're for, so a session that's advertised more than -//! one needs this to route). -//! 3. Drive it exactly like the caller side, from the opposite chair: -//! `server_stream` mode pushes with `send_data`/`close_send`; -//! `client_stream` mode drains with `recv` and finishes with -//! `send_reply`. - -use std::time::Duration; - -use crate::cbor::Value; -use crate::connection::{FrameStream, RecvFrameError, SendFrameError, Session}; -use crate::control_channel::drop_warning::{self, Kind, Reason, Subject}; -use crate::frame::{self, StreamEncoding, StreamMode, StreamRole}; -use crate::identity::KeyPair; - -pub struct StreamHandle { - stream: FrameStream, - pub stream_id: [u8; 16], - pub mode: StreamMode, - seq_out: u64, -} - -#[derive(Debug)] -pub enum OpenError { - OpenStream(quinn::ConnectionError), - Send(SendFrameError), -} - -impl std::fmt::Display for OpenError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - OpenError::OpenStream(e) => write!(f, "opening a dedicated stream: {e}"), - OpenError::Send(e) => write!(f, "sending stream_open: {e}"), - } - } -} - -impl std::error::Error for OpenError {} - -#[derive(Debug)] -pub enum AcceptError { - AcceptStream(quinn::ConnectionError), - Timeout, - Recv(RecvFrameError), -} - -/// The application error code a refused inbound stream is aborted with, in -/// both directions: RESET_STREAM on its send half and STOP_SENDING on its -/// receive half. The same code in every Macula stack. -pub const REFUSED_STREAM: u32 = 2; - -/// What became of an inbound dedicated stream that didn't open. -enum Inbound { - /// Refused, with nothing written and no handler run. - Refused { - reason: Reason, - subject: Subject, - }, - Failed(AcceptError), -} - -fn refuse(stream: FrameStream, reason: Reason, subject: Subject) -> Inbound { - stream.abort_both(REFUSED_STREAM); - Inbound::Refused { reason, subject } -} - -impl std::fmt::Display for AcceptError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - AcceptError::AcceptStream(e) => write!(f, "accepting a dedicated stream: {e}"), - AcceptError::Timeout => write!(f, "no inbound stream within the given timeout"), - AcceptError::Recv(e) => write!(f, "reading the stream's first frame: {e}"), - } - } -} - -impl std::error::Error for AcceptError {} - -/// One item [`StreamHandle::recv`] hands back: a chunk, or a clean -/// end-of-stream. -#[derive(Debug, Clone)] -pub enum StreamItem { - Data { - seq: u64, - encoding: StreamEncoding, - body: Value, - }, - Eof, -} - -#[derive(Debug)] -pub enum RecvStreamError { - Recv(RecvFrameError), - Parse(frame::ParseStreamEventError), - /// The peer sent an explicit STREAM_ERROR abort. - PeerAborted { - code: String, - message: String, - }, - /// A frame for a *different* stream_id arrived on this stream — - /// never expected on a dedicated stream with a well-behaved peer, - /// surfaced distinctly rather than silently accepted. - StreamIdMismatch, - /// A frame arrived that isn't valid in the context this call is - /// waiting in — e.g. [`StreamHandle::recv`] got a STREAM_REPLY - /// (only [`StreamHandle::await_reply`] expects one), or - /// `await_reply` got a STREAM_DATA/STREAM_END before any reply. - UnexpectedFrame, -} - -impl std::fmt::Display for RecvStreamError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - RecvStreamError::Recv(e) => write!(f, "{e}"), - RecvStreamError::Parse(e) => write!(f, "{e}"), - RecvStreamError::PeerAborted { code, message } => { - write!(f, "peer aborted the stream: {code} ({message})") - } - RecvStreamError::StreamIdMismatch => { - write!(f, "received a frame for a different stream_id") - } - RecvStreamError::UnexpectedFrame => { - write!(f, "received a frame not valid in this context") - } - } - } -} - -impl std::error::Error for RecvStreamError {} - -impl StreamHandle { - /// Open a dedicated stream on `session`'s connection and send a - /// signed STREAM_OPEN. Fire-and-forget at the wire level — no reply - /// is expected here; drive [`recv`](Self::recv) (for - /// `server_stream`/`bidi`) or [`send_data`](Self::send_data) (for - /// `client_stream`/`bidi`) next, depending on `mode`. - pub async fn open( - session: &Session, - procedure: &str, - realm: [u8; 32], - mode: StreamMode, - args: Value, - deadline_ms: i128, - identity: &KeyPair, - ) -> Result { - let stream = session - .open_dedicated_stream() - .await - .map_err(OpenError::OpenStream)?; - Self::open_on(stream, procedure, realm, mode, args, deadline_ms, identity).await - } - - /// [`open`](Self::open), over a dedicated stream already open to the - /// station. - pub(crate) async fn open_on( - mut stream: FrameStream, - procedure: &str, - realm: [u8; 32], - mode: StreamMode, - args: Value, - deadline_ms: i128, - identity: &KeyPair, - ) -> Result { - let stream_id: [u8; 16] = rand::random(); - let spec = frame::StreamOpenSpec::new( - stream_id, - procedure, - realm, - mode, - args, - deadline_ms, - identity.node_id(), - ); - let signed = frame::sign(frame::stream_open(&spec), identity); - stream.send_frame(signed).await.map_err(OpenError::Send)?; - Ok(Self { - stream, - stream_id, - mode, - seq_out: 0, - }) - } - - /// Provider role: block for the next inbound STREAM_OPEN on - /// `session`'s connection, bounded by `timeout`. Only ever succeeds - /// after [`Session::advertise`](crate::connection::Session::advertise) - /// has registered at least one procedure — otherwise the station has - /// nothing to route here. Returns the ready-to-use handle alongside - /// the parsed [`frame::StreamOpenInfo`] (check its `procedure` if - /// this session advertised more than one). - /// - /// The app decides whether to serve a stream it accepts. One it refuses - /// should get a STREAM_ERROR with macula's codes, `unauthorized` when the - /// caller may not use the procedure and `not_found` for a procedure it - /// doesn't serve, sent with [`refuse`](Self::refuse), so a caller sees the - /// same refusal from every stack. - pub async fn accept( - session: &Session, - timeout: Duration, - ) -> Result<(Self, frame::StreamOpenInfo), AcceptError> { - let deadline = tokio::time::Instant::now() + timeout; - loop { - let stream = tokio::time::timeout_at(deadline, session.accept_dedicated_stream()) - .await - .map_err(|_| AcceptError::Timeout)? - .map_err(AcceptError::AcceptStream)?; - match tokio::time::timeout_at(deadline, Self::open_inbound(stream)).await { - Err(_) => return Err(AcceptError::Timeout), - Ok(Ok(opened)) => return Ok(opened), - Ok(Err(Inbound::Refused { reason, subject })) => { - session - .drop_warnings() - .record(Kind::RefusedStreamOpen, reason, subject); - } - Ok(Err(Inbound::Failed(e))) => return Err(e), - } - } - } - - /// Opens `stream`, an inbound dedicated stream, when its first frame is a - /// STREAM_OPEN signed by the caller it names, with that caller in map - /// args as a CALL handler gets it. Any other first frame gets the stream - /// refused before anything else looks at it: both halves are aborted - /// with [`REFUSED_STREAM`] and nothing is written, as macula does. - async fn open_inbound( - mut stream: FrameStream, - ) -> Result<(Self, frame::StreamOpenInfo), Inbound> { - let first = match stream.recv_frame().await { - Ok(first) => first, - Err(RecvFrameError::Decode(_)) => { - return Err(refuse(stream, Reason::Malformed, Subject::Nothing)) - } - Err(e) => return Err(Inbound::Failed(AcceptError::Recv(e))), - }; - if !matches!(first.get("frame_type"), Some(Value::Text(t)) if t == "stream_open") { - return Err(refuse(stream, Reason::NotAStreamOpen, Subject::Nothing)); - } - if let Err(reason) = drop_warning::signed_caller(&first) { - return Err(refuse(stream, reason, drop_warning::procedure_of(&first))); - } - let Ok(mut open) = frame::parse_stream_open(&first) else { - return Err(refuse( - stream, - Reason::Malformed, - drop_warning::procedure_of(&first), - )); - }; - open.args = crate::connection::with_caller(open.args, open.caller); - let handle = Self { - stream, - stream_id: open.stream_id, - mode: open.mode, - seq_out: 0, - }; - Ok((handle, open)) - } - - /// Provider role: send the terminal STREAM_REPLY a `client_stream`/ - /// `bidi` caller's own [`await_reply`](Self::await_reply) is waiting - /// on, once this side has fully consumed and verified whatever the - /// caller streamed. - pub async fn send_reply( - &mut self, - payload: Value, - identity: &KeyPair, - ) -> Result<(), SendFrameError> { - let spec = frame::StreamReplySpec::new(self.stream_id, payload, identity.node_id()); - let signed = frame::sign(frame::stream_reply(&spec), identity); - self.stream.send_frame(signed).await - } - - /// Send one chunk. `seq` is tracked internally, starting at 0 and - /// incrementing per call — matches the reference's `seq_out` counter - /// (a sanity/debugging signal, not used for reordering: frames - /// arrive in order on a single QUIC stream by construction). - pub async fn send_data( - &mut self, - encoding: StreamEncoding, - body: Value, - identity: &KeyPair, - ) -> Result<(), SendFrameError> { - let spec = frame::StreamDataSpec::new( - self.stream_id, - self.seq_out, - encoding, - body, - Some(identity.public_bytes()), - ); - self.seq_out += 1; - let signed = frame::sign(frame::stream_data(&spec), identity); - self.stream.send_frame(signed).await - } - - /// Half-close: signal this side is done sending. For - /// `client_stream`/`bidi` modes, follow with - /// [`await_reply`](Self::await_reply). - pub async fn close_send(&mut self, identity: &KeyPair) -> Result<(), SendFrameError> { - let spec = frame::StreamEndSpec::new( - self.stream_id, - StreamRole::Send, - Some(identity.public_bytes()), - ); - let signed = frame::sign(frame::stream_end(&spec), identity); - self.stream.send_frame(signed).await - } - - /// Receive the next chunk or end-of-stream, bounded by `timeout`. - pub async fn recv(&mut self, timeout: Duration) -> Result { - let value = self - .stream - .recv_frame_timeout(timeout) - .await - .map_err(RecvStreamError::Recv)?; - match frame::parse_stream_event(&value).map_err(RecvStreamError::Parse)? { - frame::StreamEvent::Data { - stream_id, - seq, - encoding, - body, - } => { - self.check_stream_id(stream_id)?; - Ok(StreamItem::Data { - seq, - encoding, - body, - }) - } - frame::StreamEvent::End { stream_id, role: _ } => { - self.check_stream_id(stream_id)?; - Ok(StreamItem::Eof) - } - frame::StreamEvent::Error { - stream_id, - code, - message, - } => { - self.check_stream_id(stream_id)?; - Err(RecvStreamError::PeerAborted { code, message }) - } - frame::StreamEvent::Reply { .. } => Err(RecvStreamError::UnexpectedFrame), - } - } - - /// Block for the provider's terminal STREAM_REPLY (`client_stream`/ - /// `bidi` modes only) — call after [`close_send`](Self::close_send). - pub async fn await_reply( - &mut self, - timeout: Duration, - ) -> Result<(Value, [u8; 32]), RecvStreamError> { - let value = self - .stream - .recv_frame_timeout(timeout) - .await - .map_err(RecvStreamError::Recv)?; - match frame::parse_stream_event(&value).map_err(RecvStreamError::Parse)? { - frame::StreamEvent::Reply { - stream_id, - payload, - responded_by, - } => { - self.check_stream_id(stream_id)?; - Ok((payload, responded_by)) - } - frame::StreamEvent::Error { - stream_id, - code, - message, - } => { - self.check_stream_id(stream_id)?; - Err(RecvStreamError::PeerAborted { code, message }) - } - frame::StreamEvent::Data { .. } | frame::StreamEvent::End { .. } => { - Err(RecvStreamError::UnexpectedFrame) - } - } - } - - fn check_stream_id(&self, stream_id: [u8; 16]) -> Result<(), RecvStreamError> { - if stream_id == self.stream_id { - Ok(()) - } else { - Err(RecvStreamError::StreamIdMismatch) - } - } - - /// Non-normal termination: explicitly tell the peer this stream is - /// aborting, per §13.1 point 4 — the only signal the peer gets to - /// distinguish a cancellation/failure from a dropped connection. - /// Best-effort, like [`Session::close`](crate::connection::Session::close)'s - /// GOODBYE — consumes `self` so the handle can't be used again after - /// aborting. - pub async fn abort( - mut self, - code: impl Into, - message: impl Into, - identity: &KeyPair, - ) { - let spec = frame::StreamErrorSpec::new( - self.stream_id, - code, - message, - Some(identity.public_bytes()), - ); - let signed = frame::sign(frame::stream_error(&spec), identity); - let _ = self.stream.send_frame(signed).await; - } - - /// Refuses a stream this provider accepted and won't serve: writes a - /// STREAM_ERROR with `code` and `message`, macula's `unauthorized` or - /// `not_found`, then finishes the send half so the error reaches the caller - /// and stops reading with [`REFUSED_STREAM`]. When the STREAM_ERROR can't - /// be written, the stream is aborted in both directions with - /// [`REFUSED_STREAM`] instead. - pub async fn refuse( - mut self, - code: impl Into, - message: impl Into, - identity: &KeyPair, - ) -> Result<(), SendFrameError> { - let spec = frame::StreamErrorSpec::new( - self.stream_id, - code, - message, - Some(identity.public_bytes()), - ); - let signed = frame::sign(frame::stream_error(&spec), identity); - if let Err(e) = self.stream.send_frame(signed).await { - self.stream.abort_both(REFUSED_STREAM); - return Err(e); - } - self.stream.finish_and_stop_reading(REFUSED_STREAM); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - //! An inbound dedicated stream over a real QUIC connection to a local - //! endpoint, so a refusal's abort codes reach the opener as they would - //! from a station. The names match the Go, .NET and Erlang tests. - use super::*; - use crate::transport::Trust; - - const REALM: [u8; 32] = [7; 32]; - - /// A connection to a local endpoint, the endpoint's side of it, and the - /// endpoint, which has to outlive both. - async fn local_connection() -> (quinn::Connection, quinn::Connection, quinn::Endpoint) { - let key_pair = rcgen::KeyPair::generate().expect("a key pair"); - let certificate = rcgen::CertificateParams::new(vec!["localhost".to_string()]) - .expect("certificate params") - .self_signed(&key_pair) - .expect("a self-signed certificate"); - let key = rustls::pki_types::PrivateKeyDer::Pkcs8(key_pair.serialize_der().into()); - let mut crypto = macula_pqc::server_builder() - .with_no_client_auth() - .with_single_cert(vec![certificate.der().clone()], key) - .expect("a server certificate"); - // The client only talks to a peer that speaks macula's ALPN protocol. - crypto.alpn_protocols = vec![crate::transport::ALPN.to_vec()]; - let config = quinn::ServerConfig::with_crypto(std::sync::Arc::new( - quinn::crypto::rustls::QuicServerConfig::try_from(crypto) - .expect("a QUIC server config"), - )); - let endpoint = - quinn::Endpoint::server(config, ([127, 0, 0, 1], 0).into()).expect("a local endpoint"); - let port = endpoint.local_addr().expect("a local address").port(); - let (opener, provider) = tokio::join!( - crate::transport::connect("127.0.0.1", port, Trust::Insecure), - async { - endpoint - .accept() - .await - .expect("an incoming connection") - .await - .expect("the connection completes") - } - ); - (opener.expect("the opener connects"), provider, endpoint) - } - - /// Opens a stream from `opener` that starts with `first`, and takes the - /// provider's side of it. - async fn opened_with( - opener: &quinn::Connection, - provider: &quinn::Connection, - first: &[u8], - ) -> (quinn::SendStream, quinn::RecvStream, FrameStream) { - let (mut send, recv) = opener.open_bi().await.expect("a stream opens"); - send.write_all(first) - .await - .expect("the first bytes are written"); - let (provider_send, provider_recv) = - provider.accept_bi().await.expect("the stream arrives"); - (send, recv, FrameStream::new(provider_send, provider_recv)) - } - - fn stream_open(caller: &KeyPair, args: Value) -> Value { - frame::stream_open(&frame::StreamOpenSpec::new( - rand::random(), - "app/stream", - REALM, - StreamMode::ServerStream, - args, - 0, - caller.node_id(), - )) - } - - fn encoded(frame: &Value) -> Vec { - frame::encode(frame).expect("the frame encodes") - } - - #[tokio::test] - async fn a_stream_open_not_signed_by_its_caller_is_refused() { - let (opener, provider, _endpoint) = local_connection().await; - let caller = KeyPair::generate(); - let refused_code = quinn::VarInt::from_u32(REFUSED_STREAM); - let first_frames = [ - ( - encoded(&frame::sign( - stream_open(&caller, Value::Null), - &KeyPair::generate(), - )), - Reason::InvalidSignature, - ), - ( - encoded(&stream_open(&caller, Value::Null)), - Reason::Unsigned, - ), - ( - encoded(&Value::Map(vec![( - Value::text("frame_type"), - Value::text("call"), - )])), - Reason::NotAStreamOpen, - ), - // A one-byte frame whose CBOR initial byte uses a reserved value. - (vec![0, 0, 0, 1, 0x1C], Reason::Malformed), - ]; - - for (first, expected) in first_frames { - let (send, mut recv, inbound) = opened_with(&opener, &provider, &first).await; - - let refused = StreamHandle::open_inbound(inbound).await; - - assert!( - matches!(refused, Err(Inbound::Refused { reason, .. }) if reason == expected), - "expected the stream refused as {expected:?}" - ); - assert!(matches!(send.stopped().await, Ok(Some(code)) if code == refused_code)); - assert!(matches!( - recv.read(&mut [0; 1]).await, - Err(quinn::ReadError::Reset(code)) if code == refused_code - )); - } - - let genuine = encoded(&frame::sign(stream_open(&caller, Value::Null), &caller)); - let (_send, _recv, inbound) = opened_with(&opener, &provider, &genuine).await; - let opened = StreamHandle::open_inbound(inbound).await; - assert!(matches!(opened, Ok((_, ref info)) if info.caller == caller.node_id())); - } - - #[tokio::test] - async fn a_refused_stream_writes_its_error_then_finishes_and_stops_reading() { - let (opener, provider, _endpoint) = local_connection().await; - let caller = KeyPair::generate(); - let first = encoded(&frame::sign(stream_open(&caller, Value::Null), &caller)); - let (send, recv, inbound) = opened_with(&opener, &provider, &first).await; - let Ok((handle, _)) = StreamHandle::open_inbound(inbound).await else { - panic!("a STREAM_OPEN signed by its caller opens"); - }; - let stopped = send.stopped(); - - handle - .refuse( - "unauthorized", - "not authorized for this procedure", - &KeyPair::generate(), - ) - .await - .expect("the STREAM_ERROR is written"); - - let mut opener_side = FrameStream::new(send, recv); - let error = opener_side - .recv_frame() - .await - .expect("the STREAM_ERROR arrives"); - assert!(matches!( - frame::parse_stream_event(&error), - Ok(frame::StreamEvent::Error { ref code, ref message, .. }) - if code == "unauthorized" && message == "not authorized for this procedure" - )); - assert!( - matches!( - opener_side.recv_frame().await, - Err(RecvFrameError::StreamClosed) - ), - "the send half is finished, not reset" - ); - assert!(matches!( - stopped.await, - Ok(Some(code)) if code == quinn::VarInt::from_u32(REFUSED_STREAM) - )); - } - - #[tokio::test] - async fn a_stream_open_threads_its_caller_into_the_args() { - let (opener, provider, _endpoint) = local_connection().await; - let caller = KeyPair::generate(); - let claimed = KeyPair::generate().node_id(); - let args = Value::Map(vec![ - (Value::text("n"), Value::Int(21)), - (Value::text("caller"), Value::Bytes(claimed.to_vec())), - ]); - let first = encoded(&frame::sign(stream_open(&caller, args), &caller)); - let (_send, _recv, inbound) = opened_with(&opener, &provider, &first).await; - - let Ok((_, info)) = StreamHandle::open_inbound(inbound).await else { - panic!("a STREAM_OPEN signed by its caller opens"); - }; - - assert_eq!( - info.args.get("caller"), - Some(&Value::Bytes(caller.node_id().to_vec())) - ); - assert_eq!(info.args.get("n"), Some(&Value::Int(21))); - } -} diff --git a/src/transport.rs b/src/transport.rs index 4907b67..fbb31b7 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -1,323 +1,340 @@ -//! QUIC transport: dialing a macula-station, ported from -//! `native/macula_quic/src/config.rs` (`macula-io/macula`). +//! Dialing a macula 12 station over QUIC, as macula-go's `transport` does. //! -//! Raw QUIC (RFC 9000), not real HTTP/3 despite the "HTTP/3 mesh" -//! branding elsewhere — ALPN is the plain string `"macula"`, and macula's -//! own application framing rides directly on QUIC streams. `quinn` is -//! the exact QUIC engine macula-station's own `native/macula_quic` NIF -//! already runs, so this is wire-compatible by construction, not by -//! coincidence. +//! Raw QUIC (RFC 9000) with the ALPN `"macula"`, TLS 1.3 only, and the key +//! exchange macula-pqc fixes: SecP384r1MLKEM1024, then SecP256r1MLKEM768, and +//! nothing classical. A station's certificate is self-signed, so it is not +//! checked against a CA: macula-pqc's [`KeyPossessionVerifier`] accepts +//! exactly one certificate whose key is ML-DSA-87, then the station's +//! handshake signature under that key. That proves the station holds the key, +//! not who it is: the handshake then checks the station's TLS binding, which +//! ties this leaf to the identity key whose node_id the target pins (see +//! `crate::handshake`). A target without an expected node_id is refused before +//! anything is dialed. +//! +//! quinn protects QUIC Initial packets with the suite it finds in the rustls +//! provider, and RFC 9001 fixes that suite at AES-128-GCM, which macula-pqc's +//! provider does not offer for the handshake itself; the configuration is +//! therefore built with `with_initial` and macula-pqc's `quic_initial_suite`. use std::net::{SocketAddr, ToSocketAddrs}; use std::sync::Arc; use std::time::Duration; +use macula_pqc::KeyPossessionVerifier; +use quinn::crypto::rustls::QuicClientConfig; use quinn::{ClientConfig, Endpoint, IdleTimeout, TransportConfig}; -use crate::cert::{PubkeyPinVerifier, SkipServerVerification}; +use crate::profile::Profile; -/// The ALPN macula-station listens for. Not `"h3"` — see the module doc. +/// The ALPN macula stations listen for. pub const ALPN: &[u8] = b"macula"; -/// Matches macula's own defaults (`macula_quic.erl`'s `idle_timeout_ms` / -/// `keep_alive_interval_ms`): long enough to tolerate a real gap between -/// frames without closing the connection, with keepalive pings sent -/// often enough (~10x within the idle window) that a healthy connection -/// is never mistaken for a dead one. -pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(300); -pub const DEFAULT_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(15); +/// macula's QUIC idle timeout and keep-alive: long enough to tolerate a real +/// gap between frames, with pings often enough that a healthy connection is +/// never mistaken for a dead one. +pub const IDLE_TIMEOUT: Duration = Duration::from_secs(300); +pub const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(15); + +/// A station to dial: where it listens, the profile the node runs, and the +/// node_id the station must prove in the handshake. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Target { + pub host: String, + pub port: u16, + pub profile: Profile, + pub expected_node_id: [u8; 32], +} -/// How to trust whatever certificate the station presents. Mirrors the -/// three modes `macula_quic`'s own `build_client_config` supports — see -/// `plans/PLAN_WIRE_PROTOCOL.md` §2. -/// -/// `Clone, Copy`: every variant is plain data (a bare `[u8; 32]` or -/// nothing at all), and `pool.rs` needs to redial a link — possibly -/// under a DIFFERENT per-link trust than the pool's own configured -/// default, see `pool::PooledLink`'s own doc — more than once over a -/// link's lifetime (initial dial, every respawn). -#[derive(Clone, Copy)] -pub enum Trust { - /// Pin the station's known Ed25519 pubkey (its macula NodeId). The - /// right mode once a station's identity is known — DHT-resolved, or - /// configured directly, which is the normal case for a mobile client - /// dialing a known station. - Pinned([u8; 32]), - /// Standard CA-bundle + hostname validation, for a station whose TLS - /// is terminated by real PKI (e.g. Let's Encrypt) rather than a - /// self-signed macula identity cert. - WebPki, - /// Skip verification entirely. **Development/diagnostic only** — see - /// [`crate::cert::SkipServerVerification`]'s own warning. - Insecure, +/// A dialed station: the QUIC connection, the endpoint it runs on, the leaf +/// certificate the station presented (DER), and the target it was dialed as. +/// The endpoint must live as long as the connection. +pub struct Dialed { + pub connection: quinn::Connection, + pub endpoint: Endpoint, + pub leaf: Vec, + pub target: Target, } +/// Why a dial failed. #[derive(Debug)] -pub enum ConnectError { +pub enum DialError { + /// The target names no expected node_id. + NoExpectedNodeId, + /// The host resolved to no address, or not at all. Resolve(std::io::Error), - NoAddress, + /// The local endpoint could not be made. Endpoint(std::io::Error), - Config(rustls::Error), + /// The TLS or QUIC configuration could not be built. + Config(String), + /// The connection could not be started. Connect(quinn::ConnectError), + /// The QUIC or TLS handshake failed, the station's certificate among the + /// reasons: not exactly one, or not an ML-DSA-87 key. Connection(quinn::ConnectionError), + /// The station presented no certificate the connection could hand back. + NoLeaf, } -impl std::fmt::Display for ConnectError { +impl std::fmt::Display for DialError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - ConnectError::Resolve(e) => write!(f, "resolving station address: {e}"), - ConnectError::NoAddress => write!(f, "hostname resolved to no addresses"), - ConnectError::Endpoint(e) => write!(f, "creating QUIC endpoint: {e}"), - ConnectError::Config(e) => write!(f, "building TLS config: {e}"), - ConnectError::Connect(e) => write!(f, "starting QUIC connect: {e}"), - ConnectError::Connection(e) => write!(f, "QUIC connection failed: {e}"), + DialError::NoExpectedNodeId => f.write_str("the dial target names no expected node_id"), + DialError::Resolve(e) => write!(f, "resolving the station's address: {e}"), + DialError::Endpoint(e) => write!(f, "creating the QUIC endpoint: {e}"), + DialError::Config(e) => write!(f, "building the TLS configuration: {e}"), + DialError::Connect(e) => write!(f, "starting the QUIC connection: {e}"), + DialError::Connection(e) => write!(f, "the QUIC connection failed: {e}"), + DialError::NoLeaf => f.write_str("the station presented no certificate"), } } } -impl std::error::Error for ConnectError {} +impl std::error::Error for DialError {} -fn client_config(trust: Trust) -> Result { - let crypto = tls_client_config(trust); +/// Dials `target`: QUIC and TLS 1.3 with the post-quantum key exchange, +/// the station's certificate checked for an ML-DSA-87 key it holds. +pub async fn dial_target(target: &Target) -> Result { + if target.expected_node_id == [0u8; 32] { + return Err(DialError::NoExpectedNodeId); + } + let addr = (target.host.as_str(), target.port) + .to_socket_addrs() + .map_err(DialError::Resolve)? + .next() + .ok_or_else(|| DialError::Resolve(std::io::Error::other("no address")))?; + let bind: SocketAddr = if addr.is_ipv6() { + (std::net::Ipv6Addr::UNSPECIFIED, 0).into() + } else { + (std::net::Ipv4Addr::UNSPECIFIED, 0).into() + }; + let mut endpoint = Endpoint::client(bind).map_err(DialError::Endpoint)?; + endpoint.set_default_client_config(client_config()?); + let connection = endpoint + .connect(addr, &target.host) + .map_err(DialError::Connect)? + .await + .map_err(DialError::Connection)?; + let leaf = connection + .peer_identity() + .and_then(|identity| { + identity + .downcast::>>() + .ok() + }) + .and_then(|chain| chain.first().map(|leaf| leaf.to_vec())) + .ok_or(DialError::NoLeaf)?; + Ok(Dialed { + connection, + endpoint, + leaf, + target: target.clone(), + }) +} +/// The QUIC client configuration every dial uses. +fn client_config() -> Result { + let quic = QuicClientConfig::with_initial( + Arc::new(tls_client_config()?), + macula_pqc::quic_initial_suite(), + ) + .map_err(|e| DialError::Config(e.to_string()))?; let mut transport = TransportConfig::default(); transport.max_idle_timeout(Some( - IdleTimeout::try_from(DEFAULT_IDLE_TIMEOUT).expect("valid idle timeout"), + IdleTimeout::try_from(IDLE_TIMEOUT).map_err(|e| DialError::Config(e.to_string()))?, )); - transport.keep_alive_interval(Some(DEFAULT_KEEP_ALIVE_INTERVAL)); - apply_flow_control_defaults(&mut transport); - - let quic_crypto = quinn::crypto::rustls::QuicClientConfig::try_from(crypto) - .map_err(|e| rustls::Error::General(e.to_string()))?; - let mut config = ClientConfig::new(Arc::new(quic_crypto)); - config.transport_config(Arc::new(transport)); - Ok(config) -} - -/// The rustls half of a dial's configuration, before quinn wraps it: a -/// seam, so the tests drive the configuration this crate actually dials -/// with rather than a copy built the same way. -/// -/// The key exchange is `macula-pqc`'s: its builder arrives with the groups -/// and TLS 1.3 fixed, and keeps its provider where nothing here can edit -/// it. A station offering only classical groups, as macula 11.5.0 and -/// earlier do, cannot be reached. -pub(crate) fn tls_client_config(trust: Trust) -> rustls::ClientConfig { - let builder = macula_pqc::client_builder; - let mut crypto = match trust { - Trust::Pinned(pubkey) => builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(PubkeyPinVerifier::new(pubkey))) - .with_no_client_auth(), - Trust::WebPki => { - let mut roots = rustls::RootCertStore::empty(); - roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - builder() - .with_root_certificates(roots) - .with_no_client_auth() - } - Trust::Insecure => builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(SkipServerVerification::new())) - .with_no_client_auth(), - }; - crypto.alpn_protocols = vec![ALPN.to_vec()]; - crypto -} - -/// Matches macula's own `apply_flow_control_defaults` in -/// `native/macula_quic/src/config.rs`: default Quinn flow-control -/// windows are conservative enough to bottleneck a connection carrying -/// many small signed frames, well before either side's application-level -/// backpressure kicks in. -fn apply_flow_control_defaults(transport: &mut TransportConfig) { + transport.keep_alive_interval(Some(KEEP_ALIVE_INTERVAL)); transport.stream_receive_window((16u32 * 1024 * 1024).into()); transport.receive_window((64u32 * 1024 * 1024).into()); - transport.send_window(64u64 * 1024 * 1024); -} - -/// Dial a macula-station at `host:port` over QUIC with the given trust -/// mode, completing the QUIC/TLS handshake (ALPN negotiation included). -/// This is transport-only — it does **not** send or expect any macula -/// application frame (CONNECT/HELLO); see `plans/PLAN_WIRE_PROTOCOL.md` -/// §3 for what happens on top of this connection. -pub async fn connect( - host: &str, - port: u16, - trust: Trust, -) -> Result { - let addr = resolve(host, port)?; - let bind_addr: SocketAddr = if addr.is_ipv6() { - "[::]:0".parse().expect("valid unspecified v6 addr") - } else { - "0.0.0.0:0".parse().expect("valid unspecified v4 addr") - }; - - let mut endpoint = Endpoint::client(bind_addr).map_err(ConnectError::Endpoint)?; - let config = client_config(trust).map_err(ConnectError::Config)?; - endpoint.set_default_client_config(config); - - let connecting = endpoint - .connect(addr, host) - .map_err(ConnectError::Connect)?; - connecting.await.map_err(ConnectError::Connection) + transport.send_window(64 * 1024 * 1024); + let mut config = ClientConfig::new(Arc::new(quic)); + config.transport_config(Arc::new(transport)); + Ok(config) } -fn resolve(host: &str, port: u16) -> Result { - (host, port) - .to_socket_addrs() - .map_err(ConnectError::Resolve)? - .next() - .ok_or(ConnectError::NoAddress) +/// The rustls half of a dial: macula-pqc's client builder, its key possession +/// verifier, the ALPN, and no session resumption. +pub(crate) fn tls_client_config() -> Result { + let verifier = KeyPossessionVerifier::new(); + let mut config = macula_pqc::client_builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(verifier)) + .with_no_client_auth(); + config.alpn_protocols = vec![ALPN.to_vec()]; + config.resumption = rustls::client::Resumption::disabled(); + config.enable_early_data = false; + Ok(config) } #[cfg(test)] mod tests { - //! The key exchange this crate dials with, asserted on real TLS 1.3 - //! handshakes against the configuration `connect` actually uses. + //! The dial, on real QUIC against local stations: one as a macula 12 + //! station is (macula-pqc, an ML-DSA-87 certificate), and the ones a dial + //! must refuse. use std::sync::Arc; + use quinn::crypto::rustls::QuicServerConfig; use rustls::crypto::aws_lc_rs::kx_group as aws; use rustls::crypto::CryptoProvider; - use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; - use rustls::{ - ClientConnection, ConfigBuilder, Connection, NamedGroup, ServerConfig, ServerConnection, - WantsVerifier, - }; + use rustls::pki_types::{CertificateDer, PrivateKeyDer}; + use rustls::{NamedGroup, ServerConfig}; - use super::{tls_client_config, Trust, ALPN}; + use super::{dial_target, tls_client_config, DialError, Target, ALPN}; + use crate::profile::Profile; - /// `SecP384r1MLKEM1024`, code point `0x11ED`. rustls has no variant for - /// it and no rustls provider ships it: only `macula-pqc` does. + /// SecP384r1MLKEM1024, code point 0x11ED. const SECP384R1MLKEM1024: NamedGroup = NamedGroup::Unknown(0x11ED); - fn offered(provider: &CryptoProvider) -> Vec { - provider.kx_groups.iter().map(|g| g.name()).collect() + fn mldsa_certificate() -> (CertificateDer<'static>, PrivateKeyDer<'static>) { + let (certificate, key) = + macula_pqc::self_signed_certificate(&[7u8; 32], vec!["localhost".to_string()]) + .expect("a certificate"); + (certificate, key.into()) } - /// Every trust mode dials with `macula-pqc`'s two groups and nothing - /// else. A mode that built its own provider would be the one path a - /// classical group could come back through. - #[test] - fn every_trust_mode_offers_exactly_macula_pqcs_groups() { - for (mode, trust) in [ - ("pinned", Trust::Pinned([7; 32])), - ("webpki", Trust::WebPki), - ("insecure", Trust::Insecure), - ] { - assert_eq!( - offered(tls_client_config(trust).crypto_provider()), - vec![SECP384R1MLKEM1024, NamedGroup::secp256r1MLKEM768], - "{mode}" - ); - } + fn classical_certificate() -> (CertificateDer<'static>, PrivateKeyDer<'static>) { + let key_pair = rcgen::KeyPair::generate().expect("a key pair"); + let certificate = rcgen::CertificateParams::new(vec!["localhost".to_string()]) + .expect("certificate params") + .self_signed(&key_pair) + .expect("a certificate"); + ( + certificate.der().clone(), + PrivateKeyDer::Pkcs8(key_pair.serialize_der().into()), + ) } - /// A station on `macula-pqc`, as macula's own QUIC NIF now is. - #[test] - fn a_station_on_macula_pqc_negotiates_secp384r1mlkem1024() { - let station = station(macula_pqc::server_builder()); - let agreed = handshake(tls_client_config(Trust::Insecure), station); - assert_eq!(agreed, Ok(SECP384R1MLKEM1024)); + /// A station on 127.0.0.1, serving `config` over QUIC; its endpoint and + /// port. + fn station(mut config: ServerConfig, alpn: &[u8]) -> (quinn::Endpoint, u16) { + config.alpn_protocols = vec![alpn.to_vec()]; + let quic = + QuicServerConfig::with_initial(Arc::new(config), macula_pqc::quic_initial_suite()) + .expect("a QUIC server config"); + let endpoint = quinn::Endpoint::server( + quinn::ServerConfig::with_crypto(Arc::new(quic)), + ([127, 0, 0, 1], 0).into(), + ) + .expect("an endpoint"); + let port = endpoint.local_addr().expect("an address").port(); + let accepting = endpoint.clone(); + tokio::spawn(async move { + while let Some(incoming) = accepting.accept().await { + tokio::spawn(async move { + if let Ok(connection) = incoming.await { + connection.closed().await; + } + }); + } + }); + (endpoint, port) } - /// A station on `aws-lc-rs`'s post-quantum groups: `macula-pqc`'s ML-KEM - /// against `aws-lc-rs`'s, on the group both offer. - #[test] - fn a_station_on_aws_lc_rs_post_quantum_groups_negotiates_secp256r1mlkem768() { - let list = CryptoProvider { - kx_groups: vec![ - aws::SECP256R1MLKEM768, - aws::X25519MLKEM768, - aws::MLKEM1024, - aws::MLKEM768, - ], - ..rustls::crypto::aws_lc_rs::default_provider() - }; - let agreed = handshake(tls_client_config(Trust::Insecure), station_on(list)); - assert_eq!(agreed, Ok(NamedGroup::secp256r1MLKEM768)); + fn macula_station() -> (quinn::Endpoint, u16, CertificateDer<'static>) { + let (certificate, key) = mldsa_certificate(); + let config = macula_pqc::server_builder() + .with_no_client_auth() + .with_single_cert(vec![certificate.clone()], key) + .expect("a station configuration"); + let (endpoint, port) = station(config, ALPN); + (endpoint, port, certificate) + } + + fn target(port: u16) -> Target { + Target { + host: "127.0.0.1".to_string(), + port, + profile: Profile::PqHybrid, + expected_node_id: [1u8; 32], + } } - /// ⛔ THE NEGATIVE CONTROL. A station offering only classical groups, as - /// macula 11.5.0 and earlier do, must NOT agree with this dialler. It - /// can only fail to agree if the dialler's group list is in force. #[test] - fn a_classical_only_station_cannot_agree_with_us() { - let classical = CryptoProvider { - kx_groups: vec![aws::X25519, aws::SECP256R1, aws::SECP384R1], - ..rustls::crypto::aws_lc_rs::default_provider() - }; - let outcome = handshake(tls_client_config(Trust::Insecure), station_on(classical)); - assert!( - outcome.is_err(), - "a classical-only station agreed with us: {outcome:?}" + fn a_dial_offers_exactly_macula_pqcs_groups() { + let config = tls_client_config().expect("a configuration"); + let offered: Vec = config + .crypto_provider() + .kx_groups + .iter() + .map(|g| g.name()) + .collect(); + assert_eq!( + offered, + vec![SECP384R1MLKEM1024, NamedGroup::secp256r1MLKEM768] ); } - fn identity() -> (Vec>, PrivateKeyDer<'static>) { - let key_pair = rcgen::KeyPair::generate().expect("a key pair"); - let certificate = rcgen::CertificateParams::new(vec!["localhost".to_string()]) - .expect("certificate params") - .self_signed(&key_pair) - .expect("a self-signed certificate"); - let key = PrivateKeyDer::Pkcs8(key_pair.serialize_der().into()); - (vec![certificate.der().clone()], key) + #[tokio::test] + async fn a_macula_12_station_is_reached_and_its_leaf_handed_back() { + let (_station, port, certificate) = macula_station(); + let dialed = dial_target(&target(port)) + .await + .expect("the station is reached"); + assert_eq!(dialed.leaf, certificate.to_vec()); + dialed.connection.close(0u32.into(), b"done"); } - fn station(builder: ConfigBuilder) -> ServerConfig { - let (chain, key) = identity(); - let mut config = builder - .with_no_client_auth() - .with_single_cert(chain, key) - .expect("a server certificate"); - config.alpn_protocols = vec![ALPN.to_vec()]; - config + #[tokio::test] + async fn a_target_without_an_expected_node_id_is_refused_before_dialing() { + let mut unpinned = target(9); + unpinned.expected_node_id = [0u8; 32]; + assert!(matches!( + dial_target(&unpinned).await, + Err(DialError::NoExpectedNodeId) + )); } - fn station_on(provider: CryptoProvider) -> ServerConfig { - station( - ServerConfig::builder_with_provider(Arc::new(provider)) - .with_safe_default_protocol_versions() - .expect("versions"), - ) + #[tokio::test] + async fn a_station_with_a_classical_certificate_is_refused() { + let (certificate, key) = classical_certificate(); + let provider = CryptoProvider { + kx_groups: vec![aws::SECP256R1MLKEM768], + ..rustls::crypto::aws_lc_rs::default_provider() + }; + let config = ServerConfig::builder_with_provider(Arc::new(provider)) + .with_protocol_versions(&[&rustls::version::TLS13]) + .expect("TLS 1.3") + .with_no_client_auth() + .with_single_cert(vec![certificate], key) + .expect("a station configuration"); + let (_station, port) = station(config, ALPN); + assert!(matches!( + dial_target(&target(port)).await, + Err(DialError::Connection(_)) + )); } - /// One in-memory handshake; the group both sides agreed on, or why - /// they did not. A failure to agree is what the negative control asserts. - fn handshake(client: rustls::ClientConfig, server: ServerConfig) -> Result { - let name = ServerName::try_from("localhost").expect("a server name"); - let mut client = Connection::Client( - ClientConnection::new(Arc::new(client), name).map_err(|e| e.to_string())?, - ); - let mut server = - Connection::Server(ServerConnection::new(Arc::new(server)).map_err(|e| e.to_string())?); - for _ in 0..20 { - let moved = pump(&mut client, &mut server)? + pump(&mut server, &mut client)?; - if moved == 0 && !client.is_handshaking() && !server.is_handshaking() { - break; - } - } - if client.is_handshaking() || server.is_handshaking() { - return Err("handshake never completed".to_string()); - } - let agreed = |c: &Connection| c.negotiated_key_exchange_group().map(|g| g.name()); - match (agreed(&client), agreed(&server)) { - (Some(c), Some(s)) if c == s => Ok(c), - other => Err(format!("the two sides disagree on the group: {other:?}")), - } + #[tokio::test] + async fn a_classical_only_station_is_refused() { + let (certificate, key) = classical_certificate(); + let provider = CryptoProvider { + kx_groups: vec![aws::X25519, aws::SECP256R1, aws::SECP384R1], + ..rustls::crypto::aws_lc_rs::default_provider() + }; + let config = ServerConfig::builder_with_provider(Arc::new(provider)) + .with_protocol_versions(&[&rustls::version::TLS13]) + .expect("TLS 1.3") + .with_no_client_auth() + .with_single_cert(vec![certificate], key) + .expect("a station configuration"); + let (_station, port) = station(config, ALPN); + assert!(matches!( + dial_target(&target(port)).await, + Err(DialError::Connection(_)) + )); } - fn pump(from: &mut Connection, to: &mut Connection) -> Result { - let mut buf = Vec::new(); - while from.wants_write() { - from.write_tls(&mut buf).map_err(|e| e.to_string())?; - } - let mut cursor = std::io::Cursor::new(&buf[..]); - while (cursor.position() as usize) < buf.len() { - to.read_tls(&mut cursor).map_err(|e| e.to_string())?; - to.process_new_packets().map_err(|e| e.to_string())?; - } - Ok(buf.len()) + #[tokio::test] + async fn a_station_that_does_not_speak_macula_is_refused() { + let (certificate, key) = mldsa_certificate(); + let config = macula_pqc::server_builder() + .with_no_client_auth() + .with_single_cert(vec![certificate], key) + .expect("a station configuration"); + let (_station, port) = station(config, b"h3"); + assert!(matches!( + dial_target(&target(port)).await, + Err(DialError::Connection(_)) + )); } } diff --git a/src/ucan.rs b/src/ucan.rs deleted file mode 100644 index da7dd1e..0000000 --- a/src/ucan.rs +++ /dev/null @@ -1,659 +0,0 @@ -//! Macula's UCAN (User Controlled Authorization Networks) tokens: -//! creation, verification, and introspection, plus the policy layer a -//! provider gates an inbound CALL through. -//! -//! Ported from `macula-io/macula`'s `src/auth/macula_ucan_nif.erl` and its -//! native Rust NIF (`native/macula_ucan_nif/src/lib.rs`) — both hand-roll a -//! JWT-shaped token (`header.payload.signature`, base64url-no-pad), EdDSA -//! over Ed25519, UCAN spec version `"0.10.0"` (the older JWT-based draft; -//! **not** the current non-JWT/IPLD UCAN 1.0 spec). Confirmed directly by -//! reading the NIF's own `Cargo.toml`: no UCAN-spec crate is depended on at -//! all, only generic `ed25519-dalek`/`serde_json`/`base64`/`sha2` — because -//! no library implements 0.10.0 (the only actively maintained Rust/Go UCAN -//! libraries target the incompatible 1.0.0-rc.1 CBOR/IPLD format, per -//! `macula-go`'s own `ucan` package doc, which made the identical -//! choice porting this same reference). This module does the same: hand- -//! rolled on the crypto/serialization primitives already in this crate -//! (`ed25519-dalek` via [`crate::identity`], plus `serde`/`serde_json`/ -//! `base64` added for this module), matching the reference exactly rather -//! than adopting an incompatible library. -//! -//! A token minted here verifies against `macula-go`'s `ucan` package, -//! the Erlang macula SDK, or vice versa — same header shape, same payload -//! field names (`iss`/`aud`/`exp`/`nbf`/`nnc`/`cap`/`fct`/`prf`), same -//! signing input (`header_b64 + "." + payload_b64`), same algorithm. Field -//! ORDER in the JSON is not part of the compatibility contract (a verifier -//! decodes into a struct, never re-encodes and compares bytes) — only the -//! field NAMES and the exact bytes signed matter. -//! -//! Cross-referenced against `macula-go/ucan/{ucan,policy}.go`, itself -//! independently verified against this same Erlang/Rust reference earlier -//! this session — the two ports should stay behaviorally identical. - -use std::collections::HashMap; -use std::time::{SystemTime, UNIX_EPOCH}; - -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; -use serde::{Deserialize, Serialize}; - -use crate::identity::{verify as identity_verify, KeyPair}; - -const ALG: &str = "EdDSA"; -const TYP: &str = "JWT"; -const UCV: &str = "0.10.0"; - -/// Errors from token creation, decoding, or verification. -#[derive(Debug)] -pub enum UcanError { - /// Not a well-formed `header.payload.signature` triple, or a part - /// isn't valid base64url/JSON — mirrors `macula_ucan_nif`'s - /// `{error, invalid_token}`. - InvalidToken, - /// The token parsed fine but its signature does not verify against - /// the given public key — mirrors `{error, invalid_signature}`. - InvalidSignature, - /// The supplied public key isn't a 32-byte Ed25519 key — mirrors - /// `{error, invalid_public_key}`. - InvalidPublicKey, - /// The token's `exp` claim is in the past — mirrors `{error, expired}`. - Expired, - /// The token's `nbf` claim is in the future — mirrors - /// `{error, not_yet_valid}`. - NotYetValid, - /// A UCAN-gated procedure was called with no token at all — mirrors - /// `macula_station_link.erl`'s `check_ucan(<<>>, _) -> unauthorized` - /// clause (an empty/absent token is refused before ever attempting to - /// verify anything). - NoToken, - /// A UCAN-gated procedure was called with no caller to bind the token - /// to; a missing caller is unauthorized. - NoCaller, - /// The token's `aud` is not the presenting caller's node id as - /// lowercase hex, so it was minted for someone else. - WrongAudience, -} - -impl std::fmt::Display for UcanError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - UcanError::InvalidToken => write!(f, "ucan: invalid token"), - UcanError::InvalidSignature => write!(f, "ucan: invalid signature"), - UcanError::InvalidPublicKey => write!(f, "ucan: invalid public key"), - UcanError::Expired => write!(f, "ucan: token expired"), - UcanError::NotYetValid => write!(f, "ucan: token not yet valid"), - UcanError::NoToken => write!(f, "ucan: no token presented for a gated procedure"), - UcanError::NoCaller => { - write!(f, "ucan: no caller to check the token's audience against") - } - UcanError::WrongAudience => { - write!(f, "ucan: token audience is not the caller presenting it") - } - } - } -} - -impl std::error::Error for UcanError {} - -/// One entry in a UCAN token's capability list — mirrors -/// `macula_ucan_nif`'s `capability() :: #{with := binary(), can := binary()}`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Capability { - pub with: String, - pub can: String, -} - -#[derive(Serialize, Deserialize)] -struct Header { - alg: String, - typ: String, - ucv: String, -} - -/// The JSON shape actually signed/transmitted. Field names match the -/// reference exactly. -#[derive(Serialize, Deserialize)] -struct WirePayload { - iss: String, - aud: String, - #[serde(skip_serializing_if = "Option::is_none")] - exp: Option, - #[serde(skip_serializing_if = "Option::is_none")] - nbf: Option, - #[serde(skip_serializing_if = "Option::is_none")] - nnc: Option, - cap: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - fct: Option>, - prf: Vec, -} - -/// A UCAN token's decoded claims — the Rust-idiomatic counterpart to -/// `WirePayload`, returned from [`decode`]/[`verify`]. -#[derive(Debug, Clone, PartialEq)] -pub struct Payload { - pub issuer: String, - pub audience: String, - pub capabilities: Vec, - pub expires_at: Option, - pub not_before: Option, - pub nonce: String, - pub facts: Option>, - pub proofs: Vec, -} - -/// Optional claims for [`create`] — mirrors `macula_ucan_nif`'s -/// `ucan_opts()` map. -#[derive(Debug, Clone, Default)] -pub struct CreateOpts { - pub expires_at: Option, - pub not_before: Option, - pub nonce: Option, - pub facts: Option>, - pub proofs: Option>, -} - -/// Mints a new UCAN token, self-issued and signed by `id`. `issuer` and -/// `audience` are opaque DID strings (e.g. `"did:macula:io.macula.acme"`) -/// — this module does not validate or resolve DID structure, matching -/// `macula_ucan_nif:create/4,5`'s own scope exactly (that's -/// `macula_did_nif`'s job on the Erlang side, out of scope here). `id` -/// signs with its own Ed25519 private key; the resulting token verifies -/// against `id`'s public key ([`KeyPair::node_id`]), the same convention -/// every advertised capability in this SDK already uses. -/// -/// A token a caller presents to a procedure gated with -/// [`Policy::required`] must name that caller as its `audience`: the -/// caller's 32-byte node id as lowercase hex, with no `did:` prefix (for -/// example `hex::encode(caller.node_id())`). The provider refuses a token -/// whose `aud` names anyone other than the caller whose signature is on -/// the CALL. -pub fn create( - issuer: &str, - audience: &str, - capabilities: Vec, - id: &KeyPair, - opts: CreateOpts, -) -> Result, UcanError> { - let payload = WirePayload { - iss: issuer.to_string(), - aud: audience.to_string(), - exp: opts.expires_at, - nbf: opts.not_before, - nnc: opts.nonce, - cap: capabilities, - fct: opts.facts, - prf: opts.proofs.unwrap_or_default(), - }; - - let header_json = serde_json::to_vec(&Header { - alg: ALG.into(), - typ: TYP.into(), - ucv: UCV.into(), - }) - .map_err(|_| UcanError::InvalidToken)?; - let payload_json = serde_json::to_vec(&payload).map_err(|_| UcanError::InvalidToken)?; - let header_b64 = URL_SAFE_NO_PAD.encode(header_json); - let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json); - let signing_input = format!("{header_b64}.{payload_b64}"); - let sig = id.sign(signing_input.as_bytes()); - let sig_b64 = URL_SAFE_NO_PAD.encode(sig); - Ok(format!("{signing_input}.{sig_b64}").into_bytes()) -} - -fn split_token(token: &[u8]) -> Result<(&str, &str, &str), UcanError> { - let text = std::str::from_utf8(token).map_err(|_| UcanError::InvalidToken)?; - let mut parts = text.split('.'); - let (Some(h), Some(p), Some(s), None) = - (parts.next(), parts.next(), parts.next(), parts.next()) - else { - return Err(UcanError::InvalidToken); - }; - Ok((h, p, s)) -} - -fn decode_payload(payload_b64: &str) -> Result { - let raw = URL_SAFE_NO_PAD - .decode(payload_b64) - .map_err(|_| UcanError::InvalidToken)?; - let wp: WirePayload = serde_json::from_slice(&raw).map_err(|_| UcanError::InvalidToken)?; - Ok(Payload { - issuer: wp.iss, - audience: wp.aud, - capabilities: wp.cap, - expires_at: wp.exp, - not_before: wp.nbf, - nonce: wp.nnc.unwrap_or_default(), - facts: wp.fct, - proofs: wp.prf, - }) -} - -/// Parses a UCAN token's payload WITHOUT verifying its signature or -/// checking expiration. Mirrors `macula_ucan_nif:decode/1` — same warning -/// applies: never use this for an authorization decision, only [`verify`] -/// does that. -pub fn decode(token: &[u8]) -> Result { - let (_, payload_b64, _) = split_token(token)?; - decode_payload(payload_b64) -} - -fn now_unix() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -/// Checks a UCAN token's signature against `public_key` (the claimed -/// issuer's 32-byte Ed25519 public key) and its `exp`/`nbf` claims against -/// the current time, returning the decoded payload only on full success. -/// Mirrors `macula_ucan_nif:verify/2` exactly, including its check ORDER — -/// public key shape, then token shape, then `exp`, then `nbf`, then -/// signature — matching both the Erlang fallback and the Rust NIF, which -/// check claims before the signature; this module preserves that order for -/// parity even though it means an invalid-but-well-formed token's expiry -/// is observable before its signature is checked. -pub fn verify(token: &[u8], public_key: &[u8; 32]) -> Result { - let (header_b64, payload_b64, sig_b64) = split_token(token)?; - let payload = decode_payload(payload_b64)?; - let now = now_unix(); - if let Some(exp) = payload.expires_at { - if now > exp { - return Err(UcanError::Expired); - } - } - if let Some(nbf) = payload.not_before { - if now < nbf { - return Err(UcanError::NotYetValid); - } - } - let sig_bytes = URL_SAFE_NO_PAD - .decode(sig_b64) - .map_err(|_| UcanError::InvalidToken)?; - let sig: [u8; 64] = sig_bytes.try_into().map_err(|_| UcanError::InvalidToken)?; - let signing_input = format!("{header_b64}.{payload_b64}"); - if !identity_verify(signing_input.as_bytes(), &sig, public_key) { - return Err(UcanError::InvalidSignature); - } - Ok(payload) -} - -/// Returns a UCAN token's content identifier: SHA-256 of the raw token -/// bytes, base64url-no-pad encoded. NOT a real multihash/CIDv1 — matches -/// `macula_ucan_nif:compute_cid/1`'s own (loosely-named) scheme exactly. -/// Used only for proof-chain references between UCANs (a child token's -/// `prf` entries name parent tokens by this value). -pub fn compute_cid(token: &[u8]) -> String { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(token); - URL_SAFE_NO_PAD.encode(hasher.finalize()) -} - -/// Decodes `token` (without verifying it) and returns its `iss` claim. -/// Mirrors `macula_ucan_nif:get_issuer/1`. -pub fn get_issuer(token: &[u8]) -> Result { - decode(token).map(|p| p.issuer) -} - -/// Decodes `token` (without verifying it) and returns its `aud` claim. -/// Mirrors `macula_ucan_nif:get_audience/1`. -pub fn get_audience(token: &[u8]) -> Result { - decode(token).map(|p| p.audience) -} - -/// Decodes `token` (without verifying it) and returns its `cap` claim. -/// Mirrors `macula_ucan_nif:get_capabilities/1`. -pub fn get_capabilities(token: &[u8]) -> Result, UcanError> { - decode(token).map(|p| p.capabilities) -} - -/// Decodes `token` (without verifying it) and returns its `exp` claim, or -/// `None` if absent. Mirrors `macula_ucan_nif:get_expiration/1`. -pub fn get_expiration(token: &[u8]) -> Result, UcanError> { - decode(token).map(|p| p.expires_at) -} - -/// Decodes `token` (without verifying it) and returns its `prf` claim. -/// Mirrors `macula_ucan_nif:get_proofs/1`. -pub fn get_proofs(token: &[u8]) -> Result, UcanError> { - decode(token).map(|p| p.proofs) -} - -/// Decodes `token` (without verifying it) and reports whether its `exp` -/// claim is in the past. A token with no `exp` claim is never expired. -/// Mirrors `macula_ucan_nif:is_expired/1`. -pub fn is_expired(token: &[u8]) -> Result { - let payload = decode(token)?; - Ok(match payload.expires_at { - Some(exp) => now_unix() > exp, - None => false, - }) -} - -/// What a provider requires to answer one `(realm, procedure)`: open (any -/// identified caller, the default) or UCAN-gated (the caller's token must -/// verify against `required_issuer`). Mirrors `macula_station_link.erl`'s -/// own policy shape exactly — `open | {ucan_required, Issuer}` — where -/// `Issuer` there is the 32-byte Ed25519 public key the gate checks the -/// token's signature against, not a DID string (the reference code passes -/// it straight to `macula_ucan_nif:verify/2`, whose second argument is a -/// raw public key). -/// -/// Gating happens BEFORE a handler runs — see -/// [`crate::connection::Session::serve_one_call_gated`] — so a rejected -/// caller never reaches business logic, and an accepted caller's handler -/// never sees the raw token either; the policy layer already did the only -/// thing that mattered with it. -/// -/// A gated policy also binds the token to the caller presenting it: the -/// token's `aud` must be that caller's 32-byte node id as lowercase hex, so -/// a token minted for someone else is refused. The serve path hands a CALL -/// to the policy only once its signature verifies against the `caller` it -/// names. -#[derive(Debug, Clone, Default)] -pub struct Policy { - pub gated: bool, - pub required_issuer: [u8; 32], -} - -impl Policy { - /// The default, ungated policy: any identified caller may invoke the - /// procedure, no UCAN token needed. Equivalent to Erlang's `open`. - pub fn open() -> Self { - Self::default() - } - - /// Builds a UCAN-gated policy: a caller must present a token that - /// verifies (signature, `exp`, `nbf`) against `issuer_public_key` and - /// names that caller as its audience. Equivalent to Erlang's - /// `{ucan_required, issuer_public_key}`. - pub fn required(issuer_public_key: [u8; 32]) -> Self { - Self { - gated: true, - required_issuer: issuer_public_key, - } - } - - /// Applies this policy to an inbound CALL's `ucan_token` and `caller`, - /// returning `Ok(())` if the call is authorized to proceed to - /// lookup/dispatch. An open policy always passes. A gated policy - /// requires a 32-byte `caller`, requires `ucan_token` to [`verify`] - /// against `required_issuer`, and requires the token's `aud` to equal - /// `caller` as lowercase hex, the same comparison - /// `macula_station_link.erl` makes. - pub fn check(&self, ucan_token: &[u8], caller: &[u8]) -> Result<(), UcanError> { - if !self.gated { - return Ok(()); - } - if ucan_token.is_empty() { - return Err(UcanError::NoToken); - } - if caller.len() != 32 { - return Err(UcanError::NoCaller); - } - let payload = verify(ucan_token, &self.required_issuer)?; - if payload.audience != lowercase_hex(caller) { - return Err(UcanError::WrongAudience); - } - Ok(()) - } -} - -/// `bytes` as lowercase hex, the form a token's `aud` names its caller in. -fn lowercase_hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn keypair() -> KeyPair { - KeyPair::generate() - } - - #[test] - fn create_and_verify_round_trip() { - let id = keypair(); - let token = create( - "did:macula:issuer", - "did:macula:audience", - vec![Capability { - with: "mri:x".into(), - can: "read".into(), - }], - &id, - CreateOpts::default(), - ) - .unwrap(); - let payload = verify(&token, &id.node_id()).unwrap(); - assert_eq!(payload.issuer, "did:macula:issuer"); - assert_eq!(payload.audience, "did:macula:audience"); - assert_eq!( - payload.capabilities, - vec![Capability { - with: "mri:x".into(), - can: "read".into() - }] - ); - } - - #[test] - fn verify_rejects_tampered_payload() { - let id = keypair(); - let token = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap(); - let mut text = String::from_utf8(token).unwrap(); - // Flip a byte in the payload segment without corrupting base64 - // framing -- this is the same tamper strategy this crate's other - // signed-record tests already use (see dht.rs's tests). - let parts: Vec<&str> = text.split('.').collect(); - let mut payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap(); - payload_bytes[0] ^= 0xFF; - let tampered_payload = URL_SAFE_NO_PAD.encode(payload_bytes); - text = format!("{}.{}.{}", parts[0], tampered_payload, parts[2]); - let err = verify(text.as_bytes(), &id.node_id()).unwrap_err(); - assert!(matches!( - err, - UcanError::InvalidToken | UcanError::InvalidSignature - )); - } - - #[test] - fn verify_rejects_wrong_signer() { - let id = keypair(); - let other = keypair(); - let token = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap(); - let err = verify(&token, &other.node_id()).unwrap_err(); - assert!(matches!(err, UcanError::InvalidSignature)); - } - - #[test] - fn verify_rejects_expired() { - let id = keypair(); - let opts = CreateOpts { - expires_at: Some(now_unix() - 60), - ..Default::default() - }; - let token = create("iss", "aud", vec![], &id, opts).unwrap(); - let err = verify(&token, &id.node_id()).unwrap_err(); - assert!(matches!(err, UcanError::Expired)); - } - - #[test] - fn verify_rejects_not_yet_valid() { - let id = keypair(); - let opts = CreateOpts { - not_before: Some(now_unix() + 3600), - ..Default::default() - }; - let token = create("iss", "aud", vec![], &id, opts).unwrap(); - let err = verify(&token, &id.node_id()).unwrap_err(); - assert!(matches!(err, UcanError::NotYetValid)); - } - - #[test] - fn decode_does_not_check_signature() { - let id = keypair(); - let other = keypair(); - let token = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap(); - // decode() against ANY key (or none at all) still returns the - // payload -- it never checks the signature, matching - // macula_ucan_nif:decode/1's own documented warning. - let payload = decode(&token).unwrap(); - assert_eq!(payload.issuer, "iss"); - let _ = other; // not used for verification here, on purpose - } - - #[test] - fn getters_match_created_claims() { - let id = keypair(); - let caps = vec![Capability { - with: "mri:x".into(), - can: "write".into(), - }]; - let opts = CreateOpts { - expires_at: Some(now_unix() + 3600), - proofs: Some(vec!["parent-cid".into()]), - ..Default::default() - }; - let token = create("did:iss", "did:aud", caps.clone(), &id, opts).unwrap(); - assert_eq!(get_issuer(&token).unwrap(), "did:iss"); - assert_eq!(get_audience(&token).unwrap(), "did:aud"); - assert_eq!(get_capabilities(&token).unwrap(), caps); - assert!(get_expiration(&token).unwrap().is_some()); - assert_eq!(get_proofs(&token).unwrap(), vec!["parent-cid".to_string()]); - assert!(!is_expired(&token).unwrap()); - } - - #[test] - fn is_expired_true_for_past_exp() { - let id = keypair(); - let opts = CreateOpts { - expires_at: Some(now_unix() - 1), - ..Default::default() - }; - let token = create("iss", "aud", vec![], &id, opts).unwrap(); - // is_expired() never checks the signature either -- consistent - // with every other getter in this module. - assert!(is_expired(&token).unwrap()); - } - - #[test] - fn is_expired_false_with_no_exp_claim() { - let id = keypair(); - let token = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap(); - assert!(!is_expired(&token).unwrap()); - } - - #[test] - fn cid_is_deterministic_and_content_addressed() { - let id = keypair(); - let token_a = create("iss", "aud", vec![], &id, CreateOpts::default()).unwrap(); - assert_eq!(compute_cid(&token_a), compute_cid(&token_a)); - let token_b = create("iss2", "aud", vec![], &id, CreateOpts::default()).unwrap(); - assert_ne!(compute_cid(&token_a), compute_cid(&token_b)); - } - - #[test] - fn policy_open_never_requires_a_token() { - let policy = Policy::open(); - assert!(policy.check(&[], &[]).is_ok()); - } - - #[test] - fn policy_required_rejects_empty_token() { - let id = keypair(); - let caller = keypair(); - let policy = Policy::required(id.node_id()); - assert!(matches!( - policy.check(&[], &caller.node_id()).unwrap_err(), - UcanError::NoToken - )); - } - - #[test] - fn policy_required_accepts_valid_token_from_the_right_issuer() { - let id = keypair(); - let caller = keypair(); - let token = create( - "did:iss", - &hex::encode(caller.node_id()), - vec![], - &id, - CreateOpts::default(), - ) - .unwrap(); - let policy = Policy::required(id.node_id()); - assert!(policy.check(&token, &caller.node_id()).is_ok()); - } - - #[test] - fn policy_required_rejects_token_from_the_wrong_issuer() { - let id = keypair(); - let impostor = keypair(); - let caller = keypair(); - let token = create( - "did:iss", - &hex::encode(caller.node_id()), - vec![], - &impostor, - CreateOpts::default(), - ) - .unwrap(); - let policy = Policy::required(id.node_id()); - assert!(matches!( - policy.check(&token, &caller.node_id()).unwrap_err(), - UcanError::InvalidSignature - )); - } - - #[test] - fn policy_required_refuses_a_token_for_another_audience() { - let (issuer, audience, presenter) = (keypair(), keypair(), keypair()); - let token = create( - "did:iss", - &hex::encode(audience.node_id()), - vec![], - &issuer, - CreateOpts::default(), - ) - .unwrap(); - let policy = Policy::required(issuer.node_id()); - - assert!(matches!( - policy.check(&token, &presenter.node_id()).unwrap_err(), - UcanError::WrongAudience - )); - // The same token from its audience passes. - assert!(policy.check(&token, &audience.node_id()).is_ok()); - } - - #[test] - fn policy_required_refuses_a_missing_or_malformed_audience_or_caller() { - let (issuer, caller) = (keypair(), keypair()); - let policy = Policy::required(issuer.node_id()); - let caller_hex = hex::encode(caller.node_id()); - let token_for = |audience: &str| { - create("did:iss", audience, vec![], &issuer, CreateOpts::default()).unwrap() - }; - - for audience in [ - String::new(), - caller_hex.to_uppercase(), - format!("did:macula:{caller_hex}"), - ] { - assert!( - matches!( - policy.check(&token_for(&audience), &caller.node_id()), - Err(UcanError::WrongAudience) - ), - "audience {audience:?} must be refused" - ); - } - assert!(matches!( - policy.check(&token_for(&caller_hex), &[]), - Err(UcanError::NoCaller) - )); - } -} diff --git a/src/uuid_v7.rs b/src/uuid_v7.rs new file mode 100644 index 0000000..dfbfd0d --- /dev/null +++ b/src/uuid_v7.rs @@ -0,0 +1,22 @@ +//! UUID v7s, the frame ids and record versions macula 12 carries: 48 bits of +//! Unix milliseconds, the version and variant bits, and 74 random bits. + +/// Now, in Unix milliseconds. +pub(crate) fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// A new UUID v7. +pub(crate) fn new() -> [u8; 16] { + let mut id = [0u8; 16]; + // An id is an identifier, not a secret: one without randomness is still + // ordered and unique by its time, so a failure to draw is not an error. + let _ = aws_lc_rs::rand::fill(&mut id[6..]); + id[..6].copy_from_slice(&now_ms().to_be_bytes()[2..]); + id[6] = (id[6] & 0x0f) | 0x70; + id[8] = (id[8] & 0x3f) | 0x80; + id +} diff --git a/tests/cbor_decoding_rule.rs b/tests/cbor_decoding_rule.rs new file mode 100644 index 0000000..42e6c54 --- /dev/null +++ b/tests/cbor_decoding_rule.rs @@ -0,0 +1,361 @@ +//! macula 12's decoding rule, which every stack applies to what a peer sends: +//! the shared vectors (tests/vectors/cbor/decoding_rule_v1.json), and the +//! verdict macula's reference decoder, macula_record_cbor:decode_strict/1, +//! gives each input with its reason, as macula-go pins them, so an input is +//! refused here for the same reason as in every other stack. + +use macula_rust::cbor::{decode, DecodeError, MAX_ELEMENTS, MAX_NESTING_DEPTH}; + +fn nested(count: usize, innermost: &str) -> String { + "81".repeat(count) + innermost +} + +fn refusal(name: &str) -> DecodeError { + match name { + "trailing_bytes" => DecodeError::TrailingBytes, + "bad_key" => DecodeError::BadKey, + "duplicate_key" => DecodeError::DuplicateKey, + "invalid_text" => DecodeError::InvalidText, + "too_deep" => DecodeError::NestingTooDeep, + "integer_out_of_range" => DecodeError::IntegerOutOfRange, + "too_many_elements" => DecodeError::TooManyElements, + "malformed" => DecodeError::Malformed, + other => panic!("no refusal for the reference's reason {other}"), + } +} + +#[test] +fn the_limits_are_macula_s() { + assert_eq!(MAX_NESTING_DEPTH, 64); + assert_eq!(MAX_ELEMENTS, 131_072); +} + +#[test] +fn the_shared_vectors_decode_as_every_stack_decodes_them() { + let text = std::fs::read_to_string("tests/vectors/cbor/decoding_rule_v1.json").unwrap(); + let vectors: serde_json::Value = serde_json::from_str(&text).unwrap(); + let mut checked = 0; + for entry in vectors["entries"].as_array().unwrap() { + // The CALL-field entries are the request's own field rules, checked + // where a request is read. + if entry.get("via").is_some() { + continue; + } + let name = entry["name"].as_str().unwrap(); + let bytes = hex::decode(entry["cbor"].as_str().unwrap()).unwrap(); + let result = decode(&bytes); + match entry["expect"].as_str().unwrap() { + "accept" => assert!( + result.is_ok(), + "{name}: {result:?}, but every stack accepts it" + ), + "refuse" => assert!( + result.is_err(), + "{name}: accepted, but every stack refuses it" + ), + other => panic!("{name}: unknown expectation {other}"), + } + checked += 1; + } + assert_eq!(checked, 54); +} + +#[test] +fn every_input_is_refused_for_the_reference_decoder_s_reason() { + let cases: Vec<(&str, String, &str)> = vec![ + ( + "a duplicate key at the top level", + "a2616101616102".into(), + "duplicate_key", + ), + ( + "a duplicate key in a nested map", + "a16162a2616101616102".into(), + "duplicate_key", + ), + ( + "a duplicate key in a map inside an array", + "81a2616101616102".into(), + "duplicate_key", + ), + ( + "bytes after the top-level item", + "a161610100".into(), + "trailing_bytes", + ), + ( + "bytes after a nested item", + "810000".into(), + "trailing_bytes", + ), + ("truncated input", "a26161".into(), "malformed"), + ("empty input", "".into(), "malformed"), + ( + "invalid UTF-8 in a text value", + "a1616161ff".into(), + "invalid_text", + ), + ( + "invalid UTF-8 in a text key", + "a161ff01".into(), + "invalid_text", + ), + ("valid multibyte text", "a1616b65636166c3a9".into(), ""), + ( + "a UTF-16 surrogate in text", + "63eda080".into(), + "invalid_text", + ), + ("an overlong NUL in text", "62c080".into(), "invalid_text"), + ( + "a code point above U+10FFFF", + "64f4908080".into(), + "invalid_text", + ), + ("the noncharacter U+FFFE", "63efbfbe".into(), ""), + ("a byte string key", "a1416101".into(), "bad_key"), + ("a float key", "a1f93ff001".into(), "bad_key"), + ("a half float key", "a1f93c0001".into(), "bad_key"), + ("an array key", "a18001".into(), "bad_key"), + ("a map key", "a1a001".into(), "bad_key"), + ("a null key", "a1f601".into(), "bad_key"), + ("integer keys 1 and -1", "a201022003".into(), ""), + ("integer keys -1 and 0", "a220010002".into(), ""), + ( + "a duplicate integer key", + "a201020103".into(), + "duplicate_key", + ), + ( + "the empty text key twice", + "a260016002".into(), + "duplicate_key", + ), + ( + "a text key in two widths", + "a261610178016102".into(), + "duplicate_key", + ), + ( + "a text key in one-byte and two-byte widths", + "a278016101790001616102".into(), + "duplicate_key", + ), + ( + "an unsigned key in two widths", + "a20101180102".into(), + "duplicate_key", + ), + ( + "a negative key in two widths", + "a22001380002".into(), + "duplicate_key", + ), + ( + "a duplicate key whose second value is invalid text", + "a2616101616161ff".into(), + "invalid_text", + ), + ( + "a byte string key whose value nests 65 containers", + format!("a14161{}", nested(65, "00")), + "too_deep", + ), + ("a half float value", "81f93e00".into(), ""), + ("negative zero, half width", "f98000".into(), ""), + ("the smallest half subnormal", "f90001".into(), ""), + ("64 nested arrays", nested(64, "00"), ""), + ("65 nested arrays", nested(65, "00"), "too_deep"), + ( + "64 containers, the innermost an empty array", + nested(63, "80"), + "", + ), + ( + "65 containers, the innermost an empty array", + nested(64, "80"), + "too_deep", + ), + ( + "64 containers, the innermost an empty map", + nested(63, "a0"), + "", + ), + ( + "65 containers, the innermost an empty map", + nested(64, "a0"), + "too_deep", + ), + ( + "a map whose value is 63 arrays deep", + format!("a16161{}", nested(63, "00")), + "", + ), + ( + "a map whose value is 64 arrays deep", + format!("a16161{}", nested(64, "00")), + "too_deep", + ), + ( + "the smallest integer, -2^63", + "3b7fffffffffffffff".into(), + "", + ), + ( + "an integer below -2^63", + "3b8000000000000000".into(), + "integer_out_of_range", + ), + ( + "the smallest CBOR integer, -2^64", + "3bffffffffffffffff".into(), + "integer_out_of_range", + ), + ( + "the largest integer, 2^63-1", + "1b7fffffffffffffff".into(), + "", + ), + ( + "an integer above 2^63-1", + "1b8000000000000000".into(), + "integer_out_of_range", + ), + ( + "the largest CBOR integer, 2^64-1", + "1bffffffffffffffff".into(), + "integer_out_of_range", + ), + ( + "positive infinity, half width", + "f97c00".into(), + "malformed", + ), + ( + "negative infinity, half width", + "f9fc00".into(), + "malformed", + ), + ("NaN, half width", "f97e00".into(), "malformed"), + ( + "positive infinity, single width", + "fa7f800000".into(), + "malformed", + ), + ( + "negative infinity, single width", + "faff800000".into(), + "malformed", + ), + ("NaN, single width", "fa7fc00000".into(), "malformed"), + ( + "positive infinity, double width", + "fb7ff0000000000000".into(), + "malformed", + ), + ( + "negative infinity, double width", + "fbfff0000000000000".into(), + "malformed", + ), + ( + "NaN, double width", + "fb7ff8000000000000".into(), + "malformed", + ), + ("an indefinite byte string", "5f4161ff".into(), "malformed"), + ("an indefinite array", "9f01ff".into(), "malformed"), + ("an indefinite map", "bf616101ff".into(), "malformed"), + ("a lone break byte", "ff".into(), "malformed"), + ("additional info 28", "1c".into(), "malformed"), + ("additional info 29", "1d".into(), "malformed"), + ("additional info 30", "1e".into(), "malformed"), + ( + "a byte string longer than the input", + "4200".into(), + "malformed", + ), + ( + "a text length of 2^64-1", + "7bffffffffffffffff".into(), + "malformed", + ), + ( + "a map claiming 2^64-1 entries", + "bbffffffffffffffff".into(), + "malformed", + ), + ( + "an array claiming 2^32-1 items", + "9affffffff".into(), + "malformed", + ), + ( + "a byte string length in eight bytes", + "5b000000000000000100".into(), + "", + ), + ("tag 0 on text", "c060".into(), "malformed"), + ("tag 1", "c11a00000001".into(), "malformed"), + ("tag 2, a bignum", "c24101".into(), "malformed"), + ("simple value 0", "e0".into(), "malformed"), + ("simple value 19", "f3".into(), "malformed"), + ("the simple value false", "f4".into(), "malformed"), + ("the simple value true", "f5".into(), "malformed"), + ("the simple value undefined", "f7".into(), "malformed"), + ("simple value 24 in two bytes", "f818".into(), "malformed"), + ("simple value 32", "f820".into(), "malformed"), + ("null", "f6".into(), ""), + ]; + for (name, hex_input, reason) in cases { + let result = decode(&hex::decode(&hex_input).unwrap()); + if reason.is_empty() { + assert!( + result.is_ok(), + "{name}: {result:?}, but the reference accepts it" + ); + } else { + assert_eq!( + result, + Err(refusal(reason)), + "{name}: the reference refuses it as {reason}" + ); + } + } +} + +/// An array header for `count` items followed by `present` zeros. +fn zeros_array(count: u32, present: usize) -> Vec { + let mut out = vec![0x9a]; + out.extend_from_slice(&count.to_be_bytes()); + out.extend(std::iter::repeat_n(0u8, present)); + out +} + +/// A map header for `count` entries of distinct unsigned keys, each with a +/// zero value. +fn map_of_entries(count: u32) -> Vec { + let mut out = vec![0xba]; + out.extend_from_slice(&count.to_be_bytes()); + for i in 0..count { + out.push(0x1a); + out.extend_from_slice(&i.to_be_bytes()); + out.push(0x00); + } + out +} + +#[test] +fn items_count_against_macula_s_element_budget() { + // The array itself is one item, so 131,071 zeros fill the budget. + assert!(decode(&zeros_array(131_071, 131_071)).is_ok()); + assert_eq!( + decode(&zeros_array(131_072, 131_072)), + Err(DecodeError::TooManyElements) + ); + // One array item and 65,536 map entries of two items each: the item past + // the budget is a key. + let mut past = vec![0x81]; + past.extend(map_of_entries(65_536)); + assert_eq!(decode(&past), Err(DecodeError::TooManyElements)); +} diff --git a/tests/common/lab.rs b/tests/common/lab.rs new file mode 100644 index 0000000..5770c89 --- /dev/null +++ b/tests/common/lab.rs @@ -0,0 +1,201 @@ +//! The teststation's lab mode: stations and realms started one by one, and +//! the station-side facts a test asserts on (who is connected, what is +//! advertised or subscribed, how many streams are relayed). + +use std::io::BufReader; +use std::process::{Child, ChildStdin, ChildStdout}; +use std::sync::Mutex; + +use macula_rust::pool::Seed; +use macula_rust::profile::Profile; + +use super::{ask, spawn}; + +/// A station the lab started: its index, where it listens, the node_id it +/// proves. +#[derive(Debug, Clone)] +pub struct LabStation { + pub index: usize, + pub host: String, + pub port: u16, + pub node_id: [u8; 32], +} + +impl LabStation { + /// The station as a pool seed, pinned by its node_id. + pub fn seed(&self) -> Seed { + Seed { + host: self.host.clone(), + port: self.port, + node_id: self.node_id, + } + } +} + +/// A test realm with one org: its index, id and realm key as carried. +#[derive(Debug, Clone)] +pub struct LabRealm { + pub index: usize, + pub id: [u8; 32], + pub key: Vec, +} + +/// A teststation in lab mode, killed when dropped. +pub struct Lab { + pub profile: Profile, + child: Child, + io: Mutex<(ChildStdin, BufReader)>, +} + +impl Lab { + /// A lab in `profile`, with no station yet. + pub fn start(profile: Profile) -> Lab { + let (child, stdin, stdout) = spawn(&[profile.name(), "lab"]); + Lab { + profile, + child, + io: Mutex::new((stdin, stdout)), + } + } + + /// A new station named `name`, its own endpoint record in its DHT. + pub fn station(&self, name: &str) -> LabStation { + let reply = self.expect(&format!("start {name}"), "station"); + let f: Vec<&str> = reply.split_whitespace().collect(); + LabStation { + index: f[1].parse().unwrap(), + host: f[2].to_string(), + port: f[3].parse().unwrap(), + node_id: id32(f[4]), + } + } + + /// Stops a station: its listener and every connection close. + pub fn stop(&self, s: &LabStation) { + self.expect(&format!("stop {}", s.index), "stopped"); + } + + /// Closes `node`'s connection at the station, as a restart would. + pub fn drop_node(&self, s: &LabStation, node: &[u8; 32]) { + self.expect( + &format!("drop {} {}", s.index, hex::encode(node)), + "dropped", + ); + } + + pub fn connected(&self, s: &LabStation, node: &[u8; 32]) -> bool { + self.yes(&format!("connected {} {}", s.index, hex::encode(node))) + } + + pub fn advertised(&self, s: &LabStation, realm: &[u8; 32], procedure: &str) -> bool { + self.yes(&format!( + "advertised {} {} {procedure}", + s.index, + hex::encode(realm) + )) + } + + pub fn subscribed( + &self, + s: &LabStation, + node: &[u8; 32], + realm: &[u8; 32], + topic: &str, + ) -> bool { + self.yes(&format!( + "subscribed {} {} {} {topic}", + s.index, + hex::encode(node), + hex::encode(realm) + )) + } + + /// The stations hold one record store, as a replicating DHT would. + pub fn share(&self, stations: &[&LabStation]) { + let indexes: Vec = stations.iter().map(|s| s.index.to_string()).collect(); + self.expect(&format!("share {}", indexes.join(" ")), "shared"); + } + + /// Stores a verified record's wire bytes at the station. + pub fn put(&self, s: &LabStation, wire: &[u8]) { + self.expect(&format!("put {} {}", s.index, hex::encode(wire)), "put"); + } + + /// Stores `wire` under `key` whatever it is, as a lying station would. + pub fn forge(&self, s: &LabStation, key: &[u8; 32], wire: &[u8]) { + self.expect( + &format!( + "forge {} {} {}", + s.index, + hex::encode(key), + hex::encode(wire) + ), + "forged", + ); + } + + /// How many streams the station relays now. + pub fn relayed(&self, s: &LabStation) -> u64 { + let reply = self.expect(&format!("relayed {}", s.index), "relayed"); + reply.split_whitespace().nth(1).unwrap().parse().unwrap() + } + + /// A realm named `name` with the org `org`. + pub fn realm(&self, name: &str, org: &str) -> LabRealm { + assert!( + !name.contains(char::is_whitespace) && !org.contains(char::is_whitespace), + "a realm name or org with whitespace would split the lab's command: {name:?} {org:?}" + ); + self.realm_line(&format!("realm {name} {org}")) + } + + /// A realm with `r`'s id and org and another realm key. + pub fn impostor(&self, r: &LabRealm) -> LabRealm { + self.realm_line(&format!("impostor {}", r.index)) + } + + /// Puts `r`'s org directory, and its org's delegation to each of + /// `advertisers`, in the station's DHT. + pub fn admit(&self, r: &LabRealm, s: &LabStation, advertisers: &[[u8; 32]]) { + let nodes: Vec = advertisers.iter().map(hex::encode).collect(); + self.expect( + &format!("admit {} {} {}", r.index, s.index, nodes.join(" ")), + "admitted", + ); + } + + fn realm_line(&self, command: &str) -> LabRealm { + let reply = self.expect(command, "realm"); + let f: Vec<&str> = reply.split_whitespace().collect(); + LabRealm { + index: f[1].parse().unwrap(), + id: id32(f[2]), + key: hex::decode(f[3]).unwrap(), + } + } + + fn yes(&self, command: &str) -> bool { + match ask(&self.io, command).as_str() { + "yes" => true, + "no" => false, + other => panic!("{command}: {other}"), + } + } + + fn expect(&self, command: &str, answer: &str) -> String { + let reply = ask(&self.io, command); + assert!(reply.starts_with(answer), "{command}: {reply}"); + reply + } +} + +impl Drop for Lab { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn id32(text: &str) -> [u8; 32] { + hex::decode(text).unwrap().try_into().unwrap() +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..790858d --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,155 @@ +//! In-process macula 12 stations for a test file: macula-go's teststation, +//! built to target/teststation by scripts/build-teststation.sh (or named by +//! MACULA_TESTSTATION), driven over its stdin. [`TestStations`] is two +//! stations sharing a DHT and a test realm with one org; [`lab::Lab`] starts +//! and shapes stations and realms one by one. + +#![allow(dead_code)] + +pub mod lab; + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::Mutex; + +use macula_rust::profile::Profile; +use macula_rust::transport::Target; + +/// One station: where it listens and the node_id it proves. +#[derive(Debug, Clone)] +pub struct TestStation { + pub host: String, + pub port: u16, + pub node_id: [u8; 32], +} + +/// The running stations, their realm and org, and the helper's stdin. +pub struct TestStations { + pub profile: Profile, + pub stations: Vec, + pub realm_id: [u8; 32], + pub realm_key: Vec, + pub org: String, + child: Child, + io: Mutex<(ChildStdin, BufReader)>, +} + +impl TestStations { + /// Starts the stations in `profile`. A missing helper fails the test, + /// naming how to build it: a test that cannot reach its stations proves + /// nothing, so it is never skipped. + pub fn start(profile: Profile) -> TestStations { + let (child, stdin, mut stdout) = spawn(&[profile.name()]); + let mut line = String::new(); + stdout + .read_line(&mut line) + .expect("the teststation prints its stations"); + let info: serde_json::Value = + serde_json::from_str(&line).expect("the teststation's JSON line"); + let id = |v: &serde_json::Value| -> [u8; 32] { + hex::decode(v.as_str().unwrap()) + .unwrap() + .try_into() + .unwrap() + }; + let stations = info["stations"] + .as_array() + .unwrap() + .iter() + .map(|s| TestStation { + host: s["host"].as_str().unwrap().to_string(), + port: s["port"].as_u64().unwrap() as u16, + node_id: id(&s["node_id"]), + }) + .collect(); + TestStations { + profile, + stations, + realm_id: id(&info["realm_id"]), + realm_key: hex::decode(info["realm_key"].as_str().unwrap()).unwrap(), + org: info["org"].as_str().unwrap().to_string(), + child, + io: Mutex::new((stdin, stdout)), + } + } + + /// Station `i` as a dial target, pinned by its node_id. + pub fn target(&self, i: usize) -> Target { + let s = &self.stations[i]; + Target { + host: s.host.clone(), + port: s.port, + profile: self.profile, + expected_node_id: s.node_id, + } + } + + /// The org delegates its procedures to `node_id`. + pub fn admit(&self, node_id: &[u8; 32]) { + let reply = self.ask(&format!("admit {}", hex::encode(node_id))); + assert!(reply.starts_with("admitted"), "{reply}"); + } + + /// How many streams the stations relay now. + pub fn relayed(&self) -> u64 { + let reply = self.ask("relayed"); + reply + .split_whitespace() + .nth(1) + .and_then(|n| n.parse().ok()) + .expect("relayed ") + } + + fn ask(&self, command: &str) -> String { + ask(&self.io, command) + } +} + +/// Starts the teststation with `args`. A missing helper fails the test, +/// naming how to build it: a test that cannot reach its stations proves +/// nothing, so it is never skipped. +fn spawn(args: &[&str]) -> (Child, ChildStdin, BufReader) { + // target/ of the workspace: this crate's own, or, for the FFI crate, its + // parent's. + let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let binary = std::env::var("MACULA_TESTSTATION").unwrap_or_else(|_| { + [manifest, manifest.parent().unwrap_or(manifest)] + .iter() + .map(|dir| dir.join("target/teststation")) + .find(|path| path.exists()) + .unwrap_or_else(|| manifest.join("target/teststation")) + .to_string_lossy() + .into_owned() + }); + assert!( + std::path::Path::new(&binary).exists(), + "{binary} is missing: run scripts/build-teststation.sh first" + ); + let mut child = Command::new(&binary) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("the teststation starts"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + (child, stdin, stdout) +} + +/// One command to the teststation and its one-line answer. +fn ask(io: &Mutex<(ChildStdin, BufReader)>, command: &str) -> String { + let mut io = io.lock().unwrap(); + writeln!(io.0, "{command}").unwrap(); + io.0.flush().unwrap(); + let mut line = String::new(); + io.1.read_line(&mut line).unwrap(); + line.trim_end().to_string() +} + +impl Drop for TestStations { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} diff --git a/tests/frame_codec.rs b/tests/frame_codec.rs new file mode 100644 index 0000000..7dca38b --- /dev/null +++ b/tests/frame_codec.rs @@ -0,0 +1,104 @@ +//! The length-prefixed wire codec, and the payload and frame checks a sender +//! runs so nothing the decoding rule refuses on arrival leaves this node. + +use macula_rust::cbor::Value; +use macula_rust::frame::{ + check_frame, check_payload, decode, encode, Decoded, FrameError, MAX_FRAME_BYTES, + MAX_PAYLOAD_ELEMENTS, MAX_PAYLOAD_NESTING, +}; + +fn nested(depth: usize) -> Value { + (0..depth).fold(Value::Int(0), |inner, _| Value::List(vec![inner])) +} + +#[test] +fn a_frame_travels_length_prefixed_and_decodes_whole() { + let frame = Value::Map(vec![(Value::text("version"), Value::Int(2))]); + let wire = encode(&frame).unwrap(); + assert_eq!(&wire[..4], &((wire.len() - 4) as u32).to_be_bytes()); + let mut two = wire.clone(); + two.extend_from_slice(&wire); + match decode(&two).unwrap() { + Decoded::Complete { + frame: decoded, + consumed, + } => { + assert_eq!(decoded, frame); + assert_eq!(consumed, wire.len()); + } + other => panic!("{other:?}"), + } + assert_eq!(decode(&wire[..2]).unwrap(), Decoded::NeedMore(2)); + assert_eq!( + decode(&wire[..wire.len() - 1]).unwrap(), + Decoded::NeedMore(1) + ); +} + +#[test] +fn a_frame_over_the_cap_is_refused_both_ways() { + let big = Value::Bytes(vec![0u8; MAX_FRAME_BYTES]); + assert!(matches!(encode(&big), Err(FrameError::TooLarge(_)))); + let mut header = ((MAX_FRAME_BYTES + 1) as u32).to_be_bytes().to_vec(); + header.push(0); + assert!(matches!(decode(&header), Err(FrameError::TooLarge(_)))); + assert!(matches!( + decode(&[0, 0, 0, 1, 0xff]), + Err(FrameError::Malformed) + )); +} + +#[test] +fn a_payload_the_decoding_rule_would_refuse_is_refused_before_it_is_sent() { + let refused = [ + ( + "a float key", + Value::Map(vec![(Value::Float(1.0), Value::Int(1))]), + ), + ( + "a bytes key", + Value::Map(vec![(Value::Bytes(vec![1]), Value::Int(1))]), + ), + ( + "two keys that encode alike", + Value::Map(vec![ + (Value::text("a"), Value::Int(1)), + (Value::text("a"), Value::Int(2)), + ]), + ), + ( + "an integer above 2^63-1", + Value::Int(i128::from(i64::MAX) + 1), + ), + ("a NaN", Value::Float(f64::NAN)), + ("an infinity", Value::Float(f64::INFINITY)), + ("too deep", nested(MAX_PAYLOAD_NESTING + 1)), + ( + "too many items", + Value::List(vec![Value::Int(0); MAX_PAYLOAD_ELEMENTS]), + ), + ]; + for (name, payload) in refused { + assert!( + matches!(check_payload(&payload), Err(FrameError::Payload(_))), + "{name}" + ); + } + assert!(check_payload(&nested(MAX_PAYLOAD_NESTING)).is_ok()); + assert!(check_payload(&Value::List(vec![Value::Int(0); MAX_PAYLOAD_ELEMENTS - 1])).is_ok()); + assert!(check_payload(&Value::Map(vec![(Value::Int(-1), Value::text("x"))])).is_ok()); +} + +#[test] +fn a_whole_frame_is_held_to_the_rule_s_own_limits() { + let frame = Value::Map(vec![(Value::text("payload"), nested(MAX_PAYLOAD_NESTING))]); + assert!(check_frame(&frame).is_ok()); + let deeper = Value::Map(vec![( + Value::text("payload"), + nested(MAX_PAYLOAD_NESTING + 1), + )]); + assert!(matches!( + check_frame(&deeper), + Err(FrameError::BreaksDecodingRule(_)) + )); +} diff --git a/tests/frame_publication_neighbour.rs b/tests/frame_publication_neighbour.rs new file mode 100644 index 0000000..b06e337 --- /dev/null +++ b/tests/frame_publication_neighbour.rs @@ -0,0 +1,240 @@ +//! Publications (D17), signed by their publishers and held to their time, and +//! the control frames a pq_hybrid link neighbour-signs, held to the ones macula +//! itself signed (tests/vectors/frame/erlang_neighbour.json, macula_frame at +//! macula v12.1.0). + +use macula_rust::cbor::{self, Value}; +use macula_rust::frame::{ + advertise_frame, decode, goodbye_frame, neighbour_signed, sign_neighbour, sign_publish, + subscribe_frame, unadvertise_frame, unsubscribe_frame, verify_neighbour, verify_publication, + Decoded, FrameError, NeighbourLink, NeighbourPeer, PublicationSpec, +}; +use macula_rust::node_key::{NodeKey, Purpose}; +use macula_rust::profile::Profile; + +const NOW: u64 = 1_789_000_000_000; +const MINUTE: u64 = 60_000; + +fn spec() -> PublicationSpec { + PublicationSpec { + realm: [3; 32], + topic: "acme/demo/greeting_sent_v1".to_string(), + seq: 1, + published_at: NOW, + payload: Value::text("hi"), + ttl_ms: None, + } +} + +fn arrived(frame: &Value) -> Value { + cbor::decode(&cbor::encode(frame).unwrap()).unwrap() +} + +#[test] +fn a_publication_verifies_for_its_publisher_within_its_time() { + for profile in [Profile::PqPure, Profile::PqHybrid] { + let key = NodeKey::generate(Purpose::Identity, profile).unwrap(); + let frame = arrived(&sign_publish(&spec(), &key).unwrap()); + let publication = verify_publication(&frame, profile, NOW as i64).unwrap(); + assert_eq!(publication.publisher, key.key_id()); + assert_eq!(publication.topic, "acme/demo/greeting_sent_v1"); + assert_eq!(publication.seq, 1); + assert_eq!(publication.payload, Value::text("hi")); + assert_eq!(publication.expires_at, NOW + 15 * MINUTE); + + assert_eq!( + verify_publication(&frame, profile, (NOW - 6 * MINUTE) as i64).unwrap_err(), + FrameError::NotYetValid(MINUTE as i64) + ); + assert_eq!( + verify_publication(&frame, profile, (NOW + 16 * MINUTE) as i64).unwrap_err(), + FrameError::Expired(MINUTE as i64) + ); + } +} + +#[test] +fn a_publication_s_ttl_bounds_its_life_to_an_hour() { + let key = NodeKey::generate(Purpose::Identity, Profile::PqPure).unwrap(); + let mut long = spec(); + long.ttl_ms = Some(60 * MINUTE); + let frame = arrived(&sign_publish(&long, &key).unwrap()); + assert_eq!( + verify_publication(&frame, Profile::PqPure, NOW as i64) + .unwrap() + .expires_at, + NOW + 65 * MINUTE + ); + long.ttl_ms = Some(60 * MINUTE + 1); + assert!(matches!( + sign_publish(&long, &key), + Err(FrameError::OutOfRange(_)) + )); + let mut wide = spec(); + wide.topic = "t".repeat(513); + assert!(matches!( + sign_publish(&wide, &key), + Err(FrameError::TextTooLong(_)) + )); +} + +#[test] +fn an_event_carries_the_publication_with_how_it_was_delivered() { + let key = NodeKey::generate(Purpose::Identity, Profile::PqPure).unwrap(); + let Value::Map(pairs) = sign_publish(&spec(), &key).unwrap() else { + unreachable!() + }; + let as_event = |extra: Option<(Value, Value)>| { + let mut event: Vec<(Value, Value)> = pairs + .iter() + .map(|(k, v)| { + if *k == Value::text("frame_type") { + (k.clone(), Value::text("event")) + } else { + (k.clone(), v.clone()) + } + }) + .collect(); + event.extend(extra); + Value::Map(event) + }; + let direct = as_event(Some((Value::text("delivered_via"), Value::text("direct")))); + assert!(verify_publication(&direct, Profile::PqPure, NOW as i64).is_ok()); + assert_eq!( + verify_publication(&as_event(None), Profile::PqPure, NOW as i64).unwrap_err(), + FrameError::Malformed + ); +} + +fn unhex(s: &str) -> Vec { + hex::decode(s).unwrap() +} + +fn whole(bytes: &[u8]) -> Value { + match decode(bytes).unwrap() { + Decoded::Complete { frame, consumed } if consumed == bytes.len() => frame, + other => panic!("{other:?}"), + } +} + +fn frame_type(v: &Value) -> Option<&str> { + match v.get("frame_type") { + Some(Value::Text(t)) => Some(t), + _ => None, + } +} + +#[test] +fn control_frames_macula_signed_open_here() { + let text = std::fs::read_to_string("tests/vectors/frame/erlang_neighbour.json").unwrap(); + let doc: serde_json::Value = serde_json::from_str(&text).unwrap(); + let entries = doc["entries"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + let mut opened_count = 0; + for e in entries { + let profile = Profile::parse(e["profile"].as_str().unwrap()).unwrap(); + let connection: [u8; 48] = unhex(e["connection"].as_str().unwrap()).try_into().unwrap(); + let peer_key = unhex(e["peer_key"].as_str().unwrap()); + for f in e["frames"].as_array().unwrap() { + let wire = whole(&unhex(f["bytes"].as_str().unwrap())); + let peer = NeighbourPeer { + profile, + peer_key: peer_key.clone(), + connection, + seq: f["seq"].as_u64().unwrap(), + }; + let opened = verify_neighbour(&wire, &peer).unwrap(); + assert_eq!(frame_type(&opened), f["frame_type"].as_str(), "{profile:?}"); + opened_count += 1; + if profile != Profile::PqHybrid { + continue; + } + let next = NeighbourPeer { + seq: peer.seq + 1, + ..peer.clone() + }; + assert_eq!( + verify_neighbour(&wire, &next).unwrap_err(), + FrameError::Malformed + ); + let mut other = peer.clone(); + other.connection[0] ^= 1; + assert_eq!( + verify_neighbour(&wire, &other).unwrap_err(), + FrameError::Malformed + ); + } + } + assert!(opened_count >= 10); +} + +#[test] +fn control_frames_built_here_open_again_and_only_pq_hybrid_signs_them() { + let realm = [3u8; 32]; + let subscriber = [1u8; 32]; + let topic = b"io.macula/mcl-news/news/wire/news_item_reported_v1"; + for profile in [Profile::PqHybrid, Profile::PqPure] { + let key = NodeKey::generate(Purpose::Identity, profile).unwrap(); + let connection = [9u8; 48]; + let frames = [ + ("advertise", advertise_frame(b"a signed record")), + ("unadvertise", unadvertise_frame(b"a signed withdrawal")), + ( + "subscribe", + subscribe_frame(topic, &realm, &subscriber).unwrap(), + ), + ( + "unsubscribe", + unsubscribe_frame(topic, &realm, &subscriber).unwrap(), + ), + ( + "goodbye", + goodbye_frame("normal", Some(b"closing")).unwrap(), + ), + ]; + for (seq, (name, frame)) in frames.into_iter().enumerate() { + assert_eq!( + neighbour_signed(profile, name), + profile == Profile::PqHybrid + ); + let link = NeighbourLink { + connection, + seq: seq as u64, + }; + let signed = sign_neighbour(&frame, &key, &link).unwrap(); + assert_eq!( + signed.get("neighbour").is_some(), + profile == Profile::PqHybrid, + "{name}" + ); + let peer = NeighbourPeer { + profile, + peer_key: key.public_key(), + connection, + seq: seq as u64, + }; + let opened = verify_neighbour(&arrived(&signed), &peer).unwrap(); + assert_eq!(frame_type(&opened), Some(name)); + if profile == Profile::PqHybrid { + assert_eq!( + sign_neighbour(&signed, &key, &link).unwrap_err(), + FrameError::NeighbourSigned + ); + } + } + } +} + +#[test] +fn a_subscribe_topic_is_utf8_of_at_most_512_bytes() { + let (realm, subscriber) = ([1u8; 32], [2u8; 32]); + assert!(subscribe_frame(&[b't'; 512], &realm, &subscriber).is_ok()); + assert!(matches!( + subscribe_frame(&[b't'; 513], &realm, &subscriber), + Err(FrameError::TextTooLong(_)) + )); + assert!(matches!( + unsubscribe_frame(b"\xff", &realm, &subscriber), + Err(FrameError::InvalidText(_)) + )); +} diff --git a/tests/frame_request_reply.rs b/tests/frame_request_reply.rs new file mode 100644 index 0000000..a978179 --- /dev/null +++ b/tests/frame_request_reply.rs @@ -0,0 +1,335 @@ +//! Requests, replies and relay errors (D25): signed by their senders' identity +//! keys, verified by who they claim to be from and which request they answer, +//! and every build refused where macula refuses it. + +use macula_rust::cbor::{self, Value}; +use macula_rust::frame::{ + claimed_reply_ids, sign_call, sign_provider_error, sign_relay_error, sign_result, + sign_stream_open, verify_relay_error, verify_reply, verify_request, FrameError, RelayErrorSpec, + RelayErrorType, ReplyType, RequestSpec, RequestType, StreamMode, VerifiedRequest, MAX_PROOFS, + MAX_PROOFS_BYTES, +}; +use macula_rust::node_key::{NodeKey, Purpose}; +use macula_rust::profile::Profile; + +struct Keys { + caller: NodeKey, + provider: NodeKey, + station: NodeKey, +} + +fn keys(profile: Profile) -> Keys { + Keys { + caller: NodeKey::generate(Purpose::Identity, profile).unwrap(), + provider: NodeKey::generate(Purpose::Identity, profile).unwrap(), + station: NodeKey::generate(Purpose::Identity, profile).unwrap(), + } +} + +fn call_spec(keys: &Keys) -> RequestSpec { + RequestSpec { + request_id: [7; 16], + realm: [3; 32], + procedure: "acme/echo".to_string(), + target: keys.provider.key_id(), + deadline: 1_789_000_005_000, + payload: Value::text("hello"), + mode: None, + token: None, + proofs: Vec::new(), + source_route: None, + retry_budget: None, + } +} + +/// A frame as it arrives: encoded and decoded under the decoding rule. +fn arrived(frame: &Value) -> Value { + cbor::decode(&cbor::encode(frame).unwrap()).unwrap() +} + +fn verified_call(keys: &Keys, spec: &RequestSpec, profile: Profile) -> VerifiedRequest { + verify_request(&arrived(&sign_call(spec, &keys.caller).unwrap()), profile).unwrap() +} + +#[test] +fn a_call_verifies_as_its_caller_signed_it() { + for profile in [Profile::PqPure, Profile::PqHybrid] { + let k = keys(profile); + let mut spec = call_spec(&k); + spec.token = Some(b"a token".to_vec()); + spec.source_route = Some(b"route".to_vec()); + spec.retry_budget = Some(3); + let request = verified_call(&k, &spec, profile); + assert_eq!(request.frame_type, RequestType::Call); + assert_eq!(request.caller, k.caller.key_id()); + assert_eq!(request.key, k.caller.public_key()); + assert_eq!(request.request_id, spec.request_id); + assert_eq!(request.realm, spec.realm); + assert_eq!(request.procedure, "acme/echo"); + assert_eq!(request.target, spec.target); + assert_eq!(request.deadline, spec.deadline); + assert_eq!(request.payload, Value::text("hello")); + assert_eq!(request.mode, None); + assert_eq!(request.token.as_deref(), Some(&b"a token"[..])); + assert_eq!(request.proofs, None); + } +} + +#[test] +fn a_stream_open_carries_its_mode() { + let k = keys(Profile::PqPure); + let mut spec = call_spec(&k); + spec.mode = Some(StreamMode::Bidi); + let request = verify_request( + &arrived(&sign_stream_open(&spec, &k.caller).unwrap()), + Profile::PqPure, + ) + .unwrap(); + assert_eq!(request.frame_type, RequestType::StreamOpen); + assert_eq!(request.mode, Some(StreamMode::Bidi)); + spec.mode = None; + assert!(matches!( + sign_stream_open(&spec, &k.caller), + Err(FrameError::OutOfRange(_)) + )); + spec.mode = Some(StreamMode::ServerStream); + assert!(matches!( + sign_call(&spec, &k.caller), + Err(FrameError::OutOfRange(_)) + )); +} + +#[test] +fn a_request_build_is_refused_in_macula_s_order() { + let k = keys(Profile::PqPure); + let connect = NodeKey::generate(Purpose::Connect, Profile::PqPure).unwrap(); + assert_eq!( + sign_call(&call_spec(&k), &connect).unwrap_err(), + FrameError::Unsignable + ); + let mut long = call_spec(&k); + long.procedure = "p".repeat(513); + assert!(matches!( + sign_call(&long, &k.caller), + Err(FrameError::TextTooLong(_)) + )); + let mut unsendable = call_spec(&k); + unsendable.payload = Value::Float(f64::NAN); + assert!(matches!( + sign_call(&unsendable, &k.caller), + Err(FrameError::Payload(_)) + )); + let mut late = call_spec(&k); + late.deadline = 1 << 53; + assert!(matches!( + sign_call(&late, &k.caller), + Err(FrameError::OutOfRange(_)) + )); +} + +#[test] +fn a_request_carries_its_proofs_within_their_bound() { + let k = keys(Profile::PqPure); + let mut spec = call_spec(&k); + spec.proofs = vec![b"proof.one".to_vec(), b"proof.two".to_vec()]; + assert_eq!( + verified_call(&k, &spec, Profile::PqPure).proofs, + Some(spec.proofs.clone()) + ); + for (name, proofs) in [ + ( + "nine", + (0..=MAX_PROOFS as u8).map(|i| vec![i]).collect::>(), + ), + ("one repeated", vec![b"same".to_vec(), b"same".to_vec()]), + ("over the bytes", vec![vec![0u8; MAX_PROOFS_BYTES + 1]]), + ] { + spec.proofs = proofs; + assert_eq!( + sign_call(&spec, &k.caller).unwrap_err(), + FrameError::ProofsOutOfBound, + "{name}" + ); + } +} + +/// The shared decoding rule vectors macula reads as a CALL's fields: where a +/// delegation chain's proofs are bounded. +#[test] +fn the_request_field_vectors_read_as_every_stack_reads_them() { + let text = std::fs::read_to_string("tests/vectors/cbor/decoding_rule_v1.json").unwrap(); + let doc: serde_json::Value = serde_json::from_str(&text).unwrap(); + let mut ran = 0; + for e in doc["entries"].as_array().unwrap() { + if e["via"].as_str() != Some("request_fields") { + continue; + } + ran += 1; + let fields = cbor::decode(&hex::decode(e["cbor"].as_str().unwrap()).unwrap()).unwrap(); + let accepted = macula_rust::frame::request_fields_accepted(&fields); + assert_eq!(accepted, e["expect"] == "accept", "{}", e["name"]); + } + assert_eq!(ran, 5); +} + +#[test] +fn a_request_altered_or_from_another_caller_is_refused() { + let k = keys(Profile::PqPure); + let frame = arrived(&sign_call(&call_spec(&k), &k.caller).unwrap()); + // One tbs byte changed. + let altered = rewrite_object(&frame, "request", |object| { + if let Some(Value::Bytes(tbs)) = object + .iter_mut() + .find(|(k, _)| *k == Value::text("tbs")) + .map(|(_, v)| v) + { + let at = tbs.len() / 2; + tbs[at] ^= 1; + } + }); + assert_eq!( + verify_request(&altered, Profile::PqPure).unwrap_err(), + FrameError::SignatureInvalid + ); + // Another key carried in place of the caller's. + let swapped = rewrite_object(&frame, "request", |object| { + for (k2, v) in object.iter_mut() { + if *k2 == Value::text("key") { + *v = Value::Bytes(k.station.public_key()); + } + } + }); + assert_eq!( + verify_request(&swapped, Profile::PqPure).unwrap_err(), + FrameError::SignatureInvalid + ); + // An extra field on the frame. + let extra = match frame.clone() { + Value::Map(mut pairs) => { + pairs.push((Value::text("more"), Value::Int(1))); + Value::Map(pairs) + } + _ => unreachable!(), + }; + assert_eq!( + verify_request(&extra, Profile::PqPure).unwrap_err(), + FrameError::Malformed + ); +} + +fn rewrite_object(frame: &Value, name: &str, mut f: impl FnMut(&mut Vec<(Value, Value)>)) -> Value { + let Value::Map(mut pairs) = frame.clone() else { + unreachable!() + }; + for (k, v) in pairs.iter_mut() { + if *k == Value::text(name) { + if let Value::Map(object) = v { + f(object); + } + } + } + Value::Map(pairs) +} + +#[test] +fn a_reply_verifies_only_from_the_target_for_its_request() { + for profile in [Profile::PqPure, Profile::PqHybrid] { + let k = keys(profile); + let request = verified_call(&k, &call_spec(&k), profile); + + let result = + arrived(&sign_result(&request, &Value::text("pong"), None, &k.provider).unwrap()); + let reply = verify_reply(&result, &request, profile).unwrap(); + assert_eq!(reply.frame_type, ReplyType::Result); + assert_eq!(reply.responded_by, k.provider.key_id()); + assert_eq!(reply.payload, Some(Value::text("pong"))); + + let error = arrived( + &sign_provider_error( + &request, + "handler_error", + Some("refused"), + Some(b"back".to_vec()), + &k.provider, + ) + .unwrap(), + ); + let reply = verify_reply(&error, &request, profile).unwrap(); + assert_eq!(reply.frame_type, ReplyType::Error); + assert_eq!(reply.code.as_deref(), Some("handler_error")); + assert_eq!(reply.detail.as_deref(), Some("refused")); + + // Signed by anyone but the target: not signable, and not accepted. + assert_eq!( + sign_result(&request, &Value::Null, None, &k.station).unwrap_err(), + FrameError::Unsignable + ); + let mut other_target = call_spec(&k); + other_target.target = k.station.key_id(); + let other_request = verified_call(&k, &other_target, profile); + let from_station = + arrived(&sign_result(&other_request, &Value::Null, None, &k.station).unwrap()); + assert_eq!( + verify_reply(&from_station, &request, profile).unwrap_err(), + FrameError::RequestMismatch + ); + let mut same_ids = request.clone(); + same_ids.target = k.station.key_id(); + let as_other = arrived(&sign_result(&same_ids, &Value::Null, None, &k.station).unwrap()); + assert_eq!( + verify_reply(&as_other, &request, profile).unwrap_err(), + FrameError::NotTheTarget + ); + + assert_eq!( + claimed_reply_ids(&result).unwrap(), + (request.request_id, request.request_hash) + ); + } +} + +#[test] +fn a_provider_error_s_text_is_bounded() { + let k = keys(Profile::PqPure); + let request = verified_call(&k, &call_spec(&k), Profile::PqPure); + assert!(matches!( + sign_provider_error(&request, &"c".repeat(65), None, None, &k.provider), + Err(FrameError::TextTooLong(_)) + )); + assert!(matches!( + sign_provider_error(&request, "code", Some(&"d".repeat(257)), None, &k.provider), + Err(FrameError::TextTooLong(_)) + )); +} + +#[test] +fn a_relay_error_verifies_only_from_the_connection_s_station() { + let k = keys(Profile::PqPure); + let request = verified_call(&k, &call_spec(&k), Profile::PqPure); + let spec = RelayErrorSpec { + frame_type: RelayErrorType::Error, + request: request.clone(), + code: "unknown_next_peer".to_string(), + offending_hop: Some([5; 32]), + source_route_partial: None, + }; + let frame = arrived(&sign_relay_error(&spec, &k.station).unwrap()); + let relay = verify_relay_error(&frame, &request, Profile::PqPure, &k.station.key_id()).unwrap(); + assert_eq!(relay.reported_by, k.station.key_id()); + assert_eq!(relay.code, "unknown_next_peer"); + assert_eq!(relay.offending_hop, Some([5; 32])); + assert_eq!( + verify_relay_error(&frame, &request, Profile::PqPure, &[1; 32]).unwrap_err(), + FrameError::NotTheConnection + ); + assert_eq!( + claimed_reply_ids(&frame).unwrap(), + (request.request_id, request.request_hash) + ); + let mut outside = spec.clone(); + outside.code = "handler_error".to_string(); + assert_eq!( + sign_relay_error(&outside, &k.station).unwrap_err(), + FrameError::RelayCodeOutsideItsSet + ); +} diff --git a/tests/frame_stream.rs b/tests/frame_stream.rs new file mode 100644 index 0000000..16d0d15 --- /dev/null +++ b/tests/frame_stream.rs @@ -0,0 +1,202 @@ +//! Stream frames (D25 item 5): the provider's carry its key on its first frame +//! and not after, the caller's are verified with the key its STREAM_OPEN +//! carried, each side's seq runs from 0 without a gap, and nothing follows a +//! side's STREAM_END. + +use macula_rust::cbor::{self, Value}; +use macula_rust::frame::{ + open_stream, sign_caller_stream, sign_provider_stream, sign_stream_open, verify_caller_stream, + verify_provider_stream, verify_request, FrameError, RequestSpec, StreamEncoding, StreamFields, + StreamMode, StreamRole, StreamState, VerifiedRequest, +}; +use macula_rust::node_key::{NodeKey, Purpose}; +use macula_rust::profile::Profile; + +const P: Profile = Profile::PqPure; + +struct Stream { + caller: NodeKey, + provider: NodeKey, + open: VerifiedRequest, +} + +fn stream(mode: StreamMode) -> Stream { + let caller = NodeKey::generate(Purpose::Identity, P).unwrap(); + let provider = NodeKey::generate(Purpose::Identity, P).unwrap(); + let spec = RequestSpec { + request_id: [4; 16], + realm: [3; 32], + procedure: "acme/watch".to_string(), + target: provider.key_id(), + deadline: 1_789_000_005_000, + payload: Value::Null, + mode: Some(mode), + token: None, + proofs: Vec::new(), + source_route: None, + retry_budget: None, + }; + let frame = sign_stream_open(&spec, &caller).unwrap(); + let open = verify_request(&arrived(&frame), P).unwrap(); + Stream { + caller, + provider, + open, + } +} + +fn arrived(frame: &Value) -> Value { + cbor::decode(&cbor::encode(frame).unwrap()).unwrap() +} + +fn data(seq: u64, body: &[u8]) -> StreamFields { + StreamFields::Data { + seq, + encoding: StreamEncoding::Raw, + body: Value::Bytes(body.to_vec()), + } +} + +#[test] +fn a_provider_s_frames_carry_its_key_first_and_run_in_order() { + let s = stream(StreamMode::ServerStream); + let mut state = open_stream(&s.open).unwrap(); + let first = arrived(&sign_provider_stream(&data(0, b"one"), &s.open, &s.provider).unwrap()); + assert!(first.get("stream").and_then(|o| o.get("key")).is_some()); + let later = arrived(&sign_provider_stream(&data(1, b"two"), &s.open, &s.provider).unwrap()); + assert!(later.get("stream").and_then(|o| o.get("key")).is_none()); + let end = arrived( + &sign_provider_stream( + &StreamFields::End { + seq: 2, + role: StreamRole::Both, + }, + &s.open, + &s.provider, + ) + .unwrap(), + ); + + // The later frame before the first: no key held yet. + assert_eq!( + verify_provider_stream(&later, &state, P).unwrap_err(), + FrameError::SeqMismatch + ); + for (frame, want) in [(&first, data(0, b"one")), (&later, data(1, b"two"))] { + let (verified, next) = verify_provider_stream(frame, &state, P).unwrap(); + assert_eq!(verified.signer, s.provider.key_id()); + assert_eq!(verified.fields, want); + state = next; + } + // The same frame again: out of order. + assert_eq!( + verify_provider_stream(&later, &state, P).unwrap_err(), + FrameError::SeqMismatch + ); + let (_, ended) = verify_provider_stream(&end, &state, P).unwrap(); + let after = arrived(&sign_provider_stream(&data(3, b"late"), &s.open, &s.provider).unwrap()); + assert_eq!( + verify_provider_stream(&after, &ended, P).unwrap_err(), + FrameError::StreamEnded + ); +} + +#[test] +fn a_caller_s_frames_verify_with_the_stream_open_s_key() { + let s = stream(StreamMode::ClientStream); + let state = open_stream(&s.open).unwrap(); + let chunk = arrived(&sign_caller_stream(&data(0, b"up"), &s.open, &s.caller).unwrap()); + let (verified, state) = verify_caller_stream(&chunk, &state, P).unwrap(); + assert_eq!(verified.signer, s.caller.key_id()); + let end = arrived( + &sign_caller_stream( + &StreamFields::End { + seq: 1, + role: StreamRole::Send, + }, + &s.open, + &s.caller, + ) + .unwrap(), + ); + let (_, state) = verify_caller_stream(&end, &state, P).unwrap(); + + // The provider answers a client_stream with a STREAM_REPLY; a caller sends + // none. + let reply = StreamFields::Reply { + seq: 0, + payload: Value::Int(6), + }; + let from_provider = arrived(&sign_provider_stream(&reply, &s.open, &s.provider).unwrap()); + let (verified, _) = verify_provider_stream(&from_provider, &state, P).unwrap(); + assert_eq!(verified.fields, reply); + assert!(matches!( + sign_caller_stream(&reply, &s.open, &s.caller), + Err(FrameError::NotAllowed(_)) + )); +} + +#[test] +fn a_caller_sends_no_data_in_a_server_stream() { + let s = stream(StreamMode::ServerStream); + assert!(matches!( + sign_caller_stream(&data(0, b"x"), &s.open, &s.caller), + Err(FrameError::NotAllowed(_)) + )); + // An end is allowed. + assert!(sign_caller_stream( + &StreamFields::End { + seq: 0, + role: StreamRole::Both + }, + &s.open, + &s.caller + ) + .is_ok()); +} + +#[test] +fn a_stream_frame_for_another_stream_or_signer_is_refused() { + let s = stream(StreamMode::Bidi); + let other = stream(StreamMode::Bidi); + let state: StreamState = open_stream(&s.open).unwrap(); + // Signed by the provider of another stream: unsignable here, and refused + // when it arrives. + assert_eq!( + sign_provider_stream(&data(0, b"x"), &s.open, &other.provider).unwrap_err(), + FrameError::Unsignable + ); + let foreign = + arrived(&sign_provider_stream(&data(0, b"x"), &other.open, &other.provider).unwrap()); + assert_eq!( + verify_provider_stream(&foreign, &state, P).unwrap_err(), + FrameError::RequestMismatch + ); + let raw_value = StreamFields::Data { + seq: 0, + encoding: StreamEncoding::Raw, + body: Value::Int(1), + }; + assert!(matches!( + sign_provider_stream(&raw_value, &s.open, &s.provider), + Err(FrameError::OutOfRange(_)) + )); + let error = StreamFields::Error { + seq: 0, + code: "c".repeat(65), + message: String::new(), + }; + assert!(matches!( + sign_provider_stream(&error, &s.open, &s.provider), + Err(FrameError::TextTooLong(_)) + )); +} + +#[test] +fn a_stream_opens_only_on_a_stream_open() { + let s = stream(StreamMode::Bidi); + let mut call = s.open.clone(); + call.frame_type = macula_rust::frame::RequestType::Call; + call.mode = None; + assert!(matches!(open_stream(&call), Err(FrameError::OutOfRange(_)))); +} diff --git a/tests/handshake.rs b/tests/handshake.rs new file mode 100644 index 0000000..ae7c41a --- /dev/null +++ b/tests/handshake.rs @@ -0,0 +1,372 @@ +//! macula 12's version-4 handshake: opener, challenge, CONNECT, HELLO and +//! status frames. Held to the frames macula itself made +//! (tests/vectors/handshake/erlang_handshake.json, macula_handshake at macula +//! v12.1.0) both ways, then driven end to end between this crate's client and +//! station halves, and refused where macula refuses. + +use macula_rust::binding::{ + connect_binding, status_statement, tls_binding, BindingError, SignedTbs, +}; +use macula_rust::cbor::{self, Value}; +use macula_rust::handshake::{ + accept_connect, answer_challenge, challenge, opener, read_hello, read_opener, read_status, + status_frame, ClientSession, HandshakeError, Peer, PuzzleMode, PuzzleResult, RefusalCode, + StationMaterial, StationSession, +}; +use macula_rust::node_key::{NodeKey, Purpose}; +use macula_rust::profile::Profile; + +const HOUR_MS: i64 = 60 * 60 * 1000; +const DAY_MS: i64 = 24 * HOUR_MS; + +fn unhex(s: &str) -> Vec { + hex::decode(s).unwrap() +} + +/// A client's identity key, CONNECT key, binding and status statement. +struct ClientKeys { + identity: NodeKey, + connect: NodeKey, + binding: SignedTbs, + status: SignedTbs, +} + +fn client_keys(profile: Profile, now: i64) -> ClientKeys { + let identity = NodeKey::generate_identity(profile, 0).unwrap(); + let connect = NodeKey::generate(Purpose::Connect, profile).unwrap(); + let binding = connect_binding(&identity, &connect.public_key(), now, now + DAY_MS).unwrap(); + let status = status_statement(&identity, &binding, now, now + HOUR_MS).unwrap(); + ClientKeys { + identity, + connect, + binding, + status, + } +} + +fn session<'a>( + keys: &'a ClientKeys, + profile: Profile, + expected: [u8; 32], + leaf: &'a [u8], + now: i64, +) -> ClientSession<'a> { + ClientSession { + profile, + expected_node_id: expected, + leaf, + identity_key: keys.identity.public_key(), + connect_key: &keys.connect, + connect_binding: &keys.binding, + connect_status: &keys.status, + capabilities: 3, + now_ms: now, + member_endorsement: Vec::new(), + } +} + +/// A station: its identity key, and the material it challenges with for +/// `leaf`. +fn station(profile: Profile, leaf: &[u8], now: i64) -> (NodeKey, StationMaterial) { + let identity = NodeKey::generate_identity(profile, 0).unwrap(); + let binding = tls_binding(&identity, leaf, now, now + DAY_MS).unwrap(); + let status = status_statement(&identity, &binding, now, now + HOUR_MS).unwrap(); + let material = StationMaterial { + profile, + identity_key: identity.public_key(), + tls_binding: binding, + tls_status: status, + }; + (identity, material) +} + +fn station_session(profile: Profile, challenge: &[u8], leaf: &[u8], now: i64) -> StationSession { + StationSession { + profile, + challenge: challenge.to_vec(), + leaf: leaf.to_vec(), + puzzle_difficulty: 0, + puzzle_mode: PuzzleMode::Enforce, + capabilities: 5, + now_ms: now, + } +} + +/// One profile's frames: a challenge macula made as a station, its node_id, +/// and a CONNECT macula made answering a challenge macula-go made. +struct ErlangEntry { + profile: Profile, + erlang_challenge: Vec, + station_node_id: [u8; 32], + go_challenge: Vec, + erlang_connect: Vec, +} + +struct Erlang { + now: i64, + leaf: Vec, + entries: Vec, +} + +fn erlang() -> Erlang { + let text = std::fs::read_to_string("tests/vectors/handshake/erlang_handshake.json").unwrap(); + let doc: serde_json::Value = serde_json::from_str(&text).unwrap(); + let s = |v: &serde_json::Value| v.as_str().unwrap().to_owned(); + let entries: Vec<_> = doc["entries"] + .as_array() + .unwrap() + .iter() + .map(|e| ErlangEntry { + profile: Profile::parse(&s(&e["profile"])).unwrap(), + erlang_challenge: unhex(&s(&e["erlang_challenge"])), + station_node_id: unhex(&s(&e["erlang_station_node_id"])).try_into().unwrap(), + go_challenge: unhex(&s(&e["go_challenge"])), + erlang_connect: unhex(&s(&e["erlang_connect"])), + }) + .collect(); + assert_eq!(entries.len(), 2); + Erlang { + now: doc["now"].as_i64().unwrap(), + leaf: unhex(&s(&doc["leaf"])), + entries, + } +} + +#[test] +fn a_challenge_macula_made_is_answered() { + let h = erlang(); + for e in &h.entries { + let keys = client_keys(e.profile, h.now); + let (connect, station) = answer_challenge( + &e.erlang_challenge, + &session(&keys, e.profile, e.station_node_id, &h.leaf, h.now + 60_000), + ) + .unwrap(); + assert_eq!(station.node_id, e.station_node_id, "{:?}", e.profile); + assert!(!connect.is_empty()); + } +} + +#[test] +fn a_connect_macula_made_is_accepted() { + let h = erlang(); + for e in &h.entries { + let (accepted, hello) = accept_connect( + &e.erlang_connect, + &station_session(e.profile, &e.go_challenge, &h.leaf, h.now + 60_000), + ); + let client = accepted.unwrap(); + assert_eq!(read_hello(&hello).unwrap(), 5, "{:?}", e.profile); + assert!(client.member_endorsement.is_empty()); + } +} + +#[test] +fn a_client_and_a_station_complete_the_handshake_and_renew_status() { + let now = 1_789_000_000_000; + let leaf = b"the leaf this connection presents".to_vec(); + for profile in [Profile::PqPure, Profile::PqHybrid] { + let first = opener(); + read_opener(&first).unwrap(); + let (station_key, material) = station(profile, &leaf, now); + let challenge_frame = challenge(&material).unwrap(); + let keys = client_keys(profile, now); + let (connect, seen) = answer_challenge( + &challenge_frame, + &session(&keys, profile, station_key.node_id().unwrap(), &leaf, now), + ) + .unwrap(); + assert_eq!(seen.status_expires_at, now + HOUR_MS); + let (accepted, hello) = accept_connect( + &connect, + &station_session(profile, &challenge_frame, &leaf, now), + ); + let client = accepted.unwrap(); + assert_eq!(client.node_id, keys.identity.node_id().unwrap()); + assert_eq!(client.capabilities, 3); + assert_eq!(client.puzzle, PuzzleResult::Solved); + assert_eq!(read_hello(&hello).unwrap(), 5); + + // A renewed statement on the open connection. + let renewed = status_statement( + &station_key, + &material.tls_binding, + now + 900_000, + now + 900_000 + HOUR_MS, + ) + .unwrap(); + let peer = Peer { + profile, + identity_key: material.identity_key.clone(), + binding: material.tls_binding.clone(), + now_ms: now + 900_000, + }; + assert_eq!( + read_status(&status_frame(&renewed), &peer).unwrap(), + now + 900_000 + HOUR_MS + ); + } +} + +#[test] +fn a_station_that_is_not_the_node_dialed_is_refused_before_anything_is_signed() { + let now = 1_789_000_000_000; + let leaf = b"leaf".to_vec(); + let (station_key, material) = station(Profile::PqPure, &leaf, now); + let keys = client_keys(Profile::PqPure, now); + let dialed = [9u8; 32]; + match answer_challenge( + &challenge(&material).unwrap(), + &session(&keys, Profile::PqPure, dialed, &leaf, now), + ) { + Err(HandshakeError::PeerIdentityMismatch { expected, derived }) => { + assert_eq!(expected, dialed); + assert_eq!(derived, station_key.node_id().unwrap()); + } + other => panic!("{:?}", other.map(|(c, _)| c.len())), + } +} + +#[test] +fn a_challenge_for_another_leaf_or_profile_is_refused() { + let now = 1_789_000_000_000; + let (station_key, material) = station(Profile::PqPure, b"the real leaf", now); + let frame = challenge(&material).unwrap(); + let keys = client_keys(Profile::PqPure, now); + let expected = station_key.node_id().unwrap(); + assert_eq!( + answer_challenge( + &frame, + &session(&keys, Profile::PqPure, expected, b"another leaf", now) + ) + .unwrap_err(), + HandshakeError::Binding(BindingError::KeyMismatch) + ); + let hybrid = client_keys(Profile::PqHybrid, now); + assert_eq!( + answer_challenge( + &frame, + &session(&hybrid, Profile::PqHybrid, expected, b"the real leaf", now) + ) + .unwrap_err(), + HandshakeError::ProfileMismatch + ); +} + +#[test] +fn a_connect_key_that_is_the_identity_key_is_refused() { + let now = 1_789_000_000_000; + let leaf = b"leaf".to_vec(); + let (station_key, material) = station(Profile::PqPure, &leaf, now); + let identity = NodeKey::generate_identity(Profile::PqPure, 0).unwrap(); + // The identity key's own halves, as a CONNECT key: its key file with the + // purpose byte after the magic changed to connect. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("key"); + identity.save(&path).unwrap(); + let mut file = std::fs::read(&path).unwrap(); + file[b"macula-node-key-seed-v1\0".len()] = 2; + std::fs::write(&path, &file).unwrap(); + let same = NodeKey::load(&path, Purpose::Connect, Profile::PqPure).unwrap(); + let binding = connect_binding(&identity, &same.public_key(), now, now + DAY_MS).unwrap(); + let status = status_statement(&identity, &binding, now, now + HOUR_MS).unwrap(); + let keys = ClientKeys { + identity, + connect: same, + binding, + status, + }; + assert_eq!( + answer_challenge( + &challenge(&material).unwrap(), + &session( + &keys, + Profile::PqPure, + station_key.node_id().unwrap(), + &leaf, + now + ) + ) + .unwrap_err(), + HandshakeError::KeyPurposeReuse + ); +} + +#[test] +fn a_station_refuses_with_one_coarse_code() { + let now = 1_789_000_000_000; + let leaf = b"leaf".to_vec(); + let (station_key, material) = station(Profile::PqPure, &leaf, now); + let frame = challenge(&material).unwrap(); + let keys = client_keys(Profile::PqPure, now); + let (connect, _) = answer_challenge( + &frame, + &session( + &keys, + Profile::PqPure, + station_key.node_id().unwrap(), + &leaf, + now, + ), + ) + .unwrap(); + + // The proof covers the leaf: presented another one, the station refuses. + let (refused, hello) = accept_connect( + &connect, + &station_session(Profile::PqPure, &frame, b"other", now), + ); + assert_eq!(refused.unwrap_err(), HandshakeError::ProofInvalid); + assert_eq!( + read_hello(&hello).unwrap_err(), + HandshakeError::Refused(RefusalCode::NotAccepted) + ); + + // A node_id that misses an enforced puzzle. + let mut hard = station_session(Profile::PqPure, &frame, &leaf, now); + hard.puzzle_difficulty = 256; + let (refused, hello) = accept_connect(&connect, &hard); + assert_eq!(refused.unwrap_err(), HandshakeError::PuzzleInvalid); + assert_eq!( + read_hello(&hello).unwrap_err(), + HandshakeError::Refused(RefusalCode::PuzzleInvalid) + ); + + // Logged, not enforced: accepted, and reported unsolved. + hard.puzzle_mode = PuzzleMode::LogOnly; + let (accepted, _) = accept_connect(&connect, &hard); + assert_eq!(accepted.unwrap().puzzle, PuzzleResult::Unsolved); +} + +/// A frame rewritten: `f` edits its decoded map, and the result is +/// re-encoded. +fn rewritten(frame: &[u8], f: impl FnOnce(&mut Vec<(Value, Value)>)) -> Vec { + let Value::Map(mut pairs) = cbor::decode(frame).unwrap() else { + panic!("a frame is a map") + }; + f(&mut pairs); + cbor::encode(&Value::Map(pairs)).unwrap() +} + +#[test] +fn a_frame_is_read_strictly_in_macula_s_order() { + let first = opener(); + let v3 = rewritten(&first, |p| { + for (k, v) in p.iter_mut() { + if *k == Value::text("version") { + *v = Value::Int(3); + } + } + }); + assert_eq!( + read_opener(&v3).unwrap_err(), + HandshakeError::UnsupportedVersion + ); + let extra = rewritten(&first, |p| p.push((Value::text("more"), Value::Int(1)))); + assert_eq!(read_opener(&extra).unwrap_err(), HandshakeError::Malformed); + assert_eq!( + read_hello(&first).unwrap_err(), + HandshakeError::UnexpectedFrame + ); + assert_eq!(read_opener(b"\xff").unwrap_err(), HandshakeError::Malformed); +} diff --git a/tests/identity_binding.rs b/tests/identity_binding.rs new file mode 100644 index 0000000..d3d8ce6 --- /dev/null +++ b/tests/identity_binding.rs @@ -0,0 +1,329 @@ +//! TLS and CONNECT bindings and status statements, held to the ones macula +//! itself made (tests/vectors/identity/erlang_bindings.json, by +//! macula_key_bindings and macula_node_keys at macula v12.1.0): they verify +//! here as there, are refused where macula refuses them, and carry tbs bytes +//! this crate's encoder writes byte for byte. Then the ones this crate makes. + +use macula_rust::binding::{ + connect_binding, status_statement, tls_binding, verify_connect_binding, verify_status, + verify_tls_binding, BindingError, BindingUse, SignedTbs, +}; +use macula_rust::cbor; +use macula_rust::node_key::{node_id_of, NodeKey, Purpose}; +use macula_rust::profile::Profile; + +/// A change to a signed structure that must leave it unverifiable. +type Alteration = Box SignedTbs>; + +const DAY_MS: i64 = 24 * 60 * 60 * 1000; +const MINUTE_MS: i64 = 60 * 1000; +const MLDSA_SIGNATURE: usize = 4627; + +struct Entry { + profile: Profile, + now_ms: i64, + identity_key: Vec, + node_id: Vec, + leaf: Vec, + tls_binding: SignedTbs, + tls_status: SignedTbs, + connect_key: Vec, + connect_binding: SignedTbs, + connect_status: SignedTbs, +} + +fn entries() -> Vec { + let text = std::fs::read_to_string("tests/vectors/identity/erlang_bindings.json").unwrap(); + let doc: serde_json::Value = serde_json::from_str(&text).unwrap(); + let bytes = |v: &serde_json::Value| hex::decode(v.as_str().unwrap()).unwrap(); + let signed = |v: &serde_json::Value| SignedTbs { + tbs: bytes(&v["tbs"]), + signature: bytes(&v["signature"]), + }; + let entries: Vec = doc["entries"] + .as_array() + .unwrap() + .iter() + .map(|e| Entry { + profile: Profile::parse(e["profile"].as_str().unwrap()).unwrap(), + now_ms: e["now_ms"].as_i64().unwrap(), + identity_key: bytes(&e["identity_key"]), + node_id: bytes(&e["node_id"]), + leaf: bytes(&e["leaf"]), + tls_binding: signed(&e["tls_binding"]), + tls_status: signed(&e["tls_status"]), + connect_key: bytes(&e["connect_key"]), + connect_binding: signed(&e["connect_binding"]), + connect_status: signed(&e["connect_status"]), + }) + .collect(); + assert_eq!(entries.len(), 2, "one entry per profile"); + entries +} + +fn other(profile: Profile) -> Profile { + match profile { + Profile::PqPure => Profile::PqHybrid, + Profile::PqHybrid => Profile::PqPure, + } +} + +fn flipped(bytes: &[u8], at: usize) -> Vec { + let mut out = bytes.to_vec(); + out[at] ^= 1; + out +} + +#[test] +fn bindings_and_statements_macula_made_verify_here() { + for e in entries() { + let p = e.profile; + assert_eq!( + node_id_of(&e.identity_key, p).to_vec(), + e.node_id, + "{p:?}: node_id" + ); + for (name, tbs) in [ + ("TLS binding", &e.tls_binding.tbs), + ("TLS status", &e.tls_status.tbs), + ("CONNECT binding", &e.connect_binding.tbs), + ("CONNECT status", &e.connect_status.tbs), + ] { + let value = cbor::decode(tbs).unwrap(); + assert_eq!( + &cbor::encode(&value).unwrap(), + tbs, + "{p:?}: {name} re-encodes to macula's bytes" + ); + } + + let info = + verify_tls_binding(&e.tls_binding, &e.identity_key, p, &e.leaf, e.now_ms).unwrap(); + assert_eq!(info.use_, BindingUse::Tls); + assert_eq!(info.node_id.to_vec(), e.node_id); + assert_eq!(info.not_after, e.now_ms + 7 * DAY_MS); + verify_status(&e.tls_status, &e.tls_binding, &e.identity_key, p, e.now_ms).unwrap(); + verify_connect_binding( + &e.connect_binding, + &e.identity_key, + p, + &e.connect_key, + e.now_ms, + ) + .unwrap(); + verify_status( + &e.connect_status, + &e.connect_binding, + &e.identity_key, + p, + e.now_ms, + ) + .unwrap(); + + let mut other_leaf = e.leaf.clone(); + other_leaf.push(0); + assert_eq!( + verify_tls_binding(&e.tls_binding, &e.identity_key, p, &other_leaf, e.now_ms) + .unwrap_err(), + BindingError::KeyMismatch + ); + assert_eq!( + verify_tls_binding( + &e.connect_binding, + &e.identity_key, + p, + &e.connect_key, + e.now_ms + ) + .unwrap_err(), + BindingError::BindingSignatureInvalid + ); + assert_eq!( + verify_status( + &e.tls_status, + &e.connect_binding, + &e.identity_key, + p, + e.now_ms + ) + .unwrap_err(), + BindingError::StatusBindingMismatch + ); + assert_eq!( + verify_tls_binding(&e.tls_binding, &e.identity_key, other(p), &e.leaf, e.now_ms) + .unwrap_err(), + BindingError::BindingSignatureInvalid + ); + assert_eq!( + verify_tls_binding( + &e.tls_binding, + &e.identity_key, + p, + &e.leaf, + e.now_ms + 7 * DAY_MS + 6 * MINUTE_MS + ) + .unwrap_err(), + BindingError::Expired + ); + } +} + +#[test] +fn a_binding_or_statement_macula_made_altered_by_one_byte_is_refused() { + for e in entries() { + let p = e.profile; + let mut offsets = vec![10]; + if p == Profile::PqHybrid { + offsets.push(MLDSA_SIGNATURE + 10); + } + let mut alterations: Vec = vec![Box::new(|s: &SignedTbs| SignedTbs { + tbs: flipped(&s.tbs, s.tbs.len() / 2), + signature: s.signature.clone(), + })]; + for offset in offsets { + alterations.push(Box::new(move |s: &SignedTbs| SignedTbs { + tbs: s.tbs.clone(), + signature: flipped(&s.signature, offset), + })); + } + for alter in &alterations { + assert_eq!( + verify_tls_binding( + &alter(&e.tls_binding), + &e.identity_key, + p, + &e.leaf, + e.now_ms + ) + .unwrap_err(), + BindingError::BindingSignatureInvalid + ); + assert_eq!( + verify_connect_binding( + &alter(&e.connect_binding), + &e.identity_key, + p, + &e.connect_key, + e.now_ms + ) + .unwrap_err(), + BindingError::BindingSignatureInvalid + ); + assert_eq!( + verify_status( + &alter(&e.tls_status), + &e.tls_binding, + &e.identity_key, + p, + e.now_ms + ) + .unwrap_err(), + BindingError::StatusSignatureInvalid + ); + } + } +} + +#[test] +fn bindings_and_statements_made_here_verify_and_hold_their_windows() { + let now = 1_789_000_000_000; + let identity = NodeKey::generate(Purpose::Identity, Profile::PqPure).unwrap(); + let carried = identity.public_key(); + let connect = NodeKey::generate(Purpose::Connect, Profile::PqPure).unwrap(); + + let tls = tls_binding(&identity, b"a leaf", now, now + 7 * DAY_MS).unwrap(); + assert_eq!( + verify_tls_binding(&tls, &carried, Profile::PqPure, b"a leaf", now) + .unwrap() + .node_id, + identity.node_id().unwrap() + ); + let bound = connect_binding(&identity, &connect.public_key(), now, now + DAY_MS).unwrap(); + verify_connect_binding( + &bound, + &carried, + Profile::PqPure, + &connect.public_key(), + now, + ) + .unwrap(); + let status = status_statement(&identity, &bound, now, now + 60 * MINUTE_MS).unwrap(); + assert_eq!( + verify_status(&status, &bound, &carried, Profile::PqPure, now).unwrap(), + now + 60 * MINUTE_MS + ); + + assert_eq!( + verify_tls_binding( + &tls, + &carried, + Profile::PqPure, + b"a leaf", + now - 6 * MINUTE_MS + ) + .unwrap_err(), + BindingError::NotYetValid + ); + assert_eq!( + verify_status( + &status, + &bound, + &carried, + Profile::PqPure, + now + 66 * MINUTE_MS + ) + .unwrap_err(), + BindingError::StatusExpired + ); + assert_eq!( + verify_status( + &status, + &bound, + &carried, + Profile::PqPure, + now - 6 * MINUTE_MS + ) + .unwrap_err(), + BindingError::StatusFutureDated + ); + + // A window a verifier would refuse is not issued: backwards, longer than + // 7 days for a binding or an hour for a statement, or negative. + assert_eq!( + tls_binding(&identity, b"a leaf", now, now - 1).unwrap_err(), + BindingError::ValidityWindow + ); + assert_eq!( + tls_binding(&identity, b"a leaf", now, now + 7 * DAY_MS + 1).unwrap_err(), + BindingError::ValidityWindow + ); + assert_eq!( + status_statement(&identity, &bound, now, now + 60 * MINUTE_MS + 1).unwrap_err(), + BindingError::ValidityWindow + ); + assert_eq!( + tls_binding(&identity, b"a leaf", -1, now).unwrap_err(), + BindingError::ValidityWindow + ); + + // A CONNECT key has no node_id to bind for. + assert!(tls_binding(&connect, b"a leaf", now, now + DAY_MS).is_err()); +} + +#[test] +fn a_signed_tbs_travels_as_exactly_tbs_and_signature() { + let s = SignedTbs { + tbs: vec![1], + signature: vec![2], + }; + assert_eq!(SignedTbs::from_value(&s.to_value()).unwrap(), s); + let extra = cbor::Value::Map(vec![ + (cbor::Value::text("tbs"), cbor::Value::Bytes(vec![1])), + (cbor::Value::text("signature"), cbor::Value::Bytes(vec![2])), + (cbor::Value::text("more"), cbor::Value::Null), + ]); + assert_eq!( + SignedTbs::from_value(&extra).unwrap_err(), + BindingError::Malformed + ); +} diff --git a/tests/identity_cross_verify.rs b/tests/identity_cross_verify.rs new file mode 100644 index 0000000..a736d0c --- /dev/null +++ b/tests/identity_cross_verify.rs @@ -0,0 +1,33 @@ +//! pq_hybrid composites that crossed both ways with macula 12.x +//! (tests/vectors/identity/macula_12_cross, written by +//! scripts/cross-verify-macula.sh): one macula signed with a key of its own, +//! which verifies here, and one this crate signed, which macula verified. + +use macula_rust::node_key::verify; +use macula_rust::profile::Profile; + +const MLDSA_SIGNATURE: usize = 4627; + +fn crossed(signer: &str) -> (Vec, Vec, Vec) { + let read = |name: &str| { + std::fs::read(format!( + "tests/vectors/identity/macula_12_cross/{signer}/{name}" + )) + .unwrap() + }; + (read("m.bin"), read("pk.bin"), read("s.bin")) +} + +#[test] +fn a_composite_macula_signed_verifies() { + let (m, pk, mut s) = crossed("macula_signed"); + assert!(verify(&m, &s, &pk, Profile::PqHybrid)); + s[MLDSA_SIGNATURE + 10] ^= 1; + assert!(!verify(&m, &s, &pk, Profile::PqHybrid)); +} + +#[test] +fn the_composite_macula_verified_verifies_here() { + let (m, pk, s) = crossed("rust_signed"); + assert!(verify(&m, &s, &pk, Profile::PqHybrid)); +} diff --git a/tests/identity_key_file.rs b/tests/identity_key_file.rs new file mode 100644 index 0000000..4b0ac58 --- /dev/null +++ b/tests/identity_key_file.rs @@ -0,0 +1,274 @@ +//! Key files in macula's seed form, readable by their owner only: saved and +//! loaded back as the same key, the layout byte for byte, and every refusal a +//! loader owes, among them the LAMPS draft's own private key loading as a +//! pq_hybrid node key. + +#![cfg(unix)] + +use std::os::unix::fs::PermissionsExt; + +use macula_rust::node_key::{verify, KeyFileError, NodeKey, Purpose}; +use macula_rust::profile::Profile; + +const MAGIC: &[u8] = b"macula-node-key-seed-v1\0"; + +fn vector(name: &str) -> Vec { + std::fs::read(format!( + "tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/{name}" + )) + .unwrap() +} + +/// A key file written by hand, as its layout says: the magic, the purpose, +/// profile and half count, then each half's tag and its public and private +/// keys, four-byte big-endian length-prefixed. +fn key_file(purpose: u8, profile: u8, halves: &[(u8, &[u8], &[u8])]) -> Vec { + let mut out = MAGIC.to_vec(); + out.extend([purpose, profile, halves.len() as u8]); + for (tag, public, private) in halves { + out.push(*tag); + out.extend((public.len() as u32).to_be_bytes()); + out.extend(*public); + out.extend((private.len() as u32).to_be_bytes()); + out.extend(*private); + } + out +} + +fn write_owner_only(path: &std::path::Path, bytes: &[u8]) { + std::fs::write(path, bytes).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).unwrap(); +} + +#[test] +fn a_saved_key_loads_back_as_the_same_key_readable_by_its_owner_only() { + let dir = tempfile::tempdir().unwrap(); + for profile in [Profile::PqPure, Profile::PqHybrid] { + let key = NodeKey::generate(Purpose::Identity, profile).unwrap(); + let path = dir.path().join(format!("{}.key", profile.name())); + key.save(&path).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "{profile:?}"); + let loaded = NodeKey::load(&path, Purpose::Identity, profile).unwrap(); + assert_eq!(loaded.public_key(), key.public_key()); + assert_eq!(loaded.node_id().unwrap(), key.node_id().unwrap()); + let signature = loaded.sign(b"loaded").unwrap(); + assert!(verify(b"loaded", &signature, &key.public_key(), profile)); + assert_eq!( + std::fs::read_dir(dir.path()).unwrap().count(), + if profile == Profile::PqPure { 1 } else { 2 } + ); + } +} + +#[test] +fn the_draft_s_private_key_is_a_pq_hybrid_node_key() { + let dir = tempfile::tempdir().unwrap(); + let (sk, pk) = (vector("sk.bin"), vector("pk.bin")); + let path = dir.path().join("draft.key"); + write_owner_only( + &path, + &key_file( + 1, + 2, + &[(1, &pk[..2592], &sk[..32]), (2, &pk[2592..], &sk[32..])], + ), + ); + let key = NodeKey::load(&path, Purpose::Identity, Profile::PqHybrid).unwrap(); + assert_eq!(key.public_key(), pk); + let m = vector("m.bin"); + let signature = key.sign(&m).unwrap(); + assert_eq!(signature.len(), vector("s.bin").len()); + assert!(verify(&m, &signature, &pk, Profile::PqHybrid)); + + // Saved again, the key file is the same bytes: the seed form round-trips. + let again = dir.path().join("again.key"); + key.save(&again).unwrap(); + assert_eq!( + std::fs::read(&again).unwrap(), + std::fs::read(&path).unwrap() + ); +} + +#[test] +fn a_key_file_its_group_or_others_can_read_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("node.key"); + NodeKey::generate(Purpose::Identity, Profile::PqPure) + .unwrap() + .save(&path) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); + assert!(matches!( + NodeKey::load(&path, Purpose::Identity, Profile::PqPure), + Err(KeyFileError::Permissions) + )); +} + +#[test] +fn a_key_of_another_purpose_or_profile_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("node.key"); + NodeKey::generate(Purpose::Identity, Profile::PqPure) + .unwrap() + .save(&path) + .unwrap(); + assert!(matches!( + NodeKey::load(&path, Purpose::Connect, Profile::PqPure), + Err(KeyFileError::WrongPurpose(Purpose::Identity)) + )); + assert!(matches!( + NodeKey::load(&path, Purpose::Identity, Profile::PqHybrid), + Err(KeyFileError::WrongProfile(Profile::PqPure)) + )); +} + +#[test] +fn a_key_file_that_is_not_one_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let (sk, pk) = (vector("sk.bin"), vector("pk.bin")); + let cases: Vec<(&str, Vec)> = vec![ + ("no magic", b"not a key file".to_vec()), + ( + "halves missing", + key_file(1, 2, &[(1, &pk[..2592], &sk[..32])]), + ), + ("a trailing byte", { + let mut b = key_file(1, 1, &[(1, &pk[..2592], &sk[..32])]); + b.push(0); + b + }), + ( + "an unknown tag", + key_file(1, 1, &[(9, &pk[..2592], &sk[..32])]), + ), + ( + "an unknown profile", + key_file(1, 7, &[(1, &pk[..2592], &sk[..32])]), + ), + ]; + for (name, bytes) in cases { + let path = dir.path().join("bad.key"); + write_owner_only(&path, &bytes); + let result = NodeKey::load(&path, Purpose::Identity, Profile::PqHybrid); + assert!( + matches!( + result, + Err(KeyFileError::BadKeyFile) | Err(KeyFileError::WrongAlgorithms) + ), + "{name}: {result:?}" + ); + } +} + +#[test] +fn a_stored_public_key_that_is_not_the_private_key_s_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let (sk, pk) = (vector("sk.bin"), vector("pk.bin")); + let path = dir.path().join("mismatch.key"); + let wrong = { + let mut p = pk[..2592].to_vec(); + p[100] ^= 1; + p + }; + write_owner_only(&path, &key_file(1, 1, &[(1, &wrong, &sk[..32])])); + assert!(matches!( + NodeKey::load(&path, Purpose::Identity, Profile::PqPure), + Err(KeyFileError::PublicKeyMismatch) + )); +} + +#[test] +fn a_directory_or_an_oversized_file_is_refused() { + let dir = tempfile::tempdir().unwrap(); + assert!(matches!( + NodeKey::load(dir.path(), Purpose::Identity, Profile::PqPure), + Err(KeyFileError::NotRegular) + )); + let big = dir.path().join("big.key"); + write_owner_only(&big, &vec![0u8; 64 * 1024 + 1]); + assert!(matches!( + NodeKey::load(&big, Purpose::Identity, Profile::PqPure), + Err(KeyFileError::TooLarge) + )); +} + +/// A key store of the caller's own, which the trait lets any backend be. +struct InMemory(std::sync::Mutex>>); + +impl macula_rust::keystore::KeyStore for InMemory { + fn save_key(&self, key: &[u8]) -> Result<(), macula_rust::keystore::KeyStoreError> { + *self.0.lock().unwrap() = Some(key.to_vec()); + Ok(()) + } + fn load_key( + &self, + ) -> Result>, macula_rust::keystore::KeyStoreError> { + self.0 + .lock() + .unwrap() + .clone() + .map(macula_mldsa::Zeroizing::new) + .ok_or(macula_rust::keystore::KeyStoreError::NotFound) + } + fn delete_key(&self) -> Result<(), macula_rust::keystore::KeyStoreError> { + *self.0.lock().unwrap() = None; + Ok(()) + } +} + +#[test] +fn a_key_kept_in_a_key_store_loads_back_as_the_same_key_and_is_checked_as_a_file_is() { + let store = InMemory(std::sync::Mutex::new(None)); + assert!(matches!( + NodeKey::load_from_keystore(&store, Purpose::Identity, Profile::PqHybrid), + Err(KeyFileError::KeyStore( + macula_rust::keystore::KeyStoreError::NotFound + )) + )); + let key = NodeKey::generate(Purpose::Identity, Profile::PqHybrid).unwrap(); + key.save_to_keystore(&store).unwrap(); + let loaded = NodeKey::load_from_keystore(&store, Purpose::Identity, Profile::PqHybrid).unwrap(); + assert_eq!(loaded.public_key(), key.public_key()); + assert!(matches!( + NodeKey::load_from_keystore(&store, Purpose::Identity, Profile::PqPure), + Err(KeyFileError::WrongProfile(Profile::PqHybrid)) + )); +} + +#[test] +fn load_or_create_makes_a_puzzle_solved_key_once_then_loads_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("keys/node.key"); + let made = NodeKey::load_or_create(&path, Profile::PqPure).unwrap(); + assert_eq!(made.purpose(), Purpose::Identity); + assert!(macula_rust::node_key::puzzle_solved( + &made.node_id().unwrap(), + macula_rust::node_key::PUZZLE_DIFFICULTY + )); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o077, 0, "owner-only"); + let again = NodeKey::load_or_create(&path, Profile::PqPure).unwrap(); + assert_eq!(again.node_id().unwrap(), made.node_id().unwrap()); +} + +#[test] +fn load_or_create_never_replaces_a_file_that_does_not_load() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("node.key"); + write_owner_only(&path, b"not a key file"); + assert!(matches!( + NodeKey::load_or_create(&path, Profile::PqPure), + Err(KeyFileError::BadKeyFile) + )); + assert_eq!(std::fs::read(&path).unwrap(), b"not a key file"); + // A key of the other profile is refused, not replaced, too. + let hybrid = dir.path().join("hybrid.key"); + NodeKey::load_or_create(&hybrid, Profile::PqHybrid).unwrap(); + let before = std::fs::read(&hybrid).unwrap(); + assert!(matches!( + NodeKey::load_or_create(&hybrid, Profile::PqPure), + Err(KeyFileError::WrongProfile(Profile::PqHybrid)) + )); + assert_eq!(std::fs::read(&hybrid).unwrap(), before); +} diff --git a/tests/identity_node_key.rs b/tests/identity_node_key.rs new file mode 100644 index 0000000..d3cfa11 --- /dev/null +++ b/tests/identity_node_key.rs @@ -0,0 +1,286 @@ +//! A macula 12 node key, as macula-go and macula hold one: ML-DSA-87 in +//! pq_pure, the LAMPS composite id-MLDSA87-RSA4096-PSS-SHA512 in pq_hybrid, +//! node_ids and key ids, the admission puzzle, and signatures held to the LAMPS +//! draft's own vector and to one macula's OTP stack made. + +use macula_rust::node_key::{ + carried_key_well_formed, key_id_of, node_id_of, puzzle_solved, signature_size, verify, + KeyError, NodeKey, Purpose, PUZZLE_DIFFICULTY, +}; +use macula_rust::profile::Profile; +use sha2::{Digest, Sha256}; + +const MLDSA_PUBLIC: usize = 2592; +const MLDSA_SIGNATURE: usize = 4627; + +fn vector(name: &str) -> Vec { + std::fs::read(format!( + "tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/{name}" + )) + .unwrap() +} + +fn flipped(bytes: &[u8], at: usize) -> Vec { + let mut out = bytes.to_vec(); + out[at] ^= 1; + out +} + +#[test] +fn profiles_are_parsed_by_their_exact_names() { + assert_eq!(Profile::parse("pq_pure"), Ok(Profile::PqPure)); + assert_eq!(Profile::parse("pq_hybrid"), Ok(Profile::PqHybrid)); + assert!(Profile::parse("").is_err()); + assert!(Profile::parse("PQ_HYBRID").is_err()); + assert!(Profile::parse("classical").is_err()); + assert_eq!(Profile::PqPure.sig_alg(), "ML-DSA-87"); + assert_eq!(Profile::PqHybrid.sig_alg(), "ML-DSA-87-PS384"); + assert_eq!(signature_size(Profile::PqPure), MLDSA_SIGNATURE); + assert_eq!(signature_size(Profile::PqHybrid), MLDSA_SIGNATURE + 512); +} + +#[test] +fn a_pq_pure_key_is_ml_dsa_87_alone() { + let key = NodeKey::generate(Purpose::Identity, Profile::PqPure).unwrap(); + assert_eq!(key.public_key().len(), MLDSA_PUBLIC); + assert!(carried_key_well_formed(&key.public_key(), Profile::PqPure)); + let signature = key.sign(b"a fact").unwrap(); + assert_eq!(signature.len(), MLDSA_SIGNATURE); + assert!(verify( + b"a fact", + &signature, + &key.public_key(), + Profile::PqPure + )); + assert!(!verify( + b"a fact!", + &signature, + &key.public_key(), + Profile::PqPure + )); + assert!(!verify( + b"a fact", + &flipped(&signature, 10), + &key.public_key(), + Profile::PqPure + )); + assert!(!verify( + b"a fact", + &signature, + &key.public_key(), + Profile::PqHybrid + )); +} + +#[test] +fn a_pq_hybrid_key_signs_the_composite_and_both_halves_must_verify() { + let key = NodeKey::generate(Purpose::Identity, Profile::PqHybrid).unwrap(); + let public = key.public_key(); + assert!(public.len() > MLDSA_PUBLIC); + assert!(carried_key_well_formed(&public, Profile::PqHybrid)); + assert!(!carried_key_well_formed(&public, Profile::PqPure)); + let signature = key.sign(b"a fact").unwrap(); + assert_eq!(signature.len(), MLDSA_SIGNATURE + 512); + assert!(verify(b"a fact", &signature, &public, Profile::PqHybrid)); + assert!(!verify( + b"a fact", + &flipped(&signature, 10), + &public, + Profile::PqHybrid + )); + assert!(!verify( + b"a fact", + &flipped(&signature, MLDSA_SIGNATURE + 10), + &public, + Profile::PqHybrid + )); + assert!(!verify(b"a fact", &signature, &public, Profile::PqPure)); + assert!(!verify( + b"a fact", + &signature[..MLDSA_SIGNATURE], + &public, + Profile::PqHybrid + )); +} + +#[test] +fn the_lamps_draft_vector_verifies_and_every_alteration_is_refused() { + let (m, pk, s) = (vector("m.bin"), vector("pk.bin"), vector("s.bin")); + assert!(verify(&m, &s, &pk, Profile::PqHybrid)); + let mut longer = m.clone(); + longer.push(0); + assert!(!verify(&longer, &s, &pk, Profile::PqHybrid)); + assert!(!verify(&m, &flipped(&s, 10), &pk, Profile::PqHybrid)); + assert!(!verify( + &m, + &flipped(&s, MLDSA_SIGNATURE + 10), + &pk, + Profile::PqHybrid + )); + assert!(!verify(&m, &s, &pk, Profile::PqPure)); +} + +#[test] +fn a_composite_whose_rsa_half_lost_its_zero_byte_is_refused_by_its_length() { + let zero_dropped = vector("zero_dropped_sig.bin"); + assert_eq!(zero_dropped.len(), MLDSA_SIGNATURE + 511); + assert!(!verify( + &vector("m.bin"), + &zero_dropped, + &vector("pk.bin"), + Profile::PqHybrid + )); +} + +#[test] +fn a_composite_macula_s_otp_stack_made_verifies() { + let (m, pk, s) = ( + vector("otp_message.bin"), + vector("otp_pk.bin"), + vector("otp_sig.bin"), + ); + assert!(verify(&m, &s, &pk, Profile::PqHybrid)); + assert!(!verify( + &m, + &flipped(&s, MLDSA_SIGNATURE + 10), + &pk, + Profile::PqHybrid + )); +} + +#[test] +fn a_carried_hybrid_key_must_be_its_one_canonical_form() { + let pk = vector("pk.bin"); + assert!(carried_key_well_formed(&pk, Profile::PqHybrid)); + assert!(!carried_key_well_formed( + &pk[..MLDSA_PUBLIC], + Profile::PqHybrid + )); + assert!(!carried_key_well_formed( + &pk[..pk.len() - 1], + Profile::PqHybrid + )); + let mut trailing = pk.clone(); + trailing.push(0); + assert!(!carried_key_well_formed(&trailing, Profile::PqHybrid)); + assert!(!carried_key_well_formed(&pk, Profile::PqPure)); + assert!(carried_key_well_formed( + &pk[..MLDSA_PUBLIC], + Profile::PqPure + )); +} + +/// node_id and key id: SHA-256 over their label, a zero byte, the profile's +/// name with its length, and the key as carried (D5). +#[test] +fn node_ids_and_key_ids_are_labelled_hashes_of_the_carried_key() { + let pk = vector("pk.bin"); + for (profile, name) in [ + (Profile::PqPure, "pq_pure"), + (Profile::PqHybrid, "pq_hybrid"), + ] { + for (label, id) in [ + ("MACULA-NODE-ID-V1", node_id_of(&pk, profile)), + ("MACULA-KEY-ID-V1", key_id_of(&pk, profile)), + ] { + let mut h = Sha256::new(); + h.update(label.as_bytes()); + h.update([0, name.len() as u8]); + h.update(name.as_bytes()); + h.update(&pk); + assert_eq!(id.to_vec(), h.finalize().to_vec(), "{label} {name}"); + } + } +} + +#[test] +fn the_admission_puzzle_counts_leading_zero_bits() { + let mut id = [0xffu8; 32]; + assert!(puzzle_solved(&id, 0)); + assert!(!puzzle_solved(&id, 1)); + id[0] = 0; + assert!(puzzle_solved(&id, 8)); + assert!(!puzzle_solved(&id, 9)); + id[1] = 0x1f; + assert!(puzzle_solved(&id, 11)); + assert!(!puzzle_solved(&id, 12)); + assert!(puzzle_solved(&[0u8; 32], 256)); + assert!(!puzzle_solved(&[0u8; 32], 257)); + assert_eq!(PUZZLE_DIFFICULTY, 8); +} + +#[test] +fn an_identity_key_is_generated_for_the_puzzle_and_only_it_has_a_node_id() { + let identity = NodeKey::generate_identity(Profile::PqPure, PUZZLE_DIFFICULTY).unwrap(); + let node_id = identity.node_id().unwrap(); + assert!(puzzle_solved(&node_id, PUZZLE_DIFFICULTY)); + assert_eq!(node_id, node_id_of(&identity.public_key(), Profile::PqPure)); + assert_eq!(identity.key_id(), node_id); + + let connect = NodeKey::generate(Purpose::Connect, Profile::PqPure).unwrap(); + assert!(matches!(connect.node_id(), Err(KeyError::NotAnIdentityKey))); + assert_eq!( + connect.key_id(), + key_id_of(&connect.public_key(), Profile::PqPure) + ); + assert!(NodeKey::generate_identity(Profile::PqPure, 257).is_err()); +} + +#[test] +fn a_key_shows_its_purpose_profile_and_key_id_and_never_a_private_half() { + let key = NodeKey::generate(Purpose::Connect, Profile::PqPure).unwrap(); + let shown = format!("{key:?}"); + assert_eq!( + shown, + format!("connect pq_pure key {}", hex::encode(key.key_id())) + ); + assert_eq!(format!("{key}"), shown); +} + +/// The draft's bytes as macula v12.7.0 carries them, pinned by sha256 so a +/// drifted copy fails here rather than passing on bytes nobody else signed: +/// the same sums macula-go, macula-php and macula-ts pin. +#[test] +fn the_lamps_vector_is_the_bytes_macula_pins() { + let pinned = [ + ( + "m.bin", + "ef537f25c895bfa782526529a9b63d97aa631564d5d789c2b765448c8635fb6c", + ), + ( + "pk.bin", + "88560e139b35d0738857f9c8e29bbcfb108e3539bd2bf6f4994bb4b34beb019d", + ), + ( + "sk.bin", + "0d4c65edb8735b5b677ea88050662406c7affd8e29ae27184726822a5ca889ce", + ), + ( + "s.bin", + "95e17c93e9c1d6b5c3c4bae9d8687cd1606e232dca0af38e437e7e2e16894303", + ), + ( + "s_with_context.bin", + "7261d9aeaaee3eb2612bb868d00d8eb6e174717bc427e8e6fa24cb7a73dcdeec", + ), + ( + "zero_dropped_sig.bin", + "4e43a85def2b0acec014724d7d4b23ac86685ef35f28a9be85d9aff4a7cd30cd", + ), + ]; + for (name, sum) in pinned { + assert_eq!(hex::encode(Sha256::digest(vector(name))), sum, "{name}"); + } +} + +/// Every Macula object signs with the empty context, so the draft's +/// signature made with one is refused. +#[test] +fn the_draft_s_signature_made_with_a_context_is_refused() { + assert!(!verify( + &vector("m.bin"), + &vector("s_with_context.bin"), + &vector("pk.bin"), + Profile::PqHybrid + )); +} diff --git a/tests/identity_signed_object.rs b/tests/identity_signed_object.rs new file mode 100644 index 0000000..e3cf0ea --- /dev/null +++ b/tests/identity_signed_object.rs @@ -0,0 +1,105 @@ +//! Signed objects, as macula_signed_object signs and verifies them: fields +//! gain alg, the signature covers the label, the key's SHA-384 and the tbs as +//! received, and a verifier reads the object in macula's order. + +use macula_rust::cbor::Value; +use macula_rust::node_key::{NodeKey, Purpose}; +use macula_rust::profile::Profile; +use macula_rust::signed_object::{ + sign_held_object, sign_object, verify_held_object, verify_object, HeldObject, Object, + ObjectError, +}; + +fn fields() -> Vec<(Value, Value)> { + vec![ + (Value::text("procedure"), Value::text("acme/echo")), + (Value::text("seq"), Value::Int(7)), + ] +} + +#[test] +fn a_signed_object_verifies_under_its_label_and_names_its_algorithm() { + for profile in [Profile::PqPure, Profile::PqHybrid] { + let key = NodeKey::generate(Purpose::Identity, profile).unwrap(); + let object = sign_object("MACULA-TEST-V1", &fields(), &key).unwrap(); + assert_eq!(object.key, key.public_key()); + let verified = verify_object("MACULA-TEST-V1", &object.to_value(), profile).unwrap(); + assert_eq!(verified.key, key.public_key()); + assert_eq!(verified.tbs, object.tbs); + assert_eq!( + verified.fields.get("alg"), + Some(&Value::text(profile.sig_alg())) + ); + assert_eq!(verified.fields.get("seq"), Some(&Value::Int(7))); + + assert_eq!( + verify_object("MACULA-OTHER-V1", &object.to_value(), profile).unwrap_err(), + ObjectError::SignatureInvalid + ); + let mut altered = object.clone(); + altered.tbs[3] ^= 1; + assert_eq!( + verify_object("MACULA-TEST-V1", &altered.to_value(), profile).unwrap_err(), + ObjectError::SignatureInvalid + ); + } +} + +#[test] +fn an_alg_the_signer_supplied_is_replaced_by_its_profile_s() { + let key = NodeKey::generate(Purpose::Identity, Profile::PqPure).unwrap(); + let mut with_alg = fields(); + with_alg.push((Value::text("alg"), Value::text("RSA"))); + let object = sign_object("MACULA-TEST-V1", &with_alg, &key).unwrap(); + let verified = verify_object("MACULA-TEST-V1", &object.to_value(), Profile::PqPure).unwrap(); + assert_eq!(verified.fields.get("alg"), Some(&Value::text("ML-DSA-87"))); +} + +#[test] +fn a_held_object_leaves_its_key_out_and_still_signs_its_hash() { + let key = NodeKey::generate(Purpose::Identity, Profile::PqPure).unwrap(); + let held = sign_held_object("MACULA-TEST-V1", &fields(), &key).unwrap(); + let value = held.to_value(); + assert_eq!(value.get("key"), None); + verify_held_object("MACULA-TEST-V1", &value, &key.public_key(), Profile::PqPure).unwrap(); + let other = NodeKey::generate(Purpose::Identity, Profile::PqPure).unwrap(); + assert_eq!( + verify_held_object( + "MACULA-TEST-V1", + &value, + &other.public_key(), + Profile::PqPure + ) + .unwrap_err(), + ObjectError::SignatureInvalid + ); +} + +#[test] +fn an_object_of_the_wrong_shape_or_another_profile_is_refused() { + let key = NodeKey::generate(Purpose::Identity, Profile::PqPure).unwrap(); + let object = sign_object("MACULA-TEST-V1", &fields(), &key).unwrap(); + // The verifier's profile is pq_hybrid: a pq_pure key is not in its + // carried form. + assert_eq!( + verify_object("MACULA-TEST-V1", &object.to_value(), Profile::PqHybrid).unwrap_err(), + ObjectError::Malformed + ); + let missing = Value::Map(vec![(Value::text("tbs"), Value::Bytes(object.tbs.clone()))]); + assert_eq!( + Object::from_value(&missing).unwrap_err(), + ObjectError::Malformed + ); + assert_eq!( + HeldObject::from_value(&object.to_value()).unwrap_err(), + ObjectError::Malformed + ); + + let duplicate = vec![ + (Value::text("a"), Value::Int(1)), + (Value::text("a"), Value::Int(2)), + ]; + assert!(sign_object("MACULA-TEST-V1", &duplicate, &key).is_err()); + let int_key = vec![(Value::Int(1), Value::Int(1))]; + assert!(sign_object("MACULA-TEST-V1", &int_key, &key).is_err()); +} diff --git a/tests/live.rs b/tests/live.rs new file mode 100644 index 0000000..be07b59 --- /dev/null +++ b/tests/live.rs @@ -0,0 +1,131 @@ +//! A live run against one macula 12 station: the pool connects pinned with a +//! key generated for the run and never saved, reads the DHT, calls +//! mcl-echo/echo by direct dial, and hears its own publication. The tests +//! are ignored by default and run with `cargo test --test live -- --ignored`; +//! they need every one of MACULA_RUST_LIVE_SEED (host:port, [v6]:port for +//! IPv6), MACULA_RUST_LIVE_STATION_ID, MACULA_RUST_LIVE_REALM and +//! MACULA_RUST_LIVE_REALM_KEY (hex), and an unset one fails the run, naming +//! it. They put nothing in the DHT, and publish once. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use macula_rust::cbor::Value; +use macula_rust::node_key::{NodeKey, PUZZLE_DIFFICULTY}; +use macula_rust::pool::{Call, Opts, Pool, Seed}; +use macula_rust::profile::Profile; +use macula_rust::record::RecordType; +use macula_rust::station_link::Publication; + +const VARIABLES: [&str; 4] = [ + "MACULA_RUST_LIVE_SEED", + "MACULA_RUST_LIVE_STATION_ID", + "MACULA_RUST_LIVE_REALM", + "MACULA_RUST_LIVE_REALM_KEY", +]; + +fn var(name: &str) -> String { + std::env::var(name).unwrap_or_default() +} + +fn hex32(name: &str) -> [u8; 32] { + hex::decode(var(name)) + .ok() + .and_then(|b| b.try_into().ok()) + .unwrap_or_else(|| panic!("{name} must be 64 hex characters")) +} + +/// A pool on the live seed as a pq_hybrid key made for this run, and the +/// realm it trusts. +async fn live() -> (Pool, [u8; 32]) { + let missing: Vec<&str> = VARIABLES + .iter() + .copied() + .filter(|v| var(v).is_empty()) + .collect(); + assert!(missing.is_empty(), "live tests need {}", missing.join(", ")); + let seed = var("MACULA_RUST_LIVE_SEED"); + let (host, port) = seed + .rsplit_once(':') + .unwrap_or_else(|| panic!("MACULA_RUST_LIVE_SEED must be host:port, got {seed}")); + let host = host + .trim_start_matches('[') + .trim_end_matches(']') + .to_string(); + let realm = hex32("MACULA_RUST_LIVE_REALM"); + let key = Arc::new(NodeKey::generate_identity(Profile::PqHybrid, PUZZLE_DIFFICULTY).unwrap()); + let mut opts = Opts::new(key); + opts.realm_trust = HashMap::from([( + realm, + hex::decode(var("MACULA_RUST_LIVE_REALM_KEY")).unwrap(), + )]); + opts.connect_timeout = Duration::from_secs(60); + let pool = Pool::connect( + vec![Seed { + host, + port: port.parse().expect("the seed's port"), + node_id: hex32("MACULA_RUST_LIVE_STATION_ID"), + }], + opts, + ) + .await + .unwrap(); + (pool, realm) +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "live: needs MACULA_RUST_LIVE_* and a macula 12 station"] +async fn the_station_holds_verified_node_records() { + let (pool, _) = live().await; + let (records, _) = pool + .find_records_by_type(RecordType::NODE_RECORD) + .await + .unwrap(); + assert!(!records.is_empty()); + pool.close().await; +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "live: needs MACULA_RUST_LIVE_* and a macula 12 station"] +async fn mcl_echo_is_reached_by_direct_dial() { + let (pool, realm) = live().await; + let answered = pool + .call(Call { + realm, + procedure: "mcl-echo/echo".into(), + payload: Value::text("hello"), + timeout: Duration::from_secs(15), + ..Call::default() + }) + .await + .unwrap(); + assert_eq!(answered, Value::text("hello")); + pool.close().await; +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "live: needs MACULA_RUST_LIVE_* and a macula 12 station"] +async fn the_pool_hears_its_own_publication() { + let (pool, realm) = live().await; + let mut suffix = [0u8; 8]; + aws_lc_rs::rand::fill(&mut suffix).unwrap(); + let topic = format!( + "mcl-rust/live/check/publication_heard_v1/{}", + hex::encode(suffix) + ); + let mut sub = pool.subscribe(&realm, &topic).await.unwrap(); + tokio::time::sleep(Duration::from_millis(300)).await; + pool.publish(Publication { + realm, + topic, + payload: Value::text("heard"), + ttl_ms: None, + }) + .await + .unwrap(); + let event = tokio::time::timeout(Duration::from_secs(10), sub.recv()).await; + let _ = sub.unsubscribe().await; + pool.close().await; + assert_eq!(event.unwrap().unwrap().payload, Value::text("heard")); +} diff --git a/tests/live_cert_chain.rs b/tests/live_cert_chain.rs deleted file mode 100644 index 3fa39bc..0000000 --- a/tests/live_cert_chain.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! Live proof that a `cert_chain`-bearing `procedure_advertisement` survives -//! a REAL DHT publish/resolve round trip and still verifies correctly -//! afterward — the offline unit tests in `src/cert_chain.rs` never touch -//! the network, so they can't catch a wire-encoding bug (e.g. the -//! `cert_chain` bytes getting mangled in transit) the way this can. -//! -//! No fleet provisioning needed: the realm CA/leaf chain is entirely -//! self-issued by this test, since cert-chain authorization is a -//! client-side check on an opaque DHT payload the station itself never -//! inspects (mirrors `macula-go`'s `TestLiveResolveWithCertChain`, -//! which makes the same observation). -//! -//! Not run by default CI — `#[ignore]`d, matching this crate's other live -//! tests (`tests/live_station.rs`). Run explicitly with -//! `cargo test --test live_cert_chain -- --ignored`. - -use std::time::Duration; - -use macula_rust::cert_chain::{verify_advertisement_cert_chain, CertChainError}; -use macula_rust::connection; -use macula_rust::direct_dial; -use macula_rust::identity::KeyPair; -use macula_rust::transport::Trust; -use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair as RcgenKeyPair}; - -const STATION_HOST: &str = "station-de-frankfurt.macula.io"; -const STATION_PORT: u16 = 4433; - -fn self_issued_realm_ca() -> (Vec, rcgen::Issuer<'static, RcgenKeyPair>) { - let key_pair = RcgenKeyPair::generate_for(&rcgen::PKCS_ED25519).expect("ca keygen"); - let mut params = CertificateParams::new(Vec::::new()).expect("ca params"); - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "Live Test Realm CA"); - dn.push(DnType::OrganizationName, "Live Test Realm CA"); - params.distinguished_name = dn; - params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); - params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1); - params.not_after = time::OffsetDateTime::now_utc() + time::Duration::hours(1); - let cert = params.self_signed(&key_pair).expect("ca self-sign"); - let pem = cert.pem().into_bytes(); - (pem, rcgen::Issuer::new(params, key_pair)) -} - -/// RFC 8410 SubjectPublicKeyInfo DER for a raw 32-byte Ed25519 pubkey — -/// duplicated from `src/cert_chain.rs`'s own `#[cfg(test)]` helper since an -/// integration test in `tests/` can't reach items private to the lib's -/// test module. -fn ed25519_spki_der(pubkey: [u8; 32]) -> Vec { - let mut der = vec![ - 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, - ]; - der.extend_from_slice(&pubkey); - der -} - -fn issue_leaf( - ca_issuer: &rcgen::Issuer<'static, RcgenKeyPair>, - advertiser_pub: [u8; 32], - org: &str, -) -> Vec { - let subject_spki = - rcgen::SubjectPublicKeyInfo::from_der(&ed25519_spki_der(advertiser_pub)).expect("spki"); - let mut params = CertificateParams::new(Vec::::new()).expect("leaf params"); - let mut dn = DistinguishedName::new(); - dn.push(DnType::CommonName, "live-cert-chain-test-service"); - dn.push(DnType::OrganizationName, org); - params.distinguished_name = dn; - params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1); - params.not_after = time::OffsetDateTime::now_utc() + time::Duration::hours(1); - let cert = params - .signed_by(&subject_spki, ca_issuer) - .expect("leaf signed_by"); - cert.der().to_vec() -} - -fn pem_bundle(ders: &[Vec]) -> Vec { - use base64::Engine; - let mut out = Vec::new(); - for der in ders { - let b64 = base64::engine::general_purpose::STANDARD.encode(der); - out.extend_from_slice(b"-----BEGIN CERTIFICATE-----\n"); - for chunk in b64.as_bytes().chunks(64) { - out.extend_from_slice(chunk); - out.push(b'\n'); - } - out.extend_from_slice(b"-----END CERTIFICATE-----\n"); - } - out -} - -/// Publishes a `cert_chain`-bearing advertisement for real, resolves it -/// back over a SEPARATE session/identity, and confirms the resolved -/// record's embedded chain still verifies -- proving the wire round trip -/// (CBOR-encode the PEM bytes into a DHT record, publish via `_dht.put_record`, -/// read it back via `_dht.find_records`) doesn't corrupt the chain. Also -/// checks the negative control: the SAME resolved record correctly fails -/// authorization for the WRONG org. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn cert_chain_survives_a_real_dht_round_trip() { - let (ca_pem, ca_issuer) = self_issued_realm_ca(); - - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - let leaf_der = issue_leaf(&ca_issuer, provider_identity.node_id(), "acme-corp"); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake should succeed"); - let resolver_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("resolver handshake should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.live_cert_chain_test.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - direct_dial::advertise_direct_with_cert_chain( - &provider_session, - &provider_identity, - realm, - &procedure, - Duration::from_secs(120), - pem_bundle(&[leaf_der]), - ) - .await - .expect("advertise_direct_with_cert_chain should publish the DHT record"); - - let resolved = direct_dial::resolve_with_cert_chain( - &resolver_session, - &caller_identity, - realm, - &procedure, - &ca_pem, - "acme-corp", - ) - .await - .expect("resolve_with_cert_chain should find and authorize what was just published"); - assert_eq!( - resolved.station, provider_session.station.node_id, - "resolved station should be the provider's own" - ); - - // Negative control on the SAME real, network-round-tripped record. - let err = direct_dial::resolve_with_cert_chain( - &resolver_session, - &caller_identity, - realm, - &procedure, - &ca_pem, - "wrong-org", - ) - .await - .expect_err("a real cert chain issued for acme-corp must not authorize wrong-org"); - match err { - direct_dial::ResolveError::NoAuthorizedAdvertisement(CertChainError::OrgMismatch) => {} - other => panic!("expected NoAuthorizedAdvertisement(OrgMismatch), got {other:?}"), - } - - // Also confirm the record's chain still verifies directly, byte for - // byte, via the resolved path -- redundant with resolve_with_cert_chain - // succeeding above, but pins down that verify_advertisement_cert_chain - // itself (not just the resolve wrapper) is what's being exercised. - let recs = macula_rust::dht::find_records( - &resolver_session, - &caller_identity, - macula_rust::dht::procedure_key(&macula_rust::dht::discovery_uri(realm, &procedure)), - ) - .await - .expect("find_records should return the published record"); - assert!( - recs.iter() - .any(|r| verify_advertisement_cert_chain(&ca_pem, r, "acme-corp").is_ok()), - "at least one resolved record must verify byte-for-byte after the real DHT round trip" - ); - - provider_session - .close("normal", None, &provider_identity) - .await; - resolver_session - .close("normal", None, &caller_identity) - .await; -} diff --git a/tests/live_direct_dial_extensions.rs b/tests/live_direct_dial_extensions.rs deleted file mode 100644 index 81c6408..0000000 --- a/tests/live_direct_dial_extensions.rs +++ /dev/null @@ -1,264 +0,0 @@ -//! Live proof that direct-dial's resolve-and-dial core, already verified -//! for plain RPC (`tests/live_station.rs`) and cert-chain authorization -//! (`tests/live_cert_chain.rs`), reuses cleanly for streaming and content -//! transfer too — mirrors `macula-go`'s own `OpenStreamDirect`/ -//! `PutDirect`/`GetDirect` live tests. -//! -//! Separate identities per role throughout: this fleet enforces one -//! connection per identity and kicks whichever connects second (confirmed -//! multiple times this session), so a provider/caller/resolver sharing one -//! identity self-inflicts a kick rather than testing anything real. -//! -//! Not run by default CI — `#[ignore]`d, matching this crate's other live -//! tests. Run explicitly with -//! `cargo test --test live_direct_dial_extensions -- --ignored --nocapture`. - -use std::time::Duration; - -use macula_rust::cbor::Value; -use macula_rust::connection; -use macula_rust::direct_dial; -use macula_rust::frame::StreamMode; -use macula_rust::identity::KeyPair; -use macula_rust::transport::Trust; - -const STATION_HOST: &str = "station-fi-helsinki.macula.io"; -const STATION_PORT: u16 = 4433; - -fn now_ms() -> i128 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock before 1970") - .as_millis() as i128 -} - -/// Advertise+serve a stream via direct-dial in one task, resolve+dial+open -/// it from a separate session/identity, push real data, confirm it -/// arrives byte-exact. Proves `open_stream_direct` genuinely reaches a -/// live provider through the DHT, not just that resolve+dial completes. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn open_stream_direct_round_trip_against_the_real_fleet() { - let provider_id = KeyPair::generate_with_default_puzzle(); - let resolver_id = KeyPair::generate_with_default_puzzle(); - let caller_id = KeyPair::generate_with_default_puzzle(); - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "live_direct_dial_extensions.stream.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let provider_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &provider_id) - .await - .expect("provider handshake should succeed"); - - direct_dial::advertise_direct( - &provider_session, - &provider_id, - realm, - &procedure, - Duration::from_secs(3600), - ) - .await - .expect("advertise_direct should publish both the plain ADVERTISE and the DHT record"); - - // Only accept() happens inside the spawned task, exactly matching - // streaming_provider_round_trip_against_the_real_fleet's - // (tests/live_station.rs) already-proven structure -- send_data/ - // close_send happen afterward in the main task, and provider_session - // is kept alive (never let drop implicitly) until an explicit - // graceful close at the very end. An earlier draft did send_data/ - // close_send INSIDE the spawned task and let provider_session drop - // at the task's end -- real bug, reproduced live: the caller saw - // `Recv(StreamClosed)` instead of the pushed data, because the - // implicit drop tore the connection down before the already-sent - // frame had necessarily been fully processed peer-side. - let accept_task = tokio::spawn(async move { - let result = - macula_rust::stream::StreamHandle::accept(&provider_session, Duration::from_secs(15)) - .await; - (result, provider_session) - }); - - let resolver_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &resolver_id) - .await - .expect("resolver handshake should succeed"); - - let opened = match direct_dial::open_stream_direct( - &resolver_session, - &caller_id, - realm, - &procedure, - StreamMode::ServerStream, - Value::Null, - now_ms() + 15_000, - Duration::from_secs(15), - ) - .await - { - Ok(v) => v, - Err(direct_dial::OpenStreamDirectError::Resolve( - direct_dial::ResolveError::StationEndpointNotFound, - )) => { - eprintln!( - "SKIP: resolved station published no reachable station_endpoint -- known external fleet staleness, not a defect here" - ); - return; - } - Err(e) => panic!("open_stream_direct should resolve, dial, and open: {e}"), - }; - let mut handle = opened.stream; - let lease = opened.lease; - - let (accept_result, provider_session) = - accept_task.await.expect("accept task should not panic"); - let (mut provider_handle, open_info) = accept_result - .expect("provider should accept the inbound STREAM_OPEN routed via the plain ADVERTISE"); - assert_eq!(open_info.procedure, procedure); - - provider_handle - .send_data( - macula_rust::frame::StreamEncoding::Raw, - Value::Bytes(b"hello via direct-dial stream".to_vec()), - &provider_id, - ) - .await - .expect("provider should push the chunk"); - provider_handle - .close_send(&provider_id) - .await - .expect("provider should half-close"); - - match handle - .recv(Duration::from_secs(10)) - .await - .expect("caller should receive the pushed chunk") - { - macula_rust::stream::StreamItem::Data { - body: Value::Bytes(got), - .. - } => { - assert_eq!(got, b"hello via direct-dial stream"); - println!( - "OBSERVED: real data received through a direct-dial-opened stream: {} bytes", - got.len() - ); - } - other => panic!("expected a real data chunk through direct-dial, got: {other:?}"), - } - match handle - .recv(Duration::from_secs(5)) - .await - .expect("caller should see end-of-stream") - { - macula_rust::stream::StreamItem::Eof => {} - other => panic!("expected Eof, got {other:?}"), - } - - provider_session - .close("normal", Some("provider test done"), &provider_id) - .await; - lease.release(&caller_id).await; -} - -/// Put content at a known station via direct-dial, then fetch it back -/// through an independent `content_announcement` published for it, -/// confirming a byte-exact round trip entirely through direct-dial-resolved -/// connections. `get_direct` needs a real announcement to resolve, so this -/// test builds one itself with `dht::new_content_announcement` (the -/// low-level primitive this crate deliberately does NOT expose as a -/// client-facing "announce content direct" — see `get_direct`'s own doc -/// for why only an infrastructure identity can legitimately publish one) -/// naming the SAME station `put_direct` just stored the content on, which -/// is honest here: the test plays the infrastructure role for its own -/// fixture, an ordinary leaf would not do this for itself. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn put_and_get_direct_round_trip_against_the_real_fleet() { - let resolver_id = KeyPair::generate_with_default_puzzle(); - let putter_id = KeyPair::generate_with_default_puzzle(); - let announcer_id = KeyPair::generate_with_default_puzzle(); - let getter_id = KeyPair::generate_with_default_puzzle(); - - let resolver_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &resolver_id) - .await - .expect("resolver handshake should succeed"); - let station = resolver_session.station.node_id; - - let data = b"real bytes stored and fetched purely via direct-dial".to_vec(); - let mcid = match direct_dial::put_direct( - &resolver_session, - &putter_id, - station, - &data, - "live-direct-dial-extensions-test", - Duration::from_secs(15), - ) - .await - { - Ok(mcid) => mcid, - Err(direct_dial::PutDirectError::Resolve( - direct_dial::ResolveError::StationEndpointNotFound, - )) => { - eprintln!("SKIP: station published no reachable station_endpoint -- known external fleet staleness"); - return; - } - Err(e) => panic!("put_direct should resolve, dial, and store: {e}"), - }; - println!( - "OBSERVED: put_direct stored {} bytes, mcid={}", - data.len(), - hex::encode(mcid) - ); - - // Publish the content_announcement ourselves, playing the - // infrastructure role this crate's own leaf API deliberately can't -- - // see get_direct's doc. - let announcer_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &announcer_id) - .await - .expect("announcer handshake should succeed"); - let endpoint = format!("https://{STATION_HOST}:{STATION_PORT}"); - let rec = macula_rust::dht::new_content_announcement( - announcer_id.node_id(), - mcid, - endpoint, - Duration::from_secs(3600), - ); - let rec = macula_rust::dht::sign(rec, &announcer_id); - macula_rust::dht::put_record(&announcer_session, &announcer_id, &rec) - .await - .expect("publishing the content_announcement should succeed"); - - // The announced endpoint (this SAME station, in this test's fixture) - // must actually answer as the identity the announcement claims for - // get_direct's trust check to pass -- announce the announcer's own - // session as reachable there isn't meaningful (content is served by - // the STATION, not by announcer_session), so this test can only prove - // get_direct correctly REFUSES an announcement whose claimed announcer - // doesn't match who answers the dial, which is itself a real - // correctness property worth confirming. - let getter_session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &getter_id) - .await - .expect("getter handshake should succeed"); - match direct_dial::get_direct(&getter_session, &getter_id, mcid, Duration::from_secs(15)).await { - Err(direct_dial::GetDirectError::Dial(direct_dial::DialAndVerifyError::TrustViolation { resolved, dialed })) => { - println!( - "OBSERVED: get_direct correctly refused a content_announcement whose claimed announcer ({}) doesn't match the station that actually answers the dial ({}) -- confirms the trust check fires, matching how put_direct's own data landed on the real station instead", - hex::encode(resolved), hex::encode(dialed) - ); - } - Err(e) => panic!("expected a trust-violation refusal (this fixture announces an identity that can't answer the dial), got: {e}"), - Ok(got) => { - // If this ever succeeds for real (e.g. the fixture's announcer - // identity happens to equal the station), it must still be - // byte-exact. - assert_eq!(got, data); - println!("OBSERVED: get_direct fetched a byte-exact round trip through direct-dial"); - } - } -} diff --git a/tests/live_direct_dial_ucan.rs b/tests/live_direct_dial_ucan.rs deleted file mode 100644 index 1aca550..0000000 --- a/tests/live_direct_dial_ucan.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! Proves `direct_dial::call_with_ucan` actually reaches a UCAN-gated -//! procedure that plain `direct_dial::call` cannot -- the gap this -//! function closes (PLAN_CLOSE_SERVICE_AUTH_GAPS.md Phase 0, -//! macula-io/macula-architecture): every hecate-om capability is -//! advertised via `advertise_direct`, and until this function existed, -//! nothing in this crate could attach a token to a direct-dial call at -//! all -- a `ucan_required` capability was reachable in name only. Three -//! assertions against the live fleet: an unauthorized plain `call` is -//! refused, a `call_with_ucan` presenting a token from the WRONG issuer is -//! refused too (not just "any non-empty token passes"), and a -//! correctly-issued token gets a real result. -//! -//! Not run by default CI -- `#[ignore]`d, matching this crate's other live -//! tests. Run explicitly with -//! `cargo test --test live_direct_dial_ucan -- --ignored --nocapture`. -//! -//! MUST use `flavor = "multi_thread"` -- found live building this test: a -//! provider `Session` moved into a spawned task blocking inside -//! `serve_one_call_gated` starves a CONCURRENT resolver session's own DHT -//! resolution on tokio's default single-threaded (current_thread) test -//! runtime, failing with `StationEndpointNotFound` even though the record -//! is real and freshly published (confirmed by isolating it: the identical -//! resolve succeeds instantly with no concurrent task, and with a -//! concurrent task that does nothing network-related; it only breaks once -//! a spawned task owns and blocks a `Session`). Not fleet flakiness -- -//! reproduced identically against two different stations, while an -//! unrelated pre-existing test passed cleanly against both at the same -//! moment. This crate's other live tests never spawn a task holding a -//! `Session` alongside other concurrent network I/O, so this is the first -//! to hit it. - -use std::time::Duration; - -use macula_rust::cbor::Value; -use macula_rust::connection::{self, CallHandler}; -use macula_rust::direct_dial; -use macula_rust::frame::CallResponse; -use macula_rust::identity::KeyPair; -use macula_rust::transport::Trust; -use macula_rust::ucan; - -const STATION_HOST: &str = "station-de-frankfurt.macula.io"; -const STATION_PORT: u16 = 4433; - -#[tokio::test(flavor = "multi_thread")] -#[ignore = "requires network access to a live macula-station"] -async fn ucan_gated_capability_reachable_only_through_call_with_ucan() { - // Arc'd: KeyPair isn't Clone, and the provider identity is needed - // inside 3 separate spawned serve tasks below. - let provider_id = std::sync::Arc::new(KeyPair::generate_with_default_puzzle()); - let caller_id = KeyPair::generate_with_default_puzzle(); - let issuer_id = KeyPair::generate_with_default_puzzle(); - let wrong_issuer_id = KeyPair::generate_with_default_puzzle(); - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "live_direct_dial_ucan.gated.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let valid_token = ucan::create( - &hex::encode(issuer_id.node_id()), - &hex::encode(caller_id.node_id()), - vec![ucan::Capability { - with: "mri:test:live".into(), - can: "call".into(), - }], - &issuer_id, - ucan::CreateOpts::default(), - ) - .expect("mint valid token"); - let wrong_issuer_token = ucan::create( - &hex::encode(wrong_issuer_id.node_id()), - &hex::encode(caller_id.node_id()), - vec![ucan::Capability { - with: "mri:test:live".into(), - can: "call".into(), - }], - &wrong_issuer_id, - ucan::CreateOpts::default(), - ) - .expect("mint wrong-issuer token"); - - let provider = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &provider_id) - .await - .expect("provider handshake should succeed"); - direct_dial::advertise_direct( - &provider, - &provider_id, - realm, - &procedure, - Duration::from_secs(3600), - ) - .await - .expect("advertise_direct should succeed"); - - let required_policy = ucan::Policy::required(issuer_id.node_id()); - let echo: CallHandler = std::sync::Arc::new(|payload: Value| { - Box::pin(async move { Ok(Value::Map(vec![]).with_field("echo", payload)) }) - }); - let lookup = { - let procedure = procedure.clone(); - let echo = echo.clone(); - move |_realm: &[u8; 32], proc: &str| { - if proc == procedure { - Some(echo.clone()) - } else { - None - } - } - }; - let policy = { - let procedure = procedure.clone(); - move |_realm: &[u8; 32], proc: &str| { - if proc == procedure { - required_policy.clone() - } else { - ucan::Policy::open() - } - } - }; - - // serve_one_call_gated blocks waiting for an inbound call, so it must - // run CONCURRENTLY with the caller's own connect+call below, not - // before it -- spawned as a task, handing `provider` back out (and - // in again for the next round) via the JoinHandle, matching Go's - // goroutine+channel `serve()` helper in the equivalent live test. - // - // The 300ms sleep after every round matches examples/ucan.rs's own - // documented reason: `Session` has no `Drop` impl, so returning - // (and dropping `provider`, here via the task's own scope on every - // round including the reassignments below) immediately after a - // reply is sent can tear down the QUIC connection before that reply - // frame actually flushes to the peer. Found live while building this - // test: an ungated `serve_one_call`/plain `call` round-trip 3x with - // no delay was fine, but a GATED round's own RESULT reply (not its - // rejection replies, which apparently take a different, already- - // flushed path) was silently lost without this -- narrowed to - // exactly this race by direct experiment, not assumed. - let provider_id2 = provider_id.clone(); - let lookup1 = lookup.clone(); - let policy1 = policy.clone(); - let serve1 = tokio::spawn(async move { - let r = provider - .serve_one_call_gated(lookup1, policy1, &provider_id2, Duration::from_secs(15)) - .await; - tokio::time::sleep(Duration::from_millis(300)).await; - r.map(|_| provider) - }); - - // 1. Unauthorized: plain `call` cannot even attach a token. - let resolver1 = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_id) - .await - .expect("caller handshake #1 should succeed"); - let resp = direct_dial::call( - &resolver1, - &caller_id, - realm, - &procedure, - Value::text("no token"), - Duration::from_secs(12), - ) - .await - .expect("plain call should get a BOLT#4 response, not a transport error"); - match resp { - CallResponse::Error { .. } => {} - CallResponse::Result { .. } => { - panic!("plain call against a gated procedure unexpectedly SUCCEEDED") - } - } - println!("OBSERVED: plain call against a gated procedure was refused, as expected"); - let provider = serve1 - .await - .expect("serve task #1 should not panic") - .expect("serve_one_call_gated (unauthorized tick) should not error"); - - // 2. Wrong issuer. - let provider_id2 = provider_id.clone(); - let lookup2 = lookup.clone(); - let policy2 = policy.clone(); - let serve2 = tokio::spawn(async move { - let r = provider - .serve_one_call_gated(lookup2, policy2, &provider_id2, Duration::from_secs(15)) - .await; - tokio::time::sleep(Duration::from_millis(300)).await; - r.map(|_| provider) - }); - let resolver2 = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_id) - .await - .expect("caller handshake #2 should succeed"); - let resp = direct_dial::call_with_ucan( - &resolver2, - &caller_id, - realm, - &procedure, - Value::text("wrong issuer"), - Duration::from_secs(12), - wrong_issuer_token, - ) - .await - .expect("call_with_ucan (wrong issuer) should get a BOLT#4 response, not a transport error"); - match resp { - CallResponse::Error { .. } => {} - CallResponse::Result { .. } => { - panic!("call_with_ucan with a wrong-issuer token unexpectedly SUCCEEDED") - } - } - println!( - "OBSERVED: call_with_ucan with a token from the wrong issuer was refused, as expected" - ); - let provider = serve2 - .await - .expect("serve task #2 should not panic") - .expect("serve_one_call_gated (wrong-issuer tick) should not error"); - - // 3. Authorized: the actual fix under test. - let provider_id2 = provider_id.clone(); - let serve3 = tokio::spawn(async move { - let r = provider - .serve_one_call_gated(lookup, policy, &provider_id2, Duration::from_secs(15)) - .await; - tokio::time::sleep(Duration::from_millis(300)).await; - r - }); - let resolver3 = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_id) - .await - .expect("caller handshake #3 should succeed"); - let call_fut = direct_dial::call_with_ucan( - &resolver3, - &caller_id, - realm, - &procedure, - Value::text("hello gated direct-dial"), - Duration::from_secs(12), - valid_token, - ); - let (call_result, serve_result) = tokio::join!(call_fut, serve3); - let resp = call_result - .expect("call_with_ucan (authorized) should succeed -- this is the fix under test"); - match resp { - CallResponse::Result { payload, .. } => { - let echoed = payload - .get("echo") - .expect("reply payload missing echo field"); - assert_eq!(*echoed, Value::text("hello gated direct-dial")); - } - CallResponse::Error { code, name, .. } => { - panic!("call_with_ucan (authorized) returned a BOLT#4 ERROR instead of a result: code={code} name={name}") - } - } - println!( - "OBSERVED: a UCAN-gated capability, advertised only via advertise_direct, was reached and answered through call_with_ucan end to end" - ); - serve_result - .expect("serve task #3 should not panic") - .expect("serve_one_call_gated (authorized tick) should not error"); -} diff --git a/tests/live_pool.rs b/tests/live_pool.rs deleted file mode 100644 index 9c71ba9..0000000 --- a/tests/live_pool.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! Integration tests for `pool::Pool` against real, live macula-station -//! boxes. -//! -//! **Not run by default CI** — every test here is `#[ignore]`d, matching -//! `tests/live_station.rs`'s own convention. Run explicitly with: -//! -//! ```text -//! cargo test --test live_pool -- --ignored --nocapture -//! ``` - -use std::time::Duration; - -use macula_rust::cbor::Value; -use macula_rust::frame::CallResponse; -use macula_rust::identity::KeyPair; -use macula_rust::pool::{ - LinkSelection, Pool, PoolOptions, PoolStatus, Seed, StationDiscoveryOptions, -}; -use macula_rust::transport::Trust; - -const STATION_HOST: &str = "station-de-frankfurt.macula.io"; -const STATION_PORT: u16 = 4433; - -/// Polls `pool.status()` until `until` returns true or `timeout` elapses. -/// Returns the last observed [`PoolStatus`] either way, so a caller can -/// build a rich panic message from it on failure. -async fn wait_for_status( - pool: &Pool, - until: impl Fn(&PoolStatus) -> bool, - timeout: Duration, -) -> PoolStatus { - let deadline = std::time::Instant::now() + timeout; - loop { - let status = pool.status().await; - if until(&status) || std::time::Instant::now() >= deadline { - return status; - } - tokio::time::sleep(Duration::from_millis(200)).await; - } -} - -fn now_ms() -> i128 { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock before 1970") - .as_millis() as i128 -} - -/// The primitive: a pool with ONE bootstrap seed, no discovery, connects -/// and can `call` a real procedure against the real fleet. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn single_seed_pool_connects_and_calls_against_the_real_fleet() { - let identity = KeyPair::generate_with_default_puzzle(); - let pool = Pool::connect( - vec![Seed::new(STATION_HOST, STATION_PORT)], - Trust::WebPki, - identity, - PoolOptions::default(), - ); - - let status = wait_for_status(&pool, PoolStatus::is_healthy, Duration::from_secs(15)).await; - assert!( - status.is_healthy(), - "pool never completed its bootstrap handshake: {status:?}" - ); - - let deadline = now_ms() + 5_000; - let result = pool - .call( - "_dht.find_records_by_type", - [0u8; 32], - Value::Map(vec![(Value::text("type"), Value::Int(0x06))]), - deadline, - ) - .await; - assert!( - result.is_ok(), - "expected a real RESULT/ERROR, got {result:?}" - ); - - pool.close("normal", Some("test done")).await; -} - -/// Station discovery: a pool bootstrapped against ONE seed, with discovery -/// enabled, should find and connect to additional real fleet stations via -/// `hecate_stations.list_stations` — mirroring the identical live test in -/// macula-go's (`pool_discovery_live_test.go`) and macula-dotnet's -/// (`StationDiscoveryLiveTests.cs`) own ports of this feature. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn station_discovery_finds_and_connects_to_real_fleet_stations() { - let identity = KeyPair::generate_with_default_puzzle(); - let pool = Pool::connect( - vec![Seed::new(STATION_HOST, STATION_PORT)], - Trust::WebPki, - identity, - PoolOptions { - link_selection: LinkSelection::Auto, - station_discovery: StationDiscoveryOptions { - enabled: true, - refresh_interval: Duration::from_secs(3600), // one attempt is enough - max_links: 5, - }, - ..PoolOptions::default() - }, - ); - - let bootstrap_status = - wait_for_status(&pool, PoolStatus::is_healthy, Duration::from_secs(15)).await; - assert!( - bootstrap_status.is_healthy(), - "pool never completed its initial bootstrap handshake: {bootstrap_status:?}" - ); - - // Give the background discovery task time to run its first attempt - // (DHT lookup + list_stations call, both real network round trips) - // and for at least one discovered link to complete its own handshake. - let discovered_status = - wait_for_status(&pool, |s| s.healthy_links >= 2, Duration::from_secs(30)).await; - let links = pool.links().await; - assert!( - discovered_status.healthy_links >= 2, - "station discovery found no additional healthy stations against the real fleet \ - (status={discovered_status:?}, links={links:?}) -- either hecate_stations.list_stations \ - isn't currently advertised/visible from {STATION_HOST}, or discovery has a real bug" - ); - - pool.close("normal", Some("test done")).await; -} - -/// [`LinkSelection::Random`] actually rotates which link `call` tries -/// first, against two real, independently-dialed stations — not just the -/// pure-logic unit coverage in `pool.rs`'s own `#[cfg(test)]` module. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn random_link_selection_uses_more_than_one_link_against_the_real_fleet() { - let identity = KeyPair::generate_with_default_puzzle(); - let pool = Pool::connect( - vec![ - Seed::new(STATION_HOST, STATION_PORT), - Seed::new("station-it-milan.macula.io", 4433), - ], - Trust::WebPki, - identity, - PoolOptions { - link_selection: LinkSelection::Random, - ..PoolOptions::default() - }, - ); - - let status = wait_for_status(&pool, |s| s.healthy_links >= 2, Duration::from_secs(15)).await; - assert!( - status.healthy_links >= 2, - "expected both bootstrap seeds to come up healthy: {status:?}" - ); - - let mut responders = std::collections::HashSet::new(); - for _ in 0..20 { - let deadline = now_ms() + 5_000; - if let Ok(CallResponse::Result { responded_by, .. }) = pool - .call( - "_dht.find_records_by_type", - [0u8; 32], - Value::Map(vec![(Value::text("type"), Value::Int(0x06))]), - deadline, - ) - .await - { - responders.insert(responded_by); - } - } - assert!( - responders.len() >= 2, - "expected calls to be answered by at least 2 different stations under Random \ - selection across 20 calls, saw {}: {responders:?}", - responders.len() - ); - - pool.close("normal", Some("test done")).await; -} diff --git a/tests/live_station.rs b/tests/live_station.rs deleted file mode 100644 index 513b5d5..0000000 --- a/tests/live_station.rs +++ /dev/null @@ -1,2199 +0,0 @@ -//! Integration tests against real, live macula-station boxes. -//! -//! **Not run by default CI** — every test here is `#[ignore]`d, since it -//! depends on external infrastructure this crate doesn't own or control -//! (network reachability, the fleet's own uptime). Run explicitly with: -//! -//! ```text -//! cargo test --test live_station -- --ignored --nocapture -//! ``` -//! -//! **DNS gotcha, confirmed directly against the live box (2026-08-28):** -//! the bare `macula.io` hostname has an A (IPv4) record but genuinely no -//! AAAA record at all, while `macula-station-frankfurt`'s actual QUIC -//! listener (confirmed via `ss -ulnp` on the box itself) is bound to a -//! *specific* IPv6 address that has no relationship to the A record. -//! Dialing `macula.io` therefore resolves to a real, reachable IPv4 -//! address with nothing listening on port 4433 — every packet vanishes -//! silently (correct, spec-compliant QUIC behavior for unrecognized -//! traffic, indistinguishable from a firewalled port from the client -//! side alone). `station-de-frankfurt.macula.io` is the name that -//! actually resolves to the listener's real IPv6 address — this matches -//! the DNS-repoint gotcha already on file in project memory -//! (`reference_demo_fleet_boxes`), confirmed still true today. - -use macula_rust::cbor::Value; -use macula_rust::cert::ed25519_pubkey_from_cert; -use macula_rust::connection; -use macula_rust::identity::KeyPair; -use macula_rust::transport::{connect, Trust}; - -const STATION_HOST: &str = "station-de-frankfurt.macula.io"; -const STATION_PORT: u16 = 4433; - -/// `stations-linode-toronto`, provisioned 2026-08-29 specifically to have a -/// fleet member with no DNS entry and no CA-issued cert -- see -/// `macula-demo/infrastructure/stations-linode-toronto/`. Dialed by its bare -/// `host_advertised` IPv6 literal, never a hostname. -const TORONTO_HOST: &str = "2600:3c04::2000:f0ff:feb9:e155"; -const TORONTO_PORT: u16 = 4433; -const TORONTO_NODE_ID_HEX: &str = - "5748e81d89a6ea4b619fecda394ffac9f8f58a05d7a7234034783b6e1fd043d5"; - -const MILAN_HOST: &str = "station-it-milan.macula.io"; -const MILAN_PORT: u16 = 4433; - -/// Probe: dial with verification skipped, and report exactly what the -/// station presents (cert count, and its Ed25519 pubkey if the leaf is -/// Ed25519) — informational, not asserting a specific pubkey, since -/// that's fleet configuration this crate doesn't control and shouldn't -/// hardcode as a test expectation. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn probe_what_frankfurt_presents() { - let connection = connect(STATION_HOST, STATION_PORT, Trust::Insecure) - .await - .expect("QUIC/TLS handshake with ALPN=macula should succeed against a live station"); - - println!( - "connected: alpn={:?} remote={}", - connection - .handshake_data() - .and_then(|d| d.downcast::().ok()) - .and_then(|d| d.protocol) - .map(|p| String::from_utf8_lossy(&p).into_owned()), - connection.remote_address(), - ); - - let identity = connection - .peer_identity() - .expect("server cert chain should be present after a completed handshake"); - let certs = identity - .downcast::>>() - .expect("peer_identity for a rustls-backed QUIC connection is a cert chain"); - println!("station presented {} certificate(s)", certs.len()); - - let leaf = certs.first().expect("at least one cert in the chain"); - match ed25519_pubkey_from_cert(leaf.as_ref()) { - Ok(pubkey) => println!("leaf is Ed25519, pubkey = {}", hex::encode(pubkey)), - Err(e) => println!("leaf is NOT a bare Ed25519 SPKI cert: {e}"), - } - - connection.close(0u32.into(), b"probe complete"); -} - -/// **Empirical finding, 2026-08-28:** `macula-station-frankfurt` presents -/// a 3-certificate RSA chain (SPKI OID `1.2.840.113549.1.1.1`), not a -/// self-signed Ed25519 identity cert — confirmed directly via -/// `probe_what_frankfurt_presents` above. That matches macula's own -/// documented "public-IP path with Let's Encrypt-anchored certs" trust -/// mode exactly (`plans/PLAN_WIRE_PROTOCOL.md` §2's `verify => webpki`), -/// which is what this test exercises. Pubkey-pinned trust -/// (`Trust::Pinned`) is for macula's *other* documented deployment shape -/// — a station without public DNS/CA-issued TLS, identified by its raw -/// Ed25519 key instead — which no box in the current demo fleet happens -/// to be configured as. `PubkeyPinVerifier`'s own matching logic is still -/// fully covered, just as a local unit test against a synthetic cert -/// (`src/cert.rs`'s own tests), not a live one — see that module. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn webpki_trust_succeeds_against_the_real_fleet() { - let connection = connect(STATION_HOST, STATION_PORT, Trust::WebPki) - .await - .expect("CA-chain validation should succeed against a real Let's Encrypt cert"); - connection.close(0u32.into(), b"done"); -} - -/// The real milestone: not just a QUIC/TLS connection, but a complete -/// macula application-layer handshake — signed CONNECT out, verified -/// HELLO back, `accepted = true` — against a real production station. -/// Uses a **puzzle-hardened** identity deliberately: see -/// `plans/PLAN_WIRE_PROTOCOL.md` §5's callout on why an unhardened one -/// fails this silently (QUIC/TLS looks fine, the station just never -/// accepts). -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn full_handshake_succeeds_against_the_real_fleet() { - let identity = KeyPair::generate_with_default_puzzle(); - - let session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &identity) - .await - .expect("CONNECT/HELLO handshake should succeed against a live station"); - - println!( - "handshake accepted: remote={} station_node_id={} negotiated_capabilities={}", - session.remote_address(), - hex::encode(session.station.node_id), - session.station.negotiated_capabilities, - ); - assert!(session.station.accepted); - - session - .close("normal", Some("integration test done"), &identity) - .await; -} - -/// **Empirical finding, 2026-08-28 — contradicts the documented -/// expectation, recorded honestly rather than papered over.** The plan -/// (`plans/PLAN_WIRE_PROTOCOL.md` §5) and the production incident it's -/// based on both describe every station enforcing puzzle admission on -/// every CONNECT. Tested directly against `macula-station-frankfurt`: an -/// **unhardened identity was accepted** (`accepted = true`, same shape -/// as the hardened case). This crate's `puzzle_evidence` computation is -/// independently verified byte-for-byte against real Erlang -/// `crypto:hash/2` output (`src/identity.rs`'s own tests), so this isn't -/// a computation bug here — it means either (a) this specific dev-fleet -/// station has puzzle enforcement disabled or configured leniently (it's -/// documented elsewhere as throwaway dev infra, not production), (b) the -/// deployed image predates that enforcement, or (c) enforcement is -/// scoped to some condition this plain CONNECT doesn't trigger. Which -/// one is true is a `macula-station`-side question, out of scope for -/// this crate to chase — recorded here as a fact about what actually -/// happens against this fleet today, not a guarantee about macula's -/// protocol in general. **Always grind the puzzle regardless** (the cost -/// is negligible and it's clearly the intended, documented behavior) — -/// this test does not license skipping it. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn unhardened_identity_against_the_real_fleet_is_observed_not_assumed() { - let identity = KeyPair::generate(); // NOT puzzle-hardened, on purpose - - let result = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &identity).await; - match result { - Ok(session) => { - println!( - "OBSERVED: unhardened identity was ACCEPTED (accepted={}) -- see this \ - test's doc comment for why that's a fleet-configuration fact, not \ - evidence this crate's puzzle handling is wrong", - session.station.accepted - ); - session.close("normal", None, &identity).await; - } - Err(e) => { - println!("OBSERVED: unhardened identity was rejected, as: {e}"); - } - } -} - -/// A real end-to-end CALL/RESULT-or-ERROR round trip. Calls a procedure -/// name that certainly doesn't exist (`macula_rust.test_probe`, -/// under the content sentinel realm) — the point isn't to exercise any -/// particular procedure, only to prove the wire round trip itself: a -/// signed CALL sent, and a signed RESULT or ERROR received back, -/// correlated by call_id, with a real BOLT#4 code if it's an error. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn call_round_trip_against_the_real_fleet() { - let identity = KeyPair::generate_with_default_puzzle(); - let session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &identity) - .await - .expect("handshake should succeed"); - - let response = session - .call( - "macula_rust.test_probe", - [0u8; 32], // the content-sentinel realm, reused here as a harmless default - macula_rust::cbor::Value::Null, - (now_ms() + 10_000) as i128, - &identity, - std::time::Duration::from_secs(10), - ) - .await - .expect("should get SOME response (result or a well-formed error), not a timeout"); - - match response { - macula_rust::frame::CallResponse::Result { - payload, - responded_by, - } => { - println!("OBSERVED: got a RESULT (unexpected for a made-up procedure, but valid): payload={payload:?} responded_by={}", hex::encode(responded_by)); - } - macula_rust::frame::CallResponse::Error { - code, - name, - reported_by, - detail, - } => { - println!( - "OBSERVED: got an ERROR (expected for a nonexistent procedure): code={code} name={name} reported_by={} detail={detail:?}", - hex::encode(reported_by) - ); - } - } - - session - .close("normal", Some("call test done"), &identity) - .await; -} - -fn now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock after epoch") - .as_millis() as u64 -} - -/// A real end-to-end SUBSCRIBE -> PUBLISH -> (maybe) EVENT round trip. -/// Whether a subscriber receives its own publish is genuinely unknown -/// going in — this test observes and reports rather than assuming an -/// answer, same discipline as the unhardened-identity test above. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn pubsub_round_trip_against_the_real_fleet() { - let identity = KeyPair::generate_with_default_puzzle(); - let session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &identity) - .await - .expect("handshake should succeed"); - - // A realm+topic scratch value nobody else would collide with. - let realm: [u8; 32] = rand::random(); - let topic = format!( - "macula-rust.test.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let mut subscription = session - .subscribe( - &macula_rust::frame::SubscribeSpec::new(topic.clone(), realm, identity.node_id()), - &identity, - ) - .await - .expect("SUBSCRIBE should send without error"); - - session - .publish( - &macula_rust::frame::PublishSpec::new( - topic.clone(), - realm, - identity.node_id(), - 1, - macula_rust::cbor::Value::text("hello from macula-rust"), - now_ms(), - ), - &identity, - ) - .await - .expect("PUBLISH should send without error"); - - match subscription - .recv_event(std::time::Duration::from_secs(5)) - .await - { - Ok(event) => { - println!( - "OBSERVED: received our own EVENT back — topic={} seq={} delivered_via={} payload={:?}", - event.topic, event.seq, event.delivered_via, event.payload - ); - assert_eq!(event.topic, topic); - } - Err(e) => { - println!( - "OBSERVED: no EVENT arrived within 5s ({e}) — a subscriber may not receive its \ - own publish, or delivery may simply be slower than this test waits. Not \ - asserted as a failure either way; see this test's doc comment." - ); - } - } - - session - .close("normal", Some("pubsub test done"), &identity) - .await; -} - -/// Real end-to-end proof that `run_subscriber`/`run_publisher` work, not -/// just compile — same discipline as `macula-go`'s -/// `TestLiveRunSubscriberAndRunPublisher`: three SEPARATE sessions/ -/// identities (this fleet kicks whichever connection reuses an identity -/// second, confirmed elsewhere this session), a subscriber genuinely -/// receiving a real event through its callback (not manual polling), and -/// the auto-published `pubsub.publish_completed_v1` fact confirmed by an -/// INDEPENDENT fourth session subscribed BEFORE the publish happens — not -/// the publisher's own bookkeeping. A random realm scopes this test's -/// traffic away from any real third-party activity on this shared public -/// fleet, same as `pubsub_round_trip_against_the_real_fleet` above. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn run_subscriber_and_run_publisher_against_the_real_fleet() { - let realm: [u8; 32] = rand::random(); - let topic = format!( - "macula-rust.test.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - // Independent watcher, subscribed to the fact topic BEFORE anything - // publishes -- pubsub has no replay for a late subscriber. - let watcher_id = KeyPair::generate_with_default_puzzle(); - let watcher = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &watcher_id) - .await - .expect("watcher handshake should succeed"); - let mut watcher_subscription = watcher - .subscribe( - &macula_rust::frame::SubscribeSpec::new( - "pubsub.publish_completed_v1", - realm, - watcher_id.node_id(), - ), - &watcher_id, - ) - .await - .expect("watcher SUBSCRIBE should send without error"); - - let sub_id = KeyPair::generate_with_default_puzzle(); - let sub_session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &sub_id) - .await - .expect("subscriber handshake should succeed"); - - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - let sub_topic = topic.clone(); - let subscribe_task = tokio::spawn(async move { - let spec = macula_rust::frame::SubscribeSpec::new(sub_topic, realm, sub_id.node_id()); - let stop = tokio::time::sleep(std::time::Duration::from_secs(8)); - sub_session - .run_subscriber(&spec, &sub_id, stop, |evt| { - let _ = tx.send(evt); - }) - .await - }); - - // Give both SUBSCRIBEs a moment to actually land before publishing. - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let pub_id = KeyPair::generate_with_default_puzzle(); - let pub_session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &pub_id) - .await - .expect("publisher handshake should succeed"); - let spec = macula_rust::frame::PublishSpec::new( - topic.clone(), - realm, - pub_id.node_id(), - 1, - Value::text("hello from run_publisher"), - now_ms(), - ); - let publish_result = pub_session.run_publisher(&spec, &pub_id, true).await; - assert!( - publish_result.is_ok(), - "run_publisher should succeed: {publish_result:?}" - ); - println!("OBSERVED: run_publisher completed cleanly"); - pub_session - .close("normal", Some("publisher test done"), &pub_id) - .await; - - match tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv()).await { - Ok(Some(evt)) => { - println!( - "OBSERVED: run_subscriber's handler received the real EVENT -- topic={} payload={:?}", - evt.topic, evt.payload - ); - assert_eq!(evt.topic, topic); - } - _ => println!( - "OBSERVED: no EVENT arrived via the subscriber's callback within 5s -- a subscriber \ - may not receive its own publish, same caveat as pubsub_round_trip_against_the_real_fleet" - ), - } - - let sub_result = subscribe_task - .await - .expect("subscriber task should not panic"); - assert!( - sub_result.is_ok(), - "run_subscriber should return Ok after its stop future resolves: {sub_result:?}" - ); - println!("OBSERVED: run_subscriber returned cleanly after its stop future resolved"); - - // The watcher's subscription only receives events for its own topic, under - // a realm nobody else uses; wait within an overall deadline for the fact - // to arrive. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - let mut confirmed = false; - while std::time::Instant::now() < deadline { - match watcher_subscription - .recv_event(std::time::Duration::from_secs(2)) - .await - { - Ok(evt) if evt.topic == "pubsub.publish_completed_v1" => { - let outcome = evt.payload.get("outcome"); - println!( - "OBSERVED: independent watcher confirmed a real pubsub.publish_completed_v1 fact landed -- outcome={outcome:?}" - ); - assert_eq!(outcome, Some(&Value::text("completed"))); - confirmed = true; - break; - } - Ok(other) => { - println!( - "(watcher skipping unrelated event on topic {})", - other.topic - ); - } - Err(e) => { - println!("(watcher still waiting: {e})"); - } - } - } - assert!( - confirmed, - "independent watcher never observed a pubsub.publish_completed_v1 fact within the deadline" - ); - - watcher - .close("normal", Some("watcher test done"), &watcher_id) - .await; -} - -/// Regression test for a real bug found live 2026-08-29 in the Go port -/// of this exact `connect -> write -> Close` shape (macula-go's -/// `connection.Session.Close`): a PUBLISH sent immediately before -/// `close` -- exactly what every one-shot CLI/tool invocation does -- -/// could be silently dropped, because `Connection::close` is abrupt and -/// does not wait for outstanding stream data to actually reach the -/// peer. `pubsub_round_trip_against_the_real_fleet` above can't catch -/// this: it keeps reading (blocking on `recv_event`) on the SAME -/// session that published, so `close` doesn't run until well after the -/// write already had time to flush. This uses two INDEPENDENT sessions -/// specifically so the publisher's `close` isn't incidentally delayed -/// by anything the subscriber does. Fixed proactively in -/// `Session::close` (`CLOSE_DRAIN`) before this was independently -/// rediscovered against this crate -- this test is what proves that -/// held. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn publish_survives_immediate_close_against_the_real_fleet() { - let sub_identity = KeyPair::generate_with_default_puzzle(); - let sub_session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &sub_identity) - .await - .expect("handshake should succeed (subscriber)"); - - let realm: [u8; 32] = rand::random(); - let topic = format!( - "macula-rust.test.immediate-close.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let mut subscription = sub_session - .subscribe( - &macula_rust::frame::SubscribeSpec::new(topic.clone(), realm, sub_identity.node_id()), - &sub_identity, - ) - .await - .expect("SUBSCRIBE should send without error"); - // Give the SUBSCRIBE a moment to register before the publish races - // it -- this test is about the PUBLISH-then-close race, not about - // subscribe-propagation timing (a separate concern). - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - // Separate connection, separate identity: publish then close - // immediately, no read in between -- the exact shape a one-shot - // CLI/tool invocation uses. - { - let pub_identity = KeyPair::generate_with_default_puzzle(); - let pub_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &pub_identity) - .await - .expect("handshake should succeed (publisher)"); - pub_session - .publish( - &macula_rust::frame::PublishSpec::new( - topic.clone(), - realm, - pub_identity.node_id(), - 1, - macula_rust::cbor::Value::text( - "hello from the immediate-close regression test", - ), - now_ms(), - ), - &pub_identity, - ) - .await - .expect("PUBLISH should send without error"); - pub_session.close("normal", None, &pub_identity).await; - } - - // The subscription only receives events for its own topic, so the first - // one within the deadline is the EVENT this test waits for. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - loop { - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - assert!( - !remaining.is_zero(), - "EVENT for our topic never arrived after a publish immediately followed by close \ - (this is the exact race this test exists to catch)" - ); - match subscription.recv_event(remaining).await { - Ok(event) if event.topic == topic => break, - Ok(event) => println!("skipping an unrelated EVENT: topic={}", event.topic), - Err(connection::RecvEventError::Timeout) => { - panic!( - "EVENT for our topic never arrived after a publish immediately followed by \ - close (this is the exact race this test exists to catch)" - ); - } - Err(e) => panic!("the subscription ended before the EVENT arrived: {e}"), - } - } - - sub_session - .close("normal", Some("immediate-close test done"), &sub_identity) - .await; -} - -/// A real single-block put/get round trip: content small enough -/// (`<= manifest::DEFAULT_CHUNK_SIZE`) to be addressed purely by content -/// hash, no manifest involved. Every byte is randomized per run so -/// there's no risk of colliding with content some other run already -/// stored under the same MCID. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn single_block_put_get_round_trip_against_the_real_fleet() { - let identity = KeyPair::generate_with_default_puzzle(); - let session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &identity) - .await - .expect("handshake should succeed"); - - let data: Vec = (0..4096).map(|_| rand::random::()).collect(); - let mcid = macula_rust::content::put(&session, &data, "test-block", &identity) - .await - .expect("put should succeed"); - assert!( - !macula_rust::manifest::mcid_is_chunked(&mcid), - "4096 bytes is well under the chunking threshold" - ); - println!( - "OBSERVED: stored single block under mcid={}", - hex::encode(mcid) - ); - - let fetched = macula_rust::content::get(&session, mcid, &identity) - .await - .expect("get should succeed for content this session just put"); - assert_eq!( - fetched, data, - "fetched bytes must match what was put, exactly" - ); - - session - .close("normal", Some("content single-block test done"), &identity) - .await; -} - -/// A real chunked put/get round trip: content large enough to force -/// `manifest::create`'s multi-chunk path, exercising `_content.put_block` -/// (several times, sequentially — see `src/content.rs`'s module doc on -/// why this crate doesn't parallelize lanes), `_content.put_manifest`, -/// `_content.get_manifest`, and `_content.get_block` (again several -/// times) all against a real station, then verifies the reassembled -/// bytes against the manifest's Merkle root. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn chunked_put_get_round_trip_against_the_real_fleet() { - let identity = KeyPair::generate_with_default_puzzle(); - let session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &identity) - .await - .expect("handshake should succeed"); - - let size = macula_rust::manifest::DEFAULT_CHUNK_SIZE * 2 + 12_345; - let data: Vec = (0..size).map(|_| rand::random::()).collect(); - let mcid = macula_rust::content::put(&session, &data, "test-chunked", &identity) - .await - .expect("chunked put should succeed"); - assert!( - macula_rust::manifest::mcid_is_chunked(&mcid), - "{size} bytes is well over the chunking threshold" - ); - println!( - "OBSERVED: stored {size} bytes as a manifest under mcid={}", - hex::encode(mcid) - ); - - let fetched = macula_rust::content::get(&session, mcid, &identity) - .await - .expect("chunked get should succeed for content this session just put"); - assert_eq!( - fetched, data, - "reassembled bytes must match what was put, exactly" - ); - - session - .close("normal", Some("content chunked test done"), &identity) - .await; -} - -/// A made-up MCID that (with overwhelming probability) nothing has ever -/// stored — proves the wire-level `not_found` reply is reached and -/// parsed correctly, not just the happy path. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn get_of_an_unknown_block_reports_not_found_against_the_real_fleet() { - let identity = KeyPair::generate_with_default_puzzle(); - let session = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &identity) - .await - .expect("handshake should succeed"); - - let random_hash: [u8; 32] = rand::random(); - let mcid = macula_rust::manifest::block_mcid(&random_hash); - - match macula_rust::content::get(&session, mcid, &identity).await { - Err(macula_rust::content::GetError::NotFound) => { - println!("OBSERVED: not_found reported correctly for an unknown mcid"); - } - other => panic!("expected GetError::NotFound, got {other:?}"), - } - - session - .close("normal", Some("content not-found test done"), &identity) - .await; -} - -/// Proves the exact bug class `macula-station`'s mode-aware half-close -/// fix (commit `07db0d8`) addresses: a `client_stream` caller that -/// pushes its data, half-closes its own send side with `close_send`, -/// and then awaits the provider's reply. Before that fix, the relay -/// tore down the ENTIRE bidirectional stream route on the caller's -/// STREAM_END regardless of the wire's `role` field, so the provider's -/// `send_reply` returned no error locally while the caller's -/// `await_reply` timed out — this crate's SDK-side code was already -/// correct, the bug lived entirely in the station's relay. -/// -/// This deliberately does NOT reuse the shape the previous version of -/// this test had (a lone caller against a made-up, unregistered -/// procedure with no real provider): that only ever proved wire -/// mechanics, never actually exercised `send_reply`/`await_reply` -/// against a real counterpart, and a hand-written mock provider here -/// could too easily bake the old (buggy) relay behavior in as -/// "correct" without anyone noticing. Two independent connections to -/// the SAME real, live station — one provider, one caller — same -/// pattern as `streaming_provider_round_trip_against_the_real_fleet` -/// above, with the roles matched to `ClientStream`'s actual wire shape -/// instead of `ServerStream`'s: the CALLER pushes data and closes its -/// own send side, the PROVIDER drains with `recv` and finishes with -/// `send_reply`, and the caller's `await_reply` is what's actually -/// being proven. Matches `macula-go`'s own -/// `TestLiveClientStreamReplyRoundTrip` (`stream/live_test.go`), the -/// SDK that already had this shape right, including asserting the -/// actual reply payload and `responded_by` rather than just logging -/// whichever of the two possible outcomes happened to occur. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn client_stream_reply_round_trip_against_the_real_fleet() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake should succeed"); - let caller_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("caller handshake should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_client_stream.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let advertise_spec = macula_rust::frame::AdvertiseSpec::new( - realm, - procedure.clone(), - provider_identity.node_id(), - ); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await - .expect("advertise should send"); - - // Give the station a moment to register the advertisement before - // the caller dials in against it. - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let accept_task = tokio::spawn(async move { - let result = macula_rust::stream::StreamHandle::accept( - &provider_session, - std::time::Duration::from_secs(10), - ) - .await; - (result, provider_session) - }); - - let mut caller_handle = macula_rust::stream::StreamHandle::open( - &caller_session, - &procedure, - realm, - macula_rust::frame::StreamMode::ClientStream, - macula_rust::cbor::Value::Null, - (now_ms() + 10_000) as i128, - &caller_identity, - ) - .await - .expect("caller should open a stream"); - - let (accept_result, provider_session) = - accept_task.await.expect("accept task should not panic"); - let (mut provider_handle, open_info) = - accept_result.expect("provider should accept the inbound STREAM_OPEN"); - - println!( - "OBSERVED: provider accepted stream_open for procedure={} mode={:?}", - open_info.procedure, open_info.mode - ); - assert_eq!(open_info.procedure, procedure); - assert_eq!(open_info.mode, macula_rust::frame::StreamMode::ClientStream); - - caller_handle - .send_data( - macula_rust::frame::StreamEncoding::Raw, - macula_rust::cbor::Value::Bytes(b"hello from the caller".to_vec()), - &caller_identity, - ) - .await - .expect("caller should push a chunk"); - caller_handle - .close_send(&caller_identity) - .await - .expect("caller should close its send side"); - - match provider_handle - .recv(std::time::Duration::from_secs(5)) - .await - .expect("provider should receive the pushed chunk") - { - macula_rust::stream::StreamItem::Data { body, .. } => { - assert_eq!( - body, - macula_rust::cbor::Value::Bytes(b"hello from the caller".to_vec()) - ); - } - other => panic!("expected Data, got {other:?}"), - } - match provider_handle - .recv(std::time::Duration::from_secs(5)) - .await - .expect("provider should see end-of-stream") - { - macula_rust::stream::StreamItem::Eof => {} - other => panic!("expected Eof, got {other:?}"), - } - - provider_handle - .send_reply( - macula_rust::cbor::Value::Text("processed: hello from the caller".to_string()), - &provider_identity, - ) - .await - .expect("provider should send a reply"); - - let (payload, responded_by) = caller_handle - .await_reply(std::time::Duration::from_secs(5)) - .await - .expect( - "caller should receive the reply -- if this times out, macula-station's \ - mode-aware half-close fix (commit 07db0d8) is not live on this station", - ); - assert_eq!( - payload, - macula_rust::cbor::Value::Text("processed: hello from the caller".to_string()) - ); - assert_eq!(responded_by, provider_identity.node_id()); - println!( - "OBSERVED: caller received a real STREAM_REPLY through ClientStream mode: payload={payload:?} responded_by={}", - hex::encode(responded_by) - ); - - provider_session - .close("normal", Some("provider test done"), &provider_identity) - .await; - caller_session - .close("normal", Some("caller test done"), &caller_identity) - .await; -} - -/// The real point of §13.2's whole existence: two independent -/// connections to the SAME live station — one advertises a procedure -/// and accepts inbound streams for it (the provider role), the other -/// dials in and pushes/pulls data against it (the caller role, already -/// live-verified elsewhere). This is the first test in this crate where -/// this process is on the RECEIVING end of a mesh interaction it didn't -/// initiate — everything before this dialed out and waited for a -/// response; here, one session sits idle after `advertise` until the -/// station itself routes a stranger's request back to it. -/// -/// Same station on purpose: cross-station routing depends on gossip -/// propagation between stations, which isn't instant and isn't this -/// crate's concern to wait out — same-station is the direct case -/// `plans/PLAN_WIRE_PROTOCOL.md` §6.9 describes ("registers the handler -/// with the pool's advertise-gossip mechanism"), and it's what a real -/// provider dialed into one station actually needs day to day. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn streaming_provider_round_trip_against_the_real_fleet() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake should succeed"); - let caller_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("caller handshake should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_provider.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let advertise_spec = macula_rust::frame::AdvertiseSpec::new( - realm, - procedure.clone(), - provider_identity.node_id(), - ); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await - .expect("advertise should send"); - - // Give the station a moment to register the advertisement before - // the caller dials in against it. - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let accept_task = tokio::spawn(async move { - let result = macula_rust::stream::StreamHandle::accept( - &provider_session, - std::time::Duration::from_secs(10), - ) - .await; - (result, provider_session) - }); - - let mut caller_handle = macula_rust::stream::StreamHandle::open( - &caller_session, - &procedure, - realm, - macula_rust::frame::StreamMode::ServerStream, - macula_rust::cbor::Value::Null, - (now_ms() + 10_000) as i128, - &caller_identity, - ) - .await - .expect("caller should open a stream"); - - let (accept_result, provider_session) = - accept_task.await.expect("accept task should not panic"); - let (mut provider_handle, open_info) = - accept_result.expect("provider should accept the inbound STREAM_OPEN"); - - println!( - "OBSERVED: provider accepted stream_open for procedure={} mode={:?}", - open_info.procedure, open_info.mode - ); - assert_eq!(open_info.procedure, procedure); - assert_eq!(open_info.mode, macula_rust::frame::StreamMode::ServerStream); - - provider_handle - .send_data( - macula_rust::frame::StreamEncoding::Raw, - macula_rust::cbor::Value::Bytes(b"hello from the provider".to_vec()), - &provider_identity, - ) - .await - .expect("provider should push a chunk"); - provider_handle - .close_send(&provider_identity) - .await - .expect("provider should close its send side"); - - match caller_handle - .recv(std::time::Duration::from_secs(5)) - .await - .expect("caller should receive the pushed chunk") - { - macula_rust::stream::StreamItem::Data { body, .. } => { - assert_eq!( - body, - macula_rust::cbor::Value::Bytes(b"hello from the provider".to_vec()) - ); - } - other => panic!("expected Data, got {other:?}"), - } - match caller_handle - .recv(std::time::Duration::from_secs(5)) - .await - .expect("caller should see end-of-stream") - { - macula_rust::stream::StreamItem::Eof => {} - other => panic!("expected Eof, got {other:?}"), - } - - provider_session - .close("normal", Some("provider test done"), &provider_identity) - .await; - caller_session - .close("normal", Some("caller test done"), &caller_identity) - .await; -} - -/// The unary-RPC counterpart to `streaming_provider_round_trip_against_the_real_fleet` -/// above, and the gap this crate's own README used to list as "not yet -/// built": two independent connections to the SAME live station, one -/// advertising a procedure and serving inbound CALLs for it via -/// [`connection::Session::serve_one_call`], the other dialing in and -/// calling it — the caller role already covered by -/// `call_round_trip_against_the_real_fleet`. Without this, a service -/// built on this crate could call RPCs and serve streams, but could -/// never serve a request/response procedure at all. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn unary_call_provider_round_trip_against_the_real_fleet() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake should succeed"); - let caller_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("caller handshake should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_add.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let advertise_spec = macula_rust::frame::AdvertiseSpec::new( - realm, - procedure.clone(), - provider_identity.node_id(), - ); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await - .expect("advertise should send"); - - // Give the station a moment to register the advertisement before - // the caller dials in against it. - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let target_procedure = procedure.clone(); - let lookup = move |_realm: &[u8; 32], proc: &str| -> Option { - if proc != target_procedure { - return None; - } - let handler: connection::CallHandler = std::sync::Arc::new(|payload: Value| { - Box::pin(async move { - let a = match payload.get("a") { - Some(Value::Int(n)) => *n, - _ => return Err("missing or non-integer field \"a\"".to_string()), - }; - let b = match payload.get("b") { - Some(Value::Int(n)) => *n, - _ => return Err("missing or non-integer field \"b\"".to_string()), - }; - Ok(Value::Int(a + b)) - }) as connection::BoxFuture<'static, Result> - }); - Some(handler) - }; - - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call( - lookup, - &provider_identity, - std::time::Duration::from_secs(15), - ) - .await; - (result, provider_session, provider_identity) - }); - - let payload = Value::Map(vec![ - (Value::text("a"), Value::Int(3)), - (Value::text("b"), Value::Int(4)), - ]); - let response = caller_session - .call( - &procedure, - realm, - payload, - (now_ms() + 10_000) as i128, - &caller_identity, - std::time::Duration::from_secs(10), - ) - .await - .expect("call should succeed"); - - let (serve_result, provider_session, provider_identity) = - serve_task.await.expect("serve task should not panic"); - serve_result.expect("provider should serve the inbound CALL"); - - match response { - macula_rust::frame::CallResponse::Result { payload, .. } => { - assert_eq!(payload, Value::Int(7), "3 + 4 should reply with RESULT 7"); - } - other => panic!("expected a RESULT, got {other:?}"), - } - println!( - "OBSERVED: provider served the inbound CALL for procedure={procedure}, caller got RESULT 7" - ); - - provider_session - .close( - "normal", - Some("unary provider test done"), - &provider_identity, - ) - .await; - caller_session - .close("normal", Some("unary caller test done"), &caller_identity) - .await; -} - -/// Regression test for a real bug found and root-caused 2026-09-05 -/// building the quickstart example: identical to -/// `unary_call_provider_round_trip_against_the_real_fleet` above, EXCEPT -/// `#[tokio::test]`'s default flavor there is single-threaded -/// (`current_thread`) -- this test forces the MULTI-threaded flavor -/// `#[tokio::main]` itself defaults to, which is what any real -/// consumer's `main` actually runs under. -/// -/// Root cause, confirmed by instrumenting `FrameStream::send_frame`/ -/// `recv_frame` with thread-id and frame-content logging against the -/// real fleet: it is NOT fundamentally about multi-threading. It's -/// [`Session`]'s own documented "always call `close` before this goes -/// out of scope" contract (see that type's own doc, and -/// [`Session::serve_one_call`]'s) -- a bare drop tears down the -/// connection with no guarantee the last write reached the peer, and a -/// `tokio::spawn`ed task with nothing after the `served_one_call().await` -/// drops its `Session` the instant the task completes. Under a -/// multi-threaded runtime that spawned task can run to completion (and -/// drop the session) within microseconds of the write -- deterministically, -/// every run in this environment -- while a single-threaded runtime's own -/// cooperative scheduling happens to leave enough incidental delay before -/// the drop for quinn's send-scheduling to flush first. This test -/// deliberately closes `provider_session` explicitly before the task -/// ends, exactly like `unary_call_provider_round_trip_against_the_real_fleet` -/// above already does (returning the session out of the spawned task and -/// closing it afterward is equally correct) -- with that discipline -/// applied, the round trip is exactly as reliable under multi-thread as -/// under current_thread. -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "requires network access to a live macula-station"] -async fn unary_call_provider_round_trip_multi_thread_runtime() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake should succeed"); - let caller_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("caller handshake should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_multithread.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let advertise_spec = macula_rust::frame::AdvertiseSpec::new( - realm, - procedure.clone(), - provider_identity.node_id(), - ); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await - .expect("advertise should send"); - - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let target_procedure = procedure.clone(); - let lookup = move |_realm: &[u8; 32], proc: &str| -> Option { - if proc != target_procedure { - return None; - } - let handler: connection::CallHandler = std::sync::Arc::new(|payload: Value| { - Box::pin(async move { Ok(payload) }) - as connection::BoxFuture<'static, Result> - }); - Some(handler) - }; - - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call( - lookup, - &provider_identity, - std::time::Duration::from_secs(15), - ) - .await; - // The fix: close explicitly instead of letting provider_session - // drop when this task ends -- see this test's own doc comment. - provider_session - .close( - "normal", - Some("multi-thread provider test done"), - &provider_identity, - ) - .await; - result - }); - - let response = caller_session - .call( - &procedure, - realm, - Value::text("hello"), - (now_ms() + 10_000) as i128, - &caller_identity, - std::time::Duration::from_secs(10), - ) - .await - .expect("call should succeed"); - - let serve_result = serve_task.await.expect("serve task should not panic"); - serve_result.expect("provider should serve the inbound CALL"); - - match response { - macula_rust::frame::CallResponse::Result { payload, .. } => { - assert_eq!(payload, Value::text("hello")); - } - other => panic!("expected a RESULT, got {other:?}"), - } - - caller_session - .close( - "normal", - Some("multi-thread caller test done"), - &caller_identity, - ) - .await; -} - -/// Proves `call`/`serve_one_call` genuinely auto-publish `rpc.sent_v1`/ -/// `rpc.completed_v1` (caller) and `rpc.received_v1`/`rpc.replied_v1` -/// (provider) — confirmed by an INDEPENDENT watcher session, not the -/// caller's/provider's own bookkeeping. A random realm (same trick -/// `unary_call_provider_round_trip_against_the_real_fleet` and the pubsub -/// live test already use) scopes every fact this test's own call -/// generates away from any real third-party activity on this shared -/// public fleet — with only one call made under a realm nobody else -/// uses, there is exactly one of each fact to expect, so no request_id -/// correlation against unrelated traffic is needed here (unlike -/// `macula-go`'s equivalent test, which had to add that specifically -/// because it published under a FIXED, shared topic/realm). -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn rpc_telemetry_facts_against_the_real_fleet() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - let watcher_identity = KeyPair::generate_with_default_puzzle(); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_rpc_facts.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - // Watcher subscribes to all 4 topics BEFORE anything happens — pubsub - // has no replay for a late subscriber. - let watcher = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &watcher_identity) - .await - .expect("watcher handshake should succeed"); - let mut subscriptions = Vec::new(); - for topic in [ - "rpc.sent_v1", - "rpc.completed_v1", - "rpc.received_v1", - "rpc.replied_v1", - ] { - subscriptions.push( - watcher - .subscribe( - &macula_rust::frame::SubscribeSpec::new( - topic, - realm, - watcher_identity.node_id(), - ), - &watcher_identity, - ) - .await - .expect("watcher SUBSCRIBE should send without error"), - ); - } - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake should succeed"); - let caller_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("caller handshake should succeed"); - - let advertise_spec = macula_rust::frame::AdvertiseSpec::new( - realm, - procedure.clone(), - provider_identity.node_id(), - ); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await - .expect("advertise should send"); - - // Give both the advertise and the 4 SUBSCRIBEs a moment to land. - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let target_procedure = procedure.clone(); - let lookup = move |_realm: &[u8; 32], proc: &str| -> Option { - if proc != target_procedure { - return None; - } - Some(std::sync::Arc::new(|payload: Value| { - Box::pin(async move { Ok(payload) }) - as connection::BoxFuture<'static, Result> - })) - }; - - // Returns provider_session/provider_identity back out, rather than - // letting them drop when the task ends, so they can be explicitly - // `.close()`d below — NOT a stylistic choice. A bare `Drop` right - // after `serve_one_call` returns races the just-sent RESULT frame - // against abrupt QUIC connection teardown with zero drain time, - // exactly the "PUBLISH sent immediately before Close intermittently - // never reached the peer" gotcha `Session::close`'s own doc already - // warns about (quinn's `write_all`/`finish` only hand data to its - // send-scheduling machinery, they don't wait for the peer to receive - // it) — except a bare drop has even less margin than `close()`'s own - // built-in drain sleep. Found live: an earlier draft of this test let - // `provider_session` drop bare and got a deterministic, 100%-reproducible - // caller-side timeout even though `serve_one_call` itself returned - // `Ok(())` every time — isolated by bisection against - // `unary_call_provider_round_trip_against_the_real_fleet` (which - // already returns its sessions and explicitly closes them, and never - // hits this), not a fleet flake and not caused by the RPC telemetry - // facts this test actually exists to check. - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call( - lookup, - &provider_identity, - std::time::Duration::from_secs(15), - ) - .await; - (result, provider_session, provider_identity) - }); - - let response = caller_session - .call( - &procedure, - realm, - Value::text("hello"), - (now_ms() + 10_000) as i128, - &caller_identity, - std::time::Duration::from_secs(10), - ) - .await - .expect("call should succeed"); - assert!( - matches!(response, macula_rust::frame::CallResponse::Result { .. }), - "expected a RESULT, got {response:?}" - ); - - let (serve_result, provider_session, provider_identity) = - serve_task.await.expect("serve task should not panic"); - serve_result.expect("provider should serve the inbound CALL"); - provider_session - .close("normal", Some("rpc facts test done"), &provider_identity) - .await; - - let mut seen = std::collections::HashSet::new(); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while seen.len() < 4 && std::time::Instant::now() < deadline { - for subscription in subscriptions.iter_mut() { - let Ok(evt) = subscription - .recv_event(std::time::Duration::from_millis(250)) - .await - else { - continue; - }; - if seen.insert(evt.topic.clone()) { - println!( - "OBSERVED: {} fact landed with payload={:?}", - evt.topic, evt.payload - ); - } - } - } - assert_eq!( - seen, - std::collections::HashSet::from([ - "rpc.sent_v1".to_string(), - "rpc.completed_v1".to_string(), - "rpc.received_v1".to_string(), - "rpc.replied_v1".to_string(), - ]), - "expected all 4 RPC telemetry facts to land, only saw: {seen:?}" - ); - - caller_session - .close("normal", Some("rpc facts test done"), &caller_identity) - .await; - watcher - .close("normal", Some("rpc facts test done"), &watcher_identity) - .await; -} - -/// Confirms the BOLT#4 error path: a provider that's advertised but -/// whose lookup (deliberately, here) can't find a handler replies with -/// the exact same `unknown_next_peer` code the reference sends for this -/// race (`macula_station_link.erl`'s `handle_inbound_call/2`, "unknown -/// (realm, procedure)" branch). -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn unary_call_provider_reports_unknown_next_peer_on_lookup_miss_against_the_real_fleet() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake should succeed"); - let caller_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("caller handshake should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_miss.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let advertise_spec = macula_rust::frame::AdvertiseSpec::new( - realm, - procedure.clone(), - provider_identity.node_id(), - ); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await - .expect("advertise should send"); - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - - let no_handlers = |_realm: &[u8; 32], _proc: &str| -> Option { None }; - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call( - no_handlers, - &provider_identity, - std::time::Duration::from_secs(15), - ) - .await; - (result, provider_session, provider_identity) - }); - - let response = caller_session - .call( - &procedure, - realm, - Value::Null, - (now_ms() + 10_000) as i128, - &caller_identity, - std::time::Duration::from_secs(10), - ) - .await - .expect("call should succeed"); - - let (serve_result, provider_session, provider_identity) = - serve_task.await.expect("serve task should not panic"); - serve_result.expect("provider should serve the inbound CALL (with an error reply)"); - - match response { - macula_rust::frame::CallResponse::Error { code, name, .. } => { - assert_eq!(code, macula_rust::bolt4::Code::UnknownNextPeer.as_u8()); - println!("OBSERVED: lookup miss correctly reported as ERROR code={code} name={name}"); - } - other => panic!("expected an ERROR, got {other:?}"), - } - - provider_session - .close( - "normal", - Some("unary provider miss test done"), - &provider_identity, - ) - .await; - caller_session - .close( - "normal", - Some("unary caller miss test done"), - &caller_identity, - ) - .await; -} - -/// **First-ever live test of `Trust::Pinned` against a real station.** -/// Every other test in this file dials `station-de-frankfurt.macula.io` -/// under `Trust::WebPki`, because that's the only trust mode Frankfurt's -/// CA-issued cert can satisfy -- `Trust::Pinned` had unit coverage only -/// (`src/cert.rs`, a synthetic cert), never a real handshake. Toronto -/// exists specifically to close that gap: no DNS entry, no CA cert, dialed -/// by its bare `host_advertised` IPv6 literal and validated by pinning its -/// known Ed25519 NodeId instead of a certificate chain -- exactly the -/// "station without public DNS/CA-issued TLS" mode `Trust::Pinned`'s own -/// doc comment describes as the normal case for a mobile client dialing a -/// known station. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn pinned_trust_full_handshake_succeeds_against_toronto() { - let node_id: [u8; 32] = hex::decode(TORONTO_NODE_ID_HEX) - .expect("valid hex") - .try_into() - .expect("32 bytes"); - let identity = KeyPair::generate_with_default_puzzle(); - - let session = connection::connect( - TORONTO_HOST, - TORONTO_PORT, - Trust::Pinned(node_id), - &identity, - ) - .await - .expect("Pinned-trust CONNECT/HELLO handshake should succeed against a live no-DNS station"); - - println!( - "handshake accepted: remote={} station_node_id={} negotiated_capabilities={}", - session.remote_address(), - hex::encode(session.station.node_id), - session.station.negotiated_capabilities, - ); - assert!(session.station.accepted); - assert_eq!( - session.station.node_id, node_id, - "the station's own reported node_id should match the one we pinned" - ); - - session - .close("normal", Some("pinned trust test done"), &identity) - .await; -} - -/// The primitive a real cam2me call would ride on -- two independent -/// identities each dialed into a DIFFERENT station (Frankfurt, Milan, -/// mirroring an actual two-emulator cam2me session run 2026-08-29: one -/// phone left on its default station, the other switched to Milan via -/// Settings), one advertising and accepting a bidirectional stream, the -/// other opening it and both sides exchanging data -- unlike -/// `streaming_provider_round_trip_against_the_real_fleet` above, which is -/// deliberately same-station because "cross-station routing depends on -/// gossip propagation between stations, which isn't instant and isn't this -/// crate's concern to wait out". This test IS concerned with exactly that: -/// it's the one open question a real call feature can't avoid, since two -/// contacts are never guaranteed to share a station. `StreamMode::Bidi` -/// rather than the one-directional `ServerStream` used above, since a call -/// needs both directions, not one. -/// -/// **Root cause found and fixed, 2026-08-29 -- this crate's bug, not -/// `macula-station`'s.** First run of this test found STREAM_OPEN routing -/// cross-station correctly but a DATA frame sent afterward never arriving, -/// reproducible at both 5s and 25s timeouts. Traced into -/// `macula_station_peer_observer.erl`'s dedicated-stream relay -/// (`verify_dedicated/4`): non-OPEN stream frames (DATA/END/ERROR) verify -/// against an optional `signer` field when present, falling back to "the -/// connection this frame arrived on" when absent -- and the reference's own -/// comment on that fallback says outright it's "single-hop only", because -/// at a second relay hop the connection belongs to the RELAYING STATION, -/// not the original sender. This crate's STREAM_DATA/END/ERROR -/// constructors never stamped `signer` at all (`frame.rs`'s own prior doc -/// comment reasoned "a direct-dial client... has no relay hop to -/// authenticate across" -- true of the client's OWN single hop, false of -/// what the STATION does with it afterward). Fixed: `StreamDataSpec`/ -/// `StreamEndSpec`/`StreamErrorSpec` all gained `signer: Option<[u8; 32]>`, -/// and every real call site (`StreamHandle::send_data`/`close_send`/ -/// `abort`) now always supplies `Some(identity.public_bytes())`. New -/// differential vectors added in `frame.rs` for the `Some` case, generated -/// live against `macula_frame:stream_data/1` etc with `signer` in the spec -/// map -- the two pre-existing vectors testing `None` are untouched and -/// still pass, since that's a real, still-valid branch of the reference's -/// own optional field. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn cross_station_streaming_round_trip_frankfurt_provider_milan_caller() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake against Frankfurt should succeed"); - let caller_session = - connection::connect(MILAN_HOST, MILAN_PORT, Trust::WebPki, &caller_identity) - .await - .expect("caller handshake against Milan should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_call.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let advertise_spec = macula_rust::frame::AdvertiseSpec::new( - realm, - procedure.clone(), - provider_identity.node_id(), - ); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await - .expect("advertise on Frankfurt should send"); - - // Same-station tests above give this 500ms; a cross-station lookup has - // to actually reach the other station first, so this waits longer - // before concluding it never will. - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - - let accept_task = tokio::spawn(async move { - let result = macula_rust::stream::StreamHandle::accept( - &provider_session, - std::time::Duration::from_secs(15), - ) - .await; - (result, provider_session) - }); - - let open_result = macula_rust::stream::StreamHandle::open( - &caller_session, - &procedure, - realm, - macula_rust::frame::StreamMode::Bidi, - macula_rust::cbor::Value::Null, - (now_ms() + 10_000) as i128, - &caller_identity, - ) - .await; - - let mut caller_handle = match open_result { - Ok(h) => h, - Err(e) => { - println!( - "OBSERVED: cross-station STREAM_OPEN failed as: {e} -- Milan could not \ - route to a procedure only advertised on Frankfurt within 5s. This is the \ - real, useful answer to whether a call feature can rely on cross-station \ - routing working promptly; see this test's doc comment." - ); - let _ = accept_task.await; - return; - } - }; - println!("OBSERVED: cross-station STREAM_OPEN succeeded -- Milan routed it to Frankfurt"); - - let (accept_result, provider_session) = - accept_task.await.expect("accept task should not panic"); - let (mut provider_handle, open_info) = - accept_result.expect("provider should accept the inbound STREAM_OPEN"); - assert_eq!(open_info.procedure, procedure); - assert_eq!(open_info.mode, macula_rust::frame::StreamMode::Bidi); - - // Send both frames, then DRAIN both (recv the Data) before either side - // closes its send half -- closing before the peer has drained the data - // that preceded the close is exactly the ordering the first run of - // this test got wrong (a real bug in this test, not in the SDK): - // provider_handle.recv() failed with StreamClosed because both sides - // half-closed before either had received the other's frame. - caller_handle - .send_data( - macula_rust::frame::StreamEncoding::Raw, - macula_rust::cbor::Value::Bytes(b"audio frame from phone2 (milan)".to_vec()), - &caller_identity, - ) - .await - .expect("caller should push a frame"); - provider_handle - .send_data( - macula_rust::frame::StreamEncoding::Raw, - macula_rust::cbor::Value::Bytes(b"audio frame from phone1 (frankfurt)".to_vec()), - &provider_identity, - ) - .await - .expect("provider should push a frame"); - - match provider_handle - .recv(std::time::Duration::from_secs(5)) - .await - .expect( - "provider should receive the caller's frame -- see this test's doc comment, \ - fixed 2026-08-29 by stamping `signer` on stream data frames", - ) { - macula_rust::stream::StreamItem::Data { body, .. } => { - assert_eq!( - body, - macula_rust::cbor::Value::Bytes(b"audio frame from phone2 (milan)".to_vec()) - ); - println!("OBSERVED: provider (Frankfurt) received phone2's frame from Milan"); - } - other => panic!("expected Data, got {other:?}"), - } - match caller_handle - .recv(std::time::Duration::from_secs(5)) - .await - .expect("caller should receive the provider's frame") - { - macula_rust::stream::StreamItem::Data { body, .. } => { - assert_eq!( - body, - macula_rust::cbor::Value::Bytes(b"audio frame from phone1 (frankfurt)".to_vec()) - ); - println!("OBSERVED: caller (Milan) received phone1's frame from Frankfurt"); - } - other => panic!("expected Data, got {other:?}"), - } - - caller_handle - .close_send(&caller_identity) - .await - .expect("caller should half-close"); - provider_handle - .close_send(&provider_identity) - .await - .expect("provider should half-close"); - - provider_session - .close( - "normal", - Some("cross-station call test done"), - &provider_identity, - ) - .await; - caller_session - .close( - "normal", - Some("cross-station call test done"), - &caller_identity, - ) - .await; -} - -/// Follow-up to `cross_station_streaming_round_trip_frankfurt_provider_milan_caller` -/// above, asking a narrower question: that test found STREAM_OPEN routes -/// cross-station but DATA on the resulting stream does not. -/// `plans/PLAN_MACULA_STREAMING.md` (macula-architecture) says cross-relay -/// STREAM_OPEN routing "will follow the CALL path's procedure-resolver -/// pattern" -- so if plain CALL/RESULT (a single request/response, no -/// persistent per-stream relay state to pin across the station boundary) -/// also crosses stations cleanly, that's real signal that a signaling -/// exchange (SDP offer/answer, ICE candidates) built on CALL rather than a -/// long-lived STREAM_OPEN session would not hit the same gap. -/// -/// **Empirical finding, 2026-08-29, confirmed:** it does not hit the gap. -/// Milan's CALL reached Frankfurt's advertised provider, the RESULT came -/// back with the exact expected payload, round trip in ~5s (almost all of -/// it the propagation wait, not the call itself). Unlike the streaming -/// case, there's no follow-up DATA frame to lose -- a CALL is one -/// request, one response, both riding the same resolver lookup that -/// already proved reliable for STREAM_OPEN. So a signaling exchange built -/// on CALL/RESULT (or short-lived RPCs generally) rather than a -/// persistent STREAM_OPEN+DATA session is on solid ground cross-station, -/// independent of whether the streaming DATA-relay gap above ever gets -/// fixed. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn cross_station_unary_call_round_trip_frankfurt_provider_milan_caller() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake against Frankfurt should succeed"); - let caller_session = - connection::connect(MILAN_HOST, MILAN_PORT, Trust::WebPki, &caller_identity) - .await - .expect("caller handshake against Milan should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_signal.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let advertise_spec = macula_rust::frame::AdvertiseSpec::new( - realm, - procedure.clone(), - provider_identity.node_id(), - ); - provider_session - .advertise(&advertise_spec, &provider_identity) - .await - .expect("advertise on Frankfurt should send"); - - // Same wait the streaming counterpart used for its own resolver lookup. - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - - let target_procedure = procedure.clone(); - let lookup = move |_realm: &[u8; 32], proc: &str| -> Option { - if proc != target_procedure { - return None; - } - let handler: connection::CallHandler = std::sync::Arc::new(|payload: Value| { - Box::pin(async move { - match payload { - Value::Text(s) if s == "offer from phone2 (milan)" => { - Ok(Value::text("answer from phone1 (frankfurt)")) - } - other => Err(format!("unexpected payload: {other:?}")), - } - }) as connection::BoxFuture<'static, Result> - }); - Some(handler) - }; - - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call( - lookup, - &provider_identity, - std::time::Duration::from_secs(20), - ) - .await; - (result, provider_session, provider_identity) - }); - - let response = caller_session - .call( - &procedure, - realm, - Value::text("offer from phone2 (milan)"), - (now_ms() + 15_000) as i128, - &caller_identity, - std::time::Duration::from_secs(15), - ) - .await; - - let (serve_result, provider_session, provider_identity) = - serve_task.await.expect("serve task should not panic"); - - match (response, serve_result) { - (Ok(macula_rust::frame::CallResponse::Result { payload, .. }), Ok(())) => { - let matches = payload == Value::text("answer from phone1 (frankfurt)"); - println!( - "OBSERVED: cross-station CALL/RESULT succeeded -- Milan's CALL reached \ - Frankfurt's provider and the RESULT came back, content matches = {matches}" - ); - } - (Ok(other), serve_result) => { - println!( - "OBSERVED: cross-station CALL got a response but not the expected RESULT: \ - {other:?} (serve_result={serve_result:?})" - ); - } - (Err(e), serve_result) => { - println!( - "OBSERVED: cross-station CALL failed -- {e} (serve_result={serve_result:?}). \ - If this fails the same way the streaming test's DATA phase did, the CALL \ - path shares the same cross-station gap; if it succeeds, signaling built on \ - CALL rather than STREAM_OPEN+DATA is on solid ground." - ); - } - } - - provider_session - .close( - "normal", - Some("cross-station signaling test done"), - &provider_identity, - ) - .await; - caller_session - .close( - "normal", - Some("cross-station signaling test done"), - &caller_identity, - ) - .await; -} - -/// Full direct-dial loop, end to end: a provider advertises via -/// `direct_dial::advertise_direct` (publishing a signed DHT record, not -/// the ordinary gossip ADVERTISE), a caller resolves that record over a -/// SEPARATE connection via `direct_dial::resolve`, and `direct_dial::call` -/// dials the resolved station directly and gets a REAL RESULT back — -/// proving the whole chain (sign, publish, resolve, verify the trust -/// chain, dial, call, serve) works against the real fleet, not just that -/// resolution reaches the call stage. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn direct_dial_advertise_resolve_and_call_round_trip_against_the_real_fleet() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - - let provider_session = - connection::connect(MILAN_HOST, MILAN_PORT, Trust::WebPki, &provider_identity) - .await - .expect("provider handshake should succeed"); - // Used only to query the DHT -- per direct_dial::resolve's own doc, it - // does not need to be connected to the same station that ends up - // serving the call. Dialing a DIFFERENT station than the provider's - // own makes that claim meaningful rather than accidentally true. - let resolve_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("resolve-side handshake should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_direct_dial.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - macula_rust::direct_dial::advertise_direct( - &provider_session, - &provider_identity, - realm, - &procedure, - std::time::Duration::from_secs(120), - ) - .await - .expect("advertise_direct should publish the DHT record"); - - let target_procedure = procedure.clone(); - let lookup = move |_realm: &[u8; 32], proc: &str| -> Option { - if proc != target_procedure { - return None; - } - let handler: connection::CallHandler = std::sync::Arc::new(|payload: Value| { - Box::pin(async move { - let n = match payload.get("n") { - Some(Value::Int(n)) => *n, - _ => return Err("missing or non-integer field \"n\"".to_string()), - }; - Ok(Value::Int(n * 2)) - }) as connection::BoxFuture<'static, Result> - }); - Some(handler) - }; - - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call( - lookup, - &provider_identity, - std::time::Duration::from_secs(20), - ) - .await; - (result, provider_session, provider_identity) - }); - - let payload = Value::Map(vec![(Value::text("n"), Value::Int(21))]); - let response = macula_rust::direct_dial::call( - &resolve_session, - &caller_identity, - realm, - &procedure, - payload, - std::time::Duration::from_secs(15), - ) - .await - .expect("direct-dial call should resolve, dial, and complete"); - - let (serve_result, provider_session, provider_identity) = - serve_task.await.expect("serve task should not panic"); - serve_result.expect("provider should serve the direct-dialed inbound CALL"); - - match response { - macula_rust::frame::CallResponse::Result { payload, .. } => { - assert_eq!( - payload, - Value::Int(42), - "21 * 2 should reply with RESULT 42" - ); - } - other => panic!("expected a RESULT, got {other:?}"), - } - println!( - "OBSERVED: direct-dial resolved+dialed a station DIFFERENT from the resolve session's own, and got a real RESULT for procedure={procedure}" - ); - - provider_session - .close( - "normal", - Some("direct-dial provider test done"), - &provider_identity, - ) - .await; - resolve_session - .close( - "normal", - Some("direct-dial resolve-side test done"), - &caller_identity, - ) - .await; -} - -/// A direct call runs on the caller's own session to the provider's station -/// instead of dialing a second connection under the same identity, which -/// would make the station close that session. The name matches the .NET and -/// Go tests. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn resolver_session_still_connected_after_a_direct_call() { - let provider_identity = KeyPair::generate_with_default_puzzle(); - let caller_identity = KeyPair::generate_with_default_puzzle(); - let realm = [0u8; 32]; - let procedure = format!( - "macula_rust.direct_dial_reuse_test.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - - let provider_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &provider_identity, - ) - .await - .expect("provider handshake should succeed"); - macula_rust::direct_dial::advertise_direct( - &provider_session, - &provider_identity, - realm, - &procedure, - std::time::Duration::from_secs(3600), - ) - .await - .expect("advertise_direct should publish the DHT record"); - let provider_station = provider_session.station.node_id; - - let target_procedure = procedure.clone(); - let lookup = move |_realm: &[u8; 32], proc: &str| -> Option { - if proc != target_procedure { - return None; - } - let echo: connection::CallHandler = std::sync::Arc::new(|payload: Value| { - Box::pin(async move { Ok(payload) }) - as connection::BoxFuture<'static, Result> - }); - Some(echo) - }; - let serve_task = tokio::spawn(async move { - let result = provider_session - .serve_one_call( - lookup, - &provider_identity, - std::time::Duration::from_secs(20), - ) - .await; - (result, provider_session, provider_identity) - }); - - let resolver = connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &caller_identity) - .await - .expect("resolver handshake should succeed"); - // Reuse needs the resolver on the provider's own station. - assert_eq!( - hex::encode(resolver.station.node_id), - hex::encode(provider_station), - "the resolver and the provider reached different stations" - ); - - let response = macula_rust::direct_dial::call( - &resolver, - &caller_identity, - realm, - &procedure, - Value::text("hello again"), - std::time::Duration::from_secs(15), - ) - .await - .expect("the direct call should be answered"); - match response { - macula_rust::frame::CallResponse::Result { payload, .. } => { - assert_eq!(payload, Value::text("hello again")); - } - other => panic!("expected a RESULT, got {other:?}"), - } - let (serve_result, provider_session, provider_identity) = - serve_task.await.expect("serve task should not panic"); - serve_result.expect("provider should serve the direct call"); - - // A second connection under caller_identity would have made the station - // close the resolver by now. - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - assert!( - resolver.end_reason().is_none(), - "the resolver session ended: {:?}", - resolver.end_reason() - ); - // A plain DHT query, so a stale station_endpoint record on the fleet - // can't fail the check. - let advertisements = macula_rust::dht::find_records( - &resolver, - &caller_identity, - macula_rust::dht::procedure_key(&macula_rust::dht::discovery_uri(realm, &procedure)), - ) - .await - .expect("the resolver still answers DHT queries"); - assert!( - !advertisements.is_empty(), - "the resolver found no advertisement for {procedure}" - ); - - provider_session - .close( - "normal", - Some("direct-dial reuse provider done"), - &provider_identity, - ) - .await; - resolver - .close( - "normal", - Some("direct-dial reuse resolver done"), - &caller_identity, - ) - .await; -} - -/// Proves `direct_dial::keep_advertised_direct` genuinely re-publishes on -/// each tick (not a no-op) and stops cleanly once told to. -#[tokio::test] -#[ignore = "requires network access to a live macula-station"] -async fn keep_advertised_direct_republishes_against_the_real_fleet() { - // Two independent identities on purpose: `publisher_identity` is owned - // by the spawned loop task; `reader_identity` is only ever used by this - // test's own verifying reads. `_dht.find_record` doesn't care who's - // asking, so there's no need to share one identity across an await - // boundary that would otherwise fight the loop task for ownership. - let publisher_identity = KeyPair::generate_with_default_puzzle(); - let reader_identity = KeyPair::generate_with_default_puzzle(); - - let reader_session = - connection::connect(STATION_HOST, STATION_PORT, Trust::WebPki, &reader_identity) - .await - .expect("reader handshake should succeed"); - let loop_session = connection::connect( - STATION_HOST, - STATION_PORT, - Trust::WebPki, - &publisher_identity, - ) - .await - .expect("loop session handshake should succeed"); - - let realm: [u8; 32] = rand::random(); - let procedure = format!( - "macula_rust.test_keep_advertised.{}", - hex::encode(rand::random::<[u8; 8]>()) - ); - let uri = macula_rust::dht::discovery_uri(realm, &procedure); - let key = macula_rust::dht::procedure_key(&uri); - - let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>(); - let loop_procedure = procedure.clone(); - let loop_task = tokio::spawn(async move { - macula_rust::direct_dial::keep_advertised_direct( - &loop_session, - &publisher_identity, - realm, - &loop_procedure, - std::time::Duration::from_secs(120), - std::time::Duration::from_millis(500), - async move { - let _ = stop_rx.await; - }, - |e| eprintln!("keep_advertised_direct tick failed (non-fatal): {e}"), - ) - .await; - (loop_session, publisher_identity) - }); - - // Give the first (immediate) tick time to land, then read it back. - tokio::time::sleep(std::time::Duration::from_millis(300)).await; - let first = macula_rust::dht::find_record(&reader_session, &reader_identity, key) - .await - .expect("first tick should already be visible"); - - // Wait past a second tick and confirm the record genuinely changed -- - // not a stale read of the same one. - tokio::time::sleep(std::time::Duration::from_millis(700)).await; - let second = macula_rust::dht::find_record(&reader_session, &reader_identity, key) - .await - .expect("second tick should be visible"); - assert!( - second.created_at > first.created_at, - "expected the second tick's created_at ({}) to be strictly after the first's ({})", - second.created_at, - first.created_at - ); - println!( - "OBSERVED: created_at advanced from {} to {} across two KeepAdvertisedDirect ticks", - first.created_at, second.created_at - ); - - stop_tx - .send(()) - .expect("loop task should still be listening for stop"); - let (loop_session, publisher_identity) = - tokio::time::timeout(std::time::Duration::from_secs(5), loop_task) - .await - .expect("keep_advertised_direct should return promptly after stop") - .expect("loop task should not panic"); - println!("OBSERVED: keep_advertised_direct returned promptly after stop"); - - reader_session - .close( - "normal", - Some("keep_advertised_direct reader test done"), - &reader_identity, - ) - .await; - loop_session - .close( - "normal", - Some("keep_advertised_direct loop session done"), - &publisher_identity, - ) - .await; -} diff --git a/tests/pool.rs b/tests/pool.rs new file mode 100644 index 0000000..785ae77 --- /dev/null +++ b/tests/pool.rs @@ -0,0 +1,538 @@ +//! A node's pool of station links, against macula-go's in-process stations +//! (tests/common/lab.rs): one link per pinned seed, redialed when it drops +//! and given back its subscriptions and served procedures; calls and streams +//! that reach a provider at its own station, resolved from the DHT and +//! trusted only under the pinned realm key; and records through the pool. + +mod common; + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use common::lab::{Lab, LabRealm, LabStation}; +use macula_rust::cbor::Value; +use macula_rust::frame::StreamMode; +use macula_rust::node_key::{NodeKey, PUZZLE_DIFFICULTY}; +use macula_rust::pool::{Call, Offer, Opts, Pool, PoolError, Seed, StreamCall}; +use macula_rust::profile::Profile; +use macula_rust::record::{ + self, new_node_record, new_procedure_advertisement, new_station_endpoint, +}; +use macula_rust::record::{ + NodeRecordOptions, ProcedureAdvertisementOptions, RecordType, StationEndpointOptions, +}; +use macula_rust::station_link::{handler, stream_handler, LinkError, Publication, StreamEvent}; + +const ORG: &str = "mcl-echo"; +const PROCEDURE: &str = "mcl-echo/echo"; +const TOPIC: &str = "mcl-news/wire/news_item_reported_v1"; + +fn key(profile: Profile) -> Arc { + Arc::new(NodeKey::generate_identity(profile, PUZZLE_DIFFICULTY).unwrap()) +} + +fn id(key: &NodeKey) -> [u8; 32] { + key.node_id().unwrap() +} + +/// A pool as `key` on `seeds`, trusting `realm` (none when `None`). +async fn connect(key: &Arc, realm: Option<&LabRealm>, seeds: &[&LabStation]) -> Pool { + let mut opts = Opts::new(key.clone()); + opts.realm_trust = realm + .map(|r| HashMap::from([(r.id, r.key.clone())])) + .unwrap_or_default(); + opts.respawn_delay = Duration::from_millis(100); + opts.connect_timeout = Duration::from_secs(15); + Pool::connect(seeds.iter().map(|s| s.seed()).collect(), opts) + .await + .unwrap() +} + +fn echo() -> Offer { + Offer::unary( + [0; 32], + PROCEDURE, + handler(|r| async move { Ok(r.payload) }), + ) +} + +fn offer_in(realm: &LabRealm, o: Offer) -> Offer { + Offer { + realm: realm.id, + ..o + } +} + +async fn within(limit: Duration, what: &str, mut ok: impl FnMut() -> bool) { + let deadline = Instant::now() + limit; + while Instant::now() < deadline { + if ok() { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("not within {limit:?}: {what}"); +} + +async fn eventually(what: &str, ok: impl FnMut() -> bool) { + within(Duration::from_secs(10), what, ok).await; +} + +fn call(realm: [u8; 32], procedure: &str, payload: Value) -> Call { + Call { + realm, + procedure: procedure.to_string(), + payload, + ..Call::default() + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn connect_refuses_what_cannot_be_trusted() { + let lab = Lab::start(Profile::PqPure); + let s = lab.station("refusals"); + let key = key(Profile::PqPure); + let unpinned = Seed { + node_id: [0; 32], + ..s.seed() + }; + let refused = Pool::connect(vec![unpinned], Opts::new(key.clone())).await; + assert!( + matches!(refused, Err(PoolError::SeedNotPinned(_))), + "{refused:?}" + ); + + let mut bad = Opts::new(key.clone()); + bad.realm_trust = HashMap::from([([1; 32], b"not a key".to_vec())]); + let refused = Pool::connect(vec![s.seed()], bad).await; + assert!( + matches!(refused, Err(PoolError::RealmTrustInvalid(_))), + "{refused:?}" + ); + + let mut few = Opts::new(key.clone()); + few.max_seeds = 2; + let refused = Pool::connect(vec![s.seed(), s.seed(), s.seed()], few).await; + assert!( + matches!(refused, Err(PoolError::TooManySeeds { .. })), + "{refused:?}" + ); + + let refused = Pool::connect(vec![], Opts::new(key.clone())).await; + assert!(matches!(refused, Err(PoolError::NoSeeds)), "{refused:?}"); + + let mut too_wide = Opts::new(key); + too_wide.max_direct_links = 65; + let refused = Pool::connect(vec![s.seed()], too_wide).await; + assert!( + matches!(refused, Err(PoolError::InvalidOpts(_))), + "{refused:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_pool_links_every_seed_and_redials_a_dropped_one() { + let lab = Lab::start(Profile::PqPure); + let (a, b) = (lab.station("seed a"), lab.station("seed b")); + let k = key(Profile::PqPure); + let p = connect(&k, None, &[&a, &b]).await; + let me = p.node_id(); + eventually("both links up", || { + lab.connected(&a, &me) && lab.connected(&b, &me) + }) + .await; + lab.drop_node(&a, &me); + eventually("the dropped link redialed", || lab.connected(&a, &me)).await; + eventually("both links up in the status", || { + p.status().iter().filter(|l| l.up).count() == 2 + }) + .await; + p.close().await; + eventually("closed at both stations", || { + !lab.connected(&a, &me) && !lab.connected(&b, &me) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_subscription_hears_once_and_survives_a_redial() { + let lab = Lab::start(Profile::PqPure); + let (a, b) = (lab.station("pubsub a"), lab.station("pubsub b")); + let (lk, pk) = (key(Profile::PqPure), key(Profile::PqPure)); + let listener = connect(&lk, None, &[&a, &b]).await; + let publisher = connect(&pk, None, &[&a, &b]).await; + let realm = [7; 32]; + let mut sub = listener.subscribe(&realm, TOPIC).await.unwrap(); + let me = listener.node_id(); + eventually("subscribed at both stations", || { + lab.subscribed(&a, &me, &realm, TOPIC) && lab.subscribed(&b, &me, &realm, TOPIC) + }) + .await; + + async fn heard( + publisher: &Pool, + sub: &mut macula_rust::pool::Subscription, + realm: [u8; 32], + text: &str, + ) -> usize { + publisher + .publish(Publication { + realm, + topic: TOPIC.to_string(), + payload: Value::text(text), + ttl_ms: None, + }) + .await + .unwrap(); + let mut n = 0; + let until = tokio::time::Instant::now() + Duration::from_secs(1); + while let Ok(Some(event)) = tokio::time::timeout_at(until, sub.recv()).await { + if event.payload == Value::text(text) { + n += 1; + } + } + n + } + + assert_eq!(heard(&publisher, &mut sub, realm, "first").await, 1); + lab.drop_node(&a, &me); + lab.drop_node(&b, &me); + eventually("the listener resubscribed at both stations", || { + lab.subscribed(&a, &me, &realm, TOPIC) && lab.subscribed(&b, &me, &realm, TOPIC) + }) + .await; + assert_eq!(heard(&publisher, &mut sub, realm, "after").await, 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_call_dials_the_provider_s_station() { + for profile in [Profile::PqPure, Profile::PqHybrid] { + let lab = Lab::start(profile); + let (serving, callers) = (lab.station("serving"), lab.station("callers")); + lab.share(&[&serving, &callers]); + let realm = lab.realm("direct", ORG); + let pk = key(profile); + lab.admit(&realm, &serving, &[id(&pk)]); + let provider = connect(&pk, Some(&realm), &[&serving]).await; + provider.serve(offer_in(&realm, echo())).await.unwrap(); + + let ck = key(profile); + let caller = connect(&ck, Some(&realm), &[&callers]).await; + let answered = caller + .call(call(realm.id, PROCEDURE, Value::text("hello"))) + .await + .unwrap(); + assert_eq!(answered, Value::text("hello")); + assert!( + lab.connected(&serving, &caller.node_id()), + "the caller dialed the serving station" + ); + let providers = caller.providers(&realm.id, PROCEDURE).await.unwrap(); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].node, id(&pk)); + assert_eq!(providers[0].station, serving.node_id); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_call_trusts_only_the_pinned_realm() { + let lab = Lab::start(Profile::PqPure); + let s = lab.station("trust"); + let realm = lab.realm("trusted", ORG); + let impostor = lab.impostor(&realm); + let pk = key(Profile::PqPure); + lab.admit(&impostor, &s, &[id(&pk)]); + let provider = connect(&pk, Some(&impostor), &[&s]).await; + provider.serve(offer_in(&impostor, echo())).await.unwrap(); + + let caller = connect(&key(Profile::PqPure), Some(&realm), &[&s]).await; + let mut c = call(realm.id, PROCEDURE, Value::Map(vec![])); + c.timeout = Duration::from_secs(2); + let untrusted = caller.call(c.clone()).await; + assert!( + matches!(untrusted, Err(PoolError::NoProvider(_))), + "{untrusted:?}" + ); + let unpinned = caller + .call(Call { + realm: [9; 32], + ..c + }) + .await; + assert!( + matches!(unpinned, Err(PoolError::NoRealmKey)), + "{unpinned:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_served_procedure_survives_a_redial_until_stopped() { + let lab = Lab::start(Profile::PqPure); + let s = lab.station("replay"); + let realm = lab.realm("replay", ORG); + let pk = key(Profile::PqPure); + lab.admit(&realm, &s, &[id(&pk)]); + let provider = connect(&pk, Some(&realm), &[&s]).await; + let served = provider.serve(offer_in(&realm, echo())).await.unwrap(); + eventually("advertised", || lab.advertised(&s, &realm.id, PROCEDURE)).await; + lab.drop_node(&s, &provider.node_id()); + eventually("advertised again after the redial", || { + lab.connected(&s, &provider.node_id()) && lab.advertised(&s, &realm.id, PROCEDURE) + }) + .await; + served.stop().await.unwrap(); + eventually("withdrawn", || !lab.advertised(&s, &realm.id, PROCEDURE)).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_call_tries_the_next_candidate() { + let lab = Lab::start(Profile::PqPure); + let (gone, live) = (lab.station("gone"), lab.station("live")); + lab.share(&[&gone, &live]); + let realm = lab.realm("candidates", ORG); + let (lost_key, live_key) = (key(Profile::PqPure), key(Profile::PqPure)); + lab.admit(&realm, &gone, &[id(&lost_key), id(&live_key)]); + let live_provider = connect(&live_key, Some(&realm), &[&live]).await; + live_provider + .serve(Offer::unary( + realm.id, + PROCEDURE, + handler(|_| async { Err("no".to_string()) }), + )) + .await + .unwrap(); + // The lost provider advertises last, so its candidate is the freshest and + // is tried first. + tokio::time::sleep(Duration::from_millis(5)).await; + let lost = connect(&lost_key, Some(&realm), &[&gone]).await; + lost.serve(offer_in(&realm, echo())).await.unwrap(); + + let caller = connect(&key(Profile::PqPure), Some(&realm), &[&live]).await; + let providers = caller.providers(&realm.id, PROCEDURE).await.unwrap(); + assert_eq!(providers.len(), 2); + assert_eq!(providers[0].node, id(&lost_key), "freshest first"); + lab.stop(&gone); + let mut c = call(realm.id, PROCEDURE, Value::Map(vec![])); + c.timeout = Duration::from_secs(5); + match caller.call(c).await { + Err(PoolError::Link(LinkError::Provider { + code, responded_by, .. + })) => { + assert_eq!(code, "handler_error"); + assert_eq!(responded_by, id(&live_key)); + } + other => panic!("{other:?}"), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_station_endpoint_must_be_the_station_s_own() { + let lab = Lab::start(Profile::PqPure); + let (liar, victim) = (lab.station("liar"), lab.station("victim")); + let p = connect(&key(Profile::PqPure), None, &[&liar]).await; + // An endpoint record for the liar's address, signed by a key that is not + // the victim's, stored in the victim's slot. + let forger = key(Profile::PqPure); + let forged = new_station_endpoint( + liar.port, + &StationEndpointOptions { + host_advertised: vec![liar.host.clone()], + ..StationEndpointOptions::default() + }, + ) + .unwrap(); + let wire = record::encode(&record::sign(&forged, &forger).unwrap()).unwrap(); + lab.forge(&liar, &record::station_endpoint_key(&victim.node_id), &wire); + let refused = p.station_target(&victim.node_id).await; + assert!( + matches!(refused, Err(PoolError::NoStationEndpoint(_))), + "{refused:?}" + ); + let own = p.station_target(&liar.node_id).await.unwrap(); + assert_eq!(own.expected_node_id, liar.node_id); + assert_eq!( + (own.host.as_str(), own.port), + (liar.host.as_str(), liar.port) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_unreached_direct_station_is_not_kept() { + let lab = Lab::start(Profile::PqPure); + let (s, gone) = (lab.station("keeps"), lab.station("never reached")); + lab.share(&[&s, &gone]); + lab.stop(&gone); + let p = connect(&key(Profile::PqPure), None, &[&s]).await; + let reached = tokio::time::timeout(Duration::from_secs(10), p.link_to(&gone.node_id)).await; + assert!( + matches!(reached, Ok(Err(_))), + "a stopped station was linked or never refused" + ); + assert!( + p.status().iter().all(|l| l.station != gone.node_id), + "the unreached station is still held: {:?}", + p.status() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn unsubscribe_ends_the_subscription_everywhere() { + let lab = Lab::start(Profile::PqPure); + let (a, b) = (lab.station("unsubscribe a"), lab.station("unsubscribe b")); + let p = connect(&key(Profile::PqPure), None, &[&a, &b]).await; + let me = p.node_id(); + eventually("both links up", || { + lab.connected(&a, &me) && lab.connected(&b, &me) + }) + .await; + let realm = [3; 32]; + let mut sub = p.subscribe(&realm, TOPIC).await.unwrap(); + eventually("subscribed at both stations", || { + lab.subscribed(&a, &me, &realm, TOPIC) && lab.subscribed(&b, &me, &realm, TOPIC) + }) + .await; + sub.unsubscribe().await.unwrap(); + assert_eq!(sub.recv().await, None, "the subscription's events end"); + eventually("unsubscribed at both stations", || { + !lab.subscribed(&a, &me, &realm, TOPIC) && !lab.subscribed(&b, &me, &realm, TOPIC) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_record_put_through_the_pool_is_found_by_type() { + let lab = Lab::start(Profile::PqPure); + let s = lab.station("records"); + let k = key(Profile::PqPure); + let p = connect(&k, None, &[&s]).await; + let unsigned = new_node_record( + &p.node_id(), + &[], + 0, + &NodeRecordOptions { + display_name: "recorder".into(), + ..NodeRecordOptions::default() + }, + ) + .unwrap(); + let wire = record::encode(&record::sign(&unsigned, &k).unwrap()).unwrap(); + p.put_record(&wire).await.unwrap(); + let (found, dropped) = p + .find_records_by_type(RecordType::NODE_RECORD) + .await + .unwrap(); + assert_eq!(dropped, 0); + assert_eq!(found.len(), 1); + assert_eq!( + found[0].record().signed.as_ref().unwrap().key_id, + p.node_id() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_stream_dials_the_provider_s_station_and_is_released_promptly() { + let lab = Lab::start(Profile::PqPure); + let (serving, callers) = (lab.station("stream serving"), lab.station("stream callers")); + lab.share(&[&serving, &callers]); + let realm = lab.realm("streams", ORG); + let pk = key(Profile::PqPure); + lab.admit(&realm, &serving, &[id(&pk)]); + let provider = connect(&pk, Some(&realm), &[&serving]).await; + provider + .serve(Offer::stream( + realm.id, + PROCEDURE, + StreamMode::ServerStream, + stream_handler(|s| async move { + for chunk in ["a", "b"] { + s.send(chunk.as_bytes()).await.map_err(|e| e.to_string())?; + } + s.close().await.map_err(|e| e.to_string()) + }), + )) + .await + .unwrap(); + let caller = connect(&key(Profile::PqPure), Some(&realm), &[&callers]).await; + let stream = caller + .open_stream(StreamCall { + realm: realm.id, + procedure: PROCEDURE.to_string(), + mode: StreamMode::ServerStream, + ..StreamCall::default() + }) + .await + .unwrap(); + let mut got = Vec::new(); + loop { + match tokio::time::timeout(Duration::from_secs(5), stream.recv()) + .await + .unwrap() + .unwrap() + { + StreamEvent::Data { + body: Value::Bytes(b), + .. + } => got.push(b), + StreamEvent::End { .. } => break, + other => panic!("{other:?}"), + } + } + assert_eq!(got, vec![b"a".to_vec(), b"b".to_vec()]); + // macula-io/macula-rust#3: a stream ended on both sides is released at + // once, not after a timeout, without its handle being dropped. + within( + Duration::from_secs(2), + "every relayed stream released", + || lab.relayed(&serving) == 0 && lab.relayed(&callers) == 0, + ) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_procedure_in_the_node_s_own_namespace_needs_no_realm_key() { + let lab = Lab::start(Profile::PqPure); + let (serving, callers) = (lab.station("own serving"), lab.station("own callers")); + lab.share(&[&serving, &callers]); + let realm = [0x42; 32]; + let pk = key(Profile::PqPure); + let provider = connect(&pk, None, &[&serving]).await; + let ring = record::own_procedure(&provider.node_id(), "ring"); + provider + .serve(Offer::unary( + realm, + &ring, + handler(|r| async move { Ok(r.payload) }), + )) + .await + .unwrap(); + + // Another node's advertisement for the provider's namespace. + let mallory = key(Profile::PqPure); + let forged = new_procedure_advertisement( + &id(&mallory), + &realm, + &ring, + &callers.node_id, + &ProcedureAdvertisementOptions::default(), + ) + .unwrap(); + lab.put( + &callers, + &record::encode(&record::sign(&forged, &mallory).unwrap()).unwrap(), + ); + + let caller = connect(&key(Profile::PqPure), None, &[&callers]).await; + let answered = caller + .call(call(realm, &ring, Value::text("ring ring"))) + .await + .unwrap(); + assert_eq!(answered, Value::text("ring ring")); + let providers = caller.providers(&realm, &ring).await.unwrap(); + assert_eq!(providers.len(), 1, "the provider alone: {providers:?}"); + assert_eq!(providers[0].node, provider.node_id()); + let org = caller + .call(call(realm, PROCEDURE, Value::Map(vec![]))) + .await; + assert!(matches!(org, Err(PoolError::NoRealmKey)), "{org:?}"); +} diff --git a/tests/record.rs b/tests/record.rs new file mode 100644 index 0000000..a7e469c --- /dev/null +++ b/tests/record.rs @@ -0,0 +1,477 @@ +//! macula 12's DHT records: signed under MACULA-PQ-RECORD-V1, verified in the +//! design's order, stored under their storage keys, and a procedure's provider +//! authorization (D25): an org directory and a procedure delegation, or a +//! node's own namespace, held to the verdicts macula reaches on the shared +//! fixtures (tests/vectors/record/own_namespace). + +use macula_rust::cbor::{self, Value}; +use macula_rust::node_key::{key_id_of, NodeKey, Purpose}; +use macula_rust::profile::Profile; +use macula_rust::record::{ + encode, envelope, new_content_announcement, new_node_record, new_procedure_advertisement, + new_station_endpoint, new_tombstone, own_namespace, own_procedure, procedure_key, + read_node_record, read_procedure_advertisement, read_station_endpoint, read_tombstone, sign, + station_endpoint_key, storage_key, verify, verify_authorization, Authorization, + ContentAnnouncementOptions, NodeRecordOptions, ProcedureAdvertisementOptions, Reason, + RecordError, RecordType, StationEndpointOptions, TombstoneOptions, Trust, Verified, +}; +use macula_rust::signed_object::sign_object; + +const P: Profile = Profile::PqPure; +const MINUTE: u64 = 60_000; +const HOUR: u64 = 60 * MINUTE; + +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64 +} + +fn identity() -> NodeKey { + NodeKey::generate(Purpose::Identity, P).unwrap() +} + +fn verified(wire: &[u8]) -> Verified { + verify(wire, P, now() as i64).unwrap() +} + +#[test] +fn a_node_record_is_signed_by_its_node_and_stored_under_its_node_id() { + let key = identity(); + let node_id = key.node_id().unwrap(); + let opts = NodeRecordOptions { + display_name: "a node".into(), + lat: Some(50.8), + lng: Some(4.95), + peers: vec![[2; 32], [1; 32], [2; 32]], + ..NodeRecordOptions::default() + }; + let record = new_node_record(&node_id, &[[3; 32]], 5, &opts).unwrap(); + let signed = sign(&record, &key).unwrap(); + let v = verified(&encode(&signed).unwrap()); + let node = read_node_record(v.record()).unwrap(); + assert_eq!(node.node_id, node_id); + assert_eq!(node.station_id, node_id); + assert_eq!(node.realms, vec![[3; 32]]); + assert_eq!(node.capabilities, 5); + assert_eq!(node.display_name, "a node"); + assert_eq!(node.lat, Some(50.8)); + assert_eq!(node.peers, vec![[1; 32], [2; 32]]); + assert_eq!(storage_key(v.record()).unwrap(), node_id); + + // A connect key does not sign a node record; another node's is refused. + let connect = NodeKey::generate(Purpose::Connect, P).unwrap(); + assert!(matches!( + sign(&record, &connect), + Err(RecordError::KeyPurposeMismatch) + )); + let other = new_node_record(&[9; 32], &[], 0, &NodeRecordOptions::default()).unwrap(); + assert!(matches!( + sign(&other, &key), + Err(RecordError::KeyIdMismatch) + )); + let bad_geo = NodeRecordOptions { + lat: Some(91.0), + ..NodeRecordOptions::default() + }; + assert!(matches!( + new_node_record(&node_id, &[], 0, &bad_geo), + Err(RecordError::InvalidCoordinate(_)) + )); +} + +#[test] +fn a_procedure_advertisement_lives_five_minutes_under_its_procedure_key() { + let key = identity(); + let node_id = key.node_id().unwrap(); + let ad = new_procedure_advertisement( + &node_id, + &[3; 32], + "acme/echo", + &[4; 32], + &ProcedureAdvertisementOptions::default(), + ) + .unwrap(); + assert_eq!(ad.expires_at - ad.created_at, 5 * MINUTE); + let v = verified(&encode(&sign(&ad, &key).unwrap()).unwrap()); + let read = read_procedure_advertisement(v.record()).unwrap(); + assert_eq!(read.procedure, "acme/echo"); + assert_eq!(read.serving_station, [4; 32]); + assert_eq!(read.authorization, Authorization::None); + assert_eq!( + storage_key(v.record()).unwrap(), + procedure_key(&[3; 32], "acme/echo") + ); + let long = new_procedure_advertisement( + &node_id, + &[3; 32], + "acme/echo", + &[4; 32], + &ProcedureAdvertisementOptions { + ttl_ms: 5 * MINUTE + 1, + ..Default::default() + }, + ) + .unwrap(); + assert!(matches!( + sign(&long, &key), + Err(RecordError::LifetimeTooLong) + )); +} + +#[test] +fn a_station_endpoint_is_stored_under_its_station() { + let key = identity(); + let endpoint = new_station_endpoint( + 4433, + &StationEndpointOptions { + host_advertised: vec!["2001:db8::1".into()], + alpn: "macula".into(), + ttl_ms: 0, + }, + ) + .unwrap(); + let v = verified(&encode(&sign(&endpoint, &key).unwrap()).unwrap()); + let read = read_station_endpoint(v.record()).unwrap(); + assert_eq!(read.quic_port, 4433); + assert_eq!(read.host_advertised, vec!["2001:db8::1".to_string()]); + assert_eq!( + storage_key(v.record()).unwrap(), + station_endpoint_key(&key.node_id().unwrap()) + ); + assert!(matches!( + new_station_endpoint(0, &StationEndpointOptions::default()), + Err(RecordError::InvalidPort) + )); +} + +#[test] +fn a_record_is_refused_for_its_time_its_size_and_any_change() { + let key = identity(); + let node_id = key.node_id().unwrap(); + let signed = sign( + &new_node_record(&node_id, &[], 0, &NodeRecordOptions::default()).unwrap(), + &key, + ) + .unwrap(); + let wire = encode(&signed).unwrap(); + let created = signed.created_at as i64; + let expires = signed.expires_at as i64; + let tolerance = 5 * MINUTE as i64; + assert!(verify(&wire, P, created - tolerance).is_ok()); + assert!(matches!( + verify(&wire, P, created - tolerance - 1), + Err(RecordError::NotYetValid) + )); + assert!(verify(&wire, P, expires + tolerance).is_ok()); + assert!(matches!( + verify(&wire, P, expires + tolerance + 1), + Err(RecordError::Expired) + )); + assert!(matches!( + verify(&vec![0u8; 256 * 1024 + 1], P, created), + Err(RecordError::TooLarge) + )); + let mut altered = wire.clone(); + let last = altered.len() - 20; + altered[last] ^= 1; + assert!(matches!( + verify(&altered, P, created), + Err(RecordError::SignatureInvalid) + )); + assert!(matches!( + verify(&wire, Profile::PqHybrid, created), + Err(RecordError::Malformed(_)) + )); +} + +#[test] +fn a_tombstone_withdraws_a_record_from_its_slot() { + let key = identity(); + let node_id = key.node_id().unwrap(); + let ad = sign( + &new_procedure_advertisement( + &node_id, + &[3; 32], + "acme/echo", + &[4; 32], + &Default::default(), + ) + .unwrap(), + &key, + ) + .unwrap(); + let tombstone = sign( + &new_tombstone(&ad, Reason::Shutdown, &TombstoneOptions::default()).unwrap(), + &key, + ) + .unwrap(); + let v = verified(&encode(&tombstone).unwrap()); + let read = read_tombstone(v.record()).unwrap(); + assert_eq!(read.withdrawn_type, RecordType::PROCEDURE_ADVERTISEMENT); + assert_eq!(read.withdrawn_version, ad.version); + assert_eq!(read.procedure, "acme/echo"); + assert_eq!(storage_key(v.record()).unwrap(), storage_key(&ad).unwrap()); + assert!(matches!( + new_tombstone(&tombstone, Reason::Shutdown, &TombstoneOptions::default()), + Err(RecordError::TombstoneOfATombstone) + )); +} + +#[test] +fn a_content_announcement_needs_a_content_id_and_a_procedure() { + let key = identity(); + let node_id = key.node_id().unwrap(); + let mut mcid = vec![2u8, 0x55]; + mcid.extend([7u8; 48]); + let opts = ContentAnnouncementOptions { + realm_id: [3; 32], + serving_station: [4; 32], + procedure: own_procedure(&node_id, "content_v1"), + ..Default::default() + }; + let announcement = new_content_announcement(&node_id, &mcid, &opts).unwrap(); + verified(&encode(&sign(&announcement, &key).unwrap()).unwrap()); + assert!(matches!( + new_content_announcement(&node_id, &mcid[..49], &opts), + Err(RecordError::NotAContentId) + )); + let no_procedure = ContentAnnouncementOptions { + procedure: String::new(), + ..opts + }; + assert!(matches!( + new_content_announcement(&node_id, &mcid, &no_procedure), + Err(RecordError::Malformed(_)) + )); +} + +#[test] +fn a_domain_envelope_is_for_a_domain_type_with_a_subject_that_is_not_empty() { + let key = identity(); + let record = envelope(0x40, Value::Map(vec![]), Some(b"a subject".to_vec()), 0).unwrap(); + let v = verified(&encode(&sign(&record, &key).unwrap()).unwrap()); + assert_eq!(v.record().subject.as_deref(), Some(&b"a subject"[..])); + assert!(matches!( + envelope(0x06, Value::Map(vec![]), None, 0), + Err(RecordError::NotADomainType) + )); + assert!(matches!( + envelope(0x40, Value::Map(vec![]), Some(Vec::new()), 0), + Err(RecordError::InvalidSubject) + )); +} + +/// The shared fixtures: advertisements in a node's own namespace, and the +/// verdicts macula reaches on each. +#[test] +fn own_namespace_fixtures_reach_macula_s_verdicts() { + let text = std::fs::read_to_string("tests/vectors/record/own_namespace/verdicts.json").unwrap(); + let verdicts: serde_json::Value = serde_json::from_str(&text).unwrap(); + let verdicts = verdicts.as_array().unwrap(); + assert_eq!(verdicts.len(), 14); + let reason = |r: Result<(), RecordError>| match r { + Ok(()) => "ok".to_string(), + Err(RecordError::NotOwnNamespace) => "not_own_namespace".into(), + Err(RecordError::AuthorizationNotAllowed) => "authorization_not_allowed".into(), + Err(RecordError::Malformed(_)) => "malformed".into(), + Err(RecordError::NoAuthorization) => "no_authorization".into(), + Err(other) => format!("{other:?}"), + }; + for v in verdicts { + let profile = Profile::parse(v["profile"].as_str().unwrap()).unwrap(); + let wire = std::fs::read(format!( + "tests/vectors/record/own_namespace/{}", + v["file"].as_str().unwrap() + )) + .unwrap(); + let now_ms = v["now_ms"].as_i64().unwrap(); + let record = verify(&wire, profile, now_ms).unwrap(); + assert_eq!( + read_procedure_advertisement(record.record()) + .unwrap() + .procedure, + v["procedure"].as_str().unwrap() + ); + let file = v["file"].as_str().unwrap(); + assert_eq!( + reason(own_namespace(&record)), + v["own_namespace"].as_str().unwrap(), + "{file} own_namespace" + ); + let trust = Trust { + profile, + realm_key: None, + }; + assert_eq!( + reason(verify_authorization(&record, &trust, now_ms)), + v["verify_authorization"].as_str().unwrap(), + "{file} verify_authorization" + ); + } +} + +/// A record signed by hand, as the realm and org sign theirs: a signed object +/// under the record label with the record's fields. +fn hand_signed( + key: &NodeKey, + record_type: u8, + payload: Value, + created: u64, + lifetime: u64, +) -> Vec { + let mut version = [0u8; 16]; + version[0] = record_type; + version[15] = (created % 251) as u8; + let fields = vec![ + (Value::text("type"), Value::Int(i128::from(record_type))), + (Value::text("version"), Value::Bytes(version.to_vec())), + (Value::text("created_at"), Value::Int(i128::from(created))), + ( + Value::text("expires_at"), + Value::Int(i128::from(created + lifetime)), + ), + (Value::text("payload"), payload), + ]; + cbor::encode( + &sign_object("MACULA-PQ-RECORD-V1", &fields, key) + .unwrap() + .to_value(), + ) + .unwrap() +} + +struct Chain { + realm: NodeKey, + node: NodeKey, + advertisement: Verified, +} + +/// A realm that names the org acme, held by an org key that delegates to a +/// node, which advertises acme/echo with that authorization: `org_name` is the +/// org the directory names, `delegate_to_other` delegates to another node, +/// and the directory lives `directory_ms`. +fn chain(org_name: &str, delegate_to_other: bool, directory_ms: u64) -> Chain { + let (realm, org, node) = (identity(), identity(), identity()); + let t = now(); + let node_id = node.node_id().unwrap(); + let realm_id = [3u8; 32]; + let org_key_id = key_id_of(&org.public_key(), P); + let directory = hand_signed( + &realm, + RecordType::ORG_DIRECTORY.0, + Value::Map(vec![ + (Value::text("realm_id"), Value::Bytes(realm_id.to_vec())), + (Value::text("org_name"), Value::text(org_name)), + (Value::text("org_key"), Value::Bytes(org_key_id.to_vec())), + ]), + t, + directory_ms, + ); + let advertiser = if delegate_to_other { + [8u8; 32] + } else { + node_id + }; + let delegation = hand_signed( + &org, + RecordType::PROCEDURE_DELEGATION.0, + Value::Map(vec![ + (Value::text("org_key"), Value::Bytes(org_key_id.to_vec())), + (Value::text("advertiser"), Value::Bytes(advertiser.to_vec())), + ]), + t, + 6 * HOUR, + ); + let ad = new_procedure_advertisement( + &node_id, + &realm_id, + "acme/echo", + &[4; 32], + &ProcedureAdvertisementOptions { + authorization: Authorization::Delegation { + org_directory: directory, + procedure_delegation: delegation, + }, + ttl_ms: 0, + }, + ) + .unwrap(); + let advertisement = verified(&encode(&sign(&ad, &node).unwrap()).unwrap()); + Chain { + realm, + node, + advertisement, + } +} + +#[test] +fn an_org_procedure_is_authorized_by_the_realm_s_org_directory_and_the_org_s_delegation() { + let c = chain("acme", false, 6 * HOUR); + let trust = |key: Option>| Trust { + profile: P, + realm_key: key, + }; + let t = now() as i64; + assert!(verify_authorization(&c.advertisement, &trust(Some(c.realm.public_key())), t).is_ok()); + assert!(matches!( + verify_authorization(&c.advertisement, &trust(None), t), + Err(RecordError::NoRealmKey) + )); + assert!(matches!( + verify_authorization(&c.advertisement, &trust(Some(c.node.public_key())), t), + Err(RecordError::OrgDirectoryWrongRealm) + )); + + let other_org = chain("globex", false, 6 * HOUR); + assert!(matches!( + verify_authorization( + &other_org.advertisement, + &trust(Some(other_org.realm.public_key())), + t + ), + Err(RecordError::OrgDirectoryWrongOrg) + )); + let other_node = chain("acme", true, 6 * HOUR); + assert!(matches!( + verify_authorization( + &other_node.advertisement, + &trust(Some(other_node.realm.public_key())), + t + ), + Err(RecordError::DelegationMismatch) + )); + // A directory that lapses a minute from now, before the advertisement. + let short = chain("acme", false, MINUTE); + assert!(matches!( + verify_authorization( + &short.advertisement, + &trust(Some(short.realm.public_key())), + t + ), + Err(RecordError::AuthorizationOutlived) + )); +} + +#[test] +fn an_org_procedure_without_an_authorization_is_refused() { + let key = identity(); + let ad = new_procedure_advertisement( + &key.node_id().unwrap(), + &[3; 32], + "acme/echo", + &[4; 32], + &Default::default(), + ) + .unwrap(); + let v = verified(&encode(&sign(&ad, &key).unwrap()).unwrap()); + let trust = Trust { + profile: P, + realm_key: Some(identity().public_key()), + }; + assert!(matches!( + verify_authorization(&v, &trust, now() as i64), + Err(RecordError::NoAuthorization) + )); +} diff --git a/tests/statement_issuer.rs b/tests/statement_issuer.rs new file mode 100644 index 0000000..1f6e216 --- /dev/null +++ b/tests/statement_issuer.rs @@ -0,0 +1,107 @@ +//! The client's statement issuer (D22): a CONNECT key bound to the identity +//! key, a status statement for it reissued every 15 minutes and handed to its +//! subscribers, the CONNECT key rotated every 5 days, and material for a new +//! dial that is always in force. + +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::Arc; + +use macula_rust::binding::{verify_connect_binding, verify_status}; +use macula_rust::node_key::{NodeKey, Purpose}; +use macula_rust::profile::Profile; +use macula_rust::statement_issuer::{ + IssuerError, StatementIssuer, CONNECT_ROTATE_EVERY_MS, STATEMENT_EVERY_MS, +}; + +const P: Profile = Profile::PqPure; +const START: i64 = 1_789_000_000_000; + +fn issuer() -> (Arc, StatementIssuer, Vec) { + let clock = Arc::new(AtomicI64::new(START)); + let seen = clock.clone(); + let identity = Arc::new(NodeKey::generate(Purpose::Identity, P).unwrap()); + let carried = identity.public_key(); + let issuer = + StatementIssuer::new(identity, Box::new(move || seen.load(Ordering::SeqCst))).unwrap(); + (clock, issuer, carried) +} + +#[test] +fn connect_material_is_a_bound_connect_key_with_a_statement_in_force() { + let (_clock, issuer, identity) = issuer(); + let material = issuer.connect_material().unwrap(); + assert_eq!(material.key.purpose(), Purpose::Connect); + verify_connect_binding( + &material.binding, + &identity, + P, + &material.key.public_key(), + START, + ) + .unwrap(); + assert_eq!( + verify_status(&material.status, &material.binding, &identity, P, START).unwrap(), + START + 60 * 60 * 1000 + ); +} + +#[test] +fn each_tick_hands_subscribers_the_newest_statement() { + let (clock, issuer, identity) = issuer(); + let material = issuer.connect_material().unwrap(); + let mut statements = issuer.subscribe(&material.binding).unwrap(); + clock.store(START + STATEMENT_EVERY_MS, Ordering::SeqCst); + issuer.tick().unwrap(); + clock.store(START + 2 * STATEMENT_EVERY_MS, Ordering::SeqCst); + issuer.tick().unwrap(); + // The subscription holds the newest statement only. + let newest = statements.try_recv().expect("a statement was handed over"); + assert!(statements.try_recv().is_err()); + let expires = verify_status( + &newest, + &material.binding, + &identity, + P, + START + 2 * STATEMENT_EVERY_MS, + ) + .unwrap(); + assert_eq!(expires, START + 2 * STATEMENT_EVERY_MS + 60 * 60 * 1000); +} + +#[test] +fn the_connect_key_rotates_and_a_late_dial_still_gets_material_in_force() { + let (clock, issuer, identity) = issuer(); + let first = issuer.connect_material().unwrap(); + // No tick for 5 days: the next dial's material is rotated and in force. + let later = START + CONNECT_ROTATE_EVERY_MS; + clock.store(later, Ordering::SeqCst); + let second = issuer.connect_material().unwrap(); + assert_ne!(second.key.public_key(), first.key.public_key()); + verify_connect_binding( + &second.binding, + &identity, + P, + &second.key.public_key(), + later, + ) + .unwrap(); + verify_status(&second.status, &second.binding, &identity, P, later).unwrap(); + assert_eq!(issuer.rotation_failures(), 0); +} + +#[test] +fn a_binding_the_issuer_does_not_hold_cannot_be_subscribed_to() { + let (_clock, mine, _) = issuer(); + let (_clock2, other, _) = issuer(); + let foreign = other.connect_material().unwrap(); + assert!(matches!( + mine.subscribe(&foreign.binding), + Err(IssuerError::UnknownBinding) + )); +} + +#[test] +fn only_an_identity_key_issues() { + let connect = NodeKey::generate(Purpose::Connect, P).unwrap(); + assert!(StatementIssuer::new(Arc::new(connect), Box::new(|| START)).is_err()); +} diff --git a/tests/station_link.rs b/tests/station_link.rs new file mode 100644 index 0000000..d78b5c5 --- /dev/null +++ b/tests/station_link.rs @@ -0,0 +1,357 @@ +//! A link to one macula 12 station, against macula-go's in-process stations +//! (tests/common, the Go teststation): the v4 handshake over QUIC, DHT calls, +//! pubsub, serving under an org and in a node's own namespace, and streams, +//! each stream released. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::TestStations; +use macula_rust::cbor::Value; +use macula_rust::frame::{StreamEncoding, StreamMode, StreamRole}; +use macula_rust::handshake::HandshakeError; +use macula_rust::node_key::{NodeKey, PUZZLE_DIFFICULTY}; +use macula_rust::profile::Profile; +use macula_rust::record::{self, new_node_record, NodeRecordOptions, RecordType}; +use macula_rust::statement_issuer::StatementIssuer; +use macula_rust::station_link::{ + handler, stream_handler, Call, Config, Link, LinkError, Offer, Publication, StreamCall, + StreamEvent, +}; + +/// A link as a new node: its own identity key and issuer. +async fn node(env: &TestStations, station: usize) -> Link { + let identity = NodeKey::generate_identity(env.profile, PUZZLE_DIFFICULTY).unwrap(); + link_as(env, station, identity).await.unwrap() +} + +async fn link_as(env: &TestStations, station: usize, identity: NodeKey) -> Result { + let identity = Arc::new(identity); + let issuer = StatementIssuer::with_wall_clock(identity.clone()).unwrap(); + Link::dial(Config::new(env.target(station), identity, issuer)).await +} + +async fn eventually(what: &str, mut ok: impl FnMut() -> bool) { + for _ in 0..400 { + if ok() { + return; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + panic!("never: {what}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_node_links_to_a_station_it_pinned_in_either_profile() { + for profile in [Profile::PqPure, Profile::PqHybrid] { + let env = TestStations::start(profile); + let link = node(&env, 0).await; + assert_eq!(link.station_node_id(), env.stations[0].node_id); + link.close("done").await.unwrap(); + assert!(matches!(link.error(), Some(LinkError::Closed))); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_station_that_is_not_the_node_pinned_is_refused() { + let env = TestStations::start(Profile::PqPure); + let mut target = env.target(0); + target.expected_node_id = env.stations[1].node_id; + let identity = Arc::new(NodeKey::generate_identity(env.profile, PUZZLE_DIFFICULTY).unwrap()); + let issuer = StatementIssuer::with_wall_clock(identity.clone()).unwrap(); + let refused = Link::dial(Config::new(target, identity, issuer)).await; + assert!(matches!( + refused, + Err(LinkError::Handshake( + HandshakeError::PeerIdentityMismatch { .. } + )) + )); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_dht_holds_verified_records() { + let env = TestStations::start(Profile::PqPure); + let identity = NodeKey::generate_identity(env.profile, PUZZLE_DIFFICULTY).unwrap(); + let node_id = identity.node_id().unwrap(); + let signed = record::sign( + &new_node_record(&node_id, &[], 0, &NodeRecordOptions::default()).unwrap(), + &identity, + ) + .unwrap(); + let link = link_as(&env, 0, identity).await.unwrap(); + + let (endpoints, dropped) = link + .find_records_by_type(RecordType::STATION_ENDPOINT) + .await + .unwrap(); + assert_eq!(dropped, 0); + let mut signers: Vec<[u8; 32]> = endpoints + .iter() + .map(|r| r.record().signed.as_ref().unwrap().key_id) + .collect(); + signers.sort(); + let mut stations: Vec<[u8; 32]> = env.stations.iter().map(|s| s.node_id).collect(); + stations.sort(); + assert_eq!(signers, stations); + + assert!(matches!( + link.find_record(&[0x22; 32]).await, + Err(LinkError::RecordNotFound) + )); + link.put_record(&record::encode(&signed).unwrap()) + .await + .unwrap(); + let found = link.find_record(&node_id).await.unwrap(); + assert_eq!(found.record().version, signed.version); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_call_nobody_serves_is_answered_with_the_station_s_relay_error() { + let env = TestStations::start(Profile::PqPure); + let link = node(&env, 0).await; + let outcome = link + .call(Call { + realm: env.realm_id, + procedure: format!("{}/nothing", env.org), + target: [9; 32], + payload: Value::Null, + ..Call::default() + }) + .await; + match outcome { + Err(LinkError::Relay { code, reported_by }) => { + assert_eq!(code, "unknown_next_peer"); + assert_eq!(reported_by, env.stations[0].node_id); + } + other => panic!("{other:?}"), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_publication_is_heard_once_by_each_subscriber() { + let env = TestStations::start(Profile::PqHybrid); + let listener = node(&env, 0).await; + let publisher = node(&env, 0).await; + let topic = "mcl-rust/tests/greeting_sent_v1"; + let mut sub = listener.subscribe(&env.realm_id, topic).await.unwrap(); + tokio::time::sleep(Duration::from_millis(200)).await; + publisher + .publish(Publication { + realm: env.realm_id, + topic: topic.to_string(), + payload: Value::text("hi"), + ttl_ms: None, + }) + .await + .unwrap(); + let event = tokio::time::timeout(Duration::from_secs(5), sub.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(event.payload, Value::text("hi")); + assert_eq!(event.publisher, publisher.node_id()); + assert_eq!(event.topic, topic); + assert!(tokio::time::timeout(Duration::from_millis(300), sub.recv()) + .await + .is_err()); + sub.unsubscribe().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_procedure_in_a_node_s_own_namespace_is_served_and_called() { + let env = TestStations::start(Profile::PqPure); + let provider = node(&env, 0).await; + let caller = node(&env, 0).await; + let ring = record::own_procedure(&provider.node_id(), "ring"); + let served = provider + .serve(Offer::unary( + env.realm_id, + &ring, + handler(|r| async move { + if r.payload == Value::text("fail") { + return Err("refused by the handler".to_string()); + } + Ok(Value::Map(vec![( + Value::text("rung_by"), + Value::Bytes(r.caller.to_vec()), + )])) + }), + )) + .await + .unwrap(); + let call = |payload: Value| Call { + realm: env.realm_id, + procedure: ring.clone(), + target: provider.node_id(), + payload, + ..Call::default() + }; + let answered = caller.call(call(Value::Null)).await.unwrap(); + assert_eq!( + answered.get("rung_by"), + Some(&Value::Bytes(caller.node_id().to_vec())) + ); + match caller.call(call(Value::text("fail"))).await { + Err(LinkError::Provider { code, detail, .. }) => { + assert_eq!(code, "handler_error"); + assert_eq!(detail.as_deref(), Some("refused by the handler")); + } + other => panic!("{other:?}"), + } + // Withdrawn, the procedure is no longer routed to the provider. + served.stop().await.unwrap(); + assert!(matches!( + caller.call(call(Value::Null)).await, + Err(LinkError::Relay { .. }) + )); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_org_procedure_is_served_once_the_org_delegates_to_the_node() { + let env = TestStations::start(Profile::PqHybrid); + let identity = NodeKey::generate_identity(env.profile, PUZZLE_DIFFICULTY).unwrap(); + env.admit(&identity.node_id().unwrap()); + let provider = link_as(&env, 0, identity).await.unwrap(); + let caller = node(&env, 0).await; + let procedure = format!("{}/echo", env.org); + let mut offer = Offer::unary( + env.realm_id, + &procedure, + handler(|r| async move { Ok(r.payload) }), + ); + offer.realm_key = Some(env.realm_key.clone()); + let served = provider.serve(offer).await.unwrap(); + let answered = caller + .call(Call { + realm: env.realm_id, + procedure, + target: provider.node_id(), + payload: Value::text("hello"), + ..Call::default() + }) + .await + .unwrap(); + assert_eq!(answered, Value::text("hello")); + served.stop().await.unwrap(); + + // Without the realm key, an org procedure is not offered at all. + let bare = Offer::unary( + env.realm_id, + &format!("{}/other", env.org), + handler(|r| async move { Ok(r.payload) }), + ); + assert!(matches!( + provider.serve(bare).await, + Err(LinkError::InvalidOffer) + )); +} + +#[tokio::test(flavor = "multi_thread")] +async fn streams_deliver_their_frames_and_are_released() { + let env = TestStations::start(Profile::PqPure); + let provider = node(&env, 0).await; + let caller = node(&env, 0).await; + let watch = record::own_procedure(&provider.node_id(), "watch"); + let count = record::own_procedure(&provider.node_id(), "count"); + let _watch = provider + .serve(Offer::stream( + env.realm_id, + &watch, + StreamMode::ServerStream, + stream_handler(|stream| async move { + for chunk in ["one", "two", "three"] { + stream + .send(chunk.as_bytes()) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) + }), + )) + .await + .unwrap(); + let _count = provider + .serve(Offer::stream( + env.realm_id, + &count, + StreamMode::ClientStream, + stream_handler(|stream| async move { + let mut total = 0i128; + while let Ok(event) = stream.recv().await { + match event { + StreamEvent::Data { + body: Value::Bytes(b), + .. + } => total += b.len() as i128, + StreamEvent::End { .. } => break, + _ => {} + } + } + stream + .reply(Value::Int(total)) + .await + .map_err(|e| e.to_string()) + }), + )) + .await + .unwrap(); + + let open = |procedure: &str, mode| StreamCall { + realm: env.realm_id, + procedure: procedure.to_string(), + target: provider.node_id(), + mode, + payload: Value::Null, + ..StreamCall::default() + }; + let stream = caller + .open_stream(open(&watch, StreamMode::ServerStream)) + .await + .unwrap(); + let mut got = Vec::new(); + loop { + match stream.recv().await.unwrap() { + StreamEvent::Data { + body: Value::Bytes(b), + encoding: StreamEncoding::Raw, + } => got.push(b), + StreamEvent::End { role } => { + assert_eq!(role, StreamRole::Both); + break; + } + other => panic!("{other:?}"), + } + } + assert_eq!( + got, + vec![b"one".to_vec(), b"two".to_vec(), b"three".to_vec()] + ); + + let upload = caller + .open_stream(open(&count, StreamMode::ClientStream)) + .await + .unwrap(); + for chunk in ["ab", "cde", "f"] { + upload.send(chunk.as_bytes()).await.unwrap(); + } + upload.close_send().await.unwrap(); + assert_eq!( + upload.recv().await.unwrap(), + StreamEvent::Reply { + payload: Value::Int(6) + } + ); + + // A mode the provider does not serve is refused. + let wrong = caller + .open_stream(open(&watch, StreamMode::Bidi)) + .await + .unwrap(); + match wrong.recv().await { + Err(LinkError::Stream { code, .. }) => assert_eq!(code, "mode_mismatch"), + other => panic!("{other:?}"), + } + eventually("every stream released", || env.relayed() == 0).await; +} diff --git a/tests/teststation/go.mod b/tests/teststation/go.mod new file mode 100644 index 0000000..2f28aa6 --- /dev/null +++ b/tests/teststation/go.mod @@ -0,0 +1,13 @@ +module github.com/macula-io/macula-rust/tests/teststation + +go 1.27.0 + +require github.com/macula-io/macula-go v0.12.0 + +require ( + github.com/google/uuid v1.6.0 // indirect + github.com/quic-go/quic-go v0.62.0 // indirect + golang.org/x/crypto v0.56.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/tests/teststation/go.sum b/tests/teststation/go.sum new file mode 100644 index 0000000..6371f9d --- /dev/null +++ b/tests/teststation/go.sum @@ -0,0 +1,20 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/macula-io/macula-go v0.12.0 h1:zn3ppxBZSb1QZy+Qsfstwq2IPWHVNLg6wtEYhOMk3+Y= +github.com/macula-io/macula-go v0.12.0/go.mod h1:mRMXty8IIDeuUgaaU8bAVWde6g94REj7GMeDBVr3mMM= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/quic-go v0.62.0 h1:ZHDjCk5OacATwGvs8PWE97CTvX7AqZiVoW7++ZOXTf8= +github.com/quic-go/quic-go v0.62.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/tests/teststation/lab.go b/tests/teststation/lab.go new file mode 100644 index 0000000..6e0273a --- /dev/null +++ b/tests/teststation/lab.go @@ -0,0 +1,251 @@ +package main + +import ( + "bufio" + "encoding/hex" + "fmt" + "os" + "strconv" + "strings" + + "github.com/macula-io/macula-go/profile" + "github.com/macula-io/macula-go/teststation" +) + +// runLab starts stations and realms as commands ask, each named by its index +// in the order it was started, and answers each command with one line: +// +// start station +// stop stopped +// drop dropped +// connected yes | no +// advertised yes | no +// subscribed yes | no +// share ... shared +// put put +// forge forged +// relayed relayed +// realm realm +// impostor realm +// admit ... admitted +// +// An impostor of realm r has its realm_id and org and another realm key, as a +// node claiming the realm without its key would sign. +// +// A command it cannot follow is answered "error ". +func runLab(t *helperT, p profile.Profile) { + var stations []*teststation.Station + var realms []teststation.Realm + in := bufio.NewScanner(os.Stdin) + in.Buffer(make([]byte, 1<<20), 4<<20) + for in.Scan() { + fields := strings.Fields(in.Text()) + if len(fields) == 0 { + continue + } + station := func(i int) *teststation.Station { + n, err := strconv.Atoi(fields[i]) + if err != nil || n < 0 || n >= len(stations) { + return nil + } + return stations[n] + } + answer, err := lab(t, p, fields, station, &stations, &realms) + if err != nil { + fmt.Println("error", err) + continue + } + fmt.Println(answer) + } +} + +func lab(t *helperT, p profile.Profile, f []string, station func(int) *teststation.Station, + stations *[]*teststation.Station, realms *[]teststation.Realm) (string, error) { + need := func(n int) error { + if len(f) < n { + return fmt.Errorf("%s needs %d arguments", f[0], n-1) + } + return nil + } + pick := func(i int) (*teststation.Station, error) { + if err := need(i + 1); err != nil { + return nil, err + } + if s := station(i); s != nil { + return s, nil + } + return nil, fmt.Errorf("no station %s", f[i]) + } + switch f[0] { + case "start": + if err := need(2); err != nil { + return "", err + } + s := teststation.Start(t, p, strings.Join(f[1:], " ")) + *stations = append(*stations, s) + return fmt.Sprintf("station %d %s %d %s", len(*stations)-1, s.Host, s.Port, hex.EncodeToString(s.NodeID[:])), nil + case "stop": + s, err := pick(1) + if err != nil { + return "", err + } + s.Stop() + return "stopped", nil + case "drop", "connected": + s, err := pick(1) + if err != nil { + return "", err + } + node, err := id32(f, 2) + if err != nil { + return "", err + } + if f[0] == "drop" { + s.Drop(node) + return "dropped", nil + } + return yes(s.Connected(node)), nil + case "advertised": + s, err := pick(1) + if err != nil { + return "", err + } + realm, err := id32(f, 2) + if err != nil { + return "", err + } + if err := need(4); err != nil { + return "", err + } + return yes(s.Advertised(realm, f[3])), nil + case "subscribed": + s, err := pick(1) + if err != nil { + return "", err + } + node, err := id32(f, 2) + if err != nil { + return "", err + } + realm, err := id32(f, 3) + if err != nil { + return "", err + } + if err := need(5); err != nil { + return "", err + } + return yes(s.Subscribed(node, realm, f[4])), nil + case "share": + var shared []*teststation.Station + for i := 1; i < len(f); i++ { + s, err := pick(i) + if err != nil { + return "", err + } + shared = append(shared, s) + } + teststation.ShareDHT(shared...) + return "shared", nil + case "put": + s, err := pick(1) + if err != nil { + return "", err + } + wire, err := bytesAt(f, 2) + if err != nil { + return "", err + } + s.Put(wire) + return "put", nil + case "forge": + s, err := pick(1) + if err != nil { + return "", err + } + key, err := id32(f, 2) + if err != nil { + return "", err + } + wire, err := bytesAt(f, 3) + if err != nil { + return "", err + } + s.Forge(key, wire) + return "forged", nil + case "relayed": + s, err := pick(1) + if err != nil { + return "", err + } + return fmt.Sprintf("relayed %d", s.Relayed()), nil + case "realm": + if err := need(3); err != nil { + return "", err + } + r := teststation.NewRealm(t, p, f[1], f[2]) + *realms = append(*realms, r) + return fmt.Sprintf("realm %d %s %s", len(*realms)-1, hex.EncodeToString(r.ID[:]), hex.EncodeToString(r.RealmKey())), nil + case "impostor": + if err := need(2); err != nil { + return "", err + } + n, err := strconv.Atoi(f[1]) + if err != nil || n < 0 || n >= len(*realms) { + return "", fmt.Errorf("no realm %s", f[1]) + } + r := (*realms)[n] + r.Key = teststation.Key(t, p, fmt.Sprintf("impostor of realm %d", n)) + *realms = append(*realms, r) + return fmt.Sprintf("realm %d %s %s", len(*realms)-1, hex.EncodeToString(r.ID[:]), hex.EncodeToString(r.RealmKey())), nil + case "admit": + if err := need(4); err != nil { + return "", err + } + n, err := strconv.Atoi(f[1]) + if err != nil || n < 0 || n >= len(*realms) { + return "", fmt.Errorf("no realm %s", f[1]) + } + s, err := pick(2) + if err != nil { + return "", err + } + var nodes [][32]byte + for i := 3; i < len(f); i++ { + node, err := id32(f, i) + if err != nil { + return "", err + } + nodes = append(nodes, node) + } + (*realms)[n].Admit(t, s, nodes...) + return "admitted", nil + } + return "", fmt.Errorf("unknown command %s", f[0]) +} + +func yes(b bool) string { + if b { + return "yes" + } + return "no" +} + +func bytesAt(f []string, i int) ([]byte, error) { + if len(f) <= i { + return nil, fmt.Errorf("%s needs argument %d", f[0], i) + } + return hex.DecodeString(f[i]) +} + +func id32(f []string, i int) ([32]byte, error) { + var out [32]byte + raw, err := bytesAt(f, i) + if err != nil { + return out, err + } + if len(raw) != 32 { + return out, fmt.Errorf("argument %d is not 32 bytes", i) + } + copy(out[:], raw) + return out, nil +} diff --git a/tests/teststation/main.go b/tests/teststation/main.go new file mode 100644 index 0000000..cb5986e --- /dev/null +++ b/tests/teststation/main.go @@ -0,0 +1,104 @@ +// Command teststation runs in-process macula 12 stations for the Rust +// tests, in one of two modes. +// +// By default: two stations sharing one DHT, and a test realm with one org. +// It prints one JSON line, {stations: [{host, port, node_id}], realm_id, +// realm_key, org}, then reads commands on stdin until it closes: +// +// admit the org delegates its procedures to that node +// relayed how many streams the stations relay now +// +// With a second argument "lab": no stations yet, and the commands in lab.go, +// which start and shape stations and realms one by one. +// +// Either mode answers each command with one line, and exits when stdin +// closes. +package main + +import ( + "bufio" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/macula-io/macula-go/profile" + "github.com/macula-io/macula-go/teststation" +) + +// helperT is teststation.T for a process rather than a test: failures go to +// stderr, a fatal one ends the process, and cleanups run at exit. +type helperT struct{ cleanups []func() } + +func (t *helperT) Helper() {} +func (t *helperT) Errorf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "teststation: "+format+"\n", args...) +} +func (t *helperT) Fatalf(format string, args ...any) { + t.Errorf(format, args...) + t.run() + os.Exit(1) +} +func (t *helperT) Cleanup(f func()) { t.cleanups = append(t.cleanups, f) } +func (t *helperT) run() { + for i := len(t.cleanups) - 1; i >= 0; i-- { + t.cleanups[i]() + } +} + +func main() { + t := &helperT{} + defer t.run() + p := profile.PQPure + if len(os.Args) > 1 { + parsed, err := profile.Parse(os.Args[1]) + if err != nil { + t.Fatalf("%v", err) + } + p = parsed + } + if len(os.Args) > 2 && os.Args[2] == "lab" { + runLab(t, p) + return + } + stations := []*teststation.Station{teststation.Start(t, p, "rust a"), teststation.Start(t, p, "rust b")} + teststation.ShareDHT(stations...) + realm := teststation.NewRealm(t, p, "macula-rust tests", "mcl-rust") + type station struct { + Host string `json:"host"` + Port uint16 `json:"port"` + NodeID string `json:"node_id"` + } + out := struct { + Stations []station `json:"stations"` + RealmID string `json:"realm_id"` + RealmKey string `json:"realm_key"` + Org string `json:"org"` + }{RealmID: hex.EncodeToString(realm.ID[:]), RealmKey: hex.EncodeToString(realm.RealmKey()), Org: realm.Org} + for _, s := range stations { + out.Stations = append(out.Stations, station{Host: s.Host, Port: s.Port, NodeID: hex.EncodeToString(s.NodeID[:])}) + } + line, _ := json.Marshal(out) + fmt.Println(string(line)) + in := bufio.NewScanner(os.Stdin) + for in.Scan() { + fields := strings.Fields(in.Text()) + switch { + case len(fields) == 2 && fields[0] == "admit": + raw, err := hex.DecodeString(fields[1]) + if err != nil || len(raw) != 32 { + fmt.Println("error bad node_id") + continue + } + var node [32]byte + copy(node[:], raw) + realm.Admit(t, stations[0], node) + fmt.Println("admitted", fields[1]) + case len(fields) == 1 && fields[0] == "relayed": + fmt.Println("relayed", stations[0].Relayed()+stations[1].Relayed()) + default: + fmt.Println("error unknown command") + } + } +} diff --git a/tests/vectors/README.md b/tests/vectors/README.md new file mode 100644 index 0000000..f8ffcfe --- /dev/null +++ b/tests/vectors/README.md @@ -0,0 +1,16 @@ +# Interop vectors + +Every macula 12 stack checks itself against the same bytes. These are copied +unchanged from macula-go v0.12.0's `testdata/` directories, which carry +macula's own: most were generated by macula v12.1.0 on OTP 28 (each file names +its generator), and `identity/lamps_mldsa87_rsa4096_pss_sha512/` holds the LAMPS +draft's vector for id-MLDSA87-RSA4096-PSS-SHA512 as macula v12.7.0 carries it. + +| Directory | What it pins | +|-----------|--------------| +| `cbor/` | the decoding rule every stack applies to what a peer sends | +| `identity/` | TLS and CONNECT bindings, status statements, the LAMPS composite | +| `handshake/` | the version-4 handshake | +| `frame/` | signed neighbour frames | +| `record/` | own-namespace procedure advertisements and their verdicts | +| `manifest/` | content manifests and their ids | diff --git a/tests/vectors/cbor/decoding_rule_v1.json b/tests/vectors/cbor/decoding_rule_v1.json new file mode 100644 index 0000000..f0c53b0 --- /dev/null +++ b/tests/vectors/cbor/decoding_rule_v1.json @@ -0,0 +1,310 @@ +{ + "version": 1, + "rule": "DESIGN_PQ_SIGNED_FRAMES_AND_RECORDS.md, Encoding, Decoding rule", + "entries": [ + { + "name": "an empty map", + "cbor": "a0", + "expect": "accept" + }, + { + "name": "a map with a text key", + "cbor": "a1616101", + "expect": "accept" + }, + { + "name": "integer map keys", + "cbor": "a201022003", + "expect": "accept" + }, + { + "name": "map keys in any order", + "cbor": "a2616201616102", + "expect": "accept" + }, + { + "name": "a text key whose length uses a longer width", + "cbor": "a178016101", + "expect": "accept" + }, + { + "name": "an integer in a longer width", + "cbor": "1801", + "expect": "accept" + }, + { + "name": "a byte string value", + "cbor": "a161614100", + "expect": "accept" + }, + { + "name": "a half float value", + "cbor": "81f93e00", + "expect": "accept" + }, + { + "name": "a single float value", + "cbor": "81fa3fc00000", + "expect": "accept" + }, + { + "name": "a double float value", + "cbor": "81fb3ff8000000000000", + "expect": "accept" + }, + { + "name": "null", + "cbor": "f6", + "expect": "accept" + }, + { + "name": "valid multibyte text", + "cbor": "a1616b65636166c3a9", + "expect": "accept" + }, + { + "name": "the smallest integer, -2^63", + "cbor": "3b7fffffffffffffff", + "expect": "accept" + }, + { + "name": "the largest integer, 2^63-1", + "cbor": "1b7fffffffffffffff", + "expect": "accept" + }, + { + "name": "64 nested containers", + "cbor": "8181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818100", + "expect": "accept" + }, + { + "name": "bytes after the top-level item", + "cbor": "a161610100", + "expect": "refuse" + }, + { + "name": "truncated input", + "cbor": "a26161", + "expect": "refuse" + }, + { + "name": "empty input", + "cbor": "", + "expect": "refuse" + }, + { + "name": "an indefinite byte string", + "cbor": "5f4161ff", + "expect": "refuse" + }, + { + "name": "an indefinite text string", + "cbor": "7f6161ff", + "expect": "refuse" + }, + { + "name": "an indefinite array", + "cbor": "9f01ff", + "expect": "refuse" + }, + { + "name": "an indefinite map", + "cbor": "bf616101ff", + "expect": "refuse" + }, + { + "name": "tag 1", + "cbor": "c11a00000001", + "expect": "refuse" + }, + { + "name": "tag 2, a bignum", + "cbor": "c24101", + "expect": "refuse" + }, + { + "name": "the simple value false", + "cbor": "f4", + "expect": "refuse" + }, + { + "name": "the simple value true", + "cbor": "f5", + "expect": "refuse" + }, + { + "name": "the simple value undefined", + "cbor": "f7", + "expect": "refuse" + }, + { + "name": "simple value 32", + "cbor": "f820", + "expect": "refuse" + }, + { + "name": "invalid UTF-8 in a text value", + "cbor": "a1616161ff", + "expect": "refuse" + }, + { + "name": "invalid UTF-8 in a text key", + "cbor": "a161ff01", + "expect": "refuse" + }, + { + "name": "a byte string key", + "cbor": "a1416101", + "expect": "refuse" + }, + { + "name": "integer key 1 beside float key 1.0", + "cbor": "a20101f93c0002", + "expect": "refuse" + }, + { + "name": "an array key", + "cbor": "a18001", + "expect": "refuse" + }, + { + "name": "a map key", + "cbor": "a1a001", + "expect": "refuse" + }, + { + "name": "a null key", + "cbor": "a1f601", + "expect": "refuse" + }, + { + "name": "a duplicate text key", + "cbor": "a2616101616102", + "expect": "refuse" + }, + { + "name": "a text key in two widths", + "cbor": "a261610178016102", + "expect": "refuse" + }, + { + "name": "a duplicate integer key", + "cbor": "a201020103", + "expect": "refuse" + }, + { + "name": "a duplicate key in a nested map", + "cbor": "a16162a2616101616102", + "expect": "refuse" + }, + { + "name": "a duplicate key in a map inside an array", + "cbor": "81a2616101616102", + "expect": "refuse" + }, + { + "name": "65 nested containers", + "cbor": "818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818181818100", + "expect": "refuse" + }, + { + "name": "an integer below -2^63", + "cbor": "3b8000000000000000", + "expect": "refuse" + }, + { + "name": "the smallest CBOR integer, -2^64", + "cbor": "3bffffffffffffffff", + "expect": "refuse" + }, + { + "name": "an integer above 2^63-1", + "cbor": "1b8000000000000000", + "expect": "refuse" + }, + { + "name": "the largest CBOR integer, 2^64-1", + "cbor": "1bffffffffffffffff", + "expect": "refuse" + }, + { + "name": "positive infinity, half width", + "cbor": "f97c00", + "expect": "refuse" + }, + { + "name": "negative infinity, half width", + "cbor": "f9fc00", + "expect": "refuse" + }, + { + "name": "NaN, half width", + "cbor": "f97e00", + "expect": "refuse" + }, + { + "name": "positive infinity, single width", + "cbor": "fa7f800000", + "expect": "refuse" + }, + { + "name": "negative infinity, single width", + "cbor": "faff800000", + "expect": "refuse" + }, + { + "name": "NaN, single width", + "cbor": "fa7fc00000", + "expect": "refuse" + }, + { + "name": "positive infinity, double width", + "cbor": "fb7ff0000000000000", + "expect": "refuse" + }, + { + "name": "negative infinity, double width", + "cbor": "fbfff0000000000000", + "expect": "refuse" + }, + { + "name": "NaN, double width", + "cbor": "fb7ff8000000000000", + "expect": "refuse" + }, + { + "name": "a request naming two proofs", + "cbor": "a9657265616c6d582000000000000000000000000000000000000000000000000000000000000000036663616c6c6572582000000000000000000000000000000000000000000000000000000000000000016670726f6f6673824970726f6f662e6f6e654970726f6f662e74776f6674617267657458200000000000000000000000000000000000000000000000000000000000000004677061796c6f6164a068646561646c696e65016970726f6365647572656661636d652f706a6672616d655f747970656463616c6c6a726571756573745f69645000000000000000000000000000000002", + "expect": "accept", + "via": "request_fields" + }, + { + "name": "a request naming no proofs", + "cbor": "a8657265616c6d582000000000000000000000000000000000000000000000000000000000000000036663616c6c6572582000000000000000000000000000000000000000000000000000000000000000016674617267657458200000000000000000000000000000000000000000000000000000000000000004677061796c6f6164a068646561646c696e65016970726f6365647572656661636d652f706a6672616d655f747970656463616c6c6a726571756573745f69645000000000000000000000000000000002", + "expect": "accept", + "via": "request_fields" + }, + { + "name": "a request naming nine proofs, one over the bound", + "cbor": "a9657265616c6d582000000000000000000000000000000000000000000000000000000000000000036663616c6c6572582000000000000000000000000000000000000000000000000000000000000000016670726f6f6673894200014200024200034200044200054200064200074200084200096674617267657458200000000000000000000000000000000000000000000000000000000000000004677061796c6f6164a068646561646c696e65016970726f6365647572656661636d652f706a6672616d655f747970656463616c6c6a726571756573745f69645000000000000000000000000000000002", + "expect": "refuse", + "via": "request_fields" + }, + { + "name": "a request naming the same proof twice", + "cbor": "a9657265616c6d582000000000000000000000000000000000000000000000000000000000000000036663616c6c6572582000000000000000000000000000000000000000000000000000000000000000016670726f6f6673824473616d654473616d656674617267657458200000000000000000000000000000000000000000000000000000000000000004677061796c6f6164a068646561646c696e65016970726f6365647572656661636d652f706a6672616d655f747970656463616c6c6a726571756573745f69645000000000000000000000000000000002", + "expect": "refuse", + "via": "request_fields" + }, + { + "name": "a request whose proof is not a byte string", + "cbor": "a9657265616c6d582000000000000000000000000000000000000000000000000000000000000000036663616c6c6572582000000000000000000000000000000000000000000000000000000000000000016670726f6f6673824970726f6f662e6f6e65076674617267657458200000000000000000000000000000000000000000000000000000000000000004677061796c6f6164a068646561646c696e65016970726f6365647572656661636d652f706a6672616d655f747970656463616c6c6a726571756573745f69645000000000000000000000000000000002", + "expect": "refuse", + "via": "request_fields" + } + ], + "via": { + "record": "the bytes decode under the decoding rule (the default when an entry names no via)", + "request_fields": "the bytes are the fields of a CALL, read under the rules its field table gives them: at most 8 proofs, at most 256 KiB of them together, each a byte string, none repeated (D7, chain transport). The total-bytes bound has no vector here: one would be half a megabyte of hex, so each stack tests that bound itself." + } +} diff --git a/tests/vectors/frame/erlang_neighbour.json b/tests/vectors/frame/erlang_neighbour.json new file mode 100644 index 0000000..b482072 --- /dev/null +++ b/tests/vectors/frame/erlang_neighbour.json @@ -0,0 +1 @@ +{"entries":[{"connection":"3d634ec67d881a7824567ea8a2dae147fbad1c604eee868cddcae2a2e3a58291beb05bbe876efbb655f08d96df9bfda2","frames":[{"bytes":"0000154da36776657273696f6e02696e65696768626f7572a26374627358fdab63616c676f4d4c2d4453412d38372d50533338346373657100657265616c6df66763616c6c5f6964f6686672616d655f69645001a0d10e8559766b98b6fd9a9e1244246a636f6e6e656374696f6e58303d634ec67d881a7824567ea8a2dae147fbad1c604eee868cddcae2a2e3a58291beb05bbe876efbb655f08d96df9bfda26a6672616d655f74797065696164766572746973656a73656e745f61745f6d731b000001a0d10e85596c6361706162696c6974696573006c736f757263655f726f757465f66d6164766572746973656d656e74582761207369676e65642070726f6365647572655f6164766572746973656d656e74207265636f7264697369676e6174757265591413f3be773ad879c787299597d7b5e276f2892c6a47ff1439bed92632adbabd8cfe6ad0d3a02d378404fb5d0d89633f698ff73f2fd557870b7f680b5d86edddb3aa77a4ce576f34ae71c99aad170bb1a0006fdf190855a9fe9a6c34ab491cc466b6fbeaa1b875890e2c2ec6aad8c9ca675572cbd0c0153a17928f7373fe5897e8dbb33001238b75b19d1d6330dc4be05d9e52df293f6a040c496b5fff47966d60abdcc00161ddd71647d6a6a28bc9165d22f4fdc8a949833866b1813fca301b56a8f981ff7793d08e378b102867f058167e8fcd9b947a277b0877e71c2d1331088a89d3312357e4fe858bcb7c4e2fb8c8447514debd8df381dbe54d1a60d01a49228eb5d34546eb1a5f66b76aa889f21682ad5455a8299b19fe09d729b70301398b9dfa3e79df7cf6116dc6acebaac905f53160ce78808852b4aa76173e9fc84e1239284d93df7948e7e2e24bb6600a05b0cdafae7f6a959c3ae0618db0161481ddd374f0b57a08d4672ab0f51b6b144ddc52846804f1e0037bf677a1b11e8cc95d980a5a5706af9e7f0f445cced0ab1ce6a1b05cd6a22db2e25a02069dc7a68536bd067f2a1fdce5eafc1b5a33c046d1b86e87ddbdb67d11dbf9738304279639a06820ad83f02b3fe9974c85de30964d1c8c1c51d8d352130a70a87dec066e9bc7bd7a198a52c2a158d0b2243374b37edc8f598fbdc7c60642653689738b1c4562a2ae80e3d6c82c850de71b69abd23ea6637608da41413b96e2f554bc091841df5cee3799ab3b116d4cf0dad628de0a4eeb95022fbcc56de5073933f7a07bd7b1f6281ba28f84128f455f5cf0ac0c23c1fd590db606337cf1216ebc6d33d872d12d43020426d6237cbfce1fb8bbd73939e5608fd4a0816c145add1622eab8dc37265ddf97a9449ddf6a37b7960ef99137997ff60ef56ba2f7ce3e6073e662e9a59cee71a8c7b0d156f8b0d0dfc478832c51ef727eb0188f6ebcc6ca4f1502beceee457eb5e27e3424a72825b1e034a81c4b25b3efc7efbaad35585c8e8f8aeea15909f71959fdf102b35c4eb5e432a4f8e2adbd3515362213b7c4ce526fbeed5cb493997b96d4da38231cf8ed7c5207849bf0e593e224b68f6f4cd04558664031c66c2877739ed60b4c967b54ceec16fabb7902046dc296bb7137f70eb8385bb3007b9aa9820cdbea64ad27bd5f8c806cc8b7f9c18a441d9b16e7ab1765e7c23fe5d4fd77e496d7ca0432f7fe2f83bb7c80057f5adc3965ce46a88126b1075e9c5276857f3b867f44e6fe895da6361c26efa8a472d3d1445797ac0eb8afdf42772761cce55b5e22b53228e00c729ba7c513907c419b0c2f53c438bc37108140e49c01c4316643ac133f023e2aecb88e87810960e01526c3273bf57b2db3e7018b390ea2170fbb851985425235dcdc555fb264fb1d0c79271c018a1196064289cc1a9786143059793fd18b62c3e33ec0a6e63e545f76710775884092d017352165d79f1643ba8ae1d4e59805101a83ac3eefcbaa8aacbb14162db9babc06e32a3bfaa2e1e93723ad553d4041e2fb15060e6ea44723b67b30f493b3afb2cdf112aa20cb54a6e59a7ac803b70d9cd540b7da9d7019b13f0f4fae0536932263284a8f97c35ecece32ff343ecb96cb340be9033f07b73daafcd13bc947fa966cfcdbaf44bdbeeb835a51a32f4933ea557792bfa1ea76bc65665c0e1bf06d92e9be3913576cd6a863db84ba7d17574438c50d8f28675661b87fbb0cec90710d458d9e390d7e682446ff764ca6610fce31acd7d7bdad18722bbb394f1672218801c22778fc3bca3a08bbf9f1b266337948a65d49c42ae263f9f7785e001f1fc865e2e70536cc0c58de5ace962c358907c2f9c192a939495c4d624a935f8a4eb8f52a9ebe128a983d93a71f72c80204427afd1130f088d556e9871804efdb5e2f49e786bc48bab730ff83327b474b9046f38209dc5f658b5ad94cfe4e5e0bd3792da900c20df91b572885d39a122fc946cd39b33316f2504b5513a93186cac978887984baaeb3ba6581017f509207803d413ef02744ec3aff12f19564c474359ca1c06e39e3826331e33a302885b3d9df37f7eae1037096f42e37c3e547c8d8a665cde42a9a9755a9cbe5df04a22f27480d834b2780fcad8cc643182ab84d221997c6b75c22c9306053b24c463935b52e631fdbc3738fa95d7d84323716f6e35237eb523f3dcb18d68bb921d00b72c3d879409d31ad1ba4239c6e9deb0fd51c325dbd5aeb29a2fef42cd1b2ec4dd9717a21959cc30dc48f73be8cd6071dab7cf6d92f0c6da0ae034942dc68098e1a2faabd7ef1b7e8cf497dd16292a74ea96fa3609411ab5bc6b5f729cbc6964e543ad069a79343ee70ff637305afe7d9f4781f6d46be6f1aee86918e03da6477b7a2f4a714bf2ec2337be3cb7c614795cf61dd8f55b0546b9934ac6fa9a3dd4c0ae042a87267585eadf9ab6b986d93745c4e8547ab12e7b9ac9659bb27195183b617599547c25a4d2130fcbfae4a5e5acacaa124c5efb972af61276595e2cbfe0c99d9f5cdab8bbf69551dec855710f5b082d6ee46259c364b23411283b955087dc2baae05bec312a843bae1af41176f8f20ddc60d36d5e4a4ae793f73cfad0b83c2aa0dd2dfc116fd7f590e841a80f9fedd9427b79b55b8d027bf9453d3fb3760862fa119ee8d42c2b544a6f2603f039f272fd1b83a47cb364f317f13f079c1a773cc0fb56270866dd7152d42df4c4287ccf424164ed92b08ba39e94d6a14efd0d8feda9adfcf5577c864d57dcdfac55c026e5519f823712e15fb21547d9e9ddbd409935985077d0e562911ca56b994086a1cb20d7318989faf3dbbd3388db7667d92fd1b4036eaf6f4fde208fdb5434fe3b5e4bb372f02964a487906150c130f5e357616c6c2a9ddbd0e25c06792d91b5d1e052705343a338b2fe419e3badd93ff1d5981c747d34c9561f80bf83ebd322b608402a07a2209406fa94b3b9e90c6f091fd65f2db911be9342d5bba7cbb4623c0d4041ac8b066dd53c719c876e20876c232b4db1f35aabd98252045828e649d0e5f346f48fc3a9dffd961f994d2bde1044744f2be02b155e3096c0162f1c9c85c70dbf8a7d47947c6485e1d13a73164e89099468942cfd911232cc97e2a1333f0ab18a20f2e904db38c6276c75c7180596491a92cc20b0d82d5bba843bf001ae6d087734a93616f16fd4fd9e8c4d9bf780531b338b6e98375827391af5c08b7ed8b6bdcdbb5df60d344194b3adb9cad1fced5205d9fcbbc77223730bf2da560ae1310a3cb7f374db5f8db2c0f767f26320f365778516e3f6b13e9f7629cd0a996934d190f372c8f27c8014903174fdb0e2e843e6790168d3c4a061ee4888db0ccefa65541ffbc4a5798a38f5653f64fcc56a7431a7714a08bab66e1df2c38050de78533d15c412dfb20ca74156bf7bdf00ff637ee52a95236c2da059cf4514f5cee0051ec379ac024ed41c86d1f973aea3edd4394596e7875266b4cb2978c920500cda22522433d8297cb03198327a698e9e0180957a345966c7f0d7c996c2f1589c7e93a68cb8bf36fc31bc3d41440b575251ca57f76451380236b32e55313521eddb6ad851a5a8a1519d81181c6e81a6c247f649fcef26d84d8fdea65e5fcb3fa1dd2dd434d7ef2884fcda2f9921bd8905d22613fa9955890b0db18fb66f181f3f7295eab547b143c311075258006365d255932f6d8118514b977905dd435ef653a0352aae5897e49e2d521bb941ff1b01782ab035256b422395d4314777576bef9f48c5ea6f365c1a2199f88767d7e9072dec9a02b805c0a6fc828143b76f31abbd3b66114df4b3a059a265ef344649250a2062d68f8994455d87a92403094c038ac60cf6871c2b7ae6d9fb732a34e13396eec64020f7e8d0ca19060de6ee1d32965e4bb4bee7d16a715191aeccd8ddfe668c4b927c17969535c383c5fff5f7018bbf9381f6b928a2d6468217f9d93696a0b4b692a1ebc374ddb8310ed63e1f8659f40ce4193bad422a68b41291d56093665b1542dd462fafc00fa35987012d686493245658d90e5bac6845690cb30504d452ba0d83179cc937cb2f5673f30e12e25d2b328bb9391f4090d38a73dd21da6f08c9289e9eb34f453d6abb3517246e26c48d98b01c7b78015ba359b91ed792bec9fc4c71d773a496b69fa34176b60c8d3bd86458e87bf65191d44ed73386993154921794d20127ba106333e5743cda2afed9fd9d1edaea81e9532302a1187e319f39724598df72fe431cd110aa397ab447d4f7a696884fc932613cf4dd117800958196c8c62b6e697de8daf6ef0527b90efbaccf92cfeeb29b6eb7d4ceeb1e4c27c66a353faf83bb1becbf1df7e49f47e61f005d7aae1f09a755b69ec53f31cee082e9e579bd7ff2c74e69d12be3327d6bb37aa8fb1f3157faee8c06e7c7ddec4cab5042f338b482bccf6511b5f5ac733b090c47a687e72277aab239597d342673f38f00ec8a3cf7687329976961b87cb938ee28e8f0b16b9988a142fb9fcb20c6e5fccd19026643a4e88932ae0a8bfc30d520603c3ed3065167f606d8bcd3643233a0435e822b4c91963611c9b60ae61616b08918dce11653594bf1836e16345a2bc7a6d2900f43f894582927efdb4d4b97158cc0888037b1a739f94bad77c8c45cd0578d4f4eb52f00f4f1fcedcc0e162f3632673bb330fc8c1d0ae3c2ac34e761300050adcbc8d15fce667ae3db8bf7eb995ac0c5c8e0e0354476eb3846d73ac5b4039b68740a6823fab21da97da24772c457269d0dca61387c674c645f66a971fdfadc686095f0cdc88f80b0a363b7d35f0cf2f848379e5ba73352b5148cfdd2b4b5c0c728b6b38843a3db918d3830ebe39d4a162b6523bd51609982e15f3eb7962570a63f401edde57900824cc8b4bf88253c3336ef3cbcc8aac7ff09adf9524838e57ba3f0417b85d0ba063b33c688f01e934e1dd8c8ed21224886da20459ec122f09b91fe7b373c446305183ac634866900c016b32eb7988cd2d9301c95777b8d99acf3076410346e0011b1d675eb6b71cd89e65c07c8601ed7bfaf41a556fb9b12ecc715201597da7e370f7b88e1ca50c658ff281ba5f556ccd80e020721710a249b94be92e3e1fa7c0bcbe1f1e805d932a99ddbc459f198f483e5617ebdfbef2661760cb58f157be1c1a446ef3cc922cadb9cb866e88b8f232eecc2a4f0a123c0033e10e7664f6f1b072d42748466606018d07a77c5e9bca4f106be7d26a3d42e8aa197dd4f26d336866b1a0779d1f694972076e072916939be3627833cffbe159f12d95b24230eaa4b7f28a494c0d740a2b6f815d701bd06ba18e5cceeb3a244cbeca5f1ed87c1ad05119257210db7dc13ae14f209e86624f2e0d7c44d50eddf7d8db5609e23eafa9ce095b1cb6d368e63a482808dbd5484c44f74be035718dc6b23bfe17adee933c08e0d9f2893d372ff9d8abf60110c7d44ff008e0a4c75fbf26f56c6bf984312b1c1d41d4bf36eca2a912384b2af861e89b9b86a2fbe793553dda0a7bde2da6831cc4af4895218b5c23b41adbbb32ec565ac5d425f5ac9208efc11661ecce5369b815aabf3265f9625e9b7193e5195afe25a1836b49a9f80d4be36290b6dc021ea0be60a53a731207a12d9e52fa2c47a97077d0d47df3ca852d60d01c55e8f0ca2d9937eb1304213cb32814b5b7dcdb8c1622849898956c6d4a15d89aeebad7d2ba7b7de30b2a00667fba4067e75678758392adb95f0271c6968ca43c05a555edc6d0c111ed38db49d4add0e07319bf59b7495a19478d45436fa7cc8302dd846d9e834ec081741c4a202f8217dd2deca4381499963ad2e332cb15f678c58fdb39a1457117d718f3571ea89b170ca68b11bc2fd0292521c81eb791420674002ffa491d39480eab8db34e222c361101af130e3d34b046ec9b3017eb5c0b71fbd56eace6ae84f55612b372b202c8f2ac3d1225ec783f5c80a0efdc702e95bcb68c7c1dc825c82d452f5e353baefb9388063516bbbfea68e4e30e098967a292cefcd03808c786f1510837ee21213687cf0f63b39cc2b23c70e18c938cfd01b23d0e66c221227eb299bcf7a359ca4c83ee16fec133807b94f61cb4dc2cadf585043e427004e9ac281521a16a84a0025a13f3017e71e2864833f4126e3a2306b8666c34b644e7108b9e0245fd284de72f0aec436e77ab7b6ba274de80d8f93f2570ea21fdd0f78143ca866f29f598d7704fd80c31a3831ffa2b59d61c75847afa51a3b72f73695070188320eb15865eb64163b33a6434e8f9c4599a04a3df30fd1b5b9e529c1c5f8dfc17139462346b7215a25a0be8917145a2f789d8a7dd62dcc2deca4bdd6fc351e2288597a2dd4e4383983c05acf79c1314018bb0e4748ed8d41f482a4520c303101154566694abaeb0f7102a68aabddcdee3e6fb235f6a90a9b007237b8b072528587cb4cbd6f401ca0e18dd1423a3b4b9e3ee00000000000000000000000000000000000000000000000b151b1f282a2d34022d5902b2c369de3265aeb7194cc49a951b5a045cf5bbee07a524ead3967e0b5debe5c61eb01c6c6f9d4d9d87417921ce5c642df91bef30ab801a92edca053392b36ca2fb84d0f319ef61bda945d10c7923808a2ac00211d9b555f66325a5de842124515b489f1927a709e9afcfb88e0dad96974562641061510e4a2d8625ef9b7370412bc6b36aac0ca272ff0d63e2055cc31c1eabde4342ace28805a1ef5ce0931d64982411dc56a9ca405e35f52d32d1f9c8d24135dededc6aa8f8432f269793f26476df69b14ad026e78af6859f06895875ae58fb91ff6dbaad834e4fca7a0c4e22772e9ee5fdfb9b3135cddfb6a72b75df21f4c2bdb9c62bf5258e47f13be86f5219c9b776b22e8fcbcb1be8a37cc03aad8ff771ff4daf7afecf11e9ee0f2cfadc0de523dcdcb6ed2437616b64297e0906305ec54f0e32cff646ca0f9b6bb63894b00b081ed90d09af92c9bd6ab4cb32cf4bb8053ad7fe73be061aad333402a00a1988cfe55b43fe547fa68f5350b93eb7878d8ae5cc779812e41d181c612b7e630181ca1e22308430605303c7a297a01e0fce1e71e5c310f2a35b50eb1022a4ee08b6c1a8b5986f341d06b8fff281816a298186823be8de0f21b8a46eb35e7e1d48347ed25197bb23950ef1e867d4eb2eccae83c5953e05f399162387c51575facc530e998a1f9d083d50ef12db1698f1036fc2ddc7c56e66d0d9da676a6672616d655f7479706569616476657274697365","frame_type":"advertise","seq":0},{"bytes":"00001541a36776657273696f6e02696e65696768626f7572a26374627358efab63616c676f4d4c2d4453412d38372d50533338346373657101657265616c6df66763616c6c5f6964f6686672616d655f69645001a0d10e855978dfbf20dd2a708a72b96a636f6e6e656374696f6e58303d634ec67d881a7824567ea8a2dae147fbad1c604eee868cddcae2a2e3a58291beb05bbe876efbb655f08d96df9bfda26a6672616d655f747970656b756e6164766572746973656a73656e745f61745f6d731b000001a0d10e85596a7769746864726177616c581a61207369676e6564207769746864726177616c207265636f72646c6361706162696c6974696573006c736f757263655f726f757465f6697369676e61747572655914136c58dae08e778ae71c7c9ce1f6a392c643f197bd689796ddb60dd6c2e83635a40b2e919825044452b212f1eaae83097d7f9a4899b20ad56b91aeff632bc58287203bec9dca9b1363b5978d9097c2137b9a3d3b6bc7801fe582b1c1e6740502f818ade7483ae4c7a9274a4803239f67e17f2102f742b65cb015b22404b5b04e6d5cc99dd350cf3acafe5ad66c8f413fddbd21e59241c19ff5a1cac21441376296ef131da161e59d79b1812be7b09944fd49d513fd72e9da0dd79aa2026f67d56d5efa31fd3c0e1b098556c938d8159ea21ca03227b3f78f1de1c4625ef366c6f506a294693f078fd7f6b13066f2d04126c797837b67031cf73ea969d317fdb757498ae93a76bb484ae9b95b84ab1a37b1a1b0bb77e05a7275bb2c65502b7f1b3f0101146a74fc0b555dda8712ae10b0f1964fe03726c694a9fe60f8b368ae59ce63fe5f3100b6e690727b97c87ea2d52ceb7eb5a252f678be7e903ed8106cae02e98b61ec5c2681212cb22d59b3992f4d5b6503ff5a99c06e4415623b5445695af17bee0575e204e3a2f87ea000104f41ee0ffc9b2308ee44e1c47e0d57feb94613f6745cc859f964727f87e3a7264f4461ba1b515bd3c41d099442e6037773dcafd0c4a0522021f32d917aa45143a11b23a635dd310e34d604862403502ebe4df536f3fcc62d50bb30cf520c83671bb506aa53b87b5e4ae47bda2197e0f1614b3b352ff5481f6c369ad56182b6a55b6bd3a81dd51121e516661d1b4e7a34ce8aecea2c6e246a1eb3905cf672370bbf28dc5ef942fdefe035444bd8f9bfc98eaf50b99103e9606c6b884c97b1a3e2efa5c1908d0e3ed0c30d689ad66cbca5206704cd66f632a4ed6114a10e142408bb117054428b38c2364978d1bf7074a7c49f78e53014a51ba306dc2d6c893636378d21767e43d67bc5a83db88dfa8767f040fcf2bc6a0cf00d899c48e7f8e1c1fe4a25df4e09d3ee368fd8970785758ec4b4a24852f4392f8d63525326bfd453d40b860d3aee068debeba821fd615ac3738a60ac8a67c5b69eb44784cc6411c4b272edcba588f942d899cfc827a17209eb47b8c58479594e767d55916487f68b42395df279ab8bd1b4a7d2bb2bf2b96fc2283fb5b3e20131902b22bfcfe68822161c11157abd18faefc0e86edff9140a1d7667794fe80881d13ea71e86b3b77a23db3cbe463a8a1cc93c62311fc65854d6f415ef9047af28d8948d08803f796835a20ba92d44faffe0971dcff9c90efcfee1f4461f7bc6708e072bd836e8540fa9d034b57cebb4b6aefe39b986aa6ba7bfbadcba54f87a8b7de1a069fee49ca11bfb16915c8c0ab71129a3e8438e0e53f7d7bee1159cd7070f322cf1e937f3d91c75fd6a7901684384664fbf59e0ef0e13e9eb4dac9dc0c26d22b89ba1f7839d28c6320e5c92b967c7bdd3f7217530f252357fd5a3b4aeac758e73378119f71e71e1c6c1eb602a34ab391faef97cea45b48b8c829523fcc4758b98e8cec06d75416fc0eb7feaf7fcbd07fc127af0daf0a79ad80b0c8eed91d3158e91e4721f50505938ea6710fe0f66d8524f9cd5d3aea76f6da215a6b1f374257a96637b3d329b83b5e70807c564ee2e878cf07f463c0147134cef393deb4fce88187d8eac75bae35bb3e92170bf4aac3f40c0dc1a18e74cf41cf8ef5a4bad31d38111c37625ab9d941011bd7553659852bcdd728d09b7614f47d1f1179f7025a6f4abe12dfe09a949d6d88ae2ae8751c3572b393f559afc5e715fb8e0619db4aed66756d3560b81ddfb9b6e98dbb5436cafffdc5e6da66c7f35f553455282674dcf1192cac19da86294d52a4aac4818eba80c45429c084c88164c05a817fae714b5f9ebf44279ab3297380559ca76fe20efc7242777205099fed7c0be1e714db15ab60ccef8c3707ad9f8576715175cb200a8df65ba42ad2560f79475c3058bf3885a702f89389223e1f37e3d0ea08536416e10aeee941209b4162cf2845d02d597d9a71366a029f8802a13010f01d183b72a98bbe5c0514dd1aa097aa940a05a866782eec2f71242cfa116dd846199a0508434bf424234c5131b0d8d455bc258d1cb12677146748c64524b00259b53c74aff7e5334104de75b5aa51054842974786993907ad3638473f7460739508bcb1c79c48f0beaae24e8f7c828ef56140888d4bd4aa0957c1d58635b6e51d0cb9d56587dff420908dd1b6ebd461be81878498944a9a2f80e585585e8b1dcfb6f9bd975d2327780189ae0dcaa59ae752ab264b92e3099ea2304a4aea21b9b60ba4ef0e534eb8d4edd0d5e4ae621a493da2a9bfa6c8b3744b3372ce71fc002227815993667a5b8d78b7a1ae90026c188ee503be4b1de15bb5bf3b55028ac6e8719c8f53852cb09f9ceed1c9bfaf74d6710123bf4c605d4db9b6ebc14bcd7e7ff121c4bbba053f1ac2593ad774266ecd19d0af07d49a9f31e0b08d3464ee80ad6fe20e2c952a1553efbd757a2913ffdce2436c077ddfbf87d75ff35021489743ba67e57317a694ba8937d3a1bcf0b84377c78ba1a9c5f6c362604c5c4a3d1ced8b03a62f424abd3d87e6d7e7adadabb45f913546ee80c3165d0895e8c4ff0b710526263e70a3aa046adbea079e4dac7da0c3e1a9c700fccf5ee7443159108b0ade0d551b5e90d93fbc84e818b0246b1974d5f103a46dc1223297d71a1a78e5a7676d555c8ce32a155e10a04be6edcf745e163d776588b22125c3d495d368502710394bccf1d017d2f28625d93ce46520c853824fe0f293f44aea894e7dd637d8ce4e52e6cbc38cf160273999c2e118cd74cd9e9bc7f1456ba87b228619e0267a34cd5c72a0cf873e4d257616aa69bf666bba1cff26dd9fadb754bdee0fa3dadfcaf15cee55f9655c167b1dbe09914ee929505b564b9ea26cd3a73e72e378bba0aecaa1ede4e2c84790740615279a8b66af2a145b7dbe26526979c397d08031f5e8b0963e3fdbf35bab7904b07d48d8cfeb2fe39252e5de1f477f0bafe20c99ee027f03fe6d8dd80996bd8156f60f9919752bd062bd06bd907247825c826bfdac148b5b5adcb5be715b790a6a6532bd0c06b78cf8cc2e469e2da058d513438c137e409e71a0d44c66cebfb6032a9f6a9779f057167141ac3e47a6851aff424d9dd9abe566abb8d37403ca52e0acb71cac9b908d2b5d36a82db9d365ce20919255123a0d865da8f158a70186c46faadbb9370e96413807c7b570fd0a9334486cf0b009ee0115d3bffb6c1c76342d808dd2df284be7da3b89616a4fcfadc8d2ba1e9b6c46737a5af9b01ae468bfe067b6e97a2d0e49466577590f05cf05e4eee5f80512a4ec3b365e76da856c3c2c47e5e8f08933178757f83a3f48ea5e26ef92808300be508ac7173e1cb26811b59a33439c31415349f5636061020a7bd91670f3064a1429f90243bc817dc21b8973c07c3960a909899f36eaad350900327ec2069cd4afd966bc5bbc31ba2e004fba935201a5a8ac88cf82cbebccdd30572a7627198a6264d8a8c6a202e723bbffcf092b351ee0ac3f820f387771739c52d02ce992e936b8331f653278cf26b08cc3d52834722731dd45c332197a9e63bdc851b9527e7d485bdc27d6df0a08a1e8ce7f44f2fbc944c28b3264a99354ab3394292c1f43d5a570660e6364509fbe066318fa769e76eb29116b31e294bac49611f7f6b34c432a70abe6deb47f2ee7587056e65274fcae3f1771a8f5278369f10a109c4b3ce49f06244c2b168711535f1c34b90680a5ad1f98f5d4d5cb04e6a5de4411b7de9a085a22beff27baa7587d24ac642dec73b3ed7e5f119131f7431ff9f213ddff223ad23bab42c5d411c471c0fdcfb9b41d607631e2a1068fb306854e8eb897ba26b6cc366f2a781444f44f18e1ef764f0e2c2aacbd335544f2e1f03fb93619a5c02aadfe9edb2b0ac1ce6c7efdf4217cf021f578579a70ed8de7b833d5eca5e8b80c43f4feb04bfc84e1331a3151a64c62dbc02fcee856629eaa0064299a8a85281df2a3f2e7fd210e99a68d859b5df99d5a0e41d05a704a12ab64d3185d958d37c3e0d359677b8afb0a78f4f9cf759ce29f460fa7647ab7ce509bedab316c4e5c1b7b4ee3e3419025e377e22f514542ebd694d9776ef8eae03a239e2fe3a93edb1d6743a7063eed6bef4699c3134d5b03a88fc7def70188c7109a6ce45d5a0c87f482fac54464f2071eb22073e69df72018ea7f1aad86fe9902402457812b240df8fcab4a319cf2f6d87408e74946046b0dae60f765eb271bffacedbbd77a453a976cbd1519c59a00398bbd7f7e72afbf6e1cd3287bd585ca22855c8af501178ca4455f8fe07b355bbd2b35611c11871e1b35e2431d7a28781a717bbe88928e23e3b2d2e480f2fa10f466190bb77031b120402195cde6881589c1777f22d2a4ed30879ff5577a171d1d4566728edfb04a1e3fed563e70b152f1d061adf4ae225f6beebf6920bcc159617b750256e0af3eeba69571d05ad471bc23c90dc7963894c04106d9c16f630d2433e8da077e05fe9039d6cbc47cf5e4dba1a70919892627f4d45ff3f6c5ff9c14878e805f89d26a09b00be854cc95010f4b735db7a24d2ccec08f209ac69b7a33fc6aa4329cd41844b961be50b917fcadb18af09407630c88306287bb95ce4b34e5ddfeec59d96de8cb0e8cc9254dc4ffc7f0736ed331c829fb232c5f12f5663d74d1a1907538498bb9dc2b39c82d565946fbc6818e8f120de6c1e73690606330fc9d1aebef04e1e402cee393de05020fd813de2e875c5fb673f75cc0ed0316bd4b1ab61ffaad17ff9250d72c9989373ac0a67bf617f732273b3effb1b98e3afdf1113376dc0d70ae24783f1baf11d759a80a2a0706e7c9b9c93a5f18e12033ac103c6f5fc653d6880e8b5b500fa8756da11f1b85d9331a43b0639ffc509ad0b911c2c9d9e7cffe7f0d4c2fbccb2c2209fb02e3818faffa010feba881e2fce7fb703f03430518618c80b53c4283efe48426e571461de2fb6c3a57e6402f285ba55a91a14227984b0125cdbfd09343cad727aa205b5305901f465dee2c52d5b4b582a3feb9ba5a4511ff25af92095d4723d484e1385e73e7db08ae10735951927f88cb95bd658d9647921e59763b9c914171753b30bc3fca5d9a527fa5c9db80336562d43dc8d53c8e6666e272a2d6a1fcd4b024fa6d056f1c2abd50f02166e647b167bcca0cd04e78c2c55b0dd70925d79741fbbf063cc0722a1c12d39b9cef2e7f16eec7cff15301689f20e43eb25dfa187133d18705fd24412f44c5035d00894f68c0790dd47f59a5e13041c233af6251f29243bbce4a08b92a03a7657018fd4aebba0e36c89b3f8d1cefb6c10aea3ab117bc590c7757d508a9cdc9165c294107ff198da1ef9cdd05ec21ac365c7ed5979ee8016437cb5bbce81b8606ba9282233377d9ea5539ad57ad461f8765c22794ea58911bab89aa5a388b04b3a86faaf2a2ef56a3b71dfbf86500496c29f704d5a9a11b084bed3d604d67ff2bad048efef132f2db25e56e500c3518181be6969fa8cadfa4b3a1c50a629c62c96c8c0cfbc3e023c9b1747523334fbe0d792503c4113a8a46edd6427437e26604a4605823c6a35ffd158e2b08f9e143588e185aa709a80039554d74598572c0bd3679d80c7c41410f72b10e8bd95454d2c160f5fe3bad023086010982cd85acc4ad048eea2158d66f1f280f081547a5a86079c199852053aee16417845f6f2342af48b5f9d7361787d00f401996b9e216eed962c81748c50c3718d232afb58117d2b1d8026bd2815d9f6123552ef34cd9d8f4035489bf768469cddea4009c396c8b3e4d14c5e1268d0bb571a9928aa39642fa7f82e72d23d384b6d28fed9e178521014a8c8fa78c3174366b538d33b47a0ecabe4a04cf720f6386b8d40cb5516e20e4ca16476c28f86f4a1f886bb1704b3d17605989b08fbcaeb1e3ab56d74dcdf91f9f2349312a540c3fcada45f85496e6d5e51c9a54e583d72b3887dcf5001c5c359a4efcec03157bee0f1426e7504fc99b2c9739fb79ff23d24d20ee34be6efcd54b5e841b2370ae3d6d96c613966451c1f78906f394ad6920e715887256dc7418749284c544dc63b8778639271b1d2ead53a638a35836bcd6c1abdd4a854443d6bfea15be6942f96e7386a716e55e4028a48c47f936e47c7146b417eb95e12cb2a7dcfa3c1a10c4390c757c249a529460b21d047def8e687acf61f31c0e2cf1449f5bbb6efbb9c65ab6f02a647767dba2a68701ea1b3b61118050e5c193f2a1c9fe7a43fd71254c09d03654a06379bd173f86378472018213ac82ca35a3165b398e37da4ec3e3a1576f1cb0664edc8408d9eb326f5f4e253a1434048096d133cc2de92782987fe717a63ac69457605572b30b3b3ce21b519964d3509de3c513b4e7ccb022f3b770c135f88adb5b6b7c7ca3874e1ff1230586a72a4a5b3e055687375a4027c8ecb2e9fabb2b3000000000000000000000000000000000000000000000000000000000000040812161f24282d686960fab1052ac084d6e5ea9219a51faf332c6d6d10d4ed843f2fce88e9a24578f42daf524ca8465607eaee2d930c8da29b2270184b49982d0b6949253ae8f988d97edef7dafbed39761b58c751c3692b92b5c84dab8e7a5534030578d6ca9cca3c022bd2744cc18fa5d79bc6aa1ace5f9583d418a62ff15cbab69d7ec64a773620541b9845218dfcb2e7870b068c9e4aaa3221ea8f0109df53708e4f5856632dcd8e9282d67a7257fb0b215cb3abb05db40b24fbb1892b1f88476b7efd1775a185f2b57704746119e674d99210cca536263508b73fed7e0b2d16fa0e5f6c1d2f015656a66ded0e50247919d027f3e02ecdd09f38a920d3f31b97fa4ff746b2b59a64c242073ce8c41acdc87323ada353cd1216f636d23f2b2d551472f88df404565e08450e735799b2aa2689e96e00112bea128f5605a70983aaeb13938ac0b2c174ece2145a0a43d5408385597fe3ed89c92e034a003f242bdbe7a0bf9aed4b3302b278067adaa29ff79c9635fb6005676109caae2807e02dfc00db1935c12b4533d31eb6c9ce4d2c2ffdfe28eff6b877268d1d54bb4c844528dffea10d83810bfb8c58f15a7436e06d65887d0708e7a345d232c423fcf3b055e2e026e68e60b68ca0701d5e1495fd68913ee96be85c437cad8780a0769bd5d75bd6be8fc619189144e74fb1d60268925e6f382cd2746fd6a4d262ca93e491eb7ea43e21586a6672616d655f747970656b756e616476657274697365","frame_type":"unadvertise","seq":1},{"bytes":"000015a8a36776657273696f6e02696e65696768626f7572a263746273590157ad63616c676f4d4c2d4453412d38372d50533338346373657102657265616c6d5820030303030303030303030303030303030303030303030303030303030303030365746f7069635832696f2e6d6163756c612f6d636c2d6e6577732f6e6577732f776972652f6e6577735f6974656d5f7265706f727465645f76316763616c6c5f6964f6676f7074696f6e73a0686672616d655f69645001a0d10e8559724c8d211bde45bd1f016a636f6e6e656374696f6e58303d634ec67d881a7824567ea8a2dae147fbad1c604eee868cddcae2a2e3a58291beb05bbe876efbb655f08d96df9bfda26a6672616d655f74797065697375627363726962656a73656e745f61745f6d731b000001a0d10e85596a73756273637269626572582001010101010101010101010101010101010101010101010101010101010101016c6361706162696c6974696573006c736f757263655f726f757465f6697369676e6174757265591413c4531824d78255307e70f260e8c5ca44e7d81e472dd8fab2fff4b16a513f5500287d192f4448aaa392dbbab3e6dd8ad9b1bf7adde1652918b888cb1ea8f73c1e3bc89fa3788865c6d8ce1dc5196f7bed205ae109b1a64c3b7f2c0597b0cd559d996e365fd4d71b9d01bf4afd75f63e9e8ba39d5a9c006802ebd004c4f2118dff5760ab62837ca239eb8f1aac58420ded49ca0853b7c35e57b69d31f947eb52b00cb7bfb00e91221e5f25a8bed504ca6dd4e618bda9cd2c30d18b4d21b1d9062792f6315f795d9501180911c2633fd086c253ba6341c4468c636552f10f83762e66564f079894d105e0ed83a986451caa51787bb9a19a58cbf096372631c4e1cc5041f2011b0d0b9aed040a0935c79b15ccdbf8cac32080a8d2d1bc6c3d61804678efb5650d7fd7b953d3ecda7d6591e5c8f44738f213ded765060c470be658e0b7b831c83179be854f8def261861517f7562290052b8f77e9f75353eb76611c4f92bc57e34488317f0633902334a8b8fa335617c4dd80b7fa8b7e16d2c517d6292ef829ff9aece70486becf8231d2f0144b05d7898716e0e39c0a9cc66108beaca26ce7a7fbef9542b21ed8258da0fff93882182818f11d8591007d21ab5c300b30462369a30354d0b47ae61a453874a81c6cac9e2f1096f08fd2d5c702da3caadbb4b58dbbe585f5632d0b3689ab8aa5b2e804d608ec3ba1f6f8ed979e2f6ddffb4b51a521e0032b0b4b860b69fabae86ec3a2194473102abb9dee976ac2f753bd7f6f103169e661e172f0e44bcbfd8a9288cc1e7f143aa59dda5797d476a2f163f7a6411d62925b208f55296bed7a779269ac65c5568bab29439a5b5eefc10567fe6cf80d0f3e943d772a955a36b8dc70b7fe0b1a385ac6577ce8703073d358bb1815ce9aeeba098aa674f6890fd58f065898f0687d8ef75901108ec5be53e4fefd91a4f2b179558c7003581aa4e3d9821ec47ab3a18ff17941e9e0d3af28a7a4d548e027740a626c6a4fa78ba08824a4dca119c0cf733adc55a8b46c0045f84afe0ad0eb3299d33034310c3bfdb15e9f6bf3bcf2939c3f92292ff1446c3372873cb98e2d66b3dbc10f176b12aad1674c02196d0e8ac87d171e2028c7d7a0c7d5284d95dbf0d8d4e7740e03e6a02fb4165a416bab7d0bda90d0d489ee4013408e38c4a8194183e29f6f803f1132959bad5ea4d0f4955444315539eb1c0a2e8349a3f484c6a39a1acf6ca4f4289b6958e97bebe8616db273281af94f606a14561b83c6eb314e00018a5b922807928c04f10ad0604fa2e388fc34ac8431c728bd3a811c8c620df46049cca34b0be79f679e02969e7aa329c6a383482a62abf21c23f153eadf17ca888f8d668101839188061c8de0872de48acbed2605d50eb22e4ea16acfc6e0c624be36f78fdb8d1cc3df04d8544395b71a8f0969f0104480edd00dbb9774c84ebd9ee7fd07300bd386787a3544fb61eea6b7daddcff01ed178290abb3969c3e09106cc171c2c97483fb445f2181626b53ca2ea6121939b76756213f877818c5f98a86e499a3155368d13ee58d5cfbca4dd41a8fa519bc013abebe696eb25bd5621c12556ff050f963bfd242b1eb9309696c649795c387c03073d3cd4862dfe30c0ff01d9e5647ad47676836283a9d7f73d18927f5afff2bf9286d694f8824691b2a9dabf00c5e1e60c33f80e764b9ebd4524d67cdc07cd96ad65177bf109526d5b99a21ae67dc82fcc2d0eeb7dbe49bc0bb5530092941c268980e52e7cb3b25ceae2a26d593e97f91dce64c9921efe1885d5a4ad2acfbd62e0a13bd40b121d175efa8e10aed5693246e054fbcd520f93c37985c4307866cd505631ad1d28c794f743d540bb792a342bc1d8bc7f1700043a97967429438a9d74c9204a42a28a786b68d177afd5382ca911ef66fc64acd9713ec2edb0cb0e49794c73ff89dc25c94c85045fe8864338990f0319f407abf462d885e5c68f71bd30f67bda56b98b192e701a14ab670ca9cc54a288af02f55c44b83636122e07f19df2aa3fe37a8f0ca140f5163308c90a637384e0a3aa318c57b954fd727a26af28c4da2da798a041baadf958e5fbfcc58744e388485655712cacb6ab997c324e2c1a3f2836ca688042f188917c4d2187554c0f10988a7ac5b68321ed18548c04c377350098073d1a20df3c5ab7ee65fff42d52f822572152edb5f82e00168dcb8e5d5797fb1e64cdb90c5e97f645d244d1c83d596f368c9bbac0536a188b9f61dda755e36396315b1b860117e11f57a5b9a7c268e4b8cea89eba4060c4c05a52498026915e46129d633d239e453547eeb2c35603c0b918848a0ed21c7b1f28e7ad31c071e33ef02961f68f9efac344d506521bbc972ad59d22c3967953cdc46e8381a5be980a10374a1e00854c5321f7a1df7037c92e42859e0b032aac0058ade28c88427f6c286e9288e4071c1fa840f0602b0bf817b809b14836aa356a1f7ac5374f3154d5077315fac782ebcde8c15f3698780ed9bdc3290d530d0b4a1bc62da6c0c5af7e93e29253bd407d6f0d8dcad3d656e6d772f5beb7bf315a214ab73c185db361fd23ca41cd5391a0008c12a57960e7c22fd941091c7ed183b7655c84bb05c80d725688d50bd668d141f8438bef176b14b9da56c9148504dec99c574f40bde29b6f287cd93e5a0acfd172897df72e25bd9ffd620c7f4e07c902dd7bbfe7bf61b666f926e594f6a6074b8e31a118bdad16eca16982a5124f1c7585cb11266c3a386e3ed0a9f546084def30e35c07939fcaf0d40ac503550e179a3082c02a24bfd917a8468e0b888eca65d4bde6ed9b121d88ce7a2a549cd476a1966a6ae94933e9c02f2263e53bd81775378abd404c4173fd2288638268e1f96a62d63524a7dc23f6316b50130dea4c5a0c8f0c9d74e7ca4d9b08417d35cacff401c40741035a52ba391a9c8a646e34bf0944246a7301ea8cabbf49de11f46b99de37c76a932db1aa748a516b92c2cbe029ca85d058947a179c713de2fdc07039538dd361962a02d9b4039deac6bf480931a9f7d93812b85c536649c3387806abceb15d5b5a242a02e1a63762dc6c3631b35bf95b91a977ea0e2167212211aa30ed44542484f7a4ea7b5d8f8c915fef434c276c0999bd89e236a80a5e4e7c9cdaeb677f4a61ddeb564e684f697f36ca2dc6af618715357b951ba54e481fac02b527cbd3ff88bdd15adffdaceef662e73b5f2a3a35dd32e1b47d799defba0611fcb5d86c8939446bb024f638f1c887525721282190cfa73e3386ca9abb57830d95e7a7916de5b033d17b0b349c034b5ec0d906bebe818fe0db94a02082665316907c720dc1a480092479ca5e370411d4abae02e5d210a7d8b15aec48ff90c6d68e531a7e40d5e87b00a30878b19e15b5d9df594828d1b55322dfbebd55dafa94701346e4c284bf3db6a20f2591828026c62b19d750963ab1bf1ae69e0254dc6eb2ef162b6670ae26baa81b8cdce9d57eeedd89e2f57031de672444d0cc4590c49625bcc3c0b9dbdaa961c8a4917415ebacdf98512019e73da2b155b3b3722d2d1aa92d39f6baf2ff3fbd2d08b552eb6af831e52e21c857f6d2883fd9be1920b60f94bf4c0c75a96769559cd078539c23c166a4038867dd4d1402e0ce9d0194732b499d05b2a51c58f5c5cbc1e11a5d15fb5efa438eb2fb482b594bd7d90c1885d145ef56780559a57d4451fb07504e1f235edc057e1646e6ca9aff11cc0884f4a45897edb7852b6fba1d4a48261d0dae693451ad77fccd82f6405ed870bacc8757a3bb6ca0e6ec94803a0995c1829b28881c95f560132e34e3d385f4d9e54cfce2be3076ffc92fed75687431684912be375a6f3a784b3d05da4515f699f437f1743cca66258b28bd46e25c2d7ad3af3d5f8bf229027c2d7a7d0f5c69a18518199d4f9244b70c3716c58bf8cb92c0e6b350f6b4d95abebbf5d036f3d61a63c05d725e4ad0d1d71183f6f1ce186c0d9fd96ded0ce778ae3f920bddd2cb90c1d6f4ebb4b671ce368f299feb3c9e42ba68df6c73e2bdffd9fcfc18f11153ab8b600aaf20f08c8fc3f6ba27df70b0ee58d0439a8c49a87de01bb6c040ee5c7905c8243c980ef11ce2003b18bcfa8ea11ebbe06d3a0bf45f0b2ced838e08a2e682668e7d7d21a6cce5920c6df92e9331c4e60d882ffce6e7d494b20495b48d0c216c47eb3c3155623ff2f2db3aa065dd5bd99ff20cb8398ff5d4440f7ce3d02dfab0d62552468fd32e6b691533d9c183238e5475c62210faaa7aa3c1af1a0f8fb57b22de8c456a06e936746985be4326dbd95c8bf062eed4877f88cc74ee27202ea816fdffdb34440672770a7d7f029ebf2dca47d8fe6d88b4c6fa68a05b78f242b36e6bbf6cb959fa9e8d8d80de84d80f78b4ff96b91e6d22209bbc445eb6eaca545405d139f649521704157f48ac7a1572f0e51fc94aed324ab5c699707e301e4881b5021670c929e8ae4c76e3d7a6d311617f6bb51f406a111363bb496ce2c6a1e6d5bc01c803119526fa73ab794c939554a8a0a4b134a62da774f1c58768ed7d56f61a6f3eadfbcc29236c79d8cb3f3aedfd0cc94927b18fc9ff651cf3614536ea9f91cf66168363353b168e7d58afe3d62037b21a7b82ace72c553d3506f541dc0095487fda3df6caf25cb5fa3679345b5e2bc91711edc1bf87bdf56272ee8b19e4ad27ba6ede258a0c40ff5f18d84c6dba265ec61b481664615f642623439db641f76a576d555430ceed5fbdfecb1c6f45ca48d10de474970859ab192d694bd181d6b373d4a2cdcfd3884492fd6746a405f613bd10542331e7e5d1db2d47819942cbc11884a7d3965a10aa693698f95f15bc44a1d416fd9124fa97cff47b1de7546c36f92bfa784f18ba7fb58177e820b9de1f252a195d2c56dbc85ac4ee0a95653dbdd330c5f1f0e688d5b012d3eed09e2554a5ea6eb028897027ed552ae085d5145c894db4e4b98cd10f1e7b974511a2ceeb1f54964c062d23436944e3d230257867e1eeb142fa9f48ef98911de9e25383ececb4e6bc588c59092d04de0cc3221d2519236d9c9c91a4131793f3668b03a4b18e533634650a33d1857bc4bd0707fc2f641012ecab4ac925f510da0192341a5498941bc758baccb8f604e5fc4633729a0c4d6b2c5362163da645c63ffc799368236f788c03b4962d6c4b0a815a3e665c9bc0c603e37a344f6ee9e0c7169d61b170b14e36d31c816945ef53723372ad1ea87ace3b8ab397b6614cffc3814069cc37971956dbf8ec1602d3e24d149d86158908e11d98779c33e8ffa935d0df12455f707cf3e74749d0afc7cf9e993bc7a6adc5eae97ec608edc52a0a2ba3a8f876783b6b436dd06f1f40798993e35e211cf67df07463ba608579bd5b373f813746b9078316e75cecf61dbcb3f7cc56f21070783ee569f3755d374a59645aa42eb8ee636a3463a8f61cde405f9550c46a116aada0a6b1e81beccd14f8dd58ef9460ab7be2c226eff09ba13bb3ba89e640f256187f557619d8b5b39cc2fd47efbc627d40eaf796d1232386b8ba5a386d612999ae77332e13509303fd5f399833fd545540372a1303d63da926d7fd96cdb5fccc7c3954017b937056cd9b4930bf3dea2643eeb87a848c27205e769e7ad8b7a742a66e2b9071489cf68a2ade13cc845578cf1637ec02edeeedfe364b3d989b7f7007732a9a42e323d2b6b11093705d668325eac3b9eb7bd041b831fa80a498ed0f4b7f5d78603d880bd4a040121621c1444b15506596a7a16ec93e13f87578cbd0ffc4577efd0f63d7565826cb3a87852fe8f4bcd134111bfe1f7aad8a3b17182715a8869eaf3cf995714e1099027dcda5e25ef01d3cab351b36ecde09710b48683e5f542b009d77e32bbf42b970f8b634f6c968b02c14371aab0f7248d9f32d76acb2c59064e7cdeea0dbf0c6293218ff3bd60a4b5cf04e40fd3d3ed92e77890a624b5d53da6d25d86c181accfe9f986fdf58009d9cc0dfee65610d310cae66988461e8a8dbc1f1769c1ddf0aadc720de15c177d824b15f6a17248945934f11eab6ccfc9ec633f284ee4a91fb62158f4673a8d56b1dd21b4b63d5c85157d99b5090e0b1375d10748a7ef0f20f4de9cc7808297f446403f19bfb78530111ba7c5a3ee77ab9240ed57eeaf23bbc29790c240cd62a12d62262adbae577b1984151725be2d1f3b2ac06b1e53b9d585f314a95f0358d6dfd1708d83fd6afc8687b276773a7e4c4c8601c4a9eef05903324797ee985c929ad616e93ddc2b317359169623614350dec990a0c9bee6c555b63e81ca09262af4c0e856aec3c53dc68d7cb26bb33689769402581468cfa06d1e07a246cb0bead1fb85a32f881f016379075707a4556c93ef9af17c5c41cd45dd82e899d8d555545428b830d6c471369f5c8a6ea5d7fe17a3b2c6ce0e2a3a6d71728b8dadaf223b4c7e8a97c4c7e9eaf22835a7c1e7274b4d9fa1a2b0da232c7daebcd8db15203c73868ad30000000000000000000000000000000000000409131e232b32399e8b61c9006b19367c38a42c622ff7b3a4f9c1fddabde2b638b1afe5e7e10d95520593e289b536253ca05f2397874a570bc40c720c4b8a6965388e63323d4b8bf71879d389b420b4a5f8020da5956338ae1efcb12d87e0ce7a8cdec713bf08a6f54e4fa5c9d383ae994ed99522cdb95f640c4bcd77e79ae9d66c315a9ba1d49ef2adfd1008be674e3dd721ab8656554cbad29c5692729533115a4a8c1c603ecc651f8d100bb13514bc2febb4bd7fd252ca1def91ca4e740bd0c062e2e4590a80cd94d219dec48d6610a6011f0ddeae47ad2d450aca9ed6b827a43a6756526d41b233fabc806b5accc06ec1ab7fcf04ab813c938ffce7059e079a88d279ddd15e07960919ffe2c2aa7d19de55de8b4205de999422bc3890629a26a97f39589447e93c3bb0064c25a1e9d939b412b60b9e279c69245231009be3bb4f67e7ce686c37722b1393cdf4f7684ef6ed2500a509ebf810c9531f3c0df5ac550117b70294709baaacee42f435e87e2b2b3f1e7b3fae164e9ac95dbddcbd3cc730d225bd287d9cdd1a710dd614d898555442047e9a6e79ced59cf234fea02c25e8a226b41a9e7bc4ceeda317e86acc3f02a0e990535fc6edd573a7b16f78b7bc11291abc28a69fb0d2294bc00bd2baf520d852ef587526c35a899558510069fef52dda24f7fd83b49f668c506c70e4531c6de612cb582ff746af82d44826bf0571c60da03f6a6672616d655f7479706569737562736372696265","frame_type":"subscribe","seq":2},{"bytes":"000015a3a36776657273696f6e02696e65696768626f7572a263746273590150ac63616c676f4d4c2d4453412d38372d50533338346373657103657265616c6d5820030303030303030303030303030303030303030303030303030303030303030365746f7069635832696f2e6d6163756c612f6d636c2d6e6577732f6e6577732f776972652f6e6577735f6974656d5f7265706f727465645f76316763616c6c5f6964f6686672616d655f69645001a0d10e85597dea9b5eb92e71d73b7b6a636f6e6e656374696f6e58303d634ec67d881a7824567ea8a2dae147fbad1c604eee868cddcae2a2e3a58291beb05bbe876efbb655f08d96df9bfda26a6672616d655f747970656b756e7375627363726962656a73656e745f61745f6d731b000001a0d10e85596a73756273637269626572582001010101010101010101010101010101010101010101010101010101010101016c6361706162696c6974696573006c736f757263655f726f757465f6697369676e6174757265591413ebde04b03ae2e54a8720f7230032109d388953bbf183458b62821765fa50555dd5ff4faf19772af007e5f83047f3421a97520d1052103ae68539efd7105c646400ff74c0db945a632335c53bd16625a388889a7ca98c5a1a4fa3ce44bc5e03a811abe32b86d66342ac6d04c65c818f19053e22dde0555b73854cf17ae1273f7c761a11dd5bda45e728dd8a393df5e5a18ee788693b9f3e0cb6ab1724d3374d2db46e6b1c2b4e84970238dd39f15ad6c224cadc9dda18032fc8554e52ec6b4de650d450d6f5071180269935877f0ac5ab96b1bb0531251a8a33b9f782714db5f8e2d4b6f935b564502a834e25e68cc73cc4e5a086b0fc769891a9b7c7500a8b21d2d18954b0c8ddacf4b4e7d011b3f314627df866167317b6b7a22af4c2a7b1325df906fdde31a5a4a2edde666338acd13bef5aefd251204160f039baeb8429d7978c321b128709531b5e8071fd400a9a4b1efdaf3cc9890eff493e1b3df69b7ca90a9e3f5ece804663fb02581ba017c05863e9ffacb8c00a803b044a491746831574fe7292e188eb04e95f3f7e0f20c82098ee2c8c0e652212b2009c5bcf631d8b61621881f316fd612626d38959f2a30c4234c9e2a491de66be18bb380cef9e734b4a16a04eb271e285d8e96d46b728613eaf426dcd51f540afd135519cdc9c3d5514dbd79ec2d85cdfedd705bbe9a1f6f9e52bddb4cb5ad70bc1185bd139ba081558f5ea2ce832f14d6e33263cf7e15f28b99c741435e112ac71af26db087f9f987506629b69ab6e5030d0abde0f02d27fdfe915c84ba0d23a1f2ff07922c7206bc1972fd59883c1ff1b5f895ddc1466887c291446a66c001b26971acf21cb57620a652b04459ca2280dfb4e97a5f35c645e3952a3e02c608df1fe3150a4b034185722cf6f4a0bf205eaf7112aa5373ea159954e6458ec93fdc892f500913d2b2bc8bd28155c6f92b8bec7071516db099735ae058f5b5cf4cb322939873eaa88c3ceb824d82a3d8eccb742de4c16e3f89255ff2922c7e3e544b437f0c9fe10d9f511ae8297f413da235516a6c6b10e1a4ee0699d3ad98794c2ae557449ce183fda29433da07e0b4658d56462798116363fedfbb22ddfe7c46cf8fec222a124d8c4ae80f58dcae6ea1c76afa4b552e55f6c49f3f863c1ae4269978cce3d51c0ccf8d08ec3d7595137d616c919b96853555b2ce6c7f1709ab679aa2deaa4d26908769ce98792375ca7e34dc09ad48c66a36a8ae9df7621119caf645fdf367df33365c5eb50f67b84847dcd6fef1eb2c27e53a10f1fa8e9baab9d41679c8ef30207a22b760887d3af10192ac37487cdb03f1bddedcc79fb562f294282d6d8909e8759f5402d9d649843fe155db26098ed33feac0340542fa63fa388d1c2fd095c4d7bb18ef65b1a3c153d328beb6661962674732c0c12651b80f86de3f5f6e56b6fceadf56801cf8d045c94a84c94810c41ff23bfdc42e56c58bbcb329239bc305ee2514c0ae9cff2f8bde65172f6ba0c013f927bec8311404f272b84b145e3594b66daa5432cac1df4e7053f98e7f434ba4d4c21370846248ad23be30ffc34cbd9c0a5b2fe50bbeb33711718cb4c99b551036493fb1db3899c8fa5988f24d9671d5018b32822f1bad4a3da0196a7c20be504e757680640df8bb2df8a1621cf91d6751f32842c540660a3bde83ebb48c8031e36f1b529aab46c1f0a07e0e806de2074e86555f25d415ad6e8f43787c9e5947e7d256817ca55809ee4b4b5153034123c63f320d2f085bdd06a892bfdb6d21e00096e42463dd5c3d879bee3b665b35605618204ec7e4e97984f1019c00d5bf81cb885c3871708ea7995d07e3b6489f4c8c8c89b9ec29a0d7ebc76f2f54b727300bc0e0517b5097c8b23a154494bbe6afeaa1410cc57d1c82bdad60e6bca60be16ddd49fba02d56df822190dccbec90f97ad42b8133d593e3ca2a8291fd558f866aef0a59ccb37f04e5565fee38f0fddddd0afd1a9e451b92f3146a63e3bac1213d30d9692270c8a7518ebc6197a76f3b389bfd012dbe1ef5df92effd4c3296a23d5439368678a9e47f0ce530645802cdadf7a9a0d6a52bad58369bd748705b0e30ce686540f56dd9d6de6224eb8c3db10361a90173bff3143041e505dd984a5c32d77913e4ac56caf3df715c6267594ab5b832f372aed8d3807ef41c87cac1d3befa90692e489aec1cb0ec734546e73bae3fd8b84834466c7f9cc18681046b8ce1af15a13a221e94ec81ac0d5e3f5ae2279460e9422452e6c5b2dda9768d37e45c461f03d342c9b237b3fa14f703043d9a7bad0e163cc60addc7929a443f9bfe1c2336cc5a89bca9daf5f98bcb165aea8367ad8c542b1f549dc30e175b6ba8f925abb070ae0c76f3b525862d385049494bbe47df9c1a075a40ddaf105b41deb91178c11dae252617565a08fde5f2a1dfefb12aaee82bde0c047c99f50a4360392052533988a24361d9ea1f36f545753bcb231b854c29dbbc0f27a920ebc355872566de632a0dca792dd77e104e041b39530d2171e4fd3c152ca9742835ea7eebb9cf9ec836bb268080ec48478f3caa920af1a1bea6f34371bf5fdff600a328c53e195a48224166f1fb2b877b093127d7d90414e9cc9e75b3464f8bac18e5c058f1790af9dce06439033f920b94f910a3de01bd4ffcc30747df8e5eb28c5274a6db4ae68d4aeed1dec6647cba76008c9fd56a28e363b935c709453dfea51e3d22b7551a5e6c9ed5b3508eec7c5e1191c8dd37e37c555e0a83568c242325a8c8bf62d84210edb10040235c37831cfd002843cedde8e1606ed0d5819344372a3f1e487fe69366ddaf16187393aa51dab36043595d359d7e89216d01fcf69d6c07aba04699e43cc7af3a0006c1777bc378784718bc9215c4db5090089c43fcb0f238f80bf6be912c60e84b57988ab9de7c113c9f937b9dfb92c06f20d62a023b0743bfa959532706fc4115f534ceed1db64618a9c35472b7e2fa5dee68584d569ee4fa619a324235bfcd8e18ea87367f3af60cb21fd57acd34a540acfb07a8ba404b27ff2c41abbea3daee8b9bf6e4f6a4cc95d858d081253b9dc0ffaf868c33cfcf9c7765d0217128036e97e8f723ec260960f236324d668a6e31f54f3f4e704cfc7af9b8b1f12aa9a984d1f789a8b2bc6eddb0655660e0a8232dce38e486d8029a5feba8f80d6b9565052038b68aea22fb1944b8b6bb387e55e7c40edb6ec9b9b557dd49c2683b3693d35f365e8a7eebb6287685268579759014d46f1cb43a32515d35eb412dc73071b09706e9c6c9e3e4d0bd0e008e24747dc22803639038ebe6abefbafb1153704ce59431e40a408b096ca11295e1ffeec414af5bc9e18fcd15ac2b599e5d3fe88746b99669c0d5b67da481af71c5fdbeba340074ccb7563b64e89ac72fce8517d87d9f9674d3bb5593c1a5626dac4f9d73efb9dc1646043a2a58d160e48d5ebaa7d77c42e66f977be05e8ff860be84eef217697847a4eecdb1ca3bc942fcabf1b490cfa47c9e77a2acfe25b894c0893fa6d45faec9590c6c6e61cf1dfdfddc2d0f97af4a4704a8b34740820ffb462170d03b0a79eb1a73e267528a57069a902653f1ef2cd5246e42cdb4d2e16cbbd624a0e161c8ba76369f24b54d23f005c2c819bfdfc1eba2af8ccbacb343a135743d65708bb36fa04189acdd53a32305898e62c79456006526497e66adb504343d7f0607fc493498203062b8fcd46206bce27862cc6f0c1d20f7d4a125b1bfe959df323e0b8fe02af266be05dfccb362141a1c6249c7bb52da2b5fd8fa76047a4ace8bb26c3022d86dede9115d4ad3e75f58436062797c234e07d5a9f88861a15365fcc921e1db2ab17e8ea896f33d93fbc96e2d950ceb73e4982f41a099b37535fbde81e10f013d90bad3cbf38e70358089091b5fd0cdf16f87edbd8fa9a7343bb815c04b305c56b48c046090a08375d06c660489a47cae2e28afaa39d2d9ee63c0ce51746bfe5ade57aae0d71ea66ab17f3803f59153e0e084d9dc8d7e0cc79f3f1d005373c1a9ad53e5701a65e436f3df96d145b8fdbc05b9ee0ce7812a9368160a24246e86f74787a71413bb7a6e159030c678c4fcacde52e71bbfeb9780e4de966a7a8a03761646e145ee1244ed5d80c551c3709b3f2eb5008ff6fa23d7d0160cc500b525110c0c82d0b680a62aa98d75a052c3710709f106a0185a786615f34c6ffd8f8318b93cbd68ee57e39eb4d2efa62c3e59081977f8bd07efbaeeb9835266a0b9cc4e0fe143549ecb3f2476850455ae7994af1f8e5e8587fcc143d870a0fb0f148d5fc0422800778ae11dc2c351abd77756c5964d75edcc8e5b1bd0cce82ab19ea91b54d75984f108ea41325d359c1dab5933871c328385099871a68bea35576deb18b01b9ffa34dc260f661a659104219861662ce229847a5542f83751a172077cd17526292d2c5fa972095debfab6a1b292742b4f75f9be81bc53c3b38b54822516f716c2b50c568067c03d44595d470d06031498d56be3fcd5be6c2123b0127c22a7d4e3a1b30d2051baf9987c758b56657bfb76d11427c520b0a20f1cef00c16f2f1dd13330f4cc50d65c7fd435a5428adf2df4717efe387cf99155507f7048820fa53e74fd2e6889c5b34f71ef0e7c1faaf053a0b18d74ed7945cff6dd87872c6ce69f4e11354b74e880ab6320a91f6fadf0a38928d3cbd243fe1f17ef286e8065da64590552a9412d75e71aef11f8a549745c8d0f6eaeae4d9eeda50d207a05db7d2aee4687bf0e8986101a67a05a7316c6e4466013f3e0cf2fabe8853bdfad78a7d26fcf16c4c4d3c7978580067af549c5aa193cac523251ea16d64bf47753ab97840db36dfb1613e2aebdf5b4be2f931bfbba62f35a8e37d4998a3bcfe5c78e049a211a1c0037761fadb666f99a3ae7b02056d99567664b7d0aba55eab3d02835d78e1bb643b65b45b9c4ff162380abd26519614affda6a6dc0e2585cf5e4590f75d26a881c0d4ae3435f8fa8ee077e7dd6041ce83b3575bcfb78c964e72c63c80664ae45af1c64e1a40dfce98a8d08da6ec68339392aab1d00bc2421cb637d10eca2f785b0899d3045386186cbffee6364a0102a502ae8b9d786428b9e7433fc7584698ad533b0f7f452c90ddadfb4bfcc833bcd5e8810ea886c74cec8bdd9cbb4070a9e773adccc36e6ae9415098c5b5aea7510bc4d87d233e24355c1bc3f636dcf96b95f5346069514d2c019ec39ef06c6a8ca2eefb9357940f87c5404babec6c361656291d331514142ab633ccc29fcaa23848960addf1f81769b145f8025e9df792462bf79a06eabf53c9ae07d5b198e5f84ac543bde009e6539d464ef99521236581b3cd3a92c789aed42b29822c0993adcd25a9ac17c110ce3fdf39e40c88342cdca5f63fe0f0e0a752c05425a5b408351c129075dc12a9705d74a74b0b67bab01aca19111675236ef4bc6729fcea1e594811bda882ef11f3ef17965e3c8b1acf6593c0c6e0c6bd54487de1a89a6e1d01e2aaacb3dca21072893cf4346bff0fb8d5ada04a71313107f63ce9e340d9e6cfc880f2bcc80ab917bc82c595d61931a41eb46a52360b4f7b08beaae354a4f4fa9d7a4cd488923fddb76c4ec00a2df386fe7951a59a362944af7891a79ca5617b1dcfde55d0c9da0ce06ee5589bacbfb154f342b6e7170513857321f1297303de989f8c892e7bdf3eb888294f3f08643c710a63bbec771570452d86bb63d39f76812bb1349b6a01ab5c493a5934f5e56363079e06605fbea574d6b342f697ac9293c647d0b2d24305cc7f0678e8ed0f6b4b33bcc0a834fb7c746d2a180e38fd68143a94fc1d435298f4e1d016a74c6a9c1fe1fd114ce7807e2fb3d71ec8faa4a1c6ee7b9b0db4c7183c9ceb152ca7d93a6155bb9ac17cbf8b9c960142cf74fdbb3f06782fb46b16c9d611ff0b1be9b8300bba05401a60be5809ba06732f005683844a7c85dd78984d4045110510112488531a8f7243e6fc2075f13488650198b0fea8569a51bc802a41bd294494f370bf2c2e01a922eafc2dcde8030e7f0dc9e666bf335e97084979d31dbdaec6b57d8d4d5f736220d37f927528a6ed66104e47f51b37bf94af1844abfc0900520583c34508ab54e27c33246013666cd7abec9cfa6288dff261a4aedc90894ee00f95b283c9b5a6b23ba6afd36c39d6df534f6960dc9eb7df3f7d47e51f3cea0d24f3d4370acab78c9ddce2d6251eaa3f7a6630f047a925cdb0c0ea400055857853bf0debd7314503755e6727801cdccc835a24f6aef820856c9fb1e0e3e60b6376108f654b76e7349585f9d73f37042df68ba6f146bf63a6200d9f3ab9411e6cb0bbef220c7518226156a56e1ba31d832fb73165c9d242595006c3e2e837d1c06ed353db719294d3b29efd64eca4d227b6d0363e6d6eacbcd4f1fc032025329de9fc737779860c0f101429414b718587abb7ccff364b566bad103957aebaef292a509cb6bfc0c7e7000000000000000000000000000000000000030c1317252a303951b586a6474d8a5b7c0468c0e2b429126cf46fbd02b7f86a50f79c232884a565dab305b980a0e363fd88edc1a6380e33989a1fb616d32246e34d5746ec4064d442764f8c7b7a96d86e7907c2373bca6f3c3b20e3ac9362a6ddde0301e190b3c9605fefb3a2a44d6bbbcb92266f0ece21872c936bbfee548e24ce6d1580a4c27bc8b2165c4807bc571037b9a6a5dfa9b0a32308ae72b308b16c4feaa33f067d1f587dc7f75ab2986913e15dff5c11c49b52185193726b4a5efa07e7020471e64ab59d622af2db25aff9d806b74863e29335563596d1576d5d313f659b90dfab7088f9f7311d4a84db44d64cca0a110c58a133dafb0d19e4bf73199be75ac780f2df71c30fb0da16ba257784d641afacce18ef066e3ab682571454283adbc735253886d806ffdd6444b9ddde48e2f65b22b24a09ec318179da2b2a38997ee10c4cfbfa3f77972b8bc47ed8bd016beab1efc10905997cde47aef04ed30be09341adbc09a0a7d405651ead71e687a8724bd0ccf19aad73f659a51d18b342978049f00aceba72104646a1e89732db6ea13fa02a2268eceb0af3924c788fba7f958fd04e2b76d7788f850c8961260085f844476b7f0349c54c7c49c061435160630523774c63dc734e0aee845f2968080ec91581a024d8b292a12d1f397b24851960fa1d876d9692f805ff0ff96efe113f473682bbf2ad2cb76ca39e5e482ce3b5dbf96a6672616d655f747970656b756e737562736372696265","frame_type":"unsubscribe","seq":3},{"bytes":"0000152fa36776657273696f6e02696e65696768626f7572a26374627358e1ac63616c676f4d4c2d4453412d38372d50533338346373657104657265616c6df66664657461696c47636c6f73696e6766726561736f6e666e6f726d616c6763616c6c5f6964f6686672616d655f69645001a0d10e855970658fb7f7e9e24711506a636f6e6e656374696f6e58303d634ec67d881a7824567ea8a2dae147fbad1c604eee868cddcae2a2e3a58291beb05bbe876efbb655f08d96df9bfda26a6672616d655f7479706567676f6f646279656a73656e745f61745f6d731b000001a0d10e85596c6361706162696c6974696573006c736f757263655f726f757465f6697369676e6174757265591413b90db838c745d315d29c7e1589519d492ab6c8a6cf5b83f39b3d446549020cd0104500a71fb0cfba73ef41b4c3d27bdee28dce9fe5a4dc8b6cdd6550d773ad371f984a6e57026c391fce4e92fb1689107a08d901742c30c536be4fde708dd58f150dbd7bf82f949101502ab7e3d24abc6be93745d5d89084ad956e07514adf38b3fdd9d7ca0fea56a3f8d57e98c1361bbbdcfb58c20a0923da9dee819b076d073c33fdda4a89d4c503a46d6a2076fb28333300e5ec287472821b0db3e4f1277eb232e5f2341c172ea5a2432ea19d497e048fc0349916f6d2a1cd5ea11a0239ff92b1565d2ae9ae85d0980f6ca6aef688808c3c6dfd97bd08f118045fcc184aef8f0ee8020d31b494a832b54f4e31af3b230f9a35536c53e5496ae39821ecae6f13f95e9cedb784a34d5f09f3a040450c4949c2a032a8354f0043205b63a991cd0e2afb52d94edea3ca30b50b642b06648c12ec07cb18a3a48f98289b7845c911601e3ae7a5e6232f8ddb96f041ff9973214768dfeb6a9ba09ddc9f2200c9dfd945f60f630cdb7bceb0156caa964c3084bb7190c829ce257dc887a8c05cb4f50ce2dce5295787a724e1675c14eef294dd99b79e6a90584ad3d1ba9f40e258ade023b9380e78c1715225161b8d4ac56a7d15f690be1676af197081123f68cf134a878a31238292f30137bd62cdca07bc403f5587e4da2f6deb4273d8284a7d2c63951ab07867d3dd17e6d4c5238fb4e443912aec85e964df05ce579708f32bba10b4518ac248b58b6c0fbcf0f067c73e93d8c00857c733f42592d30fe30c50d0a831b500730550b195ea3f662c37e5dc21f067567d780c4cd51bf58f62713bf900e473909bd9e683b8700926c8dacb18ec72ac809ba9de9283fb7799ce03871d829b7e79d60827f3da738dbd3fc0454a70c85e4c002e549d0e32501d28508e86f3c25d3b4912b9d5c6ee8d6138ef18d4f341a7595537c837cc6f67ae42c4d7e0b5cb785f7242cc63fde100d06650e785a51c87f95ad040373394b5463a30b2a5c55bf734b4e26e403902dc1ec3897afd9e3a823bf1d5c420ee1b876e149dc163e4a0c0673abaa695a852c81f127771e5a836f738aea0c0b6b38794e4a448e3805d4d62e849cd4137e0a9ad22c9bf6ce57cb8730c29bc6a698937af8fbde008adf744dcf49f30796cc12445198a40b0b4f2a3035d608c51ae3254750515aff570a26c902dc2be64292f6fc2b10223da37e3355005e0cc58ce2c95f2e2793f1d4bb1189fdcafd013b105c99566ebf8832a122cf833c71deefa021fcae04102f9f41c403d3206b027a26b8f49976ede7617fd6af4ef696f771f34cf924a820d7960288f03d74477f41ecb7b99e854814f35eda38f13756972f74a43b6118f95a1ab68be7fc56cb1b43eedc3ff050c5e14d61720dfe3be73036775ab69ff97a8e25d7b6ead690f714865def96fec5200bdabb2429cc573993e23b92bcdc6863126609026331159c65d096c923ed8f42dcf7cea132c7e631d1a8d5495b43506eb7effced827e4811984ceefafdd71458e53fd24da4d3997c3a69ba04ba18c22ad8c02ac8a37b590c3fe5f8251abae7fb61b53fbe8e4779bd943ce354682861fbb2fb06e4b3f2850b06850b77bc393af4c64fe56a9ff4641a2bee05a878edde33d62b0b974594ba029bf8e017c55569b2675bb1502a58296da99fe6e392bb951acbafc2524f43c4e0758c14ddec0ce998755eb4bf20ad7a474f7e271703208a5fc22d65c4f63268e3c5e37e1b1d9f45286484264f85c4e1c85c715c086d585e208c07b9b238e7c60688d32bf32205c82d82c9b03edfeb054ba95ab6c0bd925ddac89b3eb8fa037be5b42b337598e6783d5ff82f1cd8f07f650a4be00a8d873a2e9269c10947a76a6446d101e85dd75f315ecfddd9382ba995f0ddf0049531ba9eb038a4100debac710129ebd94803e6be08e21745469768772320f80f71c368e32f2b4a606c321f2b6fa34f388a131d9f78d432fa1396688777e512481400999ec8cf4b51eaa53f25af11f44c947f125bff001fe38a3e43ba97314e632e34937d9f8d7abc55b9c00bb2ed2291da48a41beff6acadb918ff7de72ab9e1fe2326f0a2e846f978e9218a6ab400f4365b83847cdecc63e6a09c3f89a2a9ceb1610be811cfab01c0fdb1109506e505dd6d190b96434ad573bfd8b1d9ab7a2e2f8fa8415496c6469109e8dd1bcb3c16fe2d87aa4106339958b9220e8069d80ed3b75d3b9991039ecefa1c5c461b05102b74a9a746c96963c50dd9d671d91cfbcc295fd740d937f598c2c10ed785ed5e458d293b5140f34685fb1643e971a074f242821b79c41a5276dd63ddab42bf679e1589d15784b7b21128c95b36f00b4d65ae3b57125c8c8a30052e700197d05ef89439592540ec7b391f993abc2e16a939e5cb5a404e9d12663fce994200070c9d8f9614057e8672ebd8a78fc757e405baf6a979af32b8bc4bb675a4d0bcc6bdc1904d51ac72d2679a1f571119acefd138ebf2c21a8f5b05277da1fbe916b107d6791eda6331fad3915e33b12ca586b04c173195b6b3238fd6827df04b1e249fc618abd3191cccdcdc4b0e9cd35561163432e9951f51492400d0be560a9c0daf0b0f573fedaaeaf942d66c0ea3c2dcbef6f7d81d3e18bbc8f360b5f23b97c6328cf6f4fab4ddacd30fa4e27194542c4eeebd7b470f1f65ea9ea137ee649c25d768cab8fe1ee1f63fbb23f8b92857d81df57ec4c02bb760c733f8645bed793281abfb313ed838f21d1f67495097d20fd1ffc05a08b119dfa100c78d4c134232769a1abb3581632c1299d25c03ceb27c469d4284030381cb22ef75508e1816cd3ecf35d5a005f955012fbe603d0a0d8f7294fc94f20e11f7c62b8eec1e84fafa322df8dabb37a3240fd5ae116cc51e3e302277be027b885cd23a312e56a1f6f3382618cc6f4814d69ea567ef10c7bf5b77570a3135903057a6e399aefa4a413e012c707bfb80c7af12571fe05666476c157a9229d09656693ca5148d613416797f481f7a94a47b22bbdabd98fbf14500d53f9e80f408e5edcd4d6ed2715a012fb5fff56645df0b6d5957b4ed24812c0c0f6c959f556f2df2cbfba16e9774057a3869eb4daca3906600efa495c5a47ac30c827d84cd994fdac0854c05667d97a8c767243251bb973860a15f15cceacee66109de3d1a5108766a636bafce7fe3e0c68ddd23d030834aa82babbe50b23dd6b4ae4e02cc7cde3ccb3ca65b4999a59861441ee10ae90fe81c18349bebfe7ebed06c23d87661d8dcf83ec048ac048470eb585aecddb721f936d68f1fc770278e668263dbfed99766233bdfbf86376aab72ce8a2153af5632f1fb287406c76c89ed168d7597183cf7491c7ac45d64d58d73702b08c5a77cfe3a7364e36e40b84fdaee1bf3f83620e855ad6a8198c693a75cc68a541c4f900433d522eb853b2a1d2c7e1156e4b976272b7a7a6ad729ea4e233c881f26dbaa799b774fcb4b9efe681ed0f6f6fc73a2a423d5a96ddab85f4bc156c123bfede9943e7e5e9d2c6d5572679eb1c9522c454e96790988c255edc412dd31f536ccd5bacf7e39192b7e6b9b93879b90a4a4e5c666ee74aff2cf7c3ed2ca5943c3771a93c8e091f54a961ced82734b66445c9c477727162c05cbef42a69f113e8277b9ab60afc2a8029bd3d1157a5cb160f45f08cf5144cd1123ff68cdd572aa9a676adbd76edb70f8d2a44161637084bd7968253632555ad95c27dacc0a55ba7c55cb08e32e2ac64cb031f5b5af13b4fb75b645205c9b926fb90e518c35cb552703fac5125226a12343d568ae68866bc50708b7fc3d7a19d4f3ba1111248b75704fd71357e297d94c032981778089bae2ff7b70418ae022111ac9820894588c98b8363afa0b9ab5f583c5a8769563b37f17ea980c9bbf46dfbd5f07836f86fbc23533341bc2379dc350620762bba575e66e66a2471e07a96756d26492863f1f7c6bc1e84484996bfc5d1b9306fa956538881a118199037ecea910a6c734727b6c3308013cc543b6d0e478abe79818cfb7c9946bc443bb32835449051d09bd828733f12e47a8398ce8abbb204ce5296aa315305a81e2ef18702adbf8c750cadfe80c4c21fad56714963c7e007572eb79fa25820027e924fba5a4c5afd2898a07710185c8eed009c6b37de5a392d0ccb087a809e231ee76e7ac975292920e4e44d076ecf9552eaf335abfe3aa472c31ff88233b17850bdedb8ae0ffae54761d6b0be6ac1de844b36d73f4160e9a83c581b0dbc3bf8c5d3881111dcac9e74d359e4d44996c334fd2a7b9b081dad69d240cd8537bd421390cb06a6a5d4cb713c09fd46de360a0e1b367ab44e0f046124399c59d54f0c864298d7efd9171550adaf50d7eb5a1a41285434b3dd4976af7412d86f0927035b4ca8a3ea6bac9ebf6e5b86dc6974c95b80e383c22086d71f351086f97169a5132aa9c01b4c87686cf5f66e0073937eeedb01dcf317961991cdc697acca3cb2af9cb9cfb0c66e8be6bde0a73bbd408a99c4b7d9475d190b54cad34f2f9ef67b3a05dd411ea5d5259ca7437dcabe2bf44ef090ca0d86bb64a8de349bba7b9359a5ee701d170d15437884be3116aad28d73b90f19243ffe47beebdbd914d046b699a7c334334db82b065941cffbf7f6e26fa51ca66ba6aad1a69cfdd1119669ceb8352b4eb975fa79d8bf6c9d26f9153d5a4524248907787814d2cf175ce0e2d6a3bb1d039d313bb5b83f0fc6f7e6ca76f84f57a97727f58e37218b8da12147a976cf87b9b65f012d57eb0e6d84a06a29eb79e3730ac279d16ea650bff2266866f58c0a2a3ad1855792d86722a1a8838f74f2b3fbd173d38862551eb28a55ba56c40573fc8221cd2cf9bd941e18e54f1866e638f9078a63ef46bc94eaaabf44bce811383beadae5ad446ca7c630689847129d4e942c9982eb2a72a9de7efdac6fa424fa83d95c54c4d9ee90b49fd3b79836739f779e284a33a06a03580e106459d865798006d1e79e758887c8d588f8cae16092196430d6bbf647469e0f61bb9a3ea6b0d04bd7ab07746b569e4de9310e92dde438a68191ac00a094614b7447ff6ae609c0862d58b72fd4653e49a1478689e9a5aa8feb97ea4c64e6cc70579de84acc7aa2e70498e5c1ab8cbe57914b73f43792140b26bcafcdd18e3db1dfc18d9fd799388b24ea76954128601f668ffffc0f4bf19dc1f4fb25b92a94aa54b282bed89650d87f371e3a6368c02637d5d921a5a7fde4543a5c28269ee1664672ce150ad2ba1d43847873092e41a3379fa2137f3e658e433dd0890d62ad7f48e6ae1e0e31489b1fa3051fb81e478958817af361e14c18ae72ac99b37fffaafbfe56a1c84f5ec4d61fea4c33708b63781449ce3cf8f439bd72aa6fd7caaa9d4a4fb7cc629c11e663d7ae3d8b9896a1d3d96285039c098a1706d85fe475be9cd29c8397bac789ca268842a24990a4695da088382123eb2b2c80b520a110a81dcb1f238cb5232228680b5dad4b0f367dd86b2064f650f8f3113403429ff1af316ca40b1eecd41b50e3a765492d8cba652b4f5273af3bd50b07360e8c6afcb58d457971ab556fb4291c832289bfd8eae9af6d4ed8e6a1bfce1af543069a2db841ca4cd0e3a49e90505ba4573c78bd66cb1cfd3f0b07f23588ac7d0cecee054b48bd2242a95dd1b8c52436bb713f32fa63d2bc67836fe11d74c448dbe930662c623ad1309d4b5f101183f31f446fecf2ccdfaa3185546ccee31af34dc70c44e97a567d9630dac59fc548f857d29a8cca54eb241923981ec863e2e60649c611d369543f789ede6e87e8525e41ce6bb2365ee6c18c7a6848bceb31950599de1e5d03662c99484160fa70ce3363fbbe1d5e6dd2322a406a7cb86b6763cc63a705184c33ee1d63a8bc7dfb01103852ba38b1aca40a68c1468337918b7b4ddb70dc745c30f153ab36af93450fcc782d55364d64c3cd77bfe1ff04a1b760192b9a15c6b6906bd218beb9f361f08bb9b52848753efd8c6871e0b29184d3439c563ceb26d02a1cbf185cae76636c01b21971eeb8c9e5008ff86f2a2998342ad83c93410b7ed085339e711221450c487964593bd7d09d020d3c71a2fc9898eec31d96b26d8cc239ec6662469c1747a437d78282decd87e5c1eb3358cd8b887c9f07aa312750b6e2fd01e67ff84a7e42caaa1b6991a892e7e02cee8f1a01443c5807df5dac0f4cce8dc15f3b01d1dc2618d3559bdc65ace64246fb01fe1f21b139e44b4b2f099a5c046b8affabe8d9bdc143eb2d662751f989a1d78f61f0f18fd6615323cfcca38ffc15ad3024eccbe7a749fb7264855e48e98ae5deefc556609f5823def28959e6c60eca16df22b2d79c69e903305542941d4461d52bf84e499ed901c929789ade5d0d8089a1a6adc0eb18396988b42d81969ea9f8376482b7be1d5f717d95d5fb0817304f537097a2abcbdde4e80018315ea4df10153f656785a0b9f300000000000000000000000000000000080d13181f2c323b02926fbb379f8a46c09b74cec9e6f02ead1e047e888fadbe0f1b9077408b64ef80f86ed022885104b2950fcad9fc062324531008a7046fceb28ee006ec3902d9a0199405b38878611b2526e12f82cce1afab520c691ecfe1a9f2be4b4bc254910ca8a95be054ab80780a80b5a8d9e66fe7163c4a7f78a67e841e860aeffba70e0b7464fb2b4a70ac34edc2b96d89ca99e845f48baf2c865de989c1f0cb3ddd73ac6b2f72d67e8eb0444d201041aeaeba54b87061259374ad55c0d20601e09d1017747067d95f023a63f51d432d836ee184ac1b212a169f52c733b017abe893a12b2d413cbe38dae1d30a7239a9c6db5aec9c84e7e99069516aa134d5e7b5bfbdf0b070994a84bebcd92cfd3f0ca3234c8048133aba961f07d2f75537fe881230bc90f075c4cce3a6d106351e7e4354b41bd8f1787a30836b852749fff8ff6fbb8f8c98a78b71dba52fc2f2ec9545fcf22b692663ef551d40e9950c4288bf2dfd265f4c57c756d4098ac2cc0520fb739d18b51a47cdd3be2d7b25f1b08eb274dceea941844605592b57ea37f47d84c871f9101e8ebc6e8983700ea4e7355854ea85041e29a8b9e52a025087cd84b21b9d139c9f121d1f6ecd4a94721931f2bd95ecedd5faa9296a4dcf6cd1d357a57ed7554cc0958d829ab380695cbaa8737e4e19c8465164a4b0923cbf93c8b8eab652062bfde202f6a07f9fa359dfe463ad606a6672616d655f7479706567676f6f64627965","frame_type":"goodbye","seq":4}],"peer_key":"929523790864676af4784811f207c1251f2f51cdc0f7396ce539a680cb4739a48e46df873e936cc2cede9aa312e0020557dd1b11d8e29cc6c32c98df385e37144b7a2f3e94ff2a047f42f89a3df5bb20386ad5361aea2d200fcf5f4bb2ecfe05fab6cf8b8e900bbb5fc4c8b18805c731d48def82b02768f5b289e436e6a0fd68b7de657300ee9ee729031ec7bcd2e2f39836242584621d44f4690dde962f0ad3b652b65712b8bc22203306fc1d585fd4795d019fa2dbb252d912b5d716323a790fabb48658e8fb6351b407caf2b63f015eb27e46762445c6025920aa38ff564d34adb2f82fca088d05812f36850e1c5f92c86c9c262a380f4482adb61d2749dc1d25b360a1badd6fd486b01946eccd106ac1e73ea39f30372644c237be40bd71c866ce32e5266d9df9ce3fe49eb457a8a2c5408a8ad54772749529559a6c58dbf14439defb34cbe7a5cbcd84e7340fc4fac1dfba90c18b5cd86dc0a74a3a0e7b58d5ca08c4e5df5a2709bd80130e83aa46ef671957321255e88c1d1712d39baa079d6b36c7c03de2fcfa3dcaafe297ab5a809de6968812aaa000a9d4ef3ff0ac7814b7e06dc64cac5afc89678137b78d9aedff27705064fb1de695f8a1d5cc4e7f496082881b8d6f61f8facba731d50a32f8fe21abd6e3decd0829d142873d4eb9794df9a928e45b3df441edd6e39fca8c1d3c1ba1badb0f1eb62a701e296cb042633f007e0cfe000cca3f8ba05880837bd3840e8c993eca3527242b90763dcdd1d96503a6a6b23dd55debb290047052eeb8b0c2fc1c86e2a00429bc8209d5378ab099830e697577bc54c1f4c9d62dd40c5edcea3f1c113b8bd7e6a3ea94acb4e7c2fa6306ee5b8801247d73b59221f624835843bfe5ea713e18f58f636ccaad8e546b36964fad9cf4b9b657355772a360fb685f7b286d0beb3de9deee75f1b85375342dac6330ef8742564cc7f792de3fd50f0c865048e77e73dfd241f2dec601902466d34cc8ee37786269f055dfcc3902283b94e79057c41c018291521fc213cb5bf3d428ecd7ac5ed5c705f35ae26e845e95801246f32b14d57fe3ec2b828aadb4e9d70363c66ccc6b0b5a8fcd868b8202158a55c4e0a0d2a06e8a0344cb9266487120412c4dd5f40f3d609330be6208bce58054b48013daa112c1f27489cb4945e1ab31f6942d83b9008d5d5520db5bd93df746951ea88ba40402bd96c474a4804946d100b7978df47a15524f84e2697fe34a6223dfca75909ccbe9a65fa7e44340d498db8a67c791a3d69a4095b699ce94404d12066cf0096205f623a873655c0bb008b04c8888ba739a9848ed92babaf192e198d6fb1a2da732f0db06edc6202c75eab9c39876862a02f702a92c797f363b561f4681b3a2554152e8b1bb0f8cbda80472bcf07d4c02b4fb46fd35d698ca0641f2776e778f4dcd0f19656e40664433133f7d0ad5421718b7a8ec5fca8cdf73319e57d55896805a5c4afd4453f62631347e700c366613f43ade99835ff3e3c0abbf2ad7f35e35fd6a501f021b544c4c95a88f056810721441627ec37c3e86c57f993d71afb4b4d52d8cbeb38cb8d16b44a6594e02da5e94bc8b505a179c7a9c84d177b2fc7d8bb076b74203c458e6d58ac34225c78a65ae40f8bbe3aa45285cb09bf203ef0d0f62980283d522872631cc52d5d49bce60be560d16329356e0ca2b323a2763a9d7c62514d1e87e165df380fb61eea54fe88d633a56d95dd2855d45eca70eb6e84c0575ee96a2630fb21eccfc6984b1714622efae3a0c5aab89563e23a8bddc4fd874abdf826be8b4c43bc9599c9ae2275a38edde2fbb7c0d87b22b0853a9254b790e1d16c687d0de6b31567d927e096487febdd27cebdfc4b3f630eaf14f16e8d49b6a6096dd0d6423a5f6dfe9bb5505d25477b2d4f05d798eea629e5bb7ab1c8a6b453dd524765562855db7d54b3ab0c13a3fe8322a64a793b875987dd64a281f74b761f119758b675b0f52d259fceb43cf41e238a155d435a419cc856a9e9a63250f3731037f559ffb742439db98bcbd629361a3e69df6b4e9f44a7e0b3a5875874010c37a52b5231492daa238b21a54fb0568b5fa1ce30503b38568ed2f31993ef913ad0e109fac63d4865d7b3a479307a2a4ff6a7ea1273c3273ea93bffab4a4bb92b188656973a111fedb2b3544c2ad111f892f776d824ba260b94ea68fd6e225db254e4374c9c411e19f347188a86e718398dc23d1b6412692aee81c219b4ce0a2a30acb759cd5c7e80d556a8f8b9ca37f709dcf3fefb06b173e43f00e4b299f6562e2f65a77db6ea26760d4bfa04530c250cc6644e5367d4ec7b999a3908ca1140c9db884b6cc7d9ea6cc7e7d94570c1232c7a8f533d44946e57b9c87f59b47cb7a80026ff485f4add787b02e9e3e51d5381fd295096a21c530003f3ed0c04c0e274b7c18cd3b59c9beb954ecf0c4880234ce77b06c114c340aab9a74421b6f7dabde2a7d389d689da5962eed43e2b48191432398922f12efdfc6fbc841bc27524500e96eeefaa6d57c940f2f4f25ed9b43a26d652d467de8143be161daa5b81d0571d14069fb02e8e28fc02ffa3f44dd7eb8d736a53177b3de12e98142bf31eb468c6e1b25b1a69b06c1242bc021cf8c21209fb84d3f331e7739b2a5ea399eb46040f7030fbc62c545df979fa53283e5ea5579dae5ea7d74e7bcef609e07985646f313f4ee2fe41930daaa6a740baea1ac93c06237a8f46519a64c1d90c072412a0c672f324f50812c40a74b0d59caf906197a6db2914c3607ac646992aa257c00909f0b95b2ee2c79e7535e67309ad91492ad5248e240577a9d281e9d4dc724430fe3d5e59626e582d038047cd19747ba541855bdc72938659c275406ada774da099279621788f0fdf74cde0cc69555a41e360989488c41616f0d44b780208973609949a5859f81e46ec8ba3b5a002de6e35ceb79ec45b7bb85af125e33df26aa448339fe185a2484df48e8be0d0ea847005cd5c17c08863ed3944d023d1bf51c61e9c5d9c461719461451a47f6fa5be1fd28e47e6033fab1fb14ba0253ff9dec5e148192af07c476e14da460cc6b5cae2d397e56a389a84b09fcccfe642bcf0c840697058ec61fa2953522a32a518fb566d679b0fcdfce5111a327a4ed855eec1d760e276ed26f2420f55777c9f7274fd214fc473e636c5e802dda4dbd10a3cd08f14fa81364c6c9f0c492802fa0a119c2728a0c7457601a2a5c3c387804bd43871f1f310833620dfb74103b274248e45fd943bd628453e6eb9b873209d2487c791d40b3d53575c93a488f4bb4764f7e3430c31a9eceaf123f43664f56393defa423a953016274383b6e6acb05159fbdcd5945b74678495f09e48972efbc32bedc6bdea08d81b32004bc168b9f24bc871affe9137c755dfada727b4672f21ac05b7f9e744e478873da4d57115e0f6034d0ad2e3654f2f30e67811aa8b6e11ee73d33a6b7adfa97cad62b1c3a69b30a8202c6c0339dff586bed0f068b0dc69ca4ae0fcafa17038551d5fdcbca8db7d4533acbacd09abf2733d05c1a2b8e7caadff019ffc270f159983f958eea09550787ce7dae5baed13fd151feaaadc4e5c61f29247d424a68d20d57e253715402ad4f61059fbcd43138dab76f4cfaaef7f16a3082020a0282020100bbc7348ce5f9ee7e253a67817f215b1ad505c24912b4c3b8147c682bba8af8ed56c1218a252366995047277187744420de9a2f5f635d7a885895ac1658e8d82d8fac0c77fd4e0f56017c40f8f26b8d43045e2504c95086ea8f74c80c20ef54eab5585debb600dbcaf45ec7da92378d956155f1d61cad9369e3e25bfbc157c59f0a90eb006baf9e1323d9161c44351c41bdf5fa3515f5f6cb04d7d3f9e95a6ff667ba29b59bc6be6f3f0e81cf97c0911c1651d149ed4a3d46079f05032e909030739671d06b1e2413a35fa2dcdf4e379614240326e55c526df62afc74131df1bbea861e53c44c82cd5c541301623f4dbb26c01719ef036097e40e5fb5eb2eb847015a0b38b3f9a189ba789894d3f47a315674c2e64efaa00d0f3c7a6d2321ce47a26e194f0477386bc519c0fdf849dd72fe30d93b985cec8139939231d050e4400e295156850df447bb6a31167bed0ae8e48780b2c25d020a0156bae2a4c48bca613861ec4e771ef1fd2f810dccdf85026a83769616e0ddba20accc1fb43aafc426fe900927921c8a0d6a88de478b9146823d384d5471ecc81c1e61215339d46ae67d25ff7e8155ff19b2c2283d6bc12a3f924e2f99a943330e4532a93c6444b70855268be3765719e9f270ec6227684846fd517fc802d7f0653973d90d2274a5688b93410dc42fcc4b6d98f00a65b7d949593c26d3c7d4a29276018d5f6595090203010001","profile":"pq_hybrid"},{"connection":"eabdc9f49a7bfc3d0e335b0d9ea03cd486af2bf4328619c683ef24baa77b29a16ed1068fd27fa8a9440f85dda6948e9b","frames":[{"bytes":"000000b0a9657265616c6df66763616c6c5f6964f66776657273696f6e02686672616d655f69645001a0d10e857a757da38327905583e60e6a6672616d655f74797065696164766572746973656a73656e745f61745f6d731b000001a0d10e857a6c6361706162696c6974696573006c736f757263655f726f757465f66d6164766572746973656d656e74582761207369676e65642070726f6365647572655f6164766572746973656d656e74207265636f7264","frame_type":"advertise","seq":0},{"bytes":"000000a2a9657265616c6df66763616c6c5f6964f66776657273696f6e02686672616d655f69645001a0d10e857a7e36aefea4f1ea5457106a6672616d655f747970656b756e6164766572746973656a73656e745f61745f6d731b000001a0d10e857a6a7769746864726177616c581a61207369676e6564207769746864726177616c207265636f72646c6361706162696c6974696573006c736f757263655f726f757465f6","frame_type":"unadvertise","seq":1},{"bytes":"0000010aab657265616c6d5820030303030303030303030303030303030303030303030303030303030303030365746f7069635832696f2e6d6163756c612f6d636c2d6e6577732f6e6577732f776972652f6e6577735f6974656d5f7265706f727465645f76316763616c6c5f6964f6676f7074696f6e73a06776657273696f6e02686672616d655f69645001a0d10e857a76fbbdf9f880c2b0942a6a6672616d655f74797065697375627363726962656a73656e745f61745f6d731b000001a0d10e857a6a73756273637269626572582001010101010101010101010101010101010101010101010101010101010101016c6361706162696c6974696573006c736f757263655f726f757465f6","frame_type":"subscribe","seq":2},{"bytes":"00000103aa657265616c6d5820030303030303030303030303030303030303030303030303030303030303030365746f7069635832696f2e6d6163756c612f6d636c2d6e6577732f6e6577732f776972652f6e6577735f6974656d5f7265706f727465645f76316763616c6c5f6964f66776657273696f6e02686672616d655f69645001a0d10e857a70dd9a9e0d0ed59d6dd86a6672616d655f747970656b756e7375627363726962656a73656e745f61745f6d731b000001a0d10e857a6a73756273637269626572582001010101010101010101010101010101010101010101010101010101010101016c6361706162696c6974696573006c736f757263655f726f757465f6","frame_type":"unsubscribe","seq":3},{"bytes":"00000094aa657265616c6df66664657461696c47636c6f73696e6766726561736f6e666e6f726d616c6763616c6c5f6964f66776657273696f6e02686672616d655f69645001a0d10e857a78ff82c791369ef271e66a6672616d655f7479706567676f6f646279656a73656e745f61745f6d731b000001a0d10e857a6c6361706162696c6974696573006c736f757263655f726f757465f6","frame_type":"goodbye","seq":4}],"peer_key":"54fb24c267da083be974f6918367503efe43ebb028bcb59741bbc194db02131aabc3a644a9bd1f005f938422e4501b98e70c0d072a7d08c8415cdb496a977366051a783e4e2cc58b3ce19d1fa0dd45994c1796fe37d6f71189317f77ae77357e92051e758e199d32fe0fc6e1fd60b354947209663673805aab2c9f43ee9db253159e5cb02466e35c0c835ce8062a642a816d4e72442bf13b25dd236901f6b69ef8aa094ca7c5455c7e0d086d23551c217c839bee1fc90dc5080326064038e5444f5cf748792540743f7e3a64e44a0624e0a2cbf70301416ce3c5ae20f37b57b62119209840984a5996ebe10f4fcbe0207e9a88f3b3c450c394bc6dad93d37f8c1c965ab2c31f9c80649722a4b5b68e20ae5d14fe8f181efe430f40527fd43fef0a1318a8f24bbb53243340f45e98a91498ed30dce7a9caef197462083ff1a5c56acacc53c7f7bb55dfb4dffae34a512b3038e336d63814b1517d91afd2bb06ace992b6125c35b3e28441dcfeb1f393f45b8bb37eb17e0369e9ba9c5d0db054c3bbfcb59130830d191ea3b70eee3e966b0b9e3bd7cc110111c9c39746ef5326722bf9d20106adeb4b7c2553d627888d4213ef105da3f35fbac1cd33a73ce63bf046bde66508240a2a6216366c0ce112273abb7631fe872ba0c13a4fc37bd4f114ae37c82f01629e0938fb5cebbe801a0680f029699ec47fce1b71c35f40c3d0f547864bb7e1f8f8c964c533cdd504b7fb853d828dfcbfe1e826555061a43ea181e402a7c9c628d7dfee81ec1961ef2af220d406b80dd19e2f5d11117f31e41aefb13af34ca6a1df23619b0ca949b669604cb3de3fef3f42525d3fca10a857b4409234c5f54b2f2ba1ff86f208f2f38ae912f8d311749c902d197f31ff13ca1db8fb514286fc3815782aac1c6f6333c5e85f202d31feb10242a27a1cf510dd65f8fe6740d9d5decc218d9cde7942ece229ac2ccd0527dc581536afd3d47f2c1aa9443ea4822865c6a49e5030ffaa75e306a9337fde5901599bb6604abf05b8acbd836c89e78573a5f64ad986eafc89e9cc5dbdf80db1dea862c0044c1e92b7e19f1dcc01f08656b2d5c94ceabbdffd2abef5c89b80f644afcd353d3065ed0877d519bd5a93a598cb86c9d0c0ab8db469850fd0b253228c368b328bf63072d67171edf8c3b0b55c1521c50add912c05d75fded778743ed1017ebfb7689879a4f51165e08bdf47da6ecafe7531d9f021a67492ebff6560468deb50621f282c5496778e0d96d99133b31a41db897531bd025bca7e0cff0fe6b2ec02eb185ae9afc17f0471e2c0980f29e9b361fb8d3b97991899417440636bc0b2dbd99566e6c75ab8410ebcedfc6fc9c3c1a927a3be37eecc9435bfcd064e2584b757244370d49b9f5d4922c9038d6bd2af0467f1afde487b235dc42d014b4aacc9119121428a3ef692ffb749a15f1799d3cdf4b15374b36aebd74e730348cb1fca032b05dfd12b61c1b029a19f00a86896be0bb05254b1152819aeda59224aeb6e312e1a4c7a557b744e2f1882dc54e726fcbd432bfcab4910f17bc35f00b4bd77d91c5bae5ef6816016ae839cb153a920e2ce32f51022bcacfa382bf57555d7a5403e95b22323268c48c1f033b30ccc4c176dcdde76c32a94afbc43acab6ece51341e467025d8b5ed0629bbabcbc406f1917a2a852c574a528480597a73cbdb83b576c4d31469508d371b6f7acd24498cb6079bbc1234a2c7558f3104fb605319c00c1c9a010a89676c9be03b6d917503860dde89c0c2e38046b60114d75a02b6862311c3f773ca9249d0c1dbd77d863c33c0346203c5a627ccdc597011c812741d742a41f0f5acec586aed6093bbfdf66f998ee7b1d004d7d025acfcd7c2455e64d38239e0e05f8f8b62e9084348b68c3eaa0847c36d15e0baf88e5d29c42596203077c89974472bdde11904afeae960251d20b18f6af2b32b037036278b686779e09143bf88bea0624b458f8ef57c3e7f30c31ec88092dce8e777eff21c6ac98935c4a776f3a804ac84e6dd4e3599931afd7ee8d3f9465107804abe17b0c29877ed537ba993e63be6609debbf8e6f8151181d250dc019cfbbd26c8c2a58cdc0b202d63e1a00c247a87d0f84963fa0a81bef7e1d1c74b270ec6d38e5b4aaf2359ccf3f02dfb2d213c2806b1d54766385de46e28dc21b3b5b0ea2c7acbf2e5ec5435a4ac91f23acfaa7a43c4735f1fb3c2a7684794bf2405559633e81258bf08e7bedcce9460fc4af18794ad3dc17b185c9debfc873be7679f0f694906a70e1721233ca9d4c7bf2dbcf11034cbcc0bcbfb6dee1da17262c364298b74d03db09342fcbc0177963c0347872ab3d9d9092d73d00e8fc5596f7bd6aa16d0687837d100e766038d78e783ad88596c55775a543f18cdd57a8156371ed3b3d2aac77f12a6d99a9e632ea95bdd6817a9d6af27a5dcae7a776aa8a606354babb21c9abe7580164dd5562db2c9e296b907cd655811addd824d7d0a76f0f98441f7be89524b8373b4aed34b6158eb6e8850f5b3ab79dbf5ef267686420244014e5386e5df18537646a811039d8116c6295ffbedca63cbd5ad2cef870a32a2471a437825d8893bdac7156bdddf0c93a01a92a06f825fd694b8cd82dbddd0bc6bb36c56e48348de524d0bb879c549193ca009577ca3375d644e93f0c25c248abc024e21a93d6aec316de69821dc31a2c9528319f9d6f2e83f2da4183c5749e28fe0748fff8d1f7244d0a0cc38c991c54d37e1dfae31d603ada71efa47f3843237bcb775622aa44799d70eb49649545ccec9625a3614723d658ff378cdf797206773e96dc41903f65a039a7fe0d204eb0bdc5d32ebfa1cdd45a433bec70bf889152c8bdded5fd2d15e1a4c8f1b2f673b8269fd7d4be17d4244990c886eaf74db6c58ec61b89a055b1e815be684eb1e53b09ac32718f9eac054d9626c6c626e8a37d5849e1051b36e02f404fde2509c58e92bb0eb23c33dcf6805ff6b24ec81adfa8194154eae8da537c0d412ca72750897db8fd705a1e9c6ff86b04be780a9a3b91a87ff87a1fc1abff186732dd4e761ad7fd04a70af90ab1051b03a225405fdcd0812b4bd0b462482590b335bcd4ce758362df00593230d9f4cb73b5d34a3be47fe44a793ed1ef76b32a80dfd5cb4055203eea44d4f26200dec4850bf2d0e17e24da9a054a0c582d3a24e3896df6d52f0405a8c39ac748a6db68d1f6a089e127992d7e939f55af092311a8595db77ca310062d92329f6759c198541dacd586f4423328e2fa04a0b5befc3fbf10da008c6df1f614d2d2cb8d7e29d01d1765f6c2a5b0c2d4f433da1e48705c6a8dae5f6eee0981437284bab7f7376c52c87983af233a672269dd94a103437354bae54d7a40538e6eff1e62cbb2426648d89e46fcddfbd740a44e39abf8fcc65cf8bfcbda6cb4b2a283d8debf7b62c4070757d7ec53585b13ba1cdf843f17dbb8061cb80a44130f244ddc7efc238b2203a075e2fc9cdfb92762405ea442eba33a7c5a9cfc7abed48086c9a6666c68d1bba2484a5967a95f6addcb5ec663324015523f1cc96873b2e299c203112ce4eb7cb170e30ac1a7cb1cc241fff28d87594c8918b7f08b83f3d52efc88253e57790c093150b8beb28504264d872613a5328f4dce","profile":"pq_pure"}],"generator":"macula_frame at macula v12.1.0, OTP 28"} \ No newline at end of file diff --git a/tests/vectors/handshake/erlang_handshake.json b/tests/vectors/handshake/erlang_handshake.json new file mode 100644 index 0000000..416da77 --- /dev/null +++ b/tests/vectors/handshake/erlang_handshake.json @@ -0,0 +1,21 @@ +{ + "entries": [ + { + "erlang_challenge": "a7656e6f6e636558207bcfb17e9cf8af39418642d6f1dbcc576f3c9934756b79d23fcf9215d64efbce6770726f66696c656770715f707572656776657273696f6e046a6672616d655f74797065696368616c6c656e67656a746c735f737461747573a26374627358bda6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f696458208820d319fe9a91017f55a37b38c62e0284cc95f47ddf8257ba7123ed0dc43ad2677369675f616c67694d4c2d4453412d3837696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f6861736858307fc7561481726bb194bc07538f86d3761030f71ff0c2efa68d8d161f82f102146412510c118bf9df75fab5d2180b5a53697369676e6174757265591213b99ab24c4b19828550cd4650b12429d629fc1fdb9cf973964262e8bdeed793326cba653312d47c537786dc022f28ea88396c1213db00b2363f5c2d8a8f78ebba0470b151f8c4a275a3e7153bfbe5eb73e95bf4ef25dbb60824f6ef95baabb1edd938cf7f9064d92b2b39c680646a67cc4d00d361e4d28715a98fda4588e4a4f5116065a39b5538e9a1f2c7eb687534abf4fe4cb8fa9d775b309fe3c9d0748d5b4759539270964a12690229453893e2128ebe746ef163e9f198ad2bbb36e40e304c2cd5feb64d75efae6bc5a419b05033c2f49bd7cf604c5200952ba2f0947c70ec146fbcd7002b2f8be7b67a05bfe716461fb1d58417285b6530c7c140fdf41252ba496fe732e303e49c9fa92ef8675bc813d42d413634f6b11ef5ff1c8a0e3ca2ec1d16bc57d5f99e9157c3a0587e47e3b116dac6c21c733090960dd53918050e71147b56d52e7764109e33ac3203f208fc4b489d37d73c7aaeac0f8ca72c4450539deeec57a0ddbf34a3c3436a89fa80cd5b8915541fbadfab64b1d7a48f640dfd17b165587fa3f3b0f379d5bf0fd5303a86bb136a96b7b918cedf0973db04bf12cc0edd930f2aee906daadcebb57d463badc61209eb2905aeab77dfc66eed5e3000bfdf1ea707464aeb7c01a02acda30fa476412c3547569a814e02333ca44c33047ef43d13fbe3749f96da5e3d27c77632d90935952d27c91041fe72cf55b4a0cb1fce3d7c7d3cf0022141b1179c44d7a3e9b99007f247cba8d8c92522337a4ef9094a3c0fdc54a7c7dca0d48f148200785e6805cdb748f0561f3a075ab599b48167ee5878319d2c49187fb8f2f8e57058605ea601dc956b51a7e2080abb9c15b2118c7ef0d27467050838b0a57de1a98eaec5988f252e04ad72b2644e8074e91f75b542a8db3a8d0fc96eafd933692b4db7d96d382b87d7c1cd1717fc4988b2fedb9552968293c5f8a41893f34933526a7b2d96854485c3729c68cff0fa47dc315d7d0db7beedb0dccf1db2608880cc732b955a3ed178d2de879ab97fae983582a1ae92a322696b104c9abc74aecdd0aa63a33f0a4c0354417df3b25ade91adc395bf8daf6a6a42e60d122eab7b168da68b416036045b841e122d20a8e796246754990a34e930de7ef8e41d54ca9b4e08fe3bd19c5327231f4b26def9c43485c9505f3ba22a909a556939b688ca8745447e10dbbaddde177f4394a1e1bba77201dc1788ec27f0988fad4e7a7de9fb304a5ac1305f2e98007c90aa99d31327a499f6ed2af8b809fd1870b72925794e45ba10c57fa455ca0eab60811eb7de40a99be1a3126b11ba9e3d73e800044e8c903f2a8af562caf245793382bcfda24bff9d67e9d7a6f35f12c43d9c4a9139e011f48a95870c472707033679fb6af8abcdbd854030835bb2def9016b93e98b5a4703c6da10bb11112e332852e5a138b1d099c23ad48c597d2e8229268ef87c4487bd3f71df7a37e9344be4a487f133048100d579db554f52e6acd1c962ea933ce4b3745ac97c2166c08e797e9b980cb52c904ca6c1d5b77e96e397fe66940f04a4eaf8fe337b0586c8a374ec515dbb6318762f85c109459aaff35553824557cd0f90fbecd0414ece2dca005118e3bb64207ba02cfc0f7445d0bbff8c8c0f8a650f765e6fef7ac5d175cf0a43223ea9c6915e2435f5e6ad3cd4e823bcaed598f0dd1f5607699e366c001ac24f2f68177465e09e7ea301de53ffb00414443669c99c3def3085d5185b6d053c713786212adc9bb406fc53c591385b2f45ae44949fd0cd8264e567973ae5ca29e6fb013845af851c8bbeca3df016821bf94fb9b078d1f454e925495eecd2642c25eccf4a1ce7b06fc38451094e02fb87ac5a599abec0737934a67326bf797a9f1f94163392db4f5dbb3f8c1d5a545620b0292c049ae3027fa263308b702e8ff3929b28c9b6956ba55fcf30c3878fe638cee53599251f2cf2e45cac1d3c0e1a705439b3fc9fe84f460db442a69413ab89368869f171e111de2dc3214f7ee1bb250780d3ab2344dec178a107bfe8c1b18504c5dd3ca0af5c46da4ca28be35acb3274c991e2e70004c3cd5c757dc4dc4f9a53586b7ec5e56aa8035f793abc17b441a8b8ac6eebb59b0d7cfa6fbc54956c066106be9bcce26349608a73d9d80c0e441104c19af3bf03182545708ef6922e1b872cd4cf40a23b69be79fe3a0e3d4285c4506d7d7afed5032f57bbf7b8b83f0ed5ef5d0fbc63e85556d6d9d41bd36ae21060f53141c93627ad4fa56cefa95b8810c09f96c79faf5c95bdb3662aaedeaaac2be0a6ee033ab72ee0af8dc7685f83faf862e919b0f0da2d64c580dba7a97f5995dae3d439b6e45985818ba500d56c631ed4dc4436c3f31df444e2f774b4617020bc0302d0275fb2a2b2b3991a3fddaee025b7ce6c9b65aceb0b311532145c402f7d2af5dbc57777fde7cc02aff8665488f92febe53f487bcd3b473825b169a40c9775410255aad9d2631ab71dc62690fce7ab0e88a0a1620e1601075ee5bda824032cd17351e19a827aeff2969950b71ef7977c361c935df2e740aeddfe952b86c384d418ce31b3c6d6ad6ccdf8a6aa0137cef5e4eb8c10c6d76e2d0591d283f7c5f062d07a416d3cd782c64d58a6f32c98a8268df7670cf8f36df4535deaaae7508ea180955c316004fc7059692cab5d781bfaee14aa03b827f548d56e36a18c0a28079e3c05f2c1f6fa4ea93df3c6797d1087ddd2ef1e8cfc332046e0542fab8479461ce4b0b7001868ad24eb2142c5f3a60513258aba5aae39a475c4fba842770dbec3b64747d4501953691ff7829840afda292940df35a42ea53e1a0f3e5441a09373804e78e43082667064f5336b8eb388bfd60942ee450b4c7c3d159063fdb1aba6a453c1d8223aa9b7bf02393058d7cf9b21c322e8801e7db3336e9dfe1c69bc7dfbe99f8b9f7f04f6f4d6f2bd5a2d98d608f318a19553cbfb0c7fbf4d63241454bd2ef1492c8c2422705c0428020b0e34b04bc6c107ab5c22383d229700d024809feb4747e9fbdb6d08a24fd3adfae454495585f3705c64980fcc90844f4f1516793366d2067357f73b6822d2d765e153ebf11832c33a4936c6b4826c2a72be056a7c78be1dae8637527f9725d3911408f3f5473d6f0d2d8435c1c44c9e8797099b6ad68b772b6cb45ee06dd676bbee177b26258febfa6d1d2af77cdf51bc10bd4026ba67fae39fda8900c0a74c0ac4e93dfd1668fb3e9d2816d2a4694479452ae55d3ee34a360cea9a2f9ff9d3fcdc6d59504411968e3136ead9e9c387d505c38c6fea6e4641f2046e727d5cb366a66c003fc341fecfa0fedf4f09b0d996c12b9bbd705db9d4744646d689f3ff878cedb1af01e62bc4ef21df45356abfb464678d2d81568c4eed94e77389b8e092427f684201dbdd98cd88359d56a8e5019227d67c1e24072b156aa1c23b4b355b8fa3eda525d5b5af5bc1ddee07911c0d4d1e74551373eac6e45063f644f10d1aa58ce454d148b0bf99d5cafa46a5e72fb92ecc313934174f565b70232d7f3a18b77717ece277f06e55a6cb122aae4d1b5b62fbe9b56d0114feba3a35e6689d84aede2b9f6b6de202b3951c9fdf7a6c5ce5470062de6b5148e063e7c582448d8c5a76daf03d01fb232e377f4a4c32d2e16a9cf3d57bd79d5b492f542156010a8d027c19636bd090f376198c418537752e31920825f71ef8ea74af089515c10b6055423a385e9f3995d8be4991fd673c78b77b42b01eeae5ff75ab7845c612a044c60343b8256e463c5b77edff13b7709528f108e64480f6300568360717cbd11adf76331a84e6a87248220ad89d91fb7c56533554422f01029390c789fed7f9cae2b10b1bf058a5c7f87ce9a0535bd3a234ee3d5332825f146181bb4b98a0f9ef4736562d29ffaeaa826b8cfae39fbd99968e074f0026c6214fdbafb8b3b82b80f5cf7ffca2a2ba91cec6b74c990b0d663e2a3a649035f9482be3b69e904592344dcce9b80f9d2a4aac350d648d37fe52dcf7e5a1f6f6b50ec15e349aaad9eccbb45707afff6ad542681a417622c3b7dedb2b286a2a960a176f8b8b4bb5661399ba1b439e7211a552a80a6237f743ff72a4e380378e9347223ab5cf2806a70f9df3613c7afa8547a20bc6aef4126c3dfbd2a23d4d72ae73b2efa54245ae8e41bf42ae824c8c3f60c6c74820617d43b5b696a00646edb1753fc6e58687c12e568724ca0596e0bc4e410e4602a522a4caec97e31cb79b18a3d23952c0eb8bb52a6196ab86d5f4fc77cd41528b1dee0e8939d5fbfed682bd9ba999af8e55a9f16b58752d723b5a12644ad24eea24bb5c59ae6d87a26db41d3aad22192f031a8ed26337c706b70eb3b09596a928c19547a6378325d4339db05a9b8f1a6c69346a55a03c96e85d5f2f7dd57b7fcb31d3067c588303d04ca098c18e2d396a6beaf3944d81ab9c557d9d4235a0f7f6f76dd31aa0234c355dd3ebce9ccb5d0e81fc9c57026e704f3ceeb73a7df8a8749602114ae1e9fd00e230039a77454a3d224201752c8bb3c2d7827a9915e4332af51f184485138f7dee02293ac0e3996c60318ab1f53cef30bd28036b9f55a24016d38de0bd9f6de2ba0f79436f34d83b1194017adc2faae3864e79e951588bf8831bece0f4d6bdd46b8418bd1406980bee5f2e3fb5db64fbd195d7f4c428c9a51993fb81303eeea4be29982405da79232b195aaf2de5b61a640b101e86496df8c6bd70e665e0ddebe0028fc8589e35cc3fb1a46f33804a9839303edd334dedb924cc2e63316e0d6ef08f9f4db021ab44df53deeddc20f75bd421c99f2bfa2ca51499887b88b3cbe58f34e5e69b711c70090f3a5bb025da8926659db0999262c07789c574a05f77098b59d78522183584018dd87db2b187ba06bd3db67e2e114cde5ffe93bc80224c57afe0e6d1b57396805a16ef78b72d80b91d4f4b7b9ea7782d1f33b7875c798f439a56af07f0f25f931b1a8313a2cc6a9d6721b307a227a54c188df3f70114819d816e839861bcd8660311a703810695cea4c3c10c35625388ad436da4c9433b9845abf3c786c5b98051d99927b8f34a753414b76fe31d68f5f9aa6dbfd042add0db00aeae4877e460952fb348741ff9de4627689bffa9a7d42529c389f6d18eb1a5b60c7b83b27a99c2853aaeb77ffff1ca30c15af1f6469eda647a3f26c41b248bcdf85d4e161822d70755e2c731f94367d44d386a945195c215a953436ca71e852444a88422ab609083ad87be1b87955ececb5d199ddfac0d66ff7c705c010a8581dab0bd156afcf494ea812be5b0168427f4cfea41c8134e52160bfe247ac22175921a4733a33068fcb1bf2601a3c2cd50f74b3d9e84648b7d75e22af7917ec16d320e45fbb2101f41f96b34062b4c2faa41c9a86a0e0b0b3db7067999375603828fefdf984f6e7aac226d6e704f26965db95d2ae935f4a269ae6a49aaf3384b8e7df10eba640fc2343771165ecd9f45f146bbc43d898b55481532384871049e9668c0682610dc25dc5e6ad6a86907b33f6c7830ad9b6d693a0b4bc6f54bab7f09adf2340b6adaf5ca4c41fe1a5362104b9f93091d7ec8cf42a0c97a5fecc07f93cc98db24e606fb557698d7983bb3f1f1360f5fce0cba2059a38e70cca359d98d7a76be523e82fff6646d0887474ba6bacf8c13eb48a7542f0bde93b309e4d3b963daa41fea58399215b989abfbddb382039fcae1dbc04434acd7992b31351c5fb6b0392d59c5080c87dbd53ab5e45d42888a50fc38f7d3db69fefe984f524d6cd79c82893049c3d847bab9ae3d9576d1e167725d1c4ac3916153e28b7551889a2b269dccd2f6a048307adcace2d4d2dbc36753c19811f8d34a9b8c91d609fe0fd48c99ab6f419ce5f7da6a467b0e3ffd47d3a5c5fdf377b528c5f0ed54871f9628f24a25ff0fcd4f5402a30c4b8eb09d9131d04d4acd755d6af8c1ff83b5095cd09e114007e97ce6c30c7a3f262c922327e9d813d95a0d59e258e2761de9b88810fba74ca692375cf25f9c27a9c246ad6a84bf1858880712decd48695c5fe7ebf715e2771d3c0df8bedcd5704fcbe6129668d1a9e3de19777a14978d296a96f0cc958b89ccd5d02c42045c6f2c5795ac6acd7383e69dec95543d3167a0697ac6cff4ab5d1788eecaf39b633aec0795655d5621484ebabe08ef28ad34ab12186ba066b7655e14c8d5b30b52cd5c547bbb16a457ff3f3602b8ea1f3a0a2c7135e559e088f7fc3b9afd0cb60a14946f76206bb81d6ddd8f770bb8ebf40acb3e96b9a7f45e8fdce7c8b2b9df9809e47bb6e4dca51f1c3077a242d42069b396c58a8fb4729fe945cca3fb345ffaa928b7822f7e406dabef7d0c5697b9b8067ee9cdadd01cf5df35c30c4893a06c09262c97d8fb22c19de10962417216495090e0097e8388a2afbc56676a78a4b2bde2fb0e4bbaeef549626687abd7e2ff121c7b849097fd152ca2acadc8e2f21a8d8e9fcbe3000000000000000000000000000000000000000000040b1419212830366b746c735f62696e64696e67a26374627358f8a96375736563746c73656c6162656c78184d4143554c412d50512d42494e44494e472d544c532d5631676e6f64655f696458208820d319fe9a91017f55a37b38c62e0284cc95f47ddf8257ba7123ed0dc43ad2677369675f616c67694d4c2d4453412d383768686173685f616c67675348412d333834696e6f745f61667465721b000001a0acc226006a62696e64696e675f696450d7ff47e149a3f9a39a465eca1da79a196a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f686173685830ba5d003ee50124a47aa0d55c045bb81cc4d8f48f8f2af53deb5f9814f14bd78ea652cab197fc2348547d4cde771bf49b697369676e61747572655912138fb95de5c2d80cc16858659e90687134c7c832a60d1bc6d98a47c89f70bae04906d407cd391f6a20d8c012c1f02b4b2832795f43ac90a81005fa516ab0b5d668d89866164f1bf6f68d4fe0c5c9d23f8e1c4f96bc1f0dc3781ab7f70b91bd03faa4af1a46e0c8a4f0a5b3c67db1b7b7e494182af52708c8d29c2983ed97d77b4dcba81eba853f90351d6f0f366c199699a868d3fbcba0c7b6c4ec029af476daa498e4597afb85d4de823eb0faa7807ecfa97bd1e8951dbb75d04c62eedbfc1f01942c88e1409614c9587c21bcce487e51d99d863af58c2802073676ad4bb4f4ed66eeab2f7cdd0b00a9d0348affa7f835401f1594d9d510b3b0bebc72ce2788328fb6d411b67ee9ae500c2d10fe943bb59f558e9012fd5b8711bc5f318006413d632cc92882d729fd7d69f9bc3d77a08ce129b36cd5be28fc7be31644e5dcbd312610850b5913c213e5dac1a0e1d90f506da8754f9ee2fe7c14645273d1b21f750b68ecb7fbbf462d49afe0635c477b2685ed83881b5e667c9871389fd0bf4ec7e5a5218e75f9b0c2a38d9e16042bcc361faf6d80a58c8957b6c5c9606bcb2982ba77fa2c584a6c74179355999457d7516453ef417fd2edad4844c9969e5f00f4b30e2d59a5bc22aec2a95c731251c74f124bbfaea5d85d1220d0d71d2e17f048cd92b82c0ced3f6ad99a1143c886153454886189ef564f909c4b78dcfd54fe4967637f866acae29456e0b274ed542162d4871576616e657f1bb41f30d9ea4b751ab7e43df9d125c005f0daacace63d7d23666fb6972ff9958f11c7ae3c44ddc243a5381540799c1bda7bca77892d13b7ee7ac08c668e37b376f2ff59adab3a6a41ccca0845d27cc1b8fc64125c7144d4ab19a661b4d489ff77e76541d22e49bebdbd432683b194e338575038f4536b44daabda91fc52254f1d536d4e1ef28968fd71dd75a54b9c1b7bd8438500a679c3266e0893d45ad528795dc67e8c491ebe60a22972b917a114a9eb82fa69d319b3d2c54a0f74003adc805ba0216bb6d5e5b519e40af383cb2e1f018dea04e0068d6c3d5506ac602f22c76293464f6f8786a655be06f9841231d4902fedf148f211462b9cf78d3ac413bd207043daa020b68c0d9bcddb3bcaa1e7debc9a7ba562e72c3d5d1e1456f316d759dafcf595c3f597a6f9a83ef0849f5a83cf86057cce2d7fefccaab9925421cd32e66685cb7cac059b3287cdd4bbeac521056707abad020410cbc35e95005efe5d92f17292ff38aea015ac8a8be36ee7b34e1e4c473c82821de821def58a8c03d927054637591568d74246acdeeb9c03e593b60abd933530aa210b16434a4a99bc1d770718ec70cd0a195942b2e3532d7f19f4a7de64a38d2aa1e1dce9de9343d2380832f7e774f9670af94cea7e5c2951f338219c2b6ef2718192f16c0c87684382a553e25c6dbd8b4c751d73958616e5d1bff841f801086f6776f0bed077f06178747f275a1851a843944879e1439e0415ac0c40e176ed03933a049eb395f0b9d6682b1479bfdff6ab097058fd30bf863e0d35ec431f879b8e21763027a88e2f0950ddb7b68ebbf05543c5afd89793abf81c40598e0ff6947ea46253c6f2ca81877d618325353001afa49e7a9839d48cd2daec0271f29c74e14a70d6583e1c1b35ca6741a4db19cc35107921ec7226acd4ecb75c7218bbcf6ddbea1b2b2a0265bf75e97e87ff9fa71fe990e463792f913e897f5cee94b5daaf952a0cbb5987b8206d7c05290602469af3db0408b48bc4c54ce12eaaa5f317045e4b8948160895261ae63403510eaf280247ee610e3da7ea733c97218bdf68a151c9850db45b15fdaee4857d7abdf973e4b21b99dc308c255211a65903d4ad79332a280a6203bceaaf13323f0511da425ab13f1ccb638765dbc8dc940815d614423f3a84ed15d46346ecebaaea3472e5c5c7d3a1313fefcaa96f2eee8f663564aa8e38d00c121d45ab517bee0918ad2283d4e8d526319cba86b1d8641c045e87ab0dfa2ec3bb5c884e1fc721e2b3505012322c9a951d53aaca30f201d30c905b629032394b846e364c794b1e920d01f0999d6750b143dc2fa45cc4d149c1b79bd941e12b292bd9a8ff4bc782d1ef307f2967319a0bd9e44d5b7dd97679fa8f5afc1faabf6682e9e6a272b6571c14f75ec01f2d7772417b08977108100066c4b8b6af29d8da0e1a8f33ad56470e502cbebe1584d2edc6851cdd078d62300a6047dca236863f0f7388464b2f53f0928027d0a01ce24026752b5ede33b2c8c09eac9b69e0d84aaad7a3aa0c8e0cf55be7040397385955fab9fa48f431e53b60cdc90f7288c5d59263835cdd3574ee141cef23e4fef37a6a5591be0288d528aa13f2e0dccdb885337ad12a2de034f2ce854a037382a1478b587046022ca624f7fd8d39eb10380fb9fa2104734b69ddb1f037e1d40c0ef4b7ee8b4c0c3bb373f789e9acf737bf46f3ee383647e8e9e63f4d2cc13b5ca4ceb5cf7b1504241f73277116cec992ad6b97172ffefc4d671a0bae5f39c69daa33cfacb7a1b3e51fe38f2334f209945446f816f4c239a4678092ad31c549725f1829f52a177545935c5d93a0b20b3ad161deec40ca964aa2da5dacc6b3eb7f5020ee637c601fc2953fb4cb20dcfd32d8b1e5ea0c1e54a24492ca31a9a2c758ee74c05962855b15272fcfd3a885c0092d555880fc9ba4920bb96cde10d4399eccdd176ce718b856e3f4c1cf7172f6618598d3b22ba4db1a93f423b4658b8b689a6d27f11c4c1aa0fa988df5d2e69931bb3db0238ae0009bdb7817784bc855d1877bee548a7fec5d8e3bbd84c710c5ff4b348ed320012c5a7ba368d45902cfda7fab9e3b28094c8ea164ee41332af4ef042ead210fdb896f52c574cbaa80406f99046244f6dc188bbc9ea29d46e6fdabe006329fd8f5249ab397a278387ee4873013636de46f58b22b2bb72ff1818ae79e2cfc157c8bb78767afef7f5837e82ab75ce8cc12dc71c44496e487154be87cc45e051be38ec8f060a41481d3b17db2e2afcf7e0187197b4ab1a9871c0bfb380a9d3ffdcf678663bb961abf1488f103833644262ca1f177661dd4967b7125793a3971d26d82df9d3985747c11a65b07837013cbc8fb32cfba9c69bedfe246a319dae5397bc803881646faf459d5d47cea62f20332811f7f560a2852d9c666d321115f78223825c8b7e935b0ff8d63a621c4ad438e6b7c604133203d58ae242e2b991c0e475b27381589e810f243ad1afec4bcc570dc321033af2e561e6d16e597db734617ed10eecdc6035c9772c01f1c7efcb314c8ed11657e248db72e3fa65af47246ed4db718e1c38540060b877010c188a41490ac3fb8a00a23cce774b05d038eba7d1847a5c0fb59c72fbb3868fe743bb1ce2a3836bf7d79efb6e2a3335dd3628427d1bd8b9db9a5038d51c972232ad803f7e93556665a7e21fa265dde20fcbc1c31b807b0661ae6ff2e95fc86a5235e84217a7e53571a09e8fb75f021d718597624d1d68dd8f1e5b2e996a7539e14539431fb8928087a429a50ee5db14568a4822b3515a494fc2e666369a83ef2d51dc46d1819c1a8180c2074306ff13351f58f5e1f18f7858f031c983c33600981d1779592a18e2b46f9435d5741a083b2180515f6e42a8911f0d2754f8db9dcbc758ca28975c5361ab581732acaf9cfb179b524e2d7a1627b97d9613375601f7fc52674d0bc0ded5f5851ce194bd717ef027a3cb9e3c70c414acd6cdbbc4da7fc3cda39545d8352e3123bad17ab2f0b662dc4f8559f697f87bee781a7ed79d6c15978812d74533abec9309b8fe9e8e98817a3e0fbbc25caad5f1e13a4a8599965fa904a27c119002a815d8633479757f49e1d1a22d3ad3c574814ac578d2e5f400a8b754f53dc2fbf4502b25ad504dad60d5b192614168c67f22f1673db290320dfbc5ff14870127a9d1d0f80f21c7f2aab61ff4487adb65c36dc1e2e6f226f497d4e31a804ebf0699ea59689af0c8ee21e0527d7612188ebb14211327bf14b702de35c3ff237d16d97517df150534a34cdfc93f0de859e6a49bfb61d93276af1d0ef42370e9624c36c85ec45ba2fd718a817a429981e1b1731346cb2b7efde4137b6fe8abad5401e33b9c1457a386e9d2f1785accd5a06ce2a3cb64c06da2dcf60bf6c45e7729930851d340a1f377c670ff6460aac3505fe25bd9ef3631e9b3671ef4ac90ec149aaa9338b631f50f278ecb3e0b923108325f98666365143febbdea0741f5051acad70c2d57eb7234e28007de611644dec34eb38898c67cd25df9971bc114de7fbc70a00eab38a124899541365ec964f4d1bd044f9c6912733e703ef45a244ffcdcbcbf5c843de7c27f6b381dd86449f10f6c9907d3b48518f083e042033fc4f96a3a85b4fb23a6ee4737f5d85709acdca44108db722da0fbaf8499b551d67e555f08a8664f6979df687422758de46f14b92b2e81d58ec8f9026f4676f2ca17a059a70bdae6ed36b97fc3f4aa4a029ebdf796eed8ea44019fdf34b97e9895c18c03a00ae7afaa86c47aee5863667c526bc8b6e76460f84faf768a4b3dc939663327f13e5edd3ab0043ea50baf0de4b7629feae97255b1dce31f6f86dd4857ba7655907c6f1fbf489a525aca3124a28b2c395cd6b10ee75d70eb61b436e9bf11661773929559e85ac75e152cea625998fc48553e335bf676e780e9fdf266e925138888ab45202188d800aa2c05cfa81fdfc2e1a8572a70f6038e4507e6192a297cd2b28f11f1292ed32f2af003aa0e0b39d629d0ee732e208e0e5ba9f302717383a682cf1b7222d23035596310ce78d14f1cb4c7c4ab95fa4536988cb71d47a1eee4f4de47fefcf0565568941e3539aa0a796526de634d6bf65ba7fbf88c3696672f8d1ba938b76d6f0d7f0b3b9da41b0e3374ea78e843538971ac04d46e89df973e8ab28a61942a9e19205146019343194de6dd4811408138a9d47ccb6ba822958ec1c4787b7c40da93935dfe137859185900a7886a1240fbdc65b3151353d80770e1a9a45b4b27ac278d085d122dcea41b4e0810ea33331e103378a4f8f0f84886e2c8c746c24474f26e1528b4591cd2daf75a459c3a01315278235c7b279291879fe84127f276aec1faeac2814c6284725e172360dcbc7149093460d9434ed5290a40e3105402c08147205f79dd5e82264f16c8c97c9bbe9b7883723adf42e594bbf4a8ba7b11a34ee806f5abe5ab266f359c03adb645a8686613508a91bfd8ae187999c49e21230682f5d3fd555dd7f1db76a01e1b49ce49aacf0e58ae2bc47546d0fd19264e60c8cc24fbf09e3d53f3cf01947642ddd20c989ad51cc45e10f65c51973301474552968305f0c7882ddffe29c6058a97af316a82662862c31cc77812c494389c41ee31fb331a2ca4173feb071fe2591ddaf990bdafeef0c01d78220fb7039b56a989efa1256e8e51ab216499bf15de25958c7062d7766470403a68cf195fc22f2c6fb532ee7ce16740848488bd90f68d3c9648d57cb6151b220770640ad657e744e07a5aaab16e938e743abb4398f982c7c22b4926447de98922963f58c8b2da349258ca705aa94e70ab2ce36c81d11d05b8e6eeb50dc46b443d343152b415a4b76eb1bd82973aa73bcbded1f49c430803a92bb59f384ce248041f9d8244b5c49b734f51e02c12cc03bc0515726966dca9d771a03d88143148fd189336013b82fa2941d122f0692d62c2d853ccfd4d28f2e56a75b4b532886b9104615aafdf9df7c223f441c38e439a0dd547901074f22d1cc26725a20941e8b658440710e795ef73f091b11d734e648907654cde2e132369bc4d46e4f19650f41ef2524e8d32cb6f9e26ff066f0d918b6d0fbd8d85c1afa12d5bf7f6dac315cfb7c49b4bb13256b0773d35417b719ff6346515be6199d55dbfd68a9c914d99e08fe7fb7d59d6dc3ba0648d0a90957920727a20c287f549727246c1867b32c751b0c0e0745e3204322060796161ea15c384ae1b88ddf259dc8a53af8c9ce885b9c84a54751e5ebaa20f8d346947f66905ffa613ad6b0e008a4df83adf4365e3e2b9c6b2eb83478c06c5c07554138b9f0575bc082c8f789170a8462b411847f38ac43f58279f330a389d94a51099983b22e86c4791c06366059d9993eebc37e71e4c72b81778767db7422c82ca53ab417ef8006872e6f65549f5c1f81edee6902bb47d28380785e6aa2781490cf91c74ef6f2352a74e3bc259469829b5010d2b2f2f429827d4e139ce6bd5f7347628998b3ec5fd747565d41de3f8fde2caa84be14e59c24a4150c91922325cf9b759e48d10b4c9f234e6e86cb13866a33488a2096191796c43d0e65d35bf476abd3b6cc753a1bccb98a326935ced360e999648f8d497123d4416c40e85155710865786fd716808197a5a8a9f70e5c6d8f9ca0b6b7c4ff5f8cf813193ba2a3c2c708232d37acb9102847b1bad9f71bfe04071c334a4b71dbe8e9000000000000000000000000000000000000000000000812151c22292b356c6964656e746974795f6b6579590a202625a0a08208c97476370cda32602a44b57fc7b23111c99d0d71501215e83d75038288a90a7e7ecfaff7a53ae4663fb6bd0cd206b727938170733885949385fd4c3df249b3826c90cedf9509a707b161e2de124b9e3a1745c95aa65f05540a699c9ca747d7e1340845988a8eb154e100a0ecc9fb8be1f5cdb92962de4d6a0491da518db8e27bfaed2f707db10e81db23ead7da2e831c2ed69eaa24d8003e8f26ab492b75de1b4aa91b62e535b43ddf75829950b5eaff9840dabf352232944bf4cffb98e07aaa295a9f8d2500175c6d2e2a39d3d6b906ad2e8fe85b65f22b442d12b0759e3dd8b1b51ea3ea3ea8615340c0150d50337ca80dea9400a5bdd1e2e1e487ebfcd6e1c0b8d322c080f4c59267d108803a86afd07da4522fc740f66b6bce639d54abec4324783590640acc24d4ac022166fc7914f99f698339fc8f1acb12505e68b095f17399f7c655fccf3f64e3d966168656652f845b7f7dc60ce6e04c71ba865ede4dab6ffca101ba80cc108569c2e73bc01e9ac7a76ffa82a5857b63cd0f0f82dd9832723aab9b7a9e8aad8bc7d42faf922d9e433b2200f73ece4db0fd08c83721a766d5125e0057d3f46c184cc5429efdf60cd24934db02518929b13b2594e3f9a3fb01ff632288dcc664ff34640bede81864d96b6112ab73f66b0940fc3a39bc04420b3f4efeb267bec44be4ec2098be8680b2a996aeccaf40b2d3ef1e743cd960d3a05071845e308f549d11af599b943cbf0f303d0b85bc524f6e5048698db4146290e0f444cc9f682257c2251a2dedbfee4bd0854458221b30c5986cf59d73d77c6346f5a69d8a5bf43e863f1a93bb7df2fb0d3be31fd22fb53ecadcad661c60f61d3f42d407c9915a699fd6edc272b7df53fd0db3598bbde2c7c8c2b08db2d8e055e6ef5367d3a23078d5afff6211b0fade3cf46d7b7cc33aecbbd9e1bad36b2d042ed2b99f81dd6aeee3482e13ff8c42398d653e423befee93e57494ff57c6e5a1d7f3ed7cc6aab2f906853db25ac98176051dc40459deea3823381b520672e1bf6b423165f2f2c36ac0799d230a6735a9b9fb911828284e1f8c65c064be1b6deabbcf87d05bed5eefdeb868add89caed0a4c62371b1122bf89dd65e51707fbd32a5b94fa707a3e106a1e27b5168395b7e1fa2a6a546184747c6859750ad76d2090bf216abf27f74b8f0853d682a8bcc0de98c86afe5fc5627aaba17dbc041c75085de861a2ad4d7237de7c84bf45bedaa7c64cd061a04e265beeb57167ab814a7e69c343b05f0db0ddadcbd15b182565847afdf824a9fd1531c1a1c1e0702f15e77be455cc26f2a85743b71fcbc5c1b381876c22f4216b5d6ddcccc5881d0817301fd21aba9bbe44f73bd3abeccfe302b937100c48e9f626bf5a9403db4491ce134d08750c0ac02094d4c50322c4eafb6d8d91cd69979c6f1d25d7f586650df69854fad6e102acd70989788e5b4c863bdc4970d620b7542c7e6f8dd4b31a30247ff9cee05206d5816af70afda3fc0b2b53d94c4799c95fc674af909b7d21540befa481f57b6463dec417a2c94d8f01f1cbc9530d69edfd9f28e0a2a91449794731bcdb46a8fa0dae74fdbce03127595e908b7d90b5ec31e0ac180f3b4c770c1728df0437f00eb205d56c86f0cae0126b9f9f6cc7e107410a3b150eb005bc524bc1f28fd48ca44db7e0375bdb95fd805f8afa4da26c50f84b7b580a331dedace06d0721cb9c50e5091df89eb98681dd3d38e04a79c60eb624ae61847051a0c53d5a10537ff525f759504a50dca42b2ab3e6a661fc335e54db2cbdd4336227093fbc68388fb427b3345d57e4f82c583af83e05a4e8f33f504367668b27c83abf08b35c5ab680fe0f5ea442ff5d094c65da9bc02ce545f10c0efb64820c40965cc0fb00092ab73f5bf488ad99b7198e23762c200bec3a4d93d7de9b50da8158115697af6c4598f1b7d1af1a8f1758c9366b2521f60cfbd5e2700a839bbf4811e0ad5fc91d6935363ed0c24ead03c382d1d9316cde26c891712bdaee21cd1852da350a1ab63b2b103c0c346864abe3679f6d4082bbaf1c08d901ab709ab83ec6630fd82c00545a1f6d852fe0a1c3d41211044f1574dc8192d7c25263b1c24d32d28511d478f4927c4177b3cdd836496edfdc79c8729192dd5caec7589a4fbcbd87880061f665166c0316ffe0cf9a3e7bee20f1c380c5c4ce7cfb36ba580676e7d19afd03c5ee3e07b9d868fb1c178d3acd3f79b8374b243a662cc51b5c6bae5b61f477dceb104219ee0377b515fd710a4653c099a86d883872db9dbd485bc17b4390dce67c57a10eab10ce59f62cf89d7d21b8675399f7debc0d6f3f72c4e6fbf5b35c578b240cfe2477d7fc1f62e54848db1f1dd4ef8d1dd87853095e3a276d1a539311bbfef57c640be83cfa5b075e6fcafb88c0fd7fec8f62328ae2597ae81c14bd57435393f26973ffc0623c65b7c1d777060b1a13ddca64478fca28916cf4835af4c52522ea21d83996918ec1d6ebfcdf66893a15f4e269650b6b743262a3f1d77eda4c7d1ab229e0333b04c594ff1201d6e89d214e5c9367ff074453a36b538df095c73905961889971b33207a93ac88b8800228c84991b584f0cc6636890635faf38f8f627057c978f6464c52f40d782dbc4d40afc1e734aeaff6381445ea4988f6169d34130ae7e9430bd85acf6a88671b8d03dd0912c4eeeccd06746638106ca24728718f8ddab2cf3e51c6bc1a2d43f4a24931aeca7ccdbfcdfa35a0f5533f56e45a1c9f826eaeecb5a6d1a3477684458ae8c6d0b0837d258e561be33c6de21fd621cb831c25d1c8eac8230636bbccfb39b955c6c466ad6f352fd7777b168657799290b1340c11e6ccf0f3b0d5d717a565285f7bc6f7163201d538be3953c717991a6a591614ef4eebfeb2853d8be598f59848df974cfc6d4815141fcf3645bf3476292d836dfd7e2868a524ab63efba0efe990a3fd3f909b0a4cf85440d9300be2ab0eb0e64157ba727895d900488c31fe39c393bce02f3d5cf1582cfa165332c5fcba4d8e06477b8b86933103c3175fabccdd28d8c494fda2509e83455d2e97b12fd628f53b7b48cc9145d0079eeea8e4e4de4ba6f5cf118b20a422a6abef5dcd90e88c6396efe8cda7d9aa4ede81e20d0dbe65237a406e36563999d9a23a82e4f2ec0a458cbdb15605c1316c9b226b771a64f2961eb2b2b454692c796c92cfd199f47b736809130ddc4ca18b35254a504f710c41dbad6357ac939d453ca213d2759469505772922bbca0f2eed42f79a79b5ae95ed515319ed8b36461a56e72727cdb02c4b87a9e36bdeea8b20124935c6cfdcda2ad779d072fda36ec4eb2a7da95ac07c023eb5fd245f4fd69725e848e36fb67483ac2556c15d0aeacc808c6a702ee47020779409d7bb2a9df421c7b107c8406e57f63c9c3fa11caaaae36bfd2408941d6e7f208557c8feb14c217a98174c5e406105df4e09662449a3e1171f365a05e0b2fd7386205c5a7229b256bb1d13663b10454c6e4bb1cf07523787a45d5e25fdfed11549553de486924cfbf376e357b00498558ec9ded2a20720675d89adcedb93b8f47275372e3367bff880208222e6b75b4ab2a2013cb2689d22ac403749556a6279bd84219419a64c0133108a0d7", + "erlang_connect": "a96570726f6f6659121342c9ec52a6657d9c6e8dfe9efd12e69649a6fedd4ad44976c6751cf744ce7768badec1cb3589075489801a437140728029dbb98bcd027ebeeb0962acba7897509963a3ea28e2e5909591923f5bed6ff08252ee6917f9b7180a85d3bae78d826a116b2d7e5ea63a31fb1243479277cca4b2f98aaf35ed1279a7cca8a88e4db1ede923177b7561b5cc952f3b94997063ebf2ae49dae778817fa8214e3ca5ba24ab03f39013caf6ccd892225c4d58fef743d86ab7f9e24f742652a2d27cd582a31509df7e7a1a4b237a3e74a36e17a956f46c018ceba2fb3f0ec5aad16cc61606d87140519d5030d18b86a6de26fbb0f47725395560248a730e77c7d8fcc48e55e188e50f5ffbd06da87f8bffe62a120d1da9369881a60887f2385bd1fb822ef368735df97b1d19f6b04367e4c0464c477c0a3af3bb523b553cb54d2c2125322781adae1cb5c0127bc81eb4ffe6d297d13333ab9e9a679b44646967f30915c177baca256b341065e84af9e4aa901bfa8b32bdebedf6f58a2cdfe1049a8867dff3d76685ba6484ae743619e274229806eebcc82cdc62597f66249c91fd9a10c5bba6f419cee2e8e398843cd0f1edf358b80bc8c315893ceb2f2f41d8385718f164e1f28d48e69271091c02b0f155ded277e56d3e6e2b387eed9127fb8a0b550f2aa3430ed4901ee9eea94af3f2985f9a407112963a4659f2763caa3e9c30a46f494d52edd80701503b48d32eac8157eba1fd8ac6a96f0db9d6dc1893e6b396ebbe15be76024ca0edfdd0f1d5a21380a106fbd1b19ee4ae7031d1fc97c5f1852c6419687a70e06a263acf668c1c1cd13b2a3998a5f1a8d91db6d00f500b7d5c0a50c5d108170a727cf30af90bfc8a66825d448c21a99fa9c21124286d162b6d8c001c0c46a3e6b64132f996fb907b177398f09e9dab192a864459599bd880c7257f5784ef765ac8e9add86b831e8c2f22bcbd40ee15e190b043568a147ac4667502d5c502058c727e9ae11b16203f53674a3464aaf663d54b87cdb879d09bc7ff388ea94abc53d133519db554d6f6ed869eedeeb15195940fc17c59969863854ef519b5e2ee27b62fbd811bbaff517f4b5fd9865c6d6241b200696fb9cc9ff3856038504b94217d70ec312117b57e042b372f9c75b7cce9c5d8dfa496d2ffa0005c62cd24772836b9528b17bcf86edebb92ca7a5972697c38edbfbb813ed49a6be8b70430e468feec3d24f2bb71fb761c4b903108c9b00e1aa469ac1ced16fe02ff1c2d9d0fba0eb9071ac67c5d0dfc4768b249bb15ce0f91766e05335a6e3c997c001d2b88bb3fe6580f151fc4c59a0ec1e59bb6d61bf5679eaa21dd573d1d8c91832ef378a9bd6e225eb171a523cf4e1b69997ae8566d393a451a9190e769dae91e1c18806ec7f39b8f5f4f35f737866e8a3203bd0d5ee5ac0421a1c43a1a30f5da9acfbab254065bb50e9d4a8be4673ab411b1699ea20e8a974b9296fa421f362a106df8e399daa7b50b4f9b4343d1ac9703844176a7a622f7561292ba1360eafa18fd94a99e27d00d759db35c9615bb5e4c069eaf7d9b5c1f2ad7d97b18d8434cf8a2c96e98777aba63727f2847cdc199f0b28d0afdd4ffe5b41a440972b238f0ae06bf870a3a2348de8beca31ecff98ee7286f1f413210ac18dff616112de83cfb00b8c50aebbd2ad208e5a5949b35ee60b0b65b169e823b47ff19987426a517249b59ffd7c9ee7138e238bb3b989aeb83d1694dd78f312fb3354d8ea3afbec1cc0d2e0c83a12d5bacc6e6526135e6944bf1150cb0abfffbbdb439b948ddf60b6721242eed0e7f93972dcb120bb9a80ed7c2238ba6ed043f141b0b717de69c52e6b0007a7f6b3a5489db5ba2722a685e6ff80907e33579da29131eaa5f241ca322a6667513e99db9167f13f4137a684e4658bf6a02e8b27cb153fcb43df305e38ef6cfb8393502452368416fe5e5587bad8afa9d15796bbfd39263b72686c51bd8f20fcd177da45b3d8a429e254158ae4014a77eb6171dd6e52e08520b068c82d51d176ffdd42929f75ab2cb695e7bcff84738f38275978302d95a8a5539bde16f0e64296a617ddcc1e5a1958bcce777a065cb651dc6f65067c2ba5060e000b0f362c89d099f71e74922877d388730e4cf24942c8bdb5cadc3e71304daf879d4a5ad3638e572484645761c670904a5df674c03e15aeddf0c76ef48cba4f95ad2c16ef55cb6c6f09e9ab717d0e506e7f644080be02a02bff79f174ad49710de1450ede82beb9a9ba4630d9c101e6e5b4f6f68b8ae7a2072ba64861a0799ed2b4e0dd6c5829f59e829dc076716cd04997417bcecb49462b529397acad73ca056b1e94c8f45ca680669dff9273d2f1ef1278d08373e6898417fd443c4c8c597a3658a6e459baacf2aeda22624e92b2c4b24f6e8107ff8f1a91ac1f5c443938a4d64bb959de6132ca754b7f201a5e63f0468cae766c40b3f395438f55afebf93a9d99dfc91f98292827fa032d00b0ef97daa32b51f0ce44feb4c2d0f915bef98dfb15754ee8d3efd0c4e8769b07247839633dfdb3bad1257cfdb1c02a86fe7f323c74573eef2ce51c1fada847f078baadeb0bdba46c07cb9962ba0148a287b06fe4c75f53d4cb86e33203cf8b503f86250ee2d71471be050b0635f7ad9897c7acf638202bbff2c79bfea532d20613d913162da6ac7d294f874f27e47b15bc21fc08438dec4318fc9aab0f319b21575148159507072554713a4a37341003f4f4b20464ad4bc4ae461d66d0d8f613298e948d415a81677b50402949a61d4955b0ad71c2b4821d50345c89fc50422d42f83b63eda1dc7d6950653fa5eba039404bf4675969074cc3a6e42ad59caa31c9933b593a02179f475946b104f783d9bbd3433a34403dc56ed188458b8ead5ef45e6a76733ef6a40de743cf0e6d36198d4b5ff5727a1ba1a5b3c965acd46623b82a255ec5556041566f3ee711309fe34704bfbd90fd6eef283c8d3cf01cae31976ad08d5c3ef038c2493c9fca97a928b5dfc3b4f887655cf5bfd4e6fa5ab4ce0c713920832056fb1423b87008eec4dd6160806ab4828dabfa4dc9fcbd05f62b24b32681bb4aabfcfc053b26b877443b297d826cb9018964e92c7015c568bf64e9c07ab14527bc08316e001e7bebf1c8cd79bc392c08078e5b9f04079cc34a21fd04a846c118ae44d226d3adc72538eb25e211ddc22a2835e0fad763eb34772032a78e845a5e6cc93a773aa13385c608fc190a7b172e006d51c29aac95d853e0fc38518ec94589725e4d0913ab7e7c6e6c651e1264e5b3e4bfec39a17434c8891f2c1cd10bff765ae11fd202d325b81a0f3112290be4abcdfeb4b48c78398e7a38f4ae05fef0a1dffbd0dbf5d20416fcd238d86ed898b4508a643fd8c431bc3be1deed9455b280c6493fc69400525677486cd5c5d235127d27cbd65304b9a37cccf2c0024ee5e68b3a32122b095ee22c2afab64ad91024b8c0a37317c3bd2635b90964e1af274cfe28a48a925819dfb0e5e4ddc6ea00b59f36edaac7ca60fabe14a4d23af5840d15147ce1f8240586c3c921782310c1a64489fe6c7400c56e8beee161623275a07b7d935d252daa3f776a3e2f57689c490cc0f2caa17dd43f3c1c2fd8d85434e230e1b5eb15ff871df5054eaaff6fa77de884cb5ef9b65ccde29666bfa35615953081a7c14d1b0f24d086174cce0f6a9b16b157c2109752c623367f7caccaf8c331c260e17f7e2f344773a67d939e2689335041088c2837b4bf461963795fad9236803ef1921adeda8ef82474964430f3f4c5e010eb526de5988b8a69f2e0f9b8a7ca5a85248ad4d164257856139c8102b17f3b9cedede1ff9d3787168f6d4ac98cc4aa4f0981f4ebe135016de74a8f6a2c9192d046e7b250183187ef7e2075c8ecfa29e5166ab2132e5f71e0fcd2fc2655adb6b8cf805b76803eb16a5af40b78ecc70c3c0ef3617265e1b6f2c1c0c204341552b20a4b856a6650c5ee39a5b70129f64c9ed5d70164c90a7a2dd2830ec354ce4ebd5b5c6934b220652b883e2e5620341b5f0ab805904265058ecdd04933aa4241712c020096396331555b68c8d96478d5f4a7b6f26639471652824b96b3abbfd5a5b379e9ae94083b426e5c2070f8eb0afba2508eb65b5ec2453af272046e8a261c5cd19cdc513c97172689e57ed0d5c36e6115c7db49c91d955ed2c2438aff54d6c6fa1668d6fb77cd402ed02112d49b01713d116e6652295f045ce483d97483c55b9ef2c0db67db3ee59cd7cdca73c1d46bc23d109121d1b00bfc740493198818fac48e1df86a80e1d88785465a85a070313f8e78d83d04a38b4ca2e2c3906e95d3abb9b8ea87d9a6a3f05b463edce1166ce4cc84325ec026876739bf92157adefbdd8f385f6db0169de8cd7e0c58e64876d575de0129f8ec34caf223bf19313997ead86eb3361137d671b30d770c0521ba9812abb5d33df3fbb177163e5b3e876d5af2abbd098bcd06afc1912fa1c895b2e9a30119eef0ffc25de665cd8637012d5b04964911cc7adb67013058a17332565b36bf32e098ef0840939263268f2a3110d2cc4d689775154b30e31250a59fd9b41f47113452de274d3ad9425e8289ef711f38e29de8ea1fdbe0218b3c6ba60acb75f507f4efafbc6cd64606b1aa63b62572f5d3212ef4cb282161bf2159d2324a669b19f23abc8e80533c656a90158a0eb513f1e322554bc7b1f3758d29af5ce68c2b9f366e4197cd06cb6112c1800687c599018913fdca74cc89387f8c7f3ea1b702d6b9ea4a84b9a5e56a725b9d9a6c2d48274f4c77c0ea1976e360938046eb6fe6d872cb2dedeff2c82ee489421221ef109f8955e89e1b3a98986343ae3ab913e685411413b1415594d91324d0406e03c249a2861ca3ebe16ede8bad36fff1f5f7851973b5dae73dff98c587cc5543d885e13b9f65f65d1a64314e38d8214d01a94a8924c772dbbdb343557b1f2061103d6e1f505e7f5fcee849ef859ada7609398df0fdba6d5028a5cea2b8b93bdc05ae95beb04181216cdf40c814a5198f671f05b73967767e6a544c50b1c6c7528988f32a143c30487d1670ec97f5a2c18ed829624adead4cb5bae1cb23c3bb8451f2b9e29a8091539b0ffee3ebb31917c1f1198988093c2c15d5ecceab525740cb50810b43dee6af8a92a30b8d908a14911240cdc0914994ff83542a8e2abe0618aded2cf7b0424b3ae9d9d42c27e49d474f230a13a0785d1f75e455cdf4ae7b86c50c32c6589e569e644e5f7e0219746a8bfd85d660dfdcd23e8d5d1835fdb314ad33288ac89efe7c30b58dc47d81a6d41fac54c35db4aca35d8833775533baf99fb20ca345c32aaa31073905f036e9bcfc975ebb776bb7443f31677c19b27b5450e4d3cf423c319b68c99630b6aa74b65468bd0d814934451b5405178dc1879c1115dbf2fa7a2195d49b333e96bd9277b175c67dc246aa46c075eb5145425a91095c406107b9419c8fe8189393b331e53a2059515c0c5c29e701c41de6b6c2b0ebf1623e03233f75099aef23afa830f58a3cedfc4fdb184ced766eabbdc5384733b1e6916ea5658b6c00d51149eada8283933ef8927476bff7f2a59c72f7b3d9305ba314e742198331e552608b74f030fd07cb1d7f1882edf15493657ddc59b9c8aa9da6644fd372eaaaa2801e6f5694b08f372bb21f8eca2e41b8249dda17b5d2735995d6ef1d00570b5648b4140d43b58c706dfb652d431688ef3ae9e6a0f222e8aee03795de8ad09603a8f4b9c9170498387cf36f70b938363b96af3fa4d2fbb89b3182e3e537295bfa79733611500757a90557024c369e84d0240c95c9ca9605ad3f6db3485a0781e679e47161cf4e796adaaa56ce36d41737d93962af16c66723a1b1dbb0f3277d4fb1c22bece20648cac5e4f3300e5f8e1699d231a84f108da5de472097c8c64c0a0e3691ad93d313c61eb845448f469a7b7373e12dfa19026fe26ef5ec0101e4dd9283132ecda39c0b74aa2ee0ad4d80db966b053c4c0b9e8b8df16844936003eb85f97c8b7c7d203252fa43c1b154accc83e4c332a493364bed984ca0d85a05537fd7613596d4068ee54bed1e8ad57ce24a4316d607adbe8d9ae8b4c83e1e007dac8c41dc3df49be2bac58657bf5188976e8c4cff4e0b66aeff3172be97769163a7158e40f674a0035867d9e6a366affdde14194103d80ca9fdf88f5fc286a820d9f29e03bf32648295eb27c0ebe20f303b80115c302104e5c64b097bbd7f197861ffdda63b076fee274d094d47e5842cd48f034c689415cb47327aaf56883599eb5b5302565bba7d9e06cfe11eb8a54d3ad59df90d93eb000f01f0f0243e5bb4b8bb480150ae1d10f1a583aeec57f63ad6b61a4030503ca86d0e3e28f7019e73cd57454e573007d9dd1a4d20940436d717dd3dae6f4275c9dfb26898fe8f217204f56606b7c85c8017c8f9db90e437c9fb1e3fd13273153882c72c5d100000000000000000000000000000000000000000000000000000a0e131c21282d316776657273696f6e046a6672616d655f7479706567636f6e6e6563746b636f6e6e6563745f6b6579590a206dd4cb09742bd2737e0cb1bae5bd88ff8c2c2e58632e86d1366672b0030ee3a2dc629c8e0dac3c3b43dedf43078276d4243c88777a167029301d26c0cd5036ea6616849a2cdf653e30ac9dd70949d02d66e51d7e1f682e7962b9c7e97d92f8c4163f38e1d67721ca9b54a11d5bd9f8c7af20980bbca5089dc47cbcc60c6c08394a74a4b3d352432d3f25cdd8d27c4a48b45338c9c55a5463605a69137dfad93fb24f7c8fe6e333d05509f00993f88d209a67d5933ff220ab9fc2d63a95f4ffa41f27b10cd7dc7c817165e11d3995b5995198efda9c84922e78a6e0fd0ec8c55e623df6a9cbd2ecfa157adb9da02b8a888cf1b6521133efc19430698ca01d1126de76defc3429a19174128a6accf456fc88589fe8ecf460b8a77506a8a391b88196d60d8836e159eccafb505f0540c33c01eebbaca614a4d58a4e97de2534f18a2d7dc76e85b12c887722438806ed3784ac83e370fd8d13f93a98ecd590420aff968ae8691de631e8e5d2511048849fbd453acfd0dc87661d41f0e3477858468d335531e5e577fdb4b3436a95305d150668c0b93b7e424c65af22711cfea00eba9db32b744518a2d7e38b42c008655fb16b4ce713ad600be1fe5c187815571972e6f3fb49336d06bb7c092346a61d6353c8e64b4334d084055020815a7e728f6b040c322ea058cfabe4d44a758325c372e4579098924e2ee7f18056a79d8b3c24cf9f20e4f4e8c5e4a5f2b126db0ceb2435e636c7cf11a030da5e039a47dbf5a4e966d3120031e8566a10278d8d2a1125cacbaa1800bf95c508a9173ce4c998dec54d10e0a92d78b0ede720b2237e8ee6ec138224416ee9dcf2d0be0a69f474fe99ea1ada4c7fae4f0a3511367a6b939a64c59a52bd1bb2b107608bbabd263302ce87238aa3fe74a7dca7777e13de827d8ed92705a13c737a50371462f15c9c52d399e3861fa75050bc4bd44536942b32f308ce3f378a0fbe0eb03e54d1a005403af88274e5b6aa571c363b408a223515e8ac90f18460c3fa3fa41aacc09d758aef1fae2090b718392984a4781017b355a910b0dea2e4ff6a4fc50a1d9d27bd7e3a2e044a7c54f6b83fc2f8e8a361d7f03928b2229b4085f2d753e576d0186352e1d24610f6c7ea719e7aa8abe376349e975982d4b550b20d4c068b7cd5bf968326da901f8b47c1a966b4b124ee942d8c764f331aec726be1c643f229ed58dd2a5a7bae76e0872a07364cc9aa7c91c6d8e92934c9896bd56f824c5f72da8fdc3776126f973c1a1ce2c34c8f684f647e2879ab3e454282d5f118074c4711f73de4ccb1f9bb93aecffc36974e2ef367f515d2a697712ad0635878e5181438922aec8fad3b58264262200690f70dd9c502eb1fd7da4bf7ad7dadd475de1d474286f62153e7a5a6e41850cbdf6c9f7565fd990940df6849a84ff0aad638a1e2786f5be31fb03a209176263fbb95236f3afd715fdcb33b8a3995aa2253ca58da30b8117aeebfab88865b56a7f139eff9585f4f5a28eacb791347698fec6aaf7e9ff5834864d0369f31e8adbe597afee7f7100a13b17fb9c8b1da970a49299bb68b21d7d259b0106c32526bde8d4a4f971e591083608cb1dc45f3d862cce1b4fb3551646bd79eb1c37f73f0465a19dd04a1d95eab66831c49f7029a38b988d60aea4f5c1765378a4d709861b4719d404ff6f50896793b83d186b5593336b06193d321cec66934643d42e97e44c41f80671e5f75debf4025b43c3ce91496da3374a9cc58924684d0a1f64784e9be7f1048a1f21c8be7006f6be5e91f65ed55de400edadf813320a6ffa581ab3e1e3ea1445294b5e2dc8bcca987f01042d8475b9508fa966ff424dd89b36832d1d2135caf504142292820187d9748185fdb6d0cc56c75eb6fdd6d4ccc096cb1b2213f9a6fc1daf4257e99ae3762a8905878bfff3e5b539bb7139d6c9f4bf70447e79525a65210ef2a2100ba6b9642c457fa3bdda380c3e28afbe3e2ec2b56fead7d90b34a8e1982525ec32750343806201e41f7438681831d8842fd189cd9e899731ed6b88ee6950c69566c7d2dbc30811d3f46b5283598ce2223b87b401d82ec21d2fa1a145fbd9927bee8054943367d36808f13dbe2ba963c6e2a69b18ba81a5c71604d364bbb8fe07edc54d52e8c622b336eacc8cace22471e208072fbab7bd4c0fb72bad8fee40c99fcc58a9668255d8996d37574a3b9b6a7ee51ee560cec6f1a1a2f8f99b9e7e4ec396362ee35b4fdd3f0b0a8992338d0df83eb0503be004a101aae4720b4236a9ef1899371f9b62d98603e21b28026e4d7dfdc883ce5b099233482d57902d3414f6bba482b8b988a8c98905a342f7445f30026cbf4babd1816e0e927cfec6770cb02e6fbbc2528d24dc5181bfe0705848b92eb817b8b628a97b680480175d3990edadd92d913020c977116b126156058acf514ac2aadac39e9e173d2607081043fe1ebb8be2bf085c6341b6dbc22a0bc32077eea12cf51c857c81d880ce83d48403310d5e3b18c2af1990c4c8455fd7b1171ab9238c698a8d4ead52428f629d52aed66aee0cb8e65ad8df4d0c920867716ae569cbda73ebaff3b1863f9770575097a8b05611e3dfa940d831c6c318a183f3e54fd2cd14f900400519a9fd9c256cc0d7f296dd09d741dcfb754d0ab4eb24f33fda4427e4ca1183079d685d93031ac9f869f77e203bc5f6252d342f79973e682fc5c6352d923dd1dfd98ba2881d2732915bdf3bcf422fd5ecaad8fb36cca6410ea19ba00c3d8fedaf51b58856c403c8430f515c8f2e39cc50deeff84328d2f66a1a3bcf9da0c579cd8d829999cef041f060b3e8b3cdf65d48d0bf36a7c96d3e1a2ff5668f9d1afc7b6489954f371cffc86a4f186f32be662af88121f8b4a0c0ff83c2e0dc511e896149cb1a230f29c19d89a25fae1608d56114a9396c7fac8e06ec1ce399abeae93ef2aaeb5df14e50b49c6146667ee58c16e47ab688097f8cec5ee72bff1886de1ca68a853044f24dc57fc36050357d1bab39a892d21d121bacc998f992f41a9308c2c0213b86cde4da48f9d14fb3b82c7235bbb803d2aeb3119db73efc8541a0a1e54b5ae28625a23e906f852779b977ec60750da93b5e791f2b5d291e7f969fa183ae0de79d32ae0038edd256f276f1953fa2e65b9c1e525487235a1c61a72c77947476e33c45ae323b5154cf903121a30744a78a9beea2e5edf081ff0686350b87ceba218a4cfac3141e9f94e820f22aaa230b9c382dd60edd0b52d434fb8c6e5aeb2fe76c0c8178a0f8e2f0d92f0697c6e7ecba78e091c9f6d059363b37b35608aabb8a2cc0fe368d1489a05bfd6e4ed0ac7965d6de26152df113d532ba2187159b533e8fdc9281c2995f9433ee0c888af29d37a5bd2f53a7910851ec37dc0a4403599a07103ddae4cd84edc10a5f19dc8c61d94eff7a70638a8a21a79e0a778798a1ac69b669178826c4cd0db1a72fdeb5f66e7a05a1dd4b8488731ac7a005f9acb68d06126f98cfa617eb2c3ee6a77063f41603908fa11cedf6999ca0a5922a3bf8824bf404e050a9a3a98eb9bf257a2ee067d180c53c6b911b66e5c566fe3b7d5eb89e7278dd69b6b21bfb9cc2d013212fae94b18806365aedd54ae08deedb5e894749c21c316d2c1e22dec6aef34437d586c6361706162696c6974696573056c6964656e746974795f6b6579590a200510bbe11ab145b9b893ab1f75686d7c24878a6b54a8f8beb1bf89e28ea8fd47d82ecc4e246bb6da1764fca648c6ad1783f07d9db603c67861cc0bfa5bb21356370ebd07fa5779c8ecda387bdf260e1609d4da2eb0c627daf095b79d0ba4b848d8195057b0e97b7b6567747611a42974789b9fe8a0711bebfb0c7b006c07f1352a6583f10dd5330470e534ab04789da484b049a7bbe11d2da8767c19d4dc9150c10f0cf32c30eef462fd6cf1e5b22f1e74b71b56b30971ed5b0bd6893f627a444f1c81b703ce3412c8fee0b442fad70246736887728e6718b44ca12db9c31c5a497491b82ab91e52c96ea56ec0c9b4de5740c154563a7486400796344578de7307a02cdbf36708e46ff8919d131873dcfd8a041824ab118ebbbba5e5fd7594d0d9eb955425184293660d1a1177365623e4b45ae34611604cd253c4cd96362f5dc56954856e6b54ad635b42aee128c29960b42f7768713e8a9104e494bf3ad39d37bc599a9b49e7314eae46a8857128de11c2864f83c086fe6884dc4029aab43823fe2aeee8b7c4f84a80188822defa1618e841b36dfc2b4dccfb38faa44939de9f774f61552f3b55fdc5b8fb631881bc5fc137f46664c7fcbee14e9db8c0301c03d492f4e512a9bd61c33b405ee84b50a57e3f2c04abdc84e44d67df6039ace6dfe98c6978e52d9ebc58f0cea494e95d695fefb24e82e6c6fac8c21799a159865ceb1ae305aa9d493c58c5ab06aa16af7e11bcfdc4a7439711012535c4b42cba1a2025eccea6c478f13f371bf8b5694d1a4965b79ec4747a1dd3c7f15eb6a5eceb3258f40e0f4a7f3e0898a00860174f08116ee04d1fa1a43463623a095ead9684d60c58eb5a60e250226145dbb87c89bf234fbd80d7d9702215385785af3582701708b89fda902cc5f0523e39b03da0f4e3c46e59d640f6cf251653f58364fc03b097bbc59f129419cca5a07f86532438e1633b625ed07eed16aa6918a976452dfb1e3664059c6a31aceaab28cf7a12bd712491759b06d3058309f4ecaf3cae5ba7e5cfc584bbc4f83cb72ec599bda4fa2b6b22122f65701ab1ce18873442fc9da5df956edd44c947d5852db91f776f9c29d250d4a9a07ad411627b4e91c232e3c9bb724005f68d9e8ef2ed70508d8db1745077427e47fba031c3e05e89d9bb05fe749b679b5658d3c03e1a0f2ba3e1a0fe816cd28195bf11a366b80d68f06c42ff487d60cdd658b99ff5bf103d66d5e7fda621aa05beced6c5ef6524246763d30680cbc2471714bb7ac7e506275923cde2326d60707dcfc98f489625123a5a5515fc9779f4488123f39080bb694661f3b8460bd1704ba689877efa5ed48a75d3e590e14cfbc083669f5c05e92e3bd85adbaeaba6e68445e5e6f6092f48f8a856185492017f682dfe3a55e99bc92074cb1996f7a1750351aa7f0ffc5f72e87bf5a458f74ae61dc3f6980fe62df21966d1df467a18ce9912ef42988e881026ad3fc1fe815021aa5b833f13d9e68c3d05c049a85b40abca95de6eb1857d876f903b86523d4e0e189ee15a9989eda3fb3aa4c42702622b02b2fb1c21e96bb2e4e94e7d173f3330854cf0d0ca7039923e6dd4434ba8dc355d98bbc1fc9fccc6583e06b2d63162a3cefaac37ea56ff593edee703480f2037997a7efff187234ff5f6b23e2a95416d9cf26d4c0a2cffe0ec5a8917977f671a8ad7f7cdd5d9e2bc998066b6db14dd8d0f38ec1ed223b372ee441c3de768883b67f6683dafd3d25fe21d80634f54cd6ed8440eebe308a8acfa602c4095d76c798264631f142b08c4943114395055a2dec748385e231136a1dc1d13aca9295db9d88719bede40d5e3ec3ffa34b0772303c4e2fa8472eec942913c1c1c670b8ae78f046dd8aa8d0f22170254a8207b7e6fd17bd560b2c9bf4515e3a89245a75db2a1d1bae4e001a893f968792929000e13d698189a599e6197df12f683ead5491d6b2a14779acf3d373bdfee6f60cfceb4301b3d7a222878b52c9921a9f80f4edcf52d26922812fd939a76b736d7e13b30a323320dfc44c0aef3027c4973ade622472c732fe64628ac9c1052f6e25fc28ab9fa48ddb42fad900c6fd151f83a3c07a00d6895e4fe86bbad8d20657e6b3a8f53a7a122aca1f2460776e4bd062b89139352a33a05c3cf780a9c4b1afac6c88cc6d2441c779bf292e3cc25ab9b33a75bf4473e8bbf758834e32ac6a2a34b34ceba647fc94312b7af7fb8cb23365e99bcb7b23123acec0f0054303572c64c8f75141e2c2026d69972512a78aa305ea9d73dad0c944c79159f00e97d1f534c961dca70d45c58270775b10610d8f4046c22b0b111c0721e2edec6f147fe912354bb96dff6e89d65aa9a734e4d908165f6122dfb260d747b1ad3c1284c16a8e7564a42dbf2937457914c68f12ca12f7905471ca6509733dccbf3aea5a9c0b920efa2d25b4f4f3dd4d10c64466d4ed7be9b6bf907de4d148a2d5e49b2a9b7c263ea86c1c65ee0cb9f8a7ef69a8ef1b3e62006cd93e7f71fc9b01f1768514d85ef5bf22b5a513081d2e1769bce8cc699414139d767edc2d2c0f880986d33914d023ec7c4bb119f9017f2a047d16d65041916322c7e56dd9815be5132473f079546b542861529660ee6f97c9ca6eb54f9549fadf55fedb65987ccabb596b7a45bdc41d824069c5b6205699b55f844183c6df5b800beedb8f48c3d8c77643f90fdea8e903c6a47fb659034850452f18c3ad82d497a5fd0abb6f9bf613fa05b94452b32ad6b5d36d1afe67e2ede003e6b223f6424ff55e0a91b6daca61ec96af7b43d16cfae87d48f2a19f08d01491df2a7deb160c158c1f708abfe4a720f61c123615beb28e297641cf82bc1725cbc7a23f2bd6d1f9439818fc112d91a2f6757932fb72c01517ac6d233d70b35f2925bac291ec16902c4f97cae5a8a932ad84e973918db7bbc111c7bcb87c055102ca7f262326de3ebf509d829b6513928d9416e32bf9553abda3a9dbbfa46a1c5fd8183cfc9f5cad3de25feec794b93bdd51a0ca6d7d55b9c056140a78555ca172af42425f9538f5a652d285ece2af88a756d00c4e183679ec4d14a2b58aa77b1651aebcc5b366da79a1b7b3505e66d3bbb2231738cdbf3506a7bb0b40ab14f23d3ddc7cebe442b50cf9e02a6c00aef200170042928186b2f92e7372eb9de3c0af90c3aec912321a2a9c4c3f593f43b3c93266964409837c54a41a2fb03963ba3950e64806a1eb91e8fd75925bfa52ed1448799f07b3c8f04c30f5a2ab42860df7f728d9666e7e5d7ee96da5106eef362821579a2511c5bf0dcd70cee2ed69cdfb530a928867b792f9d716938943202c92adeedf9c859d3c2c04a9b2261b65f9ef0941e26c6c8fae246940ce1d109115c0945c14c7f5aba76cfe1d2227c853d05a322c1f6be8ffc49280b9ee8d4e067ecafc416c392e16348823ca620bbc575ec0d4b6214365cef55ec6a8b4f2f795d9ee595e9dae16da7132513ea330da950e915c429e64a51db8a18977f291328f48d2e4ecbafb189bac402d676de3c7299580ba1a5c53622e26d77c38cd4f89f8299753647c26ce42d9353545c90992c8bb65baa34b7cce9ba2d231577df032970a83a8fda0e4630951e4532e1243cdf1f838276a661a73c26fdc622259d1191d3ba6e636f6e6e6563745f737461747573a26374627358bda6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f69645820d9f78f5c69575454136fa72a33ba27f9abf42d1aad7eb07ad18601f12bb42a50677369675f616c67694d4c2d4453412d3837696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f686173685830674bdc49dfb27807edf3f5af2e4f13638b13c45e7037a09b5bcdb51ea5dec0902e472754ef698e78365d8ff4b5fb7db9697369676e617475726559121337f45f0ca5bad4568bd98265e58502169914e04f287880d8c794188ac94dee2ea4a03d420e1570328375ef9cb887118a594427649746df3058a6d1f55974284706143f003063201e8e9c10673f4f96eaad8473106846ec605421511b83865688993cd18dea5d6b5125a425d2c6dde689545e8896c91ae7cf599d1fd3a9f4393bca2f2692de082790676f7158fade9841246ed71aa257b3750df85fb2afa3ef5085b4f3efb6c3c441c51b62552dc6269ccfce8c0adbe5a30bcf51aa5b1dd90dd7b477e742ffe5280e808a2769615564a8a62f8eae28a0e1d22ff9b2b73644fcac3efff5fada05c603962a5b38a5764c033d44513c252c45de84f17ca515b09a1814d27b21d74baaecf512c8ace7194332457c0d51f83bc404ab68a475dd514f9262f840f36e6737d979d10383fa8709129c110d6c26fb3a2cc0ddf63a24ba79d87b442857b7d716162c1a467a770bbf708b3a35f28e3bd93a9b262b635010a4aa9444bbd3a13089f8a756a25beff7b56d3f5d2e4d059c9c9fedd73378bea970fc3cec3266bcf531108bec2f8f07ca5fda19b6cb8cd42d7ff079e187e8adbd3b24414a47b6e3d7c065d65d1666cc01cb3da317be4ebde013ea6191093d54a777d9b307fffe49bb63a60fed11cb5d65df12a8c987cad4e841610db27735270a6f4103195660f4ae340a7228093345ebc0eefba14f191d48810a1c96f977a04f44aa1b78e77ce0fb309133c78de78b0e0c0e495c3b8a9d5f8b71c74bdc5d4e834c6357a18ff79545d6fbf78afea317d897bfeec5638484724592282d81164c228a4622407fd6551e6054cb06349c2c4ba84a21785ae8b20f3eb714b0d43a95f52b4dd50c9f37ccab0f246b6e15f1b4a7fd832bf38b8bcb5bd28f2a970000c8ba6071e58b266d27379edf60d48520a6cfb6250c3a06b0ec2252fedb89725ca5072aa99356e33deb6702256149973b453b5e1aaa52dc47d156fe95ac5b5bde4a5dd5bb3dcef7222946225d653ada8a9b47257cbbdb29b15ec9f5fab71e8d24cc7517114f51ee2543ae1115e4f3aa6f7338467711c9334807f3834ca0df67f0b6c2860606c072d47b5780471ad497931c7922a267c4cc13e7e7d4547934a1b1125b3248a2d6d6e0e146e52d63976298adaf800038d59bacfafbff6db69150477f5af43662b4024177a51a18b0ef0ee03f52a90925368d3839fd10bc859074dd8fc8ba5cf7169b09c696ddcc03cf67e5a79640b331b5ffda358d7852c777ec0b958b8a50c08577138cbd2a2de9b2e38c5e50ad4ee40dab6ec1aa93aa02d563d63d8285f5a17ecf93e5656659a8b186f3e603ae48a8e36b72245770ff9929bd6060d0cbc36999d55abf73f414f64a788740d8097a73ff74169f5d53a9aafbd3482f441ae1edbb4c8b8bad11feb8b275054003d5a7808865f5a5d3d8506663ad45c8ae5f016f3d11071558c70d0284ce20e20278f2ef86e1ad1ac43c2a2088d65c09580fc47e0c5c5e621bfb100bb8ff29f48abb8c5ca405e28d386ebb28a81a88690e103c1fc34c156dc5c750951febed28fdef519c7fee8c5fd4333e1a46109c603c0b3eaad25ab36425e241b308ba9394800e4e4e5d8f007d7541ee6697da8da7cc5ab447d56bb985182347f04cb9034e195e555dbda0316b875bce7a30732ccfa19db464b82dd579d6e9f1d6ee554b4d40dcf27292352e70b0e2e78558c89e226944272b90ec8960b5364077a4e66394f71fc4c33d3ec4a7967308a44e7cf7b55df0ba2b8f70d4d084638e83b97b5048db709aa753ee7a7a646b3daff64ed4e017c7fbf12296ea803721248b4f4fd23a07be871c2dfaaa657f57d5e843a0498edd77f56f06a4de111d9d2066c1d18509618fd9af533d1da73326bb6db086196679071a514f28b2c57848b41cc3b12f1e6c3200d813e0c7a598a3a6113eb8b78e5b8b233b7692c7359d992f14c1f9b09200b5b0a728c2807215aa3fd4ced0f67c287dedf08891a7420a6be26bb4df3e501a896d52e20b17f444839022d45ed2deba5d08373d7f6e43b12ff71b618e6e0bb1dd8ec1935ac4240d3970326fbad14f52bf38ec1b88d7806629e0cc50ba8eb332aca4b84d6a13a79f60ea4bc8d28342d28ecd851ac98947472678516d9194ac764b6f1d3bef3b589c58606142f650fddb1a05fae06142dfabf0d41c5c62fa02fbbc200f5ee4e897c5f3ce988a692001662b1ac89cb17dc66935aadd4cafab62ca3941e3ef0da10ce7aec0dc9ea59661202b86c3db3b2555be92a950063c7bde6404002f403edd504555d4dc38cfdcdea2bc958f34ea0b06553a3b757a42cf5b5322ccc5a9aefb4482f0530423a61002946b250fe5430b99e7170d3e0aa12354ccf199b1d56c5480ea71a73a91cc58b326296a4495e408fd74e8b60c2b3008acd1aa961b62b3d465815f22dfa97b843992dd4f75723fa11fb9b9444cdb38dc04c0ef49493de4c829ea0a25389b922539561e09fb252c27d47d5cc37f441dd04f70ab8cd7cfaa2fb8899615925181bb3607457df4e95d51835ea7abe4e7191c288ae28c052532875f42870a372d291d8cd1cc68fd7379528ed53d9af881b60cd191a29809edaddfa02388a02813981ea3bed33c0b8bdb2d63c86c72c3f103cdfb4f702ed982ed5debb1ccc13fbe6bf7f33dd3dad681c84901aeec9f63cfa1bc767f5fd2778d2f53fe98f37acf24fb565c4f435363f052126ce83561c3e43223d7afb7f4dfa655724082560e547077da1cf74f13f0aeb99189a6a50b2b0a3e370a3efd75a789590a4fab7d26d235a698fb7e5c27a4036fd344586e021a3ac8171e98827c94901cae6194607b92ef6f3b52001040bd944d3ba66909e6651522a9ce688340b00db8f1ff15ca270de6bfedc5a94e62576812f9c2b57d5cbb8a3f15ead18b1491a4dfa75097d81be55f2cc639775110fe74310d2dd17507b453decacefd5ce1bdadbf3837419fa494e0839a943af7b657146a90a084b5f2a57edc5f3c84b1bfe80d1ffd94ab5fd54575e1a7bf93439166715088ccfe4f6d1bce916132eb673ba984a9c81595bbe2f2399a9eab7e449069a37b61f9c816d34a3f60ee67d0c8af35fa88cb0e47dbc287bfafd40a0c9df762465d8d444f9f168865932fb9aa08d4e3d7b81db84b8111e0529c340f61665f3b135a2eade20b5119b31bb923f78f686830b2ec7d4bff7147e60511b3fe79b2e270130cf413d3719362911d27c5e2c472bccbc7e7021714954125f5593f6703341c0be0ad41cf6ef900c52f1c33b9d1c4ef6b8decd05eec8c067182755d29658cd70aff7ea2d5e5a4d85ac6fe0fc265ebb7c0788e636575124f461a16b49e54daa3bbbf0b151020414ab7efdbebb472aaa1064c1d3290d26d9a55113caf0de9982c3b2344d868e7abcbf4bd9cfb328c9ada71ed725e01556fe7c6e11fa46d34544789de477897e19040b3fa53b079ade1bcd133ff47e244abc4e339631d981d3335b8409bd1e4fb010694691ddf8a35918a2225211e387943f6db3aa9de2dd9c5c57a43bb18a40ae5af5f7f7cced4d42ccacfbcde074b54f2bdc132b3c2fa952e088ab1fc63d5ce10c019f87574cb3b11a552bdc3417005add04cb2449e98fbb96f1d677c27713b3eda15938889ad7ca0ad81507844ba6d3d6a23c45d92c7956c343daacd8f0e880a0beb03ad91197813b8024ce600e8225250e294652189d7ccacf25029bcc484b705a19f2c56790f2df0796d5e313db9795e4cd455df5b42ca49715b890e269df9c6531e3ffcfc3fc04e4f02569fdb06d574242d1a77d51189a3757c3b690f4cbeb319c87b22c0b99e291debb0a1b4ba6065f96c672ae6bf7db9b1be8e450023a6a0c62d75ca6e4712c0be96382536bd87a0b23ae9936b1c5f3e70ba04ee4c4b379f0d9db3809a1c6bcd119b4cd020fb042220c171fb33af2d1fde960eeef9b66a6f2f2329d6713901a8a002ba4017190e142f57a52cadc7ee8dc9ff9577f39f34e87737598a00afc9b98611d7dc6fb5041c42d74d04557e154c21376b1695da9795b77c5bbf0fb6f73aa37ce37cc418d3b448682762bdc1ec4153cf72f9b48f880472371855cd6debc58e24584c0feea9387ec9b6d507867f10ef5b04e75dec46d803ac064f1c62c585ccfd613ec5a07efcfbf22a732d55bc13ab49ee466406bce7be24f82849544c9b1def1593b999a93b7e17bb59022d82cec71a040756d1ae1cd4caafe18e52366002943eacd4d8ca364a68e78906138947733a942c8744311639ad1c60c18498ea9a59800c8c0c97e0a59a1c0c81495c14a009546fc2d71adc9e6b0678b50ac58b82b7630c21193cbb5b4aa79e688ac516cbdf417247da006ad74bd57bc50761619fb72117e9824b617049716127f7a426090887cf53024bd9851dbb650195ec5810db7676cca0c1028ee784b3fcd3bd19bbdedbe26c0eccf6790367f53a655dfb15caa5c5afe1868ccca589a6921ef3fd3d6ae95bf5300a7b5774167d17d8339e76432bda7487ada03e37b299efdde2f085c40465fc174aad36a2f1d4346a969c3e7519d6002fd8868c9ce998044ca01fe27a8e41efc3aa9df3e62194768bb553f92e8c56d783bd715de90c61e6beb859e83cbe58c290dbef0b813ff963e2d2dbe58b67f351927f36fc1bc326225d2068d4ac0e9d3b58c030a548fbc2e9616ee473e29dab5a56d014f1cc54431b99f382506cd16eb46ed43ad07331a415aa35959a47328fb6af21d158fdeb4e78aef28809884d6f5147fe3401ccb8459e7bc1a1bec1052bf2cb85008164fb8e3c441da84abc3282f46cecf03976b06ee0ca0e6ab2a074c3225a9e3cf1321a169fb0f2a765fd7cceca16a2199e82a3b371d65ede3cd7d9277188c8667edcfc8a37b1e08ab32600546c5834f4ef31800f1c1e3fa9f68e151fd4bfba9a7849433ba5b766596f443622014d010a978892ec8ec63e9aca1ea875c5b2ee944023b9b30ff476b6215be88660eaf63f07ce31b5b0d720adcf27dfeecb24829c4fa884b9cd750f0db6bf8c9fba7989a0d225e3fd95292c4a5f8a45651cf8a994f42983b0f3ec377ffd551eb7d8cc93335693d689d9ff9585ed41e8de34049f2601095e419d66568dca5293197ceb49ab11bde414cf7e6ece26aa63c3c853b54cec5c6e0c6745652aef37ba28a8a14dd78d58db32b156c16c50ebf59dcef38b03d3a6d22e4a423b864d2e3eea4cb53e74fe640e63a3a9ca9e426d32d5cced2b838094d54d5e78e06dab813b22c0dfadbb7b29776bd977ce15955e73806280be46dbc0740eb27a36f42ca93e2ed795ed9e39b10c83566cb6dc3944a4fd166409a35dd5286c2bfa86410bb846b78342cfa0cb06244638f751a3299f574f81fffdba37c350796acdeff91b99fe46b3c43b9732ac63252ffea086848e1888c4d35f426921f75f3edffdb82b4d3e3bbdb343bc0eef38e6d3635072991c46cfbb852eadb86f5ba1c42a909ab87f1a53804422f824afa95942abe70c87117d7d942e3d667eec97182fc0df16af4cef3c22f705f2e9a002809c23f83f46facd916eba28ae6ce494a45728f7216fb7ad2ecb53af2d8255220066957ae902f3edd85b922e29ada44aeb7a455b381bf16f1375b57378657074c3b831579bfc7545db07ddce9e5d943913a7e124a9d63141c100cef4e7cc03b73a68b430f309b60e94e5c602827dabf49ddbe409ec03c1d4fe153f04fcf195680a8053f239af6dd298bc330732e337f399371506edd818be82962763688bc0b736cf3881379be12fc2586c73419fd80f76e29f5d41e79cbd25873d5e0b3e8fa1f7affc25e7a1c6162eedc22185b45aabc9da6416683f96d37a016dacb0f2916e87d9f357f5c8067d6f5f93df625817b2689361e63c4220d6daac4a89c46aa82d273d62de5c225a6f88e88f21d98c729fed9bb2e3f652ce1c9cc409e19982f81d0c44642334958eb56a2c2458fb22f895518477e09d399da877caea9d4e90b54e1335ce877faf9f460f992a52f53157c191f088dfab0c85fc86dbc8bd8583ecee3085cdf65c2682b7f67bb2cdf47464de1ff0f51299ebe13553c5ffe8a8e589bfff000926aaded4a448ba5f933476b8a821c539e5023d006c7b79a31844e2f73919c46796fee6c37ea3d4554466d8a16a59174fabf8c03c5138bf6773934fd482f1356ddc5040d5b890e54eada5992a25ce2c47ad9e1f8ed1026cb8646a4bae15bf3d4afbd6901d4292094602fb63dd5424db3d2243f894403adeaa000886d5d1e903cf1696aa17ee6498c6a7e7847e96573a433055029292cde425056e13c4465d42fa25993c16ec758ac3c6d9433f2d6c00b497cba8da94bd18b222458aec56459c4c5ffe61343bb53f20869e4d0af5333175d567a4eb0b352e991f3d141d7b14b42117a3f982a0488a402b38738462d1ce75134a6f919294ed3134506e72030f24354053557b8a97d9fb062024383c4e78939babbdccf6090c5a6099ca1b3d41439ba7aaacdeeaeb3f40989cb3cbce000000000000000000000000000002070c18252b363d6f636f6e6e6563745f62696e64696e67a263746273590100a96375736567636f6e6e656374656c6162656c781c4d4143554c412d50512d42494e44494e472d434f4e4e4543542d5631676e6f64655f69645820d9f78f5c69575454136fa72a33ba27f9abf42d1aad7eb07ad18601f12bb42a50677369675f616c67694d4c2d4453412d383768686173685f616c67675348412d333834696e6f745f61667465721b000001a08ddbfe006a62696e64696e675f6964504eb6492bc89f612646ea8f0cbc27a27c6a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f6861736858301b0c3989df81c3f65380425584b859bafbc09a756877f743fefa4c8e2f5bbf322b90f130cac061565fc6e88202e08b77697369676e617475726559121320cca1da6927b4fa9dc324e55928dd72bd936b8c6dc7f39af19b86e4436b34a2884d8569847205ea61cfb4996f5c418175994caf0cc5b70abe85a00c1e315d099fed0a1e9f90d986f3a6a008dfc71cc7ad4ccd11f1366d51f2e1ef6d929735ed48c39cc1149f439fcfd7969dfed7624a1b22f216e7acd67cf9b1223c90f71af401aa1cd91c3c9eeb3f496b4bead7a898897d18f8f3b264d6ad93878514a8152581af495881d2ab558e578a74aab3eb7424d553f058fba249645127f11112055ceae4e4cb53208959fea68b3d042fcfcf699859985cc0d0fb73075cc367fe5e3474139728448cfca89ade40e904e7f657365cfaddf49a0ee4ed1c5466ea5187f8d71fa24f7df51b77406a1933f2ecc3196720c7ee3e4ad1a39ec9f5436b2c20d00334f56fde2d801fde053fc5b0aa30cb60da60ca7f288980cd0e95aad7a3e69941c4bfd31cfb2cedfd1dbb517190f124c94568c2e95f73094e7f971eb56a6aac07231d246a1c7a89a903fd9df5a17bfcd88698fe29e311cbc065a7b8217649b3e5b478246132e8abb4d785ae37538b3d4fad87a3ca75b271ec6bddd6e061f79bf71097057edb6c623c5ce962a3b137b14651ae0be04b8a645b21a1f08e804e508c2fc38e1c22e93872fdc3228f08a6ef10da2688079a6c7135f06288881f973a0a1ea3064fc736c2a25f082618089f95861d6c5117749adabb2445742aef772b7636c1cd21c83a865f4d9d262ffdc700ae55deee5cbe599f6d855d335dbb63f623e49778549b9d9ee8a28005b5c0dd701dbc25c736c0cc6cf273588f7d9b4e6b534006004e706df555f0a438fda4590e69dc8144f70426a682f983eed38990ff6fca5804e7acfde68627d4cdb87269d29f7cf6ea5000d060dd1bec7cd49146b97925172d38ac644917cfda059a15f283c98b4b6b654e464d34dec6efe7706dd9928037915ff14a95183b9ceb5caa9744465539a004307da61ac969ac0cce870967f0000a6714ce4cf1403faabcff548688b2d2955bae734967554a489c7aecce3e34a7686913c1d679bb341f1b1b16ab4b556d8c8bdb3357c8ab817db955514543b5f85b60520973e11eabd9f34600b823b22298364014ec813b24d1f89208da2613f795d6db6d360dc62ac56147a8cc6bd361b487f4cb6cc8f85c6dbbd3a5fe9397376b347285ca10afd92c992b50ff24025b074f5d213907fb33bccef065f342b0c09c9e32dddc318530799721bcc0676c2d86e134b8a19959b4a4dca5008fee0a18c8c3c861b667c3d0914003d283fd7a13ae7b43dad8e1472b4b9e314443ab61d1e2dff49af29d69560c090263ad5db77300c69b9f9ac9c1e21d79995507392ec3c7a062e91950fbf7cf93f740505ecb5470f4403e27c57bfe06bfae77dd50b411d4f9ae8c46936b19cdb86eb3e12ee51c7fb4bdbcfb02aeabe7e83d41b9428e521267b549ccb057fae779798ef379baec647972a845db348d0eac4e0c5e08f0308fe64f11aedb1b54a19be848b8c1b38774e6f522ed6422215a92d3bfa53b19d188977031fd0c22e8cd80245abe4c4552ffc27ac696d427ebcea6e5a04dca88aaa6fbbf62f4c655b0c357bd053b73978b6b179be2cb0fd37e5e5941ededa4b674d6734219203a076cfb88d7b5d980a465f313ff37ab58a09d312b136cae2ffcf0e2e2fcc339f2666a18aeeaf77e6eb0edf32fc62514b1840b87306770eb806db09fdf230ba99faf7a82a36592265f19a4f270e1aed90b2fa2d47c1abfb0524ff5a9961287f1706246d26a4145d8f525cb379d014152d9fb452e08be7cd024e1e7e44277d8ff246b3abae20d724ff23d882e34ce43228a8b434d09e6e1ef878f7a3859902b8595a9cb55dc180f589e60fea4e368bdfd6f6f72dae0487b600b7581be9f5c07281cc61a9d6b65350f210c43a229ceb53ed673ca71ddbb2cd379d4bb041373e49e35307dfa6125e5ae9712925d8cb638ce75ae90a70163ba9b8c3cc18a221e2fd2be0b7648bfd922b2aa5483e6e567e33a80ba6e7f94f1e9044d36ff12fb6d856c72181ad6cd7b54b03b6561069366862f11d2e83e1d0cf1784171248dbd4858ea5ffb5414a19580d207c95a7805a6e4009e6a9d2f88df01cd156ff4b4a9cae2c98642bcdd8f32a8e92a54a546b1e5363cd5e68de84c0c5bab11868a3ea656dfd1cdc67cd960ee6e53d37e39551e3b7aa316674f4cbdc42ac9980d6876e20c6be2a42c74b8debfc8e288613784b398f46b0627d64941677f5120a369336f308d1c62c05b4bf0e5321daccd2db8dddcf38d2feb101e4bbdc143f780adae33d8bf6fc6fe4922eb7c51bbbc1fbcba94a15b4940755bb28306fd83eca8abd68bb9a394eb44b163a2ce7f6ffb3cec669baf639f7f2c89e2fdfc3d2fa5f4fff061c541b335b8806ebe335ca4746e26ffb23652273ee3b1c1f0109e4b377bba1eedcd0a52673864e397338103d8c221ce67dd7fccf2671ed16053aa4d54518e3877e4b19ab43ae1b1b419806b32aad47928751b4ba09b901d092a868e12cda10212ac24ee6748c76369d18b58120f10e7c56e6f618f5c06b1f8aa818b3cb7d81681e700227e91942a0ca838749cae43b94591f6d2487f50cf875882c63864169872e462677e708aa3df731334bfc1b8556b09dd5b8279c88295a68cedb3f619419ecf7d48fc39a8479c87e4b2a9ad393b67efe3010816818ab51befb8d190b400524ca5f7e4c3abdee3bcbcf9246091706c816372926c2de7be4c4a1c3b748b249e131163ea9d82564e03a118568df811ca942e4ae8166410fa8c3766e0c32315db21892fb8984e65d4f9659a0d012b529fda250cc564c43485eea924b2d9b9352a8658356cbef2fde1e8f43a928e345127ccbe25ab0b4861b3bc1fb89d327c5e3ca6ab8e1ea16eeed094d75e896e1bc123e916b8104d9f8958408d330ee4fe10bef7755b56ef33abaa6244a7887dca8bb3653174b75edaf87a220dff9cce8b0ba26dfb039eebe578e79efb4249c4421f83dd90d1403f4a37c1554557e955d958bea8057f9b74123e439d21c504017b3a44d497b940517ed3e6b603a5c5655be02d6e13860b525e7a556a9aed4bb940e147b42193206405da2c51edec87b54ba1b2a5f988395b8e34aa0ae20c0b7bfe9cef0ba3495dbc9081ae2569188ac6259d56952f98137d5262d5c9349ad2d80870325ca1d651e513fcc3ba1c2b726aea8bb0d8d80bab9a6ee3a92cff9cdb67dfd4370c2c85b114186852154b213f6fedc3a49e2c16511ba8d8ea7a42011a53cce78d4ff7dac68e91e44255459f08439b5e2d1d530afb6d0b0b766b9f4d2a8e00958bcafe9673ccb9f4a21eb929d746c0eddf551f7ed7b75c3baebc4a9332c1552382f4cf483980423222d16255e7c5ca8803a5e458c22ab187a97a9e72b61ed2052a6620ee20c42739c87e1153fb2b38a0048d5d8a8009a1901a62b21172d8e32d5e0a5efee332abf478341155e0b837feb207e9569cecfc533ee97dccc223d936253e94844c3672f9345a7d66c13904a86d96b2747e12bdff08586eb0637d4adffbdd1614888634d02224cf36720c3eb50f727ef78a6794be8d5bc85b732b6233725852b7c936a785cdec37472ea902e297b261ffb8f58bdbf57e8a1e6c9628631487a0a1d3c459e967abe8614a85c27e0807092de2f690d427ee0fd558a83b598e869ce65008c5dbe6832e754ad2da3a94a8a0ebf25034fa5462788b7536f164bf39f572c643275e3954547f8ff190b6a6a2cfa14bbd45ca8b172c9e9af37cc1bbb3632196b4b9dc6ce53b4a6c43f3c3927d8cc7ee36c4ed7c034733d63c66a3117fe8a9c39d05880451fe8bd245e03f22fa4cc382af7c86d6ff92019b5249c2cad6d18ada715a7d14e58c06c4daba800338b6f39ab38707af7e2ca67b747f1fa75b9d5e07bc887f345467aad507f3cd1c93c3323843c3422e8ce8fcf8cec8f5b006c6cc4637cd56f8b4ddd0ddcde3496204eb464cb66042344101bb1e6cb4d9b67460cb3ccb8ef14e6cfe14692ea0b5a8668b7a9a86d2f55b5d38a805f31c3a425afac773e4360dec2a0d3090933dc55ad99b81b83935a9efa0ad29992498ab15a49c082b2e7c4cb4ce26bdcd8832316aedbebad42287bb8e62793aafed4e4bada1a4d04a3bc6b91439072cad32305819d1fb8af4a53ddae7cd9203b8d71371870af93bf6f4c443cf64bbae40097e5aea47a21e4d73479d2d6a290079c695701f713a44e8a59c0ea89aee3b71c03809b759390c5df461d95dc19e8c33399a9f0afcbad110e8db94853986f3acd56af43af5f296f78e64cc7057f66ad57b4b1789a5290c8d02c8b2483976b428149d7d6eecacb16b2c4b41114b2830c97824216534d055a4cc6ae7e596f1317f5326cbb7c5b224125d7f3517509f887a15993492629080dd1cf5984126501c0c02746a8a9ab7ddca14968455ebb0fc1b17ec73add8b4a43272e88e49ae7b6638a5be8b91d3d786146f0d18a183fe450f0e7a8b4f08c38cb5012302e035c693f05e3e718da25a01456147b10d1e6fa9278db86796327ef76023e8320aee36212558fbe7a5c0614f85e321e1b9c869dfaf7ac739d6a4dc5cc68d11ecbffee17c7c5554dfef80c80c39689004a8fd0e5ac5329141e60e36ad630508dcbf1f5ebc0bf45475dfa7159b0b25a76e15bfc295bd002c6daa277c519c94b48985527f16d85728615d76dbd4c671ae603b641ce29a4d589ba482c12c922d0d69738685444cfdbf9309d6702199c882be393f94ad671865e4df7d79fbfe387f2dd37e9356e5574f7926bdcdff2a022f287ec92a10313385d4f06b30406e7e5446a553186c68becee8262ba86dfd975973872bb3af2a104d269cad89648c0b1a13befaf6324519fab901dabb6ab3c6c0762a68416857d7a7afd14ffefbf022450b055b5329f4f8c5710322be269c2e5e78fdb00a5f1cdf7623a3b4d605ee8166f9f60f677f3a1a51102879cf745a80efd77b1d90ed62f128532da5a63c187da40a5b32622e46f897649637a7dd8fc5f548953e5e2b6d676c69109ca5daa1495316a38e5ad740eccc7ad7d54271871aa633b37fae723098156c760979a195a67b62518816698ca2b7fe678f102fa626e08d0b5d067c00c5d9614c1e127667511643f57b099d28e135aede722b682907e6014f6e27901955ac33f0f36cb9ca79614e9c2ae64032b6b86db382c0956de9d5d937921f5e4d3fea35c1e0ba2b83f8b59cc4c4af0091417ac21fb298c3cfac58d739f16cf0e3ac04bcb77cb3febb0b2bd849e98e244dbb0272a55d572e8017083c4b05189249b02dc98ab0d35c5357c2531e150c87f2fb41d659ea5c4472ed27254f940e4a37e51a2d9bd61e6b59a5ac4d336f26728031ded624a415b4bfd39fd5cdfd5d46ce2f645d1bd6e772fafc968dc52ec8bbf01fbb7d30e33e11c24ca4cf687fbc23e507f6878d4d5aab693db733305b2d0669601f68ca69a4411d78c2b7cead182ff7b7923efa615e0f77f040ff82e99e59fcc5fa1489a20f6ba0b1c2e13d4725331450c502549a14cb3dbf825ad7a2e0c4822796696e073a691ccdd4d2f21282fd64b89c284048244fefda5551627dd09fe68b5c0d46e753a073211daf926f071fff39bbe2c473d4ffaf65265331017c912eda0c2dfbdb4464a2a178b2b899abc52255df78fe0328bf20308644a8371f1774e0ca422d0af52739dd64d8834fbb66a3f5b2c94edd47b81f2411acf833d415e9cbc7cafe63c4b9a5f09b4db49e63b6dffcc96d4b0cc5aac636785d56f219c9f48754cec287e228b4ac1835c8c949e0d78b4cc7cfbd818f58f96913bf9f7d5acae5d1605e393994120e14ac01ac6b529333c9b3805917dde3cf72d136b513cc26c791e11519c1e829f0e17d626885d84040c7adda7100e8ffcfe148e08433551859b129d3dee956624b41bf046166d972152d23ec6480c706f904cac17eaa7e3996ea09c42638ce6c30a1492cecc5b2a282b8652c8d40ae3f2994c2ece3457aed16aa59c58464279190adf9a8aac9e2bcec5b0498d677cf1b434732b7702f235f26381ebd36260f4d63ed856ebd7754b3ee7c88cf89e02798bcb7f08b6011615f5e0027de2481373e21cc665eb7fe52a3588827f02e35fe6ec31de142cd8fa05514956df2c3fea3be912cf85eb61baa2f0a9bb6da990370a0105fc0204172603b74f01f3aa30d39a7f7d68c77043b6d508d90b207522a5844bcfa809447cbaede0b97d892404cba4393fe6e168ea77e326bc788208cd8f57070c490691a881495f9cde8deeaf55d638ca1223be2eb511803cdb5c61f665d41ed21e41370ac87adf61f4719256b73c0e8f53d045d4d2a324dffddf0cb5015d256de518975003fd176b36785360c78a68239e3f5f638e66de2045416c61f26ec7c9ccd9a5ef1010570fc08585dadbfc0c5d3deeced143a58acbea4cedbedee6566c2c6fe3faec0c5cad3455763e23977ac000000000000000000000000000000000000000000000000000000000000000000030e13181d23272a726d656d6265725f656e646f7273656d656e7440", + "erlang_station_node_id": "8820d319fe9a91017f55a37b38c62e0284cc95f47ddf8257ba7123ed0dc43ad2", + "go_challenge": "a7656e6f6e63655820ced0c73e030e4733e675cf762b54fced6b40f70e756cdf63a87b2c143a007a976770726f66696c656770715f707572656776657273696f6e046a6672616d655f74797065696368616c6c656e67656a746c735f737461747573a26374627358bda6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f69645820816cd5629b43d0c4f58b915f02be3feafa20093ff2b8fb23ff4805748d42cbcd677369675f616c67694d4c2d4453412d3837696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f6861736858303581494b5a7236cc29634f9069cf57f8d760b7ac6d95cf9e8038a4d9fc0b90e7dd28b9b9ae8eca0b4818ee4a25d2cc8e697369676e6174757265591213d9d9f6f25535104b8d4099ac13393d1a81dfed864f84bf23deff154be8506892f777e67f2ded1b4b1c1e764edc90e9085068562c7267daee9f2de4f0c791fc82283b371e0cb3413325286e3beb3af7146549d1b170ebf2d909e58d47663ef2e4a2032d720ddb71b7005e279f7d45277c6a17a4ef0dbc7ee6c375494e2c77863c37879b7900be8dc4b66fb3d1da8bd833369993c9e36a1016a3f877fc79ac4a9fb3aa018db1d60378f5350fa08aa816712d301eab2e4898d43e939c105896b289201d1c9673aa25390c5d9f575caaf77578fb32c0836eb2d9a262265a4665381563452db7b50ffa2a35a88577fb3573cc444f19acb190b7bc67a27d0329886bbbb15903bbd7637dd74527dbcd0714785d3af4d3e17fd1bd5c4021d6b5d546577d09233e752bd6b4e36816e912de666b276ae89915b4a108fb1ee5972e3fd603092fa6c8dda1a2bcd085b8fb2d77862ddf43d8f86de960184ee48b8384249f35d82b4ea409a39f91c8b54f8a69e577708b2cf3c328c77748e63d3b3f92d08736431bc447236542847e4aa1c9b305bd05162bc1852d5a828875594ca1f2d72a76911a10342ed8dcb19df4e4588353f1d09909e17814144a6d4ac3c5c90b2d7f84093e8e0d7222928aec31d26571249d324564bb5115898e4cd988cb15f663dd1659a8009af01ee202523db7b498c89c0e53b35c888f674a6807a7ee444d2677a330113d774ef64d53826d6836d1b77b653e0f806922533c633d48dd0dba04bd00ace4b8ac6f5ea81e44ee9a84e5cff12f0fd9f218bd2541aed6205e0a88627311eb21f4729e8cd90c2e76bbeeb2497a2abefc991b86fee73af515589046614c817b4ee3537af92314286ca76d5a17892805abb61eb42fd85131928a2160247dfa7799104b5c55cec47af5213fee48d292609c1f3da5b7ac2289cf397028c0cc8d0030cc6ce7845693cbfbf1f6f6fbf035e605c6d988d8a05f37fef980817f726d62ed61630d76d938fb040aad829f75422737c8e42653944195be8c2d905b92f591398b5f5b9d5d3165d4201f1bbf2d5d180268ec08e421f4477e3bacea13d612c8ded3b7c5b187d8b40a003f6b14687dedad26aa9602803f11d6cbf5f1294c1f499b7933f11a76a0fe6ae22bc10e84acf1cbf8cfc257d42bc5fa27c4fc0c21831b99e238bfca616f3cf79f2d6f0d49a2c913b960986fa49ea49b2272abcefc21be52fb03f6338c8a19698400603f0c5785369898c20a7a1fb7b386869c6e4b16def3e56c458474f2991fee1cb946a6067e2279d2f6953b6f0c1e013fdc3607d3a78ac0c5a5f4d78672c180c9c371f0bdb5ebb379d4da2cea7f5899e916bfc3d435d28f7f88e0af3137d2db08686f9c007c647ca7175a4189eabf397f23dd5731cbaac03112bfa04a9916a8627bfdbb68ff243540edfce08ae44f058cedea482788bb93bb6bab60f71351141c58d0c5223b0c2690e1d29fe4e5ff68e5addb1b0252facc73684bf99f03d380bf809065fe83cffa297011bc643137699f85ec70694b04ea166e75ea8c9586197cc2a9ec64888004183831071bde0ecd0968b4b4025046868d2dae01f89c9733d43b2d2c9c0d5230715701375ab7685727eeedbd77d0639db02cb7431bd417ed2c43a28392b073422e42a18cf3c8139d6ae9b3bbab864114618bdd8fcc3eac56da32a9eba92ec5ff560ec0638615476a0026e381b29ec66d2314f0b7894c4ca3ea0fa528b6d184f9268cc3925280dd88adfb12438b4af46beb8ba5981af88bfcdffb5a0af24d998cf92930d368634d6bb6ab0728645b42a9cc4a92aaa138a3204e1153f83109f1e9c1b0ecae1bbdc50c9ac9b17fa25f96e1f577a08e9b18b3853b1315d5b7a15edc247e32f0951e4e536cf6f0c9b54ce69efac973bde561fc45ec6cb582f349bd39aa5de999ee1af30bac7deef6913cc8cc8cb643b5dcbc811aba166b8ff8932ca8166e33bec8bd323bdbe72ec110c5190d17d99d36c20ab881d3a0053160f63de0d735a44277761477b1a1c7cc4c80baa28c6be082a6579d0a8b3db57e546d6f30aaa408ac418dd5fd86024e4d0414a8c58e3c8ecabda15cf9b929f69fb2bdc8bd9007d87dfe25178110222c80cb9beeb050cf0422f17f354c8290c6e57178c26c9420473079510bb86d9bdf44879523590c135432fdc573998efb635c8e00e33434764a48ab281b4368385bb80d365b02040bc9fdbca55caa4baf03bffcb9956a7baef7e4e56b9a86dbe5ca62a109d83ae5324fa664a276cdb1e38efd4b1ae699db5ae9fa61bec3f61c8cf220cdaca94d67c5d5a7efa49e5730858b481bea31e97aa6903fc6ce2e473092365d4ffd88b157edbe77051a8de39a4ce92fe4b40e072d45a19854ce3f0ecebfa3049a2fc1366262bc479b33a1535b715b84ffa34892098190fe1018827d05b62d9cc96652fd1887445243efd1174927409a01b542c282fdb8cee9cc59fd2220273718fb7c5514cee73a5c14209ae1d819d002212035e49fcea1aabbcb4851f804b03953e78d07daccfa1d3f88d03c2167bdae3dc9dcf6f836361dc469d7d42814934db22068186d50c4ada609cc46040c14e45f9a977879c8352b7fbec47649211e980ad0277059a0c984996a15251ad8578830d8cdf0440be6991ca189b92bd2de8a1313dbb8ced743d92237d3e496e604f4bb0f72d5eb70ed5fa312b1f7388d2415e8822494f873376423da4d7f8d08e06467319208fc73af8762a7423c3ea19917287cb42b5ad47e7864dfe5c9bcc997a1300ff78eecb45c8ba4c21eb84b3c68988eb330d7c8fcfde972b4ba6c4de960d998b26bc322f2da052ba665cd1e27bcad1b61ec221ad84ae489691e678eef8c8bca91092c1103eb8f6cb1d214d4be32980cabe728cfdd66ead0ba124ddfe321e733a79cef4d7eef2906182b62f3f4244f2da9fc48f25c4fa055ec2957057e18052c0c2051d98e8320b02bf6e7c950b789e26325191563218659b01cac6fb90245540397ef72a07b3e91b4902bc852b9d70d8d19ed567c2f779dd3d6ae5202800c39b65574f312a76eb845ab6b77bae5d504dfcbeae2f792ebac152bab1ffd905b7304815bbd60bb3610595dd5b02adf6b947f3944ce2a2966d1b2b9af2139c867fc98e3b4afeab7a64308058e51e2873986cb512d7d4387ec3fd35fb6d39b85168e2d0c4ef93c4abe01ac9ba382d70b30a0878671a6c6089433a33bae3e89800a5b751d662ca1ddaee502cd604bfabc6792ba0556003ce0d7ff8c43c0b713f5f75b049a0c60a0801fa1fa73eba221f746c119cd3e1c1a15dd0a84fc72272781726a7bbc0ed6f138b3cbdaaf9a5782643ce8ea519b0752653375fbfb135fc38835e5207bd0316cfbc8c9367120923c520259b4e115fb9aaaeb98b919c8cff651f3a4bc543dfbf037d0af63c23c7bc5d16fdcdc73b6083e00a54d71d6297b6f4697fc4430e42862e8b76e42d6eac45f5b1f97922d6e690551df792a286b8ab725b5a787ec394fafde204fcc98a97d002f19bde8001529bd88c0595a3e73828fe853355ed7c5c8a5e64705fe06b5c057aec4491ebd7b9a518cf3b2d5b8049b88040a3b05201b3b9778242d77e00e8526f1f7aefede5f341f8e87d4fc643abbfc2fd1e0b08d1f1fb75e69da277b2d9fcb10ca340ef51ec68c9a2ba39324616f8d84beef843a726c4d7f36f514bab330b2bfea45defc10328dee6454b1d006c96f8a6730a9e9b2931ee35b147dfe244e8d0929c16deb001eedf9e13ead5e234c3d2a943b21b799603a4a026ef13b005974b76f620ec19ca03a11c644730b316d43667ed30fc92dc996e8c37dc62a5c10962463a19301d3a6343adde40e268aeae5af503926fa38c793947f6925b98a80ac1bc50bf84e4d6920ebedc25be2505148110184e4972e81609afba48cd20b3d0575f6c4a29b90401aab2d781aa79cdf72d187fd1523148ce31efc34c2b21e59d7ca29c28a51f8012681c533168ba302be6d93158021f320647feb06fc50f2adf05aa5cd93da76e02ea914e6388e94e048372e16adcd4471cc2e8ed93a95d831c8c4a7a87ccfe6195be0e0dd586adf3bcb2cc2af34a702291a2e6c53f77cab7f51d8d1c69cc5fff892fe64f46cf775317b86ea63ab39ae06fc3ed80f535ab1d81a96d33cd66c4639e2658801410bc2ab122e3d51407c6cebf5bcdc1b45e8a878e4171a18906abe009cb9b03388d0ff1741a1bc0caf8f64f778651bdffc8f6419b61fed39e541e5d2185a08ef2b8655e9a1b6d0807be57babe2019b4936c74ab062b568c4b42d14b497df3738dfad969ed48a8f76f269ccb0ca2479a7ff1ebc3ff3024f085f9f3cb2d316b31b94004c93b8c78c14f34b6e4b939e3cf52a429249cdef14f2fcdd2881b1943211248bd6c62794000e748cd9a6d1e2a5d35146f9b6f2b457bbc8daac7189b04d033a0e71d707377c9f05dc29907c80e5cdcd736843e4aae203b864bb797b3eca3fc01df8ec6893d2c53e0cfe2e053c4a426cb8df34f99177a4c02c5ac6a8a48380f03c7922253dcf6a832d0de5330ac6489f94b8b7f136fa071b0e76bc03cecb2209c3fd3582b7a9d4e1ed2d22e7c9d42e080968e4c81be7d52ad6161608e4a5025f7f706e450075fd443d32430ac37c0f8fec518b38ac432c68a250b9e0d943d7d72fe078076f7a856d3fbd837c5b803fd63cbab19ab522a947a525021ac2aa27a554d51d2ea10932d73976fa5838747ac546cca9e84d2d1e27c5adf95ddb82c92b64dcd2d872c6d7d94656205049b9bd8bd0d3caa4585d2b595ab0e26df991e27202092fb911f72d775bf9e5d1ad0d8255aeb6402005cb2d933dc3283ac8a2649ff676c741ea3f555842dfa55a4cd7608739e5d4d3b944054a27f8451eb7f4b0dde663ac5e51691dae87990799f5ff3e82245e7d351d0f19b0709f5dde4ffe256179f16d45ed55fa9f59aad48d1ba7ea6a50d5b00802a7e11cc42c420d7bae34cda9c75e994d0e3e5ff4465fced67e8396fc94daf8daad4dd64416c3fe431a2be21528b083d9edda1cd5af054ce9ed031c1a92eb0fb68ac531873501b29199ce387a7ef9afe828919885aed12b4335a34e2ce67abbe10b0a0d59bf8acb93e6e8dc88dd225e4a9e65fd836094630eb6ff15bed026868f2df5e7bde1f4f569e3bf0dda59af07e9e9aea13073f6499ac455ecd8e8176cd0bd80ae1681a68a831063e62451609b4dc1cbade8f922006567b9642f5fd1db70780482dfeb31f01afc4862a2233a52fd7bc0dee09658eeb67842545ba7060f3b174334c2f01c5f0fc5d96e773364770bcbab53ce9f4dedc73eb8a96a69a84cb806ded13f6ed6bee59bf93b584c11924cf86e9355bad1b15f016d0215915ab7f2de95a23e15681632476583db11021c50620a3c8eb594296087ab214577021911006c66d3af09a00be85ec593bf8a849693c0be6c5219e9aac0e5d4121a9ec51a08e2802fec28f90fe214e560cee7ace43dae0f7c6dc1be858b72d44383552a9cfd9b2fd1b46911acf0dd88822b43d726cc1119759d4ece34d4cd7a6b6b4727557b8e9e058d160a350505fc22f1d5d64c5727a391426cd82cbf3a4f008ab7299495a044041e60864e4de884f706733f990d379212707ff75e5e70c40e43b2cc60c64d7b0d29f2eb6a2bd01c9b9f48fccf3461084c48ee48e1a0517d38deacff47327a9329e2f2ae3bdb515cb48afd2b8a909bc7491ba8582f176571c740ba2dec1188b979bd9eef97f025f58f15edf82c9f9f12ce9236a58593bd8fdc8ea16625a072935e6515f64f714c9e7d7f68a411c78a46440ce9fac54dfcb971c21de6fc9ea9150a992a790c47d4d620a359ab78c971508ff8c8be2afb3173bd6b21c036e6e853530c81d77433760d9f2dec7a55daa6222b66930ec3157cb259c59b5f173245a92db7bcf5b186a438defa0f84b9de913a8cc805489f3f32d29925729ffca8041e2fb5198daf702bca24184214c106c36d43dd835f388ca89ec586403fed695c2601576bde80a1e30d6268f6a5b03cf71d08e80f1c03c0b77994bebe68abef5268fdb22f570791705d53e7266c89f5302ca0d84ff20c2a0769b88629fbe76b1fb15d0395d2353b47b7bc868cb8ff74a0e6a2edbf2682b6b6f45fab6a50ab9052b9138443b226e425bb12e802ca4985e248d0025b959af263b2ce3ba6c71a580f2fca1ec1af40a99adcd0eb7178f5c224a9f18aa5342811d98bbd418bea6556d86318295eb7e59c738ff2ebab22a3617f679506a1a92e38a41e0c7846e5d0d69e4f9ccdfc56e20c6ce1b4ce7388738254b81e78e8e5a838fec180e08786531a91be4ae008905e7ce18da17c76aff40f3e423074069876377e7e9f682fa59a34b920882f9bff641245936fc0db8f7b52fd81bb5c020df7924b60d3893169aa65ec2a38a0b7bcd8fd0419acbcf82d3b566c8f96010b1f222b6c8691e2eef4fc002162001835575da8c63f547196ab1646b4f20000000000000000000000000000000000000000000000000000070c121e21282d316b746c735f62696e64696e67a26374627358f8a96375736563746c73656c6162656c78184d4143554c412d50512d42494e44494e472d544c532d5631676e6f64655f69645820816cd5629b43d0c4f58b915f02be3feafa20093ff2b8fb23ff4805748d42cbcd677369675f616c67694d4c2d4453412d383768686173685f616c67675348412d333834696e6f745f61667465721b000001a0acc226006a62696e64696e675f696450be476821fcc7c2d804df377b6d507cb36a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f686173685830ba5d003ee50124a47aa0d55c045bb81cc4d8f48f8f2af53deb5f9814f14bd78ea652cab197fc2348547d4cde771bf49b697369676e617475726559121355f9bd3d0e6376a8ce0224214315affd6b74e277951dff425df83067eb26de8d6bfde4ee03bb42006bc4103c68027dcf3648dd3a77eb031b5c00924302aa86bcc0fe29bb486427df7581152893768ca7f75c4277793e77b03081187bc9bdfd719c820d957f9582392f65975087bd40566b1f7797f2472e87c6fba22db23b808ad59b0f9b623c2156a213fa6befd503df85ebc34f1775de1de708b58ca32704bc9a2922a71261f36ed00741f28517a167f2f37ccd5029b3c0a693a267fb15f4051a0cbda4b08a35222f8c5acf22f27bd428f24d09c74f4f405c05dfb7818f6a660becf6bbf271b89fd6aa2cb2113332d4892709613014bd6038b24f916b6d5349c4abf973de82927e22e47a458116a173439f435f70301f9b1eb41f60e8f321fde60c9c222269a36e2400792ceca12d78bbea90e5a0991802c7530fd068c639b50be4a2037ae5faa627621878c464fd6a93660dbd05c745f015ab0e20f4e291a6b3a0bd43458969316c1e4381937740404b8d1af24b80e7d7b65bef1a64bb079eb789b016fed7263b6792349de655484147672052dc0c1b7e9d088c0d9102510fddb11a55ffdca05a53088f10a43b0167d96c177f639e3aebadb4a6fab053ff397ed32dc47e99825fb6aaf3262468d236e51cf8d9bfa87f846c27c52b186aaaa2b869957c6a6fce31e5c7aac913916bb369b41c874601afd9bd653cf3ab96cc904c4509b2a8d9f5f90a3b2d506e46dfa7ea4d4ae31ff625d5d0ffbd89d70a97823f1afbeedd8b413c625f627e6d2d3558199208bbe5fa760b7bce86ea1113605e2fd192e6f0c41e85650ba21185a0810f7228e4c656341dd6cc33805209eca618bccdeacb739ae59b50e6a1fa7f60b47eb3bf8bfbf7505c6d30857988e1dc658ca385f6b377453151533856a5b405acb2e4e085bb62d354a45db1fb6995f8cfcb1fab8add7ed1994418c4f8969ede2af36306a66711b1834a9e988a675060850d1ec4d0e80154df46e581b36be6f6f6c8251d9ba41b42a75080b53edd9af57936e8f81418c2d841d6fd2a966df4da8be54c438afff7056050bbba0771ccabdd90eae98f1af9c9fa28e3c93bba8f39438386c3e117dde1bb29c0615d815819ce2493c21fdfea5bcd10b892b427648f50ab5be252787145dcc17dd7ab03d31f51d64155a4a5a51d8803e4ae03bdfbe897f34246545a6bcf0f8c03095879aabcd3584cd2e2a677202380d05040c3ee7db6c38feb093c69e6721d53a32cdf02dc28e676c0a2ce4e04603497d53914265a747d8f95273209494b448a9b7d30bdeb569238572eb4110614420d2d73f3779db2fe46cf74d12597a40d36153082f4a13ba3bf9740bf2c53ea1edda1d99edc2f380cb98530a59103e27dcb149e6cec2a0414ac89d7b7786fa8782d452126dd0600725318382427ea05aafc42e66bc41f6b22841d23ae16c14fe9e53c3c99e9124b1071492a7f522653565356c3ba33b5cde438bb38f52b4ccc9f78835ba3a01dc58e912b0c732cd4fbdd98fdb3cc0cba774f3c8fbf71ba8f163614851d1878c089f3b78f9ba8086e6032da66daa444ea161aa1288bf6259ff82e2e8315afd0b0803849635f62522241977afdb9d118053ae713238bbe695f353282d1910d0a58a2694ae3039a37dc590f5b41bb5725c7b8e79ef49998b76a26060073ab75946824f0567824386a8a6929896bded135eb5c62de8edf4cac8a16c584329e89242f08982532d17e952111c50440a71e237fbff722976bdc14f3f880dd2d372bbd017b4d72390d88b14d37f45836495afc4cbef07e08c6d88b6d5519aa1e4cddfb1a921b2061e70b631432a98e12a9d219138b3f4bd6f19f82d4cb2bebbc283b0be0bbbbf332e469ff8faa97b826b5273b39b04877776e3724bb0e4e9e1d17ae4c4b92e7894180906dc1739330b05f3c3979d81b41c8491ea2b0fde5eb9e813f74b61120de2cd8336007ef723f6d9aaaa13283caa4f3226ef741170d1a25fa5e1f96945382a82dc20b74d81b2ac86a24f63d419bc463f5af702d39bb183d252ebb7a0133face4c81a73ecedd342b8f1f58af77affb2801bd2ae056ea428af78ff722bf6a6cda5ffeb788ae6a667b583877b33f33aafef20c174d07b10aa5927bf0d6afba8b47187e1e0ba19253db1cf33044fb51cdd153f6b96aecc97c1aff4bd25cff7870ec1aa33d5cbe2217fbe78f6f38e4a5b511d090da0935dc8231fb5556e6cef8d6f793a74e4a15805614ab9da941c3646cdfd1dc038b16467f6270db4bec88f8fb241e7c53e203f7b24f78f4b1f85662f103249a5c913e3dc5da363c0f8b012bdfacacd8b55d979f1c2cd19fc0b8929804170dddd6ae29792d8efba41b157d06f3af240a679adb39eb679a26153ee17f3bdcb33d5e0d9b098fcdd422eee2df113f057861bc3b0f1e5859dabf0946868d13d8d0b8873ba0e655d8fe7a7ba18f9e24897fd5f666db9435b605b17d862acb9972b94cc210be3dfb25ed460da19f0de82dad4d24de97d71a35acbdf1abfd7ac330bf6b909f584810adf46864fbc56cea418120600d69301a1017aa42a921e70e65c78534f6bfb8f525a05297ac4b25bdc42875815328e8bbd24874cc4ed8e3cc7273a7456586982e073a4706e096bed4f775481d5891e95a5583e5c5846dcf715fbfa0c077f93bd6874786865f88b728605e5a932a520aa445bc663142b5f5cf148be621e7a76fffc0a45ab774465c9de1b573d044bcdbcc961ea1fef74d4e09e7350fb98e18784d542c210d893a8e9f52fd3f1faafda9d75c808e1d600579d40b5e9964cd782de5a7169e96db39f8389f94cb4362ecfd600a693964c8def85aa42182affa1832daad2e72776af59be66f50b1a2e419835cc3efc454aeb7b5a4296d0c03cc2f2356c3570149a0cd8c333844e992aaed7c7eab360d586588e3c3db27d2d06f1e2ada51e8e690e4c20235ec0850e2d207c380f9fdd715b671417f4043e210f4c65a69159bdba25e8cb9af8ee67861804b89b853901ab1c4fe4b7c192eb5e2d2a29f5e357bae1af006f289083d5f4924a210b78bdf3cb95f825e91f6acd5f81e47d771115eabde18ac2dbd73b19eba8749f688f40f12f99fdf1f6ceb51dfd054a1a3bf12d2df143e6f92ef6fb1f904a03fbbeaeb5672780ce4397b9a647c8177f26b3dbc5dfad819e8c1fa10f76687dfcfc1f6a10f4934b2f7a22e9b2ed89858a8440ec269801153a42feb7e8bb2b2029630bb9fb40cce79fe17a9f51ec7f209bcd84f2d7aed02797aeac77276d4532b7622d6dbce34c0ee86355af9d5c908aee632860934fae8a5eee6d838e323bf0ef5b6440b6ad96fedf385c61a12e20a8a2a18b552448f102134ab9ee1bf2ded46f58182e2e4377bc117c944cc801bcbb9a3abc7ad49e2cf60771e1245842938501f4b9f7f9200b1ad8d56a9f82aab2eba6ea21b14ae9998379da3aa8a44e1a150d5863735fb06e20103061e3cc5f029f69900fde90720fe469310c595e8e8479943af6679f7c0f74aece777b0180cf2ba7be73a6b458f9bd76f66301e702d888abfe0e38176bf3e86d9824faace433e9a5464ea0559626416c79f1ac67799a98627330ac5080555875e89c6dc561610d40ad9dd0af462cc4d6099ddb93c244c7e34691e60a004e8c480ef543fc6f53e41cdfe76024e1fa735fe0f54225572e937550c49851aadfee3ec75f8a2520e6056931515979735f6d27120c99c2004e56cfb8dbffc4506e49d98a045d0e19139a57ecb24b51a4e65911f11bc5a1b43a7858c4ab1f5bd11b3d98e9becc786998551f4c90238eb1792f8d19a65c837bf8f4b464aa605615ab9a4313cf512ff5348ea350dce20f672026c5c54b95d784f158dfef35f3e6e46475d2a55bc3d1d9c0276672004233d3ec3706eda6843a0515970bf6af43e084ce6e479274a30c0fa4ae450151bfa5778301a08cea75e4a6f6325513b303b4a707d3b3cb001c633782899ab07ef68a864bd0a6d3657fa78a3e52fa4c093cb641acb5984d3d427ce669f43302d112ddcaf619862270e902e56885c46301863dcb4135546fa1263ad2274cc722ac1acf2e01ac77632b28858926515ca0d1b9edd77daf7b9870d5c20b6fbe435881debfedf54f5e8b8e74b9bfd3c0699c530aa12791bdd069277850d48783505d063405df6f38f2a95426570b52e4d1baadac16107a0a6e4d7393c8c5243674318a4237c8c9019b46d3cfe845cec5ab4aa66799403be9ef49adb7036f341ffe2bbdd9f3b9b972ce33c93642261a007a4bce75a6bca2326f5d7f8de3e1ef2009ea0658604317f5bdd190d7e5d1240d9171b76133f914ef693fc61a1c55ec8e84a602d79458dd3eef31f8002b55cff3175aad4445b667e82ad9ae7ef7319d2b2858aa4375c90a139452d09a028526d06ec7353e5f4aa8b95582e66b9bd8dcb234125728fc7344be3373cd15a692ba279d0b14adb9b8f82a6e72440d98027d7a3e9078ed637d5fa9764b9457a6051ae0af460edd2103e6ea4634f9a78f6131de15db4eabf3a28c48781dc9f82103ea7f1f67ce5cc3962284b6d07e4876fa09bd6cec9fdbce547f7848f6ddbc159961d4e2d9c95b7c0ab465e20d941b17f2eeac57b53c33e959b732a4f4374afbae77afc142692ca78bbd02c7300adaf2accfa965cac6f3fe65fa48d702f431b7783fa6b4dcf8bbdf78d85c3f111ef3ea9923c45af497a75c7fc9ba05f18a5cc619aeb1e7cc5484f5ef0a97e3f5e3514f86331d1665c7615bd7a9a03e0ffdb73cb9646fd20d262bdba596fc1666611fbf8f5b74881c364ea0ad5b032ab7e9cc6e985efc712fef1e245caf642196dd48356e477b87c72732ba4c770793fec4997cec4ba67f7b7ed4036b2152d28ca236e22101ae7058544ea1a31fa2ec2f8a0326043e8bb45ff648847829a573b57a46eb9879cc624d943eb91fcd73936d990c1de4f98d36694ce2c06be8ef15dd67aa538f83d238d1b1b76da2c0612e226dbdfb1e9670b44cf48300f8f8eabbab14058e5cebb034f3f8f75b53e261fc3331e1d83e46a1652a4c7c7319309c24f84a4ec8eb4196da1b518a3296ae2ebce0206507aad5041e28b917f23ab615762b7d3e26feceed12bebb2717a205ee57bcfbc3a1191d07323960d39bea8f6843485894afd5101b03f96e7ba59243e78471d0c3c883162844445d148ba76848e18f4e07f8d472a4c3689b385b7e1c4da2da0959736732ea301885e193aadecdd5e57ec3c1aadbda2d12b2d38e07942b8212641b50cb106c0802265686ea9c23c46187e60b23d0af5f54a06f9927ace27538ac8d290cb19801ceb89bc563f34725e2c6cf21b5b2df0731a5514c66b31123160dc15ccc17fb8b79762def512e73a71eaaf96a1f966efa5ed533b14a4f24fe73e8abb1f3defa98e8d7f40918abe51cb30f5d95db31a42b9e615af8af03a08fc59b72a3b2a1ce9ea3c6226255c1adffe97f05468429c3d8c119ee8866c30de32a83c823c5ea951d2addd7766660da7c2de6c695268f63b8881bdb7ed95bc158882841851c86258387bee6cff70eeaf1b47508d8fb067f30512d5ffac2c0621913933c8d4efe8a1cc08dec8a2b98ec11e4931eb200fff71c9c05f31cdcc29cbd3c2fd89e255cd72d505ed85440ddaea73938376221bbd2be18da7d2a410f89f7a92d6eb6bea75756a24cf71fdc3973092b7f1978ecc9f13b7020d9a7e51380b7d96e1c9f6b710e1772b9de9b00de123d625b477160871eb89ba86c17cb5ae54ff1e115329dd959f90e5a5098cd91deda7040431486b80ca0eb1abdcdd8d9176a3283b143f053dd8a9c1f3ea096d2b440993cb7127255ebf1c74252ddaec2f9da00e12dc5de70c168ffa6b2e071607799b89d8c3e1b32bc2163461f9f2a3c4a386da6991f869fbe7f60b637a165521cfa677bc1ae74f9142a1077d9401dfc4265f648ca09285a625db27ce323f0872a6ce4ae5faf91352ed272904a04425ac9d9a24a08107084ba12ca3683caa5bbe9497338af07756abb93cd40d643554c1f3a7af7a7fe3192001cb74cf0300d4459c62ee54d492288505a6be95ebb93fe841673a04cae52e918ba2c06a5bf6321e6226968e805a497f85eac5a1602b96a0d0876a1cfe634681be422b904a5f3f43fac1c599b8684482cc15a2da308ee58c678cac432608b94dd1044462f87b23a196c7a8d3249c6e1f48d66133fb3581f2ea930db7f799fcb3b7e4f07b026ad4eadef77d3388a8a93b9656134acba8326c08754ca02a7d7249d778f86bf679c6c831b9115ebd209755a22f0ee5f2dcac179bc6d9b51efda646c260c6fefc8583e11f636b51ccadd9741b5160e3abc3b03bccb4a42a5b1ad8e9fa493eeed290faf74b9299e1625683c17b138a80b67f94885a3fa8c598bc57beabefa3625531d8aa25887230313d5059757c919496afbef2f6fe08094f537a7e86b0bae911299fe709121d3056acc4d13246697274778d8e8f979aa0ca28468386bcdbeeff38427f212b4d6692bed5fd0000000000000f191d25323a3d456c6964656e746974795f6b6579590a20a274ddc0fe236b3ac265f1167cc3da2913027f51b640d51d83a7da806cc660f6d33140dcf51c4759204d754decf3d8c60bd344bfd223cfb85b76c2d5260cb359997bc98872e5f85015df81831a9c9406e30d8237e5cd7c1f4d54e3974ec05b06a43215a3532e5c1d46868b29bd7bfd42e3f0469b5126eebbcf22fa08a4b7fe2764b7b75f60e67c520e58f73515c27227854db302e7f1577abd780dfd1149f524eeea2d563b08807350c3597d4de6fb6596be6fe926416ab02e1d0bfc53b4e823ddad6d0ff43a94ac9477507c7964a79ddbdfe5106b9834ab14581ff1e7166195e8eaf868c39da77b92ef54ef6a2abf23aaf8e7af477e306959976cc7d218dc16b80fe772bef8297a0673dd04bba332c0d58c0f0d885d4accdf10b30e611642e45c40b406a64cfc1857a1164935535cbb2f1b2ee13bc5cc57f28a56ebfb9d984f68013e043e83968663c1e48fb3d76652146241b9e68d165d16b8f80ddcaeadb5b8e322371040a926dfc8d834a710608d6941e4f0eab9188bf5f581d762724aa1cb5437c09b7a0073e825b02dfdd378b4a4ede82f93b05af6d9a6a2092afca2efd06352f91eebbd2400611eb5ee0dc9495dcec12003e06638e32850032eeebf0bd762a7a78a953d23710297b60288cedf54cb06fe85d4e4e875cd2146917e385042ffce95b679e286c28386c1ba98fa4f146b5938f9d4224c0adb80675201ee136eca097122aa7ea0746d31625741dc07cd6c4e20610e9c1091c34fb8d3cb7fcd7aff9b1e0283c3ed7571a6f7a480399370e5c910baa4b6571d4a5cdf314beb288d62ff89235f2f5e1caa3e7f1992f6c7bdea7d977368a192d503c0ffc244fe252932cf40c64842549c6b0b29b16effb027ddce33a401e7fcd90bf776f67e7c78fa6a6924dd3d05cf25b26802962be844ec6e93364e3d6416bff629cbbde36fa627e8512f92b9e31a5914d6fa825ec47fba85a8cb0e6475fb02195b008328a87c4ef74e607ccb6d35a01da103b6989715c11ce00a601af09304e00b666a6966a3f1e768aebf13d2c5fc9c7ebb5eb54e2608db27dc3babf1856fed23f832e50956a1a3c7a961191da56e9aa53a007d97b4624f6689a842906f4492a62d828f47229b87b186c558410cb101761deab3ebc4d60f9dc3d11fc8776e8b0f1c81ecedc58753db744df55cec9bee5560d4d2656e457f298a3245f7841081c2d3b4cecedb2815bd9cf9569bc197b788d818f44cd21d07b5f0eca970171fa58fa9b65c4b080a666ecd110979884fee347561eb00ad3dae72b933012da8b2528cf1a743a7a5a3a9c2a6a09d0bdb64afd3972e4949c43d97c6906b5921aff185c48a893c1a11abbe848d020a6fddfa92b7f573378f2ef00e668397e8ac6bb19985bfc55f9e66e6d29abb7a247fbf7c98a863846a69f7870ab5c602131306369fa104e814693b593a0a32df07650e510d6f5568d0126037b0fe14e9be1fc86e70de02db729be15c3b3c5bd53c562970ada633d711dd9fac9d2f089e4042448589c545c3efc057d4f0a3659a72abe25e8fa1205e6c99c86a7efcb027bb1beb8e9bbb7b5760cf99c6f2b9ac9df1b326932d0aa14e733ee34154d629d3a01ba843dcfee2f399670b0840b95909730198e0b3677f5a02c0ef4485b8fce3558b0819a5003c278fefb5c4788fac1d0ca888da6aa82d8be28692212e06aa481ffc3c1b602ee47fe883c93defa79e6b33b022baaebde04d45107bf3adb7477aa76166306313c0f0dcb75e165ba36fa8b05c4cedb36a7cf0c20b756aa4ef7ebd4f0c58465e88b187fd0584f8ccbc60c168e48515f24648a06b81eff73cad70b34e797f84b8679bdb666ae8547d7077c323d851005800b93242d9d2d459bb9eb22caf6c48e642f16bc26964bb596abab4839ceb5d3728d11271dad8ef0c7f8c1db9f628a8b22fc52c372d6499cdf9c206208189d8bb5f8d6e02849fbf9f8447adae570fd1beaf504b4f4f3d7419cbf60d8c6676d0b10960047e561ab9383091c2bedb0ebaa95b0d59a45625f4791083a90e867579db577525f019adebae7665d983d5179c6eec0534e1102923f22d688973e22c13230487b8488bff9c056953475556b3c744325029b57d34e3fbb4ba536548e224520c1c07d6f3cac9602b3dda3151da41bae26873d15e2b2bea9858604a452ce32809a75b62acf6c9d03e9367857050a2fd82cc58de5cb541df523fed9bffdacbddbc6109b31869b55c45338cdda3e45d519634883f94a86477a730cc388337c1b2d0b1f9813dd53b46d17480e73524552f40648a2ece4b7a14460f878d67a20958f1d654bdc9d86d7a4bc074aa273a45e45360ad689fb9b1977a647c1ac9f700690ac1de9d4ea883edf57f525cd2c84aacd89cfdb29208dc6531fa6025f05826206c8dcfc02feaf7b5ca79e6779c31c0a82d87de5cade3ce539fb89d6b5ecb779ba6e85ff5ace6040b54c11f1e24316a80647f943f94cef449d4ecc5fde108146ffb28e111ddd10e398a567bbce7cc8696b93275f350faef24df362eda0bd7afd7fa51b2f4186de8bb9066020ef3b12d640c347e05bb1b5836dafd69bfa88513d2c2d29f385670f82ab2ff8a10cd8d1aac805025bc38e52204fa8c84304455b79cac68b02bcd842ef3be06bbbfa68e788c9761a982e88fa688f0c805f1838a1b5761f21a68d9b98f004e06e28ba59ebbd21e52b8c492f7901e38ac49e86258623d2c2dc11493466857e445cc5417110773badecf67b0102d496fed3190db11ce9dd8736f7409da51a5ce83431f464084948a5c598a95f901ff6338509d602b26ae1120118451c2025a91557a95f40673248bc5a6076bfc5282a4caf055a3de248e39e3179087e4277ade7a6bae136aac175f65f3b6a77932076211fc72c5258adfe7247fd4a9f7cb9f1808eb555123cc1f300a8db20039e6084cf00102a7d6bdfddbd72ea00be15d33a564c8f084329ddfe408a345df2a39d60ca447b2aa72088e27a1a15e93e1451bc2aacdf6d516426a866c83c036388806dd9e465a2c43affec670d4478548b18125b6a815ab870c852449adcbb55096a1688e69b0c870663348b2a7869437a4316942880740ea7e485247173bc1e798bfccc8ff214881abfe4dd7b22307c5a74a4a8f64dbb406aeed818cfb6b71cfeed32efd2b9f4b429d1b62e84eddd71a6cc7ed45422492b97dd3c1c2a49e1bd9b8baea630bc6864f6d2d639cccb5d96371c7a4be16bc8959b4145c3e90a28bc12ed151c67ce622451f10e2f7e4cad54c22b4bdd92e07381bc745abd9f43c20797bcdf894dc2624e3d32fc77afe78c934dfd8966e4315891abbae3fbc15b04d5ff1ba0b2601bea6a52373c86fd7f3a9238713420b555efd6f04cef59b9904e7ef94b689416861f0741f35847b21333dcf3a28eddec1a7738683732d5ce176085286b6d3ce58218a652748e63558d54213b89913afaf5238aee1a2060d3c91904a36f86ee39b16df7a44bc78d64a0fda5ae7da63d0553fd38b9b3f207565f41d0583c6c645e3f6d065a0577ab016ebbde739810c4b1d7fc237279b4d9221157a66e5f2d82a5263a0f69456d626162e7f4c87a4a99aa2ac9dbeffa68a67cbddddaf0523379e4a5e2eef97b74ca6a43193b7b11ff4bfe2dfda57316f77b339a31d1e", + "profile": "pq_pure" + }, + { + "erlang_challenge": "a7656e6f6e63655820d801a5b8a818ec953e42731a6662fb78a7f7fc2eb6fdeb96a6cc11b0e92323436770726f66696c656970715f6879627269646776657273696f6e046a6672616d655f74797065696368616c6c656e67656a746c735f737461747573a26374627358c3a6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f6964582052ed288e3ff39ccbcf74b438b779aa15e3a88871c104d578dd338486fef6484e677369675f616c676f4d4c2d4453412d38372d5053333834696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f686173685830e5d1953d9720be28533073c96bba3064632ffeea770c1e5d479948a8bc9bcf21eb4a32970cbee1d88235a530d316528d697369676e6174757265591413fac6c1d54c52992ded2b540c6566f0496b50cb5338da762d1abd2b89736f71f8cf83282562ee6a40a2728c7206eecc1b664adca3f884d27f710bf95ec74c57482159c9c8a3c5fd95a71f48ba1687d88bdd7abd5b76bdf79858af56f873c06878317a62eda3a66331e0389b2536ce011a52a3602bba5955f295c142b6e3c1cd4d70c1850bdf801d3dc072c2a3a197066ef695aa9e39c03f4d0846145626ba622b7d553e056b39b26cc328762cf81a464fd871e33589845cf5a390a1936a1c2fe6b10feb3c7184966604d4585bdf3dffb468d040516791837ccec9f861e6c18c1831c33b635bdfdd52842e96f2dfe466bd2468d348cc47edf92901b606c9df50d8ee338110e3ad6d8bb59481139ab8c7433676341356d0dbf0dfc58aadc1ac42c2dbf71453fa2bbd2cdb6740fe43e7fdd23476c3044bde64f4813b2482da9153b4b7fbe71067c40257fd5b2111f052cdb43d0863f342cb7dc4e1fbcf374955c27d584ec8fbbbd1c6f7e93d3eb1b336c45ac887cd9e24475a4a674f04e0e506d6a80dc364a16382d8962571821e8764657b728f798e25ae89948beb63c8daa7ed74076771faa758038033ac280a6426089a6f95691ef7f1d2139e8dc4742165426445df2656fc765ea79cd3fc81079b72663fb2ead38b76f743b3a7246eadb890e3d48331b85ec74392d87b3db00d595c936ddf3b0e6f8e4bb0ce576588954a3d649223527e3a33c4ae4a9946f572e578a99c5930c61cc2342d3c35b9a25427637aba1adeb7c4bea7c8828b5f7cf08f0bfbf670766353ccc33935684829482a077326d7ed778b3aae6ca12f4e35f3d1868e69dd52c895985db5c1199fa716b04759c8f2336f55d4d68f501a3717ad65d4ed3c29de69ab0b644bc706f42b0146078b4d640c14e363308b1542ed4f5bb1a3674a3c182d08a10c75a726c40e5be2ee6dc05efb4e03cca62383dd3f47c8c1295608efac92bcccfb8084d435380627e3684bd96211690ed0cf4e6bb1a1fae6ca3f3c2195adc1d447391a38e2ad3ed4c20d908af61b0178d151bd70ff44e7a6201c6022ba4185826f399cd879d268a3f868f2c3b7ab08465966833a43552e25765c5de218a28fce0f7ebb2e5c9ddb2e47a30a8d3416549a4a801ebcec70dd44a4beddca124e6fc1963c6cd27eb2ad5ca74038530dfc64e4e4ce1caf01d034c06cafe2c01751cfdb0ceb585426209b7fdde2814f67b479a329180e986863db034f685f8b0516970de6ac944a312548d775b6ddfa21a347c3d7fd5207787d754a3b74d51ffffd4119adf01469800fe901c30aebac80adff36f316fe5b4dc3c99181f472d9c6071b434f5380c1380bfabbade5ccabdddbbbb088140c568e002e564a4327130773c67bad69bcce15ea2d9d9092a498c6dd36aaeeaaf9253252088892319c8fd2a2fbd542942039af02c66d6ba4b89ef8bc567b5665913dc5975248179bf37ea466b00c49055161eb406959feb2f9b38d4e2eead02531069896d3303089171f4afe5c29a3b66a120a665488c824c08d297013ad6cd04e5a96e2315c7f40bf7b5cbdd9a4da96291a45c97d7da52b4500af2899c31b14d55324b50e4c27d335fc981dc98a88fb13f1bc1222187e387cdf4d64bcce4f9f51b25a513ff1ecf0dbc3d705457b5db04a872529b70d0b4cb88d1d039ff2a1058295e398f9d52dfeef0c9aca10546068e71cd5dc30c9feaa976912b4201d6edb8eb4068b4e1bab96928cdf04509a50bdc5f37a6c641be2bb9e5e474bde62afb76db996b89dbfc76522a340f8182f9ce0777931656cce8872d7b9c55ef2e23ccd07ece423154ae486415217da9a564c94a3760a06fcde8fd43afb0c14a2411d82fdd8ea7a3763a65fb57e22d49a5e49c1fa324f8c48e79998913a9bf6416febee5fae253440cebd4af215e15409363bed4a2351b99b6fb1a1e7c79ef06ceba2850b94a052f9bd07f74eec1be62f476eeca288c9138553f288aef597cbbc6bf17d6c1e65a3869f6421ef5c6c1a66a0ab84069397bd30fa8afbb8c34bcffaee881e6d9d633c8116b32257c13b9a3bab9637c8430dbbe4b6ec31604c59254481e453f77ae40de68165ba0dbf91358ff70ecf71afdb65e25f0f671c25defd2654b07b4492ea85574214e5a0112a268295a5d387921b5a510bfb3d15b860f6e1107900faf0fb89ef9098a8ff0ea615c08c4d1f77cf845ed0c273aaafb9319c57e9d69ac8deba77d3fb4f2cb49c8dce25465a9399ac5adfb1bcf03a124fa0b7426c572ccb39d0a904b8f80710512f8e23442fd073bf1efb83012cdc054e33f01965d7e992c51452b1f2e059da2dfefcf58d785f72f9c3bf383db3bd7984818941b45df966d6e25a05a78d95b9c5a3cbe30ccab91387db9c8df1da4930d9886d8061b479e31407358148a99c97a95b83794e6ca424547a0a03dda365e7c217b336ba8916a8b24432a7ddc42096f33a61102338ce2e33bff03ccbd663b4afa1fa215a65112f722a0208b48642fdcb85ab043f2520eea023052cb4bd01614b25c1b8db4125b9e205f8219e40cbfb74cebb4fc82f46bb4b7d118ab5eade66e9942083595be6d280f5096ec45c0be1c7610392592dee3be6f1a2624561d27d1c2cc51abfff5970051fec2b12ea2a1c7d935265d43567da20107d2eee46cab7f98b9284ed2ad30a681dbdabf10f24aae27409fc9cf96e81e06c62faf130f2c17c5c761ee95fc65be15987c45fb1f570b8f0d07e7ad36f2d609cfd0bc2631d81065b51c884d63fbe1e05dad2c4306de8d55452e2fcb824ee7756733886a865265c8cbdcaede19a0662e9d5721d5a117eff66ad90908b7aa562d92bb3e8d3c416207f1dd6cac96b3796e319ba2cd21755153e50ebbf0ed64058fd9d88092d661a9f23b8b1a58e56f3faa860e3c7ab40806a48017e66bd763fb045bf94e18bbf5f235b2b8a85df54a802fbab675d5203acc3b1433d3f53f170c77a542262618949eac0b843acc24668e932718429ffae44a3db12693a1cce99e3878efe947337e998e1f874ee2d8c3ec91a4d7b14ac4c30b8f406b1d980813e75f80633484e7a1ecc575d6259da11bc9d9063fb53a691aceb5a0b470d694be2c37063cfdbc2a9bdaa743c25e7ea1d1af756d8bed89ef394d017731388fab30764bbebbf10dfe7d0200935c67d153e4cd8098be9457858f1b6fba21c00905191a36be4ca09932622ea48d6a5e96ca6382f09c801118b0e919aa01ebc5156d282c0e6ab5af7cb3e288a01923bbe163bea41b63b03e6227b2506b956c050bda4011f1a3d9db26295802c3dad3c1ccd481759374a842aae6cc42d6e981da1feb7f1c7f0f1c50c8a8793c50d34dbe0f64017cb62a4d833cf802c3e6cc2224fada0e62bf35e277e9c0e4ca4cafa56884d935ca33a36e6f6d7fbfa081f1fb6a2c8be87dc65c54b001f702f5fd11249baf8a176cdf696a5ea88983c901a2a8a76d3e98d7043d24733d119930e1b6216ef15abe9b51f4d93a61732c67a32770fbcb08f8dc3840b8270f4f43ea9d17f2d787070b90fb34b1803a1ea884588d050e06fa5b1fe0236f89c82d7d34476dc0b9bbe66518432f67af2f27eaf470af7db978a85fffc6bcc92a2286d5b83ae292f69f553b873c24fcca1403eaba0ede72013f8bcf345fce062c6d6437c219c4405272c5448a75c6c1f65565bcbaf3514a098b47de2f0e90973b9f5131113d5b8c7ca2948d8ea0b03672f1f1aa3c19e00e0615d1b849792ebcd4013a73757c2a728a94bf464581a7fbeaa91588914b06cdb9ed52831f3fcbe4406a829463a319388d5464906a84fc32f2f85de9463ab21dc41d5c51eb54e6812f4b3db5449b7064eca18cc524ff18ef6ce725a8ec948f8fc788afba410d568e8c1a172280b61c90195b4040722bb3ceb9ffef5e75082c0e8dfa5ecd48a109f23691973d01097c8ba71e741fcdd573492ac83957744a14e7ecf6fe9862534b418baac3b36c9a663629f000da9fa4b34c584d6e58a7db6f68f4d9f210c29300cbe7c9a10f0fc22e687a80d94c36961e9925fdc11f09156ca4ff3b312201b57dc0e71185e7e9585ae0dcf576b14a281ab1d372992f4a2390f807606d6e56319dbbb23bff6fc075a4d69ad6e33e40a908fb79cf8cb9754f9663b01e39575b8b0331a72a6c2ae42d25af1948107c6c850173f028fc9541fb2d45f6844a77a2395ae3425ee466e969d43b61392408fa4eac1f8c8fcb80169557a2d5a3f0c45a2b5808bd5fec071dcac4912ed4eb67f2fc860772d6c2f092cd9df2f9398e3542aa13a621a884492d810bc98ad4e23d9cf75cfcb44917d098c1e8eb630ae23b9e5e983b6f0375cf1cba87f0b8824cf228dad387f3adbd17838f6e4d1c88be377b2c724ad316e128ac6b72e1ec9e0ffd03b76f42974d0e9da0bf103aa723128fc8cbe4861484ca8bce85e973baa1ff8ef64ac5a86702626c91a69cb7dbbc6f47ebdc63805561e95b3dc28b5e08531513e8f74001ea52d923cb7528729decac5e7424c02e513ece696842c54c912a06ffee9b3711eb11cf4264f000fecf6bcb10f959eb1f56c21b113c99ccb96d9061b094d8df803400046d8cf83821e58c0d305e0dea4bdb15e50e449a6303e33b50de2c1ab8269f1fe14269a1a7b7a0660c23a9c8112f8de4104c4009f8af89987154134c3decbc9f1614e5986a9a277cf73332909c575cb3ea5edc87947850de9cdcb1a8d8b124bacc8d86c923a7969a9403997ed04cfff1e9aa637f2f7da63bb948bc9307d975b7fccee9196086cd2dfa9517ddebc3374987ec9db91292853d9fa1d93f696018c9f4f8ee508796e55a96b2e6d2350853317c44867dbe43eb32f9181b23d4d21a99bf4c80e4a12453e13bf7582f6b88a18340d4f63dbca64ad75f04065e72bb30f8028f735a1da0d4201900186bec23c85e9710a71b48760a7eaf27ff3631104b526f4c3003058b6cb754cdd58cb6f8fdeba8c7b94e2cec6cd6222c145c0c65e29bd6416fa86ba944ba5258fabc3259a5c3a9626c8d218001868c3a6528ce96d8f1c49a76b0ba2b1d29709fedb000fe4a3d22f9fa4b919f024362df14848e8d26c2193394341b72b0123bb7642ebf186f77dcd82b64f0398e04b4de33ba2ae3388aa51123d7c6e789c8f922d9f7a699027d4f126e67e468dd9369f48575921fdd4b2ad8e59cdf11aa919a8af161adcaaae2aefd0f7a92c525ee7e24c46fc0dda857c19831f653ba2aa3548f2959e288ebe44548503d833db6f3c503da85c07779058156b4c5ac247fb96f203909c922bc67aa14c95bf1b6b396117b5bb0f5b845d07d7d3ce25e642af9e7d87291b150423871ed9439bebde6f04dbc40061505880dbfe3ac183ce9428bacafde67b6206d74454c07f3ea1b08d51aa911c38c9ca1ae24a02b2e602342126396dc025816eb8bb72320cb0ab0e47ebd4ce232ecb106ebaf9f840836d734402596fd88625b821fb9cb2bbcdcbed9af3d6bc77990db262479c9bafadc9a8a72f5b61554ab11aab9c9944531a73ad80df3ae869018a046690ff9aa328f3b2056715a04f353bd658a05ab39e9cff01b026f8bd399a06e6c3a9d74a873c0a8d944a1deea6c1d613cdeac00b20c10521324f54adae39d02b9ea711e58b6c940899610f81197f0a8e242e86dd17cbb1a0a22cb48b8d357d4e7c0168f30f414fe096391120c651f006e932d541602f58fe3ed72f6ac64ca5b43da90a8609d519cad491ce1088cefb739c5d1d5e275d01c9a61f11c5411b2027e2fd5568fbe1bfa1556c210de8ea074ab387fb08a9f53aeb0b44af72b866b6699b32981d3fc20ab36d7c6871e363da406aa60ddb2e24d0fb9c0bc9d842f4574b23c32dd106308ee6c181c65e1dbedaaa776dfe7330234995fe2be5b8a547aca369de9b7aee9393df5da23631401cc8b4a8ea6a0520bb79c4b83bed856e6232a902d8cd643957e8ce0cb325fee2ea40f38d417007c1a91758d33842724ccd3bfad1bb2a71f8d30e5e24c5f67ffc771b2bb07ca75e440818e1a8cc4ef23fae25575a7cd6fc9e8fc4390ced849827c68c22b3955ea390eed6a86feb8c7ca8a43c16d181e3ac8758e66dd9d4028b18558d45a327804bf93c7c09ca5efb253c61d5d1cdf204effc07a272b7a2424e0e12c53fa6febb0141f678cad1dafd2c823536a78cc0e2882dff2a87e66a666d0c1b2284b992577823e2fbad69b91cf3c7c11bb5e4298be37c4103df2b84771b52b88867751d35f94744725f2c30dfbcc7edcf8573f4c2cc9a8055873d33ab2cdec424e5a7218a6714c4d253ba3055724e2966c6735f45279466eefbb6e18ad59590057f16e68f67d5d6c312e163e53ae2803af48e583602d4594cc743ea21b961360b5bb81a7d4688e071bc85da481729e4df7d974286eeaf73b91b03a7da6c486c8508fdf52b111b3b4b7095c6cfddf73c626ded132451636e7488c4ea5e718d969baa285dbaeaf828494a5c5d679ce3626bb3d4dee42c494c5372a0b5e0000000000000000000000000000000000000000a0e171d222a303875e6bd8acb58b4c89dafb8aac5c3f2147a5e7fa11a5db2deafac754e3e5f2d0d593f25a6a9b6b6a661ed86f6ff02e98834b8c2c0346e739071c5eec61752261402cbd1ef2ee87daaaf35be603dd567f7a25c5b081eafb6e239cab6c7292eae5ed61a3fba17dafc96dcc991dd8f638f2e6cef6cf2a4c115fbcc4388e44d3f9c02e7dde45fb0958d2a3aab114cecf04d34d4768c5e966e59d5f9cd57a211a8c02f4fa35990ab57a127e719661f05f5f04d13b4c00f55d3d6caa48666c8a19b008308604bf23a21ff79c8b197eaeb841e373f2f617864dcba137acfff711abe9b7e797efb4ca83a4a8f79f24c7d0619be5d387182a6f10ef738225dbc8274192abba963604264ce168f70db8076147a3ea0b5d9262b44de05fa443496cd13b733a1adf59136d9b8dce8f3c05501195fce40c67c56866f6d4a6ff437e5043fda8ace2f4aa96f71f9a67edfae8831e989e5729b9b537319a12b1878ac7da47458991545dedabaa96a1402b88eaeea5efb620ac9d37e396462d82dc71d02a15a549c30fcacae376f3c0ee745cedaaf416bdd82396c500dabc92a593517215efe834c881e0ee49f9aa8797c2644b81c9b66bb9b6e2829fcfeb858ce89db00539196074f47fdedc0d9370763701681799647cd5ca691d87bded8ce58896d947afda988b720855d31832c5ac551a3543036203b591dbade5521f102af979f257f97f5b16d6b746c735f62696e64696e67a26374627358fea96375736563746c73656c6162656c78184d4143554c412d50512d42494e44494e472d544c532d5631676e6f64655f6964582052ed288e3ff39ccbcf74b438b779aa15e3a88871c104d578dd338486fef6484e677369675f616c676f4d4c2d4453412d38372d505333383468686173685f616c67675348412d333834696e6f745f61667465721b000001a0acc226006a62696e64696e675f696450dc0762e962de981a26bd39fce96f09b26a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f686173685830ba5d003ee50124a47aa0d55c045bb81cc4d8f48f8f2af53deb5f9814f14bd78ea652cab197fc2348547d4cde771bf49b697369676e6174757265591413a1b437e4840e456fff8fcbcf89cb8652d331ea16c6ce7a371102d91c8cec54c78ba054391d32be324131112c0c1ccddea665412a4146402756b21637212bb9ab38094adfd5fc1ed7680698a30550b9a2360be9c1838e636acff6b0663d528a5062f2ceec751399bfec8f7bb83b159d771236487afaf989714dab98b812958398e7a747ecedf662ae8fe246c32ebb78172133f0cf35530be533828a138fece97f35cfeadb39d693929dca96cfbe9adfdff3433670cf6b5b4aef755e36bf943a8ee55711e8d5b1ca52f7903dc0482d6b598777c82f595552e296d6c6037aeccc45b4f71c6c687dba21c8f6b41066ca0aa08b8966f066446e8da4d81790e2df0d4dfd3795ec288b67e1554d27987fd00b401bec3b375fc3d5fce605c297330f3197b53cb69ac53eaaa606ac31dc3e194389879b28d2123797b106bd5ae29487fbf8af70a75edc98d0f65cc8e9945d60965b9b80f6084e7334d4e8da193d273c524beb01bb08b91240c8780ccda4f16da33798056ccfc734ff6e7a484bf7e938902e4b90cf2be0f512a5fe251408f487c39ef75e78f2b714c88dc969eae524c18d1a1a6d2de99e1ab8e862c4afef4447524f802f668bdff6380ff75147bdf331178ec902519c186c93addf276ec521bc7bc199d51e4724fd7c2e6084729e1cbf9129c5778e68afb16134afd3e3f54a4340f9abc8e087a929f69ef1ed1b6211de8431c306d41208841eacee718770da64457f0b53a44790241aac650014fba639fc8964d4a2da2ed1fcad2f50cd88270eb4cc4ee5ef05c729a2e2d6018802e3bfc4d69d1404cdef625f2eca4c2bc73cd8b7822c7a53645a79f33f2691d41aa06f8f1780cf600c4d5c3321fbc814307fc913473a0f73ad5e2472fb66d145cd79b783f6e8ccc02b7647d8cd3c299df5d474eb34d489d7641d2d143cb6f7678d0106592893bdd0bc117ba246201ca185969b01982ba06f6c6c729d6a69a4f0ff9658ab92b9c23590dd36b563a83cd6f5414f2524ad7cfa4d8f90577840fbe7794ae98b5a7ca8be32e37507613446237f7a1ae2a0a77b71384a7afe8bdc4ddb670ed92040517f4e8956ae22a408f7b4ee6d35b31e66526b09e9a31decaece6f57f9fdb578a520ed3a27eff644342befdd55f0a366af176fdb6c88fe0523ea9265791597f17a8f39f486a17d515cc20f33e4a499db34c613477ea3d1a4cdf5572e08c0860378058d3c2117899c824c9aa1e39a711e576f4d387fa0311a23647f3e44b1a6a7b9e1eea21f8d756e7865875121731dc7013663370a6b63439b6b062c1d22345a5be054aa21deab3fe83eb5230779e7ec51b3ebb710636dfa5b4e014bcdf69cfbba0dc67b8d633cf04a8d5ffb9fc9d34db666a743f64f9170dbdadf9b6430b9d15d168ffee490902f55287e184412252ef9acf0ca7e45a80e359138c1ef827c92037475acd3d2882576172bcce5a8a102bb519ce18064b9437eb7d102177e985be457e6b9d71c6d0d9f3007cbe6c3fa7ae676b68859c66d94d46fcc41f8fb39802184fdf5fda144106f190f6a7fc6b594c32cf3a625aac1b2768aa1d4c714ac2fb387bc4621de97114197a49c8baaa01d0e0944eb5b623e81db81c2d24518150ce3cf1cd461fc93280cd30f07a2213fc4a4a578a3a2683936170f2aea18dc7a5bc7cf29baf50e2f68b49304481eb1ac94afe82ddcefebde489384555ed437eb69b69f05d58eef69630f620a6a6072ab27f63e4823cd6a3e53b7afd56bdb4c4ec7a7cf300c17e86fa858bdf72284b86f6c8637efbedd22e31cfe0e803aefbcd7a1c7c491fc8e71ac70d4843d356c7aae4a58091eb7554f0d4ece1b7c2090b8ff950bf82c0dc46dbedbd01754aff476ddeb4853813c6f94e87cdfa67432e5c072b356aef221785ae968e471ca38ec6422f12b09030e56734001b3ce9f440baa44a4ca057d117dd6051b2b587f084c7878ece13eba12534f00da8c5e619fd3af261c996c72a2e36df1293aed9d30edabc1d9c05abad664935ac72bb8a3eca02644709c7dc9007d38a63ba6ed4f69c56a6b2bf12345ba02f58644620978fd63fa2424ec85dcfad7861f492387bd05ae18fe660d4ef78c2212bd56b7173a7c0aabe7cde3b498a26223deb5081cc6da82d4d54bf5e08c508c5500710d60c48c60d07a34dd764d37c5a8b0ce31a9177c21c45a28ae479af8b757609753c33ffaf0a6c3d23090ee711253cbb9e4121a77327c9708cedef4e4c10d4232445d73c85f4a771561ca9b2df14b14c3e9e3ccf5949379002955189bfdee4c02c98176d8e70ebcc796abfa610912abc442ca9e530f38484293de86691df332a4818472e76f9bac27b15381f2ac0d07a1485c4fa1c2a5f2ecfce3d0aa0676f4894b96ad6d1fd6ff0b97f0dd58b73edb15dd4d2db8a7f9300e146d873666585b22ee0a67073181813beeec90dae6c178478ce667800e62e11076ac16030e8b2fca8545e7c977723c6ed8ed8e843d7fb3e5edf0d91b23bc054efb7921866416cb762f6b46f3962bf2e94f1cf78fceec3bf025f1ab5f4d43b20dbe69fe4a87173f60a5fb442c9fa8964540aef4291449b67dfcb47380ca8ee79d5b50de4c5fb09379e03410c36d44b3492401f56d9e8629d48c14e5904fa9e7c76185a964511ddc18e131b86d4d64f8c2e7f7636bc051f0ec8af2306679068dc3d898f64dc09d7d59595fb7693bae5c6e3eb04191f476d30f2b83479273f625dab044457161709ecbd092f80ee5d80d865852791b0e5f3441ccec3c933ee30349936ec62cd4144f6fea534e289c24ca914d788ababafb0df90983dbbf3ef308ad7bfb25216359311944d7b6f5ee4ad8e27169ee3d8790af46f8074627e0023b3c22b8381fa5add86f4d9f8dc7cd490a9854eeca0af49efd5d9a4fb2e9c0417ed47dd109942c5f54a35ff211c0e481554e3345de5224c2f717f007b433ce39fe449ee551f6fbfffcdb667c3930351b99277e73afd38366fbefa95ef0a543b89bb7ead49251dc499245eb2ece9939ae56f63d9b728314902417f67781efd164dd5d6c27f8bfde5cb15187e5b232d5468c39a862105de7c3491250ef047c4a4dce0dfec3710bf8b85dcaf34db811d43f524c84431a60dbbd89653143d93b430663b88c8d0bed9560ba3b587da585c83c32871343189d6d1437c823a0cd4609e83a9b0b6b35b8d76049f919c0c816050bada3cc0ce667c2c97a91ed858b1825685026b1145dae8e2c49e44a2b82979222d8597630460f5794fc2d2e40ec9992b013bc1118501609188c918e2e478e050699ae7580f95ee80901a308dfb9ba527d7ac967afd89ff3efca73eb4466592a0545226dd83f3b7decda2f7817a2b9a9fde15775e9927dddd8b74267fd5037306023cdaf4db2bb85c795b77b789c04d704f4620eb83a352273ce5615023ef561add028c7064b044c2afb548d799fc651724fd3992bf10467610f675a395100f666483c69290296980bf8d5ab322c8f374df56807a41b3249aa73e1e603143f31dccd6d3a67026f94ac57fa6ff355aec32b990c0fa9daed396486ae6d03665c74fe362b4ef16c7d12fc88dc06d5bdf528cacdc13b85657341f07cd9b5b28a2345511c71ce54ad2ce0fb1773de1f4a2930e974a3cb8be2a984da3ec6501c9b62a70ecc5ade61bf10f81cac4b9150ceae5a97573f91b065035885b1c778735caed5161359e3b8b8693066bb903d94b69819ad1e25df4e29e169d47870b3a9864f86ea9b2d4821e98a570c76fa5a7b4b56585ff438c49ab27bf54921958646ab815166bc485a027eb24b3b2179805f377bd3dbb91a8fd763478c484e7a7bc9a58435d4c105d49ab6dacc0d168cff7df08a40a3a613c498c13ae2e46eab2479600ed16eb98299aa1e11f34f64b64b6751e572d567246bf0dd985a8bed154974c91319fd96ed2ad76cb867df4fd576190a2c35cb5d5ddd633f618838c8b64df459bcb24e65aff8ea9360631aad91728e66cb1ddac22a278f110666442433e7528b2a7c272b143a976fe89b0b2dcef987be105e56372d01707aca11f2d8330ed18be2a3129c9b3ac4ec104e23305295f5acd522897d1b103e2297ba62adce2894719a0ac3d2303650aa4220a9b754f6dd617c1525ec3d544da0caa3a0e6d8531de03e0782f963390fc0212071122372f48d58dacd38f0cd1c68c19afe15823cad778d48161cfc9698afab4ab5cdfe7b1bca07df06f6b53793bd71901986917bccaca6e48a0246b3cd78827410f9c49219c405ff5f156e349cdbfc6659b61581b3c3b6caf220d3aa4159706f983efdd0798a2a66a33f65495ec7aede8a8b55e45d3e1ca8346a410b47adcbf332d785c1fab95c2d5ff299103ffdc7b2848e8ff60cc53426dcd5b655af97d6ef320a9fc59c26d38af86e1449f8939fa6cd0a5fcdfbe3dde93ab302af9d6cdb64f12c638363263e7dfe0abcb79618c1addfdc74c72a885d19161440774996c777961ed3b36132f94ef476902d3a15ca03f347114d600a2ab8b266eb73b6f132819490790e83ebce5632f34759bf4a1288b6cf8fa8cf40c671f68f35aed3be66c239c1eeef9f353f665d104db3bfa3bf2d316f929148b740e548588013c69fdd93f5e7b7b81ca99ed797e616516485a603ca02b86accdac750ea436ccf36c6aa94f2ad534652bf969ed1c0921dfefd591d67323e120a185a8ecfcbd08346fbbf3fcf095d10c339cac1bc1806d8bddcccaace594209826463fc23be53f404a4be9fa11abf8aa94699c732cf0329b995d2c55c8495f1d6566be955b4965e71d92cb5f27f83ed6ffdd059feb525920229a008647ed22746d7c7bf8c5dbede3189fa73d6e2a87cf36b26c757c0df82848d395187b075661aa5374b6ee5b9571701a0a11fa8a401247e6183ec05cd4d729a427df17a2d312c0e159933dd9e7be45de0cf13160fae0a615286fa636ed75723c146cc1e6fa6700dae0d4bbc68f91b6912190dd9950a83bcbc5b586c1f29aedc5774fab3da90ea51e8200211e51ee0c1b02d73af7563719c4943687542816714d73e75c37c9373f6e592efdd1c49966183e5d9e10c8e6578e72c9a4d2d698d4ffb7ef38db800c3a69576827bd999b21f78e92bc76530a65966b484e473d7f03eaec2e26f5596132f96e047e64ba51ff4984a9b19c7a79a20c63872801c3f7fb5b40299ccdb07683a5f7de272ec0370b6b061326e96d01310bde053172ab348cfd3dd263210ad28997dabe726fba9bbddd9b16f29ff12cbe3284ae7741bff4b0457e05e2b7c1faee29fdb1774db64dfdc1e5d319f958dd794c24c846e8782cefdcec95d18af4ff3c8c62176ad5a5732eac211880840434b1c72c0b73eb86bb80f5dfb2e83b69d59be9bb8b5650297a6c467388fff0684643f51ef8a4fa5c26ea502633410bb0b1ff77b1cedfcc4fb8c23fa9452d6496f909fbda5d8f3b9aac7556158c8d51057b3342d9d16aa01bf9604cd2a46b78686ef0f7e43fa01b2629ea4c6dc573efecc39ff4cc2a4f0ed66bb57825f9a24641c12e75238fbd5d9f20b41710eb462d7d5679c5b2833ab126dea0d469fde06989cfe1d95ca97278c389e6492e311e3aa314e11075bc010155242675727be840d50a5ed0c80ba00dfb4cf183692ac231199ee321edd9fea38bee0e2551eaf2281981fd3c5db65406b5abfaaa54a271270f76e758bcd6659edde901da2e47ae353d9b5e14507d9e4a47107cc44db0297f878f1a6ece8f3ffae05114820c7151d7edaa28e5cbf502c6abc80a0b6b423085a4179fd3c2b260eb2d3893053bc39421f494bd64df49920955b8dddb37045aae6ace868a6f7e89fd7d42abf5c372072611ab6911a0227dc0728be4370dc3c53acbda604868264fe3fc873eb6a7a0a93733fc088f032c2167183399637090e3717be583348b5f6ce0758e0c535cf7478c9187f34100434957c792c8a49f4e00936e547064ec93d504778700944b64b981ce59482ae126a1c4d34579baea6518e68e716d7f7a27a0dc2f1773892711c10861ae7452da1934116ce0b303a113ca81fe3f2216338d356f160923e602d0549a2adf2edbb75cd5a116f793ef5dc5d93eb3fc754c90b9ffab640ac9e59ee134670dae7b9fa70bfe87d099f08fd6f3c577374a63bdf59ec0b0a89d0f28feb1ae02801a91ff1bd0f863346655b1ef2759ddc3cb055f5d4979ab51322b4677ebb8486fed50decf113b2d81a402b57a90325faae622a5bcc9a8a7344637a5f64b27e89e0131cda743adeb628653722e540b18800d86ecbc2ce496be501969943448fe3b4929d40ffa42f64dd9331820710f39099230a61154eadc11e81de15bd971443ee3da80968ab7a5b75fcad4b4b2bd37caea81a5bf4e5edfed5340cde432888cabc38f4ac2233bccf88b511f9ba9d8c47eac4a23a21c8b93fbec5dbdce52e394d65c3e1037990cedde8ea2c4e86fa393a8a91b0b62975aab0be0208333f58b2d4ebf7f902083944718489d4000000000000000000000000000000000000000000000000050b12161c212b335a8d635c4b1ac6515697b8b90cb55cc7346b45e5a1ed0af9fd3a0fb30080a1544a925265a9da843e2cfd6045beaaa94c31a277824ccefbb754ac21367b07135612d52b28f405284e335bed5ce6d46359dcab59b375c37121a8f0b8cc694d180c54d8860d4c47f15c8c2dbf5156e5ea21e86780fe22a15220568b5e009dd94e49ea7ae0578842b78e4d1b1956599a3202f97dbf08c6cda52814767a8717f3f0e266b2f0c10c078209a3f5a4f391d0773e0513a77c335402b89815f4bea3bacb01ac0443c4e3f77184d3f53aa4e7fe92cbfbd562ffbcc5d7f99cf15eba066ee3cdedaaf07a4031620defc334de0125a534dd1492b1152b69e5a2310a46ea29d881fa3e3f4df406233a5bf8634a40cd7939946a99d0036d7be5e4237c182b4f50a689df9acdb0dbb25b80673604cd717a460c7cee587816ab4e13eea11dc16a6dc97eaf1dcbe84becf2a8c0d4844e56abc0f669f5b00244fadc2e9d1ee709eee290fe4e83ec24192903c0ec4061ebe874a6cf0704057bb060520b155792d8ea399ca37f70d8d8cb56e03e8bf3d57d7ea86630d1d8109017df133738a248332c8a116483e006503e47c72fe6ae53ff090deeed99487336db0dbc1bf7e9eac9f9281d1e0b41a80f1288145dd44cd9180c35cff47893188d28dc69b40782ae277e8e4715113d7157d6453fcf1dcdb1a0f9c89d20c7c328e216b03d4322788db3c784fb6c6964656e746974795f6b6579590c2e8bb511bf43d4ac47128b2945065f1a65f223a6257b187a9a04b1487c28c710d27001567cecb164c51d3cbf78d523bbd1e3e36e6debc86666384572304406e3c4ab899e573741245268b2f8f06537337bc403461709104890b5bdfec8ed89d180b775c6f1969e8bfcc40a410e65117918a5d22d610a7db2ea8c2a84d51be0d6fa93b4de33fc2150b527659be381be57a22c895371b06b9e71e824a03f32392538886c282383a13f32cdbb0f8f6abe04615fe05f3f74218b8b7885c31612d828d94e876b3bc7d3b7904dd0c52c12b8448d16a1fe899e0e40cf11899dbb9a74fdbbb66809dcd7e0d4026c22f6cf6d6e4619999dc478d72c7131ba48a39f589d7f3ca3a5edefee35ce79e4b383b3f5b75ce052f1437dbb20a9bfa17f87d9ea0248f033d0f9579edf3bd45cc2ee973e99f37ee3d95e71d299752046acb6f54081d7165aa530ca27448dfd4d8abadb5361718e010052eda5c5b1c1f7ee470361592b31a6dc146cd015f03e6137186e4c43c8f62a21141b2e8b52eccef00eac32d987ecc2871ed21104abfc674b09ec31cbd540a18758da796bcd5b24578d6ad5254d4d3babd8d304a3787bd6f36d22f180f57b98d97c0ffdb1f4f98646c11b85ea4d0838c45eab132a3b258e07eb3b7e91a3b8824d977444e86524d90584d2eea80fbfac0f27d47fdd3b733490de0f984ce79174f71a8f9fe7fda4a2ca69f37056d926adc491914fe2fe4e7915cb376b3da5b599e65ad1859424c1cd4ec44945e19a7ddcab3150efcae9067856aaaae3e53d23b1a08d2d17b1d718d12eb430483ed26a0602bedad133c79ef7fc2a06486fa011b2d8a965966e422d4d920d123367e1e5b6b8b8ff5808ecf0dde7b0f9ab15aefc451366c62b9ec0cc13ff778977486cff767d929788436e0ae4f483e5ae1599e497030ccfbe92c989923b8c83925b7cfcc05d3655c9e0cb92aa131683a3ea77151610bdde407c7e6f10a4210b2a7e78ab2ada5f6dff573169d1b344c835581fabef8fcbe58a71a7ee787e52871049e4580148e7fb5261bc543198a1bcf8d6a041dd9379f64748cfd0d7af9e81a4cd67b9ebd59de917588e1fdfa49be9f9dcfae6ad7fb67d87a5fc3369a49b4cac51087f32d702f11f0c18d530cc39148f843a5186d5b707350391870e6ce3c7047d46a51ccdfce7f49bba048dbaf57f160d88bfa0426e2095e8cc96026d41052d5ad03ad6d4b3cac6b01024fe553f1f55f54633900c2daf947e3d289d6282f6d60410eb10af338e13f3e16de9047763bb24ef4a3178dd9ddcc86c34b63a601ea766eb370029d61b6a5c4727891386a0824057de68c243f9a3b36a714a4b86c5e1298ef792d62031e5a9d60e2c01b7f68f0b1992884894be7945b51853007dfdc12597ba2ecc2173f755a054873d77be7719281d420706963551a941cbf18981cb7ed3012f53d7a17d6072edd86d6a3c254a53a0920696bb5e0aa330ab2ba838db9d1dcf355320bfce55cd2f6b192c36fa119a358ab1604522802ceba3caf001c262e4c77048224aa1edff0c9018cc925b7882fc7336e9341c03ed7cbe6174eeb1d87b6e93d71f15be6c4f5967069816b43c222562377f606614b4916141925242b19530d0be3708b49715f29e2049087ca5535652da077a6f86b2494cfc52d74b14873476fa70a6ad6b42d1407ab1c4dbc8f13386755e539875080d8c8ee656c2f2c54e0c94ce47a0517fdb00fab37e4287510ac683c125ea8f529c6ca6454f9f05742c2b2c541407a195b49883faa8e9d26d805c3b1fd63079a60bd125c55448733236528ff5eab536a2f5098bf7f70b5ff697fab762f9f924ffbf41701edebe0e034d375f809b425f4baddc52cdbad9807cb488d8d7d3f54f952952ac0424969b878ffcc37217ae1bed97279a13b711d50e73618a6fd3f91beaed8ba8e624db50b9a333ca51dcf6dedf87d3a9b33cc7b0c903b3f07e248d8144d21278da52aae2060efb975aa9d9c055a3e7827d19bd5a980afa8d5de49ad223afe167b0c7e28cbe86b16b6cd0f9eef2f0aa876d726bc666c8270ab651abd0547c6234769119bea11af68870afd998d6d409978f1619a912728410c88ef223a63ae61fd0c4ef8af43b12ec88b0cf2deb7c9139bd9596eeb88d0c028617400ff494c9882e0b97624f2881a1a845989842495d3e72ae800eb63b877372674a02ea64ed4832bbb31443b6854225c1208581a8c55a85bd7784786c4dd88b40aa29c30515a577f91e097cf3c43d746ea82e5bf3280786ac1447824cb9665dc983525ae17aa4e4a173d6397fc1a141aaeac5ce11cb708bdde52689cc3eba61f325d08c265ab3598dd3cf2fa9780243f8e2e7b896420f30d187d1abab776a49dfedc1b39fd468d9f6ba2d9dc20fccd7537a7555996f785581d7cab7547717b8c4609bbb272122fa720b4faecf215532f2fab70bd8a228877af52626bcfcedbd235b75a1452966c31a36a3250fb10859a1bece4ce6bf34bcdcb342058653cf4ed1696d4dd9a449d20ab0d46c454dcd474f110f240b7a9010369b2d011adeea631a7f379c52d11236455fa557a4e79812f1027fdc20f4a44e851e3f8bdfb6c935df392b1c487e9988acec50adc492f08d07c40bf0a108881b376919f3dee364da5aa467ce223a20aff39205b47df1fcf84496e437f4623ab5b2f0348ea50f99683ade32dbb30af168544d004d7171315002b3216669edcc5403ed894406878b12af55aca72378c1dee94e19d97dd6036704d9ec158f730037662b4a27f1cf21a04658da3f46c92e452f65ad37f9b855d3fe5b5dd15d5bade4f2ff2ce656b9d705650f6808c304dea836156ff5e2ca299f2a17976055348c2eaea94d290ea18d0678e78e0f13518a12296eeef5abc5a9123b27c7403eba0239481307d82d28d5650a0948e0ff993a157c66258e7688d2ae3b545ae594c27d4d27ff6b1cdd1f2bc9ce3773a39f719ed5eeaaa6ab5eba9c9fee068dc275348fdbd473596816f38342d691e2f5dfbcd4f5d862c297ec4d5e1c6b67357c3409820b43102b2dd07c072ab90ce64700901c7ee8cccd99e7ed0939133271b8cc6e47385ccbfdc3c7562f4c650adadae9560e6a26739999c058c0cb2875d54de26e0e54914a64f0e8bc76d954ef4b84fab01c98ae039b450ce08045477e5ee7cfbf614f32b9e637d4506034b38d5848d5a566e99ac795e2fd14146dc4023952bb9d8e669ff1604d07c5f071e8fd8a51f4e377060618af6d398063f835f1e4b9d25352ac79c2e3efb1b92455a51bbb79ae626c94d90e908a78a7e31c4c8a4ab2121cdcc417faefada9bc9e101b9fc3c75a28e1b4acdbc121341c5608b8bb1ac15d883c3d69c66b78f039a8aa94cbe896210238987e6b8cf20a462d77d8e01d8ea07df23c9befbeebfbd99019704eb2b4672011d171b0d81f6171f2d9acbf9d98dfbbb11d1aa48c73f850d780b57404c7b1f054341af995772a1b3eb166ecd9a695ba8d12bdfd0965cb515bdced0d220260a4c70a316750eedf9be993180106488a4fac28ceeb9b8a9d57be7663d75d240c86cf7617488b73c0185da43d50b22b8069be4d34bf42d0a52742ff417a673b96b8e9567cda4d2328610ae3fe6cf423256250ceaa929df7ee8297e9d34b9e1d83b2cbe88da159878f3082020a02820201008dbdad89bfb4d00882ff84492fa70d1d8fa6e24eb252427b5364bd063304d7321c31036d2db9d9411e4a098d977b96a762c9da4c0c3d57f1bd328c1dcf7135f8b3736fb9f20c02d7556269a777d8b3827aea0cd89d938c18a0185f9a6e1726b87972d1b0c87b45b8c15896dca7ed99513ec6759cabc3e83a0b78ae3510723763c704802098da708cc5835612932f7aa354d88eef83bc120c28975a4309842d05ddb80998504d0ab4ecc970fd32ccadcaab5cb1ded2ac23d6a63436b1c1896f85de6fbf49d303557bb26dd4c861cada234a1bac5262e754d7bc26a530cba38438a6c8c39af2d075fb456b649113ebe3b8a6165d82afb561781c225bbb9f5392f964d150708e69f0fa433c7612462b8a449be69252bb2834777021eb07c822711eedc9194b1446ed9cf21789b11296f9ca74da79fd81e1b8aa94f031bec2e3a475f6ab1affd33e6607b8abbc81b294322981e8582c42b8cae0a3350aa1c073f56e8f660390c408f0bcffdb2acef85024fd82786e40d757af6de57d7322eda480cf2180e11bf1dd29fd262728c6f243e87c135198f15c3508f977a308386cdd9cc8d8630e39144c8875798095d381fe6fd0ef8c934ff204f0c57e456ec24ff9eec15ba9c1d2c32316533bfe2df63f67d948174afdb7d1cf24e2343d7bd845bb0196ae51f0b7044902f3bcf7eff8971c9ccbc86660d028ef9364db9ab4afade886470203010001", + "erlang_connect": "a96570726f6f66591413920f201b28351aad1268c6c9cce650c7ce9ab09e53a840149760632792559ebfb688b5abe1c9c3992dd369bfb66d244cbec51c72e840980c2674810577c259b4fbe0ef6a2a50b0fca2e514c74d729382d1c25dcccacb75aa5b08fc9255462f6421315354222cb41be65d78d5089bafd15c8141d4c17bddc4d53826e67d5f643244241156165dac57fd2f5aa33bbc7f6c85694bf8e49e6944c103220927632f717dc0f8aeb670c94e9a97275844e833d4264f52f019aa85fb14113b6470343d0e9b0827c555974ee40131a3aa7f46f5bbfb9cc40ae20a08752a89265bd5378f00831534bb458db8cdea17714af3f5b97c37f77e379b823219edc59aab20666bc0e5c022dbf0ba9e207d7839dd889356d866d9117a6db9c83aea6251eed988dc896b5ce62a51edbb95187eab8c32dcb1e74121f68df8cc767fa26402defe215e70e8ff7dc2dce1b2f669c407d336bc987bb3ce4110482db298a6452a927ad4a8bed1694b4b31ad5bae65f9653a183923b1b6284114402afcbe5416a72687321c7e222bbd412fc7fdd4dce609ca33bd0d78d457ef669049476ead092d2dd82d8f4ef1f2de920ffc19e36cb0ae2bf0ee1dab0650c069ce6b3974a0390b4a8dbe740b6c7f33d0b3bb342fe5d4dbff4487912cf623ec1fdd53317654b42dca32ca0b87096a3c00a23a48a1ef9c0ffeead5aa4cdd3ba42bd43409f448fb64078eddd15026514e103c5c8adcba3a1b6182d43c30ac411d94d66be6e7a8de50121e43692e109543778bde7de99a34f3af570f8c604a5894a47f36810814550336b7d1b42720aad5db7efaa46c1c425365ca48b2df27bfadc518333f3d1b0a8ae13df63028450315afefbcffb5431e22bbeeaf0a7df34768083978cc666f7c4c68d7c74edd90da53eadfe1a73e65ee8454d45fee03bed10595c3aa396b81486b356cf03c04d1d1482987638348cc2d2a9c347926b16a9f404f63a47c8e4f14cac50c93fb97ac385d1435667884bd78c70159d01f4a4dda1bf5050c5e60dda15e9caf057ea6132bc3369c1304cbe00580e3fbd40755aa1acd5fb4f17baeef6f062c36ece3c1e372554ecb7c8dcc5b5f815c53b4b64d150a6d0fc744efb2be574e28e3bc155a3f0b22b2e05bb9a878ab618195014636f1a572598f03995ca7a053c6360f2913102ed93bbf6e9548eb84235df9ee3929894d9baf721be1d6918d880fc436c0ba51c8a385e23f12ebe179ae7455b3f0926919f16b02ed6fbf8ba7a57304f2a789bb8aa3038170788fb7b1aadc5973c882a570c790a97cea448f3b539ccaf1a4b5a541c36e7b8bf26864fb812423f55092f51986d38834a232e2f9175f46b08dad1ddd1387662ca6a40413f5005a5df77b5bbf6cbcae50f64e2d467cd9b9e081180d346aba82bfb6575467f9777950529fddeb878b608b1beb78793f50829ea29a3504c2491ab8c4747289608800ec3ae3ab867953213f8782202ec25dca768a2f12ee8de7bbc0f5fd327278cb942bf98c01f4ec79ee606a9d8d1223b105497d62a418a0ae686cfe9604b4c7200f5f1064470bba52498996eb53b0ea6a749cf2adf307c663872be233c34d1baaa14ee93edefbd3c1e57a441bd726c7fb0ca1675a798b8ff790415f654edbb311d27fc555df7b89e0d563678bafd33666e07ca79a736b409378c3ca7d3acdad24098e33cd2a6f6f53533edcd271af93e2a2bf48461e2f33d18c522d0e3d4f21be913896c3c5b9b801fd549c2429a30fd86df46c9d501ae249bdb8399f6380f1fd66a1868f1a4684a5a5a143a66ecace1849250c4f35c9622fcd845c9da1cc582a6b62f5d982f4638a66fb1d47cd8e516d605aa7328c7349d4a7a0a24a4a1c6f211743681d6a2891600b7ac4d73ec99b244625c4b5a404d7877316ff38c68298b40c7820eeb2706eb4eed09566dd8c43f2d5163d7bdd9d12cec846fbef470f4ea8d5a7ccaabe11f840799f3bb9b646eff254e99cc81537668f321904664f22117b3b5f94da324e123a9cee58f6633946ee59bcc2b6cb4dcbc4cc9aa51f56b56ea980b8248397110e03454caf81c41701abf7202ee7ebb161aff331b21753d7bc925f05d42b4d19f44e1d125a2d724ab51688f06e4cd1cb419c55baa067030eabf81b8f0bf0f6d0958e7db4e7208bdddae6d45983c0b333496afef3c5c8ccfff1fa7a237e838a76e031791b438cd69f564e90757b750dd50f5f72a4375792f8a05f44861a2930cb9203cb9d45cba30d58b6a2bccea5c88d1c450d38b3ac850861ddc8085c153cc708e8117046e40717ee594d8df773dbe9581c152b2c1b5e1750e876c6056a546c923ce10a54237b4e80c7774623135394031d1d30c9941ac70a9681132bc7baf48c06645870b171f9fe8cbe8326ecb6315c33d3b7c11c4b164b81c88e17bc9fe0cd95ed77be050ce64ec648e5cdfb7f8c6c845a57224b7f4818a5019a84ea4667e1baafb37976dea3648448f9897e0dc37d93fe96cc0b20a26b948c7c7582581e55b22862acc6b0f6160bb52ea2421c29769918d3618b6548d92a3bd2e51e034ba951199484540f71c1b27699343d4b980a5579b9bc4f1d26bdf345688b1b4de9385d5b3d1065834c62d9f530c7a8c990fd0d9939811aa61840ae3afc4948dd173f3fea643a3449a759e252430dffbaed9853f9ac2d2f71f7627fcf6ee14a81bd53aa9003423d1dad5c01bd047518d75dadc5098699a363b89954abe17c394a2c3b460f0cea1c88e587906aa2a46044ec62156678c0af843ac759f06662e102331c381e8f87539775bc7fa181a6ecaa2117eb91f12e81cd4ed636b4201b8fe8561a89bbb88ad0a6c17087d1ebd96bfcd1db665c4ecc2437f79c62dbb9d81853cf9b547b61c9832af2adaaf65136dc9b5c4bbb62fdf40f6e3803e470cb7ea44f5dfba5f5cfafc95a3025f2be40d7c20f0f3c3ae10dcb5680e90544543a9b61b9a803d01d6d278d139234599396b5649968eb7cd439aebd32bf4dfee3f92f84ef612e64898c56d8a4ccfc3be461447ed228d472ec5f47a7ea311319b1007ae296ba09605cdcd803336d14f076c9cd1b9652cb72337ba53733e783a80af4c42b104a6820135bf9f4d47ed72f237793d31423df2f07855eee66c2c43f6fb0c60ef89e110662ee5b34260a910027daabe9f5ed94a2200f737c89b50b200164894420152526971199b36492a0c64b4c3a538aef343c9ee701be42f229d1efef4db6f2c150e047e57e7c11669eb6210dcdcc077b1bb9921d49a86f4633e5af6bb54f785dbd96ec674ab2fbb78162bf2ef0772a70e3926801a52a74a1552608eba7556580ae725f098e6447031107a98fe59cd998bddde50261fdc8b2a4e3474597844898670851ac09d37ae5cd5a64e7b5ec756a3c2100e88caf9aa7e59e580eae3b8b22accca14193e82aacc573798fdd9b4bd28a1b715b2e82d4b2b5b90defc55c0aa65b0bb78ca1f79e9934ecf0fa8441135d024a52e6e53cedd3aab7aee23c93f150ce9947cb9519c487f58100875492ada18a5d45d9f624510539ce404fed388c07acf50f305fa44d756538d6113143d7036080c40b564c761dc645bf2148dc602482ddd700a1c4d912cec55d1b9822f27d812e10ec44d14c65dc0521ff518f21ea438e0f40bd87db826f152575580e239433462cb8eb707620682adfd126d5ec78a4a5ec8df124a76db14774cf29175a858af0938d6291ff3334eca1fa3bcb19e85e20dae196e856fabaf046b82a6156ed2ab93ec658c37a49ed2d9cbadfccb39e530f02885c2fc1c97d7b1fd9f89d45ada5c1a8088b0035a709a51d5f3b2e5fd882e466bffceb243b54797681e62302c18cb911121ea5ab3269d8e03109dfb013d165546cfb3d6c03326c9ac18a490a6750874b8c06d995871db6284de2cf1378247d128f08a623782b3829c9cd44f425acf6dcfee270261435ae861cd8ae016fc84888f8788b6478c0ccd74e4e283eb84ae287dd2feb0b6f4cf6a941c34417a413cfffece044eeeb04140de7e61246b7c3089145bb8723f278716a3e7b36508e32f83e9c17eb8a0eed3499d46bead39596aa5ebd756eaa9990e043911ed4e05ad570e12116b07c8e6a31f938bbb100b271cb65565e8d977e2263d138b69988a05840e178be4f0b35c586c7a286d9691a1267e7fcde2040edd299eaed4f499f4b8771af9e697d2cc5c60a8c75f55060381724022f186e5d2abc1f5da02150ce1eb45319095f59c23fea6d3a8f724eddfac4bf52693d966e295ea6623d107fae4dc6ea40b36a04256e26b324897bec2fccbf9e75d5d49232efac84fc4a8aca539e930cdcb6e9fd6a6aae4f224328d0e639f7731b83b015ba4b05b75f9b391b0ebd6264a4a5d3f22139bb77f883a014859d65259e4b1b9f3db98c8dc47f43d23205a06a72872a4ac488efc59a87b3ce014ed2b9c4d4556cf257d64487fc331a5bda42cde0988a4e9c77ad8ce920c7d05462cdeae2beb326b800878368187968e0b67a2a4c50a57b971fa71fe8db1cd5ee73b840155f47345654e60641c54ad40916066580960cc009e191d4df8ef4859e64c044b6f8b29ca91e6c7f9784f9e344a1d90ae45524a76182c43e097133385aca06f13f3be3fa112e880ca3f4c5f8d782342ee527e5a55c1f92c6c7696f736fa2010e58bc19e2ab38674bbeda9293cfde629716717df71bb375144e95d9c2ec5770ef1f0224c68e8c54ee4ca6e41e3f75f1689808c5ee2e5ed16aa965308ad8538cf4f229761b63c60f6f0a45d1b9269cf1eda5f9e2d59dcc4d9029193bed07cf4c7fea18f965345b8dc8293986d4cb4a1021fe3b86c57ee760b3536f642ec9535e7d9327e7559d92ce13c404c6c8cff02b7023cad31e76f012a60ba0cf9691d4c742373f8a00fa80bc9df82c09776344437f6edf74640a815482022efdbb039878b136aa9e88da1b7214e8fc407309bbc86cfff948af02ce9fcb40a4addb6fc5c78001fdcf98e300792b37de27c89d3b869a2fe18b9e4ce58138ce66747333531b8e6a8d61634ef2fac30567f4e0ace587466ab38075b24970385c8a2f96de6612e133c2b94f690a622ac28a261068e379e3896e943c11e0bb303808cda9da88edec73a85b5247b9874b3829cf6c3a6b3fedfa62cf1d5cb5323612f23d53dd1391f3c47fcbf7b75962527386faf77a576072d142621d5975e625ab381563f6b15868080a94ef2be006f124a65136d5f6035d34121b94eb619365f2025452e4706746d268ad2a869d288f31c08c51cf00c4f4cfaf686985be8400ea44ce5aa1562d55fa9024f8a7c90ff7f728c17e41722108cfb86a0f14e51b0c1544181d101b077caa2162117c4172a34042ef08dc683ebe408c5490f2982bb9f5c60f84c0ff70082067f715a75821b5a5e23f9536252fd2d93f343a3a868a45a2af3a6ad98992d3891e14fa875380a15f7696fb633c55d891fcabb5644c9d3458752d63f4875c3ce9756faa79f24411b08141a7f568b0b7eaf740af2a56519876a34ecc80393d2cde8575e831f068e139fd1c42ee0c564878a06eef6ceefb51c90882286229bf15b99b133c76a52c732fb58401a76a82859281579860e91205f15db1c7f0a48d941a21c05b9d2be9c2ef68888386e4369a85469fab7b470370e3e28aaa29aa45dd625303a4f988d2d65d768179e818db2299935e4a54437c0fa2aa7bad394e6162726c7226c0146c0f04db5ff4eb02af590fbc7442dd712cae0a144f67c7cb9b8ce587324db2b38e4b029d32d8275bbd37eb4ad56057b9d453b2f498bb735507a7f213b0339e1340f02287afc16e97ecb6d56457c06011efc9d77343e1258798c4845813f088124d8c1afef205736575258a5ef47735ed97e4236d1dcf635cb5d0ca20d8a34fe6c7f3428040e85405a5f994e2da59280b9b43bc9cdfdcece8d8ef2c09aad03f789de07aeb81e1e5e0fb4e9168498e3df04f45e58571b44c2790fdaa6cadb9b4cff5ce4dda449f582d321fef2176af4463c621cafdae0db7217677350914b309eb104358b7161d4e99c1c1b792c9d7a5dfabc040c4d305b3021854701a7013b769ed41990a5e62aab921bf53afbbc0bbd8cf2d0763876d34e0f5337c2eb5f2eef46be1e033779fd78f20b94b302347631940578f436058d7d29a140c89d95335b90cb0ef249dca5e7fac9301f389c5546595a8ebc3c16219f6fb0451a7b3357caf758b33bec03bf64f5b30bc37b01d083340ebe31080a1d383002db187d1998d83db585c295435b7a922a3dbb10a72e8bf97fdb810179a9c407947ab4cd40154e9b07c491fdd25566db0001afbc611ef434d01518433729af292d147baf560a2d7e16893a25fc4cfeb72b85621c9a47a5850778cf9aed97d9e0856568b5d178666819d6720d62b568cb20ef3ca187a1a4ff4604ca0afb5081664797ab2bd03151a4d667e869cee1e2153b0cbcddae958979be2f710192e3951a50241c2c5deedf13d6b7e8cfc011b405776a4e4e9f0f20000000000000000000000000000000000000710181d232a2f396eed439ac404ef903583de5d27cb4e8918467d21cacdf4c45098e3b073ba486504c4346807b52c21f2ac1edf0fe5a875bf9baee23105b64e864cb3d6dd7a15b3841d96aa5d416c2d60625533ceb64a207ec4434a9993b0e282a7aab3c30f48099bfbad73c53758d91c09b238baca0881c5498cb36573a30ab6e41690ca5310b0e7a5db31e8166f2083ff4c296ca7a26a51d51dd3273fa0332e451e898a77d559c70755fba6377a59919885db14b2d95e54a25c64b46a01a555c038e3417511404fc9157456690882ca9a014cdd47bfd68f349f67d18ca4f3072258c6ec0f9f50556a445558ef3d4b13cf2c7f3955d5562ecd15501c132a174179018daadf89406b667bc0d6c4abc9f353197ffabefa007faedcc4a530fbf08bd3c0e37d73df1d2dbedaf91495fff3525abf6626480a0a1de548aca44861e885bc130a3093dd3d234b3ecd991e95952d23339adbc2b56028ea38db88461449486a65a8eb77eb8d471620c0ebdbc5e95dc6c374a338ad6a579c37e1f1eb320de866aa123df141f19c29c9f76b5cb3fd1e88e653bcc356b1f9250d54602300631c40620d4ac127591e8a1bf41f37d975e1ef765443eb0012feb60fbabcfac4f09360d0e1513c2bec5a8fb2f888517def9ed8708203e600a575eec78d62b42b834fbd44b654be0d5dd821e4c76a3e9277d8ddf4523b769bf1ccad00bf79ca87364ab3510c1e0a59686776657273696f6e046a6672616d655f7479706567636f6e6e6563746b636f6e6e6563745f6b6579590c2e342f3c94fac1c508009c50665fe3916103a28b7a4ef2ea518e2e6c5f25a8f065001a4ad4869024048934f3b65148ac7891c584fb34364a085f55b200c7f15906a5d2646719b8d64892a8d7ff68ca1000918a22d05e7fac18dd94fed80b458499e5b2dba450171d414f2029fbca0524bcc87a2cb8cbb989eb61b4ce33a55015fd134d472559dff8d425004bf140d88e0a4679a815c3f7ce87f40ec03d8e286bc580e3dc08a0eabf0c3b8e1618f347bb9dcd2a4bebb8652c88b73b16405c1fd1f2649d605d1ef51300c66ebe9eedebd52507d954dd16baee09ffec44eca2fa984967ac642dec154cf0b60e8191974032dd2e23c37db36a787ed137db7081eaf6f1b3fa3bbad926ee8cc0871b57b7b4ec0cbfde6ac8d2c86e838836613e554cc79dd69a1e91fce49658967e54c5d6e59886d5bc808d9004672043b2c345cf015b8534f31d2537de1694cc991fe0048fbb0b971fe6de6d196ef51bba991365a0dec472007aba0996f599bfc974c405050a4b80a130182445c0c5b35f367ae4ee8d59fcd5bc17ad8ecf44bf0cbaf6da18e706eadf99bd24e5c38f8a6b0d88db942b90ce8607ee2d49ff521ca7db7e9ef5ae9b9dca1bed5dec8b171498dc62e6a1b177012edde5f13bad0f0d727a8d0d839eabb1755938d8222f5e06d5500bd7bf857c8df62c0b866cd2b1e990a4199c989c44c265cece52b20e16f22d037eb74a7535c0730f28135f858c3863d84d224876b146308a277b53e7553a695b575d4f489482f82eda8d0812f35b0d55cdd873b3e2d14066e09f38eeadbed2a2c490290ec633dd36a663013ca0a2fae3690079ef770fdadd3b6a6c3e6532d8d862f19bc569bdc3d2f92b28f0b7185e1345b3717046ff29ff53a97f19d1b45a602336f952b9fb9f8eaec5ad0b83fa292e0d3cb0fd4e718fd1be210b1ab47628c6de14e8eb01cbe9b11cdcdb902e159f848085568f25de893baf244e9f4432d2932a84220f491506b6266539f32b85ab347455e8902c64ba19b07903b863a5f6d6d550606c2a568dce25239db0ff05b6f6a1dc40c17f4c3e2558f38351e8331ad22d745046fad3f14209664c062d6fe9cfcbd86fad18d33508a52cdbb5ce5a3e6a3625c2d5e70b38137bf4eae5b131889fe0100e60d978a6957076477fcb5853908d9c6fed154d145a5e3e221bcf4cd781788d772ca53f130026b82ee8730acb2e80bdebcb14afb3cfaaf3072a72d44e6a564c2ae18e3eef2f9cb28a25b53d330bdcdf0bc6db7d30bb6d8cf84b900e252a97f3e8dffd475ecb062ceafd0dc7854b9a71e2387a118f285c8c465431489fd9d6534819312a35cf5b52ebea08347bb549dd3bcd26b0b4040739d5ecb18992b823f0825477821b8848cb79d2b54a757f5e5f6b082f9c7bf4ce569cabe1af2fe466b21de2e0e22ca67857476e64da0d6744600112923d96a816078152a93973c471f1deef4c9f9dfaedf7a2d1b6ea4a09eff4887d48e9abd1b45b12f13ccd20a4a3393abf5a97256b68f9efc5b85248ff6c6afedfcfeded05a6bfcff7ad1d57d7a030a20ad008b31d61f01314c707a80011fc82a5abc93cd014effbb9147766a9daabae21bc7e6b8bac9cd8cbcc145e16cf1855bbf4f2cb5448f220b55e42d6322e8b46dc4c94395a1b8839e642fbd808d95c471354a55c5d55599abd4762588210f7c6d42ff6e0a57d323c3517e426465f9e7bb25ff442d68a1e61a47156e89da5310631d6e4467bcbdb085dbd9c3a1c82d179b963ff245bdda4ce09f78de982d0c0dbe7cdb62e47204fbb50aa2da7f93e2592fcdb38d260269ea9e63891a270fa66d49b16892ee6b997bc9bbe3dbc0c5d09fc7a1c7410ab0e71ea55b7af6582c6701666e6732e7aec2972e080054e92cecaecfb9d43c6aa64dd79e60b8c16f134b8f665d82e3ecf5276f645fc9ea50cf14607d236e2fe0f840f85592af169a9cb22d2a9b6674e897835451fd2d890f9b7db07b747608727b962d45f00facbb0951f6f5c92c0973c8bef884040e2b7f075b24fd671fea79b2cd69f6b634d476a2a033a139d648f5b6e50e25f69c7980d53324a47e6b5e85e1665bdb50c150acd19a497633f34903b5d9c5d1d6982935c862c166379fa29292b06c82a07375cf6a36f3484cdef18f27328f8af8c3856061df244a267751277ddff5f8c841093d47e132f04a92dd2c9b04f99b1c3ba20f9855b172f4ca3efabcd8325cec59866ad01eedf0b8a978e4edc772dbe019e7c113eb52c4d65523311ec6b2e739766562a2f5bf6ea6510a4b6202f8230575b834239e29915fcc35af2e0d92f74e6fab6728ce4c5ca8be35f17eb6515b3e759730625df31d04b835d431c2592d0932029a0c61514bb1dce11273c0793298a2d9a53f760d8712aa1cc103babdd79f0634f684432d6f32876dbf0c1f3e6440042777c070b49ad353504814837a77028c8b3af05291553a92490e70d3498618a0827fae2a77f137b31b511b6b8678b9c3d06235c0c83522bc62774047a5c32a6c66de9106d827d16d6c9472704587537d9e51dc0ec548fa03cd863cc03d586ff3235034442b50fed24e27bd029e74551236b1266ff521de73f9d8c5bef45b90bec6343da567ae6f0a9c9a2fb2e92ab4cf3807dbc6254bf2fc57c4cbaca88aeee8f1d4756e21769428fb4f1e576d8b81d774bc2a43838bc6c1e0c7bfa746afad36032067f4c19de86f51cfcf3ce642dcf0efed305bad79a216b94b0f0e79f980f6abbc5f03f75775d5a92e3ffdcb4d01edb9d2088c27120fd3cfaac8669c6c112934bbb6a72f4a89aedaa5ed126d1b997f5f51874c75954f340482c3cb5d6583226614f464d8158a1ca208e95eb2f8626cf17f7e0f8a457a7f9434c51541a0c1f19fc3915a3a2ae9d880bd41901d0e9e5d515d99e8967d480a5fbc117991242cf3a96b04cf06040ee06ab0a40294af1aa00ec8e14976a0746b466cb66b5b80ae755f5ca5b607dd9ccd84c6c63ffef7022a4d005122cb3499306b3a52dd3843bf03a8658ef08f398ad1cf5e8874a8faacac6600c8905aa7586683dd654eae4a7baef78057067e21efc869d8d181f9d6afe578f92c3282e5bf76dc4754346201cbdb27539122ff0a09d82c7d4c5f8873c55a4519ebae724cad082581425db2dd7eaeeadd0373d8fe544f2299cf5b1c16183f2b2b46c8f1c602c346f02d98eb2d2c40e99af390b30150b0103ec08e6cd75686505affc62585fe24655eed9da762fbc7e40b7993a8deee4bff060f57c61187a9381de40b9dcf31506b54229ead7499cac2a3192989b14a140e2f87e307124eb3c6f7d0a2d49cbb97ede9ec3784b9b392c3c8859278ae2e36f1bf14d5f28a5a5316a5563ca0171ca21b9a3e0b49c347ae064d3ba76b861cdabc9ed2390729484b2328cd38ebdaf9fa616163b00d12596fb7cc40929c2f04af07e7655e78a44b833e8f1b262249f191e76f38776c4385cbaf0917d5c68f716bbf1d147fb400156ca5742ac9c62978d6a7374cc72d23a94f44e2eed3825102993930f7723ea9a0cc4aab99c7aae1c6084ced5aa87577fd448175d0fcd001d9cfac2c8c5385385814c6b3377a35fe4679db976da372e15e293fff528a88f395b017832088d5ab31f75a772ae75e1dc7f9cf97b97d01cbd0805603a14e9d2b9173082020a0282020100a06386dd30087077c960933577f92723b50149e88b495efb3065c39fcfb13b25f4a0e0b8e140065d6e26a248e557c19ac136a37cf2ccf93b5d4669a79ba12c29595c6b6779e9ffc1dd6acd82510351d281753c164f3060e043036b3bcae7243a375e2e125707ed95216c1b722139470ac3c63d5767b1a4376b1e2b099dc4b274b207eeb4effe0bac120005440cc43700118b6d73e0522724fa8599e174f9f7fe2c3dd0c0ef2fa142d52e5be3f294ddb8296c0d16fe202a5ddcc625681d376bcbabdaaccfefa197e41f71110dab684679ece5b4eaa17fbe50fb6e65887a0265fb34ae888fe2eb883ba17058450e6634a05f93a3870b2e7ed8a18a20ed4ea91fbcd526e024cd0ba75180aada8573aa19ad5f11e18efd6485865ea91238407df544fa3f0d93e750ecb4e9c13a81311189c80787b69ad7cff7e8e42697b4131c8bc8d2cb727cfe9636bcc6932990b6f2ef854260a3683a36cd3514735cc869b6a409b430ddcf78663be2a00a04c337226899b45bef1b5ac2a37b84b57f26b075828a4e482f64debe612f586483bf2aed62ae4e612119e4b0d0cef82229aeecf4e625d073ca0f2774a7c514f6181592d03872292b4163308cb8dd3597cc3ebcd728023bc07a99badbe6b475428c67ed1dfdcf54cd228d1198e73e8998ae8f3d8a3589fda93ac3630a71c6712eab09fd79b0da0817b2fe63dd0a16fc555b047d1b461102030100016c6361706162696c6974696573056c6964656e746974795f6b6579590c2e3f006260707566be9c3854879fff53432adc304884c31763b2b0dbe2ee37dc873d3eca6d7e9cceab9b3ef3dc05ae70dfcb69f427e1d4fa3bcb3fe6f3a6bd6dcbf3e15666ee0fdd2437f7616f1a825df8890eaab2eef4d9ce1bae957697e02d3cf00e7990598b78267d644ffea7054a7376b6b9c15c69313e606df0e2dbb88d9dc1b379414f8d8a8ecc0e728ca7b3f55cd347d0dee1605fa6e78e2effb292da77b321345bb34ce2d4b26f1196da7c5d449281945ed838f924ecc9258b0a0e943d618fe2a3b0fe5e46b5a388565504f802c11f4c8b316d402fa89735da26fc52258ebc0dc38c9586b606d5ee4fbf78c1fab4f159fe985546429245fed32f90e72da7ec8afd1100c34788bf39088d8c1f88bfbdcbc5ee695bbe3298219bda6da7ea970750a2cd6ab69670c43a966969075013c80e952a41a0ab8903c69ec3e105ba8cda7da56c903d6d3c4d88a8ca87fca83884529867669344476dd2425be197bbb3529f3eabb17e5042870fedfbe5dced2465e043545df27cf7e14db6d6cc355aec9d5ee48c0b32214e513c1a34a1b03d996138f76ddce61d6d9a215884160eea8dbe994a4f7a5c62ab2373ebc4619251bab6544840308c728308a00c8bbf16a07afd77e415bb2c90f8ac283312c4fbf74cc02c820fd4e04ab9ad2e1e7c05ffa2b694c37a32a147454664f08ba6fa664ef3448004a6b06ce59ae26721e6dcd25ecf3e7515e2ab7a05cab0241a584f37738c99ffed1a5f475bb040acb30c8674a4edae88dcc841105574a4101038381223d77ff11814523d085a14beb56af185ef1a8c5f7bd9715b582cb370d16fcf376862c331df8a0ba660dce5b0a71a6282c8d3ed97806e52cbb851d32620c37a92dd076c5f43e5b81744c2d08b9d6224205b8ba7e98ebea00ff9724703e6e5c0c746488e64b78768befd9e563658c0b327b956d9be2302f5058bcc1c971179da673dd5789574faae6754c95a5cc86ef219f2eaec60c6dd2c6da4bb6f4ac25406a569276801feea4cd76be99b6681753ac5d1834151b3ee77fd91cfd2227aa46c3b60e8339381ead82d4f4ecc3483aa53145bff5c0ac9e08de875b26ca28e91b69324a10863476a61e291ae0a51aa6f7fab95459a855a06a04ccdd2b7a6eb38163b0ad0bcc8932432eb7701da2a9c354089e46c73e80dcf2a85f09266e0f90c16b56e2de961906f63fe11110fe9ee6c097df30a9d40f6e7b56d1fbf20235f78d3a24e9f17c291182931b71785f0ad6165bd9e27f0030170519eacb78bf40838642e4d389eeae900c67d49f388f4faa1a72d581934b483765ee9e5d358b1c2cc3a35f34af3a29b24c884b04eafff703c38f6019d1f2ff541dbbd761fa768f4c3138dc0a5760a35426b92cf19e891fcd74d7ac1be907a54f1d1f805bff90a05beb2db90a9589e50524924b298ad59e2273b3ab70bff2a2064d49baf9eb8f8ce8cf47ebd8baf5ecb295fdebbdc29e403659be2e6bfc38f6fc580ce92c15d79e35d78ffc487ed26e4caf61fe7ccb79a73a2daecc9b411ceeec8de967271442f78700bd249d7c85b28107e668def2a27e57d8950b5f671afd4515b0fc101be080547f956b43069f12c3dff271c770f3f6f4579af246c5a0371f75c3ccec2de410c7a3b111356898044273db8fcbd25ff27f3b30a74662fab68f9664063fdc2eb8fc588d33628939b42a23c21db78144534152d2ce9c3995754447110157429c7e738f9dec8135f60aa302624ba6bc255f4af640c6e36b9bd20f9049e584648b51a9887b44aaa6f44f2b7f34c7561f777b91fefe74ddde2819d5cc09baf7613a163d4bccb9c2518d7bb5a86c7bb00ac3656f0c887e0c812148add52ce397aa57517b46db678a36e4fa1d6030147107c6d13145cf3824db69499f16fdda28a2b350768a73977a60077f5e92231d765138af65597cddc94e9412b65cc41f14b808bfa17292e814f5bafa4f60e6ed4cc252f0e3a8e3d09de511d7e3a57645cdb89b72d61b8763cb077443c3b0183d5500c921c0fbcca3df79a62956374ed187e8aab77797ba158d6669de79a919acb1959f871df02f03c19e224dccacc6743140a8f580364bede390e287c1985f6cdd66031a92ee01c8cd3faf0bd151dd64579417e8a0aeaf27a6659cec67a7cffa97262bd229415272b2d68625c2247d21dc4300a25c193784a072c94512378b457982485d2e919cd0a9386dfe0520e5eba5c64c424c844da2dee10c9bfa92b35f6ecd151b9d9b0e8f3ee1cbabee22256b1666df173a129169913aa22efa53ec0b635fa34cae3bd96b8d2a817ddf69797c8cd3551e573a13036d15852d117843f19eb5d56411bf346464a36feaa629b7f630711a70f97b59838efed56f167a53b053628e30b72edff0a3a704a36f86b61da2954806723c402270500bd63fa6b31875dc292d0adadedef438900311a4efa6402811ed67ce195bb1b8d9339eb6d994217b134cadf245da1b7560bc4c10588edfe36c0a7a9762caceed3a33c90679fd83002261a437010242d02b2605796457dc81f8ce994dc38587ade8a12290aff351946775bb483c67f102c0ba96ade701f022e3064e2b022e663ccc4410085a5621a31dad8dd20bf18d5a5b74c63c153de819ac8d9397d99c3817f37067e3d02e8678aa213c044458f9b97976d1b8ede6b3d8e095c45dc4564bdee2ac0bc1a980ccb34dbf80759a67e629fa03b0da37494ee2763ade521a9e68c92b28310486d0c730e9c075bca9949c095bf7a98cc643c990eae03395c3b88a6f443bd4a36141f0fa33c898951c0c501c6028e22cb8f04e43d720a0ec922b5b629c388008670b7716eff7fd27a78c8da5fb6a04ddbd9d0a9cd5d6434a8286f33d076171a1624ec7bad64d8960286f62284aa70c6dabe759cc82136bfb285f08a38d2c0f5ab1a8dfd11d241d76ad4da258238d4379d3e8b47eb3796996385fa701ad36a45647f236f1391cdcb3fce6e72def84fe0a88b83cba75a928fe0d5e9411f311fe9fb50abdff88757e42c8d2e87884198c9cc836a16d7f9e3172389a71b5bf1bd6266bfcde6547b460ce64eaf4984ac898dfc904748b3b660f9f486fb462e8e2ac59e1e9251d99e2ad4027fe52a3fcefb3ca7c2d56792db46b418dfbfc7e1acbb108191ef51cc4bde743924d6648dd6f05fb808de61f30772e87de2a01eae10bfc416c09469234a966a4e7b5f70e7b13005e4b55e65397632785db4357f214d2dd5eadfc03b09209fa2d0d850603ba15c51e1c5bf79ec8707fb3e98eba06e8f7099e23114001454309c8aa7dcfab44b9b4e80330dfc6c5e91bee85840ba6698a95c11dd49129a7b5f73e26c54c747d33beae17f4a766b66162206c244e82aefa3f7b8534824692795608626d41ebbb6e1b3cd7fab2f5c82d89f36dedb922b0bf7b266be9944cfc519c3f8714ad81270a0194014658c1409dcca0a8f369e68425295e9b7f0d1fc405aecc1cddfcff40deec31a7113c35efcf11d2608f254163ce73e96270d1cef8972fc94b4d8c7c11299829a4bc0c12a174a4690e9a4c27a95988e39783affe20100898b33de315902e4668484d31c2613f10b860aace72dfa25d42cb99837a04ba013092b2617dcfdb6753a11e7ffca72652dfe76e9fabec718967a9789bab5df76b16881873082020a0282020100b46ec378fde10840198ddc0e0da48f8cecfa6b3a0711d43e394aabc108eb5738c0ee30d09d355de194026bc3f157da4dd70f942143f429c494c0699143e55a58106b9cd40421af0aa4e0b56f736a8d154c39312a5cf638935b929205096a92e7682e091e1d6d212e25f025b50382029644c0106f2ec1eca20c4646ae0b005aaac45f6acd7caa25aec4d133621c605ee95de2ff5e5d70327dd66d477f23f05b9c2f4229678cef7a9cbf478fd73463297ee22f95dbcfa348d897678e12231a52d9f5f915b73e85298bda893aee1493b5ab540c7dda7afdd02901c8677d88f4a667f6342e83e307048c12dfc182a0fd5e766dcc7cbe99766d66e0925ebecbcdfeee4f6c337e558e4d1e9bcad3d954b175ea83fdbff3d5e347b195d4ff40785a825b05267dc776ead4b4923c1b34c4a3a9593b0f531f7a4ba2832486ef2e7dd3f1c36f352f6698d0ed9cb0b55177e5cfbd412353085478efd2d8446406e09e41f209c29dd6200033ba9d02a5a646f99a68aa2df815c9b82db5ff3e3091325a1052a9f1d3d8003a804449feb237c5f6fd99b72014d0d2d02c7f41a295689d00aa37aedbefc6ed3e223b408cff41fae57f0a7a127db4f32f9ccdda29b3774f93f778af99d8815fba26948dde427b27b4a12fdaadb355eea1df66cdb6035096f56067d2381af49aaad15c1793536bedf77856fbf04b4e7bdf67e7c15c9ed3cd832b58e102030100016e636f6e6e6563745f737461747573a26374627358c3a6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f696458200bfcf258e0605d6d00c0a05f8caf44e53ea2674e41901efab7b0b48083a1f97a677369675f616c676f4d4c2d4453412d38372d5053333834696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f6861736858301e23f873fb0cbae2d67ddddf092c3eb1c68f2e17116b3a3552ccf31bec457f1a3e59aeff4dcc81f94c19cf466e18fd36697369676e6174757265591413781362196be51aaef7ef1d1b8bb363b829899eca726d026c0baa4cf7916c35e68d27f3d1712ff494b0a6dacc08711ce351ca932968f2c33e4059535ce737a5eeee9c3203501289324c09f70cb34fa19b6211a4a1fd9fd3077c568276bf9c475d7aed218b76afca43b46bc6976cd2dc7d886a4bfa5d8d2357b5bc4ab3780928c033b07799360407a197a9f1046f1c1a154c8ac9fd6fe61a9ab8ec3fbd8a975eeb1e4371294dce39626bf90d3c658f92fc34886347288ed279ee721babe35bc59edd008549832e2ebfefe0f55ad8a2d0af749fada1da13cd932842fab3cfdd78a36c0c72a90231876aafe8b1a455001e9790a10ea3be86f6a55a2cafde7611ae4d0301b675fad77e6d8f7e69b85aa56c3dfad9f3f99bb4fb4f22adfe7b0ad34075b2e085c1e7b1622238a3e4719a972566dac223858048b00a17c8080296cabb9e929668914cce5281c529be6921fb03cf0ed0ed3165fc788b1e6592887be93ae2600c47de694eaf3f08448d8d1d3acc2b9fcad5d5646198ebb880eb6086ff98e40b05ab46c85c7417069094705b6544859385aac61e6fa05590aabec655c67f06db233a61fc02fd14b5e24c7df8ddfda88708db4d99b831018d7f8edbfcb13a48ed662852369562b5c0a64b2e4884ee63c20e6a0ef51254aa736e859c34d48d53d8cfa55e2db2943ba7008191d37f39fa8f61f8e6b0f869524292e5744393415e44f5bb102aec75931966163f12febd33a0c572d92340bb9dc9b2013d1f9b39c3ec196b681595f9873cdc4bccc41c94bb1e6be34aa71ae7fb9d611938ba0f89fb3bf7537a73081949251ce857912f814fd6c41602a8faa3acc8935f97f37264a2c3e6c309ccde4e7d61da860c94fcd91c17d0ed2932e90cc289f69b65b42de966cbd9af884b5f10db739c7aa27665d16e9fd0603d65b289891b18df8e4d74e63511c054cd1473de9a8f3780ad015840f5ea690b14a5d8922ee4ec85c14065a8455ff3a66a86f2bfa59f11c5cf552c651dc849403cf225c10e5d40979023263c71ee6b69bb8fa4765cd54371f215f08351492d465a4b2386f1f9376847bcda749aa7860c5d25eb680579aa3961ed478f9f59648eb6b5c1e0f6438d3b8fd229184688ddbf5939bd007b22950ad4b17dab77412d9a6588fd2bf566265ad6d1fb46dca3d72fafa8b4103863a85e155c8fa54c9e68c51fe714d8c7ac4b72a39c8231c507028d48141690cbce07528ba5d91b9e8aac05d4b9c87bfe5af1dc4eedd3ae42c23b696cf2b3456b3a76b6e5b15c764048c0d6ae85551ea807bf42c85a26bec1e64bb13975f91646c92088ae0e3808e507a8d17ad414d83f399285ce9cd864f66524d8c19b22ba0227708471f245a2ad874da9530e6fcc19c6a5eb858c366d53c440d55a89149453bdfc851dcd8fd45b0efefcda4bca9c95901786ac2556450da6a2b70e9fd2cec2f71395a92e541beabdfd2be4334cfa24adba8980d4dfb162943d408aabae91a7cea048d4c4801e25c5bd59baa3e353d5658aaad5e368fc441e1185dbbee225c1403053bbb9f050caf638f846c25445a8dcc286826a74ca595844bee83355728fb537c7af0dac7259f8f6b950e912b85da148aac0ea231ebffdcc9ebe9bd955411dd313f628de506c2b1cabab44bae3f9a14661b87a4093045548c907a6f616747673e73594ad170224b835184eb61457e512801ec20b283af1126a56748c4989b1d2bedabc282db52fe50ff9909d5fa66d01c7ae66ace1b62dc563f7a5f2d86e589d9c68ee0dd5c9caeaf3e2c7d05e0871db1f4748c6f26b024abde8afb247dc0455341575b264b89213724cc1bf57e7cc4c2de8b2b2a1706f364f846a53ff560c55bd2c41ec8544d223e2f010f430b5e3c90e1b6c5ec26dc17ff455095c5fbe68890da86ec91f57f621d841f0c0f7eb6e974bde6f453b184a1ec7b5119e322643dbb06bba65b0ae321138d421badecc0dc1e395511285751a006dc936de7314ad5943b19eef7b1efd5d2c64ea9141265fed4f77602a964ce2799a5dcd39c036bbf35daa31fe7e6c6514a3e19e7d6e454265a11717ed14c3ce35f8c7cda4043b0161e1e98ecd1964bb5246e2cacf6fb5ce5a65fa539714966fbe5710db641874c8c4f404d0ed260a7f369887ee177bd8200f4d7c5c358d4e036b4df68880e3f7d9251c54484c09f955f177b3ad69ca6306e4a257a31aff2e08c0cc9fdcf7c0c8bfa286789b4922f71138f6227e2a4a157630b65589337871b02047681ca82813c16f4fb7317a3a06c0fd9bd59dfbf0e0c1a55ef63df2c7e400ac1058730cbce97ec8c79e16d733a3407034c6deeaf612d4ebd7aec59b522d4fab3ca33a735249d4f28822b5efa046ec5cae032760d97da7a00db6d743289ae9b3a8712e78d7755950ca09b8ca8ef37c6c570a48af29e50698e1c125a39ae3ed2f0d0dd646a3b9f9b33a90b70f32fad2e0cd45372b3c8c51fc4df65fb2919441709ed962fe8c4b3418679667cd4b308211e2b7860b95a22186bac94ea002440b5a3709adefad58820f2d9eae8d0f76a872d411160f4d5b65fb2007acdf70701b22254561d8ea8e940e8d94c3d09184c460c5956439ed7644190fb8404400a242fa0b3270fb9be58804b38eb60f81dfd211c73f7f4591562b48d76442986d68acbf9337744fe3c47885947153e332e1b92c3d672c83fddfa73e25322efa0623d9118455b040389cb1158657369d6b466eeb9585dca50c161963dd19f7f9656b0d37db0595474b0ff63ecf6078c7db54da9f34656aa4a4fa28615083fa851d5f07e31880e6145d9dc31144e03ec533becc5c902cea653069f89231b7f016d6fe0a96833eada8f6ffd2d931fffcf5ace7b3ce210e5b20146097705a4329ea1e5eb586645c7bbd7c11468285b133fd316884350fe4caf6bd75f8345c1f61382a312bcfed545c566b5e83a0a71b694334e7842149d5c0f32ff0965ec0af1a0215703a66161606ba4d539265def4e887151da885d392f6109071e13aada0a54484102dc4c39f7796b3249d2f239cd748b10e39edd796035bfb608b319dcdb7b729c88e00b8c7df6dc905ea589df7c34d555328eeaff94d4e059c931529abccbb4f99efd52428c251ad240002f34f3c3a393cf2e6d2312e81e093e6d04e8970175cbd3e3f3a86c5b64cc165ae14bce8a90ce6efb8ef10627b33ea2b1e3fac25a189d628de888cff796a89bbc6b343e1b6acfc981088f2553b412101b23c61efc115cc5fc28ca6a4dd8dacbc5025d7ae8a4c617761e5ba90262d17687ed9188f383309bacf5585d552638f0dd3c2a84b04775e89d9e0e5292b060f68cf86011959ad191af0cf2abad2ce01522307574e8163de13b4f6e5b469426b2f79a61f92439df1687b68bc8dba81becbbf52f2174006c7edfd9e0db1ea3bd228d3464a74801e08deeb380d180a1b3c75f58eebdaf550c92c364364da6583470e529ad413bd514e82669081522de297a39024016e32432d6290496cac00ac04bb2ff84891552d597d59b792577b73e2cae4168ca3640fa8af4175acbe5a35ff979fb31d2ebd17ab240dad785b587e82902bfcb73a9280d27f09fda79c7f929cc2c43434787949187bb2790f1a2441ae61ad809bedd451168b0c56cd197ab0eb48ebeff6b51876722a23b5c7903e32de6e65e7448b8d68a7eb65c292374af350e06d03ba6e078cb7648896a5ba34a64f6735697da7d4db0763f175c4b75e878793f3973e1dd8576c7ddb6b3a0c4fb0ef53528adb568ab210d718a8ece5ede3da2daaf80605b996d9961dc3805b1a818f381d55ae5fad7a4531cac5ec865f14874269c3819ac0f6043af63156fe52b7f5dc688459db6c2e2f0b2b94a772335219d6d537ca8ac46ed7651f8fbe83a38d79168ee49c67bc53d48e3ce4bce4b702012d304a6ae43e5b965adf1d69f8b12a13fe3689418cba03a7ff1d0e07e5b7c311ee2c7080ff99c02b517b2521279130a38fba515c45adb28eac43729fa0dadcf7fcb4f11c36d4e91de35bf3d6cce21531afe32e16b273ae96d8f94c63d69068a746a202c4d6ebb76cb9bac8ada2a6f783eb895f840d8b35eec6d4911d16c29b5da65baf03d0fbadb2c90795610a609eaf947eeac28bd4414a383560a2a3fffd314be8f22ee9b3f8bbc45be42f8258d2952a90a0dc0090b738187843a1549961b953c9f1002899417b64b0bded5f02e909aa7541c221e15377b432d7ca2b4260fb3414366a8ea71490da1870022755243480a3047f7db5ada3add32433ecad87791648ca15dbc26892931ac688bed1092f895bf280e981211b4867256f8e9203bd0dbe1b5ce7a05fcde89b2d4de6dda882971c18c3c2bcbf608f777fdf4d65647569f0815662d98d96542cc6ad7085a86425bfa4cec82770016e5d5eee5a74e546965620abae8824d54c07135cf7bb28d1c6ad9eff08716e2214e1f7a9da07a2e76ec36841fed388287d189141c1666b059b27e8a3685d8e326c1fe0b8a8be066894caa0667e5e449e04f581eafbcecf50bba0ff326049c5f3599b4a29f4dcaa51c872d5a995c3df406bb0343426fb7d3277a90540aa12373c2ee6d75f6670d483178a671e2afc4845225eab7a221b223aa500b99c35e6757f118ce83b5298e55cfcbbf59b545cb28bda9efea6f4e6e4cedd3ede7cbb4b7e658a986c11eaf708859d63552b02a6468fd2923c688099a44eba9af5c14afeb1952d7b85f0859ce5c75a2b999b46ea65dd58617457035ad1c3d2249ccee29a9d72858fdd08b5cde62ac1b1994058783360fbc9a5a3c73c00ad8b5b6628a0c52d8692b1681948a620b7fdfe9c164b56d8c855d4439f67085147e32207f58c7059351b8f393a398187abc493b2475f79ca8c1b03f74fd3cb140e0f839bae3c748047b053ee94e7c15a25e623c998f26eca5e523048aa933b3f7d440877e85448eb62e3cc1ce9248740cc2ada4b54f1f56c456aa6fc02a99281101b02590dc027ef9f3654dd3c8822ac837d394780271b0879a92080e6140fe6475faa88ed9a5054610ccc81929e812c432101787e3acd01248076d06649d54b3bfbc5e2c3e83fdc834d468e6205ab85d13dae313264fe37b71a3bc583ff2f36acdaa05c1e609e9a39529e69f506586b768de21c2a1fbdbfe707045252c1a404300ab7f32a673da4a820cfb2ca82bd362f4aca950c86a973052ea46a36e0f5f8c546c71d5f8dffdb350cd072de1ca3f113b1e970b256fbe4f2085d0237fbb45873af66b4c2bc2bb1c550e88300b2fa2142b13b22fc76b2489a28f1e9c6ef7cb65f5883a885bb4041156371f4ce65373eaea244bd10174a0db2046400d62c2642fc521d422069c426ca7a9385689f76b18d36886f56fe318415c10ddfc06409fa7755376d3262cd16c545648db197125f0a030250a5831e7f35206f3d0b1526b6fc385425ab83c02fdaa73b73319a4e593d7abb8be18da3f3c015f4d10a2b37592b5de0214c8c7338958a8773d12f208f44714865cfae38ebef71ce53a4a8dd38e4466e30171c608c53fdbf8729fc0828025bbb897911f0a82f719d10113aeb7b816c7e3e7a8c20fc56c466aa4308b74a77ed13416722e8176b7c0558366bdcbb93c828ddedbdabf40d66b5d2e32c3df8b1fd134e40e6959f9cb8d1fd2fb4496abdcb53e91c7bfc947d127571607676cae57e98190e31a1c4db0002ff6b3785d6b41d5d6a50a96d1dd94600a896401b4e0ade8296d910d0b39799661b6b2788e0d055a5ab06212a864a0c67af9d9a2e0a9e1fa1453b1951c454c6407b85ea1badd38dc7c20a96970a9815c7ebf6e282b631cc43b1e23606c5061e4b927ae747796dad4ebad24be3b8d54b0b83bd48ff20c76043ef4a211405df9b79df108fd9651d386a0f784b5c12fc6765c5733f0f4137ee71153bc76b5b9e3fe78ae162cf6810ddfdd164c0dfd34b5d1b109be6ae551480aa99f26029b42bf50a6dd15e994d70a3a172c45806ea4057397c75cb0b9328f1655f289c312716700b52ecc789b3a5f4de8c1b08148f90435b9bd1cbc18a3c7e8972e1f6a6224f143b4f7c84e6ee0d34078ce913c71dad57911f2faa81fa43cf4ed73067571194a4b96b93aa62acb11a43cceb637d7153239b50ed0f4692a8d259a893b53172348fe40bd3c836308bec33d612203b5cec9f355c51ab72a741e539f8ab34b23f2a86533fa1a6a873d8821bc56413c2a7d3bc5986f724cf16aafa203e26f16d0d9f2399517b04e1fb5cab575ee1d66f35287901f20abb11453c7e37989343cd6e64e0f8376f049ab94ba4c6b85dd4ddfcc53bcedf5ba1305d7fb867e2b606a6f58e88629fcf5d5501877984820c838f4e55badb224ec4e79907c89199808854de017a91fcade0bf9e7de6f89e480115a2d83c93564d2e6f9e534ccf0544bc9387b91f63a6ccd62a323a484e50e60e115aa3c4e0e2091323abcb12234c590933344b65b5bfd9ff565e5f6e7ea7a9c4d7fafe189dafc0eaff000000000000000000000000000000000000000000050c13181c253036a65d20b8c5f68704fabf072cdde757b28f867dff977ccc668f49fb45a809eb11d86e49421ea8c08417e1db5706c6fef22af0d03b66aa1f9ca7a837af9c61b0409243d719ff1376c581f3c582e26494cf13fe68b736ec04476fdef859c3cf78d1b5838068b9f4f0a84999f87895d1a6be00a12c8062595d35c6961be6b681bb11bfcc1e58a7e24fa5afacb38e983b3aae6455d1a7828564da1b8835c40e1b322fe5f5f7dd6977e571ef479693f4c39f559f9c512c36364db9258260412d1777aee36c81576bf73e8b1a27ec5133e607e55164e81d04c8ffd66a36721067b51ef784159762e47c1315e69d73c3ecbd46a25d0e8cd90d07c7a1aa0bc0cacca85d0df6adf2242729efa08390c86f3b26faddad52876055783f89b808344b28b4b208fee637e69fb5dd727c4219f56210cea9ea84b36e5bb3bd82b1fb57841a441e4ce07d612786572d1e54ec3b2e14b0d2ed2fe050c86c7fbc45e2654171b179cb0a1eac6dd722e6765277963c1287e72e3398b1808eccbb6ffa3e28d2d20b6ecae51e7068ebc16feb605cdbb216e58f765abf5eb7191022a05f9b895cb1c32b4f971c89e1cf987ffa895fecefc5237795f8fdccec6a12f2e7eab4755f3754c64c6ec11b6b05fa0b5e61f8ecd4aa2db1d6b13a1690b2522832ad9962f4cb6cc15b31332f565a002edaeaef660a15cffe2f0d3a48c9804dc08bccf13e3e476f9193da6f636f6e6e6563745f62696e64696e67a263746273590106a96375736567636f6e6e656374656c6162656c781c4d4143554c412d50512d42494e44494e472d434f4e4e4543542d5631676e6f64655f696458200bfcf258e0605d6d00c0a05f8caf44e53ea2674e41901efab7b0b48083a1f97a677369675f616c676f4d4c2d4453412d38372d505333383468686173685f616c67675348412d333834696e6f745f61667465721b000001a08ddbfe006a62696e64696e675f69645005bd2508eb8ea1ea6fd96a45da15849f6a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f6861736858303fc23b4923d093b05ed521ef378f89b6902a91f052aabeeb9ad63f7aeceb0dc8ad046c7f7acf3db70809c4946b97526d697369676e6174757265591413744582a82637d9063eba827089d6209d914c3bfa9f55281abdf5789e127f4da8e080064accc07d18ed29bd072e129387dbcae711dbf73407655e592df250f6734787576db751e42e11b05d22e8c9e36c98145eb05c2c11e7728fdd8bf1df7c08eb14a8b4d157e062a5ad23d2f96d962071d686a84b8ea873e0cf2d522184c60cb44df69bf8eb70963c862662b076a53521099254096e8fff0d0b6d195db9abfe4cbbfc6c627cb4b74162ba1b1f845d8268dbda2df704bf31acfd8a5950168e33f612c57f49bf3dbef19766a4daadb3911d95e54d6e93ab189b9bab50688fa5176b6dc2cfea3b1185dfb751aa2fb4149ce20602ba9f4431d406df40ffd550ccafc83ec9dcf286cb97fcb7267c2d060aa3cfd223bce3a467403fe51c92a21471041b7a2c9f230b90b1549db06cd9d2fd31dedb6791337d3a68f4f012a0e573413d147229cc1200b7bb4343152eb0e4c8a536ee3ba47c9e1de15f812dd372373d4c5456c9d54976438fde913228cf2bf55cc7578cde01c55a441e51728a2391488d68c6f961238773e8cfdcee2caf72bb73de69e609c936988ba3285c0b3b2b20572ccf829d6bf5575c2ec84908501cc742f38afed17c8319859dda5a393d2759d5d0a49bf60c1bed701a0b821a835514b55f9d5c387f290840b6d12870f00a02823c31ba0668e1c2982aab4efc0a81afadf7688644992e8033b671cf01a6c344acdc41a87bc5c3bcfc0d09f5503a8742f2bc704e60e5fb7c47c708f6a1fffccfd127443a80ac90996fbe862835afa190778dcfa447ef34d91518330e79c3a812b05b781962a8073c3788f100a805d642a6b01c0cfb8eb10b5033777f3d0b325ad9f91652d3de407458bfe60d1142095eb4e07fa4989efda5799986d82331349c06c7dd640a8436b66555666fbaf7c858100e15abb42dcda17f0285e1e59fc99f77485ea62094b49546b6b2475fa02bdc7fff64f0f553a7074abcd1c0b4464f075c8419aa6980476f23b019e60e02d40a85aada5ccda257872a84771d1c7e07df5186bf267ccdf518e0b2e8e569e913a7494504950e8564f2e2e1772648338b2f00c97c5d3147bb54855295cbc765e399e09f7125bd9ce2c69e9ad21ed757e74f70b05aed9596e59d4fc3685cc675f2416a76880dac4f99eca2881e8e18bae6e17f3b8dd08bd866d017f6f617bc888c9b71cb2716c064487c128cb9fa807f6d950175dbde0b84a16c9c1e2189679d502cdcee26d67e4a023fd715ac042255add568db5ac24fd9b326f0044eb521741a62a3c08a8ecfea8c90d6f194b2652620728a7dc6f92b88fddb0f3ff159690719474c3367f01d8c33144fcee87c2b84b23692cdc9d859a6b2c6dc3212d968f654168baee3e9d3ce904fc3f1cca787bc74459cf718a1f838b6408fc2f5d2e70ec7445221821785b364e9c994dbc81956f9b6bdaefb0cec6995a3a8818d88f602a289fba8c5c4b2ef63e0bb99e4c51ecfe3b9d263f23a45bc2144c76f74cded70b922d2de1d518716562e0a0f2aae04a1c69418d1fd01345e40336c62250cc7ba92e6ee42f8045a7a8ef2d3d30351f793a02a32ee2ee6cd3142f62e6d9bb1fa134f2aa449b59eb5d45754e39457cde4fa85203ace6559a9da44957cba58cc44f0341eca7fec791cea556899b10d6c24b043bcd891f0bd9f3f5bb5561ec03b5212fe455cf073d5bf1351a63799772d0e1b8116fc76259bfe5be0ee07da56cdc835dc809caa846a5a24e5f88aed167955dda04ee6261cef9b232f2ec288fee4bad10bd5e51656827aaf5cfd41969c66d9083011b218ae016b0db439969391e285368aa7e9633c6dc23db8ee64ede5b69eb1c305d2b81f51d46723455eb76104147cbd720b3cd8ec00a51777d0942185cbbcab0b3aeeccb7993da36e9fa53390a84889c66cab27dc8dc6e4375a01f88b4d5872064da14618d864ad7e70573af110c7e9d625c811a4827ba223afff0d43e8a48eb1796dfcc273f3ee590f2d1ea16a6cbd8660694ea2966bcbe746d964b9371ef46e5823a1e0bd1fae7139f9abf36d2d7f5eb270908061826803090525889db71563e3c3be57656f85fbcfa541520dbbb64b8836dfbe1ed109bdd4f4458a21b7267b4ed595a8018e8f0736db6be6e874573e13238f546224e700a034954879d3e1d5d0518155c94b95c233c7f07cbac5bf66d824eb853cda4e83fc0a82dddc9f8a4c126028e825df9c920dde37ce5266c9fa94b7406dd3eb7c59c6b1310d4ce44b82b24b43345f6d884958e9969124cb3d27dd2a4cd7f9b4bd610727c50d7fe34c2ac01e77b3fe451cf3455fed4e4f090aff1978bd97243d0890c50e81df0237555686000c8001c86bc1ada2a741e779fb90d89dab139bd94ea61f80de4fbce1e9b791111b9c3cf2b70693d2034288661aa753a5a860971b76b65125e95d0429208fb77954345c29a54a61cfe7442612166063ad043a624ed27912248d03b81e2eb5724360ef25393cb8aff94470dc555f67c66b1fb0a9cffa5a3ccc2af9c67f69cfa0f0d938171100702b5a676836a050ec3f6e53537864bbf04afe54d45db2ea3f70ea8ee2499f2a0c2c8ce16eae6a16a49c948fa65555ca754faa186822072386cc0887612325c6c15549a1896c4b2a3c07295d4bb94ff3f1664ea60d00f47fc2844c756aa4b5e6da3373317a4d4bb47cad0c2a7f7822ca6f596ffb1b13fad2d06e93614df55f744fbec9da1b950dcdcb08e1684f918ff39f3000ee567f2181b8715093917fab910e8eef4b64627155b6530776263e1124a41e858f8e98c4851a6688cbed915f30ff5cb20cbc93746a9406382ccf6f67bfae04fb110946d0f4c6fd9d3ab0b9282074d05129855cc524063db6542374776dc557df8ba1cb5cb0892ca4088eef968f051a6b7ba28135ff2a2418d6874be8a0d572240f30b9f386e998a595dacef85228801bd99d9f824fc644cc42dd056afd8a19befd60e2e60f3d0b445a12abe12d3cff88a0a5f108d5c558ca94acaea3c9577c81976708d0a4de53dbd961c3f83925d76a8eaedb776f2dffc061f60cab47479bed80e6aff6656e4760336aca55a822110b1ee8337b098e96bc6824c6d7e06477b066625980a4b0f1375e8f79e29c434c751443f7c7fa53520ae948ee4b3bed8550ba24156728cff0049faa40504f6162c3a4192b722d620369388585dc75b6309536c3092147693328f3c64abbaaf420243d592c52cbfa4e93a07561f112129d44b661a271ad27b9b6840629d4fa3d0a6c63191aa3e3d2c30c09e3827d5a57312a5744bf775b2d832a9183941ab666dc29f0e039d6aaf8eb65f9236362b3c958f4aadd9597d5833d1195ed0dc0aa9d18615a02c95f69d54072413236caae5f920c6649ecd4c89da95c848eaccedf094a82360a15418e29c435b94fff90fd4e196016447e9cce92b81b94fe4735a472d1d014f9a001b7d195c0ee62d4f0a06d2d0ce073b3cba3169e069e24009bd71b1ced0ee6b8867077a5f3fb15891652e67e199cf78ad3d57193d5dc82f88cd370cae8c91ec9af657b17883ae1c16665fd3b2f488d1717f9528db1f098d6b3dfcf797e5838abe1ef4ee86bb3f1b04de57505561e45d8855ac1730f74336c566ff9ad52eeebb292945485703e3ef6b18ad3eb84be59de2b3ddc9913079d84172ce51acdf88c6c47f780dd12cdc0debd83e417379371be9e275065e0b49928921a7110b90e12e62505b54143015edc11cea0825b092d5647fc207f95a89106316c66de0ac2957383c461273f0e42ccdb9ce07022f8bb9be3f2523803fc3a11494484c03b02dfa92fd029a764df814ac23eabe1af29e6b26192e518121211da3322e5396b8875ae9bcfc0105e9df3c28bbd2f4ecc26fdef78b455a6f271cfde8a1665e499bd3c470200eb3a6ddb417c65710f3abe15e31b375c3f596dffc5db8d7a1b98644fd9e6930891517828a0177d5ded2f3763523e82ba1f52aaf73a0d29eb96255171bc6be4ef5c15aee3511b2f253ba1dfa6d914ed0dcbeac22469285378c1558c7b7816444613b868750382aca5abff4eaa305e3e4f08c3779cd988e0fdca45ca988587ac6638424b0692ad36e9b3fb2bbb83560900112daeecfcb163fc5a3a5b698066dd1a7dc14422d8de6154e8da5d89ed40fc968bba04834e9c69b3a84182a4bc11bb2e47e64f67bdc6dab72aab3b52f719a5ff8227328cd5c50412837f3fda30b47c0af14a326ec8652f2b9fea5b3e59df73dbe0dd16a2a87ed3455da165a0891813c69002bc7badd2002d13452a238b0ef2416160dccaa99349a3264fe5b8664f5f66c3ef2e92f6bf386eabae5faa97c08f917f252154a14f55e0b5d9b7b084df7e5761944c3595987947f57666679666cce3b00311c9c91187ba5f1d8111353e8ca68ac3e3c7fb15904119721b7c43080d5e7f13d953708895a33d69f79af8686f42f8d30ce72354feb0f007fa5881f041c69fe38e9851e94fcbf97e7a8c85a6066d74da37e1ee0e70878291b38cec0735526b2d9a5689483355367f6db26a1d9f3c820c7e0cd2254c4c61c0a527587b3b549c53b990447165bc0abc12da63cd1c52531cc99ce1fb72cbe6cd6c97bc710913fa8cf63b8e0eddc78a53e1449d9cee2d2145a402212887753727d948d62af936b53a08c4bdec5b92f5b6679a865ad25fdeff38410db6bd5e63ea630c851d1d964ce97d6f3b1b12e1e9aea73846eeef9bb6331173ad0541b71059def1489d6af6e719c978ba3db1ddd0ba82707c11fb6f2a372132ada97bad59fd7a622be7850b02a356e262c4f5adf69016f3a24fb3248af14da94efd0312b260832927f83fdd76cef866368b20468bf1bb02995dac85e655e9afea8514534a5c68e2bede91c0b4771200e6c412114ece3bd770a2a0fc72cbc0b82304fcca6d42cfa23e452958cdfbb84f42a87790cbc404a9109fbf9671cf2e30eb3fa4b6ca525996196de4114d5b444d8336908f95249d2e743760908c8f4b93fa96d17681dab27448e4be6953f2161144c03c80dc3899ebb09bccde539b5075fb8a7c3acc7089995aea0818be411aae91494e37315a17035a3e97a292d0e53f02bae82d8385b58b9d19f6a3ad7c2b69db7832efbc75d0c7abf377559020f39264a3d7dd6d68fda1b6022fc501b6644d4698758bf40f4869db1683595a5591a6e19ca6c92ed3bf8d7918ff657dc5da39108f9b9b58124321eeef61fdd9a9f6f1651155d582c9d88d4670e377099d4eb0cd8041f518555cd3ae575739199c5790d8b14a4112597b9e643ef390befb56e2a151d9dd078cbcd3ffee0cc9750f5f5843610f3defff222f949a0047534d158ecff9de5ec5068f53de58741b86cb910e57ccab06d3766ec6e2c745c351612ae0982140fdb992fedb3159c734fb345c07e993a68ade555ddd86e556693eb2eee1bc7a6e67270be7868c5c21156db0e313000b128da4121c96034b8aa8490d288dcc7bd5364007e0d1f7cb1d6954f44dfb31cca1820b2fe4c0af5d6b4eb1bd16878ba938df5660873e1eb07460fe5ccbfb5517733f52ac8326ceaa868655cc17b53a47d5551235fbc482c9f93994aebdeca79b9ad42ddaf93f60ad65fbb9673d3fa0d441da9e57ae5a35658186cbf3d6161361050bb452a213ecc394eea594d41436eacde5bdc38d89f2c9ba7e14fd5c294ceadb28587edd31a019304538f1a12f732e357629952b56c5c3531b893974733881f3b72a8525514566ade8b4fbbcb568ef0ca4f36a6bfdc47674be01afb0c446709d2042bd7fbf6a858e40c196089caa3b8de12a93aff943eaa280d9a2a8fc0cb1480c4b0de8b29737e6209ef513e47e153b438c6df61be0d42e237bd2bf069de02569e2d6484de35ade6ec866c2a47843b82460ab8b087cc66eee5715ce770e678869c78e636d76b3a61b0be46c23fccaab5aa82e8efcc40c28f451d39f0040e1431413dfc5281d6f55523f28982383cbbebc8ef91181d9422f65e5f161a58e02e93041af38771611373364993bdfaa466f1618b44c9409c34ee5a04cd78591a2e2ca35b547f0a82a48167d1de3760c7b33f0d2bffab3d799e1f5845e7c7095d72b979863b2e20ff2a696ac23320a8d4715dd95baf75912b36ea52638c71580a2df99f7f020c06a8b834e5f7e98c969ac56611d88e3879a406dff9206f60473a4d2b555d893d2a28ef724935a4fc6ce00048b98f8564baf9cf58327c9aa5372ae2609ab5730f1fae52b58752a8b1addd7882d6c4af6689343f7ce81ca523e3dd7ce59cac4d881f39f24779a5d11e8d1c11172a827443b0707baee0332ad4d86c17f629e552cac34d713eaa7f1b80409f9c94d210ef27b0f1de54c380d87a082ab947a2eb6bf651365bb65afda982363ea2a312c2d011f6905fe30f657c66f8f234602cef43d17b7b9502b1e360e102852596badb8bde5fb12506c729ba8b4f3222a79cacde0f1f2f83b414d507684a0e3fe5d6784a6030c283e4c82b4c4c9eefb0612273e58727d85bfd97576898db8ea000000000000000b131c2529343e44b007fe359e530fd91fe31bd6644f2b9ce864b38be60b5f5440e145b1c870cd771487188a139321a86d024ed29b215440e5532ad97caa1a3310d271302a374d8b8194df9ca5eb1deee097cdbdee6861569fb965e0ac7bd58ce77c16f315fdbd2391913f89145af6edd3b83328a751137f2ce83ebfbafefdb47414df361f281881927b5acc0e669d811339b2b12dafe714825753d69fccd6115349591e30c4f7c5543e68264421717ccf3e4be00702d1d1b401e625135095f855f81acce1683114a1416e2fe4952ba4225a531609c05eaeca8d00cf6dda6869b569b4384b724fec619981920878d6372acfbedcf002700daf984dceee4466342728bbf8d5c73642f2d2772b70d7b99afec3bf02e86bc7af3ebc55a9fe990fe2ba8e9bffd4499b4cd703951b80e09f308b54bd8325f8a71da5d67f58a7dbc02a0fc5f0f178f96632716d6ec41258b591ffb30879476004ca72f0fd6e507bb98d7c2cfee852f401d928d4ecdd72fcae4fee0b24fbbc70c0b7081c132301cbd0a1d26155d7f041bb85544012cf5e383d2069dfa116dc62642f8a1c357e6033b8dc7de5eeb22b4e64eefa24a709b8d2a58521421391a0fa11d29a01c8e146a4033d891de1870d6002016619657cb1ad506bbfe9d40c0d1258c952bda2cdcec99bc8d21017e476dacef3e371097cd612cec41db0f1148e9aab1775e56847ac6b3b73265df7728c6cf21f726d656d6265725f656e646f7273656d656e7440", + "erlang_station_node_id": "52ed288e3ff39ccbcf74b438b779aa15e3a88871c104d578dd338486fef6484e", + "go_challenge": "a7656e6f6e63655820c69357ca9cd1ce7c4b31721928e10f1d91bb09b6a6d05b93036d9889b1e4a13a6770726f66696c656970715f6879627269646776657273696f6e046a6672616d655f74797065696368616c6c656e67656a746c735f737461747573a26374627358c3a6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f696458208cf46f6d3eac72cfa2fcb212a42924ab05b4f7a43a8a69ab91add300f95699f9677369675f616c676f4d4c2d4453412d38372d5053333834696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f686173685830b05247a00d7540f2e95e7ed900d119c88fb6d8d3aa495319eff04678531a4953b729843092b169f923286b5f2d397f95697369676e617475726559141391f93a75f1e046ca08e139b75c247f5e8276b490addfb88d2e4de0ea5eda34c9c7add299a5e2ae744a9ce9e702a2c473e9829f133c662be0e4faf600803610dd5d0326392f89d1c03ad864fd339fdc6567180a228b6ea8adb005cc36e01792a9b524c007d12bc2e19b0f985e021d4ec1b17a5e33a6da2a460e907ef9aa860a31720ca4a90ce480db46a3fd7fada94f9e612edacb5e2170f8ce7d31248234347821683798e397e36feba85367745107ef906dac34cee50875e084dfc8fc9707c7d8e7a9df6d2f408799e3769299556700dda10ecedea9a2eea203b0805a1b934ca554e2740381265f172142512c030f291b210631aa61d1a29dac504f3ac809579e5d19c72a1cf11f52c980af49f9841863c1715ca2ced96f56b36a7a7998c965acce8e1d52defde0a21ddc12f84fdcfb30e4f5772c9bb3b5bfbb33bba22346faca3d371b3a8088bf28c618f724f69801720c316f5c5a272fba9dd13f6383618c4e46960bc103aef2d3591b354cd8371f9c1d6afad55d3c230af5c71832d75f07fb08c09c18cdbfe7eb24d68d1d13409c625845b1c0bf6b015d75e0aa1f8c43d7d0026b0282cc0b60537762cc99cf3a5217f84638e79a7451fa5b8792f880f01f4f232bda7fefd3915147e6f362cc3f6f64bc84e737b2280ed082c89d2f6e9fda3b24348d563a4d16beb433dd26be5ae114e07846e9d25c087f824001942ca7c27ac8e0784edf240445eeb6b8e1e27a13ed099c5a852c890a8a040ae89d0ef78eba4bf6329e5f33d296ab3264ce9984eb50bb36c994561e18c672d3cc4ac47813168d688351395f55fe402f133d45cb9f0b47ce194e35d96ebd7bd7b64a2b9bec8379cf6f1ef6b2a6597e7d86266016e27ea0386867d50c8fa00f165ac285123763e72cd8a377a46209d68396f861bab8eea463ae4db62aa668da97b8eb647852fef32461006d092f0ff85cf31ab076b5db8c5320ab6050b9e72abca35476cd215daaeab286ceff75faf417ce08ea9d39b915edb25f70a61f599eea20fda048c84bd9fc1491045c0b49678358e0a5b543cde33fb6b727b716f7593231c2ea1d54315a4a4e528d7c5ee6190fe2d3ae1ff71e903f3bdc7388bc301a9298f2091196ab60f5814359c8dc68c51a96127cd1d67233475934805b9ec8e6f8272a183d4e9010494ccd69fbc455665c234fada70309919f5c1ff77d9b64c169f4ba5f1e73319d9a8408b62946e7340cd51dc7c5ad333956733df1a301ef5ec11b8220cc3fc4a0979f93e6b9a593ccac55d88f3441d217967c366573300319f3b0d502fbc8a1b25bc20405fd445942a1c3d9ab3cce5f64407619b740943e130b999c5f014ee443a9e1ee326a37ce4c32405378e69300dc5971fc900ce786b0de5427e7688927bec4c4eee82060c06ab9d61d7836ea6e725844812d50d7add7df5d6d90aa8641ebd0f6d742009be4e12550eb845a3ed2c5dd58d0cae1375cae0176033f515fde9db686fd43c8e783df93ee8fff77bdcc8c79ef9ac48e0622ef6279fdcea3d4d774895786f086ea4dd0626f4ce07ad176f13ec800e4f2c836e88601d017c9f27e32d17b01c827f2054acac6ddd5ccc8ffd3d1565142de0c0dfa61ca65d41d13b6c42fe79c65fa16bc3a7097a1af1bbc867cd079fd7830c7b0270d643bca930e29c9b1039ee2868d4c1575f1070a75d419d5222dec103d8322ae5986eea6adc33d46f3a817042b9ff71c5153027f252c6fc31e20ae85445f14b1c326f50ef2c90d0cbc73f91c00774f4ff49ca4b677c3b0d3fef6ab6e4cb537337c3bd6dc571ac503d318048540298b2cc795b70f8ce9759400f6a5bde27de8d1e2179e28140043b5e3f8edd6a2d9e3cf48398354a2710268fa752d5bf8841ec02b2d984d856309a98558e00a12fae92f5621edead48990cb995a9bbc4d0dfd8f90c107e6e9d9c8a0041b6dd9f8760c9de8ac8f524fc695f31187c637d9677039a7ee375f94b7c57d3b6acd0392843d8563be38db120e4459def69de88177665bf6e542ee6b7ec7dc17e45a35f0e2e751acfc1ba4abc90f39bfa1d4e746ca5af0d3dd21a81f64433fb7415003cd2ee358d5eef1826193b6c690852056227dbd417c8278dfdb222b4f5f216d4b4aaaf8b375a3ef5073863f27b6684c055b1fda0fe2ad039034642b2f3ce540d9cc3bf46747bb86b2900487d73a58507acc351b403a1f0fff3e126d0873edd6b4c72ecf74d8d785a62211be72148e13de9c73cfc18ce82da7b6274fa934a328f1cc5fd339f7ab40cc92ad7f4ef094f4ecb5c15f3ff26ee7f5a2518b9dba8508d16260a907221f1189b67936244ed006fca193bdc6f5c87533c081981e4a1a232adc5cca45a01f9d831152fecc00253e17ea8d2a381eef50844371ec66ebf5a540621eedb9297e085fef36f7cdc2f75e7856018445b59ca8cdd6861f6f601f90a99456ef430f6be0c43b85e943eb0ae157ee775a7a96bc8a4504f732d8547c4f7374097f5329c29222db57aff50deb830ea150a12ed76bf93388cda452647d32b43e5c4e9e507046d6b80aecff999f4ceb9c0889f28b480aef2fdd46b2e733ac8f0a9be5d77d62158a5e8cb1e3bcf680e9ab4a1a1ef16bf534746f70b292a30221cf89d0fd74a4805b85456f1913083ca8fe52cbc7e2889735386e3a5076bc284e2e55ed874a6a5cba769bbaa81c9402ab98e5b93fb8ee911439ec2d840c2a7f521161b6695857f8d7aaf48de02ab3e06686bbaaada00ce0c17dcbe931c8e3c6fce921798dc4841d152669d36719be6cddf94a7f75e59623d110e88fd5d412babcc22a115e9dce8b220747b7fcc21cee38e3971a17729342461bb6a39dd766341f49e5e520fe73d753ff9f6bf369681f91c745f44c66c71c76a71f5946a5a6bb3c06257b0dec9c1495d4d6540d42d8ba098e251a7d1b7f660bb603e3863448670213c966ec8280fa0199e99639adeab8938e8b67570d03ed11a5bf4b85a72267034d6427668615c689a0d8e8f6f7c4416f3ff714e5e1f8a5cbe520049539604976e1ca60f4c5437c2fd0ed1413a13051bcf457fce8c691859a74d132df4fd4eb6819390d1941d3b8641fd13e9cf4903a3745da55497571550b19617db188d95dce59dcf1da52430afbe52d6ad93d6721944972e4db7f2d9446d9d016b1cc62d484d51e1da5e011117227ce692325c880955cf50fbdd25a5ddd9a6c34f5d1a93797df97431549c2c68f469377c92a7837a8a6f2a1d74d223007f2c43f636e2bedddb9b8a3a964a252b73bfa5f14e5f69da610974818e29eb0f48de7d9a6a21d6d12f6035a8c4004b5e0ba71d0540d3d53eef15b8be1476661548be5895db8da4d1b6bde86575137cfd39c353ff81759ffec0c16ab680ce7468d05e9f493da2d2437bba36125df85bb09df2c383ee2366979765ffbbc2c85aa7103458f4385a47278f34785de98fc05652ebeda2b11b289987d872ef7e6ad51649b418bb64ce86c7af3978737c3fdeede57d6a2c4287d040a0536939eb1ffe078eb3d0a35c0a108fd104c6555596d9641afae4dcc54fdd8ed618c0d4919afec76821ad512db685dbf9d943133ba60ccf9adc5690bcd9f7476ad40a2aec781fc05722c7dc79814006f870c4c1a0f9e8eea91d1915b7caee4918cfbc53b21c843a50f6be25f8bc17bced48444e90ab8288975c70f796184946b58a451ea60bb6f5e7db598bedec80b049d8b1cc02a3e8ce2c5196753d512f5aeeea9a6c99dd029f2b9a2f94976b763b51e42d7f72d0dc20ccf30d61da12adc014c84901a27d0fac1c494162fa5e22b1ca70d15d995c2bb26031738740568ec032e1b185e16adf6221730565635d919f87785c52e8e39b26e9f790e2a3490fe57719f786012263cdca9d45c7ad6588b34e3d32837db61b7f4022b2275aa9913e9576b0ecfd22ca2eaa614ad27b47f87703b2d54594d5c2c70c5c6432579a780d2d2ba23f84ed23b3f9a0bea36d53eda53fd73d403a3ca89bec4cedb67714f50339c1932737d82723f161c4d56edf32577b3e8a957678115dc077bbf61e4fe0e3e04dd2a10263a8b1aba43656294f56950613aec9eca26f471811f7c783e8cf2f24ca3f60c17e7a2e494c975c007cdb3789286fc459c480aa2590bf7542e9ceb7a2186a7c537a5734316b94a9ad3186e6f783f42454ba9ce8c288b83f2b183963751fba0a1aa430159d29c9e78154219d35d626490f90331a16dab3abfaf6694fe9fc5db3460481e511b4f2e2a883442978da33fc8a100a7204c78976a3f8beb148df27bcee8a09c0bbfc6e7ae57257be213f6de5d0c0356a3769949e39199a2bfeeea9d8e692791ad72bc4306c5d5883107c47939c35671324086f8a099cf13b1f704ef6c8ab80e2aa8efac452ff149e8b3c16a6dc87f0ceb1290daa2d8731735c6c8a5142a0c1a808b3b7c7e4659d7f4039b1d9e13251f23b8c65609075107521b4da42dd1680ac2009d9b8ceaaf91a73ecfe93d54f6db79cc8170ef09c515458c185239dae2184ef6c423cc8f5047bcb6faa6e1c1d64d5a40f2848aaed90b53f163e682cb104ee2f7f60b0568904f71872ff844050cf2d7ece958c07de6e535b2f9f54d29099171cf7d2b8034d3a59f7d590433367c2c130d07fb86e6b3701e7637cc809929a4a18db9737cb5a71e7ff88337364ab737e9f1f594e93bf388993df549f10f83338dbc95f643f984834a01fa1f39f539696d8099586ffdd0b7b561a1fd6f3157f45e87e189eee1ecbae1425d9a0df96f8a48ff5acffc2775bd1b8dbf7f2eaf3c137d6f0828576ce19df11f1c18e65eb79aeecf1efe01defd66509bbf88e25ab8836f91150275db3fdd9ff5f634d5797b93f14a2f5ce1222bd031739aa079e1362f3787879a991721e196e3429d83874974d1f98602f8be2b250b93e39db780c2261996ab52c14621ce06f266caf38f1b6239d181e69d0cd5c0868d108394d3c40038a5b4462097220aaae4dbf8cecb68699b941d7691dd28fce5348bbaa120dadffa713cf09afe2d1a05eaf827d76e15a1f61f62f4ee07b0f5130cd3b147fe5e08f278c852e96f1e9d10c639207a9ed07043e1c99552edd58eaaed16d150e6553a4b74420ad05e01061af10336604608dd006a3dba60f9f6e4328097ab0ae2a163eede11cfb38a0cadc1ccb848eac425e360f8af98e9231ec8973a8c93075268e3d4e008cf50f28bacc2f8c31709350b9b20d2fc9edf924605c8b2ef58b7bf83e838f7ae9ac330bd7e8dfbe36dfb0401b3589ecbb7bdb4f96a726529a9bec47517b9c732691514168ff7ccc2e53dd1bd0a137ef295d35b2a5b94dcb8dfcf435ee5c9113dba46e36d236b8a8a50f40ab108aac06dd0776b20c2758eb52e06670af509e87d8232d990454de648441a2be111ffd8b530772eadf2a0109c1f768b42eb2a1f74311c3bbd4f5e4c2a6724c888c964020fd54a01ab3067ba70e96579990b7d5816e1462abcc548839bfe67134ee630f7e0cfb40873dc3537275ecaa76638c7274e4b8e0ee3d1be7cd9c559a75d61557512338bac819c40b363a01586516a90d85231cd704c77db7ff55d6853a7cbafe099a1dbb2e634aa61922775e03d7e98c9888c9f841272b250a0c60a7d915cd9ad76a7e0dd2922a6696b708415c6bd7724dae079adaa6ae8a9be480998cf460fffc92ed2287718d4945a32129845f5aeb5620e94cf139fdf0734e37b902620ade7c29f0c67a59fa884b59afa74f57daf87f22b2b5883c49fbaaf461a8604ced72b6f6d5f8d74afccbb4dac1ebeca56108babd943c04c451e2d9af8f012337d666d436d95a41d834a149ce671fb11da493b2260d71fc8ebe8eefcfb5022265c790418f57fa1d5456a8a8083f07b99b6613688be71726622917bf64d0fa3f772bcaf1324596d4ac49225a49473ab5ad855208d415d6ebb99a81e58a6ff5bc2cac068db8fa7f8b06c952b08b4f235b8284cf521a83fba8f55588e78703a6db40d39ae23f9e7e81bd578e4a817f318836af56729f192845c8004440d785123d3d6fa728cd5f190fd37c19006867959c970387b5f8450c9e591963a8210482659ff853ff63532a6e93ac073e6e11bf3cbdb555214d32260b4346ebc107528ad60930c68b3b1677f9eb5bf065112800be442bcac69452fda7de32090e707618e2877847ea968cb717ebebe5026f98cca510eb207fca2e4f73bf219dcab258171b481f300f294a2eb5717a677ab63646abd5f67b5ede0349a673210bcc6c956f2726dd26c25eb43db6defa94a9b4851717b0ff2d246ecb7bb5e7f8a4096d41cc3a192c1da9d4f8844fa846227eae5ff19319daddb3d75fb1a12520d5dba0373c1baee40fbb773b18a938578b943dce956fed772152ec96ec344bb76109cb8082c9a875dd15a490f75e1f7412bfd245f0ab527cbf14e2683c50a0c166366798389bcd0d2edf20d333611447e84e2fa0a0f20276f7986e906304a659ca7c2d0ec7293bdd7fe0d44808cc3c5e5f9000000000000000000000000000000000000000003101319212a2f37af420e4306750f849b94d8df8fae95dcd5bf3dddddecf25518d0af0bedf1788e846dfa724038fa2cd7f7482fa2a9245432b3b70750af1bd3ec570e238a518faecf4a82e35202e516d8514a199a4443b78c4e4b08e7043fe187390ba2872d290c169a0dd43bb7258d7e1c8a2ca391f39fbad47ea9516b05a8058fb1472b1eb7aa2fd53baf0db319f3e4aecd7e30c39d6e8b295cfe493e844575c34016caab6c00b6ecd9c8b91fde31909aa71a2cfd9e7567a06edc7d3f41cc9e82e1afed94179d6578b0ab32f2327ef87cbc7115467fa6d1acdab6f249b44bbe101b11efd8e092218b916d685cc50fddda9f34eb47291f2a232e8c947f4a96b5df3645a14a262c819aa2c4ad091995fad7c220a3f8bf8194163d55b11960f3d50182b2f90a0d871f611ee5e4f969bcd3a2899f9b6055191dd060831cc817ace70140a98e9e41ebd134117c0c5349837af3c2274062db2f153135ec33e29e2146ffa88a9684e13744dfbf521772a40bf96c55f02181c2fbceff6b80a566bcaa0ed1d7ed2a4c29e4dc136b027c8482ecca5bd59552815b4d118fb27214d2064f44f1dd72553b0053319e1fff2aa62e901b4d42afde979042d5b16baec4561d113fa4a445901fabd11ee409b80856e5fc7a7fa1c7c3b0336a88a8d707e6af43716b7592236eefd203ee4c7c427b3cb573d22e94153bfc27bb7720bfd351eb925c4e34002bfcae5bc06b746c735f62696e64696e67a26374627358fea96375736563746c73656c6162656c78184d4143554c412d50512d42494e44494e472d544c532d5631676e6f64655f696458208cf46f6d3eac72cfa2fcb212a42924ab05b4f7a43a8a69ab91add300f95699f9677369675f616c676f4d4c2d4453412d38372d505333383468686173685f616c67675348412d333834696e6f745f61667465721b000001a0acc226006a62696e64696e675f696450dc2ab2b5cef77e14e18aa2477ffc5b686a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f686173685830ba5d003ee50124a47aa0d55c045bb81cc4d8f48f8f2af53deb5f9814f14bd78ea652cab197fc2348547d4cde771bf49b697369676e6174757265591413038adb5a4058879c2f50243d285f1d2f4ecd7c5dc51455e503e31eee5f4a3faa497fdabcff9eadcae2200f4281668da308c5b72ffbfffd03fcbbfd1f326fff06c3978e101d23aee98687cb41ca97766bae744fa5ebac13a7ed62ceb05c90e2435fc6e1b87ac97fa6c3a69000a8afd6bdbafb016d9fb8377755637ddc7ebe335ea083e5e4b550bbc52a2ffe884c0d9ac95c94fd83e241206f6de8e4af2ffcc2916e0a6f0dffde938325beddc1048f9f808fa1edc4ca2d43b99961f98c8f3db9c3ff5e8547d4db429da3fb63f83dcfbdcffd2b19a306daa7d4310fbc41dcfb789839ce8e704715a21f28bdeca1e797e2c93e88f387913601e57111d538d6657a0379d7b31290adf6a938271644b77120f4650580883bec5206ea2473682d3c91680c3b3f29574f733291da567e372924633db844762e97d1df0245ba30fe359c2b50e1cc63035050c2b1de397e7ab0e5d298396dd7ed766a9aff049f6cd73cf71f12469896d7b170eb2704d80ad616a52ae2207b1e4a19b0bbad17cbd8e5d0d73e7770831c5396cd6d849cc7a55a0446bceab24e005bc46e6c21cf87d671b3c863e4a94772c723c394b4ffb6a90e608f27d69a19969e37b29ea0267077fb78cc35169dd094ae72ff91e94230bcc83edd503cce7d2b562923d847d6da3a8951384c2161c1ba0cecd9f86173316eefc9031d7fb9c1886a60dae591b1e7adff465538fdf73ad3f4856df219c6a65378f2f29089d637b200f4ea8f03e25766f952f4f5e77a7c679888b619475f62c06a5c5f97742d88fc77bfe70d7e626761d0178571012c8d01a3d9f1da054cee5f01287de21afa01f2b503ce49cc170c02c961f28a52403812443cfcafbd4c582fd78ef40b656fac0cdd57dd922bb0493903caddde8434ccc34cf25f3a68176bc966ded85bdab7bf81c6708bf8630832cb0105719161fdc69662a9432a6284ab6329807fc781803bcb697aa0bf6a67d58e4dc5fab740fd2c4de5ebd91edc1801689f6b609620aaf76d9fc212c4d0dec2bc2ccbecfbbe71e4352569608629b15603b4d8454da7503389e775746a25b14350fc3b2412553e21fd33779dc669ab07c57ac66d902f57d5a25ce715cd5e3a2f70906d16b13862df2ec8a73ab33b4e6316b3d3905cc041161c02a0ced85387684a5ceac5579cfc062cc1569e4dea31be1be0d3d510d4fb72b2a19259f9007533a56553fcb45212b1b25c6c16bfbe316a231d2fef7778bb7e5038bb6f763f73cb51c0fbb66f8dae720255c57eac02d6a4b1ac8fb611bef24b10e0d5ab916c8b8430f743f8e0a90e613fddc748bb1bca3333e27568101db28e2418914fdde36be70db16b61888ec481c358be7c0c1c20ae739348b7b04bfca6f1f106f6db1ea7ae95ec2ee22a782a5446bcedbc1c1e3a19842e0fedc06a70c1f9980114ddc33ad55bea486f51b0369f9e45ef6a654fc1af9cc873d998c94f90a5f7bb5c3b8f3b1d464d813d27ef701a68f0c2ee9d9da9d3ab923fcb4c92d894f720fc599cef3f3c07d09023cc6d4c8bad7a25ba07eb4d8ac0706785f4bbbfc6d2a3ca23303e562ced412bb260ebb095e76a8a005b330d51dc848e4a1fe48f898e17bcf38ddde8a35dd6efe7860a48ab19898fd5b655489acc60da756c85797980efaf667415ebc820cc96579e046c1cf0b61c84f888c8a6673929dce4ab9803bacefe596c67c1c8c063e0bc080504efbacc97a7d269a6d1ca5875843e92f56be092cf20e12ada3e4d7d72c6a0943d8a2ba8c6b08fd393abf36c0cf707be2247e45dc7daa1d64ba6162b75fb3310751476b9284abcaa3f316127411c6b9e80e3fc7bf4435377eb3725efb9d51d5386ec5330c775f4b4a7f6538ac7019862a4e0e3205794c3f9bc2e02c2dc453ba01861d9782a97187cc3263fb99a78e851770544e26a1276f20c1b633f450047021f62a8de853f3dac9aeec1c565116baf8c771e4c8a2376c7274d8c80c63da6c8b73ee123a26d97311d876ca62935ad0a8757170108be9d00d39d52bb798a72e89c92467c89ebee60864af99bed17d824f24dfe24d277feba2bc5b709d443ce816ae3d6b444075eb85a7b7f75570c2e21dff005bdc3d67ab1f6cccf4554fb21c93034538b3c62644e13309616746c10cb17f907690b57a4c3c98ccdbc78822c573ec663cb19981e04ef47cf9e66f84d3ee13f762eb11ab26c2f1025a95b12f365331f7a6432820d8318b55f42f896e39e1e5c4295ff5b33a4bc386a9ceff834baf3ab6c0b212f8d485f7df1a2ef5b1416caa2448d3dca7d6001492f3270292983c8550966751ecc8e7d730bbb904dba8b2707a8b0c4cbe174388216da38b30d476a2e5e61d7aedebf2cd087f41da00380fa0667dcbb0b24c970a5b80ef7e0a62fc3d18c6fce3ab479faca88b5f924f0ab84a7f3a554cf8e14d2a772628d8c6f5c8a3ffb3197849958b7b1e790a07e81fd183fa97fc16789ec6687853e5193a809d6b1979273e7c651c42c34325e8c568632be53eb8d69404abecf13df216b0b4fed0b3df8e13e7ef494c40700308c564970f6fb4f1b0bdc11b8c8e133e74b7043978d116b033c4f7280a911055e524224b247029693f8b27c975848c7438715f6a92510da3cc5a926c3c072d3070ae881c7b65a1fbe396ae7d5f632d20e816c38f4fb541ed2d995436ebb7f5331135ecf3497a36d17f1d441e4d34612248908f29cfd3dcc4ed1901d054c5850952bc4833c1750bfc3b68598e7dd5607e1b6b31fda8ba8cbfbad84bf8ac282c7ca09b9453979d8a3bbb972e354dee39a8e6b7ef12fcf6549964c26c765d48b63d9097862df74927d7358be16219f63cc6334f990d9a65a2930ec3382a8a0b7aaa8970d1071aa934a08e75bcb77003408fae41926fd4421e14f464c9f508c22eb42e42e3b394d4f61cc2883d72cfa0307e0f2b6f76d427a6fb01e134290c220eb6ff6d1594fb90f4169d4f2e78d646fbed3cabd590e1987f1071d6ae4f3fc0a24f9319cf298c00f7cca746e0884d85c4d243e56ca919772e5b955238de195fc9e19d2c2ff0011d580eb031bbe85a35b1c5ec0d0abd2062432c0b637a11da27cfb70cb91f302ae617cff4e0a5e38ba7e98176be2f06c1e11d6c36199b90b927f164fe61735f3b6bef8a973d70884002c2b5640ccd6018918f1de687071da74cb935d891b7534e0e00c042c9e72506999ad43b0caf16610b7d45de64808d4549d7a190cb2a5b91c5148f40eaed589c2ebeb821c5b22a2048fac143fa957703169db2a3d366364be22080d34d402fcad76bfefaad0c47b9128243a17cd68d1bf41362c3a2804545cfb9df7143103438ec7e8772933df7a551b844f21e414191f7896c29519e2246a18bd8e319ccc431466e52558948eed13d9b50c120cb58af1c246108481078004ccdf3a558f881904cbd2878f2e3ea631b82b47bb76d520eb472129d36439af5551db8af8390467fe1830b92a1a5e52bbaa8e571d0db13c93403d508b299ab7ae44f4b5a7576cd8dc11222db7717cd67744dc5a55bad713efb1c8f6ebfa88c0e9c5eed33d9c2f7b25bc4f64d00edcffac660adbcd82c5a55184a71b73203fafb871b494f661c5e677fedf89ad974689d88d0d1e474555e65c951d501e37910d30172f26f179f9c3bca222511b7fe0dc6a0ecc5ad89a72c47c8f50652e997c5b03e5e41593bbcf1fe19723d95a7ad0f75f12176ed4756328bb08d3e47247723bb9040d1184bba837a607968ed1aefd4f048425ed29e7ea6bc95d43df6ce36ed10019c51808d6355f239148562e3d66dff83dfef9490d9ab521d09a62fe7eb7f5476abf16c546c59d7f5d58f717058d8430239fa32d7d4a5b0ef3defd20db5513d0b4c0b81d6c40b0e935968ee7984cf94f19d6959bb591aedf5958956f20645a5952a684ad223b023019d7c54e349d4f72fbf57e500cd45afdde00a0fabcd40bb755aa64921b2494d8710bad72b9f845ad2c9ec131db8e9a76b58bab4d8dea73c227df964f5feb7d8318173a27aa0c2930a7d1674609649d005b4fddcecec3649970e4fa26ee892d36b273db532d765cdb513c7e1889d0b9ef18cf5902fc33fd6ae180db2d82503612259dc4c67bf1f53fbdd230630cfa9e693203947556ba72da2dd18745cf3c2f24b3ee9e76f28fe19bc4b4815092bc1fdc26f7fa9c3de6383269a0ecf3679721dc581016532c4a63d4824481045a85a5d52aa2156399d1408c03ee76bae035d0907881b3d3f1a1338be313d2907b60913d3fc4fbd4b7dfec600dbac1724959c43a48255edbf8720f88a60c5b9ae0fd58fa53e017339745e0318273c742be5f99e26480488ae134bdf6fefc34b5a7dac1ddc9540718abf4d615f670f4e598285636b3a0358c82ac7616222e24eeaca51996d3d2b2b779661c9e2aa219a260d718ad73930757ed1fc4a54df5249422df9c0441cc2c7c90f823cdacb01ee54a6b2717bd03061c3c4e2552658fdc9a8e24ccef2c5f6cfbd1c89a50c052da1f66c88e0b69bddca3638d8f745e5dd98cafee2436e873eb43787e86c21864b331c20cd7815f1d56e8fc12e1ac70c2ec60cc058306d633b94b8cc97dd6066e433fdc0a629112522408176a64d0d47f7fbda059edf458babafb0d1ac0fa995f6b679c261add9299cff8d720158c5d2199b2a84b1a18f2f12095b075924b20d0d818adfc695d21291bc36b880ffa310f8fb1368d9bb7a6af27497ed3de41128fcea13c24e894ad04c83b7b616a0efa8de82ca8ee021c41f0f3bfaefd04af24fb82119ab519e96f1221b0c0c52fb51ddc7a203ba71192a3b27cea905c5a6024fbd0afa358b4a23fbc2285fc3ac361c888edbe76876bcc0bc2da8c3fc7d3bb985ccd0d17c1748efb436cd1f18e1b8626cd848e9760532180b5087c89ccdc8fb3181d64b367d33fae9938f1c627779eadbdab6c5a4f630516777e35a9654d115589e4f16fee6a88265d87a51aed79945ad5b9c6c11017a01e2fc9c0623ddaf073ba6e3c455b7f509e81ae7db69f969dca33327c28009639ff11a9da06fa8c78d384b92285f7f9dd971353dcfee0ee0232aa0be01b1866e8102e6fa16eefbb3a202c31199006dc82acc91d0278a361063dfe0da0e8d2ec4990c0b87f7952e9b9a2892cc80ea48d279ea1407aad29661d7256260837cdce737796b093ec17e61e4baf45469b8f04d1186fbb8a9695207429d610dbe464b6378bf4b1f998fe9aa30eb4fba9f4083f5a1a1b0fd9b1609b65e51b94860eba34d50830a2884c9600e28305f4c57e9c38dd872d69ae31715bd1e5bdb10e835cf8058e1388a975de375ce32d717eaa649316e2c02e40aed8503ce901bb54216f75309a81305a9fb4b23274ae3cf0e90372a1db9ecfc9b097a1c591017d0b31154e830dfa031aa88bbffea5c02abce47283db103fc1796cbf16f6bbdf309bd7c8a3a280da243ed6058af8d270d60ec1e6488a59846da7f2fe0fa36409da3ee27b134da56831647b7270d85f0db0dbbad7a9db89543415f18a33757c5c04f07b1f190dd7e4cc1e7989557ebb75bc01cdc4add5c4c88b7fe08c3bf48b8d16c120ba2380af5017ebb63db96cdf2e89231d978372e9084c7ee9dc0b10dffe59716377153e44fa2984e3256e98864d166c1b76e47122bd9e2979114f819a8ea9a5f67ea254f2ba58115834bc7ab2c31c869156d2ecc5a7aa73a76ed62c93317ac4141cc7026acd66957cf28eaa384b6de44460cbc20205e7ad60de21b495cf9f1b3d6303059014916722799d09075742598ca1760d5c5fc31e846e5e194ee78dafad4d46323a0d6c676383129f6048d0fbd3a3b350cbb6bd693c75df3208e03c8e4ce48c421204f11a7bfa53bf99d69e310b7d35aba469657f3b00f9175a2aa12c1fd1e0b7119030470019b5dc6fea1d98b4e3ce70971fce2efac5e483257668842ac79167e5a6062f4d6ccaa3341e196a8b0c4b35ad6b639eeee20c35e3e8a05cfbc2ef2ca41ec86b6f3270e64f2a8f8ea1141239a1a4c04ad9966ea4d8593332abf6aa4a6c340b433dbe00a8d7a3d8392e25fa9710f209bf782418ad98886c1239a2b4fa9a32e68da9a99b1875b81183a70e049c8e2ea491b755b37805cbd566f9ae6e0d6cb31f73192f298184e349c2faae295ecdf3e90cbbf8e8615755cb02249d4ab3ae376b7ed9e1437c66c145ca8c547dd42ae0e563688d1d73a87727c0b0dbde3052d1364a33de97a63f42e9f995d3664417591a75eef2d5d3f4121147fdeaf1e925cbb0909d446501e20e2d7311ff02d1e67e9c492b7c1ecf2463f572e6ea5eb421d73474cbc0fed929dc037b8ea6a5b0165cf6395702bd9ffcd0355d98bc6b1133131f12b35ebe49ad3211cd087e804148ef0eef9ae77637d81deaf9ba82a367fb74854ee1d686fdb4c9df9c2ab5bff88ac4e80be7397174859e9fe8ec07090e3c5e7287a0a2f45465748fa3a80d11878a9cd9edef041f8f91afcedc11399eb8b91e73a7c0ecf235459db3fe000000000000000000000000000000000000000008121820272c3237a7a57862934d34f96af336852f4fa7a336885a141dbbca62552d6e15e1b2f38c81e326f305f719242e3b2bcd018eb43f888b457f4b88812a9b3d059026f6540f4ebd091fb291a7008324b7dce62442c1f0373ff539610ff4c8a85106e78a55f1af2a93b65be92335f8b723b2112dac8f4b390b72a41bf6fcbc696e98bf0145f19e1c4d527bd67563480637c3888bff8db8a7e9d0f730463623cf3eb4b06060c1fa198e564ae8751e569615e96a35c8acb5803b90e19d83b2f9448570cf05e5ac3e6a4f22ed055d0647e25bf8fe9000f9caff0609507646128124919074e2df0e211520d09d8001f6cb1a25e4f4893d6b0657d7ee8f1db8814a1c88f06e9353c2afdbe5a8a27b7e90fbddee60c69aaf273f994cf46cc492f56929b95c71ddd4feba92d31069e27a3ff7143d92ec75ed126692b6f152c987f70c182d69008830c76512406c015762730d18cad70f1034507fad2a48cf1bee185f827a93a825965c549370ba2584b26add0682013cca41a5329820416318b1dcd1a7f93315c6639edef5837a1c5ff6b6213912a701efb64b3e10338228a43584388ae637888efd29f358bc693631a24b14be291c2accf017a756e0976e06c483ad7da8f561ab65a18950109c67aee59fa9373dc4274cd168f0edf817bad80d6f0aceaefb99c3c9f7ead1319358c14fdcfe6c8686e06ab3a12ab1afc3ce285823120c41e6520aea2c6c6964656e746974795f6b6579590c2e5379af0a6a13e1fea75032810c1f7043f9917bcaf75332bf1e7d3b42e02247c4915a0d7b932010e9f8512f2c73c53b7a0f80965fa7fb7fa8743a85b5944520764a4aed6805d4a63265798675acd523c88895e93c6966269e8eae7259cd45185128e293ec09458b96f562fcb3fbefb7d2ab1ab68cfc94cc6bcdca1781bb6024ac6aa77c8734fabf6424f61e2cdd9e78261c16aca8b1842c8922a81af22bd11e3d8fe417f63f50a581acfdaa2fb22c263fdee4e6c682dcf2bc6b06df38d1d79f3cafdbc2b411d79f6d99f8a18f3f1f806c0cc222cbc181013a9967dc90cf3057504d3727c002d31bb31599faccba9fdd8905903db6a2f92c045e1ab682d5b5201d1a721349c5727b0361cf856f0e92137009bb0908eb828bfbf488ef6782f8024ecd66e5a1ee00d1be26fdbe4e99b022fac7b7e4dccdb35ee2f98ecf56d433c5bf8c8b3fc809e3f35cdc1db1bf7ebdd7e5318359a90f619e2b7f8ad72816a906d75878436b2afc049b66a82ffa451284fe6544925bdd5b634888a9be565344998e6ad94807baaeba6d185a624d272623c85901dba3bc2385d7917b303ece2e2ca4a1badf4c3f35a765f0ff32591a3133edd974bf7a5847997b5b31509f5a647ac12646d86fdad45bcb0b617c85e0baaa0e32dc9f12424c4ad2be40ddfa647c327995a560602b1e188210fc5e6d880c93f27e703c102ee2afe667ec4444dd50ef3375f6539cbdcc9d40c18dc8b77dd2227ebc8e1d3f4212afdbbac4f64430718761c7b81007de7c5f5a1b85ad7f0b2ff48ddc6a64b6cc75a54372d3fb473f2519af05ff83f6e46ab96d2cc52d224197ce42fa458cf574ea4d9a63310ae124bb07faa1362e5baf60dcf92acd8c6c23d54e93b72aebfc2cd918508542fc6668bbc0bc5527d9dfc80e9b23ea720e695f34b5038c80a6eac9f2d5f7db87affa72dbdc0f5e46203a167d3d40fdf94e2358b0d62525fcb0f87b746a42f939d71614cc3cdd1f6c50f02ca88c44dbad5b12185808b79c5c468e82735fa445f35a4329297ede61af2f5f004921efd02eddf504e137300f0ff9456319c1b4b013c3c5bad987f18d7faa5063db5c983a57b6e7fb7b8c03cd5c898e70070f1d4d4a605e54f3305253388577a4c36fe9f1017a28ad03ae354810f912aae15d0edd10bcac5454a47f5c3db442dd25255b28d1fee87efc7eefc7931881be20d886c769154554044ed13dc1283759a8232c52fb13d8a73ee159b957d26c846e34b8febcb75a0b9b5c5d0bb8586d7b1969c4ce5ef75cd312429f78c1225999c6089308dc0bd7a5ff3f3424c92637b3254b362dc32a4fbd6b65591c42f175d88ccc0fbbe385591d775b5f0dec7d782d221573aedba85a561ba0beef343f615e933533bc459ccc369bde60fb17387d118230902365d9ed97608aa983b4935df3794b8105292085098ba57b0022a0b2b403e500172da98732ba3b9d3e878439fee1162febb86f6338e336360f3b34c8fd019a5805ecbf898278169b79214b509a504d1e887711a037ce69a3288e5aa9b62a1d52c88eedced6baecc3d9ec5a1bd91369c95f16eea14cc20e62fa5addcfc859d553e1e074f1d435de5d9cdb723dcf12314f2f254f0ad8b5900e9cd652756a5328e47a12ddb564eae737101780d26438bbcc4f68232e98f74f5413d9f80b46d2212e4b85129a4da1c8866b1ce69fb9a64565d49febae70789b368cb8b3935deafc11b4dd2bf16438de5c8a9492654c53f4a7bfb613de0d118dd90d6ccae1c8fd51ce56eabbb58cd85428a8f408ee1c69c45eea37a6feb6ee9ea34e82df491df0a54b87b20821591e264396ae5e1e3d862116ebae9f682f9dfaf09e18b9d1eacf26d8057fce2cf9f5675837f216bfc5816da1586adc0199f8bfb9e06cc9efdad8acf0b397574dc9970d6acac02d8618e8390c07b520d571e6953dd2090c6552ca19e83bee860ab37e0ccc62041eec86fe5a2e5e7ec7ce02a42924bb3d6eab79c9e241dd9e86b1b7d2a52c2eb6fd47bb8a72f099b80fe6406a5a4b181c29bde1a72d5fcb9b3fb064cf744d248d1f651064e55ff089e0c256c4cdb3aea6188c0301a980ad6daed2f9fb6d7056e9def7d9c5cf7c7096d0b85f571b77bf0b366d0244a6af06e7efce8274ad56b55ec45ffaf4b60a1c40b09ba86a315afa216aa26c859cf3ee622dad3fda386184849a33af084bae3bf965740eff17b12bbbc069bbf764bbf59fe0640d2c838f09bcb36101d7feb5f5a57625c363223ab2a38ba06890b0a39fc5199445e5aac7b802838fcd869536a8051aebd7b42198d5ee9269823b5c73aa3a548d0fb034e5e0bcb18d5ed91353628df30a49d6665ef986d5e6db8788311bc1bb83296329fe1ed1f52b390d56da33787c1d6a30adbcf0392405aa016f7b6dcdfc19a2561f8d8d7fcdacac1334d967641cf8aa07bbfe094c839550a7e4f99249a44bc8dd50a3d4d64afed5fd3315f360604fd7c5964a37e21d8d6c0fea42c81dc36116481f6f21aa99376048efdf5abb892de421690d1024a69b511352dd96f1fa9ef31db6dcc551063e058f958e416fa0b72498296712b02326a6686584d7799accd9c90134c340ed0c9467efbf2ad404cbf1fd78e399e8bc3619e21ea69e649886054a1e39c265a5f151c5cb7596c0d376de8c45757f979e11b3e0872a5d9fa7836e06f9b500fba674be5f401ea54372acc162399d545e0e2871f433d8e75442a327fd005b486d05acb474abb8fcc2ebd297ca9a226cd53c25315bb39ee9b8dab61f75b71cf2793c37ba46f65e8fa73c997704b9c8f30344071eee7d34c3d05bf7f6443632c44b419f96f2f144403b6fa5bdde0aaa107d4315a6cd2dff696cbb4a7bb7593411ba10689c784251192ae830f34e8dc3731283f1956f4551ee3ec0bd18b6995309ffa0bd116e10d0eb6dc1c321df4f3c22923d43bae31673e2bc64ae539a7e073062e0e0bf9a7b5f4e8f7d68453e7b907a2369623ad540fbbd09ceb2a814f6415400531897b60d19c6f96bb95dc34cc536c932c718fd7b3c30957168cee019153626a6f92186064664300d5652edd3ee00a6d414498dc5301c7b2754a896e1166e1496e2d8d58b97563f43cba6558a72557916f3c71e468162f2f33f964fb8e2da1bf0051ea30e93ba1d7e5cd200be21b78e2d4a7ca56f400a6ca5ba7c53b330ea713ea97f222cbbb3a8010686c5940198a962d710953961809d59f409a59095b2178b341714dd0ea80498a2eee898fd01d0dc1d8936366996334882188878e013b7bfeb85e12eeeb4699b5ea38fb3e58fe231c479692a8860bd5b669ec466bfe74f066d51130448190c589cf85962512dde3598b31a0c839d31b61c67368b034f1dcf2099d4d536898a03a0b22375594a81a871ebfc7c23dda0a3cd32861817a7745b68894460bd541c6f8867f1cc0cf6f54869b9e624c2ed5b17a7da6826d8a243b50eb843e65095e1451929d21cd4bd98e51c5e5d81644727e2fef9158c539354bf4cc518112c7c2ceb2e71031a927a6022b484e0a9d41bf572fb5279ef4ec3bf5bb881ddd0b319290ad7c9088de42a01e40d48d286607a6fedf1beadac3e3b39a1b01bbff5a319862087c72f7b353b9e4e0f5334dbd7e5c467f10e8382388f4ca14feac44afa4a87ca32d54719bc3082020a0282020100cfe71a6226d55705d4904a9cdce0f030b9283a067064f577c106252f171e2f0258c79fa0158bfde971b2c1b30c233c18736dc6a308173adb242281fb103048720904d9c163ab861da043ac2baf4b4edae33f4b341be51da0da64cdddc8cb624f8347618a4601772496f70d798f38592af31291c553fdd0d13539aa9d17544fd4797582756fa60404bbb306e5170d0276bab1ba35af7ebbbcf83623752991920179de99e9b824a4b39d518a283d870f2091ea2bb69d62c6be790a1a2224a96342a113c0bdea0d7aaccfb2c1ab18566a7a5eb5abbc1231a29d8adedc1b27859493a17c21239c1254f088aaaad1b6c0baabd4f9c567b0bffeceb7dbdc0c9b5beefb28faf4619a7f81df21122229be00e9372aa3afceaa719bda7557e294dcd2cd42c0feb31cf9135882374b396edf34af1f004e9fdf2084d828ba51fd5a4346861622e044fa9c237ada92c3dae3442a4007b7672262d8c64c10e1b7436b17df980ca12f266a02fba83ecea5a573a2c0b6883e2bbf2e29ab4ee0fa9c00a5678ea4f93dff518c5abd0036ce26b5617e7ba3b8e12f0f381f3332b0fdfe9dc34cd904313a2e7c97a0806b996876f84fd71b520429bb33ce2cc5b517031babe1d004048ccecace93e9a47c06e1928c568b02fa0a220ca706841752e910725c758000ec6242ed677ae8ccd8ee76ef68cc9c081e3d317890cfb375c5896c1d5aa963ebea090203010001", + "profile": "pq_hybrid" + } + ], + "generator": "macula_handshake at macula v12.1.0, OTP 28", + "leaf": "61206c6561662063657274696669636174652c20617320697473206c697374656e65722070726573656e7473206974", + "now": 1789000000000 +} \ No newline at end of file diff --git a/tests/vectors/identity/erlang_bindings.json b/tests/vectors/identity/erlang_bindings.json new file mode 100644 index 0000000..1a0101b --- /dev/null +++ b/tests/vectors/identity/erlang_bindings.json @@ -0,0 +1,54 @@ +{ + "entries": [ + { + "connect_binding": { + "signature": "8e3302de5e6b923517bd935edac157095db9ce3f08e489d1f7b5d237604fa68a9f883c92f307789112b1511c9147361765bc3d8dcc78677a37d06f41d8bd9711d0ac3f6b56d78de3b66798b0aab8d95cb25624ecbe9e8cdf3777f39f70c27f2648227d8b54936edf41ebfbf4fc26c49ac13ee86825376e1d2a7b808b4e62c4c8e3625770c69175b0e92f21a2e3a5d07e646556326f2211f3ab963fda69d2b2e6f488ca2e179e598d6180bf63f5ecd131243791e1ab5f0b6f228e41eb9b3c0d6b3436a34dfdcdd2b6cdfd39e41203de18d55927ddb16c4775b4c3f348f13bde165f2368729a14c568a7c3d491f4d1ed4866d5ded69b7c17de3d04774289f40094d71cd3294b13930c063e28c014b8e2fd72cec7e61df2e2fb74965b5057a79606521243cbc5f77ba73e790e65146cceed9dc7f460258cc19ee87b4ed19881bbed68d761b08bd410e0ac5a9df5a6bee1ceb00b577d0f14025ba936be662cb721525640f90e28754f9e48d81e4c5838a0e857822c3a4326aae972089796a07cd027b7548e61a3243d86ba2c074e5192496bc0f81392a5d0a1d8a2bd28fd37b3fc0dc17069205a35cdd5d938df824fc474cdcb51258e5a0d93d2ef690a222de4b13628bf25a74e1efdccd9f4265bda02d46c34fb23097a2378259d8ee44a9088ca4b74efc14a7d29a4ea73995f22514ec1f8f55083815b9461770edebb5e57d26faa2f1d62e9116549d5aa550ca7d80ba454969fdd2158712bf06d7f822f8adc0ed9827a46708f05a01a185ad70eb29f3b8436437ac200498290b05c41b0aa4ed1854867a6529842f513375ade7f13ec2995a4c4dc3cde320f2d46bae447d9fe2c6c13716dbb8c148ece832fa057e98610b018f42166076018568cdec7704a445e125257f90a593ae63fc63f4883c8da626ce807bee1e31d63944c0f5d7b880b186582e36083cc7b20cd5e82b51b8c5fa32ef0f453da3ddf08d4eb1cf306c5963ae2201413e60be61453d7f2882c17960d2b655be85015f15dff11cedb7b0c6da921efd4e87a9090cc5455f2d4f7dd4af41b5b41457ad75b2388d8de70f8ea8fc803eba25a827792c6dac769ed9aa8ca683f7288897444856f2f15965da0d31dd1d6dc1b24bc56113cd3368f105a55a437c5322812ab7df99a321ecf71c1c5a4606124b03d51b998a10f9223cd67a2923b56ac3b2fa23ba45904eb05d5cb92cf21bdf084ce81ac3f45297d63a14648c5cbfb44d685878dca9a522cfdce5ce3c3c0909d871862c105c50fda7795cb34503d67cc2068edece1b0ed0e8bea2732c053e58ef81afc9fc2c9d76266d11281b4e4b83fba3ec2a707992d7a04b02d3f4c04e92674f0ecf1ca6aadfbfe357437a6ad6c8bd30907f593a698defba86e914dd4487e74072d3fbc4d6c9d1a93c34fb9242170d72e6a99a8a9e03117c6786b545824714656e0db19d93586a1b9d678c5d08800b606363ddc1003341fb87d5de5560eeaa96ddece51f0ff786dac932d8548e55a531ac08e609b365f0293f99201791c4b2e53f11803b2beb2d191566dc2d9844cb331e761ffd0ec2bb258f85453ebf046626ebf03dae49f6e5df8ea212c7f321a29f7d5c1a0b455391c52f988af2c111812624ae760a4dbe92d7c84e193ab876496e0d606921605685b601e59e2e9861980bce2a5d7c378dfb61bda6129a030d954fa28539725859c2e45ffbfe974190cb50cf8dc637840bc33cad225b8463631f3e32b33955d50c5b032c39a489fc91380ed02716ac212c5ab8e94205e6ccd9f280b01d19d7638de1f3110f0b96f6ffe39310166540dc8f8114049da5750af4a12bd3f3a88d4e54896f104cbede05a472510a6097b35e79a3d66c3e550fff4940ad25aa927642d8280797973e97c8c52ba8d39a4849b5bbdbd73c7dcc5ce01cda56c122abacc4969d41d952632a3477c8316d87c57257b394dcd9495c54243d430b48c590e4ab996caf0476fc417961e6ca65b25131595bb7ba83d1818a37fb3781e2ac01f3e7b654939d881cd652f3f526cca8326472597803af73685630ef0cadc9f7621609104d7db8c5c26aa9c12331f11c3b3bed5aa18e81228b64139f8876012e631417c0a9f61a95b715a8f6e1b83f1544de1ff6801433c584013dd618b82884f9e9d3e6e4ce97e9f20735e5abfa67109fed30786e36a0d41c0d2de451d4077af3bdcc1b84d2bad2012cf03080ab29d8ba16adb93b31be2821648351f2a551075dc9316dbc5cc2b722e71cdb793ecde541e0b0cf3003d9ea347fd7b68b0ce5bfb1a77928a8126046845f482f879836c926fc64b5b7165ea8a17b56226668193c8a8172f474026db3eec97810615f864a2c97d1a725d3aa075b11213c3110bdd3a2898dd02dc044223cb07c72a583cf0063d7875e47b0f935bbb42b3683c1f699d826a92fff2f2bd984518721d6ecf42924e09c5150b3e98ea3a3c791c023a61d4bc3b172cf4c9d4906d0d6455f4009bc449b8a8e5e5a50b005d0e218a146c1862ebb6611373774a6d90195c033bd15133bc891dba9fbbaa416ee7c009832533a8711a71dd17bbe5cdc5211787385e639d8d3ee9494f1e68c8bae535f1bf3541bec7d13290556ad96e7589a5bbb90b649d7a43f1011949735682aeae52f073f303817319d34ac63329c339d1dfa28cda1a8814ecf741b8ad19e04ee7153001b8f2ea688e352a33e45f80bb4cacf7b46b1dfbc308ef8d1fa7aef667dc3076046bafbca174c4ee9fc7664bcf14abd6f4b463a331eecbab297b42c1ea294aa6c0b88c08806bb8e5d318586baf090d7ac1d29f7be73d75d2e89cb210e066d952e71635576c00d513e7821bc894beafffa292cce05b2f900a6802a6dc7e620deddd78fb6e7ac67382accff516afd4e03e5ad7a99b87fe35af91a8540e76ec7210761d777ffeeb15d0e1f09b7a038778c1d134b871401be667e45588ea3b102cabc1dc7e6a83ceca7e43de67eb8bebbb1ec193f646d495620dab7d1564b7e12069e4225ee16f79bad8bf8ae2b2f0407a89c79f8c7ec49bb5991e82258004a9a19aff8d70458e4885bb27b56ebdc50e595d76096738db3f4a9f63f03627c3a4ea7dbf15c945dc9039210b7c7f3ae762f48917a7c4c96dbf588aee222093dd6ed6f294a14175a08591d1a676c42fc012389bc4a60e7050f67bc588ab3d480a876429d6a9fb5c5f7465b62fb4d38fdcce7433b18ebe1ebdc3036dde2fbf00dcf0855256d2eb8ea04dcf3bd16fbeee66e784a593eff4ecd75e2f801badf820b53c6899a2a21d5e0d8ceab7dd1c8a50ba96206f1e2415a8a11a0e0071dd3afa81ea923bef16712c2404db710b362088b279f35b41a7f021824bbe734eebff3001389d1db152e56802da08629b1fd028491982fee357552c2e0a8be9662867cae66928889774d0d89ce3bed45c7f8cd27de078e88c91e52e06bcb96685cd9ef0378a35490c8b7716d0ea1a5baf0bf997595a13ae21a4cc6fc48a0181ff74e063843fb9bcae5a8349e17e0723c71f5895426d1c3571c0edb40c80093c5a613b9e3cd1299a75d7d3231e515eb86e0ee5a83b20ade45b0cfb03ddd3e6b9e7403066975686e5d65696cb20df0b386dba8adc48b1d6e9566fc3650b8f673a52d68a675c90c575f58ee35575b6f7062dd185dc9fa706a1827b92435b90bb10bd5de399b758127bdf15c27e9a5cc799f5d98a28547117ff7fc1445218ef3232d22735d1c9b117a7c65c4b961642c1c80696180b3d5d039694fbcca14db70a667a6b32f636a1ebb2fa776912fc4efd76aeb9fb183a0994ff12a9ab7fbfef94d02bd256e65ebdca7f3eb4414f5e9dd6667f6269ba7d3dd0869627db1f933475982e0d6e55a404209857d7b98a2479ec8d2c4ebf6ebccb323866b254af2301e4b196d89085cd3e6e85e706c70d208445cb461aabc7f05da3b7c17624c66a63ffc89eeeaed55b54d5c655ca2003890bbbc1304a58bd01856a7365ca76914bed097ac8a9c961eb5163ea739354f075de9fcdb4d6504f1e0509e332ad943319e3207a8f002c7843432d55fbab6372e06c9d9cc0e90af55ecff69a706862d8cac47a3fe7f899b5ee6db335d3801241a1833e7eaac212739eeddf1d75546dcd562bfa26ceeab9fbd9704d1923f031a44dd18d50c9f221f190f6e51b7d9c0d5b8e5efc03cacc2597c489ed5ac01c372323d404ea0d5d2c56725892ff3af7a0249b3a51bdcc4f63504a2f7f0562ae61b2af47e96414e4b5cead868163e62839d3d94516022339d6c29a601ef7fbf66e0452a53db9c20511705711fb9a23e8fcf39be77eb5461d721a35de5d69d63990a3ba592134ee8171e2d4867bb1527bd018408b069bcb382a7a63e98d1eeb8e85a4bc0eded040ee09deb0a581f1e887ed0ea1a5473757a22944a4df30a4b4ab72689a93878cdfe9cd6e44522b8f14b2293603e8d651389fb2681ead876f189336a25b23ea5ee2c5968b99bc586d3f928bfd6f2873dc7c64bd75a123a2a113f36e2cdf590bbbb99381d0455b3086b03ca192556cf919a15ac334f0a92e779f75b96e7ef7bf1ddcfa1367de470267555c4f57324a3ae02ec4baa271428d4e84ad67aa841464da2ba57771777e4a83327df2924fe381d9b104953f069c0227ab59b4429095ee63ef9adae8d06754ee51304c98ddf5050ae6e609486065bdfeb3ca264db3c4c2e1dc7d38213b3dad168ebe44b5d76337c9f421d90e100c1f34e02426dd609abc6db72412d4601e44c2b44c4f75e97f271e53015ccbb02b1eb777cf14def7b43bbd7ea67d2dae9c042f70e949c79059c7f2a7b0e1d8ca694c985c7bffb3ffbcc20585e11513ca3b528b9a69b77086927f3212309e2edc3fc12185b78cb4cd99adf0adaa4bd33158e8415750f8713a214f51ee3030757c58579180af8ca1d8adc994183d9659ed3eceaa0aa3e9d92b95f17a27817ba76620adba307fa67f87beca15d5694878187b7a57e3bf2b9a6d46cb6f45d93697f56743ab0f53f387459b1453327d2b97b535e79e8c5ccec9de289f2c9f32c39d593e6a4faf62de9d544aa3f67c572bd3e1de156c7ffce9b672c40b324154f0c238332681348782ca7c7fb025c2e2e61165b8c5489148e21326c33e878c99f6408f57a00c774cda94096156d88ec8ac98fe33d090273565d97860531ffd7abc629d135ba131f8661573be85c6f6b7333a72d002fe3cf7af6d52d91c6cfb05d98a4b3f2151f376d042d61e63dbad617acc0cb1cafedc10258dfc5c53e2d20d49b6ba5579af8a13ddfff6b91604078aeedf0402315620c66fb3999b2519ae435145a61675b98257fef645af7f1d0986a13038c52701a4088f61cdfe166bc65edfd821438fb058e94d3e1458ad445a9cb0702acb0d1d3c71d211583ee2ca45865bb309547c7616e879c1e2d58695eec23275e5cb4f2f7a24e73054dd4a9aa868cc924185e82874cbe820964607143462c00bda3a312f6f1ef3a2ea53869b0e2ef1b6859bbff66dba7148e763b1be06a12110974b8f9a5012439ddaca7cccd3d2988481facc82450624228a64d4b765a7f96283e89652aa28ee73bc43a94a60de2edc574920a047284b93a82edcdc227f03cf6edc61f1b53f277453bdb9e81e76a80175c1cd259a8c7d6f03562f68c2b963447040bf5ee60a0604526f98bd415cf60a72302cc120f226121e94d0edaafe0032218f339c8d5dee18a33fde453f3072d6e6af37bdadd0d2134f9dde2e445900070cecf54411e38d49a825986a70f90a775c7dccc2b51ad05189f14c3e2efb2e4bce7e3f3009c35734ba611c11ec9127052d6475a5cabd64a3a2560901b349a409f93d4ffdf5bb475f9f104c0f68a1ef7e3fc882d5ecf521669754b4a55995db5d43304f44bea4d399d0c9ba76c5782320bb85d078c04cdd7665f61f49dd903ea4ddfa4433105a0f87c28a2f8511b792b4fad486d887daf1af918f6ef9133eb995c49b395389b0a7bb8daa1ea25e9f1047324f8c099802e50dbe5ae6a62a51917bb46dbe05b3e925acd2cebb3f27ea31094af3cfb17b264f917e8fa39589be14c6de7cfbf72e277a15f352f6ec17e37e5a15d1bf8e8e5ef0c3e3bc3a4d4d630744d96a6289cb628a86ec31936e4503f4d83bb62554c346238531d39b0d238edce50529723b5b4fd8d016b2adb9da3d4ab0032efa2c42339f4a7ccb00a9b9b5fc81422e8b15e88de6050ac0460e0a529477db00e8054aba966e8d09b53aec1b73c8106bd5d9825b7b49a27a6d739875ee64fa75aa348aa41263bdfb2a4ce4f098cfd895df5d0be66d7b7755894466db27cd7fe32a8f7a7ba18186a79731275c938ecada3e2a47bb07885c1989624868389affd947a115da819215fb901bbb85b66a105d2d9d8c0245ea40d4c3255b5c33ce6b82f9bc80fc8b69cc939c300518c1b652752e3f737981b5c9cb23282a84b8f8bfc905456c8aa4c5d7dadd195079869294a2adc8d8dc42889fcedd16175a6d92d2203b48535e698a98afc2000000000000000000000000000000000000080e101924292f39", + "tbs": "a96375736567636f6e6e656374656c6162656c781c4d4143554c412d50512d42494e44494e472d434f4e4e4543542d5631676e6f64655f69645820453222b1efc1868d63e35c005b444fae9fbadfd24ee2bd54cc2ff5a58e6197b1677369675f616c67694d4c2d4453412d383768686173685f616c67675348412d333834696e6f745f61667465721b000001a0acc226006a62696e64696e675f69645076568265b05e833374965197a96bbb976a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f686173685830bdb5c502f5d147943dbb7dda272f9ecaec4751115e55fba29a20c17662def3b520096bb80e11f4cde713113490553133" + }, + "connect_key": "ddf84bd7e30566b55c95cf9ba23427f2f7ead3cea462e89a5d2cef7af0e1331075a513617990708a6101fc0a53bd92f44c9667fcb6d4188052880bcefaac3591c1672d1a7380c8f466352ac044d84ca2c64b859897a143c8c6b18fd4968c52f7e9de8bb75d14b144fb05cbcbcbbe1d4735eee0f2c0808e1e0bf2bc8f7890fab17a1dfd96aca344b2d5ccacef3677da2fbe0185c4e8d8031de4c5a7b760a71f95a6816e6888fcf66f2c6f36fb3131a1c7cefbcb49426d39bcde5dd148f721722974b47bbc5ca5bf153756378ce55ce18ea9b5f451acc17c2f090cc42ded5b9312a6eb49afef4ac7510cf1c0a7db6ad7a6b3000e73d3360b2aa98c95e94689de05c04aa47229d5cb32e6cea8b8630f1439fda72b9918fdb7b9c0e610eed9dc7a46a84440af1154c5d2bb09d9f322882328d32a290f90ca269f6d4dc83e500fef799a836394d9b5268c73a5c097987d35e8460b77ee660a2ff82e6be372b3f9950ada976c65d989bb343adc43ac0d7544fc2a4e07579ec58eb2aed321ebdb142e9c14850093ee00a5c0413f4700d6e68459cb3f53e775e5a91fa2dba46ed2452bcacda47f1668d8205575a8a4e5c586027e24a43d68c7fa51ef5717b9d1bbe7f8183db455574945dbdd9eb1602a9598bec20737bdc14946e9d0ce9777ba1f9a3395e7ec00d7ae7562f44c18323e7740ebbe2f76ea6e615eb0077597ac538d47a67c274ed2261ae1ee84525f4ef556fd6349d5db6a9e08ef295939f658e65dfd4d8c3088c305cc93d48e49395c220c82b1a89c8c1945e5addb6ae6e2514f6a8264beebb77cc50db59efbacb40afccf65d43b74a14734acd09128abc4fbad95dbc26ff6a68aef1b8ab8275682dc51d0fb0d158f34e2f75e649cab0302692d61b3478b353b6c3d514acafa201d06bf3265762815319481bf8783bbe1b3806cab3eb5863429f220348a6dab46276c3e91c2839716abba0c9e61ab67c7618e54bcd8a5665a14a0575d228ee1990264044ff28a8ded5910a22a37012bec32913ec0a1618d9c871616c114fad1367d4ed9de51c78cdfff522284516c642fd6bb20d45419deb6536479593b8587406316683e4704292712f1cdddb3553025f04b0b6b3d8f112c7c646343c89332cd5b21c0c94aea33aeacb222dbcfc720a82ba1f519ac952eba7a1c1f4af4611e57af7cef9ae2077b76639cb3af007882e8a2150ffc757d87881be4f6d33d1dd47afb47a78d3c9181452de5df133572ca6b4ce4780359a1565458ab3ec307b65ef6be22cc92e3c873e93f5f5e68afdfd4ada7a48312264ea922071ec8b4e21d12425d4f299d2d8a6ebd501c0a81f00e893cd6d16de7cad7695033e15508765b53430f22221e7c75844bb30cf4355ba33d9cae9887e9dacc637203013e51f7da191b464aca3d870534c3c61b8d85bf574afc8268a04240fd4ec0571a36cfe763b7f6894b367576605348d0b179eac19dc5f4cd72ddce8be29872b09f05bde651a625c03b03c2cf84cffb894404ff82ec49a31868d62695ee488505b36234c5d81df318d79a543430a1544ab3c09bdcffd3db40b5a352781165dab2b6c8968bad2ae66b7e7b077bbaae08ab7538d2c46b79016c2d103e36e4bf6bcbfa0b7057fa4ba8c01ea5c24b12204a28639ea91eb4241e31d5c0fc0c025d648f7eef1432a275590c2aa6c72b6c878c3d79b4cf9bb3f5026c5f1dd1dfbcba4cd3c138b2b0e8f88f12e71395d8e5c9755325145c25e30f12cb0c181b0644430105fcdbc0c040b8d4c8ea0f5bc79feeb4090bb94d50e0d61e6ab238e14a1d2abdc750145531e72c08431852b71e8700cb81faf13a8a6dbc61591a02c7c8614cee51e32f920c766a7ec5bbd8f277b51222f82ba3839ad1a53f087258f5c5e881cbb0cb82cc8ab4eb4ae72db92962246cf3a465d2327f52f49dfc2717ec83304618d8ba1a3b55dcd38e19211f3492fc374954b29bfbf73f2042407fe8ec0e5bab4a002d1654da63db28326cb1066d2184a19750ce7011bbeb44ac013fc8e1332dbb6e25684ef0e9c59e507a45db6f1dbf2137bf7f80a26cc5a70d8684a7b38a31be349cbc8e15173573902f00edba6b58ff1ef90e759b856dba26e89bea00ba82fa6a1e008ed5078c44aba39620453b68f9c6fb6a0734793d9d18265cb68e9e90fc297c5d5d05440b93ad474f0cb7f8076b36fc062ac532d79f34e889df0ff5e63984ef72bdb58b2fc094d9993cff5d7a1adf8c5f297392c0c776bf0921412306749b669d77d34d5269460fd4a4e4dbe148bbd08c01ffac16b63a08bcbee4653aba376f0e084feeb7f323d44d1cc35e4261e0f18065f6666acc5c991a7a26d10519141dff42e224870ca38f45b926c080c5fdf4ef118f1637543090926d26596490eea15e91e2583506df876a724c89a5398f0098ce19b85d8ebde424d9147f505efa89386777ad7c03b1dc701bf7e20da6df50207ce7997e99f15972f75d0e3041005f4aa0fd4bc2d5dc216200897e38bed251da21dad496ddaf8af6ba4c4f6e6a3e6314a92f616df5d4243739cd3fd2c356dcca8bdd22a5fd0147c5ec1b76dd376f1c3f8d6ff7937a563dbf2247e3ca9576d18771ed0b9a0adc152ecf7b5ca7b742b7404dca32db14c1af7a11f07a59f61f74c46f42c70904a41998e9cecd7c057ea2bda9847546aa95ea541f60ecc988752a38615fef1ef7871f952f69416cc44d6c8e6e5be47461da1cb4713f86bdd666a4e42a73ad7e72f17a1a3a89d7d9ad05642c5356528843b2f071dbcc7e992d40c796dbe3a74d60bb7aa8ba5e0a5daa1826aa998385babdd7990c8f4bdcc4c53b8a2dd2d9482bc43922ce3e036fb02cc8c4ce76c6cd806db429557ac666bf24b92bfe55e2c5b688fd92cb6008791ef5a3ff90f68ee8ec83db411e15c72376186ba542e71e98f4bff4ac8f477fa04ec0e7d8d93a024220730cf8db268cfc7d6e083fe465af0d0695b475a98cc1df0a22d3fe5babfedd5d038353242701bcce7c1b2eaa11ac758b7614d896a41151f8023d95dec1b1b7d2f1888fe5a04bfb7b10579dd594a2521bba5cc9aded387ed3301f4fc10c585c0c8f9346c19db5000611cb237792bbab17494be949a7520747a70969d50209326bdfaf861e8d2fa99ab031af019c6f26d6e64a7a4287055e2281574c2ed3e3d4c0f6dc89c7eb4298856c35882c2cdf3e8aafa73df50f173b3542665267410e9c595b1abb7ac5c5b350b601e5ebacd7bba21a5538c6be3ae1d484330e6750e1d4a7d69df4768df127580acd881659155f2a193047c0e5543e3b214227419417ccf9b47149212a387d59d4271c1899fd93f46ab2e6f1d2f54cabef9eab561af5ad914c2f6b1406c9d12b239e4dc964c54a748519e3fcf6eaebf735e6ad7a8608319a73a6492c37f9bfd1aeecf1dd9d4bdfaba34fc0a9e61eaea2aff4c3d84a8e81c585aa22390262ad07a0de9cf954da68fba47a1aaa97c1e318b504eb600af65976f52bed30cc6d67e134c9a4af72e379b1c4bc04d3978231c620bf4a4450c98ab4a16ac3c59b1b5d35c74ea28198335f18a32fd3aaa8025b94bda3cf2760e84bab7f37d5599b3ce0fcbe5114b85ff4328563fc7b4e37ba053d8225cb932e43ac42aca9b12fd40592784da468704944dda9e9384e027d93d0c8", + "connect_status": { + "signature": "18a150c3abc11c1d9ed76294720ee2e4f5aca7dc22b3eb9a80c02a0a5e05aa44990b03326f7b3f9e336127877763ca7401feff0d5d64118d4744658deea3ac5077a0f9d068c7016edbabd2036caefb1c643ead283abd3a44d1e234f2e9d83f5e498dd9b4dd66e05b30d87ca37b0b93c8a5d948d727bdd40da50232907d3bee0280a948ecddff206582c10120399243f56eefd04b905ff0553e4acc770e2cf4631f69d66b6c957504de9824a3cee36dbca1b17720659d8565f56ba2bee0a9d0e495450edb168bf257e40e99690c13e39d44badf4bbce25e04374f51d0c34c5ebf57c16e3a1dd8a2646b3c71b4baf6b0a204ee45c1688d6bbb270c3ce79bb59ab40e7920eb0463c7e5b790978484c25a4446b71607914359a03fb762aaa6fcbd8aa2a75982021d08088af033fd3bc8bfd2ccdd246bd9f0f289467745d9698e26a9d82096761f0fb3c27329c2b621faf54924da9fd7bf14db211ae9c6921f9e2300a8b5f6eeb42a0dabe785d62f22d26f7256b665b75a3d6874a2941991894c9db83ece3f02eebd6e1544bc26b0ce33fa8c95ff18630503d901f8e5a2df86171de8d338a0d6ae32110b2b0d376ef034117fbd72c6683945389004cd508c0a81696b37e656937f04dee99a6939f2ca9e4bf8a8284839c2df71d63fb6a36af7f343d0784f43a0925e376ae90dd1d936a39bb737abaed6e7f9d5b8bbc291c9afb27f8e1e566f13a1a606a7acd6c47364d27e52e15dc98527186b993e78bcd434f41cf5ce0ed004c568f0b3271e5bddd1b11bccb74625a47b5655945bb934e38cc941a78697a39b6c109015b1528a8f1eb796f336770cf8112b3c49467f9c399899a15e0fa7dcc826a1c99611b7ae47f61f2709245bf57764ae78d154ac9e97752fda9f4494bd39219cddc7376ce286af9ba962e4c1c4770de9d2ee66749896aab59045e5a9990a549db52a51b58cd99ca2fedf1f0060406a1eefb42486ed2bb1cf3734c470e065e536cc29bfa90b29429743c94becad6ff9a62e1cddd6ddccac1486de1fc17926e3bec111eb0a7172e0e901188bb5154318d3bdd3dd880595a301f05f8851f6bcceb21f84eb133db894406c79af25074526d60a7977deb65d87fad800d4075e798499f988b4f58d84ca2f4c963d143d90c7e8c019090b7d2cb170a6527406c60b09b05700ae537d15fd73f2b0951b8cc4a31a0e57560f836c0048386d87babe3646fc2bfcd72233c98f9f659f717f4b14e9b21081553b59c7d99c65b826773b4b6e8449328eec63541d90fdb69b33a871e34c02b4e60187140a7f33c8bc6103b5865fd82d9813c99542eeb1f004ba924739305daeb6ff34c2a8c6cffb4634af3c6ca02c9de1240b045918cf15e8543f93cc6bb88f7de34e9aa44af7efa272f2f7010ab969744bfb7319f6eac29465be758cddbcddfadae4e24a20cb91662db223206281c33297f71d3dd5f0092982dde02718bae98ee68df0e16b03ff39a6081dcadbb4045b3876a3174abba0487e2abd6527e3efe5fba0316f43b313aa6e98bb8e04c622152b8552acdff0460695a0fc913d48afc250085ff24555ead21d5279632573808a12c2446ba8a2f6d5cdb6aabd9b5fa7e75d883ce2849e632fd025d22443644725127320f3ecb593f7ecf7a93a3c8ccfd800993d623d933fccea97cad801b89e8db234eb76f686f2de38309668de54b653b0a05610c65c729c219fa2916aa0a9c669ee0f0e4b8a24ca5dca614084fea18319500f960794f53cd5faf52eb6b065e979cf4e52d396fa010bd315cfac83a06fb58ec363bfeddf9cf70c8665428c8ac55bdd5abaaabacdfbc3bdac19cc19c2d7311c7709c65b3c59095ce5dd200f474f9d6e6b295d981cc7e806d52a8773b2e40ac5f02994d0e2e2a770de736b82df37ef4e4750eadabbb1e6edd3d9985f797ca9ae52ef6ce90cfb2c2e94fe98d0548308cc4046435d2d19110ba09f2b93342e799a95847d158bbaaf6cf49a0661520b6a3b003ec7067bb685c80eb00469991e21b2b2d30012a43bcebf092fe4c7957f41721323021dc3e5dc0e10c8a9f333376051f13325fe2729347e05166bcfdb52de42d081fde32d57ad85671537f17e59f3908549cb84df292f2e5c839fe56920a945fff4f54f2172c23c5de8de428fcb6be7cd6e94ceda2ef754758ae406430fd83675954ca9525689de73de5a19d13230db0ca6382234793c97037db0bf6ecf17183313e6c4e0b755c377bb74ff852ed725f436db7598b2b359a70738874efafd2341b93f8b5f1384bf86b9495b523e7367042f572ccb4165f3b98c17eca7fc3c1ba988dfcd941e06771413f79f88d3d77ae998cb4a91b88dc86a1cbde5ffa30e05a50d23f7b7c765a66cc780b68c676a0f48ae71ef950e0bc8214a6ac1d210ad2ad676de23b503afd86db133e10cf225fc2787d92fbace6b08c47424d4145cd41a7de90c90c74a22a928ceff79756c56f5099be7bed6547eaa3fb92a09fc303a9343db192d931cc53c8576fc5a7a7b1391246eb2025352aee17705733a09826e8140c8a77e7bcf0eb24d38c88cf64c7af25bb7b5d1d4890d9285420685c3a1332bdb00426e6450ed952a04ecce7c2ebfbb038b284bf39f4b59f0b089fc896dcb97b819b37c6ebf1e8599be01ba443aecfb485957ec346f385fa798e96430549c7b23ae6abefa853216c16986511a33ec72a9a4265df17f5178c563b4ced9366f1d12c2079a35454b46dda5a3517e400549958995bc0c7cad49ebeb2e8162a5146b90edac67242a604dc6be719a92598614975e20d11c17403b3b260253c5be2f25dccb88b5329ca6ed0ae1577c1865808e48307fdec9f6b6e59542ac70b1c87b40067199d6fa529fe8af5efa67e074f030974f29e1afcddbf93fe77c5b9cda30bd90c79ea930754b66d10ef37463f063fb808f905676a017189fdcc92ac5e5d161893984eb7578eac5ea7c0bb359ca4fb42132b55a932ffda20c56cad4f041b22e3efad250edf495f5223460e667a8d4b0e15945e44ed8418df9804f11e4a9a5345e6187fdf799f7622352aa0d9fb9cf4eea6fff41d44b699d504c00249e5d9a3597a3636d5038ed1757c96178f61973b09bc701a3df20f016cf468d3c558e9abe740e9009c34ede579dcaddf0ce8f2bfe1125d5d8e8b6de8d61a2355e28560866cc4eae15bb966163662e9b9da39b9bcb5c56c5e2c34337607086c40c3d91fe2310ccf5f1a5a19170856ee428c1966a9a52e4a5d317fa17e853cb0a02a4fd768ccb5960d9b5a4e11dd59ff7486d2c26aa3535be2b63c2d0b1f40890600a4786cb50ce25e7dad7551189531e2177b3c0390ff7df0d1d0f7878b03a9a3ac3ac2b2ae6f6716446ace27418c71e3fa5f9b54bc3127e2351c0009f359e5644ae64f791dd5c1c33aeeec81f58b166a2cfbcade3caf98c2d7ac19c035956b75e15cd42ed4dacd902e5b7bf857cb12c3877c822e34d279347655caa1df4dc0ea9ed07c5b55629a4b91653e3c82556bcad1147c397a7550b4116f8dde8a8e1da608b064c475e57c2ef183162b48fbf77942465eb228232884415d9339dbc9d95b761183785db32ab57137ef4f59827f08f2d444e37b703f1d8c4ccf041c4ab724424d79ae577ad6bd1aa011674d9b2bfe37258c14ee579c37ae9221e79c26aee2291ab2b80914fad10ec34020f302e81cfd80b65008f1a2207268fe19312d9c00fb594f6c854eed6c6d5c79c36bdd34f3915ec908994e04e96745cab10798c42388eb709bec1f419a6541d70221a73fdfda8fa9dcdf5b6817ecb8dbb5d38670696c64e3b3e1358bd2fccba4b4cee017ac581c0fb4f4002b7dc2c7b7f066f07be649875a59ac672f2ff6f0d25401fa75704a60d7c7e03ab712b93d0391b4a91b5f6407670f027cb79017405108f692943f6dbf48aa5ea6b81a454f04a3e4cbc925b24594a69451fa35b2fa251c5a4aaa1931815ef3aa3410fb17739b44cfbd8175db7a8930c5e2ba9bb7ba49fcf7d0b1a475f26a25f217f3e0a4fe3affc82557f4d84a2221fff2019c55ef86ca0cc42cd618399b5e78e61286e45b9e2c8b54028848323eb35f62dc70c2a7c17f83bccda8eb52c4c813c666c57ffb27985f548881987a3cb49941cc5e4431732db13977415a349203c14e662fbf2aa50fccad8bcd918bfd0d3a4a9bff43c1aa2cb5d58c64b69c71e849b7b5a8bc23af6318eef8ca7f12e721ee427f7f58f6f3d764343e38f8f9cba3e3aded0ad0e9a01168c1972ad9fc6828d211276ed84131e6e8c77a99b6cf2e33a4326aa4ff9a8efd3eb8dcbf9a20bb6e8f395e44577fccc67a0648afbc63c3b6660cc443e662423ee138a7113fa1755ab18577affed09f61b5cb4f808013509e7e92d2093cbafb23cdcc7a592a2835bcc29aa878e0f17cd7db74153045d60739e367e8994867bc7801d3034db3d4155eb89941beb7a17501838756e3cbe91f0ed4aca8445b0a6511caedef7f4bd51f248fe1cf5e4f4797dd5030c311b93399f2dd50d158b802d6cc9f606c3ddbd3622a199326e7d2570a688a7ccc25d6721dbb51b29d5b25b6314288a828d9d093d8bb14c7804f701c62c4a011ef4ab43dd4b52b4decb0a2acdebf2508d0ea9f4241c358cddf25d71ad5f2c4ca88f23c0060dc81b77ad0b7cf3553f47fd89d23a102927427622485bfa16d3e5f433cb779f3542cd3cc6ac28940222befc96790442c214d570b4f2553680707aed586e3bd3680ef4052aacb6f610b573afd249d961d00dad8a348d4d6f49daad7a92fa3a753d0f0e93a9cfc3868895be3652138af0f9a4e674d605bac9242f24f4d80318eabbe4aa7e53e918f978f4f5bcae0d61655d91e61fbe8738e6c0e89a5f4d511e956c8f170507cdb43194b9f39b6549a69a75e603d5a4f9ed10fe935f0b02dc273c47999138a706c36531e165af9309cb5b9dcc399c19dc1c5296b63be3e42e6220f0a0d055893b32db6613d004cf74115f2e93235390eebfc51b503280917e2fcf8182223053ff04e4131fe7e3c1f25267fdd85ac494638a2273ff15e07a4898327ce83b419ea925ee065ba529a13e63f23391e257810f0edf51c2488a7a2208a317f3ee76f3eb6882b3941aaa597671871c30ef015b50d2d3e1c68d18040bfbbbfb1d0513ed7f9995b6acfa46d149a131750e792d27afdca533e7575719f8c3c6007f08320635cc84f5e89572a9cc85a037d6b8bd50585fd58ed8fdf1b32630cdaf42e3e53c54d6119bb4e3875e495abab04604d6616d15866dca40db44c285fa6475445363e1feb3df53e6c3e169cdaf15c7699b113df0cad43733135be5bc95788d910432566f3975de9b83805927c991090b5048fb83ddf2de084f28850fde238a7167ae8bd5e84b5ddb3431f3b4b558a6dec4b2aa2654791166c89e6f168af2954e8f979fe1e37f7c22c13b68fd4902f632442506efc0bf93729c020d3afa8984af06f5e027683eab060e55d210dc51fbac1c8894b15d39e740596bcbe18a058ac445e834de0fee1115af0e4bea766bd7e8d04c11d30ee9c9b8cb08cc7fff0edc7fea6f865c4448be6c6dd65467d31a184293375e3cec5e438b52f5a695273849cb72a0b228923c326d71b7d8bf6199ba66d432fd25b4f8c2494307ad186c026b4677a479416885c912de3ee6fb994ed24d16c12d4460f1bba02e476c622cbd4b9cbe8b3e5d3139e26447ade2c1cd2c4b9914a405768c9ac464b82a4394ccfd7c173b9710b7787657eb01064e69b8609ab61d6eb9b9fcd3d524c252f1fc5271a554641ba457d29c6250db566d29b39e69932f059cf0827d806e59d9e209d59be4a515819a4c3b2b6de847b073f290ec2813b1bbce2c28f857afb904603d0e1df064db965e92a4edeaed06f40ad601adde83a899a4f803f30c6f9a22a63adbd9dd3b3c93880c71fd9af7ca9382a601d1c764e8148863a6c08643435a19226c5226701b291ea2c2d6236558f09783825e9b5c7c7a4eaa2c0e0a66ea33cf509d4a23c127f45f677c5a147addee2b5c80b4b5822f38d676a971c350b7a6f52241d1fb7aad30a136c16c5bd35da1bc5001d6dcc943c78611bb7dcbc4c4f2b3be4ef14d4a089f90c7e5542bf773a3640980257ec4d64d2841d2adf0d9932364a89118759f97b4ec80fdb87181987632513fac3626af0a6b853f1a079e71869e52bfee96e86d46eb9251d38b56263ec0182814a6282b06fe14faeab2d68bbdab3e67a521798c62d79c2842e1b35f9845dd79612877f67f42f8aa19a6e34948ada980b4003e8a6aa52eeb3d20514f3a7a7248caa64b0b2e9cfc8fefa11d6f99055d7dccff810a1cbe0b6743ff1b80892e7bc482451d2eafa87c1b2ad4378762658fe404369f9ec97af3064c0ac0b6772ae00919cc25221c3a6c8b0abf6157e333bd5f65c97d0177ee5c0e1c1e20505dbdc4d9e0ebfe1d1e3b3e546dca1c589598d0deee164b929ee8f9032e77abcb222c34484b88a00b3c5d81c43c40c0c4d7e300000000000000000000000000000000000000000c131a20252c3137", + "tbs": "a6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f69645820453222b1efc1868d63e35c005b444fae9fbadfd24ee2bd54cc2ff5a58e6197b1677369675f616c67694d4c2d4453412d3837696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f686173685830ea00175beaa98b68ec375ce0fb78f6546217d0357c54a196dbeca16d93371bce98f9dc9f7ad571cf6e7e5408ad231112" + }, + "identity_key": "8ac2c0370467149d6349aefa82a2ac5e8221dbd28a094c30e66d57b48f38fbc6bfbeac67f7b5f55c6ba996a211279b722c16dda0394b38fd6d56b1685d03e0183839ff2fedc910f108860568bde02c7d1f23038cf57a0526435ef93e10b5d0ab7a3171c6b8e499cc91b70eb6cc57ce4d5df15db6604934c4a310793858ce9df56fb4bd3b08add28c9ebcddc2ddb84c159470432ffbe6466a4450bb27402cee70a91da55b16a19802862fb159f92ab64ac56539ab62bd5590434de189430015f7c68e14e40f4fac05bd2994794a97db594f037c02600f4cc2996df32b98a7efa07fdcc1f62812a7e690eef63524bdbc06f5c5c94e4decea2bc2d04c6ddca5b45e81944aebcb44c84be9e54cd576e6ab6aa8110d51b44fc8eb58a03c3a22fcd0b41d0e56b4b0a0ddcfa8301a3dd99a9ff4f31576e86c6f8df11cd48991d812e6a4dd122441be3a4d7bc093ffb395b966b7d6244eb57518484f408e361715f00f9ff9c56a692f1a8e0b883f7f0a81a5b575c42c15348d53be21cea1d32db19866db9c09df9a47a85a795346207faccf2b928f756f5d131b178be5f68536492b514d5988d028af42ef78b142f76c31fc00b43b65805583021fb6738b756aec76033504ba71212b42ead5a852b32a6e03cfe3025f25027fd456a4d6459434f7b79655ddf503ee0714f733f4454a85655e9123a48a8e9964f86474f072e1eba62d4d77cbd342f7b6797de08833813159789d9ca2f1c335d2f737611a4b6e32af9b0def3d8bfdc855717716934a21cbd120ab7524bdee460c5a276f858229f08542298546ef597c3b60dfcc35d1ee112b87987abd91cd75f0e29a1788657a41488b2bb0bb7395956ddd340534b6f2be80289d26f5eb21e569929b182a5ee334f6f474417a38ccb62c67941951d20d3b1ac6f157a358686461b1d49669e60a5dca5083046f9f92d5b9b73d1ec028eee2ef0c0b7bc1fbf5233c3d4b088d41eeebfd91453e39e9df104eca00f0b428b6ad7d42a3e9848b43a82a2ab653e126d870d81635c7e1ba47bae1f06740bcd60743c0b58f6bfc5e5e7832d5cba0f81984146331613477925ccb05d30527dd9d01adc0c22250d6aeb4d33e804758c218bca76fc8c097acaedaca2dab71e8e23564b1f7eade5dfb7a3660a3fea7b2dee406ab553125b3b1c80d7dcf31eedaeb3715588730edbeff60c513df8bb91ed82959c22141c250241ea5bfa1f5f603787cde94f3354a91ec5c7392255ec5c0b573cde546ebb5430f80a855937c09af8fa56cc5a512ff64c42cf8b27dbf45a777a6124bdcd8a9ccc0cbacc4b2f5c9199066b4ee2723201b5dd6323da3f02e72bf6cdc4e420ff8e01662d9770d660a4b45cd8eb83db1c03935a57472aee22569aa31c3541cfcd8ddf0e999ca35b301251143baac0f3562ca1022292d75a60eceebf5c83f3be0c439c187be0b29caefcc624101226343c813426b5b1b02751d0905b82a652f1f21d1e9cbe712ed2036b81e55cde638c453dcfdc1bd701ee184d6ebb245c2b43ab64a8818627b4378dd9b8d1357c25b62d2bcff6ce6cdac0adb1eb446e5764953eb083f0826a5fb89f3300b6694031e5c9214ff11751d518b2ab3c7d095518eb9f23c3a6538662f080b2965ad608fd12f7655286ddc8ad11b22b462eee91939c9fc66fe0fd680109c55b06f7c28267837693b80111e6306e991a9b50e25e47709901eb31e622a936ee5e459315aed300c383c5ff50141f343423f8589ddb58509297aa323fdcd8df4c177dad4b9ef892fa3bab00fa8bcf82bd992c8091e6fd03edea2c12c84c3f6caf7517d71fe9f3293080ee2a6d9a6563ab3444c8f0a143a05da9623a16cd5a13a7934400d9e89863f12d382ecd8ab45e9f6fd0b51ce9c886bf74cdc45f5779faf4aae7495046c7b9a27e3e1f46d22b3904c6b7790f8346d7592464e15177dd297ad93f89ba929230f1bec92c96dabea155c00038bbe289d2c1ca6255051b5e701ec082b846c5d50e35f4256423babd79a096c1bf4d30654745a7dcfe6d952ea05409bdc1218472b84c87a92077e16dcb9b344ed9526518e688e5f2ef2c88cce00526d674bcba22010c8014dc4ab831ba1d5b80d19dc36c981bd2fc51c4c6b21a723a8fa1687771199198b662212a8b93110e061af41550a07577793f1d2963e4fce5b75f246ecadd9a6ee67a7a0264614b25907101578b089e5a33b469b73808d44b3c883cd5e88818f1e2645be83c9569be39f18535409af1247cc760d03b5baf4307d4cd53189d6c09ff29097fde8a6bedad5b90b1603859595508d7bba93141fe11a6381bdc8d6ebae342d03a2edf66983e45080ee94b4cc342b0ee2cf42c3c866afc178d5391820bb962b766a253dc6fb8406a3aaedaaa75c99efbdd340279cad25a5145d5f77b9b0413ddb9f3668a74592b3b9b21da97fa191446ad1792037eacc98ac5506531a4e4210535f97cad1e70dd8e35225d81a406369443f709a73d473b01dfb479eda461e41f7231d9f16d7081bebd6d1253f3e2b999121c571b796a837fa2958e33a8a330228f2272d424683966acd3ca6bf3a3f390a4d1656e47cb070a17a6a0c976ecbfb0a242cbc9df07a3d1df8102ada00a9e3bb06c845de992ca5645873715eabd7efee8c590a0dddc9630ac3ad4b85a527a52bd5edddc54ffaf000839b7cbf11187f0376772701718e47d9b160302ae4611f3685f1feff44aec5472b8425a5014208d08c17c72e4d6fe1092aebc564b09439156fb859118ec08cc6691db28b29ea0e85b229bfbfd5353660cca284e7ae68329272fdd301452fc7d628b380376034978d428c2ae43835cbe14d7f5ef9008b29bb517cb70a81500a3af369e0689ec661d383720285a10cfa9b106c86d01ad31272ff74af81e84e2fbfbb1148247d50c69f4900c155e7488e96a8aa25852100a4a5f6f71a4e988b1b7da3dffa01317444c88362986d61ac8aab7f7887f634bddb9a687310d90c815fd1e57f0caa8c97da3b5c1e759c67558107e99fe6a658276d7359ce3537d8bd90d27a2828d61d883269484b7de97782a11f04f599198fcfc3661e8a4fd8ffd1294b31081b8128f6d78fcd18e50052832b890f971a3005c678ebab50ed9d8b3da2cd2d81f688cdf058789bbb179939487896cbbb795648db68af06cc15fd44a62cb68e9041aad151e81c54aa1c153af7e7b75e0736d25a8eb57133d6a73e5c3e1ee8f6dfa93d5f6a9f78cc05e037775b96c4ee4d84e40659ddfce2d83db1728c14e86020f734912e72debe7fa04793e1f8a6f87cdaa682958ff4d1b1e8b2e68226a9b6f88f4a1c6e5ab1babff0a8368e265eb2b32a1dd9ac3e32aabc63dbed5b0e17be43e73c0a2da74f88cd4953f9c9ed32945e3ced0d0d5b4e90248e3abf1a38b8951c1a02cf532583ad6ca3eb8ed924eb39da9036f2ba8ccd3fe3b2fd5970533946ed4a42d6900dd74d74862a841c405cd6107d77029287d99f3c1160b75b2c55e04af58299bf7f33c29f5ea36bafc6f83078f8107d924d2407ba68c1c0b7beb3af4b04a6b29ad9f0e94a8ef59862da45315d0bdb16f0bc206d93346b1f17ee479061a361b4b5e715eb376dbbe37084cce962d696fdb1913c7af479e36347a0b3f1d2f49ba5303ddfc53226caa815c651292dda8518", + "leaf": "61206c6561662063657274696669636174652c20617320697473206c697374656e65722070726573656e7473206974", + "node_id": "453222b1efc1868d63e35c005b444fae9fbadfd24ee2bd54cc2ff5a58e6197b1", + "now_ms": 1789000000000, + "profile": "pq_pure", + "tls_binding": { + "signature": "ec94c843ff4a56fd54456d6a5fc9d9c4cedcef5698f45bb85b837ba5b8b3a97270f168759295fad482eecc002fda9a60a23919fdc35025c73df52c2b62ffec1f4527e5bed6c816b0d904894dc624e08ab8a24578d4d21a9de3070a48952942f989e866ff48576f3af80b3d301380ed789a8650c8f3a178a96775ae7f9214e19fd2feee670715f474e3ff3ac90775e23364282cd1c304296acf85ab57d43e4bfeb16a69f698fec8bd9a4db2d20dc409fe72decca881d2abb594f4faafb3f6e873fcdf394417280cd8f27d0bf7278e1f4f96210f42d411ecdd316477d5c13045c7dc2bfa3d35f5a8768f7c029da1f3ae0f06082189979a33a4fd967c66a1456e62b7e4eb3c89262302493ba02070b00fe4343655f836dec75142d9b90304e8d29f7f32d5dbe22210741a55729ca55448eedbabd0be316bd53c7e685c529fa899a48270334d08b8250238790d4c556ed515ee4d3cb9da8c5f2f4fbec029d0d67b324ae9d3d88f3669e403cb758cef02f1492759b44b6ad2ef50d81c59643b76bda82281bc4aa2c95ff219027003c8adeb0b139f8e1bda642cf1ad23646b9580ca37e852281598545103823a87d328605e3dddd629a245dc43a33ea0c98709d59ffdd5a286f8d04e6f9f3ff778b9701932b7b48868a3541ada5157dbebdd522b67041d6118871b291c6ac7a360fe2dd012c26b65cea7fb08c80c9c45cb0eb1cba04c8c1b0a572e83330d8146e2ee8a232f9372203db88afd6ef65a82fbe337806a346dabd80432fcac079a8982b75a9439035902b1a6270513225721357aa30fc913c638d520826301c4f7f06c0bb7b9e79a6103f71c5c04ed00f26dd40af9a10c88bd1829fa1d4f1402d40b4f09f49fdf19377e7952084eeac1eb151fbe356c3844fc4b6001c746bca25fc4d006cddd667496ccc65da177e25f458a92c10989a8155f734551b8dc9c1cd9fec7a67fe2931ba42e942b6896b042a3a134b41216d83be79ab9e7d401d22a76a698bc428b6d62b9ff098db3099698b2814ee083d0a3f33e9d2f55a1cadb7da475f8f596d23125d9ea4b47b3dd4846d5b6ee58c4f3c615dc611c881a87ba80b37e66c922747e268dea0aaaaa3606576eb2f685e3d77b316ae37ff54a80c2d6a24d2dcce51ab9edc8989d52e85df13c91d510c746c0af321047f2453f372a9d924518aebae529311cb71806b76ab9520996cfc831b8b8c2a32333e0dd9d2a6bb71e1b63bd25ea58ff44cbacee5dc8342735d3e35350e9144a3d0ed0502bf0156c41898b766892085c28089e3588dc2215b228e28abb98dae2210603d541b62efdc590c372c15cd17fe3489f6860ca74a595e6b74921a3b1ddcad60435b9df3376cb24eceb6be06beee36c16eeb26ec1b9f43ee546108c4478e1a201bb0e300650f257ad46dfdcab60c3659a78d643843d67bd8545ccaa73c1e5df1a4cb79045ada2880f937200716b15646b7f23d4db2f0b2c431f60f833bba362d473fb0a5e4b5f095aea15ea8766622920e2a4101f9c75ef806d5f3fc492f3672fac0a8cb3648a4c25737e71e7334127c7befcef8f218f7bf3851b11cec9fb7469467366e8c324f251f203ea8c63a1f6fa79c2c7e2651c4f636255f26933f90cc19f2ef6e078c9a31177d522eb1e0b239329a42b4876dfe0e9c9d452a155962bda7b2e830b65831e11fbb753474dd1e31f4596c432b7a60393e4815f1ac5598adf9b7c0e57d8ff94c973947982e6d1d4b353f434ddc58feb726dc7076c766b353cc42057665b44ffab2de76a473e4dbad9297baccebbcccdb1bc668aceb747f17ffb831436858a0f8dcbb9b2fe57ac10d43c2546ec6be2af2c8320fda93ab2fb2710737e2b839d410ffc624898832872784d28b83f3a932458fb9800d3400124deec7bdca1d5f5a0fef442c6f313e5068fcf3ad8d3abf168c9a00a55aca2295cb27dc0f012adeff59b142486b23c058f3d4193fed4199c118667f47f98b8a71919f797aa9b78cc39d6e7298d8de23d29b3ace5820261636bdc25ddf24812d3b9b798644739c6aaa96899232bdc9ecad316394afff8be9cf038ca986dc099044d37d0c5a41ac0516b9f2cb6fc454f35f0dc0d1544daeed2e0a93ed9175598031225b9d673ffeefee9fe4f979925ebcb349a8c9c0d4267b92ee30aeede008770c6bcb9dad5649560246b4b3670c4166584359117ac98812998a7d7c4220020060f1823d0cb9b1f5b181917595b6b7cf8c535bb95aad11cd07a079482b3df5f1d138c94d7d7c05e1af3eaa53989ae75a0681cecd40cbf7c5a81304c6444e5831d66d0012a66aa3a5613410b13e05ff447ec8b1f99a99eb7ec3d1d09c35bde743e95ed88b126ababe59f3aaf4025468285338810a917bea8805aae1b71d6dedc31b1dc4438ece7d97b7a6dfd11c9d13da86ffe9fd6ee543de28ba964c8724f425cd64caf95f1b581cc6e530efbac0d745d3440ec74d80eee6d351f0506550658f8452668f188d5d0806a12062d97f5e2a9bd29c0900ce35225242d1a9f2392fd3834ad92100a72b2dd28da36cd7a363636d70c3f8eb6c1311a50becf5f9b3e72f6fe2cdafd763e229b928dc53b2111fe67b7755696dd2588bb39078c5aaf73ba6f67cf91601a6303b7c7067bf663c0187af70b6fdc12aa5c360f1282eca896569fd382ea66975f3da5f879564c1a8fd48d1f497ce88fcf5765c74a5a04c0615ed53c588162fa936aa4dca13dfbc43c8b5c6776da4638611615254c7018e48f6d3b880e5a9ce17ed53dccb95c695758fa5612cc6bd43fd94b2244a0f7af4c63560001cff178bf0bbbe82cde391f35c5a90592c8d5501acb7b1609be351ff2a8c9ae2e0e522715bda63a3a0d7fe23e3d897ffd970d64da392cc334b47b994ed76baa2cec6f7dfd28fba6047309469d933317b71c42eec69854f5ab9c7185a6c62d274338bfa7e7f938d96efd444c35ce4c4f0fd4e8a855c9f481c469c51de99d558876a7c2cfba29078480d25bd7bc1a0c6268cf53e1b72195ad9e05a3285e9736ad89942874ceb8fbe576dd507b749364a62bc4b87b9e87b75a2c853daffa55bfe7728614840e57f74d725a68bfa5dbff62e3d167909a12e3fe46c0a6e772ac1007eb365d66ca8a305259463662295154bba532f469866f0bcac13976104891a9b41475ac53823cb52bb6edd403d55c492e46bb0d0f1233222ff18e8010ed1b92095dca7beadb4833a46df2a9ae85152cff18a1909d0566a3dca0599ae492e55ea2417ac1f025a6ab2ee8641a8f883277016c999561f760e7f711405f974c19c411f7640a9904d6fc6093b227cb7b02ee32f4781d298e92348a7f53eb1dc52b05da6f0a06cfd3efe736f5c13fe04af641e51915eb3ac1eb0bec2aa2fed53c85c975f0a360a9081f0423b1d1d7ff7fa5ebff2d11b7ba25e6e2b533b9aa03ae84a939adc59a13de4d5f936b95f4f2bcebf34d93d2b52616811611bd36913cf4770dbab18bb4746f5242f5f6c8e61bd97d88e7a896309f23fc79b7e7248e8c8017ad1b073e050b8cf1a4c379b29dfb3389321d7e7e946ce402b2ce60f7190ede7f3e1f8527a6b3023c93a962b9175471b35aef1bb15be5ad07e62a20cfa6841e0e35a8f60d1ab40fe32a3a1246432687a9599f9f131d5552f7bce1ac8bf0149a18cb92638f0d5610410be4d612761109bcbdf81d33e3eab14546698b76bbb8c8ae00380f93de4a1b17dce541e541bdb18c2084f7c63ba363f83c1d33f92ff1a2f446e689751defe274af4fb4e99a21323dd7309e7e9169e260c14fdb1f75d90a7b1d59f69754235394b38c58136a56e7379ce5a1463d6b804194c24fcdb080b503785f9fb827770122760ac920df636e3a5f1e1b841e890b086c2cfa1686c61d6e466d752d9e070da034f6ab19e46c50b988cd5e4e7f38453a0081c025b850def16f2478ffb7eb4fdda04b80b91d0b962f097a5bf21ac25c526e3f4a26f02fb3a523c878de57a39a4ed6fca6c1fcbefcdfc39184b02490d72ab600bf159b1476392f8382eedba3af223253c8c2b0d510450e1c2cd13121dd20912dda330b8f1a7bcf41bde47fe50c3b14990d778f7d6d636315c6deb173f20dfa9811859ef483c97d889e03f9b12612e353325fda9027a79f7bdb629aa16ade02d8777b2de406f83b7c0e2457cfc72ae5dd811575d80cd0c28c086d08c3f65e3606dc4e589447e9e56626b23ea27ef558aca63d5bafaf633ac147e02f358d4b81e2a05738a96e3d747c1dc38e8692c96ea05a5ba1114677ba0990cf233dba7c9c0d35ea3fbf7fc2d0b1b9a6902ad7f10a15f584bb75b8d55cf6de8683ce110968f4d18aa26e7096b4a49b9f5a9ade0cf785cf8de06fd6b7c4a1410eabcff4047b1b92afd1f7b90a0712a09b2c64d0e88893174d0feec584220e6542327bc654069bca5fcc847303778132f8d4c216d04cd4d4c8892d4c14f63ac25eebabc2444bcc9029f7738d747e95f1a1b0e6bbd85a24913bcf5f6b84b0e368bada6223674bc4bf3f9ee759fb4e6db1230fe2df8213cb7a59d1bf0b3e92ffccfa699168522dff52349e425f2bc3c29c986907b8362c1cbfe33261c76beaeabe0b9288d7bbd8a5d21a01018224d3a034922eeaa346b1ceaaa00e4e1cfeaab7deec6c722aa6e363ebd9ed927819246e7f9672060a2467aa0ecc0bc8ab53fcf378026463a4e48db20abc59ad02fe5532addc1cb9093145d8c977a0485f4305b9038f0f1e20eb7e8ba3ac3e830bd9b95cec50950676340b209d7d1de23d8b65cff0f476bd7101045cbdda29b0510e6c44386ca63f6c6832b90b19f5d55f35b50b9ce7c72f8a0e406286d5f2eabfe39fa243d8e33abd55f6a66edf1ccec39cebc09bab7fdec2f47c184b0991fe30df245d21f9cab2ea12a478b36c799a18647ff0492aa1e8d8ff62aa42df8efe56344780127c48335a9598647caaa64fda15bdedc6b15278fdf443047121bb5c13313002c9dcdbd9d36c59fe47a27b764f1cb57a9a1d6c0b831e3b6cf16c801269f6927545d419a7b67856f85bec355e0629306b6bf569a7d02f6ebe453f01205ac92a4a21e47416a2108258154315dfbc0af453c8ca82a7fba798b1433811ff1a828b3a0f2a802ca17fdc7b3145ca379b02bb21223571dc0e8a779068d0f1a3056463cab5e49ab4d25a8215417615ad93de3f581de640feb73a44624995c21dcc15d9a15201b693bd0796cec41adc099527fa08b23fa011925dcbd0c8c8858f75f2704780d849bd000a2ef6e0fba4abedfb519d28f16a199eaf48f210ca1173a59ee5f8225592305f19330f8ffa0ceeab0b2c7999d1afbd3e6ce5418dc466584eed9bd211a6c65d9ab159ebd4aa4ffca8b4df39b23c44164bf11d5d1268401af1e71134ae0a8128297c54d5d333e2753cf8b20618bb6a55833f47d963eaba3f75162227289f754be545925272ba6dd1a454a78e6385d0440b90add8fe81a82e9602e5a6e9ccd7bdb158ab42d6890f28259f0173fe9079ade862bd8f9d97b59a7db36e5a2bc93626e68e5484e71f2f98554842231097af4d16218d328b45b0e6e9d1d3fe66d999af7e3a857677a5c72c4e460ce1ddb085e55570f278a13a78915f7db0c91f1e06b73be02489168d48119bd5ab60fc3b14e58453cd61897c37cd13c002d1f2a22b3ef2123d2320649f49dbf8c903583d2e74ea40ba843e0ef1bbde7abbbf722a92e8e288ae9cd15ad941060e1751f7c09cd7cda82e45f980c9cc69784ee48e0f76c6bb05ad01e4d4d850281f93ae13c0896bd23b70716ce5c3a3f52068bfc57b44a3782bcd01138b331165b40183214d153825843155b48e248d4903783b2a43f3c7b50c883962e01d0d470af3bc62c4917c508e95cabc1d76ec5f459b649f48420b21dc7890ba0a1f08bdf2d46052d593e3345c8b12a8f6bce8ffb0ac9b31a77e17e9fcca6f559d99c6f9dfe76aed3b1747ccef1d1db908f9be94075ed621071b4b00e431d31eb1dea411b0f2fa31da1a5acf67eb02e98352761c2754c97f6087791d568247e243969a0cb31ba0c78cfb68191bdb7ec9d361d549d068ef0cbe631c9b7d3a2d8fac009f2488abf1a4611c8175d56a031b6177491be46f9ae9c729c384f12a371babf75e694ac6a79ec16f49ae363780aedc1d04c35eedfe3d7684897d542a9d790c0d5624a3141390f0b6c383f460069c5b93fe892e356124856eb0c4f0643ffe958d5d3dd37a73a0382b2271f7419a9105d318af175e6279154944255c029959806db137f2a6af1d4af8b1eaa7e197aefd135c26a31e8a04ca6e88b6f36ef099f58daa72fdc4c856ee933a3efb7c44bb59f099d7e67f4586b1f73e09bffa18d7948e33e82fc94be20695de6df1f07ba494c8aa29fa5a20cb843992cbbed54c7f349c32353382319221288b4c33766436b49267da301d21464a637188989de4ee333a42518f929cc12b505ec5f21b204345646994a1dbf50a0e212e557d858da0aedf22378db46066798b9195acc3fb0c375b8fc9e2eef7fc00000000000000000b1318222d313a43", + "tbs": "a96375736563746c73656c6162656c78184d4143554c412d50512d42494e44494e472d544c532d5631676e6f64655f69645820453222b1efc1868d63e35c005b444fae9fbadfd24ee2bd54cc2ff5a58e6197b1677369675f616c67694d4c2d4453412d383768686173685f616c67675348412d333834696e6f745f61667465721b000001a0acc226006a62696e64696e675f6964506333a28eaeb18615051b69ae664161886a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f686173685830ba5d003ee50124a47aa0d55c045bb81cc4d8f48f8f2af53deb5f9814f14bd78ea652cab197fc2348547d4cde771bf49b" + }, + "tls_status": { + "signature": "635ac5f74446cefda87b297699a75311b3feee800fa4d27ff4f4af56534d051cec9fe8b0d8b1a0f652bb3a7189c16a402b5312097ab5b692366855e426d55b67188c693d7e9e132858ff4a7e8359cd892f695ba6557d6457e9dcc878d2f9f3cb4ac8a6c72baa1ed8c781cff7e4a78f28aa359430ebc885ef27d7599decca53e7ccae296805a3bed63bc0fffbd1464b4ac9a0a5c6b77ea3d12447918bfb18818cfb2c92de0c3d1b9b305038a53255be63e1c7f9ae8c35cb5d519f39a275b58cf1cbfa6469d465af9f51cda1306fa50a08605bee8f49fdbcc4eef90b569b7e3e9ccfa2b42c2403405dc7ff315ce6c0e4ab617c56355a669b8d4194a12b35cc8e7b86b47ea08fd79c32f878d0d72aaad67afbea2555e042282fb4b20a3ad9afe9afe8bdba42233a798e27575f7b02cfaa29f29341e6b67df53dc002304a3165c9a56695c4b4fd0c4cdb9ac3c3e2b9d0fb7e0f5086755e74e4c7c8f326d810c7e5202b3fb2f94d7bd083f777dc11e61897729b5c1f62fcdb23d0ec07b14364bdf1edaf8cbcabb1f3ecb5b58d9420c6a06b6f0dec04e8c63820344e9b9fe716067c0fd4cf31131dce324d6a4d9321c9cea11cd066b056e490d7d6d9ad5d165a3e1ba84993126a3e4347ec1d892b9e3e9157919d3fc3511c40e1149870c6387ff7c808dbee87d8306f139942037089ab8d59534aa0d132ca70da5b3ce3a447691c03e224d30465a45ee19abc27120c5608ef65c44c74db5fb8b4046fd004819c2865f9097a4eb43477cc5abe6887030b0008aa16f1b9c0f980a55371e49c5770855e39d35cac559487fb7bbfa27814e0b2a5a81158d829253cc5b8aa857d1930d47581a742f9e33d7fca932ea564a87c787d8c56fe736d6f2545efaa6edeb6a93ce804bcf76fa5ff320c37349854cd3c17026d0b8c7378579baec5cb8e864f19d3a9d28b78722303b6c65dd2ef0a20f1b78762166e7bddf836066deaa821a0d2f7d0758661ba8a376f64112c25b1d9803da92f29df7d15ba02534d32428f1506740b8fc8b5ce212354e4db58d9bf1c8d0028c8b0479db0bd5325fd749840c95156bb92a2295b60776a8e012ca68c60494fb41dd731e7ff919465cb6813c810a9debb09862dc722f550620b1a88c502f72471e05600fcc95240f512ac30d2e1fea5078642b398cf47896e11a53799713502bcdefc06c8df1820e426e6c0f3b748dc54c658297316e23e542e1b74240892252311bec53f6795ecb36055d2ec3afb2f998cd9b5ff27fa96ca2220438331081881f6615d41e8a7825dcfba99e52bd6f21f6a398c92f2caa1a60fa5795bb7689333b932f61d322c85dd9767a168a58e4707a63e236617c0a4ec6cf1e0c7fab7d6b2baf903b7ac436c9769236dd9631d9253674aef39f358d848f7e092dea6dee03b9bf4ffeb96a80e5aa01f8e60be1dd0986eb0176eef92fc6bbdb3e810fa783d825a717092de6fcbb4d3c6f7414a19f4e1f4347851f10bcf6cb35c193dd1c8789206569439724b4602f3392c9de55da54c7a3c130c84e436ce3a93d8afe55919634211c004887714b97686c8ace9eb47990f87e689d5d3001e2b228c4c477dc889360ee57a23febddd4ab5f379fb4d0e706ffca9ee410fe33dfdc9df518aaa4d0cd2fb588d19bfacdad59752561757cbe8d40ceb70d13cfdfa89276a813002c4c10406cfb4467890764acde13d80db87dd48a27c522f8cf93d9654d6b1573c91dc441404bfa2ef5778a8d1d7abc594ce6e642dd8886ac004360bbeee97443a60c73d3518e16d734675711ca81d2b596b6141787446ae022a1783386537a5b676f132dd5fdccf852cca7a537503e028c3acd90b0e8b15c517f7db6f6ff2af02011ca8967767806e5d3395cf2baefd4001432434d1fdfbb4afa791631f2d191ebe46f42a9087dc1c6b71dd51b26a7297c44ad52fce6fed4ad23ba1d16f9a879075a2501ae004e664698ac66e6b249f52a2e65cf8751c2da8b89d29a2d2d1d58c0cc059ca2f6abb8eb4a87df6609136e775487354c2083efa92e762cdab3ee3362f880329ca719148c784504e2445d7eef8f4007fd2b858b8ae36fa4046ca6e3d3c1bf1e03c9e89db72b2d7431d3a74e2b984f10ebe9ed08fec24724aa1794bcab61fcaacfc7a62052efdce82161f24823b787eba9e0baa1c4a8974a065160ddf78099f358d45ef7a65d47f689a7cabb6965e4830ee526941448da6086d45b7ce6a128e5c17dc99adc47d113a023d5803b87d451c450572c43bae1e57b0764d312da0bbb2fd5de8df8f20efc7373421c37f281e4c64e64ad341d638b866b38848d6e660218ccac69e10496b68cf1c4dba33d5c16d3cd92d677670278080e95ced6cf14d41b0e2e807f54eb4637170659915245ab393eaf805da6e34decbd1cd23445b05469283a8e426215dbd32089e40ecff4c8a079888dca093a1be1406d7dea1ca1d8c508b6b6dc8d624531a977e27599d3566b3d4848d8b8a7f429bd401b9b805c2cf78f50bb72b0e3211359be0328638e935a6e97aaf4da6f0e7fa0760c66012527faab5fbcc203081125bc4e3a93f540f87654b37d8f882af925e46bf1e895a30525be2f38affb5b1b9f4462fc2612e056d9153b80c28ffa4ceed6e67abff50733a059730e354bf71982a908a5f68469d51067338ec08593854744cb33b6fffefba1c4602e8c7afeacd2df444b5881b417ab411c3d7e9893c277f39f4256d63a032c07488d86c3dd3622b4adcc677f19266289dc5c6fe927bf662ebb8409da466d931179088c842ccbdd02bc90f47b952dd6fd22545311744ccdd2d711324735e560223261a47f18b985f5ac24cad2bc6e30688476b0db76bd35b1c098eb39daeb4ddbc9ea4865a24fa5fcdce6199e71c598417719360fa1d6d885999f2713f9e52404b6ecf0ea2b0ca3fdebfd2b4a8dd6f40d504b609e55ffeda52d04fb331e64fd5ac7a03ba8363c25cdc3aa0fbf88fa1599232d4a410ca815dfecc31a35a2d69aaf18b48d18fbff12954a718153c63eca272ce6c76d124d76666b25f70036bd51570a3c2ee7cc6d16e6e5c4ddec165032dcfe5daaa4eeff4a193f9bc4d42ec736d8f60287024019232ed954162d39e545f9c99aec9e471218e315173ddae6925d9d539b6ab7873967114250f8aa2b6ddd6f766c1a4485b59abc045af2e5c4f3bce1c9693ff2baf2e7d2d58f500f439d966d32ab081dea293090e5bdbb3cdb52be0b6968f4b27abdf11ffa36cc4ed116624e4ede93e97151668dde370162272fc74722ae5b9fac6ea3a998e97c3190262812405a4ae03fcd71a3d88dabaeef76323837a8da01c4d0a5acf727697971bec3294b0d32ce7dc3824051e6934becda252e9054986e0876ccfa6b28bd59fff5d0a091a98762edbc1dc19b6abb922654288354d4e29c1189ae25298931598a5c09e791cce8b7fd87a0a625e291bd3b498fc715d8d63d41f9b596d852b678b5475a30110984cbc64d41c1cdadec2899b309338b90d59e6c04b1c96bf5b3e1f7cde8d6dc070899c652a8943b4d95142694e860ff6ea6ecf7bf790fa51988a42022e69989e2fac965e2852b406e776e8ac15f599786c79658e8fa5be0096c95346d5b8d78ec794f552104bdde6e510103d54e35f0adb8503f0c211db393fae6dbdb1e90a861516ce69e694f0363bc7cd8a753ab7c36c86ca6d791b1045927a83dd622eb74b1560e706e6bcb9dab0d264ad9955bb0212e2902c37803ddf4cc2c83735555af767ab817f600ba6041f2d3feb75b9dc9d3bcf88ebb586e1550337b2f84be51fe251df0baedf0f875b37962b33f67a26af984f48c533508a737808f96dbdb2dcb127a0eca9395d739c7c567478739fde2cd61fd3a726bc5888719295e6e078aca2e9edace87008b5f73653526a0fea0b5a77cace2f05403a4a07516462994719e55b4cf989211c202f1dd24d50310a1a84a591ec2d4e7e6b636a60dcb3c26c0ecd89373f52cb76bc0757fb211d650ef42913b1e54118b01e8ab729954155fd79ba9b63029c518d43cd80d25d38972bc373d80c9eb5889a69d1cee27b593198f0dbb5744b259b25814778f75a62056f96106abf779b657972d568c046947f8dcb03101154a979341a2d77d4a0a608d28e82a022c2bcf449d47de5bf7a4d7ec91255df304ab4d96b3a583ba7e04aa996998105666c68e2dc0bbb5b4542679108743fbeb97c1799569a566cc74b64a5219c5896160a75d842c64897f6b90fc24f10699783dc1033aadb55f5db69bf757eb3a36968efc230e04a33a03a8e0ddb796b8a59b61e9ba8cdc3b2d0fe404801b01bc954669215131bb6d978a1dd42b4772426d368f1092d0d674ed59a7162e5c93ec7ca24fcb39dedf2c538b852b6b7a40bdc0a783530823c10d98876d2925aff51286d87d2db41f4b8255f033932d2c89d9041fd8ac760c552fe4ff9bf8893ff2737eee2e7b29c563fc20bbdf1df1ae86928efa77face319c59c2f85bdd34360979b1ccec791a878fd7d3015d06975bb437dd2a74b60707ccf608799964243ff9f370f69ba6fba505de9245355b6b88296627526baaf308fe4f5f2e93e23bf0e7f42757ec3e41b3f8edf6ba190ffc132e2182e5bce577ca4fd8cbe94aa043b27d95917aca3d3b3c0ff9c10338fce053e738dddc39fb2b7fcf0313f0b1a3b04802bfabc90fe6d62682774b37f8420f87811c5730b8d4b94b00d254eb451e6120a5b04c457335c5305db9c76620e6485cfcc5832eb3c8ef32a5be9bad4be0d32494a227a59b53ab59db54179a9d4463fd3e696fd76a360364eb7339f06d4f417d2e15250a0f9e8cd3214f30295da3f09ce1d1616c8749d5e5e2827494e45c63968ae5a54762268403bd42236ab7a223c2a57e46fcf8e6528110d2e9247152796d179a4fcefd3d63cdb648fc8d244aa22c1b8331c47792d645eb8af78dcb2c3733c6c59bb013dd3a200942427e4a044ab6bec3b4823929c05ff61650f23070f7d717c37324fdad6db9fee06a4da7829effc7e393a7b69e64d2524ace2107ea61c72beba384ed4627e839416cf5215e956805c664841e4987b53fc1042072712020ae2d52e316b55c8f0a0b8120e400f2d464ccf262bbf07a582ad1150a6d91e7561941e2b18bdf5628f0d91856cd7bdc78aa3bc1ceca3b911729b24f200344c23d047dc99102d05ed44e3ba187a17c8ecefdae0467cb1d9a5c42a7e0c375612ed09902e47feac0e8ad5fefe2788f88442262d18048d7dd98a0afa8615fba350a90a12f49714c00ec502739fd87af66b657d17c98e9bde75b06d92245c293777927f9bb2e105aaca25fa63dd02b63e87f973be8fe1aeafe0bacb189da4a96ed331224f494f7387766f170e36108cc0cb8d653309ac95a270081f5c952e12609cbd960d882481c89a8e9d421165dc9916d8bd4336319d9192b73dac0271ae79f419a0086167d15934df1bb91198971ab3aa66db426261438069a07699a71b2f12fc10bf18941b0e3c6f8918c3201e82949e740f4d4197e1a0e9eaafdb20fc64db52ed5e15db17021c8442418d80cc2284a254d4e3b2ec71ca1ad6a16958cb28309dbafe71a81d8d02f7e13990f6c0bd5f44e82979e7962de6e11276bfbf796d828ab487bcaf093df34ff4c14015ddd12433f60ac9fc2b24aa672c45c0a2765006ce97cfd96a21f901c36e6f7955c8400f7ef225818e4697e0450b2aecc099a953f554c1b2a8e6d0b248b76a842bcc5ea323af8fec19b834dd281cf7a4094eb13eb05b2a51e208e11ecbcf75a4b2cbb69deb431c6547d3943c9abd8f56866ec4c87387c6513885345a257ba899ab07ea7feaf555658737998bf2067c6414d54eea108af4dca729a190e51554efbe989d56f95c5728b30fd27da631d26e8717be109e6e8ac9397fb0ee93f6e54178dc0314ef49c14963c42d8dc6a98c7d1c1edc3955e3833e979f20fa34edef2048d2618e99fd0779a2c29da8b0021a9d8420ab3405f8bd2d64bbe3a523530d9aece260a309cace3ec3ded6f7e78a869b4b73e83dd1504d72a63a5f921b11f9afce1bf02c6dc738f5f420df19092ec8ee8caf7d17f4246eeefe0587f9ce37a140d42d33494dcf09e9d04f2521b723d44353d1ea859fe0d92acee4ede77fcbcb8d31bbf158dc5fb7736ba306f513fec46e535f5232467a7381a12f574d49bf14451bfc9af16a8ed67f3ee04e2fac6a019ffd70255bbdd4882062e73a59cd16c46f06a366a8a4fd170d5aad884479d7fafdc4abec8867d372dcf507f598a1529558664a1b8ac0b45c175a0040c188fe66c543c501a670942b355716a73bdfdb075e2d5c7a87f04d8ad82b88344540555d163860673435728dfc802c44e1758f6ebda4ca63e5ff784d4bc721b75201bf57978daa9ff5c8f4f7b77948c5ddc61e4c2c854f6d8a2b5a9bbdc2e2e7f6053f6e8d8e96c8f6ff567bc42ca7acade3fd0c242949768ea3e3030423b6b8bbdf151a3f518b97a3b8bcc1e426828ea6a8abacb6d5fc000000000000000000000000000811141a2229343e", + "tbs": "a6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f69645820453222b1efc1868d63e35c005b444fae9fbadfd24ee2bd54cc2ff5a58e6197b1677369675f616c67694d4c2d4453412d3837696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f686173685830e38d7dc2aaec0ebd646a10d1b4547579380e37d3de802d84a95f856910544840522b108ef253051803bd7c1f6f29c50d" + } + }, + { + "connect_binding": { + "signature": "914133513407472c0b0c592546b3c5fb5bb3fb2458f11d41b620fd0fb1409cde89998f9f7e13d35eaea0b33043d53a547f5ae2869cedb888d3a88fd60902467b4e642fffcbf75bc0bc3a5a3d415cefcbc3bd9f9abd211d9b01ecc69024b93f448cfdf30e5f8d057e5b0dfaa2e582f03855668bf143ab4416da4aa7d40a81257d4dcaf44d96dedd7e88d02b32e71f3e26dfa241d9307dd8459bb47ba88075d9ca267de748e1bb63825e148afa6e990b1a16cbd2860dac12db95eb20c84b38440be59efd6952ba1da9eaa68c68b96890037431df085e59fdf4dcbd1e77395aff9d24acf136fdef9e4c1e6983de311497c358bd9703335dfa4b0be0875184de6c813764d875bcca4c8fd6bcc444c20ead804e2afe523e5d059890b538c11d8b604711bf124e8a554b3e738a7319431a676997881ae51bf1725d0de1c2742e779d71d6468a9b3cd0d2ed0fc427f34aa2da2940a828b6d281fddc735aa701d40caa8bfae841cbae2f5e9e2d6d1fc6d613af5bfae63c596d5a5a1def42b5896eca41cdaecb4c22f467fa6aa7631e573ce839e61250dd78fb8498e242b73116b145e038472ed224225dce220a12de2b26f55e8712f3f420a451da86b6fa9792b7b9d47ced48a0763c08dcaa30af98a8de3d953b1352900bfcff356b87a5b0b7716d10b788c9724571dc228a3e1f803d4e96a74817019173a02541b13a534aa707be2b590c55d57eb9528faa48e7bf4df5b915f6fbe9cfbef75cf4d3ddb24c15df8427888f0e117b5ca8878eaa481a493488a11e67409127fa2489edc641f1a2a31066a3c70acfd25c365fcae10f1331b012257e961ab4a76151187846db2a3aa25d9b7337dc28aaf287031a6e09b130d6a019537c5880841cd467dfd4a1d08931b1be4e92a25a4177223a653e6fd33d5813594a75dfddf3006a537835d23c5b79beba7c57f582256aecd1c833cd10ee949010bac9051495a4ade1b299286dc2a50806c383782469d4abad4ff630f33fed0af257d8bc1915ae77d2b3472d986824b522a0ac06497d2b819ddd5c55b71ee505bd427976fdad6ceea1fbaaf1a04fc893f6a1e067cc9c522e565702c82ad8447c4fd362b89282deee88ba50081cca5cf45e759ee35e265594b116da7c674c381a6f949215016a3bd90d7bb12313780b9d834435d8fb9e2ef2fe8fd17d12a54acefc004b5f1d138efd058ce6590420b3886f6e5613c5160e6352e5bbcf92e9f41037fb36580a7f409fcf62795ce532dfe8829076a11b9aea1f71af42fd784f7d2fd355cbd3bc2c3c3703f1baa91e530bb884269631253149679329733e2e53cc5bafedf5dc427c44974647b341b8f7db97bf0d5800eee53cc10491353369928c60b14eaae9c0af4041c1aa81f045842c1d17943be97d387cd689976d01ecb066840730bcee0460889604d7b875b50d7b6a8ea951c52ea524265d53401b02424579a6b749fc4bda400379106d59737a6e1a0f9358cb87b436fd41e1031d0013cbeb960eedbcca8f065f54dc2454d30420abaab9bd9978c8054ad302f47af674b33c701918abe6ddd9e71df634aa1faa0d0ba154073670e1ed5d872fc951749634267bac946fa2bd87ffa631c627bc2542ee51e7880a7b06686912a2ae1ee8ecdb76bc76808bb52461bb495d558271dc4ba49591eeb423a43d81846bcc012548719854c2a0b38d7ea993e04e9b2b85f3da953e7eeb126873c09809a0a59ef6738e5c73d220158ddf9c7446ec347177665a12fe7cd562fe4922f639cbf60a741e2f92da391a54acb65cee6300749da41859d55d858e536ff1f4bacb79c46f44373a917ae3c93ddd9f9ed7622faa38eaa09ef46d04e71fdb96dd8f2200a655e8f2378929c2ea64b6c7d5ce043426873f4ccde26a888661c57ff846c0aaea454ea98dbf83fa0e75d2b6d584edc1d6cba06cc466c917e812e46af746f8a0ea9c3308e1e74a9f05bff1961a42a07e61c44d2fdec995bf7cba6a7a5e8d1cc5d623bd009173cabec1eb2bb53df5e8970e0e8ae670e28be60a756dd8b83c09c903f983d8a8b119bb645de999464117ce3741ed3d12e8b46a16ef59e019e27ced68e6a1b9f80d07c72a7d15c47b813e80c93c5ff1b6ac63635eee45688235259cece1f00ed9d39ba06392028e362131ee62bf1bfc849dc6aa204dfa660f1d6f0c5aafe218abf806e9dac1b6fa5c92aa49baf587470a7570b9b5b2d354d6250be6897afe877a34687aff09404f8cd00e1079bee9ad20e56fd1d108362953ac3ce585830949870d9c3b4912b96d1fdb48cde65884cff3a5a015288c90b4bdc17ba9cd4932d3fa44d9a5e8e1ef3ec20d604a2bcbab884e493f37ea4bc2aaef0c74cc15fad29139c1da69edfaa9af2711494bbe3033df5789c751a655cf1da2568cc1a2e613f52a99396827d3bdcbeaa6d102527d90f6398effd7656ff215447169a1c58509bbc018169fe06ed078f8510e30a2f7e94826b542ac71f858554145af13ee51d3e55f181b5eef6de607f305d4b22e9216e9c90b23996013c4c27964d3f037b5e9ecb25bda8a3c3dd821b06370b9c497e615b3f9af29834bbf65c1c22e78fc3cf71f9594c99caefa522a6b171c2f3061ff56128098055a38ecb2355a8f09a88d272bc9aaab87973046b41695827f561658b27304bfdb9789426e601a9e87059eafd8e034cdb5ec1960a66acd9c1c33e47007a061a99438ef50b9910a1c876a225e64bf1d2dd0d11b76043dbf8b8b6df6801781d164387bdd3c1e23ffe796c70a19fafd4f655178b4ecb765dc90a00dbbcc67692fde266fd249fa8d83b44d3f2db315a072cfa23bc1a30bad8afc6ae85f414ee202eed5a4742bbff6a9e0d262faf646d2c8aabfabee229e00fa0e6b0308a4ac3eb483076637c875fcdf1b1f8df901ce7f603d3705ee9ff75eeebd7e8e859f21bdb99d6925c8fbde941eff45f9e74f5ded06dfe0e899b8045e95ec2de3045e1a8f954febfc02799eb66dcb5edb3420e44ee2f1d19399c5c3fc8d11add1194bb666f8030c08d817b91d9359a4091d33ffb46f9d3ba59808633bc41f95b829da6f9e648ffc54d2c1af8f720744aa711e321be159da51f40904fab3433cd288f232fe2c894f0e0b41011af88d77496dbb329b1442c482aab1f9727c0df853f76cb5ac767c086191f532d74fe68980a16fb144c8bd180293fd7e282ef655f6cacd7de8cb81c907508210c00fd91c7047ad18d021be66af97ab684dff468e9f6f127c3388dd103655a28a0dfb33c3b6891cc1f97f17cb1d9d880f7a370a33e7edae50e10c8e09185dc0bc41dcad1552131d5d9a7216a89353a64a857ee33f0ee7ae67ca45846f2b39fdc1520d26ce86831e92bf83b868a017a4a13f873a414f99e709adbf11a21b28a24c71b4e97e8d9615a6a02397be78b2efbf6f03d386a0a26c12f1595cbd455b751e745ac9af52115126654684350a16bea7323534deac49a37333dab2eba3159833bf61126a59b08d1f965590eeb0bdf1f92ed489b9949accf065e489ea5f8015252019bc0ddb7609033ea161a627c41efbe7406f29f49b392798030633afc00467c73a5583fff400f0906af7caa838a7ed5a9f5c7636ed8fd33754224cca311e5e5f6bed4b7f63692f88b557587e0a8fa86e541142399b96923d6a9238427df71e74894641f8a5226a490933837ccec536af3d6803b81e0184af10c02c08075e813c8204ebeaae0e2ad67d646884d81e49ec60d2f42c74e76fd83bcf8fa2e8d9c33e4698dbff9e81a0cb418abea2b29705838b33e1a880840db4c933a29436d21aa4fb77a4d8118694f2a2e87f774dad7218e8e80ca54a86f0ac2bd7336b76042a5565c6e9bda99adddad357458294a59119030576424ec83782ecbe9b98c2753ff8afe2829a20a92b0966ba41ec3236b512fc3e9cd0da1cba57aae80c7fc2cd2ac3eb992fda796399275fe7fd4e9344c0df35e3b5eba598ec7d01f5ae1aa42d3d5af734dcfe6d9bf6bedeb52bc9af76c689621befd5a7afd8969d352b226a637aba8b286325152d223059c7b9687eaf95d078c06fa699614a2be7b8def7319b753ff05342b248775dad2d4c8d8e8331e5d31a21cb5374ad360c0eabbfc9bbfb919eda8c7fd97cf28a75901f59f6d68810fec94da1e12eec1bb5b822af311e56b643077312594d2af15dd39417840d3b44587b13fa7a6bd9592e5f355916e826bdde5eb3395bd30cfd0d9346e2f8c7bc71ebf870e5790d348a217a2eb6d43c1dcb66ef2bca32d49f01f799741f68308977213eee75effb77c8bcc6a9b2ab1485b1506f997ee81ad4056036b3084c6c662bcc8bf6936ceed42222dfac169a14e9c6e8b8792b154af5e1a92541f45f7b47349b69c94db24e09fa75fde37925822bbb5f541967dbcfb7713ff3435e62f4e5fc5a05c5868bdd893ff36c2ee2d03886329842736bfa2af25abf3074c7c735fc989a31947ad0a2f1f0144b5a9a1e3996d50785f4ca41776bc18695162aaf306cd50a975ddb7bdc402eaf36f41d341f74162185c2a8aacb55af9108fea1eeba2dd69b8d446fc1aa1d600343701f9dc40e7e003a797f065abb1036b2424c855f7c1ddbc5ad5d9b66dae1019ef8870e4a85125ff766ca7627de8ef9299ffc5ba6ceefd613dc89244b0154d1ceb143523422e9f3292be03f448df5c4b80582b65f0b09aa1f66f669c6e7f72b7624af1fcc704d02ae9c62b62e1461f550bf893ece9c37df6ab1478889f33a3f726a8830995edb1a102414400e04559c2d972632aca870a785fd521d883f9be0939568d4014541680cffa6b8823b1b9330f9f241e84b422355107c03e6af835a515fc6d8589b9628299f8e1cc4bfa3cdf633d661a50e81414155c2ba1a79d67254b9ebdc78015abab9f99f7db2d57a0228195a80a25fbd217b63a49711c6cb963f3cacfec3c5e4ef38d50f86c5c8946ba18b225d6660e32e9ef60f37c2627eb91accb92d70392e4920b365b2dc8223bda0afc7bc0eee315dd3495289b031f5894e5293c1f887d84f30510cb1b01c3399f69d068ebf9427ac39165bf5780745cdda396276488b55bcf62919f9c635f4d9334b3a20d3f6fc8eb438a3acddfa4512546af197b32397753d441d69c9861f6eb869da76186258712f19b476b068d6d55744c73332b97fd37cc3257740befdd099e799680b3fccf9a6980acd4bd0a09c780dd4ac343efb77630b997c4c87cffc28a54018e225531713327363c2adfc441b8ae424eedd98b7a3b6337eb13eef76fbbc4ce0e0698f1d808583206558bd8a872360364c7c02452551a338d7a861f5840091ed21bafd3df467c8ba25f7a61668a33ce7ec28a783cc2e4042c7268ce13b4a40b325f441fd7b1f746d9158273a6ddb587d585232dbbee02450e955092f6c3c0ffa8270f9b2deca7acf858b2332d62cc3f3041af3ad3bd1022960b57f9118c43e5cdcfc637f19f2291506ca7d007251caf04367b9a51eb3831d025943f962a2257e0486337cf910949498f1aac34a87ca8512fb17b1b00ff0beb91c2416e6bdea0f7dbe1fd7d33452902d393f5e9104f688b5f60a56ea77deeb3d6fff4de9fe782735843e35ce3a3aae6cad3210d09fb77b6ebf2e5cd10bc04546b82b318784273c071eb56f063811539538ae123d79b6986bfbcd18b773b216a96851b2d72634eb920280634c8099b5f9456c512e47f255420d56d9f9e98e37c1d4238e914014836ef5a0dc5810af512983ecadfd556942e4d022bdffe4feac7bb47605fb1709750230da52055684838f2d3e5764dcb538c3bb437328dfba9ce9bc384dd7797682fc3cc26400ecefce06df3c87c50766bb6d2bb826f864dc61b4c77e0fbc0080fad7fec6ad1c425da65397c1e1782b84964dccdd3f936ed58651c63a60896c489502862459c5d6b63f716daef0b57bc82f7e3f93d58d95bc73bd3136ae28a03410e1632f4069e3ae940f0a5029881011c2d22ac36f665d471d333643957c3d3d13490e1b7480c9545e297a4616b3846e8ec733cc95a1d2cfb8abf72578456ddce8aba75a50392e8fe54f32306ebc1cb1dafddb599e7a8023816a5e1b716c3591dbf72cd2250e9bd048bc14c55bb6e8d926332c80ca4ae60c3a83c66e436fdef23e5262bc784294a2142a749ec2cae07d3de0b57542816fb6d4d4b5ba30fe03e0d042e45d8c513efeeb67b89418f22d9373d407286f0529c7f05fdbdab9fedb0083400203fe0640c5926e0f77e015b9a1232babdec426f0baa943d21bd2e68a93f261eaab8f581756380b35d159ce28f93f63d2ac142e69940ebd366d44fdb4751c7b94de7f347a871322059bbab4625b4305176b4e8cb8ea98b66d7f1246685e8e07233a63d8135b25242f3b2181fc7bc1ac8acb230b99cf6088bfe4ba086325b1d8ab0c2156b6742ee6fb4c8ab282181119a0c3dd0c4f74a7bd020535372f5394f92235393c426a8be8f91f202b4471f1f738506a767e9da2def91a2b48ca00000000000000000000000000000000000000000000000000000000050a0e121b222b2fb17a532bfc8c7e6f118e61ac269c177b1f30064bd9d2194404dcd8fb94671b8911f300b16318296a11b8b576849543e31d909956ad2c6bdb009fd52b695be1305802aab3eb3db1777356500da388a74e801ce2bb5f05e162e3baba438b30b7c588043deafdfdf4ebc1a739fa3aec6e10dcb880292719428f09c08c5e9389e4ed67c217b2cfefe54c2e3e3bd5febf7765219d43aa782c4f3853fe263d8a84df4d31bec809b88976148e4251d35fca8396770a4cecae1a9d61f23f07ee180c82893298edde7004d2593f92b56e30cf60133a2b3738fc03eb6717066f159ba7990abfd041646645df6a2b055582c0736dea356ce35774d318eec8fc74f6a249e0bef2404b7f2225a39eb8cc248c375d4c3715a15fa2f73fe79c9e04d0d6ce48f5072b0d807c1c85a8ebce5ab12f4cca7696b5464cc8e328fc67ab5a8f17f65eb6020c0ddd5f973c2346cc4fe12560ad07ebb3ef055cd9e03e5da3909d0b6537e65a90e4e083510912eb0421f8abcc59d966ebaeec31b2a6059e1ba01a7cae7229ed617b4e3d4cfb2056b4ceb5df6c4df5f6d010048ab1557945f1ccbd55fdfd705b545cacc7d4abf496a3543307906d1eda5d8a76e97d728eec11e4099f826cd3e40ae26df1060fc5292c002bcd30748bf86c3b0a19232f2d06b0de6951c4225b4c66391b76a11246db5fce073160814f0ea4017fa86491d2870974728fe9e615a8", + "tbs": "a96375736567636f6e6e656374656c6162656c781c4d4143554c412d50512d42494e44494e472d434f4e4e4543542d5631676e6f64655f69645820cf4966c74356a1435f52bbadda8e5eafee5d4d0300fe103a61e5f36ebbfa5971677369675f616c676f4d4c2d4453412d38372d505333383468686173685f616c67675348412d333834696e6f745f61667465721b000001a0acc226006a62696e64696e675f696450ca670732d6ead8739dbcd046e90a2f2d6a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f686173685830c301c0b4e19dd0d9a1f0ab7337bb163da6b09bbd54b6cf7ba972bef116613f2d56a28fb89cc9eef3b7fe903b5d563797" + }, + "connect_key": "c775b2468df50b8be62e1d32efa25c342a1a4ead18075e395cf17565b72f81e12845b960140145132ee13819f6c36e1d594a673ac412ec7506883a779f42f4952a0639da008a424160c6f87385ee7f166088cb536d2d1269f53cc16b210a40ae1b1720485761d29b04f4310401c64dc74eb58645f8e02f2e7609f8ececf6429f3c3d343fbbd10c60ebc2e07986bc732eb353dec8451b7131c52801dfed033211da9d4a7068d79da9cf2bdf003efd042b996475e698179d891ec6a223d529b25b0dbe6c648623b04eb3fb9aa928f2514a76c997d4f51b36c453f852fb0355914424509189d842355da52631aa6e85190f2167281e8478b4ef99f97a32a8e592f101055a4264a828b80e85d53bdfcc73ac7389b6bb895165f02d45b3ff4e81dc886a5bc85b7e65d09e2ec01083c05ad1573b221d2f7e1a2d4d473fe2d7b0eee1b5bdf7c37f8d1f03ec761ea80cb76b9553fdc791ae732371410aeef520b282b167ade7997422e6fc83363954a850baa18a93c59a7227a1c302974f9f149de4760b3f3df604330ef4d244f4713a7e2013ac103ad0afeeb922507987858e32ed48fe95978d18b9b33c125a0d9b68c8fa635fe705958cded87edcc23bf139757354ae5e95b11c0e6b680f680ece6a36ac8e51ed1ae335e0b9e79dc461cba4fc1478e4083ed2a64f2bf4c8d594f21902993ed6b0eab895366480d4fe7f41f5c6ae045250a48ed8b0c92d025448b4e89f3f6e2885cc3b3371f8f4c42bab382afe53277e9a926299b56f097f78d924f4568420b50965aebbd36b820eeafc69d471c2ecef2600e7f53a42b760e8bc3a7f3de8e58511261ba6261deb47a9f79a877110f6b0bde866391064facee597b4b8a5cf81ee81904f85c0c28003d9cf1632d40528b543c042d7cf95c97df6fbfa6be828cc8dfca03832f5586b6de211da513e31cd4d35eeb408c636b76067d23ece87cba376e39c604676720870a390450dfaab7a9f74a190a080065ce2dbcedb44c76fb1d47013e0d854e72d9fb69bc7bde1701f0c2f636f04d8e06f4c75c364aaa99eaa6a8cb64fa064cfcbd03154c12ad3182e1b5fbc2a6e0f9ef235612081643a504edc600301b237e48b323b7a0ec2d608b61f62dc7bc1d60a850e3f9f8f77f989c1f0ca3cd1f899fa2863f3b6fe46c4bd4ec0f156464150fc10d8acd64b38e391f0338d757b35d84b7b45bf4ff65c4e079f88c7028b30453df0af6616ed7f0dfaa454a086949a539443261f56ec15d4c014184543ddcf69fd52b013aef21fdd11c75b55d027e0ac4f5875b1f93b99eaf6878b81453b7679827d1a2881286d4f86cec7a25611b10cb63ff65a2ad5cc3bac0b7dbf9088fdd15e8b168e85082c96c289406f12e0300da99d94919f40b357d53392e38eb0e00bfcc531be1526909b7ca0d4ccccb35475c9878c90d3da3250dce2b6d27471606e021eb4164a3186ff459fb4bffc915c01f7ce1a120aa75d0a97d3e219fa151cb4672931af316bdffd6ecd7112efc693fa85aaba38378ca58797d74a21500673c3e2cd5dc931a8be708f430454a3f79b397662c6470ff1ed743d5352d9730852fd5101103012e597ac9f172921a0c116424eae28c302d264c5f2247a12b7f727abcaa08dba7b4865ae80b09469fa35123477b1289857dcf011404b80958c2329f2584721a1fee565279c4468c6e6c7592600533f859be48df55c8a1caa86fd744dacd17604b724af77380126a8c14d1e9b36eaa23b03a89be3c438b6890667d6aa65ccd80f8dbc0f51c9c6f094eb34eb23676650bf37f99f0abf62fd1a2c91d6b021c956c86b224635a701bfaf9ce1e82e20bfdab015b5e8ca7bd2d488d0616798a7ff7a6fa099a2d1e703c6e1069611f9132d94ad624d737199a755b8fbee67a748b3162a7db03cec1efc99c53fe55d7158a4540436927edf1c490df73a4b05836ca72c04d6dfa0564067f0db9f02409d470a428a9df3dae8884c5759f5b988c066b98eed887fa37e77c121892f5830540c22518e04c4a38611110bc5c03a7d814268989166c06e1a129fc3fbdf3f5a2faf64b5917087ed04376f4f6ad2a9bbc2fc068a28339fba4247b9461ebd0ed6784edf317b86833cace8aee091b03d9ef40914417a44e5a3ee4f511505edcd74c8cf2b6540ed26748f214e609cb173b1a977f3fc6781b9d4feb7c5a8fdf3c8488dca6ea84e1b322ec72ba76dcc360cb26b4d9faaeb5dd469104ac50384048b0ae5c6e38fd7c310bf68846dd816d56b74cacbacaef251fe45d741c1c03267f3ba1b703dd7288cd0b1032e2ba225060b191585148aa55b789e098331d38e31fb42cb953e3d1addb6a813aec9cd2213aa0a5483fa9a7fb9791c4f986d93d68b810158b451c2391f6c78a7bd18ff195e7911ad45a6634be75e6dcbbed9f4a36b6c9a1ebe4b944f0401090511bb2ef3e81a1c7263336d512043ff87363caf748a6aa0f513066f58cd85a05d4435496f5cd2d3d19b8f8c532bca28f95aeff6040d4c7d7dfce1e6b746ff2a7f4f6a96fdee6bbb24234f8ba67c0418d38af235a41eaaba52d13ef43b9514e52469d9b2407e87aecaa640cd621b820d050b206e9c7bcaf36b1760db126fde6451e4467cd4fe6066ad8ff8d24596cdd3105e9ec195c4f9a663e4369b94501f8e9ecfbd1925f43cbed5807207d711b8dffd205ac0610de782d6fd3876b14fb9aec352e0c668e1729e97ed679bf73a91ebf47a46301afbaefd8946791dd6525022046a6f98fe23c609d5e1cbbaf32e08842042b71a7c5670eb7711af5680401a8c273a414903b82c351e160626c205d438ba9d284c627b6a7a4364b833dd4e1cb9914c3061633c11bc26caefb89ae79152b0f983e703b3770df651611a57b63928072553e2a07f17b2b63ddb916e887ad56c79b3d8334fb5fdb162f9bde5a9409117f5f5a02b2168c47d67c4ea22478fc24586036f001883fb9709ccec7e49674e1f148d237644b0f61f4c54964fb056b625c567f030ca6eba27c0969c1e31f78bbe2ca7bf83103dbcacd398d559616ce70b9f42f3dbd54ce48a9b74c5ebf47485b21d1658119310953f709f2b5606d7391737c6732bd0cc4b35f58ed83423d2f294fad8d70d6663b15d3ad7a5e5fd76cec055efe6916e49c95586e9fe8ffa5850a7d231b6753b4c37835187131ee8fd8429cb34ae00be95b4e61461aae246942e0bc3cfd83586f4b1c0875f47ce1b8ffdca0f1c3050c87094df3032d0d90757f5be91bc69d6393413796a3a9459ff54e581a61309f85db5ddca5c0486f22216e7765d513289a4afa96d9db3c8fab071f7c89ef8ed600e5e4a2f636f8f45ba67ab65aa2f9c8a4f808e0354a5b7934355471057e404b5a1f1d71707bd1931078353097101e08c6bbb7da012bceed7ded703db6dbaee17c60a05c19279877d2a76c9508ff0c359c43e4e275c372644f7b80798729f8d73633b0ceacc1b52b35ee521713b40efae5c1e5052a15b82494e0247784c39854d37d6d2caa251c189e229db8e573abd35c56b35099a0eac2889411f77b1105b2e1df22fe111a3e4d984cd5480d0664a72f1eecba4ac69ce2506831f7363638a8163f14d344ff25d5b50844151b5ca7ba76bd89f576c6f9f5a19ef69335c590be33c43b5046202c7f9e61b57ba73c0a93082020a0282020100afee1ab24b2ef29acc141fd85fabd0fd325ddc698652d6ce135cf55ae156b8e7170f2aa0552b10fa5f6960d28824365c3512974cf4fe99dccabd26b5071cbc634244189e73cf9a9210286bf0c1a10f343c1318ff0790d92085e22771b7cca2139d5ba8e3d2ad0a3250d747b77dfdb85cce19b27db0d4aa041400095c6daa1ce4371b6ea98038cde46ab97f72cd2e64ebd79e922ef738548cf5e7fcbdf6e279d600ad9f34298e2d7ac42e4fccf36631fb689cff2dfaf41f0db98456ff4deaef6d95a6ee1af808abe074c2b3d9371b09c974763bb9cee6ecb5d3f539b4aee17681385b93438e47590c5bfc2a086f38de16714576dee9b09cf462f6641eeecafc52dc1333e3fc704dc1d888bdcec4ca18ac325bf3c0069b894eb6e3d3b517d8533a87e9d06aebd378652d1cf5015afefa6ce61e13913347cea82e028458b1c91d2917c9d3707210e829d77cfb1a9868a86d7ca3c2cd389c7cb85795f88dd3464bf7bfd33410102673d2651b087cd8d72c17a52cc20f3e4f09d37ab58dde0df4036909cba83a9d4a68666df52f8e0af82a92bc31350a908bac80964e917b89ce2d72eb7daf81400f74f13573b20f63ca00b30e88e0ccfb2746699f7200d99812f45f90b3e0bca247c437af23ce45ae8b4912d4fe42a6c0a76fcba57295ff919f9b93dc7065e7d74f90e11ce7d5b504ac3c35296ee4c9ab0710311d74a8319b581f750203010001", + "connect_status": { + "signature": "25eb56997638ef719a03dce258fb76709f2aa184470785320aa1d3b8b6833948a7acf9bca50f8c920a880165478b2b3d4e3162ed7a74a8aa4535b636e8b3c6cff274d49274fecfd2091bd3917faae304b48680f34aec37d0ecbae47f4dc80a08e27d9e899bad9e462e8ccd3c2318648823bcd9bda353761321f37153d986f5683d20c2f33794ddbbe97fc34277866380ac5ce175a66d761a4324d514780bc6d31a8102e1df9d2a4de4981b2c6475812b5bf728196dee27844bd4de3d3b4048a7bc4003dfab59be471b6d474c57db6b2cfd41c96cdadbb333208550509b8049811ad961570ddd0c1b8bf73b3980f76b9a1ad1613b62a581470fdaf39c39a26ef1dbccdaf07914982dc7f4e97d589ad8593dcde0e50a37b9438c70e47e50d971750510fbca90847ecdc450173c38767a6fe03a6386196a9d21acba7b3bac31b75f1329f6d1bfec64024342793ab7ca85a078565c808abc0041cc63aad8e0fda969a447855c6ddfc73afcf1e331b4229bc6fa36e8a89a90bb9bc4b728e2a1ff6f19c7e8453478ddfddb372b219024eb4bca0a57b9069bb847adfea33c929b243e91756dff27197013b1cabcd1c90a2a32ed42f1c94d1db19527515980211d64ccb0f5e14f50076aab304ad3f0d117e65b35affb6d42e7ba27d7f24714cb881d022b035ebc185915a4f69bdcc55dd3869022229992a84f7633c020f6ab9981a9d7ac2f5c458ae3d92c6bad453362f59735e25dd93fdc40a18d305f2d7808ec999b6382bf502119b0add58d4bb3e76355f08d8c64c6f76bf3dd79285e463baa80fe02e1d3ff22bd4470cc8b7fea6b8777236393b586d57bb92059efd61d381022ecbd8f4b49830caae86ae0110dee937d30e065d21760ff92d5e2dc19cd0d5ef1976cb3c34a813f85bac05e3c2dda8d92e0dd81bf14a2c85232d8d69ccf57f2c1850254939fafa5c63da6a0c8980fbb32b9c5a46ad8312058f9b28fbd3e7fced7bb8d70ab6f1b73de69fb002bd6f260a37ad94fbd9be418d1978ddaef8e6e04b34dc236527868996221aa814ecd4d8396b438db9e4e7d81a2875bddcffe23be82f1414813f424b1fcbf350949b7aaae1b91ab8f4d6435c0350a361cb74cfd24d49beffa2d52f07c79ddb4a6909db40f5c4ac1653b1d8946d8d14ae025a1c3bd0151dcb4f678c7df1d8a36d69878177e0da4ed1bd9a877d7c150c53e6b27d78c235088146a43ca948737236b5f586de49a7998b9b8fec8a6a9b470c647d7039396156b908d4ea31a6d741488646f000b5425975fd7c9a5b65f5411c07544ed9d32035fa6f64538a3614d1bef16eada74371ddad631ad8e00bed48d811e6ecce6a2565054d8bc37989c5e60c489aab1ba831c85dfe5f60f1ed533b0bd1edf158667a03ed21c26c634af85eb6d3c2ece855788dd3091cd90796dbf0cfe889aaa132f82354eb4fb3cd2f46e5ceb18bd00a6831ec84dfceb1e80de1ea7051c3fca073eda127fe4f8e91d08b737a376dc7d58e91c7f1d5b411373c998e19bf359566e0b19f0ab16b80c91d581f8ad8acb46d2020f721ee8b7a755b6dc03c6e257eb53bed14acea87425ffd5bad0779b019cce65f72f72ae16e1a85f625dc21f9996baee91fc339f8fea13b85675ccb07af086f712313d909ebec23e320ead5383da6c329404476b32862a36fcbb5c74d5852c849992ace472f9f21d96a8b1b5d7b23cc6172b04a049aae9b30a8aef1524ddbaaf38efabb7ddd757aa6726f1589eccd626edcb6e3971aea6d0fd9ef26aa4406b45f7b0930ee22969c6fee487c7b31a414ea49f4cfe8b82cc96787dc6921969413d47e0c7df174dc7e3f68942863f388f573d580e4ef46e77c2592eacaa8021299cef85f5f67a20aeb9e03d912d37544a52afb079e9e9090377136a17eaf0229f5e7d5975e90aa21d10840a148ffe9e7f5aa1deabfb5697c1e7e1630883fbe62955649cd5d833c3662cd0615596356948e69695ac665eaf4c03a03cbf7f4da363b69bb36ed7d6e26fe082048c1f13a1758e9bc017c87b70db86b966cd487caa92b3537f72b6a79605523783d40609e1bc5b5c1f59da179d0cfed434e0f4d851b9bc4f7cdc9e4951d344703e3f02d3474f5a5bc1bca4757f131895f9a3689f6e6e10c86fc5c040cce12829ce78fe20235e81139e1e6dcd1d7e12574c7fd34e0e0fce60d8a15b27486832e4a5a51cac715cf7aa1ff095814efafd3578fbe41f92825dd59b4a03d9889152d5b10a9b91eed5a9d523f2e5f011dfaff9819f569f11d8e16023e58b600a1c0867026032397436f172021ebe0caa3c42b927f5844daf6ed0bf98e07898f30bc84ccc7bfa19331c981bda68578d04842e5f5d92f357539b63a9d674fa6d8862990f261fd905193a2414b4f2c5e6affcd464ba834345d774875b070592a887f7cc17351d7183a8a6a6502630ec5db5f9aecc52c99fe81c5e420d930709b67416650bd7b12312d098112bc05b028e7d5988b92727c2b67efa5fca22578694c6ff4941bd30034517c14c5975c6c97aa4814bc0cfe5fd5ff212e80b6d02f7ff59c8a9f55721f0ba5d9ed96ffd122b336f751976d149a7d3a153d9eaf3fd2f7eedb83d403247dcd4a57d1ddabc9991dd89bdf93f934f7294b1a5aee59e647d1ecc6b560a4a096e14fe6690b9cecbdfd19b05397374465158584b66512e2a74e72e0dbade643da3b1809e9f56ac9b31b4cda95c6c8804476db18c29269096308070d880b7eb3ff403f44a515a9003fac8b655144d44c3eabf97ad359308826b65d4951a9fec7bc3e7e067b4a6333610bc3b4b979449185854830ad3c00390d050d86f68cdf369e6fe7a684d467eae0d6e5a86372f99a21bb6bbc9e05c54a21b072a13746e3bc7225a8ea9c3c795f9d7fb9d8e73dc09eaced9c615d8789126cc3ca562121d5d988a62dbf279b83d47af7464c280add3e4474adaa23effe6c75f3910d94ddda61e347ea7b289f80c09873c80c4ce5a37ef31abad3fe81d20ee2779a174c9123ad976bf6e9b5ebac0f23fc17e3af0c0797db86d616fb46193d7c380c79a947e59848f4f03a801f8706513655e1eff9bec928b2128e56c48756708b93f2cedb3badd2395d83e76d9dca15a5a29357986ad468688b6db1f95d62078c404ae76d610ce0517aaa8d22d1877397d76ffc017ff5fd0874793b0ff6a5f7c166c10c1687a4a005685fbc89d3cb0d19167ebe73c508019dc2ac659dd7fd43f1cf55d73b318dd247dc86573fd0ba16a917b85c498dc64fb8d37d581039db471671146e12f7717ec9a5229354fa4e3d4330d4ee86b39ac4ea8a69cb3dbbf9b3ca31b0224d77248a254bda5f012edf3697c3e2a66dfb9f016faa6f167ad891337dfd0ced302ef1e2cc643a70869d6fbe3b7a215e5dfb01acfa20f2f06c4042ad9fbbc77ee3adaee75281ec5a3ba8e3d0d4fce9914919c1ce804b548f4e478cc48dbc987ccf8c542511fe5cb67454047b8e96c45839b4afcd372bcbcfe9aa2d340513ac3fe23b8428c550c0c65cf82a47ba000f01cab4c820173dda7d9374be8cb16040fb693fe7e9e46d123eceef0d47e3f36f01f9a815368d335d9b0114dee7ecfb031df8eaedcfa7078defcdfa62dc4121b0cc115a016800f3826a2596412107dfbd32da1f024daa253fa68d9019b81e71b028c6425d36e57271ad798cd49b8a4a49eacc5d86f46a585b70b59a54af65a2fc0bcd77de0fcb6d7b6c6f51bb0b52a24e7083fc06e63172b3310453b70db21da146f646855393c9812bfe1e0fa6a4a1f37c6aa624cd126cf20ae1fbcd52401aa6a56a66a200bb4c7a184835f4c7b08d5953ac29564cca5610e62d25427bd61cf678ba924bc7a3d1dd76ae0a20680815facd22538e8830690c6a03f554c0b5663a063124f7013b8e0691a208ea8c61ff4d7e14f23829430aa27032c47d7d5c67d958431c14e1267154b77f758e4dd939eea03d601f152544f2303b7ed865dcc7fb8e3d73805a5f171cc2ca77f3292c0432d9e6a264233d85a66e5fe460c055841ac942fc0564095d22cc9a37f76c3650733de9a8046424c15d57773170ce945e08934324d4ea1509e1f0bee5ccd871001a660c5ff761b2ec9cf6ed3e957d20b01339ed6c0788c953045a2eb90ae637c8d7b160d6bba021088e8b2c100eb5067ba5c21a6ffe518e62c0b40d466bf050722b767a3e3ea3ffa923dd685c11aaaee61dea671a2503ea8009a8081e5c829436571bd6fdc866a3b10378a7c0ddda418c4f728e8337eeab86a6c59c15fdd750ae3f0bc362962de279bc8e9beab078388fec58476188450f373056500fa27fc30220389787849d4afc1b0301d480b6de821ec83ebe725ac862459f16dfb56dcdef2c4910dae05833afccea9259f7d27cce386c5c94112e954450a09fbcbdd3025394361a701a8976ff7a637e3c9be184619b7f3972b50db58741d7a9291785f579b48847a8d369cea47683198dffdc32277243094bd57df8e9d9e37914ef5cd3536eef4744e06efc8ddbb3d86a143c3368fa34941baab65a831dde578b57cefad8e8e9133ce0c0925daa82aed230444bf93e3eb893117870bfbe8eaa17d2d8e920cae482abb5bc986b11156ef0662392897bb67c2055d2a6f83fd824ecb33ecb08111a29cd638d19fecdbd6b5b12fd5a1838762a8e47b37f28988de355420185d9e40f779d6e4f347f220c5260869962ec69757aa32575b8df33976196cebbcae5aa8776c8fa53e2dfb4cd804149bdf6cddd11d4e96897ec2d3dae26339dfd63b8f3ea2c90f127d1fa6640043ca40aecc1b21c9c173186ea4b381d0bfc25178a5a4d5070fd598786e355b38d7d388babb37b627b3fe246e1dcf85ce072e16bed87def264a3a718b7276b883002cee5889ac4053a16981b203cf13c62801e041bed77cab8a21ef038c137d22039aab101b7222c4bc04e22188036f491e6e77aad2850f9fe2b5f5a3d2dfb48925c3497508ce779dcf668d6b2b291d1bfdb55813d60b4857bff16ae093ff486653e4e87bdeb60c3567c8118caf07122d91d7dd5290687feb32be215f5d5fe3431f1900d0e8ae7d60877d7c95fdd93f50b58abe3852dac1b26d26ecc238c058a18c4bf61975d85804c7d3fa0a8300aa762d98c9c71d857c914098b35fe639835c134bed9366f17871f609cfb106072acbd22ddca7a4aea8df6fc3d77b8e14b8715bd504f74e050c7eb7bb595e60e6fdfda03594b591a46a86b840d4809062fa125f830d0600368973c6889e3e4c85a61d8d0ed696d53daafc03eff74d777f1575414be9ff383506d163ce55cf23aafbc2885d657aa345526670d4a152d7ac14323f9ad9cc457b33bf7285b53b3917e30529b5ce2fce13ec801dfa97850d3753af463c9f734403d98d5c11ff2a67f6a5e95937322551151864989a872427227e10abe74832f3fd10cc70ecaffb602b754dc1f106cb66828c9d1ae8771ecdee91831c624ea7cbcbd58115e2017e0c318b22efdc7d728c8aacd68bd95ce5428cdc2ceec8d53c6167290dbe157e25a14811b4612c5700898c3b1c56730a067d9777f843133e499f1636c9db5700864b24fd511cc03b0a45edeb3147c4bc338952459da91b53b31bd202b8940721cebab83839abfbcd15ea13fa5ea9296a802aa5edd2e01db5ee0f4f8337b18c0eca5d135c898f90c030db86355e56a30bee23259470b965396b5ead4f8802b0cd04c8362b0c7d5e7a36bbf5ae4c102cf8406cdbeedbf0f2c1333d5e75f50a80c9f6912e0fc621826baded32e985769cfb7ce56671adca569d9b8b5d346fa406dada67658707241c2a59a61872bc974782817703b292b6cec4048052e908bebe263e5f817f6a12044504d323ca4d062e2e02338befba6c93e8f3713fb55b9c85586eee5dee4c7022b1fad0722e197741f183cb6ca03350f7aa32d44526f2aae614ece507e294e6d422482783b91c2ced821e9d7773374fde2ecb693873ae9f91acaa0cc7228f94d6ec103d4031d0b09ab03a96a887ddc45cbabdc32731cab85de8299a455e181c3289333ac33b702d4fac19dc7119b85f2dca93a0abaf4c38bef1221e0cf0eb076ab5ef872e9f626d70e9f7244c14609853520764508d3988ed2098007c1857635ef1b89b5d756a8b8cdcf80f1cbf997a31de151b759a4f5258930d71a8b7ee59527087e4045d46fed9df5270e61396c5f7c2f82567a7237bcba8902a17c877512061ce8dbbd8f865dd3ffe7b1efb07cf658fc931113a4beea54cc87acd6813a736a2144ccde6bce300099acf5f74fcd4c34370c86253daff760e1d2e9972ad1c825de2e9e124cf074692ec2c978f346ae09796a9cf26727cc06d47cc72a44286f880f40fd3efbb307f0767670eb5ecaf0899b8e868d2eafbc8557234fa9071c5e6f50cabf7c045e909cd9ad6e1d0ae40f74162a4bff6f8042a5c727c8e9095d6eff387eff816699db4b5c7dfebfe509aafb7c6cf010d373d5356a4b0e1f5145f606c99baf1f3fdbcbec2c9d7000000000000000000000000000000000611141d232d363b50042064a4cb6ec3ddb6a9f26af209f8b65df928ad9f988e7d1ce5c61aa09829586b5cf0f6c03d6d8604f2585eb6d622f79933ad589c261aff2b87b1ff70a012d3de9dc06028da83ef53b46d21504d3144c458b424ea890362437d94519ce689ef45b01e67ca640d7f3b2216dc5b7442720aa69fb4aa7e57084397df769c4e41895bada62891fb480b3ac80922e01f19f1d468514d4689d32a8965dcfab350ac46472b65f6186fcaa4411afbd1ffa1cae5b50f12a07cf29463e0455ed3e5aa082f035cf49e4a48544e6c2337e0d9ee23b78d39a8ba7dd62c54d4edafe578adb28970748283d1d665431ae32f911006b2c7967d5fd89e398ffa57518dc4a2db1a0b10cc9191c2feb84e805fce58bfdc752b620404278b28a52b027cc11b1e6c4e783550cceaefac78db34b19b3862f2d41c82875888112630c5e030ccd9473cee330e6e3fc1a4b925b969bc888efad2ef456f2baccde7fd553317401964e8903de6bd1788aa56fafb458f69dba1d841acf2af19a0bcac674fbbf7212de438c8987663587c7809280ca9d58ac7fe6ad3c8d2ba3213ec6e4533381689d3221e881853224bfd01b8f327d73f86406c081cef24b8ef66595b80d352d721aa269294978902e59b7c4afe1820567da0db5744d42db5e7f2aeca020644aceccddd7939699d0a6bf3bed3202f6a398c937c98ba82376f8765355f8d83619249a12e593d54", + "tbs": "a6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f69645820cf4966c74356a1435f52bbadda8e5eafee5d4d0300fe103a61e5f36ebbfa5971677369675f616c676f4d4c2d4453412d38372d5053333834696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f68617368583047e217d4834170c987264d024a129cb4cdd935f1f4727678bf69ef88cccd98006488b948306269fe5c8433ba568ccf30" + }, + "identity_key": "e831245cbc58a8f019fd6313e94c1737c4eecf055cea9c3370e6b60873a422ba516f742de06d5ef2af62ead2c847ff2d044212532e301583256b9265f007c6faf74097dad7bd755f1a159a83870148df1c633f016508c1776b949039b09f08e26aedd528b9f37e41da91f513d5c707db3fe5494894ebab4047e45491da0b98ba7cb7227fd12dd8a808c0c4ca99566d6ac06dab350533c7c513b1519b305acf1afe82c896a81a2361eb1fc778530438960cf428cb16b9b9004ccc5f7ae76beedce396c3b6a5513a52ae7b2679a3f8d8856746ee1a5c9f2888cc193d45739a0b921675ed4a6e9f7fb7d07e9d9cbc87e8a8de8a93112351e810d7e4121b53a376a238e78263499bae00fee6e059cd4fd22bdb08dcbd8dea1197675cc80830a42b3eb0679e828747f971de0a3b006ec1ad3de8686db1faba077faf77565c8b3aa6069c391163bfed911dabaaafc3d084a87540c666b47a5370035f071199b4e8946c358d1de6aaf79ba4ce7a69d9e684f57df0e9118eb7cfc69c54b791b574f71e631eadae9ce5e90635f0078da7f0a49c2a59241cc2ad26f37eec7247871929e87434b64dfdf6206b62f2ea90acfc78121001e350859c51aa1b0cdb83c3872f7b63becb7d63140bb12903d015b07acd82ade75944d72c793ac83aedb8878160c6eda41c33838f7553b29e05156f459a2cb7edc54e63fa52219c03604474b433163fc50c43c1f2a0712095d4579dd8a1d67625fa9d795b1dcc986c9bb896a01158c4bb8c980a081ca2fda78120c7446796cbac9d66e8ba1661304a9634a5876d114d95c2461ddf0cfabdfa31d7745ae73101e530cb38def123283c8d737fc61506ca75f7ef2ee7098ec1bdd8004f0545e333f499d8089d4bd1dffc83e0886551d78eb7c31ea37c3c37508ddf5a166ed0cac3f9ba9833c633dddc20910a5a23e44e33e82ae4fab627613565cc550e6b8bd65d0578df5c8c8841759d2daa89fb1a6911ef12a5a48d1a048e5b7f9c211d522840c924ecf900c8ae1ca946354ed8099cf9696eceff933da87b3f901c10facd2e40f82f76275bd9b512c2500e74adb80643caffb3572323e9a2b1c1b8a2759efd3d5aab342b606bb75b78f37788005108e1cd62105072dc5dd26fe1b9d454dab9f116c545e44656eff52a83c0c9f822bc170ce91a9f6ec96a04b4f58f3b20fd72c9f8697992146b0b554a2116d6c40b3e91d769e052e7f62639683972ca915985070e429c6c4811d94742efea75088b6169376efc11bb5c75f75e3edb9fc8c45e4f2e7046866ffbf02a0f82e9afa6b65a731d3e041819594c4b77ec4644d681c60f07b1867508cb50ef8316173d662e85af27cece2d190ed83c7ac57eadd1efaf0d409d340423a59646ff52ae1dd19fa6247b1eeb29cc397bcd4a79a61bd754426aa23ac8f4915d0753d5cb5dabda30b8feabe4a28849928047c30c1a0ed56f85d34599e41d95edcc5cb15a8b983f4ac60bddb346a26c5bb291ce174847681a3502da9508226cc44e82bc6bd439d4f7af8697e92172315bd7e15c1a01e9b1b77e4654085422163d42133776c5d85a7870ec5413c66376eee0bc36c3c1d17622e2a4349e1cdd2b7fc8a64ca8d1614377cfd2b938797ca4e295dc5cb0afd531cc79f96e3a0b641bfbf01b6d4e958ba048c8c117039e63aecc8dc513209f56011e1ce59f93043ff4181030f083838021d58821429d2902dfa50d8c3a456c7acd3d0f700286c4cb17926a219ad04b8ae6415971e30c879031c6c24563385a80964b20986bf121e4f589423d04a7b16e2565ebace96492fbc9f683f3254f5bf4f39963ef97564e39195bade6d36ca77161022837b544864eb156147a203ad5479f69dc2355840deeed4d9d82ed0963e80f358b925dc841a6e555a614d997adc5eefb553343fd48082613758bd4c2ca9543e82b4eb46f47631dcf5f076f5685d6073580d46b275f4a3543b2d177c99e64fa1b3287525eb190fae685e8dce3c05d097f6e80990b292b1cb0ea22af8c5d1a16730ae8981e6931abea605a384a236698667e834b12ce274309d247bcab474e1a23d1cb238f631ecc8b9ca44b09cc293e2d3216a808ccc8a4c02666cafb1412fb41efad522d2369712a05eabdb5d04e60894ba74385b26c99df3cf3f6d7353264bb9f4546128141728c24d12a9a8c71dc11f485da8f577ef34ead114439ce2d24bcda25dbd983dc544b9e590eb90fe5af2608c151f448e0625aa8574981a861ddf96687c11a3b900e6b3d01ef141677378922a1a8dd3ad87f425337cceb4a1533d1e71b859c0372249afe8a5e91a990dc207b16efa295bca7e1e3e91f0b22c72ecb2d8555441040072a539cc36f37ad9fbb1ab1b6bb4736d1536eaf70c5c58baca371035ed409bdb72fcae1d0f2f12190a379eb24e2ff2713b7c99ff84454059d3ea8b9aef8acf9094da1b36158c609cfcf7e335bb0265edb303beb86e4e1750e35ee4d67819c2fbd02a1460174306c836eb5edd888835763ccb3997586c1a4f142ffdd1624d400ba50713bf5453b4510a80528a467e31dafa67e7697e3712b6a14e0c805545bce4192ecf158dbfb6256fe6d62cbd17a0e8bd81c3bf19f3f50f47a886e4241bbb7cd73389bb7f0c67af861b97267cd65423d58fdc848d907e4e52a72a5a198efb635412d8a4ce7fc9fef68969c854d9ce0dc8aa7e20bf358d28ea2bac599d772d4127bbc2ba1018ba66c8ce95e65606c9ca28b4824ebdc455aa2ad1cc242d0f01549419b0b7e37747d1dc583081f0db3cacc4f12f783d36997c8e59cd0827509a57fd766d8e8135ef6c09f773065db8a1be1493e8b0a62c7e70dad0151cd0c4b6e599a5216c0b7aa6130d2209f47f2868264ad5f64efa3744af348a508fdc5457f808b3b3fe5bbb8905a4305fc46ee1ec73686a8d38e91e25b17680f4620af1d44c3fdbbc93b36cef0fae20074167a23b95dab03cf8bf959e46fb8df6166ddb854852fd778251d138bd27b34d2eea98f14a56611de9c57beed74385f1178d169bd783649fa80839832ab17801572635f88e76bd057f75d67bdd13be16cacabae649f54187596f45b09a975bdf347b2dfdbd06d5dd1f686d03238106821d91d06afda687ac886ae568c2a52b91a19b65d75524daa718ff32cb942a221cf443d36cb52c6dd5bf8add9587868d074fdb931ecdcf5d4e39023d82fc021b44b99371b62017bdf1ad958811f9bc280c985ab9fc08a0d9a665b7a483b1692faa76e2390a2f36f90a86607176ac9f56d6b43a8b846479d52b39485cb1c45e30d8c352d0dcdd52e790a4d28e4b0f38026fdc6375362b2033efb2249c8b6d213f633ee76c5835089238f45b1c5cfcac49156bd3699da9164317adb90d514804510094c9bff00f4ec4b2469de6f9b6dac923bb076a0a2de078bce3e942a3c3bdf6b0c6c3e1ce936c960855c6214929d276e2ef93ae909adc9472894a082f324aafe738725ceeeb514edcab833c63984358801b558ff0a10a2cdce486f7e3bfe73c8dbbea90b272b9b3152301af8ab877e4e99e0e96ce8de6783696ad79dbca9209124fc3bccad51501bec2628a0b1bfc4b387e34b23f8c704c98ee6d0c8bf511030743433d0696351a31cc82007619522ea7a844fc7e5aa893fd878351186fc3921983082020a0282020100c4cdb3683fe2b6996745940416c38dfcbf16585b532d40fa072f6b31c8d3795751d13270ac4efa8f3ea3b909313dc6f8167b0aff011424fa9b4ede5b8664baf9c5eafb2771c8a1b074fa7f14905710aaeca67c94d896d7220bce734e17e861b6465059672b013bc63dab4f8155cb6e5b5620ecd3d48d56c0aa144b5ded3ee8475cd30f87a5ae4b9683b4b86ee9a3ab54e8238be040726130eecebe30454260c603ed8f84b74a449bd6c55f651088b14bebf86d4a012b631229b290d601bd688203d13755204e3d503f0fb555a238b31815c0f2f78188c9eb27c8926a38b30a73d32345619ca802f1745824801e3bb76bc2f8c17087dca8452b037c1644432c02a1ab9747cb5c02a5926736802814e124222e637bef2fd951b1b2bce9c1850f3cdf65a9e241d101c990b97c7af84d1055ac6f3233bccc298132886af8a7e7671b5fcddbf20d06dd6c7bda59144d9fcf6a8536935f3579f0b8949aedeb1b4dbc6e24681dfd846f88a45ad93b93ef93126d3ca23b422ca33880893eadeee4ae4e5355e31e9ab7cd819c632e405d7c66fa5a8e43c0248dc2954df059388e132630cd3993f9f0829d763a49c9c5603e880c6e70f2a69a01f630f60a938d7bf206658646b0fbadc446885ab0efb294ae53dc326c9d577d66da20f63e45b8f98a2fccf5da5868bca563943d3ebbb23e8da36de9654b80e6d7c90e560aeb2b6577ebf8eb0203010001", + "leaf": "61206c6561662063657274696669636174652c20617320697473206c697374656e65722070726573656e7473206974", + "node_id": "cf4966c74356a1435f52bbadda8e5eafee5d4d0300fe103a61e5f36ebbfa5971", + "now_ms": 1789000000000, + "profile": "pq_hybrid", + "tls_binding": { + "signature": "6c61199f55cc160b6c92635dac23ed7ee3cb3b5ea743e947a14ef36b3ce70dfc6e6426087e0dd1921aa6d18f153e18c678bc4a583d750de7e654682741a1cf4f1a365eda7ede1abcd6af293c3f42ad98a07f761c15ccfc2388880c70832f698484cdff31d049e198d690522086eaf8fb342df715b13e3a2a7ad9510c262b384a138f75602484542dcb3ee3398d15b6140b050583188ff92a9b2d0fafc195e961aec2f529a848a6998f8560a1b3d6675efa6524807d0e6ff3200bfbdffc65fc7711df2410127633c11676d14b776beda6fb4e9868abe73bdd91d4a20cc4f6f7269e81919ab2523bf00822de1d3747aa68a42e6b235f99be9bfddd74327f7d19c886fe02998f53817812a0196323b0d1deeccd5626d573cf50a5d9e869ca3ab6f797ffed36a91b1663c3476f94bbd8ef337065ae2eb4aebcd25394790e512d796a6633d5a4c56df40a7481b3ac1294ced98451fb0c3fbbb7f378d40e6feefc104b8aa64ee1450653810d6a50b30cb22ed89b8aad7454beea1fe4ed5995bd315c0333407526840823537dda27ffd439a8decbf8c30e3d072876d68ec0e042f89cf5ce1c994c6ae223e5f8f7a85cc96a099d0cead3416726ea8a55b3626cb43acfd8e71ffa58cfbac9544aeda4d7b250b428ea97ed58d5578e38c2260a3c72cd98779e2afe3d4a35c136896068d5cdbfbc69a14fb11da7155bc58d0bc59fdb6e908f99222c51e8dba6763cb02f0a5293d05ba13a3937089ea47cfe45d601c496df77bd5d6f93dcc7c7a787a4a5bf731b76727a9da3361237f328b94d14d47f468ca25f7d9f86afde1a5204cf166e8a2c93e493303add80904a3ede3a2184ca108f6db1d6b55f3442504ff6862eb58ad3904874ea51d3edee4f7463e2483ace07a1a1f12d1deefb07894a012104adf92c50ba870e1c8cae511895d871c418f1d790629a9f37c3a48787b3a0dbc61178e8483976297509c279e9578be027393be05e3c7aa168a9be167a76ad61d825ba0975d2d61942cdd30f09aba74a0f83130ca4deff5baf6c03d6f4ca0e4aab4f2468772e1266fa1808f6fcac3333adfe809939adc0a419ec9ca0a32162889042f25f06f5e3b1617b566db4fb621e18681f5b2bbe99c5bed63e77da8f97c29564084cfc65fc9fd693f8ae04fe4e9adac371214c105ab9d78318f49927e935dc399528a143e52189d40b7dbf9fa6e83b5d1f119dc122137c54d22535779a91605f97074ec984da5fc993054acf9d0790808cf784c00a484c9f99253f7aa734fe002c90d09177c897c363b165d386bc97544e7e21ccbd5c684a50d7cebc399edcaa7b5a55dc22078252fce9cc97bef8f1aa75646b1527cec7d51bc31d5390250ef013dd401815d76021aecd43aef290e4411555b55960436f6ec9641860eb7aeb8d35da4a41fc6c1123edea5b3173f942d68cd5a9dc7f41b6833e97b067131c42a7a1bd5000517d1a0278bf6ba5af836204c00134179377384e43cef1fb8abbf06f6bb50cb19b0f59cfebcf794881ff592f58a1bceb4ff83be33373bb202f80b9a3809de6987df4b81a48edb77f2e3d53de1e06c41407b18453dbb837add424f309929da2890eeb43fd86578b5708101ca0152e6286794e4515d0488e449666cb0d51fc516aa81f12d1f8cfec0342187feb877b3fec80282013aeb76ca56c456301d400959fa3f481606b2a22124dd881d2643ff0b9da6ddb8e7b86e99835e1e7e680a4d04f1779cf42d89e1e28a1f3ff8a2e9400ce15d7d7d1401546585112c60f28adbfdf39790f0c0772f18ab8f96991abbd328807214e3083a75c45b2410d23b0231eca8effb4561bb6e658d6c87dbcebd70ce95f8a06c81dd1bd968c2406ad9803ef1df31688d29f71438911534744febf05c90d14454abc57ad78d542f7614eea74b0477b2f5772fef8efb3edc36aed6df85307668061a02e72e97fb7a30a918e41299918850990a030c88f29eb951e8c7c2c06559dd275246fcba3df0af203face9b6183d478da846d455d1903697893a97aa775497b479f36c244f6546104f84fa65ac278763285b4f9e7aee2fe7b095c47fad4771626aad76e77407a757dea140c0065a53793da26a41b147d7a5a0e4d288a701c559ad674bdaddd9b194f46e4bb60c3be77c703fa20c69ed398b8bc4cd1bf1eab8870e75fb2ab8f72d4cd69219f2974fd114740e19a561437cf79d14f7064828881d11bce10d79a9dd7ce8e19a3e95a1765347f82bcec355bbbd194e19b23f61166218eb05d97a1cf54bc670dafbfa0c72a7b9666468e86977fe647b67897bee0f92b0f4400f2ff1b134f6ed49b3988959981658ca8f7c7f69fa57a92a4632189a7390ce68474c7be64bd4b9336286134d824dde6db2fce3cfc92472450f768724afa3ca0a43a9a65440926a753a2e2e306a6345e9c72840110ac51b655209a3a13831dd597f5040c38999a3e4c13929a1d5f7b4f8d6af8a9f42ef1c1ec323d1c7a78a54c8924c63b5a6a266e3a11239c0a59c765b61741d653d844d36c98ef4ace7164e20854593d4a7207a7839ff466bd20d852e795095a2e4d5026abc0581869cb532ce795ded93b9fa93cbb28efb5047ded81e98dc56fbf86f62a734326f8b27c6d38886607f432423358623eb2dadbc41318eac1607237476bf5bd4b1f375a75c58bc331956a85afb45bb442ef9b7eacd91fd5793b624b06c9ea217b9c45da519d5d1b240f807d50293aebf4493d8f7ea4db6cc9539b24586de2e459ff1486b251369a8cb8aa850be11ec677771c7eeb9b04897e27a145bd11f2b32dd985b1518f0dc459bd0d1c095a5342707bc0d763db84ba2c7ac3c3a63fc193c0f3c02ce55874fa349e8f192ea79cec71e7df8a0befa0585df7e1fcbf7095da2dec46147f26716ab859c57c5d2040b97e5156479599bd9ea4405b9bae9fa00cf7945637d619786e6685c4f03cc3803ed460c56a01891740a844bf4c05677c16dc6b16d3dbc6d3d508aea3b91331638275405a6dea386bf815770303b70db13b0ad47747905c635a62c532df7f94c3ccb8e6e69c2b896c88e4e433825c32ac8b8d0737f9efcd61ccb826f9a5df73a709642ef92578f1c3a8faae4df8bff0239f7a5efbe8b1dfa6d6c897b9ba94d049dd7346702e4e7d98cc197eea670ed32786958451f270842cb0b8ba46562a4add2cdb008330d30bcefedc3d737f4cbab19d4a4bc7781da22f23ba8778931337052221c13400e35aefb8b014449e5a14dc91ab1c601dcbc944f741552d28e2aedbf020d183eca9fd6aa39afde7f39ac94506848814e626b942de6a6898a1df36f4ef41a4f2272ee3bf0c956f604ab98100082e71704efa41fc90c8e5cf43a6eb7c46e60f550e5db45e67ebb356838df2ccfefb5687ea891c3bae047aa1826e9bc60d36de9c8ac5b080c7eabb27d5e8fef3397e132df97961da73e1b46b7689bd31ffdf340458ad1645fd3917ea67937114000ee415ecbfe1870ef742289ac14f0bc7fcfc8ae78b18abbcf6d9f6c74c08db31b9ce5f678580942e7ddb7650110cbaae93b5b9d596c03a77d688c9396969dd9565a6cb761ff33a95857385c09b62ee632b2c384650d2f412ad4e9dfa7c872f258d39d4f4a2d0cf669b746cd47f5bf18e7308332f152958bf3525b533c0334dab92a4b87326c8e487da83b465dbf0b7709c3235c873236acda8633740dceaeb391bf7285205a9225b824f7a1cecad3437fdcbf3af229f3253546fc5ac5676bd8490a0a80235ea01a296fe0c641fd9168a65da8f1e867ebaa0636a58fc64517456e80b7f27d5f45183b907475145d53f3ca715baea5ff128241eba7a1b4d3df0a34149605b39a702800fd98b94e99e0062ffbc972ee6b466bf6b1baebab00b5097a0f75814a474781c2ba5a9ce2d595e51d4fb59f585e450c60d6df926862ff74a8f366c53bc9aa858efd309789a01342bfa4769160090c75b8add6973bb201f39053cc30d68cfedae3d27e94b1241bfe2feefc04de68082382bc71d778ca723c490abc00a096f8001b07a5ae60454fd3c0dd71d4dacf565f2035c739e9d84356a06f3e300375648ac13c5d93c1ab422f9e3be116f4d6000216e0869a08391560b17db4738bfe58914346e8937a679a6da0d05b62840c8236d729a9054ba7be4bbfb7c96488eb3af0ecea05763ce34078374a30df85ba7f389009c871a3d1be43301b08068ed653277e0138ee22d69abb67f0b64de30f6cb3e2dc95e88442940ea0a4e4d6c10600ca4a494e5d18ea4f371fd9fb1561d235ae2706e010e4a06437b4664388dfeda172edd9b33ce629043ba59ca643769bb4852f299d48d437fc5d6c41b3b91bfe647de90cbaaa571a204065e5f29468dc8ed86eaf9ea4936e8222190493d7f6c4195fa03121783e4e2d0eec14edb3966388c38f7f7cdfbe94c4da1db58b85a4f73efdec5864a02476a7f5a1a9b0ebc4b81193b34aca38afb7008cca324a6ad68f1800267b01bebb7e37d79a351e89a00e4cf4aeedd11f689b3d5ef67924aaadb9b7721a3feeb9875f246453c81795fc014a3be78e10ef1c068f0c6e76beffadcec31cfdfbe49bdb67e1d4348f8b9ace3df8289de22ab26ec2807e38677bd68cfe7c0433457b5e168ddfb26bd6359b4a5160bfdc1ff5608270a7bf52db4bbb85a84024a5662443ce09c3b4f14c602e8d529ceabe06f54a8dc425b7e53883758b55ea863286326301fdeb0fab0cac858e4c1223527efe3a571978b2a6e97ddecf36b846506b40bd56c2ecafc7fe0a5f58ac8dd545ce148e5797b0683d9ea5b7741cde7a67a5896d2bb09e1dfb64b65d98d4da8c4aad787302ad3391b2bc338bf9744b770bdc3d9759af41c3d41a88aabcbebe27f2ce1d3c149bc14021dfea9cb9aae3d91040aae7cde90a71d614ef7fb811587f90cf56be12b2d5c915705278b7f41a0d6a62be79e502332cc9fdf09e0e1c199635aeb29f88bdcb59edc59db1981b1683f5c36c7624cfe3fb589e939f23b5818ed10ba5f4772816beffdf2b27877836ff5b30ad0fe5fd868aa78f7123953d1a6c540f23193d44b2e6c90ea0145fdce2959fa1ed1381aaa6b074a3c8e4cd8684717582f847deaf115dac2d4159a864b9b933444082171dc8941c06a1f84d361ed59210394ed8ba9819d3d79b6596ac7d3be0e5d43990349b3462df4fd1018d3d0847fdda24cd3734d6cfd69f94a08faf350026428c433a1ffa5d5997f457144eac53c11e17bd0d67ef241d76c8bdd98d55e9130cf815be1085fb2509b6b417c96a355d6f46749c748fb96107b0f32a3c1147dce1b43d0f2dc91fc0775f7c5697931d2560ccd4059f65fbd0060e5e11b2ad43a4bac8264011c1ef47b149f3336ede7cca54094c3db5c6ac44068509083dc7224eee452be1d47d4d71a2ee92f422de75175bd2b593a634b79ddb09391b8b0119506ccf00e1755ccce612514bedc1d92f833b838b45e307511ffa71b3f111ae3e41dbcf73d2e7458a8434e5ffc6ced9f9081fb79b196330639adfb00cef829465bff34013e4022f77de93edafa50a1b38a4e219b650b4d17fcf2f34e9aec965923099ec308887e5b70478994c88ff27c168844647da8b4aadfed79d4cd0913a4473b99d58295a75bfe26097953312376cae3e4f2f107441bc6f40d6689d6a3f02883d5dfee3b09f799c8e1d9a9f132cfa26775b291df71a0b061bdbb7162617148be23ca8fc8e94d68b527e04bb05197bce11bcfb07eb1f070f454134c1af90692a79be8ceeb00a0df58fea6a6d9d33f99022ebe01b189c0aaf3e3434ea11d35537bdbf4b5d1df615d53d80359789b1ca0d103a0c89bee233bbfff3592de96c6a929e9beda7a98e72e4101153ffec78cb37d1c7632e8b159a0ebc6666f9e301d002f3e55a78375c89312b359558bf49dd21a83c9a66be5106f998af1a851b8ff8e96ed6e1c6e0027ff60a97a278f8b2aae9e469ebf4fb29d8c500d0cffc16fc55bc637c681f66ee07ebbbc2710f75ef1d88d87f29dd26fdfcddf7230197a05da0d264c09887c0cf0b53381d69474cc58e80ee031a1422d8043abe29c244bcd69aebd16dcb9fe14d7e1f4cd248eebc1fc2a20ef79ec8593bd08aae5f13b64f4b971a52913a437d67d7022439814a14edce97e97242a77bf4a04cd598b2d4d8840a59fce4b54a8f82a04fb015d661692c9fc8bdb768c736e43deb02394947d7ae1d67af5cbd6c044c042c47544a071158dd1fec096f469a0c91b1a779bbddab752b0af66a4800624c0d7e54674e3273c21ecb2edc84db6e366aff9245e5977e9bd29acddcdb69653f237d0194bf0a156fb8e950442ddaa762a3e3a549ca2f63a9eba4fbe46bac07476d1b17b968d1a5c953936ff2c5b489e48b0e0b5a2dafdb694ff6d158f4eb35d031963ed0bcb7d65fd19721b031173f43520d1e12a404d5e68aeafb0d0e4e8fb4b676a81d6ec15316979801221dee5ed0c2f6f727e23313549cce62d42579a9daab0cddd000000000000000000000000000000000000000000000000030f151a1f242a3303b43faf1f9fa9703ffe9548375e6e8d0be1ccabc61720a80a5602eb67d5aefc346e715413a6a6b2c123b71e8f1ba4d93fc51f915202a8965e9275f48b2d12f675e46757750f7f50779767fab0a59aee0f3c93f74d791f1b812e13fd7a7bf3f6d99a9f8c3697b9c0a838320166cde754494bbc2466e946f113ff993e27903523e34427d788b4a009c4425d35f9ee4a3bad14eea7bb69f36a5542af275cda390ae711d368b8ce697965d007056769d4c23391e9560d672e3f1a721a0209dc098d48b924306d7d20d947dc06565b21b39f26728e4456a9aca276c3f867815c4111c78d2fda78bb03ca82fcf5ca01348be8536bf6e234d2b8745ea980ae90b59adeb6cdc0be80337158de004e20af886d454bc837c75485444a60c082e114616fe30118070e92447ce75b0cc5dd1b7517d733eca431fd3445b960b736a8ae18cb026abd5dc085bd4be619e511c62ca1a3d5fdc0e12a28c62599e745337b409447a3d0f4631f9f5949437005f4a5f2cdc8f82327f398bbba14ba5d6292b3258e6d81d5fa50e2c85da0a431286f40c28541024081c9b1c6ec42cd0ba1a29f6ff33f66d6cbce6587dc2ef1a7309354fb488a25e922b0dd51e3ea62123ca9d1235379581d0ae509e132a29fd31ed83fcbb400dc6fc83135d2bc372285324eb6e513d3a42249ce2110cee32639f7ce4e7bb81961e4406cdc43e5518f87ce9dee4aa05833", + "tbs": "a96375736563746c73656c6162656c78184d4143554c412d50512d42494e44494e472d544c532d5631676e6f64655f69645820cf4966c74356a1435f52bbadda8e5eafee5d4d0300fe103a61e5f36ebbfa5971677369675f616c676f4d4c2d4453412d38372d505333383468686173685f616c67675348412d333834696e6f745f61667465721b000001a0acc226006a62696e64696e675f6964505e682139dd1576c3b791ad6323a701556a6e6f745f6265666f72651b000001a088b5a2006c7375626a6563745f686173685830ba5d003ee50124a47aa0d55c045bb81cc4d8f48f8f2af53deb5f9814f14bd78ea652cab197fc2348547d4cde771bf49b" + }, + "tls_status": { + "signature": "99c4585cff2feec1330185b5322afaf3816b0c5d42cf18a736fc0fac9675f6f950e3c43d5b21ad589e4516812c62251abb75a4030b5d9558abf96abe22bd852841ca14b20d402d666ecd5ea4defb78292e53a2a421751f1bca5bbe8d4033b39d9acee21a66b02823ac6b09db373ab84fc2b7cf0551833bb185b0b937473e8783f099133acbf52566eda50b0ba6e86a5fe8aabcbfbb52847309a650579272c5cb4245f51d170182f5cf05988608b1e621a6f55687efc48863dcc2658947ec8d95939a3a8bef695e5847731dd0af240723f23939564f3b34bb59fece42efd88f603a1cb8e72cddcbcb6469557bd998b8cbc34fc40165c5e139d1b5a91fec6f1eee3183282628013438fd3cde4babe98c477ce969baee98cb45b8e890c0a871a5c89b1323ceae70446902889604c5517cac947e4e737aba6a8dbd912ff20856c6e05c55cbb9673ac50b30997a3cd9eb5d6b3e1569366d62ce01604b98c3bbb1776788f798a4a4168f789e9d209e1bc21a77c4000666f31ca998d637d474b9f4f91747def2a77fe846ce6ede8ad56969285617b37d9b5ef6ee29562154281eafdb5cba29bde4a4cc355f11a40a5d20938f5b6e8beb5c776002ff70f96d783d1dcc2318c9e9ab09a5b6cfabb1341b8de1e2078ef65d6740fddf50bcf7aa4d3f05a977efa07f01433acc4d86472acde79736cfc41080ed3145a69c6d6b0a33384a9fd8fa2cf531c672f5358d33bf48b26052b3d2f7974f3e05e842df434dbad15c76459f12f37e8a0bcf53c491d95491c910a37dbc7a4c8de8a365c6dcc4985f1dc0f22716ca7a4e6e466715bde03ea98da5222e96e5cd258df9c287c32e680e37423a985cde317fb49c056959675fb0f0072d5cb12b1a9aed9eec5bfeda6536dde2dd6f12edbeab7c1f3b9df7aba6071c81c4d2ff791dedeefc2d00efa93f341365972fda3703b0f22b4baf3a4f5ee7c5a768efe3f4324b23c54b38c4da144f298f767ba09fb9d0cea33b65d4ee41f0cec3b4788f3c7d5415853d1b6783ec1b31c5f89f55a121d539e30383e26bb22fd39104ff2b2415085d7ee74cb878e4a42d8137b71d69a4419061605064466738d2eea4b99afd9cca89afd98a94d71a0607ab687c804e76b4744274143514891c19a74c4ad6cfca9329f12f35e0be72eb00a6751e21f65d4b89d677fe742a63a11630edad4be6969bb53ac26c99c1c66ca5006a30e007b92244d5e95a0f304c3af3b5a65b2ad383bdd2a4189a1f02684d7f2eb0eb23b98d21cf5e0f475abadb749a87935bfc5da917befc86ba8a1c15fff9e1174e6ef0fb6efdc7cb11fadbdbed5e9d63fc928cddd1f79a060d16e3c106b09ca17a193ef369598b7f731198287b78a3cd0eb91e8a1170b38a50d500befbe3a0b05095fce6f5507b4df29c1697419fe9ae7b093a9bf02d5b09ab6bdaab009ed94bda67e1d9a83e2472abecb32fdc0094cea0d89d453a7b7bcb895a66b5eb089c029237fbaf52b2d0fa1f7c29cb70961aa53339482e215caa71d9aa6bd83123880e8618b1a2f1db5d36abdb1283e023eee639860eb069eb8e374f887272abf6e278f922c452901ab67c998e048b828910aeb6dcbaeb187e216dcf1626c65dc61e229cf512a8bc688512c2badac8d1ec8d6285b7279c0f053556a96883a4656ad20dcff8b6ca7f233dff183e026e1d4837fc2ea3a1710e514ad3fa8856d4f51500f5f5bdac424bdfe4376df3c899fde1a50600f68ca3c03eaeea4db3f06833b20fc4924e1e07987ffd818c078cef2bdcb3ce6daf8c8a7ec8c90f8c7af22f91c6cab1b3ba05e97f301f73355b5d80dab6429100972f798b65b218c7b39101c946d89f41a374e4e558da1786346eead3cf740b5f3fa78bde48e376d83c90592408fccd3fdf7ef94f368b4723558d8cd60fb2267a0b6398098de680da4afe555691bbf986f5317c7d702a24730c53e360184945fbeacb6670f6020872a9f0870d609e4c229918f22818fdd340a70cb733ec4a42616cfba5cc8b68073c867b494b47b83a426edc143fea0fdd4a0d62c10f6e73d25a39f35b4e66a2369bb613393d583645532925092456fc117606ece0aa5d15378edaf033e47d2d8b3ca933fa4bb86f5c03c13b6cdec7f953422893f204fad87d6794eec8abae7984c0ffa455798e9bdcdb3f09c0b1ce81bf8790e28008cae51229818f4820f3e0ff7c1c0467ec0d32c684e88a4a2e62cfbe0eabf963c28f55435198aa896cc461a7ecc894c2495db76864045a4fe0c29ff1a76d9fd5d95b3be74456a44087dbafcafa08c171ccebe4058ebac38b57ff3f6cb7e704f29743b0d2aac3df05ad50fe0e07428e58179c8b7724c26a293f1524f5ac1bb0b4b471ab9179140ae4067a658e300497bab43d251d30ae9ef12e7721f503b33650bf69d698c419ac95c54281a797f54517065010edd4c42a3f02027c0a6b55ad8bb8ea53a01704aed3a4ec56645629c11c74405c71ba880f5850603e33f20ea68f115410c5a5512fee97e40ffa77fc6af46e37371906bdb078fff321e2d9d22d5f0ac06c4afb994e94f22dba37a3aad3b62bbe182baf4b4b4a770b216d79ddd7fe4acf91f8d979ecb02fb65e80c6d046119d28169820f704e8fc71170ddca1e614db0fb59533964461121626eb812ccbbd11f2679aaed86520eca3c004d290ef9d57731484f311f1a126b92b2aca4a913335fad66c4dea6508f4f704052a9aa840b6fae21fead75dfb5d6b661f434d87aaa58577a6a62da56d9b4c4a4c1c7f6ab7abd08256ba00f2a2237d5034d4b8fcf5c9fd27eb669a5dd9343b27302d4bee463b659d2dc7807fb934f57611bef4c42970742f295ddb45f70120d0c4f11d4b44b1ff8f4e2d49b809608589ffe3802656dc1fed32caae6172be7a189106f118b83e16b3decc89fd4e24d6dcc260def33002c6dfc81be834a0a50dd5a11442434d008403a0fa45739563f44ce70d38d8ee3a5a95f315e6a979bf9200b5cd33408c34f65b7f33359c1cda1b2ce4bfad4442397f87d7f28ef22b59eb59ea180c4175aad4488032c3afd0ca657099e3451a4655481f601ad1cb6f57a3566a4a962a2ed0472fd46089ebc1d8faacfe494dd997a2de9cbc0754ec4f6965e255724a139958020b712ef8f42f4141e3315d83b26f6a3d4510f5f93e1f436e738af42bf53d4e33907834b4856935f2cbcd34db13bc927e10ddcce46edea77ceee50649d6ee537f55f854774c37cd67d39e8a2859d91818c16c942ae2c14f39a997b859f1fff68f5185e72e1dc3b5425a5c722b6e5cf752be01d5637b7804fb65e4dd5669fc525c31c69bdd8fd90dbd2a49c8404fbfb6dd8cf2d2abdf43a189d0d12a5e4fe5eea0c4f2be30148410ffc08c717bd82e0eba01fdcece089075fc1d7372f6d0fc7973465165b89c6378517f83dd07c76221e5c2fc4b2d4b7b05905d9c7be22e8fede24c1597fc67dd87f06d11e6e7a16475ffceb88e8ccef362a7f1be8611dcccf2264a82be0d9daba46cbf8f79cae0632db54c1538530fa9c9145728048d002f1f3b1a168c2f3a08bbb4a68601969ad5c91b1055ee737ad35312d7c57dbda388c208ffca6c987c660f5fabd907c515760c92d8bd696d5c8c4ceb22c5560a5d710b34d990b1608924e673ce4db702dd7e2acda622b9a6d19c437a9a366267e1a52b4c2683950381eb1358e2ed04c02592faa0e3a6ad59d98db6d8d199eaa6f3b0c01d533b4c55ed4ef59ca5631dc24a73a1357dda461814a39187ab3fba9cad6b94a0ae168cc5600b03f331398b6b6629e80a1e41a18427c43f64390431e5709b166d961143901a247f7f9aff4c60aecc8458106694fb3723d68241215e9ce681ed57a074157d790e2e013585ec926992843ed1ef1766b0266f5745d21b82b7457e93bd734c6f0ba465dbc830b45874cf2fd036014aac28aca70621217fa97f9a4fc06cde56becf090f7093cd6f2a07f40d9990d153ed150889fcf98799d524f833c32045ce6aa70c92fa15989bd74ee67d6a85fac55ad8466fb1f57b218698fda55146eb46bbc7cc5099099f14a0dd15e1172a799c88afa6b5d3be427df8d6662967cfcbb0725e170a0b6d28496a42470d1158bf3dc7d5006e166937e376729ee0686a0961bd18009f0834d26b2ef2688aaa0426eab447fdbd8804fe27d48a3dcc94fad17a2f5233b6f49af1a22852467e0c7ecdae8a089e84f20a359d8d4a6e6c3c8c9801ee646f9f92658e5232d88524a139eb2c28bf41e3893db52c74634913fc24bf5e47f2f6b785cd83c82f09318cd15c0cc1c78087485df0984c3599425e6f35be097e80ee288c722778ed418adb267accd864525cf1eae539634eb7b0bd8f83223ff9323ecda0e6c7558bfb88ea596e512d282af8fbc691f9504a999b614f15b13436d87ad95a6b3d6748ecb33ad548011ed7e60ffae2524727b0a1ad0b453525b103ff4d90a268150e69ff9067cea32c4f6ebf926582c89ac489303d87ffd39386905dedf544006eba9761c66b08c1641005ca11d2be54c3b308e8690792c0a6e34c3aed5da45af20e44fb9abe06d679ee5c2d9057327692fb2cfcd2b5be75335d1b04011169e5f5dcdfde4001f5b11d786745e0e9c5d36dec708cdb4680e810aba3c421ebc2f4d703a234f19b3e18846e833be7b01be5d5dfd91ba95bd272c4cf95ad1430258dcf795529a2ec03cdc5084137ad614c15a6e092202f83fb9767400c533fe38c008546c82297d25090d18d55987c8b669de947434ed309e69ef8e1c440197f39b58136626e93456b722ba2999d343adffa3d1435ad1b585022d85d6b81a9d4cd50f18338473a50b52cc2aa06a168409319e5697e2fa2815983e2f21d0b99a1259e624f09e4533993b3b63c17178df152f53146c4c9c92cc0a8d415f7694648f3247aa01bfd23b633a6632de9de1e6d90e6d704930906fa3fd49f6030a7311666ddbfcf774a7c1eae217a419a994ffe1f5ac0c0dd412e2b92c7bcc38ffefbc7d281e84c90042c49d8e924d78c2770c84ffb3a97b895a815955679ddd9b6c970cf2957d1a57574db8dd2fd5480211cd5b0cd9ea8264062bc1f71cee08d2113bdf723dc2ac0febb2c4051ef6bb9ac67ffb1baee1d25ffde8cd9926206881f03d0bb121664f888bb1828726ff19ad2daa95a290952c309e565ece3045652ad98192ab2bf379df17c7f0bfcf692b4f31a4456201da370f413776f0332b53649c1b3972b843ff68934a785a8419a43d8dab2059eda5224f3d7a4e580599cb648bf8a3287ae4171be19ada1851a2daf1764a3fdf6381b4cdf22fffa9fb3a7bb59c7092790ffc5f8aa6a48c0620d6c768108b25b2a967e90d9d2a938017936074eb32ccab4e8629009e74f880cddfebf3852b75460ee9bef7c641923e4ab4daa3f62288bbd8cc0f2014e0a9f301abd309ff4b2b9055157227ced167317d12ec2c2c980c1b62e55441b6c5dcb2945f4e5965fd596ec4702964559c8d15631de8574f3e7496065287007ddac7a967ee15f627988b3de89265e67bd9f63eb58f40ee8ea02b85e57d22db477bdbfa3040533ca4cf9d400bdb9bca73d2b8d39a695640dd44f55cc3c0172dfced55879b71e0e13d07df32a74290af33d19185cc50b5befbf1df3b3599defad7e839c933a63451f46ece5a7e1316e5b8915e00212d6e4458779c88e4f4201975ca6d184082661d53d325ee5f31bbbba0a4422ba7df67a16dc2ee5df540844ee80667b6bcab99c1c4080a29ed58ee560e35a2a05611ad4f0f58f377a744d2a6e1ea87c915ad60c65847e9795b237e87b8180afa3af7a60e7b8ad53ba2f7cffe8d891a86178c8ba1b82b299978a9b316cf6431ac4b4961968a1f5a95f8edd6d7de61053012124262fba1eade6bc0399dacca52eb4a35da06a612078027d995c7800e9ec319031e19e45580b22ed908cf612cffe77c9db6240b6b77e7aa9dd33d3c8ebc9e7240b94b4355e158db765aa2c793c9c2102656856c3ca9443356f328c5cbd3e2bc941af31c5e577ef5dccd39ba03fa991ce428eb1173cf869fecd6dbe1db8e80600852e6ff613d607a7712fb27cade11d99c745618e1c0125c28fa680d8e3611a57ec6b4de1927d8143e007a0b415c52eb9a6f23daacf246ce7ee36b0b98a9525ff2bb4ace95e679f65e1ee7f2d090d4566ad3504e89dc41d4dcccbbad0deea3c6263082f1bc48285680dcb9f248397de724ab7fbbcd0efe353ab39d4349fd2586ae566f9034ad1f98e2478c644edac4ace54585f2df0bebbccf123f30553b1db64d0649f57181982c2ce7e85739c1c596292697b6212345fed7bb56a7ca72033e59da4626ee595a05972bb56dad75d642a4d339f8e121394400c99b6a4bde34eeff2d1d1730fc04c25fbec13750ffe6e42f03aec2e358028bca58d29a8ded3bb39364c4f7face50a323a4579ea063f828996ac30445d667f8de5ea737cb1d1d7ef3a5ca5df08172d3091b3bb000923508a9aaecadfe500000000000000000000000000000000000000000000060c121a20242b3577b6bd2e58ed4d1012190de2d8fa383916da52eefa203a3d97477c80779c2c776e5a8d1ecf9da5b37c258ad32f81537fe810c923f7857d1aabe3e7ffbf70b2452b8f50feee0c04a413acaaa6958d61717496a50513f09798b969b2bc456f5d9c4feaf4729dd5a8010f399d0c6586878199781386fd3a7c8e6e585a2343dbba2be4626c0ca5cfdf71aa6e9738730dc21f4281b71c00e9ffd1a044f0322afdb0d4e078e2538aedf8e3995edd03626b59a10f6825090742e42db86943b3fb9e6e18f9ec75af29d03ec8502e50548ca9f54bed7d12d631a1405cc9d9b41067be5f865ca21c7bc1272e1c8c68fcfcb2929fed2552c13d00ccb419f876f7b97e7191b65a08764fedb3fbb1390e7b6773bef62140fbc5b08cdaddc86fb26643f4b9b6ccdba3caaba54d6578bd65340a395d8b9632d8653296de49122224de68649c5ac5efc33cb3c25aa9add9ba268bdbd3a5ab7b1254d057926c70edb0981e4543283259e0b9d483dfa4f0a4f2d302aa97daad74d58b7e4e9bfcddd9204e0a9097b8c4d6bc9822155e0efb252acfa88787c7b09eb341cc579ffd6f1b4014076d1e9da9ebf7d36a2fb11dcc1cd8b23ff67b735b2355aec2689b31d9921b3236be7b9ad202c0578f7e211cbe315f5f101ff42e360cea141564a7e09d0e7535e66b7b99fe25b3abb9f9f974fc6ea2c93099b657137ed65f8b410933f5fe9e790d6d693105", + "tbs": "a6656c6162656c734d4143554c412d50512d5354415455532d5631676e6f64655f69645820cf4966c74356a1435f52bbadda8e5eafee5d4d0300fe103a61e5f36ebbfa5971677369675f616c676f4d4c2d4453412d38372d5053333834696973737565645f61741b000001a088b5a2006a657870697265735f61741b000001a088ec90806c62696e64696e675f686173685830ce0ff6e30724b72fbe651b5a5cff6e24e2170874d87536cfe4336c45becb5cb0011c76e1d0a1801f4b313e1cf55ab6ae" + } + } + ], + "generator": "macula_key_bindings and macula_node_keys at macula v12.1.0, OTP 28" +} + diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/ctx.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/ctx.bin new file mode 100644 index 0000000..3870f89 --- /dev/null +++ b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/ctx.bin @@ -0,0 +1 @@ +The lethargic, colorless dog sat beneath the energetic, stationary fox. \ No newline at end of file diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/m.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/m.bin new file mode 100644 index 0000000..8fe2a4b --- /dev/null +++ b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/m.bin @@ -0,0 +1 @@ +The quick brown fox jumps over the lazy dog. \ No newline at end of file diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_message.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_message.bin new file mode 100644 index 0000000..1bda0b7 --- /dev/null +++ b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_message.bin @@ -0,0 +1 @@ +macula-go interop: a composite macula signed \ No newline at end of file diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_pk.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_pk.bin new file mode 100644 index 0000000..3de31a0 Binary files /dev/null and b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_pk.bin differ diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_sig.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_sig.bin new file mode 100644 index 0000000..f42c5d7 Binary files /dev/null and b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/otp_sig.bin differ diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/pk.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/pk.bin new file mode 100644 index 0000000..707e0cf Binary files /dev/null and b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/pk.bin differ diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/s.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/s.bin new file mode 100644 index 0000000..6a70f32 Binary files /dev/null and b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/s.bin differ diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/s_with_context.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/s_with_context.bin new file mode 100644 index 0000000..b2854b4 Binary files /dev/null and b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/s_with_context.bin differ diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/sk.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/sk.bin new file mode 100644 index 0000000..bf6038d Binary files /dev/null and b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/sk.bin differ diff --git a/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/zero_dropped_sig.bin b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/zero_dropped_sig.bin new file mode 100644 index 0000000..2c62d94 Binary files /dev/null and b/tests/vectors/identity/lamps_mldsa87_rsa4096_pss_sha512/zero_dropped_sig.bin differ diff --git a/tests/vectors/identity/macula_12_cross/macula_signed/m.bin b/tests/vectors/identity/macula_12_cross/macula_signed/m.bin new file mode 100644 index 0000000..1bf679b --- /dev/null +++ b/tests/vectors/identity/macula_12_cross/macula_signed/m.bin @@ -0,0 +1 @@ +signed by macula 12.8.0 \ No newline at end of file diff --git a/tests/vectors/identity/macula_12_cross/macula_signed/pk.bin b/tests/vectors/identity/macula_12_cross/macula_signed/pk.bin new file mode 100644 index 0000000..592fbb3 Binary files /dev/null and b/tests/vectors/identity/macula_12_cross/macula_signed/pk.bin differ diff --git a/tests/vectors/identity/macula_12_cross/macula_signed/s.bin b/tests/vectors/identity/macula_12_cross/macula_signed/s.bin new file mode 100644 index 0000000..b084bfa Binary files /dev/null and b/tests/vectors/identity/macula_12_cross/macula_signed/s.bin differ diff --git a/tests/vectors/identity/macula_12_cross/rust_signed/m.bin b/tests/vectors/identity/macula_12_cross/rust_signed/m.bin new file mode 100644 index 0000000..277e8e2 --- /dev/null +++ b/tests/vectors/identity/macula_12_cross/rust_signed/m.bin @@ -0,0 +1 @@ +signed by macula-rust \ No newline at end of file diff --git a/tests/vectors/identity/macula_12_cross/rust_signed/pk.bin b/tests/vectors/identity/macula_12_cross/rust_signed/pk.bin new file mode 100644 index 0000000..4f560a3 Binary files /dev/null and b/tests/vectors/identity/macula_12_cross/rust_signed/pk.bin differ diff --git a/tests/vectors/identity/macula_12_cross/rust_signed/s.bin b/tests/vectors/identity/macula_12_cross/rust_signed/s.bin new file mode 100644 index 0000000..14424f1 Binary files /dev/null and b/tests/vectors/identity/macula_12_cross/rust_signed/s.bin differ diff --git a/tests/vectors/manifest/erlang_manifests.json b/tests/vectors/manifest/erlang_manifests.json new file mode 100644 index 0000000..441cf04 --- /dev/null +++ b/tests/vectors/manifest/erlang_manifests.json @@ -0,0 +1 @@ +{"manifests":[{"name":"626c6f62","size":600000,"chunk_size":262144,"mcid_hex":"02566b1345e2a067b6d5218a4793d9cf2e9cbb94553d70350fa861fd7f94e9b1416c492863a2ff47c0e5b76c9d3da83bac8b","root_hex":"b7dc2ec499dba61778a5fa26af1623fb42851b880cf469763fb0b8d6bc32e6e647471401451e463493b4d94476e59ae0","chunk_hashes":["26e83f50efabe15008b4baff5e4ed620b4d5b02d5c27f5e3d72d7997c4311008a43a8b4032d12524cff9c4925536c481","17b75cfa9b9f05a7215030516352e1b27c7583341610bb7864b8af22884233273238d04548310ca2471cb0ccc330621c","4b984f8d108179a12803949eec03f8ff9cbefcde3728798f9114261c002d83c3448cd6a62c485517ff44aa0047b71e5e"],"chunk_mcids":["025526e83f50efabe15008b4baff5e4ed620b4d5b02d5c27f5e3d72d7997c4311008a43a8b4032d12524cff9c4925536c481","025517b75cfa9b9f05a7215030516352e1b27c7583341610bb7864b8af22884233273238d04548310ca2471cb0ccc330621c","02554b984f8d108179a12803949eec03f8ff9cbefcde3728798f9114261c002d83c3448cd6a62c485517ff44aa0047b71e5e"],"wire_hex":"aa646d636964583202566b1345e2a067b6d5218a4793d9cf2e9cbb94553d70350fa861fd7f94e9b1416c492863a2ff47c0e5b76c9d3da83bac8b646e616d6544626c6f626473697a651a000927c0666368756e6b7383a46468617368583026e83f50efabe15008b4baff5e4ed620b4d5b02d5c27f5e3d72d7997c4311008a43a8b4032d12524cff9c4925536c4816473697a651a0004000065696e64657800666f666673657400a46468617368583017b75cfa9b9f05a7215030516352e1b27c7583341610bb7864b8af22884233273238d04548310ca2471cb0ccc330621c6473697a651a0004000065696e64657801666f66667365741a00040000a4646861736858304b984f8d108179a12803949eec03f8ff9cbefcde3728798f9114261c002d83c3448cd6a62c485517ff44aa0047b71e5e6473697a651a000127c065696e64657802666f66667365741a0008000067637265617465641a6aa1f9406776657273696f6e0169726f6f745f686173685830b7dc2ec499dba61778a5fa26af1623fb42851b880cf469763fb0b8d6bc32e6e647471401451e463493b4d94476e59ae06a6368756e6b5f73697a651a000400006b6368756e6b5f636f756e74036e686173685f616c676f726974686d66736861333834"},{"name":"6f6464","size":2500,"chunk_size":1000,"mcid_hex":"02563f3bd338869de9e519e7d4c1cc48dad8f847bd7752e1fa76be1a53c2af913a1d473ef41486789ebfa63f58ad62360493","root_hex":"b4d1f7b0bac8b14f7cdb6526ff7ecb3e61ce279968281a0995ffad3c0661db66a18ba0e1439d2005c665a677bc53fb9c","chunk_hashes":["7a2f8c7f12344964a13cb9260492b845e56615d6152b9eb9e54b580fc88405e64f31813bfda10de2a642fdf1676c61b4","e0c4b84a0cdcbccd467f329246cfe85aa82fdcb9ac57dd1b09f072eafcafebf3bf9aa82fb74fb8283b1cc99e28579653","547bdbba993465a1323175f8bdc0192c8961344bc2a0249f62c59900d7f9a336fbffcd1489fdbb268656af4fa4f96565"],"chunk_mcids":["02557a2f8c7f12344964a13cb9260492b845e56615d6152b9eb9e54b580fc88405e64f31813bfda10de2a642fdf1676c61b4","0255e0c4b84a0cdcbccd467f329246cfe85aa82fdcb9ac57dd1b09f072eafcafebf3bf9aa82fb74fb8283b1cc99e28579653","0255547bdbba993465a1323175f8bdc0192c8961344bc2a0249f62c59900d7f9a336fbffcd1489fdbb268656af4fa4f96565"],"wire_hex":"aa646d636964583202563f3bd338869de9e519e7d4c1cc48dad8f847bd7752e1fa76be1a53c2af913a1d473ef41486789ebfa63f58ad62360493646e616d65436f64646473697a651909c4666368756e6b7383a4646861736858307a2f8c7f12344964a13cb9260492b845e56615d6152b9eb9e54b580fc88405e64f31813bfda10de2a642fdf1676c61b46473697a651903e865696e64657800666f666673657400a464686173685830e0c4b84a0cdcbccd467f329246cfe85aa82fdcb9ac57dd1b09f072eafcafebf3bf9aa82fb74fb8283b1cc99e285796536473697a651903e865696e64657801666f66667365741903e8a464686173685830547bdbba993465a1323175f8bdc0192c8961344bc2a0249f62c59900d7f9a336fbffcd1489fdbb268656af4fa4f965656473697a651901f465696e64657802666f66667365741907d067637265617465641a6aa1f9406776657273696f6e0169726f6f745f686173685830b4d1f7b0bac8b14f7cdb6526ff7ecb3e61ce279968281a0995ffad3c0661db66a18ba0e1439d2005c665a677bc53fb9c6a6368756e6b5f73697a651903e86b6368756e6b5f636f756e74036e686173685f616c676f726974686d66736861333834"},{"name":"6578616374","size":3000,"chunk_size":1000,"mcid_hex":"0256cbd544d59b1dcc7a9c532f0de920e89df1d7ccaf5e6b12e563b6b3a3634d2b35da4593bde2bab641f10196992dbb192a","root_hex":"b0a1814a5782b3de118e105ad81fcbb53ca00aaf346eb6fa83eb4d2f9068aa5188b3b8c55176db06c40b0090d5e38250","chunk_hashes":["7a2f8c7f12344964a13cb9260492b845e56615d6152b9eb9e54b580fc88405e64f31813bfda10de2a642fdf1676c61b4","e0c4b84a0cdcbccd467f329246cfe85aa82fdcb9ac57dd1b09f072eafcafebf3bf9aa82fb74fb8283b1cc99e28579653","e3557155a498f2ffa97bb796637a5ad696f757b5d6e28b2bf9fcba1801d47ad50f19877da8dbd873a5685e6a98c4c2cf"],"chunk_mcids":["02557a2f8c7f12344964a13cb9260492b845e56615d6152b9eb9e54b580fc88405e64f31813bfda10de2a642fdf1676c61b4","0255e0c4b84a0cdcbccd467f329246cfe85aa82fdcb9ac57dd1b09f072eafcafebf3bf9aa82fb74fb8283b1cc99e28579653","0255e3557155a498f2ffa97bb796637a5ad696f757b5d6e28b2bf9fcba1801d47ad50f19877da8dbd873a5685e6a98c4c2cf"],"wire_hex":"aa646d63696458320256cbd544d59b1dcc7a9c532f0de920e89df1d7ccaf5e6b12e563b6b3a3634d2b35da4593bde2bab641f10196992dbb192a646e616d654565786163746473697a65190bb8666368756e6b7383a4646861736858307a2f8c7f12344964a13cb9260492b845e56615d6152b9eb9e54b580fc88405e64f31813bfda10de2a642fdf1676c61b46473697a651903e865696e64657800666f666673657400a464686173685830e0c4b84a0cdcbccd467f329246cfe85aa82fdcb9ac57dd1b09f072eafcafebf3bf9aa82fb74fb8283b1cc99e285796536473697a651903e865696e64657801666f66667365741903e8a464686173685830e3557155a498f2ffa97bb796637a5ad696f757b5d6e28b2bf9fcba1801d47ad50f19877da8dbd873a5685e6a98c4c2cf6473697a651903e865696e64657802666f66667365741907d067637265617465641a6aa1f9406776657273696f6e0169726f6f745f686173685830b0a1814a5782b3de118e105ad81fcbb53ca00aaf346eb6fa83eb4d2f9068aa5188b3b8c55176db06c40b0090d5e382506a6368756e6b5f73697a651903e86b6368756e6b5f636f756e74036e686173685f616c676f726974686d66736861333834"},{"name":"656d707479","size":0,"chunk_size":262144,"mcid_hex":"0256e6cd514fb23ed0ab26d9b6a105b7313426ab5361d9dfe1368f799061176280e6e8b07a7ce67f07cfadc920d5ea5b79be","root_hex":"38b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b","chunk_hashes":[],"chunk_mcids":[],"wire_hex":"aa646d63696458320256e6cd514fb23ed0ab26d9b6a105b7313426ab5361d9dfe1368f799061176280e6e8b07a7ce67f07cfadc920d5ea5b79be646e616d6545656d7074796473697a6500666368756e6b738067637265617465641a6aa1f9406776657273696f6e0169726f6f745f68617368583038b060a751ac96384cd9327eb1b1e36a21fdb71114be07434c0cc7bf63f6e1da274edebfe76f65fbd51ad2f14898b95b6a6368756e6b5f73697a651a000400006b6368756e6b5f636f756e74006e686173685f616c676f726974686d66736861333834"},{"name":"6e61c3af7665","size":10,"chunk_size":4,"mcid_hex":"02569ce99a71f6dcd33451fbd63483ced251b718f85b1edc3763b46e4e1e4ceef76de78934a1030b23d2ad3e81659c85c147","root_hex":"7af7433cd31d22d48ce0e02d79ab4c19958bfb0a81febbdb414f5b124a9076ea91de524d89e152683ba32e3b74cd690a","chunk_hashes":["80ae432e757826025095ca1fa4f89c06c8ba6754b1d883a8e31a1e65fcfb820bd74acfaca3d939a574ea408a74162d1d","0802c995711c267bf6895728f82a2371655912797ec4a942eba05d9e46a0abd8d2ad88c61e0971a13c44afcf4de7f279","f06846d5d0435d8b39fb6ea032011743359d84a9efafdb30edec842d9a78a87bdc299679d1f44e124217b50a02902edf"],"chunk_mcids":["025580ae432e757826025095ca1fa4f89c06c8ba6754b1d883a8e31a1e65fcfb820bd74acfaca3d939a574ea408a74162d1d","02550802c995711c267bf6895728f82a2371655912797ec4a942eba05d9e46a0abd8d2ad88c61e0971a13c44afcf4de7f279","0255f06846d5d0435d8b39fb6ea032011743359d84a9efafdb30edec842d9a78a87bdc299679d1f44e124217b50a02902edf"],"wire_hex":"aa646d636964583202569ce99a71f6dcd33451fbd63483ced251b718f85b1edc3763b46e4e1e4ceef76de78934a1030b23d2ad3e81659c85c147646e616d65466e61c3af76656473697a650a666368756e6b7383a46468617368583080ae432e757826025095ca1fa4f89c06c8ba6754b1d883a8e31a1e65fcfb820bd74acfaca3d939a574ea408a74162d1d6473697a650465696e64657800666f666673657400a4646861736858300802c995711c267bf6895728f82a2371655912797ec4a942eba05d9e46a0abd8d2ad88c61e0971a13c44afcf4de7f2796473697a650465696e64657801666f666673657404a464686173685830f06846d5d0435d8b39fb6ea032011743359d84a9efafdb30edec842d9a78a87bdc299679d1f44e124217b50a02902edf6473697a650265696e64657802666f66667365740867637265617465641a6aa1f9406776657273696f6e0169726f6f745f6861736858307af7433cd31d22d48ce0e02d79ab4c19958bfb0a81febbdb414f5b124a9076ea91de524d89e152683ba32e3b74cd690a6a6368756e6b5f73697a65046b6368756e6b5f636f756e74036e686173685f616c676f726974686d66736861333834"}],"block":{"data_hex":"68656c6c6f2c206d6163756c61","mcid_hex":"0255c3abec4b457ff517e8bcb526365c9903372a3e007928e2549ece50386a2ea867988868a3afaa65134f51ec51097aeb0c"}} diff --git a/tests/vectors/record/own_namespace/README.md b/tests/vectors/record/own_namespace/README.md new file mode 100644 index 0000000..09b23d0 --- /dev/null +++ b/tests/vectors/record/own_namespace/README.md @@ -0,0 +1,31 @@ +# Own-namespace fixtures, shared by the SDKs + +Signed procedure advertisements in a node's own namespace, `~/` (D25 item 6, revised +2026-09-24), and the verdicts every SDK must reach on them. A procedure in a node's own namespace carries no +authorization. It is accepted only when the 64 lowercase hex characters after `~` are the advertisement's +`advertiser_node`, which verifying the record binds to its signer. + +- `pq_pure/` and `pq_hybrid/`: one advertisement per case, the record's wire form, signed by a throwaway + identity of that profile. +- `verdicts.json`: for each file, its profile, its procedure, the time to evaluate it at (`now_ms`, since + advertisements expire), and the two expected verdicts: + - `own_namespace`: `macula_record:own_namespace/1`, the rule the station's two admissions also apply; + - `verify_authorization`: `macula_record:verify_authorization/3` with no realm key, what a caller decides. + A verdict is `ok` or the refusal's name. + +| Case | own_namespace | verify_authorization | +|---|---|---| +| `own_ok` | ok | ok | +| `own_other_node`: another node's hex | not_own_namespace | not_own_namespace | +| `own_with_authorization`: an authorization attached | authorization_not_allowed | authorization_not_allowed | +| `own_uppercase_hex` | malformed | malformed | +| `own_short_hex`: 62 characters | malformed | malformed | +| `org_without_chain`: `acme/ring`, no chain | not_own_namespace | no_authorization | +| `own_hex_without_a_name`: `~`, no `/` | not_own_namespace | ok | + +The last row is the rule for a name without `/` (no namespace at all, so no authorization is asked of it), which +D25 refuses at advertise time; it is here so no SDK reads `~` alone as an own namespace. + +`test/macula_own_namespace_fixtures_tests.erl` holds macula to every verdict; macula-go runs the same files. +Regenerate with `scripts/generate-own-namespace-fixtures.sh` after `rebar3 compile`: new identities and new +signatures, the same cases and verdicts. diff --git a/tests/vectors/record/own_namespace/pq_hybrid/org_without_chain.bin b/tests/vectors/record/own_namespace/pq_hybrid/org_without_chain.bin new file mode 100644 index 0000000..40aec50 Binary files /dev/null and b/tests/vectors/record/own_namespace/pq_hybrid/org_without_chain.bin differ diff --git a/tests/vectors/record/own_namespace/pq_hybrid/own_hex_without_a_name.bin b/tests/vectors/record/own_namespace/pq_hybrid/own_hex_without_a_name.bin new file mode 100644 index 0000000..dfdfb40 Binary files /dev/null and b/tests/vectors/record/own_namespace/pq_hybrid/own_hex_without_a_name.bin differ diff --git a/tests/vectors/record/own_namespace/pq_hybrid/own_ok.bin b/tests/vectors/record/own_namespace/pq_hybrid/own_ok.bin new file mode 100644 index 0000000..056d199 Binary files /dev/null and b/tests/vectors/record/own_namespace/pq_hybrid/own_ok.bin differ diff --git a/tests/vectors/record/own_namespace/pq_hybrid/own_other_node.bin b/tests/vectors/record/own_namespace/pq_hybrid/own_other_node.bin new file mode 100644 index 0000000..522e280 Binary files /dev/null and b/tests/vectors/record/own_namespace/pq_hybrid/own_other_node.bin differ diff --git a/tests/vectors/record/own_namespace/pq_hybrid/own_short_hex.bin b/tests/vectors/record/own_namespace/pq_hybrid/own_short_hex.bin new file mode 100644 index 0000000..5a9c815 Binary files /dev/null and b/tests/vectors/record/own_namespace/pq_hybrid/own_short_hex.bin differ diff --git a/tests/vectors/record/own_namespace/pq_hybrid/own_uppercase_hex.bin b/tests/vectors/record/own_namespace/pq_hybrid/own_uppercase_hex.bin new file mode 100644 index 0000000..1192842 Binary files /dev/null and b/tests/vectors/record/own_namespace/pq_hybrid/own_uppercase_hex.bin differ diff --git a/tests/vectors/record/own_namespace/pq_hybrid/own_with_authorization.bin b/tests/vectors/record/own_namespace/pq_hybrid/own_with_authorization.bin new file mode 100644 index 0000000..fa06ad9 Binary files /dev/null and b/tests/vectors/record/own_namespace/pq_hybrid/own_with_authorization.bin differ diff --git a/tests/vectors/record/own_namespace/pq_pure/org_without_chain.bin b/tests/vectors/record/own_namespace/pq_pure/org_without_chain.bin new file mode 100644 index 0000000..d5555ea Binary files /dev/null and b/tests/vectors/record/own_namespace/pq_pure/org_without_chain.bin differ diff --git a/tests/vectors/record/own_namespace/pq_pure/own_hex_without_a_name.bin b/tests/vectors/record/own_namespace/pq_pure/own_hex_without_a_name.bin new file mode 100644 index 0000000..95a4071 Binary files /dev/null and b/tests/vectors/record/own_namespace/pq_pure/own_hex_without_a_name.bin differ diff --git a/tests/vectors/record/own_namespace/pq_pure/own_ok.bin b/tests/vectors/record/own_namespace/pq_pure/own_ok.bin new file mode 100644 index 0000000..c9a548e Binary files /dev/null and b/tests/vectors/record/own_namespace/pq_pure/own_ok.bin differ diff --git a/tests/vectors/record/own_namespace/pq_pure/own_other_node.bin b/tests/vectors/record/own_namespace/pq_pure/own_other_node.bin new file mode 100644 index 0000000..7a94246 Binary files /dev/null and b/tests/vectors/record/own_namespace/pq_pure/own_other_node.bin differ diff --git a/tests/vectors/record/own_namespace/pq_pure/own_short_hex.bin b/tests/vectors/record/own_namespace/pq_pure/own_short_hex.bin new file mode 100644 index 0000000..c71d08a Binary files /dev/null and b/tests/vectors/record/own_namespace/pq_pure/own_short_hex.bin differ diff --git a/tests/vectors/record/own_namespace/pq_pure/own_uppercase_hex.bin b/tests/vectors/record/own_namespace/pq_pure/own_uppercase_hex.bin new file mode 100644 index 0000000..75d3146 Binary files /dev/null and b/tests/vectors/record/own_namespace/pq_pure/own_uppercase_hex.bin differ diff --git a/tests/vectors/record/own_namespace/pq_pure/own_with_authorization.bin b/tests/vectors/record/own_namespace/pq_pure/own_with_authorization.bin new file mode 100644 index 0000000..b78e9e8 Binary files /dev/null and b/tests/vectors/record/own_namespace/pq_pure/own_with_authorization.bin differ diff --git a/tests/vectors/record/own_namespace/verdicts.json b/tests/vectors/record/own_namespace/verdicts.json new file mode 100644 index 0000000..87fc021 --- /dev/null +++ b/tests/vectors/record/own_namespace/verdicts.json @@ -0,0 +1,115 @@ +[ + { + "file": "pq_pure/own_ok.bin", + "now_ms": 1790363855305, + "own_namespace": "ok", + "procedure": "~07a00cda919d23fd9cbf8b4b39eed85fcbd72e3922fb2eadc34eab99de71338c/ring", + "profile": "pq_pure", + "verify_authorization": "ok" + }, + { + "file": "pq_pure/own_other_node.bin", + "now_ms": 1790363855308, + "own_namespace": "not_own_namespace", + "procedure": "~0000000000000000000000000000000000000000000000000000000000000001/ring", + "profile": "pq_pure", + "verify_authorization": "not_own_namespace" + }, + { + "file": "pq_pure/own_with_authorization.bin", + "now_ms": 1790363855310, + "own_namespace": "authorization_not_allowed", + "procedure": "~07a00cda919d23fd9cbf8b4b39eed85fcbd72e3922fb2eadc34eab99de71338c/ring", + "profile": "pq_pure", + "verify_authorization": "authorization_not_allowed" + }, + { + "file": "pq_pure/own_uppercase_hex.bin", + "now_ms": 1790363855311, + "own_namespace": "malformed", + "procedure": "~07A00CDA919D23FD9CBF8B4B39EED85FCBD72E3922FB2EADC34EAB99DE71338C/ring", + "profile": "pq_pure", + "verify_authorization": "malformed" + }, + { + "file": "pq_pure/own_short_hex.bin", + "now_ms": 1790363855313, + "own_namespace": "malformed", + "procedure": "~07a00cda919d23fd9cbf8b4b39eed85fcbd72e3922fb2eadc34eab99de7133/ring", + "profile": "pq_pure", + "verify_authorization": "malformed" + }, + { + "file": "pq_pure/org_without_chain.bin", + "now_ms": 1790363855314, + "own_namespace": "not_own_namespace", + "procedure": "acme/ring", + "profile": "pq_pure", + "verify_authorization": "no_authorization" + }, + { + "file": "pq_pure/own_hex_without_a_name.bin", + "now_ms": 1790363855315, + "own_namespace": "not_own_namespace", + "procedure": "~07a00cda919d23fd9cbf8b4b39eed85fcbd72e3922fb2eadc34eab99de71338c", + "profile": "pq_pure", + "verify_authorization": "ok" + }, + { + "file": "pq_hybrid/own_ok.bin", + "now_ms": 1790363855662, + "own_namespace": "ok", + "procedure": "~a552e6b5f762128fd89a805bbe4d6d34a6bb4c5581834267a1929a958568e6f7/ring", + "profile": "pq_hybrid", + "verify_authorization": "ok" + }, + { + "file": "pq_hybrid/own_other_node.bin", + "now_ms": 1790363855672, + "own_namespace": "not_own_namespace", + "procedure": "~0000000000000000000000000000000000000000000000000000000000000001/ring", + "profile": "pq_hybrid", + "verify_authorization": "not_own_namespace" + }, + { + "file": "pq_hybrid/own_with_authorization.bin", + "now_ms": 1790363855678, + "own_namespace": "authorization_not_allowed", + "procedure": "~a552e6b5f762128fd89a805bbe4d6d34a6bb4c5581834267a1929a958568e6f7/ring", + "profile": "pq_hybrid", + "verify_authorization": "authorization_not_allowed" + }, + { + "file": "pq_hybrid/own_uppercase_hex.bin", + "now_ms": 1790363855685, + "own_namespace": "malformed", + "procedure": "~A552E6B5F762128FD89A805BBE4D6D34A6BB4C5581834267A1929A958568E6F7/ring", + "profile": "pq_hybrid", + "verify_authorization": "malformed" + }, + { + "file": "pq_hybrid/own_short_hex.bin", + "now_ms": 1790363855694, + "own_namespace": "malformed", + "procedure": "~a552e6b5f762128fd89a805bbe4d6d34a6bb4c5581834267a1929a958568e6/ring", + "profile": "pq_hybrid", + "verify_authorization": "malformed" + }, + { + "file": "pq_hybrid/org_without_chain.bin", + "now_ms": 1790363855701, + "own_namespace": "not_own_namespace", + "procedure": "acme/ring", + "profile": "pq_hybrid", + "verify_authorization": "no_authorization" + }, + { + "file": "pq_hybrid/own_hex_without_a_name.bin", + "now_ms": 1790363855708, + "own_namespace": "not_own_namespace", + "procedure": "~a552e6b5f762128fd89a805bbe4d6d34a6bb4c5581834267a1929a958568e6f7", + "profile": "pq_hybrid", + "verify_authorization": "ok" + } +] +