diff --git a/Makefile b/Makefile index 880f1ff2a..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-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 @@ -414,6 +414,12 @@ e2e-pairing-cli: ## Run the generated battery against the paired pairing-host CL e2e-chat-cli: ## Run the Chat content-screening battery against a chat signing-host CLI. scripts/battery.sh --chat-host +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/android/truapi-host/README.md b/android/truapi-host/README.md index 6718aa0d0..a377453b7 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -42,6 +42,15 @@ and the People/Bulletin genesis hashes. It must match the People chain's `NetworkSuffix.NetworkSuffix`. Include this configuration update in the embedding app's package upgrade. +`HostRuntimeConfig.assetHubChainGenesisHash` is required. Supply the Asset Hub +genesis hash from the same network configuration, as 32 bytes. Product manifests +are read from the dotNS contracts deployed there, so it is what makes a +`trustedProducts` grant resolvable: without a usable value no manifest resolves, +so every cross-product grant not already cached is refused, and the refusal is +indistinguishable from the other product having granted nothing. Pass 32 zero +bytes only to declare deliberately that this host has no Asset Hub. Include this +configuration update in the embedding app's package upgrade. + ### Compatibility - **minSdk**: 29 (Android 10). Aligns with the polkadot-app-android-v2 floor. @@ -102,6 +111,7 @@ val runtime = TrUAPIHostRuntime( hostName = "My Chat Host", peopleChainGenesisHash = peopleChainGenesisHash, // exactly 32 bytes bulletinChainGenesisHash = bulletinChainGenesisHash, + assetHubChainGenesisHash = assetHubChainGenesisHash, networkSuffix = "dot", ), ) @@ -339,6 +349,9 @@ val runtimeConfig = HostRuntimeConfig( hostIcon = "https://host.example/icon.png", peopleChainGenesisHash = ByteArray(32), bulletinChainGenesisHash = ByteArray(32), + // A real Asset Hub genesis hash. All-zero here would mean "no Asset Hub", + // which refuses every cross-product `trustedProducts` grant. + assetHubChainGenesisHash = assetHubChainGenesisHash, networkSuffix = "dot", // Optional: activate a local signing session from host-held BIP-39 entropy // (no SSO pairing). Omit for the QR pairing flow. 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 a8b0faa62..fdc3b6bd2 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 @@ -96,7 +96,12 @@ enum class ProductExecutionKind { /** * Immutable process-wide configuration shared by every product execution * opened from one [TrUAPIHostRuntime]. [peopleChainGenesisHash] and - * [bulletinChainGenesisHash] must each be exactly 32 bytes. [networkSuffix] is + * [bulletinChainGenesisHash] must each be exactly 32 bytes, and so must + * [assetHubChainGenesisHash], where the dotNS contracts are deployed: product + * manifests are read from there, so it is what makes a `trustedProducts` grant + * resolvable. 32 zero bytes says this host has no Asset Hub, and no manifest + * then resolves, so every cross-product grant is refused — except one already + * cached, which is served without consulting it. [networkSuffix] is * the network's dotNS TLD without the leading dot (`dot`, `paseo`, `testnet`); * the core derives the wallet's reserved identities under it (`uid.`, * `peopl.`), the same person the app's own onboarding derives there. @@ -109,6 +114,7 @@ data class HostRuntimeConfig( val platformVersion: String? = null, val peopleChainGenesisHash: ByteArray, val bulletinChainGenesisHash: ByteArray, + val assetHubChainGenesisHash: ByteArray, val networkSuffix: String, val localSessionSecret: ByteArray? = null, val localSessionLiteUsername: String? = null, @@ -123,6 +129,7 @@ data class HostRuntimeConfig( platformVersion = platformVersion, peopleChainGenesisHash = peopleChainGenesisHash, bulletinChainGenesisHash = bulletinChainGenesisHash, + assetHubChainGenesisHash = assetHubChainGenesisHash, networkSuffix = networkSuffix, localSessionSecret = localSessionSecret, localSessionLiteUsername = localSessionLiteUsername, @@ -138,6 +145,7 @@ data class HostRuntimeConfig( platformVersion == other.platformVersion && peopleChainGenesisHash.contentEquals(other.peopleChainGenesisHash) && bulletinChainGenesisHash.contentEquals(other.bulletinChainGenesisHash) && + assetHubChainGenesisHash.contentEquals(other.assetHubChainGenesisHash) && networkSuffix == other.networkSuffix && localSessionSecret.contentEquals(other.localSessionSecret) && localSessionLiteUsername == other.localSessionLiteUsername @@ -151,6 +159,7 @@ data class HostRuntimeConfig( result = 31 * result + (platformVersion?.hashCode() ?: 0) result = 31 * result + peopleChainGenesisHash.contentHashCode() result = 31 * result + bulletinChainGenesisHash.contentHashCode() + result = 31 * result + assetHubChainGenesisHash.contentHashCode() result = 31 * result + networkSuffix.hashCode() result = 31 * result + (localSessionSecret?.contentHashCode() ?: 0) result = 31 * result + (localSessionLiteUsername?.hashCode() ?: 0) diff --git a/docs/design/product-manifest.md b/docs/design/product-manifest.md index d59df8fa0..1931d0f2b 100644 --- a/docs/design/product-manifest.md +++ b/docs/design/product-manifest.md @@ -27,7 +27,10 @@ type Icon = { format: "jpeg" | "png"; // v1 formats; an unrecognised value is tolerated, not fatal }; -type Granted = "all"; // only v1 grant; unrecognised values are tolerated, not fatal +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 + | "context"; // read this product's account and the identity behind it ``` ### Executable Manifest @@ -145,8 +148,8 @@ directory, so its root CID is `dag-pb` — a raw block has no links and cannot b ## Cross-Product Trust Running products interact through the host — reading another product's account, asking it to -sign. Normally each is a consent prompt; `trustedProducts` skips the prompt for products the -publisher pre-approved. +sign. Normally each is a consent prompt; `trustedProducts` skips the prompt for the scopes the +publisher pre-approved, per product. Grants point inward: @@ -157,14 +160,22 @@ A's manifest: trustedProducts: { "wallet": ["all"] } → products wallet trusts get nothing on A ``` -Keys carry no TLD: `wallet`, not `wallet.dot`. Append the TLD of the network you resolve against -before matching. Missing field, empty record, empty array all mean "prompt as usual". +Each entry's value list scopes the grant: -Two rules the host owes the user: +``` +trustedProducts: { + "wallet": ["all"], → every mediated interaction, now and later + "tracker": ["storage"], → storage reads promptless; account reads still prompt + "hub": ["storage", "context"] → both, and no more when a fourth scope is defined +} +``` + +Implement the rest from [RFC — Scoped grants in `trustedProducts`](../rfcs/granted-scopes.md), +which is normative for what each value covers, how a key matches a caller, what an unrecognised +value does, and what a grant may never override. Restating those here is how the two drift. -- A grant waives the *publisher's* prompt, never a denial the user already gave. -- Revocation is a text-record edit with no signal, so cached grants must expire (see - [Caching](#caching)). +Missing field, empty record, and empty array all mean "prompt as usual". Revocation is a +text-record edit with no signal, so cached grants must expire — see [Caching](#caching). ## Error Handling @@ -174,7 +185,7 @@ Two rules the host owes the user: | Unknown `$v` | Undiscoverable; skip, surface diagnostic | | Malformed JSON / schema validation fail | Do not launch; surface diagnostic | | Unknown `icon.format` | Placeholder; never sniff or auto-correct | -| Unknown `Granted` value | Ignore it; manifest stays valid | +| Unknown `Granted` value | Ignore it; the call proceeds ungranted. Manifest stays valid | | `trustedProducts` key does not resolve | Entry inert; manifest stays valid | | `trustedProducts` key carries a TLD | Does not resolve; entry inert | | Icon CID unreachable, or bytes undecodable | Render placeholder; product launchable | diff --git a/docs/rfcs/core-manifest-resolution.md b/docs/rfcs/core-manifest-resolution.md new file mode 100644 index 000000000..978fb065e --- /dev/null +++ b/docs/rfcs/core-manifest-resolution.md @@ -0,0 +1,54 @@ +--- +title: "Core-resolved product manifests" +owner: "@filippovecchiato" +status: draft +--- + +# RFC — Core-resolved product manifests + +## Summary + +The core resolves each product's root manifest from dotNS and answers whether one +product grants another a scope. Hosts neither fetch manifests nor decide what a grant +covers. + +## Motivation + +[Scoped grants in `trustedProducts`][granted] defines what a publisher can pre-approve, +and the core already adjudicates every cross-product call in one place. But nothing +reads a manifest, so every grant is refused: a publisher who grants `storage` to a +partner still sees every read fail. + +Leaving the reading to hosts scatters it. Each would resolve, parse and adjudicate on +its own, and they would disagree — as they already do for cross-product ring-VRF keys, +where one host prompts and the core refuses. A grant that means one thing on a phone and +another on a desktop is not one a publisher can reason about. + +## Approach + +The core performs the resolution [RFC — Product Manifest Format][manifest] already +specifies and answers grant questions from the result. Nothing about the format changes. + +Every reason a grant cannot be established is one answer: unresolvable product, no +manifest, unparseable document, unreachable chain, narrower scope. Distinguishing them +would turn any cross-product call into a probe for which products exist. It also means +an unreachable chain withdraws grants rather than assuming them. + +A resolved manifest is cached and honoured for one day. dotNS attaches no signal to a +record edit, so that lifetime is the only bound on a revoked grant — a security +parameter, which is why it is fixed here rather than left to each host. + +## Trade-offs + +- A revoked grant stays in force for up to a day. The alternative is a chain read on + every cross-product call. +- A grant is only as strong as dotNS ownership: a transferred name widens access with + one `setText`. Inherent to publisher-declared trust, and why a grant never waives a + denial the user already gave. +- Hosts lose their own trust policy. That is the point, but a host with reason to be + stricter has nowhere to express it. +- Dropped: resolving per host, which reproduces the divergence above; and caching until + evicted, which makes revocation impossible. + +[granted]: granted-scopes.md +[manifest]: product-manifest.md diff --git a/docs/rfcs/granted-scopes.md b/docs/rfcs/granted-scopes.md new file mode 100644 index 000000000..0835cfe3f --- /dev/null +++ b/docs/rfcs/granted-scopes.md @@ -0,0 +1,69 @@ +--- +title: "Scoped grants in trustedProducts" +owner: "@filippovecchiato" +--- + +# RFC — Scoped grants in `trustedProducts` + +| | | +| --------------- | ---------------------------------------------------------------------------------- | +| **Start Date** | 2026-08-19 | +| **Description** | Widen `Granted` from the single `all` wildcard to `all`, `storage`, and `context`. | +| **Authors** | Filippo Vecchiato | + +## Summary + +`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 + +`all` resolves against every cross-product interaction the Host mediates at the moment the grant is used, including interactions added after publication. A wallet that wants a portfolio tracker to read its holdings has to grant `all`, which also pre-approves every account and signing interaction. "Read my stored data, prompt for anything else" is not expressible, so `all` is what gets published. + +## Detailed Design + +[RFC — Product Manifest Format][manifest] gains two `Granted` values: + +```typescript +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` 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 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 + +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 + +A separate field per scope (a foreign-storage record beside `trustedProducts`) splits one question — what may this product do to me — across fields that must be read together, and costs a top-level field per future scope. Per-scope operations (`{ storage: ["read", "write"] }`) add a second dimension to the manifest's only unbounded field; a `storage-write` value can land later under the ignore-unrecognised rule. + +## Unresolved Questions + +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 8e2279661..ab3c9de53 100644 --- a/docs/rfcs/product-manifest.md +++ b/docs/rfcs/product-manifest.md @@ -116,7 +116,10 @@ type Icon = { format: 'jpeg' | 'png'; // Formats defined by v1. An unrecognised value is tolerated, not fatal. }; -type Granted = 'all'; // The only grant v1 defines. Unrecognised values are ignored, not fatal. +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. + | 'context'; // Read this product's account and the identity behind it. ``` #### Icons @@ -134,7 +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 one, `all` — 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. Hosts MUST ignore unrecognised values, keep the recognised ones, and MUST NOT fail validation over them. +- **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. @@ -524,6 +528,6 @@ A conforming Host implementation should produce well-defined behaviour for each ## Future Directions -- `Granted` will gain per-capability values (account read, signing, …) alongside `all` once the Host runtime contracts name those capabilities; `all` stays the wildcard, and the array shape and the ignore-unrecognised-values rule let the narrower values 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/ios/truapi-host/README.md b/ios/truapi-host/README.md index 340c4092b..5b202f795 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -95,6 +95,15 @@ and the People/Bulletin genesis hashes. It must match the People chain's `NetworkSuffix.NetworkSuffix`. Include this configuration update in the embedding app's package upgrade. +`HostRuntimeConfig.assetHubChainGenesisHash` is required. Supply the Asset Hub +genesis hash from the same network configuration, as 32 bytes. Product manifests +are read from the dotNS contracts deployed there, so it is what makes a +`trustedProducts` grant resolvable: without a usable value no manifest resolves, +so every cross-product grant not already cached is refused, and the refusal is +indistinguishable from the other product having granted nothing. Pass 32 zero +bytes only to declare deliberately that this host has no Asset Hub. Include this +configuration update in the embedding app's package upgrade. + Run the package tests against an iOS simulator (the xcframework has no macOS slice): ```bash @@ -147,6 +156,7 @@ let runtime = try TrUAPIHostRuntime( hostName: "My Chat Host", peopleChainGenesisHash: peopleChainGenesisHash, // exactly 32 bytes bulletinChainGenesisHash: bulletinChainGenesisHash, + assetHubChainGenesisHash: assetHubChainGenesisHash, networkSuffix: "dot" ) ) @@ -386,6 +396,9 @@ let runtimeConfig = HostRuntimeConfig( hostIcon: "https://host.example/icon.png", peopleChainGenesisHash: Data(repeating: 0, count: 32), bulletinChainGenesisHash: Data(repeating: 0, count: 32), + // A real Asset Hub genesis hash. All-zero here would mean "no Asset Hub", + // which refuses every cross-product `trustedProducts` grant. + assetHubChainGenesisHash: assetHubChainGenesisHash, networkSuffix: "dot" ) let runtime = try TrUAPIHostRuntime(bridge: bridge, runtimeConfig: runtimeConfig) diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 9a4559c2c..67eb53e50 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -29,6 +29,12 @@ public struct HostRuntimeConfig: Sendable, Equatable { public let platformVersion: String? public let peopleChainGenesisHash: Data public let bulletinChainGenesisHash: Data + /// Asset Hub genesis hash, where the dotNS contracts are deployed. Product + /// manifests are read from there, so this is what makes a `trustedProducts` + /// grant resolvable. 32 zero bytes says this host has no Asset Hub, and no + /// manifest then resolves, so every cross-product grant is refused — except + /// one already cached, which is served without consulting this. + public let assetHubChainGenesisHash: Data /// The network's dotNS TLD without the leading dot (`dot`, `paseo`, /// `testnet`). The core derives the wallet's reserved identities under it: /// `uid.` for the identity account and `peopl.` for the @@ -46,6 +52,7 @@ public struct HostRuntimeConfig: Sendable, Equatable { platformVersion: String? = nil, peopleChainGenesisHash: Data, bulletinChainGenesisHash: Data, + assetHubChainGenesisHash: Data, networkSuffix: String, localSessionSecret: Data? = nil, localSessionLiteUsername: String? = nil @@ -57,6 +64,7 @@ public struct HostRuntimeConfig: Sendable, Equatable { self.platformVersion = platformVersion self.peopleChainGenesisHash = peopleChainGenesisHash self.bulletinChainGenesisHash = bulletinChainGenesisHash + self.assetHubChainGenesisHash = assetHubChainGenesisHash self.networkSuffix = networkSuffix self.localSessionSecret = localSessionSecret self.localSessionLiteUsername = localSessionLiteUsername @@ -72,6 +80,7 @@ public struct HostRuntimeConfig: Sendable, Equatable { platformVersion: platformVersion, peopleChainGenesisHash: peopleChainGenesisHash, bulletinChainGenesisHash: bulletinChainGenesisHash, + assetHubChainGenesisHash: assetHubChainGenesisHash, networkSuffix: networkSuffix, localSessionSecret: localSessionSecret, localSessionLiteUsername: localSessionLiteUsername diff --git a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift index 90291ed09..e34ec7d1a 100644 --- a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift +++ b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift @@ -79,6 +79,7 @@ private extension TrUAPIWsBridgeTests { hostName: "truapi-host-tests", peopleChainGenesisHash: Data(repeating: 0, count: 32), bulletinChainGenesisHash: Data(repeating: 0, count: 32), + assetHubChainGenesisHash: Data(repeating: 0, count: 32), networkSuffix: "paseo" ) } diff --git a/js/packages/truapi-host/src/runtime.ts b/js/packages/truapi-host/src/runtime.ts index 87e16bb20..c8217fd91 100644 --- a/js/packages/truapi-host/src/runtime.ts +++ b/js/packages/truapi-host/src/runtime.ts @@ -96,7 +96,16 @@ export interface ProductRuntimeConfig { /** Bulletin-chain genesis hash. */ genesisHash: string | Uint8Array; }; - /** Asset Hub configuration used to resolve session usernames from dotNS. */ + /** + * Asset Hub configuration. Used to resolve session usernames from dotNS, and + * to read the product manifests that carry `trustedProducts` grants — so + * without a usable genesis hash every cross-product call is refused, + * indistinguishably from the other product having granted nothing. An + * all-zero hash declares deliberately that this host has no Asset Hub. + * + * The wasm signing host requires the same `assetHub.genesisHash`, though it + * takes an untyped config object rather than this interface. + */ assetHub: { /** Asset Hub genesis hash. */ genesisHash: string | Uint8Array; diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index b9ccb6ee7..b809a6f18 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -193,7 +193,15 @@ export type CoreStorageKey = peerStatementAccountId: Uint8Array; peerEncryptionPublicKey: Uint8Array; }; - }; + } + /** + * Cached root manifest of one product, as published to dotNS. + * + * The value carries the manifest JSON alongside the time it was read. The + * core honours it for a bounded lifetime, which is what makes a revoked + * trust grant eventually take effect. + */ + | { tag: "ProductManifest"; value: { productId: string } }; /** * Review shown before a product creates a ring-VRF proof (RFC 0004). @@ -643,6 +651,9 @@ export const CoreStorageKey: S.Codec = S.lazy( peerStatementAccountId: Uint8Array; peerEncryptionPublicKey: Uint8Array; }>, + ProductManifest: S.Struct({ productId: S.str }) as S.Codec<{ + productId: string; + }>, }), ); @@ -1282,10 +1293,19 @@ export interface PreimageHost { * The core namespaces product keys before calling this trait. Host * implementations may treat `key` as opaque or decode it with * `ProductStorageKey` when their physical storage is separated by product. + * Storage errors are pinned to `v01` rather than taken from `truapi::latest`. + * The read error gained a cross-product refusal in v0.2 that the core decides + * before it ever calls a host, so a host has no way to produce it and should + * not have to match on it. */ export interface ProductStorage { /** * Read a value by key. + * + * Always the calling product's own storage. A read addressed at another + * product is adjudicated in the core against that product's manifest and + * refused there, so a host is never asked to enforce a grant and has no + * variant for one. */ read(key: string): Promise; diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 5d59d7f93..2956635ad 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -50,7 +50,7 @@ pub enum WireKind { /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so /// the debugger refuses to decode a frame whose contract differs from /// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "0449982638d57658"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "753c87591ca094ea"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { 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/cross-product-storage-e2e.ts b/rust/crates/truapi-host-cli/js/cross-product-storage-e2e.ts new file mode 100644 index 000000000..9e86cabdd --- /dev/null +++ b/rust/crates/truapi-host-cli/js/cross-product-storage-e2e.ts @@ -0,0 +1,134 @@ +// Cross-product storage against a real signing-host CLI. +// +// One product writes to its own storage and another reads it, which the manifest +// grant is the only thing permitting. The host resolves that grant from the +// `trustedProducts` in a local product config, which seeds the manifest cache, +// so the granted read runs before either product is deployed and without a +// chain — see `--product-config` and `truapi-host-cli/src/product_config.rs`. +// +// A target with no seeded entry is not chain-free: the cache miss goes to dotNS +// on Asset Hub before refusing, so the `read-missing` phase does reach the +// network. +// +// The runner serves one product per host process, so `scripts/cross-product-storage-e2e.sh` +// invokes this once per phase with `E2E_PHASE` set, pointing every run at the +// same `--base-path` so the storage written in one is there for the next. +// +// Phases, and what each proves: +// +// write peopl.paseo stores a value in its own storage. +// read dim2.paseo reads it. Named in trustedProducts, so allowed. +// read-untrusted stash.paseo reads the same key. Same target, same value, +// absent from trustedProducts: refused. +// read-missing dim2.paseo reads a product that published nothing. Must be +// refused identically to the above, or the call becomes a +// probe for which products exist. +// read-again dim2.paseo reads once more, so a refusal above cannot be +// the value having expired or gone. + +const OWNER = "peopl.paseo"; +const GRANTED = "dim2.paseo"; +const UNTRUSTED = "stash.paseo"; +const NO_MANIFEST = "nobody.paseo"; +const KEY = "unwrapped"; +/// "state", as the hex the storage codec takes. +const VALUE = "0x7374617465"; + +const REFUSAL = "AccessNotGranted"; + +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}`, + ); + } +} + +/// Read `product`'s storage and require the standard refusal. +async function expectRefused(product: string): Promise { + const read = await truapi.localStorage.read({ product, key: KEY }); + if (read.isOk()) { + throw new Error( + `${host.productId} read ${product} without a grant: ${stringify(read.value)}`, + ); + } + const tag = refusalTag(read.error); + if (tag !== REFUSAL) { + // Anything else leaks why it failed, which is what one refusal prevents. + throw new Error( + `${host.productId} -> ${product}: expected ${REFUSAL}, got ${stringify(read.error)}`, + ); + } + console.log(`refused ${host.productId} -> ${product}: ${REFUSAL}`); +} + +/// Read `OWNER`'s storage and require the value written in the write phase. +async function expectRead(): Promise { + const read = await truapi.localStorage.read({ product: OWNER, key: KEY }); + if (!read.isOk()) { + throw new Error( + `${host.productId} was granted ${OWNER} but refused: ${stringify(read.error)}`, + ); + } + const value = read.value.value; + if (!value) { + // The grant resolved and the host answered empty, which is what a host + // keying storage off the caller rather than the owner in the key does. + throw new Error( + `${host.productId} read ${OWNER} and got nothing: the host keyed storage off the caller`, + ); + } + if (value !== VALUE) { + throw new Error(`expected ${VALUE} from ${OWNER}, got ${value}`); + } + console.log(`read ${host.productId} -> ${OWNER}: ${value}`); +} + +const phase = process.env.E2E_PHASE; + +switch (phase) { + case "write": { + expectProduct(OWNER); + const written = await truapi.localStorage.write({ key: KEY, value: VALUE }); + if (!written.isOk()) { + throw new Error(`${OWNER} could not write its own storage: ${stringify(written.error)}`); + } + console.log(`wrote ${OWNER}/${KEY}`); + break; + } + case "read": + case "read-again": { + expectProduct(GRANTED); + await expectRead(); + break; + } + case "read-untrusted": { + expectProduct(UNTRUSTED); + await expectRefused(OWNER); + break; + } + case "read-missing": { + expectProduct(GRANTED); + await expectRefused(NO_MANIFEST); + break; + } + default: + throw new Error(`set E2E_PHASE to one of write, read, read-untrusted, read-missing, read-again (got ${phase ?? "nothing"})`); +} diff --git a/rust/crates/truapi-host-cli/js/fixtures/dim2.paseo.json b/rust/crates/truapi-host-cli/js/fixtures/dim2.paseo.json new file mode 100644 index 000000000..029a1fcac --- /dev/null +++ b/rust/crates/truapi-host-cli/js/fixtures/dim2.paseo.json @@ -0,0 +1,6 @@ +{ + "productName": "dim2.paseo", + "displayName": "Jollity", + "description": "Stands in for the game product in the cross-product storage e2e. Grants nothing: a grant points inward, so reading peopl.paseo needs only peopl.paseo's config.", + "icon": "./icon.png" +} diff --git a/rust/crates/truapi-host-cli/js/fixtures/peopl.paseo.json b/rust/crates/truapi-host-cli/js/fixtures/peopl.paseo.json new file mode 100644 index 000000000..0a0c881ce --- /dev/null +++ b/rust/crates/truapi-host-cli/js/fixtures/peopl.paseo.json @@ -0,0 +1,9 @@ +{ + "productName": "peopl.paseo", + "displayName": "Personhood", + "description": "Stands in for the personhood product in the cross-product storage and ring-VRF e2es.", + "icon": "./icon.png", + "trustedProducts": { + "dim2": ["storage", "context"] + } +} diff --git a/rust/crates/truapi-host-cli/src/frame_server.rs b/rust/crates/truapi-host-cli/src/frame_server.rs index 79a7c13ca..0f8eaca39 100644 --- a/rust/crates/truapi-host-cli/src/frame_server.rs +++ b/rust/crates/truapi-host-cli/src/frame_server.rs @@ -764,6 +764,7 @@ mod tests { }, network.people_genesis, network.bulletin_genesis, + network.asset_hub_genesis, network.network_suffix.to_string(), )?; let spawner: truapi_server::subscription::Spawner = Arc::new(|_| {}); @@ -1106,4 +1107,223 @@ mod tests { assert!(!socket_directory.exists()); Ok(()) } + + /// A grant between two products, declared the way a developer declares it. + /// + /// `peopl.paseo`'s local product config names `dim2` in `trustedProducts`, + /// exactly as the publisher will read it when the product is deployed. The + /// host applies that config and the grant resolves for the run, which is + /// the only way to exercise a partner flow before either product exists on + /// chain. + /// + /// This runs against the real `CliPlatform`, so it also pins the half a stub + /// cannot: the host keys storage off the owner encoded in the key rather + /// than off the product that asked, without which every granted read comes + /// back empty and the grant silently does nothing. + mod cross_product_storage { + use super::*; + use crate::product_config::{self, LocalProductConfig}; + use parity_scale_codec::{Decode, Encode}; + use std::sync::Mutex; + use truapi::versioned::local_storage::{ + HostLocalStorageReadRequest, HostLocalStorageWriteRequest, + }; + use truapi::{v01, v02}; + use truapi_server::frame::{Payload, ProtocolMessage, request_ids}; + + const OWNER: &str = "peopl.paseo"; + const CALLER: &str = "dim2.paseo"; + const KEY: &str = "unwrapped"; + const VALUE: &[u8] = b"the collection state both products describe"; + + /// Collects the frames a product runtime answers with. + #[derive(Default)] + struct CapturedFrames(Mutex>>); + + impl FrameSink for CapturedFrames { + fn emit_frame(&self, frame: Vec) { + self.0.lock().expect("frame mutex poisoned").push(frame); + } + } + + impl CapturedFrames { + fn take_one(&self) -> Vec { + let mut frames = self.0.lock().expect("frame mutex poisoned"); + assert_eq!(frames.len(), 1, "expected exactly one answer"); + frames.remove(0) + } + } + + fn host() -> Result<(Arc, SigningHostRuntime)> { + // A real Asset Hub genesis hash, so the signing role installs one + // and manifest resolution takes the dotNS path. The preset URL is + // replaced with a closed port to keep this off the live network: + // on the preset these tests dialled `paseo-asset-hub-next-rpc` + // for real, so they passed or failed on Paseo's reachability. + // + // It does not make them fast. A closed port costs the same 10s as + // a live one: the provider keeps retrying rather than ending the + // follow, so `wait_for_chain_head_best_hash` never sees `Stop` and + // burns all of `dotns_lookup::OPERATION_TIMEOUT`. Measured both + // ways — 10.01s live, 10.02s refused, 0.01s with no Asset Hub at + // all. An unreachable Asset Hub is not distinguishable here from a + // merely slow one. + let mut network = crate::network::Network::default().config(); + network.asset_hub_ws = "ws://127.0.0.1:1"; + let platform = crate::platform::CliPlatform::new( + network, + None, + crate::platform::ApprovalPolicy::AutoAccept, + None, + ); + let config = truapi_platform::SigningHostConfig::new( + truapi_platform::HostInfo { + name: "Cross-product storage test".into(), + icon: None, + version: None, + platform: truapi::latest::HostPlatform::Cli, + }, + truapi_platform::PlatformInfo { + kind: Some("test".into()), + version: None, + }, + network.people_genesis, + network.bulletin_genesis, + network.asset_hub_genesis, + network.network_suffix.to_string(), + )?; + let spawner: truapi_server::subscription::Spawner = Arc::new(|_| {}); + Ok(( + platform.clone(), + SigningHostRuntime::new(platform, config, spawner), + )) + } + + /// Apply `OWNER`'s local product config, `trusted` being the + /// `trustedProducts` a developer wrote in it. + async fn apply_config(platform: &crate::platform::CliPlatform, trusted: &str) -> String { + let config: LocalProductConfig = serde_json::from_str(&format!( + r#"{{"productName":"{OWNER}","displayName":"Personhood", + "trustedProducts":{trusted}}}"# + )) + .expect("the config parses"); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock is after the epoch") + .as_secs(); + let applied = product_config::apply(platform, std::slice::from_ref(&config), now) + .await + .expect("the config applies"); + applied.lines.join("\n") + } + + async fn call( + runtime: &SigningHostRuntime, + product: &str, + method: &str, + payload: Vec, + ) -> Vec { + let ids = request_ids(method).expect("the method is in the wire table"); + let frames = Arc::new(CapturedFrames::default()); + let connection = runtime.product_runtime( + ProductContext::new(product.to_string()).expect("the product id is valid"), + frames.clone(), + ); + let frame = ProtocolMessage { + request_id: "1".to_string(), + payload: Payload { + id: ids.request_id, + value: payload, + }, + } + .encode(); + connection + .receive_frame(frame) + .await + .expect("the host answers the frame"); + let answered = ProtocolMessage::decode(&mut frames.take_one().as_slice()) + .expect("the answer is a protocol message"); + assert_eq!( + answered.payload.id, ids.response_id, + "answered on the wrong discriminant" + ); + answered.payload.value + } + + /// Store a value in `OWNER`'s own storage, through `OWNER`'s connection. + async fn write_owner_value(runtime: &SigningHostRuntime) { + let payload = HostLocalStorageWriteRequest::V1(v01::HostLocalStorageWriteRequest { + key: KEY.to_string(), + value: VALUE.to_vec(), + }) + .encode(); + call(runtime, OWNER, "local_storage_write", payload).await; + } + + /// `CALLER` reads `OWNER`'s storage at `KEY`. + async fn read_across( + runtime: &SigningHostRuntime, + ) -> Result>, v02::HostLocalStorageReadError> { + let payload = HostLocalStorageReadRequest::V2(v02::HostLocalStorageReadRequest { + product: Some(OWNER.to_string()), + key: KEY.to_string(), + }) + .encode(); + let answer = call(runtime, CALLER, "local_storage_read", payload).await; + // `[version][Result tag][value]`: the frame carries the version out + // of band, so the payload inside it is the concrete version's type. + let (version, mut body) = answer.split_first().expect("the answer is not empty"); + assert_eq!(*version, 1, "expected a v0.2 answer"); + let decoded: Result< + v01::HostLocalStorageReadResponse, + truapi::CallError, + > = Decode::decode(&mut body).expect("the answer decodes"); + match decoded { + Ok(v01::HostLocalStorageReadResponse { value }) => Ok(value), + Err(truapi::CallError::Domain(error)) => Err(error), + Err(other) => panic!("unexpected refusal: {other:?}"), + } + } + + #[tokio::test] + async fn a_granted_product_reads_the_granting_products_storage() -> Result<()> { + let (platform, runtime) = host()?; + let transcript = apply_config(&platform, r#"{"dim2":["storage"]}"#).await; + // Nobody should ship believing this grant is published. + assert_eq!(transcript, "peopl.paseo: dim2 -> [storage]"); + write_owner_value(&runtime).await; + assert_eq!(read_across(&runtime).await, Ok(Some(VALUE.to_vec()))); + Ok(()) + } + + #[tokio::test] + async fn an_ungranted_product_is_refused() -> Result<()> { + let (platform, runtime) = host()?; + apply_config(&platform, r#"{"stash":["storage"]}"#).await; + write_owner_value(&runtime).await; + assert_eq!( + read_across(&runtime).await, + Err(v02::HostLocalStorageReadError::AccessNotGranted) + ); + Ok(()) + } + + #[tokio::test] + async fn a_product_with_no_config_is_refused_the_same_way() -> Result<()> { + // No config applied, so nothing is seeded in the manifest cache + // and resolution falls through to dotNS, which cannot answer here. + // Note what this pins down: an Asset Hub that never answers is + // refused identically to a config that named someone else. The + // caller cannot tell "you were not granted this" from "the grant + // could not be looked up", which is the same collapse the + // `assetHubChainGenesisHash` docs warn about. + let (_platform, runtime) = host()?; + write_owner_value(&runtime).await; + assert_eq!( + read_across(&runtime).await, + Err(v02::HostLocalStorageReadError::AccessNotGranted) + ); + Ok(()) + } + } } diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 742776687..13bd215b0 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -21,6 +21,7 @@ mod dotns_read; mod frame_server; mod network; mod platform; +mod product_config; mod qr_scanner; mod register_name; mod script_runner; @@ -382,6 +383,11 @@ struct DevArgs { /// Base directory; CLI-managed state lives in its v2 subdirectory. #[arg(long = "base-path", env = "TRUAPI_HOST_BASE_PATH")] base_path: Option, + /// Local product config declaring `trustedProducts`, as the publisher will + /// read it. Repeat to serve a grant between two products. The grants are + /// applied for this run only and never reach a chain. + #[arg(long = "product-config")] + product_config: Vec, /// Development command to run once the host is ready, after `--`. #[arg(last = true)] command: Vec, @@ -442,6 +448,10 @@ struct SigningHostArgs { /// `--auto-accept`, because a process with no terminal cannot prompt. #[arg(long)] serve: bool, + /// Local product config declaring `trustedProducts`, as the publisher will + /// read it. Repeat to serve a grant between two products. + #[arg(long = "product-config")] + product_config: Vec, /// Execute one slash command without starting the terminal UI. #[command(subcommand)] action: Option, @@ -1452,6 +1462,7 @@ async fn start_signing_host( ui.clone(), chat.clone(), )?; + apply_local_product_grants(platform.as_ref(), &args.product_config).await?; let runtime_factory = frame_server::SwitchableSigningRuntime::new(runtime.clone()); let last_script = profile .as_ref() @@ -1535,6 +1546,7 @@ fn build_signing_runtime( platform_info(), network.people_genesis, network.bulletin_genesis, + network.asset_hub_genesis, network.network_suffix.to_string(), ) .context("invalid signing host config")?; @@ -1826,6 +1838,34 @@ const INTERRUPTED_EXIT_CODE: i32 = 130; /// they cannot disagree: the product id names the development server's own /// origin, and the bridge script the product loads is generated by this /// process from the endpoint it just bound. +/// Apply the grants each local product config declares, and say that they are +/// local. +/// +/// The core resolves these through the same manifest path it uses on chain, so +/// what a developer sees here is what the published manifest will do. What it +/// cannot tell them is that the manifest is not published yet, so the host says +/// it every run: a grant that works locally and was never deployed is the +/// failure this feature would otherwise cause. +async fn apply_local_product_grants(platform: &CliPlatform, paths: &[PathBuf]) -> Result<()> { + if paths.is_empty() { + return Ok(()); + } + let configs = product_config::read_all(paths)?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("system clock is before the Unix epoch")? + .as_secs(); + let applied = product_config::apply(platform, &configs, now).await?; + if applied.is_empty() { + return Ok(()); + } + tracing::info!("Local product grants — declared in config, not published to dotNS:"); + for line in &applied.lines { + tracing::info!(" {line}"); + } + Ok(()) +} + async fn run_dev( args: DevArgs, initial_log_filter: String, @@ -1840,6 +1880,7 @@ async fn run_dev( session: args.session, mnemonic: args.mnemonic, base_path: args.base_path, + product_config: args.product_config, frame_listen: Some(SocketAddr::from((Ipv4Addr::LOCALHOST, args.port))), // A process with no terminal cannot prompt, which is why this pairs // with a testnet-only network preset. diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index facad2333..2c60d3b70 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -21,6 +21,7 @@ use sha2::{Digest, Sha256}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::sync::Mutex as AsyncMutex; use truapi::latest as api; +use truapi::v01; use truapi_platform::{ AuthState, ChainProvider, CoreStorage, CoreStorageKey, DevicePermissionStatus, Features, JsonRpcConnection, LocaleHost, Navigation, Notifications, PermissionStatusHost, Permissions, @@ -437,9 +438,9 @@ async fn prompt_yes_no(action: &str, detail: &str) -> bool { #[async_trait] impl ProductStorage for CliPlatform { - async fn read(&self, key: String) -> Result>, api::HostLocalStorageReadError> { + async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { let scoped = ProductStorageKey::decode(&key) - .map_err(|reason| api::HostLocalStorageReadError::Unknown { reason })?; + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason })?; Ok(self .product_storage .lock() @@ -453,9 +454,9 @@ impl ProductStorage for CliPlatform { &self, key: String, value: Vec, - ) -> Result<(), api::HostLocalStorageReadError> { + ) -> Result<(), v01::HostLocalStorageReadError> { let scoped = ProductStorageKey::decode(&key) - .map_err(|reason| api::HostLocalStorageReadError::Unknown { reason })?; + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason })?; let mut storage = self .product_storage .lock() @@ -463,12 +464,12 @@ impl ProductStorage for CliPlatform { let values = storage.entry(scoped.product_id().to_string()).or_default(); values.insert(scoped.key().to_string(), value); self.persist_product_storage(scoped.product_id(), values) - .map_err(|reason| api::HostLocalStorageReadError::Unknown { reason }) + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) } - async fn clear(&self, key: String) -> Result<(), api::HostLocalStorageReadError> { + async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { let scoped = ProductStorageKey::decode(&key) - .map_err(|reason| api::HostLocalStorageReadError::Unknown { reason })?; + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason })?; let mut storage = self .product_storage .lock() @@ -476,7 +477,7 @@ impl ProductStorage for CliPlatform { let values = storage.entry(scoped.product_id().to_string()).or_default(); values.remove(scoped.key()); self.persist_product_storage(scoped.product_id(), values) - .map_err(|reason| api::HostLocalStorageReadError::Unknown { reason }) + .map_err(|reason| v01::HostLocalStorageReadError::Unknown { reason }) } } diff --git a/rust/crates/truapi-host-cli/src/product_config.rs b/rust/crates/truapi-host-cli/src/product_config.rs new file mode 100644 index 000000000..b582379a6 --- /dev/null +++ b/rust/crates/truapi-host-cli/src/product_config.rs @@ -0,0 +1,207 @@ +//! The local product config a developer authors, and the grants it declares. +//! +//! [RFC — Product Manifest Format] defines `LocalProductConfig` as the file a +//! publisher reads before it writes a product's root manifest to dotNS. Its +//! `trustedProducts` field has the same shape as the published manifest's, so +//! the grants a developer intends are already written down before anything is +//! deployed. +//! +//! A product under development has nothing on chain to resolve, so every +//! cross-product call is refused and the flows a partner integration exists for +//! cannot be exercised at all. This module reads the same field the publisher +//! will read, and seeds it into the manifest cache the core would otherwise +//! fill from dotNS. +//! +//! That makes it a local implementation of the manifest path rather than a +//! development relaxation: the core resolves the grant through the code it +//! always runs, and a scope the core does not honour is refused here exactly as +//! it would be on chain. What differs is only where the document came from, so +//! the host says so on startup — a grant that works locally and was never +//! published is the one mistake this must not help anyone ship. +//! +//! [RFC — Product Manifest Format]: ../../../docs/rfcs/product-manifest.md + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::Deserialize; +use truapi_platform::{CoreStorage, CoreStorageKey}; +use truapi_server::encode_cached_root_manifest; + +/// The developer-authored config for one product. +/// +/// Only the fields the host needs are read. The rest of the shape belongs to +/// the publisher, and an unknown field is not an error: this file is +/// source-controlled by the developer and shared with tooling that will grow +/// fields the host has no use for. +#[derive(Debug, Clone, Deserialize)] +pub struct LocalProductConfig { + /// The product's dotNS base name, e.g. `peopl.paseo`. The id its grants and + /// its storage are resolved under. + #[serde(rename = "productName")] + pub product_name: String, + /// Human-readable name, carried into the seeded manifest so what the host + /// resolves looks like what the publisher will write. + #[serde(rename = "displayName", default)] + pub display_name: Option, + /// Grants this product extends to others, keyed by bare product id. + #[serde(rename = "trustedProducts", default)] + pub trusted_products: BTreeMap>, +} + +impl LocalProductConfig { + /// Read a config from a JSON file. + pub fn read(path: &Path) -> Result { + let bytes = std::fs::read(path) + .with_context(|| format!("reading product config {}", path.display()))?; + serde_json::from_slice(&bytes) + .with_context(|| format!("parsing product config {}", path.display())) + } + + /// The root manifest this config describes, as the publisher would write it. + /// + /// Only the fields a grant lookup reads are filled. The icon is a + /// placeholder: nothing resolves it locally, and the manifest parser keeps + /// an unreadable icon non-fatal precisely so a document stays usable + /// without one. + fn root_manifest_json(&self) -> String { + let trusted = serde_json::to_string(&self.trusted_products) + .expect("a map of strings to strings always serializes"); + let display = self.display_name.as_deref().unwrap_or(&self.product_name); + let display = serde_json::to_string(display).expect("a string always serializes"); + format!( + r#"{{"$v":1,"displayName":{display},"description":"Served locally by truapi-host.","icon":{{"cid":"","format":"png"}},"trustedProducts":{trusted}}}"# + ) + } + + /// One line naming what this config grants, for the startup transcript. + fn transcript_line(&self) -> String { + if self.trusted_products.is_empty() { + return format!("{}: grants nothing", self.product_name); + } + let grants = self + .trusted_products + .iter() + .map(|(product, scopes)| format!("{product} -> [{}]", scopes.join(", "))) + .collect::>() + .join(", "); + format!("{}: {grants}", self.product_name) + } +} + +/// What [`apply`] seeded, so a caller can report it before serving anything. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppliedGrants { + /// One line per config, naming the product and what it grants. + pub lines: Vec, +} + +impl AppliedGrants { + /// Whether any config was supplied at all. + pub fn is_empty(&self) -> bool { + self.lines.is_empty() + } +} + +/// Seed each config's manifest into the core's cache, so its grants resolve for +/// the life of this run. +/// +/// `now_secs` is the time the manifests count as read from. The core honours a +/// cached manifest for a fixed lifetime and then reads through to the chain, so +/// a run that outlives it would start refusing grants a developer can see in +/// their own config file. Callers seed at startup and, for a long-lived host, +/// again on the same period. +pub async fn apply( + platform: &dyn CoreStorage, + configs: &[LocalProductConfig], + now_secs: u64, +) -> Result { + let mut lines = Vec::with_capacity(configs.len()); + for config in configs { + platform + .write_core_storage( + CoreStorageKey::ProductManifest { + product_id: config.product_name.clone(), + }, + encode_cached_root_manifest(Some(&config.root_manifest_json()), now_secs), + ) + .await + .map_err(|error| anyhow::anyhow!("{error:?}")) + .with_context(|| format!("seeding the local manifest for {}", config.product_name))?; + lines.push(config.transcript_line()); + } + Ok(AppliedGrants { lines }) +} + +/// Read every config named on the command line. +pub fn read_all(paths: &[PathBuf]) -> Result> { + paths + .iter() + .map(|path| LocalProductConfig::read(path)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(json: &str) -> LocalProductConfig { + serde_json::from_str(json).expect("the config parses") + } + + #[test] + fn a_config_carrying_no_grants_still_reads() { + let parsed = config(r#"{"productName":"dim2.paseo"}"#); + assert_eq!(parsed.product_name, "dim2.paseo"); + assert!(parsed.trusted_products.is_empty()); + } + + #[test] + fn fields_the_host_does_not_read_are_not_errors() { + // The publisher owns most of this file; the host reads two fields of it. + let parsed = config( + r#"{"productName":"peopl.paseo","description":"d","icon":"./icon.png", + "app":{"root":"./dist","appVersion":[1,0,0]}, + "trustedProducts":{"dim2":["storage"]}}"#, + ); + assert_eq!(parsed.trusted_products["dim2"], vec!["storage".to_string()]); + } + + #[test] + fn the_seeded_manifest_carries_the_declared_grants() { + let parsed = config( + r#"{"productName":"peopl.paseo","displayName":"Personhood", + "trustedProducts":{"dim2":["storage"],"stash":["all"]}}"#, + ); + let json = parsed.root_manifest_json(); + assert!(json.contains(r#""displayName":"Personhood""#)); + assert!(json.contains(r#""dim2":["storage"]"#)); + assert!(json.contains(r#""stash":["all"]"#)); + assert!(json.contains(r#""$v":1"#)); + } + + #[test] + fn a_scope_the_core_does_not_know_survives_into_the_manifest() { + // The host does not filter the developer's values. An unrecognised + // scope must reach the parser and be ignored there, so local behaviour + // matches what the same document would do on chain. + let parsed = + config(r#"{"productName":"peopl.paseo","trustedProducts":{"dim2":["storage-write"]}}"#); + assert!( + parsed + .root_manifest_json() + .contains(r#""dim2":["storage-write"]"#) + ); + } + + #[test] + fn the_transcript_names_the_product_and_what_it_grants() { + let parsed = + config(r#"{"productName":"peopl.paseo","trustedProducts":{"dim2":["storage"]}}"#); + assert_eq!(parsed.transcript_line(), "peopl.paseo: dim2 -> [storage]"); + + let silent = config(r#"{"productName":"dim2.paseo"}"#); + assert_eq!(silent.transcript_line(), "dim2.paseo: grants nothing"); + } +} diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 170cbaf4e..02c8b9888 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -36,13 +36,12 @@ use truapi::latest::{ HostChatListSubscribeItem, HostChatPostMessageError, HostChatPostMessageRequest, HostChatPostMessageResponse, HostChatRegisterBotError, HostChatRegisterBotRequest, HostChatRegisterBotResponse, HostDevicePermissionRequest, HostDevicePermissionResponse, - HostFeatureSupportedRequest, HostFeatureSupportedResponse, HostLocalStorageReadError, - HostLocaleSubscribeItem, HostNavigateToError, HostPlatform, HostPushNotificationRequest, - HostPushNotificationResponse, HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, - HostSignRawRequest, HostSignRawWithLegacyAccountRequest, HostThemeSubscribeItem, - LegacyAccountTxPayload, NotificationId, ProductAccountId, ProductAccountTxPayload, - ProductProofContext, RemotePermission, RemotePermissionRequest, RemotePermissionResponse, - RingLocation, + HostFeatureSupportedRequest, HostFeatureSupportedResponse, HostLocaleSubscribeItem, + HostNavigateToError, HostPlatform, HostPushNotificationRequest, HostPushNotificationResponse, + HostSignPayloadRequest, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, + HostSignRawWithLegacyAccountRequest, HostThemeSubscribeItem, LegacyAccountTxPayload, + NotificationId, ProductAccountId, ProductAccountTxPayload, ProductProofContext, + RemotePermission, RemotePermissionRequest, RemotePermissionResponse, RingLocation, }; use truapi::v01::HostAccountSignVrfRequest; use url::{Host, Url}; @@ -92,6 +91,18 @@ pub struct SigningHostConfig { pub people_chain_genesis_hash: [u8; 32], /// Bulletin-chain genesis hash used for in-core preimage submission. pub bulletin_chain_genesis_hash: [u8; 32], + /// Asset Hub genesis hash the dotNS contracts are deployed on, used to + /// resolve the product manifests that carry `trustedProducts` grants. + /// + /// All-zero says this host has no Asset Hub. No manifest then resolves, so + /// every cross-product grant is refused — the same answer as a chain that + /// cannot be read, and the reason a host must set this deliberately rather + /// than by omission. + /// + /// Not a kill switch: a manifest already in the core-storage cache is + /// served before this is consulted, so grants resolved earlier stay + /// honoured until that entry expires. + pub asset_hub_chain_genesis_hash: [u8; 32], /// The network's dotNS TLD without the leading dot: `dot`, `paseo`, /// `testnet`. Every reserved RFC-0022 identity the wallet derives ends in /// it: the `uid.` identity account and the `peopl.` person @@ -222,6 +233,7 @@ impl SigningHostConfig { platform_info: PlatformInfo, people_chain_genesis_hash: [u8; 32], bulletin_chain_genesis_hash: [u8; 32], + asset_hub_chain_genesis_hash: [u8; 32], network_suffix: String, ) -> Result { validate_network_suffix(&network_suffix)?; @@ -229,6 +241,7 @@ impl SigningHostConfig { host: HostRuntimeConfig::new(host_info, platform_info)?, people_chain_genesis_hash, bulletin_chain_genesis_hash, + asset_hub_chain_genesis_hash, network_suffix, }) } @@ -323,6 +336,18 @@ pub fn has_trusted_remote_permissions(product_id: &str) -> bool { .is_some_and(|(label, _tld)| REMOTE_PERMISSION_TRUSTED_LABELS.contains(&label)) } +/// Largest accepted product identifier, in bytes. +/// +/// `has_dotns_tld` only inspects the suffix after the last `.`, so without a +/// cap every length of `aaa…aaa.dot` is a distinct valid id. Cross-product +/// calls carry this string from the wire, where it is self-asserted, and a +/// manifest miss caches its result under it — including the authoritative +/// "no manifest", so a miss writes an entry too. Uncapped, that is unbounded +/// attacker-keyed core storage. The real names are labels plus a short TLD, +/// so this is far above anything legitimate and matches the cap already +/// applied to product-supplied chat identifiers. +pub const PRODUCT_ID_MAX_BYTES: usize = 256; + /// Normalize product identifiers before derivation and policy checks. pub fn normalize_product_identifier( product_id: &str, @@ -330,6 +355,13 @@ pub fn normalize_product_identifier( let trimmed = product_id.trim(); require_non_empty("product_id", trimmed)?; let normalized = trimmed.nfc().collect::().to_lowercase(); + // Checked after normalizing: NFC can change the byte length, so capping the + // input would leave the stored form able to exceed the cap. + if normalized.len() > PRODUCT_ID_MAX_BYTES { + return Err(RuntimeConfigValidationError::InvalidProductId { + product_id: product_id.to_string(), + }); + } if has_dotns_tld(&normalized) || normalized == "localhost" || normalized.starts_with("localhost:") @@ -961,16 +993,32 @@ impl ProductStorageKey { /// The core namespaces product keys before calling this trait. Host /// implementations may treat `key` as opaque or decode it with /// [`ProductStorageKey`] when their physical storage is separated by product. +/// Storage errors are pinned to `v01` rather than taken from `truapi::latest`. +/// The read error gained a cross-product refusal in v0.2 that the core decides +/// before it ever calls a host, so a host has no way to produce it and should +/// not have to match on it. #[async_trait] pub trait ProductStorage: Send + Sync { /// Read a value by key. - async fn read(&self, key: String) -> Result>, HostLocalStorageReadError>; + /// + /// Always the calling product's own storage. A read addressed at another + /// product is adjudicated in the core against that product's manifest and + /// refused there, so a host is never asked to enforce a grant and has no + /// variant for one. + async fn read( + &self, + key: String, + ) -> Result>, truapi::v01::HostLocalStorageReadError>; /// Write a value to a key. - async fn write(&self, key: String, value: Vec) -> Result<(), HostLocalStorageReadError>; + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), truapi::v01::HostLocalStorageReadError>; /// Clear a value at a key. - async fn clear(&self, key: String) -> Result<(), HostLocalStorageReadError>; + async fn clear(&self, key: String) -> Result<(), truapi::v01::HostLocalStorageReadError>; } /// Open URLs in the system browser. Input is already trimmed, categorized, @@ -1296,6 +1344,16 @@ pub enum CoreStorageKey { /// Pairing peer's X25519 public key. peer_encryption_public_key: [u8; 32], }, + /// Cached root manifest of one product, as published to dotNS. + /// + /// The value carries the manifest JSON alongside the time it was read. The + /// core honours it for a bounded lifetime, which is what makes a revoked + /// trust grant eventually take effect. + #[codec(index = 12)] + ProductManifest { + /// Product whose manifest was cached, normalized. + product_id: String, + }, } /// Stable metadata describing one strictly decoded [`CoreStorageKey`]. @@ -1348,6 +1406,7 @@ pub fn describe_core_storage_key( CoreStorageKey::StatementRenewalTargets => ("StatementRenewalTargets", None), CoreStorageKey::DeviceEncryptionKey => ("DeviceEncryptionKey", None), CoreStorageKey::SsoResponderRequestLedger { .. } => ("SsoResponderRequestLedger", None), + CoreStorageKey::ProductManifest { product_id } => ("ProductManifest", Some(product_id)), }; Ok(CoreStorageKeyDescription { kind, product_id }) } @@ -1513,6 +1572,7 @@ mod tests { PlatformInfo::default(), [0; 32], [1; 32], + [2; 32], network_suffix.to_string(), ) } @@ -2219,6 +2279,32 @@ mod tests { } } + #[test] + fn an_overlong_product_id_is_not_an_identifier() { + // `has_dotns_tld` reads only the suffix after the last `.`, so every + // length of this is otherwise a valid, distinct id. A cross-product + // call carries this string from the wire and a manifest miss caches + // its answer keyed by it, so an uncapped id is unbounded + // attacker-keyed core storage. + let label = "a".repeat(PRODUCT_ID_MAX_BYTES); + let overlong = format!("{label}.dot"); + assert!(overlong.len() > PRODUCT_ID_MAX_BYTES); + assert!( + !is_product_identifier(&overlong), + "a product id past the cap must be rejected, not stored" + ); + + // The boundary itself is accepted, so the cap rejects only what is + // over it: a test that only checked a huge id would still pass if the + // cap were off by any amount. + let at_cap = format!("{}.dot", "a".repeat(PRODUCT_ID_MAX_BYTES - 4)); + assert_eq!(at_cap.len(), PRODUCT_ID_MAX_BYTES); + assert!( + is_product_identifier(&at_cap), + "an id exactly at the cap is still valid" + ); + } + #[test] fn core_storage_key_description_is_strict_and_product_scoped() { let permission = CoreStorageKey::device_permission_authorization( diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index c0863017e..35f17a6ee 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -18,7 +18,7 @@ use futures::future::{AbortHandle, Abortable}; use futures::{FutureExt, StreamExt, pin_mut}; use parity_scale_codec::{Decode, Encode}; use thiserror::Error; -use tracing::instrument; +use tracing::{instrument, warn}; use truapi::v01; use truapi::{CallContext, CancellationReason}; use truapi_platform::{ChatPlatform, PermissionStatusHost}; @@ -574,6 +574,28 @@ impl SigningHostRuntime { spawner, chat_platform, ); + // Manifest resolution reads dotNS on Asset Hub, so without this the + // signing role resolves no manifest and refuses every cross-product + // grant. The pairing role installs it in `PairingHostRole::new`. + services.install_asset_hub_genesis_hash(config.asset_hub_chain_genesis_hash); + if services.asset_hub_chain_genesis_hash().is_none() { + // Said once at startup rather than inferred from every grant + // refusing: the refusals are deliberately indistinguishable from an + // ungranted read, so a host with no Asset Hub otherwise looks + // exactly like a product that granted nothing. + // + // Only as visible as the host's log level, which is the limit of + // what the core can do from here: `logging::init` starts at + // `LevelFilter::OFF`, so on the UniFFI and wasm hosts this is + // dropped unless the host raised the level first. The CLI installs + // its own subscriber and does show it. Reaching an operator who has + // logging off needs a channel that does not run through `tracing`, + // which is a host-boundary decision rather than a line here. + warn!( + "no Asset Hub configured: no product manifest will resolve, so \ + every cross-product grant not already cached is refused" + ); + } let signing_host = SigningHostRole::new(services.clone(), config.network_suffix); Self { services, @@ -2262,6 +2284,7 @@ mod tests { PlatformInfo::default(), [0; 32], [0xbb; 32], + [0xcc; 32], "paseo".to_string(), ) .expect("signing host config is valid"); @@ -2309,6 +2332,7 @@ mod tests { PlatformInfo::default(), [0; 32], [0xbb; 32], + [0xcc; 32], "paseo".to_string(), ) .expect("signing host config is valid"); @@ -2338,4 +2362,188 @@ mod tests { assert_eq!(payload.responding_to, "m3"); assert!(payload.product_public_key.is_ok()); } + + /// Signing-host config carrying `asset_hub`, otherwise the shape every + /// other signing test here uses. + fn signing_config_with_asset_hub(asset_hub: [u8; 32]) -> truapi_platform::SigningHostConfig { + use truapi_platform::{HostInfo, PlatformInfo, SigningHostConfig}; + + SigningHostConfig::new( + HostInfo { + name: "Polkadot Mobile".to_string(), + icon: None, + version: None, + platform: truapi::latest::HostPlatform::Unknown, + }, + PlatformInfo::default(), + // Three distinct non-zero values: these are same-typed `[u8; 32]` + // passed positionally, so a transposed pair only shows up if no + // two of them are equal. + [0xaa; 32], + [0xbb; 32], + asset_hub, + "paseo".to_string(), + ) + .expect("signing host config is valid") + } + + #[test] + fn a_signing_host_runtime_installs_its_asset_hub_for_manifest_resolution() { + // Manifest grants are resolved from dotNS on Asset Hub. The pairing + // role installs its hash in `PairingHostRole::new`; the signing role + // did not, so `root_manifest` returned before reaching the chain and + // refused every `trustedProducts` grant the manifest cache could not + // already answer, on iOS, Android, the `truapi-host` CLI and the wasm + // signing host. The cache is why the CLI looked healthy: + // `--product-config` seeds it, and a seeded entry is served before the + // hash is ever consulted. + let runtime = SigningHostRuntime::new( + Arc::new(StubPlatform::default()), + signing_config_with_asset_hub([0xcc; 32]), + test_spawner(), + ); + assert_eq!( + runtime.services.asset_hub_chain_genesis_hash(), + Some([0xcc; 32]), + "the signing role resolves manifests against the Asset Hub it was configured with" + ); + } + + #[test] + fn a_pairing_host_runtime_installs_its_asset_hub_too() { + // The sibling half of the same invariant. Deleting the pairing role's + // install left every test green before this, so #660 could recur one + // line over in a role that has always been correct. + use truapi_platform::{HostInfo, PairingHostConfig, PlatformInfo}; + + let config = PairingHostConfig::new( + HostInfo { + name: "Polkadot Web".to_string(), + icon: None, + version: None, + platform: truapi::latest::HostPlatform::Web, + }, + PlatformInfo::default(), + [0; 32], + [0xbb; 32], + [0xdd; 32], + "polkadotapp".to_string(), + ) + .expect("pairing host config is valid"); + let runtime = + PairingHostRuntime::new(Arc::new(StubPlatform::default()), config, test_spawner()); + assert_eq!( + runtime.services.asset_hub_chain_genesis_hash(), + Some([0xdd; 32]), + "the pairing role resolves manifests against its configured Asset Hub" + ); + } + + #[test] + fn an_all_zero_asset_hub_is_how_a_signing_host_says_it_has_none() { + // The one spelling of "no Asset Hub". A host that has none passes zeros + // deliberately and reads back as unconfigured, so grants fail closed + // without a second sentinel to carry through the boundary. + let runtime = SigningHostRuntime::new( + Arc::new(StubPlatform::default()), + signing_config_with_asset_hub([0; 32]), + test_spawner(), + ); + // Asserting only `None` would pass with the install deleted, since an + // empty slot also reads `None` — the two states this test exists to + // separate. A refused second install is what proves the zeros were + // really written, and pins the set-once semantics with them. + assert!( + !runtime.services.install_asset_hub_genesis_hash([0xcc; 32]), + "zeros occupied the set-once slot, so no later hash can replace them" + ); + assert_eq!(runtime.services.asset_hub_chain_genesis_hash(), None); + } + + /// What a manifest lookup did: the RPC the core sent, and the genesis + /// hashes it dialled. The second is what distinguishes "asked the chain" + /// from "asked the *right* chain". + struct ManifestLookup { + rpc: Vec, + connects: Vec<[u8; 32]>, + } + + /// A cross-product storage read for `owner`, uncached, on a signing-role + /// product runtime configured with `asset_hub`. + fn signing_manifest_lookup_rpc(asset_hub: [u8; 32]) -> ManifestLookup { + use truapi::api::LocalStorage; + use truapi::versioned::local_storage::HostLocalStorageReadRequest; + + let platform = Arc::new(StubPlatform::default()); + let runtime = SigningHostRuntime::new( + platform.clone(), + signing_config_with_asset_hub(asset_hub), + test_spawner(), + ); + let host = ProductRuntimeHost::from_services( + runtime.services.clone(), + ConnectionAdapters::from_services(&runtime.services), + runtime.signing_host.clone(), + ProductContext::new("unknown.dot".to_string()).expect("valid product id"), + ); + // Nothing is cached for `wallet.dot`, so resolution has to reach dotNS + // — which is exactly the path the missing hash short-circuited. + let read = futures::executor::block_on(LocalStorage::read( + &host, + &truapi::CallContext::default(), + HostLocalStorageReadRequest::V2(truapi::v02::HostLocalStorageReadRequest { + product: Some("wallet.dot".to_string()), + key: "k".to_string(), + }), + )); + assert!( + read.is_err(), + "the stub serves no dotNS registry, so the read is refused either way" + ); + ManifestLookup { + rpc: platform + .sent_rpc + .lock() + .expect("sent rpc mutex poisoned") + .clone(), + connects: platform + .chain_connects + .lock() + .expect("chain connect mutex poisoned") + .clone(), + } + } + + #[test] + fn a_signing_host_takes_a_manifest_miss_to_the_chain() { + // The refusal is identical with and without an Asset Hub, so the only + // observable difference is whether the core asked the chain at all. + // Without the install it never asks, which is what made this silent. + let configured = signing_manifest_lookup_rpc([0xcc; 32]); + assert!( + !configured.rpc.is_empty(), + "a configured signing role resolves an uncached manifest over dotNS" + ); + + // Asking *a* chain is not the property under test. The host holds three + // same-typed genesis hashes and hands them over positionally, so a + // lookup wired to People or Bulletin would also produce RPC here and + // also refuse. Pin the chain it actually dialled. + assert_eq!( + configured.connects, + vec![[0xcc; 32]], + "the manifest lookup dials Asset Hub, not People ([0xaa; 32]) or \ + Bulletin ([0xbb; 32])" + ); + + let unconfigured = signing_manifest_lookup_rpc([0; 32]); + assert!( + unconfigured.rpc.is_empty(), + "a signing role with no Asset Hub refuses without touching the chain" + ); + assert!( + unconfigured.connects.is_empty(), + "and does not dial any chain at all" + ); + } } diff --git a/rust/crates/truapi-server/src/host_logic.rs b/rust/crates/truapi-server/src/host_logic.rs index 7d01d8263..940848829 100644 --- a/rust/crates/truapi-server/src/host_logic.rs +++ b/rust/crates/truapi-server/src/host_logic.rs @@ -14,6 +14,7 @@ pub mod extrinsic; pub mod features; pub mod permissions; pub mod product_account; +pub mod product_manifest; pub mod session; pub mod session_store; pub mod sso; diff --git a/rust/crates/truapi-server/src/host_logic/dotns_gateway.rs b/rust/crates/truapi-server/src/host_logic/dotns_gateway.rs index 397b18f6b..a7ed91f2d 100644 --- a/rust/crates/truapi-server/src/host_logic/dotns_gateway.rs +++ b/rust/crates/truapi-server/src/host_logic/dotns_gateway.rs @@ -160,13 +160,10 @@ pub fn call_no_args(signature: &str) -> Vec { selector(signature).to_vec() } -/// Calldata for a view function taking one `string` argument. -pub fn call_string(signature: &str, value: &str) -> Vec { +/// Appends a dynamic `string` tail: its length, then its bytes padded to a +/// whole number of words. The caller has already written the head offset. +fn append_dynamic_string(data: &mut Vec, value: &str) { let bytes = value.as_bytes(); - let mut data = call_no_args(signature); - let mut word = [0u8; 32]; - word[31] = 0x20; - data.extend_from_slice(&word); let mut len = [0u8; 32]; len[24..].copy_from_slice(&(bytes.len() as u64).to_be_bytes()); data.extend_from_slice(&len); @@ -175,9 +172,53 @@ pub fn call_string(signature: &str, value: &str) -> Vec { 0u8, bytes.len().div_ceil(32) * 32 - bytes.len(), )); +} + +/// Head word holding the byte offset a dynamic argument's tail starts at, +/// counted from the end of the selector. `head_words` is how many words the +/// head occupies. +fn dynamic_offset(head_words: usize) -> [u8; 32] { + let mut word = [0u8; 32]; + word[24..].copy_from_slice(&((head_words * 32) as u64).to_be_bytes()); + word +} + +/// Calldata for a view function taking one `string` argument. +pub(crate) fn call_string(signature: &str, value: &str) -> Vec { + let mut data = call_no_args(signature); + data.extend_from_slice(&dynamic_offset(1)); + append_dynamic_string(&mut data, value); + data +} + +/// Calldata for a view function taking a `bytes32` and a `string`, such as +/// `text(bytes32 node, string key)`. The string is dynamic, so the head holds +/// its offset and the tail follows. +pub(crate) fn call_bytes32_string(signature: &str, word: &[u8; 32], value: &str) -> Vec { + let mut data = call_no_args(signature); + data.extend_from_slice(word); + data.extend_from_slice(&dynamic_offset(2)); + append_dynamic_string(&mut data, value); data } +/// One component address out of the protocol registry's address book, so a +/// rotated implementation is picked up without a change here. +pub(crate) async fn protocol_component( + transport: &mut T, + protocol_registry: &[u8; 20], + name: &str, +) -> Result<[u8; 20], String> { + let output = transport + .view( + protocol_registry, + call_bytes32("get(bytes32)", ®istry_key(name)), + ) + .await + .map_err(|err| format!("ProtocolRegistry.get({name}): {err}"))?; + decode_address(&output).map_err(|err| format!("ProtocolRegistry.get({name}): {err}")) +} + /// Calldata for a view function taking one `address` argument. pub fn call_address(signature: &str, address: &[u8; 20]) -> Vec { let mut data = call_no_args(signature); @@ -811,7 +852,7 @@ const TLD_WITHOUT_VIEW: &str = ".dot"; /// `DotnsRegistry.recordExists(namehash("dot"))` must hold the TLD's own /// record, or the resolution errors. Any other failure is an error: a wrong /// TLD would drop every label carrying the real one. -async fn network_tld( +pub(crate) async fn network_tld( transport: &mut T, registry: &[u8; 20], ) -> Result { @@ -851,7 +892,7 @@ async fn network_tld( } /// The node of the network TLD: `namehash(tld)` for a single-label TLD. -fn tld_node(tld: &str) -> [u8; 32] { +pub(crate) fn tld_node(tld: &str) -> [u8; 32] { namehash_under(&[0u8; 32], tld.trim_start_matches('.')) } diff --git a/rust/crates/truapi-server/src/host_logic/product_manifest.rs b/rust/crates/truapi-server/src/host_logic/product_manifest.rs new file mode 100644 index 000000000..b43043e32 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/product_manifest.rs @@ -0,0 +1,244 @@ +//! Root product manifest parsing and grant lookup. +//! +//! Pure: the bytes arrive from [`crate::runtime::product_manifest`], and nothing +//! here reaches a chain. A manifest carries more than the trust grants, but only +//! the fields this core reads are modelled — everything else is skipped, so a +//! publisher extending the document does not break parsing. + +use std::collections::BTreeMap; + +use serde::Deserialize; + +/// Manifest schema version this core parses. +const SUPPORTED_SCHEMA_VERSION: u32 = 1; + +/// A scope a publisher pre-approves for another product in `trustedProducts`. +/// +/// `All` is a superset rather than a peer: it satisfies every other variant, +/// present and future. A value this core does not recognise parses as +/// [`Granted::Unrecognised`] rather than failing the document. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Granted { + /// Every mediated interaction, present and future. + 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, +} + +/// Manifest JSON for tests. Only `$v` and `trustedProducts` are modelled, so a +/// fixture carrying the display name, description and icon a publisher also +/// writes would be exercising serde's tolerance rather than this parser. +#[cfg(test)] +pub(crate) fn test_manifest_json(trusted: &str) -> String { + format!(r#"{{"$v":1,"trustedProducts":{trusted}}}"#) +} + +/// The product-wide manifest published at a base name's `manifest` text record. +#[derive(Debug, Clone, Deserialize)] +pub struct RootManifest { + /// Schema version. A version this core does not know makes the product + /// undiscoverable rather than malformed. + #[serde(rename = "$v")] + pub schema_version: u32, + /// What each named product may do to this one, keyed by bare product label + /// with no TLD suffix. + #[serde(default, rename = "trustedProducts")] + pub trusted_products: BTreeMap>, +} + +impl RootManifest { + /// Parses a manifest, rejecting a schema version this core cannot read. + /// + /// An unrecognised grant value is not a parse failure: it is dropped from + /// the entry it appears in and the recognised values around it still apply. + pub fn parse(json: &str) -> Result { + let manifest: Self = serde_json::from_str(json) + .map_err(|err| format!("manifest is not valid JSON: {err}"))?; + if manifest.schema_version != SUPPORTED_SCHEMA_VERSION { + return Err(format!( + "manifest schema version {} is not supported", + manifest.schema_version + )); + } + Ok(manifest) + } + + /// Whether this product grants `caller` the `wanted` scope. + /// + /// `caller` is a bare product label with no TLD suffix, matching the shape + /// of a `trustedProducts` key. A key written with a suffix names a product + /// that does not resolve, so it grants nothing. + pub fn grants(&self, caller: &str, wanted: Granted) -> bool { + // A value this core does not recognise is not a scope anyone can be + // granted. Without this an unrecognised entry in the manifest would + // satisfy a query for one, which is the opposite of ignoring it. + if wanted == Granted::Unrecognised { + return false; + } + self.trusted_products.get(caller).is_some_and(|granted| { + granted + .iter() + .any(|value| *value == Granted::All || *value == wanted) + }) + } +} + +/// The segment above the TLD of a normalized product identifier, which is the +/// bare label a `trustedProducts` key is written with. +/// +/// A product's executables are published beneath its own name, so +/// `app.dim2.dot` and `worker.dim2.dot` both yield `dim2` and carry the grants +/// published for it — they are that product, not neighbours of it. Reading the +/// first segment instead would look for a key named after the executable, and +/// reading everything below the TLD would make each executable its own product. +/// +/// A subname under a different domain resolves to that domain: `dim2.attacker.dot` +/// yields `attacker`, so it collects nothing published for `dim2`. +/// +/// A localhost development identifier has no TLD and is returned unchanged. +pub fn bare_product_label(product_id: &str) -> &str { + product_id + .rsplit_once('.') + .map_or(product_id, |(above_tld, _tld)| { + above_tld + .rsplit_once('.') + .map_or(above_tld, |(_prefix, label)| label) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest(trusted: &str) -> RootManifest { + RootManifest::parse(&test_manifest_json(trusted)).expect("fixture parses") + } + + #[test] + fn a_named_scope_is_granted_only_to_the_product_named() { + let m = manifest(r#"{"dim2":["storage"]}"#); + assert!(m.grants("dim2", Granted::Storage)); + assert!(!m.grants("stash", Granted::Storage)); + } + + #[test] + 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] + fn an_unrecognised_grant_is_ignored_and_its_neighbours_still_apply() { + // The RFC forbids failing validation over a value defined after this + // 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] + fn an_unrecognised_scope_is_never_granted() { + // The runtime asks with a `Granted`, so nothing in the type system stops + // it asking for `Unrecognised`. A manifest full of values this core does + // not know must still answer no. + let m = manifest(r#"{"dim2":["storage-write"]}"#); + assert!(!m.grants("dim2", Granted::Unrecognised)); + + let wildcard = manifest(r#"{"dim2":["all"]}"#); + assert!(!wildcard.grants("dim2", Granted::Unrecognised)); + } + + #[test] + fn a_grant_of_the_wrong_shape_still_fails_the_document() { + // Unrecognised means "a value this core does not know", not "anything at + // all". A grant list holding a number is a malformed manifest, and the + // publisher is told so rather than silently granted less than they wrote. + assert!( + RootManifest::parse( + r#"{"$v":1,"displayName":"D","description":"d", + "icon":{"cid":"c","format":"png"},"trustedProducts":{"dim2":[17]}}"# + ) + .is_err() + ); + } + + #[test] + fn a_key_written_with_a_tld_suffix_is_inert() { + // It names `dim2.dot.`, which does not exist, so the caller `dim2` + // matches nothing. + let m = manifest(r#"{"dim2.dot":["storage"]}"#); + assert!(!m.grants("dim2", Granted::Storage)); + } + + #[test] + fn an_absent_trusted_products_field_grants_nothing() { + let m = RootManifest::parse( + r#"{"$v":1,"displayName":"D","description":"d","icon":{"cid":"c","format":"png"}}"#, + ) + .expect("manifest without trustedProducts parses"); + assert!(!m.grants("dim2", Granted::Storage)); + } + + #[test] + fn an_unknown_schema_version_is_refused() { + assert!(RootManifest::parse(r#"{"$v":2,"trustedProducts":{}}"#).is_err()); + } + + #[test] + fn malformed_json_is_refused() { + assert!(RootManifest::parse("not json").is_err()); + } + + #[test] + fn the_bare_label_drops_the_tld() { + assert_eq!(bare_product_label("dim2.dot"), "dim2"); + assert_eq!(bare_product_label("dim2.paseo"), "dim2"); + assert_eq!(bare_product_label("localhost"), "localhost"); + } + + #[test] + fn an_executable_carries_the_label_of_the_product_it_belongs_to() { + assert_eq!(bare_product_label("app.dim2.dot"), "dim2"); + assert_eq!(bare_product_label("widget.dim2.dot"), "dim2"); + assert_eq!(bare_product_label("worker.dim2.paseo"), "dim2"); + assert_eq!(bare_product_label("funding.dim2.dot"), "dim2"); + } + + #[test] + fn an_executable_inherits_the_grants_published_for_its_product() { + let manifest = RootManifest::parse(r#"{"$v":1,"trustedProducts":{"dim2":["storage"]}}"#) + .expect("parses"); + assert!(manifest.grants(bare_product_label("dim2.dot"), Granted::Storage)); + assert!(manifest.grants(bare_product_label("app.dim2.dot"), Granted::Storage)); + assert!(manifest.grants(bare_product_label("worker.dim2.dot"), Granted::Storage)); + } + + #[test] + fn a_subname_of_another_domain_collects_nothing_published_for_its_first_segment() { + assert_eq!(bare_product_label("dim2.attacker.dot"), "attacker"); + let manifest = RootManifest::parse(r#"{"$v":1,"trustedProducts":{"dim2":["storage"]}}"#) + .expect("parses"); + assert!(!manifest.grants(bare_product_label("dim2.attacker.dot"), Granted::Storage)); + } +} diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index 4e9788e14..e1dfac893 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -66,6 +66,7 @@ pub use native_debug::{DebugSinkError, WsDebugSink}; #[cfg(not(target_arch = "wasm32"))] pub use runtime::StatementRenewalTarget; pub use runtime::login_failure::reports_exhausted_period; +pub use runtime::product_manifest::encode_cached_root_manifest; #[cfg(not(target_arch = "wasm32"))] pub use runtime::statement_allowance; pub use runtime::{PairedSsoPeer, ResponderExit}; diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index ec240ab58..35fbd21a2 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -188,6 +188,15 @@ pub struct NativeHostRuntimeConfig { pub people_chain_genesis_hash: Vec, /// Bulletin-chain genesis hash. Must be exactly 32 bytes. pub bulletin_chain_genesis_hash: Vec, + /// Asset Hub genesis hash, where the dotNS contracts are deployed. Must be + /// exactly 32 bytes. + /// + /// Product manifests are read from dotNS, so this is what makes a + /// `trustedProducts` grant resolvable. Pass 32 zero bytes to say this host + /// has no Asset Hub; no manifest then resolves, so every cross-product + /// grant is refused — except one already in the manifest cache, which is + /// served without consulting this and stays honoured until it expires. + pub asset_hub_chain_genesis_hash: Vec, /// The network's dotNS TLD without the leading dot (`dot`, `paseo`, /// `testnet`). The wallet's reserved identities are derived under it: /// `uid.` for the identity account, `peopl.` for the person @@ -274,6 +283,27 @@ pub enum NativeRuntimeConfigError { /// Activation failure reason. reason: String, }, + /// Asset Hub genesis hash was not exactly 32 bytes. + /// + /// Appended rather than grouped with the other genesis-hash variants: these + /// map to FFI discriminants by declaration order, and nothing in the UniFFI + /// checksum covers that order, so inserting mid-enum silently renumbers + /// every variant below it. + /// + /// Record fields are positional too, and nothing here protects them either: + /// `String` and `Vec` are both an i32 length followed by that many + /// bytes, so the two are wire-identical. A shifted field that reads a + /// `String` where a hash was written usually fails, but only because + /// `String::try_read` runs `from_utf8` and 32 random bytes are rarely valid + /// UTF-8 — an accident of the value, not a guarantee. The other direction, + /// reading `Vec` where a `String` was written, always succeeds and + /// lies. What keeps the config record honest is regenerating the bindings + /// with the lib (`make uniffi`), not its field order. + #[error("asset_hub_chain_genesis_hash must be exactly 32 bytes, got {actual}")] + InvalidAssetHubChainGenesisHash { + /// Supplied byte length. + actual: u64, + }, } impl TryFrom for NativeResolvedHostRuntimeConfig { @@ -292,6 +322,12 @@ impl TryFrom for NativeResolvedHostRuntimeConfig { actual: config.bulletin_chain_genesis_hash.len() as u64, } })?; + let asset_hub_chain_genesis_hash = + <[u8; 32]>::try_from(config.asset_hub_chain_genesis_hash.as_slice()).map_err(|_| { + NativeRuntimeConfigError::InvalidAssetHubChainGenesisHash { + actual: config.asset_hub_chain_genesis_hash.len() as u64, + } + })?; let signing = SigningHostConfig::new( HostInfo { name: config.host_name, @@ -305,6 +341,7 @@ impl TryFrom for NativeResolvedHostRuntimeConfig { }, people_chain_genesis_hash, bulletin_chain_genesis_hash, + asset_hub_chain_genesis_hash, config.network_suffix, )?; Ok(Self { @@ -2180,6 +2217,7 @@ mod tests { platform_version: None, people_chain_genesis_hash: vec![0xa2; 32], bulletin_chain_genesis_hash: vec![0xbb; 32], + asset_hub_chain_genesis_hash: vec![0xcc; 32], network_suffix: "paseo".to_string(), local_session_secret: Some(vec![7; 32]), local_session_lite_username: Some("alice".to_string()), @@ -3002,6 +3040,49 @@ mod tests { )); } + #[test] + fn each_configured_genesis_hash_reaches_its_own_field() { + // Three adjacent `Vec` at the boundary feeding three adjacent + // `[u8; 32]` in a positional constructor: transposing any two compiles + // and, without this, passes every test. Getting Asset Hub wrong sends + // manifest resolution to a chain with no dotNS contracts, which refuses + // every grant indistinguishably from a product that granted nothing. + let resolved = NativeResolvedHostRuntimeConfig::try_from(NativeHostRuntimeConfig { + people_chain_genesis_hash: vec![0xa1; 32], + bulletin_chain_genesis_hash: vec![0xb2; 32], + asset_hub_chain_genesis_hash: vec![0xc3; 32], + ..native_host_runtime_config() + }) + .expect("config is valid"); + + assert_eq!(resolved.signing.people_chain_genesis_hash, [0xa1; 32]); + assert_eq!(resolved.signing.bulletin_chain_genesis_hash, [0xb2; 32]); + assert_eq!(resolved.signing.asset_hub_chain_genesis_hash, [0xc3; 32]); + } + + #[test] + fn a_wrong_size_asset_hub_genesis_hash_is_rejected_as_its_own_field() { + // Names the field it rejects and reports that field's length, so a + // copy-paste of a sibling's validation cannot pass unnoticed. An empty + // vec must be an error, never a silent all-zero "no Asset Hub". + for len in [0usize, 31, 33] { + let err = NativeResolvedHostRuntimeConfig::try_from(NativeHostRuntimeConfig { + asset_hub_chain_genesis_hash: vec![0; len], + ..native_host_runtime_config() + }) + .unwrap_err(); + + assert!( + matches!( + err, + NativeRuntimeConfigError::InvalidAssetHubChainGenesisHash { actual } + if actual == len as u64 + ), + "{len}-byte Asset Hub hash reported as {err:?}" + ); + } + } + #[test] fn runtime_config_rejects_a_network_suffix_that_is_not_a_bare_tld() { // The suffix ends every reserved derivation (`peopl.`), so a diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 86fd42cd7..3b7594ee4 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -15,9 +15,11 @@ mod authority; pub(crate) mod bulletin_rpc; mod capabilities; mod chat; +mod dotns_lookup; mod identity; pub(crate) mod login_failure; mod pairing_host; +pub(crate) mod product_manifest; mod product_subtree; mod ring_vrf_registry; /// Role-neutral runtime services shared by product-facing runtimes. @@ -81,6 +83,7 @@ use crate::host_logic::permissions::PermissionsService; use crate::host_logic::product_account::{ derivation_index_bytes, derive_product_public_key, public_key_from_address, }; +use crate::host_logic::product_manifest::Granted; use crate::host_logic::session::SessionInfo; #[cfg(test)] use crate::host_logic::session::SessionState; @@ -424,6 +427,36 @@ impl ProductRuntimeHost { || dot_ns_identifier == product_id } + /// The normalized id to act on when the calling product may reach `target` + /// under `scope`, or `None` when it may not. + /// + /// The caller's own id is not a cross-product access and consults no grant. + /// Any other product must name this caller in its manifest's + /// `trustedProducts` with `scope` or `all`. + /// + /// Returning the id rather than a bare yes keeps one canonical spelling for + /// the callers that go on to address the target — the grant and whatever it + /// admits are then decided against the same string. + pub(crate) async fn cross_product_scope_target( + &self, + target: &str, + scope: Granted, + ) -> Option { + let normalized = normalize_product_identifier(target).ok()?; + if normalized == self.product_id() { + return Some(normalized); + } + product_manifest::grants_scope( + &self.services, + &*self.platform, + &self.product_id(), + &normalized, + scope, + ) + .await + .then_some(normalized) + } + fn normalize_product_account_id( product_account_id: v01::ProductAccountId, ) -> Result { @@ -480,9 +513,18 @@ impl ProductRuntimeHost { .map_err(|err| err.to_string()) } - fn product_storage_key(&self, key: String) -> String { - ProductStorageKey::new(self.product.product_id.as_str(), key) - .expect("product runtime context was already validated") + /// The storage key `owner` holds `key` under. + /// + /// The owner is explicit because a read may be addressed at another product: + /// deriving it from `self` would hand a granted foreign read the caller's own + /// values instead of the ones it asked for. + /// + /// `owner` must already be normalized — either this product's validated id or + /// an id returned by [`Self::cross_product_scope_target`]. `ProductStorageKey` + /// re-applies the same normalization, so the key cannot fail to build. + fn product_storage_key(&self, owner: &str, key: String) -> String { + ProductStorageKey::new(owner, key) + .expect("storage key owner was already normalized") .encode() } diff --git a/rust/crates/truapi-server/src/runtime/capabilities/account.rs b/rust/crates/truapi-server/src/runtime/capabilities/account.rs index 51c12c999..70ade0d76 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,11 @@ 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/capabilities/platform.rs b/rust/crates/truapi-server/src/runtime/capabilities/platform.rs index 1b6429812..74fa100bc 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/platform.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/platform.rs @@ -3,6 +3,7 @@ use futures::StreamExt; use tracing::{instrument, warn}; use truapi::api::{LocalStorage, Locale, Notifications, Permissions, System, Theme}; +use truapi::versioned::IntoLatest; use truapi::versioned::local_storage::{ HostLocalStorageClearError, HostLocalStorageClearRequest, HostLocalStorageClearResponse, HostLocalStorageReadError, HostLocalStorageReadRequest, HostLocalStorageReadResponse, @@ -25,11 +26,12 @@ use truapi::versioned::system::{ HostNavigateToResponse, }; use truapi::versioned::theme::HostThemeSubscribeItem; -use truapi::{CallContext, CallError, Subscription, v01}; +use truapi::{CallContext, CallError, Subscription, v01, v02}; use truapi_platform::PermissionAuthorizationStatus; use crate::host_logic::dotns::{NavigateDecision, external_host, parse_navigate}; use crate::host_logic::features::feature_supported; +use crate::host_logic::product_manifest::Granted; use crate::runtime::ProductRuntimeHost; #[truapi::async_trait] @@ -172,14 +174,40 @@ impl LocalStorage for ProductRuntimeHost { _cx: &CallContext, request: HostLocalStorageReadRequest, ) -> Result> { - let HostLocalStorageReadRequest::V1(v01::HostLocalStorageReadRequest { key }) = request; + let v02::HostLocalStorageReadRequest { product, key } = request.into_latest(); + + // One refusal for every reason the grant is not held: telling them apart + // would make this call a probe for which products exist and which hold + // data. A prompt is not the fallback either, since stored values are + // opaque bytes nobody could inspect to approve. + let owner = match product { + Some(target) => { + match self + .cross_product_scope_target(&target, Granted::Storage) + .await + { + Some(owner) => owner, + None => { + return Err(CallError::Domain(HostLocalStorageReadError::V2( + v02::HostLocalStorageReadError::AccessNotGranted, + ))); + } + } + } + None => self.product_id(), + }; + self.platform - .read(self.product_storage_key(key)) + .read(self.product_storage_key(&owner, key)) .await .map(|value| { - HostLocalStorageReadResponse::V1(v01::HostLocalStorageReadResponse { value }) + HostLocalStorageReadResponse::V2(v01::HostLocalStorageReadResponse { value }) + }) + .map_err(|err| { + CallError::Domain(HostLocalStorageReadError::V2( + HostLocalStorageReadError::V1(err).into_latest(), + )) }) - .map_err(|err| CallError::Domain(HostLocalStorageReadError::V1(err))) } #[instrument(skip_all, fields(runtime.method = "local_storage.write"))] @@ -191,7 +219,10 @@ impl LocalStorage for ProductRuntimeHost { let HostLocalStorageWriteRequest::V1(v01::HostLocalStorageWriteRequest { key, value }) = request; self.platform - .write(self.product_storage_key(key), value) + .write( + self.product_storage_key(self.product.product_id.as_str(), key), + value, + ) .await .map(|()| HostLocalStorageWriteResponse::V1) .map_err(|err| CallError::Domain(HostLocalStorageWriteError::V1(err))) @@ -205,7 +236,7 @@ impl LocalStorage for ProductRuntimeHost { ) -> Result> { let HostLocalStorageClearRequest::V1(v01::HostLocalStorageClearRequest { key }) = request; self.platform - .clear(self.product_storage_key(key)) + .clear(self.product_storage_key(self.product.product_id.as_str(), key)) .await .map(|()| HostLocalStorageClearResponse::V1) .map_err(|err| CallError::Domain(HostLocalStorageClearError::V1(err))) diff --git a/rust/crates/truapi-server/src/runtime/dotns_lookup.rs b/rust/crates/truapi-server/src/runtime/dotns_lookup.rs new file mode 100644 index 000000000..e59d3f6eb --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/dotns_lookup.rs @@ -0,0 +1,219 @@ +//! Pinned-block dotNS transport shared by the core's dotNS readers. +//! +//! Every dotNS read runs over one `chainHead_v1` follow pinned to a best block, +//! so a sequence of storage reads and contract views sees one consistent state. +//! No chain metadata is needed. + +use core::sync::atomic::{AtomicU64, Ordering}; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; + +use futures::stream::BoxStream; +use truapi::latest::{ + OperationStartedResult, RemoteChainHeadCallRequest, RemoteChainHeadFollowItem, + RemoteChainHeadFollowRequest, RemoteChainHeadStorageRequest, StorageQueryItem, + StorageQueryType, +}; + +use crate::chain_runtime::{ + ChainHeadStorageValue, ChainHeadStorageValueLookup, ChainRuntime, + wait_for_chain_head_best_hash, wait_for_chain_head_call_output, + wait_for_chain_head_storage_value, +}; +use tracing::debug; + +use crate::host_logic::dotns_gateway::{ + DotnsTransport, DotnsViewError, VIEW_CALL_ORIGIN, encode_revive_call, view_output, +}; + +/// Budget for one step of a lookup: opening the follow, one storage read, one +/// contract view. A step that stalls this long is not going to answer. +const OPERATION_TIMEOUT: Duration = Duration::from_secs(10); +/// Budget for the best-block hash the follow opens on. +const BEST_BLOCK_TIMEOUT: Duration = Duration::from_secs(2); + +/// Monotonic salt for follow ids, so concurrent lookups do not collide. +static DOTNS_LOOKUP_COUNTER: AtomicU64 = AtomicU64::new(1); + +/// One pinned-block context for a sequence of dotNS reads. It owns the +/// `chainHead_v1` follow every read and view runs over. +pub(crate) struct DotnsLookup<'a> { + chain: &'a ChainRuntime, + follow: BoxStream<'static, RemoteChainHeadFollowItem>, + genesis_hash: Vec, + follow_id: String, + hash: Vec, +} + +impl<'a> DotnsLookup<'a> { + /// Opens a follow on Asset Hub and pins it to the current best block. + /// + /// `label` distinguishes this lookup's follow id from concurrent ones and + /// appears in chain logs, so it should name what is being resolved. + pub(crate) async fn pinned_to_best_block( + chain: &'a ChainRuntime, + asset_hub_chain_genesis_hash: [u8; 32], + label: &str, + ) -> Result { + let genesis_hash = asset_hub_chain_genesis_hash.to_vec(); + let lookup_id = DOTNS_LOOKUP_COUNTER.fetch_add(1, Ordering::Relaxed); + let follow_id = format!("truapi:dotns:{lookup_id}:{label}"); + let mut follow = chain.remote_chain_head_follow( + follow_id.clone(), + RemoteChainHeadFollowRequest { + genesis_hash: genesis_hash.clone(), + with_runtime: true, + }, + ); + // This whole path used to be unreachable on the signing role, so it + // had no logging of its own at all. It can now do network I/O, stall + // for `OPERATION_TIMEOUT`, and fail closed into a refusal a caller + // cannot tell from "you were not granted this" — so say what it did. + debug!( + target: "truapi_server::dotns", + %follow_id, + genesis = %hex::encode(&genesis_hash), + "opening the Asset Hub follow for a dotNS lookup" + ); + let hash = wait_for_chain_head_best_hash( + &mut follow, + "Asset Hub", + OPERATION_TIMEOUT, + BEST_BLOCK_TIMEOUT, + ) + .await + .inspect_err(|reason| { + debug!( + target: "truapi_server::dotns", + %follow_id, %reason, + "the Asset Hub follow never initialized; every grant behind \ + this lookup is refused" + ); + })?; + Ok(Self { + chain, + follow, + genesis_hash, + follow_id, + hash, + }) + } +} + +#[async_trait::async_trait] +impl DotnsTransport for DotnsLookup<'_> { + /// Reads one storage value at the pinned block. `Ok(None)` when absent. + async fn storage(&mut self, key: Vec) -> Result>, String> { + let response = self + .chain + .remote_chain_head_storage(RemoteChainHeadStorageRequest { + genesis_hash: self.genesis_hash.clone(), + follow_subscription_id: self.follow_id.clone(), + hash: self.hash.clone(), + items: vec![StorageQueryItem { + key: key.clone(), + query_type: StorageQueryType::Value, + }], + child_trie: None, + }) + .await + .map_err(|failure| failure.reason())?; + let operation_id = started_operation_id(response.operation)?; + let value = wait_for_chain_head_storage_value( + &mut self.follow, + ChainHeadStorageValueLookup { + chain: self.chain, + genesis_hash: &self.genesis_hash, + follow_subscription_id: &self.follow_id, + operation_id: &operation_id, + key: &key, + label: "Asset Hub", + timeout: OPERATION_TIMEOUT, + }, + ) + .await?; + // `Missing` is a real answer and `Inaccessible` is a failure, but both + // end in the same refusal upstream. Distinguish them here or the two + // are indistinguishable after the fact. + match value { + ChainHeadStorageValue::Found(value) => { + debug!( + target: "truapi_server::dotns", + follow_id = %self.follow_id, + bytes = value.len(), + "dotNS storage read found a value" + ); + Ok(Some(value)) + } + ChainHeadStorageValue::Missing => { + debug!( + target: "truapi_server::dotns", + follow_id = %self.follow_id, + "dotNS storage read: no such entry" + ); + Ok(None) + } + ChainHeadStorageValue::Inaccessible => { + debug!( + target: "truapi_server::dotns", + follow_id = %self.follow_id, + "dotNS storage was inaccessible, which is not the same as absent" + ); + Err("Asset Hub storage was inaccessible".to_string()) + } + } + } + + /// Dry-runs a contract view via `ReviveApi_call` at the pinned block and + /// returns its data. + /// + /// Views originate from the synthetic always-mapped account. They work + /// regardless of the queried account's revive mapping. + async fn view(&mut self, dest: &[u8; 20], input: Vec) -> Result, DotnsViewError> { + let response = self + .chain + .remote_chain_head_call(RemoteChainHeadCallRequest { + genesis_hash: self.genesis_hash.clone(), + follow_subscription_id: self.follow_id.clone(), + hash: self.hash.clone(), + function: "ReviveApi_call".to_string(), + call_parameters: encode_revive_call(&VIEW_CALL_ORIGIN, dest, &input), + }) + .await + .map_err(|failure| DotnsViewError::Failed(failure.reason()))?; + let operation_id = + started_operation_id(response.operation).map_err(DotnsViewError::Failed)?; + let output = wait_for_chain_head_call_output( + &mut self.follow, + &operation_id, + "Asset Hub", + OPERATION_TIMEOUT, + ) + .await + .map_err(DotnsViewError::Failed)?; + let result = view_output(&output); + debug!( + target: "truapi_server::dotns", + follow_id = %self.follow_id, + dest = %hex::encode(dest), + outcome = match &result { + Ok(data) => format!("{} bytes", data.len()), + Err(reason) => format!("failed: {reason}"), + }, + "dotNS contract view returned" + ); + result + } +} + +/// Unwraps a started operation id. `LimitReached` maps to an error. +fn started_operation_id(operation: OperationStartedResult) -> Result { + match operation { + OperationStartedResult::Started { operation_id } => Ok(operation_id), + OperationStartedResult::LimitReached => { + Err("Asset Hub operation limit reached".to_string()) + } + } +} diff --git a/rust/crates/truapi-server/src/runtime/identity.rs b/rust/crates/truapi-server/src/runtime/identity.rs index e2bda3436..231c4eaa4 100644 --- a/rust/crates/truapi-server/src/runtime/identity.rs +++ b/rust/crates/truapi-server/src/runtime/identity.rs @@ -9,44 +9,25 @@ //! All reads run over one `chainHead_v1` follow via `ReviveApi_call` dry-runs. //! No chain metadata is needed. -use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(not(target_arch = "wasm32"))] use std::time::Duration; #[cfg(target_arch = "wasm32")] use web_time::Duration; -use crate::chain_runtime::{ - ChainHeadStorageValue, ChainHeadStorageValueLookup, ChainRuntime, - wait_for_chain_head_best_hash, wait_for_chain_head_call_output, - wait_for_chain_head_storage_value, -}; +use crate::chain_runtime::ChainRuntime; use crate::host_logic::dotns_gateway::{ - DotnsIdentity, DotnsTransport, DotnsViewError, VIEW_CALL_ORIGIN, classify_labels, - discover_pop_controller, encode_revive_call, resolve_labels, view_output, + DotnsIdentity, classify_labels, discover_pop_controller, resolve_labels, }; use crate::host_logic::session::SessionInfo; +use crate::runtime::dotns_lookup::DotnsLookup; -use futures::stream::BoxStream; use futures::{FutureExt, pin_mut}; use tracing::{debug, instrument, warn}; -use truapi::latest::{ - OperationStartedResult, RemoteChainHeadCallRequest, RemoteChainHeadFollowItem, - RemoteChainHeadFollowRequest, RemoteChainHeadStorageRequest, StorageQueryItem, - StorageQueryType, -}; /// Budget for the whole username resolution of one session: every attempt for /// the identity account plus the root-key fallback share it, so a slow or dead /// endpoint delays session installation by at most this long. const LOOKUP_BUDGET: Duration = Duration::from_secs(45); -/// Budget for one step of it: opening the follow, one storage read, one -/// contract view. A step that stalls this long is not going to answer. -const OPERATION_TIMEOUT: Duration = Duration::from_secs(10); -const BEST_BLOCK_TIMEOUT: Duration = Duration::from_secs(2); - -/// Monotonic salt for local identity lookup follow ids. It keeps concurrent -/// dotNS identity lookups from colliding. -static IDENTITY_LOOKUP_COUNTER: AtomicU64 = AtomicU64::new(1); /// Fills in missing usernames by querying the dotNS contracts on Asset Hub. /// Returns the session unchanged when it already carries a username. Also @@ -160,7 +141,7 @@ async fn lookup_and_apply( } /// Resolves `account_id`'s usernames from the dotNS contracts at a fresh Asset -/// Hub head. Each step carries [`OPERATION_TIMEOUT`]; the caller's +/// Hub head. Each step carries the lookup transport's own step timeout; the caller's /// [`LOOKUP_BUDGET`] bounds the whole resolution. Returns `None` when the /// gateway is not deployed. Also returns `None` when the account holds no /// labels. @@ -171,9 +152,12 @@ async fn lookup_dotns_identity( account_id: [u8; 32], ) -> Result, String> { let lookup = async { - let mut lookup = - DotnsLookup::pinned_to_best_block(chain, asset_hub_chain_genesis_hash, account_id) - .await?; + let mut lookup = DotnsLookup::pinned_to_best_block( + chain, + asset_hub_chain_genesis_hash, + &format!("identity:{}", hex::encode(account_id)), + ) + .await?; let Some(controller) = discover_pop_controller(&mut lookup).await? else { return Ok(None); }; @@ -190,132 +174,6 @@ async fn lookup_dotns_identity( lookup.await } -/// One pinned-block context for the dotNS lookup steps. It owns the -/// `chainHead_v1` follow every read and view runs over. -struct DotnsLookup<'a> { - chain: &'a ChainRuntime, - follow: BoxStream<'static, RemoteChainHeadFollowItem>, - genesis_hash: Vec, - follow_id: String, - hash: Vec, -} - -impl<'a> DotnsLookup<'a> { - /// Opens a follow on Asset Hub and pins it to the current best block. - async fn pinned_to_best_block( - chain: &'a ChainRuntime, - asset_hub_chain_genesis_hash: [u8; 32], - account_id: [u8; 32], - ) -> Result { - let genesis_hash = asset_hub_chain_genesis_hash.to_vec(); - let lookup_id = IDENTITY_LOOKUP_COUNTER.fetch_add(1, Ordering::Relaxed); - let follow_id = format!("truapi:identity:{lookup_id}:{}", hex::encode(account_id)); - let mut follow = chain.remote_chain_head_follow( - follow_id.clone(), - RemoteChainHeadFollowRequest { - genesis_hash: genesis_hash.clone(), - with_runtime: true, - }, - ); - let hash = wait_for_chain_head_best_hash( - &mut follow, - "Asset Hub", - OPERATION_TIMEOUT, - BEST_BLOCK_TIMEOUT, - ) - .await?; - Ok(Self { - chain, - follow, - genesis_hash, - follow_id, - hash, - }) - } -} - -#[truapi_platform::async_trait] -impl DotnsTransport for DotnsLookup<'_> { - /// Reads one storage value at the pinned block. `Ok(None)` when absent. - async fn storage(&mut self, key: Vec) -> Result>, String> { - let response = self - .chain - .remote_chain_head_storage(RemoteChainHeadStorageRequest { - genesis_hash: self.genesis_hash.clone(), - follow_subscription_id: self.follow_id.clone(), - hash: self.hash.clone(), - items: vec![StorageQueryItem { - key: key.clone(), - query_type: StorageQueryType::Value, - }], - child_trie: None, - }) - .await - .map_err(|failure| failure.reason())?; - let operation_id = started_operation_id(response.operation)?; - let value = wait_for_chain_head_storage_value( - &mut self.follow, - ChainHeadStorageValueLookup { - chain: self.chain, - genesis_hash: &self.genesis_hash, - follow_subscription_id: &self.follow_id, - operation_id: &operation_id, - key: &key, - label: "Asset Hub", - timeout: OPERATION_TIMEOUT, - }, - ) - .await?; - match value { - ChainHeadStorageValue::Found(value) => Ok(Some(value)), - ChainHeadStorageValue::Missing => Ok(None), - ChainHeadStorageValue::Inaccessible => { - Err("Asset Hub storage was inaccessible".to_string()) - } - } - } - - /// Dry-runs a contract view via `ReviveApi_call` at the pinned block and - /// returns its data. - /// - /// Views originate from the synthetic always-mapped account. They work - /// regardless of the queried account's revive mapping. - async fn view(&mut self, dest: &[u8; 20], input: Vec) -> Result, DotnsViewError> { - let response = self - .chain - .remote_chain_head_call(RemoteChainHeadCallRequest { - genesis_hash: self.genesis_hash.clone(), - follow_subscription_id: self.follow_id.clone(), - hash: self.hash.clone(), - function: "ReviveApi_call".to_string(), - call_parameters: encode_revive_call(&VIEW_CALL_ORIGIN, dest, &input), - }) - .await - .map_err(|failure| DotnsViewError::Failed(failure.reason()))?; - let operation_id = - started_operation_id(response.operation).map_err(DotnsViewError::Failed)?; - let output = wait_for_chain_head_call_output( - &mut self.follow, - &operation_id, - "Asset Hub", - OPERATION_TIMEOUT, - ) - .await - .map_err(DotnsViewError::Failed)?; - view_output(&output) - } -} - -/// Unwraps a started operation id. `LimitReached` maps to an error. -fn started_operation_id(operation: OperationStartedResult) -> Result { - match operation { - OperationStartedResult::Started { operation_id } => Ok(operation_id), - OperationStartedResult::LimitReached => { - Err("Asset Hub operation limit reached".to_string()) - } - } -} - #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { //! The in-core lookup drives every dotNS read over one `chainHead_v1` @@ -327,12 +185,13 @@ mod tests { use super::*; use crate::chain_runtime::{RuntimeChainProvider, RuntimeFailure}; use crate::host_logic::dotns_gateway::{ - account_to_h160, dispatcher_address_key, selector, timestamp_now_key, + VIEW_CALL_ORIGIN, account_to_h160, dispatcher_address_key, selector, timestamp_now_key, }; use crate::subscription::thread_per_subscription_spawner; use async_trait::async_trait; use futures::StreamExt; use futures::channel::mpsc; + use futures::stream::BoxStream; use parity_scale_codec::{Compact, Decode, Encode}; use serde_json::{Value as JsonValue, json}; use std::sync::{Arc, Mutex}; diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index df9453c1a..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. @@ -313,9 +318,11 @@ pub(crate) struct PairingHost { impl PairingHost { /// Build a pairing host over the shared runtime services. pub(crate) fn new(services: Arc, host_config: PairingHostConfig) -> Arc { + services.install_asset_hub_genesis_hash(host_config.asset_hub_chain_genesis_hash); 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(), @@ -1956,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( @@ -2167,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( @@ -2296,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 new file mode 100644 index 000000000..26ff64b80 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/product_manifest.rs @@ -0,0 +1,453 @@ +//! Root manifest resolution over dotNS. +//! +//! Resolves a product id to the JSON its base name publishes at the `manifest` +//! text record, following [RFC — Product Manifest Format][manifest]: derive the +//! node under the network's own TLD, find the resolver through the registry, +//! read the record. Parsing that JSON is +//! [`crate::host_logic::product_manifest`]'s job. +//! +//! [manifest]: ../../../../docs/rfcs/product-manifest.md + +use core::sync::atomic::{AtomicBool, Ordering}; + +use parity_scale_codec::{Decode, Encode}; +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::{ + DotnsTransport, DotnsViewError, call_bytes32, call_bytes32_string, call_no_args, + decode_address, decode_string, discover_pop_controller, namehash_under, network_tld, + protocol_component, tld_node, +}; +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; +use crate::runtime::services::RuntimeServices; + +/// Text record a base name publishes its root manifest at. +const MANIFEST_RECORD_KEY: &str = "manifest"; + +/// Reads `product_id`'s root manifest JSON. +/// +/// `Ok(None)` means the product does not exist as far as dotNS is concerned: +/// either the node has no resolver, or its resolver holds no manifest record. +/// The two are one answer because a caller cannot act on the difference. +/// +/// The TLD the identifier carries is discarded and the node re-derived under +/// the TLD the network reports. A product id is minted on one network but +/// [`DOTNS_TLDS`][tlds] spans them all, so `dim2.dot` reaching a `.paseo` +/// deployment has to resolve there rather than hash a name no registry holds. +/// +/// [tlds]: truapi_platform::DOTNS_TLDS +#[instrument(skip_all, fields(runtime.method = "product_manifest.fetch"))] +pub(crate) async fn fetch_root_manifest( + chain: &ChainRuntime, + asset_hub_chain_genesis_hash: [u8; 32], + product_id: &str, +) -> Result, String> { + let mut lookup = DotnsLookup::pinned_to_best_block( + chain, + asset_hub_chain_genesis_hash, + &format!("manifest:{product_id}"), + ) + .await?; + + let Some(protocol_registry) = protocol_registry(&mut lookup).await? else { + return Ok(None); + }; + + let tld = network_tld(&mut lookup, &protocol_registry).await?; + let node = namehash_under(&tld_node(&tld), bare_product_label(product_id)); + + let registry = protocol_component(&mut lookup, &protocol_registry, "registry").await?; + let resolver_output = lookup + .view(®istry, call_bytes32("resolver(bytes32)", &node)) + .await + .map_err(|err| format!("DotnsRegistry.resolver(): {err}"))?; + let resolver = decode_address(&resolver_output) + .map_err(|err| format!("DotnsRegistry.resolver(): {err}"))?; + if resolver == [0u8; 20] { + return Ok(None); + } + + let manifest_output = match lookup + .view( + &resolver, + call_bytes32_string("text(bytes32,string)", &node, MANIFEST_RECORD_KEY), + ) + .await + { + Ok(output) => output, + // The dotNS-issued default resolver does not implement `text`, which is + // the same outcome as an unpublished manifest. + Err(DotnsViewError::Reverted(_)) => return Ok(None), + Err(err @ DotnsViewError::Failed(_)) => { + return Err(format!("ContentResolver.text(): {err}")); + } + }; + let manifest = + decode_string(&manifest_output).map_err(|err| format!("ContentResolver.text(): {err}"))?; + if manifest.is_empty() { + return Ok(None); + } + Ok(Some(manifest)) +} + +/// The deployment's `DotnsProtocolRegistry`, read from the controller. +/// `Ok(None)` when the gateway is not deployed. +/// +/// [`discover_pop_controller`] resolves the controller, because +/// `DotnsGateway.DispatcherAddress` holds either the controller or a +/// `RootGatewayDispatcher` that fronts it, and both are in service. Calling +/// `protocolRegistry()` on the stored address directly reverts on a chain that +/// still keeps its dispatcher, which would refuse every grant on that chain +/// while username resolution kept working. +async fn protocol_registry( + transport: &mut T, +) -> Result, String> { + let Some(controller) = discover_pop_controller(transport).await? else { + return Ok(None); + }; + let output = transport + .view(&controller, call_no_args("protocolRegistry()")) + .await + .map_err(|err| format!("DotnsPopController.protocolRegistry(): {err}"))?; + decode_address(&output) + .map(Some) + .map_err(|err| format!("DotnsPopController.protocolRegistry(): {err}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The node a product id resolves to on a network serving `tld`. + fn node_on(tld: &str, product_id: &str) -> [u8; 32] { + namehash_under(&tld_node(tld), bare_product_label(product_id)) + } + + #[test] + fn a_node_matches_the_chain_derivation() { + // Pinned against paseo-v2, where `ProtocolRegistry.tldNode()` reads + // 0x096b43… and the dotNS SDK derives `browse.paseo` as 0x185056…. + assert_eq!( + hex::encode(tld_node(".paseo")), + "096b436ee9a398429fe33ad4b359bad4398dd74b412ec1dd043c93dfbf581874" + ); + assert_eq!( + hex::encode(node_on(".paseo", "browse")), + "1850561ffded63ac23dac8fd5e793fca1f349729ed6ade91c45f44a9f7b6b781" + ); + } + + #[test] + fn the_tld_an_identifier_carries_does_not_change_the_node_it_resolves_to() { + // A product id minted on `.dot` has to resolve against a `.paseo` + // deployment; the suffix it was written with names no node of its own. + let expected = node_on(".paseo", "dim2"); + assert_eq!(node_on(".paseo", "dim2.dot"), expected); + assert_eq!(node_on(".paseo", "dim2.paseo"), expected); + } + + #[test] + fn one_identifier_resolves_differently_on_two_networks() { + // The other half of the same property: the network's TLD is what + // separates deployments, so the same id must not collide across them. + assert_ne!(node_on(".paseo", "dim2.dot"), node_on(".dot", "dim2.dot")); + } + + #[test] + fn a_text_call_encodes_the_key_as_a_dynamic_argument() { + let call = call_bytes32_string("text(bytes32,string)", &[0x11; 32], "manifest"); + // selector, node, offset, length, one padded word for an 8-byte key. + assert_eq!(call.len(), 4 + 32 * 4); + assert_eq!(&call[4..36], &[0x11; 32]); + assert_eq!(call[67], 64, "key offset follows the node"); + assert_eq!(call[99], 8, "key length precedes its bytes"); + assert_eq!(&call[100..108], b"manifest"); + } +} + +/// How long a cached root manifest is honoured. +/// +/// This is a revocation bound, not a performance knob: dotNS attaches no signal +/// to a record edit, so a grant a publisher withdraws stays in force until the +/// manifest is read again. +pub(crate) const MANIFEST_TTL_SECS: u64 = 24 * 60 * 60; + +/// A cached root manifest lookup and when it was made. +/// +/// The document is stored verbatim rather than reduced to the grants this core +/// reads today, so a later consumer needs no cache migration. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub(crate) struct CachedManifest { + /// Seconds since the Unix epoch at which the lookup was made. + pub(crate) fetched_at_secs: u64, + /// The manifest JSON exactly as published, or `None` where the chain + /// answered that the product publishes none. + /// + /// A miss is cached because refusing is the common outcome: without it every + /// refused call reopens a chainHead follow and re-reads the contracts, and + /// the round trip tells the caller which targets have a manifest and which + /// do not — the distinction one uniform refusal exists to hide. + pub(crate) json: Option, +} + +/// Encode a root manifest the way the core caches it, for a host that seeds a +/// grant instead of resolving one. +/// +/// Write the bytes under [`CoreStorageKey::ProductManifest`] for the product the +/// manifest belongs to. The core reads that entry before it consults the Asset +/// Hub, so a seeded manifest answers a grant on a host with no dotNS access at +/// all — which is what makes a cross-product flow reachable locally, before +/// either product is deployed. +/// +/// `json` is `None` for a product that publishes no manifest, the outcome the +/// core caches for the same lifetime as a document. `fetched_at_secs` is when +/// the lookup counts as having happened: the current time for a live entry, or +/// something older than the cache lifetime to exercise a grant expiring. +/// +/// A development and testing seam. Nothing enforces that a seeded manifest +/// matches what the product actually publishes, so a host offering this owes +/// the developer a way to tell the two apart. +pub fn encode_cached_root_manifest(json: Option<&str>, fetched_at_secs: u64) -> Vec { + CachedManifest { + fetched_at_secs, + json: json.map(str::to_string), + } + .encode() +} + +/// Scopes `target`'s published manifest grants `caller_id`. +/// +/// A grant that cannot be established answers `false` whatever the reason — the +/// product does not resolve, it published no manifest, the fetch failed, or the +/// manifest names this caller with a narrower scope. Callers turn that into one +/// refusal, so the outcome never reveals which of those it was. Failing closed +/// also means an unreachable chain withdraws grants rather than assuming them. +/// +pub(crate) async fn grants_scope( + services: &RuntimeServices, + platform: &dyn Platform, + caller_id: &str, + 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; + }; + let Ok(manifest) = RootManifest::parse(&json) else { + return false; + }; + manifest.grants(bare_product_label(caller_id), scope) +} + +/// Set once the configured Asset Hub has been compared against the host's +/// chain set, so the comparison costs one `supported_chains` call per process +/// rather than one per manifest lookup. +static ASSET_HUB_CROSS_CHECKED: AtomicBool = AtomicBool::new(false); + +/// Warn when the Asset Hub hash this host was *configured* with is not the one +/// it *serves*. +/// +/// There are two sources of truth for Asset Hub on the signing role and they +/// are not reconciled anywhere. `SigningHostConfig::asset_hub` is a hash the +/// embedder supplies, and it reaches `platform.connect()` with only a length +/// check — nothing verifies it is an Asset Hub at all. The PGAS path next door +/// in `sso_responder::allocate_smart_contract_allowance` instead derives it +/// from `features::supported_chains`, and its doc comment argues for that +/// precisely so a host cannot claim "against whatever chain a stale hash +/// happens to reach". +/// +/// Both can be live at once. A host whose config and `supported_chains()` +/// disagree resolves manifests from one chain's dotNS while allocating PGAS on +/// another, so whoever holds the product name on the other network's registry +/// decides who may read the victim product's storage. +/// +/// This does not pick a winner — the configured hash still wins, as #660 +/// specifies — it only makes the divergence audible instead of silent. Which +/// source should be authoritative is a design question for #660/#454. +async fn warn_if_asset_hub_disagrees_with_chain_set(platform: &dyn Platform, configured: [u8; 32]) { + use truapi::latest::ChainIdentifier; + + use crate::host_logic::features; + + if ASSET_HUB_CROSS_CHECKED.swap(true, Ordering::Relaxed) { + return; + } + // A host that cannot answer `supported_chains` is not evidence of a + // mismatch, so stay quiet rather than cry wolf on an unrelated failure. + let Ok(chains) = features::supported_chains(platform).await else { + return; + }; + match features::genesis_for(&chains, ChainIdentifier::AssetHub) { + Some(served) if served != configured => warn!( + configured = %hex::encode(configured), + served = %hex::encode(served), + "the configured Asset Hub genesis hash is not the one this host \ + serves: manifest grants resolve against the configured chain \ + while PGAS is allocated on the served one" + ), + None => warn!( + configured = %hex::encode(configured), + "an Asset Hub genesis hash is configured but this host's chain set \ + serves no Asset Hub" + ), + Some(_) => {} + } +} + +/// 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. +/// +/// A freshly read manifest is cached even though the caller may not be granted +/// anything by it: the document describes the product, not the asker. So is the +/// chain's answer that there is no manifest, which is authoritative for the same +/// TTL. +/// +/// A failed lookup is not cached. It says nothing about the product, only that +/// the chain could not be read, and holding that for a day would turn one blip +/// into a day of withdrawn grants. +/// +/// The cache dedupes misses only once one has *finished*. Concurrent misses for +/// the same target each open their own dotNS follow, and nothing upstream caps +/// how many dispatches a product may have in flight, so a product can hold N +/// follows for up to `OPERATION_TIMEOUT` each. That shape predates this path — +/// `ProductRuntime::in_flight` and the `ws_bridge` task spawn are both +/// uncapped — but a chain round trip per request makes each one dearer than it +/// was. A real fix is a single-flight keyed by target, or a dispatch +/// concurrency cap; both belong with the request pipeline rather than here. +async fn root_manifest( + services: &RuntimeServices, + platform: &dyn Platform, + target: &str, +) -> Option { + let key = CoreStorageKey::ProductManifest { + product_id: target.to_string(), + }; + let now = current_unix_secs(); + if let Ok(Some(bytes)) = platform.read_core_storage(key.clone()).await + && let Ok(cached) = CachedManifest::decode(&mut bytes.as_slice()) + && now.saturating_sub(cached.fetched_at_secs) < MANIFEST_TTL_SECS + { + return cached.json; + } + + let genesis_hash = services.asset_hub_chain_genesis_hash()?; + warn_if_asset_hub_disagrees_with_chain_set(platform, genesis_hash).await; + let json = match fetch_root_manifest(&services.chain, genesis_hash, target).await { + Ok(json) => json, + Err(reason) => { + warn!(%target, %reason, "root manifest lookup failed"); + return None; + } + }; + let _ = platform + .write_core_storage( + key, + CachedManifest { + fetched_at_secs: now, + json: json.clone(), + } + .encode(), + ) + .await; + json +} diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index e9242a7e2..f04814b66 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -37,6 +37,10 @@ pub(crate) struct RuntimeServices { /// startup by a host that can read it. Unset leaves device grants /// resolving from stored state alone. permission_status: OnceLock>, + /// Asset Hub the dotNS contracts are deployed on, installed once at startup + /// by the host that knows its chain configuration. Unset leaves every + /// manifest unresolvable, so no cross-product grant is honoured. + asset_hub_chain_genesis_hash: OnceLock<[u8; 32]>, /// Shared chainHead-v1 runtime behind the Chain surface. pub(crate) chain: ChainRuntime, /// People-chain statement store RPC client. @@ -86,6 +90,7 @@ impl RuntimeServices { host_info, chat_platform: None, permission_status: OnceLock::new(), + asset_hub_chain_genesis_hash: OnceLock::new(), chain, statement_store, bulletin, @@ -135,6 +140,34 @@ impl RuntimeServices { self.permission_status.set(host).is_ok() } + /// Records the Asset Hub the dotNS contracts live on. Returns false when a + /// hash is already installed. + pub(crate) fn install_asset_hub_genesis_hash(&self, genesis_hash: [u8; 32]) -> bool { + self.asset_hub_chain_genesis_hash.set(genesis_hash).is_ok() + } + + /// 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. `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() + .copied() + .filter(|hash| *hash != [0u8; 32]) + } + /// The host's live OS permission-status adapter, when one is installed. pub(crate) fn permission_status_host(&self) -> Option> { self.permission_status.get().cloned() diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index c61543307..65b993359 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?; @@ -1347,6 +1360,50 @@ 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::product_manifest::grants_scope( + &services, + platform.as_ref(), + "dim2.dot", + "peopl.dot", + 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 + // 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, + "the closed default: no manifest reachable means no grant" + ); + } + fn signing_runtime() -> (Arc, Arc) { // Auto-confirm raw signing so the role-neutral confirmation gate does // not reject before reaching the signing authority. @@ -1371,6 +1428,7 @@ mod tests { PlatformInfo::default(), [0; 32], [0xbb; 32], + [0xcc; 32], TEST_NETWORK_SUFFIX.to_string(), ) .expect("signing host config is valid"); @@ -1381,6 +1439,11 @@ mod tests { config.bulletin_chain_genesis_hash, test_spawner(), ); + // This fixture builds services directly rather than through + // `SigningHostRuntime`, so it has to do what that constructor does. + // Without it the config's Asset Hub is taken and dropped, and a fixture + // that reads as configured resolves no manifest at all. + services.install_asset_hub_genesis_hash(config.asset_hub_chain_genesis_hash); let signing_host = SigningHostRole::new(services.clone(), config.network_suffix); (services, signing_host) } @@ -1450,6 +1513,366 @@ 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::product_manifest::CachedManifest { + fetched_at_secs: crate::host_logic::statement_store::current_unix_secs(), + 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. + /// + /// 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); + 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) + } + + /// 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) + ); + } + + /// 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> { + 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 { + 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/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index 393232f7a..80dff654b 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -1292,9 +1292,15 @@ pub(super) async fn allocate_statement_store_allowance( /// nothing: PGAS pre-warms a balance on an account the host already controls, so /// there is no key to hand back. /// -/// Asset Hub is resolved through the host's chain set rather than a configured -/// hash, so a host that does not serve it says so instead of claiming against -/// whatever chain a stale hash happens to reach. +/// Asset Hub is resolved here through the host's chain set rather than a +/// configured hash, so a host that does not serve it says so instead of +/// claiming against whatever chain a stale hash happens to reach. +/// +/// Note that this is no longer the only rule on this role. Manifest resolution +/// takes the Asset Hub hash from `SigningHostConfig::asset_hub` instead, so the +/// two paths can disagree — see +/// `product_manifest::warn_if_asset_hub_disagrees_with_chain_set`, which makes +/// that divergence audible but deliberately does not pick a winner. #[cfg(not(target_arch = "wasm32"))] pub(super) async fn allocate_smart_contract_allowance( services: &Arc, @@ -1719,6 +1725,7 @@ mod tests { PlatformInfo::default(), [0; 32], [0xbb; 32], + [0xcc; 32], NETWORK_SUFFIX.to_string(), ) .expect("signing host config is valid"); @@ -1729,6 +1736,10 @@ mod tests { config.bulletin_chain_genesis_hash, test_spawner(), ); + // As in `signing_host::tests`: services are built directly here, so the + // install `SigningHostRuntime` performs has to be repeated, or the + // config's Asset Hub is taken and dropped. + services.install_asset_hub_genesis_hash(config.asset_hub_chain_genesis_hash); let signing_host = SigningHost::new(services.clone(), config.network_suffix); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index b9849d763..3ca2d78f0 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -469,6 +469,11 @@ mod tests { [0xbb; 32], test_spawner(), ); + // Built directly rather than through `SigningHostRuntime`, so the + // install that constructor performs has to be repeated. This fixture + // does build a `ProductRuntimeHost` below, so manifest resolution is + // reachable from it and would otherwise refuse every grant. + services.install_asset_hub_genesis_hash([0xcc; 32]); let signing_host = SigningHostRole::new(services.clone(), "paseo".to_string()); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index f900e821b..01838cc28 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -5,9 +5,10 @@ use std::sync::atomic::Ordering; use parity_scale_codec::Encode; use truapi::api::{ - Account, Chain, Entropy, Notifications, Permissions, Preimage, ResourceAllocation, Signing, - System, Theme, + Account, Chain, Entropy, LocalStorage, Notifications, Permissions, Preimage, + ResourceAllocation, Signing, System, Theme, }; +use truapi::v02; use truapi::versioned::account::{ HostAccountConnectionStatusSubscribeItem, HostAccountCreateProofError, HostAccountCreateProofResponse, HostAccountGetAliasError, HostAccountGetAliasResponse, @@ -23,6 +24,9 @@ use truapi::versioned::chain::{ use truapi::versioned::entropy::{ HostDeriveEntropyError, HostDeriveEntropyRequest, HostDeriveEntropyResponse, }; +use truapi::versioned::local_storage::{ + HostLocalStorageReadError, HostLocalStorageReadRequest, HostLocalStorageReadResponse, +}; use truapi::versioned::notifications::{ HostPushNotificationCancelRequest, HostPushNotificationCancelResponse, HostPushNotificationRequest, HostPushNotificationResponse, @@ -51,13 +55,18 @@ use truapi::versioned::system::{ HostNavigateToResponse, }; use truapi::versioned::theme::HostThemeSubscribeItem; -use truapi_platform::{AuthState, CoreStorageKey, PermissionAuthorizationRequest}; +use truapi_platform::{ + AuthState, CoreStorage as PlatformCoreStorage, CoreStorageKey, PermissionAuthorizationRequest, +}; +use super::product_manifest::{CachedManifest, MANIFEST_TTL_SECS}; use super::*; use crate::host_logic::product_account::index_bytes; +use crate::host_logic::product_manifest::test_manifest_json; use crate::host_logic::sso::messages::{ RemoteMessage, RemoteMessageData, RingVrfAliasResponse, RingVrfProofResponse, v1, }; +use crate::host_logic::statement_store::current_unix_secs; use crate::test_support::*; fn test_product_subtree(product_id: &str) -> [u8; 32] { @@ -169,6 +178,302 @@ fn get_chain_info_round_trips_through_runtime() { assert_eq!(inner.genesis_hash, [0xaa; 32]); } +fn read_storage( + host: &ProductRuntimeHost, + product: Option<&str>, + key: &str, +) -> Result> { + futures::executor::block_on(LocalStorage::read( + host, + &CallContext::default(), + HostLocalStorageReadRequest::V2(v02::HostLocalStorageReadRequest { + product: product.map(str::to_string), + key: key.to_string(), + }), + )) +} + +#[test] +fn a_storage_key_is_namespaced_under_its_owner() { + // The owner is an argument, not `self`: keying off the caller would hand a + // granted foreign read the caller's own values under the target's name. + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let key = host.product_storage_key("wallet.dot", "k".to_string()); + let decoded = ProductStorageKey::decode(&key).expect("the key round-trips"); + assert_eq!(decoded.product_id(), "wallet.dot"); + assert_ne!(decoded.product_id(), host.product_id()); +} + +#[test] +fn a_read_naming_the_caller_however_it_is_spelled_reaches_its_own_storage() { + // `product: None` is what every v0.1 read meant, naming your own id is the + // same call, and casing is normalized before the comparison. None of the + // three is a cross-product access, so none consults a grant. + let platform = stub_platform(); + let storage = platform.clone(); + let host = ProductRuntimeHost::new_compat(platform, test_spawner()); + let own = host.product_id(); + storage.local_storage.lock().expect("mutex").insert( + ProductStorageKey::new(&own, "k") + .expect("the owner normalizes") + .encode(), + b"my own value".to_vec(), + ); + + for spelling in [None, Some(own.clone()), Some(own.to_uppercase())] { + let answered = read_storage(&host, spelling.as_deref(), "k") + .unwrap_or_else(|err| panic!("{spelling:?} must reach own storage: {err:?}")); + let HostLocalStorageReadResponse::V2(v01::HostLocalStorageReadResponse { value }) = + answered + else { + panic!("a v2 request answers with a v2 response"); + }; + assert_eq!( + value.as_deref(), + Some(&b"my own value"[..]), + "spelling {spelling:?}" + ); + } +} + +#[test] +fn an_unresolvable_product_is_refused_identically_to_an_ungranted_one() { + // One answer for every reason, so the call cannot be used to probe which + // products exist. Two of these are not product ids at all and the third is + // a perfectly good one; on a host that reaches no chain none of them + // resolves, and the caller cannot tell that apart from a refused grant. + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + for target in ["not a product", "", "wallet.dot"] { + assert_eq!( + read_storage(&host, Some(target), "k").unwrap_err(), + access_not_granted(), + "target {target:?}" + ); + } +} + +/// The one refusal every unmet grant answers with. +fn access_not_granted() -> CallError { + CallError::Domain(HostLocalStorageReadError::V2( + v02::HostLocalStorageReadError::AccessNotGranted, + )) +} + +/// Reads `owner`'s stored value at `k`, requiring the grant to admit it. +fn granted_value(host: &ProductRuntimeHost, owner: &str) -> Option> { + let HostLocalStorageReadResponse::V2(v01::HostLocalStorageReadResponse { value }) = + read_storage(host, Some(owner), "k").expect("the grant admits the read") + else { + panic!("a v2 request answers with a v2 response"); + }; + value +} + +/// Seeds `owner`'s storage with a value only a granted read can reach. +fn seed_owner_value(platform: &StubPlatform, owner: &str) { + platform.local_storage.lock().expect("mutex").insert( + ProductStorageKey::new(owner, "k") + .expect("the owner normalizes") + .encode(), + b"wallet's own value".to_vec(), + ); +} + +/// Seeds `owner`'s cached manifest, so a grant resolves without a chain. +fn cache_manifest(platform: &StubPlatform, owner: &str, trusted: &str, age_secs: u64) { + cache_manifest_entry(platform, owner, Some(test_manifest_json(trusted)), age_secs); +} + +/// Seeds `owner`'s cached lookup, `None` standing for "publishes no manifest". +fn cache_manifest_entry(platform: &StubPlatform, owner: &str, json: Option, age_secs: u64) { + let entry = CachedManifest { + fetched_at_secs: current_unix_secs().saturating_sub(age_secs), + json, + }; + futures::executor::block_on(platform.write_core_storage( + CoreStorageKey::ProductManifest { + product_id: owner.to_string(), + }, + entry.encode(), + )) + .expect("stub core storage accepts the entry"); +} + +#[test] +fn a_cached_grant_reads_the_granting_products_storage() { + // The caller is `unknown.dot`, so the manifest names the bare label. + // + // Seeding only the target's namespace is what makes this a regression pin: + // keying the read off the caller instead would miss the value entirely. + let platform = stub_platform(); + cache_manifest(&platform, "wallet.dot", r#"{"unknown":["storage"]}"#, 0); + seed_owner_value(&platform, "wallet.dot"); + let host = ProductRuntimeHost::new_compat(platform, test_spawner()); + assert_eq!( + granted_value(&host, "wallet.dot").as_deref(), + Some(&b"wallet's own value"[..]) + ); +} + +#[test] +fn all_satisfies_a_storage_read() { + let platform = stub_platform(); + cache_manifest(&platform, "wallet.dot", r#"{"unknown":["all"]}"#, 0); + seed_owner_value(&platform, "wallet.dot"); + let host = ProductRuntimeHost::new_compat(platform, test_spawner()); + assert_eq!( + granted_value(&host, "wallet.dot").as_deref(), + Some(&b"wallet's own value"[..]) + ); +} + +#[test] +fn a_grant_to_another_product_does_not_admit_this_caller() { + let platform = stub_platform(); + cache_manifest(&platform, "wallet.dot", r#"{"stash":["storage"]}"#, 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_miss_refuses_without_returning_to_the_chain() { + // "This product publishes no manifest" is an answer worth keeping. Without + // it every refusal re-reads the contracts, and the round trip separates a + // target that has a manifest from one that does not. + let platform = stub_platform(); + cache_manifest_entry(&platform, "wallet.dot", None, 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_grant_of_some_other_scope_does_not_open_storage() { + // Only `storage` and `all` open a read. A value naming anything else, now + // or once a later scope is defined, must leave storage refusing. + let platform = stub_platform(); + cache_manifest( + &platform, + "wallet.dot", + r#"{"unknown":["storage-write"]}"#, + 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_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, + // and with no Asset Hub to re-read from the grant is gone. + let platform = stub_platform(); + cache_manifest( + &platform, + "wallet.dot", + r#"{"unknown":["storage"]}"#, + MANIFEST_TTL_SECS + 1, + ); + 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() + ); +} + +/// 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()); diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 62484a53d..9494e47c1 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -148,6 +148,11 @@ pub(crate) struct StubPlatform { /// that decodes metadata or does other work between calls. pub(crate) rpc_method_responses: Vec<(&'static str, String)>, pub(crate) sso_response_script: Option, + /// Every genesis hash handed to `connect`, in order. Lets a test assert + /// *which* chain a lookup reached, not merely that it reached one: the + /// hashes a host is configured with are same-typed `[u8; 32]` passed + /// positionally, so a transposed pair still connects and still answers. + pub(crate) chain_connects: Arc>>, /// When set, `connect` fails with this reason. pub(crate) chain_connect_error: Option<&'static str>, /// When true, `connect` stays pending forever. @@ -1447,8 +1452,14 @@ impl Drop for DropFlagGuard { impl ChainProvider for StubPlatform { async fn connect( &self, - _genesis_hash: [u8; 32], + genesis_hash: [u8; 32], ) -> Result, v01::GenericError> { + // Recorded before the failure branches: a test asserting which chain + // was dialled needs the attempt even when the connect never succeeds. + self.chain_connects + .lock() + .expect("chain connect mutex poisoned") + .push(genesis_hash); if let Some(reason) = self.chain_connect_error { return Err(v01::GenericError { reason: reason.to_string(), diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index ce86f774e..b43f70f02 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -581,6 +581,7 @@ fn signing_host_config_from_js(value: &JsValue) -> Result Result u8 { } #[test] -fn foreign_account_proof_returns_not_allowlisted_without_confirmation() { +fn foreign_account_proof_refuses_without_confirmation() { let core = make_core(); let request = account::HostAccountCreateProofRequest::V1(v01::HostAccountCreateProofRequest { key_handle: v01::ProductAccountId { @@ -271,9 +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. + // 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); } @@ -500,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 { @@ -513,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(), diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 06d3557f4..ea324cca2 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -28,6 +28,7 @@ pub use async_trait::async_trait; pub mod api; pub mod v01; +pub mod v02; pub mod versioned; /// A 32-byte value, passed as plain bytes on FFI surfaces. Version-neutral: diff --git a/rust/crates/truapi/src/v02.rs b/rust/crates/truapi/src/v02.rs new file mode 100644 index 000000000..d021e70af --- /dev/null +++ b/rust/crates/truapi/src/v02.rs @@ -0,0 +1,9 @@ +//! TrUAPI Protocol v0.2 type definitions. +//! +//! Only the messages whose shape changed after v0.1 live here. A message the +//! new version does not redefine keeps its [`crate::v01`] type in the versioned +//! envelope, so this module stays a delta rather than a copy of the protocol. + +mod local_storage; + +pub use local_storage::*; diff --git a/rust/crates/truapi/src/v02/local_storage.rs b/rust/crates/truapi/src/v02/local_storage.rs new file mode 100644 index 000000000..b608e0f30 --- /dev/null +++ b/rust/crates/truapi/src/v02/local_storage.rs @@ -0,0 +1,40 @@ +use derive_more::Display; +use parity_scale_codec::{Decode, Encode}; + +/// Request to read a local storage value. +/// +/// Storage is private by default: `product: None` addresses the caller's own +/// storage, which is what every v0.1 read resolved to. Naming another product +/// reads that product's storage instead, and succeeds only if that product's +/// manifest grants this caller the `storage` scope. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostLocalStorageReadRequest { + /// Product whose storage is read. `None`, or the caller's own id, means the + /// caller, and consults no grant. + pub product: Option, + /// Storage key to read. + pub key: String, +} + +/// Local storage read failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, Display)] +pub enum HostLocalStorageReadError { + /// Storage quota exceeded. + #[display("storage quota exhausted")] + Full, + /// The addressed storage belongs to another product that has not granted + /// this caller the `storage` scope. + /// + /// One variant answers every reason: the product does not resolve, it + /// published no manifest, or its manifest grants this caller nothing. + /// Distinguishing them would make the call a probe for which products exist + /// and which hold data. + #[display("the owning product grants no read access to its storage")] + AccessNotGranted, + /// Catch-all. + #[display("{reason}")] + Unknown { + /// Human-readable failure reason. + reason: String, + }, +} diff --git a/rust/crates/truapi/src/versioned/local_storage.rs b/rust/crates/truapi/src/versioned/local_storage.rs index 708eb4c7b..c474d3ccd 100644 --- a/rust/crates/truapi/src/versioned/local_storage.rs +++ b/rust/crates/truapi/src/versioned/local_storage.rs @@ -1,11 +1,32 @@ //! Versioned wrappers for [`LocalStorage`](crate::api::LocalStorage) methods. +//! +//! v0.2 widens the read request from a bare key to a key plus the product whose +//! storage it addresses, and gives the read its own error so it can carry a +//! refusal. A v0.1 caller has no way to name storage other than its own, so +//! upgrading its request means filling the new field with `None`, which is +//! exactly what v0.1 meant. +//! +//! Write and clear keep their v0.1 shape. The manifest grants a read-only +//! `storage` scope and nothing else, so no caller can ever address another +//! product's storage for a write, and there is nothing for those requests to +//! address. -use crate::v01; +use crate::versioned::{FromLatest, IntoLatest}; +use crate::{v01, v02}; truapi_macros::versioned_type! { - pub enum HostLocalStorageReadRequest { V1 => v01::HostLocalStorageReadRequest } - pub enum HostLocalStorageReadResponse { V1 => v01::HostLocalStorageReadResponse } - pub enum HostLocalStorageReadError { V1 => v01::HostLocalStorageReadError } + pub enum HostLocalStorageReadRequest { + V1 => v01::HostLocalStorageReadRequest, + V2 => v02::HostLocalStorageReadRequest, + } + pub enum HostLocalStorageReadResponse { + V1 => v01::HostLocalStorageReadResponse, + V2 => v01::HostLocalStorageReadResponse, + } + pub enum HostLocalStorageReadError { + V1 => v01::HostLocalStorageReadError, + V2 => v02::HostLocalStorageReadError, + } pub enum HostLocalStorageWriteRequest { V1 => v01::HostLocalStorageWriteRequest } pub enum HostLocalStorageWriteResponse { V1 } pub enum HostLocalStorageWriteError { V1 => v01::HostLocalStorageReadError } @@ -13,3 +34,186 @@ truapi_macros::versioned_type! { pub enum HostLocalStorageClearResponse { V1 } pub enum HostLocalStorageClearError { V1 => v01::HostLocalStorageReadError } } + +impl IntoLatest for HostLocalStorageReadRequest { + fn into_latest(self) -> Self::Latest { + match self { + Self::V1(v01::HostLocalStorageReadRequest { key }) => { + v02::HostLocalStorageReadRequest { product: None, key } + } + Self::V2(latest) => latest, + } + } +} + +// The read response did not change shape in v0.2. It still gains a V2 variant, +// because a method's version is uniform across its request, response and error +// — without one the generated client would keep every `local_storage.read` call +// pinned to V1 and no product could reach the new field. + +impl IntoLatest for HostLocalStorageReadResponse { + fn into_latest(self) -> Self::Latest { + match self { + Self::V1(payload) | Self::V2(payload) => payload, + } + } +} + +impl FromLatest for HostLocalStorageReadResponse { + fn from_latest(latest: Self::Latest, target: u8) -> Self { + if target >= 2 { + Self::V2(latest) + } else { + Self::V1(latest) + } + } +} + +impl IntoLatest for HostLocalStorageReadError { + fn into_latest(self) -> Self::Latest { + match self { + Self::V1(v01::HostLocalStorageReadError::Full) => v02::HostLocalStorageReadError::Full, + Self::V1(v01::HostLocalStorageReadError::Unknown { reason }) => { + v02::HostLocalStorageReadError::Unknown { reason } + } + Self::V2(latest) => latest, + } + } +} + +impl FromLatest for HostLocalStorageReadError { + fn from_latest(latest: Self::Latest, target: u8) -> Self { + if target >= 2 { + return Self::V2(latest); + } + Self::V1(match latest { + v02::HostLocalStorageReadError::Full => v01::HostLocalStorageReadError::Full, + v02::HostLocalStorageReadError::Unknown { reason } => { + v01::HostLocalStorageReadError::Unknown { reason } + } + // A v0.1 caller cannot address foreign storage — its request + // carries no product — so it never provokes this. The downgrade + // still has to be total, and the variant's own text says it. + refusal @ v02::HostLocalStorageReadError::AccessNotGranted => { + v01::HostLocalStorageReadError::Unknown { + reason: refusal.to_string(), + } + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::versioned::Versioned; + + #[test] + fn a_v01_request_upgrades_to_the_callers_own_storage() { + // v0.1 had no way to say anything but "my own storage", so the upgrade + // must not invent a target. + let upgraded = HostLocalStorageReadRequest::V1(v01::HostLocalStorageReadRequest { + key: "k".to_string(), + }) + .into_latest(); + assert_eq!( + upgraded, + v02::HostLocalStorageReadRequest { + product: None, + key: "k".to_string(), + } + ); + } + + #[test] + fn a_refusal_reaches_a_v02_peer_as_itself() { + assert_eq!( + HostLocalStorageReadError::from_latest( + v02::HostLocalStorageReadError::AccessNotGranted, + 2 + ), + HostLocalStorageReadError::V2(v02::HostLocalStorageReadError::AccessNotGranted) + ); + } + + #[test] + fn a_refusal_downgrades_to_a_reason_a_v01_peer_can_read() { + // Unreachable in practice — a v0.1 caller cannot address foreign + // storage — but the downgrade has to be total. + for downgraded in [ + HostLocalStorageReadError::from_latest( + v02::HostLocalStorageReadError::AccessNotGranted, + 1, + ), + HostLocalStorageReadError::from_latest( + v02::HostLocalStorageReadError::AccessNotGranted, + 0, + ), + ] { + assert_eq!( + downgraded, + HostLocalStorageReadError::V1(v01::HostLocalStorageReadError::Unknown { + reason: v02::HostLocalStorageReadError::AccessNotGranted.to_string(), + }) + ); + } + } + + #[test] + fn a_v01_error_upgrades_without_becoming_a_refusal() { + // v0.1 has no refusal variant, so nothing it reports may arrive as one. + for (v1, expected) in [ + ( + v01::HostLocalStorageReadError::Full, + v02::HostLocalStorageReadError::Full, + ), + ( + v01::HostLocalStorageReadError::Unknown { + reason: "disk".to_string(), + }, + v02::HostLocalStorageReadError::Unknown { + reason: "disk".to_string(), + }, + ), + ] { + assert_eq!(HostLocalStorageReadError::V1(v1).into_latest(), expected); + } + } + + #[test] + fn quota_exhaustion_keeps_its_own_variant_in_both_directions() { + // `Full` is the one failure a v0.1 peer can act on differently from a + // generic error, so it must not collapse into `Unknown`. + assert_eq!( + HostLocalStorageReadError::from_latest(v02::HostLocalStorageReadError::Full, 1), + HostLocalStorageReadError::V1(v01::HostLocalStorageReadError::Full) + ); + assert_eq!( + HostLocalStorageReadError::from_latest(v02::HostLocalStorageReadError::Full, 2), + HostLocalStorageReadError::V2(v02::HostLocalStorageReadError::Full) + ); + } + + #[test] + fn v1_keeps_codec_index_zero_so_the_new_version_is_additive() { + // Not the derive's behaviour under test but the wire's: reordering the + // variants would renumber V1 and break every deployed v0.1 peer. + use parity_scale_codec::Encode; + + let v1 = HostLocalStorageReadRequest::V1(v01::HostLocalStorageReadRequest { + key: "k".to_string(), + }); + assert_eq!(v1.encode()[0], 0); + assert_eq!(v1.version(), 1); + assert_eq!(HostLocalStorageReadRequest::LATEST, 2); + } + + #[test] + fn write_and_clear_stay_on_v01() { + // The `storage` scope is read-only, so no caller can address another + // product's storage for a write. A V2 on these would advertise reach the + // manifest cannot grant. + assert_eq!(HostLocalStorageWriteRequest::LATEST, 1); + assert_eq!(HostLocalStorageClearRequest::LATEST, 1); + } +} 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 diff --git a/scripts/cross-product-storage-e2e.sh b/scripts/cross-product-storage-e2e.sh new file mode 100755 index 000000000..40d0a72d4 --- /dev/null +++ b/scripts/cross-product-storage-e2e.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Cross-product storage against a real signing-host CLI, driven through the +# `@parity/truapi` client. +# +# One product writes to its own storage and another reads it; only the manifest +# grant in `peopl.paseo`'s local product config permits that. The granted read +# touches no chain: the host resolves it from `--product-config`, which seeds the +# manifest cache. The `read-missing` phase has nothing seeded, so its cache miss +# does go to dotNS on Asset Hub before refusing. +# +# 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 read +# genuinely cross-product rather than cross-connection: the value the read +# phases return was written 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-storage-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 read 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 write peopl.paseo +run_phase read dim2.paseo +run_phase read-untrusted stash.paseo +run_phase read-missing dim2.paseo +# Last, so a refusal above cannot have been the value expiring or being cleared. +run_phase read-again dim2.paseo + +echo "==> cross-product storage e2e passed"