From 65034964371993e248d1864a761a853749d60973 Mon Sep 17 00:00:00 2001 From: peetzweg Date: Mon, 7 Sep 2026 16:01:06 +0200 Subject: [PATCH 1/4] fix: derive the reserved person and identity keys under the network suffix Product ids carry the network's dotNS TLD, so on a test network the personhood product is `peopl.paseo` or `peopl.testnet`, and the live iOS host derives its person keys and `uid` account under that TLD. The Rust core pinned both to `.dot`, so one seed was two different persons depending on which host looked, and the CLI could not find a phone-made lite username on paseo-next-v2. `SigningHostConfig` now carries the network suffix, the value the People runtime already scopes its `product/peopl.` contexts with. Every reserved derivation (`uid.`, `peopl.`) takes it; the CLI presets, the native and the wasm host configs supply it. Vectors for `.paseo` and `.testnet` are pinned against an independent RFC-0022 implementation. --- .../kotlin/io/parity/truapi/TrUAPIHost.kt | 9 +- docs/rfcs/0022-account-derivations.md | 26 ++- docs/rfcs/0024-personhood-as-product.md | 4 +- .../Sources/TrUAPIHost/TrUAPIHost.swift | 9 + .../Sources/TrUAPIHost/truapi_server.swift | 45 ++++- .../Tests/TrUAPIWsBridgeTests.swift | 3 +- rust/crates/truapi-host-cli/README.md | 8 +- rust/crates/truapi-host-cli/SPEC.md | 19 +- rust/crates/truapi-host-cli/src/accounts.rs | 86 +++++---- .../crates/truapi-host-cli/src/attestation.rs | 159 ++++++++-------- .../truapi-host-cli/src/frame_server.rs | 1 + rust/crates/truapi-host-cli/src/main.rs | 11 +- rust/crates/truapi-host-cli/src/network.rs | 23 +++ .../truapi-host-cli/src/register_name.rs | 12 +- .../tests/live_people_chain.rs | 22 +++ rust/crates/truapi-platform/src/lib.rs | 95 ++++++++++ rust/crates/truapi-server/README.md | 2 +- rust/crates/truapi-server/src/host_core.rs | 4 +- .../src/host_logic/attestation.rs | 54 ++++-- .../src/host_logic/product_account.rs | 172 +++++++++++++++--- rust/crates/truapi-server/src/native.rs | 36 ++++ .../truapi-server/src/runtime/signing_host.rs | 106 +++++++++-- .../runtime/signing_host/allowance_renewal.rs | 44 +++-- .../runtime/signing_host/local_activation.rs | 2 +- .../src/runtime/signing_host/sso_responder.rs | 45 +++-- .../src/runtime/statement_store.rs | 2 +- rust/crates/truapi-server/src/wasm.rs | 9 + rust/crates/truapi-server/src/ws_bridge.rs | 1 + 28 files changed, 771 insertions(+), 238 deletions(-) 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 b0e89c929..a8b0faa62 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,10 @@ 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. + * [bulletinChainGenesisHash] must each be exactly 32 bytes. [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. */ data class HostRuntimeConfig( val hostName: String, @@ -106,6 +109,7 @@ data class HostRuntimeConfig( val platformVersion: String? = null, val peopleChainGenesisHash: ByteArray, val bulletinChainGenesisHash: ByteArray, + val networkSuffix: String, val localSessionSecret: ByteArray? = null, val localSessionLiteUsername: String? = null, ) { @@ -119,6 +123,7 @@ data class HostRuntimeConfig( platformVersion = platformVersion, peopleChainGenesisHash = peopleChainGenesisHash, bulletinChainGenesisHash = bulletinChainGenesisHash, + networkSuffix = networkSuffix, localSessionSecret = localSessionSecret, localSessionLiteUsername = localSessionLiteUsername, ) @@ -133,6 +138,7 @@ data class HostRuntimeConfig( platformVersion == other.platformVersion && peopleChainGenesisHash.contentEquals(other.peopleChainGenesisHash) && bulletinChainGenesisHash.contentEquals(other.bulletinChainGenesisHash) && + networkSuffix == other.networkSuffix && localSessionSecret.contentEquals(other.localSessionSecret) && localSessionLiteUsername == other.localSessionLiteUsername } @@ -145,6 +151,7 @@ data class HostRuntimeConfig( result = 31 * result + (platformVersion?.hashCode() ?: 0) result = 31 * result + peopleChainGenesisHash.contentHashCode() result = 31 * result + bulletinChainGenesisHash.contentHashCode() + result = 31 * result + networkSuffix.hashCode() result = 31 * result + (localSessionSecret?.contentHashCode() ?: 0) result = 31 * result + (localSessionLiteUsername?.hashCode() ?: 0) return result diff --git a/docs/rfcs/0022-account-derivations.md b/docs/rfcs/0022-account-derivations.md index 8f9b2db25..ecdeab3c1 100644 --- a/docs/rfcs/0022-account-derivations.md +++ b/docs/rfcs/0022-account-derivations.md @@ -270,10 +270,18 @@ reserved product identities as their `productId`: | Migrating to a product soon | Game (DIM2) | `dim2.dot` | Governance-reserved 3–5 char name | | Migrating long-term / product-shaped | PoI (DIM1) | `poi.dot` | Governance-reserved 3–5 char name | | Migrating long-term / product-shaped | Funding | `fund.dot` | Governance-reserved 3–5 char name | -| Migrating long-term / product-shaped | Public light person identity | `uid.dot` | Governance-reserved 3–5 char name | -| Migrating long-term / product-shaped | Personhood | `peopl.dot` | Governance-reserved 3–5 char name | +| Migrating long-term / product-shaped | Public light person identity | `uid.` | Governance-reserved 3–5 char name | +| Migrating long-term / product-shaped | Personhood | `peopl.`| Governance-reserved 3–5 char name | | Not coercible to a product | Coinage | — | Deferred to a separate RFC (own layout today: `//pps//coin/{index}`, `//pps//ring-vrf/{index}`) | +A reserved `productId` is a dotNS name like any other, so it ends in the TLD of +the network the host runs against: `uid.dot` and `peopl.dot` on Polkadot, +`uid.paseo` and `peopl.paseo` on paseo-next-v2, `uid.testnet` and +`peopl.testnet` on previewnet. The TLD is the same network suffix the People +runtime scopes its product contexts with (`product/peopl./…`), so accounts, +contexts and keys agree on which network a person belongs to, and one seed is one +person per network. A host learns the suffix from the network it is configured +for (`SigningHostConfig::network_suffix` in the Rust core) and never assumes it. ### Well-known alias accounts The runtime defines well-known Account Contexts (`resources`, `score`, @@ -322,16 +330,22 @@ reserved product identity from the table above. `DerivationIndex` is the same 32-byte index format as product accounts, so each domain gets its own index space. -The personhood keys live under the `peopl.dot` domain: +The personhood keys live under the `peopl.` domain of the network: ```rust // Full personhood ring-VRF key -full_personhood_key = //peopl.dot//index_bytes(0) +full_personhood_key = //peopl.//index_bytes(0) // Light personhood ring-VRF key -light_personhood_key = //peopl.dot//index_bytes(1) +light_personhood_key = //peopl.//index_bytes(1) ``` +On Polkadot these are `//peopl.dot//index_bytes(0)` and +`//peopl.dot//index_bytes(1)`; on paseo-next-v2 the same seed yields the +`peopl.paseo` keys, a different pair. The `peopl.` domain is also what the +personhood product on that network derives from when it registers its keys +under RFC-0024, so the reserved keys and the product's own registry entries are +the same bytes. Existing keys migrate to these paths. Coinage's ring-VRF keys (recyclers/vouchers) are deferred to the coinage RFC. @@ -384,7 +398,7 @@ game_domain = "game" There are no production deployments of secret-component derivations or of the `u32`-index wire types; the selector change is wire-breaking for `ProductAccountId`, `ProductProofContext`, `PaymentTopUpSource`, and -`AllocatableResource`, and is made freely, with no migration path. Existing ring-VRF keys move to their `peopl.dot` +`AllocatableResource`, and is made freely, with no migration path. Existing ring-VRF keys move to their `peopl.` paths; deployed encryption keys are handled by the encryption RFC. ## Drawbacks diff --git a/docs/rfcs/0024-personhood-as-product.md b/docs/rfcs/0024-personhood-as-product.md index 6ca217b6c..595dca471 100644 --- a/docs/rfcs/0024-personhood-as-product.md +++ b/docs/rfcs/0024-personhood-as-product.md @@ -24,7 +24,7 @@ A proof is a bearer token for its context's alias and a signature is a bearer to **Personhood is welded into the Host.** RFC-0004 §"Host member-key selection" requires every Host to define the PoP ring collection internally, choose a member key corresponding to the requested `RingLocation`, fall back to the PoP key when correspondence is undeterminable, and tiebreak stably. `truapi-server` implements exactly that with the ring identities compiled in (`rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs`: `FULL_PERSON_COLLECTION`, `LITE_PERSON_COLLECTION`, `enum PersonKey { Full, Lite }`). So every change to how a person key is derived, registered, renewed, or recovered is a Host release. -A personhood product must instead own the full and light keys — under RFC-0022, the `peopl.dot` domain of the ring-VRF tree — while telling the Host and Account Holder enough to keep serving the app's own personhood-dependent features, and lending its keys and aliases to other products. The binding constraint across all of it: **no consumer may know which key is used**, not the app and not a calling product. +A personhood product must instead own the full and light keys — under RFC-0022, the `peopl.` domain of the ring-VRF tree, `peopl.dot` on Polkadot — while telling the Host and Account Holder enough to keep serving the app's own personhood-dependent features, and lending its keys and aliases to other products. The binding constraint across all of it: **no consumer may know which key is used**, not the app and not a calling product. **The obstacle** is that the member keys serve three overlapping classes of work, and only one is not extractable: @@ -108,7 +108,7 @@ fn list_ring_vrf_keys( - **Registration declares intent, not membership.** It means "this is the key I will use for that ring", not "the user is a person"; membership is still discovered only by attempting a proof, which returns `NotMember` (RFC-0004). This keeps the registry from being a personhood oracle. - **The public key is owner-visible by default, permissioned cross-product**, because a member public key is linkable across every ring it appears in. -RFC-0022 already pins `//peopl.dot//index_bytes(0)` as the full personhood key and `index_bytes(1)` as the light one. Under this RFC those constants are the personhood product's own implementation detail, expressed to everyone else as two registry entries. +RFC-0022 already pins `//peopl.//index_bytes(0)` as the full personhood key and `index_bytes(1)` as the light one, under the TLD of the network. Under this RFC those constants are the personhood product's own implementation detail, expressed to everyone else as two registry entries. The examples below are written for Polkadot, where the product is `peopl.dot`; on paseo-next-v2 read `peopl.paseo` throughout. ### Proofs, aliases, and signatures take an explicit key handle diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 43456bfab..9a4559c2c 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 + /// 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 + /// person ring-VRF keys, the same person the app's own onboarding derives + /// on that network. + public let networkSuffix: String public let localSessionSecret: Data? public let localSessionLiteUsername: String? @@ -40,6 +46,7 @@ public struct HostRuntimeConfig: Sendable, Equatable { platformVersion: String? = nil, peopleChainGenesisHash: Data, bulletinChainGenesisHash: Data, + networkSuffix: String, localSessionSecret: Data? = nil, localSessionLiteUsername: String? = nil ) { @@ -50,6 +57,7 @@ public struct HostRuntimeConfig: Sendable, Equatable { self.platformVersion = platformVersion self.peopleChainGenesisHash = peopleChainGenesisHash self.bulletinChainGenesisHash = bulletinChainGenesisHash + self.networkSuffix = networkSuffix self.localSessionSecret = localSessionSecret self.localSessionLiteUsername = localSessionLiteUsername } @@ -64,6 +72,7 @@ public struct HostRuntimeConfig: Sendable, Equatable { platformVersion: platformVersion, peopleChainGenesisHash: peopleChainGenesisHash, bulletinChainGenesisHash: bulletinChainGenesisHash, + networkSuffix: networkSuffix, localSessionSecret: localSessionSecret, localSessionLiteUsername: localSessionLiteUsername ) diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index a54cc1a3e..5b512474d 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -3453,6 +3453,15 @@ public struct NativeHostRuntimeConfig: Equatable, Hashable { * Bulletin-chain genesis hash. Must be exactly 32 bytes. */ public var bulletinChainGenesisHash: Data + /** + * 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 + * ring-VRF keys. Read it from the network the host is configured for, the + * way the host's own onboarding does; a wrong value derives a different + * person from the same seed. + */ + public var networkSuffix: String /** * Optional local signing-host secret material (raw BIP-39 entropy). */ @@ -3490,6 +3499,14 @@ public struct NativeHostRuntimeConfig: Equatable, Hashable { /** * Bulletin-chain genesis hash. Must be exactly 32 bytes. */bulletinChainGenesisHash: Data, + /** + * 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 + * ring-VRF keys. Read it from the network the host is configured for, the + * way the host's own onboarding does; a wrong value derives a different + * person from the same seed. + */networkSuffix: String, /** * Optional local signing-host secret material (raw BIP-39 entropy). */localSessionSecret: Data?, @@ -3504,6 +3521,7 @@ public struct NativeHostRuntimeConfig: Equatable, Hashable { self.platformVersion = platformVersion self.peopleChainGenesisHash = peopleChainGenesisHash self.bulletinChainGenesisHash = bulletinChainGenesisHash + self.networkSuffix = networkSuffix self.localSessionSecret = localSessionSecret self.localSessionLiteUsername = localSessionLiteUsername } @@ -3532,6 +3550,7 @@ public struct FfiConverterTypeNativeHostRuntimeConfig: FfiConverterRustBuffer { platformVersion: FfiConverterOptionString.read(from: &buf), peopleChainGenesisHash: FfiConverterData.read(from: &buf), bulletinChainGenesisHash: FfiConverterData.read(from: &buf), + networkSuffix: FfiConverterString.read(from: &buf), localSessionSecret: FfiConverterOptionData.read(from: &buf), localSessionLiteUsername: FfiConverterOptionString.read(from: &buf) ) @@ -3546,6 +3565,7 @@ public struct FfiConverterTypeNativeHostRuntimeConfig: FfiConverterRustBuffer { FfiConverterOptionString.write(value.platformVersion, into: &buf) FfiConverterData.write(value.peopleChainGenesisHash, into: &buf) FfiConverterData.write(value.bulletinChainGenesisHash, into: &buf) + FfiConverterString.write(value.networkSuffix, into: &buf) FfiConverterOptionData.write(value.localSessionSecret, into: &buf) FfiConverterOptionString.write(value.localSessionLiteUsername, into: &buf) } @@ -4424,6 +4444,15 @@ enum NativeRuntimeConfigError: Swift.Error, Equatable, Hashable, Foundation.Loca * Actual deeplink scheme value. */scheme: String ) + /** + * Network suffix was not one bare lowercase dotNS label of at most 16 + * bytes. + */ + case InvalidNetworkSuffix( + /** + * Actual network suffix value. + */networkSuffix: String + ) /** * Product id was not a valid host-spec product identifier. */ @@ -4487,10 +4516,13 @@ public struct FfiConverterTypeNativeRuntimeConfigError: FfiConverterRustBuffer { case 6: return .InvalidDeeplinkScheme( scheme: try FfiConverterString.read(from: &buf) ) - case 7: return .InvalidProductId( + case 7: return .InvalidNetworkSuffix( + networkSuffix: try FfiConverterString.read(from: &buf) + ) + case 8: return .InvalidProductId( productId: try FfiConverterString.read(from: &buf) ) - case 8: return .LocalSessionActivation( + case 9: return .LocalSessionActivation( reason: try FfiConverterString.read(from: &buf) ) @@ -4535,13 +4567,18 @@ public struct FfiConverterTypeNativeRuntimeConfigError: FfiConverterRustBuffer { FfiConverterString.write(scheme, into: &buf) - case let .InvalidProductId(productId): + case let .InvalidNetworkSuffix(networkSuffix): writeInt(&buf, Int32(7)) + FfiConverterString.write(networkSuffix, into: &buf) + + + case let .InvalidProductId(productId): + writeInt(&buf, Int32(8)) FfiConverterString.write(productId, into: &buf) case let .LocalSessionActivation(reason): - writeInt(&buf, Int32(8)) + writeInt(&buf, Int32(9)) FfiConverterString.write(reason, into: &buf) } diff --git a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift index 00213a3fe..90291ed09 100644 --- a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift +++ b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift @@ -78,7 +78,8 @@ private extension TrUAPIWsBridgeTests { HostRuntimeConfig( hostName: "truapi-host-tests", peopleChainGenesisHash: Data(repeating: 0, count: 32), - bulletinChainGenesisHash: Data(repeating: 0, count: 32) + bulletinChainGenesisHash: Data(repeating: 0, count: 32), + networkSuffix: "paseo" ) } diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index f045c1489..47a90164d 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -21,7 +21,7 @@ One binary, `truapi-host`: | --- | --- | | `pairing-host` | Seedless host: serves product frames, emits pairing deeplinks, and can run product scripts. | | `signing-host` | Wallet-local host: owns signer identity, can run product scripts, decodes copied pairing QR images or accepts deeplinks, registers statement allowance on-chain, signs. | -| `identity-check` | Probe the root and canonical `uid.dot` identity account for a registered username (read from the dotNS contracts on Asset Hub). | +| `identity-check` | Probe the root and the network's `uid.` identity account for a registered username (read from the dotNS contracts on Asset Hub). | | `register-name` | Register a full-person username via `DotnsGateway.register_name` on Asset Hub, linked to a lite username or standalone with a chat key. | | `alloc-check` | Diagnose (or `--submit`) on-chain statement-store allowance: ring membership, chosen slot, and the `set_statement_store_account` extrinsic. On a full period it prints each occupied slot's age and which one would be replaced. | | `pgas-check` | Diagnose (or `--submit`) an Asset Hub PGAS allowance claim: ring membership on People, whether Asset Hub has imported that ring revision, the day's first unclaimed slot, and the `Pgas.claim_pgas` extrinsic. | @@ -330,7 +330,7 @@ old session, resets product WebSocket connections so clients reconnect against the new runtime, and restores every paired device saved for the target session. `/session --mnemonic ""` brings an already-onboarded account into the -session catalog. The host derives its `uid.dot` identity, reads any existing +session catalog. The host derives its `uid.` identity, reads any existing full or Lite username from dotNS, falls back to the identity backend's assigned username records when no dotNS mirror exists, and confirms its People or LitePeople ring membership. This lookup is read-only and never registers a new @@ -641,7 +641,7 @@ The real statement store enforces per-account allowance. Before pairing, the signing host grants it on-chain exactly as a real client does: it proves its personhood ring membership with a bandersnatch ring-VRF and submits an unsigned General (v5) `Resources.set_statement_store_account` extrinsic for each account -that submits statements — its RFC-0022 `uid.dot` identity account and the +that submits statements — its RFC-0022 `uid.` identity account and the pairing host's per-pairing device key. The shared native implementation lives in `truapi-server/src/runtime/statement_allowance/` (metadata-driven signed-extension encoding, ring fetch, slot scan, ring-VRF proof, extrinsic @@ -708,7 +708,7 @@ gets its own signer identity on the same machine. `HOST_CLI_IDENTITY_BACKEND_BASE` swaps only the identity backend (for a local one); `HOST_CLI_IDENTITY_BACKEND_TOKEN` supplies its bearer token instead of the CLI minting one. For username -registration, an injected token's subject must match the session's `uid.dot` +registration, an injected token's subject must match the session's `uid.` candidate account. The automatically minted token uses that identity; and `HOST_CLI_DOTNS_POP_CONTROLLER` overrides on-chain `DotnsPopController` discovery (see SPEC.md §21). Both also accept `--frame-listen
` diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index d66034066..7a760e37a 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -955,9 +955,9 @@ Before a signing host answers a link, it: 1. ensures a signer; 2. decodes the V2 handshake; -3. derives its RFC-0022 `uid.dot` identity account; +3. derives its RFC-0022 `uid.` identity account; 4. reads the pairing device Statement Store account from the proposal; -5. finds the signer's rings through the pairing-attestation bootstrap `peopl.dot` +5. finds the signer's rings through the pairing-attestation bootstrap `peopl.` keys, index 0 for `People` and index 1 for `LitePeople`, scanning back from the current ring in each (RFC-0024 operational key selection uses the registry instead); @@ -1067,7 +1067,7 @@ A new auto account: 1. acquires `accounts.json.lock`; 2. generates a 12-word mnemonic; -3. derives the RFC-0022 `uid.dot` index-0 sr25519 identity account; +3. derives the RFC-0022 `uid.` index-0 sr25519 identity account; 4. chooses `auto-` as its local name; 5. checks that the requested Lite username base has an available numerical alias; @@ -1088,7 +1088,7 @@ attempts. Identity-backend HTTP clients use a 30-second timeout. The backend's username routes are bearer-gated. Unless `HOST_CLI_IDENTITY_BACKEND_TOKEN` supplies one, the CLI mints an access token -for the mnemonic's RFC-0022 `uid.dot` account. It takes a challenge from +for the mnemonic's RFC-0022 `uid.` account. It takes a challenge from `auth/challenges`. It answers `auth/token` with an sr25519 proof over `SHA256(challenge || clientId || SHA256(body))`, signed by that identity key. The backend requires the JWT subject to equal `candidateAccountId` on @@ -1192,7 +1192,7 @@ may remain. `/session --mnemonic ""` is an import-only flow: 1. parse and normalize the BIP-39 phrase; -2. derive the RFC-0022 `uid.dot` identity; +2. derive the RFC-0022 `uid.` identity; 3. read its optional full or Lite dotNS username from Asset Hub; 4. when no dotNS mirror exists, search the identity backend's assigned username records for the derived candidate account; @@ -1408,7 +1408,7 @@ state, and other role-owned runtime data. - network id; - plaintext BIP-39 mnemonic; - final Lite username; -- RFC-0022 `uid.dot` index-0 public key and address; +- RFC-0022 `uid.` index-0 public key and address; - creation timestamp; - attested state; and - exhausted Statement Store periods. @@ -1843,7 +1843,8 @@ truapi-host identity-check \ The command derives and queries two accounts: - root; and -- RFC-0022 `//product//uid.dot/index_bytes(0)`. +- RFC-0022 `//product//uid./index_bytes(0)`, `` being the selected + network's dotNS TLD (`paseo` for `paseo-next-v2`, `testnet` for `previewnet`). For each it prints one of: @@ -1867,7 +1868,7 @@ truapi-host register-name \ ``` Registers `label` as the full-person username of the mnemonic's RFC-0022 -`uid.dot` identity account, through `DotnsGateway.register_name` on Asset Hub. +`uid.` identity account, through `DotnsGateway.register_name` on Asset Hub. The account must be a recognized full person: its ring-VRF key must be built into a People-collection ring root on People, and Asset Hub's `members-subscriber` must already hold that root revision (the command waits for @@ -1979,7 +1980,7 @@ ended. This preserves the child status but bypasses later Rust destructors. | `TRUAPI_HOST_RELEASE_BASE_URL` | Release host for the installer and the updater, for mirrors and tests. | | `HOST_CLI_SIGNER_MNEMONIC` | Mnemonic for `dev`, `signing-host`, `identity-check`, `register-name`, `alloc-check` and `pgas-check` when `--mnemonic` is omitted. | | `HOST_CLI_IDENTITY_BACKEND_BASE` | Identity backend base URL override, including `/api/v1`, for instance a local backend. Chain endpoints stay on the preset. | -| `HOST_CLI_IDENTITY_BACKEND_TOKEN` | Bearer token for the identity backend's username routes. For registration its subject must be the candidate `uid.dot` account. Unset, the CLI mints one itself through the backend's `auth/challenges` → `auth/token` sr25519 handshake with that identity key. | +| `HOST_CLI_IDENTITY_BACKEND_TOKEN` | Bearer token for the identity backend's username routes. For registration its subject must be the candidate `uid.` account. Unset, the CLI mints one itself through the backend's `auth/challenges` → `auth/token` sr25519 handshake with that identity key. | | `HOST_CLI_DOTNS_POP_CONTROLLER` | `DotnsPopController` H160 override, skipping on-chain discovery (`DotnsGateway.DispatcherAddress`, used directly when `protocolRegistry()` answers on it, otherwise resolved through `TARGET()`). Only needed where discovery fails. The controller is `0xCC932348606cc1f3318cADeC5A5Cd2CA447f8a4b` on paseo-next-v2 and previewnet; `DEPLOYMENTS.md` in paritytech/dotns is the authority per network. | | `XDG_STATE_HOME` | Preferred default state parent. | | `HOME` | Fallback default state parent. | diff --git a/rust/crates/truapi-host-cli/src/accounts.rs b/rust/crates/truapi-host-cli/src/accounts.rs index 68ca96ef0..25dee6cd5 100644 --- a/rust/crates/truapi-host-cli/src/accounts.rs +++ b/rust/crates/truapi-host-cli/src/accounts.rs @@ -149,9 +149,10 @@ pub struct AccountRecord { pub mnemonic: String, /// Lite username registered through the identity backend. pub lite_username: String, - /// Hex-encoded RFC-0022 `uid.dot` identity public key. + /// Hex-encoded RFC-0022 `uid.` identity public key on this + /// account's network. pub public_key_hex: String, - /// SS58 address for the RFC-0022 `uid.dot` identity public key. + /// SS58 address for the RFC-0022 `uid.` identity public key. pub address: String, /// Creation timestamp. pub created_at_unix: u64, @@ -453,8 +454,8 @@ pub async fn inspect_imported_signer( let mnemonic = Mnemonic::parse(mnemonic.trim()) .context("invalid BIP-39 mnemonic")? .to_string(); - let identity = identity_from_mnemonic(&mnemonic)?; - let username = attestation::lookup_registered_username(network.asset_hub_ws, &identity.entropy) + let identity = identity_from_mnemonic(&mnemonic, network.network_suffix)?; + let username = attestation::lookup_registered_username(network, &identity.entropy) .await .with_context(|| { format!( @@ -464,20 +465,16 @@ pub async fn inspect_imported_signer( })?; let username = match username { Some(username) => Some(username), - None => attestation::lookup_backend_username( - network.identity_backend_base, - &identity.entropy, - &identity.address, - ) - .await - .with_context(|| { - format!( - "reverse-resolve the mnemonic's assigned identity-backend username on {}", - network.id - ) - })?, + None => attestation::lookup_backend_username(network, &identity.entropy, &identity.address) + .await + .with_context(|| { + format!( + "reverse-resolve the mnemonic's assigned identity-backend username on {}", + network.id + ) + })?, }; - wait_for_ring_membership(network.people_ws, &identity.entropy) + wait_for_ring_membership(network, &identity.entropy) .await .with_context(|| { let account = username.as_deref().unwrap_or(&identity.address); @@ -595,15 +592,11 @@ async fn create_auto_account( let mnemonic = Mnemonic::generate(12) .context("generate BIP-39 mnemonic")? .to_string(); - let identity = identity_from_mnemonic(&mnemonic)?; + let identity = identity_from_mnemonic(&mnemonic, network.network_suffix)?; - if !attestation::lite_username_available( - network.identity_backend_base, - &identity.entropy, - &lite_username, - ) - .await - .with_context(|| format!("check lite username {lite_username:?} availability"))? + if !attestation::lite_username_available(network, &identity.entropy, &lite_username) + .await + .with_context(|| format!("check lite username {lite_username:?} availability"))? { bail!("lite username {lite_username:?} is taken; pass a different --lite-username-prefix"); } @@ -632,7 +625,7 @@ async fn create_auto_account( ); record.lite_username = attest_record(network, &record, reserved_username).await?; - wait_for_ring_membership(network.people_ws, &identity.entropy).await?; + wait_for_ring_membership(network, &identity.entropy).await?; record.attested = true; store.upsert(record.clone()); store.save()?; @@ -645,9 +638,9 @@ async fn ensure_record_ready( record: &AccountRecord, reserved_username: Option<&str>, ) -> Result { - let identity = identity_from_mnemonic(&record.mnemonic)?; + let identity = identity_from_mnemonic(&record.mnemonic, network.network_suffix)?; if record.origin == AccountOrigin::Imported { - wait_for_ring_membership(network.people_ws, &identity.entropy).await?; + wait_for_ring_membership(network, &identity.entropy).await?; return Ok(record.clone()); } let mut record = record.clone(); @@ -655,10 +648,9 @@ async fn ensure_record_ready( record.lite_username = attest_record(network, &record, reserved_username).await?; record.attested = true; } else { - record.lite_username = - attestation::registered_lite_username(network.asset_hub_ws, &identity.entropy) - .await - .with_context(|| format!("resolve Lite username for account {}", record.name))?; + record.lite_username = attestation::registered_lite_username(network, &identity.entropy) + .await + .with_context(|| format!("resolve Lite username for account {}", record.name))?; } if store .get(network.id, &record.name) @@ -667,7 +659,7 @@ async fn ensure_record_ready( store.upsert(record.clone()); store.save()?; } - wait_for_ring_membership(network.people_ws, &identity.entropy).await?; + wait_for_ring_membership(network, &identity.entropy).await?; Ok(record) } @@ -680,6 +672,7 @@ async fn attest_record( let lite_username = attestation::attest(&attestation::AttestConfig { backend_base: network.identity_backend_base.to_string(), asset_hub_ws: network.asset_hub_ws.to_string(), + network_suffix: network.network_suffix.to_string(), entropy, username_base: record.lite_username.clone(), reserved_username: reserved_username.map(str::to_string), @@ -701,27 +694,32 @@ fn resolved_lite_username(username: &str) -> bool { .is_some_and(|(name, discriminator)| !name.is_empty() && !discriminator.is_empty()) } -/// Every personhood collection candidate for `entropy`, widest slot budget first. +/// Every personhood collection candidate for `entropy` on the network whose +/// dotNS TLD is `network_suffix`, widest slot budget first. /// /// Both are always offered; membership is settled on chain, not from local state. -pub(crate) fn collection_candidates(entropy: &[u8]) -> Vec { +pub(crate) fn collection_candidates( + entropy: &[u8], + network_suffix: &str, +) -> Vec { vec![ alloc::CollectionCandidate { collection: PersonhoodCollection::People, - entropy: derive_full_person_ring_vrf_entropy(entropy), + entropy: derive_full_person_ring_vrf_entropy(entropy, network_suffix), }, alloc::CollectionCandidate { collection: PersonhoodCollection::LitePeople, - entropy: derive_lite_person_ring_vrf_entropy(entropy), + entropy: derive_lite_person_ring_vrf_entropy(entropy, network_suffix), }, ] } -async fn wait_for_ring_membership(people_ws: &str, entropy: &[u8]) -> Result<()> { +async fn wait_for_ring_membership(network: NetworkConfig, entropy: &[u8]) -> Result<()> { const MAX_ATTEMPTS: usize = 30; const SLEEP: Duration = Duration::from_secs(4); - let candidates = collection_candidates(entropy); + let people_ws = network.people_ws; + let candidates = collection_candidates(entropy, network.network_suffix); let mut metadata = None; for attempt in 1..=MAX_ATTEMPTS { crate::terminal_ui::update_activity( @@ -826,10 +824,10 @@ struct SignerIdentity { address: String, } -fn identity_from_mnemonic(mnemonic: &str) -> Result { +fn identity_from_mnemonic(mnemonic: &str, network_suffix: &str) -> Result { let entropy = mnemonic_entropy(mnemonic)?; - let candidate = derive_identity_keypair(&entropy) - .map_err(|err| anyhow::anyhow!("uid.dot identity derivation failed: {err}"))?; + let candidate = derive_identity_keypair(&entropy, network_suffix) + .map_err(|err| anyhow::anyhow!("uid identity derivation failed: {err}"))?; let public_key = candidate.public.to_bytes(); Ok(SignerIdentity { entropy, @@ -1118,7 +1116,7 @@ mod tests { #[test] fn imported_signer_is_durable_named_and_excluded_from_auto_pool() -> Result<()> { let dir = tempdir()?; - let identity = identity_from_mnemonic(MNEMONIC)?; + let identity = identity_from_mnemonic(MNEMONIC, "paseo")?; let imported = ImportedSigner { mnemonic: MNEMONIC.to_string(), entropy: identity.entropy, @@ -1149,7 +1147,7 @@ mod tests { #[test] fn imported_signer_without_dotns_username_is_still_cached() -> Result<()> { let dir = tempdir()?; - let identity = identity_from_mnemonic(MNEMONIC)?; + let identity = identity_from_mnemonic(MNEMONIC, "paseo")?; let session_name = imported_session_name(None, &identity.public_key); let imported = ImportedSigner { mnemonic: MNEMONIC.to_string(), diff --git a/rust/crates/truapi-host-cli/src/attestation.rs b/rust/crates/truapi-host-cli/src/attestation.rs index 3061bd3f6..9f952ffc4 100644 --- a/rust/crates/truapi-host-cli/src/attestation.rs +++ b/rust/crates/truapi-host-cli/src/attestation.rs @@ -27,10 +27,11 @@ use truapi_server::host_logic::dotns_gateway::{ }; use truapi_server::host_logic::product_account::{ SR25519_SIGNING_CONTEXT, derive_identity_keypair, derive_root_keypair_from_entropy, - product_public_key_to_address, + identity_product_id, product_public_key_to_address, }; use crate::dotns_read::AssetHubReader; +use crate::network::NetworkConfig; /// Env var carrying an optional bearer token for the identity backend. /// Set it to reuse a token minted elsewhere. Unset, the CLI runs the sr25519 @@ -65,15 +66,17 @@ struct BackendToken { /// Bearer token for the identity backend's username routes. /// /// The explicit env token wins. Otherwise the CLI completes the backend's -/// `challenges` → `token` sr25519 handshake as the mnemonic's RFC-0022 `uid.dot` -/// account. Since device-uniqueness-backend#77, `POST /usernames` rejects a JWT -/// whose subject differs from `candidateAccountId`. +/// `challenges` → `token` sr25519 handshake as the mnemonic's RFC-0022 +/// `uid.` account on the network being attested. Since +/// device-uniqueness-backend#77, `POST /usernames` rejects a JWT whose subject +/// differs from `candidateAccountId`. async fn backend_token( client: &reqwest::Client, backend_base: &str, auth_entropy: &[u8], + network_suffix: &str, ) -> Result { - let auth_client_id = derive_identity_keypair(auth_entropy) + let auth_client_id = derive_identity_keypair(auth_entropy, network_suffix) .map_err(|err| anyhow::anyhow!("backend auth identity derivation failed: {err}"))? .public .to_bytes(); @@ -99,7 +102,7 @@ async fn backend_token( auth_client_id, }); } - let token = mint_backend_token(client, backend_base, auth_entropy).await?; + let token = mint_backend_token(client, backend_base, auth_entropy, network_suffix).await?; let token = { let mut tokens = BACKEND_TOKENS .lock() @@ -140,6 +143,7 @@ async fn mint_backend_token( client: &reqwest::Client, backend_base: &str, auth_entropy: &[u8], + network_suffix: &str, ) -> Result { let url = format!("{backend_base}/auth/challenges"); let body: Value = client @@ -161,7 +165,7 @@ async fn mint_backend_token( .decode(&challenge) .context("challenge is not valid base64")?; - let keypair = derive_identity_keypair(auth_entropy) + let keypair = derive_identity_keypair(auth_entropy, network_suffix) .map_err(|err| anyhow::anyhow!("backend auth identity derivation failed: {err}"))?; let client_id = keypair.public.to_bytes(); @@ -211,12 +215,13 @@ async fn send_with_backend_auth( client: &reqwest::Client, backend_base: &str, auth_entropy: &[u8], + network_suffix: &str, request: F, ) -> Result where F: Fn(&str) -> reqwest::RequestBuilder, { - let token = backend_token(client, backend_base, auth_entropy).await?; + let token = backend_token(client, backend_base, auth_entropy, network_suffix).await?; let response = request(&token.value).send().await?; if response.status() != reqwest::StatusCode::UNAUTHORIZED || token.source == BackendTokenSource::Environment @@ -226,7 +231,7 @@ where warn!(backend = %backend_base, "identity backend rejected cached token; authenticating again"); evict_rejected_backend_token(backend_base, &token.auth_client_id, &token.value); - let refreshed = backend_token(client, backend_base, auth_entropy) + let refreshed = backend_token(client, backend_base, auth_entropy, network_suffix) .await .context("refresh identity backend token after 401 Unauthorized")?; request(&refreshed.value).send().await.map_err(Into::into) @@ -239,6 +244,9 @@ pub struct AttestConfig { /// Asset Hub WebSocket URL for the reservation timestamp and the dotNS /// username poll. pub asset_hub_ws: String, + /// The network's dotNS TLD without the dot (`paseo`, `testnet`); the + /// registered person is `uid.` / `peopl.`. + pub network_suffix: String, /// BIP-39 entropy of the signing host's root account. pub entropy: Vec, /// Requested lite username base (6+ lowercase letters, no digits). @@ -250,18 +258,23 @@ pub struct AttestConfig { /// Check whether a lite username base is available through the identity /// backend. The username must be the base form without the digit suffix. pub async fn lite_username_available( - backend_base: &str, + network: NetworkConfig, auth_entropy: &[u8], username_base: &str, ) -> Result { + let backend_base = network.identity_backend_base; let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build()?; let url = format!("{backend_base}/usernames/available"); let body = json!({ "usernames": [username_base] }); - let response = send_with_backend_auth(&client, backend_base, auth_entropy, |token| { - client.post(&url).bearer_auth(token).json(&body) - }) + let response = send_with_backend_auth( + &client, + backend_base, + auth_entropy, + network.network_suffix, + |token| client.post(&url).bearer_auth(token).json(&body), + ) .await .with_context(|| format!("POST {url}"))?; let status = response.status(); @@ -326,6 +339,7 @@ pub async fn attest(config: &AttestConfig) -> Result { let verifier = fetch_verifier(&client, &config.backend_base).await?; let registration = build_lite_registration( &config.entropy, + &config.network_suffix, verifier, &config.username_base, config.reserved_username.as_deref(), @@ -338,16 +352,7 @@ pub async fn attest(config: &AttestConfig) -> Result { config.username_base ); - submit_registration( - &client, - &config.backend_base, - &config.entropy, - &config.username_base, - config.reserved_username.as_deref(), - signed_at, - ®istration, - ) - .await?; + submit_registration(&client, config, signed_at, ®istration).await?; let identity = wait_for_dotns_username(&mut reader, ®istration.candidate_public_key).await?; debug!("lite username registered and confirmed on-chain"); @@ -359,10 +364,10 @@ pub async fn attest(config: &AttestConfig) -> Result { /// Resolves the on-chain lite username for an already-attested signer: the /// discriminated `name.NN` the dotNS contracts hold for its identity account, /// whatever base the account record asked for. -pub async fn registered_lite_username(asset_hub_ws: &str, entropy: &[u8]) -> Result { - let identity = derive_identity_keypair(entropy) - .map_err(|err| anyhow::anyhow!("uid.dot identity derivation failed: {err}"))?; - let mut reader = AssetHubReader::connect(asset_hub_ws).await?; +pub async fn registered_lite_username(network: NetworkConfig, entropy: &[u8]) -> Result { + let identity = derive_identity_keypair(entropy, network.network_suffix) + .map_err(|err| anyhow::anyhow!("uid identity derivation failed: {err}"))?; + let mut reader = AssetHubReader::connect(network.asset_hub_ws).await?; reader .dotns_identity(&identity.public.to_bytes()) .await? @@ -373,12 +378,12 @@ pub async fn registered_lite_username(asset_hub_ws: &str, entropy: &[u8]) -> Res /// Resolve an existing full-person or Lite dotNS identity when one exists. /// An unlabeled account is a valid result and can still back a local session. pub async fn lookup_registered_username( - asset_hub_ws: &str, + network: NetworkConfig, entropy: &[u8], ) -> Result> { - let identity = derive_identity_keypair(entropy) - .map_err(|err| anyhow::anyhow!("uid.dot identity derivation failed: {err}"))?; - let mut reader = AssetHubReader::connect(asset_hub_ws).await?; + let identity = derive_identity_keypair(entropy, network.network_suffix) + .map_err(|err| anyhow::anyhow!("uid identity derivation failed: {err}"))?; + let mut reader = AssetHubReader::connect(network.asset_hub_ws).await?; let identity = reader.dotns_identity(&identity.public.to_bytes()).await?; Ok(identity.full_username.or(identity.lite_username)) } @@ -403,10 +408,10 @@ struct UsernameSearchItem { /// The backend does not currently expose an account-indexed route. Its search /// route is cursor-paginated and prefix-only, so imports search each valid /// initial letter and retain only rows whose candidate account is the mnemonic's -/// canonical `uid.dot` identity. The bearer token exempts these calls from the -/// unauthenticated proof-of-compute challenge. +/// `uid.` identity on this network. The bearer token exempts these calls +/// from the unauthenticated proof-of-compute challenge. pub async fn lookup_backend_username( - backend_base: &str, + network: NetworkConfig, auth_entropy: &[u8], candidate_account_id: &str, ) -> Result> { @@ -419,7 +424,7 @@ pub async fn lookup_backend_username( .map(|initial| { search_backend_prefix( &client, - backend_base, + network, auth_entropy, candidate_account_id, char::from(initial), @@ -442,13 +447,14 @@ pub async fn lookup_backend_username( async fn search_backend_prefix( client: &reqwest::Client, - backend_base: &str, + network: NetworkConfig, auth_entropy: &[u8], candidate_account_id: &str, initial: char, ) -> Result> { const PAGE_LIMIT: &str = "1000"; + let backend_base = network.identity_backend_base; let url = format!("{backend_base}/usernames/search"); let prefix = initial.to_string(); let mut cursor = None; @@ -459,9 +465,13 @@ async fn search_backend_prefix( if let Some(cursor) = cursor.as_deref() { query.push(("cursor", cursor)); } - let response = send_with_backend_auth(client, backend_base, auth_entropy, |token| { - client.get(&url).bearer_auth(token).query(&query) - }) + let response = send_with_backend_auth( + client, + backend_base, + auth_entropy, + network.network_suffix, + |token| client.get(&url).bearer_auth(token).query(&query), + ) .await .with_context(|| format!("GET {url} for prefix {prefix:?}"))?; let status = response.status(); @@ -513,22 +523,23 @@ fn normalize_searched_username(username: &str) -> String { format!("{base}.{digits:02}") } -/// Probes the dotNS contracts for the bare root and canonical RFC-0022 `uid.dot` -/// identity account. Prints any recorded usernames. Used to confirm a -/// pre-onboarded account. -pub async fn check_identity(asset_hub_ws: &str, entropy: &[u8]) -> Result<()> { +/// Probes the dotNS contracts for the bare root and the network's RFC-0022 +/// `uid.` identity account. Prints any recorded usernames. Used to +/// confirm a pre-onboarded account. +pub async fn check_identity(network: NetworkConfig, entropy: &[u8]) -> Result<()> { let root = derive_root_keypair_from_entropy(entropy) .map_err(|err| anyhow::anyhow!("invalid entropy: {err}"))?; - let identity = derive_identity_keypair(entropy) - .map_err(|err| anyhow::anyhow!("uid.dot identity derivation failed: {err}"))?; - let mut reader = AssetHubReader::connect(asset_hub_ws).await?; + let identity = derive_identity_keypair(entropy, network.network_suffix) + .map_err(|err| anyhow::anyhow!("uid identity derivation failed: {err}"))?; + let mut reader = AssetHubReader::connect(network.asset_hub_ws).await?; + let identity_path = format!( + "//product//{}/index_bytes(0)", + identity_product_id(network.network_suffix) + ); for (label, public) in [ ("", root.public.to_bytes()), - ( - "//product//uid.dot/index_bytes(0)", - identity.public.to_bytes(), - ), + (identity_path.as_str(), identity.public.to_bytes()), ] { let address = product_public_key_to_address(public); match reader.dotns_identity(&public).await { @@ -565,25 +576,25 @@ async fn fetch_verifier(client: &reqwest::Client, backend_base: &str) -> Result< .map_err(|bytes| anyhow::anyhow!("attester must be 32 bytes, got {}", bytes.len())) } +/// `POST /usernames` for `reg`, authenticated as the candidate account the +/// registration was built for (`config.entropy` on `config.network_suffix`). async fn submit_registration( client: &reqwest::Client, - backend_base: &str, - auth_entropy: &[u8], - username_base: &str, - reserved_username: Option<&str>, + config: &AttestConfig, signed_at: u64, reg: &truapi_server::host_logic::attestation::LiteRegistration, ) -> Result<()> { + let backend_base = config.backend_base.as_str(); let url = format!("{backend_base}/usernames"); let mut dotns = json!({ "signature": hex0x(®.dotns_signature), "signedAt": signed_at, }); - if let Some(reserved) = reserved_username { + if let Some(reserved) = config.reserved_username.as_deref() { dotns["reservedUsername"] = json!(reserved); } let body = json!({ - "username": username_base, + "username": config.username_base, "candidateAccountId": reg.candidate_account_id, "candidateSignature": hex0x(®.candidate_signature), "ringVrfKey": hex0x(®.ring_vrf_key), @@ -592,9 +603,13 @@ async fn submit_registration( "consumerRegistrationSignature": hex0x(®.consumer_registration_signature), "dotns": dotns, }); - let response = send_with_backend_auth(client, backend_base, auth_entropy, |token| { - client.post(&url).bearer_auth(token).json(&body) - }) + let response = send_with_backend_auth( + client, + backend_base, + &config.entropy, + &config.network_suffix, + |token| client.post(&url).bearer_auth(token).json(&body), + ) .await .with_context(|| format!("POST {url}"))?; let status = response.status(); @@ -713,20 +728,20 @@ mod tests { let server = tokio::spawn(serve_candidate_bound_registration(listener)); let entropy = [7u8; 16]; - let registration = build_lite_registration(&entropy, [9u8; 32], "testing", None, 123)?; + let registration = + build_lite_registration(&entropy, "paseo", [9u8; 32], "testing", None, 123)?; let client = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build()?; - let result = submit_registration( - &client, - &backend_base, - &entropy, - "testing", - None, - 123, - ®istration, - ) - .await; + let config = AttestConfig { + backend_base: backend_base.clone(), + asset_hub_ws: String::new(), + network_suffix: "paseo".to_string(), + entropy: entropy.to_vec(), + username_base: "testing".to_string(), + reserved_username: None, + }; + let result = submit_registration(&client, &config, 123, ®istration).await; let requests = server.await??; assert_eq!(requests.len(), 3); @@ -872,7 +887,7 @@ mod tests { let backend_base = format!("http://{}/api/v1", listener.local_addr()?); let server = tokio::spawn(serve_auth_retry(listener)); let entropy = [11u8; 16]; - let auth_client_id = derive_identity_keypair(&entropy) + let auth_client_id = derive_identity_keypair(&entropy, "paseo") .map_err(|err| anyhow::anyhow!("derive test auth identity: {err}"))? .public .to_bytes(); @@ -893,7 +908,7 @@ mod tests { .timeout(Duration::from_secs(30)) .build()?; let url = format!("{backend_base}/protected"); - let response = send_with_backend_auth(&client, &backend_base, &entropy, |token| { + let response = send_with_backend_auth(&client, &backend_base, &entropy, "paseo", |token| { client.post(&url).bearer_auth(token).body("{}") }) .await?; diff --git a/rust/crates/truapi-host-cli/src/frame_server.rs b/rust/crates/truapi-host-cli/src/frame_server.rs index 564f9113e..79a7c13ca 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.network_suffix.to_string(), )?; let spawner: truapi_server::subscription::Spawner = Arc::new(|_| {}); Ok(Arc::new(SigningHostRuntime::new(platform, config, spawner))) diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 323672608..c76b4e667 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -545,7 +545,7 @@ async fn dispatch( let entropy = bip39::Mnemonic::parse(mnemonic.trim()) .context("invalid BIP-39 mnemonic")? .to_entropy(); - attestation::check_identity(network.config().asset_hub_ws, &entropy).await + attestation::check_identity(network.config(), &entropy).await } Command::RegisterName { mnemonic, @@ -612,7 +612,7 @@ async fn run_pgas_check( let entropy = bip39::Mnemonic::parse(mnemonic.trim()) .context("invalid BIP-39 mnemonic")? .to_entropy(); - let candidates = accounts::collection_candidates(&entropy); + let candidates = accounts::collection_candidates(&entropy, network.network_suffix); if submit && target.is_none() { bail!("--target is required with --submit; a claim has to credit an account"); @@ -775,7 +775,7 @@ async fn run_alloc_check( let entropy = bip39::Mnemonic::parse(mnemonic.trim()) .context("invalid BIP-39 mnemonic")? .to_entropy(); - let candidates = accounts::collection_candidates(&entropy); + let candidates = accounts::collection_candidates(&entropy, network.network_suffix); if submit && target.is_none() { bail!("--target is required with --submit; the all-zero default is read-only"); @@ -1427,9 +1427,7 @@ async fn start_signing_host( reserved_username: None, }) .await?; - match attestation::registered_lite_username(network.asset_hub_ws, &explicit_signer.entropy) - .await - { + match attestation::registered_lite_username(network, &explicit_signer.entropy).await { Ok(user_id) => explicit_signer.lite_username = Some(user_id), Err(error) => { tracing::warn!(%error, "explicit signer has no resolvable dotNS username") @@ -1530,6 +1528,7 @@ fn build_signing_runtime( platform_info(), network.people_genesis, network.bulletin_genesis, + network.network_suffix.to_string(), ) .context("invalid signing host config")?; let status_host = platform.clone() as Arc; diff --git a/rust/crates/truapi-host-cli/src/network.rs b/rust/crates/truapi-host-cli/src/network.rs index b2d050045..533e560da 100644 --- a/rust/crates/truapi-host-cli/src/network.rs +++ b/rust/crates/truapi-host-cli/src/network.rs @@ -36,6 +36,7 @@ impl Network { match self { Self::PaseoNextV2 => NetworkConfig { id: "paseo-next-v2", + network_suffix: "paseo", identity_backend_base: "https://identity.dotspark.app/api/v1", people_ws: PASEO_PEOPLE.ws, bulletin_ws: PASEO_BULLETIN.ws, @@ -47,6 +48,7 @@ impl Network { }, Self::Previewnet => NetworkConfig { id: "previewnet", + network_suffix: "testnet", identity_backend_base: "https://identity-previewnet.dotspark.app/api/v1", people_ws: PREVIEWNET_PEOPLE.ws, bulletin_ws: PREVIEWNET_BULLETIN.ws, @@ -137,6 +139,13 @@ const PREVIEWNET_CHAIN_ENDPOINTS: &[ChainEndpoint] = #[derive(Debug, Clone, Copy)] pub struct NetworkConfig { pub id: &'static str, + /// The network's dotNS TLD without the dot, as its runtimes report it in + /// `NetworkSuffix.NetworkSuffix`. Every reserved RFC-0022 identity the CLI + /// derives ends in it (`uid.`, `peopl.`), so a person the + /// CLI creates here is the same person a phone derives from that seed on + /// this network. `live_people_chain::network_suffix_matches_the_preset` + /// holds it against the chain. + pub network_suffix: &'static str, pub identity_backend_base: &'static str, pub people_ws: &'static str, #[allow(dead_code)] @@ -413,6 +422,20 @@ mod tests { "identity-previewnet.dotspark.app", ]; + #[test] + fn every_preset_names_a_known_dotns_tld() { + for network in Network::value_variants() { + let config = network.preset(); + assert!( + truapi_platform::DOTNS_TLDS.contains(&config.network_suffix), + "preset `{}` derives reserved identities under `.{}`, a TLD navigation does not \ + accept", + config.id, + config.network_suffix, + ); + } + } + #[test] fn every_preset_is_a_test_network() { for network in Network::value_variants() { diff --git a/rust/crates/truapi-host-cli/src/register_name.rs b/rust/crates/truapi-host-cli/src/register_name.rs index 61c3cd26d..b7dc2d022 100644 --- a/rust/crates/truapi-host-cli/src/register_name.rs +++ b/rust/crates/truapi-host-cli/src/register_name.rs @@ -1,8 +1,9 @@ //! Full-person username registration through `DotnsGateway.register_name`. //! //! Builds and submits the v5 general transaction on Asset Hub. The signer's -//! RFC-0022 `uid.dot` account is the call's `who`. The full-person bandersnatch -//! key proves People-ring membership bound to the dotNS gateway context. A fresh +//! RFC-0022 `uid.` account is the call's `who`. The full-person +//! bandersnatch key proves People-ring membership bound to the dotNS gateway +//! context. A fresh //! sr25519 signature over the inherited-implication digest travels in the //! `AsDotnsGateway` extension. //! @@ -63,8 +64,8 @@ pub async fn register_name(config: &RegisterNameConfig) -> Result<()> { { bail!("--link-lite {lite:?} is not a dotted lite username (`name.NN`)"); } - let who = derive_identity_keypair(&config.entropy) - .map_err(|err| anyhow::anyhow!("uid.dot identity derivation failed: {err}"))?; + let who = derive_identity_keypair(&config.entropy, config.network.network_suffix) + .map_err(|err| anyhow::anyhow!("uid identity derivation failed: {err}"))?; let who_public = who.public.to_bytes(); let mut reader = AssetHubReader::connect(config.network.asset_hub_ws).await?; @@ -101,7 +102,8 @@ pub async fn register_name(config: &RegisterNameConfig) -> Result<()> { .context("connect People RPC")?; let people_metadata = alloc::fetch_metadata(&people_rpc).await?; let at = people_rpc.finalized_head().await?; - let full_entropy = derive_full_person_ring_vrf_entropy(&config.entropy); + let full_entropy = + derive_full_person_ring_vrf_entropy(&config.entropy, config.network.network_suffix); let member = proof::member_key(full_entropy); let ring_index = ring::read_member_ring_index_at( &people_rpc, diff --git a/rust/crates/truapi-host-cli/tests/live_people_chain.rs b/rust/crates/truapi-host-cli/tests/live_people_chain.rs index 0577fefa9..0c7391d1a 100644 --- a/rust/crates/truapi-host-cli/tests/live_people_chain.rs +++ b/rust/crates/truapi-host-cli/tests/live_people_chain.rs @@ -21,6 +21,10 @@ use truapi_server::statement_allowance::{self as alloc, ChainContextCache}; /// Default People-chain endpoint, kept in step with `network.rs`. const DEFAULT_PEOPLE_WS: &str = "wss://paseo-people-next-system-rpc.polkadot.io"; +/// The `paseo-next-v2` preset's `network_suffix`, kept in step with `network.rs` +/// (the binary crate exposes no library for tests to read it from). +const DEFAULT_NETWORK_SUFFIX: &str = "paseo"; + /// A genesis hash no chain will report, standing in for a host whose configured /// constant has gone stale after a testnet wipe. const STALE_CONFIGURED_GENESIS: [u8; 32] = [0xff; 32]; @@ -49,6 +53,24 @@ fn current_period() -> u32 { alloc::slot::current_period(now) } +/// The preset's network suffix is what the reserved `uid.` and +/// `peopl.` derivations end in, so it has to be the suffix the chain +/// itself scopes People contexts with, or the CLI derives a person no other +/// host on this network recognises. +#[tokio::test] +#[ignore = "needs network access to a live People chain"] +async fn network_suffix_matches_the_preset() { + let rpc = connect().await; + let live = alloc::slot::read_network_suffix(&rpc) + .await + .expect("read the live network suffix"); + assert_eq!( + String::from_utf8(live).expect("network suffix is UTF-8"), + DEFAULT_NETWORK_SUFFIX, + "the paseo-next-v2 preset's network suffix must match the chain" + ); +} + /// The genesis hash signed into allowance extrinsics must be the one the chain /// reports, not the caller's constant, and the entry must still be keyed by that /// constant so the cache actually hits. diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 983d42f50..9c069da42 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -92,6 +92,13 @@ 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], + /// 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 + /// ring-VRF keys. The People chain scopes its proof contexts with the same + /// value (`NetworkSuffix.NetworkSuffix`), so the two never disagree about + /// which network a person belongs to. + pub network_suffix: String, } /// Product identity attached to one product-facing TrUAPI connection. @@ -216,15 +223,41 @@ impl SigningHostConfig { platform_info: PlatformInfo, people_chain_genesis_hash: [u8; 32], bulletin_chain_genesis_hash: [u8; 32], + network_suffix: String, ) -> Result { + validate_network_suffix(&network_suffix)?; Ok(Self { host: HostRuntimeConfig::new(host_info, platform_info)?, people_chain_genesis_hash, bulletin_chain_genesis_hash, + network_suffix, }) } } +/// Longest network suffix the People chain accepts +/// (`indiv_support::context::MAX_NETWORK_SUFFIX_LENGTH`). +const MAX_NETWORK_SUFFIX_LENGTH: usize = 16; + +/// A network suffix is one bare dotNS label: the part after the last dot of a +/// product id. It is hashed into key derivations verbatim, so anything that +/// could also spell a different product id (`peopl.dot.x` from a suffix of +/// `dot.x`, or `peopl.` from an empty one) is rejected here rather than +/// silently deriving a person nobody else recognises. +fn validate_network_suffix(network_suffix: &str) -> Result<(), RuntimeConfigValidationError> { + require_non_empty("network_suffix", network_suffix)?; + let well_formed = network_suffix.len() <= MAX_NETWORK_SUFFIX_LENGTH + && network_suffix + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()); + if !well_formed { + return Err(RuntimeConfigValidationError::InvalidNetworkSuffix { + network_suffix: network_suffix.to_string(), + }); + } + Ok(()) +} + impl ProductContext { /// Build a product context, validating fields whose representation cannot /// be made invalid by Rust types alone. @@ -855,6 +888,15 @@ pub enum RuntimeConfigValidationError { /// Actual product id value. product_id: String, }, + /// Network suffix was not one bare lowercase dotNS label of at most 16 + /// bytes. + #[display( + "network_suffix must be a bare lowercase dotNS TLD of at most 16 bytes, got {network_suffix:?}" + )] + InvalidNetworkSuffix { + /// Actual network suffix value. + network_suffix: String, + }, } const PRODUCT_STORAGE_KEY_PREFIX: &str = "truapi:product-storage:v1:"; @@ -1475,6 +1517,59 @@ fn canonical_remote_request(request: &RemotePermissionRequest) -> RemotePermissi mod tests { use super::*; + fn signing_host_config( + network_suffix: &str, + ) -> Result { + SigningHostConfig::new( + HostInfo { + name: "Test host".to_string(), + icon: None, + version: None, + platform: HostPlatform::Unknown, + }, + PlatformInfo::default(), + [0; 32], + [1; 32], + network_suffix.to_string(), + ) + } + + #[test] + fn a_signing_host_is_configured_for_one_dotns_tld() { + // Every TLD navigation accepts is a valid suffix, and nothing is + // assumed about which one a host runs against. + for tld in DOTNS_TLDS { + let config = signing_host_config(tld).expect("a known TLD is a valid suffix"); + assert_eq!(config.network_suffix, *tld); + } + + // The suffix is hashed into the reserved derivations as `peopl.`, + // so anything that does not read as one bare label is refused rather + // than deriving keys under a name that is not a product id. + assert_eq!( + signing_host_config(""), + Err(RuntimeConfigValidationError::EmptyField { + field: "network_suffix" + }) + ); + for malformed in [ + ".paseo", + "peopl.paseo", + "Paseo", + "pas eo", + "a-b", + "abcdefghijklmnopq", + ] { + assert_eq!( + signing_host_config(malformed), + Err(RuntimeConfigValidationError::InvalidNetworkSuffix { + network_suffix: malformed.to_string() + }), + "{malformed:?} must be rejected" + ); + } + } + fn file_with_url(url: &str) -> ChatMessageContent { ChatMessageContent::File(ChatFile { url: url.to_string(), diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index fc7c68e02..2d7d20aad 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -220,7 +220,7 @@ role-specific lifecycle, so no method exists on a role that can't mean it: - **`SigningHost`** (wallet-local): signs on device from local BIP-39 entropy, no pairing flow. `signing_host/local_activation.rs` establishes a session from host-held secret material. Its public identity is the RFC-0022 - `uid.dot` index-0 product account. RFC-0024 ring-VRF keys are explicit, + `uid.` index-0 product account of the configured network. RFC-0024 ring-VRF keys are explicit, product-owned registry entries; aliases, proofs, direct signatures, and internal personhood flows use the requested or user-selected registered key without a compiled-in fallback. It resolves RFC-0004 `RingLocation` values diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 1b037d2a0..c0863017e 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -574,7 +574,7 @@ impl SigningHostRuntime { spawner, chat_platform, ); - let signing_host = SigningHostRole::new(services.clone()); + let signing_host = SigningHostRole::new(services.clone(), config.network_suffix); Self { services, signing_host, @@ -2262,6 +2262,7 @@ mod tests { PlatformInfo::default(), [0; 32], [0xbb; 32], + "paseo".to_string(), ) .expect("signing host config is valid"); let runtime = @@ -2308,6 +2309,7 @@ mod tests { PlatformInfo::default(), [0; 32], [0xbb; 32], + "paseo".to_string(), ) .expect("signing host config is valid"); let runtime = diff --git a/rust/crates/truapi-server/src/host_logic/attestation.rs b/rust/crates/truapi-server/src/host_logic/attestation.rs index f047c070b..7e7d23a88 100644 --- a/rust/crates/truapi-server/src/host_logic/attestation.rs +++ b/rust/crates/truapi-server/src/host_logic/attestation.rs @@ -71,8 +71,8 @@ pub struct LiteRegistration { /// Error while building lite-person registration parameters. #[derive(Debug, Error)] pub enum LiteRegistrationError { - /// RFC-0022 `uid.dot` identity-account derivation failed. - #[error("uid.dot identity derivation failed: {0}")] + /// RFC-0022 `uid.` identity-account derivation failed. + #[error("uid identity derivation failed: {0}")] CandidateDerivation(#[from] ProductAccountError), /// Ring-VRF proof-of-ownership failed. #[error("ring-VRF proof-of-ownership failed: {0:?}")] @@ -85,23 +85,27 @@ pub enum LiteRegistrationError { /// Build the lite-person registration parameters for `username_base` /// (6+ lowercase letters, no digit suffix) against the backend `verifier`. /// +/// `network_suffix` is the dotNS TLD of the network being registered on +/// (`paseo`, `testnet`): the candidate account is `uid.` and the member +/// key `peopl.`, the same person every other host derives there. /// `reserved_username` optionally queues a base name for a later full-person /// claim on dotNS. `dotns_signed_at_secs` must be Asset Hub chain time, meaning /// `Timestamp.Now` in seconds. The local wall clock will not do: the gateway /// rejects signatures more than 30 seconds in the chain's future. pub fn build_lite_registration( entropy: &[u8], + network_suffix: &str, verifier_account_id: [u8; 32], username_base: &str, reserved_username: Option<&str>, dotns_signed_at_secs: u64, ) -> Result { // Registration, local activation, and the SSO responder all use the - // RFC-0022 `uid.dot` default product account. - let candidate = derive_identity_keypair(entropy)?; + // RFC-0022 `uid.` default product account. + let candidate = derive_identity_keypair(entropy, network_suffix)?; let candidate_public_key = candidate.public.to_bytes(); - let vrf_entropy = derive_lite_person_ring_vrf_entropy(entropy); + let vrf_entropy = derive_lite_person_ring_vrf_entropy(entropy, network_suffix); let vrf_secret = BandersnatchVrfVerifiable::new_secret(vrf_entropy); let ring_vrf_key = BandersnatchVrfVerifiable::member_from_secret(&vrf_secret); @@ -199,25 +203,42 @@ mod tests { use schnorrkel::{PublicKey, Signature}; const ENTROPY: [u8; 16] = [0xAB; 16]; + const NETWORK_SUFFIX: &str = "paseo"; #[test] fn registration_params_have_expected_shapes_and_verify() { let verifier = [0x11u8; 32]; - let reg = - build_lite_registration(&ENTROPY, verifier, "headlesstester", None, 1_749_573_123) - .unwrap(); + let reg = build_lite_registration( + &ENTROPY, + NETWORK_SUFFIX, + verifier, + "headlesstester", + None, + 1_749_573_123, + ) + .unwrap(); assert_eq!( reg.candidate_public_key, - derive_identity_keypair(&ENTROPY).unwrap().public.to_bytes(), - "registration uses the canonical uid.dot identity account" + derive_identity_keypair(&ENTROPY, NETWORK_SUFFIX) + .unwrap() + .public + .to_bytes(), + "registration uses the network's uid.paseo identity account" ); - let lite_entropy = derive_lite_person_ring_vrf_entropy(&ENTROPY); + let lite_entropy = derive_lite_person_ring_vrf_entropy(&ENTROPY, NETWORK_SUFFIX); assert_eq!( reg.ring_vrf_key, BandersnatchVrfVerifiable::member_from_secret(&BandersnatchVrfVerifiable::new_secret( lite_entropy )), - "registration uses the canonical peopl.dot index-1 member" + "registration uses the network's peopl.paseo index-1 member" + ); + assert_ne!( + reg.ring_vrf_key, + BandersnatchVrfVerifiable::member_from_secret(&BandersnatchVrfVerifiable::new_secret( + derive_lite_person_ring_vrf_entropy(&ENTROPY, "dot") + )), + "a person registered on paseo-next-v2 is not the seed's .dot person" ); assert_eq!(reg.identifier_key[0], 0x04, "P-256 uncompressed prefix"); @@ -293,6 +314,7 @@ mod tests { let verifier = [0x33u8; 32]; let reg = build_lite_registration( &ENTROPY, + NETWORK_SUFFIX, verifier, "headlesstester", Some("reservedbase"), @@ -363,8 +385,12 @@ mod tests { #[test] fn registration_is_deterministic_per_entropy_and_username() { let verifier = [0x22u8; 32]; - let first = build_lite_registration(&ENTROPY, verifier, "aliceheadless", None, 1).unwrap(); - let again = build_lite_registration(&ENTROPY, verifier, "aliceheadless", None, 1).unwrap(); + let first = + build_lite_registration(&ENTROPY, NETWORK_SUFFIX, verifier, "aliceheadless", None, 1) + .unwrap(); + let again = + build_lite_registration(&ENTROPY, NETWORK_SUFFIX, verifier, "aliceheadless", None, 1) + .unwrap(); assert_eq!(first.candidate_public_key, again.candidate_public_key); assert_eq!(first.ring_vrf_key, again.ring_vrf_key); assert_eq!(first.candidate_account_id, again.candidate_account_id); diff --git a/rust/crates/truapi-server/src/host_logic/product_account.rs b/rust/crates/truapi-server/src/host_logic/product_account.rs index e4c18ea00..d60fdf2e6 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -3,9 +3,13 @@ //! Product subtrees use hard HDKD at `//product//{product_id}`. Individual //! accounts use one soft junction carrying the RFC-0022 32-byte derivation //! index, so a paired host can derive children from the subtree public key. -//! Reserved built-ins additionally pin the `uid.dot` identity account and the -//! legacy `peopl.dot` full/lite ring-VRF keyed-hash paths used by pairing -//! attestation. RFC-0024 operational key selection comes from the registry. +//! Reserved built-ins additionally pin the `uid.` identity account and +//! the `peopl.` full/lite ring-VRF keyed-hash paths used by pairing +//! attestation, where `` is the network's dotNS TLD (`dot`, `paseo`, +//! `testnet`) from [`truapi_platform::SigningHostConfig::network_suffix`]. The +//! People chain scopes its proof contexts with the same suffix, so one +//! network has one person per seed. RFC-0024 operational key selection comes +//! from the registry. //! Host-spec C.5-C.7 define the product-account derivation, SS58 address, and //! `ProductAccountId` shape: //! @@ -18,12 +22,31 @@ use thiserror::Error; const JUNCTION_ID_LEN: usize = 32; const PRODUCT_JUNCTION: &str = "product"; -/// Reserved RFC-0022 product id for the public light-person identity account. -pub const IDENTITY_PRODUCT_ID: &str = "uid.dot"; -/// Reserved RFC-0022 ring-VRF domain for full and light personhood. -pub const PERSONHOOD_PRODUCT_ID: &str = "peopl.dot"; +/// Reserved RFC-0022 dotNS label of the public light-person identity account; +/// the product id is `uid.`, see [`identity_product_id`]. +pub const IDENTITY_LABEL: &str = "uid"; +/// Reserved RFC-0022 dotNS label of the personhood product, whose ring-VRF +/// domain holds the full and light person keys; the product id is +/// `peopl.`, see [`personhood_product_id`]. +pub const PERSONHOOD_LABEL: &str = "peopl"; const RING_VRF_ROOT_KEY: &[u8] = b"ring-vrf"; +/// The reserved identity product id on the network with `network_suffix`: +/// `uid.dot` on Polkadot, `uid.paseo` on paseo-next-v2, `uid.testnet` on +/// previewnet. +pub fn identity_product_id(network_suffix: &str) -> String { + format!("{IDENTITY_LABEL}.{network_suffix}") +} + +/// The reserved personhood product id on the network with `network_suffix`: +/// `peopl.dot` on Polkadot, `peopl.paseo` on paseo-next-v2, `peopl.testnet` on +/// previewnet. It is the product id a personhood app is opened under on that +/// network, so the keys derived here are the ones such an app owns through +/// the RFC-0024 registry. +pub fn personhood_product_id(network_suffix: &str) -> String { + format!("{PERSONHOOD_LABEL}.{network_suffix}") +} + /// Substrate sr25519 signing-context string. Shared by every sr25519 signature /// the core produces: statement store, product raw signing, dotNS gateway. pub const SR25519_SIGNING_CONTEXT: &[u8] = b"substrate"; @@ -85,32 +108,46 @@ pub fn derivation_index_bytes(index: &truapi::v01::DerivationIndex) -> [u8; 32] truapi::v01::DerivationIndex::Raw(bytes) => *bytes, } } -/// Derive the RFC-0022 public light-person identity account: -/// `//product//uid.dot/index_bytes(0)`. -pub fn derive_identity_keypair(entropy: &[u8]) -> Result { +/// Derive the RFC-0022 public light-person identity account on the network +/// with `network_suffix`: `//product//uid./index_bytes(0)`. +pub fn derive_identity_keypair( + entropy: &[u8], + network_suffix: &str, +) -> Result { let root = derive_root_keypair_from_entropy(entropy)?; - let subtree = derive_hard_path_from_keypair(root, &[PRODUCT_JUNCTION, IDENTITY_PRODUCT_ID])?; + let subtree = derive_hard_path_from_keypair( + root, + &[PRODUCT_JUNCTION, &identity_product_id(network_suffix)], + )?; Ok(subtree.derived_key_simple(ChainCode(index_bytes(0)), []).0) } -/// Derive the RFC-0022 full-person ring-VRF entropy at -/// `hash(root_entropy, "ring-vrf")//peopl.dot//index_bytes(0)`. -pub fn derive_full_person_ring_vrf_entropy(root_entropy: &[u8]) -> [u8; 32] { - derive_person_ring_vrf_entropy(root_entropy, 0) +/// Derive the RFC-0022 full-person ring-VRF entropy on the network with +/// `network_suffix`: +/// `hash(root_entropy, "ring-vrf")//peopl.//index_bytes(0)`. +pub fn derive_full_person_ring_vrf_entropy(root_entropy: &[u8], network_suffix: &str) -> [u8; 32] { + derive_person_ring_vrf_entropy(root_entropy, network_suffix, 0) } -/// Derive the RFC-0022 light-person ring-VRF entropy at -/// `hash(root_entropy, "ring-vrf")//peopl.dot//index_bytes(1)`. -pub fn derive_lite_person_ring_vrf_entropy(root_entropy: &[u8]) -> [u8; 32] { - derive_person_ring_vrf_entropy(root_entropy, 1) +/// Derive the RFC-0022 light-person ring-VRF entropy on the network with +/// `network_suffix`: +/// `hash(root_entropy, "ring-vrf")//peopl.//index_bytes(1)`. +pub fn derive_lite_person_ring_vrf_entropy(root_entropy: &[u8], network_suffix: &str) -> [u8; 32] { + derive_person_ring_vrf_entropy(root_entropy, network_suffix, 1) } -fn derive_person_ring_vrf_entropy(root_entropy: &[u8], index: u32) -> [u8; 32] { +fn derive_person_ring_vrf_entropy( + root_entropy: &[u8], + network_suffix: &str, + index: u32, +) -> [u8; 32] { derive_ring_vrf_entropy( root_entropy, - PERSONHOOD_PRODUCT_ID, + &personhood_product_id(network_suffix), &truapi::v01::DerivationIndex::Index(index), ) + // The only failing junction is an all-digit string outside `u64`, and + // this one always starts with the `peopl.` label. .expect("the reserved personhood product id is a valid junction") } @@ -414,15 +451,64 @@ mod tests { "372b08255c7798fe3193756296005adc4c44adb9f3986fb718aa98a48b4bf725" ); assert_eq!( - hex::encode(derive_full_person_ring_vrf_entropy(&root_entropy)), + hex::encode(derive_full_person_ring_vrf_entropy(&root_entropy, "dot")), "c47086f94a7f4c05b7afd9f2339d3fea168f3823b5424ba1f7b31043d8ef60af" ); assert_eq!( - hex::encode(derive_lite_person_ring_vrf_entropy(&root_entropy)), + hex::encode(derive_lite_person_ring_vrf_entropy(&root_entropy, "dot")), "8d7f5e1510a7e8d813887e100f5a260ec9de60e68695477b93360ee7e3d16a9f" ); } + #[test] + fn person_ring_vrf_entropy_follows_the_network_suffix() { + // Same seed, one person per network. The vectors come from an + // independent RFC-0022 implementation (`@web3-citizenship/accounts` + // `fullPersonRingVrfEntropy(entropy, tld)`), which is also what the + // iOS host derives; a `.dot` key is never a `.paseo` key. + let root_entropy: Vec = (1..=32).collect(); + let vectors = [ + ( + "paseo", + "50a2adfe7b557a72521789d3961795e71619ac8a19f9cb42b581f3fb703d7453", + "a0e869e303f9828ccfd006682798ab0e91c29ce3f1ce3d93c640449be4a157dc", + ), + ( + "testnet", + "b46833f2492571d719072e0a04478433cc8f836fc626c0690bc13e00bd7d1547", + "574bdaa99061a6a88442c3d90207de487a753fc7da34f960e305e9d9152bb060", + ), + ]; + for (suffix, full, lite) in vectors { + assert_eq!( + hex::encode(derive_full_person_ring_vrf_entropy(&root_entropy, suffix)), + full, + "full person key under peopl.{suffix}" + ); + assert_eq!( + hex::encode(derive_lite_person_ring_vrf_entropy(&root_entropy, suffix)), + lite, + "light person key under peopl.{suffix}" + ); + // The reserved keys are exactly what a `peopl.` product + // registers through RFC-0024 at indexes 0 and 1: no special case. + let product_id = personhood_product_id(suffix); + for (index, expected) in [(0u32, full), (1, lite)] { + assert_eq!( + hex::encode( + derive_ring_vrf_entropy( + &root_entropy, + &product_id, + &truapi::v01::DerivationIndex::Index(index), + ) + .unwrap() + ), + expected + ); + } + } + } + #[test] fn ring_vrf_domain_entropy_derives_the_same_registered_key_as_the_root() { use truapi::v01::DerivationIndex; @@ -440,14 +526,18 @@ mod tests { #[test] fn identity_is_uid_dot_default_product_account_and_signs() { let entropy = [0xAB; 16]; - let identity = derive_identity_keypair(&entropy).unwrap(); + let identity = derive_identity_keypair(&entropy, "dot").unwrap(); let root = derive_root_keypair_from_entropy(&entropy).unwrap(); let uid_subtree = - derive_hard_path_from_keypair(root, &[PRODUCT_JUNCTION, IDENTITY_PRODUCT_ID]).unwrap(); + derive_hard_path_from_keypair(root, &[PRODUCT_JUNCTION, "uid.dot"]).unwrap(); let expected = uid_subtree .derived_key_simple(ChainCode(index_bytes(0)), []) .0; assert_eq!(identity.public, expected.public); + assert_eq!( + product_public_key_to_address(identity.public.to_bytes()), + "5ESC9GvvMe6troagKBHZHNAqw7kcpXafF9NvTgnnwCJATi8Z" + ); let message = b"RFC-0022 identity signing vector"; let signature = @@ -462,6 +552,38 @@ mod tests { ); } + #[test] + fn identity_account_follows_the_network_suffix() { + // `//product//uid./index_bytes(0)`, one ordinary product + // account per network; addresses from the same independent + // implementation as the ring-VRF vectors above. + let entropy = [0xAB; 16]; + for (suffix, address) in [ + ("paseo", "5CtVJYWUwK7WksAU9CDm6u58ucgKkmWPE26TKhjjwiJKo2hW"), + ( + "testnet", + "5DhuA7ba5CxLtFjuU4YkAeCoCCH1YV1jjUUjFf3W5VSEmRxx", + ), + ] { + let identity = derive_identity_keypair(&entropy, suffix).unwrap(); + assert_eq!( + product_public_key_to_address(identity.public.to_bytes()), + address, + "identity account under uid.{suffix}" + ); + let subtree = derive_product_subtree_keypair( + &derive_root_keypair_from_entropy(&entropy).unwrap(), + &identity_product_id(suffix), + ) + .unwrap(); + assert_eq!( + derive_product_public_key(subtree.public.to_bytes(), index_bytes(0)).unwrap(), + identity.public.to_bytes(), + "the identity account is the product account at index 0" + ); + } + } + #[test] fn raw_index_space_is_disjoint_from_plain_indexes() { // A raw all-zero index must not collide with plain index 0: the magic diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 415469e97..b97eb37ee 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -188,6 +188,13 @@ pub struct NativeHostRuntimeConfig { pub people_chain_genesis_hash: Vec, /// Bulletin-chain genesis hash. Must be exactly 32 bytes. pub bulletin_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 + /// ring-VRF keys. Read it from the network the host is configured for, the + /// way the host's own onboarding does; a wrong value derives a different + /// person from the same seed. + pub network_suffix: String, /// Optional local signing-host secret material (raw BIP-39 entropy). pub local_session_secret: Option>, /// Optional lite username attached to the local signing-host session. @@ -249,6 +256,15 @@ pub enum NativeRuntimeConfigError { /// Actual deeplink scheme value. scheme: String, }, + /// Network suffix was not one bare lowercase dotNS label of at most 16 + /// bytes. + #[error( + "network_suffix must be a bare lowercase dotNS TLD of at most 16 bytes, got {network_suffix:?}" + )] + InvalidNetworkSuffix { + /// Actual network suffix value. + network_suffix: String, + }, /// Product id was not a valid host-spec product identifier. #[error("invalid product_id: {product_id}")] InvalidProductId { @@ -292,6 +308,7 @@ impl TryFrom for NativeResolvedHostRuntimeConfig { }, people_chain_genesis_hash, bulletin_chain_genesis_hash, + config.network_suffix, )?; Ok(Self { signing, @@ -330,6 +347,9 @@ impl From for NativeRuntimeConfigError { RuntimeConfigValidationError::InvalidProductId { product_id } => { Self::InvalidProductId { product_id } } + RuntimeConfigValidationError::InvalidNetworkSuffix { network_suffix } => { + Self::InvalidNetworkSuffix { network_suffix } + } } } } @@ -2163,6 +2183,7 @@ mod tests { platform_version: None, people_chain_genesis_hash: vec![0xa2; 32], bulletin_chain_genesis_hash: vec![0xbb; 32], + network_suffix: "paseo".to_string(), local_session_secret: Some(vec![7; 32]), local_session_lite_username: Some("alice".to_string()), } @@ -2984,6 +3005,21 @@ mod tests { )); } + #[test] + fn runtime_config_rejects_a_network_suffix_that_is_not_a_bare_tld() { + // The suffix ends every reserved derivation (`peopl.`), so a + // shell passing the dotted form would silently derive a stranger. + let err = NativeResolvedHostRuntimeConfig::try_from(NativeHostRuntimeConfig { + network_suffix: ".paseo".to_string(), + ..native_host_runtime_config() + }) + .unwrap_err(); + assert!(matches!( + err, + NativeRuntimeConfigError::InvalidNetworkSuffix { network_suffix } if network_suffix == ".paseo" + )); + } + #[test] fn product_execution_config_rejects_empty_product_id() { let err = ProductContext::try_from(NativeProductExecutionConfig { diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index d2f0c82d2..c61543307 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -70,6 +70,12 @@ use ring_vrf::{ }; use sso_replay::SsoReplayLocks; +/// The network suffix the unit tests configure their signing host for. `dot` +/// keeps the `peopl.dot` handles the RFC examples use meaningful; the +/// per-network behaviour has its own tests. +#[cfg(test)] +const TEST_NETWORK_SUFFIX: &str = "dot"; + use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, v01}; use truapi_platform::{ @@ -110,6 +116,10 @@ impl LocalGrantState { pub(crate) struct SigningHost { services: Arc, platform: Arc, + /// The dotNS TLD of the network this wallet serves, from + /// [`truapi_platform::SigningHostConfig::network_suffix`]. Every reserved + /// RFC-0022 derivation (`uid.`, `peopl.`) ends in it. + network_suffix: String, session_state: Arc, auth_state: AuthStateMachine, ring_resolver: Arc, @@ -128,13 +138,15 @@ pub(crate) struct SigningHost { } impl SigningHost { - /// Build a signing host with no active session. - pub(crate) fn new(services: Arc) -> Arc { + /// Build a signing host with no active session, serving the network whose + /// dotNS TLD is `network_suffix`. + pub(crate) fn new(services: Arc, network_suffix: String) -> Arc { let platform = services.platform.clone(); let ring_resolver = ChainRingResolver::new(services.chain.clone()); Arc::new(Self { services, platform: platform.clone(), + network_suffix, session_state: SessionState::new(), auth_state: AuthStateMachine::new(platform.clone()), ring_resolver, @@ -151,6 +163,15 @@ impl SigningHost { fn new_with_ring_resolver( platform: Arc, ring_resolver: Arc, + ) -> Arc { + Self::new_with_ring_resolver_on(platform, ring_resolver, TEST_NETWORK_SUFFIX) + } + + #[cfg(test)] + fn new_with_ring_resolver_on( + platform: Arc, + ring_resolver: Arc, + network_suffix: &str, ) -> Arc { let services = RuntimeServices::new( platform.clone(), @@ -167,6 +188,7 @@ impl SigningHost { Arc::new(Self { services, platform: platform.clone(), + network_suffix: network_suffix.to_string(), session_state: SessionState::new(), auth_state: AuthStateMachine::new(platform.clone()), ring_resolver, @@ -184,6 +206,12 @@ impl SigningHost { self.session_state.clone() } + /// The dotNS TLD of the network this wallet serves: the suffix of every + /// reserved identity it derives. + pub(super) fn network_suffix(&self) -> &str { + &self.network_suffix + } + /// Current root entropy, or [`AuthorityError::Disconnected`] when no local /// session is active. fn root_entropy(&self) -> Result>, AuthorityError> { @@ -320,7 +348,7 @@ impl SigningHost { fn identity_keypair(&self) -> Result { let entropy = self.root_entropy()?; - derive_identity_keypair(&entropy).map_err(product_authority_error) + derive_identity_keypair(&entropy, &self.network_suffix).map_err(product_authority_error) } fn install_local_session(&self, secret: Zeroizing>, session: SessionInfo) { @@ -398,9 +426,10 @@ impl SigningHost { /// Every personhood collection this wallet can derive allowance aliases for, /// widest slot budget first. /// - /// Wallet-internal allowance proofs use the reserved `peopl.dot` keys the - /// mobile hosts use. Product-facing RFC-0024 operations are unrelated: those - /// resolve only explicitly registered handles. + /// Wallet-internal allowance proofs use the reserved `peopl.` keys + /// the mobile hosts derive on the same network. Product-facing RFC-0024 + /// operations are unrelated: those resolve only explicitly registered + /// handles. /// /// Both entropies are always returned; which collections the person is /// actually a member of is settled on chain by looking for a ring that @@ -416,11 +445,11 @@ impl SigningHost { Ok(vec![ CollectionCandidate { collection: PersonhoodCollection::People, - entropy: derive_full_person_ring_vrf_entropy(&root), + entropy: derive_full_person_ring_vrf_entropy(&root, &self.network_suffix), }, CollectionCandidate { collection: PersonhoodCollection::LitePeople, - entropy: derive_lite_person_ring_vrf_entropy(&root), + entropy: derive_lite_person_ring_vrf_entropy(&root, &self.network_suffix), }, ]) } @@ -1264,6 +1293,7 @@ mod tests { SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; use super::super::{ProductAuthority, ProductRuntimeHost, RuntimeServices, SigningHostRole}; + use super::TEST_NETWORK_SUFFIX; use super::ring_vrf::{MemberCandidate, ResolvedRing, RingResolver, member_from_entropy}; use super::{ BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, RingVrfError, @@ -1341,6 +1371,7 @@ mod tests { PlatformInfo::default(), [0; 32], [0xbb; 32], + TEST_NETWORK_SUFFIX.to_string(), ) .expect("signing host config is valid"); let services = RuntimeServices::new( @@ -1350,7 +1381,7 @@ mod tests { config.bulletin_chain_genesis_hash, test_spawner(), ); - let signing_host = SigningHostRole::new(services.clone()); + let signing_host = SigningHostRole::new(services.clone(), config.network_suffix); (services, signing_host) } @@ -1477,6 +1508,59 @@ mod tests { assert_ne!(candidates[0].entropy, candidates[1].entropy); } + #[test] + fn reserved_identities_follow_the_configured_network_suffix() { + // A wallet on paseo-next-v2 is the `peopl.paseo` person and the + // `uid.paseo` account: the ones a `peopl.paseo` product registers and the + // ones the identity backend records a lite username for. The `.dot` + // derivations of the same seed are a different person. + let platform: Arc = Arc::new(StubPlatform::default()); + let authority = SigningHostRole::new_with_ring_resolver_on( + platform, + full_person_ring_resolver(), + "paseo", + ); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = authority.current_session().expect("active session"); + + let candidates = authority + .reserved_person_collection_candidates(&session) + .expect("reserved keys derive"); + for (candidate, index) in candidates.iter().zip([0u32, 1]) { + assert_eq!( + candidate.entropy, + derive_ring_vrf_entropy( + &ENTROPY, + "peopl.paseo", + &v01::DerivationIndex::Index(index) + ) + .expect("reserved RFC-0024 handle derives"), + "{} candidate does not use peopl.paseo/{index}", + candidate.collection + ); + assert_ne!( + candidate.entropy, + derive_ring_vrf_entropy(&ENTROPY, "peopl.dot", &v01::DerivationIndex::Index(index)) + .expect("reserved RFC-0024 handle derives"), + ); + } + + let identity = derive_identity_keypair(&ENTROPY, "paseo") + .expect("uid.paseo identity derivation") + .public + .to_bytes(); + assert_eq!(session.identity_account_id, Some(identity)); + assert_eq!( + authority + .identity_keypair() + .expect("identity") + .public + .to_bytes(), + identity + ); + } + #[test] fn ring_alias_and_proof_share_the_explicit_registered_key() { let resolver = full_person_ring_resolver(); @@ -1687,7 +1771,7 @@ mod tests { .expect("activation succeeds"); let session = authority.current_session().expect("active session"); - let identity = derive_identity_keypair(&ENTROPY) + let identity = derive_identity_keypair(&ENTROPY, TEST_NETWORK_SUFFIX) .expect("uid.dot identity derivation") .public .to_bytes(); @@ -2132,7 +2216,7 @@ mod tests { .expect("activation succeeds"); let session = authority.current_session().expect("active session"); let cx = CallContext::default(); - let identity = derive_identity_keypair(&ENTROPY).unwrap(); + let identity = derive_identity_keypair(&ENTROPY, TEST_NETWORK_SUFFIX).unwrap(); let request = |account| SignRawAuthorityRequest::LegacyAccount { account, request: v01::HostSignRawWithLegacyAccountRequest { diff --git a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs index 0ecb6a5b9..4a4f47ddb 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/allowance_renewal.rs @@ -244,6 +244,7 @@ fn target_label(target: &StatementRenewalTarget) -> String { fn resolve_target( entropy: &[u8], + network_suffix: &str, target: &StatementRenewalTarget, ) -> Result { let label = target_label(target); @@ -260,7 +261,8 @@ fn resolve_target( }) } StatementRenewalTarget::WalletSso => { - let pair = derive_identity_keypair(entropy).map_err(|err| err.to_string())?; + let pair = + derive_identity_keypair(entropy, network_suffix).map_err(|err| err.to_string())?; Ok(ResolvedRenewalTarget { label, account_id: pair.public.to_bytes(), @@ -310,17 +312,20 @@ pub(super) async fn untrack_account_for_signing_host( /// not stop every other target from being renewed. fn resolve_targets( entropy: &[u8], + network_suffix: &str, targets: &[StatementRenewalTarget], ) -> Vec { targets .iter() - .filter_map(|target| match resolve_target(entropy, target) { - Ok(resolved) => Some(resolved), - Err(reason) => { - warn!(?target, %reason, "skipping an unresolvable renewal target"); - None - } - }) + .filter_map( + |target| match resolve_target(entropy, network_suffix, target) { + Ok(resolved) => Some(resolved), + Err(reason) => { + warn!(?target, %reason, "skipping an unresolvable renewal target"); + None + } + }, + ) .collect() } @@ -382,7 +387,7 @@ pub(super) async fn renew_now( owner_key(&entropy)?, ) .await?; - let resolved = resolve_targets(&entropy, &targets); + let resolved = resolve_targets(&entropy, signing_host.network_suffix(), &targets); if resolved.is_empty() { return Ok(StatementRenewalReport { period, @@ -831,19 +836,19 @@ mod tests { let unresolvable = product(&"9".repeat(25)); let entropy = [7u8; 32]; - assert!(resolve_target(&entropy, &unresolvable).is_err()); + assert!(resolve_target(&entropy, "paseo", &unresolvable).is_err()); let targets = [unresolvable, product("a.dot")]; // Resolving strictly loses the healthy target with the broken one. assert!( targets .iter() - .map(|target| resolve_target(&entropy, target)) + .map(|target| resolve_target(&entropy, "paseo", target)) .collect::, _>>() .is_err() ); - let resolved = resolve_targets(&entropy, &targets); + let resolved = resolve_targets(&entropy, "paseo", &targets); assert_eq!(resolved.len(), 1); assert_eq!(resolved[0].label, "product:a.dot"); } @@ -967,7 +972,7 @@ mod tests { .public .to_bytes(); - let resolved = resolve_target(&entropy, &product("a.dot")).unwrap(); + let resolved = resolve_target(&entropy, "paseo", &product("a.dot")).unwrap(); assert_eq!( resolved, ResolvedRenewalTarget { @@ -980,12 +985,15 @@ mod tests { #[test] fn wallet_sso_target_resolves_to_the_responder_identity() { let entropy = [7u8; 32]; - let expected = crate::host_logic::product_account::derive_identity_keypair(&entropy) - .unwrap() - .public - .to_bytes(); + let expected = + crate::host_logic::product_account::derive_identity_keypair(&entropy, "paseo") + .unwrap() + .public + .to_bytes(); + + let resolved = + resolve_target(&entropy, "paseo", &StatementRenewalTarget::WalletSso).unwrap(); - let resolved = resolve_target(&entropy, &StatementRenewalTarget::WalletSso).unwrap(); assert_eq!( resolved, ResolvedRenewalTarget { diff --git a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs index df9a0c922..45957401f 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs @@ -44,7 +44,7 @@ impl LocalActivation for SigningHost { let secret = Zeroizing::new(secret); let root = derive_root_keypair_from_entropy(&secret).map_err(product_authority_error)?; let public_key = root.public.to_bytes(); - let identity_account_id = derive_identity_keypair(&secret) + let identity_account_id = derive_identity_keypair(&secret, self.network_suffix()) .map_err(product_authority_error)? .public .to_bytes(); 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 174075f72..f117dd741 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 @@ -78,8 +78,9 @@ const MAX_DECODE_FAILURE_REQUEST_IDS: usize = 1024; fn derive_responder_identity( entropy: &[u8], + network_suffix: &str, ) -> Result<(ResponderIdentity, [u8; 32]), ProductAccountError> { - let statement = derive_identity_keypair(entropy)?; + let statement = derive_identity_keypair(entropy, network_suffix)?; let (encryption_secret_key, encryption_public_key) = derive_x25519_keypair_from_entropy(entropy, SSO_ENCRYPTION_DOMAIN); let identity_chat_private_key = derive_identity_chat_private_key(entropy); @@ -279,11 +280,13 @@ async fn establish_pairing_session( .root_entropy() .map_err(|err| format!("signing host has no active local session: {err}"))?; // Product accounts and the SSO statement identity derive from the - // canonical root key; the identity is the RFC-0022 uid.dot default account. + // canonical root key; the identity is the RFC-0022 `uid.` default + // account of the network this host is configured for. let root = derive_root_keypair_from_entropy(&entropy) .map_err(|err| format!("root account derivation failed: {err}"))?; - let (identity, identity_chat_private_key) = derive_responder_identity(&entropy) - .map_err(|err| format!("responder identity derivation failed: {err}"))?; + let (identity, identity_chat_private_key) = + derive_responder_identity(&entropy, signing_host.network_suffix()) + .map_err(|err| format!("responder identity derivation failed: {err}"))?; let device_enc_pub_key = x25519_public_key(services.device_encryption_secret().await?); let session = responder_session_from_identity(&identity, peer)?; @@ -331,7 +334,7 @@ pub(crate) async fn resume_pairing( .map_err(|err| format!("signing host has no active local session: {err}"))?; let root = derive_root_keypair_from_entropy(&entropy) .map_err(|err| format!("root account derivation failed: {err}"))?; - let session = responder_session(&entropy, peer)?; + let session = responder_session(&entropy, signing_host.network_suffix(), peer)?; serve_session( services, signing_host, @@ -345,8 +348,12 @@ pub(crate) async fn resume_pairing( .await } -fn responder_session(entropy: &[u8], peer: PairedSsoPeer) -> Result { - let (identity, _) = derive_responder_identity(entropy) +fn responder_session( + entropy: &[u8], + network_suffix: &str, + peer: PairedSsoPeer, +) -> Result { + let (identity, _) = derive_responder_identity(entropy, network_suffix) .map_err(|err| format!("responder identity derivation failed: {err}"))?; responder_session_from_identity(&identity, peer) } @@ -1709,6 +1716,9 @@ mod tests { use truapi_platform::{HostInfo, Platform, PlatformInfo, SigningHostConfig}; const ENTROPY: [u8; 16] = [0xab; 16]; + /// The fixture's People chain is paseo-next-v2 (see `PEOPLE_METADATA`), + /// whose runtime carries the `paseo` network suffix. + const NETWORK_SUFFIX: &str = "paseo"; fn signing_fixture(platform: Arc) -> (Arc, Arc) { let platform: Arc = platform; @@ -1722,6 +1732,7 @@ mod tests { PlatformInfo::default(), [0; 32], [0xbb; 32], + NETWORK_SUFFIX.to_string(), ) .expect("signing host config is valid"); let services = RuntimeServices::new( @@ -1731,7 +1742,7 @@ mod tests { config.bulletin_chain_genesis_hash, test_spawner(), ); - let signing_host = SigningHost::new(services.clone()); + 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"); (services, signing_host) @@ -1869,8 +1880,18 @@ mod tests { .unwrap() .identity_account_id .unwrap(); - let (identity, _) = derive_responder_identity(&ENTROPY).unwrap(); + let (identity, _) = derive_responder_identity(&ENTROPY, NETWORK_SUFFIX).unwrap(); assert_eq!(identity.statement_public_key, local_identity); + // The statement identity is the network's `uid.` account, the + // one the pairing host resolves a username for; a `.dot` account has + // no lite record on a test network. + assert_ne!( + derive_responder_identity(&ENTROPY, "dot") + .unwrap() + .0 + .statement_public_key, + local_identity + ); let (_, host_encryption_public_key) = derive_x25519_keypair_from_entropy(&[0x42; 16], b"sso"); @@ -1937,14 +1958,14 @@ mod tests { statement_account_id: [0x53; 32], encryption_public_key: x25519_public_key([0x64; 32]), }; - let (identity, _) = derive_responder_identity(&ENTROPY).unwrap(); + let (identity, _) = derive_responder_identity(&ENTROPY, NETWORK_SUFFIX).unwrap(); let mut expected = establish_responder_session_info( &identity, peer.statement_account_id, peer.encryption_public_key, ) .unwrap(); - let resumed = responder_session(&ENTROPY, peer).unwrap(); + let resumed = responder_session(&ENTROPY, NETWORK_SUFFIX, peer).unwrap(); assert_eq!( crate::host_logic::statement_store::statement_public_key_from_secret(resumed.ss_secret) @@ -2195,7 +2216,7 @@ mod tests { create_transaction_confirmed: true, ..StubPlatform::default() })); - let identity = derive_identity_keypair(&ENTROPY).unwrap(); + let identity = derive_identity_keypair(&ENTROPY, NETWORK_SUFFIX).unwrap(); let payload = api::LegacyAccountTxPayload { signer: identity.public.to_bytes(), genesis_hash: [0xaa; 32], diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index 15021e903..b9849d763 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -469,7 +469,7 @@ mod tests { [0xbb; 32], test_spawner(), ); - let signing_host = SigningHostRole::new(services.clone()); + 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"); let host = ProductRuntimeHost::from_services( diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index e6a212c0d..fd4e228c9 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -581,6 +581,8 @@ fn signing_host_config_from_js(value: &JsValue) -> Result Result &str { "people_chain_genesis_hash" => "people.genesisHash", "bulletin_chain_genesis_hash" => "bulletin.genesisHash", "asset_hub_chain_genesis_hash" => "assetHub.genesisHash", + "network_suffix" => "networkSuffix", other => other, } } @@ -669,6 +673,11 @@ fn runtime_config_validation_to_js(err: RuntimeConfigValidationError) -> JsValue "runtimeConfig.productId must be a dotNS or localhost product identifier, got {product_id:?}" )) } + RuntimeConfigValidationError::InvalidNetworkSuffix { network_suffix } => { + JsValue::from_str(&format!( + "runtimeConfig.networkSuffix must be a bare lowercase dotNS TLD of at most 16 bytes, got {network_suffix:?}" + )) + } } } diff --git a/rust/crates/truapi-server/src/ws_bridge.rs b/rust/crates/truapi-server/src/ws_bridge.rs index 7cc279d3f..15ea1d669 100644 --- a/rust/crates/truapi-server/src/ws_bridge.rs +++ b/rust/crates/truapi-server/src/ws_bridge.rs @@ -522,6 +522,7 @@ mod tests { PlatformInfo::default(), [0; 32], [0xbb; 32], + "paseo".to_string(), ) .expect("test signing host config is valid"); let runtime = Arc::new(SigningHostRuntime::new(platform, config, test_spawner())); From d90eccbc1ba42ed5f036969d920d77d0a6d6414e Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 7 Sep 2026 22:28:44 +0200 Subject: [PATCH 2/4] fix(cli): reject obsolete signer state Require version 2 account and pairing stores for network-scoped keys. Older CLI state must be discarded and devices paired again. --- README.md | 3 ++ rust/crates/truapi-host-cli/README.md | 17 ++++++ rust/crates/truapi-host-cli/SPEC.md | 16 ++++-- rust/crates/truapi-host-cli/src/accounts.rs | 52 +++++++++++++++++-- .../crates/truapi-host-cli/src/attestation.rs | 2 +- rust/crates/truapi-host-cli/src/sessions.rs | 45 ++++++++++++++-- .../truapi-host-cli/tests/signing_host_cli.rs | 8 +-- 7 files changed, 126 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 06282047d..1ced0e0ce 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,9 @@ SSO transport for local end-to-end work. See [Install the CLI](#install-the-cli) to get it, and the [`truapi-host-cli` guide](rust/crates/truapi-host-cli/README.md) for its commands and controls. +CLI reserved identities follow the selected network's dotNS suffix. Old account +and pairing stores require [fresh state and re-pairing](rust/crates/truapi-host-cli/README.md#resetting-state-for-network-specific-identities). + `scripts/battery.sh` drives that CLI from source over every code-generated example and writes both committed compatibility reports: `explorer/diagnosis-reports/spa/signing-host-cli.md` from a direct signing-host diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index 47a90164d..d1b026ca7 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -94,6 +94,23 @@ curl -fsSL https://raw.githubusercontent.com/paritytech/host-rust-core/main/scri serves a fake release over loopback, installs it with the real installer, and updates it. Nothing contacts GitHub. +### Resetting state for network-specific identities + +Reserved identities derive under `uid.paseo` / `peopl.paseo` on +`paseo-next-v2`, and `uid.testnet` / `peopl.testnet` on `previewnet`. +Account and paired-host stores use version `2`. Older stores are rejected with +reset instructions because their `.dot` identities and pairings cannot be +reused. There is no state migration. + +Stop the CLI and discard its previous base directory, or start with a fresh one: + +```bash +truapi-host signing-host --network paseo-next-v2 --base-path ./truapi-host-paseo +``` + +Onboard a new test identity, sign out on each paired host, and pair again. +Existing `.dot` personhood membership does not transfer to the new keys. + ### Building from source A source build resolves the product-script runner from the checkout, so it also diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index 7a760e37a..8ffd84263 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -1334,8 +1334,9 @@ treated as not remembered. ### 13.4 Paired-host store -`paired-hosts.json` is version `1`. It contains a list of versioned peer records. -Each record contains: +`paired-hosts.json` is version `2`. It contains a list of version `1` peer +records. Other store versions are rejected before resuming saved peers, with +instructions to use fresh CLI state and pair again. Each record contains: - the statement account ID used as its unique key; - the public encryption key needed to resume SSO; and @@ -1402,7 +1403,9 @@ state, and other role-owned runtime data. ### 13.7 Account store -`accounts.json` is versioned and stores records containing: +`accounts.json` is version `2`. Other versions are rejected before restoring +or provisioning an account, with instructions to use fresh CLI state. The +store contains records with: - local name; - network id; @@ -1417,6 +1420,13 @@ Account mutations hold an exclusive `accounts.json.lock`. Secret-file writes use a temporary file, flush, atomic rename, and `0600` permissions on Unix. The lock file can be created during a read-only cached-signer lookup. +Version `1` account and paired-host stores used the fixed `.dot` identity +derivation and cannot be reused with network-specific reserved keys. Discard +the previous base directory, onboard a new test identity, and pair devices +again. Alternatively, use fresh paths as +shown in the [CLI reset instructions](README.md#resetting-state-for-network-specific-identities). +There is no state migration. + ### 13.8 Write and corruption behavior Product and core storage writes: diff --git a/rust/crates/truapi-host-cli/src/accounts.rs b/rust/crates/truapi-host-cli/src/accounts.rs index 25dee6cd5..c89d25b5b 100644 --- a/rust/crates/truapi-host-cli/src/accounts.rs +++ b/rust/crates/truapi-host-cli/src/accounts.rs @@ -23,6 +23,7 @@ use zeroize::Zeroize; const ACCOUNT_STORE_FILE: &str = "accounts.json"; const ACCOUNT_STORE_LOCK_FILE: &str = "accounts.json.lock"; +const ACCOUNT_STORE_VERSION: u32 = 2; const DEFAULT_USERNAME_PREFIX: &str = "headless"; const IMPORTED_ACCOUNT_NAME: &str = "imported"; @@ -262,11 +263,19 @@ impl AccountStore { serde_json::from_str(&text).with_context(|| format!("decode {}", path.display()))? } Err(err) if err.kind() == std::io::ErrorKind::NotFound => AccountStoreData { - version: 1, + version: ACCOUNT_STORE_VERSION, accounts: Vec::new(), }, Err(err) => return Err(err).with_context(|| format!("read {}", path.display())), }; + anyhow::ensure!( + data.version == ACCOUNT_STORE_VERSION, + "unsupported account store version {} in {}; \ + reserved keys use the network suffix; restart with a fresh \ + --base-path, onboard again, and re-pair devices", + data.version, + path.display() + ); Ok(Self { path, data }) } @@ -1012,6 +1021,36 @@ mod tests { ); } + #[test] + fn incompatible_account_stores_require_fresh_state() -> Result<()> { + let dir = tempdir()?; + let path = dir.path().join(ACCOUNT_STORE_FILE); + for version in [1, 3] { + let stored = serde_json::to_vec(&serde_json::json!({ + "version": version, + "accounts": [record("auto-1", "paseo-next-v2", true)], + }))?; + fs::write(&path, &stored)?; + + let error = resolve_cached_signer(dir.path(), "paseo-next-v2", None) + .expect_err("incompatible state must not activate a cached signer"); + + assert_eq!( + (error.to_string(), fs::read(&path)?), + ( + format!( + "unsupported account store version {version} in {}; \ + reserved keys use the network suffix; restart with a fresh \ + --base-path, onboard again, and re-pair devices", + path.display() + ), + stored, + ) + ); + } + Ok(()) + } + #[test] fn save_roundtrips_account_store() -> Result<()> { let dir = tempdir()?; @@ -1022,10 +1061,13 @@ mod tests { let loaded = AccountStore::load(dir.path())?; assert_eq!( - loaded - .get("paseo-next-v2", "auto-1") - .map(|record| record.name.as_str()), - Some("auto-1") + ( + loaded.data.version, + loaded + .get("paseo-next-v2", "auto-1") + .map(|record| record.name.as_str()), + ), + (2, Some("auto-1")) ); assert!(!temp_path(&dir.path().join(ACCOUNT_STORE_FILE)).exists()); Ok(()) diff --git a/rust/crates/truapi-host-cli/src/attestation.rs b/rust/crates/truapi-host-cli/src/attestation.rs index 9f952ffc4..75205b284 100644 --- a/rust/crates/truapi-host-cli/src/attestation.rs +++ b/rust/crates/truapi-host-cli/src/attestation.rs @@ -6,7 +6,7 @@ //! `/usernames`. Polls the dotNS contracts on Asset Hub until the lite username //! lands. //! -//! Registers the signing host's RFC-0022 `uid.dot` identity account. The paired +//! Registers the signing host's RFC-0022 `uid.` identity account. The paired //! host can then resolve its username via `get_user_id`. use std::time::Duration; diff --git a/rust/crates/truapi-host-cli/src/sessions.rs b/rust/crates/truapi-host-cli/src/sessions.rs index 511aec1f9..d8c293bda 100644 --- a/rust/crates/truapi-host-cli/src/sessions.rs +++ b/rust/crates/truapi-host-cli/src/sessions.rs @@ -16,7 +16,7 @@ const SESSION_INFO_FILE: &str = "session.json"; const PAIRED_HOSTS_FILE: &str = "paired-hosts.json"; const PAIRED_HOSTS_LOCK_FILE: &str = "paired-hosts.json.lock"; const PAIRED_HOST_VERSION: u32 = 1; -const PAIRED_HOST_STORE_VERSION: u32 = 1; +const PAIRED_HOST_STORE_VERSION: u32 = 2; /// Safe display metadata retained from a paired host's proposal. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -617,7 +617,9 @@ fn read_paired_host_store(session_path: &Path) -> Result { .with_context(|| format!("decode paired hosts {}", path.display()))?; anyhow::ensure!( store.version == PAIRED_HOST_STORE_VERSION, - "unsupported paired host store version {} in {}", + "unsupported paired host store version {} in {}; \ + reserved keys use the network suffix; restart with a fresh \ + --base-path, onboard again, and re-pair devices", store.version, path.display() ); @@ -863,7 +865,7 @@ mod tests { ); let metadata: serde_json::Value = serde_json::from_slice(&fs::read(profile.path.join(PAIRED_HOSTS_FILE))?)?; - assert_eq!(metadata["version"], 1); + assert_eq!(metadata["version"], 2); assert_eq!( metadata["paired_hosts"][0], serde_json::json!({ @@ -1003,6 +1005,41 @@ mod tests { Ok(()) } + #[test] + fn incompatible_paired_host_stores_require_fresh_state() -> Result<()> { + let temporary = tempdir()?; + let catalog = SessionCatalog::new(temporary.path().to_path_buf(), "paseo-next-v2")?; + let profile = catalog.ensure_profile("alice")?; + let path = profile.path.join(PAIRED_HOSTS_FILE); + for version in [1, 3] { + let stored = serde_json::to_vec(&serde_json::json!({ + "version": version, + "paired_hosts": [paired_host(1, 11, "first")], + }))?; + fs::write(&path, &stored)?; + + let error = catalog + .paired_hosts(&profile) + .expect_err("incompatible pairings must not resume"); + assert_eq!( + error.to_string(), + format!( + "unsupported paired host store version {version} in {}; \ + reserved keys use the network suffix; restart with a fresh \ + --base-path, onboard again, and re-pair devices", + path.display() + ) + ); + assert!( + catalog + .store_paired_host(&profile, paired_host(2, 22, "second")) + .is_err() + ); + assert_eq!(fs::read(&path)?, stored); + } + Ok(()) + } + #[test] fn unsupported_paired_host_records_are_not_overwritten() -> Result<()> { let temporary = tempdir()?; @@ -1010,7 +1047,7 @@ mod tests { let profile = catalog.ensure_profile("alice")?; let path = profile.path.join(PAIRED_HOSTS_FILE); let unsupported = serde_json::to_vec_pretty(&serde_json::json!({ - "version": 1, + "version": 2, "paired_hosts": [{ "version": 2, "statement_account_id": vec![1_u8; 32], diff --git a/rust/crates/truapi-host-cli/tests/signing_host_cli.rs b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs index 7c741b13c..21a34907b 100644 --- a/rust/crates/truapi-host-cli/tests/signing_host_cli.rs +++ b/rust/crates/truapi-host-cli/tests/signing_host_cli.rs @@ -239,7 +239,7 @@ fn exec_devices_lists_and_removes_exactly_one_paired_device() { std::fs::write( profile.join("paired-hosts.json"), serde_json::to_vec_pretty(&serde_json::json!({ - "version": 1, + "version": 2, "paired_hosts": [ { "version": 1, @@ -329,7 +329,7 @@ fn existing_local_signer_is_activated_and_cached_at_startup() { std::fs::write( base_path.join("accounts.json"), r#"{ - "version": 1, + "version": 2, "accounts": [{ "name": "auto-1", "network": "paseo-next-v2", @@ -375,7 +375,7 @@ fn imported_session_restores_the_exact_bound_account() { std::fs::write( profile.join("accounts.json"), r#"{ - "version": 1, + "version": 2, "accounts": [{ "name": "imported", "network": "paseo-next-v2", @@ -430,7 +430,7 @@ fn imported_session_without_dotns_username_restores_by_account_binding() { std::fs::write( profile.join("accounts.json"), r#"{ - "version": 1, + "version": 2, "accounts": [{ "name": "imported", "network": "paseo-next-v2", From fa0240177261c61e44b5efa10e12cbfbb28405e2 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 7 Sep 2026 22:33:19 +0200 Subject: [PATCH 3/4] fix: align signing host suffix validation --- android/truapi-host/README.md | 8 +++++ ios/truapi-host/README.md | 12 +++++-- .../Sources/TrUAPIHost/truapi_server.swift | 3 +- js/packages/truapi-host/README.md | 8 +++++ rust/crates/truapi-platform/src/lib.rs | 34 ++++--------------- rust/crates/truapi-server/README.md | 9 +++++ rust/crates/truapi-server/src/native.rs | 7 ++-- rust/crates/truapi-server/src/wasm.rs | 2 +- 8 files changed, 46 insertions(+), 37 deletions(-) diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md index 80c2a2290..6718aa0d0 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -36,6 +36,12 @@ The package is public, so any authenticated GitHub identity can read it. In GitH The consuming app must declare `android.permission.INTERNET` — the localhost WebSocket bridge binds a `127.0.0.1` TCP socket, which requires it even for loopback. +`HostRuntimeConfig.networkSuffix` is required. Supply the bare TLD (`dot`, +`paseo`, or `testnet`) from the same network configuration used by onboarding +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. + ### Compatibility - **minSdk**: 29 (Android 10). Aligns with the polkadot-app-android-v2 floor. @@ -96,6 +102,7 @@ val runtime = TrUAPIHostRuntime( hostName = "My Chat Host", peopleChainGenesisHash = peopleChainGenesisHash, // exactly 32 bytes bulletinChainGenesisHash = bulletinChainGenesisHash, + networkSuffix = "dot", ), ) // Chat needs an active session; without one every Chat call answers `Denied`. @@ -332,6 +339,7 @@ val runtimeConfig = HostRuntimeConfig( hostIcon = "https://host.example/icon.png", peopleChainGenesisHash = ByteArray(32), bulletinChainGenesisHash = ByteArray(32), + networkSuffix = "dot", // Optional: activate a local signing session from host-held BIP-39 entropy // (no SSO pairing). Omit for the QR pairing flow. localSessionSecret = null, diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index 89fb49d04..bebb2581d 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -72,6 +72,12 @@ manifest PR to keep `main` current. SPM pins the resolved revision in the app's `Package.resolved`; update it with File > Packages > Update in Xcode or `xcodebuild -resolvePackageDependencies` after the tag is published. +`HostRuntimeConfig.networkSuffix` is required. Supply the bare TLD (`dot`, +`paseo`, or `testnet`) from the same network configuration used by onboarding +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. + Run the package tests against an iOS simulator (the xcframework has no macOS slice): ```bash @@ -123,7 +129,8 @@ let runtime = try TrUAPIHostRuntime( runtimeConfig: HostRuntimeConfig( hostName: "My Chat Host", peopleChainGenesisHash: peopleChainGenesisHash, // exactly 32 bytes - bulletinChainGenesisHash: bulletinChainGenesisHash + bulletinChainGenesisHash: bulletinChainGenesisHash, + networkSuffix: "dot" ) ) // Chat needs an active session; without one every Chat call answers denied. @@ -361,7 +368,8 @@ let runtimeConfig = HostRuntimeConfig( hostName: "My Host", hostIcon: "https://host.example/icon.png", peopleChainGenesisHash: Data(repeating: 0, count: 32), - bulletinChainGenesisHash: Data(repeating: 0, count: 32) + bulletinChainGenesisHash: Data(repeating: 0, count: 32), + networkSuffix: "dot" ) let runtime = try TrUAPIHostRuntime(bridge: bridge, runtimeConfig: runtimeConfig) try runtime.activateLocalSession(secret: entropyBytes, liteUsername: nil) diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index 5b512474d..07ea8b768 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -4445,8 +4445,7 @@ enum NativeRuntimeConfigError: Swift.Error, Equatable, Hashable, Foundation.Loca */scheme: String ) /** - * Network suffix was not one bare lowercase dotNS label of at most 16 - * bytes. + * Network suffix was not a supported dotNS TLD. */ case InvalidNetworkSuffix( /** diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index a77780ddc..7cbd923ca 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -15,6 +15,14 @@ The package exposes tree-shakeable subpath exports — import only what your env | `@parity/truapi-host/worker-runtime` | Web Worker entrypoint (import with your bundler's `?worker` suffix) so the WASM core runs off the page main thread. | | `@parity/truapi-host/wasm/web` | The raw browser `wasm-bindgen` glue, if you need to instantiate the core yourself. | +The shipped WASM is built by `scripts/build-wasm.mjs` with +`--no-default-features`, so it excludes `WasmSigningHostRuntime`. +`ProductRuntimeConfig` configures the pairing host and requires no network +suffix. A custom build enabling the Rust `wasm-signing-host` feature exposes +the signing constructor, whose configuration requires +`runtimeConfig.networkSuffix`: the bare TLD (`dot`, `paseo`, or `testnet`) +matching the People chain and the wallet's onboarding configuration. + ## Bundler requirements The worker imports the WASM glue by a literal specifier, so every bundler diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 9c069da42..170cbaf4e 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -95,9 +95,8 @@ pub struct SigningHostConfig { /// 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 - /// ring-VRF keys. The People chain scopes its proof contexts with the same - /// value (`NetworkSuffix.NetworkSuffix`), so the two never disagree about - /// which network a person belongs to. + /// ring-VRF keys. Must match the People chain's + /// `NetworkSuffix.NetworkSuffix` value used for proof contexts. pub network_suffix: String, } @@ -235,22 +234,9 @@ impl SigningHostConfig { } } -/// Longest network suffix the People chain accepts -/// (`indiv_support::context::MAX_NETWORK_SUFFIX_LENGTH`). -const MAX_NETWORK_SUFFIX_LENGTH: usize = 16; - -/// A network suffix is one bare dotNS label: the part after the last dot of a -/// product id. It is hashed into key derivations verbatim, so anything that -/// could also spell a different product id (`peopl.dot.x` from a suffix of -/// `dot.x`, or `peopl.` from an empty one) is rejected here rather than -/// silently deriving a person nobody else recognises. fn validate_network_suffix(network_suffix: &str) -> Result<(), RuntimeConfigValidationError> { require_non_empty("network_suffix", network_suffix)?; - let well_formed = network_suffix.len() <= MAX_NETWORK_SUFFIX_LENGTH - && network_suffix - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()); - if !well_formed { + if !DOTNS_TLDS.contains(&network_suffix) { return Err(RuntimeConfigValidationError::InvalidNetworkSuffix { network_suffix: network_suffix.to_string(), }); @@ -888,11 +874,8 @@ pub enum RuntimeConfigValidationError { /// Actual product id value. product_id: String, }, - /// Network suffix was not one bare lowercase dotNS label of at most 16 - /// bytes. - #[display( - "network_suffix must be a bare lowercase dotNS TLD of at most 16 bytes, got {network_suffix:?}" - )] + /// Network suffix was not a supported dotNS TLD. + #[display("network_suffix must be a supported dotNS TLD, got {network_suffix:?}")] InvalidNetworkSuffix { /// Actual network suffix value. network_suffix: String, @@ -1536,16 +1519,11 @@ mod tests { #[test] fn a_signing_host_is_configured_for_one_dotns_tld() { - // Every TLD navigation accepts is a valid suffix, and nothing is - // assumed about which one a host runs against. for tld in DOTNS_TLDS { let config = signing_host_config(tld).expect("a known TLD is a valid suffix"); assert_eq!(config.network_suffix, *tld); } - // The suffix is hashed into the reserved derivations as `peopl.`, - // so anything that does not read as one bare label is refused rather - // than deriving keys under a name that is not a product id. assert_eq!( signing_host_config(""), Err(RuntimeConfigValidationError::EmptyField { @@ -1553,6 +1531,8 @@ mod tests { }) ); for malformed in [ + "pasoe", + "unknown", ".paseo", "peopl.paseo", "Paseo", diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index 2d7d20aad..fcd639cc0 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -206,6 +206,15 @@ each product connection. Role-specific operations live only on the matching hand touching the session or other products. Calling the wrong operation is a compile error, not a runtime `Unavailable`. +`SigningHostConfig.network_suffix` is the network's bare dotNS TLD (`dot`, +`paseo`, or `testnet`). The shell supplies it alongside the chain genesis hashes +from the same network configuration used by wallet onboarding. It must match +the People chain's `NetworkSuffix.NetworkSuffix`: reserved identities derive +under `uid.` and `peopl.`, while the chain uses that suffix for +proof contexts. Configuration keeps local activation and key derivation +available offline. The core validates supported suffixes but does not +automatically check that the configured suffix matches the chain. + ### The two roles Both implement the role-neutral **`ProductAuthority`** trait; each owns its diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index b97eb37ee..ec240ab58 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -256,11 +256,8 @@ pub enum NativeRuntimeConfigError { /// Actual deeplink scheme value. scheme: String, }, - /// Network suffix was not one bare lowercase dotNS label of at most 16 - /// bytes. - #[error( - "network_suffix must be a bare lowercase dotNS TLD of at most 16 bytes, got {network_suffix:?}" - )] + /// Network suffix was not a supported dotNS TLD. + #[error("network_suffix must be a supported dotNS TLD, got {network_suffix:?}")] InvalidNetworkSuffix { /// Actual network suffix value. network_suffix: String, diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index fd4e228c9..bf152555e 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -675,7 +675,7 @@ fn runtime_config_validation_to_js(err: RuntimeConfigValidationError) -> JsValue } RuntimeConfigValidationError::InvalidNetworkSuffix { network_suffix } => { JsValue::from_str(&format!( - "runtimeConfig.networkSuffix must be a bare lowercase dotNS TLD of at most 16 bytes, got {network_suffix:?}" + "runtimeConfig.networkSuffix must be a supported dotNS TLD, got {network_suffix:?}" )) } } From 2cb10f9ac84c23f9ef8ef97c004badf0966decb2 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Mon, 7 Sep 2026 22:47:54 +0200 Subject: [PATCH 4/4] fix(cli): version the state directory Start fresh under v2 and leave old identities and pairings untouched. Keep original JSON formats; remove per-file reset checks. --- README.md | 3 +- rust/crates/truapi-host-cli/README.md | 40 ++++--- rust/crates/truapi-host-cli/SPEC.md | 40 ++++--- rust/crates/truapi-host-cli/src/accounts.rs | 52 +-------- rust/crates/truapi-host-cli/src/main.rs | 25 +++-- rust/crates/truapi-host-cli/src/sessions.rs | 45 +------- .../truapi-host-cli/tests/signing_host_cli.rs | 105 ++++++++++++++++-- 7 files changed, 158 insertions(+), 152 deletions(-) diff --git a/README.md b/README.md index 1ced0e0ce..42e166af5 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,8 @@ to get it, and the [`truapi-host-cli` guide](rust/crates/truapi-host-cli/README. for its commands and controls. CLI reserved identities follow the selected network's dotNS suffix. Old account -and pairing stores require [fresh state and re-pairing](rust/crates/truapi-host-cli/README.md#resetting-state-for-network-specific-identities). +and pairing stores are left unused as the CLI starts fresh under its +[versioned state directory](rust/crates/truapi-host-cli/README.md#state-directory). `scripts/battery.sh` drives that CLI from source over every code-generated example and writes both committed compatibility reports: diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index d1b026ca7..b948ca9a8 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -94,21 +94,19 @@ curl -fsSL https://raw.githubusercontent.com/paritytech/host-rust-core/main/scri serves a fake release over loopback, installs it with the real installer, and updates it. Nothing contacts GitHub. -### Resetting state for network-specific identities +### State directory Reserved identities derive under `uid.paseo` / `peopl.paseo` on `paseo-next-v2`, and `uid.testnet` / `peopl.testnet` on `previewnet`. -Account and paired-host stores use version `2`. Older stores are rejected with -reset instructions because their `.dot` identities and pairings cannot be -reused. There is no state migration. - -Stop the CLI and discard its previous base directory, or start with a fresh one: - -```bash -truapi-host signing-host --network paseo-next-v2 --base-path ./truapi-host-paseo -``` - -Onboard a new test identity, sign out on each paired host, and pair again. +All managed CLI state lives under `/v2`, including accounts, +sessions, pairings, core and product storage, managed scripts, and log +preferences. The CLI appends `v2` to both the default base path and a path set +through `--base-path` or `TRUAPI_HOST_BASE_PATH`. For example, +`--base-path ./truapi-host-paseo` uses `./truapi-host-paseo/v2`. + +The CLI leaves previous state outside `v2` untouched and unused, and starts +normal onboarding automatically. There is no state migration. Pair devices +again; sign out first on any paired host that still uses an old identity. Existing `.dot` personhood membership does not transfer to the new keys. ### Building from source @@ -204,11 +202,11 @@ press Ctrl-V, use the terminal's paste shortcut, or drop an image file. You can also provide an image file or deeplink with `/pair `, run `/script`, or use `/help` to discover the available commands. It uses `--mnemonic` / `HOST_CLI_SIGNER_MNEMONIC` if set. -Otherwise it auto-selects or creates a stored account under `--base-path` (default -`$XDG_STATE_HOME/truapi-host` or `~/.local/state/truapi-host`), attests it -through the identity backend, waits for ring readiness, and rotates when the -current account exhausts Statement Store slots and no saved pairing depends on -its identity. A full period replaces the oldest slot past the runtime's +Otherwise it auto-selects or creates a stored account under `/v2` +(default `$XDG_STATE_HOME/truapi-host/v2` or `~/.local/state/truapi-host/v2`), +attests it through the identity backend, waits for ring readiness, and rotates +when the current account exhausts Statement Store slots and no saved pairing +depends on its identity. A full period replaces the oldest slot past the runtime's replacement cooldown, so rotation only happens when no slot is replaceable. ### Interactive terminal UI @@ -334,7 +332,7 @@ settings containing arguments, such as `EDITOR='code --wait'`, are supported. Managed sessions isolate signer accounts, product/core storage, and permissions. Once a signer identity is known, its public session name is the Lite username and its files live under -`//_signing_host`. Provisional named sessions +`/v2//_signing_host`. Provisional named sessions are promoted to that user-owned root, so an old name such as `pgtest` does not remain the durable namespace. The selected username is remembered per network but is not repeated in the status bar as a separate session field. @@ -498,7 +496,7 @@ the selected id, so the newly selected product sees its own state. The next `/script` also receives the new id through `host.productId`. Pairing-host state follows the same identity rule under -`//_pairing_host`. Before the first identity is +`/v2//_pairing_host`. Before the first identity is known it uses the small `/pairing-host` bootstrap; connecting moves that bootstrap data to the first resolved user. After `/logout`, connecting as a different user swaps to that user's KV/core namespace instead of carrying @@ -626,7 +624,7 @@ are unavailable on the pairing host and in one-shot `exec` mode. Use the global `--log-level` option (`error`, `warn`, `info`, `debug`, or `trace`) before or after the subcommand, or `/log ` in the terminal UI. -`/log` saves the level under `--base-path`, so pairing and signing hosts restore +`/log` saves the level under `/v2`, so pairing and signing hosts restore it after restart. A one-off `--log-level` or `TRUAPI_HOST_LOG` value overrides the saved level for that process without changing it; otherwise the fallback is `info`. @@ -686,7 +684,7 @@ product may not: it reports the period as exhausted, because every entry in the table is one of this wallet's own products and reclaiming space belongs to the renewal pass. `alloc-check` prints both collections' member keys, ring indices and slot tables. Auto-managed accounts are stored in -`accounts.json` under `--base-path`; mnemonics are plaintext local test secrets +`accounts.json` under `/v2`; mnemonics are plaintext local test secrets and the file is written with `0600` permissions on Unix. `alloc-check` verifies membership and can submit a test registration. diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index 8ffd84263..fa45c9c83 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -285,7 +285,7 @@ Commands: The option is global and is accepted before or after a subcommand. `TRUAPI_HOST_LOG` supplies the same per-process override. Without either, the -CLI restores the level saved by `/log` under the selected base path, then falls +CLI restores the level saved by `/log` under `/v2`, then falls back to `info`. Command-line and environment overrides do not rewrite the saved level. @@ -305,7 +305,7 @@ truapi-host pairing-host [options] | `--script ` | none | Run one JS/TS product script and exit with its status. | | `--product-id ` | `headless-playground.dot` | Initial product scope. | | `--frame-listen ` | none | Opt into a TCP product WebSocket listener. When omitted, use a private per-process Unix socket. Port `0` selects an available TCP port. | -| `--base-path ` | section 12.1 | Root for network, identity, core, script, and product state. | +| `--base-path ` | section 12.1 | Base directory; managed state lives under its `v2/` subdirectory. | | `--network ` | `paseo-next-v2` | Select the complete endpoint/genesis preset (`paseo-next-v2`, `previewnet`). | | `--auto-accept` | off | Approve platform confirmations automatically. | @@ -344,7 +344,7 @@ truapi-host signing-host [options] [exec ''] | `--session ` | remembered session | Restore or create a managed session. | | `--lite-username-prefix ` | session-derived | Prefix for newly generated Lite username bases. | | `--reserved-username