diff --git a/.changeset/pvm-host-runtime-authority.md b/.changeset/pvm-host-runtime-authority.md index d087577fd9..1dad7b94ad 100644 --- a/.changeset/pvm-host-runtime-authority.md +++ b/.changeset/pvm-host-runtime-authority.md @@ -22,6 +22,9 @@ full-source declaration snapshots, while retaining deterministic code generation Preserve incoming-payment ownership and native Coinage ledger records when migrating either the historical Chat store or current main's iOS store to the combined model. Retain both historical model variants for migration detection. + +Centralize iOS permission presentation for consent prompts and app settings, +preserving the separate Chat and genesis-scoped JAM peer disclosures. Make native permission settings use canonical core decisions, including grants already persisted before this update. Enumerate existing native core storage, import legacy decisions only when no canonical record exists, and retain reset diff --git a/.changeset/pvm-jam-peer-transport.md b/.changeset/pvm-jam-peer-transport.md new file mode 100644 index 0000000000..30f519d7a7 --- /dev/null +++ b/.changeset/pvm-jam-peer-transport.md @@ -0,0 +1,42 @@ +--- +"@parity/truapi": minor +"@parity/truapi-host": minor +--- + +Add the `JamPeerTransport` host service (trait 111): host-terminated JAMNP-S QUIC or WebTransport streams to JAM peers +with `dial`, `open`, `send`, `recv`, `reset`, `close` and `events`. Access is the runtime permission +`RemotePermission::JamPeers { genesis }`, appended as variant 5: before a `dial` connects, the host checks the +product's stored decision, prompts when it is undetermined and persists the answer per product and genesis. App +manifests declare nothing. The trait's default implementation returns `NotGranted`, and the browser core keeps it. +Ships the browser WebTransport adapter, whose `createJamPeerTransportSession({ authorize })` asks once per genesis per +session, and the deterministic PolkaJAM certificate-hash derivation under `@parity/truapi/jam-peer-transport`. Native +Rust product runtimes (iOS, Android, CLI) serve the service over JAMNP-S QUIC with the same session rules, and the iOS +and Android hosts show their remote-permission prompt for `JamPeers`. + +The browser adapter pins two certificates per validity period: PolkaJAM's stock serial-0 certificate and one whose +serial is derived from the peer's P-256 key and period (first 8 bytes of SHA-256(compressed key ‖ period as big-endian +u64), top bit cleared, 1 if zero). Firefox's NSS rejects a second certificate with the same issuer and serial, so with +stock nodes it reaches one validator; nodes that use the derived serial are all reachable, and stock nodes keep working. + +Receive queues reserve length-prefixed message bytes against the per-connection budget before allocating payloads, +including empty messages, and apply backpressure until the guest drains or resets a stream. Pending opens reserve stream +slots before transport setup; cancelled or closed operations cannot publish late streams. Browser cancellation also +settles blocked stream opens and writes, not only dials. Cancelled browser writes retain their connection quota until +the underlying sink releases the buffered frame. +Native stream senders carry reset-on-drop guards from asynchronous opening onward, including results abandoned before +the caller observes them. +Native and browser dials reserve from the eight-connection budget before awaiting permission, and retain at most eight distinct +genesis decisions per execution, including pending and refused decisions. A new ninth network returns `Limit`, without +evicting or re-prompting old decisions. Cancellation frees its operation slot and removes its decision subscriber. + +The full genesis scopes permission decisions, not cryptographic validator membership. Native QUIC negotiates the +JAMNP-S ALPN containing a genesis prefix; WebTransport uses HTTP/3, and the current PolkaJAM CONNECT endpoint does not +negotiate a genesis. Both transports pin caller-supplied peer keys. Guests must verify chain data themselves; access +permits sending as well as receiving peer messages and is not read-only. + +Android and iOS consent now describe bidirectional messaging and display the complete genesis in the permission +prompt, including batched requests, and in permission details. Short summary titles do not replace the full identity. + +JAM consent uses the shared canonical product permission authority, including revision fences and revocation of +execution-local grants, while preserving the account-neutral product/genesis scope. +Quota reservations use the current atomic update API so warning-denied source installs remain supported. diff --git a/.changeset/scoped-authority-approval-lifecycle.md b/.changeset/scoped-authority-approval-lifecycle.md new file mode 100644 index 0000000000..dfa6add398 --- /dev/null +++ b/.changeset/scoped-authority-approval-lifecycle.md @@ -0,0 +1,6 @@ +--- +"@parity/truapi": patch +"@parity/truapi-host": patch +--- + +Product clearing revokes only that product's in-flight signing, resource allocation, identity disclosure and contact authority, while account replacement or disconnection revokes all prior authority. Resource reviews and allowance-cache commits revalidate their product and account. Identity disclosure retains identity-owner checks and lookup-error propagation, and contact selection and labels retain independent directory-mutation fences. Cancelled CLI signing requests withdraw their permission and signature reviews, including on product disconnection, without authorizing late answers or losing the command draft. diff --git a/CHANGELOG.md b/CHANGELOG.md index cd16363c15..329454ff7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). - migrate the generic runtime and Rust client to SDK 0.16's scoped wire codec 3; the handshake requires an exact codec match, so product guests built against an earlier codec must be rebuilt +- Code-generation verification checks deterministic protocol and host output instead of pinning implementation-text snapshots; generated bindings are compiled and exercised against the runtime. - integrate the current native SDK with PolkaVM host runtime `0.3.2-rc.9`, pinned to `959ad63f7312a2f4598b9f718ccc2516927cbbff`, retaining canonical permission administration and live-execution revocation (#540) @@ -39,9 +40,20 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). subscription interrupts, and host-initiated Renderer subscription codecs. - Generate complete App, Widget, Worker, and Worker-only method catalogs from the canonical protocol schema. +- Host-owned background notification receiving on Notifications actions 2 through 7, with durable consent, revision-fenced watches and receipts, authenticated delivery and activation, and resident native/WASM transport hooks. Receipt results distinguish confirmed OS display from an outstanding display claim; explicit display failures release the claim. Hosts without an adapter explicitly report unsupported. +- Generic Ed25519 notification assertions in bounded standard Gordian envelopes, with opaque byte-leaf digest witnesses and exact chain/channel plus authenticated topic filtering, exposed through `@parity/truapi/notification-envelope`. +- A wallet-free, single-writer browser receiving runtime and immutable execution-scope dispatch. Receiving consent lasts only through its approved watch expiry (at most 30 days); transport leases may renew within that bound without product UI. Native/provider adapters and real-device qualification remain host responsibilities; resident hooks do not imply delivery after a desktop host fully quits. +- Browser host account selection fences all persisted product authorities, including closed products. Explicit logout durably revokes every authority before identity removal; ordinary client closure retains consent. Receiving storage uses reserved key 20 without colliding with the PolkaVM runtime's existing keys. +- Foreground receipts distinguish confirmed OS display from an unresolved durable display reservation. Hosts confirm successful presentation or cancel a known failure; an unknown outcome after restart remains pending until event expiry and must not trigger a competing OS alert. Provider-rendered APNs alerts remain advisory until authenticated local processing. ### Fixed +- Browser receiving synchronizes and revokes each authority scope independently, so account or verified-artifact replacement cannot leave the current enrollment in a revoke/register loop. +- Message catch-up receipts preserve an already queued notification activation until the product explicitly acknowledges its sequence. +- Generated WASM bridges preserve owned `String` parameters instead of emitting unsized `str` arguments. +- Generated host tagged-union codecs preserve explicit SCALE discriminants, including receiving storage slot 20 when slots 13–19 are absent from the generic runtime. +- Swift's unsupported receiving callbacks use the generated native rejection case. + - persist typed Statement Store allowance approvals and denials per product and account selector for implicit, idempotent provisioning; explicit requests for additional quota retain per-operation confirmation and increase semantics. diff --git a/Cargo.lock b/Cargo.lock index 3e53ea83b2..28aefb7f4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -543,6 +543,45 @@ dependencies = [ "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 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.19", + "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-channel" version = "2.5.0" @@ -755,7 +794,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] [[package]] @@ -764,6 +803,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitcoin-consensus-encoding" version = "1.2.0" @@ -1549,6 +1597,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1675,6 +1737,20 @@ dependencies = [ "signature", ] +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "ed25519-zebra" version = "4.2.0" @@ -3602,6 +3678,15 @@ dependencies = [ "objc2-core-foundation", ] +[[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" @@ -4344,6 +4429,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" +dependencies = [ + "ring", + "rustls-pki-types", + "time", + "x509-parser 0.18.1", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4518,6 +4616,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -5927,6 +6034,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -5935,6 +6043,16 @@ 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 = "tiny-keccak" version = "2.0.2" @@ -6217,6 +6335,7 @@ dependencies = [ "chacha20poly1305", "console_error_panic_hook", "derive_more 2.1.1", + "ed25519-dalek", "frame-metadata", "futures", "futures-timer", @@ -6229,9 +6348,13 @@ dependencies = [ "nanoid", "parity-scale-codec", "parking_lot", + "quinn", "rand 0.8.7", + "rcgen", + "ring", "rusqlite", "rusqlite_migration", + "rustls", "scale-decode", "scale-info", "schnorrkel", @@ -6265,6 +6388,7 @@ dependencies = [ "web-sys", "web-time", "x25519-dalek", + "x509-parser 0.17.0", "zeroize", ] @@ -7134,7 +7258,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" dependencies = [ "arrayvec 0.7.8", - "bit-vec", + "bit-vec 0.8.0", "bitflags 2.13.1", "cfg_aliases", "document-features", @@ -7518,6 +7642,41 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.19", + "time", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.19", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -7540,6 +7699,16 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe269e7b803a5e8e20cbd97860e136529cd83bf2c9c6d37b142467e7e1f051f" +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index d86bd47d21..af4b6d773b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ console_error_panic_hook = "0.1" convert_case = "0.6" crossterm = "0.29" derive_more = { version = "2", default-features = false } +ed25519-dalek = { version = "2", default-features = false, features = ["alloc"] } flate2 = "1" frame-metadata = { version = "23", default-features = false } fs2 = "0.4" diff --git a/README.md b/README.md index 3586077dfd..4ed4906354 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,12 @@ session name requires at least six lowercase ASCII letters after digits and separators are omitted. A new `/session foo` fails immediately as too short; existing saved accounts and aliases still restore normally. +CLI approval reviews belong to their waiting request. Cancelling a signing +request withdraws both its chain-permission prerequisite and its signature +review; disconnecting its product socket also removes the review. Withdrawn +queued reviews are skipped, late answers cannot authorize them, and the command +draft is restored before the next request is reviewed. + The signing host registers its built-in full and lite personhood keys when an authorized product first lists `peopl.` (for example, `peopl.paseo`). The first listing reads People-chain metadata; later listings @@ -68,6 +74,18 @@ handles discoverable; proof creation still checks permission and ring membership The `listRingVrfKeys` example checks that both built-in keys are discoverable under `peopl.paseo` on Paseo. +Local signing-host product clearing revokes only that product's grants and +in-flight authority. Unrelated products and account-scoped authority remain valid. +Reactivating, replacing or clearing the account still invalidates every prior +authority snapshot. Resource reviews and allowance-cache commits revalidate +the relevant product and account before granting or retaining authority. +Contact resolution, picking and labels use the same product-scoped authority +check. Independent directory-mutation fences still apply: product clearing +invalidates the shared contact directory, so an interrupted selection must be +retried without treating an unrelated product clear as account disconnection. +Labels invalidated while drawing are withdrawn before the call returns; +directory-only invalidation reports an interruption, not `NotConnected`. + Preimage lookups that miss the core's cache read the selected network's Bulletin node through `bitswap_v1_get`. The CLI verifies the returned bytes against the requested key and keeps missing lookups subscribed until the blob arrives. @@ -202,6 +220,19 @@ navigation and requires `Notifications` for push delivery. Hosts preserve the us `Deny` choice; Rust owns one-use grants for Rust-backed executions. Android permission prompts belong to one request and close when it finishes or is cancelled, including cancellation while the app is backgrounded. +Background receiving appends actions 2–7 to Notifications without changing send/cancel. +The resident Rust service owns consent, revision-fenced watches, authenticated delivery, +receipts and activation; each product execution supplies an immutable verified authority. +The browser's single-writer `WasmNotificationReceiver` uses the same service without +starting a wallet or product runtime. Enrollment needs a host receiving adapter and +separate consent; unsupported hosts report that explicitly. Logout revokes locally +without waiting for a relay. See the [host receiving contract](js/packages/truapi-host/README.md) +and [product notification helpers](js/packages/truapi/README.md). These hooks do not +establish OS/provider delivery guarantees or replace the separate PolkaVM runtime. +Relay revocation and synchronization acknowledgements use the full receiving +authority, so retained records from a previous account or verified artifact cannot +revoke the current enrollment. + The shared Rust core auto-grants remote permissions to trusted products (`peopl`, `dim2` and `stash`, on every supported network) only when no stored decision overrides that default. Recorded denials still apply. Device permissions, identity disclosure, account access and Chat authority retain their separate consent checks. @@ -262,12 +293,19 @@ The native composition pins release `v0.3.2-rc.9` at immutable source revision `959ad63f7312a2f4598b9f718ccc2516927cbbff`; `Cargo.toml`, `Cargo.lock`, and `truapi-polkavm-host`'s public provenance constants identify the same runtime. -Taking a screenshot opens **Report app issue** wherever the shake-opened Debug menu is, which is every build except the -store submission: `DEBUG_TOOLS_ENABLED` on Android, false only for the `release` build type, and `TESTNET_FEATURE` on -iOS, unset only for the `Release` configuration. Android screenshot detection requires Android 14+. The modal includes a -snapshot of the app screen, a description, and ZIP logs. Send uploads the report through -[issue-proxy](https://github.com/paritytech/issue-proxy). Configure these Firebase Remote Config string parameters for -each mobile environment: +The native JAM peer transport's live fixture targets JAM-TEST-INSTANCE. +Run `cargo test -p truapi --features mock --test live_jam_test_instance -- --include-ignored` +with network access to its six validators; see the +[peer transport contract](rust/crates/truapi/RUNTIME.md#jam-peer-transport). + +Taking a screenshot opens **Report app issue** wherever the shake-opened Debug +menu is, which is every build except the store submission: `DEBUG_TOOLS_ENABLED` +on Android, false only for the `release` build type, and `TESTNET_FEATURE` on +iOS, unset only for the `Release` configuration. Android screenshot detection +requires Android 14+. +The modal includes a snapshot of the app screen, a description, and ZIP logs. +Send uploads the report through [issue-proxy](https://github.com/paritytech/issue-proxy). +Configure these Firebase Remote Config string parameters for each mobile environment: | Parameter | Value | | --------------------- | --------------------------------------------------------- | diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt index 6f41e39ea2..3f7ec06fcf 100644 --- a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt @@ -111,6 +111,10 @@ import uniffi.truapi.PlacedContactLabels import uniffi.truapi.HostContactsPlaceLabelsException import uniffi.truapi.NativeContactsCallbacks import uniffi.truapi.SsoRequestOutcome +import uniffi.truapi.ReceivingAuthority +import uniffi.truapi.ReceivingWatch +import uniffi.truapi.ReceivingRegistration +import uniffi.truapi.ReceivingEvent /** Package metadata. */ object TrUAPIHost { @@ -246,6 +250,21 @@ interface HostBridge : NativeChatFilesHost { @Throws(HostRejection::class) fun cancelNotification(id: UInt) {} + /** Resident bridge returns current host scope even with products closed. + * Product bridge returns immutable verified artifact/account scope captured at execution creation. */ + suspend fun receiverAuthority(productId: String): ReceivingAuthority? = null + + /** Receiving consent is distinct from OS notification permission. */ + suspend fun receiverConsent(authority: ReceivingAuthority, watches: List): Boolean = + throw HostRejection.Rejected("background receiving unsupported") + + /** Wake synchronization without waiting for a provider or network. */ + suspend fun receiverChanged(): Unit = + throw HostRejection.Rejected("background receiving unsupported") + + /** Forward to the sole receiving owner, or return null to use the native engine. */ + suspend fun receiverCommand(productId: String, action: UByte, payload: ByteArray): ByteArray? = null + /** Non-consuming ordered batch (at most 32) for this verified execution. */ @Throws(HostRejection::class) suspend fun activationEvents(): List = @@ -625,6 +644,18 @@ private class HostCallbackAdapter(private val bridge: HostBridge) : HostCallback override fun cancelNotification(id: UInt) = withHostRejection { bridge.cancelNotification(id) } + override suspend fun receiverAuthority(productId: String): ReceivingAuthority? = + withHostRejection { bridge.receiverAuthority(productId) } + + override suspend fun receiverConsent(authority: ReceivingAuthority, watches: List): Boolean = + withHostRejection { bridge.receiverConsent(authority, watches) } + + override suspend fun receiverChanged() = + withHostRejection { bridge.receiverChanged() } + + override suspend fun receiverCommand(productId: String, action: UByte, payload: ByteArray): ByteArray? = + withHostRejection { bridge.receiverCommand(productId, action, payload) } + override suspend fun activationEvents(): List = withHostRejection { bridge.activationEvents() } @@ -1058,6 +1089,44 @@ class TrUAPIHostRuntime @Throws(NativeRuntimeConfigException::class) constructor return TrUAPIProductExecution(execution, adapter, chatAdapter, pocketAdapter, gameAdapter) } + /** All durable registrations; inspect syncPending before synchronizing. */ + suspend fun receivingPending(): List = inner.receivingPending() + + suspend fun receivingSynchronized(productId: String, revision: ULong): Boolean = + inner.receivingSynchronized(productId, revision) + + suspend fun receivingIngest( + productId: String, revision: ULong, watchId: String, + actualGenesis: String, actualChannel: String, actualTopics: List, frame: ByteArray, + ): List = inner.receivingIngest(productId, revision, watchId, actualGenesis, actualChannel, actualTopics, frame) + + suspend fun receivingIngestStatement( + productId: String, revision: ULong, watchId: String, + actualGenesis: String, statement: ByteArray, + ): List = inner.receivingIngestStatement(productId, revision, watchId, actualGenesis, statement) + + /** Reserve display only after rechecking current authority and receipts. */ + suspend fun receivingPrepareDisplay(productId: String, revision: ULong, eventId: String): ReceivingEvent? = + inner.receivingPrepareDisplay(productId, revision, eventId) + + /** Read-only authorization before opening a verified product, with sequence zero. */ + suspend fun receivingValidateActivation(productId: String, revision: ULong, eventId: String): ReceivingEvent? = + inner.receivingValidateActivation(productId, revision, eventId) + + suspend fun receivingConfirmDisplay(productId: String, revision: ULong, eventId: String) = + inner.receivingConfirmDisplay(productId, revision, eventId) + + /** Clear only an explicitly failed display; an unknown outcome remains pending. */ + suspend fun receivingCancelDisplay(productId: String, revision: ULong, eventId: String) = + inner.receivingCancelDisplay(productId, revision, eventId) + + suspend fun receivingActivate(productId: String, revision: ULong, eventId: String): ReceivingEvent? = + inner.receivingActivate(productId, revision, eventId) + + suspend fun receivingRevoke(productId: String) = inner.receivingRevoke(productId) + + suspend fun receivingMarkTransportChanged(productId: String) = inner.receivingMarkTransportChanged(productId) + /** * Take one reference on the product's worker for a modality holder that is * on screen or in flight. The first one reports a start transition to diff --git a/docs/rfcs/0002-permission-model.md b/docs/rfcs/0002-permission-model.md index 8b8131c498..16f4d727f8 100644 --- a/docs/rfcs/0002-permission-model.md +++ b/docs/rfcs/0002-permission-model.md @@ -154,7 +154,12 @@ enum RemotePermission { PreimageSubmit, // Submit statements to the statement store via // remote_statement_store_submit. - StatementSubmit + StatementSubmit, + // Bidirectional JAMNP-S QUIC/WebTransport peer messaging via + // jam_peer_transport_dial. The product names endpoints and peer keys; + // every byte received is untrusted. Decided per product and full genesis, + // without granting account access or signing authority. + JamPeers { genesis: [u8; 32] } } ``` @@ -253,6 +258,7 @@ The following business methods gate on a specific permission and MUST internally | `remote_chain_transaction_broadcast` | `RemotePermission::ChainSubmit` | | `remote_preimage_submit` | `RemotePermission::PreimageSubmit` | | `remote_statement_store_submit` | `RemotePermission::StatementSubmit` | +| `jam_peer_transport_dial` | `RemotePermission::JamPeers { genesis }` | | `host_navigate_to` | `DevicePermission::OpenUrl` | | `send_push_notification` | `DevicePermission::Notifications` | @@ -296,7 +302,8 @@ enum RemotePermission { WebRTC, ChainSubmit, PreimageSubmit, - StatementSubmit + StatementSubmit, + JamPeers { genesis: [u8; 32] } } // Single-permission device request (unchanged semantics, updated type name) @@ -343,7 +350,7 @@ This RFC introduces breaking changes: 3. **New `DevicePermission` variants**: `NFC`, `Clipboard`, `OpenUrl`, and `Biometrics` are new enum variants appended after the existing four. Older hosts that receive an unrecognized variant SHOULD return `false` (permission not granted) rather than an error, to allow graceful degradation. -4. **New `RemotePermission` variants**: `PreimageSubmit` and `StatementSubmit` are new variants. Older hosts that receive an unrecognized variant in a batch SHOULD treat it as denied and return `false`. +4. **New `RemotePermission` variants**: `PreimageSubmit`, `StatementSubmit` and `JamPeers` are new variants, appended so earlier indices are unchanged. Older hosts that receive an unrecognized variant in a batch SHOULD treat it as denied and return `false`. Migration is straightforward for implementors following semantic versioning: bump the major version, update type names, and wrap single-permission calls in a `vec![...]`. diff --git a/hosts/android/common/src/main/res/values/strings.xml b/hosts/android/common/src/main/res/values/strings.xml index e1f08df705..b54416f9fb 100644 --- a/hosts/android/common/src/main/res/values/strings.xml +++ b/hosts/android/common/src/main/res/values/strings.xml @@ -904,12 +904,15 @@ I’m attaching PDF with more information and details regarding the Tattoo Stenc Statements will be published to the statement store. %1$s %1$s would like to submit preimages Preimages will be uploaded to the bulletin chain. %1$s + %1$s would like to connect to JAM network %2$s + Send and receive messages with peers selected by this app, with no access to your accounts or signing keys. %1$s %1$s Would Like Access to: Access %1$s Use WebRTC Submit transactions Submit statements Submit preimages + Connect to JAM network %1$s Allow always all Allow once all Deny all @@ -1363,6 +1366,7 @@ I’m attaching PDF with more information and details regarding the Tattoo Stenc Chain transactions Statement submission Preimage submission + JAM network peers Remote permission Chat identity access %1$s wants to use your Chat identity diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/HostApiInteractor.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/HostApiInteractor.kt index 5a708807bf..e6a9c8b0a8 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/HostApiInteractor.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/hostApi/HostApiInteractor.kt @@ -490,4 +490,5 @@ private fun RemotePermissionRequest.toDomainPermissions(): List listOf(ProductPermission.RemotePermission.ChainSubmitAccess) RemotePermissionRequest.StatementSubmit -> listOf(ProductPermission.RemotePermission.StatementSubmitAccess) RemotePermissionRequest.PreimageSubmit -> listOf(ProductPermission.RemotePermission.PreimageSubmitAccess) + is RemotePermissionRequest.JamPeers -> listOf(ProductPermission.RemotePermission.JamPeersAccess(genesis)) } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/CanonicalProductPermissions.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/CanonicalProductPermissions.kt index f719ada9d6..e01fea41af 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/CanonicalProductPermissions.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/CanonicalProductPermissions.kt @@ -1,11 +1,13 @@ package io.paritytech.polkadotapp.feature_products_impl.domain.permissions +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString import io.paritytech.polkadotapp.common.domain.model.toDataByteArray import io.paritytech.polkadotapp.feature_products_impl.domain.permissions.models.AllowanceAccountSelector -import uniffi.truapi.DerivationIndex import io.paritytech.polkadotapp.feature_products_impl.domain.permissions.models.DeviceCapabilityType import io.paritytech.polkadotapp.feature_products_impl.domain.permissions.models.ProductPermission import io.paritytech.polkadotapp.feature_products_impl.domain.truapi.normalizeProductId +import uniffi.truapi.DerivationIndex import uniffi.truapi.HostDevicePermissionRequest import uniffi.truapi.PermissionAuthorizationRequest import uniffi.truapi.RemotePermission @@ -34,6 +36,9 @@ internal fun ProductPermission.canonicalRequest(): PermissionAuthorizationReques ProductPermission.RemotePermission.ChainSubmitAccess -> RemotePermission.ChainSubmit ProductPermission.RemotePermission.StatementSubmitAccess -> RemotePermission.StatementSubmit ProductPermission.RemotePermission.PreimageSubmitAccess -> RemotePermission.PreimageSubmit + is ProductPermission.RemotePermission.JamPeersAccess -> RemotePermission.JamPeers(genesis.fromHex().also { + require(it.size == 32) { "JAM genesis must contain 32 bytes" } + }) })) } @@ -61,6 +66,7 @@ internal fun PermissionAuthorizationRequest.legacyPermission(): ProductPermissio RemotePermission.ChainSubmit -> ProductPermission.RemotePermission.ChainSubmitAccess RemotePermission.StatementSubmit -> ProductPermission.RemotePermission.StatementSubmitAccess RemotePermission.PreimageSubmit -> ProductPermission.RemotePermission.PreimageSubmitAccess + is RemotePermission.JamPeers -> ProductPermission.RemotePermission.JamPeersAccess(remote.genesis.toHexString(withPrefix = true)) } } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/handlers/RemotePermissionHandler.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/handlers/RemotePermissionHandler.kt index 84494f85f1..aca5162c0e 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/handlers/RemotePermissionHandler.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/handlers/RemotePermissionHandler.kt @@ -19,7 +19,8 @@ class RemotePermissionHandler @Inject constructor( is RemotePermission.WebRtcAccess, is RemotePermission.ChainSubmitAccess, is RemotePermission.StatementSubmitAccess, - is RemotePermission.PreimageSubmitAccess -> repository.isGranted(productId, permission) + is RemotePermission.PreimageSubmitAccess, + is RemotePermission.JamPeersAccess -> repository.isGranted(productId, permission) } } @@ -30,7 +31,8 @@ class RemotePermissionHandler @Inject constructor( is RemotePermission.WebRtcAccess, is RemotePermission.ChainSubmitAccess, is RemotePermission.StatementSubmitAccess, - is RemotePermission.PreimageSubmitAccess -> requestSimple(productId, permission) + is RemotePermission.PreimageSubmitAccess, + is RemotePermission.JamPeersAccess -> requestSimple(productId, permission) } } @@ -41,7 +43,8 @@ class RemotePermissionHandler @Inject constructor( is RemotePermission.WebRtcAccess, is RemotePermission.ChainSubmitAccess, is RemotePermission.StatementSubmitAccess, - is RemotePermission.PreimageSubmitAccess -> repository.revoke(productId, permission) + is RemotePermission.PreimageSubmitAccess, + is RemotePermission.JamPeersAccess -> repository.revoke(productId, permission) } } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/models/ProductPermission.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/models/ProductPermission.kt index 9085d5f660..3a62f1149d 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/models/ProductPermission.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/models/ProductPermission.kt @@ -53,6 +53,21 @@ sealed interface ProductPermission { override val typeName: String get() = TYPE_NAME override val key: String get() = "" } + + /** Stored per network; [genesis] is the lowercase `0x`-prefixed genesis hash. */ + data class JamPeersAccess( + val genesis: String, + ) : RemotePermission { + override val typeName: String get() = TYPE_NAME + override val key: String get() = genesis + + /** `0x` plus the first 8 hex digits, as shown in prompts and the permissions list. */ + val shortGenesis: String get() = genesis.take(10) + "…" + + companion object { + const val TYPE_NAME = "jam_peers" + } + } } data class DeviceCapability( @@ -137,6 +152,7 @@ sealed interface ProductPermission { RemotePermission.ChainSubmitAccess.TYPE_NAME -> RemotePermission.ChainSubmitAccess RemotePermission.StatementSubmitAccess.TYPE_NAME -> RemotePermission.StatementSubmitAccess RemotePermission.PreimageSubmitAccess.TYPE_NAME -> RemotePermission.PreimageSubmitAccess + RemotePermission.JamPeersAccess.TYPE_NAME -> RemotePermission.JamPeersAccess(key) else -> error("Unknown permission type: $typeName") } } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/models/RemotePermissionRequest.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/models/RemotePermissionRequest.kt index 2f71dcb014..5e359b689f 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/models/RemotePermissionRequest.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/models/RemotePermissionRequest.kt @@ -6,4 +6,5 @@ sealed interface RemotePermissionRequest { data object ChainSubmit : RemotePermissionRequest data object StatementSubmit : RemotePermissionRequest data object PreimageSubmit : RemotePermissionRequest + data class JamPeers(val genesis: String) : RemotePermissionRequest } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt index fdf4a03182..e2e8998764 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/domain/truapi/ProductTrUAPIHostBridge.kt @@ -16,6 +16,7 @@ import androidx.core.net.toUri import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import io.novasama.substrate_sdk_android.extensions.toHexString import dagger.Lazy import io.parity.truapi.GameHostBridge import io.parity.truapi.HostBridge @@ -237,11 +238,12 @@ class ProductTrUAPIHostBridge @AssistedInject constructor( override suspend fun remotePermission( product: ProductExecutionConfig, request: RemotePermission, - ): TrUAPIPermissionDecision = - hostApiInteractor + ): TrUAPIPermissionDecision { + return hostApiInteractor .requestRemotePermissionDecision(callingProductId, request.toDomain()) .getOrElse { throw it } .toNative() + } /** * Answered from the same snapshot [chainConnect] dials rather than the @@ -473,6 +475,7 @@ private fun RemotePermission.toDomain(): RemotePermissionRequest = when (this) { RemotePermission.ChainSubmit -> RemotePermissionRequest.ChainSubmit RemotePermission.PreimageSubmit -> RemotePermissionRequest.PreimageSubmit RemotePermission.StatementSubmit -> RemotePermissionRequest.StatementSubmit + is RemotePermission.JamPeers -> RemotePermissionRequest.JamPeers(genesis.toHexString(withPrefix = true)) } private fun PermissionDecision.toNative(): TrUAPIPermissionDecision = when (this) { diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/permissionPrompt/compose/PermissionMapping.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/permissionPrompt/compose/PermissionMapping.kt index fa20299ca3..ebfd2b5a8c 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/permissionPrompt/compose/PermissionMapping.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/permissionPrompt/compose/PermissionMapping.kt @@ -38,6 +38,7 @@ internal val ProductPermission.icon: ImageVector ProductPermission.RemotePermission.ChainSubmitAccess -> NovaIcons.Send ProductPermission.RemotePermission.StatementSubmitAccess -> NovaIcons.CloudOn ProductPermission.RemotePermission.PreimageSubmitAccess -> NovaIcons.CloudOn + is ProductPermission.RemotePermission.JamPeersAccess -> NovaIcons.WiFi } private val DeviceCapabilityType.icon: ImageVector @@ -69,6 +70,7 @@ internal fun ProductPermission.title(productId: String): String { ProductPermission.RemotePermission.ChainSubmitAccess -> stringResource(RCommon.string.product_permission_chain_submit_title, productId) ProductPermission.RemotePermission.StatementSubmitAccess -> stringResource(RCommon.string.product_permission_statement_submit_title, productId) ProductPermission.RemotePermission.PreimageSubmitAccess -> stringResource(RCommon.string.product_permission_preimage_submit_title, productId) + is ProductPermission.RemotePermission.JamPeersAccess -> stringResource(RCommon.string.product_permission_jam_peers_title, productId, shortGenesis) } } @@ -105,6 +107,7 @@ internal fun ProductPermission.subtitle(): String { ProductPermission.RemotePermission.ChainSubmitAccess -> stringResource(RCommon.string.product_permission_chain_submit_subtitle, manageLater) ProductPermission.RemotePermission.StatementSubmitAccess -> stringResource(RCommon.string.product_permission_statement_submit_subtitle, manageLater) ProductPermission.RemotePermission.PreimageSubmitAccess -> stringResource(RCommon.string.product_permission_preimage_submit_subtitle, manageLater) + is ProductPermission.RemotePermission.JamPeersAccess -> stringResource(RCommon.string.product_permission_jam_peers_subtitle, manageLater) + "\n\n" + genesis } } @@ -133,5 +136,6 @@ internal fun ProductPermission.RemotePermission.shortLabel(): String { ProductPermission.RemotePermission.ChainSubmitAccess -> stringResource(RCommon.string.product_permission_chain_submit_label) ProductPermission.RemotePermission.StatementSubmitAccess -> stringResource(RCommon.string.product_permission_statement_submit_label) ProductPermission.RemotePermission.PreimageSubmitAccess -> stringResource(RCommon.string.product_permission_preimage_submit_label) + is ProductPermission.RemotePermission.JamPeersAccess -> stringResource(RCommon.string.product_permission_jam_peers_label, genesis) } } diff --git a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/productPermissions/compose/components/ProductPermissionItem.kt b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/productPermissions/compose/components/ProductPermissionItem.kt index 02c7e141ea..8b86b4a5e8 100644 --- a/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/productPermissions/compose/components/ProductPermissionItem.kt +++ b/hosts/android/feature/products/impl/src/main/java/io/paritytech/polkadotapp/feature_products_impl/presentation/productPermissions/compose/components/ProductPermissionItem.kt @@ -76,12 +76,18 @@ private fun ProductPermission.displayName(): String { ProductPermission.RemotePermission.ChainSubmitAccess -> stringResource(RCommon.string.product_permission_type_chain_submit) ProductPermission.RemotePermission.StatementSubmitAccess -> stringResource(RCommon.string.product_permission_type_statement_submit) ProductPermission.RemotePermission.PreimageSubmitAccess -> stringResource(RCommon.string.product_permission_type_preimage_submit) + is ProductPermission.RemotePermission.JamPeersAccess -> stringResource(RCommon.string.product_permission_jam_peers_label, shortGenesis) } } @Composable private fun ProductPermission.displayDescription(): String { - return stringResource(descriptionRes()) + val description = stringResource(descriptionRes()) + return if (this is ProductPermission.RemotePermission.JamPeersAccess) { + "$description\n$genesis" + } else { + description + } } @StringRes @@ -107,6 +113,7 @@ private fun ProductPermission.descriptionRes(): Int = when (this) { ProductPermission.ProfileDisclosure -> RCommon.string.product_permission_profile_disclosure_description is ProductPermission.StatementStoreAllowance -> RCommon.string.product_permission_allowance_description is ProductPermission.RemotePermission.NetworkAccess -> RCommon.string.product_permission_type_network_access + is ProductPermission.RemotePermission.JamPeersAccess -> RCommon.string.product_permission_type_jam_peers is ProductPermission.RemotePermission.NetworkAccessSet -> RCommon.string.product_permission_type_network_access ProductPermission.RemotePermission.WebRtcAccess, ProductPermission.RemotePermission.ChainSubmitAccess, diff --git a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/ProductPermissionRepositoryTest.kt b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/ProductPermissionRepositoryTest.kt index b9fc3822e0..c6ef85847c 100644 --- a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/ProductPermissionRepositoryTest.kt +++ b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/ProductPermissionRepositoryTest.kt @@ -261,6 +261,15 @@ class ProductPermissionRepositoryTest { assertTrue(runCatching { canonicalDomain("https://example.com") }.isFailure) } + @Test + fun `JAM permission mapping preserves the full genesis and rejects short keys`() { + val permission = ProductPermission.RemotePermission.JamPeersAccess("0x" + "ab".repeat(32)) + assertEquals(permission, permission.canonicalRequest()!!.legacyPermission()) + assertTrue(runCatching { + ProductPermission.RemotePermission.JamPeersAccess("0xab").canonicalRequest() + }.isFailure) + } + @Test fun `account notifications invalidate executable aliases but not other products`() { val changes = PermissionAuthorizationChanges() diff --git a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/RealProductPermissionGuardTest.kt b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/RealProductPermissionGuardTest.kt index 6465d84bcb..0016636ad6 100644 --- a/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/RealProductPermissionGuardTest.kt +++ b/hosts/android/feature/products/impl/src/test/java/io/paritytech/polkadotapp/feature_products_impl/domain/permissions/RealProductPermissionGuardTest.kt @@ -131,6 +131,22 @@ class RealProductPermissionGuardTest { verifyGrantedPermanently() } + @Test + fun `JAM networks sharing a display prefix are prompted and granted separately`() = runBlocking { + withNotGranted() + withBatchedDecision(PermissionDecision.AllowAlways) + val first = RemotePermission.JamPeersAccess("0x10c123f0" + "ab".repeat(28)) + val second = RemotePermission.JamPeersAccess("0x10c123f0" + "cd".repeat(28)) + + assertTrue(guard.requestPermissionsBatched(productId, listOf(first, second))) + + verify(requester).promptBatched(productId, listOf(first, second)) + coVerify { repository.grant(productId, first) } + coVerify { repository.grant(productId, second) } + assertEquals(first, ProductPermission.fromLocal(first.typeName, first.key)) + assertEquals(second, ProductPermission.fromLocal(second.typeName, second.key)) + } + @Test fun `consumePermission consumes a grant issued while waiting for the lock without prompting`() = runBlocking { withOneTimeGrantIssuedWhileWaiting() diff --git a/hosts/ios/Packages/Products/Sources/Products/Permissions/Guard/ProductPermissionGuard.swift b/hosts/ios/Packages/Products/Sources/Products/Permissions/Guard/ProductPermissionGuard.swift index 9d35d2645e..00f0dcc4e7 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Permissions/Guard/ProductPermissionGuard.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Permissions/Guard/ProductPermissionGuard.swift @@ -78,6 +78,7 @@ public final class ProductPermissionGuard: ProductPermissionGuarding, @unchecked .chainSubmitAccess, .preimageSubmitAccess, .statementSubmitAccess, + .jamPeersAccess, .userIdentityAccess, .chatAuthority, .profileDisclosure, @@ -170,6 +171,7 @@ public final class ProductPermissionGuard: ProductPermissionGuarding, @unchecked .chainSubmitAccess, .preimageSubmitAccess, .statementSubmitAccess, + .jamPeersAccess, .userIdentityAccess, .chatAuthority, .profileDisclosure, diff --git a/hosts/ios/Packages/Products/Sources/Products/Permissions/Handlers/RemotePermissionHandler.swift b/hosts/ios/Packages/Products/Sources/Products/Permissions/Handlers/RemotePermissionHandler.swift index 46de78ad83..908af86c77 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Permissions/Handlers/RemotePermissionHandler.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Permissions/Handlers/RemotePermissionHandler.swift @@ -1,8 +1,8 @@ import Foundation /// Handles simple remote permissions (`webRtcAccess`, `chainSubmitAccess`, -/// `preimageSubmitAccess`, `statementSubmitAccess`) with a check-or-prompt -/// pattern. Network-access permissions are routed to +/// `preimageSubmitAccess`, `statementSubmitAccess`, `jamPeersAccess`) with a +/// check-or-prompt pattern. Network-access permissions are routed to /// ``NetworkAccessPermissionHandler`` instead. public final class RemotePermissionHandler: Sendable { private let repository: ProductPermissionRepositoryProtocol diff --git a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift index 0e9490e661..063d311489 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift @@ -13,6 +13,7 @@ public enum ProductPermission: Equatable, Sendable { public static let preimageSubmitAccessTypeName = "preimage_submit" public static let balanceAccessTypeName = "balance_access" public static let statementSubmitAccessTypeName = "statement_submit" + public static let jamPeersAccessTypeName = "jam_peers" public static let userIdentityAccessTypeName = "user_identity_access" public static let chatAuthorityTypeName = "chat_authority" public static let profileDisclosureTypeName = "profile_disclosure" @@ -28,6 +29,9 @@ public enum ProductPermission: Equatable, Sendable { case chainSubmitAccess case preimageSubmitAccess case statementSubmitAccess + /// Peer messaging access to the JAM network whose genesis header hash is + /// `genesis` (lowercase `0x`-prefixed hex). + case jamPeersAccess(genesis: String) case userIdentityAccess case chatAuthority case profileDisclosure @@ -45,7 +49,7 @@ public enum ProductPermission: Equatable, Sendable { public var isRemoteAccess: Bool { switch self { case .networkAccess, .networkAccessBundle, .webRtcAccess, .chainSubmitAccess, .preimageSubmitAccess, - .statementSubmitAccess: + .statementSubmitAccess, .jamPeersAccess: true case .deviceCapability, .accountAccess, .balanceAccess, .userIdentityAccess, .chatAuthority, .profileDisclosure, .statementStoreAllowance: @@ -53,6 +57,12 @@ public enum ProductPermission: Equatable, Sendable { } } + /// A JAM genesis as shown to the user: `0x`, its first 8 hex digits and `…`. + public static func shortGenesis(_ genesis: String) -> String { + let digits = genesis.hasPrefix("0x") ? genesis.dropFirst(2) : Substring(genesis) + return "0x\(digits.prefix(8))…" + } + public var typeName: String { switch self { case .deviceCapability: @@ -73,6 +83,8 @@ public enum ProductPermission: Equatable, Sendable { Self.preimageSubmitAccessTypeName case .statementSubmitAccess: Self.statementSubmitAccessTypeName + case .jamPeersAccess: + Self.jamPeersAccessTypeName case .userIdentityAccess: Self.userIdentityAccessTypeName case .chatAuthority: @@ -94,6 +106,8 @@ public enum ProductPermission: Equatable, Sendable { domains.joined(separator: "\n") case let .accountAccess(targetProductId): targetProductId + case let .jamPeersAccess(genesis): + genesis case let .statementStoreAllowance(derivationIndex): switch derivationIndex { case nil: @@ -138,6 +152,8 @@ public enum ProductPermission: Equatable, Sendable { return .preimageSubmitAccess case statementSubmitAccessTypeName: return .statementSubmitAccess + case jamPeersAccessTypeName: + return .jamPeersAccess(genesis: key) case userIdentityAccessTypeName: return .userIdentityAccess case chatAuthorityTypeName: diff --git a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/RemotePermissionRequest.swift b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/RemotePermissionRequest.swift index 5836784730..9aa5dd3d89 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/RemotePermissionRequest.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/RemotePermissionRequest.swift @@ -7,6 +7,9 @@ public enum RemotePermissionRequest: Equatable, Sendable { case chainSubmit case preimageSubmit case statementSubmit + /// Peer messaging access to the JAM network whose genesis header hash is + /// `genesis` (lowercase `0x`-prefixed hex). + case jamPeers(genesis: String) /// Converts to domain-level permissions used by the permission guard. /// `Remote` expands into one `networkAccess` per domain (lowercased). @@ -22,6 +25,8 @@ public enum RemotePermissionRequest: Equatable, Sendable { [.preimageSubmitAccess] case .statementSubmit: [.statementSubmitAccess] + case let .jamPeers(genesis): + [.jamPeersAccess(genesis: genesis)] } } } diff --git a/hosts/ios/Packages/Products/Tests/ProductsTests/TrustedRemoteProductPermissionRequesterTests.swift b/hosts/ios/Packages/Products/Tests/ProductsTests/TrustedRemoteProductPermissionRequesterTests.swift index 423103a651..c1fbc99799 100644 --- a/hosts/ios/Packages/Products/Tests/ProductsTests/TrustedRemoteProductPermissionRequesterTests.swift +++ b/hosts/ios/Packages/Products/Tests/ProductsTests/TrustedRemoteProductPermissionRequesterTests.swift @@ -25,7 +25,8 @@ struct TrustedRemoteProductPermissionRequesterTests { .webRtcAccess, .chainSubmitAccess, .preimageSubmitAccess, - .statementSubmitAccess + .statementSubmitAccess, + .jamPeersAccess(genesis: "0x10c123f0" + String(repeating: "0", count: 56)) ] /// The core grants a first-party product every remote permission without diff --git a/hosts/ios/README.md b/hosts/ios/README.md index 8fdad87fa3..76c4c77153 100644 --- a/hosts/ios/README.md +++ b/hosts/ios/README.md @@ -146,6 +146,8 @@ Consent prompts and app settings share permission descriptions and icon metadata Built with **UIKit** and programmatic layout (no Storyboards), using **VIPER** for every feature module: code is split between the main app target and 28 local Swift packages under [`Packages/`](./Packages) with `AppDependencies` as the root package, chain access goes through [substrate-sdk-ios](https://github.com/novasamatech/substrate-sdk-ios) (JSON-RPC, storage subscriptions, extrinsics), and local data lives in CoreData. +Permission prompts and revocation settings share the `ProductPermission` presentation metadata so capability descriptions stay consistent. JAM peer consent displays the full network genesis and remains scoped to that network and product; it does not grant accounts or signing. + This repository ships a **GitHub Actions + Fastlane CI/CD setup** — PR build and tests, plus maintainer-gated TestFlight and Firebase App Distribution. Build-time configuration, signing, the required secrets, and the pipeline itself are diff --git a/hosts/ios/polkadot-app/Localization/Products.xcstrings b/hosts/ios/polkadot-app/Localization/Products.xcstrings index 7bd3082573..5b77f72962 100644 --- a/hosts/ios/polkadot-app/Localization/Products.xcstrings +++ b/hosts/ios/polkadot-app/Localization/Products.xcstrings @@ -232,6 +232,17 @@ } } }, + "app.permission.jamPeers.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "JAM network peers" + } + } + } + }, "app.permission.network.title": { "extractionState": "manual", "localizations": { @@ -541,6 +552,17 @@ } } }, + "permission.body.jamPeers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send and receive messages with peers selected by this app, with no access to your accounts or signing keys." + } + } + } + }, "permission.body.manageInSettingsHint": { "extractionState": "manual", "localizations": { @@ -872,6 +894,17 @@ } } }, + "permission.label.jamPeers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect to JAM network %1$(genesis)@" + } + } + } + }, "permission.title.accountAccess": { "extractionState": "manual", "localizations": { @@ -939,6 +972,17 @@ } } }, + "permission.title.jamPeers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$(productId)@ would like to connect to JAM network %2$(shortGenesis)@" + } + } + } + }, "permission.title.networkAccess": { "extractionState": "manual", "localizations": { diff --git a/hosts/ios/polkadot-app/Modules/Products/Permissions/Repository/ProductPermissionRepository.swift b/hosts/ios/polkadot-app/Modules/Products/Permissions/Repository/ProductPermissionRepository.swift index a17701d5c9..33cc30a39e 100644 --- a/hosts/ios/polkadot-app/Modules/Products/Permissions/Repository/ProductPermissionRepository.swift +++ b/hosts/ios/polkadot-app/Modules/Products/Permissions/Repository/ProductPermissionRepository.swift @@ -1,6 +1,7 @@ import Foundation import Operation_iOS import Products +import SubstrateSdk import TrUAPIHost protocol ProductPermissionAuthority: Sendable { @@ -386,8 +387,8 @@ extension ProductPermission { case .balanceAccess: return nil case .networkAccess, .networkAccessBundle, .webRtcAccess, - .chainSubmitAccess, .preimageSubmitAccess, .statementSubmitAccess: - return permission.remoteAuthorizationRequest() + .chainSubmitAccess, .preimageSubmitAccess, .statementSubmitAccess, .jamPeersAccess: + return try permission.remoteAuthorizationRequest() } } @@ -403,7 +404,7 @@ extension ProductPermission { } } - private func remoteAuthorizationRequest() -> PermissionAuthorizationRequest { + private func remoteAuthorizationRequest() throws -> PermissionAuthorizationRequest { switch self { case let .networkAccess(domain): return .remote(.init(permission: .remote(domains: [domain]))) @@ -417,6 +418,12 @@ extension ProductPermission { return .remote(.init(permission: .preimageSubmit)) case .statementSubmitAccess: return .remote(.init(permission: .statementSubmit)) + case let .jamPeersAccess(genesis): + let bytes = try Data(hexString: genesis) + guard bytes.count == 32 else { + throw ProductPermissionMappingError.unsupported(typeName, genesis) + } + return .remote(.init(permission: .jamPeers(genesis: bytes))) default: preconditionFailure("Expected a remote permission") } diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift b/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift index 8b723bccc3..ae9463b2df 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift @@ -73,6 +73,7 @@ private extension ProductPermissionPromptViewFactory { permission: ProductPermission ) -> PromptContent { let title: String + let body: String switch permission { case let .deviceCapability(capability): title = String( @@ -81,10 +82,13 @@ private extension ProductPermissionPromptViewFactory { capability: capabilityDisplayName(capability) ) ) + body = permission.permissionDescription case .networkAccess, .networkAccessBundle: title = String(localized: .Products.permissionTitleNetworkAccess(productId: productId)) + body = permission.permissionDescription case .accountAccess: title = String(localized: .Products.permissionTitleAccountAccess(productId: productId)) + body = permission.permissionDescription case .balanceAccess, .webRtcAccess, .chainSubmitAccess, @@ -92,18 +96,26 @@ private extension ProductPermissionPromptViewFactory { .statementSubmitAccess, .userIdentityAccess: title = String(localized: .Products.permissionTitleRemote(productId: productId)) + body = permission.permissionDescription + case let .jamPeersAccess(genesis): + title = String( + localized: .Products.permissionTitleJamPeers( + productId: productId, + shortGenesis: ProductPermission.shortGenesis(genesis) + ) + ) + body = String(localized: .Products.permissionBodyJamPeers) + "\n\n" + genesis case .chatAuthority: title = String(localized: .Products.permissionTitleChatAuthority(productId: productId)) + body = permission.permissionDescription case .profileDisclosure: title = String(localized: .Products.permissionTitleProfileDisclosure(productId: productId)) + body = permission.permissionDescription case .statementStoreAllowance: title = String(localized: .Products.permissionTitleStatementStoreAllowance(productId: productId)) + body = permission.permissionDescription } - return PromptContent( - title: title, - body: permission.permissionDescription, - icon: makeIcon(systemName: permission.permissionIconSystemName) - ) + return PromptContent(title: title, body: body, icon: makeIcon(systemName: permission.permissionIconSystemName)) } static func makeBatchedContent( diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift index e07251aa53..2d58365fbe 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/RustProductExecutionBridge.swift @@ -314,6 +314,7 @@ extension RemotePermission { case .chainSubmit: .chainSubmit case .preimageSubmit: .preimageSubmit case .statementSubmit: .statementSubmit + case let .jamPeers(genesis): .jamPeers(genesis: genesis.toHex(includePrefix: true)) } } } diff --git a/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift b/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift index fcacdf910b..df0370fd0c 100644 --- a/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift @@ -39,6 +39,8 @@ extension ProductPermission { case .chainSubmitAccess: String(localized: .Products.permissionBodyChainSubmit) case .preimageSubmitAccess: String(localized: .Products.permissionBodyPreimageSubmit) case .statementSubmitAccess: String(localized: .Products.permissionBodyStatementSubmit) + case let .jamPeersAccess(genesis): + String(localized: .Products.permissionLabelJamPeers(genesis: genesis)) case .userIdentityAccess: String(localized: .Products.permissionBodyUserIdentityAccess) case .chatAuthority: String(localized: .Products.permissionBodyChatAuthority) case .profileDisclosure: String(localized: .Products.permissionBodyProfileDisclosure) @@ -57,6 +59,7 @@ extension ProductPermission { case .chainSubmitAccess: "link" case .preimageSubmitAccess: "doc.text" case .statementSubmitAccess: "text.bubble" + case .jamPeersAccess: "point.3.connected.trianglepath.dotted" case .userIdentityAccess: "person.text.rectangle" case .chatAuthority: "message.badge.shield" case .profileDisclosure: "person.crop.square" @@ -74,6 +77,7 @@ extension ProductPermission { case .chainSubmitAccess: String(localized: .Products.appPermissionChainSubmitTitle) case .preimageSubmitAccess: String(localized: .Products.appPermissionPreimageSubmitTitle) case .statementSubmitAccess: String(localized: .Products.appPermissionStatementSubmitTitle) + case .jamPeersAccess: String(localized: .Products.appPermissionJamPeersTitle) case .userIdentityAccess: String(localized: .Products.appPermissionUserIdentityTitle) case .chatAuthority: String(localized: .Products.appPermissionChatAuthorityTitle) case .profileDisclosure: String(localized: .Products.appPermissionProfileDisclosureTitle) diff --git a/hosts/ios/polkadot-appTests/Products/ProductPermissionRepositoryTests.swift b/hosts/ios/polkadot-appTests/Products/ProductPermissionRepositoryTests.swift index 751ef52875..b1833a84b7 100644 --- a/hosts/ios/polkadot-appTests/Products/ProductPermissionRepositoryTests.swift +++ b/hosts/ios/polkadot-appTests/Products/ProductPermissionRepositoryTests.swift @@ -67,6 +67,38 @@ struct ProductPermissionRepositoryTests { #expect(state == .notDetermined) } + @Test("JAM grants and revocation distinguish full genesis and product") + func jamGrantsAreScopedToFullGenesisAndProduct() async throws { + let sut = makeSUT() + let first = ProductPermission.jamPeersAccess( + genesis: "0x10c123f0" + String(repeating: "ab", count: 28) + ) + let second = ProductPermission.jamPeersAccess( + genesis: "0x10c123f0" + String(repeating: "cd", count: 28) + ) + + try await sut.grant(productId: "product-a", permission: first) + #expect(try await sut.getPermissionState( + productId: "product-a", permission: second + ) == .notDetermined) + #expect(try await sut.getPermissionState( + productId: "product-b", permission: first + ) == .notDetermined) + + try await sut.grant(productId: "product-a", permission: second) + let restored = try await sut.getAllByProduct(productId: "product-a") + #expect(restored.contains { $0.permission == first }) + #expect(restored.contains { $0.permission == second }) + + try await sut.revoke(productId: "product-a", permission: first) + #expect(try await sut.getPermissionState( + productId: "product-a", permission: first + ) == .notDetermined) + #expect(try await sut.getPermissionState( + productId: "product-a", permission: second + ) == .allowedAlways) + } + // MARK: - deny @Test("deny persists and getPermissionState returns denied") @@ -505,6 +537,16 @@ extension ProductPermissionRepositoryTests { try ProductPermission.networkAccess(domain: "https://example.com/path").authorizationRequest() } } + + @Test("JAM permission mapping preserves the full genesis and rejects short keys") + func canonicalJamPermissionMapping() throws { + let permission = ProductPermission.jamPeersAccess(genesis: "0x" + String(repeating: "ab", count: 32)) + let request = try #require(try permission.authorizationRequest()) + #expect(try ProductPermission.fromAuthorization(request) == [permission]) + #expect(throws: ProductPermissionMappingError.self) { + try ProductPermission.jamPeersAccess(genesis: "0xab").authorizationRequest() + } + } } @MainActor diff --git a/hosts/ios/polkadot-appTests/TrUAPI/RustRuntimeBridgeTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/RustRuntimeBridgeTests.swift index 46bbbb3347..93b97eb0c0 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/RustRuntimeBridgeTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/RustRuntimeBridgeTests.swift @@ -268,6 +268,24 @@ struct RustRuntimeBridgeTests { #expect(guard_.requestedBatchedPermissions == [.webRtcAccess]) } + /// `remotePermission`: JamPeers asks for access keyed by its genesis as + /// lowercase `0x` hex, so each network is granted separately. + @Test func remotePermissionJamPeers() async throws { + let guard_ = MockPermissionGuard() + let bridge = makeBridge(permissionGuard: guard_) + let genesis = Data([0x10, 0xC1, 0x23, 0xF0] + [UInt8](repeating: 0xAB, count: 28)) + + let result = try await bridge.remotePermission( + product: testProduct, + request: .jamPeers(genesis: genesis) + ) + + #expect(result == .allowAlways) + #expect(guard_.requestedBatchedPermissions == [ + .jamPeersAccess(genesis: "0x10c123f0" + String(repeating: "ab", count: 28)) + ]) + } + // MARK: pushNotification /// `pushNotification` maps text/deeplink/scheduledAt onto the scheduler diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 87d0effd8f..b9bed7a3a5 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -133,6 +133,16 @@ public protocol HostBridge: NativeChatFilesHost { /// Cancel a previously scheduled notification id. func cancelNotification(id: UInt32) throws + /// Resident bridge: current host scope even with products closed. + /// Product bridge: immutable verified artifact/account scope captured at execution creation. + func receiverAuthority(productId: String) async throws -> ReceivingAuthority? + /// Request receiving consent separately from OS notification permission. + func receiverConsent(authority: ReceivingAuthority, watches: [ReceivingWatch]) async throws -> Bool + /// Wake synchronization without waiting for a provider or network. + func receiverChanged() async throws + /// Forward to the sole receiving owner, or return nil to use the native engine. + func receiverCommand(productId: String, action: UInt8, payload: Data) async throws -> Data? + /// Non-consuming ordered batch (at most 32), bound to the verified execution. /// Must not enroll receiving or request notification permission. func activationEvents() async throws -> [NotificationActivation] @@ -410,6 +420,14 @@ public extension HostBridge { func onCoreLog(marker: String, detail: String) {} func pushNotification(request: HostPushNotificationRequest) async throws -> UInt32 { 0 } func cancelNotification(id: UInt32) throws {} + func receiverAuthority(productId: String) async throws -> ReceivingAuthority? { nil } + func receiverConsent(authority: ReceivingAuthority, watches: [ReceivingWatch]) async throws -> Bool { + throw HostRejection.Rejected(reason: "background receiving unsupported") + } + func receiverChanged() async throws { + throw HostRejection.Rejected(reason: "background receiving unsupported") + } + func receiverCommand(productId: String, action: UInt8, payload: Data) async throws -> Data? { nil } func activationEvents() async throws -> [NotificationActivation] { throw HostRejection.Rejected(reason: "notification activation unsupported") } @@ -735,6 +753,24 @@ private final class HostCallbackAdapter: HostCallbacks, @unchecked Sendable { } } + func receiverAuthority(productId: String) async throws -> ReceivingAuthority? { + try await withHostRejection { try await bridge.receiverAuthority(productId: productId) } + } + + func receiverConsent(authority: ReceivingAuthority, watches: [ReceivingWatch]) async throws -> Bool { + try await withHostRejection { try await bridge.receiverConsent(authority: authority, watches: watches) } + } + + func receiverChanged() async throws { + try await withHostRejection { try await bridge.receiverChanged() } + } + + func receiverCommand(productId: String, action: UInt8, payload: Data) async throws -> Data? { + try await withHostRejection { + try await bridge.receiverCommand(productId: productId, action: action, payload: payload) + } + } + func activationEvents() async throws -> [NotificationActivation] { try await withHostRejection { try await bridge.activationEvents() } } @@ -1174,6 +1210,62 @@ public final class TrUAPIHostRuntime: @unchecked Sendable { inner.disconnect() } + /// All durable registrations; inspect `syncPending` before synchronizing. + public func receivingPending() async throws -> [ReceivingRegistration] { + try await inner.receivingPending() + } + + public func receivingSynchronized(productId: String, revision: UInt64) async throws -> Bool { + try await inner.receivingSynchronized(productId: productId, revision: revision) + } + + public func receivingIngest( + productId: String, revision: UInt64, watchId: String, + actualGenesis: String, actualChannel: String, actualTopics: [String], frame: Data + ) async throws -> [ReceivingEvent] { + try await inner.receivingIngest(productId: productId, revision: revision, watchId: watchId, + actualGenesis: actualGenesis, actualChannel: actualChannel, actualTopics: actualTopics, frame: frame) + } + + public func receivingIngestStatement( + productId: String, revision: UInt64, watchId: String, + actualGenesis: String, statement: Data + ) async throws -> [ReceivingEvent] { + try await inner.receivingIngestStatement(productId: productId, revision: revision, watchId: watchId, + actualGenesis: actualGenesis, statement: statement) + } + + /// Reserve a display only after the core rechecks current authority and receipts. + public func receivingPrepareDisplay(productId: String, revision: UInt64, eventId: String) async throws -> ReceivingEvent? { + try await inner.receivingPrepareDisplay(productId: productId, revision: revision, eventId: eventId) + } + + /// Read-only authorization before opening a verified product, with sequence zero. + public func receivingValidateActivation(productId: String, revision: UInt64, eventId: String) async throws -> ReceivingEvent? { + try await inner.receivingValidateActivation(productId: productId, revision: revision, eventId: eventId) + } + + public func receivingConfirmDisplay(productId: String, revision: UInt64, eventId: String) async throws { + try await inner.receivingConfirmDisplay(productId: productId, revision: revision, eventId: eventId) + } + + /// Clear only an explicitly failed display; an unknown outcome remains pending. + public func receivingCancelDisplay(productId: String, revision: UInt64, eventId: String) async throws { + try await inner.receivingCancelDisplay(productId: productId, revision: revision, eventId: eventId) + } + + public func receivingActivate(productId: String, revision: UInt64, eventId: String) async throws -> ReceivingEvent? { + try await inner.receivingActivate(productId: productId, revision: revision, eventId: eventId) + } + + public func receivingRevoke(productId: String) async throws { + try await inner.receivingRevoke(productId: productId) + } + + public func receivingMarkTransportChanged(productId: String) async throws { + try await inner.receivingMarkTransportChanged(productId: productId) + } + /// Take one reference on the product's worker for a modality holder that /// is on screen or in flight. The first one reports `.start` to /// ``HostBridge/workerDemandChanged(productId:transition:)``, which is diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 599445046c..1a76fd1a9f 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -20,6 +20,8 @@ The package exposes tree-shakeable subpath exports — import only what your env | `@parity/truapi-host/testing/dev-accounts` | Named dev accounts derived from fixed BIP-39 entropy, which sign for real. | | `@parity/truapi-host/testing/host-page` | The browser half the fixture drives, for a suite that boots its own page. | | `@parity/truapi-host/wasm/testing` | The raw glue for the signing-enabled bundle the test host runs on. | +| `@parity/truapi-host/browser-receiving` | Trusted host-page client for the durable service-worker receiving owner. | +| `@parity/truapi-host/browser-receiving-worker` | Service-worker installation helper with an injected canonical WASM factory. | `scripts/build-wasm.mjs` builds two WASM bundles, both `--no-default-features`. `wasm/web` is the production browser host and excludes `WasmSigningHostRuntime`; `wasm/testing` adds the Rust `wasm-signing-host` and `test-host` features, @@ -122,6 +124,106 @@ Activation changes, disconnect, disposal and worker failure invalidate pending r seconds rather than leaving the caller pending; native read work may finish later, but cannot populate a replaced wallet. Shells must additionally fence their selected network/product context. +## Durable browser receiving + +Compose `installBrowserReceivingWorker` into the existing root-scoped host service +worker. It owns opaque canonical receiving ledger bytes in IndexedDB, serializes +core access with Web Locks across worker replacement, and uses the generic relay +v2 WebPush endpoints. Products never receive its authority registry, transport +credentials, or raw host hooks. + +Transport synchronization matches the complete product, account, environment, +artifact, genesis and generation scope. Disabled records from an older scope do +not revoke or block acknowledgement of a current enrollment. Explicit product +revocation and host logout still revoke every applicable enrollment. + +```ts +import init, { WasmNotificationReceiver } from "@parity/truapi-host/wasm/web"; +import { installBrowserReceivingWorker } from "@parity/truapi-host/browser-receiving-worker"; + +const ready = init({ + module_or_path: new URL("/receiving/truapi_server_bg.wasm", self.location.origin), +}); +installBrowserReceivingWorker({ + scope: self, + createReceiver: async callbacks => { + await ready; + return new WasmNotificationReceiver(callbacks); + }, + relayUrl: RECEIVING_RELAY_URL, + pushOrigin: self.location.origin, + hostEntryUrl: "/", + notificationTitle: "New activity", +}); +``` + +Build this entry with the host's existing service-worker build. A classic worker +can be bundled with `esbuild host-sw.ts --bundle --format=iife --platform=browser +--outfile=public/sw.js`; preserve existing cache/install/push handlers. Do not use +dynamic `import()` in a service worker. Copy the matching +`dist/wasm/web/truapi_server_bg.wasm` to the explicit URL above, permit that +same-origin asset and WASM compilation in CSP, and keep its cache revision tied +to the bundled glue. A standalone receiving artifact does not replace a host's +different PolkaVM runtime artifact. + +The page imports `createBrowserReceivingClient` from `/browser-receiving` with its +existing `ServiceWorkerRegistration`, a real host `consent(authority, watches)` +prompt, and an `activate(authority, event)` callback. Activation returns true +only after the exact verified product is ready in the correct unlocked account +and environment. It must never silently switch accounts or navigate `event.route` +as a URL. That route is an opaque product token. + +Call `setActiveAccount(account, environment, genesis)` only from the host's current, +authenticated account selection, before updating or binding product authority. +Fence asynchronous login callbacks and stale tabs before this call. Replacing +that scope durably revokes mismatched authorities, including products no longer +open. Passing `undefined` pauses authority without discarding consent; do not use +it for ordinary page/product closure, suspension, or a transient network outage. + +Read `getAuthority(productId)` to recover the durable generation. It returns the +canonical authority plus `revoked`; reuse a live matching scope's generation +across reloads, and increment it for account/artifact replacement or explicit +re-enrollment after revocation. Call `updateAuthority` only with host-verified +scope, then `bindExecution` with an immutable snapshot. Forward page-core +`receiverCommand` through that execution's `command(action, payload)`, checking +the callback product ID against the trusted execution closure. The worker calls +canonical `commandForExecution`, including its post-consent scope recheck. +Call execution `ready()` once the verified product is ready and `close()` on +suspension. Suspension/lock retains enrollment. Explicit logout/account removal +must await local `revokeAll()` before forgetting identity; it covers closed +products and retains durable remote-deletion intent. Use scoped `revoke(productId)` +for artifact replacement or removing one product. Neither waits for relay deletion. + +Call `enableWebPush(vapidPublicKey)` directly from a user gesture. Obtain the +trusted relay's VAPID key from `GET /v2/config`, not a product-provided URL. +`refresh()` refreshes the browser destination; online, push, worker activation +and supported background/periodic sync events retry durable transport work. +Network requests have a ten-second abort deadline and bounded responses; retry +backoff and pending deletion survive worker termination. Standard WebPush +subscriptions use `userVisibleOnly: true`. Invalid, stale, revoked or +foreground-receipted wakes never cause a fabricated notification. + +The core alone gates authenticated ingress, foreground receipts, reservations +and click activation. The worker confirms display only after `showNotification` +resolves and cancels reservations only on explicit display failure. Retained +v2 responses contain a carrier and actual source metadata, so this adapter uses +`receivingIngest`, not `receivingIngestStatement`. + +Republishing an identical watch snapshot under the same live authority preserves +its registration revision, synchronization state, and queued events. This lets +cold-start products restore their watches without invalidating the click that +opened them. Stale compare-and-swap tokens still fail; a changed policy, including +its activation route or expiry, advances the revision and fences old clicks. + +Relay watches retain each original core-consent expiry, at most 30 days, without +a separate 24-hour lease or dependence on periodic browser execution. Transport +rotation and retries never extend that expiry; renewal beyond it requires fresh +core consent. Event/header freshness remains independently bounded to 24 hours. +Browser background scheduling is not guaranteed. Unsupported Web Locks, WebPush, Ed25519 +WebCrypto, denied notification permission, or unavailable WASM are not simulated +as successful support. Physical page-closed delivery still requires a secure +origin, valid relay/VAPID configuration and browser/provider qualification. + ## Bundler requirements The worker imports the WASM glue by a literal specifier, so every bundler resolves it statically and emits diff --git a/js/packages/truapi-host/package.json b/js/packages/truapi-host/package.json index 0ac1d02560..621919851b 100644 --- a/js/packages/truapi-host/package.json +++ b/js/packages/truapi-host/package.json @@ -29,6 +29,14 @@ "types": "./dist/web/index.d.ts", "import": "./dist/web/index.js" }, + "./browser-receiving": { + "types": "./dist/browser-receiving.d.ts", + "import": "./dist/browser-receiving.js" + }, + "./browser-receiving-worker": { + "types": "./dist/browser-receiving-worker.d.ts", + "import": "./dist/browser-receiving-worker.js" + }, "./testing": { "types": "./dist/testing.d.ts", "import": "./dist/testing.js" diff --git a/js/packages/truapi-host/src/browser-receiving-transport.test.ts b/js/packages/truapi-host/src/browser-receiving-transport.test.ts new file mode 100644 index 0000000000..0401550864 --- /dev/null +++ b/js/packages/truapi-host/src/browser-receiving-transport.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { BrowserReceivingTransport, createReceiverEnrollment } from "./browser-receiving-transport.js"; +import type { ReceivingAuthority } from "./runtime.js"; + +const originalFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = originalFetch; }); + +const authority: ReceivingAuthority = { + productId: "receiver.test", account: "11".repeat(32), environment: "test", + artifact: "22".repeat(32), genesis: "33".repeat(32), generation: 4n, + osPermission: true, transportReady: true, +}; + +const decodeHex = (value: string) => Uint8Array.from(value.match(/../g)!, byte => Number.parseInt(byte, 16)); + +describe("browser receiving relay v2 transport", () => { + it("preserves the original core-consent expiry beyond 24 hours without extending it", async () => { + const enrollment = await createReceiverEnrollment(authority); + enrollment.relayRevision = 7; + enrollment.coreRevision = 9007199254740993n; + const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000; + enrollment.routes = { watch: "44".repeat(32) }; + let request: Request | undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + request = new Request(input, init); + return new Response(JSON.stringify({ ok: true })); + }) as typeof fetch; + const transport = new BrowserReceivingTransport("https://relay.test", "https://host.test"); + await transport.register(enrollment, { + authority, revision: enrollment.coreRevision, enabled: true, syncPending: true, + watches: [{ id: "watch", genesis: authority.genesis, channel: "55".repeat(32), topics: ["66".repeat(32)], + senders: ["88".repeat(32), "77".repeat(32)], expiresAt: BigInt(expiresAt), + mutedUntil: 18446744073709551615n, route: "private-product-route" }], + }, { endpoint: "https://push.test/subscription", keys: { auth: "auth", p256dh: "p256dh" } }); + expect(request!.url).toBe(`https://relay.test/v2/receivers/${enrollment.deviceId}`); + expect(request!.headers.get("authorization")).toBe(`Bearer ${enrollment.secret}`); + const wire = await request!.json(); + expect(wire.revision).toBe(7); + expect(wire.watches[0].expiresAt).toBe(expiresAt); + expect(wire.watches[0].mutedUntil).toBe(Number.MAX_SAFE_INTEGER); + expect(wire.watches[0].senderKeys).toEqual(["77".repeat(32), "88".repeat(32)]); + expect(wire.watches[0].routeToken).toBe(enrollment.routes.watch); + const serialized = JSON.stringify(wire); + for (const secret of [authority.account, authority.artifact, "private-product-route", enrollment.coreRevision.toString()]) { + expect(serialized.includes(secret)).toBe(false); + } + const publicKey = await crypto.subtle.importKey("raw", decodeHex(wire.binding.ownerKey), "Ed25519", false, ["verify"]); + expect(await crypto.subtle.verify("Ed25519", publicKey, decodeHex(wire.binding.signature), new TextEncoder().encode(JSON.stringify([ + "truapi:receiver-binding:v2", 2, authority.productId, "https://host.test", enrollment.deviceId, + enrollment.ownerKey, wire.binding.issuedAt, + ])))).toBe(true); + }); + + it("decodes retained frames only for the exact transport revision", async () => { + const enrollment = await createReceiverEnrollment(authority); + enrollment.relayRevision = 8; + let revision = 8; + globalThis.fetch = (async () => Response.json({ v: 2, revision, watchId: "watch", + genesis: authority.genesis, channel: "55".repeat(32), topics: ["66".repeat(32)], frame: "AQID" })) as typeof fetch; + const transport = new BrowserReceivingTransport("https://relay.test", "https://host.test"); + expect((await transport.event(enrollment, "event")).frame).toEqual(new Uint8Array([1, 2, 3])); + revision = 9; + await expect(transport.event(enrollment, "event")).rejects.toThrow("invalid retained receiving event"); + }); + + it("revokes with the transport revision and bearer, never core revision", async () => { + const enrollment = await createReceiverEnrollment(authority); + enrollment.relayRevision = 10; + enrollment.coreRevision = 1000n; + let request: Request | undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + request = new Request(input, init); + return new Response(null, { status: 204 }); + }) as typeof fetch; + await new BrowserReceivingTransport("https://relay.test", "https://host.test").revoke(enrollment); + expect(request!.method).toBe("DELETE"); + expect(await request!.json()).toEqual({ revision: 10 }); + }); + + it("rejects nonlocal plaintext relays and oversized retained responses", async () => { + expect(() => new BrowserReceivingTransport("http://relay.test", "https://host.test")).toThrow("requires HTTPS"); + const enrollment = await createReceiverEnrollment(authority); + globalThis.fetch = (async () => new Response("x".repeat(384 * 1024 + 1))) as typeof fetch; + await expect(new BrowserReceivingTransport("https://relay.test", "https://host.test").event(enrollment, "event")) + .rejects.toThrow("oversized receiving relay response"); + }); +}); diff --git a/js/packages/truapi-host/src/browser-receiving-transport.ts b/js/packages/truapi-host/src/browser-receiving-transport.ts new file mode 100644 index 0000000000..64fd81b30f --- /dev/null +++ b/js/packages/truapi-host/src/browser-receiving-transport.ts @@ -0,0 +1,115 @@ +import type { ReceivingAuthority, ReceivingRegistration } from "./runtime.js"; + +const hex = (bytes: ArrayBuffer | Uint8Array): string => + Array.from(new Uint8Array(bytes instanceof Uint8Array ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) : bytes), byte => byte.toString(16).padStart(2, "0")).join(""); +export const randomReceiverToken = (): string => hex(crypto.getRandomValues(new Uint8Array(32))); + +/** Host-private transport credentials. Never expose these records to products. */ +export interface BrowserReceiverEnrollment { + authority: ReceivingAuthority; + deviceId: string; + secret: string; + ownerKey: string; + privateKey: CryptoKey; + relayRevision: number; + coreRevision: bigint; + routes: Record; + acknowledged: boolean; + revoked: boolean; +} + +export async function createReceiverEnrollment(authority: ReceivingAuthority): Promise { + const keys = await crypto.subtle.generateKey({ name: "Ed25519" }, false, ["sign", "verify"]) as CryptoKeyPair; + const secret = randomReceiverToken(); + const secretBytes = Uint8Array.from(secret.match(/../g)!, value => Number.parseInt(value, 16)); + return { + authority, secret, privateKey: keys.privateKey, + deviceId: hex(await crypto.subtle.digest("SHA-256", secretBytes)), + ownerKey: hex(await crypto.subtle.exportKey("raw", keys.publicKey)), + relayRevision: 0, coreRevision: 0n, routes: {}, + acknowledged: false, revoked: false, + }; +} + +/** The configured relay is trusted transport, never authority for app content. */ +export class BrowserReceivingTransport { + private readonly base: URL; + constructor(relayUrl: string, private readonly origin: string) { + this.base = new URL(relayUrl); + if (this.base.protocol !== "https:" && !(this.base.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(this.base.hostname))) { + throw new Error("receiving relay requires HTTPS"); + } + if (this.base.username || this.base.password || this.base.search || this.base.hash) throw new Error("invalid receiving relay URL"); + } + + private async request(path: string, enrollment: BrowserReceiverEnrollment, method: string, body?: unknown): Promise { + const controller = new AbortController(); + const deadline = setTimeout(() => controller.abort(), 10_000); + try { + const response = await fetch(new URL(`${this.base.pathname.replace(/\/$/, "")}${path}`, this.base.origin), { + method, credentials: "omit", redirect: "error", cache: "no-store", signal: controller.signal, + headers: { Authorization: `Bearer ${enrollment.secret}`, "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!response.ok) throw new Error(`receiving relay HTTP ${response.status}`); + if (method !== "GET") { await response.body?.cancel(); return undefined; } + const reader = response.body?.getReader(); + if (!reader) throw new Error("empty receiving relay response"); + const parts: Uint8Array[] = []; + let size = 0; + while (true) { + const part = await reader.read(); + if (part.done) break; + size += part.value.byteLength; + if (size > 384 * 1024) { await reader.cancel(); throw new Error("oversized receiving relay response"); } + parts.push(part.value); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const part of parts) { bytes.set(part, offset); offset += part.length; } + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } finally { clearTimeout(deadline); } + } + + async register(enrollment: BrowserReceiverEnrollment, registration: ReceivingRegistration, destination: PushSubscriptionJSON): Promise { + const issuedAt = Date.now(); + const { productId: product } = enrollment.authority; + const { deviceId, ownerKey } = enrollment; + const tuple = ["truapi:receiver-binding:v2", 2, product, this.origin, deviceId, ownerKey, issuedAt]; + const signature = hex(await crypto.subtle.sign("Ed25519", enrollment.privateKey, new TextEncoder().encode(JSON.stringify(tuple)))); + await this.request(`/v2/receivers/${deviceId}`, enrollment, "PUT", { + binding: { v: 2, product, origin: this.origin, deviceId, ownerKey, issuedAt, signature }, + destination: { endpoint: destination.endpoint, keys: destination.keys }, + revision: enrollment.relayRevision, enabled: true, + watches: registration.watches.filter(watch => watch.expiresAt > BigInt(issuedAt)).map(watch => ({ + watchId: watch.id, genesis: watch.genesis, channel: watch.channel, topics: watch.topics, + senderKeys: [...watch.senders].sort(), + expiresAt: Number(watch.expiresAt), + mutedUntil: Number(watch.mutedUntil > BigInt(Number.MAX_SAFE_INTEGER) ? BigInt(Number.MAX_SAFE_INTEGER) : watch.mutedUntil), + routeToken: enrollment.routes[watch.id], + })), + }); + } + + async revoke(enrollment: BrowserReceiverEnrollment): Promise { + await this.request(`/v2/receivers/${enrollment.deviceId}`, enrollment, "DELETE", { revision: enrollment.relayRevision }); + } + + async event(enrollment: BrowserReceiverEnrollment, eventId: string): Promise<{ + revision: number; watchId: string; genesis: string; channel: string; topics: string[]; frame: Uint8Array; + }> { + if (!/^[A-Za-z0-9._:-]{1,256}$/.test(eventId)) throw new Error("invalid receiver event ID"); + const value = await this.request(`/v2/receivers/${enrollment.deviceId}/events/${encodeURIComponent(eventId)}`, enrollment, "GET") as Record; + if (!value || value.v !== 2 || value.revision !== enrollment.relayRevision || typeof value.watchId !== "string" || + typeof value.genesis !== "string" || typeof value.channel !== "string" || !Array.isArray(value.topics) || + value.topics.length > 4 || !value.topics.every(topic => typeof topic === "string" && /^[a-f0-9]{64}$/.test(topic)) || + !/^[a-f0-9]{64}$/.test(value.genesis) || !/^[a-f0-9]{64}$/.test(value.channel) || + typeof value.frame !== "string" || value.frame.length > 349528 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value.frame)) { + throw new Error("invalid retained receiving event"); + } + const frame = Uint8Array.from(atob(value.frame), value => value.charCodeAt(0)); + if (frame.length > 256 * 1024) throw new Error("oversized receiving frame"); + return { revision: value.revision as number, watchId: value.watchId, genesis: value.genesis, + channel: value.channel, topics: value.topics as string[], frame }; + } +} diff --git a/js/packages/truapi-host/src/browser-receiving-worker.ts b/js/packages/truapi-host/src/browser-receiving-worker.ts new file mode 100644 index 0000000000..fb5341259f --- /dev/null +++ b/js/packages/truapi-host/src/browser-receiving-worker.ts @@ -0,0 +1,565 @@ +import type { ReceivingEvent, ReceivingWatch } from "@parity/truapi"; +import { + ReceivingAuthority, createNotificationReceiverCallbacks, + receivingRegistrationsCodec, receivingEventsCodec, receivingEventCodec, + type RawNotificationReceiver, type ReceivingRegistration, +} from "./runtime.js"; +import { + BrowserReceivingTransport, createReceiverEnrollment, randomReceiverToken, + type BrowserReceiverEnrollment, +} from "./browser-receiving-transport.js"; + +export interface BrowserReceivingWorkerOptions { + scope: ServiceWorkerGlobalScope; + createReceiver(callbacks: { + receiverAuthority(productId: string): Promise; + receiverConsent(authority: Uint8Array, watches: Uint8Array): Promise; + receiverChanged(): Promise; + readReceivingState(): Promise; + writeReceivingState(bytes: Uint8Array): Promise; + }): Promise | RawNotificationReceiver; + relayUrl: string; + /** Must equal the relay's configured PUSH_ORIGIN, not a product URL. */ + pushOrigin: string; + /** Fixed trusted same-origin host entry. Routes in events are never navigated. */ + hostEntryUrl: string; + notificationTitle: string; + databaseName?: string; +} + +interface AuthorityEntry { authority: ReceivingAuthority; revoked: boolean } +interface AccountScope { account: string; environment: string; genesis: string } +interface Execution { authority: ReceivingAuthority; clientId: string; ready: boolean } +interface Activation { authority: ReceivingAuthority; revision: bigint; eventId: string; expiresAt: bigint } +interface Wake { v: 2; deviceId: string; routeToken: string; messageId: string; revision: number } +interface SyncRegistration { enrollment: BrowserReceiverEnrollment; registration?: ReceivingRegistration } + +function sameScope(left: ReceivingAuthority, right: ReceivingAuthority): boolean { + return left.productId === right.productId && left.account === right.account && + left.environment === right.environment && left.artifact === right.artifact && + left.genesis === right.genesis && left.generation === right.generation; +} + +function sameAccount(scope: AccountScope, authority: ReceivingAuthority): boolean { + return scope.account === authority.account && scope.environment === authority.environment && scope.genesis === authority.genesis; +} + +class ReceiverDatabase { + private readonly opened: Promise; + constructor(name: string) { + this.opened = new Promise((resolve, reject) => { + const request = indexedDB.open(name, 1); + request.onupgradeneeded = () => request.result.createObjectStore("receiver"); + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error("receiving database upgrade blocked")); + request.onsuccess = () => { + request.result.onversionchange = () => request.result.close(); + resolve(request.result); + }; + }); + } + async get(key: string): Promise { + const database = await this.opened; + return new Promise((resolve, reject) => { + const transaction = database.transaction("receiver", "readonly"); + const request = transaction.objectStore("receiver").get(key); + transaction.oncomplete = () => resolve(request.result as T | undefined); + transaction.onabort = () => reject(transaction.error); + transaction.onerror = () => reject(transaction.error); + }); + } + async update(key: string, change: (previous: T | undefined) => T | undefined): Promise { + const database = await this.opened; + return new Promise((resolve, reject) => { + const transaction = database.transaction("receiver", "readwrite"); + const store = transaction.objectStore("receiver"); + const request = store.get(key); + let failure: unknown; + request.onsuccess = () => { + try { + const next = change(request.result as T | undefined); + if (next === undefined) store.delete(key); else store.put(next, key); + } catch (error) { failure = error; transaction.abort(); } + }; + transaction.oncomplete = () => resolve(); + transaction.onabort = () => reject(failure ?? transaction.error); + transaction.onerror = () => reject(transaction.error); + }); + } + put(key: string, value: T): Promise { return this.update(key, () => value); } + async entries(prefix: string): Promise> { + const database = await this.opened; + return new Promise((resolve, reject) => { + const transaction = database.transaction("receiver", "readonly"); + const request = transaction.objectStore("receiver").openCursor(IDBKeyRange.bound(prefix, `${prefix}\uffff`)); + const values: Array<[string, T]> = []; + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) return; + values.push([String(cursor.key), cursor.value as T]); + cursor.continue(); + }; + transaction.oncomplete = () => resolve(values); + transaction.onabort = () => reject(transaction.error); + transaction.onerror = () => reject(transaction.error); + }); + } +} + +/** Compose into the host's existing service worker. Installs no competing worker. */ +export function installBrowserReceivingWorker(options: BrowserReceivingWorkerOptions): void { + const { scope } = options; + const entryUrl = new URL(options.hostEntryUrl, scope.location.origin); + if (entryUrl.origin !== scope.location.origin || entryUrl.username || entryUrl.password) throw new Error("receiving host entry must be same-origin"); + if (options.pushOrigin !== scope.location.origin) throw new Error("receiving origin must match the trusted host"); + if (!scope.navigator.locks) throw new Error("durable receiving requires Web Locks"); + const databaseName = options.databaseName ?? "truapi-browser-receiving"; + const database = new ReceiverDatabase(databaseName); + const transport = new BrowserReceivingTransport(options.relayUrl, options.pushOrigin); + const executions = new Map(); + const activating = new Set(); + let synchronization: Promise | undefined; + + async function askHost(clientId: string, operation: "consent" | "activate", authority: ReceivingAuthority, value: ReceivingWatch[] | ReceivingEvent): Promise { + const client = await scope.clients.get(clientId); + if (!client || client.type !== "window" || new URL(client.url).origin !== scope.location.origin || (client as WindowClient).frameType !== "top-level") return false; + const channel = new MessageChannel(); + return new Promise(resolve => { + const timer = setTimeout(() => finish(false), 25_000); + function finish(value: boolean) { clearTimeout(timer); channel.port1.close(); resolve(value); } + channel.port1.onmessage = event => finish(event.data === true); + channel.port1.onmessageerror = () => finish(false); + client.postMessage({ type: "truapi:receiving-host", operation, authority, + ...(operation === "consent" ? { watches: value } : { event: value }) }, [channel.port2]); + }); + } + + async function liveAuthority(product: string): Promise { + const entry = await database.get(`authority:${product}`); + if (!entry || entry.revoked) return undefined; + const account = await database.get("activeAccount"); + if (!account || !sameAccount(account, entry.authority)) return undefined; + const destination = await database.get("destination"); + return { ...entry.authority, osPermission: Notification.permission === "granted", + transportReady: Boolean(destination?.endpoint) }; + } + + // Reconstruct for every exclusive lease: a replaced worker must never use a stale in-memory ledger. + async function withCore(operation: (receiver: RawNotificationReceiver) => Promise, consentClient?: string, executionLive?: () => boolean): Promise { + const abort = new AbortController(); + const timer = setTimeout(() => abort.abort(), 40_000); + try { + return await scope.navigator.locks.request(`${databaseName}:writer`, { signal: abort.signal }, async () => { + clearTimeout(timer); + const receiver = await options.createReceiver(createNotificationReceiverCallbacks({ + receiverAuthority: async product => { + if (executionLive && !executionLive()) return undefined; + const authority = await liveAuthority(product); + return executionLive && !executionLive() ? undefined : authority; + }, + receiverConsent: (authority, watches) => consentClient && (!executionLive || executionLive()) + ? askHost(consentClient, "consent", authority, watches) : false, + receiverChanged: () => database.put("syncRequested", true), + readReceivingState: () => database.get("ledger"), + writeReceivingState: bytes => database.put("ledger", bytes), + })); + try { return await operation(receiver); } finally { receiver.free(); } + }); + } finally { clearTimeout(timer); } + } + + async function queueRetry(): Promise { + await database.put("syncRequested", true); + const registration = scope.registration as ServiceWorkerRegistration & { sync?: { register(tag: string): Promise } }; + await registration.sync?.register("truapi:receiving").catch(() => {}); + const periodic = scope.registration as ServiceWorkerRegistration & { periodicSync?: { register(tag: string, options: { minInterval: number }): Promise } }; + await periodic.periodicSync?.register("truapi:receiving", { minInterval: 12 * 60 * 60 * 1000 }).catch(() => {}); + } + + async function revokeEnrollments(product: string, authority?: ReceivingAuthority): Promise { + for (const [key, enrollment] of await database.entries("enrollment:")) { + if (enrollment.authority.productId !== product || enrollment.revoked) continue; + if (authority && !sameScope(enrollment.authority, authority)) continue; + await database.put(key, { ...enrollment, revoked: true, acknowledged: false, relayRevision: enrollment.relayRevision + 1 }); + } + } + + async function refreshDestination(): Promise { + const subscription = await scope.registration.pushManager.getSubscription(); + const destination = subscription?.toJSON(); + const prior = await database.get("destination"); + if (JSON.stringify(destination) === JSON.stringify(prior)) return; + await withCore(async receiver => { + await database.put("destination", destination); + for (const registration of receivingRegistrationsCodec.dec(await receiver.receivingPending())) { + if (registration.enabled) await receiver.receivingMarkTransportChanged(registration.authority.productId); + } + }); + } + + async function prepareSync(): Promise { + return withCore(async receiver => { + const registrations = receivingRegistrationsCodec.dec(await receiver.receivingPending()); + for (const [, enrollment] of await database.entries("enrollment:")) { + const authority = await database.get(`authority:${enrollment.authority.productId}`); + if (!enrollment.revoked && (authority?.revoked || (authority && !sameScope(authority.authority, enrollment.authority)))) { + await database.put(`enrollment:${enrollment.deviceId}`, { + ...enrollment, revoked: true, acknowledged: false, relayRevision: enrollment.relayRevision + 1, + }); + } + } + const destination = await database.get("destination"); + const pending: SyncRegistration[] = []; + for (const registration of registrations) { + const product = registration.authority.productId; + if (!registration.enabled) { + await revokeEnrollments(product, registration.authority); + const existing = (await database.entries("enrollment:")).some(([, item]) => sameScope(item.authority, registration.authority)); + if (!existing && registration.syncPending) await receiver.receivingSynchronized(product, registration.revision); + continue; + } + if (!destination?.endpoint) continue; + let enrollment = (await database.entries("enrollment:")) + .map(([, value]) => value).find(value => !value.revoked && sameScope(value.authority, registration.authority)); + if (!enrollment) enrollment = await createReceiverEnrollment(registration.authority); + if (!enrollment.acknowledged || registration.syncPending || enrollment.coreRevision !== registration.revision) { + if (enrollment.acknowledged || enrollment.relayRevision === 0 || enrollment.coreRevision !== registration.revision) { + enrollment.relayRevision++; + if (!Number.isSafeInteger(enrollment.relayRevision)) throw new Error("relay revision exhausted"); + enrollment.coreRevision = registration.revision; + enrollment.routes = Object.fromEntries(registration.watches.map(watch => [watch.id, enrollment!.routes[watch.id] ?? randomReceiverToken()])); + } + enrollment.acknowledged = false; + await database.put(`enrollment:${enrollment.deviceId}`, enrollment); + pending.push({ enrollment, registration }); + } + } + for (const [, enrollment] of await database.entries("enrollment:")) { + if (enrollment.revoked && !enrollment.acknowledged) pending.push({ enrollment }); + } + return pending; + }); + } + + function synchronize(): Promise { + if (synchronization) return synchronization; + synchronization = scope.navigator.locks.request(`${databaseName}:relay`, async () => { + const retry = await database.get<{ attempts: number; after: number }>("retry"); + if (retry && retry.after > Date.now()) { await queueRetry(); return; } + const deadline = Date.now() + 25_000; + await database.put("syncRequested", false); + const jobs = await prepareSync(); + let failed = false; + for (const { enrollment, registration } of jobs) { + if (Date.now() >= deadline) { failed = true; break; } + try { + if (enrollment.revoked) await transport.revoke(enrollment); + else { + const authority = await liveAuthority(enrollment.authority.productId); + const current = await database.get(`enrollment:${enrollment.deviceId}`); + if (!authority || !sameScope(authority, enrollment.authority) || !current || current.revoked || current.relayRevision !== enrollment.relayRevision) continue; + const destination = await database.get("destination"); + if (!destination || !registration) continue; + await transport.register(enrollment, registration, destination); + } + await withCore(async receiver => { + const current = await database.get(`enrollment:${enrollment.deviceId}`); + if (!current || current.relayRevision !== enrollment.relayRevision || current.revoked !== enrollment.revoked) return; + await database.put(`enrollment:${enrollment.deviceId}`, enrollment.revoked ? undefined : { ...current, acknowledged: true }); + const pending = receivingRegistrationsCodec.dec(await receiver.receivingPending()) + .find(value => sameScope(value.authority, enrollment.authority)); + const deletionPending = (await database.entries("enrollment:")) + .some(([, value]) => sameScope(value.authority, enrollment.authority) && value.revoked && !value.acknowledged); + if (pending && pending.enabled === !enrollment.revoked && + (enrollment.revoked ? !deletionPending : pending.revision === enrollment.coreRevision && sameScope(pending.authority, enrollment.authority))) { + await receiver.receivingSynchronized(pending.authority.productId, pending.revision); + } + }); + } catch { failed = true; } + } + for (const [key, pending] of await database.entries<{ wake: Wake; receivedAt: number }>("wake:")) { + if (Date.now() >= deadline) { failed = true; break; } + try { + if (pending.receivedAt + 60 * 60 * 1000 > Date.now()) await receivePush(pending.wake); + await database.put(key, undefined); + } catch { failed = true; } + } + if (failed) { + const attempts = Math.min((retry?.attempts ?? 0) + 1, 8); + await database.put("retry", { attempts, after: Date.now() + Math.min(30_000 * 2 ** (attempts - 1), 60 * 60 * 1000) }); + } else await database.put("retry", undefined); + if (failed || await database.get("syncRequested")) await queueRetry(); + const periodic = scope.registration as ServiceWorkerRegistration & { periodicSync?: { register(tag: string, options: { minInterval: number }): Promise } }; + await periodic.periodicSync?.register("truapi:receiving", { minInterval: 12 * 60 * 60 * 1000 }).catch(() => {}); + }).catch(async () => { await queueRetry(); }).finally(() => { synchronization = undefined; }); + return synchronization; + } + + async function trustedClient(event: ExtendableMessageEvent): Promise { + if (!event.source || !("id" in event.source)) throw new Error("receiving requires a host window"); + const client = await scope.clients.get(event.source.id); + if (!client || client.type !== "window" || new URL(client.url).origin !== scope.location.origin || (client as WindowClient).frameType !== "top-level") { + throw new Error("untrusted receiving message source"); + } + return client as WindowClient; + } + + function boundExecution(id: string, clientId: string): Execution { + const execution = executions.get(id); + if (!execution || execution.clientId !== clientId) throw new Error("receiving execution unavailable; bind again after worker restart"); + return execution; + } + + async function deliverActivation(key: string, activation: Activation, clientId: string): Promise { + if (activating.has(key)) return; + activating.add(key); + try { + if (activation.expiresAt <= BigInt(Date.now())) { await database.put(key, undefined); return; } + const preview = await withCore(async receiver => receivingEventCodec.dec(await receiver.receivingValidateActivation( + activation.authority.productId, activation.revision, activation.eventId))); + if (!preview) { await database.put(key, undefined); return; } + const authority = await liveAuthority(activation.authority.productId); + if (!authority || !sameScope(authority, activation.authority)) return; + if (!await askHost(clientId, "activate", activation.authority, preview)) return; + const executionLive = () => { + for (const execution of executions.values()) { + if (execution.ready && execution.clientId === clientId && sameScope(execution.authority, activation.authority)) return true; + } + return false; + }; + await withCore(async receiver => { + const current = await liveAuthority(activation.authority.productId); + if (!current || !sameScope(current, activation.authority)) return; + if (!executionLive()) return; + const event = receivingEventCodec.dec(await receiver.receivingActivate(activation.authority.productId, activation.revision, activation.eventId)); + if (event) await database.put(key, undefined); + }, undefined, executionLive); + } finally { activating.delete(key); } + } + + async function dispatch(event: ExtendableMessageEvent): Promise { + const client = await trustedClient(event); + const { operation, value } = event.data; + switch (operation) { + case "getAuthority": { + const entry = await database.get(`authority:${String(value)}`); + return entry ? { ...entry.authority, revoked: entry.revoked } : undefined; + } + case "activeAccount": { + if (!value || (value.account !== undefined && (typeof value.account !== "string" || !/^[0-9a-f]{64}$/.test(value.account))) || + typeof value.environment !== "string" || value.environment.length < 1 || value.environment.length > 128 || + typeof value.genesis !== "string" || !/^[0-9a-f]{64}$/.test(value.genesis)) throw new Error("invalid receiving account scope"); + const account: AccountScope | undefined = value.account === undefined ? undefined + : { account: value.account, environment: value.environment, genesis: value.genesis }; + // Commit this fence without waiting behind an outstanding consent prompt. + await database.put("activeAccount", account); + for (const [id, execution] of executions) { + if (!account || !sameAccount(account, execution.authority)) executions.delete(id); + } + if (account) { + for (const [key, entry] of await database.entries("authority:")) { + if (entry.revoked || sameAccount(account, entry.authority)) continue; + await database.update(key, current => current && !sameAccount(account, current.authority) + ? { ...current, revoked: true } : current); + await withCore(async receiver => { + const current = await database.get(key); + if (!current?.revoked) return; + await receiver.receivingRevoke(entry.authority.productId); + await revokeEnrollments(entry.authority.productId); + }); + } + } + return; + } + case "revokeAll": { + // Durable host-global fence comes first; absent products are included. + await database.put("activeAccount", undefined); + executions.clear(); + const entries = await database.entries("authority:"); + for (const [key] of entries) { + await database.update(key, current => current ? { ...current, revoked: true } : current); + } + await withCore(async receiver => { + for (const [, entry] of entries) { + await receiver.receivingRevoke(entry.authority.productId); + await revokeEnrollments(entry.authority.productId); + } + }); + return; + } + case "authority": { + const authority = ReceivingAuthority.dec(ReceivingAuthority.enc(value)); + const account = await database.get("activeAccount"); + if (!account || !sameAccount(account, authority)) throw new Error("receiving account is not active"); + let replaced = false; + // This transaction deliberately does not wait behind a consent prompt. Core rechecks it after consent. + await database.update(`authority:${authority.productId}`, previous => { + if (previous && (authority.generation < previous.authority.generation || + ((!sameScope(previous.authority, authority) || previous.revoked) && authority.generation <= previous.authority.generation))) { + throw new Error("stale receiving authority generation"); + } + replaced = Boolean(previous && (!sameScope(previous.authority, authority) || previous.revoked)); + return { authority, revoked: false }; + }); + if (replaced) await withCore(async receiver => { + await receiver.receivingRevoke(authority.productId); + await revokeEnrollments(authority.productId); + }); + return; + } + case "revoke": { + const product = String(value); + await database.update(`authority:${product}`, previous => previous ? { ...previous, revoked: true } : undefined); + await withCore(async receiver => { await receiver.receivingRevoke(product); await revokeEnrollments(product); }); + for (const [id, execution] of executions) if (execution.authority.productId === product) executions.delete(id); + return; + } + case "bind": { + const authority = ReceivingAuthority.dec(ReceivingAuthority.enc(value)); + const current = await liveAuthority(authority.productId); + if (!current || !sameScope(current, authority)) throw new Error("receiving authority mismatch"); + if (executions.size >= 512) { + for (const [id, execution] of executions) if (!await scope.clients.get(execution.clientId)) executions.delete(id); + if (executions.size >= 512) throw new Error("receiving execution capacity"); + } + const id = randomReceiverToken(); + executions.set(id, { authority, clientId: client.id, ready: false }); + return id; + } + case "command": { + const execution = boundExecution(value.id, client.id); + if (!Number.isInteger(value.action) || value.action < 2 || value.action > 7 || !(value.payload instanceof Uint8Array) || value.payload.byteLength > 2 * 1024 * 1024) throw new Error("invalid receiving command"); + return withCore(receiver => receiver.commandForExecution(ReceivingAuthority.enc(execution.authority), value.action, value.payload), + client.id, () => executions.get(value.id) === execution); + } + case "unbind": boundExecution(value.id, client.id); executions.delete(value.id); return; + case "ready": { + const execution = boundExecution(value.id, client.id); + const authority = await liveAuthority(execution.authority.productId); + if (!authority || !sameScope(authority, execution.authority)) throw new Error("receiving execution expired"); + execution.ready = true; + event.waitUntil((async () => { + for (const [key, activation] of await database.entries("activation:")) { + if (sameScope(activation.authority, execution.authority)) await deliverActivation(key, activation, client.id); + } + })()); + return; + } + case "refresh": await refreshDestination(); return; + default: throw new Error("unknown receiving operation"); + } + } + + scope.addEventListener("message", event => { + if (event.data?.type !== "truapi:receiving" || !event.ports[0]) return; + const port = event.ports[0]; + event.waitUntil(dispatch(event).then(value => port.postMessage({ ok: true, value }), error => { + port.postMessage({ ok: false, error: error instanceof Error ? error.message : "receiving operation failed" }); + }).finally(() => port.close()).then(() => synchronize())); + }); + + async function receivePush(wake: Wake): Promise { + if (wake.v !== 2 || !/^[a-f0-9]{64}$/.test(wake.deviceId) || !/^[a-f0-9]{64}$/.test(wake.routeToken) || !Number.isSafeInteger(wake.revision)) return; + const enrollment = await database.get(`enrollment:${wake.deviceId}`); + if (!enrollment || enrollment.revoked || enrollment.relayRevision !== wake.revision) return; + const product = enrollment.authority.productId; + const authority = await liveAuthority(product); + if (!authority || !sameScope(authority, enrollment.authority)) return; + const retained = await transport.event(enrollment, wake.messageId); + if (enrollment.routes[retained.watchId] !== wake.routeToken) return; + const events = await withCore(async receiver => { + const current = await database.get(`enrollment:${wake.deviceId}`); + if (!current || current.revoked || current.relayRevision !== wake.revision) return []; + return receivingEventsCodec.dec(await receiver.receivingIngest(product, enrollment.coreRevision, + retained.watchId, retained.genesis, retained.channel, retained.topics, retained.frame)); + }); + // The relay supplies the authenticated frame plus actual source metadata, not a SCALE statement. + // Other signed siblings may become durable events, but this wake may display only its exact event. + const accepted = events.find(event => event.eventId === wake.messageId); + // A retry can find an already-ingested event whose prior OS display explicitly failed. + const eventId = accepted?.eventId ?? wake.messageId; + await new Promise(resolve => setTimeout(resolve, 2100)); + await withCore(async receiver => { + const display = receivingEventCodec.dec(await receiver.receivingPrepareDisplay(product, enrollment.coreRevision, eventId)); + if (!display) return; + const current = await liveAuthority(product); + if (!current || !sameScope(current, enrollment.authority) || !current.osPermission) return; + try { + await scope.registration.showNotification(options.notificationTitle, { + body: "New activity", tag: `truapi:${wake.deviceId}:${eventId}`, + data: { type: "truapi:receiving", authority: enrollment.authority, revision: enrollment.coreRevision.toString(), eventId }, + }); + } catch (error) { + await receiver.receivingCancelDisplay(product, enrollment.coreRevision, eventId); + throw error; + } + await receiver.receivingConfirmDisplay(product, enrollment.coreRevision, eventId); + }); + } + + scope.addEventListener("push", event => { + if (!event.data) return; + let wake: Wake; + try { if (event.data.text().length > 4096) return; wake = event.data.json() as Wake; } catch { return; } + if (wake?.v !== 2) return; + event.waitUntil((async () => { + if (!/^[a-f0-9]{64}$/.test(wake.deviceId) || typeof wake.messageId !== "string" || !/^[A-Za-z0-9._:-]{1,256}$/.test(wake.messageId)) return; + const prefix = `wake:${wake.deviceId}:`; + const pending = await database.entries<{ wake: Wake; receivedAt: number }>(prefix); + pending.sort((left, right) => left[1].receivedAt - right[1].receivedAt); + for (const [key] of pending.slice(0, Math.max(0, pending.length - 3))) await database.put(key, undefined); + const key = `${prefix}${wake.messageId}`; + await database.put(key, { wake, receivedAt: Date.now() }); + try { await receivePush(wake); await database.put(key, undefined); } catch { await queueRetry(); } + await synchronize(); + })()); + }); + + scope.addEventListener("notificationclick", event => { + if (event.notification.data?.type !== "truapi:receiving") return; + event.notification.close(); + event.waitUntil((async () => { + const { authority, revision, eventId } = event.notification.data as { authority: ReceivingAuthority; revision: string; eventId: string }; + const current = await liveAuthority(authority.productId); + if (!current || !sameScope(current, authority)) return; + const preview = await withCore(async receiver => receivingEventCodec.dec(await receiver.receivingValidateActivation(authority.productId, BigInt(revision), eventId))); + if (!preview) return; + const activation: Activation = { authority, revision: BigInt(revision), eventId, expiresAt: preview.expiresAt }; + const key = `activation:${authority.productId}:${eventId}`; + await database.put(key, activation); + for (const execution of executions.values()) { + if (!execution.ready || !sameScope(execution.authority, authority)) continue; + const client = await scope.clients.get(execution.clientId) as WindowClient | undefined; + if (!client) continue; + await client.focus(); + await deliverActivation(key, activation, client.id); + return; + } + const clients = await scope.clients.matchAll({ type: "window", includeUncontrolled: true }); + const host = clients.find(client => client.frameType === "top-level" && new URL(client.url).origin === scope.location.origin); + if (host) { await host.focus(); await deliverActivation(key, activation, host.id); } + else { + const opened = await scope.clients.openWindow(entryUrl.href); + if (opened) await deliverActivation(key, activation, opened.id); + } + })()); + }); + + scope.addEventListener("pushsubscriptionchange", event => { + const changed = event as ExtendableEvent & { oldSubscription?: PushSubscription | null }; + changed.waitUntil((async () => { + const key = changed.oldSubscription?.options.applicationServerKey; + if (key && Notification.permission === "granted") await scope.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }); + await refreshDestination(); + await synchronize(); + })().catch(() => queueRetry())); + }); + scope.addEventListener("activate", event => { event.waitUntil(refreshDestination().then(() => synchronize()).catch(() => queueRetry())); }); + for (const type of ["sync", "periodicsync"] as const) { + scope.addEventListener(type, ((event: ExtendableEvent & { tag: string }) => { + if (event.tag === "truapi:receiving") event.waitUntil(refreshDestination().then(() => synchronize()).then(async () => { + if (await database.get("syncRequested")) throw new Error("receiving synchronization remains pending"); + })); + }) as EventListener); + } +} diff --git a/js/packages/truapi-host/src/browser-receiving.ts b/js/packages/truapi-host/src/browser-receiving.ts new file mode 100644 index 0000000000..c9fc33d8c4 --- /dev/null +++ b/js/packages/truapi-host/src/browser-receiving.ts @@ -0,0 +1,142 @@ +import type { ReceivingEvent, ReceivingWatch } from "@parity/truapi"; +import type { ReceivingAuthority } from "./runtime.js"; + +export interface BrowserReceivingClientOptions { + registration: ServiceWorkerRegistration; + /** Trusted host UI. An OS notification grant is not receiving consent. */ + consent(authority: ReceivingAuthority, watches: ReceivingWatch[]): Promise; + /** Return true only after the exact verified product is ready in the unlocked account. */ + activate(authority: ReceivingAuthority, event: ReceivingEvent): Promise; +} + +export interface BrowserReceivingExecution { + command(action: number, payload: Uint8Array): Promise; + ready(): Promise; + close(): void; +} + +export interface BrowserReceivingAuthorityState extends ReceivingAuthority { + revoked: boolean; +} + +export interface BrowserReceivingClient { + getAuthority(productId: string): Promise; + updateAuthority(authority: ReceivingAuthority): Promise; + /** Update only from the current trusted host selection, never a product claim. + * Undefined pauses; closing a product/page must not call this method. */ + setActiveAccount(account: string | undefined, environment: string, genesis: string): Promise; + bindExecution(authority: ReceivingAuthority): Promise; + enableWebPush(vapidPublicKey: string): Promise; + revoke(productId: string): Promise; + /** Explicit host logout/erase, including products with no open execution. */ + revokeAll(): Promise; + refresh(): Promise; + close(): void; +} + +/** Use only in the trusted host page, never in a product iframe. */ +export function createBrowserReceivingClient(options: BrowserReceivingClientOptions): BrowserReceivingClient { + const { registration } = options; + let closed = false; + const executions = new Set(); + async function request(operation: string, value?: unknown): Promise { + if (closed) throw new Error("receiving client closed"); + const worker = registration.active; + if (!worker) throw new Error("receiving service worker unavailable"); + const channel = new MessageChannel(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => finish(new Error("receiving service worker stalled")), 45_000); + function finish(error?: Error, result?: T) { + clearTimeout(timer); + channel.port1.close(); + if (error) reject(error); else resolve(result as T); + } + channel.port1.onmessage = event => { + const reply = event.data; + if (!reply || typeof reply.ok !== "boolean") { finish(new Error("invalid receiving worker reply")); return; } + finish(reply.ok ? undefined : new Error(String(reply.error)), reply.value); + }; + channel.port1.onmessageerror = () => finish(new Error("receiving worker message failed")); + try { worker.postMessage({ type: "truapi:receiving", operation, value }, [channel.port2]); } + catch (error) { finish(error instanceof Error ? error : new Error(String(error))); } + }); + } + const onMessage = (message: MessageEvent) => { + if (closed || message.source !== registration.active || message.data?.type !== "truapi:receiving-host" || !message.ports[0]) return; + const port = message.ports[0]; + const { operation, authority, watches, event } = message.data; + void Promise.resolve().then(() => operation === "consent" ? options.consent(authority, watches) + : operation === "activate" ? options.activate(authority, event) : false) + .then(value => port.postMessage(value === true), () => port.postMessage(false)).finally(() => port.close()); + }; + navigator.serviceWorker.addEventListener("message", onMessage); + const onOnline = () => { void request("refresh").catch(() => {}); }; + window.addEventListener("online", onOnline); + return { + getAuthority: (productId: string) => request("getAuthority", productId), + updateAuthority: (authority: ReceivingAuthority) => request("authority", structuredClone(authority)), + setActiveAccount: (account, environment, genesis) => request("activeAccount", { account, environment, genesis }), + async bindExecution(authority: ReceivingAuthority): Promise { + const snapshot = structuredClone(authority); + let id = await request("bind", snapshot); + executions.add(id); + let disposed = false; + let ready = false; + async function execute(operation: string, value: Record): Promise { + if (disposed || !executions.has(id)) throw new Error("receiving execution closed"); + try { return await request(operation, { ...value, id }); } + catch (error) { + // A missing lease guarantees the command never reached core. Never retry an ambiguous timeout. + if (!(error instanceof Error) || error.message !== "receiving execution unavailable; bind again after worker restart" || !executions.has(id)) throw error; + executions.delete(id); + id = await request("bind", snapshot); + if (disposed || closed) { + void request("unbind", { id }).catch(() => {}); + throw new Error("receiving execution closed"); + } + executions.add(id); + if (ready && operation !== "ready") await request("ready", { id }); + return request(operation, { ...value, id }); + } + } + return { + command: (action, payload) => execute("command", { action, payload }), + ready: async () => { await execute("ready", {}); ready = true; }, + close: () => { + disposed = true; + if (!executions.delete(id)) return; + void request("unbind", { id }).catch(() => {}); + }, + }; + }, + /** Call directly from a user gesture; never from product startup. */ + async enableWebPush(vapidPublicKey: string): Promise { + if (!navigator.userActivation?.isActive) throw new Error("WebPush permission requires a user gesture"); + if (!("PushManager" in window) || !("Notification" in window)) throw new Error("WebPush unavailable"); + const permission = await Notification.requestPermission(); + if (permission !== "granted") { await request("refresh"); throw new Error("notification permission denied"); } + const base64 = vapidPublicKey.replace(/-/g, "+").replace(/_/g, "/"); + const key = Uint8Array.from(atob(base64 + "=".repeat((4 - base64.length % 4) % 4)), value => value.charCodeAt(0)); + if (key.length !== 65 || key[0] !== 4) throw new Error("invalid WebPush VAPID public key"); + const existing = await registration.pushManager.getSubscription(); + const existingKey = existing?.options.applicationServerKey; + if (existing && (!existingKey || new Uint8Array(existingKey).some((byte, index) => byte !== key[index]) || existingKey.byteLength !== key.length)) { + await existing.unsubscribe(); + } + try { await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }); } + finally { await request("refresh"); } + }, + revoke: (productId: string) => request("revoke", productId), + revokeAll: () => request("revokeAll"), + refresh: () => request("refresh"), + close() { + for (const id of executions) void request("unbind", { id }).catch(() => {}); + executions.clear(); + closed = true; + navigator.serviceWorker.removeEventListener("message", onMessage); + window.removeEventListener("online", onOnline); + }, + }; +} + +export type { ReceivingAuthority } from "./runtime.js"; diff --git a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts index 9150ce7226..e12ffa4291 100644 --- a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -12,6 +12,9 @@ import { HostPushNotificationResponse, HostThemeSubscribeItem, RemotePermissionRequest, + ReceivingWatch, + ReceivingReceiptKind, + HostNotificationReceiptResult, } from "@parity/truapi"; import type { GenericError, @@ -30,6 +33,7 @@ import { NativeChatPickedFile, NativeCoinageRequest, NativeCoinageResponse, + ReceivingAuthority, PermissionDecision, PlacedAvatars, PresentedContactProfile, @@ -38,6 +42,7 @@ import { UserConfirmationReview, } from "./generated/host-callbacks.js"; import { makeHostCallbacks, settle } from "./test-support.js"; +import { createNotificationReceiverCallbacks } from "./runtime.js"; // The generated `createWasmRawCallbacks` adapter speaks the symmetric SCALE // byte boundary: codec-typed requests arrive as `Uint8Array` and are decoded @@ -46,6 +51,11 @@ import { makeHostCallbacks, settle } from "./test-support.js"; const GENESIS = `0x${"11".repeat(32)}` as `0x${string}`; +it("keeps receiving state at its native SCALE slot rather than another host capability's slot", () => { + expect(CoreStorageKey.enc({ tag: "NotificationReceiving" })).toEqual(new Uint8Array([20])); + expect(CoreStorageKey.dec(new Uint8Array([20])).tag).toBe("NotificationReceiving"); +}); + it("preserves one-use permission decisions across the WASM callback", async () => { const review = { tag: "IdentityDisclosure" as const, @@ -69,6 +79,70 @@ it("preserves one-use permission decisions across the WASM callback", async () = } }); +it("keeps receiving authority host-owned and preserves forever mute across SCALE", async () => { + const authority: ReceivingAuthority = { + productId: "playground.dot", account: "11".repeat(32), environment: "paseo", + artifact: "22".repeat(32), genesis: "33".repeat(32), generation: 7n, + osPermission: true, transportReady: false, + }; + const watches: ReceivingWatch[] = [{ + id: "group", genesis: authority.genesis, channel: "44".repeat(32), + topics: ["55".repeat(32)], senders: ["66".repeat(32)], + expiresAt: 1_800_000_000_000n, mutedUntil: (1n << 64n) - 1n, route: "/group", + }]; + const calls: unknown[] = []; + const raw = createWasmRawCallbacks(makeHostCallbacks({ + notifications: { + receiverAuthority: async (productId) => { + calls.push(productId); + return authority; + }, + receiverConsent: async (scope, policies) => { + calls.push({ scope, policies }); + return false; + }, + receiverCommand: async (productId, action, payload) => { + calls.push({ productId, action, payload }); + throw new Error("owner unavailable"); + }, + }, + })); + const encoded = await raw.receiverAuthority("playground.dot"); + expect(ReceivingAuthority.dec(encoded!)).toEqual(authority); + expect(await raw.receiverConsent(ReceivingAuthority.enc(authority), Vector(ReceivingWatch).enc(watches))).toBe(false); + const payload = new Uint8Array([1, 2]); + await expect(raw.receiverCommand("playground.dot", 4, payload)).rejects.toThrow("owner unavailable"); + expect(calls).toEqual([ + "playground.dot", { scope: authority, policies: watches }, + { productId: "playground.dot", action: 4, payload }, + ]); +}); + +it("does not claim receiving support when callbacks are absent", async () => { + const raw = createWasmRawCallbacks(makeHostCallbacks()); + expect(await raw.receiverAuthority("playground.dot")).toBeUndefined(); + expect(await raw.receiverCommand("playground.dot", 2, new Uint8Array())).toBeUndefined(); + await expect(raw.receiverChanged()).rejects.toThrow("background receiving unsupported"); + const standalone = createNotificationReceiverCallbacks({ + readReceivingState: () => undefined, + writeReceivingState: () => { throw new Error("storage unavailable"); }, + }); + expect(await standalone.receiverAuthority("playground.dot")).toBeUndefined(); + await expect(standalone.receiverConsent(new Uint8Array(), new Uint8Array())).rejects.toThrow("background receiving unsupported"); + await expect(standalone.receiverChanged()).rejects.toThrow("background receiving unsupported"); + await expect(standalone.writeReceivingState(new Uint8Array())).rejects.toThrow("storage unavailable"); +}); + +it("preserves confirmed versus pending display in the receipt wire payload", () => { + expect(ReceivingReceiptKind.enc("Foreground")).toEqual(new Uint8Array([0])); + expect(ReceivingReceiptKind.enc("Read")).toEqual(new Uint8Array([1])); + expect(ReceivingReceiptKind.enc("Displayed")).toEqual(new Uint8Array([2])); + expect(HostNotificationReceiptResult.enc({ displayed: false, displayPending: true })) + .toEqual(new Uint8Array([0, 1])); + expect(HostNotificationReceiptResult.dec(new Uint8Array([1, 0]))) + .toEqual({ displayed: true, displayPending: false }); +}); + const defaultTheme = (variant: ThemeVariant): HostThemeSubscribeItemValue => ({ name: { tag: "Default" }, variant, diff --git a/js/packages/truapi-host/src/runtime.ts b/js/packages/truapi-host/src/runtime.ts index fe6553fd1e..1de44fcca8 100644 --- a/js/packages/truapi-host/src/runtime.ts +++ b/js/packages/truapi-host/src/runtime.ts @@ -5,6 +5,12 @@ import type { RendererNode, WireProvider, } from "@parity/truapi"; +import { ReceivingEvent, ReceivingWatch } from "@parity/truapi"; +import { Option, Vector } from "@parity/truapi/scale"; +import { + ReceivingAuthority, + ReceivingRegistration, +} from "./generated/host-callbacks.js"; import { CoreStorageKey as GeneratedCoreStorageKey } from "./generated/host-callbacks.js"; import type { CoreAdmin, @@ -51,6 +57,77 @@ export interface NativeChatContactsSnapshot { */ export type Awaitable = T | Promise; +/** Canonical SCALE results returned by resident receiving runtime hooks. */ +export const receivingRegistrationsCodec = Vector(ReceivingRegistration); +export const receivingEventsCodec = Vector(ReceivingEvent); +export const receivingEventCodec = Option(ReceivingEvent); +const receivingWatchesCodec = Vector(ReceivingWatch); + +/** Minimal host-owned callbacks for a wallet-free service-worker receiver. */ +export interface NotificationReceiverCallbacks { + /** Resolve current verified artifact/account state, never product message claims. */ + receiverAuthority?(productId: string): Awaitable; + /** Separate receiving consent, not an OS permission or relay acknowledgement. */ + receiverConsent?(authority: ReceivingAuthority, watches: ReceivingWatch[]): Awaitable; + /** Wake asynchronous transport work; never await remote synchronization. */ + receiverChanged?(): Awaitable; + readReceivingState(): Awaitable; + /** Atomically replace the private ledger. Only one receiver may write it. */ + writeReceivingState(bytes: Uint8Array): Awaitable; +} + +/** Encode only the canonical domain records at the standalone WASM boundary. */ +export function createNotificationReceiverCallbacks(callbacks: NotificationReceiverCallbacks) { + return { + receiverAuthority: async (productId: string) => { + const authority = await callbacks.receiverAuthority?.(productId); + return authority === undefined ? undefined : ReceivingAuthority.enc(authority); + }, + receiverConsent: async (authority: Uint8Array, watches: Uint8Array) => { + if (!callbacks.receiverConsent) throw new Error("background receiving unsupported"); + return callbacks.receiverConsent(ReceivingAuthority.dec(authority), receivingWatchesCodec.dec(watches)); + }, + receiverChanged: async () => { + if (!callbacks.receiverChanged) throw new Error("background receiving unsupported"); + return callbacks.receiverChanged(); + }, + readReceivingState: async () => callbacks.readReceivingState(), + writeReceivingState: async (bytes: Uint8Array) => callbacks.writeReceivingState(bytes), + }; +} + +/** Raw host-only receiving hooks on both full resident and standalone WASM cores. + * Products must use Notifications actions, never these host-authority hooks. + */ +export interface RawReceivingRuntime { + receivingPending(): Promise; + receivingSynchronized(productId: string, revision: bigint): Promise; + receivingIngest( + productId: string, revision: bigint, watchId: string, + actualGenesis: string, actualChannel: string, actualTopics: string[], frame: Uint8Array, + ): Promise; + receivingIngestStatement( + productId: string, revision: bigint, watchId: string, + actualGenesis: string, statement: Uint8Array, + ): Promise; + receivingPrepareDisplay(productId: string, revision: bigint, eventId: string): Promise; + receivingConfirmDisplay(productId: string, revision: bigint, eventId: string): Promise; + /** Clear a reservation after explicit display failure, never after an unknown outcome. */ + receivingCancelDisplay(productId: string, revision: bigint, eventId: string): Promise; + receivingValidateActivation(productId: string, revision: bigint, eventId: string): Promise; + receivingActivate(productId: string, revision: bigint, eventId: string): Promise; + receivingRevoke(productId: string): Promise; + receivingMarkTransportChanged(productId: string): Promise; +} + +/** Standalone commands carry the immutable authority captured by the trusted execution channel. + * Authority bytes encode ReceivingAuthority; response is Result. + */ +export interface RawNotificationReceiver extends RawReceivingRuntime { + commandForExecution(authority: Uint8Array, action: number, payload: Uint8Array): Promise; + free(): void; +} + /** * Open a JSON-RPC connection for `genesisHash`. The wasm bridge passes * `onResponse` so the host can push JSON-RPC replies back asynchronously. diff --git a/js/packages/truapi-host/src/test-support.ts b/js/packages/truapi-host/src/test-support.ts index a6d34f91fd..181b15d859 100644 --- a/js/packages/truapi-host/src/test-support.ts +++ b/js/packages/truapi-host/src/test-support.ts @@ -27,6 +27,10 @@ export function makeHostCallbacks( notifications: { pushNotification: async () => ({ id: 0 }), cancelNotification: async () => {}, + receiverAuthority: async () => undefined, + receiverConsent: async () => { throw new Error("background receiving unsupported"); }, + receiverChanged: async () => { throw new Error("background receiving unsupported"); }, + receiverCommand: async () => undefined, activationEvents: async () => { throw new Error("notification activation is unsupported"); }, diff --git a/js/packages/truapi-host/src/wasm-module.ts b/js/packages/truapi-host/src/wasm-module.ts index 6c154cb225..589d5c034f 100644 --- a/js/packages/truapi-host/src/wasm-module.ts +++ b/js/packages/truapi-host/src/wasm-module.ts @@ -7,6 +7,7 @@ import type { PermissionAuthorizationRuntime } from "./worker-permission-authori import type { LocalIdentity } from "./worker-protocol.js"; import type { WalletAllowanceSnapshot } from "./wallet-allowances.js"; import type { NativeChatContactsSnapshot } from "./runtime.js"; +import type { RawNotificationReceiver, RawReceivingRuntime } from "./runtime.js"; /** Cancellable handle on one live render stream inside the core. */ export interface WorkerRendererSubscription { @@ -49,7 +50,7 @@ export interface WorkerProductRuntime { export type WorkerTransition = "Start" | "Stop"; /** Runtime operations shared by paired and browser-local signing hosts. */ -export interface WorkerHostRuntime extends PermissionAuthorizationRuntime { +export interface WorkerHostRuntime extends PermissionAuthorizationRuntime, RawReceivingRuntime { productRuntime( product: unknown, coreCallbacks: unknown, @@ -132,6 +133,8 @@ export interface WorkerSigningHostRuntime extends WorkerHostRuntime { /** Module surface the wasm-pack glue exports. */ export interface WasmModuleShape { default: (input?: unknown) => Promise; + /** Wallet-free receiver for the host's single durable service-worker owner. */ + WasmNotificationReceiver: new (callbacks: unknown) => RawNotificationReceiver; WasmPairingHostRuntime: new ( callbacks: unknown, hostConfig: unknown, diff --git a/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts b/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts index 249b2b6f9e..5d8474839d 100644 --- a/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts +++ b/js/packages/truapi-host/src/wasm/web/truapi_server.d.ts @@ -13,6 +13,7 @@ export default init; export const WasmPairingHostRuntime: WasmModuleShape["WasmPairingHostRuntime"]; export const WasmSigningHostRuntime: WasmModuleShape["WasmSigningHostRuntime"]; export const WasmProductRuntime: WasmModuleShape["WasmProductRuntime"]; +export const WasmNotificationReceiver: WasmModuleShape["WasmNotificationReceiver"]; export const setLogLevel: (level: string) => void; export const deriveProductAccountPublicKey: WasmModuleShape["deriveProductAccountPublicKey"]; export const productAccountAddress: WasmModuleShape["productAccountAddress"]; diff --git a/js/packages/truapi-host/src/web/create-mock-host.ts b/js/packages/truapi-host/src/web/create-mock-host.ts index 2404c705fe..89d28ee3c6 100644 --- a/js/packages/truapi-host/src/web/create-mock-host.ts +++ b/js/packages/truapi-host/src/web/create-mock-host.ts @@ -1163,6 +1163,10 @@ export function createMockHost(config: MockHostConfig = {}): MockHost { const entry = pushedNotifications.find((n) => n.id === id); if (entry) entry.cancelled = true; }, + async receiverAuthority() { return undefined; }, + async receiverConsent() { throw new Error("background receiving unsupported"); }, + async receiverChanged() { throw new Error("background receiving unsupported"); }, + async receiverCommand() { return undefined; }, async activationEvents() { throw new Error("notification activation is unsupported"); }, diff --git a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts index 72f1dea29d..b2d30ef03b 100644 --- a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts +++ b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts @@ -872,7 +872,7 @@ async function handleChainConnectStart( }; try { const conn = await (msg.kind === "hopConnectStart" - ? state.rawCallbacks.hopConnect( + ? state.rawCallbacks.hopConnect?.( msg.genesisHash, msg.endpoint, onResponse, diff --git a/js/packages/truapi-host/src/web/mock-host-surface.test.ts b/js/packages/truapi-host/src/web/mock-host-surface.test.ts index b04434b4e1..31908df20e 100644 --- a/js/packages/truapi-host/src/web/mock-host-surface.test.ts +++ b/js/packages/truapi-host/src/web/mock-host-surface.test.ts @@ -208,7 +208,7 @@ describe("mock host surface agreement", () => { ].map(([, variant, key]) => ({ variant, key })); // A regex that matched nothing would make this pass forever. - expect(arms.length).toBe(14); + expect(arms.length).toBe(15); expect(arms.filter(({ variant, key }) => variant !== key)).toEqual([]); }); diff --git a/js/packages/truapi/README.md b/js/packages/truapi/README.md index afc61f4a33..e8b329eeb3 100644 --- a/js/packages/truapi/README.md +++ b/js/packages/truapi/README.md @@ -41,6 +41,81 @@ Request methods take the inner request value directly. The transport adds the wi Requests reject with `RequestTimeoutError` when no matching response arrives within 120 seconds. Pass `{ requestTimeoutMs }` to `createTransport` to select a different positive deadline. +## Authenticated notification envelopes + +`@parity/truapi/notification-envelope` provides synchronous Ed25519/SHA-256 helpers +for browsers, Node and Bun without requiring Web Crypto: + +- `signNotificationHeader(metadata, opaqueBytes, seed32)` signs a header that an + application can add to its existing standard Envelope as a `truapiNotification` + text assertion with `JSON.stringify(header)`. Existing application assertions and + byte leaves need not change. +- `signNotificationEnvelope(metadata, opaqueBytes, seed32)` emits a minimal standard + carrier containing the byte leaf and its reserved assertion. +- `verifyNotificationEnvelopes(frame, actualGenesis, actualChannel, actualTopics, nowMs)` + returns all eligible `{ header, carrier }` candidates. Invalid candidate metadata + or proofs are omitted; malformed/ambiguous containers throw. +- `authenticateNotificationEnvelopes(frame, actualGenesis, actualChannel, actualTopics)` + verifies stored history without current-time notification eligibility. +- `authenticateNotificationHeader(json, opaqueBytes)` authenticates optional stored + metadata directly, without constructing or copying a carrier. It checks strict + JSON, static bounds, byte digest and signature only. It establishes **neither + actual source nor current-time eligibility, enrollment or sender approval**. + Hosts and relays must use the whole-carrier verification APIs above. +- The singular `verifyNotificationEnvelope`, `authenticateNotificationEnvelope` and + `decodeNotificationEnvelope` require exactly one candidate. Decode does not verify + authenticity. `encodeNotificationEnvelope(header, opaqueBytes)` only validates + structure. `notificationSigningBytes(header)` exposes the signed tuple. + +Callers must enforce product binding, enrollment, approved senders, mute and replay +policy. A watch's topics must be a subset of the **signed header topics**, which +must themselves be present in the actual source topics. Unsigned extra actual topics +never broaden the authenticated watch scope. Genesis and channel match exactly. + +The container uses the base [Gordian Envelope grammar](https://datatracker.ietf.org/doc/html/draft-mcnally-envelope-12#section-3): +outer CBOR tag 200, leaf tag 201, node arrays and single-entry assertion maps. +The parser traverses standard nodes without interpreting application predicates. +Each reserved assertion contains strict JSON text; its `ciphertextDigest` must +resolve to a byte leaf somewhere in the same container. Byte leaves are indexed by +SHA-256 once per traversal, so a nested assertion can authenticate an existing +opaque leaf without decorating or copying it. Application fields unrelated to +the reserved assertion are not authenticated by this header. + +The notification profile accepts canonical definite-length integer-only dCBOR +leaves, NFC text, sorted map keys and digest-sorted unique node assertions. +Floats and unsupported Envelope extensions are rejected. Limits are 256 KiB for +the complete frame (`MAX_FULL_FRAME_BYTES`), 240 KiB per referenced byte leaf, +16 KiB per JSON header, 32 candidates globally, 128 assertions per node, +4096 CBOR items and depth 16. Duplicate reserved assertions on one node are rejected. +`isNotificationEnvelope(frame)` performs bounded structural traversal, returning +false for valid unmarked base Envelopes and non-Envelope bytes, and true when a +reserved assertion exists even if its JSON/proof is invalid. Malformed containers +throw: callers must drop them, never fall back to another authentication path. +Returned byte witnesses are copied to prevent later input mutation changing them. + +Header fields are exactly `v`, `product`, `genesis`, `channel`, `topics`, `eventId`, +`createdAt`, `expiresAt`, `ciphertextDigest`, `senderKey` and `signature`. +Duplicate, missing and unknown fields are rejected. Version is 1; product matches +`[a-z0-9._-]{1,128}`. Hashes, channel, topics and raw keys are 32-byte lowercase hex +without `0x`; signatures are 64-byte lowercase hex. Signed topics are ordered and +distinct, with one to four entries. Safe-integer epoch-millisecond timestamps have +exclusive expiry, a maximum 24-hour lifetime and a maximum 60-second future skew. +JSON integer spellings cannot contain a minus sign, decimal point or exponent. + +The Ed25519 acceptance profile matches Rust's `ed25519-dalek::verify_strict` with +canonical point encodings: valid canonical A and R, scalar S below the subgroup +order, no small-order A or R, and the exact uncofactored equation +`R = [S]B - [SHA512(R || A || signingBytes) mod L]A`. Mixed-order points are not +blanket-rejected if they satisfy this exact equation. The JS helper uses Noble's +point/scalar/hash primitives directly because Noble's `verify`, even with +`zip215: false`, checks a cofactored equation and therefore has different acceptance. + +Signed bytes are whitespace-free UTF-8 JSON of +`["truapi:notification:v1",v,product,genesis,channel,topics,eventId,createdAt,expiresAt,ciphertextDigest,senderKey]`. +This matches the Rust runtime verifier. Signing metadata omits `v`, +`ciphertextDigest`, `senderKey` and `signature`, which the helper derives. +No product codec, decryption key or notification permission is provided. + ## Subscriptions Streaming methods return a small Observable-compatible object: diff --git a/js/packages/truapi/package.json b/js/packages/truapi/package.json index 51715a46af..cb5b9ae84c 100644 --- a/js/packages/truapi/package.json +++ b/js/packages/truapi/package.json @@ -35,10 +35,18 @@ "types": "./dist/internal.d.ts", "import": "./dist/internal.js" }, + "./jam-peer-transport": { + "types": "./dist/jam-peer-transport.d.ts", + "import": "./dist/jam-peer-transport.js" + }, "./scale": { "types": "./dist/scale.d.ts", "import": "./dist/scale.js" }, + "./notification-envelope": { + "types": "./dist/notification-envelope.d.ts", + "import": "./dist/notification-envelope.js" + }, "./wire-table": { "types": "./dist/generated/wire-table.d.ts", "import": "./dist/generated/wire-table.js" @@ -87,6 +95,7 @@ "typescript": "^6.0" }, "dependencies": { + "@noble/curves": "^2.0.1", "@noble/hashes": "^2.2.0", "neverthrow": "^8.2.0", "scale-ts": "^1.6.1" diff --git a/js/packages/truapi/src/jam-peer-transport-cert.test.ts b/js/packages/truapi/src/jam-peer-transport-cert.test.ts new file mode 100644 index 0000000000..8b705c5616 --- /dev/null +++ b/js/packages/truapi/src/jam-peer-transport-cert.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "bun:test"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { + decompressP256, + ed25519IdToKey, + p256IdToCompressed, + peerIdText, + validityBounds, + validityPeriodAt, + webTransportCertificateDer, + webTransportCertificateHash, + webTransportCertificateHashes, + webTransportSerial, +} from "./jam-peer-transport-cert.js"; + +// Two validators of a local network running PolkaJAM dd9af78 with +// jam-explore's `polkajam-webtransport-serial.patch`. `der2072` is the +// certificate each node served during period 2072, dumped with +// `openssl s_client -quic -alpn h3 -showcerts`. The other `distinct` hashes +// come from the patched node's own `net/cert.rs` for those periods; the +// `legacy` (serial 0) hashes from jam-explore `crates/jam-webtransport-cert`, +// whose legacy output equals the stock certificate in `STOCK`. +const PATCHED = [ + { + id: "v3bl2cgtywlclprhhuc5tumdn4ulhwir2mahkcqm6tkdbyo6v2hba", + compressed: "023b2c2dccc47689f5e23954f449d9689cae6351d40c1c2520f3538d809daffa04", + serial2072: 0x20dbcba0be3fb21cn, + distinct: { + 2071: "45b4746857e99aef73adc8d14599ad772ea2e209fb2b78b24956e3c7d43090f8", + 2072: "31f457efb35cc02a9db8c4b725a20626828dce21648d7b498663cff82131ea49", + 2073: "9e8398400a3c76597747a04b86315f8c5272742065c70d490412e80bd7538417", + }, + legacy: { + 2071: "8dec8b1989d2d284b2a6ab179ee6c78805907d6b1b97f338fa7a6cf3377b9549", + 2072: "6e7e01e8e40edfeba2a56b4555b5e6dba01cdd4e8a2650bd2890d5ab559a2f97", + 2073: "396d273a20dcf3cf067fbd2ce00482bb86a7c0a33ef7bc278c1fbe930be53401", + }, + der2072: + "308201443081f7a003020102020820dbcba0be3fb21c300506032b6570300e310c300a06035504030c036a616d301e170d3236303932333030303030305a170d3236313030353030303030305a300e310c300a06035504030c036a616d3059301306072a8648ce3d020106082a8648ce3d030107034200043b2c2dccc47689f5e23954f449d9689cae6351d40c1c2520f3538d809daffa0498fb2c7ecfde6e286fd1c9203c1c8e9d50f37bce6b571b60d3978617424cfd48a344304230400603551d110439303782357633626c3263677479776c636c7072686875633574756d646e34756c68776972326d61686b63716d36746b6462796f367632686261300506032b657003410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + }, + { + id: "oyhjn3rnl7vx235ayvn2f4uhsyorfflk5tnrn5s3d6erxacv3vjla", + compressed: "03f8a4b6635bbf5ebd3bc0b5e9c2e991d8c55296eab3c5d6e51e9ec40b44dd352d", + serial2072: 0x4d2e1400cc6928d7n, + distinct: { + 2071: "6322b9d00cf2522232687f7bd0bf78a5127e96dd74d1b162870cb7cf5529b7b6", + 2072: "7fa6a214c42db09966ee8b673ba989a0b8235963972bbf635cef03be2f60c1d1", + 2073: "1f21b2db288a5e7e5522f947b2224fa87cdcb28acf54fa64f8699ce9145514ce", + }, + legacy: { + 2071: "f82ad0ada188038205f02ec01311ac0570ec5a7e42a3eff9eebfd047d75557f4", + 2072: "5cc86b9b585a3d91f049084caadc9ab66e2d3d75bd4551e4a2dc9d418675b423", + 2073: "08cab2920d2fc1914fcdd0e0ba3399b42a7a4199a2c3ec19048bfd4f735f6cfe", + }, + der2072: + "308201443081f7a00302010202084d2e1400cc6928d7300506032b6570300e310c300a06035504030c036a616d301e170d3236303932333030303030305a170d3236313030353030303030305a300e310c300a06035504030c036a616d3059301306072a8648ce3d020106082a8648ce3d03010703420004f8a4b6635bbf5ebd3bc0b5e9c2e991d8c55296eab3c5d6e51e9ec40b44dd352df4537bba2c6db972303b6c2ba95d5c9ae402f556032e2f058e1a569ffabd457fa344304230400603551d110439303782356f79686a6e33726e6c3776783233356179766e326634756873796f7266666c6b35746e726e357333643665727861637633766a6c61300506032b657003410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + }, +] as const; + +// Certificate a stock JAM-TEST-INSTANCE validator served during period 2072. +const STOCK = { + compressed: "03aac17e3833a6679e7064934a6f2a2bc9450d3b2b779a9930102ae16a4f16062e", + der2072: + "3082013d3081f0a003020102020100300506032b6570300e310c300a06035504030c036a616d301e170d3236303932333030303030305a170d3236313030353030303030305a300e310c300a06035504030c036a616d3059301306072a8648ce3d020106082a8648ce3d03010703420004aac17e3833a6679e7064934a6f2a2bc9450d3b2b779a9930102ae16a4f16062ef93e4094a88912344d64606e51e6ec210633140ad0d6d3c3d292690171463813a344304230400603551d110439303782356f6b6e713568346d6767357a346a79726d7475733667766d666a6f723271356d66787467746a7961636b6a797677687a6367716c61300506032b657003410000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", +} as const; + +describe("PolkaJAM peer id text", () => { + test("decodes P-256 ids to the compressed point and back", () => { + for (const vector of PATCHED) { + const compressed = p256IdToCompressed(vector.id); + expect(bytesToHex(compressed)).toBe(vector.compressed); + expect(peerIdText(vector.id[0]!, compressed.subarray(1))).toBe(vector.id); + } + }); + + test("decodes Ed25519 ids and rejects the wrong prefix", () => { + const key = ed25519IdToKey("e5ayk2kkzlxdvih2pud5ndhb4qtj2ub4hnpkwmonlma4i55xm6wra"); + expect(peerIdText("e", key)).toBe("e5ayk2kkzlxdvih2pud5ndhb4qtj2ub4hnpkwmonlma4i55xm6wra"); + expect(() => ed25519IdToKey(PATCHED[0].id)).toThrow("begin with 'e'"); + expect(() => p256IdToCompressed("e5ayk2kkzlxdvih2pud5ndhb4qtj2ub4hnpkwmonlma4i55xm6wra")).toThrow( + "'o' or 'v'", + ); + }); +}); + +describe("P-256 decompression", () => { + test("matches the uncompressed point the node embedded", () => { + for (const vector of PATCHED) { + const point = decompressP256(hexToBytes(vector.compressed)); + // The SPKI BIT STRING carries 0x04 ‖ x ‖ y. + const index = vector.der2072.indexOf("03420004") + 6; + expect(bytesToHex(point)).toBe(vector.der2072.slice(index, index + 130)); + } + }); + + test("rejects off-curve x", () => { + const bad = hexToBytes(PATCHED[0].compressed); + bad[32] ^= 1; + expect(() => decompressP256(bad)).toThrow("not on the curve"); + }); +}); + +describe("validity periods", () => { + test("splits time into padded 10-day windows", () => { + expect(validityPeriodAt(1_790_380_800)).toBe(2072); + expect(validityBounds(2072)).toEqual([2072 * 864_000 - 86_400, 2073 * 864_000 + 86_400]); + }); +}); + +describe("certificate derivation", () => { + test("reproduces the certificates real nodes served byte for byte", () => { + for (const vector of PATCHED) { + const compressed = hexToBytes(vector.compressed); + expect(webTransportSerial(compressed, 2072)).toBe(vector.serial2072); + expect(bytesToHex(webTransportCertificateDer(compressed, 2072, "distinct"))).toBe(vector.der2072); + } + expect(bytesToHex(webTransportCertificateDer(hexToBytes(STOCK.compressed), 2072, "legacy"))).toBe( + STOCK.der2072, + ); + }); + + test("encodes a serial with a zero top byte as a minimal positive INTEGER", () => { + // Serial 0x00ada8453f66c43e: drop the zero byte, then pad because 0xad has the sign bit set. + const compressed = hexToBytes(PATCHED[0].compressed); + expect(webTransportSerial(compressed, 2099)).toBe(0x00ada8453f66c43en); + const der = webTransportCertificateDer(compressed, 2099, "distinct"); + expect(bytesToHex(der.subarray(12, 22))).toBe("020800ada8453f66c43e"); + // Hash of the patched node's own certificate for this period. + expect(bytesToHex(sha256(der))).toBe("2d1583ea892ae8c792fc499d2091f91d9ef9c4973ff3002eea577e639bee8800"); + }); + + test("pins both serial variants for the current period and both neighbours", () => { + for (const vector of PATCHED) { + const compressed = hexToBytes(vector.compressed); + for (const period of [2071, 2072, 2073] as const) { + expect(bytesToHex(webTransportCertificateHash(compressed, period, "distinct"))).toBe(vector.distinct[period]); + expect(bytesToHex(webTransportCertificateHash(compressed, period, "legacy"))).toBe(vector.legacy[period]); + } + expect(webTransportCertificateHashes(compressed, 1_790_380_800).map(bytesToHex)).toEqual([ + vector.distinct[2071], + vector.legacy[2071], + vector.distinct[2072], + vector.legacy[2072], + vector.distinct[2073], + vector.legacy[2073], + ]); + } + }); +}); diff --git a/js/packages/truapi/src/jam-peer-transport-cert.ts b/js/packages/truapi/src/jam-peer-transport-cert.ts new file mode 100644 index 0000000000..b0c923b3ed --- /dev/null +++ b/js/packages/truapi/src/jam-peer-transport-cert.ts @@ -0,0 +1,248 @@ +import { sha256 } from "@noble/hashes/sha2.js"; + +/** + * Deterministic WebTransport certificate hashes for a PolkaJAM peer. + * + * PolkaJAM (`crates/node/src/net/cert.rs`, `dd9af78`) serves an unsigned X.509 + * certificate for its P-256 peer key: issuer and subject `CN=jam`, one dNSName + * SAN equal to the peer-id text, Ed25519 signature algorithm with an all-zero + * 64-byte signature, and a validity window derived from a fixed 10-day period + * padded by one day on both sides. A client that knows the peer's compressed + * P-256 key can therefore compute the certificate hashes offline and pass them + * as `serverCertificateHashes`. These bytes mirror PolkaJAM's + * `crates/node/src/net/cert.rs` generated with rcgen 0.14.8. + * + * Stock PolkaJAM gives every certificate serial 0. NSS (Firefox) rejects a + * second certificate with an issuer and serial it has already seen + * (`SEC_ERROR_REUSED_ISSUER_AND_SERIAL`), so such a browser reaches only one + * validator. Nodes built with jam-explore's + * `polkajam-webtransport-serial.patch` use {@link webTransportSerial} instead; + * clients pin both variants so they reach stock and patched nodes alike. + */ + +export const UNPADDED_VALIDITY_PERIOD_SECS = 10 * 24 * 3600; +export const VALIDITY_PERIOD_PADDING_SECS = 24 * 3600; + +const BITS_TO_CHAR = "abcdefghijklmnopqrstuvwxyz234567"; +const P = (1n << 256n) - (1n << 224n) + (1n << 192n) + (1n << 96n) - 1n; +const B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn; +const OID_ED25519 = [0x06, 0x03, 0x2b, 0x65, 0x70]; +const OID_EC_PUBLIC_KEY = [0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01]; +const OID_PRIME256V1 = [0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07]; +const OID_SUBJECT_ALT_NAME = [0x06, 0x03, 0x55, 0x1d, 0x11]; +const ascii = new TextEncoder(); + +/** PolkaJAM peer-id text: prefix letter then 32 bytes, base-32 LSB-first. */ +export function peerIdText(prefix: string, bytes: Uint8Array): string { + if (bytes.length !== 32) throw new Error("peer id needs 32 bytes"); + let text = prefix; + for (let i = 0; i < 256; i += 5) { + const low = bytes[i >> 3]!; + const high = bytes[(i >> 3) + 1] ?? 0; + text += BITS_TO_CHAR[((low | (high << 8)) >> (i % 8)) & 0x1f]; + } + return text; +} + +/** Parse `e…`, `o…` or `v…` text into (prefix, 32 bytes). */ +export function parsePeerIdText(text: string): { prefix: string; bytes: Uint8Array } { + if (text.length !== 53) throw new Error(`peer id text must be 53 characters, got ${text.length}`); + const bytes = new Uint8Array(32); + let acc = 0; + let n = 0; + let i = 0; + for (const char of text.slice(1)) { + const bits = BITS_TO_CHAR.indexOf(char); + if (bits < 0) throw new Error(`invalid peer id character ${JSON.stringify(char)}`); + acc |= bits << n; + n += 5; + if (n >= 8) { + bytes[i++] = acc & 0xff; + acc >>= 8; + n -= 8; + } + } + if (acc !== 0) throw new Error("peer id has non-zero trailing bits"); + return { prefix: text[0]!, bytes }; +} + +/** `o…`/`v…` text to a compressed SEC1 P-256 point (0x03 odd y / 0x02 even y). */ +export function p256IdToCompressed(text: string): Uint8Array { + const { prefix, bytes } = parsePeerIdText(text); + if (prefix !== "o" && prefix !== "v") throw new Error("P-256 peer ids begin with 'o' or 'v'"); + const out = new Uint8Array(33); + out[0] = prefix === "o" ? 3 : 2; + out.set(bytes, 1); + return out; +} + +/** Ed25519 `e…` text to the 32-byte public key. */ +export function ed25519IdToKey(text: string): Uint8Array { + const { prefix, bytes } = parsePeerIdText(text); + if (prefix !== "e") throw new Error("Ed25519 peer ids begin with 'e'"); + return bytes; +} + +function bigintFromBytes(bytes: Uint8Array): bigint { + let v = 0n; + for (const b of bytes) v = (v << 8n) | BigInt(b); + return v; +} + +function bytesFromBigint(v: bigint, length: number): Uint8Array { + const out = new Uint8Array(length); + for (let i = length - 1; i >= 0; i--) { + out[i] = Number(v & 0xffn); + v >>= 8n; + } + return out; +} + +function modPow(base: bigint, exp: bigint, mod: bigint): bigint { + let result = 1n; + base %= mod; + while (exp > 0n) { + if (exp & 1n) result = (result * base) % mod; + base = (base * base) % mod; + exp >>= 1n; + } + return result; +} + +/** Uncompressed SEC1 (0x04 ‖ x ‖ y) for a compressed P-256 point; p ≡ 3 mod 4. */ +export function decompressP256(compressed: Uint8Array): Uint8Array { + if (compressed.length !== 33 || (compressed[0] !== 2 && compressed[0] !== 3)) { + throw new Error("expected a 33-byte compressed P-256 point"); + } + const x = bigintFromBytes(compressed.subarray(1)); + if (x >= P) throw new Error("P-256 x coordinate out of range"); + const rhs = (((x * x) % P) * x - 3n * x + B) % P; + const alpha = (rhs + P) % P; + let y = modPow(alpha, (P + 1n) >> 2n, P); + if ((y * y) % P !== alpha) throw new Error("P-256 x coordinate is not on the curve"); + if ((y & 1n) !== BigInt(compressed[0]! & 1)) y = P - y; + const out = new Uint8Array(65); + out[0] = 4; + out.set(bytesFromBigint(x, 32), 1); + out.set(bytesFromBigint(y, 32), 33); + return out; +} + +function der(tag: number, ...parts: ArrayLike[]): number[] { + const body = parts.flatMap((part) => Array.from(part)); + const len = body.length; + const header = + len < 0x80 + ? [tag, len] + : len < 0x100 + ? [tag, 0x81, len] + : [tag, 0x82, len >> 8, len & 0xff]; + return header.concat(body); +} + +/** Minimal DER encoding of a non-negative INTEGER. */ +function derUnsigned(v: bigint): number[] { + const bytes: number[] = []; + for (; v > 0n; v >>= 8n) bytes.unshift(Number(v & 0xffn)); + if (bytes.length === 0 || bytes[0]! >= 0x80) bytes.unshift(0); + return der(0x02, bytes); +} + +function utcTime(unixSecs: number): number[] { + const date = new Date(unixSecs * 1000); + const year = date.getUTCFullYear(); + if (year < 1950 || year >= 2050) { + throw new Error("validity outside the UTCTime range PolkaJAM certificates use"); + } + const two = (n: number): string => String(n).padStart(2, "0"); + const text = `${two(year % 100)}${two(date.getUTCMonth() + 1)}${two(date.getUTCDate())}${two( + date.getUTCHours(), + )}${two(date.getUTCMinutes())}${two(date.getUTCSeconds())}Z`; + return der(0x17, ascii.encode(text)); +} + +const JAM_DN = der(0x30, der(0x31, der(0x30, [0x06, 0x03, 0x55, 0x04, 0x03], der(0x0c, ascii.encode("jam"))))); +const ED25519_ALG = der(0x30, OID_ED25519); + +/** Fixed 10-day period index for a unix time; the server switches at boundaries. */ +export function validityPeriodAt(unixSecs: number): number { + return Math.floor(unixSecs / UNPADDED_VALIDITY_PERIOD_SECS); +} + +/** `[notBefore, notAfter]` unix seconds of a period (padded by one day). */ +export function validityBounds(period: number): [number, number] { + return [ + Math.max(period * UNPADDED_VALIDITY_PERIOD_SECS - VALIDITY_PERIOD_PADDING_SECS, 0), + (period + 1) * UNPADDED_VALIDITY_PERIOD_SECS + VALIDITY_PERIOD_PADDING_SECS, + ]; +} + +/** + * Which serial a certificate carries: `distinct` ({@link webTransportSerial}, + * patched nodes) or `legacy` (0, stock PolkaJAM). + */ +export type CertificateSerial = "distinct" | "legacy"; + +/** + * Serial of the patched PolkaJAM certificate for `compressed` during + * `period`: the first 8 bytes of SHA-256(`compressed` ‖ period as a + * big-endian u64), read big-endian with the top bit cleared, or 1 if that + * is 0. + */ +export function webTransportSerial(compressed: Uint8Array, period: number): bigint { + const input = new Uint8Array(compressed.length + 8); + input.set(compressed); + input.set(bytesFromBigint(BigInt(period), 8), compressed.length); + const serial = bigintFromBytes(sha256(input).subarray(0, 8)) & ((1n << 63n) - 1n); + return serial === 0n ? 1n : serial; +} + +/** DER certificate PolkaJAM presents for `compressed` during `period`. */ +export function webTransportCertificateDer( + compressed: Uint8Array, + period: number, + serial: CertificateSerial, +): Uint8Array { + const point = decompressP256(compressed); + const altName = peerIdText(compressed[0] === 3 ? "o" : "v", compressed.subarray(1)); + const [notBefore, notAfter] = validityBounds(period); + const spki = der(0x30, der(0x30, OID_EC_PUBLIC_KEY, OID_PRIME256V1), der(0x03, [0x00], point)); + const san = der(0x30, der(0x30, OID_SUBJECT_ALT_NAME, der(0x04, der(0x30, der(0x82, ascii.encode(altName)))))); + const tbs = der( + 0x30, + der(0xa0, der(0x02, [0x02])), + derUnsigned(serial === "distinct" ? webTransportSerial(compressed, period) : 0n), + ED25519_ALG, + JAM_DN, + der(0x30, utcTime(notBefore), utcTime(notAfter)), + JAM_DN, + spki, + der(0xa3, san), + ); + return Uint8Array.from(der(0x30, tbs, ED25519_ALG, der(0x03, [0x00], new Uint8Array(64)))); +} + +/** SHA-256 of {@link webTransportCertificateDer}. */ +export function webTransportCertificateHash( + compressed: Uint8Array, + period: number, + serial: CertificateSerial, +): Uint8Array { + return sha256(webTransportCertificateDer(compressed, period, serial)); +} + +/** + * Hashes to pass as `serverCertificateHashes` at `unixSecs`: both serial + * variants for the current period plus both neighbours, so a clock skew or a + * boundary crossing during the handshake still matches whichever certificate + * a stock or patched server picked. + */ +export function webTransportCertificateHashes(compressed: Uint8Array, unixSecs: number): Uint8Array[] { + const period = validityPeriodAt(unixSecs); + return [period - 1, period, period + 1] + .filter((p) => p >= 0) + .flatMap((p) => [ + webTransportCertificateHash(compressed, p, "distinct"), + webTransportCertificateHash(compressed, p, "legacy"), + ]); +} diff --git a/js/packages/truapi/src/jam-peer-transport-wire.test.ts b/js/packages/truapi/src/jam-peer-transport-wire.test.ts new file mode 100644 index 0000000000..ab6cddfc05 --- /dev/null +++ b/js/packages/truapi/src/jam-peer-transport-wire.test.ts @@ -0,0 +1,99 @@ +import { expect, test } from "bun:test"; +import * as S from "./scale.js"; +import * as T from "./generated/types.js"; +import { + JAM_PEER_TRANSPORT_CLOSE, + JAM_PEER_TRANSPORT_DIAL, + JAM_PEER_TRANSPORT_EVENTS, + JAM_PEER_TRANSPORT_OPEN, + JAM_PEER_TRANSPORT_RECV, + JAM_PEER_TRANSPORT_RESET, + JAM_PEER_TRANSPORT_SEND, +} from "./generated/wire-table.js"; +import { decodeWireMessage, encodeWireMessage } from "./transport.js"; + +// The same bytes `rust/crates/truapi-server/tests/jam_peer_transport_contract.rs` +// pins for the Rust SCALE codec: both sides must agree on the frozen V1 layout. +const GENESIS = "0x353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f" as const; +const LOOPBACK_V4_MAPPED = "0x00000000000000000000ffff7f000001" as const; + +test("JamPeerTransport is namespace 111 with methods 0..6 in contract order", () => { + const ids = [JAM_PEER_TRANSPORT_DIAL, JAM_PEER_TRANSPORT_OPEN, JAM_PEER_TRANSPORT_SEND, JAM_PEER_TRANSPORT_RECV, + JAM_PEER_TRANSPORT_RESET, JAM_PEER_TRANSPORT_CLOSE, JAM_PEER_TRANSPORT_EVENTS]; + ids.forEach((id, method) => { + expect(id.trait).toBe(111); + expect(id.method).toBe(method); + expect(id.kind).toBe("request"); + }); +}); + +test("the JamPeers permission is RemotePermission index 5 carrying the genesis, as in Rust", () => { + const permission = { tag: "JamPeers", value: { genesis: GENESIS } } as const; + const encoded = T.RemotePermission.enc(permission); + expect([...encoded]).toEqual([5, ...S.hexToBytes(GENESIS)]); + expect(T.RemotePermission.dec(encoded)).toEqual(permission); + expect([...T.RemotePermission.enc({ tag: "StatementSubmit" })]).toEqual([4]); +}); + +test("dial request encodes genesis, v4-mapped ip, port, ed25519 and optional p256 as Rust does", () => { + const encoded = T.VersionedHostJamPeerTransportDialRequest.enc({ + tag: "V1", + value: { genesis: GENESIS, ip: LOOPBACK_V4_MAPPED, port: 43000, ed25519: `0x${"11".repeat(32)}`, p256: `0x${"02".repeat(33)}` }, + }); + expect([...encoded]).toEqual([ + 0, + ...S.hexToBytes(GENESIS), + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1, + 0xf8, 0xa7, + ...new Array(32).fill(0x11), + 1, + ...new Array(33).fill(0x02), + ]); + const withoutP256 = T.VersionedHostJamPeerTransportDialRequest.enc({ + tag: "V1", + value: { genesis: GENESIS, ip: LOOPBACK_V4_MAPPED, port: 43000, ed25519: `0x${"11".repeat(32)}`, p256: undefined }, + }); + expect(withoutP256.length).toBe(1 + 32 + 16 + 2 + 32 + 1); + expect(withoutP256[withoutP256.length - 1]).toBe(0); + expect(T.VersionedHostJamPeerTransportDialRequest.dec(withoutP256)).toEqual({ + tag: "V1", + value: { genesis: GENESIS, ip: LOOPBACK_V4_MAPPED, port: 43000, ed25519: `0x${"11".repeat(32)}`, p256: undefined }, + }); +}); + +test("send, recv and events payloads match the Rust SCALE bytes", () => { + expect([...T.VersionedHostJamPeerTransportSendRequest.enc({ tag: "V1", value: { stream: 7, message: "0xaabb", fin: true } })]) + .toEqual([0, 7, 0, 0, 0, 8, 0xaa, 0xbb, 1]); + expect([...T.VersionedHostJamPeerTransportRecvResponse.enc({ tag: "V1", value: { message: undefined, fin: false, reset: true } })]) + .toEqual([0, 0, 0, 1]); + expect([...T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream: 3, max: 1 << 20 } })]) + .toEqual([0, 3, 0, 0, 0, 0, 0, 0x10, 0]); + const events = T.VersionedHostJamPeerTransportEventsResponse.enc({ + tag: "V1", + value: { + events: [ + { tag: "ConnClosed", value: { conn: 1 } }, + { tag: "StreamFin", value: { stream: 2 } }, + { tag: "Accepted", value: { conn: 1, stream: 3, kind: 0 } }, + ], + }, + }); + expect([...events]).toEqual([0, 12, 0, 1, 0, 0, 0, 1, 2, 0, 0, 0, 2, 1, 0, 0, 0, 3, 0, 0, 0, 0]); + expect([...T.VersionedHostJamPeerTransportEventsRequest.enc({ tag: "V1", value: undefined })]).toEqual([0]); + expect([...T.VersionedHostJamPeerTransportDialError.enc({ tag: "V1", value: "Unreachable" })]).toEqual([0, 3]); +}); + +test("a NotGranted dial response decodes from a host frame", () => { + const resultCodec = S.Result(T.VersionedHostJamPeerTransportDialResponse, S.CallError(T.VersionedHostJamPeerTransportDialError)); + const value = resultCodec.enc({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "NotGranted" } } }); + const frame = encodeWireMessage({ requestId: "p", payload: { traitId: 111, methodId: 0, messageType: 1, value } }); + if (frame.isErr()) throw frame.error; + expect([...frame.value]).toEqual([4, 112, 111, 0, 1, 1, 0, 0, 0]); + const decoded = decodeWireMessage(frame.value); + if (decoded.isErr()) throw decoded.error; + expect(decoded.value.requestId).toBe("p"); + expect(resultCodec.dec(decoded.value.payload.value)).toEqual({ + success: false, + value: { tag: "Domain", value: { tag: "V1", value: "NotGranted" } }, + }); +}); diff --git a/js/packages/truapi/src/jam-peer-transport.test.ts b/js/packages/truapi/src/jam-peer-transport.test.ts new file mode 100644 index 0000000000..34403b41d8 --- /dev/null +++ b/js/packages/truapi/src/jam-peer-transport.test.ts @@ -0,0 +1,755 @@ +import { describe, expect, test } from "bun:test"; +import * as S from "./scale.js"; +import * as T from "./generated/types.js"; +import { TRUAPI_CODEC_VERSION } from "./generated/client.js"; +import { + JAM_PEER_TRANSPORT_CLOSE, + JAM_PEER_TRANSPORT_DIAL, + JAM_PEER_TRANSPORT_EVENTS, + JAM_PEER_TRANSPORT_OPEN, + JAM_PEER_TRANSPORT_RECV, + JAM_PEER_TRANSPORT_SEND, + SYSTEM_HANDSHAKE, +} from "./generated/wire-table.js"; +import { decodeWireMessage, encodeWireMessage, MESSAGE_TYPE_CANCEL, MESSAGE_TYPE_REQUEST, type MethodIds } from "./transport.js"; +import { + createJamPeerTransportSession, + frameTraitId, + peerUrl, + JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION, + JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES, + type JamPeerTransportSession, + type WebTransportBidirectionalStreamLike, + type WebTransportLike, +} from "./jam-peer-transport.js"; + +const GENESIS = "0x353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f"; +const P256 = "0x028874174c8f469438a1b1bab2fde75f9c4999461382ec6d47e9b3b4511294c607"; +const LOOPBACK = "0x00000000000000000000ffff7f000001"; + +interface FakeStream { + local: WebTransportBidirectionalStreamLike; + /** Bytes the session wrote, in order. */ + sent: Uint8Array[]; + peerWrite(bytes: Uint8Array): void; + peerFin(): void; +} + +interface FakeTransport extends WebTransportLike { + url: string; + hashes: Uint8Array[]; + streams: FakeStream[]; + /** Whether the session closed this transport. */ + closedByHost: boolean; + /** Simulate the peer opening a stream toward us. */ + peerOpen(): FakeStream; + peerClose(): void; +} + +function fakeStream(write?: (chunk: Uint8Array) => Promise): FakeStream { + const sent: Uint8Array[] = []; + let peerController!: ReadableByteStreamController; + const readable = new ReadableStream({ type: "bytes", start: (c) => (peerController = c) }); + const writable = new WritableStream({ + write: (chunk) => { + sent.push(chunk); + return write?.(chunk); + }, + }); + return { + local: { readable, writable }, + sent, + peerWrite: (bytes) => peerController.enqueue(bytes), + peerFin: () => { + peerController.close(); + peerController.byobRequest?.respond(0); + }, + }; +} + +/** `ready` settles as named; `"hang"` models a handshake that never completes. */ +type FakeReady = "ok" | "fail" | "hang"; + +function fakeTransport(url: string, hashes: Uint8Array[], ready: FakeReady = "ok"): FakeTransport { + let incoming!: ReadableStreamDefaultController; + const closed = Promise.withResolvers(); + const streams: FakeStream[] = []; + const transport: FakeTransport = { + url, + hashes, + streams, + closedByHost: false, + ready: + ready === "ok" ? Promise.resolve() : ready === "fail" ? Promise.reject(new Error("refused")) : new Promise(() => undefined), + closed: closed.promise, + incomingBidirectionalStreams: new ReadableStream({ start: (c) => (incoming = c) }), + async createBidirectionalStream() { + const stream = fakeStream(); + streams.push(stream); + return stream.local; + }, + close: () => { + transport.closedByHost = true; + closed.resolve(); + }, + peerOpen() { + const stream = fakeStream(); + streams.push(stream); + incoming.enqueue(stream.local); + return stream; + }, + peerClose: () => closed.resolve(), + }; + return transport; +} + +let requestCounter = 0; +function frame(ids: MethodIds, value: Uint8Array, requestId = `t${requestCounter++}`, messageType = MESSAGE_TYPE_REQUEST): Uint8Array { + const encoded = encodeWireMessage({ + requestId, + payload: { traitId: ids.trait, methodId: ids.method, messageType, value }, + }); + if (encoded.isErr()) throw encoded.error; + return encoded.value; +} + +function decodeReply(bytes: Uint8Array, codec: S.Codec): V { + const response = decodeWireMessage(bytes); + if (response.isErr()) throw response.error; + return codec.dec(response.value.payload.value); +} + +async function call(session: JamPeerTransportSession, ids: MethodIds, request: Uint8Array, codec: S.Codec): Promise { + return decodeReply(await session.handleFrame(frame(ids, request)), codec); +} + +const dialCodec = S.Result(T.VersionedHostJamPeerTransportDialResponse, S.CallError(T.VersionedHostJamPeerTransportDialError)); +const openCodec = S.Result(T.VersionedHostJamPeerTransportOpenResponse, S.CallError(T.VersionedHostJamPeerTransportOpenError)); +const sendCodec = S.Result(T.VersionedHostJamPeerTransportSendResponse, S.CallError(T.VersionedHostJamPeerTransportSendError)); +const recvCodec = S.Result(T.VersionedHostJamPeerTransportRecvResponse, S.CallError(T.VersionedHostJamPeerTransportRecvError)); +const closeCodec = S.Result(T.VersionedHostJamPeerTransportCloseResponse, S.CallError(T.VersionedHostJamPeerTransportCloseError)); +const eventsCodec = S.Result(T.VersionedHostJamPeerTransportEventsResponse, S.CallError(T.VersionedHostJamPeerTransportEventsError)); +const handshakeCodec = S.Result(T.VersionedHostHandshakeResponse, S.CallError(T.VersionedHostHandshakeError)); + +function dialRequest(overrides: Partial = {}): Uint8Array { + return T.VersionedHostJamPeerTransportDialRequest.enc({ + tag: "V1", + value: { genesis: GENESIS, ip: LOOPBACK, port: 43000, ed25519: `0x${"11".repeat(32)}`, p256: P256, ...overrides }, + }); +} + +const OTHER_GENESIS = `0x${"ab".repeat(32)}`; +const NOT_GRANTED = { success: false, value: { tag: "Domain", value: { tag: "V1", value: "NotGranted" } } }; + +async function negotiated( + options: { + /** How the handshake of the `index`-th transport settles. */ + ready?: (index: number) => FakeReady; + authorize?: (genesis: string) => Promise; + dialTimeoutMs?: number; + } = {}, +): Promise<{ session: JamPeerTransportSession; transports: FakeTransport[] }> { + const transports: FakeTransport[] = []; + const session = createJamPeerTransportSession({ + authorize: options.authorize ?? (async (genesis) => genesis === GENESIS), + now: () => 1_790_380_800, + dialTimeoutMs: options.dialTimeoutMs, + connect: (url, hashes) => { + const transport = fakeTransport(url, hashes, options.ready?.(transports.length)); + transports.push(transport); + return transport; + }, + }); + const handshake = await call(session, SYSTEM_HANDSHAKE, T.VersionedHostHandshakeRequest.enc({ tag: "V1", value: { codecVersion: TRUAPI_CODEC_VERSION } }), handshakeCodec); + expect(handshake.success).toBe(true); + return { session, transports }; +} + +async function dialed(): Promise<{ session: JamPeerTransportSession; transport: FakeTransport; conn: number }> { + const { session, transports } = await negotiated(); + const dial = await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); + if (!dial.success) throw new Error("dial failed"); + return { session, transport: transports[0]!, conn: dial.value.value.conn }; +} + +/** Yield one macrotask so stream pumps observe enqueued chunks; 0 ms, not a duration guess. */ +function tick(): Promise { + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, 0); + return promise; +} + +describe("peerUrl", () => { + test("renders v4-mapped and native IPv6 authorities", () => { + expect(peerUrl(S.hexToBytes(LOOPBACK), 43000)).toBe("https://127.0.0.1:43000"); + expect(peerUrl(S.hexToBytes(`0x${"00".repeat(15)}01`), 443)).toBe("https://[0:0:0:0:0:0:0:1]:443"); + }); +}); + +describe("authorization", () => { + const LIMIT = { success: false, value: { tag: "Domain", value: { tag: "V1", value: "Limit" } } }; + + test("permission waits consume connection slots and cancellation releases them before a retry", async () => { + const pending = Promise.withResolvers(); + const asked: string[] = []; + const { session, transports } = await negotiated({ + authorize: (genesis) => { + asked.push(genesis); + return genesis === GENESIS ? pending.promise : Promise.resolve(false); + }, + }); + const dials = Array.from({ length: 8 }, (_, i) => + session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, dialRequest(), `permission-${i}`))); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis: OTHER_GENESIS }), dialCodec)).toEqual(LIMIT); + expect(asked).toEqual([GENESIS]); + expect(transports).toHaveLength(0); + await session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, new Uint8Array(), "permission-0", MESSAGE_TYPE_CANCEL)); + expect(decodeReply(await dials[0]!, dialCodec)).toEqual({ success: false, value: { tag: "Cancelled" } }); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis: OTHER_GENESIS }), dialCodec)).toEqual(NOT_GRANTED); + expect(asked).toEqual([GENESIS, OTHER_GENESIS]); + pending.resolve(true); + expect((await Promise.all(dials.slice(1))).every((bytes) => decodeReply(bytes, dialCodec).success)).toBe(true); + expect((await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual(LIMIT); + expect(transports).toHaveLength(8); + session.close(); + }); + + test("denied and failed distinct-genesis decisions fill the lifetime budget without eviction", async () => { + const asked: string[] = []; + const { session, transports } = await negotiated({ + authorize: async (genesis) => { + asked.push(genesis); + if (asked.length % 2 === 0) throw new Error("dismissed"); + return false; + }, + }); + const networks = Array.from({ length: 9 }, (_, i) => `0x${i.toString(16).padStart(2, "0").repeat(32)}` as const); + for (const genesis of networks.slice(0, 8)) { + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis }), dialCodec)).toEqual(NOT_GRANTED); + } + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis: networks[8]! }), dialCodec)).toEqual(LIMIT); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis: networks[0]! }), dialCodec)).toEqual(NOT_GRANTED); + expect(asked).toEqual(networks.slice(0, 8)); + expect(transports).toHaveLength(0); + session.close(); + }); + + test("cancelled distinct-network prompts stay bounded and close withdraws repeated waiters", async () => { + const pending = Promise.withResolvers(); + const asked: string[] = []; + const { session, transports } = await negotiated({ + authorize: (genesis) => { + asked.push(genesis); + return pending.promise; + }, + }); + const networks = Array.from({ length: 9 }, (_, i) => `0x${i.toString(16).padStart(2, "0").repeat(32)}` as const); + for (const genesis of networks.slice(0, 8)) { + const dial = session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis }), "cancel-prompt")); + await session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, new Uint8Array(), "cancel-prompt", MESSAGE_TYPE_CANCEL)); + expect(decodeReply(await dial, dialCodec)).toEqual({ success: false, value: { tag: "Cancelled" } }); + } + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis: networks[8]! }), dialCodec)).toEqual(LIMIT); + // Repeated cancelled subscribers must not hold operation slots or ask again. + for (let i = 0; i < 16; i++) { + const dial = session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis: networks[0]! }), "retry-prompt")); + await session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, new Uint8Array(), "retry-prompt", MESSAGE_TYPE_CANCEL)); + expect(decodeReply(await dial, dialCodec)).toEqual({ success: false, value: { tag: "Cancelled" } }); + } + const waiting = Array.from({ length: 8 }, () => call(session, JAM_PEER_TRANSPORT_DIAL, + dialRequest({ genesis: networks[0]! }), dialCodec)); + session.close(); + expect(await Promise.all(waiting)).toEqual(Array.from({ length: 8 }, () => ({ success: false, value: { tag: "Denied" } }))); + pending.resolve(true); + await tick(); + expect(asked).toEqual(networks.slice(0, 8)); + expect(transports).toHaveLength(0); + }); + + test("dial before the handshake is NotGranted and asks nothing", async () => { + const asked: string[] = []; + const session = createJamPeerTransportSession({ + authorize: async (genesis) => { + asked.push(genesis); + return true; + }, + connect: () => fakeTransport("", []), + }); + const dial = await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); + expect(dial).toEqual(NOT_GRANTED); + expect(asked).toEqual([]); + }); + + test("a granted genesis is asked once for the whole session", async () => { + const asked: string[] = []; + const { session, transports } = await negotiated({ + authorize: async (genesis) => { + asked.push(genesis); + return true; + }, + }); + for (let i = 0; i < 3; i++) { + expect((await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + } + expect(asked).toEqual([GENESIS]); + expect(transports).toHaveLength(3); + }); + + test("a denied or failed decision is NotGranted, remembered, and connects nothing", async () => { + const asked: string[] = []; + const { session, transports } = await negotiated({ + authorize: async (genesis) => { + asked.push(genesis); + if (genesis === OTHER_GENESIS) throw new Error("prompt dismissed"); + return false; + }, + }); + for (let i = 0; i < 2; i++) { + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual(NOT_GRANTED); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis: OTHER_GENESIS }), dialCodec)).toEqual(NOT_GRANTED); + } + expect(asked).toEqual([GENESIS, OTHER_GENESIS]); + expect(transports).toHaveLength(0); + }); + + test("concurrent dials share one pending decision per genesis", async () => { + const asked: string[] = []; + const pending = new Map void>(); + const { session, transports } = await negotiated({ + authorize: (genesis) => { + asked.push(genesis); + const { promise, resolve } = Promise.withResolvers(); + pending.set(genesis, resolve); + return promise; + }, + }); + const granted = [0, 1, 2].map(() => call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)); + const refused = [0, 1].map(() => call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ genesis: OTHER_GENESIS }), dialCodec)); + await tick(); + expect(asked).toEqual([GENESIS, OTHER_GENESIS]); + expect(transports).toHaveLength(0); + + pending.get(GENESIS)!(true); + pending.get(OTHER_GENESIS)!(false); + expect((await Promise.all(granted)).map((dial) => dial.success)).toEqual([true, true, true]); + expect(await Promise.all(refused)).toEqual([NOT_GRANTED, NOT_GRANTED]); + expect(asked).toEqual([GENESIS, OTHER_GENESIS]); + expect(transports).toHaveLength(3); + }); + + test("closing the session answers a dial still waiting for its decision, and connects nothing", async () => { + const { promise, resolve } = Promise.withResolvers(); + const { session, transports } = await negotiated({ authorize: () => promise }); + const dial = call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); + await tick(); + session.close(); + expect(await dial).toEqual({ success: false, value: { tag: "Denied" } }); + resolve(true); + await tick(); + expect(transports).toHaveLength(0); + }); + + test("a granted dial without p256 is Unreachable in the browser", async () => { + const { session, transports } = await negotiated(); + const quicOnly = await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest({ p256: undefined }), dialCodec); + expect(quicOnly).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Unreachable" } } }); + expect(transports).toHaveLength(0); + }); + + test("frames for other traits are Denied and the trait id is exposed for routing", async () => { + const { session } = await negotiated(); + const bytes = frame({ trait: 20, method: 0, kind: "request" }, new Uint8Array()); + expect(frameTraitId(bytes)).toBe(20); + const response = decodeWireMessage(await session.handleFrame(bytes)); + expect(response.isOk() && S.Result(S._void, S.CallError(S._void)).dec(response.value.payload.value)).toEqual({ success: false, value: { tag: "Denied" } }); + }); +}); + +describe("dial", () => { + test("connects to the peer URL with both serial variants of the three period certificate hashes", async () => { + const { transport, conn } = await dialed(); + expect(conn).toBe(1); + expect(transport.url).toBe("https://127.0.0.1:43000"); + expect(transport.hashes.map((h) => S.bytesToHex(h))).toEqual([ + "0x51fc12ea78bc97eb7d969bd4ff221f2063f205111893cbff22cd9a1b5f8c8ad6", + "0xccf30196b29007b42fca6f406363ce17781bab0f011bac47dcbe307e0e6a316d", + "0x7d89ee4abc9820e55e5f8c3b9cdfb10967391be26707f5d3397bf2bd46cdea08", + "0xeb09b6b027f5953cb8ca2e8f296e21052c3423370876e67f1634180ddf99f1ec", + "0x505c184ed39a7dfa39876a5a1734d118f8fb9ad90932b78d7c90d3a062646224", + "0x8bdfa3a2b7822822f5da33fadfa118d39d05b0a6b1086fc82a62a2209f6fae27", + ]); + }); + + test("a rejected handshake is Refused and holds no connection slot", async () => { + const { session } = await negotiated({ ready: () => "fail" }); + const dial = await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); + expect(dial).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Refused" } } }); + const close = await call(session, JAM_PEER_TRANSPORT_CLOSE, T.VersionedHostJamPeerTransportCloseRequest.enc({ tag: "V1", value: { conn: 1 } }), closeCodec); + expect(close.success).toBe(false); + }); + + test("the ninth connection hits Limit", async () => { + const { session } = await negotiated(); + for (let i = 0; i < 8; i++) { + expect((await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + } + const ninth = await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec); + expect(ninth).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Limit" } } }); + }); +}); + +describe("withdrawn dials", () => { + const UNREACHABLE = { success: false, value: { tag: "Domain", value: { tag: "V1", value: "Unreachable" } } }; + const CANCELLED = { success: false, value: { tag: "Cancelled" } }; + const cancel = (session: JamPeerTransportSession, requestId: string): Promise => + session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, new Uint8Array(), requestId, MESSAGE_TYPE_CANCEL)); + const events = async (session: JamPeerTransportSession): Promise => + call(session, JAM_PEER_TRANSPORT_EVENTS, T.VersionedHostJamPeerTransportEventsRequest.enc({ tag: "V1", value: undefined }), eventsCodec); + + test("a prompt outlasting the guest's wait opens nothing, and the retry reuses the answer", async () => { + const asked: string[] = []; + const decision = Promise.withResolvers(); + const { session, transports } = await negotiated({ + dialTimeoutMs: 20, + authorize: (genesis) => { + asked.push(genesis); + return decision.promise; + }, + }); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual(UNREACHABLE); + // The user answers after the guest stopped waiting: that dial opens nothing. + decision.resolve(true); + await tick(); + expect(transports).toHaveLength(0); + + expect((await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + expect(asked).toEqual([GENESIS]); + expect(transports).toHaveLength(1); + }); + + test("a handshake outlasting the deadline is closed and frees its slot", async () => { + const { session, transports } = await negotiated({ dialTimeoutMs: 20, ready: (index) => (index === 0 ? "hang" : "ok") }); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual(UNREACHABLE); + expect(transports[0]!.closedByHost).toBe(true); + for (let i = 0; i < 8; i++) { + expect((await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + } + expect(await events(session)).toEqual({ success: true, value: { tag: "V1", value: { events: [] } } }); + }); + + test("a CANCEL during the prompt answers Cancelled and opens nothing", async () => { + const decision = Promise.withResolvers(); + const { session, transports } = await negotiated({ authorize: () => decision.promise }); + const dial = session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, dialRequest(), "d1")); + await tick(); + expect(await cancel(session, "d1")).toEqual(new Uint8Array()); + expect(decodeReply(await dial, dialCodec)).toEqual(CANCELLED); + decision.resolve(true); + await tick(); + expect(transports).toHaveLength(0); + // A CANCEL naming nothing in flight lost the race and changes nothing. + expect(await cancel(session, "d1")).toEqual(new Uint8Array()); + expect((await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + }); + + test("a CANCEL during the handshake closes the connection without consuming the cap", async () => { + const { session, transports } = await negotiated({ ready: (index) => (index === 7 ? "hang" : "ok") }); + for (let i = 0; i < 7; i++) { + expect((await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + } + const eighth = session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, dialRequest(), "d8")); + await tick(); + expect(transports).toHaveLength(8); + await cancel(session, "d8"); + expect(decodeReply(await eighth, dialCodec)).toEqual(CANCELLED); + expect(transports[7]!.closedByHost).toBe(true); + + expect((await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).success).toBe(true); + expect(await call(session, JAM_PEER_TRANSPORT_DIAL, dialRequest(), dialCodec)).toEqual({ + success: false, + value: { tag: "Domain", value: { tag: "V1", value: "Limit" } }, + }); + expect(await events(session)).toEqual({ success: true, value: { tag: "V1", value: { events: [] } } }); + }); + + test("a CANCEL for a completed or unknown dial leaves its connection open", async () => { + const { session, transports } = await negotiated(); + const reply = decodeReply(await session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, dialRequest(), "d1")), dialCodec); + expect(reply.success).toBe(true); + expect(await cancel(session, "d1")).toEqual(new Uint8Array()); + expect(await cancel(session, "unknown")).toEqual(new Uint8Array()); + expect(transports[0]!.closedByHost).not.toBe(true); + expect(await events(session)).toEqual({ success: true, value: { tag: "V1", value: { events: [] } } }); + }); +}); + +describe("streams", () => { + test("concurrent opens reserve the sixteen slots before transport creation settles", async () => { + const { session, transport, conn } = await dialed(); + const pending: Array<(stream: WebTransportBidirectionalStreamLike) => void> = []; + transport.createBidirectionalStream = () => { + const { promise, resolve } = Promise.withResolvers(); + pending.push(resolve); + return promise; + }; + const opens = Array.from({ length: 16 }, () => + call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec)); + const excess = await call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + expect(excess).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Limit" } } }); + expect(pending).toHaveLength(16); + for (const resolve of pending) resolve(fakeStream().local); + expect((await Promise.all(opens)).every((result) => result.success)).toBe(true); + session.close(); + }); + + test("an incoming stream waiting for its kind reserves a slot too", async () => { + const { session, transport, conn } = await dialed(); + const incoming = transport.peerOpen(); + await tick(); + for (let i = 0; i < 15; i++) { + const opened = await call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + expect(opened.success).toBe(true); + } + const excess = await call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + expect(excess).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Limit" } } }); + incoming.peerWrite(new Uint8Array([128])); + await tick(); + const events = await call(session, JAM_PEER_TRANSPORT_EVENTS, T.VersionedHostJamPeerTransportEventsRequest.enc({ tag: "V1" }), eventsCodec); + expect(events.success && events.value.value.events).toEqual([{ tag: "Accepted", value: { conn, stream: 16, kind: 128 } }]); + session.close(); + }); + + test("cancelled and closed pending opens cannot publish late transport streams", async () => { + for (const cancel of [true, false]) { + const { session, transport, conn } = await dialed(); + const pending = Promise.withResolvers(); + transport.createBidirectionalStream = () => pending.promise; + const opening = session.handleFrame(frame(JAM_PEER_TRANSPORT_OPEN, + T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), "pending-open")); + if (cancel) { + await session.handleFrame(frame(JAM_PEER_TRANSPORT_OPEN, new Uint8Array(), "pending-open", MESSAGE_TYPE_CANCEL)); + expect(decodeReply(await opening, openCodec)).toEqual({ success: false, value: { tag: "Cancelled" } }); + } else { + await call(session, JAM_PEER_TRANSPORT_CLOSE, T.VersionedHostJamPeerTransportCloseRequest.enc({ tag: "V1", value: { conn } }), closeCodec); + expect(decodeReply(await opening, openCodec)).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Closed" } } }); + } + const late = fakeStream(); + pending.resolve(late.local); + await tick(); + expect(late.sent).toEqual([]); + expect(() => late.peerWrite(new Uint8Array([0]))).toThrow(); + session.close(); + } + }); + + test("CANCEL settles blocked kind writes and message writes without waiting for the peer", async () => { + for (const duringOpen of [true, false]) { + const { session, transport, conn } = await dialed(); + const blocked = Promise.withResolvers(); + const wire = fakeStream((chunk) => duringOpen || chunk.length > 1 ? blocked.promise : Promise.resolve()); + transport.createBidirectionalStream = async () => wire.local; + const opening = session.handleFrame(frame(JAM_PEER_TRANSPORT_OPEN, + T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), "blocked")); + let request = opening; + const ids = duringOpen ? JAM_PEER_TRANSPORT_OPEN : JAM_PEER_TRANSPORT_SEND; + if (!duringOpen) { + const opened = decodeReply(await opening, openCodec); + if (!opened.success) throw new Error("open failed"); + request = session.handleFrame(frame(ids, T.VersionedHostJamPeerTransportSendRequest.enc({ + tag: "V1", value: { stream: opened.value.value.stream, message: "0x01", fin: false }, + }), "blocked")); + } + await tick(); + await session.handleFrame(frame(ids, new Uint8Array(), "blocked", MESSAGE_TYPE_CANCEL)); + expect(decodeReply(await request, S.Result(S._void, S.CallError(S._void)))).toEqual({ success: false, value: { tag: "Cancelled" } }); + blocked.resolve(); + await tick(); + session.close(); + } + }); + + test("cancelled writes retain connection quota until the underlying sink settles", async () => { + const { session, transport, conn } = await dialed(); + const blocked = Promise.withResolvers(); + const sending: Promise[] = []; + for (let i = 0; i < 3; i++) { + const wire = fakeStream((chunk) => chunk.length > 1 ? blocked.promise : Promise.resolve()); + transport.createBidirectionalStream = async () => wire.local; + const opened = await call(session, JAM_PEER_TRANSPORT_OPEN, + T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + if (!opened.success) throw new Error("open failed"); + sending.push(session.handleFrame(frame(JAM_PEER_TRANSPORT_SEND, + T.VersionedHostJamPeerTransportSendRequest.enc({ + tag: "V1", value: { stream: opened.value.value.stream, message: `0x${"00".repeat(JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES)}`, fin: false }, + }), `retained-${i}`))); + } + await tick(); + for (let i = 0; i < sending.length; i++) { + await session.handleFrame(frame(JAM_PEER_TRANSPORT_SEND, new Uint8Array(), `retained-${i}`, MESSAGE_TYPE_CANCEL)); + expect(decodeReply(await sending[i]!, sendCodec)).toEqual({ success: false, value: { tag: "Cancelled" } }); + } + transport.createBidirectionalStream = async () => fakeStream().local; + const opened = await call(session, JAM_PEER_TRANSPORT_OPEN, + T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + if (!opened.success) throw new Error("open failed"); + const request = T.VersionedHostJamPeerTransportSendRequest.enc({ + tag: "V1", value: { stream: opened.value.value.stream, message: `0x${"00".repeat(JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES)}`, fin: false }, + }); + expect(await call(session, JAM_PEER_TRANSPORT_SEND, request, sendCodec)) + .toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Limit" } } }); + blocked.resolve(); + await tick(); + expect((await call(session, JAM_PEER_TRANSPORT_SEND, request, sendCodec)).success).toBe(true); + session.close(); + }); + + test("receive backpressure counts frames across streams and resumes after recv releases space", async () => { + const { session, transport, conn } = await dialed(); + const ids: number[] = []; + const length = JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION / 4 - 4; + for (let i = 0; i < 2; i++) { + const opened = await call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + if (!opened.success) throw new Error("open failed"); + ids.push(opened.value.value.stream); + for (let message = 0; message < (i === 0 ? 4 : 1); message++) { + const bytes = new Uint8Array((i === 0 ? length : 1) + 4); + new DataView(bytes.buffer).setUint32(0, bytes.length - 4, true); + transport.streams[i]!.peerWrite(bytes); + } + await tick(); + } + const receive = (stream: number) => call(session, JAM_PEER_TRANSPORT_RECV, + T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES } }), recvCodec); + expect(await receive(ids[1]!)).toEqual({ success: true, value: { tag: "V1", value: { message: undefined, fin: false, reset: false } } }); + const first = await receive(ids[0]!); + expect(first.success && first.value.value.message?.length).toBe(2 + length * 2); + await tick(); + expect(await receive(ids[1]!)).toEqual({ success: true, value: { tag: "V1", value: { message: "0x00", fin: false, reset: false } } }); + session.close(); + }); + + test("FIN excludes concurrent sends before its blocked write completes", async () => { + const { session, transport, conn } = await dialed(); + const blocked = Promise.withResolvers(); + const wire = fakeStream((chunk) => chunk.length > 1 ? blocked.promise : Promise.resolve()); + transport.createBidirectionalStream = async () => wire.local; + const opened = await call(session, JAM_PEER_TRANSPORT_OPEN, + T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + if (!opened.success) throw new Error("open failed"); + const stream = opened.value.value.stream; + const finishing = call(session, JAM_PEER_TRANSPORT_SEND, + T.VersionedHostJamPeerTransportSendRequest.enc({ tag: "V1", value: { stream, message: "0x01", fin: true } }), sendCodec); + const following = call(session, JAM_PEER_TRANSPORT_SEND, + T.VersionedHostJamPeerTransportSendRequest.enc({ tag: "V1", value: { stream, message: "0x02", fin: false } }), sendCodec); + blocked.resolve(); + expect((await finishing).success).toBe(true); + expect(await following).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Closed" } } }); + expect(wire.sent).toHaveLength(2); + session.close(); + }); + + test("a peer withholding one kind byte does not block other incoming streams", async () => { + const { session, transport, conn } = await dialed(); + transport.peerOpen(); + const ready = transport.peerOpen(); + ready.peerWrite(new Uint8Array([128])); + await tick(); + const events = await call(session, JAM_PEER_TRANSPORT_EVENTS, + T.VersionedHostJamPeerTransportEventsRequest.enc({ tag: "V1" }), eventsCodec); + expect(events.success && events.value.value.events).toEqual([ + { tag: "Accepted", value: { conn, stream: 1, kind: 128 } }, + ]); + session.close(); + }); + + test("a final send releases a stream whose receive side was already consumed", async () => { + const { session, transport, conn } = await dialed(); + for (let i = 0; i < 17; i++) { + const opened = await call(session, JAM_PEER_TRANSPORT_OPEN, + T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + if (!opened.success) throw new Error("completed stream retained its slot"); + const stream = opened.value.value.stream; + transport.streams[i]!.peerFin(); + await tick(); + expect(await call(session, JAM_PEER_TRANSPORT_RECV, + T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 } }), recvCodec)) + .toEqual({ success: true, value: { tag: "V1", value: { message: undefined, fin: true, reset: false } } }); + expect((await call(session, JAM_PEER_TRANSPORT_SEND, + T.VersionedHostJamPeerTransportSendRequest.enc({ tag: "V1", value: { stream, message: "0x", fin: true } }), sendCodec)).success).toBe(true); + } + session.close(); + }); + + test("open sends the kind byte; send frames with a u32-LE prefix; recv unframes", async () => { + const { session, transport, conn } = await dialed(); + const open = await call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 128 } }), openCodec); + if (!open.success) throw new Error("open failed"); + const stream = open.value.value.stream; + const send = await call(session, JAM_PEER_TRANSPORT_SEND, T.VersionedHostJamPeerTransportSendRequest.enc({ tag: "V1", value: { stream, message: "0x0102", fin: true } }), sendCodec); + expect(send.success).toBe(true); + const wire = transport.streams[0]!; + expect(wire.sent.map((c) => S.bytesToHex(c))).toEqual(["0x80", "0x020000000102"]); + + // Peer replies with two messages split across arbitrary chunk boundaries, then FIN. + wire.peerWrite(new Uint8Array([3, 0, 0, 0, 0xaa])); + wire.peerWrite(new Uint8Array([0xbb, 0xcc, 1, 0, 0])); + await tick(); + const early = await call(session, JAM_PEER_TRANSPORT_RECV, T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 << 20 } }), recvCodec); + expect(early).toEqual({ success: true, value: { tag: "V1", value: { message: "0xaabbcc", fin: false, reset: false } } }); + wire.peerWrite(new Uint8Array([0, 0xdd])); + wire.peerFin(); + await tick(); + await tick(); + const second = await call(session, JAM_PEER_TRANSPORT_RECV, T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 << 20 } }), recvCodec); + expect(second).toEqual({ success: true, value: { tag: "V1", value: { message: "0xdd", fin: true, reset: false } } }); + const drained = await call(session, JAM_PEER_TRANSPORT_RECV, T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 << 20 } }), recvCodec); + expect(drained).toEqual({ success: true, value: { tag: "V1", value: { message: undefined, fin: true, reset: false } } }); + const consumed = await call(session, JAM_PEER_TRANSPORT_RECV, T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 << 20 } }), recvCodec); + expect(consumed).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Closed" } } }); + const events = await call(session, JAM_PEER_TRANSPORT_EVENTS, T.VersionedHostJamPeerTransportEventsRequest.enc({ tag: "V1" }), eventsCodec); + expect(events).toEqual({ success: true, value: { tag: "V1", value: { events: [{ tag: "StreamFin", value: { stream } }] } } }); + }); + + test("oversized send is TooLarge and a message above the caller's max resets the stream", async () => { + const { session, transport, conn } = await dialed(); + const open = await call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + if (!open.success) throw new Error("open failed"); + const stream = open.value.value.stream; + const big = `0x${"00".repeat(JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES + 1)}` as const; + const send = await call(session, JAM_PEER_TRANSPORT_SEND, T.VersionedHostJamPeerTransportSendRequest.enc({ tag: "V1", value: { stream, message: big, fin: false } }), sendCodec); + expect(send).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "TooLarge" } } }); + transport.streams[0]!.peerWrite(new Uint8Array([2, 0, 0, 0, 1, 2])); + await tick(); + const recv = await call(session, JAM_PEER_TRANSPORT_RECV, T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream, max: 1 } }), recvCodec); + expect(recv).toEqual({ success: true, value: { tag: "V1", value: { message: undefined, fin: false, reset: true } } }); + }); + + test("peer-opened streams surface as Accepted with their kind byte", async () => { + const { session, transport, conn } = await dialed(); + const incoming = transport.peerOpen(); + incoming.peerWrite(new Uint8Array([0, 2, 0, 0, 0, 9, 9])); + await tick(); + await tick(); + const events = await call(session, JAM_PEER_TRANSPORT_EVENTS, T.VersionedHostJamPeerTransportEventsRequest.enc({ tag: "V1" }), eventsCodec); + expect(events).toEqual({ success: true, value: { tag: "V1", value: { events: [{ tag: "Accepted", value: { conn, stream: 1, kind: 0 } }] } } }); + const recv = await call(session, JAM_PEER_TRANSPORT_RECV, T.VersionedHostJamPeerTransportRecvRequest.enc({ tag: "V1", value: { stream: 1, max: 1 << 20 } }), recvCodec); + expect(recv).toEqual({ success: true, value: { tag: "V1", value: { message: "0x0909", fin: false, reset: false } } }); + }); + + test("a peer close reports ConnClosed and invalidates streams; session close denies everything", async () => { + const { session, transport, conn } = await dialed(); + const open = await call(session, JAM_PEER_TRANSPORT_OPEN, T.VersionedHostJamPeerTransportOpenRequest.enc({ tag: "V1", value: { conn, kind: 0 } }), openCodec); + if (!open.success) throw new Error("open failed"); + transport.peerClose(); + await tick(); + await tick(); + const events = await call(session, JAM_PEER_TRANSPORT_EVENTS, T.VersionedHostJamPeerTransportEventsRequest.enc({ tag: "V1" }), eventsCodec); + expect(events).toEqual({ success: true, value: { tag: "V1", value: { events: [{ tag: "ConnClosed", value: { conn } }] } } }); + const send = await call(session, JAM_PEER_TRANSPORT_SEND, T.VersionedHostJamPeerTransportSendRequest.enc({ tag: "V1", value: { stream: open.value.value.stream, message: "0x00", fin: false } }), sendCodec); + expect(send).toEqual({ success: false, value: { tag: "Domain", value: { tag: "V1", value: "Closed" } } }); + session.close(); + const response = decodeWireMessage(await session.handleFrame(frame(JAM_PEER_TRANSPORT_DIAL, dialRequest()))); + expect(response.isOk() && S.Result(S._void, S.CallError(S._void)).dec(response.value.payload.value)).toEqual({ success: false, value: { tag: "Denied" } }); + }); +}); diff --git a/js/packages/truapi/src/jam-peer-transport.ts b/js/packages/truapi/src/jam-peer-transport.ts new file mode 100644 index 0000000000..4ccf5831d5 --- /dev/null +++ b/js/packages/truapi/src/jam-peer-transport.ts @@ -0,0 +1,735 @@ +import * as S from "./scale.js"; +import * as T from "./generated/types.js"; +import { TRUAPI_CODEC_VERSION } from "./generated/client.js"; +import { + JAM_PEER_TRANSPORT_CLOSE, + JAM_PEER_TRANSPORT_DIAL, + JAM_PEER_TRANSPORT_EVENTS, + JAM_PEER_TRANSPORT_OPEN, + JAM_PEER_TRANSPORT_RECV, + JAM_PEER_TRANSPORT_RESET, + JAM_PEER_TRANSPORT_SEND, + SYSTEM_HANDSHAKE, +} from "./generated/wire-table.js"; +import { + decodeWireMessage, + encodeWireMessage, + MESSAGE_TYPE_CANCEL, + MESSAGE_TYPE_REQUEST, + MESSAGE_TYPE_RESPONSE, + type MethodIds, + type ProtocolMessage, +} from "./transport.js"; +import { webTransportCertificateHashes } from "./jam-peer-transport-cert.js"; + +/** Caps mirrored from `truapi::v01::jam_peer_transport`. */ +export const JAM_PEER_TRANSPORT_MAX_CONNECTIONS = 8; +export const JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION = 16; +export const JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES = 1 << 20; +export const JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION = 4 << 20; +/** Largest request frame: a `send` of a maximal message plus SCALE and wire overhead. */ +export const JAM_PEER_TRANSPORT_MAX_FRAME_BYTES = JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES + 4096; +const MAX_PENDING_EVENTS = 1024; +/** + * Bound on one `dial`, from its arrival to its reply, the permission decision + * included. A guest that waits at least this long for a dial reply never + * misses one, and anything a dial would open after it is closed instead. + */ +export const JAM_PEER_TRANSPORT_DIAL_TIMEOUT_MS = 10_000; +const textEncoder = new TextEncoder(); + +const handshakeResult = S.Result(T.VersionedHostHandshakeResponse, S.CallError(T.VersionedHostHandshakeError)); +const frameworkResult = S.Result(S._void, S.CallError(S._void)); +const dialResult = S.Result(T.VersionedHostJamPeerTransportDialResponse, S.CallError(T.VersionedHostJamPeerTransportDialError)); +const openResult = S.Result(T.VersionedHostJamPeerTransportOpenResponse, S.CallError(T.VersionedHostJamPeerTransportOpenError)); +const sendResult = S.Result(T.VersionedHostJamPeerTransportSendResponse, S.CallError(T.VersionedHostJamPeerTransportSendError)); +const recvResult = S.Result(T.VersionedHostJamPeerTransportRecvResponse, S.CallError(T.VersionedHostJamPeerTransportRecvError)); +const resetResult = S.Result(T.VersionedHostJamPeerTransportResetResponse, S.CallError(T.VersionedHostJamPeerTransportResetError)); +const closeResult = S.Result(T.VersionedHostJamPeerTransportCloseResponse, S.CallError(T.VersionedHostJamPeerTransportCloseError)); +const eventsResult = S.Result(T.VersionedHostJamPeerTransportEventsResponse, S.CallError(T.VersionedHostJamPeerTransportEventsError)); +const cancelledReply = frameworkResult.enc({ success: false, value: { tag: "Cancelled" } }); + +/** Minimal WebTransport surface the session needs; lets tests inject a fake. */ +export interface WebTransportLike { + readonly ready: Promise; + readonly closed: Promise; + readonly incomingBidirectionalStreams: ReadableStream; + createBidirectionalStream(): Promise; + close(): void; +} + +export interface WebTransportBidirectionalStreamLike { + readonly readable: ReadableStream; + readonly writable: WritableStream; +} + +export interface JamPeerTransportOptions { + /** + * Decide whether this execution may dial peers of `genesis`, a `0x`-prefixed + * lower-case 32-byte genesis header hash: the host's check of the + * `RemotePermission::JamPeers` runtime permission. The session asks at most + * once per genesis and concurrent dials share the pending answer; `false` or + * a rejection answers `NotGranted` for the rest of the session. + * At most eight distinct genesis decisions, including pending/refused ones, + * are retained per session; a new ninth genesis answers `Limit`. + */ + authorize(genesis: string): Promise; + /** Host transport injection; defaults to the browser `WebTransport` constructor. */ + connect?: (url: string, certificateHashes: Uint8Array[]) => WebTransportLike; + /** Unix seconds used to select certificate validity periods; defaults to the wall clock. */ + now?: () => number; + /** Dial deadline in milliseconds; defaults to {@link JAM_PEER_TRANSPORT_DIAL_TIMEOUT_MS}. */ + dialTimeoutMs?: number; +} + +/** Execution-local peer endpoint. It provides no account or signing authority. */ +export interface JamPeerTransportSession { + /** Handle one request frame; CANCEL frames return zero bytes. */ + handleFrame(frame: Uint8Array): Promise; + /** Close every connection on stop or replacement and refuse further requests. */ + close(): void; +} + +/** Trait id of a request frame, or `undefined` when it does not decode. */ +export function frameTraitId(frame: Uint8Array): number | undefined { + const decoded = decodeWireMessage(frame); + return decoded.isOk() ? decoded.value.payload.traitId : undefined; +} + +function exact(codec: S.Codec, bytes: Uint8Array): V { + const value = codec.dec(bytes); + const canonical = codec.enc(value); + if (canonical.length !== bytes.length || canonical.some((byte, index) => byte !== bytes[index])) { + throw new Error("Noncanonical or trailing SCALE bytes"); + } + return value; +} + +function decodeFrame(bytes: Uint8Array): ProtocolMessage { + if (!(bytes instanceof Uint8Array) || bytes.length > JAM_PEER_TRANSPORT_MAX_FRAME_BYTES) { + throw new Error("Invalid or oversized peer-transport frame"); + } + const decoded = decodeWireMessage(bytes); + if (decoded.isErr()) throw decoded.error; + const message = decoded.value; + if (textEncoder.encode(message.requestId).length > 64) throw new Error("Oversized request id"); + const encoded = encodeWireMessage(message); + if (encoded.isErr()) throw encoded.error; + if (encoded.value.length !== bytes.length || encoded.value.some((byte, index) => byte !== bytes[index])) { + throw new Error("Noncanonical request frame"); + } + return message; +} + +function hasIds(message: ProtocolMessage, ids: MethodIds): boolean { + return message.payload.traitId === ids.trait && message.payload.methodId === ids.method; +} + +function reply(request: ProtocolMessage, value: Uint8Array): Uint8Array { + const encoded = encodeWireMessage({ + requestId: request.requestId, + payload: { ...request.payload, messageType: MESSAGE_TYPE_RESPONSE, value }, + }); + if (encoded.isErr()) throw encoded.error; + return encoded.value; +} + +function ok(codec: S.Codec>, value: NoInfer): Uint8Array { + return codec.enc({ success: true, value }); +} + +function domain(codec: S.Codec>>, error: E): Uint8Array { + return codec.enc({ success: false, value: { tag: "Domain", value: { tag: "V1", value: error } } }); +} + +/** `https://` authority for a 16-byte IPv6 or v4-mapped address. */ +export function peerUrl(ip: Uint8Array, port: number): string { + if (ip.length !== 16) throw new Error("peer ip must be 16 bytes"); + const v4Mapped = ip.subarray(0, 10).every((byte) => byte === 0) && ip[10] === 0xff && ip[11] === 0xff; + if (v4Mapped) return `https://${ip[12]}.${ip[13]}.${ip[14]}.${ip[15]}:${port}`; + const groups: string[] = []; + for (let i = 0; i < 16; i += 2) groups.push(((ip[i]! << 8) | ip[i + 1]!).toString(16)); + return `https://[${groups.join(":")}]:${port}`; +} + +interface PeerStream { + id: number; + conn: PeerConnection; + writer: WritableStreamDefaultWriter; + reader: ReadableStreamBYOBReader; + /** Reserved receive bytes, including each message's length prefix. */ + rxBytes: number; + /** Complete messages not yet delivered by `recv`. */ + messages: Uint8Array[]; + fin: boolean; + reset: boolean; + /** `recv` reported `fin` with an empty queue; further reads are `Closed`. */ + rxConsumed: boolean; + txClosed: boolean; + onClose: Set<() => void>; +} + +interface PeerConnection { + id: number; + transport: WebTransportLike; + streams: Map; + opening: number; + rxBytes: number; + /** Outgoing frames retain their reservation until writes settle, even after stream removal. */ + txBytes: number; + rxWaiters: Set<() => void>; + onClose: Set<() => void>; + closed: boolean; +} + +interface PendingRequest { + method: number; + response?: Uint8Array; + promise: Promise; + withdraw(response: Uint8Array): void; +} + +/** Fill exactly one header/payload without reading or allocating the following message. */ +async function readExact(reader: ReadableStreamBYOBReader, length: number): Promise { + let bytes = new Uint8Array(length); + let offset = 0; + while (offset < length) { + const { value, done } = await reader.read(bytes.subarray(offset)); + if (value !== undefined) { + bytes = new Uint8Array(value.buffer); + offset += value.byteLength; + } + if (done) { + if (offset !== 0) throw new Error("Truncated peer message"); + return undefined; + } + } + return bytes; +} + +/** + * Create the browser JamPeerTransport endpoint for one execution. Every `dial` + * is authorized for its genesis through `options.authorize` before anything + * connects; the other methods act only on connections an authorized dial + * opened. A dial answers within its deadline, prompt included: one still + * waiting then answers `Unreachable`, a CANCEL naming it answers `Cancelled`, + * and in both cases whatever it opened is closed without holding a slot. The + * permission decision is remembered either way, so a retry does not ask + * again. The host must fence late replies against execution stop or + * replacement. + * Pending permission/handshake dials share the eight-connection admission + * budget with established connections, before any permission request is made. + */ +export function createJamPeerTransportSession(options: JamPeerTransportOptions): JamPeerTransportSession { + const decisions = new Map void>; + }>(); + const authorized = async (genesis: string, withdrawn: Promise): Promise => { + let decision = decisions.get(genesis); + if (decision === undefined) { + decision = { waiters: new Set() }; + decisions.set(genesis, decision); + const current = decision; + void (async () => { + let granted = false; + try { + granted = (await options.authorize(genesis)) === true; + } catch { + // A failed or dismissed permission request grants nothing. + } + if (closed) return; + current.result = granted; + for (const settle of current.waiters) settle(granted); + current.waiters.clear(); + })(); + } + if (decision.result !== undefined) return decision.result; + // Only live dials subscribe; repeated cancellation cannot accumulate reactions + // on the retained, potentially indefinitely pending permission request. + let settle!: (granted: boolean) => void; + const answer = new Promise((resolve) => { settle = resolve; }); + decision.waiters.add(settle); + try { + return await Promise.race([answer, withdrawn]); + } finally { + decision.waiters.delete(settle); + } + }; + const connect = + options.connect ?? + ((url, hashes): WebTransportLike => + new WebTransport(url, { + serverCertificateHashes: hashes.map((value) => ({ algorithm: "sha-256", value: value as Uint8Array })), + }) as unknown as WebTransportLike); + const now = options.now ?? ((): number => Math.floor(Date.now() / 1000)); + const dialTimeoutMs = options.dialTimeoutMs ?? JAM_PEER_TRANSPORT_DIAL_TIMEOUT_MS; + /** Every asynchronous request remains addressable until its reply is settled. */ + const pendingRequests = new Map(); + let closed = false; + let negotiated = false; + let nextConn = 1; + let nextStream = 1; + const connections = new Map(); + let pendingDialSlots = 0; + const streams = new Map(); + const events: T.JamPeerTransportEvent[] = []; + + const pushEvent = (event: T.JamPeerTransportEvent): void => { + if (events.length < MAX_PENDING_EVENTS) events.push(event); + }; + + const wakeReaders = (conn: PeerConnection): void => { + for (const wake of conn.rxWaiters) wake(); + conn.rxWaiters.clear(); + }; + + const releaseReceived = (stream: PeerStream): void => { + stream.conn.rxBytes -= stream.rxBytes; + stream.rxBytes = 0; + stream.messages.length = 0; + wakeReaders(stream.conn); + }; + + const abortReceive = (stream: PeerStream): void => { + stream.reset = true; + for (const close of stream.onClose) close(); + stream.onClose.clear(); + releaseReceived(stream); + void stream.writer.abort().catch(() => undefined); + void stream.reader.cancel().catch(() => undefined); + }; + + const abortBidi = (bidi: WebTransportBidirectionalStreamLike): void => { + void bidi.writable.abort().catch(() => undefined); + void bidi.readable.cancel().catch(() => undefined); + }; + + const dropStream = (stream: PeerStream, abort: boolean): void => { + streams.delete(stream.id); + stream.conn.streams.delete(stream.id); + for (const close of stream.onClose) close(); + stream.onClose.clear(); + releaseReceived(stream); + if (abort) { + stream.reset = true; + void stream.writer.abort().catch(() => undefined); + void stream.reader.cancel().catch(() => undefined); + } + }; + + const dropConnection = (conn: PeerConnection): void => { + if (conn.closed) return; + conn.closed = true; + connections.delete(conn.id); + for (const close of conn.onClose) close(); + conn.onClose.clear(); + wakeReaders(conn); + for (const stream of [...conn.streams.values()]) dropStream(stream, true); + try { + conn.transport.close(); + } catch { + // Already closed by the peer. + } + pushEvent({ tag: "ConnClosed", value: { conn: conn.id } }); + }; + + const reserveReceive = async (stream: PeerStream, bytes: number): Promise => { + while (!stream.reset && !stream.conn.closed) { + if (stream.conn.rxBytes + stream.conn.txBytes + bytes <= JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION) { + stream.rxBytes += bytes; + stream.conn.rxBytes += bytes; + return true; + } + // The package targets ES2022, before Promise.withResolvers. + await new Promise((resolve) => stream.conn.rxWaiters.add(resolve)); + } + return false; + }; + + const pump = async (stream: PeerStream): Promise => { + try { + while (!stream.reset && !stream.conn.closed) { + if (!await reserveReceive(stream, 4)) return; + const header = await readExact(stream.reader, 4); + if (stream.reset || stream.conn.closed) return; + if (header === undefined) { + stream.rxBytes -= 4; + stream.conn.rxBytes -= 4; + wakeReaders(stream.conn); + stream.fin = true; + if (streams.has(stream.id)) pushEvent({ tag: "StreamFin", value: { stream: stream.id } }); + return; + } + const length = new DataView(header.buffer, header.byteOffset, 4).getUint32(0, true); + if (length > JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES) { + abortReceive(stream); + return; + } + if (!await reserveReceive(stream, length)) return; + const message = await readExact(stream.reader, length); + if (stream.reset || stream.conn.closed) return; + if (message === undefined) throw new Error("Truncated peer message"); + stream.messages.push(message); + } + } catch { + if (!stream.reset && !stream.conn.closed) abortReceive(stream); + } + }; + + const register = (conn: PeerConnection, bidi: WebTransportBidirectionalStreamLike, reader = bidi.readable.getReader({ mode: "byob" })): PeerStream => { + const stream: PeerStream = { + id: nextStream++, + conn, + writer: bidi.writable.getWriter(), + reader, + rxBytes: 0, + messages: [], + fin: false, + reset: false, + rxConsumed: false, + txClosed: false, + onClose: new Set(), + }; + streams.set(stream.id, stream); + conn.streams.set(stream.id, stream); + void pump(stream); + return stream; + }; + + const acceptLoop = async (conn: PeerConnection): Promise => { + const incoming = conn.transport.incomingBidirectionalStreams.getReader(); + const stop = (): void => { void incoming.cancel().catch(() => undefined); }; + conn.onClose.add(stop); + try { + while (!conn.closed) { + const { value: bidi, done } = await incoming.read(); + if (done) break; + if (conn.closed || conn.streams.size + conn.opening >= JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION) { + abortBidi(bidi); + continue; + } + conn.opening++; + const reader = bidi.readable.getReader({ mode: "byob" }); + const cancel = (): void => { + void reader.cancel().catch(() => undefined); + void bidi.writable.abort().catch(() => undefined); + }; + conn.onClose.add(cancel); + // A peer that withholds one kind byte must not block the other reserved streams. + void (async () => { + try { + const kind = await readExact(reader, 1); + if (kind === undefined || conn.closed || events.length >= MAX_PENDING_EVENTS) { + cancel(); + return; + } + const stream = register(conn, bidi, reader); + pushEvent({ tag: "Accepted", value: { conn: conn.id, stream: stream.id, kind: kind[0]! } }); + } catch { + cancel(); + } finally { + conn.opening--; + conn.onClose.delete(cancel); + } + })(); + } + } catch { + // The connection is closing; `closed` handling reports it. + } finally { + conn.onClose.delete(stop); + incoming.releaseLock(); + } + }; + + /** + * One dial. `withdrawn` settles with the reply when the guest cancels or the + * deadline passes; the guest then no longer waits for what this dial opens, + * so nothing it opens outlives it or holds a connection slot. + */ + const dial = async (request: T.HostJamPeerTransportDialRequest, withdrawn: Promise): Promise => { + if (connections.size + pendingDialSlots >= JAM_PEER_TRANSPORT_MAX_CONNECTIONS || + (!decisions.has(request.genesis) && decisions.size >= JAM_PEER_TRANSPORT_MAX_CONNECTIONS)) { + return domain(dialResult, "Limit"); + } + pendingDialSlots++; + let granted: boolean | Uint8Array; + try { + granted = await authorized(request.genesis, withdrawn); + } finally { + pendingDialSlots--; + } + if (granted instanceof Uint8Array) return granted; + if (!granted) return domain(dialResult, "NotGranted"); + if (closed) return frameworkResult.enc({ success: false, value: { tag: "Denied" } }); + if (connections.size >= JAM_PEER_TRANSPORT_MAX_CONNECTIONS) return domain(dialResult, "Limit"); + // Browsers only expose WebTransport; JAMNP-S QUIC needs the P-256 identity. + if (request.p256 === undefined) return domain(dialResult, "Unreachable"); + const p256 = S.hexToBytes(request.p256); + if (p256.length !== 33 || (p256[0] !== 2 && p256[0] !== 3)) return domain(dialResult, "Refused"); + let transport: WebTransportLike; + try { + transport = connect(peerUrl(S.hexToBytes(request.ip), request.port), webTransportCertificateHashes(p256, now())); + } catch { + return domain(dialResult, "Unreachable"); + } + const conn: PeerConnection = { + id: nextConn++, transport, streams: new Map(), closed: false, + opening: 0, rxBytes: 0, txBytes: 0, rxWaiters: new Set(), onClose: new Set(), + }; + connections.set(conn.id, conn); + const failure = await Promise.race([ + transport.ready.then( + () => undefined, + () => domain(dialResult, "Refused"), + ), + withdrawn, + ]); + if (failure !== undefined || closed) { + // The guest never learns this connection id, so it frees its slot + // without a `ConnClosed` event. + connections.delete(conn.id); + conn.closed = true; + try { + transport.close(); + } catch { + // Never opened. + } + return failure ?? frameworkResult.enc({ success: false, value: { tag: "Denied" } }); + } + void transport.closed.then( + () => dropConnection(conn), + () => dropConnection(conn), + ); + void acceptLoop(conn); + return ok(dialResult, { tag: "V1", value: { conn: conn.id } }); + }; + + const requestFrame = async ( + request: ProtocolMessage, + run: (pending: PendingRequest) => Promise, + timeout?: number, + ): Promise => { + // Executor form is required by this package's ES2022 target. + let resolve!: (response: Uint8Array) => void; + const promise = new Promise((settle) => { resolve = settle; }); + const pending: PendingRequest = { + method: request.payload.methodId, + promise, + withdraw(response) { + if (pending.response !== undefined) return; + pending.response = response; + resolve(response); + }, + }; + pendingRequests.set(request.requestId, pending); + const timer = timeout === undefined ? undefined : + setTimeout(() => pending.withdraw(domain(dialResult, "Unreachable")), timeout); + try { + return await run(pending); + } finally { + clearTimeout(timer); + pendingRequests.delete(request.requestId); + } + }; + + const open = async (request: T.HostJamPeerTransportOpenRequest, pending: PendingRequest): Promise => { + const conn = connections.get(request.conn); + if (conn === undefined || conn.closed) return domain(openResult, "Closed"); + if (conn.streams.size + conn.opening >= JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION) return domain(openResult, "Limit"); + conn.opening++; + let reserved = true; + const close = (): void => pending.withdraw(domain(openResult, "Closed")); + conn.onClose.add(close); + let stream: PeerStream | undefined; + try { + const opening = conn.transport.createBidirectionalStream(); + void opening.then((bidi) => { + if (pending.response !== undefined) abortBidi(bidi); + }, () => undefined); + const bidi = await Promise.race([opening, pending.promise]); + if (bidi instanceof Uint8Array) return bidi; + if (pending.response !== undefined || conn.closed) { + abortBidi(bidi); + return pending.response ?? domain(openResult, "Closed"); + } + conn.opening--; + reserved = false; + stream = register(conn, bidi); + stream.onClose.add(close); + const result = await Promise.race([stream.writer.write(new Uint8Array([request.kind])), pending.promise]); + if (result instanceof Uint8Array) return result; + return ok(openResult, { tag: "V1", value: { stream: stream.id } }); + } catch { + if (stream !== undefined) dropStream(stream, true); + return domain(openResult, "Closed"); + } finally { + if (reserved) conn.opening--; + conn.onClose.delete(close); + stream?.onClose.delete(close); + if (pending.response !== undefined && stream !== undefined) dropStream(stream, true); + } + }; + + const send = async (request: T.HostJamPeerTransportSendRequest, pending: PendingRequest): Promise => { + const stream = streams.get(request.stream); + if (stream === undefined || stream.txClosed || stream.reset || stream.conn.closed) return domain(sendResult, "Closed"); + const message = S.hexToBytes(request.message); + if (message.length > JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES) return domain(sendResult, "TooLarge"); + if (stream.conn.rxBytes + stream.conn.txBytes + message.length + 4 > JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION) return domain(sendResult, "Limit"); + const frame = new Uint8Array(4 + message.length); + new DataView(frame.buffer).setUint32(0, message.length, true); + frame.set(message, 4); + stream.conn.txBytes += frame.length; + const close = (): void => pending.withdraw(domain(sendResult, "Closed")); + stream.onClose.add(close); + // Reserve FIN before yielding, so a concurrent send cannot pass it. + if (request.fin) stream.txClosed = true; + try { + // Cancellation settles the guest request immediately, but a browser + // write can still retain its frame until the underlying sink settles. + const writing = stream.writer.write(frame).finally(() => { + stream.conn.txBytes -= frame.length; + wakeReaders(stream.conn); + }); + let result = await Promise.race([writing, pending.promise]); + if (result instanceof Uint8Array) return result; + if (request.fin) { + result = await Promise.race([stream.writer.close(), pending.promise]); + if (result instanceof Uint8Array) return result; + if (stream.rxConsumed) { + stream.onClose.delete(close); + dropStream(stream, false); + } + } + return ok(sendResult, { tag: "V1" }); + } catch { + stream.txClosed = true; + return domain(sendResult, "Closed"); + } finally { + stream.onClose.delete(close); + if (pending.response !== undefined) dropStream(stream, true); + } + }; + + const recv = (request: T.HostJamPeerTransportRecvRequest): Uint8Array => { + const stream = streams.get(request.stream); + if (stream === undefined || stream.rxConsumed) return domain(recvResult, "Closed"); + const next = stream.messages[0]; + if (next !== undefined && next.length > request.max) { + // The guest cannot take this message; treat it as a protocol violation. + abortReceive(stream); + } + let message: S.HexString | undefined; + if (!stream.reset && next !== undefined) { + stream.messages.shift(); + stream.rxBytes -= next.length + 4; + stream.conn.rxBytes -= next.length + 4; + wakeReaders(stream.conn); + message = S.bytesToHex(next); + } + const drained = stream.messages.length === 0; + const fin = stream.fin && drained; + const reset = stream.reset; + if (message === undefined && (fin || reset)) { + stream.rxConsumed = true; + if (stream.txClosed || reset) dropStream(stream, reset); + } + return ok(recvResult, { tag: "V1", value: { message, fin, reset } }); + }; + + const reset = (request: T.HostJamPeerTransportResetRequest): Uint8Array => { + const stream = streams.get(request.stream); + if (stream === undefined) return domain(resetResult, "Closed"); + dropStream(stream, true); + return ok(resetResult, { tag: "V1" }); + }; + + const close = (request: T.HostJamPeerTransportCloseRequest): Uint8Array => { + const conn = connections.get(request.conn); + if (conn === undefined || conn.closed) return domain(closeResult, "Closed"); + dropConnection(conn); + return ok(closeResult, { tag: "V1" }); + }; + + return { + async handleFrame(bytes) { + const request = decodeFrame(bytes); + if (request.payload.messageType === MESSAGE_TYPE_CANCEL) { + if (request.payload.traitId !== JAM_PEER_TRANSPORT_DIAL.trait || request.payload.value.length !== 0) { + throw new Error("Invalid cancellation frame"); + } + const pending = pendingRequests.get(request.requestId); + if (pending?.method === request.payload.methodId) pending.withdraw(cancelledReply); + return new Uint8Array(); + } + if (request.payload.messageType !== MESSAGE_TYPE_REQUEST) { + throw new Error("Invalid request frame"); + } + if (closed) return reply(request, frameworkResult.enc({ success: false, value: { tag: "Denied" } })); + if (hasIds(request, SYSTEM_HANDSHAKE)) { + let handshake: T.VersionedHostHandshakeRequest; + try { + handshake = exact(T.VersionedHostHandshakeRequest, request.payload.value); + } catch { + return reply(request, frameworkResult.enc({ success: false, value: { tag: "MalformedFrame", value: { reason: "invalid handshake" } } })); + } + if (handshake.value.codecVersion !== TRUAPI_CODEC_VERSION) { + return reply(request, handshakeResult.enc({ success: false, value: { tag: "Domain", value: { tag: "V1", value: { tag: "UnsupportedProtocolVersion" } } } })); + } + negotiated = true; + return reply(request, handshakeResult.enc({ success: true, value: { tag: "V1" } })); + } + if (request.payload.traitId !== JAM_PEER_TRANSPORT_DIAL.trait) { + return reply(request, frameworkResult.enc({ success: false, value: { tag: "Denied" } })); + } + const malformed = (): Uint8Array => + reply(request, frameworkResult.enc({ success: false, value: { tag: "MalformedFrame", value: { reason: "invalid peer-transport request" } } })); + if (pendingRequests.has(request.requestId)) return new Uint8Array(); + try { + if (hasIds(request, JAM_PEER_TRANSPORT_DIAL)) { + const value = exact(T.VersionedHostJamPeerTransportDialRequest, request.payload.value).value; + if (!negotiated) return reply(request, domain(dialResult, "NotGranted")); + return reply(request, await requestFrame(request, (pending) => dial(value, pending.promise), dialTimeoutMs)); + } + if (hasIds(request, JAM_PEER_TRANSPORT_OPEN)) { + const value = exact(T.VersionedHostJamPeerTransportOpenRequest, request.payload.value).value; + return reply(request, negotiated ? await requestFrame(request, (pending) => open(value, pending)) : domain(openResult, "NotGranted")); + } + if (hasIds(request, JAM_PEER_TRANSPORT_SEND)) { + const value = exact(T.VersionedHostJamPeerTransportSendRequest, request.payload.value).value; + return reply(request, negotiated ? await requestFrame(request, (pending) => send(value, pending)) : domain(sendResult, "Closed")); + } + if (hasIds(request, JAM_PEER_TRANSPORT_RECV)) { + const value = exact(T.VersionedHostJamPeerTransportRecvRequest, request.payload.value).value; + return reply(request, negotiated ? recv(value) : domain(recvResult, "Closed")); + } + if (hasIds(request, JAM_PEER_TRANSPORT_RESET)) { + const value = exact(T.VersionedHostJamPeerTransportResetRequest, request.payload.value).value; + return reply(request, negotiated ? reset(value) : domain(resetResult, "Closed")); + } + if (hasIds(request, JAM_PEER_TRANSPORT_CLOSE)) { + const value = exact(T.VersionedHostJamPeerTransportCloseRequest, request.payload.value).value; + return reply(request, negotiated ? close(value) : domain(closeResult, "Closed")); + } + if (hasIds(request, JAM_PEER_TRANSPORT_EVENTS)) { + exact(T.VersionedHostJamPeerTransportEventsRequest, request.payload.value); + if (!negotiated) return reply(request, domain(eventsResult, "NotGranted")); + return reply(request, ok(eventsResult, { tag: "V1", value: { events: events.splice(0, events.length) } })); + } + } catch { + return malformed(); + } + return reply(request, frameworkResult.enc({ success: false, value: { tag: "Unsupported" } })); + }, + close() { + closed = true; + const denied = frameworkResult.enc({ success: false, value: { tag: "Denied" } }); + for (const pending of pendingRequests.values()) pending.withdraw(denied); + for (const conn of [...connections.values()]) dropConnection(conn); + for (const decision of decisions.values()) decision.waiters.clear(); + decisions.clear(); + events.length = 0; + }, + }; +} diff --git a/js/packages/truapi/src/notification-container.ts b/js/packages/truapi/src/notification-container.ts new file mode 100644 index 0000000000..2d0336af4c --- /dev/null +++ b/js/packages/truapi/src/notification-container.ts @@ -0,0 +1,159 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, concatBytes } from "@noble/hashes/utils.js"; + +// Base Gordian Envelope grammar, independently implemented from +// https://datatracker.ietf.org/doc/html/draft-mcnally-envelope-12#section-3 +// This notification profile accepts integer-only dCBOR leaves (no floats). +export const MAX_CONTAINER_BYTES = 256 * 1024; +export const NOTIFICATION_PREDICATE = "truapiNotification"; +export const MAX_NOTIFICATION_CANDIDATES = 32; +const encoder = new TextEncoder(); +const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); +const MAX_DEPTH = 16; +const MAX_ITEMS = 4096; +const MAX_ASSERTIONS = 128; +interface Item { + major: number; + argument: number; + start: number; + end: number; + children: Item[]; + bytes?: Uint8Array; + text?: string; +} +function compareBytes(left: Uint8Array, right: Uint8Array): number { + for (let index = 0; index < Math.min(left.length, right.length); index++) { + if (left[index] !== right[index]) return left[index]! - right[index]!; + } + return left.length - right.length; +} + +/** Canonical definite-length, bounded integer-only dCBOR reader. */ +function readContainer(bytes: Uint8Array): Item { + if (bytes.length > MAX_CONTAINER_BYTES) throw new Error("notification container too large"); + let offset = 0; + let remainingItems = MAX_ITEMS; + function read(depth: number): Item { + if (depth > MAX_DEPTH || --remainingItems < 0 || offset >= bytes.length) throw new Error("invalid CBOR bounds"); + const start = offset; + const initial = bytes[offset++]!; + const major = initial >>> 5; + const additional = initial & 31; + if (additional >= 28 || (major === 7 && additional > 23)) throw new Error("unsupported CBOR value"); + let argument = additional; + if (additional >= 24) { + const size = 2 ** (additional - 24); + if (offset + size > bytes.length) throw new Error("truncated CBOR argument"); + argument = 0; + for (let index = 0; index < size; index++) argument = argument * 256 + bytes[offset++]!; + if (!Number.isSafeInteger(argument) || argument < [24, 256, 65536, 4294967296][additional - 24]!) throw new Error("noncanonical CBOR integer"); + } + const item: Item = { major, argument, start, end: 0, children: [] }; + if (major === 2 || major === 3) { + if (argument > bytes.length - offset) throw new Error("truncated CBOR bytes"); + item.bytes = bytes.subarray(offset, offset + argument); + offset += argument; + if (major === 3) { + item.text = decoder.decode(item.bytes); + if (item.text.normalize("NFC") !== item.text) throw new Error("noncanonical CBOR text"); + } + } else if (major === 4 || major === 5 || major === 6) { + const count = major === 6 ? 1 : argument * (major === 5 ? 2 : 1); + if (count > remainingItems) throw new Error("CBOR item limit"); + for (let index = 0; index < count; index++) item.children.push(read(depth + 1)); + if (major === 5) { + for (let index = 2; index < item.children.length; index += 2) { + const prior = item.children[index - 2]!; + const current = item.children[index]!; + if (compareBytes(bytes.subarray(prior.start, prior.end), bytes.subarray(current.start, current.end)) >= 0) throw new Error("noncanonical CBOR map"); + } + } + } else if (major === 7 && ![20, 21, 22].includes(argument)) { + throw new Error("unsupported CBOR simple value"); + } + item.end = offset; + return item; + } + const root = read(0); + if (offset !== bytes.length) throw new Error("trailing CBOR data"); + return root; +} +interface Envelope { + kind: "leaf" | "node" | "assertion" | "elided" | "wrapped"; + digest: Uint8Array; + bytes?: Uint8Array; + text?: string; + predicate?: Envelope; + object?: Envelope; + assertions?: Envelope[]; +} + +/** Inspect bounded standard structure without interpreting application assertions. */ +export function decodeNotificationContainer(bytes: Uint8Array): { headers: string[]; subjects: Map } { + const root = readContainer(bytes); + if (root.major !== 6 || root.argument !== 200) throw new Error("not a Gordian Envelope"); + const headers: string[] = []; + const subjects = new Map(); + function envelope(item: Item): Envelope { + if (item.major === 6 && item.argument === 201) { + const value = item.children[0]!; + if (value.major === 2) { + const digest = bytesToHex(sha256(value.bytes!)); + const prior = subjects.get(digest); + if (prior && compareBytes(prior, value.bytes!) !== 0) throw new Error("contradictory byte witness"); + subjects.set(digest, value.bytes!); + } + return { kind: "leaf", digest: sha256(bytes.subarray(value.start, value.end)), bytes: value.major === 2 ? value.bytes : undefined, text: value.text }; + } + if (item.major === 2 && item.argument === 32) return { kind: "elided", digest: item.bytes! }; + if (item.major === 5 && item.argument === 1) { + const predicate = envelope(item.children[0]!); + const object = envelope(item.children[1]!); + if (predicate.text === NOTIFICATION_PREDICATE && (predicate.kind !== "leaf" || object.kind !== "leaf" || object.text === undefined)) throw new Error("ambiguous notification assertion"); + if (predicate.text === NOTIFICATION_PREDICATE) { + if (headers.length >= MAX_NOTIFICATION_CANDIDATES) throw new Error("notification candidate limit"); + headers.push(object.text!); + } + return { kind: "assertion", predicate, object, digest: sha256(concatBytes(predicate.digest, object.digest)) }; + } + if (item.major === 6 && item.argument === 200) { + const child = envelope(item.children[0]!); + return { kind: "wrapped", digest: sha256(child.digest) }; + } + if (item.major === 4 && item.argument >= 2 && item.argument <= MAX_ASSERTIONS + 1) { + const subject = envelope(item.children[0]!); + const assertions = item.children.slice(1).map(envelope); + let reservedCount = 0; + for (let index = 0; index < assertions.length; index++) { + const assertion = assertions[index]!; + if (assertion.kind !== "assertion" && assertion.kind !== "elided") throw new Error("invalid Envelope assertion"); + if (index > 0 && compareBytes(assertions[index - 1]!.digest, assertion.digest) >= 0) throw new Error("noncanonical Envelope assertion order"); + if (assertion.predicate?.text === NOTIFICATION_PREDICATE && ++reservedCount > 1) throw new Error("duplicate notification assertion"); + } + return { kind: "node", bytes: subject.bytes, text: subject.text, assertions, digest: sha256(concatBytes(subject.digest, ...assertions.map((assertion) => assertion.digest))) }; + } + throw new Error("unsupported Envelope structure"); + } + envelope(root.children[0]!); + return { headers, subjects }; +} + +function cborHead(major: number, argument: number): Uint8Array { + if (argument < 24) return Uint8Array.of((major << 5) | argument); + const size = argument <= 255 ? 1 : argument <= 65535 ? 2 : 4; + const result = new Uint8Array(size + 1); + result[0] = (major << 5) | (size === 1 ? 24 : size === 2 ? 25 : 26); + for (let index = size; index > 0; index--) { result[index] = argument & 255; argument = Math.floor(argument / 256); } + return result; +} + +/** Encode the minimal standard carrier, leaving application assertions to its owner. */ +export function encodeNotificationContainer(headerJson: string, carrier: Uint8Array): Uint8Array { + const predicate = encoder.encode(NOTIFICATION_PREDICATE); + const header = encoder.encode(headerJson); + return concatBytes( + Uint8Array.of(0xd8, 200, 0x82, 0xd8, 201), cborHead(2, carrier.length), carrier, + Uint8Array.of(0xa1, 0xd8, 201), cborHead(3, predicate.length), predicate, + Uint8Array.of(0xd8, 201), cborHead(3, header.length), header, + ); +} diff --git a/js/packages/truapi/src/notification-envelope.test.ts b/js/packages/truapi/src/notification-envelope.test.ts new file mode 100644 index 0000000000..56e20bfe78 --- /dev/null +++ b/js/packages/truapi/src/notification-envelope.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "bun:test"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { concatBytes, hexToBytes } from "@noble/hashes/utils.js"; +import { + authenticateNotificationEnvelope, authenticateNotificationHeader, decodeNotificationEnvelope, encodeNotificationEnvelope, + isNotificationEnvelope, notificationSigningBytes, signNotificationEnvelope, signNotificationHeader, + verifyNotificationEnvelope, verifyNotificationEnvelopes, MAX_CARRIER_BYTES, MAX_FULL_FRAME_BYTES, + MAX_HEADER_BYTES, type NotificationHeader, +} from "./notification-envelope.js"; + +// Independent OpenSSL Ed25519 vector: raw seed 00..1f, subject 'abc'. +const header: NotificationHeader = { + v: 1, product: "example.paseo", genesis: "11".repeat(32), channel: "55".repeat(32), + topics: ["22".repeat(32), "33".repeat(32)], eventId: "44".repeat(32), + createdAt: 1700000000000, expiresAt: 1700000060000, + ciphertextDigest: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + senderKey: "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8", + signature: "6bc192199982a1b8e850b66b1f0cd85035354e0b788edecddfad7505da94c7347447b5fa4c9a64647045eea6d52e4aecec14dcb16ea64de317b1b3e76b0f830b", +}; +const seed = hexToBytes("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"); +const encoder = new TextEncoder(); +const carrier = encoder.encode("abc"); +const { product, genesis, channel, topics, eventId, createdAt, expiresAt } = header; +const metadata = { product, genesis, channel, topics, eventId, createdAt, expiresAt }; + +// Independent test-only implementation of the standard base Envelope grammar. +interface Element { bytes: Uint8Array; digest: Uint8Array } +function head(major: number, length: number): Uint8Array { + if (length < 24) return Uint8Array.of(major * 32 + length); + if (length < 256) return Uint8Array.of(major * 32 + 24, length); + if (length < 65536) return Uint8Array.of(major * 32 + 25, length >>> 8, length & 255); + return Uint8Array.of(major * 32 + 26, length >>> 24, length >>> 16 & 255, length >>> 8 & 255, length & 255); +} +function leaf(value: string | Uint8Array): Element { + const bytes = typeof value === "string" ? encoder.encode(value) : value; + const encoded = concatBytes(head(typeof value === "string" ? 3 : 2, bytes.length), bytes); + return { bytes: concatBytes(Uint8Array.of(0xd8, 201), encoded), digest: sha256(encoded) }; +} +function assertion(predicate: string, object: Element): Element { + const key = leaf(predicate); + return { bytes: concatBytes(Uint8Array.of(0xa1), key.bytes, object.bytes), digest: sha256(concatBytes(key.digest, object.digest)) }; +} +function node(subject: Element, assertions: Element[]): Element { + const sorted = [...assertions].sort((left, right) => { + for (let index = 0; index < 32; index++) if (left.digest[index] !== right.digest[index]) return left.digest[index]! - right.digest[index]!; + return 0; + }); + return { bytes: concatBytes(head(4, sorted.length + 1), subject.bytes, ...sorted.map((entry) => entry.bytes)), + digest: sha256(concatBytes(subject.digest, ...sorted.map((entry) => entry.digest))) }; +} +function frame(json = JSON.stringify(header), body = carrier): Uint8Array { + return concatBytes(Uint8Array.of(0xd8, 200), node(leaf(body), [assertion("truapiNotification", leaf(json))]).bytes); +} +function verify(bytes: Uint8Array) { + return verifyNotificationEnvelope(bytes, genesis, channel, topics, createdAt); +} + +describe("standard generic notification carriers", () => { + it("matches the independent fixed signing bytes, signature and standard encoding used by Rust", () => { + expect(new TextDecoder().decode(notificationSigningBytes(header))).toBe('["truapi:notification:v1",1,"example.paseo","' + genesis + '","' + channel + '",["' + topics[0] + '","' + topics[1] + '"],"' + eventId + '",1700000000000,1700000060000,"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad","03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"]'); + expect(signNotificationHeader(metadata, carrier, seed)).toEqual(header); + const signed = signNotificationEnvelope(metadata, carrier, seed); + expect(decodeNotificationEnvelope(signed).header).toEqual(header); + expect(verify(frame()).carrier).toEqual(carrier); + expect(encodeNotificationEnvelope(header, carrier)).toEqual(frame()); + }); + + it("matches Dalek's exact equation without blanket mixed-torsion rejection", () => { + // Independently constructed with scalar a=r=1 and order-two T=(0,-1). + // R=B+T, A=B satisfies only the cofactored equation and MUST fail. + const cofactored = { ...header, + senderKey: "5866666666666666666666666666666666666666666666666666666666666666", + signature: "95999999999999999999999999999999999999999999999999999999999999994d0d311b42ae0fbd5231e2b7e106d734ca5e5045ba0882ac54c3f232e5718300", + }; + expect(() => authenticateNotificationHeader(JSON.stringify(cofactored), carrier)).toThrow(); + expect(() => verify(frame(JSON.stringify(cofactored)))).toThrow(); + // A=B+T, R=B and even reduced challenge satisfies the exact equation. + const mixedKey = { ...header, eventId: "00".repeat(31) + "04", + senderKey: "9599999999999999999999999999999999999999999999999999999999999999", + signature: "58666666666666666666666666666666666666666666666666666666666666663114ba2ce5c96de9e15fbc99e889f60672480566a4420d0d7806399239ed5a06", + }; + expect(authenticateNotificationHeader(JSON.stringify(mixedKey), carrier)).toEqual(mixedKey); + expect(verify(frame(JSON.stringify(mixedKey))).header).toEqual(mixedKey); + }); + + it("authenticates optional stored metadata without an actual-source or clock claim", () => { + expect(authenticateNotificationHeader(JSON.stringify(header), carrier)).toEqual(header); + expect(() => authenticateNotificationHeader(JSON.stringify(header), encoder.encode("different"))).toThrow(); + expect(() => authenticateNotificationHeader(JSON.stringify({ ...header, eventId: "66".repeat(32) }), carrier)).toThrow(); + expect(() => authenticateNotificationHeader(JSON.stringify(header).replace('"v":1', '"v":1,"v":1'), carrier)).toThrow(); + expect(() => authenticateNotificationHeader(JSON.stringify(header), new Uint8Array(MAX_CARRIER_BYTES + 1))).toThrow(); + expect(() => verifyNotificationEnvelope(frame(), genesis, "00".repeat(32), topics, expiresAt)).toThrow(); + }); + + it("authenticates history without making expired candidates notification-eligible", () => { + const bytes = frame(); + const authenticated = authenticateNotificationEnvelope(bytes, genesis, channel, topics); + expect(() => verifyNotificationEnvelope(bytes, genesis, channel, topics, expiresAt)).toThrow(); + bytes[7] ^= 1; + expect(authenticated.carrier).toEqual(carrier); + }); + + it("resolves a byte witness elsewhere without interpreting application predicates", () => { + const prior = node(leaf("unrelated subject"), [ + assertion("opaque-slot", leaf(carrier)), assertion("truapiNotification", leaf(JSON.stringify(header))), + ]); + const outer = node(leaf("control"), [assertion("application-defined", prior)]); + const bytes = concatBytes(Uint8Array.of(0xd8, 200), outer.bytes); + expect(isNotificationEnvelope(bytes)).toBe(true); + expect(verify(bytes).carrier).toEqual(carrier); + expect(verifyNotificationEnvelopes(bytes, genesis, channel, ["66".repeat(32), ...topics], createdAt)).toHaveLength(1); + expect(verifyNotificationEnvelopes(bytes, genesis, channel, [topics[0]!], createdAt)).toEqual([]); + }); + + it("does not let an invalid sibling suppress a valid authenticated candidate", () => { + const valid = node(leaf(carrier), [assertion("truapiNotification", leaf(JSON.stringify(header)))]); + const forged = node(leaf("another subject"), [assertion("truapiNotification", leaf(JSON.stringify({ ...header, signature: "00".repeat(64) })))]); + const bytes = concatBytes(Uint8Array.of(0xd8, 200), node(leaf("container"), [assertion("first", valid), assertion("second", forged)]).bytes); + expect(verifyNotificationEnvelopes(bytes, genesis, channel, topics, createdAt)).toHaveLength(1); + expect(() => verify(bytes)).toThrow("exactly one"); + expect(verifyNotificationEnvelopes(frame(JSON.stringify(header), encoder.encode("wrong")), genesis, channel, topics, createdAt)).toEqual([]); + }); + + it("discriminates unmarked carriers and fails closed on structural ambiguities", () => { + const unmarked = concatBytes(Uint8Array.of(0xd8, 200), node(leaf(carrier), [assertion("scheme", leaf("opaque"))]).bytes); + expect(isNotificationEnvelope(unmarked)).toBe(false); + expect(isNotificationEnvelope(encoder.encode("ordinary bytes"))).toBe(false); + expect(isNotificationEnvelope(frame("not valid JSON"))).toBe(true); + const duplicate = concatBytes(Uint8Array.of(0xd8, 200), node(leaf(carrier), [ + assertion("truapiNotification", leaf(JSON.stringify(header))), assertion("truapiNotification", leaf("different")), + ]).bytes); + expect(() => isNotificationEnvelope(duplicate)).toThrow(); + expect(() => verify(duplicate)).toThrow(); + expect(() => verify(concatBytes(frame(), Uint8Array.of(0)))).toThrow(); + expect(() => verify(concatBytes(Uint8Array.of(0xd9, 0, 200), frame().subarray(2)))).toThrow(); + const malformedUtf8 = frame(); malformedUtf8[malformedUtf8.indexOf(0x7b) + 1] = 0xff; + expect(() => verify(malformedUtf8)).toThrow(); + }); + + it("rejects duplicate/escaped duplicate/unknown/missing fields and noninteger JSON", () => { + const json = JSON.stringify(header); + for (const invalid of [ + json.replace('"v":1', '"v":1,"v":1'), json.replace('"v":1', '"v":1,"\\u0076":1'), + json.replace('"v":1', '"v":1,"extra":false'), json.replace('"v":1,', ""), + json.replace('"v":1', '"v":1.0'), json.replace('"v":1', '"v":1e0'), json.replace('"v":1', '"v":-0'), + ]) expect(() => verify(frame(invalid))).toThrow(); + }); + + it("rejects invalid metadata, source, proof and lifetime", () => { + for (const patch of [ + { product: "😀" }, { product: "A" }, { product: "x".repeat(129) }, { product: "" }, + { genesis: "AA".repeat(32) }, { channel: "0x" + channel }, { eventId: "66".repeat(32) }, + { topics: [] }, { topics: [topics[0], topics[0]] }, { topics: Array(5).fill(topics[0]) }, + { createdAt: Number.MAX_SAFE_INTEGER + 1 }, { createdAt: -1 }, { expiresAt: createdAt }, + { expiresAt: createdAt + 86_400_001 }, { signature: "00".repeat(64) }, { senderKey: "00".repeat(32) }, + { ciphertextDigest: "00".repeat(32) }, + ]) expect(() => verify(frame(JSON.stringify({ ...header, ...patch })))).toThrow(); + expect(() => verifyNotificationEnvelope(frame(), "00".repeat(32), channel, topics, createdAt)).toThrow(); + expect(() => verifyNotificationEnvelope(frame(), genesis, "00".repeat(32), topics, createdAt)).toThrow(); + expect(() => verifyNotificationEnvelope(frame(), genesis, channel, topics, createdAt - 60_001)).toThrow(); + }); + + it("bounds container bytes, headers, candidate count and nesting", () => { + expect(() => verify(new Uint8Array(MAX_FULL_FRAME_BYTES + 1))).toThrow(); + expect(() => signNotificationEnvelope(metadata, new Uint8Array(MAX_CARRIER_BYTES + 1), seed)).toThrow(); + expect(() => verify(frame(" ".repeat(MAX_HEADER_BYTES + 1)))).toThrow(); + const candidates = Array.from({ length: 33 }, (_, index) => assertion(`entry-${index}`, node(leaf(String(index)), [assertion("truapiNotification", leaf(JSON.stringify(header)))]))); + expect(() => verify(concatBytes(Uint8Array.of(0xd8, 200), node(leaf(carrier), candidates).bytes))).toThrow(); + let nested = leaf(carrier); + for (let index = 0; index < 20; index++) nested = node(leaf("subject"), [assertion("nested", nested)]); + expect(() => verify(concatBytes(Uint8Array.of(0xd8, 200), nested.bytes))).toThrow(); + }); +}); diff --git a/js/packages/truapi/src/notification-envelope.ts b/js/packages/truapi/src/notification-envelope.ts new file mode 100644 index 0000000000..8e04a9794e --- /dev/null +++ b/js/packages/truapi/src/notification-envelope.ts @@ -0,0 +1,216 @@ +import { ed25519 } from "@noble/curves/ed25519.js"; +import { bytesToNumberLE } from "@noble/curves/utils.js"; +import { sha256, sha512 } from "@noble/hashes/sha2.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { decodeNotificationContainer, encodeNotificationContainer, MAX_CONTAINER_BYTES } from "./notification-container.js"; +export { NOTIFICATION_PREDICATE, MAX_NOTIFICATION_CANDIDATES } from "./notification-container.js"; + +export const MAX_HEADER_BYTES = 16 * 1024; +export const MAX_CARRIER_BYTES = 240 * 1024; +export const MAX_FULL_FRAME_BYTES = MAX_CONTAINER_BYTES; +export const MAX_TTL_MS = 86_400_000; +export const FUTURE_SKEW_MS = 60_000; +const encoder = new TextEncoder(); + +/** Public authenticated metadata. Hex is lowercase, without a 0x prefix. */ +export interface NotificationHeader { + v: 1; + product: string; + genesis: string; + channel: string; + topics: string[]; + eventId: string; + createdAt: number; + expiresAt: number; + ciphertextDigest: string; + senderKey: string; + signature: string; +} +export type UnsignedNotificationHeader = Omit; +export type NotificationMetadata = Omit; +/** Decoding alone does not authenticate the result. */ +export interface NotificationEnvelope { + header: NotificationHeader; + carrier: Uint8Array; +} +const fields = ["v", "product", "genesis", "channel", "topics", "eventId", "createdAt", "expiresAt", "ciphertextDigest", "senderKey", "signature"]; +const hex32 = /^[0-9a-f]{64}$/; +function isHex(value: unknown, pattern = hex32): value is string { + return typeof value === "string" && pattern.test(value); +} +function validateHeader(value: unknown): asserts value is NotificationHeader { + if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid notification header"); + const header = value as NotificationHeader; + if (Object.keys(header).length !== fields.length || fields.some((field) => !Object.hasOwn(header, field)) + || header.v !== 1 || typeof header.product !== "string" || !/^[a-z0-9._-]{1,128}$/.test(header.product) + || !isHex(header.genesis) || !isHex(header.channel) || !isHex(header.eventId) || !isHex(header.ciphertextDigest) || !isHex(header.senderKey) + || !isHex(header.signature, /^[0-9a-f]{128}$/) + || !Array.isArray(header.topics) || header.topics.length < 1 || header.topics.length > 4 + || header.topics.some((topic) => !isHex(topic)) || new Set(header.topics).size !== header.topics.length + || !Number.isSafeInteger(header.createdAt) || header.createdAt < 0 + || !Number.isSafeInteger(header.expiresAt) || header.expiresAt <= header.createdAt + || header.expiresAt - header.createdAt > MAX_TTL_MS) throw new Error("invalid notification header"); +} + +/** Exact domain-separated UTF-8 tuple shared with the Rust verifier. */ +export function notificationSigningBytes(header: UnsignedNotificationHeader): Uint8Array { + validateHeader({ ...header, signature: "00".repeat(64) }); + return encoder.encode(JSON.stringify([ + "truapi:notification:v1", header.v, header.product, header.genesis, header.channel, + header.topics, header.eventId, header.createdAt, header.expiresAt, header.ciphertextDigest, header.senderKey, + ])); +} + +function parseHeader(text: string): NotificationHeader { + if (encoder.encode(text).length > MAX_HEADER_BYTES) throw new Error("notification header too large"); + const value: unknown = JSON.parse(text); + // JSON.parse discards duplicate keys. Tokenize valid JSON first to retain them, + // including escaped key spellings, and reject non-integer number spellings. + const tokens = text.match(/"(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null|[{}\[\],:]/g) ?? []; + const keys = new Set(); + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]!; + if (tokens[index + 1] === ":") { + const key: string = JSON.parse(token); + if (keys.has(key)) throw new Error("duplicate notification field"); + keys.add(key); + } else if (/^-?\d/.test(token) && !/^(0|[1-9]\d*)$/.test(token)) { + throw new Error("non-integer notification number"); + } + } + validateHeader(value); + return value; +} + +/** Inspect bounded standard Envelope nodes for reserved assertions. + * This parses structure but not header JSON or its proof. Malformed containers throw: + * callers must drop them, never fall back to another authentication path on errors. + * Non-Envelope bytes and valid unmarked base Envelopes return false. + */ +export function isNotificationEnvelope(frame: Uint8Array): boolean { + if (frame.length === 0 || (frame[0]! >>> 5) !== 6) return false; + return decodeNotificationContainer(frame).headers.length > 0; +} + +/** Decode exactly one candidate; does not verify proof, source or freshness. */ +export function decodeNotificationEnvelope(frame: Uint8Array): NotificationEnvelope { + const { headers, subjects } = decodeNotificationContainer(frame); + if (headers.length !== 1) throw new Error("expected exactly one notification candidate"); + return decodeCandidate(headers[0]!, subjects); +} + +function decodeCandidate(json: string, subjects: ReadonlyMap): NotificationEnvelope { + const header = parseHeader(json); + const carrier = subjects.get(header.ciphertextDigest); + if (carrier === undefined || carrier.length > MAX_CARRIER_BYTES) throw new Error("missing or oversized notification byte witness"); + return { header, carrier: carrier.slice() }; +} + +/** Encode a minimal standard carrier; supplied proof is not verified. */ +export function encodeNotificationEnvelope(header: NotificationHeader, carrier: Uint8Array): Uint8Array { + validateHeader(header); + if (carrier.length > MAX_CARRIER_BYTES) throw new Error("notification carrier too large"); + const json = JSON.stringify(header); + if (encoder.encode(json).length > MAX_HEADER_BYTES) throw new Error("notification header too large"); + const frame = encodeNotificationContainer(json, carrier); + if (frame.length > MAX_FULL_FRAME_BYTES) throw new Error("notification container too large"); + return frame; +} + +/** Sign public metadata over an opaque byte witness using a raw 32-byte seed. + * Existing standard carriers can add JSON.stringify(result) as a + * NOTIFICATION_PREDICATE assertion on any node containing the byte witness. + */ +export function signNotificationHeader(metadata: NotificationMetadata, carrier: Uint8Array, seed: Uint8Array): NotificationHeader { + if (carrier.length > MAX_CARRIER_BYTES) throw new Error("notification carrier too large"); + const unsigned: UnsignedNotificationHeader = { + ...metadata, v: 1, ciphertextDigest: bytesToHex(sha256(carrier)), senderKey: bytesToHex(ed25519.getPublicKey(seed)), + }; + const signature = bytesToHex(ed25519.sign(notificationSigningBytes(unsigned), seed)); + return { ...unsigned, signature }; +} + +/** Sign and emit a minimal standard carrier, in Node, Bun or a browser. */ +export function signNotificationEnvelope(metadata: NotificationMetadata, carrier: Uint8Array, seed: Uint8Array): Uint8Array { + return encodeNotificationEnvelope(signNotificationHeader(metadata, carrier, seed), carrier); +} + +/** Authenticate optional stored metadata against supplied opaque bytes. + * Checks strict JSON, static bounds, digest and Ed25519 proof only. + * Does NOT establish current-time eligibility, actual source, product authority, + * enrollment or sender approval. Hosts/relays must use whole-carrier verification. + */ +export function authenticateNotificationHeader(json: string, carrier: Uint8Array): NotificationHeader { + if (carrier.length > MAX_CARRIER_BYTES) throw new Error("notification carrier too large"); + const header = parseHeader(json); + if (bytesToHex(sha256(carrier)) !== header.ciphertextDigest) throw new Error("notification byte witness digest mismatch"); + verifyHeaderProof(header); + return header; +} + +/** Authenticate stored bytes without applying current-time notification eligibility. + * Enrollment, product, sender approval and replay policy remain caller responsibilities. + * Subject bytes are copied so input mutation cannot alter authenticated output. + */ +export function authenticateNotificationEnvelope(frame: Uint8Array, actualGenesis: string, actualChannel: string, actualTopics: readonly string[]): NotificationEnvelope { + return authenticateCandidate(decodeNotificationEnvelope(frame), actualGenesis, actualChannel, actualTopics); +} + +function authenticateCandidate(envelope: NotificationEnvelope, actualGenesis: string, actualChannel: string, actualTopics: readonly string[]): NotificationEnvelope { + const { header } = envelope; + if (header.genesis !== actualGenesis || header.channel !== actualChannel + || actualTopics.length < 1 || actualTopics.length > 4 || actualTopics.some((topic) => !isHex(topic)) + || new Set(actualTopics).size !== actualTopics.length + || header.topics.some((topic) => !actualTopics.includes(topic))) throw new Error("notification source mismatch"); + verifyHeaderProof(header); + return envelope; +} + +function verifyHeaderProof(header: NotificationHeader): void { + const key = hexToBytes(header.senderKey); + const signature = hexToBytes(header.signature); + const publicPoint = ed25519.Point.fromBytes(key, false); + const noncePoint = ed25519.Point.fromBytes(signature.subarray(0, 32), false); + const order = ed25519.Point.CURVE().n; + const scalar = bytesToNumberLE(signature.subarray(32)); + if (publicPoint.isSmallOrder() || noncePoint.isSmallOrder() || scalar >= order) throw new Error("invalid notification signature"); + const challenge = bytesToNumberLE(sha512.create() + .update(signature.subarray(0, 32)).update(key).update(notificationSigningBytes(header)).digest()) % order; + // Noble's verify clears the cofactor even with zip215:false. Dalek verify_strict + // requires this exact, uncofactored equation; mixed-order points are not banned. + const expectedNonce = ed25519.Point.BASE.multiplyUnsafe(scalar).subtract(publicPoint.multiplyUnsafe(challenge)); + if (!expectedNonce.equals(noncePoint)) throw new Error("invalid notification signature"); +} + +/** Authenticate a carrier and enforce current-time notification eligibility, or throw. */ +export function verifyNotificationEnvelope(frame: Uint8Array, actualGenesis: string, actualChannel: string, actualTopics: readonly string[], nowMs: number): NotificationEnvelope { + const envelope = authenticateNotificationEnvelope(frame, actualGenesis, actualChannel, actualTopics); + const { header } = envelope; + if (!Number.isSafeInteger(nowMs) || nowMs < 0 || header.createdAt > nowMs + FUTURE_SKEW_MS || header.expiresAt <= nowMs) throw new Error("notification outside validity window"); + return envelope; +} + +/** Authenticate all candidates, omitting invalid proofs/headers, without current-time checks. + * Malformed containers or structural ambiguities throw before any result is returned. + */ +export function authenticateNotificationEnvelopes(frame: Uint8Array, actualGenesis: string, actualChannel: string, actualTopics: readonly string[]): NotificationEnvelope[] { + const { headers, subjects } = decodeNotificationContainer(frame); + const authenticated: NotificationEnvelope[] = []; + for (const json of headers) { + try { + authenticated.push(authenticateCandidate(decodeCandidate(json, subjects), actualGenesis, actualChannel, actualTopics)); + } catch { + // A candidate's invalid proof cannot suppress independently valid siblings. + } + } + return authenticated; +} + +/** Authenticate up to 32 generic candidates and keep only notification-eligible ones. + * A watch must match the SIGNED header topics, not unsigned extra actual topics. + */ +export function verifyNotificationEnvelopes(frame: Uint8Array, actualGenesis: string, actualChannel: string, actualTopics: readonly string[], nowMs: number): NotificationEnvelope[] { + if (!Number.isSafeInteger(nowMs) || nowMs < 0) throw new Error("invalid notification clock"); + return authenticateNotificationEnvelopes(frame, actualGenesis, actualChannel, actualTopics).filter(({ header }) => + header.createdAt <= nowMs + FUTURE_SKEW_MS && header.expiresAt > nowMs); +} diff --git a/package-lock.json b/package-lock.json index 6769d00281..6285f4b3a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "version": "0.24.0", "license": "MIT", "dependencies": { + "@noble/curves": "^2.0.1", "@noble/hashes": "^2.2.0", "neverthrow": "^8.2.0", "scale-ts": "^1.6.1" @@ -869,10 +870,25 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/@noble/curves": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.4.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "engines": { "node": ">= 20.19.0" diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 8fbbd08436..6300ce2da5 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "064ab9d59b73f723"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "ec0d28820f74178d"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -1195,6 +1195,195 @@ impl RequestMethod for EntropyDerive { const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } +/// `jam_peer_transport_dial` method marker. +pub struct JamPeerTransportDial; +impl JamPeerTransportDial { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "JamPeerTransport", + method: "dial", + wire_name: "jam_peer_transport_dial", + request_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportDialRequest", + response_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportDialResponse", + error_type: Some("truapi::versioned::jam_peer_transport::HostJamPeerTransportDialError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 111, + method_id: 0, + }), + }; +} +impl RequestMethod for JamPeerTransportDial { + type Request = truapi::versioned::jam_peer_transport::HostJamPeerTransportDialRequest; + type Response = truapi::versioned::jam_peer_transport::HostJamPeerTransportDialResponse; + type Error = truapi::versioned::jam_peer_transport::HostJamPeerTransportDialError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `jam_peer_transport_open` method marker. +pub struct JamPeerTransportOpen; +impl JamPeerTransportOpen { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "JamPeerTransport", + method: "open", + wire_name: "jam_peer_transport_open", + request_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportOpenRequest", + response_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportOpenResponse", + error_type: Some("truapi::versioned::jam_peer_transport::HostJamPeerTransportOpenError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 111, + method_id: 1, + }), + }; +} +impl RequestMethod for JamPeerTransportOpen { + type Request = truapi::versioned::jam_peer_transport::HostJamPeerTransportOpenRequest; + type Response = truapi::versioned::jam_peer_transport::HostJamPeerTransportOpenResponse; + type Error = truapi::versioned::jam_peer_transport::HostJamPeerTransportOpenError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `jam_peer_transport_send` method marker. +pub struct JamPeerTransportSend; +impl JamPeerTransportSend { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "JamPeerTransport", + method: "send", + wire_name: "jam_peer_transport_send", + request_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportSendRequest", + response_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportSendResponse", + error_type: Some("truapi::versioned::jam_peer_transport::HostJamPeerTransportSendError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 111, + method_id: 2, + }), + }; +} +impl RequestMethod for JamPeerTransportSend { + type Request = truapi::versioned::jam_peer_transport::HostJamPeerTransportSendRequest; + type Response = truapi::versioned::jam_peer_transport::HostJamPeerTransportSendResponse; + type Error = truapi::versioned::jam_peer_transport::HostJamPeerTransportSendError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `jam_peer_transport_recv` method marker. +pub struct JamPeerTransportRecv; +impl JamPeerTransportRecv { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "JamPeerTransport", + method: "recv", + wire_name: "jam_peer_transport_recv", + request_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportRecvRequest", + response_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportRecvResponse", + error_type: Some("truapi::versioned::jam_peer_transport::HostJamPeerTransportRecvError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 111, + method_id: 3, + }), + }; +} +impl RequestMethod for JamPeerTransportRecv { + type Request = truapi::versioned::jam_peer_transport::HostJamPeerTransportRecvRequest; + type Response = truapi::versioned::jam_peer_transport::HostJamPeerTransportRecvResponse; + type Error = truapi::versioned::jam_peer_transport::HostJamPeerTransportRecvError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `jam_peer_transport_reset` method marker. +pub struct JamPeerTransportReset; +impl JamPeerTransportReset { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "JamPeerTransport", + method: "reset", + wire_name: "jam_peer_transport_reset", + request_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportResetRequest", + response_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportResetResponse", + error_type: Some("truapi::versioned::jam_peer_transport::HostJamPeerTransportResetError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 111, + method_id: 4, + }), + }; +} +impl RequestMethod for JamPeerTransportReset { + type Request = truapi::versioned::jam_peer_transport::HostJamPeerTransportResetRequest; + type Response = truapi::versioned::jam_peer_transport::HostJamPeerTransportResetResponse; + type Error = truapi::versioned::jam_peer_transport::HostJamPeerTransportResetError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `jam_peer_transport_close` method marker. +pub struct JamPeerTransportClose; +impl JamPeerTransportClose { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "JamPeerTransport", + method: "close", + wire_name: "jam_peer_transport_close", + request_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportCloseRequest", + response_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportCloseResponse", + error_type: Some("truapi::versioned::jam_peer_transport::HostJamPeerTransportCloseError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 111, + method_id: 5, + }), + }; +} +impl RequestMethod for JamPeerTransportClose { + type Request = truapi::versioned::jam_peer_transport::HostJamPeerTransportCloseRequest; + type Response = truapi::versioned::jam_peer_transport::HostJamPeerTransportCloseResponse; + type Error = truapi::versioned::jam_peer_transport::HostJamPeerTransportCloseError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `jam_peer_transport_events` method marker. +pub struct JamPeerTransportEvents; +impl JamPeerTransportEvents { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "JamPeerTransport", + method: "events", + wire_name: "jam_peer_transport_events", + request_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportEventsRequest", + response_type: "truapi::versioned::jam_peer_transport::HostJamPeerTransportEventsResponse", + error_type: Some("truapi::versioned::jam_peer_transport::HostJamPeerTransportEventsError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 111, + method_id: 6, + }), + }; +} +impl RequestMethod for JamPeerTransportEvents { + type Request = truapi::versioned::jam_peer_transport::HostJamPeerTransportEventsRequest; + type Response = truapi::versioned::jam_peer_transport::HostJamPeerTransportEventsResponse; + type Error = truapi::versioned::jam_peer_transport::HostJamPeerTransportEventsError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + /// `expanded_card_set_face_shown` method marker. pub struct ExpandedCardSetFaceShown; impl ExpandedCardSetFaceShown { @@ -1492,6 +1681,170 @@ impl RequestMethod for NotificationsCancelPushNotification { const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } +/// `notifications_receiver_status` method marker. +pub struct NotificationsReceiverStatus; +impl NotificationsReceiverStatus { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Notifications", + method: "receiver_status", + wire_name: "notifications_receiver_status", + request_type: "truapi::versioned::notifications::HostNotificationReceiverStatusRequest", + response_type: "truapi::versioned::notifications::HostNotificationReceiverStatusResponse", + error_type: Some("truapi::versioned::notifications::HostNotificationReceivingError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 8, + method_id: 2, + }), + }; +} +impl RequestMethod for NotificationsReceiverStatus { + type Request = truapi::versioned::notifications::HostNotificationReceiverStatusRequest; + type Response = truapi::versioned::notifications::HostNotificationReceiverStatusResponse; + type Error = truapi::versioned::notifications::HostNotificationReceivingError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `notifications_replace_receiver` method marker. +pub struct NotificationsReplaceReceiver; +impl NotificationsReplaceReceiver { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Notifications", + method: "replace_receiver", + wire_name: "notifications_replace_receiver", + request_type: "truapi::versioned::notifications::HostNotificationReplaceReceiverRequest", + response_type: "truapi::versioned::notifications::HostNotificationReplaceReceiverResponse", + error_type: Some("truapi::versioned::notifications::HostNotificationReceivingError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 8, + method_id: 3, + }), + }; +} +impl RequestMethod for NotificationsReplaceReceiver { + type Request = truapi::versioned::notifications::HostNotificationReplaceReceiverRequest; + type Response = truapi::versioned::notifications::HostNotificationReplaceReceiverResponse; + type Error = truapi::versioned::notifications::HostNotificationReceivingError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `notifications_disable_receiver` method marker. +pub struct NotificationsDisableReceiver; +impl NotificationsDisableReceiver { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Notifications", + method: "disable_receiver", + wire_name: "notifications_disable_receiver", + request_type: "truapi::versioned::notifications::HostNotificationDisableReceiverRequest", + response_type: "truapi::versioned::notifications::HostNotificationDisableReceiverResponse", + error_type: Some("truapi::versioned::notifications::HostNotificationReceivingError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 8, + method_id: 4, + }), + }; +} +impl RequestMethod for NotificationsDisableReceiver { + type Request = truapi::versioned::notifications::HostNotificationDisableReceiverRequest; + type Response = truapi::versioned::notifications::HostNotificationDisableReceiverResponse; + type Error = truapi::versioned::notifications::HostNotificationReceivingError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `notifications_record_receipt` method marker. +pub struct NotificationsRecordReceipt; +impl NotificationsRecordReceipt { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Notifications", + method: "record_receipt", + wire_name: "notifications_record_receipt", + request_type: "truapi::versioned::notifications::HostNotificationRecordReceiptRequest", + response_type: "truapi::versioned::notifications::HostNotificationRecordReceiptResponse", + error_type: Some("truapi::versioned::notifications::HostNotificationReceivingError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 8, + method_id: 5, + }), + }; +} +impl RequestMethod for NotificationsRecordReceipt { + type Request = truapi::versioned::notifications::HostNotificationRecordReceiptRequest; + type Response = truapi::versioned::notifications::HostNotificationRecordReceiptResponse; + type Error = truapi::versioned::notifications::HostNotificationReceivingError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `notifications_receiver_events` method marker. +pub struct NotificationsReceiverEvents; +impl NotificationsReceiverEvents { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Notifications", + method: "receiver_events", + wire_name: "notifications_receiver_events", + request_type: "truapi::versioned::notifications::HostNotificationReceiverEventsRequest", + response_type: "truapi::versioned::notifications::HostNotificationReceiverEventsResponse", + error_type: Some("truapi::versioned::notifications::HostNotificationReceivingError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 8, + method_id: 6, + }), + }; +} +impl RequestMethod for NotificationsReceiverEvents { + type Request = truapi::versioned::notifications::HostNotificationReceiverEventsRequest; + type Response = truapi::versioned::notifications::HostNotificationReceiverEventsResponse; + type Error = truapi::versioned::notifications::HostNotificationReceivingError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + +/// `notifications_acknowledge_receiver_event` method marker. +pub struct NotificationsAcknowledgeReceiverEvent; +impl NotificationsAcknowledgeReceiverEvent { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Notifications", + method: "acknowledge_receiver_event", + wire_name: "notifications_acknowledge_receiver_event", + request_type: "truapi::versioned::notifications::HostNotificationAcknowledgeReceiverEventRequest", + response_type: "truapi::versioned::notifications::HostNotificationAcknowledgeReceiverEventResponse", + error_type: Some("truapi::versioned::notifications::HostNotificationReceivingError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + wire: MethodWire::Request(MethodIds { + trait_id: 8, + method_id: 7, + }), + }; +} +impl RequestMethod for NotificationsAcknowledgeReceiverEvent { + type Request = + truapi::versioned::notifications::HostNotificationAcknowledgeReceiverEventRequest; + type Response = + truapi::versioned::notifications::HostNotificationAcknowledgeReceiverEventResponse; + type Error = truapi::versioned::notifications::HostNotificationReceivingError; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + /// `notifications_activation_events` method marker. pub struct NotificationsActivationEvents; impl NotificationsActivationEvents { @@ -2758,6 +3111,13 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ ContactsPickMany::DESCRIPTOR, ContactsPlaceLabels::DESCRIPTOR, EntropyDerive::DESCRIPTOR, + JamPeerTransportDial::DESCRIPTOR, + JamPeerTransportOpen::DESCRIPTOR, + JamPeerTransportSend::DESCRIPTOR, + JamPeerTransportRecv::DESCRIPTOR, + JamPeerTransportReset::DESCRIPTOR, + JamPeerTransportClose::DESCRIPTOR, + JamPeerTransportEvents::DESCRIPTOR, GameRemindNextGame::DESCRIPTOR, GameCancelNextGame::DESCRIPTOR, LocalStorageRead::DESCRIPTOR, @@ -2768,6 +3128,12 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ LocaleLocalizeTimestamps::DESCRIPTOR, NotificationsSendPushNotification::DESCRIPTOR, NotificationsCancelPushNotification::DESCRIPTOR, + NotificationsReceiverStatus::DESCRIPTOR, + NotificationsReplaceReceiver::DESCRIPTOR, + NotificationsDisableReceiver::DESCRIPTOR, + NotificationsRecordReceipt::DESCRIPTOR, + NotificationsReceiverEvents::DESCRIPTOR, + NotificationsAcknowledgeReceiverEvent::DESCRIPTOR, NotificationsActivationEvents::DESCRIPTOR, NotificationsAcknowledgeActivation::DESCRIPTOR, PaymentBalanceSubscribe::DESCRIPTOR, @@ -2850,6 +3216,13 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ ContactsPickMany::DESCRIPTOR, ContactsPlaceLabels::DESCRIPTOR, EntropyDerive::DESCRIPTOR, + JamPeerTransportDial::DESCRIPTOR, + JamPeerTransportOpen::DESCRIPTOR, + JamPeerTransportSend::DESCRIPTOR, + JamPeerTransportRecv::DESCRIPTOR, + JamPeerTransportReset::DESCRIPTOR, + JamPeerTransportClose::DESCRIPTOR, + JamPeerTransportEvents::DESCRIPTOR, ExpandedCardSetFaceShown::DESCRIPTOR, GameRemindNextGame::DESCRIPTOR, GameCancelNextGame::DESCRIPTOR, @@ -2861,6 +3234,12 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ LocaleLocalizeTimestamps::DESCRIPTOR, NotificationsSendPushNotification::DESCRIPTOR, NotificationsCancelPushNotification::DESCRIPTOR, + NotificationsReceiverStatus::DESCRIPTOR, + NotificationsReplaceReceiver::DESCRIPTOR, + NotificationsDisableReceiver::DESCRIPTOR, + NotificationsRecordReceipt::DESCRIPTOR, + NotificationsReceiverEvents::DESCRIPTOR, + NotificationsAcknowledgeReceiverEvent::DESCRIPTOR, NotificationsActivationEvents::DESCRIPTOR, NotificationsAcknowledgeActivation::DESCRIPTOR, PaymentBalanceSubscribe::DESCRIPTOR, @@ -2948,6 +3327,13 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ ContactsPickMany::DESCRIPTOR, ContactsPlaceLabels::DESCRIPTOR, EntropyDerive::DESCRIPTOR, + JamPeerTransportDial::DESCRIPTOR, + JamPeerTransportOpen::DESCRIPTOR, + JamPeerTransportSend::DESCRIPTOR, + JamPeerTransportRecv::DESCRIPTOR, + JamPeerTransportReset::DESCRIPTOR, + JamPeerTransportClose::DESCRIPTOR, + JamPeerTransportEvents::DESCRIPTOR, GameRemindNextGame::DESCRIPTOR, GameCancelNextGame::DESCRIPTOR, LocalStorageRead::DESCRIPTOR, @@ -2958,6 +3344,12 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ LocaleLocalizeTimestamps::DESCRIPTOR, NotificationsSendPushNotification::DESCRIPTOR, NotificationsCancelPushNotification::DESCRIPTOR, + NotificationsReceiverStatus::DESCRIPTOR, + NotificationsReplaceReceiver::DESCRIPTOR, + NotificationsDisableReceiver::DESCRIPTOR, + NotificationsRecordReceipt::DESCRIPTOR, + NotificationsReceiverEvents::DESCRIPTOR, + NotificationsAcknowledgeReceiverEvent::DESCRIPTOR, NotificationsActivationEvents::DESCRIPTOR, NotificationsAcknowledgeActivation::DESCRIPTOR, PaymentBalanceSubscribe::DESCRIPTOR, diff --git a/rust/crates/truapi-codegen/src/platform.rs b/rust/crates/truapi-codegen/src/platform.rs index c7962d523c..cf67b985a3 100644 --- a/rust/crates/truapi-codegen/src/platform.rs +++ b/rust/crates/truapi-codegen/src/platform.rs @@ -298,6 +298,9 @@ fn collect_referenced_local_types( names: &NameContext, ) -> Result> { let mut referenced = BTreeSet::new(); + // Resident runtime results are not platform callbacks, but share the + // canonical host-side codec surface. + referenced.insert("ReceivingRegistration".to_string()); for trait_def in traits { for method in &trait_def.methods { for param in &method.params { diff --git a/rust/crates/truapi-codegen/src/platform_callbacks.rs b/rust/crates/truapi-codegen/src/platform_callbacks.rs index 9216e86cee..517551b3c5 100644 --- a/rust/crates/truapi-codegen/src/platform_callbacks.rs +++ b/rust/crates/truapi-codegen/src/platform_callbacks.rs @@ -230,6 +230,9 @@ pub fn snake_case(name: &str) -> String { pub fn collect_local_bridge_payload_types(definition: &PlatformDefinition) -> BTreeSet<&str> { let local: BTreeSet<&str> = definition.types.iter().map(|ty| ty.name.as_str()).collect(); let mut out = BTreeSet::new(); + if local.contains("ReceivingRegistration") { + out.insert("ReceivingRegistration"); + } for trait_def in &definition.traits { for method in &trait_def.methods { for param in &method.params { diff --git a/rust/crates/truapi-codegen/src/rust.rs b/rust/crates/truapi-codegen/src/rust.rs index c32a026fc7..5f1749c4ea 100644 --- a/rust/crates/truapi-codegen/src/rust.rs +++ b/rust/crates/truapi-codegen/src/rust.rs @@ -63,6 +63,7 @@ const TRAIT_MODULE_MAP: &[(&str, &str)] = &[ ("Chat", "chat"), ("Contacts", "contacts"), ("Entropy", "entropy"), + ("JamPeerTransport", "jam_peer_transport"), ("JsonRpc", "jsonrpc"), ("LocalStorage", "local_storage"), ("Payment", "payment"), diff --git a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs index bb4dedb3b0..78d0a74ec1 100644 --- a/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs +++ b/rust/crates/truapi-codegen/src/rust/wasm_bridge.rs @@ -46,6 +46,7 @@ pub fn generate_wasm_bridge( WasmPlatform, call_js_function, decode_bytes, decode_js_item, generic, get_function, get_optional_function, invoke_bool, invoke_bytes_return, invoke_js_subscription, invoke_optional_bytes_return, invoke_optional_string_return, invoke_unit, missing_callback, parse_optional_bytes_item, + absent_optional_callback, }}; /// JS-side callbacks invoked by the wasm platform bridge. Methods with @@ -81,12 +82,16 @@ pub fn generate_wasm_bridge( .unwrap(); for field in bridge_fields(&traits, &trait_names, &optional_traits) { if field.optional { + let fallback = if field.absent_is_none { + "absent_optional_callback()".to_string() + } else { + format!("missing_callback({:?})", field.raw_name) + }; writeln!( out, - " {}: get_optional_function(callbacks, \"{}\")?\n .unwrap_or_else(|| missing_callback(\"{}\")),", - field.field_name, field.raw_name, field.raw_name - ) - .unwrap(); + " {}: get_optional_function(callbacks, {:?})?\n .unwrap_or_else(|| {}),", + field.field_name, field.raw_name, fallback + ).unwrap(); } else { writeln!( out, @@ -186,6 +191,12 @@ impl<'a> BridgeCtx<'a> { matches!(ty, TypeRef::Named { name, .. } if self.local_codec_types.contains(name.as_str())) } + fn is_encoded_codec(&self, ty: &TypeRef) -> bool { + self.is_api_codec(ty) + || self.is_local_codec(ty) + || matches!(ty, TypeRef::Vec(inner) if self.is_encoded_codec(inner)) + } + fn alias_primitive(&self, ty: &TypeRef) -> Option<&'a str> { let TypeRef::Named { name, .. } = ty else { return None; @@ -212,6 +223,7 @@ struct BridgeField { field_name: String, raw_name: String, optional: bool, + absent_is_none: bool, namespace: Option, } @@ -240,7 +252,15 @@ fn bridge_fields( fields.push(BridgeField { field_name: raw_callback_field_name(trait_def, method, platform_trait_names), raw_name: raw_callback_wire_name(trait_def, method, platform_trait_names), - optional, + optional: optional || method.has_default, + absent_is_none: method.has_default + && matches!( + &method.return_shape.inner, + PlatformInner::Result { + ok: TypeRef::Option(_), + .. + } + ), namespace: namespace.clone(), }); } @@ -349,7 +369,26 @@ fn emit_result_method( ), &map_err, ) - } else if ctx.is_api_codec(ok) || ctx.is_local_codec(ok) || is_scale_vector_result(ok) { + } else if let TypeRef::Option(inner) = ok + && ctx.is_encoded_codec(inner) + { + let call = bridge_call( + "invoke_optional_bytes_return", + &method.name, + &args, + &[format!( + "{:?}", + format!("{raw} must resolve to Uint8Array, null or undefined") + )], + ); + let inner_type = rust_type(inner, ctx)?; + formatdoc! { + r#" + let bytes = {call}.await{map_err}?; + bytes.map(|bytes| decode_bytes::<{inner_type}>(bytes, "{raw} response did not decode"){map_err}).transpose() + "# + } + } else if ctx.is_encoded_codec(ok) || is_scale_vector_result(ok) { formatdoc_decode_result(method, ok, &raw, &args, &map_err, ctx)? } else { bail!("unsupported wasm bridge result type for `{raw}`: {ok:?}"); @@ -472,7 +511,11 @@ fn rust_params(method: &PlatformMethod, ctx: &BridgeCtx<'_>) -> Result { Ok(format!( "{}: {reference}{}", param.name, - rust_type(¶m.type_ref, ctx)? + if param.borrowed && is_string(¶m.type_ref) { + "str".to_owned() + } else { + rust_type(¶m.type_ref, ctx)? + } )) }) .collect::>>() @@ -604,11 +647,23 @@ fn js_arg_expr(name: &str, ty: &TypeRef, ctx: &BridgeCtx<'_>) -> Result if is_bytes(ty) { return Ok(format!("Uint8Array::from({name}.as_slice()).into()")); } - if ctx.is_api_codec(ty) || ctx.is_local_codec(ty) { + if ctx.is_encoded_codec(ty) { return Ok(format!( "Uint8Array::from({name}.encode().as_slice()).into()" )); } + if let TypeRef::Option(inner) = ty { + if ctx.is_encoded_codec(inner) { + return Ok(format!( + "{name}.as_ref().map_or(JsValue::UNDEFINED, |value| Uint8Array::from(value.encode().as_slice()).into())" + )); + } + if is_bytes(inner) { + return Ok(format!( + "{name}.as_ref().map_or(JsValue::UNDEFINED, |value| Uint8Array::from(value.as_slice()).into())" + )); + } + } if let Some(primitive) = ctx.alias_primitive(ty) { return numeric_js_arg(name, primitive); } @@ -833,3 +888,104 @@ fn indent_body(body: &str, spaces: usize) -> String { .collect::>() .join("\n") } + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::{PlatformParam, PlatformReturn}; + + fn context() -> BridgeCtx<'static> { + static API_TYPE_PATHS: BTreeMap = BTreeMap::new(); + BridgeCtx { + api_types: BTreeMap::new(), + api_type_paths: &API_TYPE_PATHS, + codec_types: BTreeSet::new(), + local_types: ["ReceivingAuthority"].into_iter().collect(), + local_codec_types: ["ReceivingAuthority"].into_iter().collect(), + } + } + + fn authority_type() -> TypeRef { + TypeRef::Named { + name: "ReceivingAuthority".into(), + args: Vec::new(), + } + } + + #[test] + fn optional_codec_result_propagates_callback_failure_before_decode() { + let ok = TypeRef::Option(Box::new(authority_type())); + let error = TypeRef::Named { + name: "GenericError".into(), + args: Vec::new(), + }; + let method = PlatformMethod { + name: "receiver_authority".into(), + docs: None, + params: vec![PlatformParam { + name: "product".into(), + type_ref: TypeRef::Primitive("str".into()), + borrowed: true, + }], + return_shape: PlatformReturn { + is_async: true, + inner: PlatformInner::Result { + ok: ok.clone(), + err: error.clone(), + }, + }, + has_default: true, + }; + let output = emit_result_method(&method, &ok, &error, &context()).unwrap(); + assert!(output.contains("product: &str"), "{output}"); + assert!(output.contains(".await.map_err(generic)?;"), "{output}"); + assert!( + output + .contains("bytes.map(|bytes| decode_bytes::"), + "{output}" + ); + assert!(output.contains(".transpose()"), "{output}"); + } + + #[test] + fn optional_and_vector_codec_arguments_use_matching_scale_payloads() { + let context = context(); + let vector = TypeRef::Vec(Box::new(authority_type())); + assert_eq!( + js_arg_expr("watches", &vector, &context).unwrap(), + "Uint8Array::from(watches.encode().as_slice()).into()" + ); + let optional = TypeRef::Option(Box::new(authority_type())); + let output = js_arg_expr("authority", &optional, &context).unwrap(); + assert!(output.contains("map_or(JsValue::UNDEFINED")); + assert!(output.contains("value.encode()")); + } + + #[test] + fn optional_default_authority_is_absent_not_successful_enrollment() { + let method = PlatformMethod { + name: "receiver_authority".into(), + docs: None, + params: Vec::new(), + return_shape: PlatformReturn { + is_async: true, + inner: PlatformInner::Result { + ok: TypeRef::Option(Box::new(authority_type())), + err: TypeRef::Named { + name: "GenericError".into(), + args: Vec::new(), + }, + }, + }, + has_default: true, + }; + let notifications = PlatformTrait { + name: "Notifications".into(), + docs: None, + methods: vec![method], + }; + let fields = bridge_fields(&[¬ifications], &BTreeSet::new(), &BTreeSet::new()); + assert!(fields[0].optional); + assert!(fields[0].absent_is_none); + } +} diff --git a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index 77361a9e0e..d3ad6f92d9 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -283,7 +283,23 @@ fn emit_wasm_adapter( "#, ) .unwrap(); - if !result_codecs.is_empty() { + let needs_scale = traits.iter().flat_map(|t| &t.methods).any(|method| { + let vector_codec = |ty: &TypeRef| { + let ty = match ty { + TypeRef::Option(inner) => inner.as_ref(), + other => other, + }; + matches!(ty, TypeRef::Vec(_)) + && encoded_codec_expr(ty, codec_types, local_codec_types).is_some() + }; + method.params.iter().any(|p| vector_codec(&p.type_ref)) + || match &method.return_shape.inner { + PlatformInner::Result { ok, .. } | PlatformInner::Plain(ok) => vector_codec(ok), + PlatformInner::Stream(item) => vector_codec(stream_item(item)), + _ => false, + } + }); + if needs_scale || !result_codecs.is_empty() { out.push_str("import * as S from \"@parity/truapi/scale\";\n"); } emit_import_block(&mut out, false, "@parity/truapi", &imports); @@ -882,7 +898,7 @@ fn emit_raw_callbacks( local_codec_types, platform_trait_names, ); - out.push_str(&if optional { + out.push_str(&if optional || method.has_default { mark_member_optional(&member) } else { member @@ -1081,11 +1097,15 @@ fn adapter_arg( local_codec_types: &BTreeSet, ) -> String { let name = to_camel_case(¶m.name); + if let Some(codec) = encoded_codec_expr(¶m.type_ref, codec_types, local_codec_types) { + return format!("{codec}.dec({name})"); + } match ¶m.type_ref { - TypeRef::Named { name: ty, .. } - if codec_types.contains(ty) || local_codec_types.contains(ty) => + TypeRef::Option(inner) + if encoded_codec_expr(inner, codec_types, local_codec_types).is_some() => { - format!("{ty}.dec({name})") + let codec = encoded_codec_expr(inner, codec_types, local_codec_types).unwrap(); + format!("{name} == null ? undefined : {codec}.dec({name})") } _ => name, } @@ -1214,6 +1234,11 @@ fn validate_adapter_codec_boundary_type( position: &str, method_name: &str, ) -> Result<()> { + if encoded_codec_expr(ty, codec_types, local_codec_types).is_some() + || matches!(ty, TypeRef::Option(inner) if encoded_codec_expr(inner, codec_types, local_codec_types).is_some()) + { + return Ok(()); + } if contains_non_direct_codec_type(ty, codec_types, local_codec_types) { bail!( "unsupported compound codec type in host callback `{method_name}` {position}: {ty:?}" @@ -1222,9 +1247,7 @@ fn validate_adapter_codec_boundary_type( Ok(()) } -/// Named codec payload parameters must cross directly. Vector results use an -/// emitted inline SCALE codec and validate their elements separately; other -/// containers of named codecs remain unsupported. +/// Reject compound shapes for which the bridge has no shared codec lowering. fn contains_non_direct_codec_type( ty: &TypeRef, codec_types: &BTreeSet, @@ -1258,6 +1281,24 @@ fn contains_non_direct_codec_type( walk(ty, false, codec_types, local_codec_types) } +fn encoded_codec_expr( + ty: &TypeRef, + codec_types: &BTreeSet, + local_codec_types: &BTreeSet, +) -> Option { + match ty { + TypeRef::Named { name, args } + if args.is_empty() + && (codec_types.contains(name) || local_codec_types.contains(name)) => + { + Some(name.clone()) + } + TypeRef::Vec(inner) => encoded_codec_expr(inner, codec_types, local_codec_types) + .map(|codec| format!("S.Vector({codec})")), + _ => None, + } +} + /// The adapter implementation expression for a unary callback: decode codec /// params, call the typed host method, SCALE-encode a codec result. fn adapter_unary_impl( @@ -1275,16 +1316,18 @@ fn adapter_unary_impl( .collect::>() .join(", "); let call = format!("{host_method}({args})"); - let body = match ok { - TypeRef::Named { name: ty, .. } - if codec_types.contains(ty) || local_codec_types.contains(ty) => - { - format!("{ty}.enc(await {call})") - } - ty if is_scale_vector_result(ty) => { - format!("{}ResultCodec.enc(await {call})", raw_callback_name(method)) - } - _ => format!("await {call}"), + let body = if is_scale_vector_result(ok) { + format!("{}ResultCodec.enc(await {call})", raw_callback_name(method)) + } else if let Some(codec) = encoded_codec_expr(ok, codec_types, local_codec_types) { + format!("{codec}.enc(await {call})") + } else if let TypeRef::Option(inner) = ok + && let Some(codec) = encoded_codec_expr(inner, codec_types, local_codec_types) + { + format!( + "{{ const value = await {call}; return value == null ? undefined : {codec}.enc(value); }}" + ) + } else { + format!("await {call}") }; Ok(format!("async ({params}) => {body}")) } @@ -1424,18 +1467,31 @@ fn local_codec_expr_for_type(type_def: &TypeDef) -> Result { .join(", ") )); } + let indexed = variants.iter().enumerate().any(|(position, variant)| { + variant + .codec_index + .is_some_and(|index| index as usize != position) + }); let entries = variants .iter() - .map(|variant| { - Ok(format!( - "{}: {}", - variant.name, - local_variant_codec_expr(&variant.fields)? - )) + .enumerate() + .map(|(position, variant)| { + let codec = local_variant_codec_expr(&variant.fields)?; + if indexed { + let index = variant.codec_index.map_or(position, |index| index as usize); + Ok(format!("{}: [{index}, {codec}] as const", variant.name)) + } else { + Ok(format!("{}: {codec}", variant.name)) + } }) .collect::>>()? .join(", "); - Ok(format!("S.TaggedUnion({{{entries}}})")) + let constructor = if indexed { + "indexedTaggedUnion" + } else { + "TaggedUnion" + }; + Ok(format!("S.{constructor}({{{entries}}})")) } } } @@ -2012,15 +2068,48 @@ mod tests { #[test] fn wasm_adapter_rejects_unsupported_compound_codec_return_shapes() { let codec = named("HostFeatureSupportedResponse"); - assert_rejects_compound_codec(method_with_return(TypeRef::Option(Box::new(codec.clone())))); assert_rejects_compound_codec(method_with_return(TypeRef::Tuple(vec![codec]))); } #[test] fn wasm_adapter_rejects_compound_codec_param_shapes() { let codec = named("HostFeatureSupportedRequest"); - assert_rejects_compound_codec(method_with_param(TypeRef::Vec(Box::new(codec.clone())))); - assert_rejects_compound_codec(method_with_param(TypeRef::Option(Box::new(codec.clone())))); assert_rejects_compound_codec(method_with_param(TypeRef::Tuple(vec![codec]))); } + + #[test] + fn wasm_adapter_encodes_optional_and_vector_codec_results() { + for (shape, expected) in [ + ( + TypeRef::Vec(Box::new(named("HostFeatureSupportedResponse"))), + "featureSupportedResultCodec.enc(await callbacks.features.featureSupported(", + ), + ( + TypeRef::Option(Box::new(named("HostFeatureSupportedResponse"))), + "value == null ? undefined : HostFeatureSupportedResponse.enc(value)", + ), + ] { + let definition = platform_with_method(method_with_return(shape)); + let output = emit_wasm_adapter(&definition, &codec_types(), &BTreeSet::new()).unwrap(); + assert!(output.contains(expected), "{output}"); + } + } + + #[test] + fn wasm_adapter_decodes_optional_and_vector_codec_parameters() { + for (shape, expected) in [ + ( + TypeRef::Vec(Box::new(named("HostFeatureSupportedRequest"))), + "S.Vector(HostFeatureSupportedRequest).dec(request)", + ), + ( + TypeRef::Option(Box::new(named("HostFeatureSupportedRequest"))), + "request == null ? undefined : HostFeatureSupportedRequest.dec(request)", + ), + ] { + let definition = platform_with_method(method_with_param(shape)); + let output = emit_wasm_adapter(&definition, &codec_types(), &BTreeSet::new()).unwrap(); + assert!(output.contains(expected), "{output}"); + } + } } diff --git a/rust/crates/truapi-codegen/tests/emission.rs b/rust/crates/truapi-codegen/tests/emission.rs new file mode 100644 index 0000000000..bf2a6c7068 --- /dev/null +++ b/rust/crates/truapi-codegen/tests/emission.rs @@ -0,0 +1,206 @@ +//! Determinism test for generated protocol and host bindings. +//! +//! `cargo rustdoc` runs once per package, under the nightly named in the +//! repository's `nightly-toolchain` file, into a dedicated +//! `target/codegen-test-rustdoc/` directory, off the shared +//! `target/doc/.json` path that a concurrent `cargo doc` would +//! claim. Every test reads the same JSON, so the build is paid once per +//! package per run. That toolchain is required; if it is not installed the +//! test panics rather than silently passing (`rustup toolchain install +//! "$(cat nightly-toolchain)"`). `TRUAPI_NIGHTLY_TOOLCHAIN` overrides it. + +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{Mutex, OnceLock}; + +/// The dated nightly CI runs, unless `TRUAPI_NIGHTLY_TOOLCHAIN` names another. +fn nightly_toolchain() -> String { + std::env::var("TRUAPI_NIGHTLY_TOOLCHAIN").unwrap_or_else(|_| { + include_str!("../../../../nightly-toolchain") + .trim() + .to_string() + }) +} + +/// Path to the rustdoc JSON of `truapi`'s protocol definitions alone, the +/// input codegen reads the API from, building it on first use. +fn produce_rustdoc_json(workspace_root: &Path) -> PathBuf { + produce_rustdoc_json_for_package( + workspace_root, + "truapi", + &["--no-default-features", "--features", "host-api"], + ) +} + +/// Path to `package`'s rustdoc JSON built with `cargo_args`, building it on +/// first use and reusing that build for every later caller in this test +/// binary. Panics with a clear message if nightly is unavailable so CI cannot +/// pass vacuously. +fn produce_rustdoc_json_for_package( + workspace_root: &Path, + package: &str, + cargo_args: &[&str], +) -> PathBuf { + static BUILT: OnceLock>> = OnceLock::new(); + let built = BUILT.get_or_init(|| Mutex::new(HashMap::new())); + // Held across the build so two tests asking for the same package queue + // instead of racing into one target directory. + let mut built = built + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let key = [package] + .into_iter() + .chain(cargo_args.iter().copied()) + .collect::>() + .join(" "); + if let Some(json) = built.get(&key) { + return json.clone(); + } + + let target_dir = workspace_root + .join("target/codegen-test-rustdoc") + .join(key.replace(' ', "_")); + let json = run_rustdoc_json(workspace_root, &target_dir, package, cargo_args); + built.insert(key, json.clone()); + json +} + +/// One `cargo rustdoc --output-format json` invocation on the pinned nightly, returning +/// the path to the JSON it wrote. +fn run_rustdoc_json( + workspace_root: &Path, + target_dir: &Path, + package: &str, + cargo_args: &[&str], +) -> PathBuf { + let toolchain = nightly_toolchain(); + let mut command = Command::new("cargo"); + command + .arg(format!("+{toolchain}")) + .args(["rustdoc", "-p", package]) + .args(cargo_args) + .arg("--target-dir") + .arg(target_dir) + .args(["--", "-Z", "unstable-options", "--output-format", "json"]) + .current_dir(workspace_root); + let output = command.output().expect( + "failed to spawn rustdoc; install the pinned nightly named in nightly-toolchain via rustup", + ); + assert!( + output.status.success(), + "`cargo +{toolchain} rustdoc -p {package}` failed (status {}); that nightly toolchain is required.\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + let json_name = package.replace('-', "_"); + let json = target_dir.join(format!("doc/{json_name}.json")); + assert!( + json.exists(), + "rustdoc JSON not found at {} after successful rustdoc invocation", + json.display(), + ); + json +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .expect("workspace root above rust/crates/truapi-codegen") + .to_path_buf() +} + +fn workspace_tempdir(workspace: &Path) -> tempfile::TempDir { + let parent = workspace.join("target/codegen-test-tmp"); + fs::create_dir_all(&parent).expect("create workspace codegen temp directory"); + tempfile::Builder::new() + .prefix("golden-") + .tempdir_in(parent) + .expect("workspace tempdir") +} + +/// Idempotence guard at the integration level: running the binary twice +/// against the same input must produce identical output. This catches +/// non-determinism (HashMap iteration order, timestamps, etc.) that the +/// inline unit tests might miss because they exercise smaller APIs. +#[test] +fn binary_emission_is_idempotent() { + let workspace = workspace_root(); + let rustdoc_json = produce_rustdoc_json(&workspace); + let runtime_json = produce_rustdoc_json_for_package(&workspace, "truapi", &[]); + let provider_json = produce_rustdoc_json_for_package(&workspace, "truapi-provider", &[]); + + // Every emitted file, not a hand-picked list: the nondeterminism this + // guards against has landed in the TypeScript output as readily as in the + // Rust output, and a list only covers what someone remembered to add. + let run_once = || -> BTreeMap { + let tmp = workspace_tempdir(&workspace); + let status = Command::new(env!("CARGO_BIN_EXE_truapi-codegen")) + .args([ + "--input", + rustdoc_json.to_str().unwrap(), + "--output", + tmp.path().join("ts").to_str().unwrap(), + "--rust-output", + tmp.path().join("rust").to_str().unwrap(), + "--platform-input", + runtime_json.to_str().unwrap(), + "--platform-input", + provider_json.to_str().unwrap(), + "--platform-ts-output", + tmp.path().join("host").to_str().unwrap(), + "--platform-wasm-adapter-output", + tmp.path().join("wasm").to_str().unwrap(), + "--platform-rust-output", + tmp.path().join("rust-wasm").to_str().unwrap(), + ]) + .status() + .expect("run truapi-codegen"); + assert!(status.success(), "codegen run failed"); + read_tree(tmp.path()) + }; + + let first = run_once(); + let second = run_once(); + assert!(!first.is_empty(), "codegen emitted nothing"); + assert_eq!( + first.keys().collect::>(), + second.keys().collect::>(), + "the two runs emitted different files" + ); + for (path, contents) in &first { + assert_eq!( + contents, + &second[path], + "{} differs between runs", + path.display() + ); + } +} + +/// Every file under `root`, keyed by its path relative to `root`. +fn read_tree(root: &Path) -> BTreeMap { + let mut files = BTreeMap::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(dir) = pending.pop() { + for entry in fs::read_dir(&dir).expect("read generated directory") { + let path = entry.expect("read generated entry").path(); + if path.is_dir() { + pending.push(path); + } else { + let relative = path + .strip_prefix(root) + .expect("path under root") + .to_path_buf(); + files.insert( + relative, + fs::read_to_string(&path).expect("read generated file"), + ); + } + } + } + files +} diff --git a/rust/crates/truapi-host-cli/src/frame_server.rs b/rust/crates/truapi-host-cli/src/frame_server.rs index e60a2a3a3c..4e3bfdbbc5 100644 --- a/rust/crates/truapi-host-cli/src/frame_server.rs +++ b/rust/crates/truapi-host-cli/src/frame_server.rs @@ -771,6 +771,7 @@ mod tests { second_dispatch_finished: Notify, dispatch_cancelled: Arc, dispose_calls: AtomicUsize, + ui: Option, } struct DispatchCancellation(Arc); @@ -790,7 +791,12 @@ mod tests { } let _cancellation = DispatchCancellation(self.dispatch_cancelled.clone()); self.dispatch_started.notify_one(); - std::future::pending().await + if let Some(ui) = &self.ui { + ui.confirm("sign raw", "pending product payload").await; + Ok(()) + } else { + std::future::pending().await + } } fn dispose(&self) { @@ -1034,7 +1040,17 @@ mod tests { async fn disconnect_cancels_pending_dispatch_and_disposes_runtime() -> Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; let address = listener.local_addr()?; - let runtime = Arc::new(PendingRuntime::default()); + let (mut ui, handle) = crate::terminal_ui::TerminalUi::new( + "testnet", + "localhost:3000", + "default", + Vec::new(), + "info".into(), + ); + let runtime = Arc::new(PendingRuntime { + ui: Some(handle), + ..PendingRuntime::default() + }); let server_runtime = runtime.clone(); let product = ProductSelection::new("localhost:3000".into(), ProductExecutionKind::App)?; let product_updates = product.subscribe(); @@ -1059,6 +1075,7 @@ mod tests { let (mut websocket, _) = client_async("ws://localhost/", stream).await?; websocket.send(Message::Binary(vec![0])).await?; tokio::time::timeout(Duration::from_secs(1), runtime.dispatch_started.notified()).await?; + assert!(ui.has_pending_approval()); websocket.send(Message::Binary(vec![1])).await?; tokio::time::timeout( Duration::from_secs(1), @@ -1068,6 +1085,7 @@ mod tests { drop(websocket); tokio::time::timeout(Duration::from_secs(1), server).await???; + assert!(!ui.has_pending_approval()); assert_eq!( ( runtime.dispose_calls.load(Ordering::SeqCst), diff --git a/rust/crates/truapi-host-cli/src/terminal_ui.rs b/rust/crates/truapi-host-cli/src/terminal_ui.rs index 1d7f2ecc65..5fb0be8195 100644 --- a/rust/crates/truapi-host-cli/src/terminal_ui.rs +++ b/rust/crates/truapi-host-cli/src/terminal_ui.rs @@ -358,6 +358,7 @@ enum UiEvent { kind: ApprovalKind, response: oneshot::Sender, }, + ApprovalWithdrawn, ChatFiles { detail: String, max_files: u32, @@ -606,6 +607,21 @@ pub struct UiHandle { sender: mpsc::UnboundedSender, } +struct ApprovalAnswer<'a> { + answer: oneshot::Receiver, + sender: Option<&'a mpsc::UnboundedSender>, +} + +impl Drop for ApprovalAnswer<'_> { + fn drop(&mut self) { + if let Some(sender) = self.sender { + // Close before waking the UI when the owning call is dropped. + self.answer.close(); + let _ = sender.send(UiEvent::ApprovalWithdrawn); + } + } +} + impl UiHandle { /// Add a successful human-facing outcome to the transcript. pub fn success(&self, title: impl Into, detail: Option) { @@ -675,7 +691,15 @@ impl UiHandle { { return PermissionDecision::Deny; } - answer.await.unwrap_or(PermissionDecision::Deny) + let mut answer = ApprovalAnswer { + answer, + sender: Some(&self.sender), + }; + let decision = (&mut answer.answer) + .await + .unwrap_or(PermissionDecision::Deny); + answer.sender = None; + decision } /// Collect paths only through the existing terminal event owner. Input is @@ -707,6 +731,15 @@ pub struct TerminalUi { } impl TerminalUi { + /// Process queued events and report whether a review still owns the editor. + #[cfg(test)] + pub fn has_pending_approval(&mut self) -> bool { + while let Ok(event) = self.receiver.try_recv() { + self.app.handle_event(event); + } + self.app.pending_approval.is_some() + } + /// Create a terminal transcript and its cloneable host bridge. pub fn new( network: impl Into, @@ -1628,6 +1661,7 @@ impl App { } fn handle_event(&mut self, event: UiEvent) { + self.withdraw_closed_approval(); match event { UiEvent::Log(text) => self.push(FeedItem::Log(text)), UiEvent::Notice { @@ -1655,6 +1689,9 @@ impl App { kind, response, } => { + if response.is_closed() { + return; + } if self.pending_approval.is_some() || self.pending_chat_files.is_some() { let _ = response.send(PermissionDecision::Deny); self.notice( @@ -1681,6 +1718,7 @@ impl App { saved_input, }); } + UiEvent::ApprovalWithdrawn => {} UiEvent::ChatFiles { detail, max_files, @@ -2282,6 +2320,9 @@ impl App { } fn handle_approval_key(&mut self, key: KeyEvent) { + if self.withdraw_closed_approval() { + return; + } let Some(pending) = &self.pending_approval else { return; }; @@ -2319,6 +2360,22 @@ impl App { } } + fn withdraw_closed_approval(&mut self) -> bool { + if !self + .pending_approval + .as_ref() + .is_some_and(|pending| pending.response.is_closed()) + { + return false; + } + let pending = self.pending_approval.take().expect("closed approval"); + self.entries + .retain(|entry| !matches!(entry, FeedItem::Approval { id, .. } if *id == pending.id)); + self.recalculate_retained(); + self.editor.set_text(pending.saved_input); + true + } + fn answer_approval(&mut self, approved: PermissionDecision) { let Some(pending) = self.pending_approval.take() else { return; @@ -3402,6 +3459,141 @@ mod tests { ) } + fn test_ui() -> (TerminalUi, UiHandle) { + TerminalUi::new( + "testnet", + "playground.dot", + "default", + Vec::new(), + "info".into(), + ) + } + + #[tokio::test] + async fn cancelling_a_visible_approval_restores_the_draft_without_a_decision() { + let (mut ui, handle) = test_ui(); + ui.app.editor.set_text("/script draft.ts"); + let (abort, registration) = futures::future::AbortHandle::new_pair(); + let mut call = Box::pin(futures::future::Abortable::new( + handle.decide("sign raw", "withdrawn payload", ApprovalKind::Action), + registration, + )); + assert!(futures::poll!(call.as_mut()).is_pending()); + assert!(ui.has_pending_approval()); + ui.app.editor.set_text("unfinished answer"); + + abort.abort(); + assert!(call.await.is_err()); + assert!(!ui.has_pending_approval()); + assert_eq!(ui.app.editor.text(), "/script draft.ts"); + assert!(!ui.app.transcript_text().contains("withdrawn payload")); + + let mut next = Box::pin(handle.decide("sign raw", "next payload", ApprovalKind::Action)); + assert!(futures::poll!(next.as_mut()).is_pending()); + assert!(ui.has_pending_approval()); + ui.app + .handle_approval_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert_eq!(next.await, PermissionDecision::AllowAlways); + assert_eq!(ui.app.editor.text(), "/script draft.ts"); + } + + #[tokio::test] + async fn cancelling_a_queued_approval_never_replaces_the_editor() { + let (mut ui, handle) = test_ui(); + ui.app.editor.set_text("/script draft.ts"); + let mut call = + Box::pin(handle.decide("sign raw", "withdrawn payload", ApprovalKind::Action)); + assert!(futures::poll!(call.as_mut()).is_pending()); + drop(call); + + assert!(!ui.has_pending_approval()); + assert_eq!(ui.app.editor.text(), "/script draft.ts"); + assert!(!ui.app.transcript_text().contains("withdrawn payload")); + } + + #[tokio::test] + async fn a_late_key_cannot_answer_a_withdrawn_approval() { + let (mut ui, handle) = test_ui(); + ui.app.editor.set_text("/script draft.ts"); + let mut call = + Box::pin(handle.decide("sign raw", "withdrawn payload", ApprovalKind::Action)); + assert!(futures::poll!(call.as_mut()).is_pending()); + assert!(ui.has_pending_approval()); + drop(call); + + // The key can win the event-loop race against the withdrawal wake. + ui.app + .handle_approval_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert!(ui.app.pending_approval.is_none()); + assert_eq!(ui.app.editor.text(), "/script draft.ts"); + assert!(!ui.app.transcript_text().contains("withdrawn payload")); + } + + #[tokio::test] + async fn a_queued_successor_survives_the_previous_approval_withdrawal() { + let (mut ui, handle) = test_ui(); + let mut first = Box::pin(handle.decide("first", "first payload", ApprovalKind::Action)); + assert!(futures::poll!(first.as_mut()).is_pending()); + assert!(ui.has_pending_approval()); + let mut next = Box::pin(handle.decide("next", "next payload", ApprovalKind::Action)); + assert!(futures::poll!(next.as_mut()).is_pending()); + drop(first); + + // The next request was enqueued before the old request's withdrawal. + assert!(ui.has_pending_approval()); + assert!(!ui.app.transcript_text().contains("first payload")); + assert!(ui.app.transcript_text().contains("next payload")); + assert!(futures::poll!(next.as_mut()).is_pending()); + ui.app + .handle_approval_key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + assert_eq!(next.await, PermissionDecision::Deny); + } + + #[tokio::test] + async fn rejecting_an_overlap_does_not_withdraw_the_active_approval() { + let (mut ui, handle) = test_ui(); + let mut first = Box::pin(handle.decide("first", "first payload", ApprovalKind::Action)); + assert!(futures::poll!(first.as_mut()).is_pending()); + assert!(ui.has_pending_approval()); + let mut overlap = Box::pin(handle.decide("overlap", "other payload", ApprovalKind::Action)); + assert!(futures::poll!(overlap.as_mut()).is_pending()); + assert!(ui.has_pending_approval()); + assert_eq!(overlap.await, PermissionDecision::Deny); + assert!(ui.has_pending_approval()); + assert!(futures::poll!(first.as_mut()).is_pending()); + ui.app + .handle_approval_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert_eq!(first.await, PermissionDecision::AllowAlways); + } + + #[tokio::test] + async fn cancelling_platform_approvals_releases_the_prompt_lock_in_order() { + let (mut ui, handle) = test_ui(); + let platform = crate::platform::CliPlatform::new( + crate::network::Network::default().config(), + None, + crate::platform::ApprovalPolicy::Prompt, + Some(handle), + ); + let mut first = Box::pin(platform.decide("first", "first payload".into())); + assert!(futures::poll!(first.as_mut()).is_pending()); + assert!(ui.has_pending_approval()); + let mut withdrawn = Box::pin(platform.decide("withdrawn", "queued payload".into())); + assert!(futures::poll!(withdrawn.as_mut()).is_pending()); + let mut next = Box::pin(platform.decide("next", "next payload".into())); + assert!(futures::poll!(next.as_mut()).is_pending()); + drop(withdrawn); + drop(first); + assert!(futures::poll!(next.as_mut()).is_pending()); + assert!(ui.has_pending_approval()); + assert!(!ui.app.transcript_text().contains("first payload")); + assert!(!ui.app.transcript_text().contains("queued payload")); + assert!(ui.app.transcript_text().contains("next payload")); + ui.app + .handle_approval_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert!(next.await); + } + #[test] fn approval_preserves_the_decision_and_restores_command_draft() { for (kind, key, expected) in [ diff --git a/rust/crates/truapi/Cargo.toml b/rust/crates/truapi/Cargo.toml index 6421a2e186..66a6bc0c8a 100644 --- a/rust/crates/truapi/Cargo.toml +++ b/rust/crates/truapi/Cargo.toml @@ -61,6 +61,7 @@ runtime = [ "dep:blake2b_simd", "dep:sp-crypto-hashing", "dep:schnorrkel", + "dep:ed25519-dalek", "dep:substrate-bip39", "dep:getrandom", "dep:hkdf", @@ -88,6 +89,11 @@ runtime = [ "dep:base64", "dep:rand", "dep:libc", + "dep:quinn", + "dep:rcgen", + "dep:ring", + "dep:rustls", + "dep:x509-parser", "dep:rusqlite", "dep:async-sqlite", "dep:rusqlite_migration", @@ -125,6 +131,7 @@ nanoid = { workspace = true, optional = true } blake2b_simd = { workspace = true, optional = true } sp-crypto-hashing = { workspace = true, optional = true } schnorrkel = { workspace = true, features = ["alloc", "getrandom"], optional = true } +ed25519-dalek = { workspace = true, optional = true } substrate-bip39 = { workspace = true, optional = true } zeroize = { workspace = true, default-features = false, features = ["alloc", "derive"] } getrandom = { workspace = true, features = ["js"], optional = true } @@ -152,6 +159,12 @@ uniffi = { workspace = true, optional = true } subxt = { workspace = true, features = ["native"], optional = true } subxt-rpcs = { workspace = true, features = ["jsonrpsee", "native"], optional = true } base64 = { workspace = true, optional = true } +# JAMNP-S QUIC is native-only; browsers dial WebTransport in JavaScript. +quinn = { version = "0.11", default-features = false, features = ["runtime-tokio", "rustls-ring"], optional = true } +rcgen = { version = "0.14", default-features = false, features = ["crypto", "ring"], optional = true } +ring = { version = "0.17", optional = true } +rustls = { version = "0.23", default-features = false, features = ["std", "ring"], optional = true } +x509-parser = { version = "0.17", optional = true } rusqlite = { workspace = true, features = ["bundled", "hooks"], optional = true } async-sqlite = { workspace = true, features = ["bundled"], optional = true } rusqlite_migration = { workspace = true, optional = true } @@ -195,6 +208,14 @@ required-features = ["runtime"] name = "signing_host_mock_platform" required-features = ["mock"] +[[test]] +name = "jam_peer_transport_contract" +required-features = ["runtime"] + +[[test]] +name = "live_jam_test_instance" +required-features = ["mock"] + [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test = { workspace = true } diff --git a/rust/crates/truapi/RUNTIME.md b/rust/crates/truapi/RUNTIME.md index 92cfc36216..30545c167c 100644 --- a/rust/crates/truapi/RUNTIME.md +++ b/rust/crates/truapi/RUNTIME.md @@ -253,6 +253,60 @@ return an absent value and open a second purse. This refuses incoming claims as identity-only wallet loader is not another allocator; a single Host runtime may own the purse while that loader retains responsibility for secure unlock. +#### JAM peer transport + +Native product runtimes (iOS, Android, CLI) serve `JamPeerTransport` (trait +111) themselves over JAMNP-S QUIC, with one endpoint per product connection. +Every `dial` first requires `RemotePermission::JamPeers { genesis }` through the +flow above. The connection asks once per genesis: concurrent dials wait for the +same prompt, a refusal stays `NotGranted` for the connection, and the answer is +persisted even when every dial waiting on it has timed out. Revoking or dropping +the session instead cancels its pending permission checks and transport operations. + +Pending dials reserve from the eight-connection budget before awaiting permission, +alongside retained connection handles. The session remembers at most eight distinct +full-genesis decisions, including pending and denied decisions; a new ninth +genesis returns `Limit` without prompting or evicting a prior decision. Cancelled, +timed-out, or dropped dial futures release their pending reservation and subscription. +The one bounded permission task per genesis remains until its answer or session stop. + +- A dial answers within 10 seconds, prompt included. One still waiting then + answers `Unreachable`, a cancelled one `Cancelled`, and what it would have + opened is dropped without holding one of the 8 connection slots. Native + closed connection handles retain their slot until the guest calls `close`, + so their remaining streams and queued data cannot bypass the cap. +- The ALPN is `jam_peer_transport::alpn(genesis)`, and the peer certificate + must carry the Ed25519 key the dial names. The P-256 key is for WebTransport + hosts and is ignored. +- The first granted dial creates the endpoint, so a refused product binds no + socket. Disposing the connection closes every peer connection, and later + calls are `Denied`. +- Stream opens reserve one of 16 slots before waiting for the transport. + Cancellation and session closure cannot publish late stream handles. +- Incoming and outgoing length-prefixed messages share a 4 MiB per-connection + reservation budget. Headers and empty messages consume space too; readers + wait for capacity before allocating payloads, and draining/resetting streams + releases capacity. Individual payloads remain limited to 1 MiB. + +The browser core keeps the trait's `NotGranted` defaults, because its +JavaScript session answers trait 111 before frames reach the core. +Browser dials share the eight-connection limit with established connections while +awaiting permission or the handshake. Its execution-local cache holds at most +eight distinct genesis decisions, counting pending/refused decisions too; a new +ninth genesis returns `Limit` without evicting any remembered decision. +Cancelling a dial releases its operation slot and removes its subscription to a +pending decision. The shared decision remains available to retries until stop. + +The browser uses WebTransport's readable byte streams with BYOB reads to reserve +space before receiving each payload; its cancellation path handles blocked opens +and writes as well as dials. WebTransport negotiates HTTP/3, not the native +genesis-prefix ALPN. The current PolkaJAM CONNECT endpoint has no additional +genesis negotiation. On both platforms, the full genesis keys permission +decisions and TLS pins the caller-supplied peer key; neither proves validator +membership or makes received chain data trustworthy. Guests must verify it. + +`cargo test -p truapi --features mock --test live_jam_test_instance -- --include-ignored` +dials JAM-TEST-INSTANCE through a product runtime. ### Core database Native signing hosts (iOS, Android, the CLI) give the core a directory for its diff --git a/rust/crates/truapi/src/api.rs b/rust/crates/truapi/src/api.rs index 24acc8a0c2..df81c43ba6 100644 --- a/rust/crates/truapi/src/api.rs +++ b/rust/crates/truapi/src/api.rs @@ -8,6 +8,7 @@ pub mod contacts; pub mod entropy; pub mod expanded_card; pub mod game; +pub mod jam_peer_transport; pub mod local_storage; pub mod locale; pub mod notifications; @@ -33,6 +34,7 @@ pub use contacts::Contacts; pub use entropy::Entropy; pub use expanded_card::ExpandedCard; pub use game::Game; +pub use jam_peer_transport::JamPeerTransport; pub use local_storage::LocalStorage; pub use locale::Locale; pub use notifications::Notifications; @@ -58,6 +60,7 @@ pub trait TrUApi: + CoinPayment + Contacts + Entropy + + JamPeerTransport + ExpandedCard + Game + LocalStorage @@ -88,6 +91,7 @@ impl TrUApi for T where + CoinPayment + Contacts + Entropy + + JamPeerTransport + ExpandedCard + Game + LocalStorage diff --git a/rust/crates/truapi/src/api/jam_peer_transport.rs b/rust/crates/truapi/src/api/jam_peer_transport.rs new file mode 100644 index 0000000000..0ee759f446 --- /dev/null +++ b/rust/crates/truapi/src/api/jam_peer_transport.rs @@ -0,0 +1,159 @@ +//! Unified [`JamPeerTransport`] trait. + +use crate::versioned::jam_peer_transport::{ + HostJamPeerTransportCloseError, HostJamPeerTransportCloseRequest, + HostJamPeerTransportCloseResponse, HostJamPeerTransportDialError, + HostJamPeerTransportDialRequest, HostJamPeerTransportDialResponse, + HostJamPeerTransportEventsError, HostJamPeerTransportEventsRequest, + HostJamPeerTransportEventsResponse, HostJamPeerTransportOpenError, + HostJamPeerTransportOpenRequest, HostJamPeerTransportOpenResponse, + HostJamPeerTransportRecvError, HostJamPeerTransportRecvRequest, + HostJamPeerTransportRecvResponse, HostJamPeerTransportResetError, + HostJamPeerTransportResetRequest, HostJamPeerTransportResetResponse, + HostJamPeerTransportSendError, HostJamPeerTransportSendRequest, + HostJamPeerTransportSendResponse, +}; +use crate::{CallContext, CallError, v01, wire, wire_trait}; + +/// Host-terminated QUIC/WebTransport streams to JAM peers (JAMNP-S). +/// +/// The host owns TLS, certificate verification and length framing; the guest +/// verifies every byte it consumes. Access is a runtime permission, not a +/// manifest declaration: `dial` requires +/// [`RemotePermission::JamPeers`](crate::v01::RemotePermission::JamPeers) for +/// its `genesis`, checking the product's stored decision, prompting when it is +/// undetermined and persisting the answer per product and genesis. The other +/// methods act only on connections a granted `dial` opened. A grant is +/// separate from account, signing and storage authority. +#[wire_trait(id = 111)] +#[crate::async_trait] +pub trait JamPeerTransport: Send + Sync { + /// Dial one peer. Native QUIC builds the ALPN from `genesis` and requires + /// the peer certificate to carry `ed25519`. WebTransport negotiates + /// HTTP/3 and pins the certificate derived from `p256`. These checks + /// authenticate the caller-supplied peer identity, not chain membership. + /// + /// ```ts + /// const result = await truapi.jamPeerTransport.dial({ + /// genesis: "0x353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f", + /// ip: "0x00000000000000000000ffff7f000001", + /// port: 43000, + /// ed25519: "0x0000000000000000000000000000000000000000000000000000000000000000", + /// p256: undefined, + /// }); + /// if (result.isOk()) console.log("connection:", result.value.conn); + /// ``` + #[wire(id = 0)] + async fn dial( + &self, + _cx: &CallContext, + _request: HostJamPeerTransportDialRequest, + ) -> Result> { + Err(CallError::Domain(HostJamPeerTransportDialError::V1( + v01::HostJamPeerTransportDialError::NotGranted, + ))) + } + + /// Open a bidirectional stream on a connection and send its kind byte. + /// + /// ```ts + /// const result = await truapi.jamPeerTransport.open({ conn: 0, kind: 0 }); + /// if (result.isOk()) console.log("stream:", result.value.stream); + /// ``` + #[wire(id = 1)] + async fn open( + &self, + _cx: &CallContext, + _request: HostJamPeerTransportOpenRequest, + ) -> Result> { + Err(CallError::Domain(HostJamPeerTransportOpenError::V1( + v01::HostJamPeerTransportOpenError::NotGranted, + ))) + } + + /// Queue one message; the host prepends the `u32` little-endian length. + /// + /// ```ts + /// const result = await truapi.jamPeerTransport.send({ stream: 0, message: "0x00", fin: false }); + /// console.log("sent:", result.isOk()); + /// ``` + #[wire(id = 2)] + async fn send( + &self, + _cx: &CallContext, + _request: HostJamPeerTransportSendRequest, + ) -> Result> { + Err(CallError::Domain(HostJamPeerTransportSendError::V1( + v01::HostJamPeerTransportSendError::Closed, + ))) + } + + /// Poll one complete message without blocking; the host strips the length. + /// + /// ```ts + /// const result = await truapi.jamPeerTransport.recv({ stream: 0, max: 1048576 }); + /// if (result.isOk()) console.log("message:", result.value.message, "fin:", result.value.fin); + /// ``` + #[wire(id = 3)] + async fn recv( + &self, + _cx: &CallContext, + _request: HostJamPeerTransportRecvRequest, + ) -> Result> { + Err(CallError::Domain(HostJamPeerTransportRecvError::V1( + v01::HostJamPeerTransportRecvError::Closed, + ))) + } + + /// Abort a stream in both directions. + /// + /// ```ts + /// const result = await truapi.jamPeerTransport.reset({ stream: 0 }); + /// console.log("reset:", result.isOk()); + /// ``` + #[wire(id = 4)] + async fn reset( + &self, + _cx: &CallContext, + _request: HostJamPeerTransportResetRequest, + ) -> Result> { + Err(CallError::Domain(HostJamPeerTransportResetError::V1( + v01::HostJamPeerTransportResetError::Closed, + ))) + } + + /// Close a connection and every stream on it. + /// + /// ```ts + /// const result = await truapi.jamPeerTransport.close({ conn: 0 }); + /// console.log("closed:", result.isOk()); + /// ``` + #[wire(id = 5)] + async fn close( + &self, + _cx: &CallContext, + _request: HostJamPeerTransportCloseRequest, + ) -> Result> { + Err(CallError::Domain(HostJamPeerTransportCloseError::V1( + v01::HostJamPeerTransportCloseError::Closed, + ))) + } + + /// Drain connection, stream-finish and inbound-stream events. + /// + /// ```ts + /// const result = await truapi.jamPeerTransport.events(); + /// if (result.isOk()) console.log("events:", result.value.events); + /// ``` + #[wire(id = 6)] + async fn events( + &self, + _cx: &CallContext, + _request: HostJamPeerTransportEventsRequest, + ) -> Result> + { + Err(CallError::Domain(HostJamPeerTransportEventsError::V1( + v01::HostJamPeerTransportEventsError::NotGranted, + ))) + } +} diff --git a/rust/crates/truapi/src/api/notifications.rs b/rust/crates/truapi/src/api/notifications.rs index a007c7939c..ed7e92f75a 100644 --- a/rust/crates/truapi/src/api/notifications.rs +++ b/rust/crates/truapi/src/api/notifications.rs @@ -1,6 +1,13 @@ //! Unified [`Notifications`] trait. use crate::versioned::notifications::{ + HostNotificationAcknowledgeReceiverEventRequest, + HostNotificationAcknowledgeReceiverEventResponse, HostNotificationDisableReceiverRequest, + HostNotificationDisableReceiverResponse, HostNotificationReceiverEventsRequest, + HostNotificationReceiverEventsResponse, HostNotificationReceiverStatusRequest, + HostNotificationReceiverStatusResponse, HostNotificationReceivingError, + HostNotificationRecordReceiptRequest, HostNotificationRecordReceiptResponse, + HostNotificationReplaceReceiverRequest, HostNotificationReplaceReceiverResponse, HostPushNotificationCancelError, HostPushNotificationCancelRequest, HostPushNotificationCancelResponse, HostPushNotificationError, HostPushNotificationRequest, HostPushNotificationResponse, NotificationActivationAcknowledgeError, @@ -11,7 +18,7 @@ use crate::versioned::notifications::{ use crate::{CallContext, CallError}; use crate::{wire, wire_trait}; -/// Notification methods for locally-rendered push notifications. +/// Local notification scheduling and consent-scoped background receiving. #[wire_trait(id = 8)] #[crate::async_trait] pub trait Notifications: Send + Sync { @@ -60,6 +67,116 @@ pub trait Notifications: Send + Sync { request: HostPushNotificationCancelRequest, ) -> Result>; + /// Inspect current host support, consent and durable registration state. + /// + /// ```ts + /// const result = await truapi.notifications.receiverStatus(); + /// assert(result.isOk(), "receiverStatus failed:", result); + /// console.log(result.value); + /// ``` + #[wire(id = 2)] + async fn receiver_status( + &self, + cx: &CallContext, + request: HostNotificationReceiverStatusRequest, + ) -> Result>; + + /// Atomically replace watches under explicit receiving consent. + /// + /// ```ts + /// const status = await truapi.notifications.receiverStatus(); + /// assert(status.isOk(), "receiverStatus failed:", status); + /// // An empty policy removes every watch; nonempty policies require scoped consent. + /// const result = await truapi.notifications.replaceReceiver({ + /// expectedRevision: status.value.revision, + /// watches: [], + /// }); + /// assert(result.isOk(), "replaceReceiver failed:", result); + /// ``` + #[wire(id = 3)] + async fn replace_receiver( + &self, + cx: &CallContext, + request: HostNotificationReplaceReceiverRequest, + ) -> Result>; + + /// Disable locally and queue transport revocation without waiting for it. + /// + /// ```ts + /// const status = await truapi.notifications.receiverStatus(); + /// assert(status.isOk(), "receiverStatus failed:", status); + /// const result = await truapi.notifications.disableReceiver({ + /// expectedRevision: status.value.revision, + /// }); + /// assert(result.isOk(), "disableReceiver failed:", result); + /// ``` + #[wire(id = 4)] + async fn disable_receiver( + &self, + cx: &CallContext, + request: HostNotificationDisableReceiverRequest, + ) -> Result>; + + /// Record foreground handling, reading or actual OS display, and return the + /// confirmed/pending display outcome. A reservation is not proof of display. + /// + /// ```ts + /// const events = await truapi.notifications.receiverEvents({ afterSequence: 0n }); + /// assert(events.isOk(), "receiverEvents failed:", events); + /// const event = events.value[0]; + /// if (event) { + /// const result = await truapi.notifications.recordReceipt({ + /// revision: event.revision, watchId: event.watchId, + /// eventId: event.eventId, kind: "Foreground", + /// }); + /// assert(result.isOk(), "recordReceipt failed:", result); + /// console.log(result.value); + /// } + /// ``` + #[wire(id = 5)] + async fn record_receipt( + &self, + cx: &CallContext, + request: HostNotificationRecordReceiptRequest, + ) -> Result>; + + /// Poll bounded durable delivery and activation events. + /// + /// ```ts + /// const result = await truapi.notifications.receiverEvents({ afterSequence: 0n }); + /// assert(result.isOk(), "receiverEvents failed:", result); + /// console.log(result.value); + /// ``` + #[wire(id = 6)] + async fn receiver_events( + &self, + cx: &CallContext, + request: HostNotificationReceiverEventsRequest, + ) -> Result>; + + /// Acknowledge an event after application handling. + /// + /// ```ts + /// const events = await truapi.notifications.receiverEvents({ afterSequence: 0n }); + /// assert(events.isOk(), "receiverEvents failed:", events); + /// for (const event of events.value) { + /// console.log("Received event:", event.kind, event.watchId); + /// const result = await truapi.notifications.acknowledgeReceiverEvent({ + /// sequence: event.sequence, + /// }); + /// assert(result.isOk(), "acknowledgeReceiverEvent failed:", result); + /// } + /// ``` + #[wire(id = 7)] + async fn acknowledge_receiver_event( + &self, + cx: &CallContext, + request: HostNotificationAcknowledgeReceiverEventRequest, + ) -> Result< + HostNotificationAcknowledgeReceiverEventResponse, + CallError, + >; + /// Retrieve up to 32 pending activations for this runtime's authenticated /// product, account and environment. Retrieval does not consume events, /// prompt for permissions or enroll a background receiver. diff --git a/rust/crates/truapi/src/host_core.rs b/rust/crates/truapi/src/host_core.rs index a5b6cccd6b..5c1b2ae221 100644 --- a/rust/crates/truapi/src/host_core.rs +++ b/rust/crates/truapi/src/host_core.rs @@ -188,6 +188,11 @@ pub struct PairingHostRuntime { } impl PairingHostRuntime { + /// Host-only receiving engine, independent of product execution lifetime. + pub fn receiving(&self) -> &Arc { + &self.services.receiving + } + /// Keep preimage submissions in the core instead of the Bulletin chain. /// /// For test hosts only, with the `test-host` feature enabled. @@ -373,12 +378,22 @@ impl PairingHostRuntime { /// /// The next product login request generates a fresh pairing identity and /// presents a new deeplink suitable for another signing host. + /// Local receiving revocation is attempted first; even if persistence fails, + /// the captured session is closed and a warning is returned. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.logout"))] pub async fn logout(&self) -> Result<(), v01::GenericError> { - self.pairing_host - .logout_and_reset_pairing() - .await - .map_err(|reason| v01::GenericError { reason }) + let revocation = self.services.receiving.revoke_all().await; + let logout = self.pairing_host.logout_and_reset_pairing().await; + if revocation.is_err() { + return Err(v01::GenericError { + reason: if logout.is_ok() { + "logged out, but background receiving could not be durably revoked and may remain enabled" + } else { + "background receiving could not be durably revoked and may remain enabled; logout reset also failed" + }.to_string(), + }); + } + logout.map_err(|reason| v01::GenericError { reason }) } /// Clear one product's capability state while preserving the active @@ -616,6 +631,11 @@ pub struct SigningHostRuntime { } impl SigningHostRuntime { + /// Host-only receiving engine, independent of product execution lifetime. + pub fn receiving(&self) -> &Arc { + &self.services.receiving + } + /// Answer resource allocation as granted without performing it. /// /// For test hosts only, with the `test-host` feature enabled. @@ -1942,7 +1962,8 @@ impl ProductRuntime { /// Dispose this host core. Idempotent. /// /// Disposal suppresses future outgoing frames, aborts in-flight dispatch - /// futures, and cancels active subscriptions. + /// futures, cancels active subscriptions and closes the connection's JAM + /// peer connections. #[instrument(skip_all, fields(runtime.method = "product_runtime.dispose"))] pub fn dispose(&self) { // Aborting under the lock can wake code that re-enters disposal. @@ -1965,6 +1986,8 @@ impl ProductRuntime { self.admin.product_runtime.detach_renderer(); self.admin.product_runtime.release_open_operations(); self.admin.product_runtime.release_contact_avatars(); + #[cfg(not(target_arch = "wasm32"))] + self.admin.product_runtime.close_jam_peer_transport(); self.admin.product_runtime.release_contact_labels(); self.host_subscriptions.close(); self.core.cancel_subscriptions(); diff --git a/rust/crates/truapi/src/jam_peer_transport.rs b/rust/crates/truapi/src/jam_peer_transport.rs new file mode 100644 index 0000000000..c0c12b5cf0 --- /dev/null +++ b/rust/crates/truapi/src/jam_peer_transport.rs @@ -0,0 +1,72 @@ +//! JAMNP-S helpers for hosts that implement `JamPeerTransport`. +//! +//! Peer access is a runtime permission, not a manifest capability: before a +//! `dial` connects, the host requires +//! [`RemotePermission::JamPeers`](truapi::latest::RemotePermission::JamPeers) +//! for the requested genesis, reading the product's stored decision, prompting +//! when it is undetermined and persisting the answer per product and genesis. +//! Anything short of a grant answers +//! [`NotGranted`](truapi::latest::HostJamPeerTransportDialError::NotGranted). +//! +//! The host also owns the transport: native QUIC builds the JAMNP-S ALPN from +//! the first four genesis bytes ([`alpn`]) and pins the guest-named Ed25519 +//! identity. WebTransport uses HTTP/3 and a guest-named P-256 identity. +//! Neither authenticates chain membership. The host frames messages and +//! enforces the `JAM_PEER_TRANSPORT_MAX_*` caps from `truapi::latest`. +//! +//! Native product runtimes serve `JamPeerTransport` themselves over JAMNP-S +//! QUIC, one endpoint per product connection. The browser core keeps the +//! trait's `NotGranted` defaults: its JavaScript session answers trait 111 +//! over WebTransport before frames reach the core. + +use core::fmt; + +#[cfg(not(target_arch = "wasm32"))] +mod peer_id; +#[cfg(not(target_arch = "wasm32"))] +mod quic; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) mod session; +#[cfg(not(target_arch = "wasm32"))] +mod tls; + +/// JAMNP-S ALPN prefix; the suffix is the first eight hex nibbles of the genesis +/// header hash. +pub const ALPN_PREFIX: &str = "jamnp-s/1/"; + +/// A genesis spelling other than 32 bytes of lowercase hex. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("JAM genesis must be 32 bytes of lowercase hex")] +pub struct InvalidGenesis; + +/// The JAMNP-S ALPN protocol id for `genesis`: +/// `jamnp-s/1/`. +/// This protocol selector is not a cryptographic chain-membership proof. +pub fn alpn(genesis: &[u8; 32]) -> String { + let mut alpn = String::with_capacity(ALPN_PREFIX.len() + 8); + alpn.push_str(ALPN_PREFIX); + for byte in &genesis[..4] { + use fmt::Write as _; + write!(alpn, "{byte:02x}").expect("String never fails to write"); + } + alpn +} + +/// Parse a genesis header hash: exactly 64 lowercase hex digits, with or +/// without a `0x` prefix. Uppercase is refused so one hash has one spelling. +pub fn parse_genesis(text: &str) -> Result<[u8; 32], InvalidGenesis> { + let hex = text.strip_prefix("0x").unwrap_or(text); + if hex.len() != 64 { + return Err(InvalidGenesis); + } + let mut genesis = [0u8; 32]; + for (index, pair) in hex.as_bytes().as_chunks::<2>().0.iter().enumerate() { + let nibble = |byte: u8| match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + _ => Err(InvalidGenesis), + }; + genesis[index] = (nibble(pair[0])? << 4) | nibble(pair[1])?; + } + Ok(genesis) +} diff --git a/rust/crates/truapi/src/jam_peer_transport/peer_id.rs b/rust/crates/truapi/src/jam_peer_transport/peer_id.rs new file mode 100644 index 0000000000..86b35dbdd3 --- /dev/null +++ b/rust/crates/truapi/src/jam_peer_transport/peer_id.rs @@ -0,0 +1,44 @@ +//! JAMNP-S alternative-name text form of Ed25519 peer keys. +//! +//! `N(k) = "e" ++ B(E32^-1(k), 52)`: the 256-bit key read as a little-endian +//! integer, emitted five bits at a time (least significant first) through the +//! alphabet `abcdefghijklmnopqrstuvwxyz234567`. + +const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567"; +/// One prefix letter plus 52 base-32 digits. +const TEXT_LEN: usize = 1 + 256_usize.div_ceil(5); + +/// Alternative name of an Ed25519 peer key (`e…`). +pub(super) fn ed25519_text(key: &[u8; 32]) -> String { + let mut text = String::with_capacity(TEXT_LEN); + text.push('e'); + for bit in (0..256).step_by(5) { + let low = u16::from(key[bit / 8]); + let high = u16::from(key.get(bit / 8 + 1).copied().unwrap_or(0)); + let window = low | (high << 8); + text.push(char::from( + ALPHABET[usize::from((window >> (bit % 8)) & 0x1f)], + )); + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A PolkaJAM node logs its key as `This node is @`; the name + /// must match byte for byte or the peer's certificate check fails. + #[test] + fn matches_the_name_a_polkajam_node_logs_for_its_key() { + let key: [u8; 32] = + hex::decode("1d60a595caeb8e8a8e7e74f4364ee070264d033fed29cb5c5b0c70d4fb65de46") + .unwrap() + .try_into() + .unwrap(); + assert_eq!( + ed25519_text(&key), + "e5ayk2kkzlxdvih2pud5ndhb4qtj2ub4hnpkwmonlma4i55xm6wra" + ); + } +} diff --git a/rust/crates/truapi/src/jam_peer_transport/quic.rs b/rust/crates/truapi/src/jam_peer_transport/quic.rs new file mode 100644 index 0000000000..d9901aa8e4 --- /dev/null +++ b/rust/crates/truapi/src/jam_peer_transport/quic.rs @@ -0,0 +1,1148 @@ +//! Host-terminated JAMNP-S QUIC connections with per-execution caps. +//! +//! One [`Transport`] is one execution's peer-transport authority: it owns a +//! QUIC endpoint on an ephemeral UDP port, the local identity and every +//! connection and stream the guest holds. Only `dial` and `open` wait, for the +//! QUIC handshake or the peer's stream credit, and both are bounded. The work +//! runs on the transport's own tokio runtime, so any executor may await it. +//! The guest never sees a length prefix: the host frames outgoing messages and +//! reassembles incoming ones. + +use std::collections::{HashMap, VecDeque}; +use std::net::{Ipv6Addr, SocketAddr}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use futures::stream::{FuturesUnordered, StreamExt}; +use parking_lot::Mutex; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use truapi::latest; + +use super::peer_id; +use super::tls::{self, Identity, IdentityError}; + +/// Connections one execution may hold. +pub(super) const MAX_CONNECTIONS: usize = latest::JAM_PEER_TRANSPORT_MAX_CONNECTIONS as usize; +/// Streams one execution may hold per connection. +const MAX_STREAMS_PER_CONNECTION: usize = + latest::JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION as usize; +/// Largest message in either direction, without its length prefix. +const MAX_MESSAGE_BYTES: usize = latest::JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES as usize; +/// Bytes buffered per connection (outgoing not yet written plus incoming not +/// yet received by the guest) before sends fail and reads pause. +const MAX_BUFFERED_BYTES_PER_CONNECTION: usize = + latest::JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION as usize; +/// Undrained events kept for the guest; later ones are dropped. +const MAX_PENDING_EVENTS: usize = 1024; + +/// QUIC handshake deadline. +const DIAL_TIMEOUT: Duration = Duration::from_secs(5); +/// Deadline for the peer to grant stream credit on `open`. +const OPEN_TIMEOUT: Duration = Duration::from_secs(5); +/// Deadline for the kind byte of a peer-opened stream. +const ACCEPT_KIND_TIMEOUT: Duration = Duration::from_secs(5); +// Same values as a PolkaJAM node: the client keeps the connection alive. +const IDLE_TIMEOUT: Duration = Duration::from_secs(15); +const KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(7); +const BACKPRESSURE_POLL: Duration = Duration::from_millis(5); +/// How long a dropped transport lets its connections finish closing. +const CLOSE_GRACE: Duration = Duration::from_secs(1); + +/// Everything `dial` needs; mirrors the TrUAPI request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct Dial { + pub(super) genesis: [u8; 32], + /// IPv6 or v4-mapped IPv6. + pub(super) ip: [u8; 16], + pub(super) port: u16, + /// Ed25519 key the peer certificate must carry. + pub(super) ed25519: [u8; 32], +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub(super) enum DialError { + #[error("peer refused the connection or presented another identity")] + Refused, + #[error("connection cap exhausted")] + Limit, + #[error("peer unreachable")] + Unreachable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub(super) enum OpenError { + #[error("connection closed or unknown")] + Closed, + #[error("stream cap exhausted")] + Limit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub(super) enum SendError { + #[error("stream closed, finished or unknown")] + Closed, + #[error("message exceeds the message cap")] + TooLarge, + #[error("connection buffer full")] + Limit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("stream or connection unknown, or fully consumed")] +pub(super) struct Closed; + +/// Result of a non-blocking `recv`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(super) struct Received { + /// One complete message, or `None` when nothing has arrived yet. + pub(super) message: Option>, + /// The peer finished its send side and every message has been delivered. + pub(super) fin: bool, + /// The peer reset the stream (or sent unframeable data); nothing more + /// will arrive. Reported after any complete messages. + pub(super) reset: bool, +} + +/// A frame owns its quota until delivered, flushed, or dropped on any error. +struct Reservation { + buffered: Arc, + bytes: usize, +} + +impl Reservation { + fn new(buffered: &Arc, bytes: usize) -> Option { + buffered + .try_update(Ordering::AcqRel, Ordering::Acquire, |used| { + used.checked_add(bytes) + .filter(|&total| total <= MAX_BUFFERED_BYTES_PER_CONNECTION) + }) + .ok()?; + Some(Self { + buffered: buffered.clone(), + bytes, + }) + } +} + +impl Drop for Reservation { + fn drop(&mut self) { + self.buffered.fetch_sub(self.bytes, Ordering::AcqRel); + } +} + +struct Incoming { + bytes: Vec, + _reservation: Reservation, +} + +struct Outgoing { + bytes: Vec, + fin: bool, + _reservation: Reservation, +} + +struct Conn { + quic: quinn::Connection, + streams: Vec, + opening: usize, + buffered: Arc, + closed: bool, + task: JoinHandle<()>, +} + +struct Stream { + conn: u32, + inbox: VecDeque, + fin: bool, + reset: bool, + send_open: bool, + /// `recv` reported the end of the receive side. + consumed: bool, + tx: mpsc::UnboundedSender, + reader: JoinHandle<()>, + writer: JoinHandle<()>, +} + +#[derive(Default)] +struct Inner { + conns: HashMap, + streams: HashMap, + events: VecDeque, + next_id: u32, + /// Dials between the cap check and registration; they hold a slot so + /// concurrent dials cannot exceed `MAX_CONNECTIONS`. + dialing: usize, +} + +impl Inner { + fn allocate(&mut self) -> u32 { + self.next_id += 1; + self.next_id + } + + fn push_event(&mut self, event: latest::JamPeerTransportEvent) { + if self.events.len() < MAX_PENDING_EVENTS { + self.events.push_back(event); + } + } + + /// Drop a stream, aborting both directions when `abort`; a finished + /// writer is left to flush. + fn forget_stream(&mut self, stream: u32, abort: bool) { + let Some(entry) = self.streams.remove(&stream) else { + return; + }; + if abort { + entry.reader.abort(); + entry.writer.abort(); + } + if let Some(conn) = self.conns.get_mut(&entry.conn) { + conn.streams.retain(|&id| id != stream); + } + } +} + +type Shared = Arc>; + +/// A reserved connection slot; released on drop unless the dial registered. +struct DialSlot<'a> { + shared: &'a Shared, + armed: bool, +} + +impl DialSlot<'_> { + fn commit(mut self, inner: &mut Inner) { + inner.dialing -= 1; + self.armed = false; + } +} + +impl Drop for DialSlot<'_> { + fn drop(&mut self) { + if self.armed { + self.shared.lock().dialing -= 1; + } + } +} + +/// Both local opens waiting for credit and incoming streams awaiting a kind +/// byte hold a slot until they register or are cancelled. +struct StreamSlot { + shared: Shared, + conn: u32, + armed: bool, +} + +impl StreamSlot { + fn reserve(shared: &Shared, inner: &mut Inner, conn: u32) -> Result { + let entry = inner.conns.get_mut(&conn).filter(|entry| !entry.closed) + .ok_or(OpenError::Closed)?; + if entry.streams.len() + entry.opening >= MAX_STREAMS_PER_CONNECTION { + return Err(OpenError::Limit); + } + entry.opening += 1; + Ok(Self { shared: shared.clone(), conn, armed: true }) + } + + fn commit(mut self, inner: &mut Inner) { + if let Some(entry) = inner.conns.get_mut(&self.conn) { + entry.opening -= 1; + } + self.armed = false; + } +} + +impl Drop for StreamSlot { + fn drop(&mut self) { + if self.armed && let Some(entry) = self.shared.lock().conns.get_mut(&self.conn) { + entry.opening -= 1; + } + } +} + +/// Dropping an awaiting call must cancel, not detach, its driver task. +struct AbortOnDrop(JoinHandle); + +impl Drop for AbortOnDrop { + fn drop(&mut self) { + self.0.abort(); + } +} + +/// Quinn implicitly finishes a dropped sender; cancellation must reset it. +struct ResetOnDrop(quinn::SendStream); + +impl Drop for ResetOnDrop { + fn drop(&mut self) { + let _ = self.0.reset(0u32.into()); + } +} + +/// Failure to bring up the endpoint. +#[derive(Debug, thiserror::Error)] +pub(super) enum TransportError { + #[error(transparent)] + Identity(#[from] IdentityError), + #[error("cannot start the transport runtime: {0}")] + Runtime(std::io::Error), + #[error("cannot bind the QUIC endpoint: {0}")] + Bind(std::io::Error), +} + +/// One execution's JAMNP-S client. +pub(super) struct Transport { + /// Taken on drop and shut down in the background, so the last owner may + /// release the transport from inside any async context. + runtime: Option, + endpoint: quinn::Endpoint, + identity: Identity, + shared: Shared, + client_transport: Arc, +} + +impl Transport { + /// Generate an identity, bind an ephemeral dual-stack UDP port and start + /// the driver runtime. + pub(super) fn new() -> Result { + let identity = Identity::generate()?; + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .thread_name("jam-peer-transport") + .enable_all() + .build() + .map_err(TransportError::Runtime)?; + let endpoint = { + let _guard = runtime.enter(); + quinn::Endpoint::client(SocketAddr::from((Ipv6Addr::UNSPECIFIED, 0))) + }; + let endpoint = match endpoint { + Ok(endpoint) => endpoint, + Err(error) => { + // Construction may be called from an async runtime too. + runtime.shutdown_background(); + return Err(TransportError::Bind(error)); + } + }; + tracing::debug!( + identity = %peer_id::ed25519_text(identity.public()), + local = ?endpoint.local_addr().ok(), + "JAMNP-S endpoint bound", + ); + let mut client_transport = quinn::TransportConfig::default(); + client_transport.max_idle_timeout(Some( + IDLE_TIMEOUT.try_into().expect("idle timeout fits a VarInt"), + )); + client_transport.keep_alive_interval(Some(KEEP_ALIVE_INTERVAL)); + Ok(Self { + runtime: Some(runtime), + endpoint, + identity, + shared: Shared::default(), + client_transport: Arc::new(client_transport), + }) + } + + fn runtime(&self) -> &tokio::runtime::Runtime { + self.runtime + .as_ref() + .expect("the runtime lives until the transport drops") + } + + /// Run a future on the driver runtime and await it from any executor, + /// waiting at most `timeout`; `None` on timeout or runtime shutdown. The + /// deadline is armed on the driver runtime: the caller may have no timer. + async fn run( + &self, + timeout: Duration, + future: impl Future + Send + 'static, + ) -> Option { + let mut task = AbortOnDrop(self.runtime().spawn(async move { + tokio::time::timeout(timeout, future).await.ok() + })); + (&mut task.0) + .await + .ok() + .flatten() + } + + /// Count permission-waiting dials against retained connection handles. + /// Keep the connection lock through reservation so a completing handshake + /// cannot fall between the connection and pending-count snapshots. + pub(super) fn reserve_pending_dial(&self, pending: &AtomicUsize) -> bool { + let inner = self.shared.lock(); + pending + .try_update(Ordering::AcqRel, Ordering::Acquire, |used| { + (inner.conns.len() + used < MAX_CONNECTIONS).then_some(used + 1) + }) + .is_ok() + } + + /// Connect to one peer, requiring its certificate to carry `ed25519`, + /// within [`DIAL_TIMEOUT`]. + pub(super) async fn dial(&self, dial: &Dial) -> Result { + let (slot, connecting) = self.connecting(dial)?; + self.connected(slot, self.run(DIAL_TIMEOUT, connecting).await) + } + + fn connecting(&self, dial: &Dial) -> Result<(DialSlot<'_>, quinn::Connecting), DialError> { + let slot = { + let mut inner = self.shared.lock(); + if inner.conns.len() + inner.dialing >= MAX_CONNECTIONS { + return Err(DialError::Limit); + } + inner.dialing += 1; + DialSlot { + shared: &self.shared, + armed: true, + } + }; + let alpn = super::alpn(&dial.genesis).into_bytes(); + let tls = tls::client_config(&self.identity, dial.ed25519, alpn) + .map_err(|_| DialError::Refused)?; + let quic_tls: quinn::crypto::rustls::QuicClientConfig = + tls.try_into().map_err(|_| DialError::Refused)?; + let mut config = quinn::ClientConfig::new(Arc::new(quic_tls)); + config.transport_config(self.client_transport.clone()); + let addr = SocketAddr::from((Ipv6Addr::from(dial.ip), dial.port)); + let name = peer_id::ed25519_text(&dial.ed25519); + // quinn spawns the connection driver from `connect_with`. + let connecting = { + let _guard = self.runtime().enter(); + self.endpoint.connect_with(config, addr, &name) + }; + let connecting = connecting.map_err(|_| DialError::Unreachable)?; + Ok((slot, connecting)) + } + + fn connected( + &self, + slot: DialSlot<'_>, + outcome: Option>, + ) -> Result { + let connection = match outcome { + Some(Ok(connection)) => connection, + // Any TLS alert (QUIC crypto error 0x100–0x1ff) means the peer + // answered but the handshake failed: a certificate that does not + // carry the pinned key, a foreign ALPN, or a rejected client. + Some(Err(quinn::ConnectionError::TransportError(error))) + if (0x100..0x200).contains(&u64::from(error.code)) => + { + return Err(DialError::Refused); + } + Some(Err( + quinn::ConnectionError::ConnectionClosed(_) + | quinn::ConnectionError::ApplicationClosed(_), + )) => return Err(DialError::Refused), + Some(Err(_)) | None => return Err(DialError::Unreachable), + }; + let mut inner = self.shared.lock(); + slot.commit(&mut inner); + let conn = inner.allocate(); + let buffered = Arc::new(AtomicUsize::new(0)); + let task = self.runtime().spawn(accept_loop( + self.shared.clone(), + conn, + connection.clone(), + buffered.clone(), + )); + inner.conns.insert( + conn, + Conn { + quic: connection, + streams: Vec::new(), + opening: 0, + buffered, + closed: false, + task, + }, + ); + Ok(conn) + } + + /// Open a bidirectional stream and send its kind byte, within + /// [`OPEN_TIMEOUT`]. + pub(super) async fn open(&self, conn: u32, kind: u8) -> Result { + let (slot, quic, buffered) = self.open_target(conn)?; + let opened = self + .run(OPEN_TIMEOUT, async move { + quic.open_bi() + .await + .map(|(send, recv)| (ResetOnDrop(send), recv)) + }) + .await; + self.opened(slot, conn, kind, buffered, opened) + } + + fn open_target(&self, conn: u32) -> Result<(StreamSlot, quinn::Connection, Arc), OpenError> { + let mut inner = self.shared.lock(); + let slot = StreamSlot::reserve(&self.shared, &mut inner, conn)?; + let entry = &inner.conns[&conn]; + Ok((slot, entry.quic.clone(), entry.buffered.clone())) + } + + fn opened( + &self, + slot: StreamSlot, + conn: u32, + kind: u8, + buffered: Arc, + opened: Option>, + ) -> Result { + let (send, recv) = match opened { + Some(Ok(pair)) => pair, + Some(Err(_)) => return Err(OpenError::Closed), + None => return Err(OpenError::Limit), + }; + let mut inner = self.shared.lock(); + let Some(entry) = inner.conns.get(&conn).filter(|entry| !entry.closed) else { + return Err(OpenError::Closed); + }; + if entry.streams.len() >= MAX_STREAMS_PER_CONNECTION { + return Err(OpenError::Limit); + } + let reservation = Reservation::new(&buffered, 1).ok_or(OpenError::Limit)?; + slot.commit(&mut inner); + let stream = register_stream( + self.runtime().handle(), + &self.shared, + &mut inner, + conn, + send, + recv, + buffered, + ); + let entry = inner.streams.get(&stream).expect("just registered"); + let _ = entry.tx.send(Outgoing { + bytes: vec![kind], + fin: false, + _reservation: reservation, + }); + Ok(stream) + } + + /// Queue one framed message; `fin` finishes the send side after it. + pub(super) fn send(&self, stream: u32, message: &[u8], fin: bool) -> Result<(), SendError> { + if message.len() > MAX_MESSAGE_BYTES { + return Err(SendError::TooLarge); + } + let mut inner = self.shared.lock(); + let entry = inner.streams.get(&stream).ok_or(SendError::Closed)?; + if !entry.send_open { + return Err(SendError::Closed); + } + let buffered = inner + .conns + .get(&entry.conn) + .map(|conn| conn.buffered.clone()) + .ok_or(SendError::Closed)?; + let framed_len = message.len() + 4; + let reservation = Reservation::new(&buffered, framed_len).ok_or(SendError::Limit)?; + let mut bytes = Vec::with_capacity(framed_len); + bytes.extend_from_slice(&(message.len() as u32).to_le_bytes()); + bytes.extend_from_slice(message); + let entry = inner.streams.get_mut(&stream).expect("checked above"); + if fin { + entry.send_open = false; + } + entry + .tx + .send(Outgoing { + bytes, + fin, + _reservation: reservation, + }) + .map_err(|_| SendError::Closed)?; + if fin && entry.consumed { + inner.forget_stream(stream, false); + } + Ok(()) + } + + /// Pop one complete message if any; report fin or reset once drained. + /// + /// The call that reports the end with no message consumes the receive + /// side: later calls are [`Closed`], and a stream whose send side is also + /// done is forgotten. + pub(super) fn recv(&self, stream: u32, max: usize) -> Result { + let mut inner = self.shared.lock(); + let entry = inner + .streams + .get_mut(&stream) + .filter(|entry| !entry.consumed) + .ok_or(Closed)?; + if entry.inbox.front().is_some_and(|front| front.bytes.len() > max) { + // The guest cannot take this message; the stream cannot progress. + entry.inbox.clear(); + entry.reset = true; + entry.reader.abort(); + } + let message = entry.inbox.pop_front().map(|incoming| incoming.bytes); + let drained = entry.inbox.is_empty(); + let received = Received { + fin: drained && entry.fin, + reset: drained && entry.reset, + message, + }; + entry.consumed = received.message.is_none() && (received.fin || received.reset); + let forget = entry.consumed && (received.reset || !entry.send_open); + if forget { + inner.forget_stream(stream, received.reset); + } + Ok(received) + } + + /// Abort both directions and forget the stream. + pub(super) fn reset(&self, stream: u32) -> Result<(), Closed> { + let mut inner = self.shared.lock(); + if !inner.streams.contains_key(&stream) { + return Err(Closed); + } + inner.forget_stream(stream, true); + Ok(()) + } + + /// Close a connection and every stream on it. No `ConnClosed` event is + /// queued for a guest-initiated close. + pub(super) fn close(&self, conn: u32) -> Result<(), Closed> { + let mut inner = self.shared.lock(); + let entry = inner.conns.remove(&conn).ok_or(Closed)?; + entry.task.abort(); + for stream in entry.streams { + if let Some(stream) = inner.streams.remove(&stream) { + stream.reader.abort(); + stream.writer.abort(); + } + } + entry.quic.close(0u32.into(), b""); + Ok(()) + } + + /// Drain pending events in arrival order. + pub(super) fn events(&self) -> Vec { + self.shared.lock().events.drain(..).collect() + } + + /// Close every connection and the endpoint. + pub(super) fn shutdown(&self) { + let conns: Vec = self.shared.lock().conns.keys().copied().collect(); + for conn in conns { + let _ = self.close(conn); + } + self.endpoint.close(0u32.into(), b""); + } +} + +impl Drop for Transport { + fn drop(&mut self) { + self.shutdown(); + let Some(runtime) = self.runtime.take() else { + return; + }; + // A validator keeps a connection that was never closed until it idles + // out, and may refuse this address's next dials meanwhile, so the + // driver gets to send the close frames. It runs on its own thread: + // dropping a runtime blocks on its workers, which panics inside + // another runtime. + let endpoint = self.endpoint.clone(); + let (handoff, handed) = std::sync::mpsc::channel::(); + let closer = std::thread::Builder::new() + .name("jam-peer-transport-close".into()) + .spawn(move || { + if let Ok(runtime) = handed.recv() { + runtime.block_on(async { + let _ = tokio::time::timeout(CLOSE_GRACE, endpoint.wait_idle()).await; + }); + } + }); + let unsent = match closer { + Ok(_) => handoff.send(runtime).err().map(|unsent| unsent.0), + Err(_) => Some(runtime), + }; + if let Some(runtime) = unsent { + runtime.shutdown_background(); + } + } +} + +fn register_stream( + handle: &tokio::runtime::Handle, + shared: &Shared, + inner: &mut Inner, + conn: u32, + send: ResetOnDrop, + recv: quinn::RecvStream, + buffered: Arc, +) -> u32 { + let stream = inner.allocate(); + let (tx, rx) = mpsc::unbounded_channel(); + let reader = handle.spawn(read_loop(shared.clone(), stream, recv, buffered)); + let writer = handle.spawn(write_loop(shared.clone(), stream, send, rx)); + inner.streams.insert( + stream, + Stream { + conn, + inbox: VecDeque::new(), + fin: false, + reset: false, + send_open: true, + consumed: false, + tx, + reader, + writer, + }, + ); + inner + .conns + .get_mut(&conn) + .expect("caller checked the connection") + .streams + .push(stream); + stream +} + +async fn accept_loop( + shared: Shared, + conn: u32, + quic: quinn::Connection, + buffered: Arc, +) { + let mut accepting = FuturesUnordered::new(); + loop { + tokio::select! { + _ = accepting.next(), if !accepting.is_empty() => {} + incoming = quic.accept_bi() => match incoming { + Ok((send, recv)) => { + let send = ResetOnDrop(send); + let slot = { + let mut inner = shared.lock(); + if inner.events.len() >= MAX_PENDING_EVENTS { + continue; + } + match StreamSlot::reserve(&shared, &mut inner, conn) { + Ok(slot) => slot, + Err(OpenError::Closed) => return, + Err(OpenError::Limit) => continue, + } + }; + accepting.push(accept_stream( + shared.clone(), slot, conn, send, recv, buffered.clone(), + )); + } + Err(error) => { + tracing::debug!("JAM peer connection {conn} closed: {error}"); + let mut inner = shared.lock(); + if let Some(entry) = inner.conns.get_mut(&conn) { + entry.closed = true; + inner.push_event(latest::JamPeerTransportEvent::ConnClosed { conn }); + } + return; + } + } + } + } +} + +async fn accept_stream( + shared: Shared, + slot: StreamSlot, + conn: u32, + send: ResetOnDrop, + mut recv: quinn::RecvStream, + buffered: Arc, +) { + let mut kind = [0u8; 1]; + let read = tokio::time::timeout(ACCEPT_KIND_TIMEOUT, recv.read_exact(&mut kind)); + if !matches!(read.await, Ok(Ok(()))) { + return; + } + let mut inner = shared.lock(); + if !inner.conns.get(&conn).is_some_and(|entry| !entry.closed) + || inner.events.len() >= MAX_PENDING_EVENTS + { + return; + } + slot.commit(&mut inner); + let stream = register_stream( + &tokio::runtime::Handle::current(), + &shared, + &mut inner, + conn, + send, + recv, + buffered, + ); + inner.push_event(latest::JamPeerTransportEvent::Accepted { + conn, + stream, + kind: kind[0], + }); +} + +async fn read_loop( + shared: Shared, + stream: u32, + mut recv: quinn::RecvStream, + buffered: Arc, +) { + use quinn::ReadExactError; + loop { + let mut len = [0u8; 4]; + let clean_fin = match recv.read_exact(&mut len).await { + Ok(()) => None, + Err(ReadExactError::FinishedEarly(0)) => Some(true), + Err(_) => Some(false), + }; + if let Some(clean) = clean_fin { + finish_read(&shared, stream, clean); + return; + } + let len = u32::from_le_bytes(len) as usize; + if len > MAX_MESSAGE_BYTES { + let _ = recv.stop(0u32.into()); + finish_read(&shared, stream, false); + return; + } + // Reserve before allocating or reading the payload. Include framing + // so a peer cannot buffer an unbounded number of empty messages. + let reservation = loop { + if let Some(reservation) = Reservation::new(&buffered, len + 4) { + break reservation; + } + tokio::time::sleep(BACKPRESSURE_POLL).await; + }; + let mut message = vec![0u8; len]; + if recv.read_exact(&mut message).await.is_err() { + finish_read(&shared, stream, false); + return; + } + let mut inner = shared.lock(); + match inner.streams.get_mut(&stream) { + Some(entry) => entry.inbox.push_back(Incoming { + bytes: message, + _reservation: reservation, + }), + None => return, + } + } +} + +fn finish_read(shared: &Shared, stream: u32, clean: bool) { + let mut inner = shared.lock(); + if let Some(entry) = inner.streams.get_mut(&stream) { + if clean { + entry.fin = true; + inner.push_event(latest::JamPeerTransportEvent::StreamFin { stream }); + } else { + entry.reset = true; + } + } +} + +async fn write_loop( + shared: Shared, + stream: u32, + mut send: ResetOnDrop, + mut rx: mpsc::UnboundedReceiver, +) { + while let Some(outgoing) = rx.recv().await { + let Outgoing { bytes, fin, _reservation } = outgoing; + let written = send.0.write_all(&bytes).await; + drop(bytes); + drop(_reservation); + if written.is_err() { + let mut inner = shared.lock(); + if let Some(entry) = inner.streams.get_mut(&stream) { + entry.send_open = false; + if entry.consumed { + inner.forget_stream(stream, true); + } + } + return; + } + if fin { + let _ = send.0.finish(); + // Retain the reset guard until acknowledged, so reset/close can + // still abandon buffered transmission after finish. + let _ = send.0.stopped().await; + return; + } + } + // The reset guard also aborts the send side when its queue closes. +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reservations_bound_empty_frames_and_release_queued_bytes_on_drop() { + let buffered = Arc::new(AtomicUsize::new(0)); + let held = Reservation::new(&buffered, MAX_BUFFERED_BYTES_PER_CONNECTION - 4).unwrap(); + let (tx, rx) = mpsc::unbounded_channel(); + tx.send(Outgoing { + bytes: vec![0; 4], + fin: false, + _reservation: Reservation::new(&buffered, 4).unwrap(), + }) + .ok() + .unwrap(); + assert!(Reservation::new(&buffered, 4).is_none()); + drop(rx); + assert_eq!(buffered.load(Ordering::Acquire), MAX_BUFFERED_BYTES_PER_CONNECTION - 4); + drop(held); + assert_eq!(buffered.load(Ordering::Acquire), 0); + + let mut inbox = VecDeque::new(); + inbox.push_back(Incoming { + bytes: Vec::new(), + _reservation: Reservation::new(&buffered, 4).unwrap(), + }); + assert_eq!(buffered.load(Ordering::Acquire), 4); + let message = inbox.pop_front().map(|incoming| incoming.bytes); + assert_eq!(message, Some(Vec::new())); + assert_eq!(buffered.load(Ordering::Acquire), 0); + } + + #[test] + fn simultaneous_reservations_never_exceed_the_connection_budget() { + let buffered = Arc::new(AtomicUsize::new(0)); + let barrier = std::sync::Barrier::new(16); + std::thread::scope(|scope| { + let workers: Vec<_> = (0..16) + .map(|_| scope.spawn(|| { + let reservation = Reservation::new(&buffered, MAX_MESSAGE_BYTES); + barrier.wait(); + let used = buffered.load(Ordering::Acquire); + barrier.wait(); + (reservation.is_some(), used) + })) + .collect(); + let results: Vec<_> = workers.into_iter().map(|worker| worker.join().unwrap()).collect(); + assert!(results.iter().all(|&(_, used)| used == MAX_BUFFERED_BYTES_PER_CONNECTION)); + assert_eq!(results.iter().filter(|&&(granted, _)| granted).count(), 4); + }); + assert_eq!(buffered.load(Ordering::Acquire), 0); + } + + #[tokio::test] + async fn dropping_a_driver_call_cancels_its_work_and_releases_its_reservations() { + let transport = Transport::new().unwrap(); + let buffered = Arc::new(AtomicUsize::new(0)); + let reservation = Reservation::new(&buffered, 4).unwrap(); + let (started, ready) = tokio::sync::oneshot::channel(); + let mut call = Box::pin(transport.run(Duration::from_secs(60), async move { + let _reservation = reservation; + let _ = started.send(()); + std::future::pending::<()>().await; + })); + tokio::select! { + _ = &mut call => panic!("pending work completed"), + _ = ready => {} + } + drop(call); + tokio::time::timeout(Duration::from_secs(1), async { + while buffered.load(Ordering::Acquire) != 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("dropping the caller aborts its driver task"); + } + + fn server() -> (quinn::Endpoint, Identity) { + let identity = Identity::generate().unwrap(); + let mut tls = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_no_client_auth() + .with_single_cert(identity.cert_chain(), identity.private_key()) + .unwrap(); + tls.alpn_protocols = vec![super::super::alpn(&[1; 32]).into_bytes()]; + let config = quinn::ServerConfig::with_crypto(Arc::new( + quinn::crypto::rustls::QuicServerConfig::try_from(tls).unwrap(), + )); + let endpoint = quinn::Endpoint::server( + config, + (std::net::Ipv4Addr::LOCALHOST, 0).into(), + ) + .unwrap(); + (endpoint, identity) + } + + async fn connect( + transport: &Transport, + server: &quinn::Endpoint, + identity: &Identity, + ) -> (u32, quinn::Connection) { + let request = Dial { + genesis: [1; 32], + ip: std::net::Ipv4Addr::LOCALHOST.to_ipv6_mapped().octets(), + port: server.local_addr().unwrap().port(), + ed25519: *identity.public(), + }; + let (client, peer) = tokio::join!( + transport.dial(&request), + async { server.accept().await.unwrap().await.unwrap() }, + ); + (client.unwrap(), peer) + } + + #[tokio::test] + async fn sending_fin_after_consuming_peer_fin_releases_the_stream_slot() { + let transport = Transport::new().unwrap(); + let (server, identity) = server(); + let (conn, peer) = connect(&transport, &server, &identity).await; + let stream = transport.open(conn, 0).await.unwrap(); + let (mut send, mut recv) = peer.accept_bi().await.unwrap(); + let mut kind = [0]; + recv.read_exact(&mut kind).await.unwrap(); + send.finish().unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if transport.recv(stream, 1024).unwrap().fin { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + transport.send(stream, b"done", true).unwrap(); + assert!(!transport.shared.lock().streams.contains_key(&stream)); + let mut framed = [0; 8]; + recv.read_exact(&mut framed).await.unwrap(); + assert_eq!(&framed, b"\x04\0\0\0done"); + } + + #[tokio::test] + async fn remote_closed_handles_still_consume_connection_slots_until_closed() { + let transport = Transport::new().unwrap(); + let (server, identity) = server(); + let mut conns = Vec::new(); + for _ in 0..MAX_CONNECTIONS { + let (conn, peer) = connect(&transport, &server, &identity).await; + peer.close(0u32.into(), b""); + tokio::time::timeout(Duration::from_secs(1), async { + while !transport.shared.lock().conns[&conn].closed { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + conns.push(conn); + } + let request = Dial { + genesis: [1; 32], + ip: std::net::Ipv4Addr::LOCALHOST.to_ipv6_mapped().octets(), + port: server.local_addr().unwrap().port(), + ed25519: *identity.public(), + }; + assert_eq!(transport.dial(&request).await, Err(DialError::Limit)); + transport.close(conns[0]).unwrap(); + connect(&transport, &server, &identity).await; + } + + #[tokio::test] + async fn empty_messages_pause_at_quota_and_reset_releases_buffers_and_resets_peer() { + let transport = Transport::new().unwrap(); + let (server, identity) = server(); + let (conn, peer) = connect(&transport, &server, &identity).await; + let stream = transport.open(conn, 0).await.unwrap(); + let (mut send, mut recv) = peer.accept_bi().await.unwrap(); + let mut kind = [0]; + recv.read_exact(&mut kind).await.unwrap(); + let buffered = transport.shared.lock().conns[&conn].buffered.clone(); + tokio::time::timeout(Duration::from_secs(1), async { + while buffered.load(Ordering::Acquire) != 0 { + tokio::task::yield_now().await; + } + }).await.unwrap(); + let held = Reservation::new(&buffered, MAX_BUFFERED_BYTES_PER_CONNECTION - 8).unwrap(); + send.write_all(&[0; 12]).await.unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while transport.shared.lock().streams[&stream].inbox.len() != 2 { + tokio::task::yield_now().await; + } + }).await.unwrap(); + assert_eq!(buffered.load(Ordering::Acquire), MAX_BUFFERED_BYTES_PER_CONNECTION); + assert_eq!(transport.recv(stream, 0).unwrap().message, Some(Vec::new())); + tokio::time::timeout(Duration::from_secs(1), async { + while transport.shared.lock().streams[&stream].inbox.len() != 2 { + tokio::task::yield_now().await; + } + }).await.unwrap(); + transport.reset(stream).unwrap(); + drop(held); + tokio::time::timeout(Duration::from_secs(1), async { + while buffered.load(Ordering::Acquire) != 0 { + tokio::task::yield_now().await; + } + }).await.unwrap(); + let result = tokio::time::timeout(Duration::from_secs(1), recv.read(&mut kind)) + .await + .unwrap(); + assert!(matches!(result, Err(quinn::ReadError::Reset(_)))); + } + + #[tokio::test] + async fn an_inbound_stream_is_reset_when_its_handle_cannot_be_announced() { + let transport = Transport::new().unwrap(); + let (server, identity) = server(); + let (_conn, peer) = connect(&transport, &server, &identity).await; + { + let mut inner = transport.shared.lock(); + for _ in 0..MAX_PENDING_EVENTS { + inner.push_event(latest::JamPeerTransportEvent::StreamFin { stream: 0 }); + } + } + let (mut send, mut recv) = peer.open_bi().await.unwrap(); + send.write_all(&[0]).await.unwrap(); + let mut bytes = [0]; + let result = tokio::time::timeout(Duration::from_secs(1), recv.read(&mut bytes)) + .await + .unwrap(); + assert!(matches!(result, Err(quinn::ReadError::Reset(_)))); + assert!(transport.shared.lock().streams.is_empty()); + } + + #[tokio::test] + async fn pending_streams_hold_slots_and_dropping_them_releases_credit() { + let transport = Transport::new().unwrap(); + let (server, identity) = server(); + let (conn, _peer) = connect(&transport, &server, &identity).await; + let pending: Vec<_> = (0..MAX_STREAMS_PER_CONNECTION) + .map(|_| transport.open_target(conn).unwrap()) + .collect(); + assert!(matches!(transport.open_target(conn), Err(OpenError::Limit))); + drop(pending); + assert!(transport.open_target(conn).is_ok()); + assert_eq!(transport.shared.lock().conns[&conn].opening, 0); + } + + #[tokio::test] + async fn an_inbound_stream_without_a_kind_does_not_block_later_streams() { + let transport = Transport::new().unwrap(); + let (server, identity) = server(); + let (conn, peer) = connect(&transport, &server, &identity).await; + // Sending on the higher stream id implicitly opens this lower id, + // but leaves its kind byte unavailable. + let (_silent_send, _silent_recv) = peer.open_bi().await.unwrap(); + let (mut send, _recv) = peer.open_bi().await.unwrap(); + send.write_all(&[42]).await.unwrap(); + let stream = tokio::time::timeout(Duration::from_secs(1), async { + loop { + for event in transport.events() { + if let latest::JamPeerTransportEvent::Accepted { stream, kind: 42, .. } = event { + return stream; + } + } + tokio::task::yield_now().await; + } + }).await.expect("one missing kind byte must not stall another stream"); + assert_eq!(transport.shared.lock().streams[&stream].conn, conn); + } +} diff --git a/rust/crates/truapi/src/jam_peer_transport/session.rs b/rust/crates/truapi/src/jam_peer_transport/session.rs new file mode 100644 index 0000000000..3375f8cb88 --- /dev/null +++ b/rust/crates/truapi/src/jam_peer_transport/session.rs @@ -0,0 +1,442 @@ +//! One product connection's `JamPeerTransport` over JAMNP-S QUIC. +//! +//! `dial` requires `RemotePermission::JamPeers { genesis }`: the caller hands +//! in the permission check (stored decision, else prompt, then persist), and +//! the session runs it once per genesis for this connection and keeps the +//! answer, so concurrent dials wait for one prompt and a refusal stays +//! `NotGranted` without asking again. The check runs on the runtime spawner, +//! so its answer is kept even when every dial waiting on it has given up. +//! Pending dials reserve from the eight-connection budget before permission +//! is awaited. At most eight distinct genesis decisions are kept, including +//! pending and refused checks; a ninth is `Limit`, with no eviction or prompt. +//! +//! A dial answers within [`DIAL_DEADLINE`], prompt included: one still waiting +//! then answers `Unreachable`, a cancelled one `Cancelled`, and whatever it +//! would have opened is dropped without holding a connection slot. The QUIC +//! endpoint is created by the first granted dial, so a connection that never +//! dials, or is refused, binds no socket. After [`JamPeerSession::revoke`] +//! every call is `Denied`. + +use core::time::Duration; +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +use futures::{FutureExt, pin_mut}; +use parking_lot::Mutex; +use tokio::sync::watch; +use truapi::versioned::jam_peer_transport as wire; +use truapi::{CallContext, CallError, latest}; + +use super::quic::{self, Dial, Transport}; +use crate::subscription::Spawner; + +/// Bound on one `dial`, from its arrival to its reply, the permission prompt +/// included. A guest that waits at least this long for a dial reply never +/// misses one, and anything a dial would open after it is closed instead. +const DIAL_DEADLINE: Duration = Duration::from_secs(10); + +/// Largest reply frame a native PolkaVM runtime accepts +/// (`polkavm_host_runtime::MAX_HOST_FRAME_BYTES`); a `recv` reply carries its +/// message inside one, next to the request id and the SCALE envelope. +const MAX_REPLY_FRAME_BYTES: usize = 1024 * 1024; +/// Room left in a `recv` reply frame for everything but the message. +const REPLY_ENVELOPE_BYTES: usize = 128; + +/// The answer to `RemotePermission::JamPeers` for one genesis. +type Decision = Result<(), CallError>; + +/// One product connection's peer transport. +pub(crate) struct JamPeerSession { + /// Created by the first granted dial; `None` again after [`Self::revoke`]. + transport: Mutex>>, + /// Permission answers for this connection, by genesis. + decisions: Mutex>>>, + /// Dials still awaiting permission or a handshake. + pending_dials: AtomicUsize, + dial_deadline: Duration, + revoked: AtomicBool, + revoked_signal: truapi::CancellationToken, +} + +/// A pending dial reserves capacity before authorization and releases it on +/// every completion path. Successful connections are then counted by QUIC. +struct DialAdmission<'a> { + slots: &'a AtomicUsize, +} + +impl<'a> DialAdmission<'a> { + fn reserve( + slots: &'a AtomicUsize, + transport: Option<&Transport>, + ) -> Result> { + let admitted = match transport { + Some(transport) => transport.reserve_pending_dial(slots), + None => slots + .try_update(Ordering::AcqRel, Ordering::Acquire, |used| { + (used < quic::MAX_CONNECTIONS).then_some(used + 1) + }) + .is_ok(), + }; + if !admitted { + return Err(dial_error(latest::HostJamPeerTransportDialError::Limit)); + } + Ok(Self { slots }) + } +} + +impl Drop for DialAdmission<'_> { + fn drop(&mut self) { + self.slots.fetch_sub(1, Ordering::AcqRel); + } +} + +fn dial_error( + error: latest::HostJamPeerTransportDialError, +) -> CallError { + CallError::Domain(wire::HostJamPeerTransportDialError::V1(error)) +} + +impl JamPeerSession { + /// A session with no endpoint and no permission answers yet. + pub(crate) fn new() -> Self { + Self { + transport: Mutex::new(None), + decisions: Mutex::new(HashMap::new()), + pending_dials: AtomicUsize::new(0), + dial_deadline: DIAL_DEADLINE, + revoked: AtomicBool::new(false), + revoked_signal: truapi::CancellationToken::default(), + } + } + + /// Close every connection; every later call, including one still waiting + /// for a handshake or a permission prompt, is `Denied`. + pub(crate) fn revoke(&self) { + let transport = { + let mut transport = self.transport.lock(); + self.revoked.store(true, Ordering::Release); + transport.take() + }; + self.revoked_signal.cancel(); + if let Some(transport) = transport { + transport.shutdown(); + } + } + + fn live(&self) -> Result<(), CallError> { + if self.revoked.load(Ordering::Acquire) { + Err(CallError::Denied) + } else { + Ok(()) + } + } + + /// The endpoint, if a granted dial has created it. + fn existing(&self) -> Option> { + self.transport.lock().clone() + } + + /// The endpoint, created on first use. Never created after a revoke. + fn endpoint(&self) -> Result, CallError> { + let mut transport = self.transport.lock(); + self.live()?; + if let Some(transport) = &*transport { + return Ok(transport.clone()); + } + let created = Arc::new(Transport::new().map_err(|error| CallError::HostFailure { + reason: format!("JAM peer transport unavailable: {error}"), + })?); + *transport = Some(created.clone()); + Ok(created) + } + + /// `JamPeers { genesis }` for this connection, checked once. + async fn permitted( + &self, + genesis: [u8; 32], + authorize: impl FnOnce() -> F, + spawner: &Spawner, + ) -> Decision + where + F: Future + Send + 'static, + { + let (mut decision, first) = { + let mut decisions = self.decisions.lock(); + match decisions.get(&genesis) { + Some(decision) => (decision.clone(), None), + None => { + if decisions.len() >= quic::MAX_CONNECTIONS { + return Err(dial_error(latest::HostJamPeerTransportDialError::Limit)); + } + let (answer, decision) = watch::channel(None); + decisions.insert(genesis, decision.clone()); + (decision, Some(answer)) + } + } + }; + if let Some(answer) = first { + let check = authorize().fuse(); + let revoked = self.revoked_signal.cancelled().fuse(); + spawner(Box::pin(async move { + pin_mut!(check, revoked); + let result = futures::select_biased! { + _ = revoked => Err(CallError::Denied), + result = check => result, + }; + let _ = answer.send(Some(result)); + })); + } + match decision.wait_for(Option::is_some).await { + Ok(answer) => answer.clone().expect("waited for an answer"), + // A check that ended without answering refuses. + Err(_) => Err(dial_error( + latest::HostJamPeerTransportDialError::NotGranted, + )), + } + } + + async fn dial_granted( + &self, + request: latest::HostJamPeerTransportDialRequest, + authorize: impl FnOnce() -> F, + spawner: &Spawner, + ) -> Result< + wire::HostJamPeerTransportDialResponse, + CallError, + > + where + F: Future + Send + 'static, + { + let _admission = { + // Serialize admission with endpoint creation and revocation. + let transport = self.transport.lock(); + self.live()?; + DialAdmission::reserve(&self.pending_dials, transport.as_deref())? + }; + self.permitted(request.genesis, authorize, spawner).await?; + let transport = self.endpoint()?; + // Native hosts speak JAMNP-S QUIC; the P-256 id is for WebTransport hosts. + let conn = transport + .dial(&Dial { + genesis: request.genesis, + ip: request.ip, + port: request.port, + ed25519: request.ed25519, + }) + .await + .map_err(|error| { + dial_error(match error { + quic::DialError::Refused => latest::HostJamPeerTransportDialError::Refused, + quic::DialError::Limit => latest::HostJamPeerTransportDialError::Limit, + quic::DialError::Unreachable => { + latest::HostJamPeerTransportDialError::Unreachable + } + }) + })?; + if self.revoked.load(Ordering::Acquire) { + // Revoked while the handshake ran: the connection must not outlive it. + let _ = transport.close(conn); + return Err(CallError::Denied); + } + Ok(wire::HostJamPeerTransportDialResponse::V1( + latest::HostJamPeerTransportDialResponse { conn }, + )) + } + + /// Dial one peer once `authorize` grants its genesis. `authorize` runs at + /// most once per genesis for this connection. + pub(crate) async fn dial( + &self, + cx: &CallContext, + request: wire::HostJamPeerTransportDialRequest, + authorize: impl FnOnce() -> F, + spawner: &Spawner, + ) -> Result< + wire::HostJamPeerTransportDialResponse, + CallError, + > + where + F: Future + Send + 'static, + { + self.live()?; + let wire::HostJamPeerTransportDialRequest::V1(request) = request; + // Dropping `granted` early drops its pending handshake, whose + // connection slot is released with it. + let granted = self.dial_granted(request, authorize, spawner).fuse(); + let cancelled = cx.cancel().cancelled().fuse(); + let deadline = futures_timer::Delay::new(self.dial_deadline).fuse(); + let revoked = self.revoked_signal.cancelled().fuse(); + pin_mut!(granted, cancelled, deadline, revoked); + futures::select_biased! { + _ = revoked => Err(CallError::Denied), + _ = cancelled => Err(CallError::Cancelled), + reply = granted => reply, + () = deadline => Err(dial_error(latest::HostJamPeerTransportDialError::Unreachable)), + } + } + + /// Open a stream on a connection a granted dial opened. + pub(crate) async fn open( + &self, + cx: &CallContext, + request: wire::HostJamPeerTransportOpenRequest, + ) -> Result< + wire::HostJamPeerTransportOpenResponse, + CallError, + > { + self.live()?; + let wire::HostJamPeerTransportOpenRequest::V1(request) = request; + let closed = || { + CallError::Domain(wire::HostJamPeerTransportOpenError::V1( + latest::HostJamPeerTransportOpenError::Closed, + )) + }; + let transport = self.existing().ok_or_else(closed)?; + let opened = transport.open(request.conn, request.kind).fuse(); + let cancelled = cx.cancel().cancelled().fuse(); + let revoked = self.revoked_signal.cancelled().fuse(); + pin_mut!(opened, cancelled, revoked); + let result = futures::select_biased! { + _ = revoked => return Err(CallError::Denied), + _ = cancelled => return Err(CallError::Cancelled), + result = opened => result, + }; + let stream = result.map_err(|error| match error { + quic::OpenError::Closed => closed(), + quic::OpenError::Limit => { + CallError::Domain(wire::HostJamPeerTransportOpenError::V1( + latest::HostJamPeerTransportOpenError::Limit, + )) + } + })?; + if self.revoked.load(Ordering::Acquire) { + let _ = transport.reset(stream); + return Err(CallError::Denied); + } + Ok(wire::HostJamPeerTransportOpenResponse::V1( + latest::HostJamPeerTransportOpenResponse { stream }, + )) + } + + /// Queue one message on a stream. + pub(crate) fn send( + &self, + request: wire::HostJamPeerTransportSendRequest, + ) -> Result< + wire::HostJamPeerTransportSendResponse, + CallError, + > { + self.live()?; + let wire::HostJamPeerTransportSendRequest::V1(request) = request; + let domain = |error| CallError::Domain(wire::HostJamPeerTransportSendError::V1(error)); + self.existing() + .ok_or(quic::SendError::Closed) + .and_then(|transport| transport.send(request.stream, &request.message, request.fin)) + .map_err(|error| { + domain(match error { + quic::SendError::Closed => latest::HostJamPeerTransportSendError::Closed, + quic::SendError::TooLarge => latest::HostJamPeerTransportSendError::TooLarge, + quic::SendError::Limit => latest::HostJamPeerTransportSendError::Limit, + }) + })?; + Ok(wire::HostJamPeerTransportSendResponse::V1) + } + + /// Poll one message from a stream. + pub(crate) fn recv( + &self, + request: wire::HostJamPeerTransportRecvRequest, + ) -> Result< + wire::HostJamPeerTransportRecvResponse, + CallError, + > { + self.live()?; + let wire::HostJamPeerTransportRecvRequest::V1(request) = request; + let max = (request.max as usize).min(MAX_REPLY_FRAME_BYTES - REPLY_ENVELOPE_BYTES); + let received = self + .existing() + .ok_or(quic::Closed) + .and_then(|transport| transport.recv(request.stream, max)) + .map_err(|quic::Closed| { + CallError::Domain(wire::HostJamPeerTransportRecvError::V1( + latest::HostJamPeerTransportRecvError::Closed, + )) + })?; + Ok(wire::HostJamPeerTransportRecvResponse::V1( + latest::HostJamPeerTransportRecvResponse { + message: received.message, + fin: received.fin, + reset: received.reset, + }, + )) + } + + /// Abort a stream in both directions. + pub(crate) fn reset( + &self, + request: wire::HostJamPeerTransportResetRequest, + ) -> Result< + wire::HostJamPeerTransportResetResponse, + CallError, + > { + self.live()?; + let wire::HostJamPeerTransportResetRequest::V1(request) = request; + self.existing() + .ok_or(quic::Closed) + .and_then(|transport| transport.reset(request.stream)) + .map_err(|quic::Closed| { + CallError::Domain(wire::HostJamPeerTransportResetError::V1( + latest::HostJamPeerTransportResetError::Closed, + )) + })?; + Ok(wire::HostJamPeerTransportResetResponse::V1) + } + + /// Close a connection and every stream on it. + pub(crate) fn close( + &self, + request: wire::HostJamPeerTransportCloseRequest, + ) -> Result< + wire::HostJamPeerTransportCloseResponse, + CallError, + > { + self.live()?; + let wire::HostJamPeerTransportCloseRequest::V1(request) = request; + self.existing() + .ok_or(quic::Closed) + .and_then(|transport| transport.close(request.conn)) + .map_err(|quic::Closed| { + CallError::Domain(wire::HostJamPeerTransportCloseError::V1( + latest::HostJamPeerTransportCloseError::Closed, + )) + })?; + Ok(wire::HostJamPeerTransportCloseResponse::V1) + } + + /// Drain pending events. + pub(crate) fn events( + &self, + ) -> Result< + wire::HostJamPeerTransportEventsResponse, + CallError, + > { + self.live()?; + let events = self + .existing() + .map(|transport| transport.events()) + .unwrap_or_default(); + Ok(wire::HostJamPeerTransportEventsResponse::V1( + latest::HostJamPeerTransportEventsResponse { events }, + )) + } +} + +#[cfg(test)] +mod tests; + +impl Drop for JamPeerSession { + fn drop(&mut self) { + self.revoke(); + } +} diff --git a/rust/crates/truapi/src/jam_peer_transport/session/tests.rs b/rust/crates/truapi/src/jam_peer_transport/session/tests.rs new file mode 100644 index 0000000000..ed2e7afeb8 --- /dev/null +++ b/rust/crates/truapi/src/jam_peer_transport/session/tests.rs @@ -0,0 +1,680 @@ +use std::net::Ipv4Addr; +use std::sync::atomic::AtomicUsize; + +use super::super::tls::Identity; +use super::*; +use crate::test_support::test_spawner; + +const GENESIS: [u8; 32] = [0x35; 32]; + +fn cx() -> CallContext { + CallContext::with_request_id("t".into()) +} + +fn not_granted() -> Decision { + Err(dial_error( + latest::HostJamPeerTransportDialError::NotGranted, + )) +} + +/// What the runtime's permission check answers: `GENESIS` only, counting how +/// often it is asked. +fn grant_genesis( + asked: &Arc, + genesis: [u8; 32], +) -> impl Future + Send + 'static { + asked.fetch_add(1, Ordering::SeqCst); + async move { + if genesis == GENESIS { + Ok(()) + } else { + not_granted() + } + } +} + +/// A permission check that answers once `decision` fires. +fn prompt( + asked: &Arc, + decision: &watch::Receiver, +) -> impl Future + Send + 'static { + asked.fetch_add(1, Ordering::SeqCst); + let mut decision = decision.clone(); + async move { + let _ = decision.changed().await; + let granted = *decision.borrow(); + if granted { Ok(()) } else { not_granted() } + } +} + +fn session_with_deadline(dial_deadline: Duration) -> JamPeerSession { + let mut session = JamPeerSession::new(); + session.dial_deadline = dial_deadline; + session +} + +fn dial_request( + genesis: [u8; 32], + port: u16, + ed25519: [u8; 32], +) -> wire::HostJamPeerTransportDialRequest { + wire::HostJamPeerTransportDialRequest::V1(latest::HostJamPeerTransportDialRequest { + genesis, + ip: Ipv4Addr::LOCALHOST.to_ipv6_mapped().octets(), + port, + ed25519, + p256: None, + }) +} + +fn domain(error: CallError) -> Option { + match error { + CallError::Domain(error) => Some(error), + _ => None, + } +} + +fn dial_failure( + error: CallError, +) -> Option { + domain(error).map(|wire::HostJamPeerTransportDialError::V1(error)| error) +} + +/// A bound socket that never answers keeps every dial in its handshake. +fn silent_port() -> (std::net::UdpSocket, u16) { + let silent = std::net::UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = silent.local_addr().unwrap().port(); + (silent, port) +} + +/// A JAMNP-S peer on loopback: presents a certificate for `identity`, then +/// answers the first message of the first stream with `reply` and finishes. +fn peer(identity: &Identity, reply: &'static [u8]) -> (quinn::Endpoint, u16) { + peer_with_stream_credit(identity, reply, 100) +} + +fn peer_with_stream_credit( + identity: &Identity, + reply: &'static [u8], + credit: u32, +) -> (quinn::Endpoint, u16) { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let mut tls = rustls::ServerConfig::builder_with_provider(provider) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_no_client_auth() + .with_single_cert(identity.cert_chain(), identity.private_key()) + .unwrap(); + tls.alpn_protocols = vec![super::super::alpn(&GENESIS).into_bytes()]; + let crypto = quinn::crypto::rustls::QuicServerConfig::try_from(tls).unwrap(); + let mut config = quinn::ServerConfig::with_crypto(Arc::new(crypto)); + let mut transport = quinn::TransportConfig::default(); + transport.max_concurrent_bidi_streams(credit.into()); + config.transport_config(Arc::new(transport)); + let endpoint = quinn::Endpoint::server( + config, + (Ipv4Addr::LOCALHOST, 0).into(), + ) + .unwrap(); + let port = endpoint.local_addr().unwrap().port(); + let server = endpoint.clone(); + tokio::spawn(async move { + let connection = server.accept().await.unwrap().await.unwrap(); + // Admission/lifecycle tests intentionally close without opening a stream. + let Ok((mut send, mut recv)) = connection.accept_bi().await else { + return; + }; + let mut kind = [0u8; 1]; + recv.read_exact(&mut kind).await.unwrap(); + let mut length = [0u8; 4]; + recv.read_exact(&mut length).await.unwrap(); + let mut message = vec![0u8; u32::from_le_bytes(length) as usize]; + recv.read_exact(&mut message).await.unwrap(); + assert_eq!(kind, [0], "the host sends the stream kind first"); + assert_eq!(message, b"hello", "the host frames the message"); + send.write_all(&(reply.len() as u32).to_le_bytes()) + .await + .unwrap(); + send.write_all(reply).await.unwrap(); + send.finish().unwrap(); + connection.closed().await; + }); + (endpoint, port) +} + +#[test] +fn a_granted_dial_frames_messages_outside_tokio_and_revoke_denies_everything() { + // The native runtime drives host traits on a futures executor: no tokio + // timer or reactor exists on the calling thread. + let server = tokio::runtime::Runtime::new().unwrap(); + let identity = Identity::generate().unwrap(); + let (_peer, port) = { + let _context = server.enter(); + peer(&identity, b"welcome") + }; + let asked = Arc::new(AtomicUsize::new(0)); + let spawner = test_spawner(); + futures::executor::block_on(async { + let session = JamPeerSession::new(); + let wire::HostJamPeerTransportDialResponse::V1(latest::HostJamPeerTransportDialResponse { + conn, + }) = session + .dial( + &cx(), + dial_request(GENESIS, port, *identity.public()), + || grant_genesis(&asked, GENESIS), + &spawner, + ) + .await + .unwrap(); + let wire::HostJamPeerTransportOpenResponse::V1(latest::HostJamPeerTransportOpenResponse { + stream, + }) = session + .open(&cx(), wire::HostJamPeerTransportOpenRequest::V1( + latest::HostJamPeerTransportOpenRequest { conn, kind: 0 }, + )) + .await + .unwrap(); + session + .send(wire::HostJamPeerTransportSendRequest::V1( + latest::HostJamPeerTransportSendRequest { + stream, + message: b"hello".to_vec(), + fin: false, + }, + )) + .unwrap(); + + // Poll until the peer's reply and its finish have been reported. The + // finish may ride on the last message or come alone after it. + let recv = + wire::HostJamPeerTransportRecvRequest::V1(latest::HostJamPeerTransportRecvRequest { + stream, + max: 1024, + }); + let mut messages = Vec::new(); + let mut end = None; + for _ in 0..500 { + let wire::HostJamPeerTransportRecvResponse::V1(response) = + session.recv(recv.clone()).unwrap(); + match response.message { + Some(message) => messages.push(message), + None if response.fin || response.reset => { + end = Some(response); + break; + } + None => std::thread::sleep(Duration::from_millis(10)), + } + } + assert_eq!( + (messages, end), + ( + vec![b"welcome".to_vec()], + Some(latest::HostJamPeerTransportRecvResponse { + message: None, + fin: true, + reset: false, + }), + ), + "the host strips the length, then reports the finish once drained", + ); + assert_eq!( + session.recv(recv.clone()).map_err(domain), + Err(Some(wire::HostJamPeerTransportRecvError::V1( + latest::HostJamPeerTransportRecvError::Closed + ))), + "a fully consumed receive side is closed", + ); + + session.revoke(); + let denied = [ + session + .dial( + &cx(), + dial_request(GENESIS, port, *identity.public()), + || grant_genesis(&asked, GENESIS), + &spawner, + ) + .await + .is_err_and(|error| matches!(error, CallError::Denied)), + session + .recv(recv.clone()) + .is_err_and(|error| matches!(error, CallError::Denied)), + session + .events() + .is_err_and(|error| matches!(error, CallError::Denied)), + ]; + assert_eq!(denied, [true; 3], "a revoked session denies every call"); + }); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_refused_genesis_binds_nothing_and_a_foreign_key_is_refused() { + let identity = Identity::generate().unwrap(); + let (_peer, port) = peer(&identity, b"unused"); + let asked = Arc::new(AtomicUsize::new(0)); + let spawner = test_spawner(); + let session = JamPeerSession::new(); + + let foreign = session + .dial( + &cx(), + dial_request([0x11; 32], port, *identity.public()), + || grant_genesis(&asked, [0x11; 32]), + &spawner, + ) + .await + .unwrap_err(); + assert_eq!( + (dial_failure(foreign), session.existing().is_none()), + ( + Some(latest::HostJamPeerTransportDialError::NotGranted), + true + ), + "a refused dial never creates the QUIC endpoint, so no packet leaves", + ); + + let impostor = Identity::generate().unwrap(); + let refused = session + .dial( + &cx(), + dial_request(GENESIS, port, *impostor.public()), + || grant_genesis(&asked, GENESIS), + &spawner, + ) + .await + .unwrap_err(); + assert_eq!( + dial_failure(refused), + Some(latest::HostJamPeerTransportDialError::Refused), + "a certificate for another key is refused" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn concurrent_dials_never_exceed_the_connection_cap_and_ask_once() { + let asked = Arc::new(AtomicUsize::new(0)); + let session = Arc::new(JamPeerSession::new()); + let (silent, port) = silent_port(); + let dials = (0..quic::MAX_CONNECTIONS + 4).map(|_| { + let session = session.clone(); + let asked = asked.clone(); + tokio::spawn(async move { + session + .dial( + &cx(), + dial_request(GENESIS, port, [7; 32]), + || grant_genesis(&asked, GENESIS), + &test_spawner(), + ) + .await + .unwrap_err() + }) + }); + let (mut unreachable, mut limited) = (0, 0); + for dial in dials.collect::>() { + match dial_failure(dial.await.unwrap()) { + Some(latest::HostJamPeerTransportDialError::Unreachable) => unreachable += 1, + Some(latest::HostJamPeerTransportDialError::Limit) => limited += 1, + other => panic!("unexpected dial result {other:?}"), + } + } + assert_eq!( + (unreachable, limited), + (quic::MAX_CONNECTIONS, 4), + "at most the cap dials at once; every dial beyond it is refused immediately", + ); + // Released slots are reusable. + let again = session + .dial( + &cx(), + dial_request(GENESIS, port, [7; 32]), + || grant_genesis(&asked, GENESIS), + &test_spawner(), + ) + .await + .unwrap_err(); + assert_eq!( + (dial_failure(again), asked.load(Ordering::SeqCst)), + (Some(latest::HostJamPeerTransportDialError::Unreachable), 1), + "concurrent dials of one genesis ask once", + ); + drop(silent); + // Dropping the session here, inside a runtime worker, must not panic. +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_prompt_outlasting_the_deadline_is_unreachable_and_remembered() { + let identity = Identity::generate().unwrap(); + let (_peer, port) = peer(&identity, b"unused"); + let asked = Arc::new(AtomicUsize::new(0)); + let (answer, decision) = watch::channel(false); + let spawner = test_spawner(); + let session = session_with_deadline(Duration::from_millis(50)); + + let late = session + .dial( + &cx(), + dial_request(GENESIS, port, *identity.public()), + || prompt(&asked, &decision), + &spawner, + ) + .await + .unwrap_err(); + assert_eq!( + dial_failure(late), + Some(latest::HostJamPeerTransportDialError::Unreachable) + ); + assert_eq!(session.pending_dials.load(Ordering::Acquire), 0); + // The user answers after the guest stopped waiting: nothing was opened. + answer.send(true).unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(session.existing().is_none()); + + session + .dial( + &cx(), + dial_request(GENESIS, port, *identity.public()), + || prompt(&asked, &decision), + &spawner, + ) + .await + .expect("the retry reuses the remembered grant"); + assert_eq!( + asked.load(Ordering::SeqCst), + 1, + "the retry does not ask again" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_cancel_during_the_prompt_is_cancelled_and_opens_nothing() { + let asked = Arc::new(AtomicUsize::new(0)); + let (answer, decision) = watch::channel(false); + let spawner = test_spawner(); + let session = JamPeerSession::new(); + let cx = cx(); + let cancel = cx.cancel().clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + cancel.cancel(); + }); + let (_silent, port) = silent_port(); + + let error = session + .dial( + &cx, + dial_request(GENESIS, port, [7; 32]), + || prompt(&asked, &decision), + &spawner, + ) + .await + .unwrap_err(); + assert!(matches!(error, CallError::Cancelled)); + answer.send(true).unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + + // Every slot is free: the cancelled dial left no handshake behind. + let dials = (0..quic::MAX_CONNECTIONS).map(|_| { + session.dial_granted( + latest::HostJamPeerTransportDialRequest { + genesis: GENESIS, + ip: Ipv4Addr::LOCALHOST.to_ipv6_mapped().octets(), + port, + ed25519: [7; 32], + p256: None, + }, + || prompt(&asked, &decision), + &spawner, + ) + }); + for result in futures::future::join_all(dials).await { + assert_eq!( + dial_failure(result.unwrap_err()), + Some(latest::HostJamPeerTransportDialError::Unreachable) + ); + } + assert_eq!(asked.load(Ordering::SeqCst), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_handshake_outlasting_the_deadline_frees_its_slot() { + let asked = Arc::new(AtomicUsize::new(0)); + let spawner = test_spawner(); + let session = session_with_deadline(Duration::from_millis(50)); + let (_silent, port) = silent_port(); + for _ in 0..quic::MAX_CONNECTIONS + 2 { + let error = session + .dial( + &cx(), + dial_request(GENESIS, port, [7; 32]), + || grant_genesis(&asked, GENESIS), + &spawner, + ) + .await + .unwrap_err(); + assert_eq!( + dial_failure(error), + Some(latest::HostJamPeerTransportDialError::Unreachable), + "an expired dial never holds a slot, so none hits Limit" + ); + } +} + +#[tokio::test] +async fn revoking_a_session_interrupts_a_pending_permission_prompt() { + let session = JamPeerSession::new(); + let (_silent, port) = silent_port(); + let (started, ready) = tokio::sync::oneshot::channel(); + let (held, dropped) = tokio::sync::oneshot::channel::<()>(); + let call_context = cx(); + let spawner = test_spawner(); + let mut dial = Box::pin(session.dial( + &call_context, + dial_request(GENESIS, port, [7; 32]), + || async move { + let _held = held; + let _ = started.send(()); + std::future::pending::().await + }, + &spawner, + )); + tokio::select! { + _ = &mut dial => panic!("an unanswered prompt completed"), + _ = ready => {} + } + session.revoke(); + let result = tokio::time::timeout(Duration::from_millis(100), dial) + .await + .expect("revocation must not wait for the dial deadline"); + assert!(matches!(result, Err(CallError::Denied))); + assert!(session.existing().is_none()); + assert!(tokio::time::timeout(Duration::from_secs(1), dropped).await.unwrap().is_err()); +} + +#[tokio::test] +async fn a_pre_cancelled_dial_does_not_prompt_or_bind() { + let session = JamPeerSession::new(); + let call_context = cx(); + call_context.cancel().cancel(); + let result = session.dial( + &call_context, + dial_request(GENESIS, 1, [7; 32]), + || async { panic!("a cancelled dial must not start authorization") }, + &test_spawner(), + ).await; + assert!(matches!(result, Err(CallError::Cancelled))); + assert!(session.existing().is_none()); +} + +#[tokio::test] +async fn cancelling_open_does_not_wait_for_the_peers_stream_credit() { + let identity = Identity::generate().unwrap(); + let (_peer, port) = peer_with_stream_credit(&identity, b"unused", 0); + let session = JamPeerSession::new(); + let wire::HostJamPeerTransportDialResponse::V1(response) = session.dial( + &cx(), + dial_request(GENESIS, port, *identity.public()), + || async { Ok(()) }, + &test_spawner(), + ).await.unwrap(); + let call_context = cx(); + let request = wire::HostJamPeerTransportOpenRequest::V1( + latest::HostJamPeerTransportOpenRequest { conn: response.conn, kind: 0 }, + ); + let mut open = Box::pin(session.open(&call_context, request)); + assert!(futures::poll!(&mut open).is_pending()); + call_context.cancel().cancel(); + let result = tokio::time::timeout(Duration::from_millis(100), open).await.unwrap(); + assert!(matches!(result, Err(CallError::Cancelled))); +} + +#[tokio::test] +async fn permission_waits_are_bounded_before_prompting_and_cancelled_slots_are_reusable() { + let session = JamPeerSession::new(); + let asked = Arc::new(AtomicUsize::new(0)); + let (_answer, decision) = watch::channel(false); + let spawner = test_spawner(); + let contexts: Vec<_> = (0..quic::MAX_CONNECTIONS).map(|_| cx()).collect(); + let mut dials: Vec<_> = contexts.iter().enumerate().map(|(index, context)| { + Box::pin(session.dial( + context, + dial_request([index as u8; 32], 1, [7; 32]), + || prompt(&asked, &decision), + &spawner, + )) + }).collect(); + for dial in &mut dials { + assert!(futures::poll!(dial.as_mut()).is_pending()); + } + assert_eq!(asked.load(Ordering::SeqCst), quic::MAX_CONNECTIONS); + assert_eq!(session.pending_dials.load(Ordering::Acquire), quic::MAX_CONNECTIONS); + assert!(session.existing().is_none()); + + let ninth = session.dial( + &cx(), + dial_request([0; 32], 1, [7; 32]), + || async { panic!("capacity must be checked before authorization") }, + &spawner, + ).await.unwrap_err(); + assert_eq!(dial_failure(ninth), Some(latest::HostJamPeerTransportDialError::Limit)); + contexts[0].cancel().cancel(); + assert!(matches!(dials[0].as_mut().await, Err(CallError::Cancelled))); + assert_eq!(session.pending_dials.load(Ordering::Acquire), quic::MAX_CONNECTIONS - 1); + + let retry_context = cx(); + let mut retry = Box::pin(session.dial( + &retry_context, + dial_request([0; 32], 1, [7; 32]), + || async { panic!("a cached pending decision must not prompt again") }, + &spawner, + )); + assert!(futures::poll!(&mut retry).is_pending()); + assert_eq!(session.pending_dials.load(Ordering::Acquire), quic::MAX_CONNECTIONS); + drop(retry); + drop(dials); + assert_eq!(session.pending_dials.load(Ordering::Acquire), 0); + + // Pending decisions remain bounded even after every caller has left. + let ninth = session.dial( + &cx(), + dial_request([9; 32], 1, [7; 32]), + || async { panic!("the decision cache must not evict a pending check") }, + &spawner, + ).await.unwrap_err(); + assert_eq!(dial_failure(ninth), Some(latest::HostJamPeerTransportDialError::Limit)); + assert_eq!(session.pending_dials.load(Ordering::Acquire), 0); + assert_eq!(session.decisions.lock().len(), quic::MAX_CONNECTIONS); + session.revoke(); +} + +#[tokio::test] +async fn denied_genesis_decisions_are_retained_and_the_ninth_is_limited() { + let session = JamPeerSession::new(); + let asked = AtomicUsize::new(0); + let spawner = test_spawner(); + for index in 0..quic::MAX_CONNECTIONS { + let result = session.dial( + &cx(), + dial_request([index as u8; 32], 1, [7; 32]), + || { + asked.fetch_add(1, Ordering::SeqCst); + async { not_granted() } + }, + &spawner, + ).await.unwrap_err(); + assert_eq!(dial_failure(result), Some(latest::HostJamPeerTransportDialError::NotGranted)); + } + for (genesis, expected) in [ + ([9; 32], latest::HostJamPeerTransportDialError::Limit), + ([0; 32], latest::HostJamPeerTransportDialError::NotGranted), + ] { + let result = session.dial( + &cx(), + dial_request(genesis, 1, [7; 32]), + || async { panic!("denied decisions must not be evicted or re-prompted") }, + &spawner, + ).await.unwrap_err(); + assert_eq!(dial_failure(result), Some(expected)); + } + assert_eq!(asked.load(Ordering::SeqCst), quic::MAX_CONNECTIONS); + assert_eq!(session.decisions.lock().len(), quic::MAX_CONNECTIONS); + assert_eq!(session.pending_dials.load(Ordering::Acquire), 0); + assert!(session.existing().is_none()); +} + +#[tokio::test] +async fn live_connections_and_permission_waits_share_capacity_and_close_releases_it() { + let session = JamPeerSession::new(); + let identity = Identity::generate().unwrap(); + let spawner = test_spawner(); + let mut peers = Vec::new(); + let mut conns = Vec::new(); + for _ in 0..quic::MAX_CONNECTIONS { + let (peer, port) = peer(&identity, b"unused"); + peers.push(peer); + let wire::HostJamPeerTransportDialResponse::V1(response) = session.dial( + &cx(), + dial_request(GENESIS, port, *identity.public()), + || async { Ok(()) }, + &spawner, + ).await.unwrap(); + conns.push(response.conn); + } + assert_eq!(session.pending_dials.load(Ordering::Acquire), 0); + let full = session.dial( + &cx(), + dial_request([9; 32], 1, [7; 32]), + || async { panic!("live handles must exclude new permission waits") }, + &spawner, + ).await.unwrap_err(); + assert_eq!(dial_failure(full), Some(latest::HostJamPeerTransportDialError::Limit)); + assert_eq!(session.decisions.lock().len(), 1); + + session.close(wire::HostJamPeerTransportCloseRequest::V1( + latest::HostJamPeerTransportCloseRequest { conn: conns[0] }, + )).unwrap(); + let (_answer, decision) = watch::channel(false); + let asked = Arc::new(AtomicUsize::new(0)); + let context = cx(); + let mut pending = Box::pin(session.dial( + &context, + dial_request([9; 32], 1, [7; 32]), + || prompt(&asked, &decision), + &spawner, + )); + assert!(futures::poll!(&mut pending).is_pending()); + assert_eq!(asked.load(Ordering::SeqCst), 1); + assert_eq!(session.pending_dials.load(Ordering::Acquire), 1); + let full = session.dial( + &cx(), + dial_request([10; 32], 1, [7; 32]), + || async { panic!("live plus pending must share the eight slots") }, + &spawner, + ).await.unwrap_err(); + assert_eq!(dial_failure(full), Some(latest::HostJamPeerTransportDialError::Limit)); + session.revoke(); + assert!(matches!(pending.await, Err(CallError::Denied))); + assert_eq!(session.pending_dials.load(Ordering::Acquire), 0); + drop(peers); +} diff --git a/rust/crates/truapi/src/jam_peer_transport/tls.rs b/rust/crates/truapi/src/jam_peer_transport/tls.rs new file mode 100644 index 0000000000..083c4b92ba --- /dev/null +++ b/rust/crates/truapi/src/jam_peer_transport/tls.rs @@ -0,0 +1,230 @@ +//! JAMNP-S TLS: a self-signed Ed25519 client certificate whose single DNS +//! alternative name is the key's text form, and a server verifier that pins +//! the peer's Ed25519 key. Certificate signatures are not checked (the spec +//! neither requires nor forbids self-signing); the TLS 1.3 handshake signature +//! is verified against the pinned key. + +use std::sync::Arc; + +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName, UnixTime}; +use rustls::{DigitallySignedStruct, SignatureScheme}; +use x509_parser::der_parser::asn1_rs::oid; +use x509_parser::prelude::*; + +use super::peer_id; + +/// Failure to build the local identity. +#[derive(Debug, thiserror::Error)] +#[error("cannot build the local JAMNP-S identity: {0}")] +pub(super) struct IdentityError(#[from] rcgen::Error); + +/// Local Ed25519 identity with its self-signed certificate. +pub(super) struct Identity { + public: [u8; 32], + cert: CertificateDer<'static>, + key: PrivatePkcs8KeyDer<'static>, +} + +impl Identity { + /// A fresh identity; every peer accepts any well-formed key. + pub(super) fn generate() -> Result { + let key_pair = rcgen::KeyPair::generate_for(&rcgen::PKCS_ED25519)?; + let public: [u8; 32] = key_pair + .public_key_raw() + .try_into() + .expect("Ed25519 public keys are 32 bytes"); + let mut params = rcgen::CertificateParams::new(vec![peer_id::ed25519_text(&public)])?; + let mut name = rcgen::DistinguishedName::new(); + name.push(rcgen::DnType::CommonName, "jam"); + params.distinguished_name = name; + let cert = params.self_signed(&key_pair)?; + Ok(Self { + public, + cert: cert.der().clone(), + key: PrivatePkcs8KeyDer::from(key_pair.serialize_der()), + }) + } + + /// Our Ed25519 public key. + pub(super) fn public(&self) -> &[u8; 32] { + &self.public + } + + pub(super) fn cert_chain(&self) -> Vec> { + vec![self.cert.clone()] + } + + pub(super) fn private_key(&self) -> PrivateKeyDer<'static> { + PrivateKeyDer::Pkcs8(self.key.clone_key()) + } +} + +const BAD_ENCODING: rustls::Error = + rustls::Error::InvalidCertificate(rustls::CertificateError::BadEncoding); +const APP_VERIF_FAILURE: rustls::Error = + rustls::Error::InvalidCertificate(rustls::CertificateError::ApplicationVerificationFailure); +const NOT_VALID_FOR_NAME: rustls::Error = + rustls::Error::InvalidCertificate(rustls::CertificateError::NotValidForName); +const BAD_SIGNATURE: rustls::Error = + rustls::Error::InvalidCertificate(rustls::CertificateError::BadSignature); + +/// Ed25519 key of a JAMNP-S certificate, after checking its form: Ed25519 +/// SPKI, at most V3, one DNS alternative name equal to the key's text form, +/// no unknown critical extensions. +fn peer_key(cert: &CertificateDer<'_>) -> Result<[u8; 32], rustls::Error> { + let (rest, cert) = X509Certificate::from_der(cert).map_err(|_| BAD_ENCODING)?; + if !rest.is_empty() || cert.version.0 > 2 { + return Err(BAD_ENCODING); + } + let spki = &cert.subject_pki; + if spki.algorithm.algorithm != oid!(1.3.101.112) || spki.algorithm.parameters.is_some() { + return Err(APP_VERIF_FAILURE); + } + if spki.subject_public_key.unused_bits != 0 { + return Err(BAD_ENCODING); + } + let key: [u8; 32] = spki + .subject_public_key + .as_ref() + .try_into() + .map_err(|_| BAD_ENCODING)?; + let mut alternative_names = None; + for extension in cert.extensions() { + match extension.parsed_extension() { + ParsedExtension::SubjectAlternativeName(names) => { + if alternative_names.replace(names).is_some() { + return Err(BAD_ENCODING); + } + } + ParsedExtension::ParseError { .. } => return Err(BAD_ENCODING), + _ if extension.critical => { + return Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::UnhandledCriticalExtension, + )); + } + _ => {} + } + } + let names = &alternative_names.ok_or(APP_VERIF_FAILURE)?.general_names; + let [GeneralName::DNSName(name)] = names.as_slice() else { + return Err(APP_VERIF_FAILURE); + }; + if *name != peer_id::ed25519_text(&key) { + return Err(APP_VERIF_FAILURE); + } + Ok(key) +} + +/// Pins one expected Ed25519 peer key for a single dial. +#[derive(Debug)] +struct PinnedPeer { + expected: [u8; 32], +} + +impl ServerCertVerifier for PinnedPeer { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + let key = peer_key(end_entity)?; + if key != self.expected { + return Err(APP_VERIF_FAILURE); + } + let ServerName::DnsName(name) = server_name else { + return Err(NOT_VALID_FOR_NAME); + }; + if name.as_ref() != peer_id::ed25519_text(&key) { + return Err(NOT_VALID_FOR_NAME); + } + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Err(rustls::Error::PeerIncompatible( + rustls::PeerIncompatible::Tls12NotOffered, + )) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + if dss.scheme != SignatureScheme::ED25519 { + return Err(rustls::Error::PeerMisbehaved( + rustls::PeerMisbehaved::SignedHandshakeWithUnadvertisedSigScheme, + )); + } + let key = peer_key(cert)?; + ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, key) + .verify(message, dss.signature()) + .map_err(|_| BAD_SIGNATURE)?; + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![SignatureScheme::ED25519] + } +} + +/// rustls client configuration for one dial: TLS 1.3, ring, our certificate, +/// the pinned peer verifier and the chain's ALPN. +pub(super) fn client_config( + identity: &Identity, + expected: [u8; 32], + alpn: Vec, +) -> Result { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let mut config = rustls::ClientConfig::builder_with_provider(provider) + .with_protocol_versions(&[&rustls::version::TLS13]) + .expect("ring supports TLS 1.3") + .dangerous() + .with_custom_certificate_verifier(Arc::new(PinnedPeer { expected })) + .with_client_auth_cert(identity.cert_chain(), identity.private_key())?; + config.alpn_protocols = vec![alpn]; + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A dial names one key; any other certificate, or the right certificate + /// under another name, must fail the handshake. + #[test] + fn only_the_pinned_key_under_its_own_name_passes() { + let identity = Identity::generate().unwrap(); + let other = Identity::generate().unwrap(); + let name = |identity: &Identity| { + ServerName::try_from(peer_id::ed25519_text(identity.public())).unwrap() + }; + let verify = |expected: &Identity, server_name: &ServerName<'_>| { + PinnedPeer { + expected: *expected.public(), + } + .verify_server_cert(&identity.cert, &[], server_name, &[], UnixTime::now()) + .is_ok() + }; + + assert_eq!(peer_key(&identity.cert).unwrap(), *identity.public()); + assert_eq!( + [ + verify(&identity, &name(&identity)), + verify(&other, &name(&identity)), + verify(&identity, &name(&other)), + ], + [true, false, false], + ); + } +} diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index a69347ca2c..6c870a7d86 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -97,17 +97,32 @@ pub mod latest { EffectProps, GenericError, HorizontalAlignment, HostAccountCreateProofRequest, HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, - HostAccountSignVrfError, HostAccountSignVrfRequest, HostPlatform, HostSignPayloadData, - HostWorkerOperationError, ImageFit, ImageProps, ImageSource, Modifier, - OperationStartedResult, PocketCard, ProductAccountId, ProductProofContext, RawPayload, - RegisteredRingVrfKey, RemotePermission, RemoteStatementStoreCreateProofError, - RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, - RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, RenderContext, - RendererNode, RingLocation, RingLocationJunction, RingVrfKeyDisclosure, RowProps, - RuntimeApi, RuntimeSpec, RuntimeType, ScanOutcome, Shape, SignedStatement, Size, Statement, - StatementProof, StorageQueryItem, StorageQueryType, StorageResultItem, TextFieldProps, - TextProps, ThemeName, ThemeVariant, TxPayloadExtension, TypographyStyle, VerticalAlignment, - VrfSignature, + HostAccountSignVrfError, HostAccountSignVrfRequest, HostJamPeerTransportCloseError, + HostJamPeerTransportCloseRequest, HostJamPeerTransportDialError, + HostJamPeerTransportDialRequest, HostJamPeerTransportDialResponse, + HostJamPeerTransportEventsError, HostJamPeerTransportEventsResponse, + HostJamPeerTransportOpenError, HostJamPeerTransportOpenRequest, + HostJamPeerTransportOpenResponse, HostJamPeerTransportRecvError, + HostJamPeerTransportRecvRequest, HostJamPeerTransportRecvResponse, + HostJamPeerTransportResetError, HostJamPeerTransportResetRequest, + HostJamPeerTransportSendError, HostJamPeerTransportSendRequest, + HostNotificationAcknowledgeReceiverEventRequest, HostNotificationDisableReceiverRequest, + HostNotificationReceiptResult, HostNotificationReceiverEventsRequest, + HostNotificationReceiverStatus, HostNotificationReceivingError, + HostNotificationRecordReceiptRequest, HostNotificationReplaceReceiverRequest, HostPlatform, + HostSignPayloadData, HostWorkerOperationError, ImageFit, ImageProps, ImageSource, + JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION, JAM_PEER_TRANSPORT_MAX_CONNECTIONS, + JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES, JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION, + JamPeerTransportEvent, Modifier, OperationStartedResult, PocketCard, ProductAccountId, + ProductProofContext, RawPayload, ReceivingEvent, ReceivingEventKind, ReceivingReceiptKind, + ReceivingWatch, RegisteredRingVrfKey, RemotePermission, + RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, + RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, + RemoteStatementStoreSubscribeRequest, RenderContext, RendererNode, RingLocation, + RingLocationJunction, RingVrfKeyDisclosure, RowProps, RuntimeApi, RuntimeSpec, RuntimeType, + ScanOutcome, Shape, SignedStatement, Size, Statement, StatementProof, StorageQueryItem, + StorageQueryType, StorageResultItem, TextFieldProps, TextProps, ThemeName, ThemeVariant, + TxPayloadExtension, TypographyStyle, VerticalAlignment, VrfSignature, }; pub use crate::v02::{ HostNativeChatAcknowledgment, HostNativeChatAttachment, HostNativeChatAttachmentKind, @@ -664,6 +679,7 @@ runtime_items! { pub mod host_logic; mod host_rpc_client; mod interrupt; + pub mod jam_peer_transport; pub mod logging; pub mod platform; mod protocol_error; diff --git a/rust/crates/truapi/src/native/callbacks.rs b/rust/crates/truapi/src/native/callbacks.rs index f9f50fde81..bd141890d1 100644 --- a/rust/crates/truapi/src/native/callbacks.rs +++ b/rust/crates/truapi/src/native/callbacks.rs @@ -72,6 +72,30 @@ pub trait HostCallbacks: Send + Sync { /// Cancel a notification by id. fn cancel_notification(&self, id: u32) -> Result<(), HostRejection>; + /// Runtime callbacks return current host scope, including while products are closed. + /// Per-execution callbacks return the immutable verified artifact/account scope + /// captured at execution creation, never the latest replacement scope. + async fn receiver_authority( + &self, + product_id: String, + ) -> Result, HostRejection>; + + /// Request consent distinct from OS notification permission. + async fn receiver_consent( + &self, + authority: crate::platform::ReceivingAuthority, + watches: Vec, + ) -> Result; + + /// Wake asynchronous transport synchronization without awaiting network I/O. + async fn receiver_changed(&self) -> Result<(), HostRejection>; + + /// Optionally forward a trusted product receiving command to its sole owner. + /// `None` uses the native resident engine; failure must not select another owner. + async fn receiver_command( + &self, product_id: String, action: u8, payload: Vec, + ) -> Result>, HostRejection>; + /// Non-consuming, ordered activation batch for this execution's trusted scope. /// Independent of receiving enrollment and OS permission prompts. async fn activation_events(&self) -> Result, HostRejection>; diff --git a/rust/crates/truapi/src/native/errors.rs b/rust/crates/truapi/src/native/errors.rs index fc34e6b974..de8765e84f 100644 --- a/rust/crates/truapi/src/native/errors.rs +++ b/rust/crates/truapi/src/native/errors.rs @@ -74,6 +74,12 @@ impl From for HostRejection { } } +impl From for HostRejection { + fn from(error: crate::latest::HostNotificationReceivingError) -> Self { + Self::Rejected { reason: format!("background receiving: {error:?}") } + } +} + /// Rejection of a card face, read from its declared JSON or from the bytes a /// host kept. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] diff --git a/rust/crates/truapi/src/native/platform.rs b/rust/crates/truapi/src/native/platform.rs index 5cf9924810..57038504a4 100644 --- a/rust/crates/truapi/src/native/platform.rs +++ b/rust/crates/truapi/src/native/platform.rs @@ -275,6 +275,33 @@ impl Notifications for CallbackPlatform { .map_err(v01::GenericError::from) } + async fn receiver_authority( + &self, + product_id: &str, + ) -> Result, v01::GenericError> { + self.callbacks.receiver_authority(product_id.to_owned()).await + .map_err(v01::GenericError::from) + } + + async fn receiver_consent( + &self, + authority: crate::platform::ReceivingAuthority, + watches: Vec, + ) -> Result { + self.callbacks.receiver_consent(authority, watches).await + .map_err(v01::GenericError::from) + } + + async fn receiver_changed(&self) -> Result<(), v01::GenericError> { + self.callbacks.receiver_changed().await.map_err(v01::GenericError::from) + } + + async fn receiver_command( + &self, product_id: String, action: u8, payload: Vec, + ) -> Result>, v01::GenericError> { + self.callbacks.receiver_command(product_id, action, payload).await.map_err(v01::GenericError::from) + } + async fn activation_events(&self) -> Result { self.callbacks .activation_events() diff --git a/rust/crates/truapi/src/native/runtime.rs b/rust/crates/truapi/src/native/runtime.rs index 9b91057467..2ac15bb33a 100644 --- a/rust/crates/truapi/src/native/runtime.rs +++ b/rust/crates/truapi/src/native/runtime.rs @@ -357,6 +357,83 @@ impl NativeTrUApiHostRuntime { ) } + /// Enumerate durable receiving registrations, including synchronized ones. + pub async fn receiving_pending(&self) -> Result, HostRejection> { + self.runtime.receiving().pending().await.map_err(HostRejection::from) + } + + /// Acknowledge exactly the durable revision synchronized by the transport. + pub async fn receiving_synchronized(&self, product_id: String, revision: u64) -> Result { + self.runtime.receiving().synchronized(&product_id, revision).await.map_err(HostRejection::from) + } + + /// Verify a complete frame against its independently observed chain and topics. + #[allow( + clippy::too_many_arguments, + reason = "Preserve the native receiving API shared with generated host bindings" + )] + pub async fn receiving_ingest( + &self, product_id: String, revision: u64, watch_id: String, + actual_genesis: String, actual_channel: String, actual_topics: Vec, frame: Vec, + ) -> Result, HostRejection> { + self.runtime.receiving().ingest(&product_id, revision, watch_id, actual_genesis, actual_channel, actual_topics, frame) + .await.map_err(HostRejection::from) + } + + /// Decode and authenticate a raw SCALE statement before receiving its frame. + pub async fn receiving_ingest_statement( + &self, product_id: String, revision: u64, watch_id: String, + actual_genesis: String, statement: Vec, + ) -> Result, HostRejection> { + self.runtime.receiving().ingest_statement(&product_id, revision, watch_id, actual_genesis, statement) + .await.map_err(HostRejection::from) + } + + /// Reserve display after foreground grace, rechecking receipts and authority. + pub async fn receiving_prepare_display( + &self, product_id: String, revision: u64, event_id: String, + ) -> Result, HostRejection> { + self.runtime.receiving().prepare_display(&product_id, revision, event_id).await.map_err(HostRejection::from) + } + + /// Validate a click before loading the verified product, without enqueueing it. + pub async fn receiving_validate_activation( + &self, product_id: String, revision: u64, event_id: String, + ) -> Result, HostRejection> { + self.runtime.receiving().validate_activation(&product_id, revision, event_id).await.map_err(HostRejection::from) + } + + /// Clear a reservation only after explicit display failure, not an unknown outcome. + pub async fn receiving_cancel_display( + &self, product_id: String, revision: u64, event_id: String, + ) -> Result<(), HostRejection> { + self.runtime.receiving().cancel_display(&product_id, revision, event_id).await.map_err(HostRejection::from) + } + + /// Record actual platform display, not enrollment or ingestion. + pub async fn receiving_confirm_display( + &self, product_id: String, revision: u64, event_id: String, + ) -> Result<(), HostRejection> { + self.runtime.receiving().confirm_display(&product_id, revision, event_id).await.map_err(HostRejection::from) + } + + /// Resolve a click only under current trusted authority, without launching URLs. + pub async fn receiving_activate( + &self, product_id: String, revision: u64, event_id: String, + ) -> Result, HostRejection> { + self.runtime.receiving().activate(&product_id, revision, event_id).await.map_err(HostRejection::from) + } + + /// Revoke locally before logout or destructive account erasure. + pub async fn receiving_revoke(&self, product_id: String) -> Result<(), HostRejection> { + self.runtime.receiving().revoke(&product_id).await.map_err(HostRejection::from) + } + + /// Queue synchronization after the host durably rotates its selected transport. + pub async fn receiving_mark_transport_changed(&self, product_id: String) -> Result<(), HostRejection> { + self.runtime.receiving().mark_transport_changed(&product_id).await.map_err(HostRejection::from) + } + /// Enumerate existing core decisions, including resets. Does not prompt /// and does not substitute the host application's OS permission state. pub async fn permission_authorizations( diff --git a/rust/crates/truapi/src/native/tests.rs b/rust/crates/truapi/src/native/tests.rs index c00a899079..ec321522b8 100644 --- a/rust/crates/truapi/src/native/tests.rs +++ b/rust/crates/truapi/src/native/tests.rs @@ -293,6 +293,19 @@ impl HostCallbacks for EventCallbacks { async fn identity_username_candidates(&self, _: String, _: Vec) -> Result>, HostRejection> { Err(HostRejection::Rejected { reason: "no identity provider in fixture".into() }) } async fn allowed_hop_endpoints(&self, _: Vec) -> Result, HostRejection> { Ok(Vec::new()) } fn hop_connect(&self, _: Vec, _: String) -> Result, HostRejection> { Ok(None) } + async fn receiver_authority(&self, _: String) -> Result, HostRejection> { + Ok(None) + } + async fn receiver_consent(&self, _: crate::platform::ReceivingAuthority, _: Vec) -> Result { + Err(HostRejection::Rejected { reason: "background receiving unsupported".into() }) + } + async fn receiver_changed(&self) -> Result<(), HostRejection> { + Err(HostRejection::Rejected { reason: "background receiving unsupported".into() }) + } + async fn receiver_command(&self, _: String, _: u8, _: Vec) -> Result>, HostRejection> { + Ok(None) + } + async fn core_storage_keys(&self) -> Result>, HostRejection> { if self.core_storage_keys_failure.load(Ordering::SeqCst) { return Err(HostRejection::Rejected { @@ -1994,6 +2007,19 @@ fn start_ws_bridge_twice_returns_already_running() { async fn identity_username_candidates(&self, _: String, _: Vec) -> Result>, HostRejection> { Err(HostRejection::Rejected { reason: "no identity provider in fixture".into() }) } async fn allowed_hop_endpoints(&self, _: Vec) -> Result, HostRejection> { Ok(Vec::new()) } fn hop_connect(&self, _: Vec, _: String) -> Result, HostRejection> { Ok(None) } + async fn receiver_authority(&self, _: String) -> Result, HostRejection> { + Ok(None) + } + async fn receiver_consent(&self, _: crate::platform::ReceivingAuthority, _: Vec) -> Result { + Err(HostRejection::Rejected { reason: "background receiving unsupported".into() }) + } + async fn receiver_changed(&self) -> Result<(), HostRejection> { + Err(HostRejection::Rejected { reason: "background receiving unsupported".into() }) + } + async fn receiver_command(&self, _: String, _: u8, _: Vec) -> Result>, HostRejection> { + Ok(None) + } + async fn core_storage_keys(&self) -> Result>, HostRejection> { Err(HostRejection::Rejected { reason: "storage unavailable in bridge-start fixture".into(), @@ -2201,6 +2227,19 @@ fn pending_permission_decision_does_not_stall_bridge() { async fn identity_username_candidates(&self, _: String, _: Vec) -> Result>, HostRejection> { Err(HostRejection::Rejected { reason: "no identity provider in fixture".into() }) } async fn allowed_hop_endpoints(&self, _: Vec) -> Result, HostRejection> { Ok(Vec::new()) } fn hop_connect(&self, _: Vec, _: String) -> Result, HostRejection> { Ok(None) } + async fn receiver_authority(&self, _: String) -> Result, HostRejection> { + Ok(None) + } + async fn receiver_consent(&self, _: crate::platform::ReceivingAuthority, _: Vec) -> Result { + Err(HostRejection::Rejected { reason: "background receiving unsupported".into() }) + } + async fn receiver_changed(&self) -> Result<(), HostRejection> { + Err(HostRejection::Rejected { reason: "background receiving unsupported".into() }) + } + async fn receiver_command(&self, _: String, _: u8, _: Vec) -> Result>, HostRejection> { + Ok(None) + } + async fn core_storage_keys(&self) -> Result>, HostRejection> { Err(HostRejection::Rejected { reason: "enumeration unavailable in prompt fixture".into(), diff --git a/rust/crates/truapi/src/platform.rs b/rust/crates/truapi/src/platform.rs index bfc09ae1a7..f001365f1d 100644 --- a/rust/crates/truapi/src/platform.rs +++ b/rust/crates/truapi/src/platform.rs @@ -1163,6 +1163,46 @@ pub trait Navigation: Send + Sync { async fn navigate_to(&self, url: String) -> Result<(), HostNavigateToError>; } +/// Trusted host receiving scope, derived from verified artifact and account state. +/// It must remain available after the product execution closes. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, serde::Serialize, serde::Deserialize)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))] +pub struct ReceivingAuthority { + /// Verified product identifier. + pub product_id: String, + /// Opaque stable account identity, encoded as 32-byte lowercase hex. + pub account: String, + /// Host-selected network environment. + pub environment: String, + /// Verified artifact digest, encoded as 32-byte lowercase hex. + pub artifact: String, + /// Host-selected receiving chain genesis, encoded as 32-byte lowercase hex. + pub genesis: String, + /// Host logout, account and artifact fence. + pub generation: u64, + /// Current OS notification permission. + pub os_permission: bool, + /// Whether the explicitly selected transport is ready. + pub transport_ready: bool, +} + +/// Host-only durable registration awaiting transport synchronization. +/// Routes and authority identifiers stay local, not in provider payloads. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, serde::Serialize, serde::Deserialize)] +#[cfg_attr(not(target_arch = "wasm32"), derive(uniffi::Record))] +pub struct ReceivingRegistration { + /// Trusted scope under which consent was recorded. + pub authority: ReceivingAuthority, + /// Durable local revision, distinct from provider token revisions. + pub revision: u64, + /// Whether this revision enrolls watches or revokes them. + pub enabled: bool, + /// Locally approved source filters and activation routes. + pub watches: Vec, + /// Whether this revision still needs synchronization. + pub sync_pending: bool, +} + /// Deliver push notifications. #[async_trait] pub trait Notifications: Send + Sync { @@ -1180,6 +1220,50 @@ pub trait Notifications: Send + Sync { Ok(()) } + /// Resolve trusted authority without prompting or consulting transport. + /// The resident platform returns current host scope, even with product UI closed. + /// A product execution's platform returns the immutable scope captured when + /// that verified execution opened, never a replacement artifact/account's scope. + /// Returning `None` explicitly advertises unsupported receiving. + async fn receiver_authority( + &self, + product_id: &str, + ) -> Result, GenericError> { + let _ = product_id; + Ok(None) + } + + /// Request distinct consent for this authority and full watch scope. + async fn receiver_consent( + &self, + authority: ReceivingAuthority, + watches: Vec, + ) -> Result { + let _ = (authority, watches); + Err(GenericError { reason: "background receiving unsupported".into() }) + } + + /// Wake the host's asynchronous synchronization loop, never await network I/O. + async fn receiver_changed(&self) -> Result<(), GenericError> { + Err(GenericError { reason: "background receiving unsupported".into() }) + } + + /// Forward receiving actions to the single host-owned receiver, if external. + /// The host must bind `product_id` to the trusted execution, not page input. + /// Payloads are latest SCALE requests for actions 2..7; the response encodes + /// `Result` without a version tag. + /// `None` selects this runtime's resident engine. An unavailable external + /// owner must return an error, never `None`, to avoid a second persistent writer. + async fn receiver_command( + &self, + product_id: String, + action: u8, + payload: Vec, + ) -> Result>, GenericError> { + let _ = (product_id, action, payload); + Ok(None) + } + /// Return at most 32 pending activations, ordered by sequence, without /// consuming them. The embedding host binds this platform to the verified /// product, authenticated account and environment; none is caller input. @@ -2011,6 +2095,10 @@ pub enum CoreStorageKey { /// Host-selected Chat network. genesis_hash: [u8; 32], }, + /// Versioned bounded receiving ledger, shared across product executions. + /// Host product/account deletion must invoke ReceivingService::revoke first. + #[codec(index = 20)] + NotificationReceiving, } /// Stable metadata describing one strictly decoded [`CoreStorageKey`]. @@ -2077,6 +2165,7 @@ pub fn describe_core_storage_key( CoreStorageKey::NativeChatFileChunk { product_id, .. } => { ("NativeChatFileChunk", Some(product_id)) } + CoreStorageKey::NotificationReceiving => ("NotificationReceiving", None), }; Ok(CoreStorageKeyDescription { kind, product_id }) } @@ -3245,6 +3334,14 @@ mod tests { let other_target = CoreStorageKey::account_access_authorization("product.dot", "other.dot"); let chat_authority = CoreStorageKey::chat_authority_authorization("product.dot"); let other_product_chat = CoreStorageKey::chat_authority_authorization("other.dot"); + let jam_peers = |product_id: &str, genesis: [u8; 32]| { + CoreStorageKey::remote_permission_authorization( + product_id, + &RemotePermissionRequest { + permission: RemotePermission::JamPeers { genesis }, + }, + ) + }; assert_ne!(camera, other_product); assert_ne!(camera, remote); @@ -3256,6 +3353,16 @@ mod tests { assert_ne!(chat_authority, identity); assert_ne!(chat_authority, account_access); assert_ne!(chat_authority, other_product_chat); + // A JAM peer decision is kept per product and per genesis. + assert_ne!(jam_peers("product.dot", [0x35; 32]), remote); + assert_ne!( + jam_peers("product.dot", [0x35; 32]), + jam_peers("product.dot", [0x36; 32]) + ); + assert_ne!( + jam_peers("product.dot", [0x35; 32]), + jam_peers("other.dot", [0x35; 32]) + ); } #[test] diff --git a/rust/crates/truapi/src/platform/mock.rs b/rust/crates/truapi/src/platform/mock.rs index e3f1ae500d..79aae1eecd 100644 --- a/rust/crates/truapi/src/platform/mock.rs +++ b/rust/crates/truapi/src/platform/mock.rs @@ -424,7 +424,7 @@ impl MockPlatform { /// /// The key is the permission's SCALE variant tag -- `"Camera"`, or /// `"Remote"` for a [`latest::RemotePermission::Remote`] whatever domains - /// it names. + /// it names, `"JamPeers"` whatever genesis. pub fn grant_permission(&self, permission: impl Into) { self.permission_decisions .lock() @@ -871,6 +871,7 @@ fn core_key(key: &CoreStorageKey) -> String { CoreStorageKey::ProductManifest { product_id } => { format!("core:product-manifest:{product_id}") } + CoreStorageKey::NotificationReceiving => "core:notification-receiving".to_string(), CoreStorageKey::AllowanceKeys { session_id } => { format!("core:allowance-keys:{session_id}") } @@ -1131,6 +1132,7 @@ fn remote_permission_key(permission: &latest::RemotePermission) -> &'static str Permission::ChainSubmit => "ChainSubmit", Permission::PreimageSubmit => "PreimageSubmit", Permission::StatementSubmit => "StatementSubmit", + Permission::JamPeers { .. } => "JamPeers", } } diff --git a/rust/crates/truapi/src/runtime.rs b/rust/crates/truapi/src/runtime.rs index 129eb36d1d..90b39d125c 100644 --- a/rust/crates/truapi/src/runtime.rs +++ b/rust/crates/truapi/src/runtime.rs @@ -30,6 +30,10 @@ mod pairing_host; pub mod product_manifest; mod product_subtree; mod profile; +/// Durable, host-owned notification registration and activation policy. +pub mod receiving; +/// Transport-independent authenticated notification frames. +pub mod notification_envelope; mod renderer; mod ring_vrf_registry; /// Role-neutral runtime services shared by product-facing runtimes. @@ -93,6 +97,8 @@ pub use signing_host::{ respond_to_pairing, resume_pairing, }; pub use signing_host::{LocalIdentity, LocalIdentityContext, WalletAllowanceSnapshot}; +#[cfg(any(test, not(target_arch = "wasm32")))] +use tracing::Instrument; use tracing::{instrument, warn}; use truapi::api::{Chat, Contacts, Pocket, Profile, Renderer}; use truapi::versioned::account::{ @@ -105,6 +111,8 @@ use truapi::versioned::chat::{ HostChatPostMessageError, HostChatPostMessageRequest, HostChatPostMessageResponse, HostChatRegisterBotError, HostChatRegisterBotRequest, HostChatRegisterBotResponse, }; +#[cfg(any(test, not(target_arch = "wasm32")))] +use truapi::versioned::jam_peer_transport::HostJamPeerTransportDialError; use truapi::versioned::contacts::{ HostContactsPickError, HostContactsPickManyError, HostContactsPickManyRequest, HostContactsPickManyResponse, HostContactsPickRequest, HostContactsPickResponse, @@ -349,6 +357,9 @@ pub struct ProductRuntimeHost { /// operations is the host's call, made in `begin_operation`, since the /// host is what the operations keep running. open_operations: Mutex>, + /// This connection's JAM peer connections, closed on dispose. + #[cfg(not(target_arch = "wasm32"))] + jam_peers: crate::jam_peer_transport::session::JamPeerSession, } /// A connection that goes away without ending its operations still owes the @@ -389,6 +400,8 @@ impl ProductRuntimeHost { expanded_card: adapters.expanded_card, game_platform: adapters.game_platform, open_operations: Mutex::new(HashSet::new()), + #[cfg(not(target_arch = "wasm32"))] + jam_peers: crate::jam_peer_transport::session::JamPeerSession::new(), } } @@ -523,6 +536,8 @@ impl ProductRuntimeHost { expanded_card: None, game_platform: None, open_operations: Mutex::new(HashSet::new()), + #[cfg(not(target_arch = "wasm32"))] + jam_peers: crate::jam_peer_transport::session::JamPeerSession::new(), }; (host, pairing_host) } @@ -820,6 +835,67 @@ impl ProductRuntimeHost { .await } + /// Gate `JamPeerTransport::dial` on + /// [`RemotePermission::JamPeers`](v01::RemotePermission::JamPeers) for + /// `genesis`, before anything connects. + /// + /// Like [`Self::require_remote_permission`], this reads the product's + /// stored decision, prompts only while it is undetermined and persists the + /// answer per product and genesis. Unlike it, a one-use grant is not spent + /// by the first dial: it lives as long as the execution's one-use grants, + /// so a light client dialing several validators of one chain is asked once + /// per genesis. Anything short of a grant, including a dismissed prompt, + /// is `NotGranted`. + /// + /// The check owns what it reads, so it may outlive the dial that started + /// it: an answer given after the dial stopped waiting is still persisted. + #[cfg(any(test, not(target_arch = "wasm32")))] + pub(crate) fn require_jam_peers( + &self, + genesis: [u8; 32], + ) -> impl Future>> + Send + 'static + { + let platform = self.platform.clone(); + let product = self.product.clone(); + let permission_status = self.permission_status.clone(); + let temporary_permissions = self.temporary_permissions.clone(); + let permission_authority = self.services.permissions.clone(); + let request = v01::RemotePermissionRequest { + permission: v01::RemotePermission::JamPeers { genesis }, + }; + async move { + let status = PermissionsService::new(platform.as_ref(), platform.as_ref(), &product) + .with_status_host(permission_status.as_deref()) + .with_temporary_permissions(temporary_permissions) + .with_authority(permission_authority) + .check_or_prompt_remote(request) + .await; + match status { + Ok(PermissionAuthorizationStatus::Authorized) => Ok(()), + Ok( + PermissionAuthorizationStatus::Denied + | PermissionAuthorizationStatus::NotDetermined, + ) => Err(CallError::Domain(HostJamPeerTransportDialError::V1( + v01::HostJamPeerTransportDialError::NotGranted, + ))), + Err(err) => Err(CallError::HostFailure { + reason: format!("permission storage failed: {err:?}"), + }), + } + } + .instrument(tracing::info_span!( + "require_jam_peers", + runtime.method = "jam_peer_transport.require_jam_peers" + )) + } + + /// Close this connection's JAM peer connections; every later + /// `JamPeerTransport` call is `Denied`. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn close_jam_peer_transport(&self) { + self.jam_peers.revoke(); + } + #[instrument(skip_all, fields(runtime.method = "permissions.identity_disclosure_authorization"))] async fn identity_disclosure_authorization( &self, @@ -1431,7 +1507,10 @@ impl ProductRuntimeHost { let resolved = resolve_contact_accounts(&self.services, platform.as_ref(), &handles, requested) .await?; - if self.authority.current_session() != session { + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) { return Err(ContactResolutionError::NotConnected); } Ok(resolved) @@ -1546,7 +1625,10 @@ impl Contacts for ProductRuntimeHost { // Read before the picker opens: a removal signalled while the user is // choosing must not be undone by caching their choice. let generation = self.services.contact_handles.generation(); - if self.authority.current_session() != session { + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) { return Err(CallError::Domain(wrap( v01::HostContactsPickError::NotConnected, ))); @@ -1558,7 +1640,10 @@ impl Contacts for ProductRuntimeHost { reason: "contact picker interrupted".into(), }) })?; - if self.authority.current_session() != session { + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) { return Err(CallError::Domain(wrap( v01::HostContactsPickError::NotConnected, ))); @@ -1613,7 +1698,10 @@ impl Contacts for ProductRuntimeHost { }), })?; let generation = self.services.contact_handles.generation(); - if self.authority.current_session() != session { + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) { return Err(error(Error::NotConnected)); } let resolved = until_cancelled( @@ -1626,7 +1714,10 @@ impl Contacts for ProductRuntimeHost { reason: "contact lookup interrupted".into(), }) })?; - if self.authority.current_session() != session { + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) { return Err(error(Error::NotConnected)); } if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { @@ -1657,7 +1748,10 @@ impl Contacts for ProductRuntimeHost { reason: "contact picker interrupted".into(), }) })?; - if self.authority.current_session() != session { + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) { return Err(error(Error::NotConnected)); } if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { @@ -1732,7 +1826,10 @@ impl Contacts for ProductRuntimeHost { return Err(error(Error::NotConnected)); } let generation = self.services.contact_handles.generation(); - if self.authority.current_session() != session { + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) { return Err(error(Error::NotConnected)); } let requested: Vec<_> = request.slots.iter().map(|slot| slot.handle.bytes).collect(); @@ -1746,7 +1843,10 @@ impl Contacts for ProductRuntimeHost { reason: "contact lookup interrupted".into(), }) })?; - if self.authority.current_session() != session { + if !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) { return Err(error(Error::NotConnected)); } if self.services.contact_handles.generation() != generation || cx.cancel().is_cancelled() { @@ -1786,10 +1886,12 @@ impl Contacts for ProductRuntimeHost { }, ) .await; - if self.authority.current_session() != session - || cx.cancel().is_cancelled() - || placement.is_closed() - { + let request_closed = !session.as_ref().is_some_and(|session| { + self.authority + .session_is_current(session, Some(&self.product.product_id)) + }) || cx.cancel().is_cancelled() + || placement.is_closed(); + if request_closed || self.services.contact_handles.generation() != generation { let _ = platform .place_contact_labels( &self.product, @@ -1800,7 +1902,13 @@ impl Contacts for ProductRuntimeHost { }, ) .await; - return Err(error(Error::NotConnected)); + return Err(error(if request_closed { + Error::NotConnected + } else { + Error::Unknown { + reason: "contact labels interrupted".into(), + } + })); } if matches!(result, Ok(false) | Err(Error::Unsupported)) { return Err(CallError::Unsupported); diff --git a/rust/crates/truapi/src/runtime/authority.rs b/rust/crates/truapi/src/runtime/authority.rs index a300be8439..6c436899f6 100644 --- a/rust/crates/truapi/src/runtime/authority.rs +++ b/rust/crates/truapi/src/runtime/authority.rs @@ -445,6 +445,12 @@ pub trait ProductAuthority: Send + Sync { /// Current account-authority session, if connected. fn current_session(&self) -> Option; + /// Whether a snapshot still authorizes work for this product, or for the + /// account itself when no product is supplied. + fn session_is_current(&self, session: &AuthoritySession, _product_id: Option<&str>) -> bool { + self.current_session().as_ref() == Some(session) + } + /// Shared session holder owned by this authority. /// /// Product runtimes use it for connection-status subscriptions. The diff --git a/rust/crates/truapi/src/runtime/capabilities.rs b/rust/crates/truapi/src/runtime/capabilities.rs index c6e7dc2438..202611f47b 100644 --- a/rust/crates/truapi/src/runtime/capabilities.rs +++ b/rust/crates/truapi/src/runtime/capabilities.rs @@ -2,6 +2,7 @@ mod account; mod chain; +mod jam_peer_transport; mod expanded_card; mod game; mod payment; diff --git a/rust/crates/truapi/src/runtime/capabilities/account.rs b/rust/crates/truapi/src/runtime/capabilities/account.rs index 5821e1f9b6..15a2f9c603 100644 --- a/rust/crates/truapi/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi/src/runtime/capabilities/account.rs @@ -532,7 +532,7 @@ impl Account for ProductRuntimeHost { _cx: &CallContext, _request: HostGetUserIdRequest, ) -> Result> { - let Some(session) = self.authority.current_session() else { + let Some(mut session) = self.authority.current_session() else { return Err(CallError::Domain(HostGetUserIdError::V1( v01::HostGetUserIdError::NotConnected, ))); @@ -557,15 +557,24 @@ impl Account for ProductRuntimeHost { .await .map_err(|reason| CallError::HostFailure { reason })?; } - // Consent and chain resolution both await. Never disclose a cached - // name for an account that was disconnected or replaced meanwhile. + // Consent and chain resolution both await. Revalidate this product's + // authority and identity owner without revoking unrelated products. let session = self .authority .current_session() - .filter(|current| { - current.validation_id == session.validation_id - && current.public_key == session.public_key - && current.identity_account_id == session.identity_account_id + .and_then(|current| { + if current.public_key != session.public_key + || current.identity_account_id != session.identity_account_id + { + return None; + } + // Refresh display metadata without adopting a newer authority + // token that could hide revocation while consent was pending. + session.lite_username = current.lite_username; + session.full_username = current.full_username; + self.authority + .session_is_current(&session, Some(&self.product.product_id)) + .then_some(session) }) .ok_or(CallError::Domain(HostGetUserIdError::V1( v01::HostGetUserIdError::NotConnected, diff --git a/rust/crates/truapi/src/runtime/capabilities/jam_peer_transport.rs b/rust/crates/truapi/src/runtime/capabilities/jam_peer_transport.rs new file mode 100644 index 0000000000..f2dad45294 --- /dev/null +++ b/rust/crates/truapi/src/runtime/capabilities/jam_peer_transport.rs @@ -0,0 +1,116 @@ +//! Product-facing JAM peer transport. +//! +//! Native runtimes dial JAMNP-S QUIC through the connection's +//! [`JamPeerSession`](crate::jam_peer_transport::session::JamPeerSession), +//! gating every dial on [`ProductRuntimeHost::require_jam_peers`]. The browser +//! core keeps the trait's `NotGranted` defaults: its JavaScript session answers +//! trait 111 before frames reach the core. + +use crate::runtime::ProductRuntimeHost; + +#[cfg(target_arch = "wasm32")] +#[truapi::async_trait] +impl truapi::api::JamPeerTransport for ProductRuntimeHost {} + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use tracing::instrument; + use truapi::versioned::jam_peer_transport::{ + HostJamPeerTransportCloseError, HostJamPeerTransportCloseRequest, + HostJamPeerTransportCloseResponse, HostJamPeerTransportDialError, + HostJamPeerTransportDialRequest, HostJamPeerTransportDialResponse, + HostJamPeerTransportEventsError, HostJamPeerTransportEventsRequest, + HostJamPeerTransportEventsResponse, HostJamPeerTransportOpenError, + HostJamPeerTransportOpenRequest, HostJamPeerTransportOpenResponse, + HostJamPeerTransportRecvError, HostJamPeerTransportRecvRequest, + HostJamPeerTransportRecvResponse, HostJamPeerTransportResetError, + HostJamPeerTransportResetRequest, HostJamPeerTransportResetResponse, + HostJamPeerTransportSendError, HostJamPeerTransportSendRequest, + HostJamPeerTransportSendResponse, + }; + use truapi::{CallContext, CallError}; + + use super::ProductRuntimeHost; + + #[truapi::async_trait] + impl truapi::api::JamPeerTransport for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.dial"))] + async fn dial( + &self, + cx: &CallContext, + request: HostJamPeerTransportDialRequest, + ) -> Result> + { + let HostJamPeerTransportDialRequest::V1(inner) = &request; + let genesis = inner.genesis; + self.jam_peers + .dial( + cx, + request, + || self.require_jam_peers(genesis), + &self.services.spawner, + ) + .await + } + + #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.open"))] + async fn open( + &self, + cx: &CallContext, + request: HostJamPeerTransportOpenRequest, + ) -> Result> + { + self.jam_peers.open(cx, request).await + } + + #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.send"))] + async fn send( + &self, + _cx: &CallContext, + request: HostJamPeerTransportSendRequest, + ) -> Result> + { + self.jam_peers.send(request) + } + + #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.recv"))] + async fn recv( + &self, + _cx: &CallContext, + request: HostJamPeerTransportRecvRequest, + ) -> Result> + { + self.jam_peers.recv(request) + } + + #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.reset"))] + async fn reset( + &self, + _cx: &CallContext, + request: HostJamPeerTransportResetRequest, + ) -> Result> + { + self.jam_peers.reset(request) + } + + #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.close"))] + async fn close( + &self, + _cx: &CallContext, + request: HostJamPeerTransportCloseRequest, + ) -> Result> + { + self.jam_peers.close(request) + } + + #[instrument(skip_all, fields(runtime.method = "jam_peer_transport.events"))] + async fn events( + &self, + _cx: &CallContext, + _request: HostJamPeerTransportEventsRequest, + ) -> Result> + { + self.jam_peers.events() + } + } +} diff --git a/rust/crates/truapi/src/runtime/capabilities/platform.rs b/rust/crates/truapi/src/runtime/capabilities/platform.rs index 3dce3eb775..da00c555b3 100644 --- a/rust/crates/truapi/src/runtime/capabilities/platform.rs +++ b/rust/crates/truapi/src/runtime/capabilities/platform.rs @@ -2,6 +2,7 @@ use crate::platform::PermissionAuthorizationStatus; use futures::StreamExt; +use parity_scale_codec::{Decode, Encode}; use tracing::{instrument, warn}; use truapi::api::{LocalStorage, Locale, Notifications, Permissions, System, Theme, Worker}; use truapi::versioned::IntoLatest; @@ -20,6 +21,13 @@ use truapi::versioned::notifications::{ HostPushNotificationCancelError, HostPushNotificationCancelRequest, HostPushNotificationCancelResponse, HostPushNotificationError, HostPushNotificationRequest, HostPushNotificationResponse, + HostNotificationReceiverStatusRequest, HostNotificationReceiverStatusResponse, + HostNotificationReplaceReceiverRequest, HostNotificationReplaceReceiverResponse, + HostNotificationDisableReceiverRequest, HostNotificationDisableReceiverResponse, + HostNotificationRecordReceiptRequest, HostNotificationRecordReceiptResponse, + HostNotificationReceiverEventsRequest, HostNotificationReceiverEventsResponse, + HostNotificationAcknowledgeReceiverEventRequest, HostNotificationAcknowledgeReceiverEventResponse, + HostNotificationReceivingError, NotificationActivationAcknowledgeError, NotificationActivationAcknowledgeRequest, NotificationActivationAcknowledgeResponse, NotificationActivationEventsError, NotificationActivationEventsRequest, NotificationActivationEventsResponse, @@ -445,8 +453,43 @@ impl Locale for ProductRuntimeHost { } } -// `Notifications` delegates to the platform so hosts can own scheduling and -// cancellation while the core preserves the typed TrUAPI wire shape. +// Scheduling belongs to the platform; receiving belongs to its sole resident +// engine or explicitly forwarded external owner, never to a product lifetime. + +impl ProductRuntimeHost { + async fn forwarded_receiving( + &self, action: u8, payload: Vec, + ) -> Result, CallError> { + let Some(bytes) = self.platform.receiver_command( + self.product.product_id.clone(), action, payload, + ).await.map_err(|error| CallError::HostFailure { reason: error.reason })? else { + return Ok(None); + }; + let mut input = bytes.as_slice(); + let result = Result::::decode(&mut input) + .map_err(|_| CallError::HostFailure { reason: "invalid receiving owner response".into() })?; + if !input.is_empty() { + return Err(CallError::HostFailure { reason: "trailing receiving owner response bytes".into() }); + } + result.map(Some).map_err(|error| CallError::Domain(HostNotificationReceivingError::V1(error))) + } + + async fn receiving_execution_authority( + &self, + ) -> Result> { + let authority = self.platform.receiver_authority(&self.product.product_id).await + .map_err(|error| CallError::HostFailure { reason: error.reason })? + .ok_or(CallError::Domain(HostNotificationReceivingError::V1( + crate::latest::HostNotificationReceivingError::Unsupported, + )))?; + if authority.product_id != self.product.product_id { + return Err(CallError::Domain(HostNotificationReceivingError::V1( + crate::latest::HostNotificationReceivingError::PermissionDenied, + ))); + } + Ok(authority) + } +} #[truapi::async_trait] impl Notifications for ProductRuntimeHost { @@ -509,6 +552,128 @@ impl Notifications for ProductRuntimeHost { }) } + async fn receiver_status( + &self, + _cx: &CallContext, + _request: HostNotificationReceiverStatusRequest, + ) -> Result> { + if let Some(status) = self.forwarded_receiving(2, Vec::new()).await? { + return Ok(HostNotificationReceiverStatusResponse::V1(status)); + } + let authority = match self.receiving_execution_authority().await { + Ok(authority) => authority, + Err(CallError::Domain(HostNotificationReceivingError::V1( + crate::latest::HostNotificationReceivingError::Unsupported, + ))) => return Ok(HostNotificationReceiverStatusResponse::V1( + crate::latest::HostNotificationReceiverStatus { + supported: false, os_permission: false, consent: false, enabled: false, + revision: 0, sync_pending: false, transport_ready: false, + }, + )), + Err(error) => return Err(error), + }; + self.services.receiving.for_execution(authority).status().await + .map(HostNotificationReceiverStatusResponse::V1) + .map_err(|error| CallError::Domain(HostNotificationReceivingError::V1(error))) + } + + async fn replace_receiver( + &self, + cx: &CallContext, + request: HostNotificationReplaceReceiverRequest, + ) -> Result> { + let HostNotificationReplaceReceiverRequest::V1(request) = request; + let status = self.permissions_service() + .authorize_device(v01::HostDevicePermissionRequest::Notifications).await + .map_err(|error| CallError::Domain(HostNotificationReceivingError::V1( + crate::latest::HostNotificationReceivingError::Storage { + reason: format!("permission storage failed: {error:?}"), + }, + )))?; + if status != PermissionAuthorizationStatus::Authorized { + return Err(CallError::Domain(HostNotificationReceivingError::V1( + crate::latest::HostNotificationReceivingError::PermissionDenied, + ))); + } + if let Some(reason) = cx.cancel().reason() { + return Err(CallError::Domain(HostNotificationReceivingError::V1( + crate::latest::HostNotificationReceivingError::InvalidRequest { + reason: format!("receiving enrollment {reason}"), + }, + ))); + } + if let Some(status) = self.forwarded_receiving(3, request.encode()).await? { + return Ok(HostNotificationReplaceReceiverResponse::V1(status)); + } + let authority = self.receiving_execution_authority().await?; + self.services.receiving.for_execution(authority) + .replace(request.expected_revision, request.watches).await + .map(HostNotificationReplaceReceiverResponse::V1) + .map_err(|error| CallError::Domain(HostNotificationReceivingError::V1(error))) + } + + async fn disable_receiver( + &self, + _cx: &CallContext, + request: HostNotificationDisableReceiverRequest, + ) -> Result> { + let HostNotificationDisableReceiverRequest::V1(request) = request; + if let Some(status) = self.forwarded_receiving(4, request.encode()).await? { + return Ok(HostNotificationDisableReceiverResponse::V1(status)); + } + let authority = self.receiving_execution_authority().await?; + self.services.receiving.for_execution(authority).disable(request.expected_revision).await + .map(HostNotificationDisableReceiverResponse::V1) + .map_err(|error| CallError::Domain(HostNotificationReceivingError::V1(error))) + } + + async fn record_receipt( + &self, + _cx: &CallContext, + request: HostNotificationRecordReceiptRequest, + ) -> Result> { + let HostNotificationRecordReceiptRequest::V1(request) = request; + if let Some(outcome) = self.forwarded_receiving(5, request.encode()).await? { + return Ok(HostNotificationRecordReceiptResponse::V1(outcome)); + } + let authority = self.receiving_execution_authority().await?; + self.services.receiving.for_execution(authority).receipt( + request.revision, request.watch_id, request.event_id, request.kind, + ).await + .map(HostNotificationRecordReceiptResponse::V1) + .map_err(|error| CallError::Domain(HostNotificationReceivingError::V1(error))) + } + + async fn receiver_events( + &self, + _cx: &CallContext, + request: HostNotificationReceiverEventsRequest, + ) -> Result> { + let HostNotificationReceiverEventsRequest::V1(request) = request; + if let Some(events) = self.forwarded_receiving(6, request.encode()).await? { + return Ok(HostNotificationReceiverEventsResponse::V1(events)); + } + let authority = self.receiving_execution_authority().await?; + self.services.receiving.for_execution(authority).events(request.after_sequence).await + .map(HostNotificationReceiverEventsResponse::V1) + .map_err(|error| CallError::Domain(HostNotificationReceivingError::V1(error))) + } + + async fn acknowledge_receiver_event( + &self, + _cx: &CallContext, + request: HostNotificationAcknowledgeReceiverEventRequest, + ) -> Result> { + let HostNotificationAcknowledgeReceiverEventRequest::V1(request) = request; + if let Some(()) = self.forwarded_receiving(7, request.encode()).await? { + return Ok(HostNotificationAcknowledgeReceiverEventResponse::V1); + } + let authority = self.receiving_execution_authority().await?; + self.services.receiving.for_execution(authority).acknowledge(request.sequence).await + .map(|()| HostNotificationAcknowledgeReceiverEventResponse::V1) + .map_err(|error| CallError::Domain(HostNotificationReceivingError::V1(error))) + } + #[instrument(skip_all, fields(runtime.method = "notifications.activation_events"))] async fn activation_events( &self, diff --git a/rust/crates/truapi/src/runtime/capabilities/resources.rs b/rust/crates/truapi/src/runtime/capabilities/resources.rs index 7e453713f9..9ce7ca14f7 100644 --- a/rust/crates/truapi/src/runtime/capabilities/resources.rs +++ b/rust/crates/truapi/src/runtime/capabilities/resources.rs @@ -25,15 +25,18 @@ impl ProductRuntimeHost { session: &crate::runtime::authority::AuthoritySession, derivation_index: Option, ) -> Result<(), String> { + let product_id = self.product_id(); let require_session = || { - if self.authority.current_session().as_ref() == Some(session) { + if self + .authority + .session_is_current(session, Some(&product_id)) + { Ok(()) } else { Err("Statement allowance session changed".to_string()) } }; require_session()?; - let product_id = self.product_id(); let service = self.permissions_service(); let request = PermissionAuthorizationRequest::StatementStoreAllowance { derivation_index: derivation_index.clone(), @@ -123,7 +126,10 @@ impl ProductRuntimeHost { err.reason ) })?; - if self.authority.current_session().as_ref() != Some(session) { + if !self + .authority + .session_is_current(session, Some(&self.product_id())) + { return Err("Statement allowance session changed".to_string()); } if status != PermissionAuthorizationStatus::Authorized { @@ -151,8 +157,12 @@ impl ResourceAllocation for ProductRuntimeHost { ))); }; + let product_id = self.product_id(); let require_session = || { - if self.authority.current_session().as_ref() == Some(&session) { + if self + .authority + .session_is_current(&session, Some(&product_id)) + { Ok(()) } else { Err(CallError::HostFailure { @@ -192,12 +202,13 @@ impl ResourceAllocation for ProductRuntimeHost { // A withdrawn call stops waiting on the review and authorizes nothing. let confirmed = until_cancelled( cx, - self.platform.confirm_user_action(UserConfirmationReview::ResourceAllocation( - ResourceAllocationReview { - calling_product_id: self.product_id(), - resources: inner.resources.clone(), - }, - )), + self.platform + .confirm_user_action(UserConfirmationReview::ResourceAllocation( + ResourceAllocationReview { + calling_product_id: product_id.clone(), + resources: inner.resources.clone(), + }, + )), ) .await .map_err(|err| { @@ -262,7 +273,7 @@ impl ResourceAllocation for ProductRuntimeHost { remote_authority_call( &cx, self.authority - .allocate_resources(&cx, &session, self.product_id(), inner), + .allocate_resources(&cx, &session, product_id, inner), ) .await .map(HostRequestResourceAllocationResponse::V1) diff --git a/rust/crates/truapi/src/runtime/capabilities/signing.rs b/rust/crates/truapi/src/runtime/capabilities/signing.rs index 2ed96abe4e..536c8f4938 100644 --- a/rust/crates/truapi/src/runtime/capabilities/signing.rs +++ b/rust/crates/truapi/src/runtime/capabilities/signing.rs @@ -42,10 +42,14 @@ impl Signing for ProductRuntimeHost { v01::HostSignPayloadError::PermissionDenied, )) })?; - self.require_chain_submit(HostSignPayloadError::V1( - v01::HostSignPayloadError::PermissionDenied, - )) - .await?; + until_cancelled( + cx, + self.require_chain_submit(HostSignPayloadError::V1( + v01::HostSignPayloadError::PermissionDenied, + )), + ) + .await + .map_err(|reason| signing_call_error(HostSignPayloadError::V1, reason))??; let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostSignPayloadError::V1( v01::HostSignPayloadError::Rejected, @@ -134,10 +138,14 @@ impl Signing for ProductRuntimeHost { v01::HostCreateTransactionError::PermissionDenied, )) })?; - self.require_chain_submit(HostCreateTransactionError::V1( - v01::HostCreateTransactionError::PermissionDenied, - )) - .await?; + until_cancelled( + cx, + self.require_chain_submit(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::PermissionDenied, + )), + ) + .await + .map_err(|reason| transaction_call_error(HostCreateTransactionError::V1, reason))??; let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostCreateTransactionError::V1( v01::HostCreateTransactionError::Rejected, @@ -249,10 +257,16 @@ impl Signing for ProductRuntimeHost { }), )); } - self.require_chain_submit(HostSignPayloadWithLegacyAccountError::V1( - v01::HostSignPayloadError::PermissionDenied, - )) - .await?; + until_cancelled( + cx, + self.require_chain_submit(HostSignPayloadWithLegacyAccountError::V1( + v01::HostSignPayloadError::PermissionDenied, + )), + ) + .await + .map_err(|reason| { + signing_call_error(HostSignPayloadWithLegacyAccountError::V1, reason) + })??; let confirmed = until_cancelled( cx, self.platform @@ -343,10 +357,16 @@ impl Signing for ProductRuntimeHost { }, )) })?; - self.require_chain_submit(HostCreateTransactionWithLegacyAccountError::V1( - v01::HostCreateTransactionError::PermissionDenied, - )) - .await?; + until_cancelled( + cx, + self.require_chain_submit(HostCreateTransactionWithLegacyAccountError::V1( + v01::HostCreateTransactionError::PermissionDenied, + )), + ) + .await + .map_err(|reason| { + transaction_call_error(HostCreateTransactionWithLegacyAccountError::V1, reason) + })??; let confirmed = until_cancelled( cx, self.platform @@ -431,10 +451,14 @@ impl ProductRuntimeHost { v01::HostSignPayloadError::PermissionDenied, )) })?; - self.require_chain_submit(HostSignRawError::V1( - v01::HostSignPayloadError::PermissionDenied, - )) - .await?; + until_cancelled( + cx, + self.require_chain_submit(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied, + )), + ) + .await + .map_err(|reason| signing_call_error(HostSignRawError::V1, reason))??; let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostSignRawError::V1( v01::HostSignPayloadError::Rejected, @@ -517,10 +541,14 @@ impl ProductRuntimeHost { err.into_host_error(LEGACY_ACCOUNT_UNAVAILABLE_REASON), )) })?; - self.require_chain_submit(HostSignRawWithLegacyAccountError::V1( - v01::HostSignPayloadError::PermissionDenied, - )) - .await?; + until_cancelled( + cx, + self.require_chain_submit(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::PermissionDenied, + )), + ) + .await + .map_err(|reason| signing_call_error(HostSignRawWithLegacyAccountError::V1, reason))??; let confirmed = until_cancelled( cx, self.platform diff --git a/rust/crates/truapi/src/runtime/native_chat/background.rs b/rust/crates/truapi/src/runtime/native_chat/background.rs index 8a0ca6e501..e42b2c9284 100644 --- a/rust/crates/truapi/src/runtime/native_chat/background.rs +++ b/rust/crates/truapi/src/runtime/native_chat/background.rs @@ -26,7 +26,7 @@ const RECONCILE_INTERVAL: Duration = Duration::from_secs(5); const MAX_RETRY: Duration = Duration::from_secs(30); pub(super) struct Recovery { - session: Vec, + session_valid: Arc bool + Send + Sync>, active: Arc, abort: AbortHandle, } @@ -56,8 +56,7 @@ impl NativeChatRegistry { return; } if recoveries.get(&key).is_some_and(|recovery| { - recovery.session == context.session.validation_id - && recovery.active.load(Ordering::Acquire) + (recovery.session_valid)() && recovery.active.load(Ordering::Acquire) }) { return; } @@ -71,7 +70,7 @@ impl NativeChatRegistry { recoveries.insert( key, Recovery { - session: context.session.validation_id.clone(), + session_valid: context.session_valid.clone(), active: active.clone(), abort, }, diff --git a/rust/crates/truapi/src/runtime/notification_envelope.rs b/rust/crates/truapi/src/runtime/notification_envelope.rs new file mode 100644 index 0000000000..146bd231fe --- /dev/null +++ b/rust/crates/truapi/src/runtime/notification_envelope.rs @@ -0,0 +1,520 @@ +//! Generic authenticated notifications in a bounded standard Gordian Envelope. +//! The container grammar follows draft-mcnally-envelope-12, section 3; it does +//! not interpret application assertions. This profile permits integer-only dCBOR +//! leaves, at most 128 assertions per node, 4096 CBOR items and depth 16. + +use std::collections::BTreeMap; +use ed25519_dalek::{Signature, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use unicode_normalization::UnicodeNormalization; + +/// Maximum UTF-8 JSON header length. +pub const MAX_HEADER_BYTES: usize = 16 * 1024; +/// Maximum opaque byte-leaf witness length. +pub const MAX_CARRIER_BYTES: usize = 240 * 1024; +/// Total container bound, including unrelated application assertions. +pub const MAX_FULL_FRAME_BYTES: usize = 256 * 1024; +/// Maximum signed candidates anywhere in a container. +pub const MAX_NOTIFICATION_CANDIDATES: usize = 32; +/// Maximum signed lifetime in milliseconds. +pub const MAX_TTL_MS: u64 = 86_400_000; +/// Maximum creation time ahead of the local clock. +pub const FUTURE_SKEW_MS: u64 = 60_000; +const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +const PREDICATE: &str = "truapiNotification"; + +/// Strict version-one metadata. Hex fields are lowercase without a prefix. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct NotificationHeader { + /// Exactly one. + pub v: u64, + /// ASCII product label matching `[a-z0-9._-]{1,128}`. + pub product: String, + /// Actual chain genesis hash. + pub genesis: String, + /// Actual source channel. + pub channel: String, + /// Ordered, distinct authenticated topic subset, between one and four. + pub topics: Vec, + /// Application-generated event identifier. + pub event_id: String, + /// Creation time in epoch milliseconds. + pub created_at: u64, + /// Exclusive expiry time in epoch milliseconds. + pub expires_at: u64, + /// SHA-256 of a byte-leaf witness in the carrier, not application assertions. + pub ciphertext_digest: String, + /// Raw Ed25519 public key. + pub sender_key: String, + /// Raw Ed25519 signature of the domain-separated tuple. + pub signature: String, +} + +/// Verified metadata with proven byte membership, not an enrollment or replay decision. +#[derive(Debug)] +pub struct VerifiedNotification { + /// Authenticated public metadata. + pub header: NotificationHeader, +} + +fn canonical_hex(value: &str, bytes: usize) -> bool { + value.len() == bytes * 2 + && value.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn validate_header(header: &NotificationHeader) -> Result<(), String> { + if header.v != 1 + || header.product.is_empty() + || header.product.len() > 128 + || !header.product.bytes().all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte)) + || !canonical_hex(&header.genesis, 32) + || !canonical_hex(&header.channel, 32) + || !canonical_hex(&header.event_id, 32) + || !canonical_hex(&header.ciphertext_digest, 32) + || !canonical_hex(&header.sender_key, 32) + || !canonical_hex(&header.signature, 64) + || !(1..=4).contains(&header.topics.len()) + || header.topics.iter().enumerate().any(|(index, topic)| !canonical_hex(topic, 32) || header.topics[..index].contains(topic)) + || header.created_at > MAX_SAFE_INTEGER + || header.expires_at > MAX_SAFE_INTEGER + || header.expires_at <= header.created_at + || header.expires_at - header.created_at > MAX_TTL_MS + { + return Err("invalid notification header".into()); + } + Ok(()) +} + +fn signing_bytes(header: &NotificationHeader) -> Result, String> { + serde_json::to_vec(&( + "truapi:notification:v1", header.v, &header.product, &header.genesis, + &header.channel, &header.topics, &header.event_id, header.created_at, + header.expires_at, &header.ciphertext_digest, &header.sender_key, + )).map_err(|error| error.to_string()) +} + +#[derive(Clone, Copy, PartialEq)] +enum Kind { Leaf, Node, Assertion, Elided, Wrapped } + +struct Envelope<'a> { + kind: Kind, + digest: [u8; 32], + bytes: Option<&'a [u8]>, + text: Option<&'a str>, + header: Option<&'a str>, +} + +struct Cbor<'a> { + input: &'a [u8], + offset: usize, + items_left: usize, + headers: Vec<&'a str>, + subjects: BTreeMap<[u8; 32], &'a [u8]>, +} + +impl<'a> Cbor<'a> { + fn head(&mut self, depth: usize) -> Result<(u8, u64), String> { + if depth > 16 || self.items_left == 0 || self.offset >= self.input.len() { + return Err("invalid CBOR bounds".into()); + } + self.items_left -= 1; + let initial = self.input[self.offset]; + self.offset += 1; + let major = initial >> 5; + let additional = initial & 31; + if additional >= 28 || (major == 7 && additional > 23) { + return Err("unsupported CBOR value".into()); + } + let mut argument = u64::from(additional); + if additional >= 24 { + let size = 1usize << (additional - 24); + if size > self.input.len() - self.offset { return Err("truncated CBOR argument".into()); } + argument = 0; + for byte in &self.input[self.offset..self.offset + size] { + argument = argument * 256 + u64::from(*byte); + } + self.offset += size; + if argument > MAX_SAFE_INTEGER || argument < [24, 256, 65_536, 4_294_967_296][usize::from(additional - 24)] { + return Err("noncanonical CBOR integer".into()); + } + } + Ok((major, argument)) + } + + fn data(&mut self, length: u64) -> Result<&'a [u8], String> { + if length > (self.input.len() - self.offset) as u64 { return Err("truncated CBOR bytes".into()); } + let start = self.offset; + self.offset += length as usize; + Ok(&self.input[start..self.offset]) + } + + fn text(bytes: &'a [u8]) -> Result<&'a str, String> { + let text = std::str::from_utf8(bytes).map_err(|_| "invalid CBOR UTF-8")?; + if !text.nfc().eq(text.chars()) { return Err("noncanonical CBOR text".into()); } + Ok(text) + } + + fn skip(&mut self, depth: usize) -> Result<(), String> { + let (major, argument) = self.head(depth)?; + match major { + 0 | 1 => {}, + 2 => { self.data(argument)?; }, + 3 => { Self::text(self.data(argument)?)?; }, + 4 | 5 => { + let count = argument.checked_mul(if major == 5 { 2 } else { 1 }).ok_or("CBOR item limit")?; + if count > self.items_left as u64 { return Err("CBOR item limit".into()); } + let mut prior_key: Option<&[u8]> = None; + for index in 0..count { + let start = self.offset; + self.skip(depth + 1)?; + if major == 5 && index % 2 == 0 { + let key = &self.input[start..self.offset]; + if prior_key.is_some_and(|prior| prior >= key) { return Err("noncanonical CBOR map".into()); } + prior_key = Some(key); + } + } + }, + 6 => self.skip(depth + 1)?, + 7 if matches!(argument, 20..=22) => {}, + _ => return Err("unsupported CBOR simple value".into()), + } + Ok(()) + } + + fn envelope(&mut self, depth: usize) -> Result, String> { + let (major, argument) = self.head(depth)?; + let mut result = Envelope { kind: Kind::Leaf, digest: [0; 32], bytes: None, text: None, header: None }; + match (major, argument) { + (6, 201) => { + let start = self.offset; + // Inspect simple leaf values without building a CBOR value tree. + let saved_items = self.items_left; + let (leaf_major, length) = self.head(depth + 1)?; + match leaf_major { + 2 => result.bytes = Some(self.data(length)?), + 3 => result.text = Some(Self::text(self.data(length)?)?), + _ => { + self.offset = start; + self.items_left = saved_items; + self.skip(depth + 1)?; + }, + } + result.digest = Sha256::digest(&self.input[start..self.offset]).into(); + if let Some(bytes) = result.bytes { + let digest: [u8; 32] = Sha256::digest(bytes).into(); + if self.subjects.get(&digest).is_some_and(|prior| *prior != bytes) { + return Err("contradictory byte witness".into()); + } + self.subjects.insert(digest, bytes); + } + }, + (2, 32) => { + result.kind = Kind::Elided; + result.digest.copy_from_slice(self.data(32)?); + }, + (5, 1) => { + let predicate = self.envelope(depth + 1)?; + let object = self.envelope(depth + 1)?; + result.kind = Kind::Assertion; + let mut hash = Sha256::new(); + hash.update(predicate.digest); + hash.update(object.digest); + result.digest = hash.finalize().into(); + if predicate.text == Some(PREDICATE) { + if predicate.kind != Kind::Leaf || object.kind != Kind::Leaf || object.text.is_none() { + return Err("ambiguous notification assertion".into()); + } + result.header = object.text; + if self.headers.len() >= MAX_NOTIFICATION_CANDIDATES { return Err("notification candidate limit".into()); } + self.headers.push(object.text.ok_or("invalid notification header object")?); + } + }, + (6, 200) => { + result.kind = Kind::Wrapped; + result.digest = Sha256::digest(self.envelope(depth + 1)?.digest).into(); + }, + (4, 2..=129) => { + let subject = self.envelope(depth + 1)?; + let mut hash = Sha256::new(); + hash.update(subject.digest); + result.kind = Kind::Node; + result.text = subject.text; + let mut prior: Option<[u8; 32]> = None; + for _ in 1..argument { + let assertion = self.envelope(depth + 1)?; + if !matches!(assertion.kind, Kind::Assertion | Kind::Elided) || prior.is_some_and(|digest| digest >= assertion.digest) { + return Err("invalid Envelope assertion order or structure".into()); + } + if let Some(header) = assertion.header { + if result.header.is_some() { return Err("duplicate notification assertion".into()); } + result.header = Some(header); + } + prior = Some(assertion.digest); + hash.update(assertion.digest); + } + result.digest = hash.finalize().into(); + }, + _ => return Err("unsupported Envelope structure".into()), + } + Ok(result) + } +} + +fn extract(frame: &[u8]) -> Result, String> { + if frame.len() > MAX_FULL_FRAME_BYTES { return Err("notification container too large".into()); } + let mut reader = Cbor { input: frame, offset: 0, items_left: 4096, headers: Vec::new(), subjects: BTreeMap::new() }; + if reader.head(0)? != (6, 200) { return Err("not a Gordian Envelope".into()); } + reader.envelope(1)?; + if reader.offset != frame.len() { return Err("trailing notification container data".into()); } + Ok(reader) +} + + +/// Verify all eligible candidates with a byte-leaf membership witness in the carrier. +/// Malformed containers fail as a whole. Invalid candidate headers/proofs are omitted. +/// Callers separately enforce product authority, approved senders, mute and replay policy. +pub fn verify_frames( + frame: &[u8], actual_genesis: &str, actual_channel: &str, + actual_topics: &[String], now_ms: u64, +) -> Result, String> { + if now_ms > MAX_SAFE_INTEGER { return Err("invalid notification clock".into()); } + let candidates = extract(frame)?; + Ok(candidates.headers.iter().filter_map(|json| { + verify_candidate(json, &candidates.subjects, actual_genesis, actual_channel, actual_topics, now_ms).ok() + }).collect()) +} + +fn verify_candidate( + json: &str, subjects: &BTreeMap<[u8; 32], &[u8]>, actual_genesis: &str, actual_channel: &str, + actual_topics: &[String], now_ms: u64, +) -> Result { + if json.len() > MAX_HEADER_BYTES { return Err("notification header too large".into()); } + let header: NotificationHeader = serde_json::from_str(json).map_err(|error| format!("invalid notification JSON: {error}"))?; + validate_header(&header)?; + let mut digest = [0u8; 32]; + hex::decode_to_slice(&header.ciphertext_digest, &mut digest).map_err(|error| error.to_string())?; + let carrier = *subjects.get(&digest).ok_or("missing notification byte witness")?; + if carrier.len() > MAX_CARRIER_BYTES { return Err("notification carrier too large".into()); } + if now_ms > MAX_SAFE_INTEGER || header.created_at > now_ms.saturating_add(FUTURE_SKEW_MS) || header.expires_at <= now_ms { + return Err("notification outside validity window".into()); + } + if header.genesis != actual_genesis || header.channel != actual_channel + || !(1..=4).contains(&actual_topics.len()) + || actual_topics.iter().enumerate().any(|(index, topic)| !canonical_hex(topic, 32) || actual_topics[..index].contains(topic)) + || header.topics.iter().any(|topic| !actual_topics.contains(topic)) { + return Err("notification source mismatch".into()); + } + let mut key_bytes = [0u8; 32]; + let mut signature_bytes = [0u8; 64]; + hex::decode_to_slice(&header.sender_key, &mut key_bytes).map_err(|error| error.to_string())?; + hex::decode_to_slice(&header.signature, &mut signature_bytes).map_err(|error| error.to_string())?; + let mut field_modulus = [0xff; 32]; + field_modulus[0] = 0xed; + field_modulus[31] = 0x7f; + for compressed in [&key_bytes[..], &signature_bytes[..32]] { + let mut coordinate = [0u8; 32]; + coordinate.copy_from_slice(compressed); + coordinate[31] &= 0x7f; + if coordinate.iter().rev().cmp(field_modulus.iter().rev()) != std::cmp::Ordering::Less { return Err("noncanonical Ed25519 point".into()); } + } + let key = VerifyingKey::from_bytes(&key_bytes).map_err(|error| error.to_string())?; + key.verify_strict(&signing_bytes(&header)?, &Signature::from_bytes(&signature_bytes)).map_err(|_| "invalid notification signature".to_owned())?; + Ok(VerifiedNotification { header }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn verify_frame( + frame: &[u8], actual_genesis: &str, actual_channel: &str, + actual_topics: &[String], now_ms: u64, + ) -> Result { + let candidates = extract(frame)?; + if candidates.headers.len() != 1 { return Err("expected exactly one notification candidate".into()); } + verify_candidate(candidates.headers[0], &candidates.subjects, actual_genesis, actual_channel, actual_topics, now_ms) + } + + // Independent OpenSSL vector, raw seed 00..1f and byte witness "abc". + const VECTOR: &str = r#"{"v":1,"product":"example.paseo","genesis":"1111111111111111111111111111111111111111111111111111111111111111","channel":"5555555555555555555555555555555555555555555555555555555555555555","topics":["2222222222222222222222222222222222222222222222222222222222222222","3333333333333333333333333333333333333333333333333333333333333333"],"eventId":"4444444444444444444444444444444444444444444444444444444444444444","createdAt":1700000000000,"expiresAt":1700000060000,"ciphertextDigest":"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad","senderKey":"03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8","signature":"6bc192199982a1b8e850b66b1f0cd85035354e0b788edecddfad7505da94c7347447b5fa4c9a64647045eea6d52e4aecec14dcb16ea64de317b1b3e76b0f830b"}"#; + const SIGNED: &str = r#"["truapi:notification:v1",1,"example.paseo","1111111111111111111111111111111111111111111111111111111111111111","5555555555555555555555555555555555555555555555555555555555555555",["2222222222222222222222222222222222222222222222222222222222222222","3333333333333333333333333333333333333333333333333333333333333333"],"4444444444444444444444444444444444444444444444444444444444444444",1700000000000,1700000060000,"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad","03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"]"#; + const NOW: u64 = 1_700_000_000_000; + + struct Element { bytes: Vec, digest: [u8; 32] } + + fn head(major: u8, length: usize) -> Vec { + if length < 24 { return vec![(major << 5) | length as u8]; } + if length < 256 { return vec![(major << 5) | 24, length as u8]; } + if length < 65_536 { + let mut bytes = vec![(major << 5) | 25]; + bytes.extend_from_slice(&(length as u16).to_be_bytes()); + return bytes; + } + let mut bytes = vec![(major << 5) | 26]; + bytes.extend_from_slice(&(length as u32).to_be_bytes()); + bytes + } + + fn leaf(major: u8, value: &[u8]) -> Element { + let mut raw = head(major, value.len()); + raw.extend_from_slice(value); + let digest = Sha256::digest(&raw).into(); + let mut bytes = vec![0xd8, 201]; + bytes.extend(raw); + Element { bytes, digest } + } + + fn assertion(predicate: &str, object: Element) -> Element { + let key = leaf(3, predicate.as_bytes()); + let mut bytes = vec![0xa1]; + bytes.extend(key.bytes); + bytes.extend(object.bytes); + let mut hash = Sha256::new(); + hash.update(key.digest); + hash.update(object.digest); + Element { bytes, digest: hash.finalize().into() } + } + + fn node(subject: Element, mut assertions: Vec) -> Element { + assertions.sort_by_key(|entry| entry.digest); + let mut bytes = head(4, assertions.len() + 1); + bytes.extend(subject.bytes); + let mut hash = Sha256::new(); + hash.update(subject.digest); + for entry in assertions { + bytes.extend(entry.bytes); + hash.update(entry.digest); + } + Element { bytes, digest: hash.finalize().into() } + } + + fn tagged(element: Element) -> Vec { + let mut bytes = vec![0xd8, 200]; + bytes.extend(element.bytes); + bytes + } + + fn frame(json: &str, body: &[u8]) -> Vec { + tagged(node(leaf(2, body), vec![assertion(PREDICATE, leaf(3, json.as_bytes()))])) + } + + fn verify(bytes: &[u8]) -> Result { + verify_frame(bytes, &"11".repeat(32), &"55".repeat(32), &["22".repeat(32), "33".repeat(32)], NOW) + } + + #[test] + fn independent_cross_language_vector() { + let bytes = frame(VECTOR, b"abc"); + let verified = verify(&bytes).unwrap(); + assert_eq!(signing_bytes(&verified.header).unwrap(), SIGNED.as_bytes()); + let actual_topics = ["66".repeat(32), "33".repeat(32), "22".repeat(32)]; + assert!(verify_frame(&frame(VECTOR, b"abc"), &"11".repeat(32), &"55".repeat(32), &actual_topics, NOW).is_ok()); + } + + #[test] + fn resolves_generic_byte_membership_and_independent_siblings() { + let prior = node(leaf(3, b"unrelated subject"), vec![ + assertion("opaque-slot", leaf(2, b"abc")), + assertion(PREDICATE, leaf(3, VECTOR.as_bytes())), + ]); + let forged_json = VECTOR.replace("6bc19219", "00000000"); + let forged = node(leaf(3, b"another subject"), vec![assertion(PREDICATE, leaf(3, forged_json.as_bytes()))]); + let bytes = tagged(node(leaf(3, b"control"), vec![assertion("first", prior), assertion("second", forged)])); + let verified = verify_frames(&bytes, &"11".repeat(32), &"55".repeat(32), &["22".repeat(32), "33".repeat(32)], NOW).unwrap(); + assert_eq!(verified.len(), 1); + assert_eq!(signing_bytes(&verified[0].header).unwrap(), SIGNED.as_bytes()); + assert!(verify(&frame(VECTOR, b"wrong")).is_err()); + } + + #[test] + fn rejects_ambiguous_json_and_schema() { + for json in [ + VECTOR.replace("\"v\":1", "\"v\":1,\"v\":1"), + VECTOR.replace("\"v\":1", "\"v\":1,\"\\u0076\":1"), + VECTOR.replace("\"v\":1", "\"v\":1,\"extra\":false"), + VECTOR.replace("\"v\":1,", ""), + VECTOR.replace("\"v\":1", "\"v\":1.0"), + VECTOR.replace("\"v\":1", "\"v\":1e0"), + VECTOR.replace("\"v\":1", "\"v\":-0"), + format!("{VECTOR} trailing"), + ] { assert!(verify(&frame(&json, b"abc")).is_err(), "{json}"); } + } + + #[test] + fn rejects_invalid_metadata_proofs_and_sources() { + for (field, value) in [ + ("product", serde_json::json!("😀")), ("product", serde_json::json!("A")), + ("product", serde_json::json!("x".repeat(129))), ("product", serde_json::json!("")), + ("genesis", serde_json::json!("AA".repeat(32))), ("channel", serde_json::json!("00".repeat(32))), + ("eventId", serde_json::json!("66".repeat(32))), ("topics", serde_json::json!([])), + ("topics", serde_json::json!(vec!["22".repeat(32); 5])), + ("topics", serde_json::json!(vec!["22".repeat(32); 2])), + ("createdAt", serde_json::json!(MAX_SAFE_INTEGER + 1)), ("createdAt", serde_json::json!(-1)), + ("expiresAt", serde_json::json!(NOW)), ("expiresAt", serde_json::json!(NOW + MAX_TTL_MS + 1)), + ("signature", serde_json::json!("00".repeat(64))), ("senderKey", serde_json::json!("00".repeat(32))), + ("ciphertextDigest", serde_json::json!("00".repeat(32))), + ] { + let mut header: serde_json::Value = serde_json::from_str(VECTOR).unwrap(); + header[field] = value; + assert!(verify(&frame(&header.to_string(), b"abc")).is_err(), "{field}"); + } + let bytes = frame(VECTOR, b"abc"); + let topics = ["22".repeat(32), "33".repeat(32)]; + assert!(verify_frame(&bytes, &"00".repeat(32), &"55".repeat(32), &topics, NOW).is_err()); + assert!(verify_frame(&bytes, &"11".repeat(32), &"00".repeat(32), &topics, NOW).is_err()); + assert!(verify_frame(&bytes, &"11".repeat(32), &"55".repeat(32), &topics[..1], NOW).is_err()); + assert!(verify_frame(&bytes, &"11".repeat(32), &"55".repeat(32), &topics, NOW + 60_000).is_err()); + assert!(verify_frame(&bytes, &"11".repeat(32), &"55".repeat(32), &topics, NOW - FUTURE_SKEW_MS - 1).is_err()); + } + + #[test] + fn rejects_container_ambiguities_and_resource_exhaustion() { + assert!(verify(&[]).is_err()); + assert!(verify(&vec![0; MAX_FULL_FRAME_BYTES + 1]).is_err()); + assert!(verify(&frame(&" ".repeat(MAX_HEADER_BYTES + 1), b"abc")).is_err()); + assert!(verify(&frame(VECTOR, &vec![0; MAX_CARRIER_BYTES + 1])).is_err()); + let mut trailing = frame(VECTOR, b"abc"); trailing.push(0); + assert!(verify(&trailing).is_err()); + let canonical = frame(VECTOR, b"abc"); + let mut noncanonical = vec![0xd9, 0, 200]; noncanonical.extend_from_slice(&canonical[2..]); + assert!(verify(&noncanonical).is_err()); + let duplicate = tagged(node(leaf(2, b"abc"), vec![ + assertion(PREDICATE, leaf(3, VECTOR.as_bytes())), assertion(PREDICATE, leaf(3, b"different")), + ])); + assert!(verify(&duplicate).is_err()); + let candidates = (0..33).map(|index| assertion(&format!("entry-{index}"), node(leaf(3, b"subject"), vec![assertion(PREDICATE, leaf(3, VECTOR.as_bytes()))]))).collect(); + assert!(verify(&tagged(node(leaf(2, b"abc"), candidates))).is_err()); + let mut nested = leaf(2, b"abc"); + for _ in 0..20 { nested = node(leaf(3, b"subject"), vec![assertion("nested", nested)]); } + assert!(verify(&tagged(nested)).is_err()); + } +} + +#[cfg(test)] +mod strict_equation_tests { + use super::*; + + #[test] + fn rejects_cofactor_only_proof_but_accepts_exact_mixed_key_proof() { + // Independent scalar a=r=1 fixtures with order-two T=(0,-1). + let mut header = NotificationHeader { + v: 1, product: "example.paseo".into(), genesis: "11".repeat(32), + channel: "55".repeat(32), topics: vec!["22".repeat(32), "33".repeat(32)], + event_id: "44".repeat(32), created_at: 1_700_000_000_000, expires_at: 1_700_000_060_000, + ciphertext_digest: "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad".into(), + sender_key: "5866666666666666666666666666666666666666666666666666666666666666".into(), + signature: "95999999999999999999999999999999999999999999999999999999999999994d0d311b42ae0fbd5231e2b7e106d734ca5e5045ba0882ac54c3f232e5718300".into(), + }; + let mut subjects = BTreeMap::new(); + subjects.insert(Sha256::digest(b"abc").into(), &b"abc"[..]); + assert!(verify_candidate(&serde_json::to_string(&header).unwrap(), &subjects, + &header.genesis, &header.channel, &header.topics, header.created_at).is_err()); + header.event_id = format!("{}04", "00".repeat(31)); + header.sender_key = "9599999999999999999999999999999999999999999999999999999999999999".into(); + header.signature = "58666666666666666666666666666666666666666666666666666666666666663114ba2ce5c96de9e15fbc99e889f60672480566a4420d0d7806399239ed5a06".into(); + assert!(verify_candidate(&serde_json::to_string(&header).unwrap(), &subjects, + &header.genesis, &header.channel, &header.topics, header.created_at).is_ok()); + } +} diff --git a/rust/crates/truapi/src/runtime/profile/avatars.rs b/rust/crates/truapi/src/runtime/profile/avatars.rs index 5a6d71c292..218d7ea9b8 100644 --- a/rust/crates/truapi/src/runtime/profile/avatars.rs +++ b/rust/crates/truapi/src/runtime/profile/avatars.rs @@ -360,7 +360,7 @@ impl ContactAvatarPlacement { ) .await .unwrap_or_default(); - if authority.current_session().as_ref() == Some(&session) { + if authority.session_is_current(&session, None) { resolved } else { Vec::new() diff --git a/rust/crates/truapi/src/runtime/receiving.rs b/rust/crates/truapi/src/runtime/receiving.rs new file mode 100644 index 0000000000..ac817efb32 --- /dev/null +++ b/rust/crates/truapi/src/runtime/receiving.rs @@ -0,0 +1,805 @@ +//! Host-owned receiving policy. One service is the sole writer for one CoreStorage +//! namespace; browser window/worker adapters must route to the same owner, not +//! independently perform read/modify/write against the slot. No product lifetime +//! owns this service, and no transport request is awaited by product operations. + +use std::collections::HashSet; +use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; +use futures::lock::Mutex; +use serde::{Deserialize, Serialize}; +use crate::latest::{HostNotificationReceiverStatus, HostNotificationReceiptResult, HostNotificationReceivingError as Error, + ReceivingEvent, ReceivingEventKind, ReceivingReceiptKind, ReceivingWatch}; +use crate::platform::{CoreStorageKey, Platform, ProductContext, ReceivingAuthority, ReceivingRegistration}; +use crate::subscription::Spawner; +use super::notification_envelope::verify_frames; + +const MAX_REGISTRATIONS: usize = 32; +const MAX_WATCHES: usize = 256; +const MAX_SENDERS: usize = 1_000; +const MAX_TOTAL_SENDERS: usize = 10_000; +const MAX_RECEIPTS: usize = 2_048; +const MAX_EVENTS: usize = 128; +const MAX_STATE_BYTES: usize = 32 * 1024 * 1024; +const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +const DAY: u64 = 86_400_000; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Ledger { + version: u8, + revision: u64, + sequence: u64, + records: Vec, +} +impl Default for Ledger { + fn default() -> Self { Self { version: 1, revision: 0, sequence: 0, records: Vec::new() } } +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Record { + authority: ReceivingAuthority, + revision: u64, + consent: bool, + enabled: bool, + sync_pending: bool, + watches: Vec, + receipts: Vec, + events: Vec, + display_attempts: Vec, +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Watch { + policy: ReceivingWatch, + not_before: u64, +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Receipt { + event_id: String, + watch_id: String, + expires_at: u64, + displayed: bool, + read: bool, + activated: bool, + reserved: bool, + foreground_seen: bool, +} + +/// Minimal trusted backend for a receiver-only browser worker. No wallet, +/// product execution or chain provider is required to validate incoming frames. +#[crate::platform::async_trait] +pub trait ReceivingBackend: Send + Sync { + /// Resolve the host's current verified scope; absence means unsupported. + async fn receiver_authority(&self, product: &str) -> Result, crate::latest::GenericError>; + /// Ask trusted UI for durable receiving consent over the disclosed policy. + async fn receiver_consent(&self, authority: ReceivingAuthority, watches: Vec) -> Result; + /// Wake an independent transport synchronizer; never perform network here. + async fn receiver_changed(&self) -> Result<(), crate::latest::GenericError>; + /// Read the opaque host-private receiving ledger. + async fn load(&self) -> Result>, crate::latest::GenericError>; + /// Atomically durably replace that ledger. Single writer is REQUIRED. + async fn save(&self, bytes: Vec) -> Result<(), crate::latest::GenericError>; +} + +struct PlatformBackend(Arc); + +#[crate::platform::async_trait] +impl ReceivingBackend for PlatformBackend { + async fn receiver_authority(&self, product: &str) -> Result, crate::latest::GenericError> { + self.0.receiver_authority(product).await + } + async fn receiver_consent(&self, authority: ReceivingAuthority, watches: Vec) -> Result { + self.0.receiver_consent(authority, watches).await + } + async fn receiver_changed(&self) -> Result<(), crate::latest::GenericError> { + self.0.receiver_changed().await + } + async fn load(&self) -> Result>, crate::latest::GenericError> { + self.0.read_core_storage(CoreStorageKey::NotificationReceiving).await + } + async fn save(&self, bytes: Vec) -> Result<(), crate::latest::GenericError> { + self.0.write_core_storage(CoreStorageKey::NotificationReceiving, bytes).await + } +} + +#[derive(Default)] +struct WakeState { + running: AtomicBool, + requested: AtomicBool, +} + +/// Resident host service. Reconstructing it reads the durable ledger on demand. +/// Authority callbacks must read local trusted state, including verified artifact +/// provenance. They must not use a product-supplied identity or network lookup. +pub struct ReceivingService { + platform: Arc, + gate: Mutex<()>, + spawner: Spawner, + wake_pending: Arc, +} +impl ReceivingService { + pub fn new(platform: Arc, spawner: Spawner) -> Self { + Self::from_backend(Arc::new(PlatformBackend(platform)), spawner) + } + + /// Construct a wallet-free receiver on a minimal trusted storage/authority backend. + pub fn from_backend(platform: Arc, spawner: Spawner) -> Self { + Self { platform, gate: Mutex::new(()), spawner, wake_pending: Arc::new(WakeState::default()) } + } + + /// Bind a call to immutable, host-verified execution provenance. The snapshot + /// is supplied only by the trusted connection adapter, never by product wire. + pub fn for_execution(&self, authority: ReceivingAuthority) -> ReceivingExecution<'_> { + ReceivingExecution { service: self, authority } + } + + async fn authority_for(&self, product: &str, expected: Option<&ReceivingAuthority>) -> Result { + let current = self.authority(product).await?; + if expected.is_some_and(|expected| !same_authority(¤t, expected)) { + return Err(Error::PermissionDenied); + } + Ok(current) + } + + async fn authority(&self, product: &str) -> Result { + ProductContext::new(product.to_owned()).map_err(|_| invalid("invalid product"))?; + let authority = self.platform.receiver_authority(product).await.map_err(storage)? + .ok_or(Error::Unsupported)?; + if authority.product_id != product || !hex32(&authority.account) || !hex32(&authority.artifact) + || !hex32(&authority.genesis) || authority.environment.is_empty() + || authority.environment.len() > 128 || authority.generation > MAX_SAFE_INTEGER { + return Err(Error::PermissionDenied); + } + Ok(authority) + } + + async fn load(&self) -> Result { + let Some(bytes) = self.platform.load() + .await.map_err(storage)? else { return Ok(Ledger::default()); }; + if bytes.len() > MAX_STATE_BYTES { return Err(invalid("receiving ledger exceeds budget")); } + let ledger: Ledger = serde_json::from_slice(&bytes).map_err(|_| invalid("invalid receiving ledger"))?; + if ledger.version != 1 || ledger.records.len() > MAX_REGISTRATIONS + || ledger.records.iter().any(|r| r.watches.len() > MAX_WATCHES || r.receipts.len() > MAX_RECEIPTS + || r.events.len() > MAX_EVENTS || r.display_attempts.len() > 60) + || ledger.revision > MAX_SAFE_INTEGER || ledger.sequence > MAX_SAFE_INTEGER { + return Err(invalid("unsupported receiving ledger")); + } + Ok(ledger) + } + + async fn save(&self, ledger: &Ledger) -> Result<(), Error> { + let bytes = serde_json::to_vec(ledger).map_err(|_| invalid("cannot encode receiving ledger"))?; + if bytes.len() > MAX_STATE_BYTES { return Err(Error::Capacity); } + self.platform.save(bytes).await.map_err(storage) + } + + fn wake(&self) { + self.wake_pending.requested.store(true, Ordering::Release); + if self.wake_pending.running.swap(true, Ordering::AcqRel) { return; } + let platform = self.platform.clone(); + let pending = self.wake_pending.clone(); + (self.spawner)(Box::pin(async move { + loop { + pending.requested.store(false, Ordering::Release); + // A hint, not a transport ACK. Failure leaves durable pending work. + let _ = platform.receiver_changed().await; + pending.running.store(false, Ordering::Release); + // A mutation during the hint must not lose the newer revision. + if !pending.requested.load(Ordering::Acquire) + || pending.running.swap(true, Ordering::AcqRel) { break; } + } + })); + } + + pub async fn status(&self, product: &str) -> Result { + self.status_scoped(product, None).await + } + + async fn status_scoped(&self, product: &str, expected: Option<&ReceivingAuthority>) -> Result { + let _guard = self.gate.lock().await; + let authority = match self.authority_for(product, expected).await { + Ok(value) => value, + Err(Error::Unsupported) => return Ok(HostNotificationReceiverStatus { + supported: false, os_permission: false, consent: false, enabled: false, + revision: 0, sync_pending: false, transport_ready: false, + }), + Err(error) => return Err(error), + }; + let ledger = self.load().await?; + Ok(status(&authority, find(&ledger, &authority))) + } + + /// Local CAS commit; explicit durable receiving consent is not an OS grant. + pub async fn replace(&self, product: &str, expected_revision: u64, watches: Vec) + -> Result { + self.replace_scoped(product, expected_revision, watches, None).await + } + + async fn replace_scoped(&self, product: &str, expected_revision: u64, watches: Vec, + expected: Option<&ReceivingAuthority>) -> Result { + let authority = self.authority_for(product, expected).await?; + if !authority.os_permission { return Err(Error::PermissionDenied); } + validate_watches(&watches, &authority, now())?; + let (fence, needs_consent) = { + let _guard = self.gate.lock().await; + let ledger = self.load().await?; + let record = find(&ledger, &authority); + check_revision(record, expected_revision)?; + (ledger.revision, !record.is_some_and(|r| current(r, &authority) && r.consent + && watches.iter().all(|w| r.watches.iter().any(|old| within_consent(w, &old.policy))))) + }; + if needs_consent && !self.platform.receiver_consent(authority.clone(), watches.clone()) + .await.map_err(storage)? { return Err(Error::PermissionDenied); } + let _guard = self.gate.lock().await; + let fresh = self.authority_for(product, expected).await?; + if !same_authority(&fresh, &authority) || !fresh.os_permission { return Err(Error::PermissionDenied); } + let mut ledger = self.load().await?; + // Revocation, account replacement or a concurrent first enrollment during + // a prompt fences the result, including when no record existed yet. + if ledger.revision != fence { return Err(Error::Conflict); } + check_revision(find(&ledger, &authority), expected_revision)?; + let timestamp = now(); + validate_watches(&watches, &authority, timestamp)?; + // Startup may republish the same policy before a pending click is delivered. + // Only a real policy change should fence that click or restart relay sync. + if let Some(record) = find(&ledger, &authority) + && current(record, &authority) && record.consent && record.enabled == !watches.is_empty() + && record.watches.iter().map(|watch| &watch.policy).eq(watches.iter()) { + return Ok(status(&fresh, Some(record))); + } + let position = ledger.records.iter().position(|r| same_scope(&r.authority, &authority)); + if position.is_none() && ledger.records.len() >= MAX_REGISTRATIONS { return Err(Error::Capacity); } + // A product's previous account/artifact cannot keep receiving accidentally. + for index in 0..ledger.records.len() { + if ledger.records[index].authority.product_id == product + && !same_scope(&ledger.records[index].authority, &authority) { + let retired_revision = next_revision(&mut ledger)?; + disable_record(&mut ledger.records[index], retired_revision); + } + } + // Each outbox entry has its own ACK token, including retired scopes. + let revision = next_revision(&mut ledger)?; + let position = position.unwrap_or_else(|| { + ledger.records.push(Record { authority: authority.clone(), revision: 0, consent: false, + enabled: false, sync_pending: false, watches: Vec::new(), receipts: Vec::new(), events: Vec::new(), + display_attempts: Vec::new() }); + ledger.records.len() - 1 + }); + let record = &mut ledger.records[position]; + let policies = watches.into_iter().map(|policy| { + let old = record.watches.iter().find(|w| w.policy.id == policy.id); + let not_before = old.filter(|w| current(record, &authority) + && w.policy.genesis == policy.genesis && w.policy.channel == policy.channel && w.policy.topics == policy.topics + && w.policy.senders == policy.senders && w.policy.muted_until == policy.muted_until) + .map_or(timestamp, |w| w.not_before); + Watch { policy, not_before } + }).collect(); + record.authority = fresh; + record.watches = policies; + record.revision = revision; + record.consent = true; + record.enabled = !record.watches.is_empty(); + record.sync_pending = true; + // Old click handles are never reinterpreted under a new policy revision. + record.events.clear(); + prune(record, timestamp); + let result = status(&record.authority, Some(record)); + self.save(&ledger).await?; + self.wake(); + Ok(result) + } + + pub async fn disable(&self, product: &str, expected_revision: u64) + -> Result { + self.disable_scoped(product, expected_revision, None).await + } + + async fn disable_scoped(&self, product: &str, expected_revision: u64, + expected: Option<&ReceivingAuthority>) -> Result { + let _guard = self.gate.lock().await; + let authority = self.authority_for(product, expected).await?; + let mut ledger = self.load().await?; + check_revision(find(&ledger, &authority), expected_revision)?; + let revision = next_revision(&mut ledger)?; + for record in &mut ledger.records { + if same_scope(&record.authority, &authority) { disable_record(record, revision); } + } + let result = status(&authority, find(&ledger, &authority)); + self.save(&ledger).await?; + self.wake(); + Ok(result) + } + + /// Trusted logout/deletion path, independent of active account availability. + /// Invoke before deleting account data. A failed durable write is an error. + pub async fn revoke(&self, product: &str) -> Result<(), Error> { + let _guard = self.gate.lock().await; + let mut ledger = self.load().await?; + next_revision(&mut ledger)?; // Fence even a first enrollment still in consent. + for index in 0..ledger.records.len() { + if ledger.records[index].authority.product_id == product { + let revision = next_revision(&mut ledger)?; + disable_record(&mut ledger.records[index], revision); + } + } + self.save(&ledger).await?; + self.wake(); + Ok(()) + } + + /// Explicit host logout revokes locally without contacting any transport. + /// Ordinary runtime disposal must not call this method. + pub async fn revoke_all(&self) -> Result<(), Error> { + let _guard = self.gate.lock().await; + let mut ledger = self.load().await?; + next_revision(&mut ledger)?; + for index in 0..ledger.records.len() { + let revision = next_revision(&mut ledger)?; + disable_record(&mut ledger.records[index], revision); + } + self.save(&ledger).await?; + self.wake(); + Ok(()) + } + + /// Provider token rotation invalidates old synchronization results and handles. + pub async fn mark_transport_changed(&self, product: &str) -> Result<(), Error> { + let _guard = self.gate.lock().await; + let mut ledger = self.load().await?; + next_revision(&mut ledger)?; + for index in 0..ledger.records.len() { + if ledger.records[index].authority.product_id == product { + let revision = next_revision(&mut ledger)?; + let record = &mut ledger.records[index]; + record.revision = revision; + record.sync_pending = true; + // Rotation is not a new watch policy: preserve accepted events + // and replay receipts, but fence stale transport acknowledgements. + for event in &mut record.events { event.revision = revision; } + } + } + self.save(&ledger).await?; + self.wake(); + Ok(()) + } + + /// Local outbox snapshot. Transport must strip routes and use opaque provider + /// handles. Never send the artifact/account to a provider as notification text. + pub async fn pending(&self) -> Result, Error> { + let _guard = self.gate.lock().await; + let mut ledger = self.load().await?; + let mut changed = false; + let mut available = [true; MAX_REGISTRATIONS]; + for index in 0..ledger.records.len() { + if !ledger.records[index].enabled { continue; } + let product = &ledger.records[index].authority.product_id; + let live = self.authority(product).await; + if live.is_err() { + available[index] = false; + continue; + } + // A temporarily unavailable/locked authority pauses local handling; + // only a positively resolved changed scope invalidates the grant. + if live.as_ref().is_ok_and(|a| !current(&ledger.records[index], a) || !a.os_permission) { + let revision = next_revision(&mut ledger)?; + disable_record(&mut ledger.records[index], revision); + changed = true; + } + } + if changed { self.save(&ledger).await?; } + Ok(ledger.records.iter().enumerate().filter(|(index, _)| available[*index]).map(|(_, r)| ReceivingRegistration { + authority: r.authority.clone(), revision: r.revision, enabled: r.enabled, + watches: r.watches.iter().map(|w| w.policy.clone()).collect(), sync_pending: r.sync_pending, + }).collect()) + } + + pub async fn synchronized(&self, product: &str, revision: u64) -> Result { + let _guard = self.gate.lock().await; + let mut ledger = self.load().await?; + let mut matched = false; + for record in &mut ledger.records { + if record.authority.product_id == product && record.revision == revision && record.sync_pending { + record.sync_pending = false; + matched = true; + } + } + if matched { self.save(&ledger).await?; } + Ok(matched) + } + + pub async fn receipt(&self, product: &str, revision: u64, watch_id: String, event_id: String, + kind: ReceivingReceiptKind) -> Result { + self.receipt_scoped(product, revision, watch_id, event_id, kind, None).await + } + + async fn receipt_scoped(&self, product: &str, revision: u64, watch_id: String, event_id: String, + kind: ReceivingReceiptKind, expected: Option<&ReceivingAuthority>) -> Result { + if !hex32(&event_id) { return Err(invalid("invalid receipt event id")); } + let _guard = self.gate.lock().await; + let authority = self.authority_for(product, expected).await?; + let mut ledger = self.load().await?; + let record = find_mut(&mut ledger, &authority).ok_or(Error::Conflict)?; + require_record(record, &authority, revision)?; + let timestamp = now(); + if !record.watches.iter().any(|w| w.policy.id == watch_id && w.policy.expires_at > timestamp) { + return Err(invalid("unknown receipt watch")); + } + prune(record, timestamp); + let receipt = if let Some(index) = record.receipts.iter().position(|r| r.event_id == event_id) { + if record.receipts[index].watch_id != watch_id { return Err(invalid("receipt watch mismatch")); } + &mut record.receipts[index] + } else { + if record.receipts.len() >= MAX_RECEIPTS { return Err(Error::Capacity); } + record.receipts.push(Receipt { event_id: event_id.clone(), watch_id, expires_at: timestamp + DAY, + displayed: false, read: false, activated: false, reserved: false, foreground_seen: false }); + record.receipts.last_mut().expect("just inserted") + }; + match kind { + ReceivingReceiptKind::Foreground => receipt.foreground_seen = true, + ReceivingReceiptKind::Read => receipt.read = true, + ReceivingReceiptKind::Displayed => { + receipt.displayed = true; + receipt.reserved = false; + } + } + let result = HostNotificationReceiptResult { + displayed: receipt.displayed, + display_pending: receipt.reserved && !receipt.displayed, + }; + // Message catch-up can race the click that opened the product. Only an + // explicit event acknowledgement consumes that user's queued navigation. + record.events.retain(|event| event.event_id != event_id || event.kind == ReceivingEventKind::Activation); + self.save(&ledger).await?; + Ok(result) + } + + /// Release a local display reservation only when the platform knows its + /// presentation failed. Unknown/crashed outcomes stay pending until expiry: + /// neither a restart nor a timer is evidence that no OS alert was shown. + pub async fn cancel_display(&self, product: &str, revision: u64, event_id: String) -> Result<(), Error> { + let _guard = self.gate.lock().await; + let authority = self.authority(product).await?; + let mut ledger = self.load().await?; + let record = find_mut(&mut ledger, &authority).ok_or(Error::Conflict)?; + require_record(record, &authority, revision)?; + let receipt = record.receipts.iter_mut().find(|r| r.event_id == event_id && r.expires_at > now()) + .ok_or_else(|| invalid("unknown display handle"))?; + if receipt.displayed { return Ok(()); } + receipt.reserved = false; + self.save(&ledger).await + } + + /// Final local display gate after the host's foreground grace period. A + /// durable reservation prevents two callbacks from authorizing the same OS + /// alert. A crash after reservation may lose an alert, never duplicate it. + pub async fn prepare_display(&self, product: &str, revision: u64, event_id: String) + -> Result, Error> { + let _guard = self.gate.lock().await; + let authority = self.authority(product).await?; + let mut ledger = self.load().await?; + let record = find_mut(&mut ledger, &authority).ok_or(Error::Conflict)?; + require_record(record, &authority, revision)?; + if !authority.os_permission { return Err(Error::PermissionDenied); } + let timestamp = now(); + prune(record, timestamp); + let Some(event) = record.events.iter().find(|e| e.event_id == event_id + && e.revision == revision && e.kind == ReceivingEventKind::Delivery).cloned() + else { return Ok(None); }; + let Some(receipt) = record.receipts.iter_mut().find(|r| r.event_id == event_id) + else { return Ok(None); }; + if receipt.read || receipt.foreground_seen || receipt.displayed || receipt.reserved { return Ok(None); } + if !record.watches.iter().any(|w| w.policy.id == event.watch_id + && w.policy.expires_at > timestamp && w.policy.muted_until <= timestamp) { return Ok(None); } + if record.display_attempts.len() >= 60 { return Ok(None); } + receipt.reserved = true; + record.display_attempts.push(timestamp); + self.save(&ledger).await?; + Ok(Some(event)) + } + + /// Decode the canonical Statement Store shape, not an application codec. + /// The source genesis is supplied by the host's selected chain connection. + pub async fn ingest_statement(&self, product: &str, revision: u64, watch_id: String, + actual_genesis: String, statement: Vec) -> Result, Error> { + if statement.len() > 256 * 1024 { return Err(Error::Capacity); } + let statement = crate::host_logic::statement_store::decode_signed_statement(&statement) + .map_err(|_| invalid("invalid Statement Store statement"))?; + let expiry = statement.expiry.ok_or_else(|| invalid("statement has no expiry"))?; + if crate::host_logic::statement_store::statement_expiry_elapsed(expiry, now() / 1000) { + return Ok(Vec::new()); + } + let topics = statement.topics.iter().map(hex::encode).collect(); + let channel = hex::encode(statement.channel.ok_or_else(|| invalid("statement has no channel"))?); + let frame = statement.data.ok_or_else(|| invalid("statement has no frame"))?; + self.ingest(product, revision, watch_id, actual_genesis, channel, topics, frame).await + } + + /// Called only after the host actually displays an accepted delivery. + pub async fn confirm_display(&self, product: &str, revision: u64, event_id: String) -> Result<(), Error> { + let _guard = self.gate.lock().await; + let authority = self.authority(product).await?; + let mut ledger = self.load().await?; + let record = find_mut(&mut ledger, &authority).ok_or(Error::Conflict)?; + require_record(record, &authority, revision)?; + let receipt = record.receipts.iter_mut().find(|r| r.event_id == event_id && r.expires_at > now()) + .ok_or_else(|| invalid("unknown display handle"))?; + // APNs may already have rendered an advisory alert before our callback. + // This is a trusted report of actual display, never display permission. + if !receipt.reserved && !receipt.displayed && record.display_attempts.len() < 60 { + record.display_attempts.push(now()); + } + receipt.displayed = true; + receipt.reserved = false; + self.save(&ledger).await + } + + /// Validate actual source and the complete authenticated frame before minting + /// a local handle. Provider payload IDs alone must never call this path. + #[allow( + clippy::too_many_arguments, + reason = "Keep observed source fields explicit and preserve the host receiving API" + )] + pub async fn ingest(&self, product: &str, revision: u64, watch_id: String, + actual_genesis: String, actual_channel: String, actual_topics: Vec, frame: Vec) + -> Result, Error> { + let _guard = self.gate.lock().await; + let authority = self.authority(product).await?; + let mut ledger = self.load().await?; + let record = find_mut(&mut ledger, &authority).ok_or(Error::Conflict)?; + require_record(record, &authority, revision)?; + if !authority.os_permission { return Err(Error::PermissionDenied); } + let timestamp = now(); + prune(record, timestamp); + let watch = record.watches.iter().find(|w| w.policy.id == watch_id) + .ok_or_else(|| invalid("unknown delivery watch"))?; + if watch.policy.expires_at <= timestamp || watch.policy.muted_until > timestamp { return Ok(Vec::new()); } + if watch.policy.genesis != actual_genesis || watch.policy.channel != actual_channel + || !watch.policy.topics.iter().all(|topic| actual_topics.contains(topic)) { + return Err(invalid("delivery source mismatch")); + } + let verified = verify_frames(&frame, &actual_genesis, &actual_channel, &actual_topics, timestamp) + .map_err(|reason| Error::InvalidRequest { reason })?; + let mut accepted = Vec::new(); + for candidate in verified { + let record = find_mut(&mut ledger, &authority).expect("record retained"); + let watch = record.watches.iter().find(|w| w.policy.id == watch_id).expect("watch retained"); + let header = candidate.header; + if header.product != product || !watch.policy.senders.contains(&header.sender_key) + || !watch.policy.topics.iter().all(|topic| header.topics.contains(topic)) + || header.created_at < watch.not_before || header.created_at < watch.policy.muted_until + || record.receipts.iter().any(|r| r.event_id == header.event_id) { continue; } + if record.receipts.len() >= MAX_RECEIPTS || record.events.len() >= MAX_EVENTS + || record.receipts.iter().filter(|r| !r.displayed && !r.foreground_seen && !r.read && !r.reserved).count() >= 4 { + // Never evict a live replay entry to admit new traffic. Commit + // the bounded accepted prefix, leaving excess candidates fresh. + break; + } + let route = watch.policy.route.clone(); + let expires_at = header.expires_at.min(watch.policy.expires_at); + record.receipts.push(Receipt { event_id: header.event_id.clone(), watch_id: watch_id.clone(), + expires_at: header.expires_at, displayed: false, read: false, activated: false, reserved: false, foreground_seen: false }); + let sequence = next_sequence(&mut ledger)?; + let event = ReceivingEvent { sequence, revision, watch_id: watch_id.clone(), event_id: header.event_id, + kind: ReceivingEventKind::Delivery, route, expires_at }; + find_mut(&mut ledger, &authority).expect("record retained").events.push(event.clone()); + accepted.push(event); + } + if !accepted.is_empty() { + let fresh = self.authority(product).await?; + if !same_authority(&fresh, &authority) || !fresh.os_permission { return Err(Error::PermissionDenied); } + self.save(&ledger).await?; + } + Ok(accepted) + } + + /// Preview a current click handle before opening the verified product. + /// Sequence zero is a preview, not an event; call activate after readiness. + pub async fn validate_activation(&self, product: &str, revision: u64, event_id: String) + -> Result, Error> { + let _guard = self.gate.lock().await; + let authority = self.authority(product).await?; + let ledger = self.load().await?; + let record = find(&ledger, &authority).ok_or(Error::Conflict)?; + require_record(record, &authority, revision)?; + let timestamp = now(); + let Some(receipt) = record.receipts.iter().find(|r| r.event_id == event_id + && r.expires_at > timestamp && !r.read && !r.activated) else { return Ok(None); }; + let Some(watch) = record.watches.iter().find(|w| w.policy.id == receipt.watch_id + && w.policy.expires_at > timestamp && w.policy.muted_until <= timestamp) else { return Ok(None); }; + Ok(Some(ReceivingEvent { sequence: 0, revision, watch_id: receipt.watch_id.clone(), + event_id, kind: ReceivingEventKind::Activation, route: watch.policy.route.clone(), + expires_at: receipt.expires_at.min(watch.policy.expires_at) })) + } + + /// User activation resolves only a durable accepted local handle. The shell + /// may focus/open its verified product; it must never auto-switch accounts. + pub async fn activate(&self, product: &str, revision: u64, event_id: String) + -> Result, Error> { + let _guard = self.gate.lock().await; + let authority = self.authority(product).await?; + let mut ledger = self.load().await?; + let record = find_mut(&mut ledger, &authority).ok_or(Error::Conflict)?; + require_record(record, &authority, revision)?; + let timestamp = now(); + prune(record, timestamp); + let receipt = record.receipts.iter_mut().find(|r| r.event_id == event_id) + .ok_or_else(|| invalid("unknown activation handle"))?; + if receipt.read || receipt.activated { return Ok(None); } + let watch = record.watches.iter().find(|w| w.policy.id == receipt.watch_id + && w.policy.expires_at > timestamp && w.policy.muted_until <= timestamp) + .ok_or(Error::PermissionDenied)?; + if record.events.len() >= MAX_EVENTS { return Err(Error::Capacity); } + let watch_id = receipt.watch_id.clone(); + let route = watch.policy.route.clone(); + let expires_at = receipt.expires_at.min(watch.policy.expires_at); + receipt.activated = true; + let sequence = next_sequence(&mut ledger)?; + let event = ReceivingEvent { sequence, revision, watch_id, event_id, + kind: ReceivingEventKind::Activation, route, expires_at }; + find_mut(&mut ledger, &authority).expect("record retained").events.push(event.clone()); + self.save(&ledger).await?; + Ok(Some(event)) + } + + pub async fn events(&self, product: &str, after_sequence: u64) -> Result, Error> { + self.events_scoped(product, after_sequence, None).await + } + + async fn events_scoped(&self, product: &str, after_sequence: u64, expected: Option<&ReceivingAuthority>) -> Result, Error> { + let _guard = self.gate.lock().await; + let authority = self.authority_for(product, expected).await?; + let ledger = self.load().await?; + let Some(record) = find(&ledger, &authority) else { return Ok(Vec::new()); }; + if !current(record, &authority) || !record.enabled || !record.consent { return Ok(Vec::new()); } + let timestamp = now(); + Ok(record.events.iter().filter(|e| e.sequence > after_sequence && e.expires_at > timestamp + && e.revision == record.revision).cloned().collect()) + } + + /// Acknowledge exactly one event; unrelated delivery/activation stays queued. + pub async fn acknowledge(&self, product: &str, sequence: u64) -> Result<(), Error> { + self.acknowledge_scoped(product, sequence, None).await + } + + async fn acknowledge_scoped(&self, product: &str, sequence: u64, expected: Option<&ReceivingAuthority>) -> Result<(), Error> { + let _guard = self.gate.lock().await; + let authority = self.authority_for(product, expected).await?; + let mut ledger = self.load().await?; + let record = find_mut(&mut ledger, &authority).ok_or(Error::Conflict)?; + if !current(record, &authority) { return Err(Error::PermissionDenied); } + record.events.retain(|event| event.sequence != sequence); + self.save(&ledger).await + } +} + +fn now() -> u64 { + #[cfg(not(target_arch = "wasm32"))] + use std::time::{SystemTime, UNIX_EPOCH}; + #[cfg(target_arch = "wasm32")] + use web_time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64 +} +fn invalid(reason: &str) -> Error { Error::InvalidRequest { reason: reason.to_owned() } } +fn storage(error: crate::latest::GenericError) -> Error { Error::Storage { reason: error.reason } } +fn hex32(value: &str) -> bool { value.len() == 64 && value.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } +fn same_scope(a: &ReceivingAuthority, b: &ReceivingAuthority) -> bool { + a.product_id == b.product_id && a.account == b.account && a.environment == b.environment + && a.artifact == b.artifact && a.genesis == b.genesis +} +fn same_authority(a: &ReceivingAuthority, b: &ReceivingAuthority) -> bool { same_scope(a, b) && a.generation == b.generation } +fn current(record: &Record, authority: &ReceivingAuthority) -> bool { same_authority(&record.authority, authority) } +fn find<'a>(ledger: &'a Ledger, authority: &ReceivingAuthority) -> Option<&'a Record> { + ledger.records.iter().find(|r| same_scope(&r.authority, authority)) +} +fn find_mut<'a>(ledger: &'a mut Ledger, authority: &ReceivingAuthority) -> Option<&'a mut Record> { + ledger.records.iter_mut().find(|r| same_scope(&r.authority, authority)) +} +fn check_revision(record: Option<&Record>, expected: u64) -> Result<(), Error> { + if record.map_or(0, |r| r.revision) != expected { Err(Error::Conflict) } else { Ok(()) } +} +fn next_revision(ledger: &mut Ledger) -> Result { + ledger.revision = ledger.revision.checked_add(1).filter(|n| *n <= MAX_SAFE_INTEGER).ok_or(Error::Capacity)?; + Ok(ledger.revision) +} +fn next_sequence(ledger: &mut Ledger) -> Result { + ledger.sequence = ledger.sequence.checked_add(1).filter(|n| *n <= MAX_SAFE_INTEGER).ok_or(Error::Capacity)?; + Ok(ledger.sequence) +} +fn disable_record(record: &mut Record, revision: u64) { + record.revision = revision; + record.enabled = false; + record.consent = false; + record.sync_pending = true; + record.watches.clear(); + record.events.clear(); +} +fn require_record(record: &Record, authority: &ReceivingAuthority, revision: u64) -> Result<(), Error> { + if record.revision != revision { return Err(Error::Conflict); } + if !current(record, authority) || !record.consent || !record.enabled { return Err(Error::PermissionDenied); } + Ok(()) +} +fn prune(record: &mut Record, timestamp: u64) { + record.receipts.retain(|r| r.expires_at > timestamp); + record.events.retain(|e| e.expires_at > timestamp); + record.display_attempts.retain(|at| at.saturating_add(3_600_000) > timestamp); +} +fn status(authority: &ReceivingAuthority, record: Option<&Record>) -> HostNotificationReceiverStatus { + let valid = record.is_some_and(|r| current(r, authority)); + HostNotificationReceiverStatus { supported: true, os_permission: authority.os_permission, + transport_ready: authority.transport_ready, consent: valid && record.is_some_and(|r| r.consent), + enabled: valid && authority.os_permission && record.is_some_and(|r| r.enabled), + revision: record.map_or(0, |r| r.revision), sync_pending: record.is_some_and(|r| r.sync_pending) } +} +fn within_consent(new: &ReceivingWatch, old: &ReceivingWatch) -> bool { + new.genesis == old.genesis && new.channel == old.channel && new.expires_at <= old.expires_at + && old.topics.iter().all(|topic| new.topics.contains(topic)) + && new.senders.iter().all(|key| old.senders.contains(key)) +} +fn validate_watches(watches: &[ReceivingWatch], authority: &ReceivingAuthority, timestamp: u64) -> Result<(), Error> { + if watches.len() > MAX_WATCHES { return Err(Error::Capacity); } + let mut ids = HashSet::with_capacity(watches.len()); + let mut senders = 0usize; + for watch in watches { + if watch.id.is_empty() || watch.id.len() > 128 || !watch.id.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.:".contains(&b)) + || !ids.insert(&watch.id) { return Err(invalid("invalid or duplicate watch id")); } + if watch.genesis != authority.genesis || !hex32(&watch.genesis) { return Err(Error::PermissionDenied); } + if !hex32(&watch.channel) { return Err(invalid("invalid channel")); } + if watch.topics.is_empty() || watch.topics.len() > 4 || watch.topics.iter().any(|t| !hex32(t)) + || watch.topics.iter().collect::>().len() != watch.topics.len() { return Err(invalid("invalid topics")); } + senders += watch.senders.len(); + if watch.senders.is_empty() || watch.senders.len() > MAX_SENDERS || senders > MAX_TOTAL_SENDERS { return Err(Error::Capacity); } + if watch.senders.iter().any(|s| !hex32(s)) || watch.senders.iter().collect::>().len() != watch.senders.len() { + return Err(invalid("invalid sender policy")); + } + if watch.expires_at <= timestamp || watch.expires_at > timestamp.saturating_add(30 * DAY) + || watch.expires_at > MAX_SAFE_INTEGER || (watch.muted_until > MAX_SAFE_INTEGER && watch.muted_until != u64::MAX) { + return Err(invalid("invalid watch timestamps")); + } + // Conservative relative routes: reject encoding tricks, separators and + // traversal rather than interpreting them differently in native/browser. + if watch.route.len() > 512 || !watch.route.starts_with('/') || watch.route.starts_with("//") + || watch.route.bytes().any(|b| !b.is_ascii() || b.is_ascii_control() || b"\\%?#:".contains(&b)) + || watch.route.split('/').any(|part| part == "." || part == "..") { + return Err(invalid("invalid product-relative route")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests; + +/// Product-call view tied to the verified execution rather than a mutable global +/// product-name lookup. Background callbacks use the resident service directly. +pub struct ReceivingExecution<'a> { + service: &'a ReceivingService, + authority: ReceivingAuthority, +} + +impl ReceivingExecution<'_> { + /// Read receiver state without accepting a stale execution's authority. + pub async fn status(&self) -> Result { + self.service.status_scoped(&self.authority.product_id, Some(&self.authority)).await + } + /// Atomically replace policy, rechecking immutable provenance after consent. + pub async fn replace(&self, expected_revision: u64, watches: Vec) -> Result { + self.service.replace_scoped(&self.authority.product_id, expected_revision, watches, Some(&self.authority)).await + } + /// Disable only the calling execution's current account scope. + pub async fn disable(&self, expected_revision: u64) -> Result { + self.service.disable_scoped(&self.authority.product_id, expected_revision, Some(&self.authority)).await + } + /// Record product-confirmed foreground/read evidence. + pub async fn receipt(&self, revision: u64, watch_id: String, event_id: String, kind: ReceivingReceiptKind) -> Result { + self.service.receipt_scoped(&self.authority.product_id, revision, watch_id, event_id, kind, Some(&self.authority)).await + } + /// Read durable events belonging to the verified execution. + pub async fn events(&self, after_sequence: u64) -> Result, Error> { + self.service.events_scoped(&self.authority.product_id, after_sequence, Some(&self.authority)).await + } + /// Acknowledge exactly one event under the same authority fence. + pub async fn acknowledge(&self, sequence: u64) -> Result<(), Error> { + self.service.acknowledge_scoped(&self.authority.product_id, sequence, Some(&self.authority)).await + } +} diff --git a/rust/crates/truapi/src/runtime/receiving/tests.rs b/rust/crates/truapi/src/runtime/receiving/tests.rs new file mode 100644 index 0000000000..d179d51d5b --- /dev/null +++ b/rust/crates/truapi/src/runtime/receiving/tests.rs @@ -0,0 +1,511 @@ +use super::*; +use crate::test_support::{StubPlatform, test_spawner}; +use ed25519_dalek::{Signer, SigningKey}; +use futures::executor::block_on; +use sha2::{Digest, Sha256}; + +const PRODUCT: &str = "receiver.paseo"; +fn authority() -> ReceivingAuthority { + ReceivingAuthority { product_id: PRODUCT.into(), account: "11".repeat(32), environment: "paseo".into(), + artifact: "22".repeat(32), genesis: "33".repeat(32), generation: 1, os_permission: true, transport_ready: true } +} +fn key() -> SigningKey { SigningKey::from_bytes(&[42; 32]) } +fn watch() -> ReceivingWatch { + ReceivingWatch { id: "inbox".into(), genesis: authority().genesis, channel: "44".repeat(32), + topics: vec!["55".repeat(32)], senders: vec![hex::encode(key().verifying_key().as_bytes())], + expires_at: now() + DAY, muted_until: 0, route: "/inbox".into() } +} +fn setup() -> (Arc, ReceivingService) { + let platform = Arc::new(StubPlatform::default()); + *platform.receiving_authority.lock() = Some(authority()); + platform.receiving_consent.store(true, Ordering::SeqCst); + let service = ReceivingService::new(platform.clone(), test_spawner()); + (platform, service) +} +// Independent minimal standard Gordian Envelope writer for the real verifier. +fn cbor(out: &mut Vec, major: u8, value: u64) { + if value < 24 { out.push(major << 5 | value as u8); } + else if value <= 255 { out.extend([major << 5 | 24, value as u8]); } + else if value <= 65535 { out.push(major << 5 | 25); out.extend((value as u16).to_be_bytes()); } + else { out.push(major << 5 | 26); out.extend((value as u32).to_be_bytes()); } +} +fn leaf(out: &mut Vec, major: u8, bytes: &[u8]) { + cbor(out, 6, 201); cbor(out, major, bytes.len() as u64); out.extend(bytes); +} +fn frame(event: u8, created_at: u64, topics: &[String]) -> Vec { + let watch = watch(); + let carrier = [1u8, 2, 3]; + let digest = hex::encode(Sha256::digest(carrier)); + let sender = hex::encode(key().verifying_key().as_bytes()); + let event_id = hex::encode([event; 32]); + let expires_at = created_at + 60_000; + let signed = serde_json::to_vec(&("truapi:notification:v1", 1, PRODUCT, &watch.genesis, + &watch.channel, topics, &event_id, created_at, expires_at, &digest, &sender)).unwrap(); + let header = serde_json::json!({ "v":1,"product":PRODUCT,"genesis":watch.genesis,"channel":watch.channel, + "topics":topics,"eventId":event_id,"createdAt":created_at,"expiresAt":expires_at, + "ciphertextDigest":digest,"senderKey":sender,"signature":hex::encode(key().sign(&signed).to_bytes()) }); + let header = serde_json::to_vec(&header).unwrap(); + let mut out = Vec::new(); + cbor(&mut out, 6, 200); cbor(&mut out, 4, 2); + leaf(&mut out, 2, &carrier); + cbor(&mut out, 5, 1); + leaf(&mut out, 3, b"truapiNotification"); leaf(&mut out, 3, &header); + out +} +async fn deliver(service: &ReceivingService, revision: u64, event: u8) -> Result, Error> { + let watch = watch(); + // Actual transport may add topics; signed topics must still cover the watch. + let topics = vec![watch.topics[0].clone(), "66".repeat(32)]; + service.ingest(PRODUCT, revision, watch.id, watch.genesis, watch.channel, + topics, frame(event, now(), &watch.topics)).await +} + +#[test] +fn missing_backend_is_unsupported_and_os_grant_is_not_consent() { + block_on(async { + let service = ReceivingService::new(Arc::new(StubPlatform::default()), test_spawner()); + assert!(!service.status(PRODUCT).await.unwrap().supported); + assert!(matches!(service.replace(PRODUCT, 0, vec![watch()]).await, Err(Error::Unsupported))); + let (platform, service) = setup(); + platform.receiving_consent.store(false, Ordering::SeqCst); + assert!(matches!(service.replace(PRODUCT, 0, vec![watch()]).await, Err(Error::PermissionDenied))); + assert!(!service.status(PRODUCT).await.unwrap().consent); + }); +} + +#[test] +fn registration_and_receipts_survive_all_product_executions_closing() { + block_on(async { + let (platform, service) = setup(); + let status = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap(); + let event = deliver(&service, status.revision, 1).await.unwrap().remove(0); + drop(service); + let restored = ReceivingService::new(platform, test_spawner()); + assert_eq!(restored.events(PRODUCT, 0).await.unwrap(), vec![event]); + assert!(deliver(&restored, status.revision, 1).await.unwrap().is_empty()); + assert_eq!(restored.pending().await.unwrap().len(), 1); + }); +} + +#[test] +fn foreground_and_read_receipts_win_before_display_reservation() { + block_on(async { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let event = deliver(&service, revision, 2).await.unwrap().remove(0); + service.receipt(PRODUCT, revision, watch().id, event.event_id.clone(), ReceivingReceiptKind::Foreground).await.unwrap(); + assert!(service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().is_none()); + service.receipt(PRODUCT, revision, watch().id, event.event_id.clone(), ReceivingReceiptKind::Read).await.unwrap(); + assert!(service.validate_activation(PRODUCT, revision, event.event_id).await.unwrap().is_none()); + }); +} + +#[test] +fn display_reservation_and_activation_are_distinct_and_replay_safe() { + block_on(async { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let event = deliver(&service, revision, 3).await.unwrap().remove(0); + assert!(service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().is_some()); + assert!(service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().is_none()); + service.confirm_display(PRODUCT, revision, event.event_id.clone()).await.unwrap(); + let preview = service.validate_activation(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + assert_eq!(preview.sequence, 0); + let activation = service.activate(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + assert_eq!(activation.kind, ReceivingEventKind::Activation); + assert!(service.activate(PRODUCT, revision, event.event_id).await.unwrap().is_none()); + service.acknowledge(PRODUCT, activation.sequence).await.unwrap(); + assert!(service.events(PRODUCT, 0).await.unwrap().iter().all(|e| e.sequence != activation.sequence)); + }); +} + +#[test] +fn unchanged_watch_republication_preserves_cold_activation() { + block_on(async { + let (platform, service) = setup(); + let watch = watch(); + let revision = service.replace(PRODUCT, 0, vec![watch.clone()]).await.unwrap().revision; + let event = deliver(&service, revision, 3).await.unwrap().remove(0); + service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + service.confirm_display(PRODUCT, revision, event.event_id.clone()).await.unwrap(); + assert!(service.synchronized(PRODUCT, revision).await.unwrap()); + drop(service); + let restored = ReceivingService::new(platform, test_spawner()); + let status = restored.replace(PRODUCT, revision, vec![watch.clone()]).await.unwrap(); + assert_eq!(status.revision, revision); + assert!(!status.sync_pending); + assert_eq!(restored.events(PRODUCT, 0).await.unwrap(), vec![event.clone()]); + let preview = restored.validate_activation(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + assert_eq!(preview.route, watch.route); + let activation = restored.activate(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + restored.replace(PRODUCT, revision, vec![watch]).await.unwrap(); + assert!(restored.events(PRODUCT, 0).await.unwrap().contains(&activation)); + assert!(restored.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().is_none()); + assert!(restored.activate(PRODUCT, revision, event.event_id).await.unwrap().is_none()); + assert!(deliver(&restored, revision, 3).await.unwrap().is_empty()); + }); +} + +#[test] +fn changed_route_still_fences_pending_click_and_stale_replacement() { + block_on(async { + let (_, service) = setup(); + let mut watch = watch(); + let revision = service.replace(PRODUCT, 0, vec![watch.clone()]).await.unwrap().revision; + let event = deliver(&service, revision, 3).await.unwrap().remove(0); + service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + service.confirm_display(PRODUCT, revision, event.event_id.clone()).await.unwrap(); + watch.route = "/another-conversation".into(); + let changed = service.replace(PRODUCT, revision, vec![watch.clone()]).await.unwrap(); + assert_ne!(changed.revision, revision); + assert!(matches!(service.validate_activation(PRODUCT, revision, event.event_id.clone()).await, Err(Error::Conflict))); + assert!(matches!(service.activate(PRODUCT, revision, event.event_id).await, Err(Error::Conflict))); + assert!(matches!(service.replace(PRODUCT, revision, vec![watch]).await, Err(Error::Conflict))); + assert!(service.events(PRODUCT, 0).await.unwrap().is_empty()); + }); +} + +#[test] +fn message_receipts_do_not_acknowledge_a_queued_user_activation() { + block_on(async { + for kind in [ReceivingReceiptKind::Foreground, ReceivingReceiptKind::Displayed, ReceivingReceiptKind::Read] { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let event = deliver(&service, revision, 3).await.unwrap().remove(0); + service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + service.confirm_display(PRODUCT, revision, event.event_id.clone()).await.unwrap(); + let activation = service.activate(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + service.receipt(PRODUCT, revision, watch().id, event.event_id, kind).await.unwrap(); + assert_eq!(service.events(PRODUCT, 0).await.unwrap(), vec![activation.clone()]); + service.acknowledge(PRODUCT, activation.sequence).await.unwrap(); + assert!(service.events(PRODUCT, 0).await.unwrap().is_empty()); + } + }); +} + +#[test] +fn replacement_is_atomic_and_stale_sync_cannot_acknowledge_revoke() { + block_on(async { + let (_, service) = setup(); + let first = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap(); + let mut invalid_watch = watch(); invalid_watch.route = "//evil.invalid".into(); + assert!(service.replace(PRODUCT, first.revision, vec![invalid_watch]).await.is_err()); + assert_eq!(service.status(PRODUCT).await.unwrap().revision, first.revision); + let disabled = service.disable(PRODUCT, first.revision).await.unwrap(); + assert!(!disabled.enabled); + assert!(!service.synchronized(PRODUCT, first.revision).await.unwrap()); + assert!(service.status(PRODUCT).await.unwrap().sync_pending); + assert!(matches!(deliver(&service, first.revision, 4).await, Err(Error::Conflict))); + }); +} + +#[test] +fn pending_confirmation_cannot_resurrect_revoked_first_registration() { + block_on(async { + let (platform, service) = setup(); + let (release, gate) = futures::channel::oneshot::channel(); + *platform.receiving_consent_gate.lock() = Some(gate); + let replace = service.replace(PRODUCT, 0, vec![watch()]); + let revoke = async { + // join polls replace first, which reaches and parks at consent. + service.revoke(PRODUCT).await.unwrap(); + release.send(()).unwrap(); + }; + let (result, ()) = futures::join!(replace, revoke); + assert!(matches!(result, Err(Error::Conflict))); + assert!(!service.status(PRODUCT).await.unwrap().enabled); + }); +} + +#[test] +fn failed_durable_write_never_reports_enrollment_or_display_success() { + block_on(async { + let (platform, service) = setup(); + platform.receiving_write_failure.store(true, Ordering::SeqCst); + assert!(matches!(service.replace(PRODUCT, 0, vec![watch()]).await, Err(Error::Storage { .. }))); + assert_eq!(service.status(PRODUCT).await.unwrap().revision, 0); + platform.receiving_write_failure.store(false, Ordering::SeqCst); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let event = deliver(&service, revision, 5).await.unwrap().remove(0); + platform.receiving_write_failure.store(true, Ordering::SeqCst); + assert!(service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.is_err()); + platform.receiving_write_failure.store(false, Ordering::SeqCst); + assert!(service.prepare_display(PRODUCT, revision, event.event_id).await.unwrap().is_some()); + }); +} + +#[test] +fn account_artifact_environment_generation_and_os_changes_revalidate() { + block_on(async { + for field in 0..5 { + let (platform, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let mut next = authority(); + match field { 0 => next.account = "77".repeat(32), 1 => next.artifact = "88".repeat(32), + 2 => next.environment = "polkadot".into(), 3 => next.generation += 1, _ => next.os_permission = false } + *platform.receiving_authority.lock() = Some(next); + assert!(!service.status(PRODUCT).await.unwrap().enabled); + assert!(deliver(&service, revision, 6).await.is_err()); + assert!(service.pending().await.unwrap().iter().all(|r| !r.enabled)); + } + }); +} + +#[test] +fn sender_channel_source_and_signature_cannot_be_forged() { + block_on(async { + let (_, service) = setup(); + let watch = watch(); + let revision = service.replace(PRODUCT, 0, vec![watch.clone()]).await.unwrap().revision; + let body = frame(7, now(), &watch.topics); + assert!(service.ingest(PRODUCT, revision, watch.id.clone(), watch.genesis.clone(), "99".repeat(32), watch.topics.clone(), body.clone()).await.is_err()); + assert!(service.ingest(PRODUCT, revision, watch.id.clone(), "99".repeat(32), watch.channel.clone(), watch.topics.clone(), body.clone()).await.is_err()); + let mut altered = body; + // Subject byte tamper leaves the valid signature over the old digest intact. + let index = altered.windows(4).position(|w| w == [0x43, 1, 2, 3]).unwrap(); + altered[index + 1] ^= 1; + assert!(service.ingest(PRODUCT, revision, watch.id, watch.genesis, watch.channel, watch.topics, altered).await.unwrap().is_empty()); + }); +} + +#[test] +fn capacity_rejection_preserves_prior_state_and_live_replay_entries() { + block_on(async { + let (_, service) = setup(); + let first = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap(); + let too_many: Vec<_> = (0..257).map(|i| { let mut w = watch(); w.id = format!("w{i}"); w }).collect(); + assert!(matches!(service.replace(PRODUCT, first.revision, too_many).await, Err(Error::Capacity))); + for event in 10..14 { assert_eq!(deliver(&service, first.revision, event).await.unwrap().len(), 1); } + assert!(deliver(&service, first.revision, 14).await.unwrap().is_empty()); + assert!(deliver(&service, first.revision, 10).await.unwrap().is_empty()); + assert_eq!(service.status(PRODUCT).await.unwrap().revision, first.revision); + }); +} + +#[test] +fn mute_narrowing_does_not_prompt_and_unmute_does_not_replay_history() { + block_on(async { + let (platform, service) = setup(); + let mut watch = watch(); + let first = service.replace(PRODUCT, 0, vec![watch.clone()]).await.unwrap(); + watch.muted_until = u64::MAX; + let muted = service.replace(PRODUCT, first.revision, vec![watch.clone()]).await.unwrap(); + assert!(deliver(&service, muted.revision, 20).await.unwrap().is_empty()); + assert_eq!(platform.receiving_prompts.load(Ordering::SeqCst), 1); + watch.muted_until = 0; + let unmuted = service.replace(PRODUCT, muted.revision, vec![watch.clone()]).await.unwrap(); + assert!(service.ingest(PRODUCT, unmuted.revision, watch.id, watch.genesis, watch.channel, + watch.topics.clone(), frame(21, now() - 10_000, &watch.topics)).await.unwrap().is_empty()); + }); +} + +#[test] +fn durable_hourly_budget_is_checked_at_display_not_enrollment() { + block_on(async { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let event = deliver(&service, revision, 22).await.unwrap().remove(0); + let mut ledger = service.load().await.unwrap(); + ledger.records[0].display_attempts = vec![now(); 60]; + service.save(&ledger).await.unwrap(); + assert!(service.prepare_display(PRODUCT, revision, event.event_id).await.unwrap().is_none()); + assert!(service.status(PRODUCT).await.unwrap().enabled); + }); +} + +#[test] +fn expired_or_missing_statement_expiry_never_qualifies() { + use crate::host_logic::statement_store::{StatementField, StatementProof}; + use parity_scale_codec::Encode; + block_on(async { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let fields = vec![ + StatementField::Proof(StatementProof::Sr25519 { signature: [0; 64], signer: [0; 32] }), + StatementField::Expiry(((now() / 1000) - 10) << 32), + StatementField::Channel([0x44; 32]), + StatementField::Topic1([0x55; 32]), + StatementField::Data(frame(30, now(), &watch().topics)), + ]; + assert!(service.ingest_statement(PRODUCT, revision, watch().id, watch().genesis, fields.encode()).await.unwrap().is_empty()); + let without_expiry: Vec<_> = fields.into_iter().filter(|f| !matches!(f, StatementField::Expiry(_))).collect(); + assert!(service.ingest_statement(PRODUCT, revision, watch().id, watch().genesis, without_expiry.encode()).await.is_err()); + }); +} + +#[test] +fn independent_authenticated_backfill_candidates_have_separate_receipts() { + block_on(async { + let (_, service) = setup(); + let watch = watch(); + let revision = service.replace(PRODUCT, 0, vec![watch.clone()]).await.unwrap().revision; + let first = frame(31, now(), &watch.topics); + let second = frame(32, now(), &watch.topics); + let mut carrier = Vec::new(); + cbor(&mut carrier, 6, 200); + cbor(&mut carrier, 4, 2); + carrier.extend(&first[2..]); + cbor(&mut carrier, 5, 1); + leaf(&mut carrier, 3, b"history"); + carrier.extend(&second[2..]); + let accepted = service.ingest(PRODUCT, revision, watch.id.clone(), watch.genesis.clone(), watch.channel.clone(), + watch.topics.clone(), carrier.clone()).await.unwrap(); + assert_eq!(accepted.len(), 2); + assert_ne!(accepted[0].event_id, accepted[1].event_id); + assert!(service.ingest(PRODUCT, revision, watch.id, watch.genesis, watch.channel, watch.topics, + carrier).await.unwrap().is_empty()); + }); +} + +#[test] +fn provider_confirmed_display_is_not_a_local_display_authorization() { + block_on(async { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let event = deliver(&service, revision, 33).await.unwrap().remove(0); + service.confirm_display(PRODUCT, revision, event.event_id.clone()).await.unwrap(); + assert!(service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().is_none()); + assert!(service.validate_activation(PRODUCT, revision, event.event_id).await.unwrap().is_some()); + }); +} + +#[test] +fn stale_product_execution_cannot_enroll_under_a_new_verified_artifact() { + block_on(async { + let (platform, service) = setup(); + let old_execution = service.for_execution(authority()); + let mut replacement = authority(); + replacement.artifact = "aa".repeat(32); + *platform.receiving_authority.lock() = Some(replacement.clone()); + assert!(matches!(old_execution.status().await, Err(Error::PermissionDenied))); + assert!(matches!(old_execution.replace(0, vec![watch()]).await, Err(Error::PermissionDenied))); + assert_eq!(platform.receiving_prompts.load(Ordering::SeqCst), 0); + assert!(service.for_execution(replacement).replace(0, vec![watch()]).await.unwrap().enabled); + }); +} + +#[test] +fn execution_provenance_is_rechecked_after_receiving_consent() { + block_on(async { + let (platform, service) = setup(); + let execution = service.for_execution(authority()); + let (release, gate) = futures::channel::oneshot::channel(); + *platform.receiving_consent_gate.lock() = Some(gate); + let enroll = execution.replace(0, vec![watch()]); + let switch = async { + let mut replacement = authority(); + replacement.generation += 1; + *platform.receiving_authority.lock() = Some(replacement); + release.send(()).unwrap(); + }; + let (result, ()) = futures::join!(enroll, switch); + assert!(matches!(result, Err(Error::PermissionDenied))); + assert!(!service.status(PRODUCT).await.unwrap().enabled); + }); +} + +#[test] +fn explicit_logout_revokes_all_and_does_not_await_transport() { + block_on(async { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + service.revoke_all().await.unwrap(); + assert!(!service.status(PRODUCT).await.unwrap().enabled); + assert!(!service.synchronized(PRODUCT, revision).await.unwrap()); + assert!(service.pending().await.unwrap().iter().all(|r| !r.enabled && r.sync_pending)); + }); +} + +#[test] +fn foreground_receipt_reports_actual_display_not_a_reserved_claim() { + block_on(async { + let (platform, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let event = deliver(&service, revision, 34).await.unwrap().remove(0); + service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + let outcome = service.receipt(PRODUCT, revision, watch().id, event.event_id.clone(), ReceivingReceiptKind::Foreground).await.unwrap(); + assert!(!outcome.displayed); + assert!(outcome.display_pending); + drop(service); + let restored = ReceivingService::new(platform, test_spawner()); + let unknown_after_restart = restored.receipt(PRODUCT, revision, watch().id, event.event_id.clone(), ReceivingReceiptKind::Foreground).await.unwrap(); + assert!(!unknown_after_restart.displayed); + assert!(unknown_after_restart.display_pending); + restored.confirm_display(PRODUCT, revision, event.event_id.clone()).await.unwrap(); + let confirmed = restored.receipt(PRODUCT, revision, watch().id, event.event_id, ReceivingReceiptKind::Foreground).await.unwrap(); + assert!(confirmed.displayed); + assert!(!confirmed.display_pending); + }); +} + +#[test] +fn known_failed_display_releases_claim_without_claiming_os_success() { + block_on(async { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let event = deliver(&service, revision, 35).await.unwrap().remove(0); + service.prepare_display(PRODUCT, revision, event.event_id.clone()).await.unwrap().unwrap(); + service.receipt(PRODUCT, revision, watch().id, event.event_id.clone(), ReceivingReceiptKind::Foreground).await.unwrap(); + service.cancel_display(PRODUCT, revision, event.event_id.clone()).await.unwrap(); + let fallback = service.receipt(PRODUCT, revision, watch().id, event.event_id.clone(), ReceivingReceiptKind::Foreground).await.unwrap(); + assert!(!fallback.displayed && !fallback.display_pending); + let shown = service.receipt(PRODUCT, revision, watch().id, event.event_id.clone(), ReceivingReceiptKind::Displayed).await.unwrap(); + assert!(shown.displayed && !shown.display_pending); + service.cancel_display(PRODUCT, revision, event.event_id.clone()).await.unwrap(); + let repeated = service.receipt(PRODUCT, revision, watch().id, event.event_id, ReceivingReceiptKind::Foreground).await.unwrap(); + assert!(repeated.displayed && !repeated.display_pending); + }); +} + +#[test] +fn foreground_before_host_ingest_suppresses_host_without_faking_display() { + block_on(async { + let (_, service) = setup(); + let revision = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let outcome = service.receipt(PRODUCT, revision, watch().id, hex::encode([36u8; 32]), ReceivingReceiptKind::Foreground).await.unwrap(); + assert!(!outcome.displayed && !outcome.display_pending); + assert!(deliver(&service, revision, 36).await.unwrap().is_empty()); + }); +} + +#[test] +fn token_rotation_preserves_accepted_events_and_fences_old_acknowledgements() { + block_on(async { + let (_, service) = setup(); + let old = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap().revision; + let accepted = deliver(&service, old, 37).await.unwrap().remove(0); + service.mark_transport_changed(PRODUCT).await.unwrap(); + let current = service.status(PRODUCT).await.unwrap().revision; + assert!(current > old); + assert!(!service.synchronized(PRODUCT, old).await.unwrap()); + let events = service.events(PRODUCT, 0).await.unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_id, accepted.event_id); + assert_eq!(events[0].revision, current); + assert!(deliver(&service, current, 37).await.unwrap().is_empty()); + assert!(service.prepare_display(PRODUCT, current, accepted.event_id).await.unwrap().is_some()); + }); +} + +#[test] +fn new_account_sync_cannot_acknowledge_old_account_revocation() { + block_on(async { + let (platform, service) = setup(); + service.replace(PRODUCT, 0, vec![watch()]).await.unwrap(); + let mut second = authority(); + second.account = "bb".repeat(32); + second.generation += 1; + *platform.receiving_authority.lock() = Some(second); + let active = service.replace(PRODUCT, 0, vec![watch()]).await.unwrap(); + let pending = service.pending().await.unwrap(); + let retired = pending.iter().find(|r| !r.enabled).unwrap(); + assert_ne!(retired.revision, active.revision); + assert!(service.synchronized(PRODUCT, active.revision).await.unwrap()); + let pending = service.pending().await.unwrap(); + assert!(pending.iter().find(|r| !r.enabled).unwrap().sync_pending); + assert!(!pending.iter().find(|r| r.enabled).unwrap().sync_pending); + service.revoke_all().await.unwrap(); + let revoked = service.pending().await.unwrap(); + assert_ne!(revoked[0].revision, revoked[1].revision); + }); +} diff --git a/rust/crates/truapi/src/runtime/services.rs b/rust/crates/truapi/src/runtime/services.rs index 6656e28cb9..6e63633b7c 100644 --- a/rust/crates/truapi/src/runtime/services.rs +++ b/rust/crates/truapi/src/runtime/services.rs @@ -34,6 +34,8 @@ const STATEMENT_CACHE_MAX_ENTRIES: usize = 64; pub struct RuntimeServices { /// Host platform backing all syscalls. pub platform: Arc, + /// Durable receiving survives all product connections closing. + pub receiving: Arc, /// Permission decisions and prompt cancellation shared by every execution. pub(crate) permissions: Arc, /// Host identity reported to products via `System::host_info`. @@ -164,6 +166,10 @@ impl RuntimeServices { StatementStoreRpc::new(platform.clone(), people_chain_genesis_hash, spawner.clone()); let bulletin = BulletinRpc::new(chain.clone(), bulletin_chain_genesis_hash); Arc::new(Self { + receiving: Arc::new(crate::runtime::receiving::ReceivingService::new( + platform.clone(), + spawner.clone(), + )), platform, permissions: Arc::default(), host_info, diff --git a/rust/crates/truapi/src/runtime/signing_host.rs b/rust/crates/truapi/src/runtime/signing_host.rs index 8edee8e0ad..7445de6b83 100644 --- a/rust/crates/truapi/src/runtime/signing_host.rs +++ b/rust/crates/truapi/src/runtime/signing_host.rs @@ -99,7 +99,10 @@ use zeroize::Zeroizing; #[derive(Default)] struct LocalGrantState { + // Activation and product revocations are lower bounds on the snapshot clock. activation_generation: u64, + generation: u64, + product_revocations: HashMap, auto_signing_grants: HashSet<([u8; 32], String)>, /// Chat authority the user allowed for this session only, by owner and product. chat_session_grants: HashSet<([u8; 32], String)>, @@ -111,20 +114,55 @@ struct LocalGrantState { impl LocalGrantState { fn advance_activation(&mut self) { - self.activation_generation = self - .activation_generation - .checked_add(1) - .expect("local activation generation exhausted"); + self.advance_generation(); + self.activation_generation = self.generation; + self.product_revocations.clear(); self.auto_signing_grants.clear(); self.chat_session_grants.clear(); self.statement_allowance_keys.clear(); } - fn revoke_product(&mut self, product_id: &str) { - self.activation_generation = self - .activation_generation + fn advance_generation(&mut self) { + self.generation = self + .generation .checked_add(1) - .expect("local activation generation exhausted"); + .expect("local authority generation exhausted"); + } + + fn require_generation(&self, generation: u64) -> Result<(), AuthorityError> { + if generation < self.activation_generation || generation > self.generation { + return Err(AuthorityError::Disconnected); + } + Ok(()) + } + + fn require_product_generation( + &self, + generation: u64, + product_id: &str, + ) -> Result<(), AuthorityError> { + self.require_generation(generation)?; + if generation != self.generation { + let product_id = normalize_product_identifier(product_id).map_err(|error| { + AuthorityError::Unavailable { + reason: error.to_string(), + } + })?; + if self + .product_revocations + .get(&product_id) + .is_some_and(|revoked| *revoked > generation) + { + return Err(AuthorityError::Disconnected); + } + } + Ok(()) + } + + fn revoke_product(&mut self, product_id: &str) { + self.advance_generation(); + self.product_revocations + .insert(product_id.to_owned(), self.generation); self.auto_signing_grants .retain(|(_, granted_product_id)| granted_product_id != product_id); self.chat_session_grants @@ -134,13 +172,11 @@ impl LocalGrantState { fn statement_allowance_key( &self, - activation_generation: u64, + generation: u64, product_id: &str, period: u32, ) -> Result, AuthorityError> { - if self.activation_generation != activation_generation { - return Err(AuthorityError::Disconnected); - } + self.require_product_generation(generation, product_id)?; Ok(self .statement_allowance_keys .get(product_id) @@ -160,14 +196,12 @@ impl LocalGrantState { fn remember_statement_allowance_key( &mut self, - activation_generation: u64, + generation: u64, product_id: String, period: u32, key: StatementStoreAllowanceKey, ) -> Result<(), AuthorityError> { - if self.activation_generation != activation_generation { - return Err(AuthorityError::Disconnected); - } + self.require_product_generation(generation, &product_id)?; self.statement_allowance_keys .insert(product_id, (period, key)); Ok(()) @@ -434,7 +468,7 @@ impl SigningHost { session: &AuthoritySession, product_id: &str, ) -> Result<(), AuthorityError> { - let (_, activation_generation) = self.require_current_session(session)?; + let (_, generation) = self.require_current_product_session(session, product_id)?; let entropy = self.root_entropy()?; let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; let owner = root.public.to_bytes(); @@ -452,9 +486,7 @@ impl SigningHost { .local_grants .lock() .expect("local AutoSigning grant mutex poisoned"); - if state.activation_generation != activation_generation { - return Err(AuthorityError::Disconnected); - } + state.require_product_generation(generation, &product_id)?; state.auto_signing_grants.insert((owner, product_id)); Ok(()) } @@ -465,7 +497,7 @@ impl SigningHost { product_id: &str, policy: OnExistingAllowancePolicy, ) -> Result { - let (_, activation_generation) = self.require_current_session(session)?; + let (_, generation) = self.require_current_product_session(session, product_id)?; let allocation = sso_responder::allocate_statement_store_allowance( &self.services, self, @@ -479,7 +511,7 @@ impl SigningHost { .lock() .expect("local AutoSigning grant mutex poisoned") .remember_statement_allowance_key( - activation_generation, + generation, product_id.to_string(), allocation.period, key.clone(), @@ -489,7 +521,7 @@ impl SigningHost { fn has_auto_signing_grant( &self, - activation_generation: u64, + generation: u64, owner: [u8; 32], calling_product_id: &str, account_product_id: &str, @@ -508,7 +540,9 @@ impl SigningHost { .local_grants .lock() .expect("local AutoSigning grant mutex poisoned"); - state.activation_generation == activation_generation + state + .require_product_generation(generation, &calling_product_id) + .is_ok() && state .auto_signing_grants .contains(&(owner, calling_product_id)) @@ -626,7 +660,7 @@ impl SigningHost { let session = self.session_state.current()?; Some(AuthoritySession::from_session_info( &session, - local_session_validation_id(&session, state.activation_generation), + local_session_validation_id(&session, state.generation), )) } @@ -642,12 +676,22 @@ impl SigningHost { .session_state .current() .ok_or(AuthorityError::Disconnected)?; - if local_session_validation_id(¤t, state.activation_generation) - != session.validation_id - { - return Err(AuthorityError::Disconnected); - } - Ok((current, state.activation_generation)) + let generation = local_session_generation(¤t, &session.validation_id)?; + state.require_generation(generation)?; + Ok((current, generation)) + } + + fn require_current_product_session( + &self, + session: &AuthoritySession, + product_id: &str, + ) -> Result<(SessionInfo, u64), AuthorityError> { + let (current, generation) = self.require_current_session(session)?; + self.local_grants + .lock() + .expect("local AutoSigning grant mutex poisoned") + .require_product_generation(generation, product_id)?; + Ok((current, generation)) } fn native_chat_context( @@ -665,7 +709,8 @@ impl SigningHost { .lock() .expect("local AutoSigning grant mutex poisoned"); session_state.current().is_some_and(|current| { - local_session_validation_id(¤t, grants.activation_generation) == validation_id + local_session_generation(¤t, &validation_id) + .is_ok_and(|generation| grants.require_generation(generation).is_ok()) }) }); let local_grants = self.local_grants.clone(); @@ -689,6 +734,40 @@ impl SigningHost { }) } + fn native_chat_product_context( + &self, + session: &AuthoritySession, + product_id: &str, + ) -> Result { + let (_, generation) = self.require_current_product_session(session, product_id)?; + let mut context = self.native_chat_context(session)?; + let session_valid = context.session_valid.clone(); + let grants = self.local_grants.clone(); + let product_id = product_id.to_owned(); + context.session_valid = Arc::new(move || { + session_valid() + && grants + .lock() + .expect("local AutoSigning grant mutex poisoned") + .require_product_generation(generation, &product_id) + .is_ok() + }); + Ok(context) + } + + fn require_signing_session( + &self, + session: &AuthoritySession, + caller: Option<&str>, + account_product: Option<&str>, + ) -> Result<(), AuthorityError> { + self.require_current_session(session)?; + for product in [caller, account_product].into_iter().flatten() { + self.require_current_product_session(session, product)?; + } + Ok(()) + } + /// Read the current wallet's authenticated native Chat roster for the host shell. pub async fn get_native_chat_contacts( &self, @@ -722,7 +801,7 @@ impl SigningHost { session: &AuthoritySession, handle: &v01::ProductAccountId, ) -> Result, RingVrfError> { - self.require_current_session(session)?; + self.require_current_product_session(session, &handle.dot_ns_identifier)?; let root = self.root_entropy()?; derive_ring_vrf_entropy(&root, &handle.dot_ns_identifier, &handle.derivation_index) .map(Zeroizing::new) @@ -832,7 +911,7 @@ impl SigningHost { session: &AuthoritySession, handle: &v01::ProductAccountId, ) -> Result, RingVrfError> { - self.require_current_session(session)?; + self.require_current_product_session(session, &handle.dot_ns_identifier)?; self.ring_vrf_registry .entry(session.public_key, handle) .await @@ -973,17 +1052,19 @@ impl SigningHost { request: v01::HostAccountSignVrfRequest, authenticated_caller: bool, ) -> Result { - self.require_current_session(session)?; + self.require_current_product_session(session, &calling_product_id)?; + self.require_current_product_session(session, &request.account.dot_ns_identifier)?; validate_vrf_transcript(&request).map_err(|reason| AuthorityError::Unknown { reason })?; let keypair = self.product_keypair(&request.account)?; - let (current, activation_generation) = self.require_current_session(session)?; + let (current, generation) = + self.require_current_product_session(session, &calling_product_id)?; let granted = authenticated_caller && super::authority::is_blessed_owner( &calling_product_id, &request.account.dot_ns_identifier, ) || self.has_auto_signing_grant( - activation_generation, + generation, current.public_key, &calling_product_id, &request.account.dot_ns_identifier, @@ -993,7 +1074,7 @@ impl SigningHost { cx, self.platform .confirm_user_action(UserConfirmationReview::SignVrf(SignVrfReview { - calling_product_id, + calling_product_id: calling_product_id.clone(), request: request.clone(), })), ) @@ -1005,6 +1086,8 @@ impl SigningHost { return Err(AuthorityError::Rejected); } } + self.require_current_product_session(session, &calling_product_id)?; + self.require_current_product_session(session, &request.account.dot_ns_identifier)?; let (pre_output, proof) = crate::dynamic_vrf::sign_dynamic_vrf( &keypair, &request.transcript_label, @@ -1073,17 +1156,34 @@ impl SigningHost { #[async_trait::async_trait] impl ProductAuthority for SigningHost { fn chat_session_granted(&self, session: &AuthoritySession, product_id: &str) -> bool { - self.local_grants + let Ok((_, generation)) = self.require_current_session(session) else { + return false; + }; + let state = self + .local_grants .lock() - .expect("local AutoSigning grant mutex poisoned") - .chat_session_grants - .contains(&(session.public_key, product_id.to_owned())) + .expect("local AutoSigning grant mutex poisoned"); + state + .require_product_generation(generation, product_id) + .is_ok() + && state + .chat_session_grants + .contains(&(session.public_key, product_id.to_owned())) } fn current_session(&self) -> Option { self.current_local_session() } + fn session_is_current(&self, session: &AuthoritySession, product_id: Option<&str>) -> bool { + match product_id { + Some(product_id) => self + .require_current_product_session(session, product_id) + .is_ok(), + None => self.require_current_session(session).is_ok(), + } + } + async fn refresh_session_identity(&self) -> Result, String> { let Ok(context) = self.local_identity_context() else { return Ok(None); @@ -1134,7 +1234,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, product_id: String, ) -> Result<[u8; 32], AuthorityError> { - self.require_current_session(session)?; + self.require_current_product_session(session, &product_id)?; let product_id = normalize_product_identifier(&product_id).map_err(|err| { AuthorityError::Unavailable { reason: err.to_string(), @@ -1168,10 +1268,12 @@ impl ProductAuthority for SigningHost { // `grant_auto_signing` refuses to record a grant whose owner is not // the session's own key, so the session carries the owner a grant can // be keyed on and no root derivation is needed to answer this. - let (current, activation_generation) = self.require_current_session(session)?; + let (current, generation) = + self.require_current_product_session(session, calling_product_id)?; + self.require_current_product_session(session, &account.dot_ns_identifier)?; if super::authority::is_blessed_owner(calling_product_id, &account.dot_ns_identifier) || self.has_auto_signing_grant( - activation_generation, + generation, current.public_key, calling_product_id, &account.dot_ns_identifier, @@ -1198,19 +1300,26 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - _calling_product_id: Option<&str>, + calling_product_id: Option<&str>, request: SignPayloadAuthorityRequest, ) -> Result { self.require_current_session(session)?; - let (keypair, payload) = match request { - SignPayloadAuthorityRequest::Product(request) => { - (self.product_keypair(&request.account)?, request.payload) - } + let (keypair, payload, product_id) = match request { + SignPayloadAuthorityRequest::Product(request) => ( + self.product_keypair(&request.account)?, + request.payload, + request.account.dot_ns_identifier, + ), SignPayloadAuthorityRequest::LegacyAccount { product_account, request, - } => (self.product_keypair(&product_account)?, request.payload), + } => ( + self.product_keypair(&product_account)?, + request.payload, + product_account.dot_ns_identifier, + ), }; + self.require_signing_session(session, calling_product_id, Some(&product_id))?; Ok(sign_extrinsic_payload(&keypair, payload)?) } @@ -1218,14 +1327,16 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - _calling_product_id: Option<&str>, + calling_product_id: Option<&str>, request: SignRawAuthorityRequest, watermarked: bool, ) -> Result { - let (keypair, payload) = match request { - SignRawAuthorityRequest::Product(request) => { - (self.product_keypair(&request.account)?, request.payload) - } + let (keypair, payload, product_id) = match request { + SignRawAuthorityRequest::Product(request) => ( + self.product_keypair(&request.account)?, + request.payload, + Some(request.account.dot_ns_identifier), + ), SignRawAuthorityRequest::LegacyAccount { account, request } => { let keypair = self.identity_keypair()?; if keypair.public.to_bytes() != account { @@ -1235,10 +1346,10 @@ impl ProductAuthority for SigningHost { .to_string(), }); } - (keypair, request.payload) + (keypair, request.payload, None) } }; - self.require_current_session(session)?; + self.require_signing_session(session, calling_product_id, product_id.as_deref())?; let message = raw_payload_bytes(payload, watermarked)?; let signature = keypair .secret @@ -1254,11 +1365,20 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - _calling_product_id: Option<&str>, + calling_product_id: Option<&str>, request: CreateTransactionAuthorityRequest, ) -> Result { - self.require_current_session(session)?; - match request { + let account_product = match &request { + CreateTransactionAuthorityRequest::Product(payload) => { + Some(payload.signer.dot_ns_identifier.as_str()) + } + CreateTransactionAuthorityRequest::LegacyAccount { + product_account, .. + } => Some(product_account.dot_ns_identifier.as_str()), + CreateTransactionAuthorityRequest::IdentityAccount(_) => None, + }; + self.require_signing_session(session, calling_product_id, account_product)?; + let response = match &request { CreateTransactionAuthorityRequest::Product(payload) => { // The product account is authoritative and caller-scoping is // enforced upstream, so the derived key defines the signer. @@ -1278,7 +1398,7 @@ impl ProductAuthority for SigningHost { product_account, request, } => { - let keypair = self.product_keypair(&product_account)?; + let keypair = self.product_keypair(product_account)?; // Defense-in-depth: the slot-zero key must match the legacy // signer the caller asked for (also validated upstream). Never // sign with a diverging key. @@ -1320,7 +1440,9 @@ impl ProductAuthority for SigningHost { .await .map_err(AuthorityError::from) } - } + }; + self.require_signing_session(session, calling_product_id, account_product)?; + response } async fn account_alias( @@ -1329,7 +1451,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: ProductRequest, ) -> Result { - self.require_current_session(session)?; + self.require_current_product_session(session, &request.calling_product_id)?; // A `context` grant covers this. RFC-0024 defines the scope as "acting // as the granting product's account: reading it and the identity that // follows from it", and the contextual alias is that identity: it and @@ -1407,6 +1529,8 @@ impl ProductAuthority for SigningHost { self.ring_resolver .validate(&request.payload.ring_location) .await?; + self.require_current_product_session(session, &request.calling_product_id)?; + self.require_current_product_session(session, &key_handle.dot_ns_identifier)?; let context = development_context_bytes(&request.payload.context); let alias = vrf.alias(&entropy, &context)?; Ok(v01::ContextualAlias { @@ -1421,7 +1545,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: ProductRequest, ) -> Result { - self.require_current_session(session)?; + self.require_current_product_session(session, &request.calling_product_id)?; let (key_handle, access) = self .require_ring_vrf_key_access(&request.calling_product_id, &request.payload.key_handle) .await?; @@ -1451,7 +1575,8 @@ impl ProductAuthority for SigningHost { .await?; // Reject a stale request if the local session disconnected or changed // while its chain snapshot was being resolved. - self.require_current_session(session)?; + self.require_current_product_session(session, &request.calling_product_id)?; + self.require_current_product_session(session, &key_handle.dot_ns_identifier)?; let context = development_context_bytes(&request.payload.context); let (proof, alias) = create_proof( &vrf, @@ -1477,7 +1602,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: ProductRequest, ) -> Result<[u8; 32], RingVrfError> { - self.require_current_session(session)?; + self.require_current_product_session(session, &request.calling_product_id)?; self.ring_resolver.validate(&request.payload.ring).await?; let handle = v01::ProductAccountId { @@ -1490,9 +1615,11 @@ impl ProductAuthority for SigningHost { }; let entropy = self.ring_vrf_entropy(session, &handle)?; let public_key = vrf::load().await?.member(&entropy)?; + self.require_current_product_session(session, &request.calling_product_id)?; self.ring_vrf_registry .register(session.public_key, handle, request.payload.ring, public_key) .await?; + self.require_current_product_session(session, &request.calling_product_id)?; Ok(public_key) } @@ -1502,7 +1629,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: ProductRequest, ) -> Result, RingVrfError> { - self.require_current_session(session)?; + self.require_current_product_session(session, &request.calling_product_id)?; let owner = normalize_product_identifier(&request.payload.owner).map_err(|err| { RingVrfError::Unknown { reason: err.to_string(), @@ -1543,7 +1670,8 @@ impl ProductAuthority for SigningHost { .ring_vrf_registry .owner_entries(session.public_key, &owner) .await?; - self.require_current_session(session)?; + self.require_current_product_session(session, &caller)?; + self.require_current_product_session(session, &owner)?; if request.payload.disclosure == v01::RingVrfKeyDisclosure::Anonymized { for entry in &mut entries { entry.public_key = None; @@ -1558,7 +1686,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: ProductRequest, ) -> Result, RingVrfError> { - self.require_current_session(session)?; + self.require_current_product_session(session, &request.calling_product_id)?; let (key_handle, _access) = self .require_ring_vrf_key_access(&request.calling_product_id, &request.payload.key_handle) .await?; @@ -1566,6 +1694,8 @@ impl ProductAuthority for SigningHost { let entropy = self .resolve_registered_ring_vrf_key(&vrf, session, &key_handle) .await?; + self.require_current_product_session(session, &request.calling_product_id)?; + self.require_current_product_session(session, &key_handle.dot_ns_identifier)?; vrf.sign(&entropy, &request.payload.message) } @@ -1576,7 +1706,8 @@ impl ProductAuthority for SigningHost { request: ProductDeviceChatAuthorityRequest, ) -> Result { - let (_, activation_generation) = self.require_current_session(session)?; + let (_, generation) = + self.require_current_product_session(session, &request.calling_product_id)?; let calling_product_id = normalize_product_identifier(&request.calling_product_id) .map_err(|_| { ProductDeviceChatAuthorityError::Domain( @@ -1588,16 +1719,14 @@ impl ProductAuthority for SigningHost { .local_grants .lock() .expect("local AutoSigning grant mutex poisoned"); - // A revocation or reactivation since the session check advanced the - // generation; the grant must not outlive it. - if state.activation_generation != activation_generation { - return Err(ProductDeviceChatAuthorityError::Disconnected); - } + state + .require_product_generation(generation, &calling_product_id) + .map_err(ProductDeviceChatAuthorityError::from)?; state .chat_session_grants .insert((session.public_key, calling_product_id.clone())); } - let context = self.native_chat_context(session)?; + let context = self.native_chat_product_context(session, &calling_product_id)?; self.native_chat .execute(context, calling_product_id, request.operation) .await @@ -1610,12 +1739,12 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: PaymentTopUpRequest, ) -> Result<(), PaymentTopUpAuthorityError> { - self.require_current_session(session)?; + self.require_current_product_session(session, &request.calling_product_id)?; let product = normalize_product_identifier(&request.calling_product_id).map_err(|_| { PaymentTopUpAuthorityError::Domain(v01::HostPaymentTopUpError::InvalidSource) })?; let context = self - .native_chat_context(session) + .native_chat_product_context(session, &product) .map_err(|error| match error { ProductDeviceChatAuthorityError::Disconnected => AuthorityError::Disconnected, _ => AuthorityError::Unavailable { @@ -1641,7 +1770,7 @@ impl ProductAuthority for SigningHost { product_id: &str, peer_identity: [u8; 32], ) -> Option { - let context = self.native_chat_context(session).ok()?; + let context = self.native_chat_product_context(session, product_id).ok()?; self.native_chat .contact_username(&context, product_id, peer_identity) .await @@ -1654,7 +1783,7 @@ impl ProductAuthority for SigningHost { product_id: String, request: v01::HostRequestResourceAllocationRequest, ) -> Result { - self.require_current_session(session)?; + self.require_current_product_session(session, &product_id)?; #[cfg(feature = "test-host")] if self .grant_allowances_unchecked @@ -1758,7 +1887,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, product_id: String, ) -> Result { - let (_, activation_generation) = self.require_current_session(session)?; + let (_, generation) = self.require_current_product_session(session, &product_id)?; #[cfg(feature = "test-host")] self.refuse_withheld(&v01::AllocatableResource::StatementStoreAllowance)?; let period = statement_allowance::slot::current_period( @@ -1769,7 +1898,7 @@ impl ProductAuthority for SigningHost { .local_grants .lock() .expect("local AutoSigning grant mutex poisoned") - .statement_allowance_key(activation_generation, &product_id, period)? + .statement_allowance_key(generation, &product_id, period)? { return Ok(key.clone()); } @@ -1804,7 +1933,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, product_id: String, ) -> Result { - self.require_current_session(session)?; + self.require_current_product_session(session, &product_id)?; #[cfg(feature = "test-host")] self.refuse_withheld(&v01::AllocatableResource::BulletinAllowance)?; let secret = sso_responder::allocate_bulletin_allowance( @@ -1825,7 +1954,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, product_id: String, ) -> Result { - self.require_current_session(session)?; + self.require_current_product_session(session, &product_id)?; #[cfg(feature = "test-host")] self.refuse_withheld(&v01::AllocatableResource::BulletinAllowance)?; let secret = sso_responder::allocate_bulletin_allowance( @@ -1844,12 +1973,17 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - _calling_product_id: Option<&str>, + calling_product_id: Option<&str>, account: v01::ProductAccountId, payload: Vec, ) -> Result<[u8; 64], AuthorityError> { - self.require_current_session(session)?; + self.require_current_product_session(session, &account.dot_ns_identifier)?; let keypair = self.product_keypair(&account)?; + self.require_signing_session( + session, + calling_product_id, + Some(&account.dot_ns_identifier), + )?; Ok(keypair .secret .sign_simple(SR25519_SIGNING_CONTEXT, &payload, &keypair.public) @@ -1862,7 +1996,7 @@ impl ProductAuthority for SigningHost { product_id: &str, context: &[u8], ) -> Result<[u8; 32], AuthorityError> { - self.require_current_session(session)?; + self.require_current_product_session(session, product_id)?; let entropy = self.root_entropy()?; derive_product_entropy(&entropy, product_id, context).map_err(|err| { AuthorityError::Unknown { @@ -1883,13 +2017,27 @@ impl ProductAuthority for SigningHost { } } -fn local_session_validation_id(session: &SessionInfo, activation_generation: u64) -> Vec { +fn local_session_validation_id(session: &SessionInfo, generation: u64) -> Vec { let mut id = authority_session_validation_id(session); - id.extend_from_slice(b":activation:"); - id.extend_from_slice(&activation_generation.to_le_bytes()); + id.extend_from_slice(b":generation:"); + id.extend_from_slice(&generation.to_le_bytes()); id } +fn local_session_generation( + current: &SessionInfo, + validation_id: &[u8], +) -> Result { + let encoded = validation_id + .last_chunk::<8>() + .ok_or(AuthorityError::Disconnected)?; + let generation = u64::from_le_bytes(*encoded); + if local_session_validation_id(current, generation) != validation_id { + return Err(AuthorityError::Disconnected); + } + Ok(generation) +} + fn product_authority_error(err: ProductAccountError) -> AuthorityError { AuthorityError::Unavailable { reason: err.to_string(), @@ -1956,6 +2104,43 @@ mod tests { const ENTROPY: [u8; 16] = [0xAB; 16]; + #[derive(Clone, Copy, Debug)] + enum AuthorityChange { + ClearOtherProduct, + ClearProduct, + Reactivate, + ReplaceWallet, + Disconnect, + } + + impl AuthorityChange { + const ALL: [Self; 5] = [ + Self::ClearOtherProduct, + Self::ClearProduct, + Self::Reactivate, + Self::ReplaceWallet, + Self::Disconnect, + ]; + + async fn apply(self, authority: &SigningHostRole) { + match self { + Self::ClearOtherProduct => { + authority.clear_product_state("other.dot").await.unwrap() + } + Self::ClearProduct => authority.clear_product_state(" MYAPP.DOT ").await.unwrap(), + Self::Reactivate => authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(), + Self::ReplaceWallet => authority + .activate_local_session(vec![0xCD; 16]) + .await + .unwrap(), + Self::Disconnect => authority.disconnect().await, + } + } + } + #[derive(Clone)] struct StubRingResolver { collection: [u8; 32], @@ -4292,6 +4477,12 @@ mod tests { .grant_auto_signing(&stale_session, "other.dot") .expect("other product grant succeeds"); let context = authority.native_chat_context(&stale_session).unwrap(); + let product_context = authority + .native_chat_product_context(&stale_session, "myapp.dot") + .unwrap(); + let other_context = authority + .native_chat_product_context(&stale_session, "other.dot") + .unwrap(); let wallet = futures::executor::block_on(authority.native_chat.wallet(&context)).unwrap(); let custody = Arc::downgrade(&wallet); drop(wallet); @@ -4302,6 +4493,18 @@ mod tests { custody.upgrade().is_some(), "product clear must preserve wallet custody" ); + assert_eq!( + ( + product_context.require_current(), + other_context.require_current(), + context.require_current(), + ), + ( + Err(truapi::latest::HostProductDeviceChatError::NotConnected), + Ok(()), + Ok(()), + ), + ); let current_session = authority.current_session().expect("session remains active"); let (_, current_generation) = authority @@ -4323,6 +4526,77 @@ mod tests { authority.grant_auto_signing(&stale_session, "myapp.dot"), Err(AuthorityError::Disconnected) )); + authority + .grant_auto_signing(&stale_session, "other.dot") + .expect("another product's snapshot remains authorized"); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())).unwrap(); + assert_eq!( + (other_context.require_current(), context.require_current()), + ( + Err(truapi::latest::HostProductDeviceChatError::NotConnected), + Err(truapi::latest::HostProductDeviceChatError::NotConnected), + ), + ); + } + + #[test] + fn product_review_revalidation_preserves_unrelated_work_and_fences_revocation() { + use futures::FutureExt; + + for change in AuthorityChange::ALL { + let (release, gate) = futures::channel::oneshot::channel(); + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + ..Default::default() + }); + *platform + .resource_allocation_confirmation_gate + .lock() + .unwrap() = Some(gate); + let (services, authority) = signing_runtime_with_platform(platform); + futures::executor::block_on(async { + authority + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let runtime = product_runtime(services, authority.clone()); + let cx = CallContext::default(); + let allocation = ResourceAllocation::request( + &runtime, + &cx, + HostRequestResourceAllocationRequest::V1( + v01::HostRequestResourceAllocationRequest { + resources: vec![v01::AllocatableResource::AutoSigning], + }, + ), + ); + futures::pin_mut!(allocation); + assert!(allocation.as_mut().now_or_never().is_none()); + change.apply(&authority).await; + release.send(()).unwrap(); + let result = allocation.await; + if matches!(change, AuthorityChange::ClearOtherProduct) { + assert_eq!( + result.unwrap(), + HostRequestResourceAllocationResponse::V1( + v01::HostRequestResourceAllocationResponse { + outcomes: vec![v01::AllocationOutcome::Allocated], + }, + ), + ); + } else { + assert!(result.is_err(), "{change:?}: {result:?}"); + assert!( + authority + .local_grants + .lock() + .unwrap() + .auto_signing_grants + .is_empty() + ); + } + }); + } } #[test] diff --git a/rust/crates/truapi/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi/src/runtime/signing_host/sso_responder.rs index 48fed9f598..883189e604 100644 --- a/rust/crates/truapi/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi/src/runtime/signing_host/sso_responder.rs @@ -955,7 +955,10 @@ pub async fn allocate_statement_store_allowance( product_id: &str, policy: OnExistingAllowancePolicy, ) -> Result { - signing_host.require_current_session(session)?; + #[cfg(any(test, not(target_arch = "wasm32")))] + use super::allowance_renewal::{self, StatementRenewalTarget}; + + signing_host.require_current_product_session(session, product_id)?; let entropy = signing_host.root_entropy()?; let allowance = derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id])?; @@ -979,7 +982,18 @@ pub async fn allocate_statement_store_allowance( policy, ) .await?; - signing_host.require_current_session(session)?; + #[cfg(any(test, not(target_arch = "wasm32")))] + if let Err(reason) = allowance_renewal::track( + signing_host, + vec![StatementRenewalTarget::ProductStatementAllowance { + product_id: product_id.to_string(), + }], + ) + .await + { + warn!(%product_id, %reason, "failed to record statement-store renewal target"); + } + signing_host.require_current_product_session(session, product_id)?; Ok(StatementStoreAllocation { secret: allowance.secret.to_bytes().to_vec(), period, @@ -994,7 +1008,7 @@ pub(super) async fn allocate_product_statement_store_allowance( derivation_index: &v01::DerivationIndex, policy: OnExistingAllowancePolicy, ) -> Result<(), AllowanceAllocationError> { - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; #[cfg(feature = "test-host")] if signing_host.grants_allowances_unchecked() { return Ok(()); @@ -1024,7 +1038,7 @@ async fn register_statement_store_target( register_statement_account_pooled, scan_collections, }; - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; let candidates = signing_host.reserved_person_collection_candidates(session)?; let client = services .statement_store @@ -1050,7 +1064,7 @@ async fn register_statement_store_target( reuse_existing, ) .await?; - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; if let Some((collection, seq)) = allocated_in(&scans) { debug!( %product_id, @@ -1068,7 +1082,7 @@ async fn register_statement_store_target( resource: "statement-store", }); } - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; let outcome = register_statement_account_pooled( rpc, &chain.metadata, @@ -1114,7 +1128,7 @@ async fn register_statement_store_target( } } } - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; Ok(period) } @@ -1131,7 +1145,7 @@ pub async fn allocate_bulletin_allowance( wait_bulletin_authorization, }; - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; let entropy = signing_host.root_entropy()?; let allowance = derive_sr25519_hard_path(&entropy, &["allowance", "bulletin", product_id])?; #[cfg(feature = "test-host")] @@ -1154,7 +1168,7 @@ pub async fn allocate_bulletin_allowance( if matches!(policy, OnExistingAllowancePolicy::Ignore) && current_allowance.is_some_and(|allowance| allowance.available()) { - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; return Ok(allowance.secret.to_bytes().to_vec()); } @@ -1189,7 +1203,7 @@ pub async fn allocate_bulletin_allowance( current_unix_secs()?, period_duration, )?; - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; let outcome = claim_long_term_storage( statement_allowance::LongTermStorageClaim { rpc: people_rpc, @@ -1201,7 +1215,11 @@ pub async fn allocate_bulletin_allowance( period, ring: &membership.ring, }, - || signing_host.require_current_session(session).is_ok(), + || { + signing_host + .require_current_product_session(session, product_id) + .is_ok() + }, ) .await?; let statement_allowance::LongTermStorageOutcome::Claimed { @@ -1230,7 +1248,7 @@ pub async fn allocate_bulletin_allowance( remained_transactions = authorization.remained_transactions, "Bulletin authorization visible" ); - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; Ok(allowance.secret.to_bytes().to_vec()) } @@ -1258,7 +1276,7 @@ pub async fn allocate_smart_contract_allowance( use crate::host_logic::features; use crate::runtime::statement_allowance::{self, ChainClient, find_including_rings, pgas}; - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; // PGAS credits the product account the caller named. let target = signing_host @@ -1295,7 +1313,7 @@ pub async fn allocate_smart_contract_allowance( && pgas::holds_a_full_claim(asset_hub_client.rpc(), &asset_hub.metadata, &target).await? { debug!(%product_id, "PGAS allowance already funded; leaving it alone"); - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; return Ok(()); } let network_suffix = @@ -1317,7 +1335,7 @@ pub async fn allocate_smart_contract_allowance( .next() .ok_or(AllowanceAllocationError::MissingPersonhoodMembership { resource: "PGAS" })?; - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; let outcome = pgas::claim_pgas(pgas::PgasClaim { asset_hub_rpc: asset_hub_client.rpc(), asset_hub: &asset_hub, @@ -1337,7 +1355,7 @@ pub async fn allocate_smart_contract_allowance( block = %outcome.block_hash, "claimed PGAS allowance" ); - signing_host.require_current_session(session)?; + signing_host.require_current_product_session(session, product_id)?; Ok(()) } diff --git a/rust/crates/truapi/src/runtime/signing_host/sso_service.rs b/rust/crates/truapi/src/runtime/signing_host/sso_service.rs index ed35588a1c..4337bd8113 100644 --- a/rust/crates/truapi/src/runtime/signing_host/sso_service.rs +++ b/rust/crates/truapi/src/runtime/signing_host/sso_service.rs @@ -463,12 +463,12 @@ impl SigningHostSsoService { } self.signing_host - .require_current_session(&cx.session) + .require_current_product_session(&cx.session, &request.calling_product_id) .map_err(|err| err.to_string())?; let mut outcomes = Vec::with_capacity(request.resources.len()); for resource in request.resources { self.signing_host - .require_current_session(&cx.session) + .require_current_product_session(&cx.session, &request.calling_product_id) .map_err(|err| err.to_string())?; if cx.call.cancel().is_cancelled() { return Err(WITHDRAWN.to_string()); @@ -482,7 +482,7 @@ impl SigningHostSsoService { ) .await; self.signing_host - .require_current_session(&cx.session) + .require_current_product_session(&cx.session, &request.calling_product_id) .map_err(|err| err.to_string())?; outcomes.push(outcome.unwrap_or_else(|err| { let reason = err.to_string(); @@ -667,7 +667,7 @@ impl SigningHostSsoService { }; self.signing_host - .require_current_session(&cx.session) + .require_current_product_session(&cx.session, &request.calling_product_id) .map_err(|_| WireError::V1(api::HostProductDeviceChatError::NotConnected))?; let calling_product_id = normalize_product_identifier(&request.calling_product_id) .map_err(|_| WireError::V1(api::HostProductDeviceChatError::InvalidRequest))?; diff --git a/rust/crates/truapi/src/runtime/signing_host/tests/allowance_keys.rs b/rust/crates/truapi/src/runtime/signing_host/tests/allowance_keys.rs index a8411654f6..8b81217a2e 100644 --- a/rust/crates/truapi/src/runtime/signing_host/tests/allowance_keys.rs +++ b/rust/crates/truapi/src/runtime/signing_host/tests/allowance_keys.rs @@ -25,7 +25,11 @@ fn chain_with_allocated_slot() -> Arc { let allowance = derive_sr25519_hard_path(&ENTROPY, &["allowance", "statement-store", PRODUCT_ID]) .expect("allowance derivation succeeds"); - let slot_entry = (allowance.public.to_bytes(), 0u32, 0u64).encode(); + chain_with_allocated_target(allowance.public.to_bytes()) +} + +fn chain_with_allocated_target(target: [u8; 32]) -> Arc { + let slot_entry = (target, 0u32, 0u64).encode(); let people_row = slot::testing::slot_row( derive_full_person_ring_vrf_entropy(&ENTROPY, TEST_NETWORK_SUFFIX), TEST_NETWORK_SUFFIX.as_bytes(), @@ -134,7 +138,7 @@ fn current_generation(signing_host: &SigningHostRole) -> u64 { .local_grants .lock() .expect("local AutoSigning grant mutex poisoned") - .activation_generation + .generation } fn remembered(signing_host: &SigningHostRole, product_id: &str, period: u32) -> Option<[u8; 64]> { @@ -143,7 +147,7 @@ fn remembered(signing_host: &SigningHostRole, product_id: &str, period: u32) -> .lock() .expect("local AutoSigning grant mutex poisoned"); state - .statement_allowance_key(state.activation_generation, product_id, period) + .statement_allowance_key(state.generation, product_id, period) .expect("the generation is current") .map(|key| key.secret) } @@ -214,6 +218,174 @@ fn clearing_a_product_forgets_only_its_key() { ); } +#[test] +fn clearing_another_product_preserves_in_flight_allowance_authority() { + for previously_bound in [false, true] { + let platform = chain_with_allocated_slot(); + let signing_host = active_signing_host(platform.clone()); + futures::executor::block_on(async { + if previously_bound { + signing_host.clear_product_state("other.dot").await.unwrap(); + } + let (release, gate) = futures::channel::oneshot::channel(); + *platform.rpc_method_responses_gate.lock().unwrap() = Some(gate); + let session = signing_host.current_session().unwrap(); + let cx = CallContext::default(); + let allocation = + signing_host.statement_store_allowance_key(&cx, &session, PRODUCT_ID.to_string()); + futures::pin_mut!(allocation); + assert!(allocation.as_mut().now_or_never().is_none()); + signing_host.clear_product_state("other.dot").await.unwrap(); + release.send(()).unwrap(); + let key = futures::select! { + result = allocation.fuse() => result, + _ = futures_timer::Delay::new(std::time::Duration::from_secs(30)).fuse() => { + panic!("the allocation blocked after releasing the chain response") + } + } + .expect("clearing another product must not disconnect this allocation"); + let expected = + derive_sr25519_hard_path(&ENTROPY, &["allowance", "statement-store", PRODUCT_ID]) + .unwrap(); + assert_eq!(key.public_key, expected.public.to_bytes()); + }); + } +} + +#[test] +fn product_and_account_revocation_fence_in_flight_allowance_keys() { + for change in [ + AuthorityChange::ClearProduct, + AuthorityChange::Reactivate, + AuthorityChange::Disconnect, + ] { + let platform = chain_with_allocated_slot(); + let signing_host = active_signing_host(platform.clone()); + futures::executor::block_on(async { + let (release, gate) = futures::channel::oneshot::channel(); + *platform.rpc_method_responses_gate.lock().unwrap() = Some(gate); + let session = signing_host.current_session().unwrap(); + let cx = CallContext::default(); + let allocation = + signing_host.statement_store_allowance_key(&cx, &session, PRODUCT_ID.to_string()); + futures::pin_mut!(allocation); + assert!(allocation.as_mut().now_or_never().is_none()); + change.apply(&signing_host).await; + release.send(()).unwrap(); + let result = futures::select! { + result = allocation.fuse() => result, + _ = futures_timer::Delay::new(std::time::Duration::from_secs(30)).fuse() => { + panic!("the allocation blocked after releasing the chain response") + } + }; + assert!( + matches!(result, Err(AuthorityError::Disconnected)), + "{change:?}: {result:?}" + ); + assert_eq!( + remembered( + &signing_host, + PRODUCT_ID, + slot::current_period(crate::unix_time::current_unix_secs()) + ), + None + ); + }); + } +} + +#[test] +fn raw_product_allowance_revalidates_only_its_product_and_account() { + use super::super::sso_responder::allocate_product_statement_store_allowance; + use crate::host_internal::sso_messages::OnExistingAllowancePolicy; + + for change in AuthorityChange::ALL { + let account = v01::ProductAccountId { + dot_ns_identifier: PRODUCT_ID.to_string(), + derivation_index: v01::DerivationIndex::Raw([0x44; 32]), + }; + let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); + let target = derive_product_keypair(&root, PRODUCT_ID, [0x44; 32]) + .unwrap() + .public + .to_bytes(); + let platform = chain_with_allocated_target(target); + let (services, signing_host) = signing_runtime_with_platform(platform.clone()); + futures::executor::block_on(async { + signing_host + .activate_local_session(ENTROPY.to_vec()) + .await + .unwrap(); + let (release, gate) = futures::channel::oneshot::channel(); + *platform.rpc_method_responses_gate.lock().unwrap() = Some(gate); + let session = signing_host.current_session().unwrap(); + let allocation = allocate_product_statement_store_allowance( + &services, + &signing_host, + &session, + PRODUCT_ID, + &account.derivation_index, + OnExistingAllowancePolicy::Ignore, + ); + futures::pin_mut!(allocation); + assert!(allocation.as_mut().now_or_never().is_none()); + change.apply(&signing_host).await; + release.send(()).unwrap(); + let result = futures::select! { + result = allocation.fuse() => result, + _ = futures_timer::Delay::new(std::time::Duration::from_secs(30)).fuse() => { + panic!("the raw allocation blocked after releasing the chain response") + } + } + .map_err(|error| error.into_authority_error()); + let expected = if matches!(change, AuthorityChange::ClearOtherProduct) { + Ok(()) + } else { + Err(AuthorityError::Disconnected) + }; + assert_eq!(result, expected, "{change:?}"); + }); + } +} + +#[test] +fn product_revocation_fences_allowance_cache_commits() { + let signing_host = active_signing_host(Arc::new(StubPlatform::default())); + let stale_generation = current_generation(&signing_host); + futures::executor::block_on(signing_host.clear_product_state(PRODUCT_ID)).unwrap(); + let result = signing_host + .local_grants + .lock() + .unwrap() + .remember_statement_allowance_key( + stale_generation, + PRODUCT_ID.to_string(), + PERIOD, + secret_key(), + ); + assert_eq!( + (result, remembered(&signing_host, PRODUCT_ID, PERIOD)), + (Err(AuthorityError::Disconnected), None) + ); + + let fresh_generation = current_generation(&signing_host); + futures::executor::block_on(signing_host.clear_product_state("unrelated.dot")).unwrap(); + let result = signing_host + .local_grants + .lock() + .unwrap() + .remember_statement_allowance_key( + fresh_generation, + PRODUCT_ID.to_string(), + PERIOD, + secret_key(), + ); + assert_eq!( + (result, remembered(&signing_host, PRODUCT_ID, PERIOD)), + (Ok(()), Some(SECRET)) + ); +} + #[test] fn a_replaced_session_is_not_served_the_new_sessions_key() { let signing_host = active_signing_host(Arc::new(StubPlatform::default())); diff --git a/rust/crates/truapi/src/runtime/tests.rs b/rust/crates/truapi/src/runtime/tests.rs index 24c6d47e4a..65cc4ae09f 100644 --- a/rust/crates/truapi/src/runtime/tests.rs +++ b/rust/crates/truapi/src/runtime/tests.rs @@ -6,6 +6,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use crate::platform::{ AuthState, CoreStorage as PlatformCoreStorage, CoreStorageKey, PermissionAuthorizationRequest, }; +use crate::runtime::signing_host::LocalActivation; use parity_scale_codec::Encode; use truapi::api::{ Account, Chain, Entropy, ExpandedCard, Game, LocalStorage, Notifications, Permissions, @@ -821,6 +822,8 @@ struct AudienceContactsPlatform { after_lookup: parking_lot::Mutex>>, after_pick: parking_lot::Mutex>>, after_labels: parking_lot::Mutex>>, + pick_gate: Mutex>>, + labels_gate: Mutex>>, } impl AudienceContactsPlatform { @@ -836,6 +839,8 @@ impl AudienceContactsPlatform { after_lookup: Default::default(), after_pick: Default::default(), after_labels: Default::default(), + pick_gate: Default::default(), + labels_gate: Default::default(), }) } } @@ -860,6 +865,10 @@ impl crate::platform::ContactsPlatform for AudienceContactsPlatform { selection: crate::platform::ContactSelection, ) -> Result { self.selected.lock().push(selection.selected); + let gate = self.pick_gate.lock().unwrap().take(); + if let Some(gate) = gate { + let _ = gate.await; + } if let Some(changed) = self.after_pick.lock().take() { changed(); } @@ -872,6 +881,10 @@ impl crate::platform::ContactsPlatform for AudienceContactsPlatform { placed: crate::platform::PlacedContactLabels, ) -> Result { self.labels.lock().push(placed); + let gate = self.labels_gate.lock().unwrap().take(); + if let Some(gate) = gate { + let _ = gate.await; + } if let Some(changed) = self.after_labels.lock().take() { changed(); } @@ -890,6 +903,155 @@ fn pick_many( )) } +fn local_contacts_host( + contacts: Arc, +) -> (ProductRuntimeHost, Arc) { + let mut host = contacts_host("seity.dot", stub_platform(), Some(contacts), false); + let authority = SigningHostRole::new(host.services.clone(), "dot".into(), None); + futures::executor::block_on(authority.activate_local_session(vec![0xAB; 16])).unwrap(); + host.authority = authority.clone(); + (host, authority) +} + +fn revoke_contact_authority(authority: &SigningHostRole, product: Option<&str>) { + futures::executor::block_on(async { + if let Some(product) = product { + authority.clear_product_state(product).await.unwrap(); + } else { + authority + .activate_local_session(vec![0xAB; 16]) + .await + .unwrap(); + } + }); +} + +#[test] +fn local_contact_selection_preserves_directory_and_authority_fences() { + for revoked_product in [Some("other.dot"), Some("seity.dot"), None] { + let account = [10; 32]; + let contacts = AudienceContactsPlatform::new( + vec![account], + crate::platform::HostContactsPick::Picked { + accounts: vec![account], + }, + ); + let (host, authority) = local_contacts_host(contacts.clone()); + let (_, handles) = host.contacts_picker().unwrap(); + let handle = truapi::latest::ContactHandle { + bytes: handles.mint(&account), + }; + let (release, gate) = futures::channel::oneshot::channel(); + *contacts.pick_gate.lock().unwrap() = Some(gate); + let cx = CallContext::default(); + let mut call = Box::pin(Contacts::pick_many( + &host, + &cx, + HostContactsPickManyRequest::V1(truapi::latest::HostContactsPickManyRequest { + selected: vec![], + }), + )); + assert!(call.as_mut().now_or_never().is_none()); + revoke_contact_authority(&authority, revoked_product); + release.send(()).unwrap(); + let result = futures::executor::block_on(call); + if revoked_product == Some("other.dot") { + // Product clearing also invalidates the shared contact directory. + // Retry that selection, but do not report the account disconnected. + assert!(matches!( + result, + Err(CallError::Domain(HostContactsPickManyError::V1( + truapi::latest::HostContactsPickManyError::Unknown { .. } + ))) + )); + assert_eq!( + host.services.contact_handles.get(&handle.bytes, &handles), + None + ); + let HostContactsPickManyResponse::V1(response) = pick_many(&host, vec![]).unwrap(); + assert_eq!( + response.outcome, + truapi::latest::ContactPickManyOutcome::Picked { + handles: vec![handle], + } + ); + assert_eq!( + host.services.contact_handles.get(&handle.bytes, &handles), + Some(account) + ); + } else { + assert_eq!( + result, + Err(CallError::Domain(HostContactsPickManyError::V1( + truapi::latest::HostContactsPickManyError::NotConnected, + ))) + ); + assert_eq!( + host.services.contact_handles.get(&handle.bytes, &handles), + None + ); + } + } +} + +#[test] +fn local_contact_labels_withdraw_on_product_or_directory_revocation() { + for revoked_product in [Some("other.dot"), Some("seity.dot"), None] { + let account = [10; 32]; + let contacts = AudienceContactsPlatform::new( + vec![account], + crate::platform::HostContactsPick::Dismissed, + ); + let (host, authority) = local_contacts_host(contacts.clone()); + let (_, handles) = host.contacts_picker().unwrap(); + let (release, gate) = futures::channel::oneshot::channel(); + *contacts.labels_gate.lock().unwrap() = Some(gate); + let rect = truapi::latest::AvatarRect { + x: 0, + y: 0, + width: 180, + height: 24, + }; + let cx = CallContext::default(); + let mut call = Box::pin(Contacts::place_labels( + &host, + &cx, + HostContactsPlaceLabelsRequest::V1(truapi::latest::HostContactsPlaceLabelsRequest { + surface_width: 300, + surface_height: 200, + slots: vec![truapi::latest::ContactLabelSlot { + slot: 0, + handle: truapi::latest::ContactHandle { + bytes: handles.mint(&account), + }, + rect, + clip: rect, + }], + }), + )); + assert!(call.as_mut().now_or_never().is_none()); + revoke_contact_authority(&authority, revoked_product); + release.send(()).unwrap(); + let result = futures::executor::block_on(call); + if revoked_product == Some("other.dot") { + assert!(matches!( + result, + Err(CallError::Domain(HostContactsPlaceLabelsError::V1( + truapi::latest::HostContactsPlaceLabelsError::Unknown { .. } + ))) + )); + } else { + assert_eq!( + result, + Err(CallError::Domain(HostContactsPlaceLabelsError::V1( + truapi::latest::HostContactsPlaceLabelsError::NotConnected, + ))) + ); + } + assert!(contacts.labels.lock().last().unwrap().labels.is_empty()); + } +} + #[test] fn multi_picker_preserves_confirmed_empty_and_dismissed_outcomes() { use crate::platform::HostContactsPick; @@ -5208,6 +5370,156 @@ fn permission_prompts_name_the_requesting_product_and_execution_kind() { ); } +fn jam_peers(genesis: [u8; 32]) -> v01::RemotePermissionRequest { + v01::RemotePermissionRequest { + permission: v01::RemotePermission::JamPeers { genesis }, + } +} + +fn jam_peers_not_granted() -> CallError { + CallError::Domain(HostJamPeerTransportDialError::V1( + v01::HostJamPeerTransportDialError::NotGranted, + )) +} + +#[test] +fn jam_peer_dials_follow_the_stored_decision_without_prompting() { + futures::executor::block_on(async { + let platform = stub_platform(); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + for (genesis, status) in [ + ([0x11; 32], PermissionAuthorizationStatus::Authorized), + ([0x22; 32], PermissionAuthorizationStatus::Denied), + ] { + host.set_permission_authorization_status( + PermissionAuthorizationRequest::Remote(jam_peers(genesis)), + status, + ) + .await + .unwrap(); + } + + assert_eq!( + ( + host.require_jam_peers([0x11; 32]).await, + host.require_jam_peers([0x22; 32]).await, + platform.remote_permission_requests.lock().unwrap().clone(), + ), + (Ok(()), Err(jam_peers_not_granted()), vec![]), + ); + }); +} + +#[test] +fn an_undetermined_genesis_prompts_once_per_execution() { + futures::executor::block_on(async { + let genesis = [0x35; 32]; + let platform = Arc::new(StubPlatform { + remote_permission_decisions: Mutex::new( + [ + PermissionDecision::AllowOnce, + PermissionDecision::AllowAlways, + ] + .into(), + ), + ..Default::default() + }); + // A light client dialing six validators at once is asked once, and a + // one-use answer covers the rest of the execution. + let first = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + let dials = + futures::future::join_all((0..6).map(|_| first.require_jam_peers(genesis))).await; + assert_eq!(dials, vec![Ok(()); 6]); + assert_eq!(first.require_jam_peers(genesis).await, Ok(())); + + // The next execution holds no one-use grant, so it asks again; a + // lasting answer is persisted for the product and not asked again. + let second = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + assert_eq!(second.require_jam_peers(genesis).await, Ok(())); + let third = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + assert_eq!(third.require_jam_peers(genesis).await, Ok(())); + + assert_eq!( + platform.remote_permission_requests.lock().unwrap().clone(), + vec![jam_peers(genesis), jam_peers(genesis)], + ); + }); +} + +#[test] +fn jam_peer_one_time_grant_is_revoked_by_canonical_permission_authority() { + futures::executor::block_on(async { + let genesis = [0x37; 32]; + let platform = Arc::new(StubPlatform { + remote_permission_decisions: Mutex::new([PermissionDecision::AllowOnce].into()), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + assert_eq!(host.require_jam_peers(genesis).await, Ok(())); + + host.set_permission_authorization_status( + PermissionAuthorizationRequest::Remote(jam_peers(genesis)), + PermissionAuthorizationStatus::Denied, + ) + .await + .unwrap(); + + assert_eq!(host.require_jam_peers(genesis).await, Err(jam_peers_not_granted())); + assert_eq!( + platform.remote_permission_requests.lock().unwrap().clone(), + vec![jam_peers(genesis)], + ); + }); +} + +#[test] +fn each_genesis_is_a_separate_jam_peers_decision() { + futures::executor::block_on(async { + let (granted, refused) = ([0x35; 32], [0x36; 32]); + let platform = Arc::new(StubPlatform { + remote_permission_decisions: Mutex::new( + [PermissionDecision::AllowAlways, PermissionDecision::Deny].into(), + ), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + + assert_eq!(host.require_jam_peers(granted).await, Ok(())); + // A grant for one chain says nothing about another: it prompts, and + // the refusal is persisted for that genesis alone. + assert_eq!( + host.require_jam_peers(refused).await, + Err(jam_peers_not_granted()) + ); + assert_eq!( + host.require_jam_peers(refused).await, + Err(jam_peers_not_granted()) + ); + assert_eq!(host.require_jam_peers(granted).await, Ok(())); + + let statuses = host + .permission_authorization_statuses(vec![ + PermissionAuthorizationRequest::Remote(jam_peers(granted)), + PermissionAuthorizationRequest::Remote(jam_peers(refused)), + ]) + .await + .unwrap(); + assert_eq!( + ( + platform.remote_permission_requests.lock().unwrap().clone(), + statuses, + ), + ( + vec![jam_peers(granted), jam_peers(refused)], + vec![ + PermissionAuthorizationStatus::Authorized, + PermissionAuthorizationStatus::Denied, + ], + ), + ); + }); +} + #[test] fn navigate_to_rejects_invalid_input_without_prompting_or_calling_platform() { let platform = stub_platform(); @@ -6771,6 +7083,69 @@ fn get_user_id_rejects_a_cached_name_after_owner_changes_during_consent() { }); } +#[test] +fn get_user_id_accepts_refreshed_names_for_the_same_pairing_identity() { + futures::executor::block_on(async { + let (release, gate) = futures::channel::oneshot::channel(); + let platform = Arc::new(StubPlatform { + identity_disclosure_confirmed: true, + identity_disclosure_confirmation_gate: parking_lot::Mutex::new(Some(gate)), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform, test_spawner()); + let mut session = session_info(); + session.full_username = None; + session.lite_username = None; + install_pairing_session(&host, session.clone()); + let cx = CallContext::default(); + let disclosure = host.get_user_id(&cx, HostGetUserIdRequest::V1); + futures::pin_mut!(disclosure); + assert!(futures::poll!(&mut disclosure).is_pending()); + session.full_username = Some("refreshed.dot".into()); + install_pairing_session(&host, session); + release.send(()).unwrap(); + let HostGetUserIdResponse::V1(response) = disclosure.await.unwrap(); + assert_eq!(response.primary_username, "refreshed.dot"); + }); +} + +#[test] +fn get_user_id_local_consent_respects_product_and_account_revocation() { + for revoked_product in [Some("other.dot"), Some("seity.dot"), None] { + let (release, gate) = futures::channel::oneshot::channel(); + let platform = Arc::new(StubPlatform { + identity_disclosure_confirmed: true, + identity_disclosure_confirmation_gate: parking_lot::Mutex::new(Some(gate)), + ..Default::default() + }); + let mut host = contacts_host("seity.dot", platform, None, false); + let authority = SigningHostRole::new(host.services.clone(), "dot".into(), None); + futures::executor::block_on(authority.activate_local_session(vec![0xAB; 16])).unwrap(); + let state = authority.session_state(); + let mut session = state.current().unwrap(); + session.full_username = Some("alice.dot".into()); + state.set_session(session); + host.authority = authority.clone(); + let cx = CallContext::default(); + let mut disclosure = Box::pin(host.get_user_id(&cx, HostGetUserIdRequest::V1)); + assert!(disclosure.as_mut().now_or_never().is_none()); + revoke_contact_authority(&authority, revoked_product); + release.send(()).unwrap(); + let result = futures::executor::block_on(disclosure); + if revoked_product == Some("other.dot") { + let HostGetUserIdResponse::V1(response) = result.unwrap(); + assert_eq!(response.primary_username, "alice.dot"); + } else { + assert!(matches!( + result, + Err(CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::NotConnected + ))) + )); + } + } +} + #[test] fn get_user_id_respects_pre_authorized_identity_disclosure() { let platform = Arc::new(StubPlatform::default()); diff --git a/rust/crates/truapi/src/runtime/tests/signing.rs b/rust/crates/truapi/src/runtime/tests/signing.rs index 3c4c1d3b94..d8261c7292 100644 --- a/rust/crates/truapi/src/runtime/tests/signing.rs +++ b/rust/crates/truapi/src/runtime/tests/signing.rs @@ -1,5 +1,61 @@ use super::*; +#[test] +fn signing_cancelled_during_permission_review_cannot_persist_a_late_grant() { + let (release, gate) = futures::channel::oneshot::channel(); + let platform = Arc::new(StubPlatform { + remote_permission_gate: Mutex::new(Some(gate)), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + install_pairing_session(&host, sso_session_info()); + let cx = CallContext::with_parts( + "cancel-permission-review".to_string(), + truapi::CancellationToken::default(), + ); + let mut call = Box::pin(host.sign_raw( + &cx, + HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: v01::RawPayload::Bytes { + bytes: b"cancel before signing consent".to_vec(), + }, + }), + )); + assert!(call.as_mut().now_or_never().is_none()); + cx.cancel().cancel(); + assert!(matches!( + call.as_mut() + .now_or_never() + .expect("cancellation must stop waiting for permission"), + Err(CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::Unknown { .. } + ))) + )); + assert!( + release.send(()).is_err(), + "the permission review must be withdrawn" + ); + assert_eq!( + futures::executor::block_on(host.permission_authorization_status( + PermissionAuthorizationRequest::Remote(v01::RemotePermissionRequest { + permission: v01::RemotePermission::ChainSubmit, + }), + )) + .unwrap(), + PermissionAuthorizationStatus::NotDetermined, + ); + assert!(platform.sign_raw_reviews.lock().unwrap().is_empty()); + assert_eq!( + recorded_rpc_method_count(&platform.sent_rpc, "statement_submit"), + 0, + ); +} + #[test] #[allow(deprecated)] // Exercise the temporary API's paired-host wire routing. fn unwatermarked_signing_routes_product_and_legacy_accounts_without_downgrading() { diff --git a/rust/crates/truapi/src/test_support.rs b/rust/crates/truapi/src/test_support.rs index 5ed15f5810..7188cb4bfe 100644 --- a/rust/crates/truapi/src/test_support.rs +++ b/rust/crates/truapi/src/test_support.rs @@ -142,6 +142,7 @@ pub struct StubPlatform { pub remote_permission_denied: bool, pub remote_permission_decisions: Mutex>, + pub remote_permission_gate: Mutex>>, /// Every `remote_permission` request, in order, so a test can assert which /// domains reached the prompt and that a stored grant suppresses a re-ask. pub remote_permission_requests: Arc>>, @@ -238,6 +239,11 @@ pub struct StubPlatform { pub notification_id: u32, pub pushed_notifications: Arc>>, pub cancelled_notifications: Arc>>, + pub receiving_authority: parking_lot::Mutex>, + pub receiving_consent: AtomicBool, + pub receiving_prompts: AtomicUsize, + pub receiving_consent_gate: parking_lot::Mutex>>, + pub receiving_write_failure: AtomicBool, pub sent_rpc: Arc>>, pub rpc_responses: Vec, /// Responses keyed by JSON-RPC method, answered as each request arrives with @@ -1209,6 +1215,9 @@ impl PlatformCoreStorage for StubPlatform { reason: "injected core write failure".into(), }); } + if key == CoreStorageKey::NotificationReceiving && self.receiving_write_failure.load(Ordering::SeqCst) { + return Err(v01::GenericError { reason: "receiving storage unavailable".to_owned() }); + } if let CoreStorageKey::AuthSession = key { self.session_writes .lock() @@ -1276,6 +1285,17 @@ impl PlatformNavigation for StubPlatform { #[crate::platform::async_trait] impl PlatformNotifications for StubPlatform { + async fn receiver_authority(&self, product_id: &str) -> Result, v01::GenericError> { + Ok(self.receiving_authority.lock().clone().filter(|a| a.product_id == product_id)) + } + + async fn receiver_consent(&self, _authority: crate::platform::ReceivingAuthority, _watches: Vec) -> Result { + self.receiving_prompts.fetch_add(1, Ordering::SeqCst); + let gate = self.receiving_consent_gate.lock().take(); + if let Some(gate) = gate { let _ = gate.await; } + Ok(self.receiving_consent.load(Ordering::SeqCst)) + } + async fn push_notification( &self, notification: v01::HostPushNotificationRequest, @@ -1340,6 +1360,10 @@ impl PlatformPermissions for StubPlatform { .lock() .expect("remote permission list mutex poisoned") .push(request); + let gate = self.remote_permission_gate.lock().unwrap().take(); + if let Some(gate) = gate { + let _ = gate.await; + } if let Some(decision) = self .remote_permission_decisions .lock() diff --git a/rust/crates/truapi/src/truapi_core.rs b/rust/crates/truapi/src/truapi_core.rs index 121d258d47..a958f19bf4 100644 --- a/rust/crates/truapi/src/truapi_core.rs +++ b/rust/crates/truapi/src/truapi_core.rs @@ -187,6 +187,70 @@ mod tests { use crate::frame::{Payload, request_ids, subscription_ids}; use crate::test_support::{StubPlatform, runtime_config, test_spawner}; + /// A dial needs the user's `JamPeers` grant for its genesis, so a refusal + /// answers `NotGranted` before anything connects. The browser core never + /// asks: its JavaScript session serves trait 111 before frames reach it. + #[test] + fn a_dial_the_user_refuses_is_not_granted() { + let genesis = [0x35; 32]; + let platform = Arc::new(StubPlatform { + remote_permission_denied: true, + ..Default::default() + }); + let asked = platform.remote_permission_requests.clone(); + let (host_config, product) = runtime_config("dotli.dot"); + let core = + TrUApiCore::from_platform_with_config(platform, host_config, product, test_spawner()); + let ids = request_ids("jam_peer_transport_dial").expect("registered peer transport"); + let frame = ProtocolMessage { + request_id: "p:peer".into(), + payload: Payload { + trait_id: ids.trait_id, + method_id: ids.method_id, + message_type: crate::frame::MESSAGE_TYPE_REQUEST, + value: truapi::versioned::jam_peer_transport::HostJamPeerTransportDialRequest::V1( + truapi::latest::HostJamPeerTransportDialRequest { + genesis, + ip: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1], + port: 43000, + ed25519: [0; 32], + p256: None, + }, + ) + .encode(), + }, + }; + let response = futures::executor::block_on(core.receive_from_product(&frame.encode())) + .expect("registered method must answer explicitly"); + let prompted = if cfg!(target_arch = "wasm32") { + vec![] + } else { + vec![v01::RemotePermissionRequest { + permission: v01::RemotePermission::JamPeers { genesis }, + }] + }; + assert_eq!( + ( + ProtocolMessage::decode(&mut &response[..]) + .unwrap() + .payload + .value, + asked.lock().unwrap().clone(), + ), + ( + Err::( + truapi::CallError::Domain( + truapi::versioned::jam_peer_transport::HostJamPeerTransportDialError::V1( + truapi::latest::HostJamPeerTransportDialError::NotGranted, + ), + ), + ) + .encode(), + prompted, + ), + ); + } + /// A request payload must consume exactly its own bytes. Trailing bytes /// mean the sender and this build disagree about the shape, so running the /// handler on the prefix would act on a frame neither side agreed to. diff --git a/rust/crates/truapi/src/v01.rs b/rust/crates/truapi/src/v01.rs index 849a8fe8b5..24a01dad8b 100644 --- a/rust/crates/truapi/src/v01.rs +++ b/rust/crates/truapi/src/v01.rs @@ -9,6 +9,7 @@ mod contacts; mod entropy; mod expanded_card; mod game; +mod jam_peer_transport; mod local_storage; mod locale; mod notifications; @@ -36,6 +37,7 @@ pub use contacts::*; pub use entropy::*; pub use expanded_card::*; pub use game::*; +pub use jam_peer_transport::*; pub use local_storage::*; pub use locale::*; pub use notifications::*; diff --git a/rust/crates/truapi/src/v01/jam_peer_transport.rs b/rust/crates/truapi/src/v01/jam_peer_transport.rs new file mode 100644 index 0000000000..2132e32862 --- /dev/null +++ b/rust/crates/truapi/src/v01/jam_peer_transport.rs @@ -0,0 +1,194 @@ +use alloc::vec::Vec; +use parity_scale_codec::{Decode, Encode}; + +/// Host-side limits every `JamPeerTransport` implementation enforces. +pub const JAM_PEER_TRANSPORT_MAX_CONNECTIONS: u32 = 8; +/// Streams one execution may hold open per connection. +pub const JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION: u32 = 16; +/// Largest framed message accepted by `send` or delivered by `recv`. +pub const JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES: u32 = 1 << 20; +/// Bytes the host buffers per connection before applying back-pressure. +pub const JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION: u32 = 4 << 20; + +/// Failure to dial a JAM peer. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostJamPeerTransportDialError { + /// The product holds no `RemotePermission::JamPeers` grant for the + /// requested genesis, or this host offers no peer transport. + NotGranted, + /// The peer refused the connection or presented a certificate that does + /// not match the requested identity. + Refused, + /// The execution's connection budget (including pending dials), or its + /// eight distinct genesis decisions, is exhausted. + Limit, + /// The endpoint could not be reached. + Unreachable, +} + +/// Dial one JAM peer over JAMNP-S (QUIC) or WebTransport. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostJamPeerTransportDialRequest { + /// Genesis header hash authorizing this dial. Native QUIC derives its + /// ALPN from the first four bytes; WebTransport negotiates HTTP/3. + /// Neither transport authenticates the peer's chain membership. + pub genesis: [u8; 32], + /// Peer IP address, IPv6 or v4-mapped IPv6. + pub ip: [u8; 16], + /// Peer UDP port. + pub port: u16, + /// Ed25519 key the peer's TLS certificate must carry. + pub ed25519: [u8; 32], + /// Compressed P-256 peer key for WebTransport certificate hashes. + pub p256: Option<[u8; 33]>, +} + +/// An open connection handle. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostJamPeerTransportDialResponse { + /// Execution-local connection id. + pub conn: u32, +} + +/// Failure to open a stream. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostJamPeerTransportOpenError { + /// This execution has no peer-transport grant. + NotGranted, + /// The connection is closed or unknown. + Closed, + /// The stream cap for this connection is exhausted. + Limit, +} + +/// Open a bidirectional stream and send its kind byte. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostJamPeerTransportOpenRequest { + /// Connection returned by `dial`. + pub conn: u32, + /// JAMNP-S stream kind (UP 0, CE 128, ...). + pub kind: u8, +} + +/// An open stream handle. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostJamPeerTransportOpenResponse { + /// Execution-local stream id. + pub stream: u32, +} + +/// Failure to send a message. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostJamPeerTransportSendError { + /// The stream is closed, finished or unknown. + Closed, + /// The message exceeds the host's message limit. + TooLarge, + /// The per-connection buffer is full. + Limit, +} + +/// Send one framed message; the host adds the `u32` little-endian length. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostJamPeerTransportSendRequest { + /// Stream returned by `open` or reported by an `Accepted` event. + pub stream: u32, + /// Message bytes without length prefix. + pub message: Vec, + /// Finish the send side after this message. + pub fin: bool, +} + +/// Failure to receive from a stream. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostJamPeerTransportRecvError { + /// The stream is unknown or already fully consumed. + Closed, +} + +/// Poll one complete framed message without blocking. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostJamPeerTransportRecvRequest { + /// Stream to read from. + pub stream: u32, + /// Largest message the caller accepts. + pub max: u32, +} + +/// One unframed message, or none available yet. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostJamPeerTransportRecvResponse { + /// Complete message bytes without length prefix, or `None` when nothing + /// has arrived yet. + pub message: Option>, + /// The peer finished its send side; no further messages will arrive. + pub fin: bool, + /// The peer reset the stream; buffered data may be incomplete. + pub reset: bool, +} + +/// Failure to reset a stream. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostJamPeerTransportResetError { + /// The stream is unknown or already closed. + Closed, +} + +/// Abort both directions of a stream. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostJamPeerTransportResetRequest { + /// Stream to reset. + pub stream: u32, +} + +/// Failure to close a connection. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostJamPeerTransportCloseError { + /// The connection is unknown or already closed. + Closed, +} + +/// Close a connection and every stream on it. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostJamPeerTransportCloseRequest { + /// Connection to close. + pub conn: u32, +} + +/// Failure to drain events. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostJamPeerTransportEventsError { + /// This execution has no peer-transport grant. + NotGranted, +} + +/// Asynchronous transport notification. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum JamPeerTransportEvent { + /// The connection was closed by the peer or the host. + ConnClosed { + /// Connection that closed. + conn: u32, + }, + /// The peer finished its send side of a stream. + StreamFin { + /// Stream that finished. + stream: u32, + }, + /// The peer opened a stream to us on a dialed connection. + Accepted { + /// Connection the stream arrived on. + conn: u32, + /// Execution-local stream id. + stream: u32, + /// Stream kind byte the peer sent. + kind: u8, + }, +} + +/// Events in arrival order. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostJamPeerTransportEventsResponse { + /// Pending events; empty when nothing happened. + pub events: Vec, +} diff --git a/rust/crates/truapi/src/v01/notifications.rs b/rust/crates/truapi/src/v01/notifications.rs index d096e5d17c..0d3ee156ec 100644 --- a/rust/crates/truapi/src/v01/notifications.rs +++ b/rust/crates/truapi/src/v01/notifications.rs @@ -49,6 +49,192 @@ pub struct HostPushNotificationCancelRequest { pub id: u32, } +/// An authenticated source filter enrolled under host-owned receiving consent. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct ReceivingWatch { + /// Product-local opaque watch identifier. + pub id: String, + /// Canonical lowercase 32-byte chain genesis hash. + pub genesis: String, + /// Exact source channel, encoded as a canonical lowercase 32-byte hash. + pub channel: String, + /// One to four selected topics; every topic must occur in the signed header. + pub topics: Vec, + /// Approved Ed25519 public keys in canonical lowercase hex. + pub senders: Vec, + /// Expiration in Unix milliseconds, bounded to a JavaScript safe integer. + pub expires_at: u64, + /// Unix milliseconds before which delivery is muted; `u64::MAX` means forever. + pub muted_until: u64, + /// Product-relative activation route, retained locally and never relayed. + pub route: String, +} + +/// Receiving support, consent and durable synchronization state. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct HostNotificationReceiverStatus { + /// Whether this host supplies trusted receiving authority. + pub supported: bool, + /// Current OS permission for visible notifications. + pub os_permission: bool, + /// Whether current authority and watches have receiving consent. + pub consent: bool, + /// Whether receiving is locally enabled. + pub enabled: bool, + /// Compare-and-swap token for the durable registration. + pub revision: u64, + /// Whether the transport still needs to synchronize this revision. + pub sync_pending: bool, + /// Whether the selected platform transport is ready. + pub transport_ready: bool, +} + +/// Confirmed application handling, independent of transport acknowledgement. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] +pub enum ReceivingReceiptKind { + /// The application handled the event in the foreground; not proof of OS display. + Foreground, + /// The user read the event. + Read, + /// The product's OS notification API successfully displayed the event. + Displayed, +} + +/// Actual display outcome after recording a receipt, distinct from enrollment ACKs. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct HostNotificationReceiptResult { + /// An OS display was positively confirmed by the host or product. + pub displayed: bool, + /// A display is reserved but unconfirmed; do not start a competing fallback. + /// Explicit failure cancels the reservation; unknown outcomes remain pending until expiry. + pub display_pending: bool, +} + +/// Why an authenticated receiving event was queued. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Enum) +)] +pub enum ReceivingEventKind { + /// An authenticated event arrived without focusing the product. + Delivery, + /// The user activated a locally accepted notification. + Activation, +} + +/// A bounded durable event containing opaque identifiers, never plaintext. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "runtime", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + all(feature = "runtime", not(target_arch = "wasm32")), + derive(uniffi::Record) +)] +pub struct ReceivingEvent { + /// Durable sequence used for polling and acknowledgement. + pub sequence: u64, + /// Registration revision that accepted the event. + pub revision: u64, + /// Product-local watch identifier. + pub watch_id: String, + /// Authenticated event identifier. + pub event_id: String, + /// Delivery or user activation. + pub kind: ReceivingEventKind, + /// Locally enrolled product-relative route. + pub route: String, + /// Expiration in Unix milliseconds. + pub expires_at: u64, +} + +/// Receiving policy, persistence or support failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostNotificationReceivingError { + /// This host has no receiving adapter. + Unsupported, + /// Notification permission or scoped receiving consent was denied. + PermissionDenied, + /// The request violates the receiving schema or authenticated policy. + InvalidRequest { + /// Human-readable reason. + reason: String, + }, + /// The registration revision or trusted authority changed. + Conflict, + /// The bounded receiving store or watch budget is full. + Capacity, + /// Durable persistence failed. + Storage { + /// Human-readable reason. + reason: String, + }, +} + +/// Atomically replace the current authority's complete watch set. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNotificationReplaceReceiverRequest { + /// Revision observed by the caller. + pub expected_revision: u64, + /// Complete replacement, at most 256 watches and 10,000 senders total. + /// Each watch permits at most 1,000 senders. + pub watches: Vec, +} + +/// Disable locally without awaiting transport revocation. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNotificationDisableReceiverRequest { + /// Revision observed by the caller. + pub expected_revision: u64, +} + +/// Record confirmed foreground handling or reading. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNotificationRecordReceiptRequest { + /// Revision that accepted the event. + pub revision: u64, + /// Product-local watch identifier. + pub watch_id: String, + /// Authenticated event identifier. + pub event_id: String, + /// Confirmed handling kind. + pub kind: ReceivingReceiptKind, +} + +/// Poll durable events without creating a UI-lifetime subscription. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNotificationReceiverEventsRequest { + /// Return only events after this sequence. + pub after_sequence: u64, +} + +/// Acknowledge a durable event after application handling. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostNotificationAcknowledgeReceiverEventRequest { + /// Durable event sequence. + pub sequence: u64, +} + /// A host-admitted notification activation for the authenticated product, /// account and environment bound to this runtime. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] diff --git a/rust/crates/truapi/src/v01/permissions.rs b/rust/crates/truapi/src/v01/permissions.rs index 098c36e6a2..2b28c64923 100644 --- a/rust/crates/truapi/src/v01/permissions.rs +++ b/rust/crates/truapi/src/v01/permissions.rs @@ -52,8 +52,9 @@ pub enum HostDevicePermissionRequest { /// One remote-operation permission requested by the product (RFC 0002). /// -/// `ChainSubmit`, `PreimageSubmit`, and `StatementSubmit` are also triggered -/// implicitly by the corresponding business calls when not yet granted. +/// `ChainSubmit`, `PreimageSubmit`, `StatementSubmit` and `JamPeers` are also +/// triggered implicitly by the corresponding business calls when not yet +/// granted (`JamPeerTransport::dial` for `JamPeers`). #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, Display)] #[cfg_attr( all(feature = "runtime", not(target_arch = "wasm32")), @@ -89,6 +90,25 @@ pub enum RemotePermission { /// Submitting statements on behalf of the user via `remote_statement_store_submit`. #[display("submit statements")] StatementSubmit, + /// Peer access over JAMNP-S QUIC/WebTransport, authorized for the full + /// genesis hash through the `JamPeerTransport` service. + /// + /// The app names endpoints and pinned keys. Native QUIC negotiates the + /// genesis-derived ALPN; WebTransport negotiates HTTP/3. Neither proves + /// chain membership. The guest must verify chain data itself. The grant + /// carries no host account, signing or submission authority and does not + /// restrict which framed protocol messages the guest sends. + #[display( + "connections to JAM network 0x{:02x}{:02x}{:02x}{:02x}…", + genesis[0], + genesis[1], + genesis[2], + genesis[3] + )] + JamPeers { + /// Genesis header hash under which peer access is authorized. + genesis: [u8; 32], + }, } /// remote-permission request (RFC 0002). diff --git a/rust/crates/truapi/src/versioned.rs b/rust/crates/truapi/src/versioned.rs index 4257c256ad..0c80484604 100644 --- a/rust/crates/truapi/src/versioned.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -43,6 +43,7 @@ pub mod contacts; pub mod entropy; pub mod expanded_card; pub mod game; +pub mod jam_peer_transport; pub mod local_storage; pub mod locale; pub mod notifications; diff --git a/rust/crates/truapi/src/versioned/jam_peer_transport.rs b/rust/crates/truapi/src/versioned/jam_peer_transport.rs new file mode 100644 index 0000000000..ccadbdad35 --- /dev/null +++ b/rust/crates/truapi/src/versioned/jam_peer_transport.rs @@ -0,0 +1,27 @@ +//! Versioned wrappers for [`JamPeerTransport`](crate::api::JamPeerTransport) methods. + +use crate::v01; + +truapi_macros::versioned_type! { + pub enum HostJamPeerTransportDialRequest { V1 => v01::HostJamPeerTransportDialRequest } + pub enum HostJamPeerTransportDialResponse { V1 => v01::HostJamPeerTransportDialResponse } + pub enum HostJamPeerTransportDialError { V1 => v01::HostJamPeerTransportDialError } + pub enum HostJamPeerTransportOpenRequest { V1 => v01::HostJamPeerTransportOpenRequest } + pub enum HostJamPeerTransportOpenResponse { V1 => v01::HostJamPeerTransportOpenResponse } + pub enum HostJamPeerTransportOpenError { V1 => v01::HostJamPeerTransportOpenError } + pub enum HostJamPeerTransportSendRequest { V1 => v01::HostJamPeerTransportSendRequest } + pub enum HostJamPeerTransportSendResponse { V1 } + pub enum HostJamPeerTransportSendError { V1 => v01::HostJamPeerTransportSendError } + pub enum HostJamPeerTransportRecvRequest { V1 => v01::HostJamPeerTransportRecvRequest } + pub enum HostJamPeerTransportRecvResponse { V1 => v01::HostJamPeerTransportRecvResponse } + pub enum HostJamPeerTransportRecvError { V1 => v01::HostJamPeerTransportRecvError } + pub enum HostJamPeerTransportResetRequest { V1 => v01::HostJamPeerTransportResetRequest } + pub enum HostJamPeerTransportResetResponse { V1 } + pub enum HostJamPeerTransportResetError { V1 => v01::HostJamPeerTransportResetError } + pub enum HostJamPeerTransportCloseRequest { V1 => v01::HostJamPeerTransportCloseRequest } + pub enum HostJamPeerTransportCloseResponse { V1 } + pub enum HostJamPeerTransportCloseError { V1 => v01::HostJamPeerTransportCloseError } + pub enum HostJamPeerTransportEventsRequest { V1 } + pub enum HostJamPeerTransportEventsResponse { V1 => v01::HostJamPeerTransportEventsResponse } + pub enum HostJamPeerTransportEventsError { V1 => v01::HostJamPeerTransportEventsError } +} diff --git a/rust/crates/truapi/src/versioned/notifications.rs b/rust/crates/truapi/src/versioned/notifications.rs index d1b3630e7e..566c4c4fd6 100644 --- a/rust/crates/truapi/src/versioned/notifications.rs +++ b/rust/crates/truapi/src/versioned/notifications.rs @@ -1,6 +1,7 @@ //! Versioned wrappers for [`Notifications`](crate::api::Notifications) methods. use crate::v01; +use alloc::vec::Vec; truapi_macros::versioned_type! { pub enum HostPushNotificationRequest { V1 => v01::HostPushNotificationRequest } @@ -9,6 +10,19 @@ truapi_macros::versioned_type! { pub enum HostPushNotificationCancelRequest { V1 => v01::HostPushNotificationCancelRequest } pub enum HostPushNotificationCancelResponse { V1 } pub enum HostPushNotificationCancelError { V1 => v01::GenericError } + pub enum HostNotificationReceiverStatusRequest { V1 } + pub enum HostNotificationReceiverStatusResponse { V1 => v01::HostNotificationReceiverStatus } + pub enum HostNotificationReplaceReceiverRequest { V1 => v01::HostNotificationReplaceReceiverRequest } + pub enum HostNotificationReplaceReceiverResponse { V1 => v01::HostNotificationReceiverStatus } + pub enum HostNotificationDisableReceiverRequest { V1 => v01::HostNotificationDisableReceiverRequest } + pub enum HostNotificationDisableReceiverResponse { V1 => v01::HostNotificationReceiverStatus } + pub enum HostNotificationRecordReceiptRequest { V1 => v01::HostNotificationRecordReceiptRequest } + pub enum HostNotificationRecordReceiptResponse { V1 => v01::HostNotificationReceiptResult } + pub enum HostNotificationReceiverEventsRequest { V1 => v01::HostNotificationReceiverEventsRequest } + pub enum HostNotificationReceiverEventsResponse { V1 => Vec } + pub enum HostNotificationAcknowledgeReceiverEventRequest { V1 => v01::HostNotificationAcknowledgeReceiverEventRequest } + pub enum HostNotificationAcknowledgeReceiverEventResponse { V1 } + pub enum HostNotificationReceivingError { V1 => v01::HostNotificationReceivingError } pub enum NotificationActivationEventsRequest { V1 } pub enum NotificationActivationEventsResponse { V1 => v01::NotificationActivations } pub enum NotificationActivationEventsError { V1 => v01::GenericError } diff --git a/rust/crates/truapi/src/wasm.rs b/rust/crates/truapi/src/wasm.rs index bf299f9e75..fb86da4b1b 100644 --- a/rust/crates/truapi/src/wasm.rs +++ b/rust/crates/truapi/src/wasm.rs @@ -45,6 +45,9 @@ use crate::{ }; mod generated_bridge; +mod receiving; + +pub use receiving::WasmNotificationReceiver; use generated_bridge::JsBridge; @@ -570,7 +573,7 @@ fn get_optional_function(callbacks: &JsValue, name: &str) -> Result Function { .unchecked_into() } +fn absent_optional_callback() -> Function { + Closure:: JsValue>::new(|| JsValue::UNDEFINED) + .into_js_value() + .unchecked_into() +} + /// Stand-in for a callback of an optional capability the host left out. The /// core only holds an adapter for a capability the bridge reports as present, /// so this is never invoked; it throws rather than returning a value the @@ -1081,6 +1090,10 @@ fn install_worker_demand_observer( Ok(()) } +fn receiving_error_to_js(error: crate::latest::HostNotificationReceivingError) -> JsValue { + js_sys::Error::new(&format!("background receiving: {error:?}")).into() +} + /// JS-callable handle to a long-lived pairing-host runtime shared by product /// cores. #[wasm_bindgen] @@ -1090,6 +1103,83 @@ pub struct WasmPairingHostRuntime { #[wasm_bindgen] impl WasmPairingHostRuntime { + /// All durable registrations as SCALE `Vec`. + #[wasm_bindgen(js_name = receivingPending)] + pub async fn receiving_pending(&self) -> Result, JsValue> { + self.runtime.receiving().pending().await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Acknowledge the exact durable local revision synchronized by transport. + #[wasm_bindgen(js_name = receivingSynchronized)] + pub async fn receiving_synchronized(&self, product_id: String, revision: u64) -> Result { + self.runtime.receiving().synchronized(&product_id, revision).await.map_err(receiving_error_to_js) + } + + /// Verify source chain/channel/topics and all candidates; returns SCALE `Vec`. + #[wasm_bindgen(js_name = receivingIngest)] + pub async fn receiving_ingest( + &self, product_id: String, revision: u64, watch_id: String, + actual_genesis: String, actual_channel: String, actual_topics: Vec, frame: Vec, + ) -> Result, JsValue> { + self.runtime.receiving().ingest(&product_id, revision, watch_id, actual_genesis, actual_channel, actual_topics, frame) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Authenticate a raw SCALE statement; returns SCALE `Vec`. + #[wasm_bindgen(js_name = receivingIngestStatement)] + pub async fn receiving_ingest_statement( + &self, product_id: String, revision: u64, watch_id: String, + actual_genesis: String, statement: Vec, + ) -> Result, JsValue> { + self.runtime.receiving().ingest_statement(&product_id, revision, watch_id, actual_genesis, statement) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Reserve display after grace and revalidation; returns SCALE `Option`. + #[wasm_bindgen(js_name = receivingPrepareDisplay)] + pub async fn receiving_prepare_display(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.runtime.receiving().prepare_display(&product_id, revision, event_id) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Validate a click without enqueueing activation; returns SCALE `Option`. + #[wasm_bindgen(js_name = receivingValidateActivation)] + pub async fn receiving_validate_activation(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.runtime.receiving().validate_activation(&product_id, revision, event_id) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Confirm actual platform display, not ingestion or transport acknowledgement. + #[wasm_bindgen(js_name = receivingConfirmDisplay)] + pub async fn receiving_confirm_display(&self, product_id: String, revision: u64, event_id: String) -> Result<(), JsValue> { + self.runtime.receiving().confirm_display(&product_id, revision, event_id).await.map_err(receiving_error_to_js) + } + + /// Clear a reservation only after explicit platform display failure. + #[wasm_bindgen(js_name = receivingCancelDisplay)] + pub async fn receiving_cancel_display(&self, product_id: String, revision: u64, event_id: String) -> Result<(), JsValue> { + self.runtime.receiving().cancel_display(&product_id, revision, event_id).await.map_err(receiving_error_to_js) + } + + /// Resolve a click under current authority; returns SCALE `Option`. + #[wasm_bindgen(js_name = receivingActivate)] + pub async fn receiving_activate(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.runtime.receiving().activate(&product_id, revision, event_id) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Revoke locally without waiting for remote transport. + #[wasm_bindgen(js_name = receivingRevoke)] + pub async fn receiving_revoke(&self, product_id: String) -> Result<(), JsValue> { + self.runtime.receiving().revoke(&product_id).await.map_err(receiving_error_to_js) + } + + /// Queue synchronization after durable provider-token rotation. + #[wasm_bindgen(js_name = receivingMarkTransportChanged)] + pub async fn receiving_mark_transport_changed(&self, product_id: String) -> Result<(), JsValue> { + self.runtime.receiving().mark_transport_changed(&product_id).await.map_err(receiving_error_to_js) + } + /// Keep preimage submissions in the core instead of the Bulletin chain. /// /// For a test host whose wallet answers allowances in-page: the Bulletin @@ -1412,6 +1502,83 @@ pub struct WasmSigningHostRuntime { #[cfg(feature = "wasm-signing-host")] #[wasm_bindgen] impl WasmSigningHostRuntime { + /// All durable registrations as SCALE `Vec`. + #[wasm_bindgen(js_name = receivingPending)] + pub async fn receiving_pending(&self) -> Result, JsValue> { + self.runtime.receiving().pending().await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Acknowledge the exact durable local revision synchronized by transport. + #[wasm_bindgen(js_name = receivingSynchronized)] + pub async fn receiving_synchronized(&self, product_id: String, revision: u64) -> Result { + self.runtime.receiving().synchronized(&product_id, revision).await.map_err(receiving_error_to_js) + } + + /// Verify source chain/channel/topics and all candidates; returns SCALE `Vec`. + #[wasm_bindgen(js_name = receivingIngest)] + pub async fn receiving_ingest( + &self, product_id: String, revision: u64, watch_id: String, + actual_genesis: String, actual_channel: String, actual_topics: Vec, frame: Vec, + ) -> Result, JsValue> { + self.runtime.receiving().ingest(&product_id, revision, watch_id, actual_genesis, actual_channel, actual_topics, frame) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Authenticate a raw SCALE statement; returns SCALE `Vec`. + #[wasm_bindgen(js_name = receivingIngestStatement)] + pub async fn receiving_ingest_statement( + &self, product_id: String, revision: u64, watch_id: String, + actual_genesis: String, statement: Vec, + ) -> Result, JsValue> { + self.runtime.receiving().ingest_statement(&product_id, revision, watch_id, actual_genesis, statement) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Reserve display after grace and revalidation; returns SCALE `Option`. + #[wasm_bindgen(js_name = receivingPrepareDisplay)] + pub async fn receiving_prepare_display(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.runtime.receiving().prepare_display(&product_id, revision, event_id) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Validate a click without enqueueing activation; returns SCALE `Option`. + #[wasm_bindgen(js_name = receivingValidateActivation)] + pub async fn receiving_validate_activation(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.runtime.receiving().validate_activation(&product_id, revision, event_id) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Confirm actual platform display, not ingestion or transport acknowledgement. + #[wasm_bindgen(js_name = receivingConfirmDisplay)] + pub async fn receiving_confirm_display(&self, product_id: String, revision: u64, event_id: String) -> Result<(), JsValue> { + self.runtime.receiving().confirm_display(&product_id, revision, event_id).await.map_err(receiving_error_to_js) + } + + /// Clear a reservation only after explicit platform display failure. + #[wasm_bindgen(js_name = receivingCancelDisplay)] + pub async fn receiving_cancel_display(&self, product_id: String, revision: u64, event_id: String) -> Result<(), JsValue> { + self.runtime.receiving().cancel_display(&product_id, revision, event_id).await.map_err(receiving_error_to_js) + } + + /// Resolve a click under current authority; returns SCALE `Option`. + #[wasm_bindgen(js_name = receivingActivate)] + pub async fn receiving_activate(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.runtime.receiving().activate(&product_id, revision, event_id) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Revoke locally without waiting for remote transport. + #[wasm_bindgen(js_name = receivingRevoke)] + pub async fn receiving_revoke(&self, product_id: String) -> Result<(), JsValue> { + self.runtime.receiving().revoke(&product_id).await.map_err(receiving_error_to_js) + } + + /// Queue synchronization after durable provider-token rotation. + #[wasm_bindgen(js_name = receivingMarkTransportChanged)] + pub async fn receiving_mark_transport_changed(&self, product_id: String) -> Result<(), JsValue> { + self.runtime.receiving().mark_transport_changed(&product_id).await.map_err(receiving_error_to_js) + } + /// Answer resource allocation as granted without performing it. /// /// A test host serves suites that exercise allowance-dependent product diff --git a/rust/crates/truapi/src/wasm/receiving.rs b/rust/crates/truapi/src/wasm/receiving.rs new file mode 100644 index 0000000000..d28abfd79d --- /dev/null +++ b/rust/crates/truapi/src/wasm/receiving.rs @@ -0,0 +1,214 @@ +//! Wallet-free receiving owner for a host-origin service worker. + +use std::sync::Arc; +use js_sys::{Function, Uint8Array}; +use parity_scale_codec::{Decode, Encode}; +use send_wrapper::SendWrapper; +use wasm_bindgen::prelude::*; +use crate::latest::{self, HostNotificationReceivingError as Error}; +use crate::platform::ReceivingAuthority; +use crate::runtime::receiving::{ReceivingBackend, ReceivingService}; +use super::{get_function, get_optional_function, invoke_optional_bytes_return, invoke_bool, invoke_unit, generic, receiving_error_to_js}; + +struct JsReceivingBackend { + authority: SendWrapper, + consent: SendWrapper, + changed: SendWrapper, + load: SendWrapper, + save: SendWrapper, +} + +#[crate::platform::async_trait] +impl ReceivingBackend for JsReceivingBackend { + async fn receiver_authority(&self, product: &str) -> Result, latest::GenericError> { + let bytes = invoke_optional_bytes_return( + &self.authority, vec![JsValue::from_str(product)], + "receiverAuthority must return SCALE ReceivingAuthority or undefined", + ).await.map_err(generic)?; + bytes.map(|bytes| decode_exact(&bytes).map_err(generic)).transpose() + } + + async fn receiver_consent(&self, authority: ReceivingAuthority, watches: Vec) -> Result { + invoke_bool(&self.consent, vec![ + Uint8Array::from(authority.encode().as_slice()).into(), + Uint8Array::from(watches.encode().as_slice()).into(), + ]).await.map_err(generic) + } + + async fn receiver_changed(&self) -> Result<(), latest::GenericError> { + invoke_unit(&self.changed, Vec::new()).await.map_err(generic) + } + + async fn load(&self) -> Result>, latest::GenericError> { + invoke_optional_bytes_return(&self.load, Vec::new(), "readReceivingState must return bytes or undefined") + .await.map_err(generic) + } + + async fn save(&self, bytes: Vec) -> Result<(), latest::GenericError> { + invoke_unit(&self.save, vec![Uint8Array::from(bytes.as_slice()).into()]).await.map_err(generic) + } +} + +fn decode_exact(bytes: &[u8]) -> Result { + let mut input = bytes; + let value = T::decode(&mut input).map_err(|_| "invalid receiving SCALE payload".to_string())?; + if !input.is_empty() { + return Err("trailing receiving SCALE payload bytes".into()); + } + Ok(value) +} + +/// Standalone receiver with one durable writer and no wallet or product execution. +/// The host must serialize ownership across service-worker replacement and bind +/// command product IDs to trusted execution sessions, never message-body claims. +#[wasm_bindgen] +pub struct WasmNotificationReceiver { + service: Arc, +} + +#[wasm_bindgen] +impl WasmNotificationReceiver { + /// Construct from raw receiverAuthority/receiverConsent/receiverChanged, + /// readReceivingState and writeReceivingState callbacks. Persistence callbacks + /// are required; absent authority advertises unsupported, never enrollment. + #[wasm_bindgen(constructor)] + pub fn new(callbacks: JsValue) -> Result { + let backend = JsReceivingBackend { + authority: SendWrapper::new(get_optional_function(&callbacks, "receiverAuthority")? + .unwrap_or_else(super::absent_optional_callback)), + consent: SendWrapper::new(get_optional_function(&callbacks, "receiverConsent")? + .unwrap_or_else(|| super::missing_callback("receiverConsent"))), + changed: SendWrapper::new(get_optional_function(&callbacks, "receiverChanged")? + .unwrap_or_else(|| super::missing_callback("receiverChanged"))), + load: SendWrapper::new(get_function(&callbacks, "readReceivingState")?), + save: SendWrapper::new(get_function(&callbacks, "writeReceivingState")?), + }; + let spawner: crate::subscription::Spawner = Arc::new(|future| wasm_bindgen_futures::spawn_local(future)); + Ok(Self { service: Arc::new(ReceivingService::from_backend(Arc::new(backend), spawner)) }) + } + + /// Execute actions 2..7 under the immutable authority captured by the trusted + /// execution channel. Authority is SCALE ReceivingAuthority, never page input. + /// Request has no version tag; response is SCALE + /// `Result`. Action 3 requires + /// the forwarding runtime's ordinary Notifications permission authorization. + #[wasm_bindgen(js_name = commandForExecution)] + pub async fn command_for_execution(&self, authority: Vec, action: u8, payload: Vec) -> Vec { + let result = self.command_inner(&authority, action, &payload).await; + match result { + Ok(encoded) => encoded, + Err(error) => Result::<(), Error>::Err(error).encode(), + } + } + + /// All local registrations, including synchronized ones, as SCALE Vec. + #[wasm_bindgen(js_name = receivingPending)] + pub async fn receiving_pending(&self) -> Result, JsValue> { + self.service.pending().await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Acknowledge only the revision actually synchronized by transport. + #[wasm_bindgen(js_name = receivingSynchronized)] + pub async fn receiving_synchronized(&self, product_id: String, revision: u64) -> Result { + self.service.synchronized(&product_id, revision).await.map_err(receiving_error_to_js) + } + + /// Authenticate observed source metadata and carrier; returns SCALE `Vec`. + #[wasm_bindgen(js_name = receivingIngest)] + pub async fn receiving_ingest(&self, product_id: String, revision: u64, watch_id: String, + actual_genesis: String, actual_channel: String, actual_topics: Vec, frame: Vec, + ) -> Result, JsValue> { + self.service.ingest(&product_id, revision, watch_id, actual_genesis, actual_channel, actual_topics, frame) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Authenticate a SCALE statement including source metadata; returns SCALE `Vec`. + #[wasm_bindgen(js_name = receivingIngestStatement)] + pub async fn receiving_ingest_statement(&self, product_id: String, revision: u64, watch_id: String, + actual_genesis: String, statement: Vec, + ) -> Result, JsValue> { + self.service.ingest_statement(&product_id, revision, watch_id, actual_genesis, statement) + .await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Revalidate and reserve display after foreground grace; returns SCALE `Option`. + #[wasm_bindgen(js_name = receivingPrepareDisplay)] + pub async fn receiving_prepare_display(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.service.prepare_display(&product_id, revision, event_id).await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Confirm actual visible display, not ingestion or transport acknowledgement. + #[wasm_bindgen(js_name = receivingConfirmDisplay)] + pub async fn receiving_confirm_display(&self, product_id: String, revision: u64, event_id: String) -> Result<(), JsValue> { + self.service.confirm_display(&product_id, revision, event_id).await.map_err(receiving_error_to_js) + } + + /// Clear a reservation only after explicit display failure, not an unknown outcome. + #[wasm_bindgen(js_name = receivingCancelDisplay)] + pub async fn receiving_cancel_display(&self, product_id: String, revision: u64, event_id: String) -> Result<(), JsValue> { + self.service.cancel_display(&product_id, revision, event_id).await.map_err(receiving_error_to_js) + } + + /// Read-only click validation before loading the verified product; sequence is zero. + #[wasm_bindgen(js_name = receivingValidateActivation)] + pub async fn receiving_validate_activation(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.service.validate_activation(&product_id, revision, event_id).await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Queue durable activation only after the matching product is ready. + #[wasm_bindgen(js_name = receivingActivate)] + pub async fn receiving_activate(&self, product_id: String, revision: u64, event_id: String) -> Result, JsValue> { + self.service.activate(&product_id, revision, event_id).await.map(|value| value.encode()).map_err(receiving_error_to_js) + } + + /// Revoke locally before logout or destructive identity erasure. + #[wasm_bindgen(js_name = receivingRevoke)] + pub async fn receiving_revoke(&self, product_id: String) -> Result<(), JsValue> { + self.service.revoke(&product_id).await.map_err(receiving_error_to_js) + } + + /// Queue synchronization after the host durably changes the selected transport. + #[wasm_bindgen(js_name = receivingMarkTransportChanged)] + pub async fn receiving_mark_transport_changed(&self, product_id: String) -> Result<(), JsValue> { + self.service.mark_transport_changed(&product_id).await.map_err(receiving_error_to_js) + } +} + +impl WasmNotificationReceiver { + async fn command_inner(&self, authority: &[u8], action: u8, payload: &[u8]) -> Result, Error> { + if payload.len() > 2 * 1024 * 1024 || authority.len() > 4096 { + return Err(Error::Capacity); + } + fn request(payload: &[u8]) -> Result { + decode_exact(payload).map_err(|reason| Error::InvalidRequest { reason }) + } + let execution = self.service.for_execution(request::(authority)?); + match action { + 2 => { + request::<()>(payload)?; + Ok(execution.status().await.encode()) + } + 3 => { + let value: latest::HostNotificationReplaceReceiverRequest = request(payload)?; + Ok(execution.replace(value.expected_revision, value.watches).await.encode()) + } + 4 => { + let value: latest::HostNotificationDisableReceiverRequest = request(payload)?; + Ok(execution.disable(value.expected_revision).await.encode()) + } + 5 => { + let value: latest::HostNotificationRecordReceiptRequest = request(payload)?; + Ok(execution.receipt(value.revision, value.watch_id, value.event_id, value.kind).await.encode()) + } + 6 => { + let value: latest::HostNotificationReceiverEventsRequest = request(payload)?; + Ok(execution.events(value.after_sequence).await.encode()) + } + 7 => { + let value: latest::HostNotificationAcknowledgeReceiverEventRequest = request(payload)?; + Ok(execution.acknowledge(value.sequence).await.encode()) + } + _ => Err(Error::InvalidRequest { reason: "unknown receiving action".into() }), + } + } +} diff --git a/rust/crates/truapi/tests/jam_peer_transport_contract.rs b/rust/crates/truapi/tests/jam_peer_transport_contract.rs new file mode 100644 index 0000000000..38b541de2a --- /dev/null +++ b/rust/crates/truapi/tests/jam_peer_transport_contract.rs @@ -0,0 +1,169 @@ +//! JamPeerTransport contract regression test. +//! +//! Pins the frozen trait-111 wire ids and SCALE layouts, the `JamPeers` +//! permission's place in `RemotePermission`, and the genesis/ALPN helpers a +//! host uses on dial. + +use parity_scale_codec::{Decode, Encode}; +use truapi::generated::wire_table::{ + JAM_PEER_TRANSPORT_CLOSE, JAM_PEER_TRANSPORT_DIAL, JAM_PEER_TRANSPORT_EVENTS, + JAM_PEER_TRANSPORT_OPEN, JAM_PEER_TRANSPORT_RECV, JAM_PEER_TRANSPORT_RESET, + JAM_PEER_TRANSPORT_SEND, MethodIds, +}; +use truapi::jam_peer_transport::{InvalidGenesis, alpn, parse_genesis}; +use truapi::latest; +use truapi::versioned::jam_peer_transport; + +const GENESIS_HEX: &str = "353963b9cedfe4ea22038081052a5c151b06b55a4a026a97522cd0320cabf49f"; + +fn genesis() -> [u8; 32] { + parse_genesis(GENESIS_HEX).unwrap() +} + +#[test] +fn a_genesis_has_one_spelling_and_names_its_alpn() { + assert_eq!(parse_genesis(&format!("0x{GENESIS_HEX}")), Ok(genesis())); + assert_eq!(genesis()[..4], [0x35, 0x39, 0x63, 0xb9]); + assert_eq!(alpn(&genesis()), "jamnp-s/1/353963b9"); + assert_eq!(alpn(&[0xab; 32]), "jamnp-s/1/abababab"); + for text in [ + GENESIS_HEX[..62].to_string(), + GENESIS_HEX.to_uppercase(), + format!("{GENESIS_HEX}0"), + format!("0X{GENESIS_HEX}"), + format!("{}zz", &GENESIS_HEX[..62]), + String::new(), + ] { + assert_eq!(parse_genesis(&text), Err(InvalidGenesis), "{text}"); + } +} + +/// `JamPeers` is appended last, so every earlier permission keeps the SCALE +/// index stored decisions and older peers already use. +#[test] +fn jam_peers_is_the_last_remote_permission_and_names_its_genesis() { + for (permission, index) in [ + ( + latest::RemotePermission::Remote { + domains: Vec::new(), + }, + 0u8, + ), + (latest::RemotePermission::WebRtc, 1), + (latest::RemotePermission::ChainSubmit, 2), + (latest::RemotePermission::PreimageSubmit, 3), + (latest::RemotePermission::StatementSubmit, 4), + ] { + assert_eq!(permission.encode()[0], index, "{permission:?}"); + } + let jam = latest::RemotePermission::JamPeers { genesis: genesis() }; + let mut expected = vec![5u8]; + expected.extend_from_slice(&genesis()); + assert_eq!(jam.encode(), expected); + assert_eq!( + latest::RemotePermission::decode(&mut &expected[..]), + Ok(jam.clone()) + ); + assert_eq!(jam.to_string(), "connections to JAM network 0x353963b9…"); +} + +/// The frozen contract: namespace 111, methods 0..6 in this order, V1 payloads. +#[test] +fn the_wire_ids_and_scale_layout_match_the_frozen_contract() { + for (ids, method_id) in [ + (JAM_PEER_TRANSPORT_DIAL, 0), + (JAM_PEER_TRANSPORT_OPEN, 1), + (JAM_PEER_TRANSPORT_SEND, 2), + (JAM_PEER_TRANSPORT_RECV, 3), + (JAM_PEER_TRANSPORT_RESET, 4), + (JAM_PEER_TRANSPORT_CLOSE, 5), + (JAM_PEER_TRANSPORT_EVENTS, 6), + ] { + assert_eq!( + ids, + MethodIds { + trait_id: 111, + method_id + } + ); + } + + let dial = jam_peer_transport::HostJamPeerTransportDialRequest::V1( + latest::HostJamPeerTransportDialRequest { + genesis: genesis(), + ip: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1], + port: 43000, + ed25519: [0x11; 32], + p256: Some([0x02; 33]), + }, + ) + .encode(); + let mut expected = vec![0u8]; + expected.extend_from_slice(&genesis()); + expected.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1]); + expected.extend_from_slice(&43000u16.to_le_bytes()); + expected.extend_from_slice(&[0x11; 32]); + expected.push(1); + expected.extend_from_slice(&[0x02; 33]); + assert_eq!(dial, expected); + + assert_eq!( + jam_peer_transport::HostJamPeerTransportSendRequest::V1( + latest::HostJamPeerTransportSendRequest { + stream: 7, + message: vec![0xaa, 0xbb], + fin: true, + } + ) + .encode(), + vec![0, 7, 0, 0, 0, 8, 0xaa, 0xbb, 1] + ); + assert_eq!( + jam_peer_transport::HostJamPeerTransportRecvResponse::V1( + latest::HostJamPeerTransportRecvResponse { + message: None, + fin: false, + reset: true, + } + ) + .encode(), + vec![0, 0, 0, 1] + ); + assert_eq!( + jam_peer_transport::HostJamPeerTransportEventsResponse::V1( + latest::HostJamPeerTransportEventsResponse { + events: vec![ + latest::JamPeerTransportEvent::ConnClosed { conn: 1 }, + latest::JamPeerTransportEvent::StreamFin { stream: 2 }, + latest::JamPeerTransportEvent::Accepted { + conn: 1, + stream: 3, + kind: 0, + }, + ], + } + ) + .encode(), + vec![ + 0, 12, 0, 1, 0, 0, 0, 1, 2, 0, 0, 0, 2, 1, 0, 0, 0, 3, 0, 0, 0, 0 + ] + ); + assert_eq!( + jam_peer_transport::HostJamPeerTransportDialError::V1( + latest::HostJamPeerTransportDialError::Unreachable + ) + .encode(), + vec![0, 3] + ); + assert_eq!( + jam_peer_transport::HostJamPeerTransportEventsRequest::V1.encode(), + vec![0] + ); + assert_eq!(latest::JAM_PEER_TRANSPORT_MAX_CONNECTIONS, 8); + assert_eq!(latest::JAM_PEER_TRANSPORT_MAX_STREAMS_PER_CONNECTION, 16); + assert_eq!(latest::JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES, 1 << 20); + assert_eq!( + latest::JAM_PEER_TRANSPORT_MAX_BUFFERED_BYTES_PER_CONNECTION, + 4 << 20 + ); +} diff --git a/rust/crates/truapi/tests/live_jam_test_instance.rs b/rust/crates/truapi/tests/live_jam_test_instance.rs new file mode 100644 index 0000000000..cbf20ece34 --- /dev/null +++ b/rust/crates/truapi/tests/live_jam_test_instance.rs @@ -0,0 +1,445 @@ +//! `JamPeerTransport` through a native product runtime against JAM-TEST-INSTANCE +//! (six validators on 51.159.188.61). +//! +//! Every call is a product frame: through the generated dispatcher into +//! `ProductRuntimeHost`, whose dial asks the platform for +//! `RemotePermission::JamPeers` and then speaks JAMNP-S QUIC to the validator +//! the frame names. + +use std::collections::{BTreeSet, HashMap}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use parity_scale_codec::{Decode, Encode}; +use truapi::frame::{MESSAGE_TYPE_REQUEST, Payload, ProtocolMessage, request_ids}; +use truapi::platform::ProductContext; +use truapi::platform::mock::{MockPlatform, PermissionKind}; +use truapi::versioned::jam_peer_transport as wire; +use truapi::{CallError, latest}; +use truapi::{FrameSink, PairingHostRuntime, ProductRuntime}; + +// Shared harness; this binary uses only part of it. +#[allow(dead_code)] +mod common; +use common::{test_runtime_config, test_spawner}; + +const GENESIS: &str = "10c123f02eb6df4c01397d797a112055be691883baa2e82f83b618ed6ce45e46"; +/// `follow.json` slot timing of JAM-TEST-INSTANCE. +const SLOT_EPOCH_UNIX_MS: u64 = 1_735_732_800_000; +const SLOT_DURATION_MS: u64 = 6_000; +/// Validator UDP ports on 51.159.188.61 and the Ed25519 keys their +/// certificates carry, from JAM-TEST-INSTANCE's `bootnodes.json`. +const BOOTNODES: [(u16, &str); 6] = [ + ( + 43000, + "5f7eac6e6a73897aca3ddcc99b763664b9c7e8474d0549d22c553bca8a3d4570", + ), + ( + 43001, + "89a6ecb6e586291ef811d8cecb338e2a61ab2f70ed62c94e2f80979d202f1250", + ), + ( + 43002, + "273bfffaf8201d658547cd718953c613088ab4d1ee2db448dc481bf165e71ce7", + ), + ( + 43003, + "976b529948b1f855ebf146a1254d126eefa4d2ceafa1f9c3328adefdca4de71e", + ), + ( + 43004, + "f6be75919e06d1b5ae290a9320c5ece980b42c9d128c2e6f522f16eb77daab96", + ), + ( + 43005, + "b22bc9fd19b4dfb2ed0bf1a550cdba3da9afc35323081ea6a456da76bf1c6bef", + ), +]; +const UP_BLOCK_ANNOUNCEMENT: u8 = 0; + +/// The tests below compare the process's UDP sockets, so they must not run +/// side by side. +static SERIAL: Mutex<()> = Mutex::new(()); + +fn bytes32(hex: &str) -> [u8; 32] { + hex::decode(hex).unwrap().try_into().unwrap() +} + +fn dial_request(port: u16, ed25519: &str) -> wire::HostJamPeerTransportDialRequest { + wire::HostJamPeerTransportDialRequest::V1(latest::HostJamPeerTransportDialRequest { + genesis: bytes32(GENESIS), + ip: std::net::Ipv4Addr::new(51, 159, 188, 61) + .to_ipv6_mapped() + .octets(), + port, + ed25519: bytes32(ed25519), + p256: None, + }) +} + +type Dialed = + Result>; + +/// Keeps each response by request id. +#[derive(Default)] +struct Responses(Mutex>>); + +impl FrameSink for Responses { + fn emit_frame(&self, frame: Vec) { + let message = ProtocolMessage::decode(&mut &frame[..]).expect("decode emitted frame"); + self.0 + .lock() + .unwrap() + .insert(message.request_id, message.payload.value); + } +} + +/// One product connection driven by frames. +struct Product { + runtime: ProductRuntime, + responses: Arc, + next_id: AtomicU64, +} + +impl Product { + fn open(platform: Arc) -> Self { + let (host_config, _) = test_runtime_config(); + let host = PairingHostRuntime::new(platform, host_config, test_spawner()); + let responses = Arc::new(Responses::default()); + let product = ProductContext::new("jam.paseo".to_string()).unwrap(); + Self { + runtime: host.product_runtime(product, responses.clone()), + responses, + next_id: AtomicU64::new(0), + } + } + + /// Send one request frame and decode its response. + fn call(&self, method: &str, request: impl Encode) -> Response { + let ids = request_ids(method).expect("registered method"); + let request_id = format!("p:{}", self.next_id.fetch_add(1, Ordering::Relaxed)); + let frame = ProtocolMessage { + request_id: request_id.clone(), + payload: Payload { + trait_id: ids.trait_id, + method_id: ids.method_id, + message_type: MESSAGE_TYPE_REQUEST, + value: request.encode(), + }, + }; + futures::executor::block_on(self.runtime.receive_frame(frame.encode())) + .expect("the dispatcher accepts the frame"); + let value = self + .responses + .0 + .lock() + .unwrap() + .remove(&request_id) + .expect("a request is answered before its dispatch returns"); + Response::decode(&mut &value[..]).expect("decode response") + } + + /// Dial every bootnode at once, as a light client does, trying a + /// refused validator up to `attempts` times: a busy validator refuses + /// some connections and a light client dials it again. + fn dial_all(&self, attempts: u32) -> Vec { + let refused = CallError::Domain(wire::HostJamPeerTransportDialError::V1( + latest::HostJamPeerTransportDialError::Refused, + )); + std::thread::scope(|scope| { + let dials: Vec<_> = BOOTNODES + .iter() + .map(|(port, ed25519)| { + let refused = &refused; + scope.spawn(move || { + let mut attempt = 1; + loop { + let dial: Dialed = + self.call("jam_peer_transport_dial", dial_request(*port, ed25519)); + if dial.as_ref().err() != Some(refused) || attempt == attempts { + return dial; + } + eprintln!("validator :{port} refused attempt {attempt}; retrying"); + attempt += 1; + std::thread::sleep(Duration::from_secs(3)); + } + }) + }) + .collect(); + dials.into_iter().map(|dial| dial.join().unwrap()).collect() + }) + } + + fn recv(&self, stream: u32) -> latest::HostJamPeerTransportRecvResponse { + let response: Result< + wire::HostJamPeerTransportRecvResponse, + CallError, + > = self.call( + "jam_peer_transport_recv", + wire::HostJamPeerTransportRecvRequest::V1(latest::HostJamPeerTransportRecvRequest { + stream, + max: latest::JAM_PEER_TRANSPORT_MAX_MESSAGE_BYTES, + }), + ); + let wire::HostJamPeerTransportRecvResponse::V1(response) = + response.expect("the stream stays readable"); + response + } +} + +/// UDP sockets this process holds, by inode. +#[cfg(target_os = "linux")] +fn udp_sockets() -> BTreeSet { + let held: BTreeSet = std::fs::read_dir("/proc/self/fd") + .unwrap() + .filter_map(|entry| std::fs::read_link(entry.ok()?.path()).ok()) + .filter_map(|target| { + let target = target.to_str()?; + target + .strip_prefix("socket:[")? + .strip_suffix(']')? + .parse() + .ok() + }) + .collect(); + ["/proc/self/net/udp", "/proc/self/net/udp6"] + .iter() + .flat_map(|table| { + std::fs::read_to_string(table) + .unwrap_or_default() + .lines() + .skip(1) + .map(str::to_owned) + .collect::>() + }) + .filter_map(|line| line.split_whitespace().nth(9)?.parse().ok()) + .filter(|inode| held.contains(inode)) + .collect() +} + +fn blake2b_256(bytes: &[u8]) -> [u8; 32] { + blake2b_simd::Params::new() + .hash_length(32) + .hash(bytes) + .as_bytes() + .try_into() + .unwrap() +} + +fn slot_now() -> u64 { + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + (now_ms - SLOT_EPOCH_UNIX_MS) / SLOT_DURATION_MS +} + +/// What one validator told us on UP 0. +#[derive(Debug, Default)] +struct Up0 { + /// Finalized slot the peer's handshake claims. + finalized_slot: Option, + /// `(slot, header hash)` of each announced block. + announced: Vec<(u32, [u8; 32])>, +} + +#[test] +#[ignore = "needs network access to JAM-TEST-INSTANCE"] +fn dials_every_test_instance_validator_and_hears_block_announcements() { + let _serial = SERIAL + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let platform = Arc::new(MockPlatform::new()); + platform.grant_permission("JamPeers"); + let product = Product::open(platform.clone()); + eprintln!( + "ALPN {}", + truapi::jam_peer_transport::alpn(&bytes32(GENESIS)) + ); + + let started = Instant::now(); + let conns: Vec = product + .dial_all(5) + .into_iter() + .zip(BOOTNODES) + .map(|(dial, (port, _))| { + let wire::HostJamPeerTransportDialResponse::V1(dialed) = + dial.unwrap_or_else(|error| panic!("dial :{port} failed: {error:?}")); + dialed.conn + }) + .collect(); + eprintln!( + "dialed {} validators in {:?}", + conns.len(), + started.elapsed() + ); + let prompts = platform + .permission_log() + .iter() + .filter(|entry| entry.kind == PermissionKind::Remote) + .count(); + + // UP 0: Handshake = Final ++ len++[Leaf]; Final = Header Hash ++ Slot. + let mut handshake = bytes32(GENESIS).to_vec(); + handshake.extend_from_slice(&0u32.to_le_bytes()); + handshake.push(0); + let streams: Vec = conns + .iter() + .map(|&conn| { + let opened: Result< + wire::HostJamPeerTransportOpenResponse, + CallError, + > = product.call( + "jam_peer_transport_open", + wire::HostJamPeerTransportOpenRequest::V1( + latest::HostJamPeerTransportOpenRequest { + conn, + kind: UP_BLOCK_ANNOUNCEMENT, + }, + ), + ); + let wire::HostJamPeerTransportOpenResponse::V1(opened) = opened.unwrap(); + let sent: Result< + wire::HostJamPeerTransportSendResponse, + CallError, + > = product.call( + "jam_peer_transport_send", + wire::HostJamPeerTransportSendRequest::V1( + latest::HostJamPeerTransportSendRequest { + stream: opened.stream, + message: handshake.clone(), + fin: false, + }, + ), + ); + sent.unwrap(); + opened.stream + }) + .collect(); + + let mut peers: Vec = streams.iter().map(|_| Up0::default()).collect(); + let deadline = Instant::now() + Duration::from_secs(40); + while Instant::now() < deadline && peers.iter().any(|peer| peer.announced.is_empty()) { + for (peer, &stream) in peers.iter_mut().zip(&streams) { + let received = product.recv(stream); + assert!( + !received.fin && !received.reset, + "UP 0 stays open: {received:?}" + ); + let Some(message) = received.message else { + continue; + }; + if peer.finalized_slot.is_none() { + peer.finalized_slot = Some(u32::from_le_bytes(message[32..36].try_into().unwrap())); + continue; + } + // Announcement = Header ++ Final; the header's slot follows the + // parent hash, prior state root and extrinsic hash. + let header = &message[..message.len() - 36]; + let slot = u32::from_le_bytes(header[96..100].try_into().unwrap()); + peer.announced.push((slot, blake2b_256(header))); + } + std::thread::sleep(Duration::from_millis(20)); + } + let wall_slot = slot_now(); + for ((port, _), peer) in BOOTNODES.iter().zip(&peers) { + eprintln!( + "validator :{port} finalized_slot={:?} announced={:?}", + peer.finalized_slot, + peer.announced + .iter() + .map(|(slot, hash)| format!("{slot}:{}", hex::encode(&hash[..8]))) + .collect::>(), + ); + } + eprintln!("wall-clock slot {wall_slot}"); + + let events: Result< + wire::HostJamPeerTransportEventsResponse, + CallError, + > = product.call( + "jam_peer_transport_events", + wire::HostJamPeerTransportEventsRequest::V1, + ); + let wire::HostJamPeerTransportEventsResponse::V1(events) = events.unwrap(); + eprintln!("events {:?}", events.events); + assert!( + !events + .events + .iter() + .any(|event| matches!(event, latest::JamPeerTransportEvent::ConnClosed { .. })), + "no validator dropped us: {:?}", + events.events, + ); + + let live = |peer: &Up0| { + peer.finalized_slot.is_some() + && peer.announced.iter().any(|&(slot, _)| { + (wall_slot.saturating_sub(5)..=wall_slot + 1).contains(&u64::from(slot)) + }) + }; + let blocks: BTreeSet<_> = peers + .iter() + .flat_map(|peer| peer.announced.iter()) + .collect(); + let shared_blocks = blocks + .iter() + .filter(|&&block| { + peers + .iter() + .filter(|peer| peer.announced.contains(block)) + .count() + > 1 + }) + .count(); + assert_eq!( + ( + prompts, + peers.iter().filter(|peer| live(peer)).count(), + shared_blocks > 0 + ), + (1, BOOTNODES.len(), true), + "one prompt covers every dial; each validator completes the UP 0 handshake and announces \ + a block at the wall-clock slot; validators agree on at least one block", + ); + + for conn in conns { + let closed: Result< + wire::HostJamPeerTransportCloseResponse, + CallError, + > = product.call( + "jam_peer_transport_close", + wire::HostJamPeerTransportCloseRequest::V1(latest::HostJamPeerTransportCloseRequest { + conn, + }), + ); + closed.unwrap(); + } + product.runtime.dispose(); +} + +/// A refused product must not reach the network at all: no dial binds a +/// socket, so no packet can leave for the validators it named. +#[cfg(target_os = "linux")] +#[test] +fn a_refused_product_opens_no_socket_for_any_dial() { + let _serial = SERIAL + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let platform = Arc::new(MockPlatform::new()); + platform.revoke_permission("JamPeers"); + let product = Product::open(platform.clone()); + let before = udp_sockets(); + + let dials = product.dial_all(1); + + let not_granted = CallError::Domain(wire::HostJamPeerTransportDialError::V1( + latest::HostJamPeerTransportDialError::NotGranted, + )); + assert_eq!( + (dials, udp_sockets().difference(&before).count()), + (vec![Err(not_granted); BOOTNODES.len()], 0), + ); + product.runtime.dispose(); +}