From 614e0a8857615fad6483442297119e4280b88a21 Mon Sep 17 00:00:00 2001 From: valentunn Date: Mon, 20 Jul 2026 19:44:13 +0700 Subject: [PATCH 1/6] Rfc 0022 --- docs/rfcs/0022-account-derivations.md | 334 ++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 docs/rfcs/0022-account-derivations.md diff --git a/docs/rfcs/0022-account-derivations.md b/docs/rfcs/0022-account-derivations.md new file mode 100644 index 00000000..a04bc559 --- /dev/null +++ b/docs/rfcs/0022-account-derivations.md @@ -0,0 +1,334 @@ +--- +title: "Account key derivations" +owner: "@valentunn" +--- + +# RFC 0022 — Account key derivations + +| | | +| --------------- | ---------------------------------------------------------------------------------------- | +| **RFC Number** | 22 | +| **Start Date** | 2026-07-20 | +| **Description** | Specify how hosts derive product accounts, ring-VRF keys, and ECDH keys from the user's root account. | +| **Authors** | Valentin Sergeev | + +## Summary + +This RFC defines the derivation scheme for every key rooted in the user's main +account: + +- **Product accounts** — sr25519 keys at `//{productId}/{suffix}`: a hard + junction at the product boundary, plain soft derivation below it, no secret + path components. +- **Ring-VRF keys** — a hard-only keyed-hash chain rooted at + `hash(root_entropy, "ring-vrf")`, with paths `//{domain}//{index}`. +- **ECDH keys** — sr25519 keys hard-derived at `//ecdh//{domain}`. + +It amends `ProductAccountId` (`derivation_index: u32` → +`derivation_suffix: Bytes`), adds one Accounts Protocol request for fetching a +product's subtree public key, collapses RFC-0010's `AutoSigning` payload to a +single product-root secret key, and assigns product identities to built-in app +features. No truAPI methods are added or removed. + +## Definitions + +- **The Account Entropy** (`root_entropy`) — the BIP-39 entropy of the main + user account, created when the user installs the app. +- **The Account Seed** (`root_seed`) — the 32-byte substrate-compatible + mini-secret derived from the Account Entropy (per `substrate-bip39`). +- **Root keypair** — the sr25519 keypair obtained from the Account Seed. All + account and ECDH derivations in this RFC start here. +- **Host** / **Account Holder** — as in RFC-0010: the runtime executing + products, and the device holding the user's root secret, respectively. +- **MDS** — the multi-device spec describing key management and encryption in + a multi-device environment. +- **SSO** — single sign-on; a synonym for the Accounts Protocol. +- `Bytes` — a variable-length byte array (SCALE `Vec` on the wire). +- `Sr25519PublicKey` — a 32-byte sr25519 public key. +- `hash(data, key)` — 32-byte BLAKE2b-256 in keyed mode. + +## Motivation + +Today the app's derivations are unspecced and non-unified: each feature +hard-derives its own accounts ad hoc. This RFC replaces that with a single +scheme covering product accounts, built-in features, ring-VRF keys, and ECDH +keys. + +The scheme must respect a cryptographic constraint. In sr25519, soft +derivation is invertible from the child side: a **child +private key** plus the **parent public key** and **derivation path** recovers +the **parent private key**. Every key in a purely soft-derived subtree is +therefore equivalent to the subtree root's key. + +This rules out deriving product accounts from the root keypair with soft +junctions, even when segments are salted with secret components as in +RFC-0010's current "Product account" definition: + +``` +/{productId ++ productDerivationSecret}/{index ++ indexDerivationSecret} +``` + +`AutoSigning` hands the Host the product-subtree private key *together with* +`productDerivationSecret`: the Host can invert the soft junction and recover +the **root private key**. Secret components also force a round trip to the +Account Holder for every account — even for public keys — on Hosts that don't +hold them, contradicting the goal of cheap, prompt-free account operations. + +## Detailed Design + +Account keys use **sr25519**. + +### Product account derivations + +``` +//{productId}/{suffix} +``` + +derived from the root keypair with standard substrate sr25519 HDKD — no +secret components, no intermediate hashing layer. + +- `//{productId}` — **hard** junction; `productId` is the product's dotNS + identifier (e.g. `browse.dot`). +- `/{suffix}` — **soft** junction. + +The hard junction is the security firewall: leaking the `//{productId}` +secret key (via `AutoSigning` or compromise) exposes exactly that product's +subtree. Below it, soft derivation adds no exposure — any party holding a +child secret key already holds the product-root secret key. + +#### `ProductAccountId` + +The wire-level `ProductAccountId` is amended to carry the derivation suffix +directly: + +```rust +ProductAccountId { + /// A dotNS domain name identifier (e.g., `"my-product.dot"`). + dot_ns_identifier: String, + /// Derivation suffix selecting an account within the product subtree. + derivation_suffix: Bytes, +} +``` + +The suffix equals the RFC-0004 proof-context suffix — the alias ↔ account +mapping is the identity on the suffix bytes. RFC-0004's +`product_account_id_for_proof_context` convention (4-byte suffixes packed into +the former `u32`) is obsolete. + +#### Junction encoding + +A suffix is interpreted exactly as the standard substrate junction encoder +interprets a path segment — no custom rules — keeping paths compatible with +existing tooling (`polkadot-js`, `subkey`). A suffix that is not valid UTF-8 +(which the encoder cannot see as a path segment) is a raw-bytes junction. + +One implication: the encoder normalizes numeric segments, so multiple byte +forms of the same number — `b"5"`, `b"05"`, `b"+5"` — derive the same key. +Products that need distinct accounts must use distinct numbers or +non-numeric suffixes. + +The empty suffix is invalid; a product's default account uses suffix `b"0"`. + +`//browse.dot/5` in stock tooling therefore derives the same key as +`derivation_suffix = b"5"`. + +#### Fetching the product subtree + +`//{productId}` is hard, so the root public key alone no longer determines +product account public keys. One new Accounts Protocol request closes the +gap: + +```rust +/// Host → Account Holder. +ApProductSubtreeRequest { + /// dotNS identifier of the product whose subtree is requested. + product_id: String, +} + +/// Account Holder → Host. +ApProductSubtreeResponse { + /// sr25519 public key of `//{product_id}`. + product_public_key: Sr25519PublicKey, +} +``` + +The request is **consent-free** — the response contains no secret material, +and individual product accounts become public on-chain once used; only +`AutoSigning` (secret material) requires consent, per RFC-0010. + +Host behavior: + +- Fetch and cache the response on first use of a product's accounts — one + round trip per product, ever — then derive account public keys locally via + the soft suffix junction. +- Without `AutoSigning`, signing round-trips to the Account Holder, which + derives `//{productId}/{suffix}` from the root keypair and signs. +- With `AutoSigning`, the Host soft-derives the child secret key and signs + locally. + +#### Amendment to RFC-0010 `AutoSigning` + +The `AutoSigning` payload collapses to the product-root secret key alone. +`ApAllocatedResource` (and its implementation counterpart +`SsoAllocatedResource`) is amended to: + +```rust +AutoSigning { + /// Secret key of `//{productId}`. + product_root_private_key: Sr25519SecretKey, +} +``` + +`Sr25519SecretKey = Sr25519PrivateKey ++ Sr25519Nonce` (64 bytes) — the full +expanded secret needed to sign and soft-derive. This supersedes RFC-0010's +"Product account" definition and drops `product_derivation_secret`. Allowance +accounts (`//allowance//{system}//{productId}`) are unchanged. + +### Built-in app features + +Built-in features derive accounts through the same product scheme, using +reserved product identities as their `productId`: + +| Category | Feature | `productId` | Protection | +| ------------------------------------ | ---------------------------- | ------------ | ------------------------------------------------------------------------------------------------ | +| Migrating to a product soon | Game (DIM2) | `dimtwo.dot` | Convention only — the product ships imminently and claims the 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 | +| Not coercible to a product | Coinage | — | Deferred to a separate RFC (own layout today: `//pps//coin/{index}`, `//pps//ring-vrf/{index}`) | + +### Well-known alias accounts + +The runtime defines well-known Account Contexts (`resources`, `score`, +`mob-rule`) that are not owned by any product and do not follow truAPI's +product-based context construction. Their handling, including derivation of +linked accounts, is deferred to a separate RFC. + +### Ring-VRF derivations + +Ring-VRF keys live in their own tree, rooted directly in the Account Entropy: + +``` +root_ringvrf_entropy = hash(root_entropy, "ring-vrf") +``` + +#### HDKD for ring-VRF keys + +```rust +RingVrfEntropy = [u8; 32] +ChainCode = [u8; 32] + +fn derive_ringvrf_hard(parent: RingVrfEntropy, chain_code: ChainCode) -> RingVrfEntropy { + hash(parent, chain_code) +} +``` + +To derive a child `RingVrfEntropy` from `parent` for a path `path`: + +1. Parse `path` per the substrate derivation scheme, with the standard 32-byte + chain-code normalization of each segment. Only **hard** junction + separators (`//`) are allowed; a path containing a soft separator is + invalid. Produces `codes: Vec`. +2. Fold: `codes.fold(parent, |acc, code| derive_ringvrf_hard(acc, code))`. + +#### General scheme and domains + +The general path shape, derived from `root_ringvrf_entropy`, is: + +``` +//{DerivationDomain}//{DerivationIndex} +``` + +The currently defined domains are single keys and carry **no index segment**: + +```rust +// Ring-VRF key used in the lite-people ring +light_people_domain = "lite-people" // key: //lite-people + +// Ring-VRF key used in the people ring +people_domain = "people" // key: //people +``` + +The indexed form is reserved for future domains that need multiple keys. +Coinage's ring-VRF keys (recyclers/vouchers) are deferred to the coinage RFC. + +### ECDH key derivations + +Keys used for ECDH-based E2E encryption are **sr25519** keys, hard-derived +from the root keypair with standard substrate HDKD: + +``` +//ecdh//{DerivationDomain} +``` + +This RFC specifies only the derivation of these keys; key agreement, KDF, and +AEAD choices are specified in a separate encryption RFC. + +Domains for built-in app features: + +```rust +// Previously used for ECDH between chat participants. +// Post-MDS this key is shared across all devices and is used for device +// authentication in chat requests; encryption keys are generated randomly +// per device. +chat_domain = "chat" + +// E2E communication encryption in the SSO transport. +sso_domain = "sso" + +// E2E encryption in the "Chat with Players" chat in DIM2. +// "Chat with Players" is not covered by MDS, so this key is used for E2E +// encryption directly. +game_domain = "game" +``` + +> **Note:** the `game` domain is expected to go away soon. The Game is +> migrating to the `dimtwo.dot` product, which will obtain its key material +> via `host_derive_entropy` (RFC-0007) instead. + +### Compatibility + +There are no production deployments of secret-component derivations or of the +`derivation_index`-based `ProductAccountId`; breaking changes are made freely, +with no migration path. + +## Drawbacks + +- **One new Accounts Protocol message**, amortized to one round trip per + product per Host. +- **Convention-only protection for `dimtwo.dot`** until the Game product + deployment claims the name. +- **Public enumerability**: a party holding a product's subtree public key + can enumerate all of its account public keys. Secret path components would + add no guarantee here: the `//{productId}` public key is published nowhere + — not even exposed to the product, only Hosts see it via + `ApProductSubtreeResponse` — and a secret component would have to be shared + with Hosts through that same response, so whatever leaks the subtree public + key leaks the secret with it. Individual product accounts are public + on-chain anyway; privacy-sensitive identities use ring-VRF contextual + aliases (RFC-0004). + +## Alternatives + +- **Secret-component soft paths** + (`/{productId ++ secret}/{suffix ++ secret}`) — rejected for the root-key + recovery and round-trip problems described in Motivation. +- **Secret components as chain codes** — the same idea carried inside the + derivation standard itself rather than managed separately; only available + for ed25519, which lacks the soft public-key derivation this design relies + on. Rejected together with ed25519. +- **`u32` index suffix** — keeping the current wire type as the derivation + primitive. Rejected in favor of arbitrary byte suffixes, which subsume the + index and equal RFC-0004 suffixes without the 4-byte packing convention. + +## Prior Art and References + +- **RFC-0004** — context-scoped proofs; its `product//` context + prefix is the alias-side analogue of the hard product junction here. +- **RFC-0007** — `host_derive_entropy`; a separate per-product entropy + namespace for products that need raw key material. +- **RFC-0010** — allowance and `AutoSigning`; amended here, allowance + accounts untouched. +- **Earlier derivations spec draft** — internal discussion material exploring + secret-component soft derivations and per-feature domains; never confirmed, + superseded in full by this RFC. From 38cf36be1d4b47ed398365bb6a2388dd973b8902 Mon Sep 17 00:00:00 2001 From: valentunn Date: Mon, 20 Jul 2026 20:14:59 +0700 Subject: [PATCH 2/6] Adjust rust/ts types --- README.md | 2 +- docs/rfcs/0022-account-derivations.md | 20 ++-- explorer/package-lock.json | 4 +- .../src/host-callbacks-adapter.test.ts | 18 ++-- js/packages/truapi/README.md | 2 +- js/packages/truapi/src/client.test.ts | 4 +- .../src/host_logic/product_account.rs | 92 ++++++++++++++----- .../src/host_logic/sso/messages.rs | 27 ++---- rust/crates/truapi-server/src/runtime.rs | 42 ++++----- .../truapi-server/src/runtime/signing_host.rs | 24 ++--- .../src/runtime/statement_store.rs | 4 +- rust/crates/truapi-server/src/test_support.rs | 7 +- rust/crates/truapi/src/api/account.rs | 4 +- rust/crates/truapi/src/api/signing.rs | 8 +- rust/crates/truapi/src/api/statement_store.rs | 2 +- rust/crates/truapi/src/v01/account.rs | 8 +- 16 files changed, 159 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index 56b74bd1..aad44b91 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ const transport = createTransport(createMessagePortProvider(port)); const truapi = createClient(transport); const result = await truapi.accountManagement.accountGet({ - productAccountId: { dotNsIdentifier: "my-product.dot", derivationIndex: 0 }, + productAccountId: { dotNsIdentifier: "my-product.dot", derivationSuffix: "0x30" }, }); ``` diff --git a/docs/rfcs/0022-account-derivations.md b/docs/rfcs/0022-account-derivations.md index a04bc559..a415bc65 100644 --- a/docs/rfcs/0022-account-derivations.md +++ b/docs/rfcs/0022-account-derivations.md @@ -17,7 +17,7 @@ owner: "@valentunn" This RFC defines the derivation scheme for every key rooted in the user's main account: -- **Product accounts** — sr25519 keys at `//{productId}/{suffix}`: a hard +- **Product accounts** — sr25519 keys at `//product//{productId}/{suffix}`: a hard junction at the product boundary, plain soft derivation below it, no secret path components. - **Ring-VRF keys** — a hard-only keyed-hash chain rooted at @@ -81,17 +81,19 @@ Account keys use **sr25519**. ### Product account derivations ``` -//{productId}/{suffix} +//product//{productId}/{suffix} ``` derived from the root keypair with standard substrate sr25519 HDKD — no secret components, no intermediate hashing layer. +- `//product` — **hard** namespace junction separating product accounts from + the root keypair's other derivations. - `//{productId}` — **hard** junction; `productId` is the product's dotNS identifier (e.g. `browse.dot`). - `/{suffix}` — **soft** junction. -The hard junction is the security firewall: leaking the `//{productId}` +The hard junction is the security firewall: leaking the `//product//{productId}` secret key (via `AutoSigning` or compromise) exposes exactly that product's subtree. Below it, soft derivation adds no exposure — any party holding a child secret key already holds the product-root secret key. @@ -129,12 +131,12 @@ non-numeric suffixes. The empty suffix is invalid; a product's default account uses suffix `b"0"`. -`//browse.dot/5` in stock tooling therefore derives the same key as +`//product//browse.dot/5` in stock tooling therefore derives the same key as `derivation_suffix = b"5"`. #### Fetching the product subtree -`//{productId}` is hard, so the root public key alone no longer determines +`//product//{productId}` is hard, so the root public key alone no longer determines product account public keys. One new Accounts Protocol request closes the gap: @@ -147,7 +149,7 @@ ApProductSubtreeRequest { /// Account Holder → Host. ApProductSubtreeResponse { - /// sr25519 public key of `//{product_id}`. + /// sr25519 public key of `//product//{product_id}`. product_public_key: Sr25519PublicKey, } ``` @@ -162,7 +164,7 @@ Host behavior: round trip per product, ever — then derive account public keys locally via the soft suffix junction. - Without `AutoSigning`, signing round-trips to the Account Holder, which - derives `//{productId}/{suffix}` from the root keypair and signs. + derives `//product//{productId}/{suffix}` from the root keypair and signs. - With `AutoSigning`, the Host soft-derives the child secret key and signs locally. @@ -174,7 +176,7 @@ The `AutoSigning` payload collapses to the product-root secret key alone. ```rust AutoSigning { - /// Secret key of `//{productId}`. + /// Secret key of `//product//{productId}`. product_root_private_key: Sr25519SecretKey, } ``` @@ -300,7 +302,7 @@ with no migration path. deployment claims the name. - **Public enumerability**: a party holding a product's subtree public key can enumerate all of its account public keys. Secret path components would - add no guarantee here: the `//{productId}` public key is published nowhere + add no guarantee here: the `//product//{productId}` public key is published nowhere — not even exposed to the product, only Hosts see it via `ApProductSubtreeResponse` — and a secret component would have to be shared with Hosts through that same response, so whatever leaks the subtree public diff --git a/explorer/package-lock.json b/explorer/package-lock.json index 3a8436b6..8a691f16 100644 --- a/explorer/package-lock.json +++ b/explorer/package-lock.json @@ -29,13 +29,15 @@ }, "../js/packages/truapi": { "name": "@parity/truapi", - "version": "0.3.0", + "version": "0.4.1", "license": "MIT", "dependencies": { + "@noble/hashes": "^2.2.0", "neverthrow": "^8.2.0", "scale-ts": "^1.6.1" }, "devDependencies": { + "@types/bun": "^1.3.0", "typescript": "^6.0" } }, diff --git a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts index f35b277c..e949176f 100644 --- a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -30,7 +30,7 @@ import { makeHostCallbacks, settle } from "./test-support.js"; const GENESIS = `0x${"11".repeat(32)}` as `0x${string}`; const PRODUCT_ACCOUNT = { dotNsIdentifier: "playground.dot", - derivationIndex: 0, + derivationSuffix: "0x30", }; const SIGN_PAYLOAD: HostSignPayloadData = { blockHash: GENESIS, @@ -181,13 +181,15 @@ describe("createWasmRawCallbacks", () => { case "CreateTransaction": return ( review.value.tag === "Product" && - review.value.value.signer.derivationIndex === 0 && + review.value.value.signer.derivationSuffix === "0x30" && review.value.value.callData === "0x0506" ); case "AccountAlias": return ( - review.value.requestingProductId === "playground.dot" && - review.value.targetProductId === "wallet.dot" + review.value.callingProductId === "playground.dot" && + review.value.context.suffix === "0x30" && + review.value.ringLocation.junctions[0]?.tag === + "PalletInstance" ); case "AccountAccess": return ( @@ -286,8 +288,12 @@ describe("createWasmRawCallbacks", () => { UserConfirmationReview.enc({ tag: "AccountAlias", value: { - requestingProductId: "playground.dot", - targetProductId: "wallet.dot", + callingProductId: "playground.dot", + context: { productId: "playground.dot", suffix: "0x30" }, + ringLocation: { + chainId: GENESIS, + junctions: [{ tag: "PalletInstance", value: 1 }], + }, }, }), ), diff --git a/js/packages/truapi/README.md b/js/packages/truapi/README.md index dae112d2..34f76f2f 100644 --- a/js/packages/truapi/README.md +++ b/js/packages/truapi/README.md @@ -29,7 +29,7 @@ const transport = createTransport(provider); const truapi: Client = createClient(transport); const result = await truapi.accountManagement.accountGet({ - productAccountId: { dotNsIdentifier: "my-product.dot", derivationIndex: 0 }, + productAccountId: { dotNsIdentifier: "my-product.dot", derivationSuffix: "0x30" }, }); if (result.isErr()) throw result.error; diff --git a/js/packages/truapi/src/client.test.ts b/js/packages/truapi/src/client.test.ts index 5d971886..de6bb387 100644 --- a/js/packages/truapi/src/client.test.ts +++ b/js/packages/truapi/src/client.test.ts @@ -96,7 +96,7 @@ describe("generated client transport", () => { const client = createClient(transport); const request = { - productAccountId: { dotNsIdentifier: "foo", derivationIndex: 0 }, + productAccountId: { dotNsIdentifier: "foo", derivationSuffix: "0x30" }, }; void client.account.getAccount(request); @@ -156,7 +156,7 @@ describe("generated client transport", () => { const client = createClient(transport); const response = client.account.getAccount({ - productAccountId: { dotNsIdentifier: "foo", derivationIndex: 0 }, + productAccountId: { dotNsIdentifier: "foo", derivationSuffix: "0x30" }, }); const reason = { tag: "V1", value: { tag: "NotConnected", value: undefined } } as const; const frame = unwrap( 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 2a660db7..56979dd5 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -1,7 +1,7 @@ //! Product account derivation shared by all hosts. //! //! Mirrors host product-account derivation: derive an sr25519 public -//! key through soft HDKD junctions `["product", product_id, derivation_index]`. +//! key through soft HDKD junctions `["product", product_id, derivation_suffix]`. //! Host-spec C.5-C.7 define the product-account derivation, SS58 address, and //! `ProductAccountId` shape: //! @@ -47,19 +47,17 @@ pub fn derive_root_keypair_from_entropy(entropy: &[u8]) -> Result Result { let mut keypair = root.clone(); - let derivation_index = derivation_index.to_string(); - for junction in [PRODUCT_JUNCTION, product_id, derivation_index.as_str()] { - let chain_code = ChainCode(create_chain_code(junction)?); - keypair = keypair.derived_key_simple(chain_code, []).0; + for chain_code in product_chain_codes(product_id, derivation_suffix)? { + keypair = keypair.derived_key_simple(ChainCode(chain_code), []).0; } Ok(keypair) } @@ -68,21 +66,41 @@ pub fn derive_product_keypair( pub fn derive_product_public_key( root_public_key: [u8; 32], product_id: &str, - derivation_index: u32, + derivation_suffix: &[u8], ) -> Result<[u8; 32], ProductAccountError> { let mut public_key = PublicKey::from_bytes(&root_public_key) .map_err(|_| ProductAccountError::InvalidRootPublicKey)?; - let derivation_index = derivation_index.to_string(); - for junction in [PRODUCT_JUNCTION, product_id, derivation_index.as_str()] { - let chain_code = ChainCode(create_chain_code(junction)?); - let (derived, _) = public_key.derived_key_simple(chain_code, []); + for chain_code in product_chain_codes(product_id, derivation_suffix)? { + let (derived, _) = public_key.derived_key_simple(ChainCode(chain_code), []); public_key = derived; } Ok(public_key.to_bytes()) } +/// Chain codes for the product-account junction path +/// `["product", product_id, derivation_suffix]`. +fn product_chain_codes( + product_id: &str, + derivation_suffix: &[u8], +) -> Result<[[u8; 32]; 3], ProductAccountError> { + Ok([ + create_chain_code(PRODUCT_JUNCTION)?, + create_chain_code(product_id)?, + suffix_chain_code(derivation_suffix)?, + ]) +} + +/// Chain code for a derivation suffix: a valid-UTF-8 suffix is interpreted as +/// a standard path segment; any other suffix is a raw-bytes junction. +fn suffix_chain_code(suffix: &[u8]) -> Result<[u8; 32], ProductAccountError> { + match core::str::from_utf8(suffix) { + Ok(segment) => create_chain_code(segment), + Err(_) => Ok(normalize_chain_code(suffix.encode())), + } +} + /// Encode a product account public key as a generic Substrate SS58 address. /// /// Delegates to subxt's `AccountId32` Display, which is the generic-substrate @@ -106,14 +124,18 @@ fn create_chain_code(code: &str) -> Result<[u8; 32], ProductAccountError> { } else { code.encode() }; + Ok(normalize_chain_code(encoded)) +} +/// Normalize a SCALE-encoded junction to a 32-byte chain code. +fn normalize_chain_code(encoded: Vec) -> [u8; 32] { let mut chain_code = [0u8; JUNCTION_ID_LEN]; if encoded.len() > JUNCTION_ID_LEN { chain_code = sp_crypto_hashing::blake2_256(&encoded); } else { chain_code[..encoded.len()].copy_from_slice(&encoded); } - Ok(chain_code) + chain_code } #[cfg(test)] @@ -128,7 +150,7 @@ mod tests { #[test] fn derives_dotli_product_account_vector() { - let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", 0).unwrap(); + let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", b"0").unwrap(); assert_eq!( hex::encode(derived), "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" @@ -137,7 +159,7 @@ mod tests { #[test] fn derives_different_index_vector() { - let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", 1).unwrap(); + let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", b"1").unwrap(); assert_eq!( hex::encode(derived), "ec8a80808b46e44c1351b68e295eb975c55bda4855e5ea9fc1325be7296a2a4e" @@ -149,7 +171,7 @@ mod tests { let derived = derive_product_public_key( ROOT_PUBLIC_KEY, "w-credentialless-staticblitz-com.local-credentialless.webcontainer-api.io", - 0, + b"0", ) .unwrap(); assert_eq!( @@ -160,7 +182,7 @@ mod tests { #[test] fn ss58_address_matches_dotli_vector() { - let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", 0).unwrap(); + let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", b"0").unwrap(); assert_eq!( product_public_key_to_address(derived), "5CyFsdhwjXy7wWpDEM6isungQ3LfGnu9UXkt7paBQ6DYRxk1" @@ -169,7 +191,7 @@ mod tests { #[test] fn ss58_address_round_trips_to_public_key() { - let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", 0).unwrap(); + let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", b"0").unwrap(); let address = product_public_key_to_address(derived); assert_eq!(public_key_from_address(&address), Some(derived)); @@ -179,21 +201,43 @@ mod tests { #[test] fn product_secret_derivation_matches_public_derivation() { // The signing-host secret path and the seedless public path must agree - // on the product public key for any root, index, and product id. + // on the product public key for any root, suffix, and product id. let entropy = [0xABu8; 16]; let root = derive_root_keypair_from_entropy(&entropy).unwrap(); let root_public = root.public.to_bytes(); - for (product_id, index) in [("myapp.dot", 0u32), ("myapp.dot", 1), ("localhost:3000", 7)] { - let keypair = derive_product_keypair(&root, product_id, index).unwrap(); - let public = derive_product_public_key(root_public, product_id, index).unwrap(); + for (product_id, suffix) in [ + ("myapp.dot", &b"0"[..]), + ("myapp.dot", b"1"), + ("localhost:3000", b"7"), + ("myapp.dot", b"settings"), + ("myapp.dot", &[0xFF, 0xFE][..]), + ] { + let keypair = derive_product_keypair(&root, product_id, suffix).unwrap(); + let public = derive_product_public_key(root_public, product_id, suffix).unwrap(); assert_eq!( keypair.public.to_bytes(), public, - "{product_id}#{index} secret vs public derivation", + "{product_id}#{suffix:02x?} secret vs public derivation", ); } } + #[test] + fn numeric_suffix_forms_alias_to_one_key() { + // Substrate junction encoding normalizes numeric segments, so + // non-canonical decimal forms of the same number share a key. + let canonical = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", b"5").unwrap(); + let padded = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", b"05").unwrap(); + assert_eq!(canonical, padded); + } + + #[test] + fn non_utf8_suffix_uses_raw_bytes_junction() { + let raw = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", &[0xFF, 0xFE]).unwrap(); + let canonical = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", b"0").unwrap(); + assert_ne!(raw, canonical); + } + #[test] fn root_keypair_from_entropy_regression_pin() { // Regression pin for the entropy -> mini-secret -> sr25519 root path @@ -215,7 +259,7 @@ mod tests { #[test] fn product_secret_signs_verifiably() { let root = derive_root_keypair_from_entropy(&[0xABu8; 16]).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", b"0").unwrap(); let message = b"hello"; let signature = keypair .secret diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index b46d4fe8..9662ec3e 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -331,17 +331,10 @@ pub enum SsoAllocationOutcome { /// Resource material allocated by the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SsoAllocatedResource { - StatementStoreAllowance { - slot_account_key: Vec, - }, - BulletinAllowance { - slot_account_key: Vec, - }, + StatementStoreAllowance { slot_account_key: Vec }, + BulletinAllowance { slot_account_key: Vec }, SmartContractAllowance, - AutoSigning { - product_derivation_secret: String, - product_root_private_key: Vec, - }, + AutoSigning { product_root_private_key: Vec }, } /// Request sent when a product asks the signing host to create a signed transaction @@ -712,7 +705,7 @@ mod tests { fn account() -> ProductAccountId { ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 7, + derivation_suffix: b"7".to_vec(), } } @@ -830,13 +823,13 @@ mod tests { } #[test] - fn create_transaction_message_matches_host_papp_0_8_8_fixture() { + fn create_transaction_message_wire_shape_pin() { let message = create_transaction_message( "m-product-tx".to_string(), ProductAccountTxPayload { signer: ProductAccountId { dot_ns_identifier: "truapi-playground.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, genesis_hash: sequential_bytes(32), call_data: vec![0, 0], @@ -851,18 +844,18 @@ mod tests { assert_host_papp_0_8_8_fixture( message, - "0x306d2d70726f647563742d7478000700547472756170692d706c617967726f756e642e646f7400000000202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f0800000428436865636b4e6f6e6365040108020300", + "0x306d2d70726f647563742d7478000700547472756170692d706c617967726f756e642e646f740430202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f0800000428436865636b4e6f6e6365040108020300", ); } #[test] - fn playground_create_transaction_message_matches_host_papp_0_8_8_fixture() { + fn playground_create_transaction_message_wire_shape_pin() { let message = create_transaction_message( "create-transaction-1".to_string(), ProductAccountTxPayload { signer: ProductAccountId { dot_ns_identifier: "truapi-playground.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, genesis_hash: [ 0xbf, 0x04, 0x88, 0xdb, 0xe9, 0xda, 0xa1, 0xde, 0x1c, 0x08, 0xc5, 0xf7, 0x43, @@ -877,7 +870,7 @@ mod tests { assert_host_papp_0_8_8_fixture( message, - "0x506372656174652d7472616e73616374696f6e2d31000700547472756170692d706c617967726f756e642e646f7400000000bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f0800000000", + "0x506372656174652d7472616e73616374696f6e2d31000700547472756170692d706c617967726f756e642e646f740430bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f0800000000", ); } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index ee990b2d..3f1e659e 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -419,7 +419,7 @@ impl ProductRuntimeHost { Ok(v01::ProductAccountId { dot_ns_identifier: normalize_product_identifier(&product_account_id.dot_ns_identifier) .map_err(|_| ())?, - derivation_index: product_account_id.derivation_index, + derivation_suffix: product_account_id.derivation_suffix, }) } @@ -428,7 +428,7 @@ impl ProductRuntimeHost { } fn legacy_slot_zero_public_key(&self, session: &AuthoritySession) -> Result<[u8; 32], String> { - derive_product_public_key(session.public_key, &self.product_id(), 0) + derive_product_public_key(session.public_key, &self.product_id(), b"0") .map_err(|err| err.to_string()) } @@ -837,7 +837,7 @@ impl Account for ProductRuntimeHost { let public_key = derive_product_public_key( session.public_key, &product_account_id.dot_ns_identifier, - product_account_id.derivation_index, + &product_account_id.derivation_suffix, ) .map_err(|err| { CallError::Domain(HostAccountGetError::V1(v01::HostAccountGetError::Unknown { @@ -980,7 +980,7 @@ impl Account for ProductRuntimeHost { let product_id = self.product_id(); let public_key = - derive_product_public_key(session.public_key, &product_id, 0).map_err(|err| { + derive_product_public_key(session.public_key, &product_id, b"0").map_err(|err| { CallError::Domain(HostGetLegacyAccountsError::V1( v01::HostAccountGetError::Unknown { reason: err.to_string(), @@ -1348,7 +1348,7 @@ impl Signing for ProductRuntimeHost { SignPayloadAuthorityRequest::LegacyAccount { product_account: v01::ProductAccountId { dot_ns_identifier: self.product_id(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, request: inner, }, @@ -1403,7 +1403,7 @@ impl Signing for ProductRuntimeHost { LegacySigner::Product => SignRawAuthorityRequest::Product(v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: self.product_id(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, payload: inner.payload, }), @@ -1486,7 +1486,7 @@ impl Signing for ProductRuntimeHost { CreateTransactionAuthorityRequest::LegacyAccount { product_account: v01::ProductAccountId { dot_ns_identifier: self.product_id(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, request: inner, }, @@ -2320,7 +2320,7 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, }); let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); @@ -2341,7 +2341,7 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "example.com".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, }); let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); @@ -2366,7 +2366,7 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "other.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, }); let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); @@ -2402,7 +2402,7 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "other.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, }); let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); @@ -2427,14 +2427,14 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "other.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, }); let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); let HostAccountGetResponse::V1(inner) = response; assert_eq!( inner.account.public_key, - derive_product_public_key(session.public_key, "other.dot", 0) + derive_product_public_key(session.public_key, "other.dot", b"0") .unwrap() .to_vec() ); @@ -2449,7 +2449,7 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, }); let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); @@ -2469,7 +2469,7 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "MyApp.DOT".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, }); let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); @@ -2495,7 +2495,7 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, }); let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); @@ -3726,7 +3726,7 @@ mod tests { request.product_account_id, v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), } ); assert!(matches!( @@ -3739,7 +3739,7 @@ mod tests { #[test] fn legacy_sign_raw_accepts_derived_hex_then_returns_sso_response() { let session = sso_session_info(); - let signer = derive_product_public_key(session.public_key, "myapp.dot", 0).unwrap(); + let signer = derive_product_public_key(session.public_key, "myapp.dot", b"0").unwrap(); let platform = Arc::new(StubPlatform { sign_raw_confirmed: true, sso_response_script: Some(sso_success_response_script( @@ -3779,7 +3779,7 @@ mod tests { request.product_account_id, v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), } ); } @@ -3879,7 +3879,7 @@ mod tests { #[test] fn legacy_create_transaction_accepts_derived_key_then_returns_sso_response() { let session = sso_session_info(); - let signer = derive_product_public_key(session.public_key, "myapp.dot", 0).unwrap(); + let signer = derive_product_public_key(session.public_key, "myapp.dot", b"0").unwrap(); let platform = Arc::new(StubPlatform { create_transaction_confirmed: true, sso_response_script: Some(sso_success_response_script( @@ -3933,7 +3933,7 @@ mod tests { payload.signer, v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), } ); } diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 886853b8..6e27e763 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -85,7 +85,7 @@ impl SigningHost { reason: err.to_string(), } })?; - derive_product_keypair(&root, &product_id, account.derivation_index) + derive_product_keypair(&root, &product_id, &account.derivation_suffix) .map_err(product_authority_error) } } @@ -462,7 +462,7 @@ mod tests { let request = HostSignRawRequest::V1(v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, payload: v01::RawPayload::Bytes { bytes: b"hello world".to_vec(), @@ -473,7 +473,7 @@ mod tests { assert!(response.signed_transaction.is_none()); let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", b"0").unwrap(); let signature = schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); assert!( @@ -493,7 +493,7 @@ mod tests { let request = HostSignRawRequest::V1(v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, payload: v01::RawPayload::Bytes { bytes: vec![1, 2, 3], @@ -507,7 +507,7 @@ mod tests { fn product_account(index: u32) -> v01::ProductAccountId { v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: index, + derivation_suffix: index.to_string().into_bytes(), } } @@ -544,7 +544,7 @@ mod tests { assert_eq!(tail, vec![1, 0x00, 0x00], "body tail is extra ++ call_data"); let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", b"0").unwrap(); assert_eq!(account, keypair.public.to_bytes()); // Payload = call_data ++ extra ++ additional_signed (call first). @@ -613,7 +613,7 @@ mod tests { let cx = CallContext::new(); let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", b"0").unwrap(); let request = CreateTransactionAuthorityRequest::LegacyAccount { product_account: product_account(0), @@ -692,7 +692,7 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, }); let err = futures::executor::block_on(runtime.get_account(&cx, request)) @@ -768,7 +768,7 @@ mod tests { let request = HostSignRawRequest::V1(v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, payload: v01::RawPayload::Bytes { bytes: b"hi".to_vec(), @@ -777,7 +777,7 @@ mod tests { let HostSignRawResponse::V1(response) = futures::executor::block_on(runtime.sign_raw(&cx, request)).expect("sign_raw ok"); let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", b"0").unwrap(); let signature = schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); assert!( @@ -820,7 +820,7 @@ mod tests { let request = v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, payload: v01::RawPayload::Bytes { bytes: vec![1, 2, 3], @@ -849,7 +849,7 @@ mod tests { let request = v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_suffix: b"0".to_vec(), }, payload: v01::RawPayload::Bytes { bytes: vec![1] }, }; diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index b55d34d4..6367f4f2 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -274,7 +274,7 @@ impl ProductRuntimeHost { let signer = derive_product_public_key( session.public_key, &product_account_id.dot_ns_identifier, - product_account_id.derivation_index, + &product_account_id.derivation_suffix, ) .map_err(|err| StatementProofFailure::UnableToSign(err.to_string()))?; let fields = statement_fields_from_v01(statement) @@ -461,7 +461,7 @@ mod tests { let statement = statement(); let payload = statement_payload(statement.clone()); let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let product_keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let product_keypair = derive_product_keypair(&root, "myapp.dot", b"0").unwrap(); let expected_signer = product_keypair.public.to_bytes(); let cx = CallContext::new(); let request = RemoteStatementStoreCreateProofRequest::V1( diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 3f322a28..4c20225b 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -610,11 +610,12 @@ pub(crate) fn sign_raw_legacy_response_message( } } -/// Product account id fixture for `identifier` and derivation slot. -pub(crate) fn account_id(identifier: &str, derivation_index: u32) -> v01::ProductAccountId { +/// Product account id fixture for `identifier`; the derivation suffix is the +/// canonical decimal form of `index`. +pub(crate) fn account_id(identifier: &str, index: u32) -> v01::ProductAccountId { v01::ProductAccountId { dot_ns_identifier: identifier.to_string(), - derivation_index, + derivation_suffix: index.to_string().into_bytes(), } } diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index aa3f3799..635eaaed 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -38,7 +38,7 @@ pub trait Account: Send + Sync { /// const result = await truapi.account.getAccount({ /// productAccountId: { /// dotNsIdentifier: "truapi-playground.dot", - /// derivationIndex: 0, + /// derivationSuffix: "0x30", /// }, /// }); /// assert(result.isOk(), "getAccount failed:", result); @@ -47,7 +47,7 @@ pub trait Account: Send + Sync { /// const otherProduct = await truapi.account.getAccount({ /// productAccountId: { /// dotNsIdentifier: "other-product.dot", - /// derivationIndex: 0, + /// derivationSuffix: "0x30", /// }, /// }); /// assert(otherProduct.isOk(), "cross-product getAccount was denied or failed:", otherProduct); diff --git a/rust/crates/truapi/src/api/signing.rs b/rust/crates/truapi/src/api/signing.rs index f688f5ed..4dedb77e 100644 --- a/rust/crates/truapi/src/api/signing.rs +++ b/rust/crates/truapi/src/api/signing.rs @@ -25,7 +25,7 @@ pub trait Signing: Send + Sync { /// const payload = await buildCreateTransactionPayload({ /// signer: { /// dotNsIdentifier: "truapi-playground.dot", - /// derivationIndex: 0, + /// derivationSuffix: "0x30", /// }, /// genesisHash: PASEO_NEXT_V2_INDIVIDUALITY.genesis, /// callData: "0x000000", @@ -59,7 +59,7 @@ pub trait Signing: Send + Sync { /// const payload = await buildCreateTransactionPayload({ /// signer: { /// dotNsIdentifier: "truapi-playground.dot", - /// derivationIndex: 0, + /// derivationSuffix: "0x30", /// }, /// genesisHash: PASEO_NEXT_V2_INDIVIDUALITY.genesis, /// callData: "0x000000", @@ -156,7 +156,7 @@ pub trait Signing: Send + Sync { /// /// ```ts /// const result = await truapi.signing.signRaw({ - /// account: { dotNsIdentifier: "truapi-playground.dot", derivationIndex: 0 }, + /// account: { dotNsIdentifier: "truapi-playground.dot", derivationSuffix: "0x30" }, /// payload: { /// tag: "Bytes", /// value: { @@ -182,7 +182,7 @@ pub trait Signing: Send + Sync { /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; /// /// const result = await truapi.signing.signPayload({ - /// account: { dotNsIdentifier: "truapi-playground.dot", derivationIndex: 0 }, + /// account: { dotNsIdentifier: "truapi-playground.dot", derivationSuffix: "0x30" }, /// payload: { /// blockHash: "0xd6eec26135305a8ad257a20d003357284c8aa03d0bdb2b357ab0a22371e11ef2", /// blockNumber: "0x00000000", diff --git a/rust/crates/truapi/src/api/statement_store.rs b/rust/crates/truapi/src/api/statement_store.rs index 1110155c..67093646 100644 --- a/rust/crates/truapi/src/api/statement_store.rs +++ b/rust/crates/truapi/src/api/statement_store.rs @@ -80,7 +80,7 @@ pub trait StatementStore: Send + Sync { /// const result = await truapi.statementStore.createProof({ /// productAccountId: { /// dotNsIdentifier: "truapi-playground.dot", - /// derivationIndex: 0, + /// derivationSuffix: "0x30", /// }, /// statement, /// }); diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index 2cfc1198..ba3ff68c 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -2,13 +2,15 @@ use crate::v01::transaction::GenesisHash; use parity_scale_codec::{Decode, Encode}; /// Identifies a product-specific account by combining a dotNS domain name with a -/// derivation index. +/// derivation suffix. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct ProductAccountId { /// A dotNS domain name identifier (e.g., `"my-product.dot"`). pub dot_ns_identifier: String, - /// Key derivation index for generating product-specific accounts. - pub derivation_index: u32, + /// Derivation suffix selecting an account within the product subtree. + /// Interpreted as a standard substrate derivation-path segment when valid + /// UTF-8, and as a raw-bytes junction otherwise. + pub derivation_suffix: Vec, } /// A user-imported (legacy) account: public key plus an optional user-chosen From 76fb31f9f3efd6b7abdeef2c7a1070ac1ff97cc6 Mon Sep 17 00:00:00 2001 From: valentunn Date: Tue, 21 Jul 2026 15:22:40 +0700 Subject: [PATCH 3/6] Fix ECDH wording ambiguity --- docs/rfcs/0022-account-derivations.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/rfcs/0022-account-derivations.md b/docs/rfcs/0022-account-derivations.md index a415bc65..eb7da821 100644 --- a/docs/rfcs/0022-account-derivations.md +++ b/docs/rfcs/0022-account-derivations.md @@ -22,7 +22,8 @@ account: path components. - **Ring-VRF keys** — a hard-only keyed-hash chain rooted at `hash(root_entropy, "ring-vrf")`, with paths `//{domain}//{index}`. -- **ECDH keys** — sr25519 keys hard-derived at `//ecdh//{domain}`. +- **ECDH keys** — P-256 keys whose key material is hashed from sr25519 keys + hard-derived at `//ecdh//{domain}`. It amends `ProductAccountId` (`derivation_index: u32` → `derivation_suffix: Bytes`), adds one Accounts Protocol request for fetching a @@ -256,15 +257,20 @@ Coinage's ring-VRF keys (recyclers/vouchers) are deferred to the coinage RFC. ### ECDH key derivations -Keys used for ECDH-based E2E encryption are **sr25519** keys, hard-derived -from the root keypair with standard substrate HDKD: +Keys used for ECDH-based E2E encryption are **P-256 (NIST)** keys. Their key +material comes from the same substrate tree as everything else: an sr25519 +key is hard-derived from the root keypair with standard substrate HDKD at ``` //ecdh//{DerivationDomain} ``` -This RFC specifies only the derivation of these keys; key agreement, KDF, and -AEAD choices are specified in a separate encryption RFC. +and the derived sr25519 private key is hashed to obtain the P-256 key +material. + +This RFC specifies only this derivation. The exact hash-to-P-256 key mapping, +key agreement, KDF, and AEAD choices — and a potential migration from P-256 +to x25519 — are specified in a separate encryption RFC. Domains for built-in app features: From 20402786d404ae2df335c5daafd4f9b1a756d8ab Mon Sep 17 00:00:00 2001 From: valentunn Date: Wed, 22 Jul 2026 19:33:08 +0700 Subject: [PATCH 4/6] Adjustements --- README.md | 2 +- docs/rfcs/0022-account-derivations.md | 195 ++++++++++-------- .../src/host-callbacks-adapter.test.ts | 4 +- js/packages/truapi/README.md | 2 +- js/packages/truapi/src/client.test.ts | 4 +- .../src/host_logic/product_account.rs | 143 ++++++++----- .../src/host_logic/sso/messages.rs | 11 +- rust/crates/truapi-server/src/runtime.rs | 62 +++--- .../truapi-server/src/runtime/signing_host.rs | 34 +-- .../src/runtime/statement_store.rs | 7 +- rust/crates/truapi-server/src/test_support.rs | 2 +- rust/crates/truapi/src/api/account.rs | 4 +- rust/crates/truapi/src/api/signing.rs | 8 +- rust/crates/truapi/src/api/statement_store.rs | 2 +- rust/crates/truapi/src/lib.rs | 10 +- rust/crates/truapi/src/v01/account.rs | 22 +- 16 files changed, 302 insertions(+), 210 deletions(-) diff --git a/README.md b/README.md index 469ee998..bf47f78a 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ const transport = createTransport(createMessagePortProvider(port)); const truapi = createClient(transport); const result = await truapi.accountManagement.accountGet({ - productAccountId: { dotNsIdentifier: "my-product.dot", derivationSuffix: "0x30" }, + productAccountId: { dotNsIdentifier: "my-product.dot", derivationIndex: { tag: "Left", value: 0 } }, }); ``` diff --git a/docs/rfcs/0022-account-derivations.md b/docs/rfcs/0022-account-derivations.md index eb7da821..a0fc3359 100644 --- a/docs/rfcs/0022-account-derivations.md +++ b/docs/rfcs/0022-account-derivations.md @@ -17,19 +17,21 @@ owner: "@valentunn" This RFC defines the derivation scheme for every key rooted in the user's main account: -- **Product accounts** — sr25519 keys at `//product//{productId}/{suffix}`: a hard - junction at the product boundary, plain soft derivation below it, no secret - path components. +- **Product accounts** — sr25519 keys at `//product//{productId}/{index}`: a + hard junction at the product boundary, plain soft derivation below it, no + secret path components. `{index}` is a 32-byte derivation index. - **Ring-VRF keys** — a hard-only keyed-hash chain rooted at - `hash(root_entropy, "ring-vrf")`, with paths `//{domain}//{index}`. -- **ECDH keys** — P-256 keys whose key material is hashed from sr25519 keys - hard-derived at `//ecdh//{domain}`. + `hash(root_entropy, "ring-vrf")`, with paths `//{domain}//{index}` mirroring + the product account paths (a product's domain is its `productId`). +- **ECDH keys** — P-256 keys whose key material comes from the same + keyed-hash chain, rooted at `hash(root_entropy, "ecdh")`, with paths + `//{domain}`. It amends `ProductAccountId` (`derivation_index: u32` → -`derivation_suffix: Bytes`), adds one Accounts Protocol request for fetching a -product's subtree public key, collapses RFC-0010's `AutoSigning` payload to a -single product-root secret key, and assigns product identities to built-in app -features. No truAPI methods are added or removed. +`derivation_index: Either`), adds one Accounts Protocol request +for fetching a product's subtree public key, collapses RFC-0010's +`AutoSigning` payload to a single product-root secret key, and assigns product +identities to built-in app features. No truAPI methods are added or removed. ## Definitions @@ -38,13 +40,13 @@ features. No truAPI methods are added or removed. - **The Account Seed** (`root_seed`) — the 32-byte substrate-compatible mini-secret derived from the Account Entropy (per `substrate-bip39`). - **Root keypair** — the sr25519 keypair obtained from the Account Seed. All - account and ECDH derivations in this RFC start here. + account derivations in this RFC start here. - **Host** / **Account Holder** — as in RFC-0010: the runtime executing products, and the device holding the user's root secret, respectively. - **MDS** — the multi-device spec describing key management and encryption in a multi-device environment. - **SSO** — single sign-on; a synonym for the Accounts Protocol. -- `Bytes` — a variable-length byte array (SCALE `Vec` on the wire). +- `Either` — a two-variant sum type: `Left(L)` or `Right(R)`. - `Sr25519PublicKey` — a 32-byte sr25519 public key. - `hash(data, key)` — 32-byte BLAKE2b-256 in keyed mode. @@ -82,7 +84,7 @@ Account keys use **sr25519**. ### Product account derivations ``` -//product//{productId}/{suffix} +//product//{productId}/{index} ``` derived from the root keypair with standard substrate sr25519 HDKD — no @@ -92,48 +94,64 @@ secret components, no intermediate hashing layer. the root keypair's other derivations. - `//{productId}` — **hard** junction; `productId` is the product's dotNS identifier (e.g. `browse.dot`). -- `/{suffix}` — **soft** junction. +- `/{index}` — **soft** junction carrying the 32-byte derivation index. The hard junction is the security firewall: leaking the `//product//{productId}` secret key (via `AutoSigning` or compromise) exposes exactly that product's subtree. Below it, soft derivation adds no exposure — any party holding a child secret key already holds the product-root secret key. -#### `ProductAccountId` +#### The 32-byte derivation index -The wire-level `ProductAccountId` is amended to carry the derivation suffix -directly: +Internally — between the Account Holder and Hosts, and in derivation paths — +an account within a product is always identified by a 32-byte index: ```rust -ProductAccountId { - /// A dotNS domain name identifier (e.g., `"my-product.dot"`). - dot_ns_identifier: String, - /// Derivation suffix selecting an account within the product subtree. - derivation_suffix: Bytes, +Index32 = [u8; 32] + +INDEX_MAGIC: [u8; 28] = blake2b256("product-account-index")[..28] + +fn index_bytes(index: u32) -> Index32 { + u32_le_bytes(index) ++ INDEX_MAGIC } ``` -The suffix equals the RFC-0004 proof-context suffix — the alias ↔ account -mapping is the identity on the suffix bytes. RFC-0004's -`product_account_id_for_proof_context` convention (4-byte suffixes packed into -the former `u32`) is obsolete. +Plain `u32` indices are the primary form: they keep a product's accounts +enumerable, and products are expected to use them for all ordinary accounts. +Raw 32-byte values are the escape hatch for cases where bytes are genuinely +necessary. `INDEX_MAGIC` keeps the two spaces separate for all practical use +cases: a raw value only collides with an index if it ends in the magic. -#### Junction encoding +In derivation paths the 32-byte index is used directly as the soft-junction +chain code — it is already exactly 32 bytes, so no substrate path-segment +parsing or normalization is involved. The string segments (`product`, +`productId`) use the standard substrate junction normalization. -A suffix is interpreted exactly as the standard substrate junction encoder -interprets a path segment — no custom rules — keeping paths compatible with -existing tooling (`polkadot-js`, `subkey`). A suffix that is not valid UTF-8 -(which the encoder cannot see as a path segment) is a raw-bytes junction. +A product's default account is index `0`, i.e. `index_bytes(0)`. -One implication: the encoder normalizes numeric segments, so multiple byte -forms of the same number — `b"5"`, `b"05"`, `b"+5"` — derive the same key. -Products that need distinct accounts must use distinct numbers or -non-numeric suffixes. +The same 32-byte index format applies to RFC-0004 contextual-alias suffixes: +`ProductProofContext.suffix` carries the same value, and the alias ↔ account +mapping is the identity on it. This obsoletes RFC-0004's +`product_account_id_for_proof_context` convention (4-byte suffixes packed into +a `u32`). -The empty suffix is invalid; a product's default account uses suffix `b"0"`. +#### `ProductAccountId` -`//product//browse.dot/5` in stock tooling therefore derives the same key as -`derivation_suffix = b"5"`. +The wire-level `ProductAccountId` lets products choose between the two index +forms: + +```rust +ProductAccountId { + /// A dotNS domain name identifier (e.g., `"my-product.dot"`). + dot_ns_identifier: String, + /// Account selector within the product subtree: + /// Left — a plain index (primary form); Right — a raw 32-byte index. + derivation_index: Either, +} +``` + +Hosts map `Left(n)` to `index_bytes(n)` and pass `Right(bytes)` through +unchanged; past the host API boundary only the 32-byte form exists. #### Fetching the product subtree @@ -163,9 +181,9 @@ Host behavior: - Fetch and cache the response on first use of a product's accounts — one round trip per product, ever — then derive account public keys locally via - the soft suffix junction. + the soft index junction. - Without `AutoSigning`, signing round-trips to the Account Holder, which - derives `//product//{productId}/{suffix}` from the root keypair and signs. + derives `//product//{productId}/{index}` from the root keypair and signs. - With `AutoSigning`, the Host soft-derives the child secret key and signs locally. @@ -192,13 +210,14 @@ accounts (`//allowance//{system}//{productId}`) are unchanged. Built-in features derive accounts through the same product scheme, using reserved product identities as their `productId`: -| Category | Feature | `productId` | Protection | -| ------------------------------------ | ---------------------------- | ------------ | ------------------------------------------------------------------------------------------------ | -| Migrating to a product soon | Game (DIM2) | `dimtwo.dot` | Convention only — the product ships imminently and claims the 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 | -| Not coercible to a product | Coinage | — | Deferred to a separate RFC (own layout today: `//pps//coin/{index}`, `//pps//ring-vrf/{index}`) | +| Category | Feature | `productId` | Protection | +| ------------------------------------ | ---------------------------- | ------------ |------------------------------------------------------------------------------------------------| +| 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 | +| Not coercible to a product | Coinage | — | Deferred to a separate RFC (own layout today: `//pps//coin/{index}`, `//pps//ring-vrf/{index}`) | ### Well-known alias accounts @@ -228,49 +247,60 @@ fn derive_ringvrf_hard(parent: RingVrfEntropy, chain_code: ChainCode) -> RingVrf To derive a child `RingVrfEntropy` from `parent` for a path `path`: -1. Parse `path` per the substrate derivation scheme, with the standard 32-byte - chain-code normalization of each segment. Only **hard** junction - separators (`//`) are allowed; a path containing a soft separator is - invalid. Produces `codes: Vec`. +1. Compute each segment's chain code: string segments use the standard + 32-byte substrate normalization; index segments are the 32-byte index used + directly. Only **hard** junction separators (`//`) are allowed; a path + containing a soft separator is invalid. Produces `codes: Vec`. 2. Fold: `codes.fold(parent, |acc, code| derive_ringvrf_hard(acc, code))`. #### General scheme and domains -The general path shape, derived from `root_ringvrf_entropy`, is: +The path shape mirrors the product account paths, derived from +`root_ringvrf_entropy`: ``` //{DerivationDomain}//{DerivationIndex} ``` -The currently defined domains are single keys and carry **no index segment**: +A `DerivationDomain` is always a `productId` — for built-in features, the +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: ```rust -// Ring-VRF key used in the lite-people ring -light_people_domain = "lite-people" // key: //lite-people +// Full personhood ring-VRF key +full_personhood_key = //peopl.dot//index_bytes(0) -// Ring-VRF key used in the people ring -people_domain = "people" // key: //people +// Light personhood ring-VRF key +light_personhood_key = //peopl.dot//index_bytes(1) ``` -The indexed form is reserved for future domains that need multiple keys. -Coinage's ring-VRF keys (recyclers/vouchers) are deferred to the coinage RFC. +Existing keys migrate to these paths. Coinage's ring-VRF keys +(recyclers/vouchers) are deferred to the coinage RFC. ### ECDH key derivations Keys used for ECDH-based E2E encryption are **P-256 (NIST)** keys. Their key -material comes from the same substrate tree as everything else: an sr25519 -key is hard-derived from the root keypair with standard substrate HDKD at +material comes from the same keyed-hash HDKD as ring-VRF keys — not from +schnorrkel derivations — in a tree rooted directly in the Account Entropy: ``` -//ecdh//{DerivationDomain} +root_ecdh_entropy = hash(root_entropy, "ecdh") ``` -and the derived sr25519 private key is hashed to obtain the P-256 key -material. +The key material for a domain is the entropy derived from `root_ecdh_entropy` +for the path (via the ring-VRF HDKD fold): + +``` +//{DerivationDomain} +``` -This RFC specifies only this derivation. The exact hash-to-P-256 key mapping, -key agreement, KDF, and AEAD choices — and a potential migration from P-256 -to x25519 — are specified in a separate encryption RFC. +This RFC specifies only this derivation. The exact material-to-P-256 key +mapping, key agreement, KDF, and AEAD choices — and a potential migration +from P-256 to x25519 — are specified in a separate encryption RFC, along with +migration mechanics for currently deployed keys. Domains for built-in app features: @@ -291,43 +321,40 @@ game_domain = "game" ``` > **Note:** the `game` domain is expected to go away soon. The Game is -> migrating to the `dimtwo.dot` product, which will obtain its key material +> migrating to the `dim2.dot` product, which will obtain its key material > via `host_derive_entropy` (RFC-0007) instead. ### Compatibility There are no production deployments of secret-component derivations or of the `derivation_index`-based `ProductAccountId`; breaking changes are made freely, -with no migration path. +with no migration path. Existing ring-VRF keys move to their `peopl.dot` +paths; deployed encryption keys are handled by the encryption RFC. ## Drawbacks - **One new Accounts Protocol message**, amortized to one round trip per product per Host. -- **Convention-only protection for `dimtwo.dot`** until the Game product - deployment claims the name. -- **Public enumerability**: a party holding a product's subtree public key - can enumerate all of its account public keys. Secret path components would - add no guarantee here: the `//product//{productId}` public key is published nowhere - — not even exposed to the product, only Hosts see it via - `ApProductSubtreeResponse` — and a secret component would have to be shared - with Hosts through that same response, so whatever leaks the subtree public - key leaks the secret with it. Individual product accounts are public - on-chain anyway; privacy-sensitive identities use ring-VRF contextual - aliases (RFC-0004). +- **No path-string tooling round trip.** The 32-byte index junction cannot be + typed as a path segment, so `//product//browse.dot/5` in stock tooling + (`polkadot-js`, `subkey`) does not derive index `5` (`index_bytes(5)`) ## Alternatives - **Secret-component soft paths** - (`/{productId ++ secret}/{suffix ++ secret}`) — rejected for the root-key + (`/{productId ++ secret}/{index ++ secret}`) — rejected for the root-key recovery and round-trip problems described in Motivation. - **Secret components as chain codes** — the same idea carried inside the derivation standard itself rather than managed separately; only available for ed25519, which lacks the soft public-key derivation this design relies on. Rejected together with ed25519. -- **`u32` index suffix** — keeping the current wire type as the derivation - primitive. Rejected in favor of arbitrary byte suffixes, which subsume the - index and equal RFC-0004 suffixes without the 4-byte packing convention. +- **`u32`-only index (status quo wire type)** — keeps accounts enumerable but + cannot express byte-valued selectors (e.g. alias-linked accounts). Rejected: + raw bytes are sometimes necessary. +- **Arbitrary byte suffixes** — maximally general, but makes accounts + non-enumerable by default and pulls substrate path-parsing quirks (numeric + aliasing) into the scheme. Rejected in favor of the fixed 32-byte index + with the `u32` form as the primary, encouraged selector. ## Prior Art and References diff --git a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts index 35393c7d..5b8ec8ec 100644 --- a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -30,7 +30,7 @@ import { makeHostCallbacks, settle } from "./test-support.js"; const GENESIS = `0x${"11".repeat(32)}` as `0x${string}`; const PRODUCT_ACCOUNT = { dotNsIdentifier: "playground.dot", - derivationSuffix: "0x30", + derivationIndex: { tag: "Left" as const, value: 0 }, }; const PROOF_CONTEXT = { productId: "playground.dot", @@ -189,7 +189,7 @@ describe("createWasmRawCallbacks", () => { case "CreateTransaction": return ( review.value.tag === "Product" && - review.value.value.signer.derivationSuffix === "0x30" && + review.value.value.signer.derivationIndex.tag === "Left" && review.value.value.callData === "0x0506" ); case "AccountAlias": diff --git a/js/packages/truapi/README.md b/js/packages/truapi/README.md index 34f76f2f..c8d0d874 100644 --- a/js/packages/truapi/README.md +++ b/js/packages/truapi/README.md @@ -29,7 +29,7 @@ const transport = createTransport(provider); const truapi: Client = createClient(transport); const result = await truapi.accountManagement.accountGet({ - productAccountId: { dotNsIdentifier: "my-product.dot", derivationSuffix: "0x30" }, + productAccountId: { dotNsIdentifier: "my-product.dot", derivationIndex: { tag: "Left", value: 0 } }, }); if (result.isErr()) throw result.error; diff --git a/js/packages/truapi/src/client.test.ts b/js/packages/truapi/src/client.test.ts index de6bb387..300a0c54 100644 --- a/js/packages/truapi/src/client.test.ts +++ b/js/packages/truapi/src/client.test.ts @@ -96,7 +96,7 @@ describe("generated client transport", () => { const client = createClient(transport); const request = { - productAccountId: { dotNsIdentifier: "foo", derivationSuffix: "0x30" }, + productAccountId: { dotNsIdentifier: "foo", derivationIndex: { tag: "Left", value: 0 } }, }; void client.account.getAccount(request); @@ -156,7 +156,7 @@ describe("generated client transport", () => { const client = createClient(transport); const response = client.account.getAccount({ - productAccountId: { dotNsIdentifier: "foo", derivationSuffix: "0x30" }, + productAccountId: { dotNsIdentifier: "foo", derivationIndex: { tag: "Left", value: 0 } }, }); const reason = { tag: "V1", value: { tag: "NotConnected", value: undefined } } as const; const frame = unwrap( 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 56979dd5..7769ba99 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -1,7 +1,8 @@ //! Product account derivation shared by all hosts. //! //! Mirrors host product-account derivation: derive an sr25519 public -//! key through soft HDKD junctions `["product", product_id, derivation_suffix]`. +//! key through soft HDKD junctions `["product", product_id, derivation_index]`, +//! where the derivation index is the 32-byte format defined by RFC-0022. //! Host-spec C.5-C.7 define the product-account derivation, SS58 address, and //! `ProductAccountId` shape: //! @@ -44,19 +45,45 @@ pub fn derive_root_keypair_from_entropy(entropy: &[u8]) -> Result [u8; 28] { + let digest = sp_crypto_hashing::blake2_256(b"product-account-index"); + let mut magic = [0u8; 28]; + magic.copy_from_slice(&digest[..28]); + magic +} + +/// 32-byte derivation index for a plain `u32` index: the index little-endian +/// followed by the index magic. +pub fn index_bytes(index: u32) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[..4].copy_from_slice(&index.to_le_bytes()); + bytes[4..].copy_from_slice(&index_magic()); + bytes +} + +/// Internal 32-byte derivation index for a wire-level account selector. +pub fn derivation_index_bytes(index: &truapi::v01::DerivationIndex) -> [u8; 32] { + match index { + truapi::v01::DerivationIndex::Left(index) => index_bytes(*index), + truapi::v01::DerivationIndex::Right(bytes) => *bytes, + } +} + /// Derive a product-account keypair from the root keypair. /// /// Applies the same soft HDKD junctions `["product", product_id, -/// derivation_suffix]` as [`derive_product_public_key`] on the secret side, so +/// derivation_index]` as [`derive_product_public_key`] on the secret side, so /// the resulting public key equals the seedless public derivation by /// construction. pub fn derive_product_keypair( root: &Keypair, product_id: &str, - derivation_suffix: &[u8], + derivation_index: [u8; 32], ) -> Result { let mut keypair = root.clone(); - for chain_code in product_chain_codes(product_id, derivation_suffix)? { + for chain_code in product_chain_codes(product_id, derivation_index)? { keypair = keypair.derived_key_simple(ChainCode(chain_code), []).0; } Ok(keypair) @@ -66,12 +93,12 @@ pub fn derive_product_keypair( pub fn derive_product_public_key( root_public_key: [u8; 32], product_id: &str, - derivation_suffix: &[u8], + derivation_index: [u8; 32], ) -> Result<[u8; 32], ProductAccountError> { let mut public_key = PublicKey::from_bytes(&root_public_key) .map_err(|_| ProductAccountError::InvalidRootPublicKey)?; - for chain_code in product_chain_codes(product_id, derivation_suffix)? { + for chain_code in product_chain_codes(product_id, derivation_index)? { let (derived, _) = public_key.derived_key_simple(ChainCode(chain_code), []); public_key = derived; } @@ -80,27 +107,19 @@ pub fn derive_product_public_key( } /// Chain codes for the product-account junction path -/// `["product", product_id, derivation_suffix]`. +/// `["product", product_id, derivation_index]`. The 32-byte derivation index +/// is used directly as its junction's chain code. fn product_chain_codes( product_id: &str, - derivation_suffix: &[u8], + derivation_index: [u8; 32], ) -> Result<[[u8; 32]; 3], ProductAccountError> { Ok([ create_chain_code(PRODUCT_JUNCTION)?, create_chain_code(product_id)?, - suffix_chain_code(derivation_suffix)?, + derivation_index, ]) } -/// Chain code for a derivation suffix: a valid-UTF-8 suffix is interpreted as -/// a standard path segment; any other suffix is a raw-bytes junction. -fn suffix_chain_code(suffix: &[u8]) -> Result<[u8; 32], ProductAccountError> { - match core::str::from_utf8(suffix) { - Ok(segment) => create_chain_code(segment), - Err(_) => Ok(normalize_chain_code(suffix.encode())), - } -} - /// Encode a product account public key as a generic Substrate SS58 address. /// /// Delegates to subxt's `AccountId32` Display, which is the generic-substrate @@ -149,20 +168,25 @@ mod tests { ]; #[test] - fn derives_dotli_product_account_vector() { - let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", b"0").unwrap(); + fn derives_product_account_vector() { + // Self-computed regression pin for the RFC-0022 32-byte-index path; + // replace with a cross-implementation vector once the Account Holder + // ships the scheme. + let derived = + derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", index_bytes(0)).unwrap(); assert_eq!( hex::encode(derived), - "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + "0c7da1b57ade0827b6518174da49945b24d79541ee5e5403f646537e5746c80b" ); } #[test] fn derives_different_index_vector() { - let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", b"1").unwrap(); + let derived = + derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", index_bytes(1)).unwrap(); assert_eq!( hex::encode(derived), - "ec8a80808b46e44c1351b68e295eb975c55bda4855e5ea9fc1325be7296a2a4e" + "20cce591a5e5306591de475e3c2efec3d94c6a00b8f52d3703a21f132555ee44" ); } @@ -171,27 +195,29 @@ mod tests { let derived = derive_product_public_key( ROOT_PUBLIC_KEY, "w-credentialless-staticblitz-com.local-credentialless.webcontainer-api.io", - b"0", + index_bytes(0), ) .unwrap(); assert_eq!( hex::encode(derived), - "56769a234038defb62a7ad42f251091cc24846c2473a31b5bdd17d366c38c211" + "06b64516f806d13dceafca5fda4aeac4c99265bc2e5ab3036decef3e7371e03f" ); } #[test] - fn ss58_address_matches_dotli_vector() { - let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", b"0").unwrap(); + fn ss58_address_regression_pin() { + let derived = + derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", index_bytes(0)).unwrap(); assert_eq!( product_public_key_to_address(derived), - "5CyFsdhwjXy7wWpDEM6isungQ3LfGnu9UXkt7paBQ6DYRxk1" + "5CM5kaayBqheti7ugSEty5ptuzFhaP16fVm3ujAMVEtZqnKy" ); } #[test] fn ss58_address_round_trips_to_public_key() { - let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", b"0").unwrap(); + let derived = + derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", index_bytes(0)).unwrap(); let address = product_public_key_to_address(derived); assert_eq!(public_key_from_address(&address), Some(derived)); @@ -201,41 +227,58 @@ mod tests { #[test] fn product_secret_derivation_matches_public_derivation() { // The signing-host secret path and the seedless public path must agree - // on the product public key for any root, suffix, and product id. + // on the product public key for any root, index, and product id. let entropy = [0xABu8; 16]; let root = derive_root_keypair_from_entropy(&entropy).unwrap(); let root_public = root.public.to_bytes(); - for (product_id, suffix) in [ - ("myapp.dot", &b"0"[..]), - ("myapp.dot", b"1"), - ("localhost:3000", b"7"), - ("myapp.dot", b"settings"), - ("myapp.dot", &[0xFF, 0xFE][..]), + for (product_id, index) in [ + ("myapp.dot", index_bytes(0)), + ("myapp.dot", index_bytes(1)), + ("localhost:3000", index_bytes(7)), + ("myapp.dot", [0xEE; 32]), ] { - let keypair = derive_product_keypair(&root, product_id, suffix).unwrap(); - let public = derive_product_public_key(root_public, product_id, suffix).unwrap(); + let keypair = derive_product_keypair(&root, product_id, index).unwrap(); + let public = derive_product_public_key(root_public, product_id, index).unwrap(); assert_eq!( keypair.public.to_bytes(), public, - "{product_id}#{suffix:02x?} secret vs public derivation", + "{product_id}#{index:02x?} secret vs public derivation", ); } } #[test] - fn numeric_suffix_forms_alias_to_one_key() { - // Substrate junction encoding normalizes numeric segments, so - // non-canonical decimal forms of the same number share a key. - let canonical = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", b"5").unwrap(); - let padded = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", b"05").unwrap(); - assert_eq!(canonical, padded); + fn index_bytes_layout_pin() { + let index = index_bytes(5); + assert_eq!(&index[..4], &[5, 0, 0, 0]); + assert_eq!( + index[4..], + sp_crypto_hashing::blake2_256(b"product-account-index")[..28] + ); + } + + #[test] + fn derivation_index_bytes_maps_both_selector_forms() { + use truapi::v01::DerivationIndex; + + assert_eq!( + derivation_index_bytes(&DerivationIndex::Left(7)), + index_bytes(7) + ); + assert_eq!( + derivation_index_bytes(&DerivationIndex::Right([0xEE; 32])), + [0xEE; 32] + ); } #[test] - fn non_utf8_suffix_uses_raw_bytes_junction() { - let raw = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", &[0xFF, 0xFE]).unwrap(); - let canonical = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", b"0").unwrap(); - assert_ne!(raw, canonical); + fn raw_index_space_is_disjoint_from_plain_indexes() { + // A raw all-zero index must not collide with plain index 0: the magic + // keeps the two spaces separate. + let indexed = + derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", index_bytes(0)).unwrap(); + let raw = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", [0u8; 32]).unwrap(); + assert_ne!(indexed, raw); } #[test] @@ -259,7 +302,7 @@ mod tests { #[test] fn product_secret_signs_verifiably() { let root = derive_root_keypair_from_entropy(&[0xABu8; 16]).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", b"0").unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", index_bytes(0)).unwrap(); let message = b"hello"; let signature = keypair .secret diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index 9662ec3e..caf39b6a 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -700,12 +700,13 @@ mod tests { use p256::SecretKey as P256SecretKey; use p256::elliptic_curve::sec1::ToEncodedPoint; use schnorrkel::{ExpansionMode, MiniSecretKey}; + use truapi::latest::DerivationIndex; use truapi::latest::{HostSignPayloadData, TxPayloadExtension}; fn account() -> ProductAccountId { ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_suffix: b"7".to_vec(), + derivation_index: DerivationIndex::Left(7), } } @@ -829,7 +830,7 @@ mod tests { ProductAccountTxPayload { signer: ProductAccountId { dot_ns_identifier: "truapi-playground.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: DerivationIndex::Left(0), }, genesis_hash: sequential_bytes(32), call_data: vec![0, 0], @@ -844,7 +845,7 @@ mod tests { assert_host_papp_0_8_8_fixture( message, - "0x306d2d70726f647563742d7478000700547472756170692d706c617967726f756e642e646f740430202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f0800000428436865636b4e6f6e6365040108020300", + "0x306d2d70726f647563742d7478000700547472756170692d706c617967726f756e642e646f740000000000202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f0800000428436865636b4e6f6e6365040108020300", ); } @@ -855,7 +856,7 @@ mod tests { ProductAccountTxPayload { signer: ProductAccountId { dot_ns_identifier: "truapi-playground.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: DerivationIndex::Left(0), }, genesis_hash: [ 0xbf, 0x04, 0x88, 0xdb, 0xe9, 0xda, 0xa1, 0xde, 0x1c, 0x08, 0xc5, 0xf7, 0x43, @@ -870,7 +871,7 @@ mod tests { assert_host_papp_0_8_8_fixture( message, - "0x506372656174652d7472616e73616374696f6e2d31000700547472756170692d706c617967726f756e642e646f740430bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f0800000000", + "0x506372656174652d7472616e73616374696f6e2d31000700547472756170692d706c617967726f756e642e646f740000000000bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f0800000000", ); } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 3f1e659e..d2c3cb6f 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -33,7 +33,9 @@ use crate::host_logic::bulletin::preimage_key; use crate::host_logic::dotns::{NavigateDecision, parse_navigate}; use crate::host_logic::features::feature_supported; use crate::host_logic::permissions::PermissionsService; -use crate::host_logic::product_account::{derive_product_public_key, public_key_from_address}; +use crate::host_logic::product_account::{ + derivation_index_bytes, derive_product_public_key, index_bytes, public_key_from_address, +}; use crate::host_logic::session::SessionInfo; #[cfg(test)] use crate::host_logic::session::SessionState; @@ -419,7 +421,7 @@ impl ProductRuntimeHost { Ok(v01::ProductAccountId { dot_ns_identifier: normalize_product_identifier(&product_account_id.dot_ns_identifier) .map_err(|_| ())?, - derivation_suffix: product_account_id.derivation_suffix, + derivation_index: product_account_id.derivation_index, }) } @@ -428,7 +430,7 @@ impl ProductRuntimeHost { } fn legacy_slot_zero_public_key(&self, session: &AuthoritySession) -> Result<[u8; 32], String> { - derive_product_public_key(session.public_key, &self.product_id(), b"0") + derive_product_public_key(session.public_key, &self.product_id(), index_bytes(0)) .map_err(|err| err.to_string()) } @@ -837,7 +839,7 @@ impl Account for ProductRuntimeHost { let public_key = derive_product_public_key( session.public_key, &product_account_id.dot_ns_identifier, - &product_account_id.derivation_suffix, + derivation_index_bytes(&product_account_id.derivation_index), ) .map_err(|err| { CallError::Domain(HostAccountGetError::V1(v01::HostAccountGetError::Unknown { @@ -979,8 +981,8 @@ impl Account for ProductRuntimeHost { let product_id = self.product_id(); - let public_key = - derive_product_public_key(session.public_key, &product_id, b"0").map_err(|err| { + let public_key = derive_product_public_key(session.public_key, &product_id, index_bytes(0)) + .map_err(|err| { CallError::Domain(HostGetLegacyAccountsError::V1( v01::HostAccountGetError::Unknown { reason: err.to_string(), @@ -1348,7 +1350,7 @@ impl Signing for ProductRuntimeHost { SignPayloadAuthorityRequest::LegacyAccount { product_account: v01::ProductAccountId { dot_ns_identifier: self.product_id(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, request: inner, }, @@ -1403,7 +1405,7 @@ impl Signing for ProductRuntimeHost { LegacySigner::Product => SignRawAuthorityRequest::Product(v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: self.product_id(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, payload: inner.payload, }), @@ -1486,7 +1488,7 @@ impl Signing for ProductRuntimeHost { CreateTransactionAuthorityRequest::LegacyAccount { product_account: v01::ProductAccountId { dot_ns_identifier: self.product_id(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, request: inner, }, @@ -2320,7 +2322,7 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, }); let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); @@ -2341,7 +2343,7 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "example.com".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, }); let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); @@ -2366,7 +2368,7 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "other.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, }); let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); @@ -2402,7 +2404,7 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "other.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, }); let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); @@ -2427,14 +2429,14 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "other.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, }); let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); let HostAccountGetResponse::V1(inner) = response; assert_eq!( inner.account.public_key, - derive_product_public_key(session.public_key, "other.dot", b"0") + derive_product_public_key(session.public_key, "other.dot", index_bytes(0)) .unwrap() .to_vec() ); @@ -2449,14 +2451,14 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, }); let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); let HostAccountGetResponse::V1(inner) = response; assert_eq!( hex::encode(inner.account.public_key), - "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + "0c7da1b57ade0827b6518174da49945b24d79541ee5e5403f646537e5746c80b" ); } @@ -2469,14 +2471,14 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "MyApp.DOT".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, }); let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); let HostAccountGetResponse::V1(inner) = response; assert_eq!( hex::encode(inner.account.public_key), - "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + "0c7da1b57ade0827b6518174da49945b24d79541ee5e5403f646537e5746c80b" ); } @@ -2495,14 +2497,14 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, }); let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); let HostAccountGetResponse::V1(inner) = response; assert_eq!( hex::encode(inner.account.public_key), - "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + "0c7da1b57ade0827b6518174da49945b24d79541ee5e5403f646537e5746c80b" ); } @@ -2712,7 +2714,7 @@ mod tests { assert_eq!(inner.accounts[0].name.as_deref(), Some("alice")); assert_eq!( hex::encode(&inner.accounts[0].public_key), - "1c822b488297fde8c60d9cbc5585839f70a69fb2c5c69daa66b6043c75184467" + "b8de3e1888d4f0c37313a1fc8309b3c0fbaee7dd09346173ede41c5a507cc049" ); } @@ -3671,7 +3673,7 @@ mod tests { let cx = CallContext::new(); let request = HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { - signer: "5CyFsdhwjXy7wWpDEM6isungQ3LfGnu9UXkt7paBQ6DYRxk1".to_string(), + signer: "5CM5kaayBqheti7ugSEty5ptuzFhaP16fVm3ujAMVEtZqnKy".to_string(), payload: raw_payload(), }); let err = futures::executor::block_on(host.sign_raw_with_legacy_account(&cx, request)) @@ -3704,7 +3706,7 @@ mod tests { let cx = CallContext::with_request_id("legacy-sign-raw-1".to_string()); let request = HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { - signer: "5CyFsdhwjXy7wWpDEM6isungQ3LfGnu9UXkt7paBQ6DYRxk1".to_string(), + signer: "5CM5kaayBqheti7ugSEty5ptuzFhaP16fVm3ujAMVEtZqnKy".to_string(), payload: raw_payload(), }); let response = @@ -3726,7 +3728,7 @@ mod tests { request.product_account_id, v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), } ); assert!(matches!( @@ -3739,7 +3741,8 @@ mod tests { #[test] fn legacy_sign_raw_accepts_derived_hex_then_returns_sso_response() { let session = sso_session_info(); - let signer = derive_product_public_key(session.public_key, "myapp.dot", b"0").unwrap(); + let signer = + derive_product_public_key(session.public_key, "myapp.dot", index_bytes(0)).unwrap(); let platform = Arc::new(StubPlatform { sign_raw_confirmed: true, sso_response_script: Some(sso_success_response_script( @@ -3779,7 +3782,7 @@ mod tests { request.product_account_id, v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), } ); } @@ -3879,7 +3882,8 @@ mod tests { #[test] fn legacy_create_transaction_accepts_derived_key_then_returns_sso_response() { let session = sso_session_info(); - let signer = derive_product_public_key(session.public_key, "myapp.dot", b"0").unwrap(); + let signer = + derive_product_public_key(session.public_key, "myapp.dot", index_bytes(0)).unwrap(); let platform = Arc::new(StubPlatform { create_transaction_confirmed: true, sso_response_script: Some(sso_success_response_script( @@ -3933,7 +3937,7 @@ mod tests { payload.signer, v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), } ); } diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 6e27e763..a2fb0ce1 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -22,7 +22,7 @@ use super::connected_session_ui_info; use crate::host_logic::entropy::derive_product_entropy; use crate::host_logic::extrinsic::{Sr25519Signer, build_signed_extrinsic_v4}; use crate::host_logic::product_account::{ - ProductAccountError, SR25519_SIGNING_CONTEXT, derive_product_keypair, + ProductAccountError, SR25519_SIGNING_CONTEXT, derivation_index_bytes, derive_product_keypair, derive_root_keypair_from_entropy, }; use crate::host_logic::session::SessionState; @@ -85,8 +85,12 @@ impl SigningHost { reason: err.to_string(), } })?; - derive_product_keypair(&root, &product_id, &account.derivation_suffix) - .map_err(product_authority_error) + derive_product_keypair( + &root, + &product_id, + derivation_index_bytes(&account.derivation_index), + ) + .map_err(product_authority_error) } } @@ -388,7 +392,7 @@ mod tests { use super::{BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, raw_payload_bytes}; use crate::host_logic::extrinsic::tests::split_v4; use crate::host_logic::product_account::{ - derive_product_keypair, derive_root_keypair_from_entropy, + derive_product_keypair, derive_root_keypair_from_entropy, index_bytes, }; use crate::test_support::{StubPlatform, test_spawner}; use truapi::api::{Account, Entropy, Signing}; @@ -462,7 +466,7 @@ mod tests { let request = HostSignRawRequest::V1(v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, payload: v01::RawPayload::Bytes { bytes: b"hello world".to_vec(), @@ -473,7 +477,7 @@ mod tests { assert!(response.signed_transaction.is_none()); let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", b"0").unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", index_bytes(0)).unwrap(); let signature = schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); assert!( @@ -493,7 +497,7 @@ mod tests { let request = HostSignRawRequest::V1(v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, payload: v01::RawPayload::Bytes { bytes: vec![1, 2, 3], @@ -507,7 +511,7 @@ mod tests { fn product_account(index: u32) -> v01::ProductAccountId { v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_suffix: index.to_string().into_bytes(), + derivation_index: v01::DerivationIndex::Left(index), } } @@ -544,7 +548,7 @@ mod tests { assert_eq!(tail, vec![1, 0x00, 0x00], "body tail is extra ++ call_data"); let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", b"0").unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", index_bytes(0)).unwrap(); assert_eq!(account, keypair.public.to_bytes()); // Payload = call_data ++ extra ++ additional_signed (call first). @@ -613,7 +617,7 @@ mod tests { let cx = CallContext::new(); let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", b"0").unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", index_bytes(0)).unwrap(); let request = CreateTransactionAuthorityRequest::LegacyAccount { product_account: product_account(0), @@ -692,7 +696,7 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, }); let err = futures::executor::block_on(runtime.get_account(&cx, request)) @@ -768,7 +772,7 @@ mod tests { let request = HostSignRawRequest::V1(v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, payload: v01::RawPayload::Bytes { bytes: b"hi".to_vec(), @@ -777,7 +781,7 @@ mod tests { let HostSignRawResponse::V1(response) = futures::executor::block_on(runtime.sign_raw(&cx, request)).expect("sign_raw ok"); let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let keypair = derive_product_keypair(&root, "myapp.dot", b"0").unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", index_bytes(0)).unwrap(); let signature = schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); assert!( @@ -820,7 +824,7 @@ mod tests { let request = v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, payload: v01::RawPayload::Bytes { bytes: vec![1, 2, 3], @@ -849,7 +853,7 @@ mod tests { let request = v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_suffix: b"0".to_vec(), + derivation_index: v01::DerivationIndex::Left(0), }, payload: v01::RawPayload::Bytes { bytes: vec![1] }, }; diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index 6367f4f2..158c7e37 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -10,7 +10,7 @@ use super::{ ProductRuntimeHost, REMOTE_PERMISSION_DENIED_REASON, remote_authority_call, remote_authority_context, }; -use crate::host_logic::product_account::derive_product_public_key; +use crate::host_logic::product_account::{derivation_index_bytes, derive_product_public_key}; use crate::host_logic::statement_store::{ MAX_MATCH_ALL_TOPICS, MAX_MATCH_ANY_TOPICS, TopicFilterKind, decode_signed_statement, parse_new_statements_result, sign_statement_fields, signed_statement_to_scale, @@ -274,7 +274,7 @@ impl ProductRuntimeHost { let signer = derive_product_public_key( session.public_key, &product_account_id.dot_ns_identifier, - &product_account_id.derivation_suffix, + derivation_index_bytes(&product_account_id.derivation_index), ) .map_err(|err| StatementProofFailure::UnableToSign(err.to_string()))?; let fields = statement_fields_from_v01(statement) @@ -385,6 +385,7 @@ mod tests { use super::*; use crate::host_logic::product_account::{ SR25519_SIGNING_CONTEXT, derive_product_keypair, derive_root_keypair_from_entropy, + index_bytes, }; use crate::test_support::{ StubPlatform, account_id, new_statements_frame, runtime_config, signed_statement, @@ -461,7 +462,7 @@ mod tests { let statement = statement(); let payload = statement_payload(statement.clone()); let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); - let product_keypair = derive_product_keypair(&root, "myapp.dot", b"0").unwrap(); + let product_keypair = derive_product_keypair(&root, "myapp.dot", index_bytes(0)).unwrap(); let expected_signer = product_keypair.public.to_bytes(); let cx = CallContext::new(); let request = RemoteStatementStoreCreateProofRequest::V1( diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 4c20225b..548522c4 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -615,7 +615,7 @@ pub(crate) fn sign_raw_legacy_response_message( pub(crate) fn account_id(identifier: &str, index: u32) -> v01::ProductAccountId { v01::ProductAccountId { dot_ns_identifier: identifier.to_string(), - derivation_suffix: index.to_string().into_bytes(), + derivation_index: v01::DerivationIndex::Left(index), } } diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index 635eaaed..c0fc05cd 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -38,7 +38,7 @@ pub trait Account: Send + Sync { /// const result = await truapi.account.getAccount({ /// productAccountId: { /// dotNsIdentifier: "truapi-playground.dot", - /// derivationSuffix: "0x30", + /// derivationIndex: { tag: "Left", value: 0 }, /// }, /// }); /// assert(result.isOk(), "getAccount failed:", result); @@ -47,7 +47,7 @@ pub trait Account: Send + Sync { /// const otherProduct = await truapi.account.getAccount({ /// productAccountId: { /// dotNsIdentifier: "other-product.dot", - /// derivationSuffix: "0x30", + /// derivationIndex: { tag: "Left", value: 0 }, /// }, /// }); /// assert(otherProduct.isOk(), "cross-product getAccount was denied or failed:", otherProduct); diff --git a/rust/crates/truapi/src/api/signing.rs b/rust/crates/truapi/src/api/signing.rs index 4dedb77e..2c272587 100644 --- a/rust/crates/truapi/src/api/signing.rs +++ b/rust/crates/truapi/src/api/signing.rs @@ -25,7 +25,7 @@ pub trait Signing: Send + Sync { /// const payload = await buildCreateTransactionPayload({ /// signer: { /// dotNsIdentifier: "truapi-playground.dot", - /// derivationSuffix: "0x30", + /// derivationIndex: { tag: "Left", value: 0 }, /// }, /// genesisHash: PASEO_NEXT_V2_INDIVIDUALITY.genesis, /// callData: "0x000000", @@ -59,7 +59,7 @@ pub trait Signing: Send + Sync { /// const payload = await buildCreateTransactionPayload({ /// signer: { /// dotNsIdentifier: "truapi-playground.dot", - /// derivationSuffix: "0x30", + /// derivationIndex: { tag: "Left", value: 0 }, /// }, /// genesisHash: PASEO_NEXT_V2_INDIVIDUALITY.genesis, /// callData: "0x000000", @@ -156,7 +156,7 @@ pub trait Signing: Send + Sync { /// /// ```ts /// const result = await truapi.signing.signRaw({ - /// account: { dotNsIdentifier: "truapi-playground.dot", derivationSuffix: "0x30" }, + /// account: { dotNsIdentifier: "truapi-playground.dot", derivationIndex: { tag: "Left", value: 0 } }, /// payload: { /// tag: "Bytes", /// value: { @@ -182,7 +182,7 @@ pub trait Signing: Send + Sync { /// import { PASEO_NEXT_V2_ASSET_HUB } from "@parity/truapi"; /// /// const result = await truapi.signing.signPayload({ - /// account: { dotNsIdentifier: "truapi-playground.dot", derivationSuffix: "0x30" }, + /// account: { dotNsIdentifier: "truapi-playground.dot", derivationIndex: { tag: "Left", value: 0 } }, /// payload: { /// blockHash: "0xd6eec26135305a8ad257a20d003357284c8aa03d0bdb2b357ab0a22371e11ef2", /// blockNumber: "0x00000000", diff --git a/rust/crates/truapi/src/api/statement_store.rs b/rust/crates/truapi/src/api/statement_store.rs index 67093646..a0a2d802 100644 --- a/rust/crates/truapi/src/api/statement_store.rs +++ b/rust/crates/truapi/src/api/statement_store.rs @@ -80,7 +80,7 @@ pub trait StatementStore: Send + Sync { /// const result = await truapi.statementStore.createProof({ /// productAccountId: { /// dotNsIdentifier: "truapi-playground.dot", - /// derivationSuffix: "0x30", + /// derivationIndex: { tag: "Left", value: 0 }, /// }, /// statement, /// }); diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 2708a906..245f1e54 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -27,11 +27,11 @@ pub mod latest { use crate::versioned::{self, Versioned}; pub use crate::v01::{ - AccountId, AllocatableResource, AllocationOutcome, ContextualAlias, GenericError, - HostSignPayloadData, NotificationId, OperationStartedResult, ProductAccountId, - ProductProofContext, RawPayload, RemotePermission, RingLocation, RuntimeApi, RuntimeSpec, - RuntimeType, StorageQueryItem, StorageQueryType, StorageResultItem, ThemeVariant, - TxPayloadExtension, + AccountId, AllocatableResource, AllocationOutcome, ContextualAlias, DerivationIndex, + GenericError, HostSignPayloadData, NotificationId, OperationStartedResult, + ProductAccountId, ProductProofContext, RawPayload, RemotePermission, RingLocation, + RuntimeApi, RuntimeSpec, RuntimeType, StorageQueryItem, StorageQueryType, + StorageResultItem, ThemeVariant, TxPayloadExtension, }; pub type LatestOf = ::Latest; diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index ba3ff68c..526a25d2 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -1,16 +1,28 @@ use crate::v01::transaction::GenesisHash; use parity_scale_codec::{Decode, Encode}; +/// Account selector within a product subtree: `Either`. +/// +/// `Left` is the primary form — plain indices keep a product's accounts +/// enumerable. `Right` carries a raw 32-byte derivation index for cases where +/// bytes are genuinely necessary. Hosts expand `Left(n)` to the internal +/// 32-byte index (`u32` little-endian plus the index magic). +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum DerivationIndex { + /// Plain account index. + Left(u32), + /// Raw 32-byte derivation index. + Right([u8; 32]), +} + /// Identifies a product-specific account by combining a dotNS domain name with a -/// derivation suffix. +/// derivation index. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct ProductAccountId { /// A dotNS domain name identifier (e.g., `"my-product.dot"`). pub dot_ns_identifier: String, - /// Derivation suffix selecting an account within the product subtree. - /// Interpreted as a standard substrate derivation-path segment when valid - /// UTF-8, and as a raw-bytes junction otherwise. - pub derivation_suffix: Vec, + /// Account selector within the product subtree. + pub derivation_index: DerivationIndex, } /// A user-imported (legacy) account: public key plus an optional user-chosen From ead7bb8c9402aa2db430d704263989e636ff742c Mon Sep 17 00:00:00 2001 From: valentunn Date: Wed, 22 Jul 2026 20:29:00 +0700 Subject: [PATCH 5/6] Adjustements --- docs/rfcs/0022-account-derivations.md | 29 ++++++++++++++----- .../src/host-callbacks-adapter.test.ts | 4 +-- rust/crates/truapi-server/src/test_support.rs | 2 +- .../truapi-server/tests/wire_result_shape.rs | 2 +- rust/crates/truapi/src/api/account.rs | 4 +-- rust/crates/truapi/src/v01/account.rs | 5 ++-- 6 files changed, 31 insertions(+), 15 deletions(-) diff --git a/docs/rfcs/0022-account-derivations.md b/docs/rfcs/0022-account-derivations.md index a0fc3359..9c963475 100644 --- a/docs/rfcs/0022-account-derivations.md +++ b/docs/rfcs/0022-account-derivations.md @@ -28,7 +28,8 @@ account: `//{domain}`. It amends `ProductAccountId` (`derivation_index: u32` → -`derivation_index: Either`), adds one Accounts Protocol request +`derivation_index: Either`) and `ProductProofContext.suffix` +(arbitrary bytes → the same `Either`), adds one Accounts Protocol request for fetching a product's subtree public key, collapses RFC-0010's `AutoSigning` payload to a single product-root secret key, and assigns product identities to built-in app features. No truAPI methods are added or removed. @@ -129,12 +130,6 @@ parsing or normalization is involved. The string segments (`product`, A product's default account is index `0`, i.e. `index_bytes(0)`. -The same 32-byte index format applies to RFC-0004 contextual-alias suffixes: -`ProductProofContext.suffix` carries the same value, and the alias ↔ account -mapping is the identity on it. This obsoletes RFC-0004's -`product_account_id_for_proof_context` convention (4-byte suffixes packed into -a `u32`). - #### `ProductAccountId` The wire-level `ProductAccountId` lets products choose between the two index @@ -153,6 +148,26 @@ ProductAccountId { Hosts map `Left(n)` to `index_bytes(n)` and pass `Right(bytes)` through unchanged; past the host API boundary only the 32-byte form exists. +#### `ProductProofContext` (RFC-0004) + +The contextual-alias suffix is the same selector. `ProductProofContext` is +amended to: + +```rust +ProductProofContext { + /// dotNS product identifier (e.g. `"my-product.dot"`) scoping the context. + product_id: String, + /// Selector distinguishing contexts within the product; expands to the + /// same 32-byte derivation index as `ProductAccountId.derivation_index`. + suffix: Either, +} +``` + +The suffix expands to the same 32-byte value as an account's derivation +index, so the alias ↔ account mapping is the identity on it. This obsoletes +RFC-0004's `product_account_id_for_proof_context` convention (4-byte suffixes +packed into a `u32`). + #### Fetching the product subtree `//product//{productId}` is hard, so the root public key alone no longer determines diff --git a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts index 5b8ec8ec..8aeac81b 100644 --- a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -34,7 +34,7 @@ const PRODUCT_ACCOUNT = { }; const PROOF_CONTEXT = { productId: "playground.dot", - suffix: "0x00" as const, + suffix: { tag: "Left" as const, value: 0 }, }; const RING_LOCATION = { chainId: GENESIS, @@ -202,7 +202,7 @@ describe("createWasmRawCallbacks", () => { case "CreateProof": return ( review.value.callingProductId === "playground.dot" && - review.value.context.suffix === "0x00" && + review.value.context.suffix.tag === "Left" && review.value.message[0] === 7 ); case "AccountAccess": diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 548522c4..b84d09d2 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -630,7 +630,7 @@ pub(crate) fn raw_payload() -> v01::RawPayload { pub(crate) fn product_proof_context(product_id: &str) -> v01::ProductProofContext { v01::ProductProofContext { product_id: product_id.to_string(), - suffix: vec![7], + suffix: v01::DerivationIndex::Left(7), } } diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs index f742a88b..f6084b6f 100644 --- a/rust/crates/truapi-server/tests/wire_result_shape.rs +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -185,7 +185,7 @@ fn account_proof_declined_confirmation_returns_rejected() { let request = account::HostAccountCreateProofRequest::V1(v01::HostAccountCreateProofRequest { context: v01::ProductProofContext { product_id: "myapp.dot".to_string(), - suffix: Vec::new(), + suffix: v01::DerivationIndex::Left(0), }, ring_location: v01::RingLocation { chain_id: [0u8; 32], diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index c0fc05cd..41795115 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -71,7 +71,7 @@ pub trait Account: Send + Sync { /// "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; /// /// const result = await truapi.account.getAccountAlias({ - /// context: { productId: "truapi-playground.dot", suffix: "0x00" }, + /// context: { productId: "truapi-playground.dot", suffix: { tag: "Left", value: 0 } }, /// ringLocation: { /// chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, /// junctions: [ @@ -101,7 +101,7 @@ pub trait Account: Send + Sync { /// "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; /// /// const result = await truapi.account.createAccountProof({ - /// context: { productId: "truapi-playground.dot", suffix: "0x00" }, + /// context: { productId: "truapi-playground.dot", suffix: { tag: "Left", value: 0 } }, /// ringLocation: { /// chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, /// junctions: [ diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index 526a25d2..8a1c8423 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -82,8 +82,9 @@ pub struct RingLocation { pub struct ProductProofContext { /// dotNS product identifier (e.g. `"my-product.dot"`) scoping the context. pub product_id: String, - /// Arbitrary-byte suffix distinguishing contexts within the product. - pub suffix: Vec, + /// Selector distinguishing contexts within the product; expands to the + /// same 32-byte derivation index as [`ProductAccountId::derivation_index`]. + pub suffix: DerivationIndex, } /// Request to create a ring VRF proof. From 4a230dcba4ede0362a5fcca467f793665196981d Mon Sep 17 00:00:00 2001 From: valentunn Date: Fri, 24 Jul 2026 18:51:45 +0700 Subject: [PATCH 6/6] All other usages of derivation index --- docs/rfcs/0022-account-derivations.md | 63 ++++++++++++++++--- .../src/host_logic/sso/messages.rs | 22 +++---- .../truapi-server/tests/wire_result_shape.rs | 2 +- rust/crates/truapi/src/api/payment.rs | 6 +- .../truapi/src/api/resource_allocation.rs | 2 +- rust/crates/truapi/src/v01/payment.rs | 8 ++- .../truapi/src/v01/resource_allocation.rs | 6 +- 7 files changed, 79 insertions(+), 30 deletions(-) diff --git a/docs/rfcs/0022-account-derivations.md b/docs/rfcs/0022-account-derivations.md index 9c963475..8f9b2db2 100644 --- a/docs/rfcs/0022-account-derivations.md +++ b/docs/rfcs/0022-account-derivations.md @@ -27,12 +27,15 @@ account: keyed-hash chain, rooted at `hash(root_entropy, "ecdh")`, with paths `//{domain}`. -It amends `ProductAccountId` (`derivation_index: u32` → -`derivation_index: Either`) and `ProductProofContext.suffix` -(arbitrary bytes → the same `Either`), adds one Accounts Protocol request -for fetching a product's subtree public key, collapses RFC-0010's -`AutoSigning` payload to a single product-root secret key, and assigns product -identities to built-in app features. No truAPI methods are added or removed. +It makes `Either` the single selector for an account within a +product subtree, amending every wire field that carries one: +`ProductAccountId.derivation_index`, `ProductProofContext.suffix`, +`PaymentTopUpSource::ProductAccount.derivation_index`, and +`AllocatableResource::SmartContractAllowance`. It also adds one Accounts +Protocol request for fetching a product's subtree public key, collapses +RFC-0010's `AutoSigning` payload to a single product-root secret key, and +assigns product identities to built-in app features. No truAPI methods are +added or removed. ## Definitions @@ -130,7 +133,14 @@ parsing or normalization is involved. The string segments (`product`, A product's default account is index `0`, i.e. `index_bytes(0)`. -#### `ProductAccountId` +#### Selector-carrying wire types + +Every wire field that picks an account inside a product subtree carries the +same `Either` selector, so a product spells an account the same +way whether it is signing with it, proving against it, funding from it, or +pre-warming it. + +##### `ProductAccountId` The wire-level `ProductAccountId` lets products choose between the two index forms: @@ -148,7 +158,7 @@ ProductAccountId { Hosts map `Left(n)` to `index_bytes(n)` and pass `Right(bytes)` through unchanged; past the host API boundary only the 32-byte form exists. -#### `ProductProofContext` (RFC-0004) +##### `ProductProofContext` (RFC-0004) The contextual-alias suffix is the same selector. `ProductProofContext` is amended to: @@ -168,6 +178,36 @@ index, so the alias ↔ account mapping is the identity on it. This obsoletes RFC-0004's `product_account_id_for_proof_context` convention (4-byte suffixes packed into a `u32`). +##### `PaymentTopUpSource::ProductAccount` (RFC-0006) + +A top-up funded from the calling product's own accounts names one of them, so +the source variant carries the selector rather than a bare index: + +```rust +PaymentTopUpSource::ProductAccount { + /// Account selector within the calling product's subtree. + derivation_index: Either, +} +``` + +The other `PaymentTopUpSource` variants (`PrivateKey`, `Coins`) are unchanged — +they carry raw secret keys and name no derived account. + +##### `AllocatableResource::SmartContractAllowance` (RFC-0010) + +Pre-warming PGAS targets a product account, so the allowance names it with the +same selector: + +```rust +AllocatableResource::SmartContractAllowance(Either) +``` + +RFC-0010 spells this payload `dest: DerivationIndex`; `DerivationIndex` is the +selector defined here. `ApAllocatableResource::SmartContractAllowance`, its +Accounts Protocol counterpart, is amended identically. The allocated material +(`SmartContractAllowance` in `ApAllocatedResource`) stays empty — pre-warming +returns no key. + #### Fetching the product subtree `//product//{productId}` is hard, so the root public key alone no longer determines @@ -342,8 +382,9 @@ game_domain = "game" ### Compatibility There are no production deployments of secret-component derivations or of the -`derivation_index`-based `ProductAccountId`; breaking changes are made freely, -with no migration path. Existing ring-VRF keys move to their `peopl.dot` +`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` paths; deployed encryption keys are handled by the encryption RFC. ## Drawbacks @@ -375,6 +416,8 @@ paths; deployed encryption keys are handled by the encryption RFC. - **RFC-0004** — context-scoped proofs; its `product//` context prefix is the alias-side analogue of the hard product junction here. +- **RFC-0006** — payments; its `PaymentTopUpSource::ProductAccount` selector is + amended here. - **RFC-0007** — `host_derive_entropy`; a separate per-product entropy namespace for products that need raw key material. - **RFC-0010** — allowance and `AutoSigning`; amended here, allowance diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index 5c9c962f..9a6f30f7 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -21,9 +21,10 @@ use parity_scale_codec::{Decode, Encode, OptionBool}; use truapi::latest::{ - AccountId, AllocatableResource, HostAccountCreateProofResponse, HostAccountGetAliasResponse, - HostSignPayloadRequest, HostSignRawRequest, LegacyAccountTxPayload, ProductAccountId, - ProductAccountTxPayload, ProductProofContext, RawPayload, RingLocation, + AccountId, AllocatableResource, DerivationIndex, HostAccountCreateProofResponse, + HostAccountGetAliasResponse, HostSignPayloadRequest, HostSignRawRequest, + LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, ProductProofContext, + RawPayload, RingLocation, }; use crate::host_logic::session::SsoSessionInfo; @@ -339,9 +340,9 @@ pub enum SsoAllocatableResource { StatementStoreAllowance, /// Bulletin chain slot allowance for the product's allowance account. BulletinAllowance, - /// Pre-warmed PGAS balance for the smart-contract account at the given + /// Pre-warmed PGAS balance for the product account selected by this /// derivation index. - SmartContractAllowance(u32), + SmartContractAllowance(DerivationIndex), /// Transfer of the product subtree key so the host can sign locally. AutoSigning, } @@ -788,7 +789,6 @@ mod tests { use p256::SecretKey as P256SecretKey; use p256::elliptic_curve::sec1::ToEncodedPoint; use schnorrkel::{ExpansionMode, MiniSecretKey}; - use truapi::latest::DerivationIndex; use truapi::latest::{HostSignPayloadData, TxPayloadExtension}; fn account() -> ProductAccountId { @@ -892,14 +892,14 @@ mod tests { } #[test] - fn resource_allocation_message_matches_host_papp_0_8_8_fixture() { + fn resource_allocation_message_wire_shape_pin() { let message = resource_allocation_message( "m-resource".to_string(), "truapi-playground.dot".to_string(), vec![ AllocatableResource::StatementStoreAllowance, AllocatableResource::BulletinAllowance, - AllocatableResource::SmartContractAllowance(9), + AllocatableResource::SmartContractAllowance(DerivationIndex::Left(9)), AllocatableResource::AutoSigning, ], OnExistingAllowancePolicy::Increase, @@ -907,7 +907,7 @@ mod tests { assert_host_papp_0_8_8_fixture( message, - "0x286d2d7265736f757263650005547472756170692d706c617967726f756e642e646f7410000102090000000301", + "0x286d2d7265736f757263650005547472756170692d706c617967726f756e642e646f741000010200090000000301", ); } @@ -1051,7 +1051,7 @@ mod tests { vec![ AllocatableResource::StatementStoreAllowance, AllocatableResource::BulletinAllowance, - AllocatableResource::SmartContractAllowance(9), + AllocatableResource::SmartContractAllowance(DerivationIndex::Left(9)), AllocatableResource::AutoSigning, ], OnExistingAllowancePolicy::Increase, @@ -1067,7 +1067,7 @@ mod tests { vec![ SsoAllocatableResource::StatementStoreAllowance, SsoAllocatableResource::BulletinAllowance, - SsoAllocatableResource::SmartContractAllowance(9), + SsoAllocatableResource::SmartContractAllowance(DerivationIndex::Left(9)), SsoAllocatableResource::AutoSigning, ] ); diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs index f6084b6f..4ce82193 100644 --- a/rust/crates/truapi-server/tests/wire_result_shape.rs +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -238,7 +238,7 @@ fn deferred_payment_requests_return_dotli_not_implemented_errors() { into: None, amount: 1, source: v01::PaymentTopUpSource::ProductAccount { - derivation_index: 0, + derivation_index: v01::DerivationIndex::Left(0), }, }); assert_request_returns_domain_error( diff --git a/rust/crates/truapi/src/api/payment.rs b/rust/crates/truapi/src/api/payment.rs index cd1d3aae..01d61868 100644 --- a/rust/crates/truapi/src/api/payment.rs +++ b/rust/crates/truapi/src/api/payment.rs @@ -40,7 +40,7 @@ pub trait Payment: Send + Sync { /// // Fund the balance first so the request is not rejected for lack of funds. /// const topUp = await truapi.payment.topUp({ /// amount: 1000n, - /// source: { tag: "ProductAccount", value: { derivationIndex: 0 } }, + /// source: { tag: "ProductAccount", value: { derivationIndex: { tag: "Left", value: 0 } } }, /// }); /// assert(topUp.isOk(), "topUp failed:", topUp); /// @@ -69,7 +69,7 @@ pub trait Payment: Send + Sync { /// // Fund the balance and start a payment first so there is a status to watch. /// const topUp = await truapi.payment.topUp({ /// amount: 1000n, - /// source: { tag: "ProductAccount", value: { derivationIndex: 0 } }, + /// source: { tag: "ProductAccount", value: { derivationIndex: { tag: "Left", value: 0 } } }, /// }); /// assert(topUp.isOk(), "topUp failed:", topUp); /// @@ -106,7 +106,7 @@ pub trait Payment: Send + Sync { /// ```ts /// const result = await truapi.payment.topUp({ /// amount: 1000n, - /// source: { tag: "ProductAccount", value: { derivationIndex: 0 } }, + /// source: { tag: "ProductAccount", value: { derivationIndex: { tag: "Left", value: 0 } } }, /// }); /// assert(result.isOk(), "topUp failed:", result); /// console.log("balance topped up"); diff --git a/rust/crates/truapi/src/api/resource_allocation.rs b/rust/crates/truapi/src/api/resource_allocation.rs index 813ecef4..0950c82a 100644 --- a/rust/crates/truapi/src/api/resource_allocation.rs +++ b/rust/crates/truapi/src/api/resource_allocation.rs @@ -16,7 +16,7 @@ pub trait ResourceAllocation: Send + Sync { /// resources: [ /// { tag: "StatementStoreAllowance" }, /// { tag: "BulletinAllowance" }, - /// { tag: "SmartContractAllowance", value: 0 }, + /// { tag: "SmartContractAllowance", value: { tag: "Left", value: 0 } }, /// { tag: "AutoSigning" }, /// ], /// }); diff --git a/rust/crates/truapi/src/v01/payment.rs b/rust/crates/truapi/src/v01/payment.rs index 4c428d74..7b26901c 100644 --- a/rust/crates/truapi/src/v01/payment.rs +++ b/rust/crates/truapi/src/v01/payment.rs @@ -1,5 +1,6 @@ use parity_scale_codec::{Decode, Encode}; +use super::account::DerivationIndex; use super::coin_payment::CoinPaymentPurseId; /// Balance amount for payment operations. Interpreted according to the host's @@ -33,8 +34,11 @@ pub struct HostPaymentBalanceSubscribeItem { pub enum PaymentTopUpSource { /// Fund from one of the calling product's scoped accounts. ProductAccount { - /// Product account derivation index. - derivation_index: u32, + /// Account selector within the product subtree; the same selector as + /// [`ProductAccountId::derivation_index`]. + /// + /// [`ProductAccountId::derivation_index`]: super::account::ProductAccountId::derivation_index + derivation_index: DerivationIndex, }, /// Fund from a one-time account represented by its private key. This is a /// standard account holding public funds, not a coin key. diff --git a/rust/crates/truapi/src/v01/resource_allocation.rs b/rust/crates/truapi/src/v01/resource_allocation.rs index 66f3e21e..39d01b99 100644 --- a/rust/crates/truapi/src/v01/resource_allocation.rs +++ b/rust/crates/truapi/src/v01/resource_allocation.rs @@ -1,5 +1,7 @@ use parity_scale_codec::{Decode, Encode}; +use super::account::DerivationIndex; + /// A resource the host can pre-allocate on behalf of the product (RFC 0010). /// /// For the slot-table allowances (`StatementStoreAllowance`, @@ -13,9 +15,9 @@ pub enum AllocatableResource { StatementStoreAllowance, /// Bulletin chain slot allowance for the product's own allowance account. BulletinAllowance, - /// Pre-warmed PGAS balance for the smart-contract account at the given + /// Pre-warmed PGAS balance for the product account selected by this /// derivation index. - SmartContractAllowance(u32), + SmartContractAllowance(DerivationIndex), /// Permission to sign on the product's behalf without per-call user prompts. AutoSigning, }