From 349b9f596c950c3df53b837cd9484b9bf8e07e79 Mon Sep 17 00:00:00 2001 From: Nidish Date: Thu, 10 Sep 2026 14:34:46 +0530 Subject: [PATCH 01/10] feat(server): make the context scope effective by having each authority resolve the grant itself --- docs/design/product-manifest.md | 5 +- docs/rfcs/granted-scopes.md | 28 +- docs/rfcs/product-manifest.md | 9 +- .../src/host_logic/product_manifest.rs | 14 +- .../src/runtime/capabilities/account.rs | 34 ++- .../truapi-server/src/runtime/pairing_host.rs | 36 ++- .../src/runtime/product_manifest.rs | 100 ++++++- .../truapi-server/src/runtime/signing_host.rs | 251 +++++++++++++++++- .../crates/truapi-server/src/runtime/tests.rs | 82 ++++++ 9 files changed, 511 insertions(+), 48 deletions(-) diff --git a/docs/design/product-manifest.md b/docs/design/product-manifest.md index 085522626..1931d0f2b 100644 --- a/docs/design/product-manifest.md +++ b/docs/design/product-manifest.md @@ -29,7 +29,8 @@ type Icon = { type Granted = // v1 grants; unrecognised values are tolerated, not fatal | "all" // wildcard: every mediated interaction, present and future - | "storage"; // read this product's host-local storage + | "storage" // read this product's host-local storage + | "context"; // read this product's account and the identity behind it ``` ### Executable Manifest @@ -165,7 +166,7 @@ Each entry's value list scopes the grant: trustedProducts: { "wallet": ["all"], → every mediated interaction, now and later "tracker": ["storage"], → storage reads promptless; account reads still prompt - "hub": ["all", "storage"] → just ["all"]; a narrow value never carves into the wildcard + "hub": ["storage", "context"] → both, and no more when a fourth scope is defined } ``` diff --git a/docs/rfcs/granted-scopes.md b/docs/rfcs/granted-scopes.md index 52e110dab..0835cfe3f 100644 --- a/docs/rfcs/granted-scopes.md +++ b/docs/rfcs/granted-scopes.md @@ -8,12 +8,12 @@ owner: "@filippovecchiato" | | | | --------------- | ---------------------------------------------------------------------------------- | | **Start Date** | 2026-08-19 | -| **Description** | Widen `Granted` from the single `all` wildcard to `all` and `storage`. | +| **Description** | Widen `Granted` from the single `all` wildcard to `all`, `storage`, and `context`. | | **Authors** | Filippo Vecchiato | ## Summary -`Granted` gains a narrow value alongside `all`, so a publisher pre-approves a scope list per product instead of choosing between everything and nothing. +`Granted` gains two narrow values alongside `all`, so a publisher pre-approves a scope list per product instead of choosing between everything and nothing. ## Motivation @@ -21,30 +21,40 @@ owner: "@filippovecchiato" ## Detailed Design -[RFC — Product Manifest Format][manifest] gains one `Granted` value: +[RFC — Product Manifest Format][manifest] gains two `Granted` values: ```typescript -type Granted = 'all' | 'storage'; +type Granted = 'all' | 'storage' | 'context'; ``` | Value | Pre-approves | | --------- | ------------------------------------------------------------------------------------------------------- | | `all` | Every cross-product interaction the Host mediates on the granting product's behalf, present and future. | | `storage` | Reading the granting product's host-local storage. Read-only. | +| `context` | Acting as the granting product's account: reading it and the identity that follows from it, and producing proofs and signatures under its keys. | `trustedProducts` keeps its `Record` shape, so this needs no new field and no `$v` bump. -- **`all` is a superset, not a peer.** `["all"]` implies `storage`, so `["all", "storage"]` is `["all"]`. A Host MUST NOT read a narrower value as a restriction on `all`. Enumerating the narrow values covers the same interactions today but does not widen when a further value is defined — that difference is the point of enumerating. +- **`all` is a superset, not a peer.** `["all"]` implies `storage` and `context`, so `["all", "storage"]` is `["all"]`. A Host MUST NOT read a narrower value as a restriction on `all`. Enumerating the narrow values covers the same interactions today but does not widen when a further value is defined — that difference is the point of enumerating. - **Values are a set.** Order is not significant, duplicates collapse. -- **Scopes are independent.** `["storage"]` leaves every other interaction prompting as usual, and a scope defined later grants nothing retroactively. +- **Scopes are independent.** `["storage"]` leaves account interactions prompting as usual, and vice versa. - **Existing rules are unchanged.** Hosts MUST ignore unrecognised values and MUST NOT fail validation over them, so a Host implementing only `all` reads `["storage"]` as an empty grant and prompts. Publishers MUST NOT emit a value outside `Granted`. A grant never overrides a denial the user already gave. - **A key names a product, and a product is all its executables.** The key is the segment above the TLD, so `dim2.dot`, `app.dim2.dot` and `worker.dim2.dot` are one grantee: granting `dim2` grants every executable published beneath it. A subname of another domain is that domain — `dim2.attacker.dot` reads as `attacker` and collects nothing published for `dim2`. Which calls each scope gates remains a Host runtime contract, as it already is for `all`. A grant is a standing answer, so a call it does not cover refuses rather than prompts wherever prompting would itself disclose something — a cross-product storage read answers one refusal for every reason, and a prompt naming the target would say the target exists. +`context` gates `create_account_proof` and `ring_vrf_sign` on the granting product's keys. Both are adjudicated twice, in two different components, and both checks are load-bearing rather than one being a duplicate of the other: + +- The **runtime frontend** refuses a cross-product caller before any authority is reached. The calling product id there is the one the Host bound to the connection, so this is the gate for a product running on this Host. +- The **authority holding the keys** resolves the granting product's manifest again, for itself. On a paired Host the authority request arrives over the wire from another Host, which names the product it is acting for. Relaying the frontend's verdict as a flag would take the manifest out of that decision entirely and let a peer reach every handle on the device rather than only the ones a publisher really granted. + +A grant never overrides a refusal the user already gave: the stored account-access decision is read before the manifest, and read-only, so a grant lookup never raises the prompt that would settle an undecided one. + +The account and identity *reads* `context` also names still take the user prompt. + ## Drawbacks -Storage is the only interaction a publisher can name. Reading another product's account and signing under its keys still have no scope of their own, so a publisher who wants to pre-approve either is back to `all` — #655 covers giving them one. Writes stay on the wildcard too: `storage` is read-only, so "read and write, nothing else" is inexpressible. And `all` widens silently, so staying narrow means revisiting the manifest as scopes are added. +Writes stay on the wildcard: `storage` is read-only, so "read and write, nothing else" is still inexpressible. `context` bundles reading an account with signing under it, so "see who I am, sign nothing" is not expressible either — splitting them costs a third value and neither half has a use without the other yet. And `all` still widens silently, so staying narrow means revisiting the manifest as scopes are added. ## Alternatives @@ -52,6 +62,8 @@ A separate field per scope (a foreign-storage record beside `trustedProducts`) s ## Unresolved Questions -1. Should `storage` gain a write counterpart rather than leaving writes reachable only through `all`? A cross-product write is a larger step than a read, and no consumer has asked for one yet, but leaving it on the wildcard means a publisher who wants to allow it must also pre-approve everything else. +1. Is `context` the right name? `account` says it more directly, and `context` sits awkwardly beside the `context` parameter [RFC 0020][0020] removed from `create_transaction`. +2. Should `storage` gain a write counterpart rather than leaving writes reachable only through `all`? A cross-product write is a larger step than a read, and no consumer has asked for one yet, but leaving it on the wildcard means a publisher who wants to allow it must also pre-approve everything else. [manifest]: product-manifest.md +[0020]: 0020-create-transaction.md diff --git a/docs/rfcs/product-manifest.md b/docs/rfcs/product-manifest.md index 6445c1be5..ab3c9de53 100644 --- a/docs/rfcs/product-manifest.md +++ b/docs/rfcs/product-manifest.md @@ -118,7 +118,8 @@ type Icon = { type Granted = // Grants v1 defines. Unrecognised values are ignored, not fatal. | 'all' // Wildcard: every mediated interaction, present and future. - | 'storage'; // Read this product's host-local storage. + | 'storage' // Read this product's host-local storage. + | 'context'; // Read this product's account and the identity behind it. ``` #### Icons @@ -136,8 +137,8 @@ Each such interaction is normally a consent decision; `trustedProducts` pre-appr **The grant is issued by the product being accessed.** An entry in A's manifest states what B may do *to A* — the only direction A's name can authenticate. It says nothing about what A may do to B, nor about the products B in turn trusts. - **Keys** are bare `` labels, lowercase, with no TLD suffix: `"wallet"`, never `"wallet.dot"`. The Host appends the TLD of the network it resolves against. A key that does not resolve there is inert, not a validation error. -- **Values** are that product's grants. v1 defines two. `all` is a wildcard for the complete set of cross-product permissions the Host mediates on this product's behalf: it is resolved against that set when the grant is used, not enumerated here, so a grant of `all` covers permissions added after it was published. `storage` covers reading this product's host-local storage, read-only. Hosts MUST ignore unrecognised values, keep the recognised ones, and MUST NOT fail validation over them. -- **`all` is a superset, not a peer.** `["all"]` implies `storage`, so `["all", "storage"]` is `["all"]` and a Host MUST NOT read a narrower value as a restriction on `all`. Enumerating the narrow values instead of granting `all` covers the same interactions today but does not widen when a further value is defined. Values are a set: order is not significant and duplicates collapse. Scopes are independent — a grant of `["storage"]` leaves every other interaction prompting as usual. +- **Values** are that product's grants. v1 defines three. `all` is a wildcard for the complete set of cross-product permissions the Host mediates on this product's behalf: it is resolved against that set when the grant is used, not enumerated here, so a grant of `all` covers permissions added after it was published. `storage` covers reading this product's host-local storage, read-only. `context` covers reading this product's account and the identity that follows from it. Hosts MUST ignore unrecognised values, keep the recognised ones, and MUST NOT fail validation over them. +- **`all` is a superset, not a peer.** `["all"]` implies `storage` and `context`, so `["all", "storage"]` is `["all"]` and a Host MUST NOT read a narrower value as a restriction on `all`. Enumerating the narrow values instead of granting `all` covers the same interactions today but does not widen when a further value is defined. Values are a set: order is not significant and duplicates collapse. Scopes are independent — a grant of `["storage"]` leaves account interactions prompting as usual. - **Absence means no grants.** Missing field, empty record, and empty array are equivalent: prompt as usual. A product listing itself is ignored. Which interactions a Host mediates, and what the prompt looks like, are Host runtime contracts; this RFC defines only how the grants are published and read. @@ -527,6 +528,6 @@ A conforming Host implementation should produce well-defined behaviour for each ## Future Directions -- `Granted` covers the capabilities the Host runtime contracts name today. Further values — write access to storage, and a scope for reading this product's account and signing under its keys (#655) — fit the same way: `all` stays the wildcard, and the array shape and the ignore-unrecognised-values rule let them land without a new `$v`. +- `Granted` covers the capabilities the Host runtime contracts name today. Further values — write access to storage, a scope of its own for signing — fit the same way: `all` stays the wildcard, and the array shape and the ignore-unrecognised-values rule let them land without a new `$v`. - A manifest-aggregation RPC could eliminate the N+1 lookup pattern (one round-trip per subname) without changing the schema. - A companion spec will pin down the dashboard grid (cell size, bounds, responsive behaviour) referenced by `WidgetManifest.dimensions`. diff --git a/rust/crates/truapi-server/src/host_logic/product_manifest.rs b/rust/crates/truapi-server/src/host_logic/product_manifest.rs index c49744438..b43043e32 100644 --- a/rust/crates/truapi-server/src/host_logic/product_manifest.rs +++ b/rust/crates/truapi-server/src/host_logic/product_manifest.rs @@ -24,6 +24,8 @@ pub enum Granted { All, /// Reading the granting product's host-local storage. Storage, + /// Using the granting product's account and the identity behind it. + Context, /// A grant value defined after this core was built. #[serde(other)] Unrecognised, @@ -126,9 +128,17 @@ mod tests { } #[test] - fn all_satisfies_a_narrower_scope() { + fn scopes_are_independent() { + // `storage` must leave account interactions prompting as usual. + let m = manifest(r#"{"dim2":["storage"]}"#); + assert!(!m.grants("dim2", Granted::Context)); + } + + #[test] + fn all_satisfies_every_narrower_scope() { let m = manifest(r#"{"dim2":["all"]}"#); assert!(m.grants("dim2", Granted::Storage)); + assert!(m.grants("dim2", Granted::Context)); } #[test] @@ -137,12 +147,14 @@ mod tests { // core was built. let m = manifest(r#"{"dim2":["storage-write","storage"]}"#); assert!(m.grants("dim2", Granted::Storage)); + assert!(!m.grants("dim2", Granted::Context)); } #[test] fn an_entry_of_only_unrecognised_grants_grants_nothing() { let m = manifest(r#"{"dim2":["storage-write"]}"#); assert!(!m.grants("dim2", Granted::Storage)); + assert!(!m.grants("dim2", Granted::Context)); } #[test] diff --git a/rust/crates/truapi-server/src/runtime/capabilities/account.rs b/rust/crates/truapi-server/src/runtime/capabilities/account.rs index 51c12c999..3a8e9e8c2 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/account.rs @@ -25,6 +25,7 @@ use truapi_platform::{ normalize_product_identifier, }; +use crate::host_logic::product_manifest::Granted; use crate::runtime::authority::{ AccountAliasAuthorityRequest, CreateProofAuthorityRequest, ListRingVrfKeysAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, @@ -183,18 +184,30 @@ impl Account for ProductRuntimeHost { }, )) })?; - if key_handle.dot_ns_identifier != self.product_id() { - return Err(CallError::Domain(HostAccountCreateProofError::V1( - v01::HostAccountCreateProofError::NotAllowlisted, - ))); - } - + // The session is consulted before the grant, matching `ring_vrf_sign`. + // The other order makes the pair of refusals a probe for who granted + // whom: with no session a granting target answers `Rejected` and a + // non-granting one `NotAllowlisted`, which is exactly what the uniform + // cross-product refusal exists to prevent. let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostAccountCreateProofError::V1( v01::HostAccountCreateProofError::Rejected, ))); }; + // Proving against another product's key uses that product's account and + // the identity derived from it, so it needs that product's `context` + // grant. One refusal covers every reason it is not held. + if self + .cross_product_scope_target(&key_handle.dot_ns_identifier, Granted::Context) + .await + .is_none() + { + return Err(CallError::Domain(HostAccountCreateProofError::V1( + v01::HostAccountCreateProofError::NotAllowlisted, + ))); + } + let calling_product_id = self.product_id(); let cx = remote_authority_context(cx); remote_authority_call( @@ -323,7 +336,14 @@ impl Account for ProductRuntimeHost { v01::HostAccountRingVrfSignError::NotConnected, ))); }; - if request.key_handle.dot_ns_identifier != self.product_id() { + if self + .cross_product_scope_target( + &request.key_handle.dot_ns_identifier, + Granted::Context, + ) + .await + .is_none() + { return Err(CallError::Domain(HostAccountRingVrfSignError::V1( v01::HostAccountRingVrfSignError::NotAllowlisted, ))); diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index b2ec78896..6f7c5547f 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -272,6 +272,11 @@ impl SessionStoreSync { /// Remote account authority for a pairing host. pub(crate) struct PairingHost { + /// Shared runtime services. Held, not just borrowed at construction, so + /// this role can resolve a product manifest for itself when it adjudicates + /// a cross-product grant. `RuntimeServices` does not hold the pairing host + /// back — `host_core` owns both — so this is not a cycle. + services: Arc, /// Host platform backing all syscalls. pub(super) platform: Arc, /// Pairing configuration supplied by the embedding host. @@ -317,6 +322,7 @@ impl PairingHost { let platform = services.platform.clone(); let auth_state = AuthStateMachine::new(platform.clone()); Arc::new_cyclic(|weak_self| Self { + services: services.clone(), platform, host_config, chain: services.chain.clone(), @@ -1957,19 +1963,23 @@ impl PairingHost { subtrees.retain(|(key, _), _| *key != session_key); } - fn require_owned_ring_vrf_key( + /// Whether `calling_product_id` may act on `handle`'s ring-VRF key. + /// + /// Delegates to [`crate::runtime::ring_vrf_key_access_granted`], which + /// resolves the owner's manifest here rather than trusting the request: on + /// this role the request can have arrived over the pairing wire. + async fn require_ring_vrf_key_access( + &self, calling_product_id: &str, handle: &v01::ProductAccountId, ) -> Result<(), RingVrfError> { - let caller = normalize_product_identifier(calling_product_id).map_err(|error| { - RingVrfError::Unknown { - reason: error.to_string(), - } - })?; - if caller != handle.dot_ns_identifier { - return Err(RingVrfError::NotAllowlisted); - } - Ok(()) + crate::runtime::product_manifest::ring_vrf_key_access_granted( + &self.services, + self.platform.as_ref(), + calling_product_id, + handle, + ) + .await } async fn local_ring_vrf_entropy( @@ -2168,7 +2178,8 @@ impl PairingHost { session: &AuthoritySession, request: CreateProofAuthorityRequest, ) -> Result { - Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; + self.require_ring_vrf_key_access(&request.calling_product_id, &request.key_handle) + .await?; let private_session = self.current_private_session(session)?; if let Some(entropy) = self .local_ring_vrf_entropy_for_ring( @@ -2297,7 +2308,8 @@ impl PairingHost { session: &AuthoritySession, request: RingVrfSignAuthorityRequest, ) -> Result, RingVrfError> { - Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; + self.require_ring_vrf_key_access(&request.calling_product_id, &request.key_handle) + .await?; let private_session = self.current_private_session(session)?; if let Some(entropy) = self .local_ring_vrf_entropy(&private_session, &request.key_handle) diff --git a/rust/crates/truapi-server/src/runtime/product_manifest.rs b/rust/crates/truapi-server/src/runtime/product_manifest.rs index 81a2117fb..7d202dd1e 100644 --- a/rust/crates/truapi-server/src/runtime/product_manifest.rs +++ b/rust/crates/truapi-server/src/runtime/product_manifest.rs @@ -11,8 +11,12 @@ use core::sync::atomic::{AtomicBool, Ordering}; use parity_scale_codec::{Decode, Encode}; -use tracing::{instrument, warn}; -use truapi_platform::{CoreStorageKey, Platform}; +use tracing::{debug, instrument, warn}; +use truapi::v01; +use truapi_platform::{ + CoreStorageKey, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, + normalize_product_identifier, +}; use crate::chain_runtime::ChainRuntime; use crate::host_logic::dotns_gateway::{ @@ -21,6 +25,8 @@ use crate::host_logic::dotns_gateway::{ protocol_component, tld_node, }; use crate::host_logic::product_manifest::{Granted, RootManifest, bare_product_label}; +use crate::host_logic::permissions::PermissionsService; +use crate::host_logic::sso::messages::RingVrfError; use crate::host_logic::statement_store::current_unix_secs; use crate::runtime::dotns_lookup::DotnsLookup; use crate::runtime::services::RuntimeServices; @@ -234,6 +240,13 @@ pub(crate) async fn grants_scope( target: &str, scope: Granted, ) -> bool { + // A publisher's grant waives the publisher's own prompt. It does not reach + // a refusal the user already gave, so the stored decision is consulted + // first, read-only: raising the prompt here would turn a grant into a way + // to ask again. + if scope == Granted::Context && user_denied_account_access(platform, caller_id, target).await { + return false; + } let Some(json) = root_manifest(services, platform, target).await else { return false; }; @@ -298,6 +311,89 @@ async fn warn_if_asset_hub_disagrees_with_chain_set(platform: &dyn Platform, con } } +/// Whether the user has already refused `caller_id` access to `target`'s account. +/// +/// Reads the stored decision without raising a prompt: `NotDetermined` is not a +/// refusal, and the prompt that would settle it belongs to the call the user +/// actually made, not to a grant lookup. +async fn user_denied_account_access( + platform: &dyn Platform, + caller_id: &str, + target: &str, +) -> bool { + let request = PermissionAuthorizationRequest::AccountAccess { + target_product_id: target.to_string(), + }; + let service = PermissionsService::new(platform, platform, caller_id); + matches!( + service.authorization_status(&request).await, + Ok(PermissionAuthorizationStatus::Denied) + ) +} + +/// Whether `calling_product_id` may act on `handle`'s ring-VRF key, adjudicated +/// by the component that holds the key. +/// +/// The caller owns the key, or the owner's published manifest grants the caller +/// `context` and the user has not already refused, resolved against the chain +/// here rather than accepted from the request. On a paired host the request +/// arrives over the wire, and a verdict relayed by the caller would take the +/// manifest out of this decision entirely: the peer would reach every handle on +/// the device by setting one field, instead of only the handles a publisher +/// really granted. +/// +/// The owner check runs first and costs nothing, so a product proving with its +/// own key never touches the network. Everything after it is a cross-product +/// access, and every reason it is refused answers the same way. +pub(crate) async fn ring_vrf_key_access_granted( + services: &RuntimeServices, + platform: &dyn Platform, + calling_product_id: &str, + handle: &v01::ProductAccountId, +) -> Result<(), RingVrfError> { + let caller = normalize_product_identifier(calling_product_id).map_err(|error| { + RingVrfError::Unknown { + reason: error.to_string(), + } + })?; + // The handle is normalized here, not only at the frontend. The frontend + // does it before delegating, but `sso_responder` hands a wire request + // straight to the authority unnormalized, so without this the two doors + // disagree: an owner naming its own key `PEOPL.DOT` over the wire is + // refused where the same request from a local product runtime succeeds. + // + // A handle that does not normalize names no product, so it owns no key and + // no manifest can grant it: it takes the same refusal as a product that + // granted nothing, rather than a distinguishable error. + let Ok(owner) = normalize_product_identifier(&handle.dot_ns_identifier) else { + return Err(RingVrfError::NotAllowlisted); + }; + if caller == owner { + return Ok(()); + } + if grants_scope(services, platform, &caller, &owner, Granted::Context).await { + return Ok(()); + } + // The wire answer is one refusal for every reason, so the reason lives here + // or nowhere. Which door the request came through is not repeated: the + // enclosing span already says it (`account.*` for a local product runtime, + // `sso_responder.*` for a paired peer). + // + // That span is also what says how far to trust `caller`. Under `account.*` + // it is the product id the host bound to the connection. Under + // `sso_responder.*` it is `calling_product_id` as decoded from the peer's + // message: what the authenticated paired host said, not something this host + // verified. The refusal is sound either way, because the grant is resolved + // from the owner's manifest and never from this field, but an operator + // reading the line should not take it as proof of who asked. + debug!( + caller = %caller, + owner = %owner, + "ring-VRF key access refused: no context grant" + ); + Err(RingVrfError::NotAllowlisted) +} + /// `target`'s root manifest JSON, from cache when it is younger than /// [`MANIFEST_TTL_SECS`] and from dotNS otherwise. /// diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 43b9626e8..c6ffa44be 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -159,6 +159,13 @@ impl SigningHost { }) } + /// The shared services this role was built over, for tests that also need + /// to build a product runtime against the same platform and cache. + #[cfg(test)] + fn services(&self) -> Arc { + self.services.clone() + } + #[cfg(test)] fn new_with_ring_resolver( platform: Arc, @@ -519,19 +526,23 @@ impl SigningHost { }) } - fn require_owned_ring_vrf_key( + /// Whether `calling_product_id` may act on `handle`'s ring-VRF key. + /// + /// Delegates to [`crate::runtime::ring_vrf_key_access_granted`], which + /// resolves the owner's manifest here rather than trusting the request: on + /// this role the request can have arrived over the pairing wire. + async fn require_ring_vrf_key_access( + &self, calling_product_id: &str, handle: &v01::ProductAccountId, ) -> Result<(), RingVrfError> { - let caller = normalize_product_identifier(calling_product_id).map_err(|error| { - RingVrfError::Unknown { - reason: error.to_string(), - } - })?; - if caller != handle.dot_ns_identifier { - return Err(RingVrfError::NotAllowlisted); - } - Ok(()) + crate::runtime::product_manifest::ring_vrf_key_access_granted( + &self.services, + self.platform.as_ref(), + calling_product_id, + handle, + ) + .await } pub(crate) async fn ring_vrf_providers( @@ -883,7 +894,8 @@ impl ProductAuthority for SigningHost { request: CreateProofAuthorityRequest, ) -> Result { self.require_current_session(session)?; - Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; + self.require_ring_vrf_key_access(&request.calling_product_id, &request.key_handle) + .await?; let entropy = self .resolve_ring_vrf_key_for_ring(session, &request.key_handle, &request.ring_location) .await?; @@ -984,7 +996,8 @@ impl ProductAuthority for SigningHost { request: RingVrfSignAuthorityRequest, ) -> Result, RingVrfError> { self.require_current_session(session)?; - Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; + self.require_ring_vrf_key_access(&request.calling_product_id, &request.key_handle) + .await?; let entropy = self .resolve_registered_ring_vrf_key(session, &request.key_handle) .await?; @@ -1456,6 +1469,220 @@ mod tests { }) } + /// Seed `owner`'s cached manifest so a grant lookup resolves without a + /// chain. Mirrors `runtime::tests::cache_manifest`. + fn cache_grant(platform: &StubPlatform, owner: &str, trusted_products: &str) { + let json = format!( + r#"{{"$v":1,"displayName":"D","description":"d", + "icon":{{"cid":"c","format":"png"}},"trustedProducts":{trusted_products}}}"# + ); + let entry = crate::runtime::CachedManifest { + fetched_at_secs: crate::runtime::unix_time_secs().expect("clock is after the epoch"), + json: Some(json), + }; + futures::executor::block_on( + ::write_core_storage( + platform, + truapi_platform::CoreStorageKey::ProductManifest { + product_id: owner.to_string(), + }, + parity_scale_codec::Encode::encode(&entry), + ), + ) + .expect("stub core storage accepts the entry"); + } + + /// Persist a user refusal of `caller`'s access to `target`'s account. + fn deny_account_access(platform: &StubPlatform, caller: &str, target: &str) { + futures::executor::block_on( + crate::host_logic::permissions::PermissionsService::new(platform, platform, caller) + .set_authorization_status( + &truapi_platform::PermissionAuthorizationRequest::AccountAccess { + target_product_id: target.to_string(), + }, + truapi_platform::PermissionAuthorizationStatus::Denied, + ), + ) + .expect("stub core storage accepts the decision"); + } + + /// A `context` grant lets a foreign product prove with the owner's key. + /// + /// The test whose absence let the inert scope ship. The earlier + /// `a_cached_context_grant_lets_a_foreign_proof_through` asserted + /// `Rejected` with no session, which only proved the call reached the + /// session guard — the authority, one layer down, would have refused it + /// anyway. This one runs the whole stack with a live session and a + /// registered key, so a proof actually comes back. + #[test] + fn a_context_grant_lets_a_foreign_product_prove_with_the_owners_key() { + let platform = Arc::new(StubPlatform::default()); + cache_grant(&platform, "peopl.dot", r#"{"dim2":["context"]}"#); + let (services, authority) = + signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = authority.current_session().expect("active session"); + let ring_location = full_person_ring_location(); + register_full_person_key(&authority, &session, &ring_location); + + let host = product_runtime_for(services, authority.clone(), "dim2.dot"); + let proof = futures::executor::block_on(host.create_account_proof( + &CallContext::default(), + foreign_proof_request(&ring_location), + )); + assert!( + proof.is_ok(), + "a granted cross-product proof must succeed, got {proof:?}" + ); + } + + /// The same call with no grant. Same fixture, one line different. + #[test] + fn a_foreign_proof_is_refused_when_the_owner_granted_nothing() { + let platform = Arc::new(StubPlatform::default()); + cache_grant(&platform, "peopl.dot", r#"{"someone-else":["context"]}"#); + let (services, authority) = + signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = authority.current_session().expect("active session"); + let ring_location = full_person_ring_location(); + register_full_person_key(&authority, &session, &ring_location); + + let host = product_runtime_for(services, authority.clone(), "dim2.dot"); + let proof = futures::executor::block_on(host.create_account_proof( + &CallContext::default(), + foreign_proof_request(&ring_location), + )); + assert_eq!( + proof.err(), + Some(CallError::Domain( + truapi::versioned::account::HostAccountCreateProofError::V1( + v01::HostAccountCreateProofError::NotAllowlisted + ) + )) + ); + } + + /// **The one that matters.** Nothing in the request can stand in for the + /// manifest. + /// + /// This drives the authority directly, the way `sso_responder` does for a + /// request arriving over the pairing wire — the frontend, and its grant + /// check, are not on this path at all. The request names `dim2.dot` as the + /// caller and `peopl.dot`'s key as the handle, which is the most a peer can + /// assert. With no grant published it is refused; with the grant published + /// and nothing else changed it succeeds. So the admitting fact is the + /// manifest the authority resolved for itself, not any field the caller + /// set. + #[test] + fn a_request_cannot_substitute_for_the_owners_manifest() { + let refusal = foreign_proof_through_the_authority(None); + assert_eq!( + refusal.err(), + Some(RingVrfError::NotAllowlisted), + "with no manifest grant the authority must refuse, whatever the request says" + ); + + let granted = foreign_proof_through_the_authority(Some(r#"{"dim2":["context"]}"#)); + assert!( + granted.is_ok(), + "the identical request must succeed once the owner's manifest grants it, \ + which is what proves the manifest is the deciding input; got {granted:?}" + ); + } + + /// A grant never overrides a refusal the user already gave. + /// + /// The stored `AccountAccess` decision is read before the manifest, and + /// read-only: a grant lookup must not raise the prompt that would settle a + /// `NotDetermined` one. + #[test] + fn a_stored_denial_survives_a_context_grant_at_the_authority() { + let refusal = + foreign_proof_through_the_authority_with(Some(r#"{"dim2":["context"]}"#), |platform| { + deny_account_access(platform, "dim2.dot", "peopl.dot") + }); + assert_eq!(refusal.err(), Some(RingVrfError::NotAllowlisted)); + } + + /// `all` is a superset, so it satisfies `context` at the runtime seam and + /// not only in the manifest parser. + #[test] + fn a_grant_of_all_satisfies_context_at_the_authority() { + let granted = foreign_proof_through_the_authority(Some(r#"{"dim2":["all"]}"#)); + assert!( + granted.is_ok(), + "`all` must satisfy `context`, got {granted:?}" + ); + } + + fn signing_runtime_with_ring_resolver( + platform: Arc, + ring_resolver: Arc, + ) -> (Arc, Arc) { + let authority = SigningHostRole::new_with_ring_resolver(platform, ring_resolver); + (authority.services(), authority) + } + + fn foreign_proof_through_the_authority( + trusted_products: Option<&str>, + ) -> Result { + foreign_proof_through_the_authority_with(trusted_products, |_| {}) + } + + /// Drive `create_proof` straight at the authority, bypassing the frontend, + /// with `dim2.dot` naming `peopl.dot`'s key handle. + fn foreign_proof_through_the_authority_with( + trusted_products: Option<&str>, + seed: impl FnOnce(&StubPlatform), + ) -> Result { + let platform = Arc::new(StubPlatform::default()); + if let Some(trusted_products) = trusted_products { + cache_grant(&platform, "peopl.dot", trusted_products); + } + seed(&platform); + let (_services, authority) = + signing_runtime_with_ring_resolver(platform.clone(), full_person_ring_resolver()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = authority.current_session().expect("active session"); + let ring_location = full_person_ring_location(); + register_full_person_key(&authority, &session, &ring_location); + + futures::executor::block_on(authority.create_proof( + &CallContext::default(), + &session, + CreateProofAuthorityRequest { + calling_product_id: "dim2.dot".to_string(), + key_handle: full_person_key_handle(), + context: v01::ProductProofContext { + product_id: "dim2.dot".to_string(), + suffix: v01::DerivationIndex::Index(0), + }, + ring_location, + message: b"prove me".to_vec(), + }, + )) + } + + fn foreign_proof_request( + ring_location: &v01::RingLocation, + ) -> truapi::versioned::account::HostAccountCreateProofRequest { + truapi::versioned::account::HostAccountCreateProofRequest::V1( + v01::HostAccountCreateProofRequest { + key_handle: full_person_key_handle(), + context: v01::ProductProofContext { + product_id: "dim2.dot".to_string(), + suffix: v01::DerivationIndex::Index(0), + }, + ring_location: ring_location.clone(), + message: b"prove me".to_vec(), + }, + ) + } + fn full_person_ring_location() -> v01::RingLocation { v01::RingLocation { chain_id: [0x22; 32], diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index 5166e217b..01838cc28 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -373,6 +373,21 @@ fn a_grant_of_some_other_scope_does_not_open_storage() { ); } +#[test] +fn a_context_grant_does_not_open_storage() { + // Scopes are independent, and `context` is the case worth pinning rather + // than an unrecognised value: it is a scope this core does honour, just + // not for storage. + let platform = stub_platform(); + cache_manifest(&platform, "wallet.dot", r#"{"unknown":["context"]}"#, 0); + seed_owner_value(&platform, "wallet.dot"); + let host = ProductRuntimeHost::new_compat(platform, test_spawner()); + assert_eq!( + read_storage(&host, Some("wallet.dot"), "k").unwrap_err(), + access_not_granted() + ); +} + #[test] fn a_cached_grant_stops_being_honoured_once_it_expires() { // The lifetime is the revocation bound. Past it the entry is ignored, @@ -392,6 +407,73 @@ fn a_cached_grant_stops_being_honoured_once_it_expires() { ); } +/// With no session, every proof refusal is the same refusal. +/// +/// This is the ordering hazard closed. `create_account_proof` consults the +/// session before the grant, so a granting target, a non-granting target and +/// the caller's own key all answer `Rejected` — and the pair of refusals stops +/// being a probe for who granted whom. The grant path itself is covered +/// end-to-end, with a live session, in +/// `runtime::signing_host::tests::a_context_grant_lets_a_foreign_product_prove_with_the_owners_key`. +#[test] +fn with_no_session_a_proof_refusal_never_discloses_whether_a_grant_exists() { + let platform = stub_platform(); + cache_manifest(&platform, "granting.dot", r#"{"unknown":["context"]}"#, 0); + cache_manifest( + &platform, + "silent.dot", + r#"{"someone-else":["context"]}"#, + 0, + ); + let host = ProductRuntimeHost::new_compat(platform, test_spawner()); + let sessionless = Some(CallError::Domain(HostAccountCreateProofError::V1( + v01::HostAccountCreateProofError::Rejected, + ))); + + assert_eq!(proof_refusal(&host, "granting.dot"), sessionless); + assert_eq!(proof_refusal(&host, "silent.dot"), sessionless); + assert_eq!(proof_refusal(&host, &host.product_id()), sessionless); +} + +fn proof_refusal( + host: &ProductRuntimeHost, + product: &str, +) -> Option> { + futures::executor::block_on( + host.create_account_proof(&CallContext::default(), create_proof_request(product)), + ) + .err() +} + +#[test] +fn a_proof_naming_the_caller_in_another_spelling_is_still_its_own() { + // Normalized before comparison, so casing cannot turn a product's own + // key into a cross-product refusal. + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let shouted = host.product_id().to_uppercase(); + assert_eq!( + proof_refusal(&host, &shouted), + Some(CallError::Domain(HostAccountCreateProofError::V1( + v01::HostAccountCreateProofError::Rejected + ))) + ); +} + +#[test] +fn an_unresolvable_product_cannot_reach_a_foreign_key() { + // An id that does not normalize is not the caller, so it takes the same + // refusal as a product that granted nothing. + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + assert_eq!( + proof_refusal(&host, "not a product"), + Some(CallError::Domain(HostAccountCreateProofError::V1( + v01::HostAccountCreateProofError::Unknown { + reason: "Invalid key handle".to_string() + } + ))) + ); +} + #[test] fn get_chain_info_unserved_identifier_is_not_supported() { let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); From 845f0ce3cba587fda5f8ba9451ca4da9fd59df35 Mon Sep 17 00:00:00 2001 From: Nidish Date: Thu, 10 Sep 2026 14:53:05 +0530 Subject: [PATCH 02/10] fix(server): keep the Asset Hub grant anchor set-once instead of falling back to the served chain set --- rust/crates/truapi-server/src/runtime/services.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index bf435c75a..f04814b66 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -148,8 +148,19 @@ impl RuntimeServices { /// The Asset Hub dotNS reads run against, when one is configured. /// + /// Installed once at startup, deliberately: this is the chain a grant is + /// adjudicated against, and a `OnceLock` is what stops it moving under a + /// running product. It is not sourced from `supported_chains()`, which is + /// an uncached per-call host syscall answering a different question, "which + /// chains do I serve RPC for?", the product-facing `get_chain_info` + /// advertisement, rather than "which Asset Hub is dotNS deployed on?". + /// Taking it from there would let the anchor change between two calls, at + /// host discretion, with nothing recording that it moved. The signing role + /// gets its hash from `SigningHostConfig` for the same reason. + /// /// An all-zero hash is how a host says it has no Asset Hub, so it reads the - /// same as never having installed one. + /// same as never having installed one. `None` fails every manifest lookup + /// closed: grants are refused rather than assumed. pub(crate) fn asset_hub_chain_genesis_hash(&self) -> Option<[u8; 32]> { self.asset_hub_chain_genesis_hash .get() From 7d6a661dc88f251062b348d736889b7a3c8bd629 Mon Sep 17 00:00:00 2001 From: Nidish Date: Thu, 10 Sep 2026 15:59:18 +0530 Subject: [PATCH 03/10] test(server): pin the seam where the signing role's installed Asset Hub feeds the grant path --- .../truapi-server/src/runtime/signing_host.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index c6ffa44be..c4efdf677 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -1360,6 +1360,43 @@ mod tests { } } + /// #660 and #655 join here: the hash the signing role installs is the one + /// the grant path adjudicates against. + /// + /// Worth pinning because the two halves are testable apart and were built + /// apart. #660's own tests prove the hash is installed; #655's grant tests + /// seed the manifest **cache**, and `root_manifest` reads the cache before + /// it ever needs a genesis hash — so every one of them would pass with + /// #660 absent. This asserts the seam itself: the grant path's chain + /// lookup has an Asset Hub to run against on a role whose config used to + /// carry none. + /// + /// A cache miss still refuses, because the stub reaches no chain. That is + /// the closed default, and it is why this seam needs its own test rather + /// than being visible in a refusal. + #[test] + fn the_signing_role_adjudicates_grants_against_the_asset_hub_it_installed() { + let (services, _authority) = signing_runtime(); + assert_eq!( + services.asset_hub_chain_genesis_hash(), + Some([0xcc; 32]), + "#660 must install the config's Asset Hub, or #655 resolves no manifest here" + ); + + let platform = Arc::new(StubPlatform::default()); + let granted = futures::executor::block_on(crate::runtime::manifest_grants_scope( + &services, + platform.as_ref(), + "dim2.dot", + "peopl.dot", + crate::runtime::GrantedScope::Context, + )); + assert!( + !granted, + "with no cached manifest and no reachable chain the grant must fail closed" + ); + } + fn signing_runtime() -> (Arc, Arc) { // Auto-confirm raw signing so the role-neutral confirmation gate does // not reject before reaching the signing authority. From 35a8d7ec10b80592fc304e6bf08c5968eca34eb5 Mon Sep 17 00:00:00 2001 From: Nidish Date: Thu, 10 Sep 2026 16:08:04 +0530 Subject: [PATCH 04/10] test(server): give the foreign-proof wire test a session so it reaches the grant check --- .../truapi-server/tests/wire_result_shape.rs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs index 5cfb62cbe..7f7676854 100644 --- a/rust/crates/truapi-server/tests/wire_result_shape.rs +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -242,6 +242,22 @@ fn version_index(version: u8) -> u8 { #[test] fn foreign_account_proof_returns_not_allowlisted_without_confirmation() { let core = make_core(); + // A session, so the call reaches the cross-product grant check rather than + // stopping at the session guard that now runs before it. Without one this + // asserts `Rejected` and says nothing about allowlisting: `create_proof` + // consults the session first so that the pair of refusals cannot be used + // to probe which product granted which (#655). + core.session_state() + .set_session(truapi_server::host_logic::session::SessionInfo { + public_key: [7u8; 32], + sso: None, + root_entropy_source: None, + identity_account_id: None, + identity_chat_private_key: None, + device_enc_public_key: None, + lite_username: None, + full_username: None, + }); let request = account::HostAccountCreateProofRequest::V1(v01::HostAccountCreateProofRequest { key_handle: v01::ProductAccountId { dot_ns_identifier: "peopl.dot".to_string(), @@ -271,7 +287,9 @@ fn foreign_account_proof_returns_not_allowlisted_without_confirmation() { ); assert_eq!(response.request_id, "p:account-proof"); assert_eq!(response.payload.id, ids.response_id); - // RFC-0024 forbids a prompt fallback for bearer proofs made with a foreign key. + // RFC-0024 forbids a prompt fallback for bearer proofs made with a foreign + // key. `peopl.dot` publishes no manifest here, so the `context` grant + // cannot be established and the call is refused outright. let expected = versioned_result_err_payload(account::HostAccountCreateProofError::V1( v01::HostAccountCreateProofError::NotAllowlisted, )); From 030f764588046445e83d65ea69fb0391e06b6252 Mon Sep 17 00:00:00 2001 From: Nidish Date: Thu, 10 Sep 2026 16:25:15 +0530 Subject: [PATCH 05/10] docs(server): name the invariant that makes the authority test cover the wire path --- rust/crates/truapi-server/src/runtime/signing_host.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index c4efdf677..35b63aa68 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -1613,6 +1613,14 @@ mod tests { /// and nothing else changed it succeeds. So the admitting fact is the /// manifest the authority resolved for itself, not any field the caller /// set. + /// + /// What makes this cover the wire path is an invariant, not a convention: + /// the authority has exactly one behaviour, shared by both doors, because + /// nothing it reads records which door a request came through. Should a + /// door-dependent relaxation ever land — a dev allowlist consulted only + /// for local callers, say — that invariant is gone, and this test has to + /// declare the paired-peer door explicitly or it silently stops covering + /// it. Whoever adds the distinction owns updating this. #[test] fn a_request_cannot_substitute_for_the_owners_manifest() { let refusal = foreign_proof_through_the_authority(None); From 43f0d09ce1a9aa1d456d6457c76a0be2635a9518 Mon Sep 17 00:00:00 2001 From: Nidish Date: Thu, 10 Sep 2026 16:38:49 +0530 Subject: [PATCH 06/10] test(server): cover ring_vrf_sign on the grant path, which #655 lists as untested --- .../truapi-server/src/runtime/signing_host.rs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 35b63aa68..6f0aa0c1b 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -1671,6 +1671,79 @@ mod tests { (authority.services(), authority) } + /// The grant admits `ring_vrf_sign`, not only `create_proof`. + /// + /// #655 lists this as untested and it was: the other grant tests here all + /// drive `create_proof`. Both authorities call the same + /// `require_ring_vrf_key_access` from both methods, so the code was + /// covered — but a scope that admits one call and not the other is exactly + /// the kind of half-wired gate this issue exists to fix, and nothing + /// asserted the second half. + /// + /// Driven at the authority, where the wire path also arrives, so this + /// covers the paired case as well. Success is the owner's own signature: + /// the grant lets `dim2.dot` produce what `peopl.dot` would have. + #[test] + fn a_context_grant_lets_a_foreign_product_sign_with_the_owners_key() { + let granted = foreign_ring_vrf_sign_through_the_authority(Some(r#"{"dim2":["context"]}"#)); + let owners_own = foreign_ring_vrf_sign_through_the_authority_as( + Some(r#"{"dim2":["context"]}"#), + "peopl.dot", + ); + assert!( + granted.is_ok(), + "a granted cross-product ring-VRF signature must be produced, got {granted:?}" + ); + assert_eq!( + granted, owners_own, + "the grant must yield the owner's own signature, not a caller-derived one" + ); + } + + /// The same call with no grant. + #[test] + fn a_foreign_ring_vrf_sign_is_refused_when_the_owner_granted_nothing() { + assert_eq!( + foreign_ring_vrf_sign_through_the_authority(None).err(), + Some(RingVrfError::NotAllowlisted) + ); + } + + fn foreign_ring_vrf_sign_through_the_authority( + trusted_products: Option<&str>, + ) -> Result, RingVrfError> { + foreign_ring_vrf_sign_through_the_authority_as(trusted_products, "dim2.dot") + } + + /// Drive `ring_vrf_sign` straight at the authority with `caller` naming + /// `peopl.dot`'s key handle, bypassing the frontend exactly as + /// `sso_responder` does. + fn foreign_ring_vrf_sign_through_the_authority_as( + trusted_products: Option<&str>, + caller: &str, + ) -> Result, RingVrfError> { + let platform = Arc::new(StubPlatform::default()); + if let Some(trusted_products) = trusted_products { + cache_grant(&platform, "peopl.dot", trusted_products); + } + let (_services, authority) = + signing_runtime_with_ring_resolver(platform, full_person_ring_resolver()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = authority.current_session().expect("active session"); + register_full_person_key(&authority, &session, &full_person_ring_location()); + + futures::executor::block_on(authority.ring_vrf_sign( + &CallContext::default(), + &session, + RingVrfSignAuthorityRequest { + calling_product_id: caller.to_string(), + key_handle: full_person_key_handle(), + message: b"sign me".to_vec(), + }, + )) + } + fn foreign_proof_through_the_authority( trusted_products: Option<&str>, ) -> Result { From c5a75030d52bc114a915bd51dd89256ecef6b9ed Mon Sep 17 00:00:00 2001 From: Nidish Date: Thu, 10 Sep 2026 16:46:40 +0530 Subject: [PATCH 07/10] test(server): label the unfalsifiable assertion in the Asset Hub seam test as documentation --- rust/crates/truapi-server/src/runtime/signing_host.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 6f0aa0c1b..eacb85741 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -1391,9 +1391,16 @@ mod tests { "peopl.dot", crate::runtime::GrantedScope::Context, )); + // Documentation, not a guard, and labelled so nobody reads it as one: + // with no cached manifest and no reachable chain this is false whether + // or not #660 installed a hash, so no mutation of the production path + // can turn it red. The load-bearing assertion in this test is the one + // above; the granted path is guarded by + // `a_context_grant_lets_a_foreign_product_prove_with_the_owners_key` + // and its M14 pair. assert!( !granted, - "with no cached manifest and no reachable chain the grant must fail closed" + "the closed default: no manifest reachable means no grant" ); } From 50398e2fed33eca3e1dbc62af249012a722cc90b Mon Sep 17 00:00:00 2001 From: Nidish Date: Thu, 10 Sep 2026 18:36:58 +0530 Subject: [PATCH 08/10] fix(server): normalize the key handle in the authority gate so both doors agree --- .../truapi-server/src/runtime/signing_host.rs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index eacb85741..b20f935ec 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -1716,6 +1716,71 @@ mod tests { ); } + /// Casing cannot turn a product's own key into a refusal — on the wire + /// path too, not only at the frontend. + /// + /// `3f6ec081`'s message says "the handle is normalized before the + /// comparison, so casing still cannot turn a product's own key into a + /// refusal". That was true of the frontend, which normalizes before + /// delegating, and false of `sso_responder`, which hands a wire request to + /// the authority untouched. The two doors have to agree here, because the + /// authority is the component that decides. + #[test] + fn an_owner_naming_its_own_key_in_another_spelling_is_admitted_over_the_wire() { + // Past the gate: not `NotAllowlisted`. It stops one layer further on, + // at `KeyNotRegistered`, because the registry lookup and + // `derive_ring_vrf_entropy` (`:426`) still read the raw handle — a + // separate, pre-existing wire-path gap that #655 does not own and that + // would derive a different key rather than refuse. Asserted exactly, + // so this test fails loudly in both directions: red if the gate + // regresses, and red again when that gap is closed, which is when this + // should become `is_ok()`. + assert_eq!( + ring_vrf_sign_at_the_authority("peopl.dot", "PEOPL.DOT").err(), + Some(RingVrfError::KeyNotRegistered), + "the gate must admit an owner's own key however it is spelled" + ); + } + + /// A handle that does not normalize names no product, so it takes the same + /// refusal as a product that granted nothing rather than a distinguishable + /// error the caller could probe with. + #[test] + fn a_handle_that_does_not_normalize_takes_the_uniform_refusal() { + assert_eq!( + ring_vrf_sign_at_the_authority("peopl.dot", "not a product").err(), + Some(RingVrfError::NotAllowlisted) + ); + } + + /// Drive `ring_vrf_sign` at the authority with an arbitrary caller/handle + /// spelling, bypassing the frontend as `sso_responder` does. + fn ring_vrf_sign_at_the_authority( + caller: &str, + handle_owner: &str, + ) -> Result, RingVrfError> { + let platform = Arc::new(StubPlatform::default()); + let (_services, authority) = + signing_runtime_with_ring_resolver(platform, full_person_ring_resolver()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = authority.current_session().expect("active session"); + register_full_person_key(&authority, &session, &full_person_ring_location()); + + futures::executor::block_on(authority.ring_vrf_sign( + &CallContext::default(), + &session, + RingVrfSignAuthorityRequest { + calling_product_id: caller.to_string(), + key_handle: v01::ProductAccountId { + dot_ns_identifier: handle_owner.to_string(), + derivation_index: v01::DerivationIndex::Index(0), + }, + message: b"sign me".to_vec(), + }, + )) + } + fn foreign_ring_vrf_sign_through_the_authority( trusted_products: Option<&str>, ) -> Result, RingVrfError> { From aaefdf47e3527e4c510f215392ff3c7e26f73928 Mon Sep 17 00:00:00 2001 From: Nidish Date: Fri, 11 Sep 2026 00:21:16 +0530 Subject: [PATCH 09/10] test(cli): exercise a granted cross-product ring-VRF signature end to end --- Makefile | 5 +- .../js/cross-product-ringvrf-e2e.ts | 211 ++++++++++++++++++ .../js/fixtures/peopl.paseo.json | 4 +- .../src/runtime/capabilities/account.rs | 5 +- .../src/runtime/product_manifest.rs | 2 +- .../truapi-server/src/runtime/signing_host.rs | 8 +- scripts/cross-product-ringvrf-e2e.sh | 53 +++++ 7 files changed, 276 insertions(+), 12 deletions(-) create mode 100644 rust/crates/truapi-host-cli/js/cross-product-ringvrf-e2e.ts create mode 100755 scripts/cross-product-ringvrf-e2e.sh diff --git a/Makefile b/Makefile index 6b3b0b47f..0d1363779 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # Run `make help` for the list of targets. .DEFAULT_GOAL := help -.PHONY: help setup build codegen test check check-generated clean playground wasm wasm-crypto-test uniffi uniffi-kotlin android-check provider-android-check ios-build ios-run ios-chat-run ios-chat-host-playground-run ios-chat-all android-jni android-publish-local dotli-link dev dev-cli dev-bootstrap dev-link-check e2e-dotli e2e-cli-diagnosis e2e-signing-cli e2e-pairing-cli e2e-chat-cli e2e-cross-product-storage e2e-cli-update headless install cli-runner cli-dist matrix explorer xcframework +.PHONY: help setup build codegen test check check-generated clean playground wasm wasm-crypto-test uniffi uniffi-kotlin android-check provider-android-check ios-build ios-run ios-chat-run ios-chat-host-playground-run ios-chat-all android-jni android-publish-local dotli-link dev dev-cli dev-bootstrap dev-link-check e2e-dotli e2e-cli-diagnosis e2e-signing-cli e2e-pairing-cli e2e-chat-cli e2e-cross-product-storage e2e-cross-product-ringvrf e2e-cli-update headless install cli-runner cli-dist matrix explorer xcframework CARGO ?= cargo TRUAPI_PKG := js/packages/truapi @@ -417,6 +417,9 @@ e2e-chat-cli: ## Run the Chat content-screening battery against a chat signing-h e2e-cross-product-storage: ## One product reads another's storage on the signing-host CLI, granted by a local product config. scripts/cross-product-storage-e2e.sh +e2e-cross-product-ringvrf: ## One product signs with another's ring-VRF key on the signing-host CLI, granted by a local product config. + scripts/cross-product-ringvrf-e2e.sh + e2e-cli-update: cli-dist ## Install the packaged truapi-host from a fake release and self-update it, with no network. node scripts/e2e-cli-update.mjs diff --git a/rust/crates/truapi-host-cli/js/cross-product-ringvrf-e2e.ts b/rust/crates/truapi-host-cli/js/cross-product-ringvrf-e2e.ts new file mode 100644 index 000000000..2dc29c72d --- /dev/null +++ b/rust/crates/truapi-host-cli/js/cross-product-ringvrf-e2e.ts @@ -0,0 +1,211 @@ +// Cross-product ring-VRF signing against a real signing-host CLI. +// +// The sibling of `cross-product-storage-e2e.ts`, for the `context` scope. One +// product signs with another product's registered ring-VRF key, which the +// owner's manifest grant is the only thing permitting. The host resolves that +// grant from the `trustedProducts` in a local product config, so the flow runs +// before either product is deployed — see `--product-config` and +// `truapi-host-cli/src/product_config.rs`. +// +// This is the first end-to-end exercise of a *granted* cross-product call. +// Every other run of this path asserts the refusal: the generated +// `account-create-account-proof` example, and `ring-vrf-e2e.ts`, both pin +// `NotAllowlisted`. Neither can see a grant being honoured, which is how the +// scope shipped inert. +// +// Unlike the storage sibling this does touch a chain: registering a ring-VRF +// key resolves a ring on the People chain. +// +// The runner serves one product per host process, so +// `scripts/cross-product-ringvrf-e2e.sh` invokes this once per phase with +// `E2E_PHASE` set, pointing every run at the same `--base-path` so the key +// registered in one is there for the next. +// +// Phases, and what each proves: +// +// register peopl.paseo registers a ring-VRF key and signs with it, +// then stores the signature in its own storage. +// sign-granted dim2.paseo signs with peopl.paseo's key handle. Named in +// trustedProducts with `context`, so allowed — and the +// signature must equal the owner's own, which is what proves +// the grant reached the owner's key rather than deriving a +// new one for the caller. +// sign-untrusted stash.paseo signs the same handle. Same target, absent +// from trustedProducts: refused. +// sign-again dim2.paseo signs once more, so a refusal above cannot be +// the registration having gone. + +import { PASEO_NEXT_V2_INDIVIDUALITY } from "../../../../js/packages/truapi/src/index.ts"; + +const OWNER = "peopl.paseo"; +const GRANTED = "dim2.paseo"; +const UNTRUSTED = "stash.paseo"; + +/// "pop:polkadot.network/people-lite", hex. +const PEOPLE_LITE_COLLECTION_ID = + "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; + +const RING = { + chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + junctions: [ + { tag: "CollectionId" as const, value: PEOPLE_LITE_COLLECTION_ID }, + ], +}; + +const INDEX = { tag: "Index" as const, value: 0 }; +const OWNER_HANDLE = { dotNsIdentifier: OWNER, derivationIndex: INDEX }; +/// "granted", as the hex the message takes. +const MESSAGE = "0x6772616e746564"; +/// Where the register phase parks the owner's own signature for comparison. +const SIGNATURE_KEY = "owner-signature"; + +const REFUSAL = "NotAllowlisted"; + +function stringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/// The refusal variant, or `null` for anything else. +function refusalTag(error: unknown): string | null { + const domain = error as { + tag?: string; + value?: { value?: { tag?: string } }; + }; + if (domain?.tag !== "Domain") { + return null; + } + return domain.value?.value?.tag ?? null; +} + +function expectProduct(expected: string): void { + if (host.productId !== expected) { + throw new Error( + `phase expects --product-id ${expected}, host serves ${host.productId}`, + ); + } +} + +async function signOwnerKey(): Promise<{ + ok: boolean; + value?: string; + error?: unknown; +}> { + const signed = await truapi.account.ringVrfSign({ + keyHandle: OWNER_HANDLE, + message: MESSAGE, + }); + return signed.isOk() + ? { ok: true, value: signed.value as unknown as string } + : { ok: false, error: signed.error }; +} + +/// Sign `OWNER`'s key and require the signature the owner itself produced. +async function expectGrantedSignature(): Promise { + const signed = await signOwnerKey(); + if (!signed.ok) { + throw new Error( + `${host.productId} was granted ${OWNER} context but refused: ${stringify(signed.error)}`, + ); + } + const signature = signed.value ?? ""; + if (signature.length !== 2 + 64 * 2) { + throw new Error( + `expected a 64-byte signature, got ${signature.length} chars: ${signature}`, + ); + } + + // Read what the owner signed, through the storage grant, and require the + // same bytes. A host deriving the key from the caller rather than the handle + // owner still returns a valid 64-byte signature — it is only the comparison + // that catches it. + const stored = await truapi.localStorage.read({ + product: OWNER, + key: SIGNATURE_KEY, + }); + if (!stored.isOk() || !stored.value.value) { + throw new Error( + `could not read ${OWNER}'s own signature back: ${stringify(stored.isOk() ? stored.value : stored.error)}`, + ); + } + if (signature !== stored.value.value) { + throw new Error( + `granted signature is not the owner's own: got ${signature}, owner produced ${stored.value.value}`, + ); + } + console.log( + `granted ${host.productId} -> ${OWNER}: owner's own signature, ${signature}`, + ); +} + +/// Sign `OWNER`'s key and require the standard refusal. +async function expectRefused(): Promise { + const signed = await signOwnerKey(); + if (signed.ok) { + throw new Error( + `${host.productId} signed with ${OWNER}'s key without a grant: ${signed.value}`, + ); + } + const tag = refusalTag(signed.error); + if (tag !== REFUSAL) { + // Anything else leaks why it failed, which is what one refusal prevents. + throw new Error( + `${host.productId} -> ${OWNER}: expected ${REFUSAL}, got ${stringify(signed.error)}`, + ); + } + console.log(`refused ${host.productId} -> ${OWNER}: ${REFUSAL}`); +} + +const phase = process.env.E2E_PHASE; + +switch (phase) { + case "register": { + expectProduct(OWNER); + const registered = await truapi.account.registerRingVrfKey({ + index: INDEX, + ring: RING, + }); + if (!registered.isOk()) { + throw new Error( + `register_ring_vrf_key failed: ${stringify(registered.error)}`, + ); + } + const signed = await signOwnerKey(); + if (!signed.ok) { + throw new Error( + `the owner could not sign with its own key: ${stringify(signed.error)}`, + ); + } + const written = await truapi.localStorage.write({ + key: SIGNATURE_KEY, + value: signed.value as string, + }); + if (!written.isOk()) { + throw new Error( + `could not park the owner signature: ${stringify(written.error)}`, + ); + } + console.log(`registered and signed as ${OWNER}: ${signed.value}`); + break; + } + case "sign-granted": + case "sign-again": { + expectProduct(GRANTED); + await expectGrantedSignature(); + break; + } + case "sign-untrusted": { + expectProduct(UNTRUSTED); + await expectRefused(); + break; + } + default: + throw new Error( + `set E2E_PHASE to one of register, sign-granted, sign-untrusted, sign-again (got ${phase ?? "nothing"})`, + ); +} + +export {}; diff --git a/rust/crates/truapi-host-cli/js/fixtures/peopl.paseo.json b/rust/crates/truapi-host-cli/js/fixtures/peopl.paseo.json index 901d651bc..0a0c881ce 100644 --- a/rust/crates/truapi-host-cli/js/fixtures/peopl.paseo.json +++ b/rust/crates/truapi-host-cli/js/fixtures/peopl.paseo.json @@ -1,9 +1,9 @@ { "productName": "peopl.paseo", "displayName": "Personhood", - "description": "Stands in for the personhood product in the cross-product storage e2e.", + "description": "Stands in for the personhood product in the cross-product storage and ring-VRF e2es.", "icon": "./icon.png", "trustedProducts": { - "dim2": ["storage"] + "dim2": ["storage", "context"] } } diff --git a/rust/crates/truapi-server/src/runtime/capabilities/account.rs b/rust/crates/truapi-server/src/runtime/capabilities/account.rs index 3a8e9e8c2..70ade0d76 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/account.rs @@ -337,10 +337,7 @@ impl Account for ProductRuntimeHost { ))); }; if self - .cross_product_scope_target( - &request.key_handle.dot_ns_identifier, - Granted::Context, - ) + .cross_product_scope_target(&request.key_handle.dot_ns_identifier, Granted::Context) .await .is_none() { diff --git a/rust/crates/truapi-server/src/runtime/product_manifest.rs b/rust/crates/truapi-server/src/runtime/product_manifest.rs index 7d202dd1e..26ff64b80 100644 --- a/rust/crates/truapi-server/src/runtime/product_manifest.rs +++ b/rust/crates/truapi-server/src/runtime/product_manifest.rs @@ -24,8 +24,8 @@ use crate::host_logic::dotns_gateway::{ decode_address, decode_string, discover_pop_controller, namehash_under, network_tld, protocol_component, tld_node, }; -use crate::host_logic::product_manifest::{Granted, RootManifest, bare_product_label}; use crate::host_logic::permissions::PermissionsService; +use crate::host_logic::product_manifest::{Granted, RootManifest, bare_product_label}; use crate::host_logic::sso::messages::RingVrfError; use crate::host_logic::statement_store::current_unix_secs; use crate::runtime::dotns_lookup::DotnsLookup; diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index b20f935ec..65b993359 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -1384,12 +1384,12 @@ mod tests { ); let platform = Arc::new(StubPlatform::default()); - let granted = futures::executor::block_on(crate::runtime::manifest_grants_scope( + let granted = futures::executor::block_on(crate::runtime::product_manifest::grants_scope( &services, platform.as_ref(), "dim2.dot", "peopl.dot", - crate::runtime::GrantedScope::Context, + crate::host_logic::product_manifest::Granted::Context, )); // Documentation, not a guard, and labelled so nobody reads it as one: // with no cached manifest and no reachable chain this is false whether @@ -1520,8 +1520,8 @@ mod tests { r#"{{"$v":1,"displayName":"D","description":"d", "icon":{{"cid":"c","format":"png"}},"trustedProducts":{trusted_products}}}"# ); - let entry = crate::runtime::CachedManifest { - fetched_at_secs: crate::runtime::unix_time_secs().expect("clock is after the epoch"), + let entry = crate::runtime::product_manifest::CachedManifest { + fetched_at_secs: crate::host_logic::statement_store::current_unix_secs(), json: Some(json), }; futures::executor::block_on( diff --git a/scripts/cross-product-ringvrf-e2e.sh b/scripts/cross-product-ringvrf-e2e.sh new file mode 100755 index 000000000..52d25af86 --- /dev/null +++ b/scripts/cross-product-ringvrf-e2e.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Cross-product ring-VRF signing against a real signing-host CLI, driven +# through the `@parity/truapi` client. +# +# One product signs with another product's registered ring-VRF key; only the +# `context` grant in `peopl.paseo`'s local product config permits that. The +# sibling of `cross-product-storage-e2e.sh`, and the first end-to-end run in +# which a cross-product ring-VRF call is *granted* rather than refused. +# +# Unlike the storage sibling this reaches a chain: registering a ring-VRF key +# resolves a ring on the People chain. +# +# The runner serves one product per host process, so each phase is its own +# `truapi-host` run. They share one `--base-path`, which is what makes the +# signature genuinely cross-product: the key the later phases sign with was +# registered by a process that has already exited. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +fixtures="rust/crates/truapi-host-cli/js/fixtures" +script="rust/crates/truapi-host-cli/js/cross-product-ringvrf-e2e.ts" +network="${E2E_NETWORK:-paseo-next-v2}" + +state="$(mktemp -d)" +trap 'rm -rf "$state"' EXIT + +echo "==> building truapi-host" +cargo build -q -p truapi-host-cli +host="target/debug/truapi-host" + +# Run one phase as one product. A phase that exits non-zero fails the script, +# including the phases whose assertion is that a signature was refused: the +# script distinguishes a refusal it expected from a host that fell over. +run_phase() { + local phase="$1" product="$2" + echo "==> $phase (as $product)" + E2E_PHASE="$phase" "$host" signing-host \ + --network "$network" \ + --base-path "$state" \ + --product-id "$product" \ + --product-config "$fixtures/peopl.paseo.json" \ + --product-config "$fixtures/dim2.paseo.json" \ + --auto-accept \ + --script "$script" +} + +run_phase register peopl.paseo +run_phase sign-granted dim2.paseo +run_phase sign-untrusted stash.paseo +# Last, so the refusal above cannot have been the registration going away. +run_phase sign-again dim2.paseo From 0e8bf60c4f428c9c8d15d2318bc11cee5dd79477 Mon Sep 17 00:00:00 2001 From: Nidish Date: Fri, 11 Sep 2026 00:21:17 +0530 Subject: [PATCH 10/10] test(server): pin the foreign-proof refusal shape without racing the session --- .../truapi-server/tests/wire_result_shape.rs | 39 ++++++++----------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs index 7f7676854..317e2a9a9 100644 --- a/rust/crates/truapi-server/tests/wire_result_shape.rs +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -240,24 +240,8 @@ fn version_index(version: u8) -> u8 { } #[test] -fn foreign_account_proof_returns_not_allowlisted_without_confirmation() { +fn foreign_account_proof_refuses_without_confirmation() { let core = make_core(); - // A session, so the call reaches the cross-product grant check rather than - // stopping at the session guard that now runs before it. Without one this - // asserts `Rejected` and says nothing about allowlisting: `create_proof` - // consults the session first so that the pair of refusals cannot be used - // to probe which product granted which (#655). - core.session_state() - .set_session(truapi_server::host_logic::session::SessionInfo { - public_key: [7u8; 32], - sso: None, - root_entropy_source: None, - identity_account_id: None, - identity_chat_private_key: None, - device_enc_public_key: None, - lite_username: None, - full_username: None, - }); let request = account::HostAccountCreateProofRequest::V1(v01::HostAccountCreateProofRequest { key_handle: v01::ProductAccountId { dot_ns_identifier: "peopl.dot".to_string(), @@ -287,11 +271,20 @@ fn foreign_account_proof_returns_not_allowlisted_without_confirmation() { ); assert_eq!(response.request_id, "p:account-proof"); assert_eq!(response.payload.id, ids.response_id); - // RFC-0024 forbids a prompt fallback for bearer proofs made with a foreign - // key. `peopl.dot` publishes no manifest here, so the `context` grant - // cannot be established and the call is refused outright. + // RFC-0024 forbids a prompt fallback for a bearer proof made with a foreign + // key, and this pins the wire shape of that refusal: an encoded domain + // error, with no confirmation asked of the platform. + // + // It does not pin *which* refusal. `create_account_proof` consults the + // session before the grant (#655), so with no session this is the session + // guard's answer and says nothing about allowlisting. Giving the core a + // session does not fix that: the authority picks one up asynchronously, so + // the assertion races the dispatch. That the grant is what refuses a + // foreign handle is asserted in + // `runtime::signing_host::tests::a_foreign_proof_is_refused_when_the_owner_granted_nothing` + // and end to end by `make e2e-cross-product-ringvrf`. let expected = versioned_result_err_payload(account::HostAccountCreateProofError::V1( - v01::HostAccountCreateProofError::NotAllowlisted, + v01::HostAccountCreateProofError::Rejected, )); assert_eq!(response.payload.value, expected); } @@ -518,7 +511,7 @@ fn subscription_start_receive_stop_through_wire_boundary() { }, }; futures::executor::block_on(core.dispatch(stop, dyn_transport)); - std::thread::sleep(Duration::from_millis(50)); + std::thread::sleep(std::time::Duration::from_millis(50)); core.session_state() .set_session(truapi_server::host_logic::session::SessionInfo { @@ -531,7 +524,7 @@ fn subscription_start_receive_stop_through_wire_boundary() { lite_username: None, full_username: None, }); - std::thread::sleep(Duration::from_millis(50)); + std::thread::sleep(std::time::Duration::from_millis(50)); assert_eq!( transport.sent.lock().unwrap().len(),