diff --git a/docs/design/name-identifiers.md b/docs/design/name-identifiers.md new file mode 100644 index 00000000..e5847cab --- /dev/null +++ b/docs/design/name-identifiers.md @@ -0,0 +1,89 @@ +--- +title: "Name Identifiers" +type: design +--- + +# Name Identifiers + +Name identifiers are unique pointers of Products and Users. They are +labels registered in the [DotNS protocol](https://github.com/paritytech/dotns). + +The protocol handles a name as its +[ENS-style namehash](https://docs.ens.domains/resolution/names#namehash), +originally specified in [EIP-137](https://eips.ethereum.org/EIPS/eip-137). It is computed as +`namehash(tldNode, keccak256(label))` in +[`LabelUtils`](https://github.com/paritytech/dotns/blob/main/contracts/utils/LabelUtils.sol), +so the stored identifier covers both the label and the Top Level Domain (TLD): + +``` +namehash("dot") = keccak256(0x00…00 ++ keccak256("dot")) + = 0x3fce7d1364a893e213bc4212792b517ffc88f5b13b86c8ef9c8d390c3a1370ce +namehash("example.dot") = keccak256(namehash("dot") ++ keccak256("example")) + = 0x50cef3746492e11fe07821077c650ed11a908315a91b3a85b4a12afd21249605 +``` + +## Motivation + +Three motivations argue for a well defined design: + +- A test product must not be able to act as the mainnet product. If identity + ignores the TLD, `game.test` controls the same accounts, permissions, and + storage as `game.dot`, and a throwaway test deployment can mislead users + when using real value. +- A developer who reuses one root mnemonic across networks gets the same + product addresses on every network when identity ignores the TLD, which + links their activity across networks. Deriving with the TLD keeps those + address spaces unlinkable. +- Distinct identities per TLD reduce confusion across Polkadot App versions + and web domains, because what the user sees named differently is also keyed + differently. +- The Individuality runtime takes this side for ring contexts: + [`build_product_context`](https://github.com/paritytech/individuality/blob/be61b7720e5345afff53f28b924f8bc129938e24/support/src/context.rs#L61-L80) + hashes the preimage `product/{name}.{tld}/{suffix}`, with the network suffix + an explicit argument. A host that derived ring contexts TLD-free would + disagree with the chain. + +## Convention + +Each network declares its own TLD, fixed at registry initialisation and +exposed by the +[`tld()` view function](https://github.com/paritytech/dotns/blob/main/contracts/registry/DotnsProtocolRegistry.sol) +of the DotNS protocol registry. The protocol owns the bare label, and the TLD +is appended when a label is rendered as a full name, so the same label is +served differently per network. + +The identity is the full served name, TLD included. `game.test` and +`game.dot` are different products with different accounts, ring contexts, +entropy, permissions, and storage. Hosts MUST NOT strip or rewrite the TLD +when deriving or scoping, so nothing carries over between networks implicitly. +A product graduating from testnet to mainnet starts fresh, and any carry-over +MUST be an explicit migration. + +The host still normalizes the spelling once, through +[`normalize_product_identifier`](../../rust/crates/truapi-platform/src/lib.rs): +trim, NFC-normalize, lowercase. A name MUST end in a TLD the host recognizes +(`DOTNS_TLDS`), with `localhost` and `localhost:{port}` accepted for +development. Everything else is rejected. + +Every party that derives keys MUST apply the identical normalization. The +[mobile Account Holder](https://github.com/Polkadot-Community-Foundation/polkadot-app-ios-v2) +mirrors it, and +[host-spec C.5 to C.7](https://github.com/paritytech/host-spec/blob/main/spec/C-account-derivation.md) +plus the +[interop vectors](../../rust/crates/truapi-server/tests/wasm_crypto_vectors.rs) +pin it byte-for-byte. Everything keyed by a name id re-keys if the rule +drifts, so it MUST NOT be reimplemented outside +[`normalize_product_identifier`](../../rust/crates/truapi-platform/src/lib.rs) +and the +[reserved-id table](../../rust/crates/truapi-server/src/host_logic/product_account.rs). + +## Where Name Identifiers Exist + +| Use Case | Description | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Navigation | A host resolves the name a user typed or followed into the content it should load, via [`NavigateDecision`](../../rust/crates/truapi-server/src/host_logic/dotns.rs). Here the name is an address rather than an identity, and it is used verbatim. | +| Product accounts | The account tree of a product hangs off its identifier: `//product//{nameId}/{index}` ([RFC-0022](../rfcs/0022-account-derivations.md)), implemented in [`product_account.rs`](../../rust/crates/truapi-server/src/host_logic/product_account.rs). The built-ins `uid.dot` and `peopl.dot` are reserved identifiers in the same tree. | +| Ring contexts | A personhood proof carries the identifier of the product it was made for, so no other product can replay it. [The ring-VRF signer](../../rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs) builds the proof context ([RFC-0004](../rfcs/0004-ringlocation-redesign.md)), and the [ring-VRF registry](../../rust/crates/truapi-server/src/runtime/ring_vrf_registry.rs) records which keys belong to which identifier ([RFC-0024](../rfcs/0024-personhood-as-product.md)). | +| Per-product entropy | Each product gets deterministic secret material ([RFC-0007](../rfcs/0007-derive-entropy.md)), and the identifier is what separates one product entropy space from another, in [`entropy.rs`](../../rust/crates/truapi-server/src/host_logic/entropy.rs). | +| Permissions and storage | Everything a host remembers about a product, from consent grants to stored values, sits under a `CoreStorageKey` built from the identifier, in [`truapi-platform`](../../rust/crates/truapi-platform/src/lib.rs). | +| User identity | The primary username a product may request ([RFC-0015](../rfcs/0015-get-user-id.md)) is itself a name identifier, and it points at the `uid.dot` identity account in [`product_account.rs`](../../rust/crates/truapi-server/src/host_logic/product_account.rs). | diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift index 9e3c170f..963c8e0b 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift @@ -858,6 +858,11 @@ public struct HostChainSet: Equatable, Hashable { * Ecosystem the host is configured for, e.g. "polkadot", "paseo". */ public var network: String + /** + * dotNS TLD the network's registry declares via its `tld()` view, e.g. + * `dot`, `paseo`, `test`. `None` when the host does not know it. + */ + public var tld: String? /** * Chains this host serves, keyed by protocol role. */ @@ -869,10 +874,15 @@ public struct HostChainSet: Equatable, Hashable { /** * Ecosystem the host is configured for, e.g. "polkadot", "paseo". */network: String, + /** + * dotNS TLD the network's registry declares via its `tld()` view, e.g. + * `dot`, `paseo`, `test`. `None` when the host does not know it. + */tld: String?, /** * Chains this host serves, keyed by protocol role. */chains: [HostChainEntry]) { self.network = network + self.tld = tld self.chains = chains } @@ -893,12 +903,14 @@ public struct FfiConverterTypeHostChainSet: FfiConverterRustBuffer { return try HostChainSet( network: FfiConverterString.read(from: &buf), + tld: FfiConverterOptionString.read(from: &buf), chains: FfiConverterSequenceTypeHostChainEntry.read(from: &buf) ) } public static func write(_ value: HostChainSet, into buf: inout [UInt8]) { FfiConverterString.write(value.network, into: &buf) + FfiConverterOptionString.write(value.tld, into: &buf) FfiConverterSequenceTypeHostChainEntry.write(value.chains, into: &buf) } } diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index 5ab518bd..88c5c3cf 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -3893,6 +3893,11 @@ public struct NativeHostRuntimeConfig: Equatable, Hashable { * Optional lite username attached to the local signing-host session. */ public var localSessionLiteUsername: String? + /** + * dotNS TLD of the host's configured network, scoping the reserved + * built-in derivations. `None` means `dot`. + */ + public var dotnsTld: String? // Default memberwise initializers are never public by default, so we // declare one manually. @@ -3923,7 +3928,11 @@ public struct NativeHostRuntimeConfig: Equatable, Hashable { */localSessionSecret: Data?, /** * Optional lite username attached to the local signing-host session. - */localSessionLiteUsername: String?) { + */localSessionLiteUsername: String?, + /** + * dotNS TLD of the host's configured network, scoping the reserved + * built-in derivations. `None` means `dot`. + */dotnsTld: String?) { self.hostName = hostName self.hostIcon = hostIcon self.hostVersion = hostVersion @@ -3933,6 +3942,7 @@ public struct NativeHostRuntimeConfig: Equatable, Hashable { self.bulletinChainGenesisHash = bulletinChainGenesisHash self.localSessionSecret = localSessionSecret self.localSessionLiteUsername = localSessionLiteUsername + self.dotnsTld = dotnsTld } @@ -3959,7 +3969,8 @@ public struct FfiConverterTypeNativeHostRuntimeConfig: FfiConverterRustBuffer { peopleChainGenesisHash: FfiConverterData.read(from: &buf), bulletinChainGenesisHash: FfiConverterData.read(from: &buf), localSessionSecret: FfiConverterOptionData.read(from: &buf), - localSessionLiteUsername: FfiConverterOptionString.read(from: &buf) + localSessionLiteUsername: FfiConverterOptionString.read(from: &buf), + dotnsTld: FfiConverterOptionString.read(from: &buf) ) } @@ -3973,6 +3984,7 @@ public struct FfiConverterTypeNativeHostRuntimeConfig: FfiConverterRustBuffer { FfiConverterData.write(value.bulletinChainGenesisHash, into: &buf) FfiConverterOptionData.write(value.localSessionSecret, into: &buf) FfiConverterOptionString.write(value.localSessionLiteUsername, into: &buf) + FfiConverterOptionString.write(value.dotnsTld, into: &buf) } } @@ -4113,6 +4125,11 @@ public struct NativeRuntimeConfig: Equatable, Hashable { * Deeplink scheme used in pairing QR payloads. */ public var pairingDeeplinkScheme: NativePairingDeeplinkScheme + /** + * dotNS TLD of the host's configured network, scoping the reserved + * built-in derivations. `None` means `dot`. + */ + public var dotnsTld: String? // Default memberwise initializers are never public by default, so we // declare one manually. @@ -4152,7 +4169,11 @@ public struct NativeRuntimeConfig: Equatable, Hashable { */localSessionLiteUsername: String?, /** * Deeplink scheme used in pairing QR payloads. - */pairingDeeplinkScheme: NativePairingDeeplinkScheme) { + */pairingDeeplinkScheme: NativePairingDeeplinkScheme, + /** + * dotNS TLD of the host's configured network, scoping the reserved + * built-in derivations. `None` means `dot`. + */dotnsTld: String?) { self.productId = productId self.executionKind = executionKind self.hostName = hostName @@ -4165,6 +4186,7 @@ public struct NativeRuntimeConfig: Equatable, Hashable { self.localSessionSecret = localSessionSecret self.localSessionLiteUsername = localSessionLiteUsername self.pairingDeeplinkScheme = pairingDeeplinkScheme + self.dotnsTld = dotnsTld } @@ -4194,7 +4216,8 @@ public struct FfiConverterTypeNativeRuntimeConfig: FfiConverterRustBuffer { bulletinChainGenesisHash: FfiConverterData.read(from: &buf), localSessionSecret: FfiConverterOptionData.read(from: &buf), localSessionLiteUsername: FfiConverterOptionString.read(from: &buf), - pairingDeeplinkScheme: FfiConverterTypeNativePairingDeeplinkScheme.read(from: &buf) + pairingDeeplinkScheme: FfiConverterTypeNativePairingDeeplinkScheme.read(from: &buf), + dotnsTld: FfiConverterOptionString.read(from: &buf) ) } @@ -4211,6 +4234,7 @@ public struct FfiConverterTypeNativeRuntimeConfig: FfiConverterRustBuffer { FfiConverterOptionData.write(value.localSessionSecret, into: &buf) FfiConverterOptionString.write(value.localSessionLiteUsername, into: &buf) FfiConverterTypeNativePairingDeeplinkScheme.write(value.pairingDeeplinkScheme, into: &buf) + FfiConverterOptionString.write(value.dotnsTld, into: &buf) } } @@ -4977,6 +5001,14 @@ enum NativeRuntimeConfigError: Swift.Error, Equatable, Hashable, Foundation.Loca * Parse failure reason. */reason: String ) + /** + * Configured dotNS TLD is not a recognized entry. + */ + case UnknownDotnsTld( + /** + * Rejected TLD value. + */tld: String + ) /** * Host icon URL used a non-HTTPS scheme. */ @@ -5050,16 +5082,19 @@ public struct FfiConverterTypeNativeRuntimeConfigError: FfiConverterRustBuffer { case 4: return .InvalidHostIcon( reason: try FfiConverterString.read(from: &buf) ) - case 5: return .InsecureHostIcon( + case 5: return .UnknownDotnsTld( + tld: try FfiConverterString.read(from: &buf) + ) + case 6: return .InsecureHostIcon( scheme: try FfiConverterString.read(from: &buf) ) - case 6: return .InvalidDeeplinkScheme( + case 7: return .InvalidDeeplinkScheme( scheme: try FfiConverterString.read(from: &buf) ) - case 7: return .InvalidProductId( + case 8: return .InvalidProductId( productId: try FfiConverterString.read(from: &buf) ) - case 8: return .LocalSessionActivation( + case 9: return .LocalSessionActivation( reason: try FfiConverterString.read(from: &buf) ) @@ -5094,23 +5129,28 @@ public struct FfiConverterTypeNativeRuntimeConfigError: FfiConverterRustBuffer { FfiConverterString.write(reason, into: &buf) - case let .InsecureHostIcon(scheme): + case let .UnknownDotnsTld(tld): writeInt(&buf, Int32(5)) + FfiConverterString.write(tld, into: &buf) + + + case let .InsecureHostIcon(scheme): + writeInt(&buf, Int32(6)) FfiConverterString.write(scheme, into: &buf) case let .InvalidDeeplinkScheme(scheme): - writeInt(&buf, Int32(6)) + writeInt(&buf, Int32(7)) FfiConverterString.write(scheme, into: &buf) case let .InvalidProductId(productId): - writeInt(&buf, Int32(7)) + 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/js/packages/truapi-host/src/runtime.ts b/js/packages/truapi-host/src/runtime.ts index e1cba867..326563eb 100644 --- a/js/packages/truapi-host/src/runtime.ts +++ b/js/packages/truapi-host/src/runtime.ts @@ -92,6 +92,11 @@ export interface ProductRuntimeConfig { /** URI scheme used for wallet pairing deeplinks. */ deeplinkScheme: string; }; + /** + * dotNS TLD of the host's configured network, scoping the reserved + * built-in derivations. Defaults to `dot`. + */ + dotnsTld?: string; } export interface TrUApiProductProvider extends WireProvider, CoreAdmin { diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 75a025d0..ff9cd154 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -232,6 +232,12 @@ export interface HostChainSet { */ network: string; + /** + * dotNS TLD the network's registry declares via its `tld()` view, e.g. + * `dot`, `paseo`, `test`. ``undefined`` when the host does not know it. + */ + tld?: string; + /** * Chains this host serves, keyed by protocol role. */ @@ -615,6 +621,7 @@ export const HostChainSet: S.Codec = S.lazy( (): S.Codec => S.Struct({ network: S.str, + tld: S.Option(S.str), chains: S.Vector(HostChainEntry), }) as S.Codec, ); diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index 2dae02e5..ab3076bb 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, accepts pairing deeplinks, registers statement allowance on-chain, signs. | -| `identity-check` | Probe the root and canonical `uid.dot` identity account for a registered username. | +| `identity-check` | Probe the root and canonical `uid.{tld}` identity account for a registered username. | | `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. | @@ -406,7 +406,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.{tld}` 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 diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index 21b80518..19e969c4 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -724,9 +724,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.{tld}` 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.{tld}` 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); @@ -809,7 +809,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.{tld}` index-0 sr25519 identity account; 4. chooses `auto-` as its local name; 5. tries up to eight available Lite username bases; 6. saves a pending account record; @@ -1065,7 +1065,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.{tld}` index-0 public key and address; - creation timestamp; - attested state; and - exhausted Statement Store periods. @@ -1468,7 +1468,7 @@ 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.{tld}/index_bytes(0)`. For each it prints one of: diff --git a/rust/crates/truapi-host-cli/src/accounts.rs b/rust/crates/truapi-host-cli/src/accounts.rs index 538abdcc..a090dcc1 100644 --- a/rust/crates/truapi-host-cli/src/accounts.rs +++ b/rust/crates/truapi-host-cli/src/accounts.rs @@ -374,7 +374,7 @@ 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.tld)?; for attempt in 0..8 { let lite_username = generated_username(username_prefix, attempt); @@ -408,7 +408,7 @@ async fn create_auto_account( ); record.lite_username = attest_record(network, &record).await?; - wait_for_ring_membership(network.people_ws, &identity.entropy).await?; + wait_for_ring_membership(network.people_ws, &identity.entropy, network.tld).await?; record.attested = true; store.upsert(record.clone()); store.save()?; @@ -423,16 +423,19 @@ async fn ensure_record_ready( network: NetworkConfig, record: &AccountRecord, ) -> Result { - let identity = identity_from_mnemonic(&record.mnemonic)?; + let identity = identity_from_mnemonic(&record.mnemonic, network.tld)?; let mut record = record.clone(); if !record.attested { record.lite_username = attest_record(network, &record).await?; record.attested = true; } else { - record.lite_username = - attestation::registered_lite_username(network.people_ws, &identity.entropy) - .await - .with_context(|| format!("resolve Lite username for account {}", record.name))?; + record.lite_username = attestation::registered_lite_username( + network.people_ws, + &identity.entropy, + network.tld, + ) + .await + .with_context(|| format!("resolve Lite username for account {}", record.name))?; } if store .get(network.id, &record.name) @@ -441,7 +444,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.people_ws, &identity.entropy, network.tld).await?; Ok(record) } @@ -452,6 +455,7 @@ async fn attest_record(network: NetworkConfig, record: &AccountRecord) -> Result people_ws: network.people_ws.to_string(), entropy, username_base: record.lite_username.clone(), + dotns_tld: network.tld.to_string(), }) .await .with_context(|| format!("attest account {}", record.name))?; @@ -473,24 +477,24 @@ fn resolved_lite_username(username: &str) -> bool { /// Every personhood collection candidate for `entropy`, 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], tld: &str) -> Vec { vec![ alloc::CollectionCandidate { collection: PersonhoodCollection::People, - entropy: derive_full_person_ring_vrf_entropy(entropy), + entropy: derive_full_person_ring_vrf_entropy(entropy, tld), }, alloc::CollectionCandidate { collection: PersonhoodCollection::LitePeople, - entropy: derive_lite_person_ring_vrf_entropy(entropy), + entropy: derive_lite_person_ring_vrf_entropy(entropy, tld), }, ] } -async fn wait_for_ring_membership(people_ws: &str, entropy: &[u8]) -> Result<()> { +async fn wait_for_ring_membership(people_ws: &str, entropy: &[u8], tld: &str) -> Result<()> { const MAX_ATTEMPTS: usize = 10; const SLEEP: Duration = Duration::from_secs(4); - let candidates = collection_candidates(entropy); + let candidates = collection_candidates(entropy, tld); let mut metadata = None; for attempt in 1..=MAX_ATTEMPTS { crate::terminal_ui::update_activity( @@ -587,10 +591,10 @@ struct SignerIdentity { address: String, } -fn identity_from_mnemonic(mnemonic: &str) -> Result { +fn identity_from_mnemonic(mnemonic: &str, tld: &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, tld) + .map_err(|err| anyhow::anyhow!("identity derivation failed: {err}"))?; let public_key = candidate.public.to_bytes(); Ok(SignerIdentity { entropy, diff --git a/rust/crates/truapi-host-cli/src/attestation.rs b/rust/crates/truapi-host-cli/src/attestation.rs index ad906e27..a413e485 100644 --- a/rust/crates/truapi-host-cli/src/attestation.rs +++ b/rust/crates/truapi-host-cli/src/attestation.rs @@ -30,6 +30,8 @@ pub struct AttestConfig { pub entropy: Vec, /// Requested lite username base (6+ lowercase letters, no digits). pub username_base: String, + /// dotNS TLD of the target network, scoping the identity derivation. + pub dotns_tld: String, } /// Check whether a lite username base is available through the identity @@ -67,8 +69,13 @@ pub async fn attest(config: &AttestConfig) -> Result { .build()?; let verifier = fetch_verifier(&client, &config.backend_base).await?; - let registration = build_lite_registration(&config.entropy, verifier, &config.username_base) - .map_err(|reason| anyhow::anyhow!("failed to build registration params: {reason}"))?; + let registration = build_lite_registration( + &config.entropy, + verifier, + &config.username_base, + &config.dotns_tld, + ) + .map_err(|reason| anyhow::anyhow!("failed to build registration params: {reason}"))?; debug!( candidate = %registration.candidate_account_id, "attesting lite username '{}'", @@ -101,9 +108,13 @@ pub async fn attest(config: &AttestConfig) -> Result { /// Older CLI account records stored the requested username base rather than /// the final `name.discriminator` assigned by the People chain. Reading the /// consumer record repairs those records without re-attesting the account. -pub async fn registered_lite_username(people_ws: &str, entropy: &[u8]) -> Result { - let identity = derive_identity_keypair(entropy) - .map_err(|err| anyhow::anyhow!("uid.dot identity derivation failed: {err}"))?; +pub async fn registered_lite_username( + people_ws: &str, + entropy: &[u8], + tld: &str, +) -> Result { + let identity = derive_identity_keypair(entropy, tld) + .map_err(|err| anyhow::anyhow!("identity derivation failed: {err}"))?; let storage_key = format!( "0x{}", hex::encode(resources_consumers_storage_key(&identity.public.to_bytes())) @@ -119,11 +130,11 @@ pub async fn registered_lite_username(people_ws: &str, entropy: &[u8]) -> Result /// Probe the People chain for the bare root and canonical RFC-0022 `uid.dot` /// identity account, printing any `Resources.Consumers` record. Used to /// confirm a pre-onboarded account. -pub async fn check_identity(people_ws: &str, entropy: &[u8]) -> Result<()> { +pub async fn check_identity(people_ws: &str, entropy: &[u8], tld: &str) -> 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 identity = derive_identity_keypair(entropy, tld) + .map_err(|err| anyhow::anyhow!("identity derivation failed: {err}"))?; for (label, public) in [ ("", root.public.to_bytes()), diff --git a/rust/crates/truapi-host-cli/src/main.rs b/rust/crates/truapi-host-cli/src/main.rs index 3618d3da..0c4eee22 100644 --- a/rust/crates/truapi-host-cli/src/main.rs +++ b/rust/crates/truapi-host-cli/src/main.rs @@ -361,7 +361,8 @@ async fn main() -> Result<()> { let entropy = bip39::Mnemonic::parse(mnemonic.trim()) .context("invalid BIP-39 mnemonic")? .to_entropy(); - attestation::check_identity(network.config().people_ws, &entropy).await + let config = network.config(); + attestation::check_identity(config.people_ws, &entropy, config.tld).await } Command::AllocCheck { mnemonic, @@ -400,7 +401,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.tld); if submit && target.is_none() { bail!("--target is required with --submit; a claim has to credit an account"); @@ -558,7 +559,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.tld); if submit && target.is_none() { bail!("--target is required with --submit; the all-zero default is read-only"); @@ -812,6 +813,7 @@ async fn run_pairing_host( network.bulletin_genesis, DEEPLINK_SCHEME.to_string(), ) + .and_then(|config| config.with_dotns_tld(network.tld.to_string())) .context("invalid pairing host config")?; let storage_platform = platform.clone(); let chat_host = args.execution_kind.chat_host(); @@ -1113,8 +1115,12 @@ async fn start_signing_host( lite_username_prefix: None, }) .await?; - match attestation::registered_lite_username(network.people_ws, &explicit_signer.entropy) - .await + match attestation::registered_lite_username( + network.people_ws, + &explicit_signer.entropy, + network.tld, + ) + .await { Ok(user_id) => explicit_signer.lite_username = Some(user_id), Err(error) => { @@ -1212,6 +1218,7 @@ fn build_signing_runtime( network.people_genesis, network.bulletin_genesis, ) + .and_then(|config| config.with_dotns_tld(network.tld.to_string())) .context("invalid signing host config")?; let runtime = Arc::new(SigningHostRuntime::with_chat_platform( platform, @@ -1442,7 +1449,14 @@ async fn prepare_pairing_response(session: &mut SigningHostSession, deeplink: &s signer.account_name.clone(), ) }; - match register_pairing_allowances(session.network.people_ws, &entropy, deeplink).await { + match register_pairing_allowances( + session.network.people_ws, + &entropy, + deeplink, + session.network.tld, + ) + .await + { Ok(device) => { track_pairing_renewal_targets(session, device).await; return Ok(()); @@ -1787,21 +1801,22 @@ async fn register_pairing_allowances( statement_store_url: &str, entropy: &[u8], deeplink: &str, + tld: &str, ) -> Result<[u8; 32]> { use truapi_server::host_logic::product_account::derive_identity_keypair; use truapi_server::host_logic::sso::pairing::{ VersionedHandshakeProposal, decode_pairing_deeplink, }; - let identity = derive_identity_keypair(entropy) - .map_err(|e| anyhow::anyhow!("uid.dot identity derivation failed: {e}"))? + let identity = derive_identity_keypair(entropy, tld) + .map_err(|e| anyhow::anyhow!("identity derivation failed: {e}"))? .public .to_bytes(); let VersionedHandshakeProposal::V2(proposal) = decode_pairing_deeplink(deeplink).map_err(anyhow::Error::msg)?; let device = proposal.device.statement_account_id; - let candidates = accounts::collection_candidates(entropy); + let candidates = accounts::collection_candidates(entropy, tld); let rpc = alloc::rpc::RpcClient::connect(statement_store_url) .await .map_err(anyhow::Error::msg)?; diff --git a/rust/crates/truapi-host-cli/src/network.rs b/rust/crates/truapi-host-cli/src/network.rs index f38854b7..4b3c06e4 100644 --- a/rust/crates/truapi-host-cli/src/network.rs +++ b/rust/crates/truapi-host-cli/src/network.rs @@ -25,6 +25,7 @@ impl Network { match self { Self::PaseoNextV2 => NetworkConfig { id: "paseo-next-v2", + tld: "paseo", identity_backend_base: "https://identity-backend-next.parity-testnet.parity.io/api/v1", people_ws: "wss://paseo-people-next-system-rpc.polkadot.io", bulletin_ws: "wss://paseo-bulletin-next-rpc.polkadot.io", @@ -42,6 +43,7 @@ impl Network { }, Self::Previewnet => NetworkConfig { id: "previewnet", + tld: "test", identity_backend_base: "https://polkadot-app-stg.parity.io/api/v1", people_ws: "wss://previewnet.substrate.dev/people", bulletin_ws: "wss://previewnet.substrate.dev/bulletin", @@ -113,6 +115,8 @@ const PREVIEWNET_CHAIN_ENDPOINTS: &[ChainEndpoint] = &[ #[derive(Debug, Clone, Copy)] pub struct NetworkConfig { pub id: &'static str, + /// dotNS TLD the network's registry declares, mirroring its `tld()` view. + pub tld: &'static str, pub identity_backend_base: &'static str, pub people_ws: &'static str, #[allow(dead_code)] @@ -155,6 +159,7 @@ impl NetworkConfig { pub fn host_chain_set(&self) -> HostChainSet { HostChainSet { network: self.id.to_string(), + tld: Some(self.tld.to_string()), chains: vec![ HostChainEntry { identifier: ChainIdentifier::People, diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 37ce7524..810e02cf 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -54,6 +54,9 @@ pub struct HostRuntimeConfig { pub host_info: HostInfo, /// Platform metadata. pub platform_info: PlatformInfo, + /// dotNS TLD of the host's configured network, scoping the reserved + /// built-in derivations (`uid.{tld}`, `peopl.{tld}`). `None` means `dot`. + pub dotns_tld: Option, } /// Pairing-host runtime configuration supplied by the embedding host. @@ -162,11 +165,29 @@ impl HostRuntimeConfig { Ok(Self { host_info, platform_info, + dotns_tld: None, }) } + + /// Scope the reserved built-in derivations to `tld`, the dotNS TLD the + /// host's network declares. Must be a recognized entry of [`DOTNS_TLDS`]. + pub fn with_dotns_tld(mut self, tld: String) -> Result { + if !DOTNS_TLDS.contains(&tld.as_str()) { + return Err(RuntimeConfigValidationError::UnknownDotnsTld { tld }); + } + self.dotns_tld = Some(tld); + Ok(self) + } } impl PairingHostConfig { + /// Scope the reserved built-in derivations to `tld`. See + /// [`HostRuntimeConfig::with_dotns_tld`]. + pub fn with_dotns_tld(mut self, tld: String) -> Result { + self.host = self.host.with_dotns_tld(tld)?; + Ok(self) + } + /// Build a pairing-host runtime config, validating fields whose /// representation cannot be made invalid by Rust types alone. pub fn new( @@ -193,6 +214,13 @@ impl PairingHostConfig { } impl SigningHostConfig { + /// Scope the reserved built-in derivations to `tld`. See + /// [`HostRuntimeConfig::with_dotns_tld`]. + pub fn with_dotns_tld(mut self, tld: String) -> Result { + self.host = self.host.with_dotns_tld(tld)?; + Ok(self) + } + /// Build a signing-host runtime config, validating fields whose /// representation cannot be made invalid by Rust types alone. pub fn new( @@ -802,6 +830,12 @@ pub enum RuntimeConfigValidationError { /// Actual URL scheme. scheme: String, }, + /// Configured dotNS TLD is not a recognized [`DOTNS_TLDS`] entry. + #[display("dotns_tld must be one of the recognized dotNS TLDs, got {tld:?}")] + UnknownDotnsTld { + /// Rejected TLD value. + tld: String, + }, /// Pairing deeplink scheme included a URL separator. #[display("pairing_deeplink_scheme must not include ://, got {scheme:?}")] InvalidDeeplinkScheme { @@ -1068,6 +1102,9 @@ pub struct HostChainEntry { pub struct HostChainSet { /// Ecosystem the host is configured for, e.g. "polkadot", "paseo". pub network: String, + /// dotNS TLD the network's registry declares via its `tld()` view, e.g. + /// `dot`, `paseo`, `test`. `None` when the host does not know it. + pub tld: Option, /// Chains this host serves, keyed by protocol role. pub chains: Vec, } diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index 38899726..22587ef6 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -190,7 +190,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.{tld}` index-0 product account for the configured network TLD (default `dot`). 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/core.rs b/rust/crates/truapi-server/src/core.rs index dcd45211..6433c703 100644 --- a/rust/crates/truapi-server/src/core.rs +++ b/rust/crates/truapi-server/src/core.rs @@ -14,7 +14,9 @@ use crate::dispatcher::Dispatcher; use crate::frame::ProtocolMessage; use crate::generated::dispatcher; use crate::host_logic::session::SessionState; -use crate::runtime::{PairingHostRole, ProductAuthority, ProductRuntimeHost, RuntimeServices}; +use crate::runtime::{ + PairingHostRole, ProductAuthority, ProductRuntimeHost, RuntimeServices, dotns_tld, +}; use crate::subscription::Spawner; use crate::transport::Transport; @@ -60,6 +62,7 @@ impl TrUApiCore { host_config.people_chain_genesis_hash, host_config.bulletin_chain_genesis_hash, spawner.clone(), + dotns_tld(&host_config.host), ); let pairing_host = PairingHostRole::new(services.clone(), host_config); pairing_host.clone().start_session_store_sync(spawner); diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 6e623db3..3824ce1b 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -30,7 +30,8 @@ use crate::frame::ProtocolMessage; use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, SsoRequestOutcome, v1}; use crate::runtime::{ ChatConnection, LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, - ResponderExit, RuntimeServices, SigningHostRole, answer_remote_message, respond_to_pairing, + ResponderExit, RuntimeServices, SigningHostRole, answer_remote_message, dotns_tld, + respond_to_pairing, }; use crate::subscription::{HostInitiatedSubscriptionManager, Spawner}; use crate::transport::Transport; @@ -117,6 +118,7 @@ impl PairingHostRuntime { config.people_chain_genesis_hash, config.bulletin_chain_genesis_hash, spawner.clone(), + dotns_tld(&config.host), chat_platform, ); let pairing_host = PairingHostRole::new(services.clone(), config); @@ -405,6 +407,7 @@ impl SigningHostRuntime { config.people_chain_genesis_hash, config.bulletin_chain_genesis_hash, spawner, + dotns_tld(&config.host), chat_platform, ); let signing_host = SigningHostRole::new(services.clone()); diff --git a/rust/crates/truapi-server/src/host_logic/attestation.rs b/rust/crates/truapi-server/src/host_logic/attestation.rs index 098edec4..3049521c 100644 --- a/rust/crates/truapi-server/src/host_logic/attestation.rs +++ b/rust/crates/truapi-server/src/host_logic/attestation.rs @@ -82,13 +82,14 @@ pub fn build_lite_registration( entropy: &[u8], verifier_account_id: [u8; 32], username_base: &str, + dotns_tld: &str, ) -> 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.{tld}` default product account. + let candidate = derive_identity_keypair(entropy, dotns_tld)?; 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, dotns_tld); let vrf_secret = BandersnatchVrfVerifiable::new_secret(vrf_entropy); let ring_vrf_key = BandersnatchVrfVerifiable::member_from_secret(&vrf_secret); @@ -172,13 +173,16 @@ mod tests { #[test] fn registration_params_have_expected_shapes_and_verify() { let verifier = [0x11u8; 32]; - let reg = build_lite_registration(&ENTROPY, verifier, "headlesstester").unwrap(); + let reg = build_lite_registration(&ENTROPY, verifier, "headlesstester", "dot").unwrap(); assert_eq!( reg.candidate_public_key, - derive_identity_keypair(&ENTROPY).unwrap().public.to_bytes(), + derive_identity_keypair(&ENTROPY, "dot") + .unwrap() + .public + .to_bytes(), "registration uses the canonical uid.dot identity account" ); - let lite_entropy = derive_lite_person_ring_vrf_entropy(&ENTROPY); + let lite_entropy = derive_lite_person_ring_vrf_entropy(&ENTROPY, "dot"); assert_eq!( reg.ring_vrf_key, BandersnatchVrfVerifiable::member_from_secret(&BandersnatchVrfVerifiable::new_secret( @@ -266,8 +270,8 @@ mod tests { #[test] fn registration_is_deterministic_per_entropy_and_username() { let verifier = [0x22u8; 32]; - let first = build_lite_registration(&ENTROPY, verifier, "aliceheadless").unwrap(); - let again = build_lite_registration(&ENTROPY, verifier, "aliceheadless").unwrap(); + let first = build_lite_registration(&ENTROPY, verifier, "aliceheadless", "dot").unwrap(); + let again = build_lite_registration(&ENTROPY, verifier, "aliceheadless", "dot").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/features.rs b/rust/crates/truapi-server/src/host_logic/features.rs index f4e44c43..2255204a 100644 --- a/rust/crates/truapi-server/src/host_logic/features.rs +++ b/rust/crates/truapi-server/src/host_logic/features.rs @@ -65,6 +65,7 @@ mod tests { fn paseo_set() -> HostChainSet { HostChainSet { network: "paseo".to_string(), + tld: Some("paseo".to_string()), chains: vec![ HostChainEntry { identifier: ChainIdentifier::AssetHub, @@ -174,6 +175,7 @@ mod genesis_lookup_tests { fn set() -> HostChainSet { HostChainSet { network: "paseo".to_string(), + tld: Some("paseo".to_string()), chains: vec![HostChainEntry { identifier: ChainIdentifier::AssetHub, genesis_hash: [0xab; 32], 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 67c9d58c..832de7b1 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,11 @@ //! 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.{tld}` identity account and +//! the legacy `peopl.{tld}` full/lite ring-VRF keyed-hash paths used by +//! pairing attestation, where `{tld}` is the host's configured network TLD +//! (default `dot`). 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,10 +20,19 @@ 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"; +/// dotNS TLD assumed when a host does not configure one, keeping today's +/// mobile-pinned `uid.dot` and `peopl.dot` derivations. +pub const DEFAULT_DOTNS_TLD: &str = "dot"; +/// Reserved RFC-0022 product id for the public light-person identity account +/// on the network serving `tld`. +pub fn identity_product_id(tld: &str) -> String { + format!("uid.{tld}") +} +/// Reserved RFC-0022 ring-VRF domain for full and light personhood on the +/// network serving `tld`. +pub fn personhood_product_id(tld: &str) -> String { + format!("peopl.{tld}") +} const RING_VRF_ROOT_KEY: &[u8] = b"ring-vrf"; /// Substrate sr25519 signing-context string, shared by every sr25519 signature @@ -86,29 +97,30 @@ pub fn derivation_index_bytes(index: &truapi::v01::DerivationIndex) -> [u8; 32] } } /// Derive the RFC-0022 public light-person identity account: -/// `//product//uid.dot/index_bytes(0)`. -pub fn derive_identity_keypair(entropy: &[u8]) -> Result { +/// `//product//uid.{tld}/index_bytes(0)`. +pub fn derive_identity_keypair(entropy: &[u8], tld: &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(tld)])?; 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) +/// `hash(root_entropy, "ring-vrf")//peopl.{tld}//index_bytes(0)`. +pub fn derive_full_person_ring_vrf_entropy(root_entropy: &[u8], tld: &str) -> [u8; 32] { + derive_person_ring_vrf_entropy(root_entropy, tld, 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) +/// `hash(root_entropy, "ring-vrf")//peopl.{tld}//index_bytes(1)`. +pub fn derive_lite_person_ring_vrf_entropy(root_entropy: &[u8], tld: &str) -> [u8; 32] { + derive_person_ring_vrf_entropy(root_entropy, tld, 1) } -fn derive_person_ring_vrf_entropy(root_entropy: &[u8], index: u32) -> [u8; 32] { +fn derive_person_ring_vrf_entropy(root_entropy: &[u8], tld: &str, index: u32) -> [u8; 32] { derive_ring_vrf_entropy( root_entropy, - PERSONHOOD_PRODUCT_ID, + &personhood_product_id(tld), &truapi::v01::DerivationIndex::Index(index), ) .expect("the reserved personhood product id is a valid junction") @@ -389,11 +401,17 @@ 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, + DEFAULT_DOTNS_TLD + )), "c47086f94a7f4c05b7afd9f2339d3fea168f3823b5424ba1f7b31043d8ef60af" ); assert_eq!( - hex::encode(derive_lite_person_ring_vrf_entropy(&root_entropy)), + hex::encode(derive_lite_person_ring_vrf_entropy( + &root_entropy, + DEFAULT_DOTNS_TLD + )), "8d7f5e1510a7e8d813887e100f5a260ec9de60e68695477b93360ee7e3d16a9f" ); } @@ -415,10 +433,13 @@ 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, DEFAULT_DOTNS_TLD).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(); + let uid_subtree = derive_hard_path_from_keypair( + root, + &[PRODUCT_JUNCTION, &identity_product_id(DEFAULT_DOTNS_TLD)], + ) + .unwrap(); let expected = uid_subtree .derived_key_simple(ChainCode(index_bytes(0)), []) .0; @@ -437,6 +458,20 @@ mod tests { ); } + #[test] + fn built_in_derivations_are_network_scoped() { + let entropy = [0xAB; 16]; + let dot = derive_identity_keypair(&entropy, "dot").unwrap(); + let test = derive_identity_keypair(&entropy, "test").unwrap(); + assert_ne!(dot.public, test.public); + + let root_entropy: Vec = (1..=32).collect(); + assert_ne!( + derive_full_person_ring_vrf_entropy(&root_entropy, "dot"), + derive_full_person_ring_vrf_entropy(&root_entropy, "test"), + ); + } + #[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 2d297616..b5e73b7f 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -202,6 +202,9 @@ pub struct NativeRuntimeConfig { pub local_session_lite_username: Option, /// Deeplink scheme used in pairing QR payloads. pub pairing_deeplink_scheme: NativePairingDeeplinkScheme, + /// dotNS TLD of the host's configured network, scoping the reserved + /// built-in derivations. `None` means `dot`. + pub dotns_tld: Option, } /// Process-owned native host configuration shared by every product execution. @@ -225,6 +228,9 @@ pub struct NativeHostRuntimeConfig { pub local_session_secret: Option>, /// Optional lite username attached to the local signing-host session. pub local_session_lite_username: Option, + /// dotNS TLD of the host's configured network, scoping the reserved + /// built-in derivations. `None` means `dot`. + pub dotns_tld: Option, } /// Trusted identity attached by a native host to one executable connection. @@ -276,6 +282,12 @@ pub enum NativeRuntimeConfigError { /// Parse failure reason. reason: String, }, + /// Configured dotNS TLD is not a recognized entry. + #[error("dotns_tld must be one of the recognized dotNS TLDs, got {tld:?}")] + UnknownDotnsTld { + /// Rejected TLD value. + tld: String, + }, /// Host icon URL used a non-HTTPS scheme. #[error("host_icon must use https scheme, got {scheme:?}")] InsecureHostIcon { @@ -319,6 +331,7 @@ impl TryFrom for NativeResolvedRuntimeConfig { local_session_secret, local_session_lite_username, pairing_deeplink_scheme: _, + dotns_tld, } = config; let host: NativeResolvedHostRuntimeConfig = NativeHostRuntimeConfig { host_name, @@ -330,6 +343,7 @@ impl TryFrom for NativeResolvedRuntimeConfig { bulletin_chain_genesis_hash, local_session_secret, local_session_lite_username, + dotns_tld, } .try_into()?; let product = NativeProductExecutionConfig { @@ -370,6 +384,10 @@ impl TryFrom for NativeResolvedHostRuntimeConfig { people_chain_genesis_hash, bulletin_chain_genesis_hash, )?; + let signing = match config.dotns_tld { + Some(tld) => signing.with_dotns_tld(tld)?, + None => signing, + }; Ok(Self { signing, local_session_secret: config.local_session_secret, @@ -398,6 +416,7 @@ impl From for NativeRuntimeConfigError { RuntimeConfigValidationError::InvalidHostIcon { source } => Self::InvalidHostIcon { reason: source.to_string(), }, + RuntimeConfigValidationError::UnknownDotnsTld { tld } => Self::UnknownDotnsTld { tld }, RuntimeConfigValidationError::InsecureHostIcon { scheme } => { Self::InsecureHostIcon { scheme } } @@ -2213,6 +2232,7 @@ mod tests { fn supported_chains(&self) -> Result { Ok(truapi_platform::HostChainSet { network: "paseo".to_string(), + tld: Some("paseo".to_string()), chains: Vec::new(), }) } @@ -2339,6 +2359,7 @@ mod tests { local_session_secret: None, local_session_lite_username: None, pairing_deeplink_scheme: NativePairingDeeplinkScheme::PolkadotApp, + dotns_tld: None, } } @@ -2352,6 +2373,7 @@ mod tests { people_chain_genesis_hash: vec![0xa2; 32], bulletin_chain_genesis_hash: vec![0xbb; 32], local_session_secret: Some(vec![7; 32]), + dotns_tld: None, local_session_lite_username: Some("alice".to_string()), } } @@ -3238,6 +3260,7 @@ mod tests { fn supported_chains(&self) -> Result { Ok(truapi_platform::HostChainSet { network: "paseo".to_string(), + tld: Some("paseo".to_string()), chains: Vec::new(), }) } @@ -3386,6 +3409,7 @@ mod tests { fn supported_chains(&self) -> Result { Ok(truapi_platform::HostChainSet { network: "paseo".to_string(), + tld: Some("paseo".to_string()), chains: Vec::new(), }) } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index a56627db..8505f06b 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -63,7 +63,7 @@ pub(crate) use chat::{ChatConnection, chat_platform_for}; #[cfg(test)] use pairing_host::PairingHost; pub(crate) use pairing_host::PairingHost as PairingHostRole; -pub(crate) use services::RuntimeServices; +pub(crate) use services::{RuntimeServices, dotns_tld}; pub use signing_host::ResponderExit; #[cfg(not(target_arch = "wasm32"))] pub use signing_host::StatementRenewalTarget; @@ -445,6 +445,7 @@ impl ProductRuntimeHost { host_config.people_chain_genesis_hash, host_config.bulletin_chain_genesis_hash, spawner.clone(), + dotns_tld(&host_config.host), ); let pairing_host = PairingHost::new(services.clone(), host_config); let core_instance = services.next_core_instance(); @@ -3047,6 +3048,7 @@ mod tests { host_config.people_chain_genesis_hash, host_config.bulletin_chain_genesis_hash, spawner.clone(), + "dot".to_string(), ); let chat_platform = Arc::new(RecordingChatPlatform::default()); let pairing_host = PairingHost::new(services.clone(), host_config); @@ -3189,6 +3191,7 @@ mod tests { host_config.people_chain_genesis_hash, host_config.bulletin_chain_genesis_hash, spawner.clone(), + "dot".to_string(), ); let chat_platform = Arc::new(RecordingChatPlatform::default()); let pairing_host = PairingHost::new(services.clone(), host_config); @@ -3272,6 +3275,7 @@ mod tests { host_config.people_chain_genesis_hash, host_config.bulletin_chain_genesis_hash, spawner.clone(), + "dot".to_string(), ); let chat_platform = Arc::new(RecordingChatPlatform::default()); let pairing_host = PairingHost::new(services.clone(), host_config); @@ -3356,6 +3360,7 @@ mod tests { host_config.people_chain_genesis_hash, host_config.bulletin_chain_genesis_hash, spawner.clone(), + "dot".to_string(), ); let chat_platform = Arc::new(RecordingChatPlatform::default()); let pairing_host = PairingHost::new(services.clone(), host_config); @@ -3402,6 +3407,7 @@ mod tests { host_config.people_chain_genesis_hash, host_config.bulletin_chain_genesis_hash, spawner.clone(), + dotns_tld(&host_config.host), ); let pairing_host = PairingHost::new(services.clone(), host_config); let first = ProductRuntimeHost::from_services( diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index bb093146..00dfc2c8 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -49,6 +49,9 @@ pub(crate) struct RuntimeServices { statement_cache: Mutex, /// Task spawner for background runtime work. pub(crate) spawner: Spawner, + /// dotNS TLD of the host's configured network, scoping the reserved + /// built-in derivations. Defaults to `dot`. + pub(crate) dotns_tld: String, /// Serializes the read-or-create of the persisted device encryption key. /// Concurrent first-time readers would otherwise each generate a secret and /// persist it, leaving peers addressing an overwritten key. @@ -56,6 +59,15 @@ pub(crate) struct RuntimeServices { next_core_instance: AtomicU64, } +/// The dotNS TLD a host config scopes built-in derivations to, `dot` unless +/// configured otherwise. +pub(crate) fn dotns_tld(config: &truapi_platform::HostRuntimeConfig) -> String { + config + .dotns_tld + .clone() + .unwrap_or_else(|| crate::host_logic::product_account::DEFAULT_DOTNS_TLD.to_string()) +} + impl RuntimeServices { /// Build role-neutral runtime services from the platform, the People-chain /// genesis hash used by statement-store backed protocols, and the @@ -65,6 +77,7 @@ impl RuntimeServices { people_chain_genesis_hash: [u8; 32], bulletin_chain_genesis_hash: [u8; 32], spawner: Spawner, + dotns_tld: String, ) -> Arc { let chain_provider = Arc::new(HostChainProvider { platform: platform.clone(), @@ -84,6 +97,7 @@ impl RuntimeServices { preimage_cache: Mutex::new(PreimageCache::default()), statement_cache: Mutex::new(StatementCache::default()), spawner, + dotns_tld, device_encryption_key: futures::lock::Mutex::new(()), next_core_instance: AtomicU64::new(1), }) @@ -95,6 +109,7 @@ impl RuntimeServices { people_chain_genesis_hash: [u8; 32], bulletin_chain_genesis_hash: [u8; 32], spawner: Spawner, + dotns_tld: String, chat_platform: Option>, ) -> Arc { let services = Self::new( @@ -102,6 +117,7 @@ impl RuntimeServices { people_chain_genesis_hash, bulletin_chain_genesis_hash, spawner, + dotns_tld, ); let Some(chat_platform) = chat_platform else { return services; diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index f2220c3b..514aa1a1 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -150,6 +150,7 @@ impl SigningHost { [0; 32], [0xbb; 32], crate::test_support::test_spawner(), + "dot".to_string(), ); Arc::new(Self { services, @@ -302,7 +303,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.services.dotns_tld).map_err(product_authority_error) } fn install_local_session(&self, secret: Zeroizing>, session: SessionInfo) { @@ -398,11 +399,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.services.dotns_tld), }, CollectionCandidate { collection: PersonhoodCollection::LitePeople, - entropy: derive_lite_person_ring_vrf_entropy(&root), + entropy: derive_lite_person_ring_vrf_entropy(&root, &self.services.dotns_tld), }, ]) } @@ -1311,6 +1312,7 @@ mod tests { config.people_chain_genesis_hash, config.bulletin_chain_genesis_hash, test_spawner(), + "dot".to_string(), ); let signing_host = SigningHostRole::new(services.clone()); (services, signing_host) @@ -1649,7 +1651,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, "dot") .expect("uid.dot identity derivation") .public .to_bytes(); @@ -2094,7 +2096,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, "dot").unwrap(); let request = |account| SignRawAuthorityRequest::LegacyAccount { account, request: v01::HostSignRawWithLegacyAccountRequest { 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 df9a0c92..6eec78cd 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.services.dotns_tld) .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 afb299dd..a844c647 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 @@ -77,8 +77,9 @@ const MAX_SERVED_REQUEST_IDS: usize = 1024; fn derive_responder_identity( entropy: &[u8], + dotns_tld: &str, ) -> Result<(ResponderIdentity, [u8; 32]), ProductAccountError> { - let statement = derive_identity_keypair(entropy)?; + let statement = derive_identity_keypair(entropy, dotns_tld)?; 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); @@ -230,11 +231,13 @@ pub(crate) async fn respond_to_pairing( .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.{tld} default + // account. 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, &services.dotns_tld) + .map_err(|err| format!("responder identity derivation failed: {err}"))?; let device_enc_pub_key = x25519_public_key(services.device_encryption_secret().await?); let session = establish_responder_session_info( &identity, @@ -1590,6 +1593,7 @@ mod tests { config.people_chain_genesis_hash, config.bulletin_chain_genesis_hash, test_spawner(), + "dot".to_string(), ); let signing_host = SigningHost::new(services.clone()); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) @@ -1716,7 +1720,7 @@ mod tests { .unwrap() .identity_account_id .unwrap(); - let (identity, _) = derive_responder_identity(&ENTROPY).unwrap(); + let (identity, _) = derive_responder_identity(&ENTROPY, "dot").unwrap(); assert_eq!(identity.statement_public_key, local_identity); let (_, host_encryption_public_key) = @@ -1966,7 +1970,7 @@ mod tests { create_transaction_confirmed: true, ..StubPlatform::default() })); - let identity = derive_identity_keypair(&ENTROPY).unwrap(); + let identity = derive_identity_keypair(&ENTROPY, "dot").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 97da9839..86ce1392 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -457,7 +457,13 @@ mod tests { fn signing_host_runtime(product_id: &str) -> (ProductRuntimeHost, Arc) { let platform: Arc = Arc::new(StubPlatform::default()); - let services = RuntimeServices::new(platform.clone(), [0; 32], [0xbb; 32], test_spawner()); + let services = RuntimeServices::new( + platform.clone(), + [0; 32], + [0xbb; 32], + test_spawner(), + "dot".to_string(), + ); let signing_host = SigningHostRole::new(services.clone()); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index af42352d..096e7b0c 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -933,6 +933,7 @@ impl PlatformFeatures for StubPlatform { async fn supported_chains(&self) -> Result { Ok(truapi_platform::HostChainSet { network: "paseo".to_string(), + tld: Some("paseo".to_string()), chains: vec![truapi_platform::HostChainEntry { identifier: v01::ChainIdentifier::AssetHub, genesis_hash: [0xaa; 32], diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index 1c33d674..b29d635d 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -466,7 +466,7 @@ fn pairing_host_config_from_js(value: &JsValue) -> Result Result config + .with_dotns_tld(tld) + .map_err(runtime_config_validation_to_js), + None => Ok(config), + } } #[cfg(feature = "wasm-signing-host")] @@ -510,7 +517,7 @@ fn signing_host_config_from_js(value: &JsValue) -> Result Result config + .with_dotns_tld(tld) + .map_err(runtime_config_validation_to_js), + None => Ok(config), + } } fn product_context_from_js(value: &JsValue) -> Result { @@ -590,6 +604,9 @@ fn runtime_config_validation_to_js(err: RuntimeConfigValidationError) -> JsValue "runtimeConfig.productId must be a dotNS or localhost product identifier, got {product_id:?}" )) } + RuntimeConfigValidationError::UnknownDotnsTld { tld } => JsValue::from_str(&format!( + "runtimeConfig.dotnsTld must be a recognized dotNS TLD, got {tld:?}" + )), } } diff --git a/rust/crates/truapi-server/tests/common/mod.rs b/rust/crates/truapi-server/tests/common/mod.rs index 78c30a95..9c4cd589 100644 --- a/rust/crates/truapi-server/tests/common/mod.rs +++ b/rust/crates/truapi-server/tests/common/mod.rs @@ -135,6 +135,7 @@ impl Features for WireShapePlatform { async fn supported_chains(&self) -> Result { Ok(truapi_platform::HostChainSet { network: "paseo".to_string(), + tld: Some("paseo".to_string()), chains: vec![truapi_platform::HostChainEntry { identifier: v01::ChainIdentifier::AssetHub, genesis_hash: [0xaa; 32],