Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Run `make help` for the list of targets.

.DEFAULT_GOAL := help
.PHONY: help setup build codegen test check check-generated clean playground wasm wasm-crypto-test uniffi uniffi-kotlin android-check provider-android-check ios-build ios-run ios-chat-run ios-chat-host-playground-run ios-chat-all android-jni android-publish-local dotli-link dev dev-cli dev-bootstrap dev-link-check e2e-dotli e2e-cli-diagnosis e2e-signing-cli e2e-pairing-cli e2e-chat-cli e2e-cross-product-storage e2e-cli-update headless install cli-runner cli-dist matrix explorer xcframework
.PHONY: help setup build codegen test check check-generated clean playground wasm wasm-crypto-test uniffi uniffi-kotlin android-check provider-android-check ios-build ios-run ios-chat-run ios-chat-host-playground-run ios-chat-all android-jni android-publish-local dotli-link dev dev-cli dev-bootstrap dev-link-check e2e-dotli e2e-cli-diagnosis e2e-signing-cli e2e-pairing-cli e2e-chat-cli e2e-cross-product-storage e2e-cross-product-ringvrf e2e-cli-update headless install cli-runner cli-dist matrix explorer xcframework

CARGO ?= cargo
TRUAPI_PKG := js/packages/truapi
Expand Down Expand Up @@ -417,6 +417,9 @@ e2e-chat-cli: ## Run the Chat content-screening battery against a chat signing-h
e2e-cross-product-storage: ## One product reads another's storage on the signing-host CLI, granted by a local product config.
scripts/cross-product-storage-e2e.sh

e2e-cross-product-ringvrf: ## One product signs with another's ring-VRF key on the signing-host CLI, granted by a local product config.
scripts/cross-product-ringvrf-e2e.sh

e2e-cli-update: cli-dist ## Install the packaged truapi-host from a fake release and self-update it, with no network.
node scripts/e2e-cli-update.mjs

Expand Down
13 changes: 13 additions & 0 deletions android/truapi-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 every cross-product
call 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.
Expand Down Expand Up @@ -102,6 +111,7 @@ val runtime = TrUAPIHostRuntime(
hostName = "My Chat Host",
peopleChainGenesisHash = peopleChainGenesisHash, // exactly 32 bytes
bulletinChainGenesisHash = bulletinChainGenesisHash,
assetHubChainGenesisHash = assetHubChainGenesisHash,
networkSuffix = "dot",
),
)
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.<suffix>`,
* `peopl.<suffix>`), the same person the app's own onboarding derives there.
Expand All @@ -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,
Expand All @@ -123,6 +129,7 @@ data class HostRuntimeConfig(
platformVersion = platformVersion,
peopleChainGenesisHash = peopleChainGenesisHash,
bulletinChainGenesisHash = bulletinChainGenesisHash,
assetHubChainGenesisHash = assetHubChainGenesisHash,
networkSuffix = networkSuffix,
localSessionSecret = localSessionSecret,
localSessionLiteUsername = localSessionLiteUsername,
Expand All @@ -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
Expand All @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions docs/design/product-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ type Icon = {

type Granted = // v1 grants; unrecognised values are tolerated, not fatal
| "all" // wildcard: every mediated interaction, present and future
| "storage"; // read this product's host-local storage
| "storage" // read this product's host-local storage
| "context"; // read this product's account and the identity behind it
```

### Executable Manifest
Expand Down Expand Up @@ -165,7 +166,7 @@ Each entry's value list scopes the grant:
trustedProducts: {
"wallet": ["all"], → every mediated interaction, now and later
"tracker": ["storage"], → storage reads promptless; account reads still prompt
"hub": ["all", "storage"] → just ["all"]; a narrow value never carves into the wildcard
"hub": ["storage", "context"] → both, and no more when a fourth scope is defined
}
```

Expand Down
28 changes: 20 additions & 8 deletions docs/rfcs/granted-scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,50 +8,62 @@ owner: "@filippovecchiato"
| | |
| --------------- | ---------------------------------------------------------------------------------- |
| **Start Date** | 2026-08-19 |
| **Description** | Widen `Granted` from the single `all` wildcard to `all` and `storage`. |
| **Description** | Widen `Granted` from the single `all` wildcard to `all`, `storage`, and `context`. |
| **Authors** | Filippo Vecchiato |

## Summary

`Granted` gains a narrow value alongside `all`, so a publisher pre-approves a scope list per product instead of choosing between everything and nothing.
`Granted` gains two narrow values alongside `all`, so a publisher pre-approves a scope list per product instead of choosing between everything and nothing.

## Motivation

`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 one `Granted` value:
[RFC — Product Manifest Format][manifest] gains two `Granted` values:

```typescript
type Granted = 'all' | 'storage';
type Granted = 'all' | 'storage' | 'context';
```

| Value | Pre-approves |
| --------- | ------------------------------------------------------------------------------------------------------- |
| `all` | Every cross-product interaction the Host mediates on the granting product's behalf, present and future. |
| `storage` | Reading the granting product's host-local storage. Read-only. |
| `context` | Acting as the granting product's account: reading it and the identity that follows from it, and producing proofs and signatures under its keys. |

`trustedProducts` keeps its `Record<string, Granted[]>` shape, so this needs no new field and no `$v` bump.

- **`all` is a superset, not a peer.** `["all"]` implies `storage`, so `["all", "storage"]` is `["all"]`. A Host MUST NOT read a narrower value as a restriction on `all`. Enumerating the narrow values covers the same interactions today but does not widen when a further value is defined — that difference is the point of enumerating.
- **`all` is a superset, not a peer.** `["all"]` implies `storage` and `context`, so `["all", "storage"]` is `["all"]`. A Host MUST NOT read a narrower value as a restriction on `all`. Enumerating the narrow values covers the same interactions today but does not widen when a further value is defined — that difference is the point of enumerating.
- **Values are a set.** Order is not significant, duplicates collapse.
- **Scopes are independent.** `["storage"]` leaves every other interaction prompting as usual, and a scope defined later grants nothing retroactively.
- **Scopes are independent.** `["storage"]` leaves account interactions prompting as usual, and vice versa.
- **Existing rules are unchanged.** Hosts MUST ignore unrecognised values and MUST NOT fail validation over them, so a Host implementing only `all` reads `["storage"]` as an empty grant and prompts. Publishers MUST NOT emit a value outside `Granted`. A grant never overrides a denial the user already gave.
- **A key names a product, and a product is all its executables.** The key is the segment above the TLD, so `dim2.dot`, `app.dim2.dot` and `worker.dim2.dot` are one grantee: granting `dim2` grants every executable published beneath it. A subname of another domain is that domain — `dim2.attacker.dot` reads as `attacker` and collects nothing published for `dim2`.

Which calls each scope gates remains a Host runtime contract, as it already is for `all`. A grant is a standing answer, so a call it does not cover refuses rather than prompts wherever prompting would itself disclose something — a cross-product storage read answers one refusal for every reason, and a prompt naming the target would say the target exists.

`context` gates `create_account_proof` and `ring_vrf_sign` on the granting product's keys. Both are adjudicated twice, in two different components, and both checks are load-bearing rather than one being a duplicate of the other:

- The **runtime frontend** refuses a cross-product caller before any authority is reached. The calling product id there is the one the Host bound to the connection, so this is the gate for a product running on this Host.
- The **authority holding the keys** resolves the granting product's manifest again, for itself. On a paired Host the authority request arrives over the wire from another Host, which names the product it is acting for. Relaying the frontend's verdict as a flag would take the manifest out of that decision entirely and let a peer reach every handle on the device rather than only the ones a publisher really granted.

A grant never overrides a refusal the user already gave: the stored account-access decision is read before the manifest, and read-only, so a grant lookup never raises the prompt that would settle an undecided one.

The account and identity *reads* `context` also names still take the user prompt.

## Drawbacks

Storage is the only interaction a publisher can name. Reading another product's account and signing under its keys still have no scope of their own, so a publisher who wants to pre-approve either is back to `all`#655 covers giving them one. Writes stay on the wildcard too: `storage` is read-only, so "read and write, nothing else" is inexpressible. And `all` widens silently, so staying narrow means revisiting the manifest as scopes are added.
Writes stay on the wildcard: `storage` is read-only, so "read and write, nothing else" is still inexpressible. `context` bundles reading an account with signing under it, so "see who I am, sign nothing" is not expressible eithersplitting 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. Should `storage` gain a write counterpart rather than leaving writes reachable only through `all`? A cross-product write is a larger step than a read, and no consumer has asked for one yet, but leaving it on the wildcard means a publisher who wants to allow it must also pre-approve everything else.
1. Is `context` the right name? `account` says it more directly, and `context` sits awkwardly beside the `context` parameter [RFC 0020][0020] removed from `create_transaction`.
2. Should `storage` gain a write counterpart rather than leaving writes reachable only through `all`? A cross-product write is a larger step than a read, and no consumer has asked for one yet, but leaving it on the wildcard means a publisher who wants to allow it must also pre-approve everything else.

[manifest]: product-manifest.md
[0020]: 0020-create-transaction.md
9 changes: 5 additions & 4 deletions docs/rfcs/product-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ type Icon = {

type Granted = // Grants v1 defines. Unrecognised values are ignored, not fatal.
| 'all' // Wildcard: every mediated interaction, present and future.
| 'storage'; // Read this product's host-local storage.
| 'storage' // Read this product's host-local storage.
| 'context'; // Read this product's account and the identity behind it.
```

#### Icons
Expand All @@ -136,8 +137,8 @@ Each such interaction is normally a consent decision; `trustedProducts` pre-appr
**The grant is issued by the product being accessed.** An entry in A's manifest states what B may do *to A* — the only direction A's name can authenticate. It says nothing about what A may do to B, nor about the products B in turn trusts.

- **Keys** are bare `<product_id>` labels, lowercase, with no TLD suffix: `"wallet"`, never `"wallet.dot"`. The Host appends the TLD of the network it resolves against. A key that does not resolve there is inert, not a validation error.
- **Values** are that product's grants. v1 defines two. `all` is a wildcard for the complete set of cross-product permissions the Host mediates on this product's behalf: it is resolved against that set when the grant is used, not enumerated here, so a grant of `all` covers permissions added after it was published. `storage` covers reading this product's host-local storage, read-only. Hosts MUST ignore unrecognised values, keep the recognised ones, and MUST NOT fail validation over them.
- **`all` is a superset, not a peer.** `["all"]` implies `storage`, so `["all", "storage"]` is `["all"]` and a Host MUST NOT read a narrower value as a restriction on `all`. Enumerating the narrow values instead of granting `all` covers the same interactions today but does not widen when a further value is defined. Values are a set: order is not significant and duplicates collapse. Scopes are independent — a grant of `["storage"]` leaves every other interaction prompting as usual.
- **Values** are that product's grants. v1 defines three. `all` is a wildcard for the complete set of cross-product permissions the Host mediates on this product's behalf: it is resolved against that set when the grant is used, not enumerated here, so a grant of `all` covers permissions added after it was published. `storage` covers reading this product's host-local storage, read-only. `context` covers reading this product's account and the identity that follows from it. Hosts MUST ignore unrecognised values, keep the recognised ones, and MUST NOT fail validation over them.
- **`all` is a superset, not a peer.** `["all"]` implies `storage` and `context`, so `["all", "storage"]` is `["all"]` and a Host MUST NOT read a narrower value as a restriction on `all`. Enumerating the narrow values instead of granting `all` covers the same interactions today but does not widen when a further value is defined. Values are a set: order is not significant and duplicates collapse. Scopes are independent — a grant of `["storage"]` leaves account interactions prompting as usual.
- **Absence means no grants.** Missing field, empty record, and empty array are equivalent: prompt as usual. A product listing itself is ignored.

Which interactions a Host mediates, and what the prompt looks like, are Host runtime contracts; this RFC defines only how the grants are published and read.
Expand Down Expand Up @@ -527,6 +528,6 @@ A conforming Host implementation should produce well-defined behaviour for each

## Future Directions

- `Granted` covers the capabilities the Host runtime contracts name today. Further values — write access to storage, and a scope for reading this product's account and signing under its keys (#655) — fit the same way: `all` stays the wildcard, and the array shape and the ignore-unrecognised-values rule let them land without a new `$v`.
- `Granted` covers the capabilities the Host runtime contracts name today. Further values — write access to storage, a scope of its own for signing — fit the same way: `all` stays the wildcard, and the array shape and the ignore-unrecognised-values rule let them land without a new `$v`.
- A manifest-aggregation RPC could eliminate the N+1 lookup pattern (one round-trip per subname) without changing the schema.
- A companion spec will pin down the dashboard grid (cell size, bounds, responsive behaviour) referenced by `WidgetManifest.dimensions`.
Loading
Loading