diff --git a/README.md b/README.md index af0ad7ad..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", derivationIndex: 0 }, + 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 new file mode 100644 index 00000000..8f9b2db2 --- /dev/null +++ b/docs/rfcs/0022-account-derivations.md @@ -0,0 +1,427 @@ +--- +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 `//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}` 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 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 + +- **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 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. +- `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. + +## 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 + +``` +//product//{productId}/{index} +``` + +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`). +- `/{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. + +#### The 32-byte derivation index + +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 +Index32 = [u8; 32] + +INDEX_MAGIC: [u8; 28] = blake2b256("product-account-index")[..28] + +fn index_bytes(index: u32) -> Index32 { + u32_le_bytes(index) ++ INDEX_MAGIC +} +``` + +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. + +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 product's default account is index `0`, i.e. `index_bytes(0)`. + +#### 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: + +```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. + +##### `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`). + +##### `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 +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//{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 index junction. +- Without `AutoSigning`, signing round-trips to the Account Holder, which + derives `//product//{productId}/{index}` 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 `//product//{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) | `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 + +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. 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 path shape mirrors the product account paths, derived from +`root_ringvrf_entropy`: + +``` +//{DerivationDomain}//{DerivationIndex} +``` + +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 +// Full personhood ring-VRF key +full_personhood_key = //peopl.dot//index_bytes(0) + +// Light personhood ring-VRF key +light_personhood_key = //peopl.dot//index_bytes(1) +``` + +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 keyed-hash HDKD as ring-VRF keys — not from +schnorrkel derivations — in a tree rooted directly in the Account Entropy: + +``` +root_ecdh_entropy = hash(root_entropy, "ecdh") +``` + +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 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: + +```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 `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 +`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 + +- **One new Accounts Protocol message**, amortized to one round trip per + product per Host. +- **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}/{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`-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 + +- **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 + 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. 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 a2a500e2..8aeac81b 100644 --- a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -30,11 +30,11 @@ import { makeHostCallbacks, settle } from "./test-support.js"; const GENESIS = `0x${"11".repeat(32)}` as `0x${string}`; const PRODUCT_ACCOUNT = { dotNsIdentifier: "playground.dot", - derivationIndex: 0, + derivationIndex: { tag: "Left" as const, value: 0 }, }; const PROOF_CONTEXT = { productId: "playground.dot", - suffix: "0x00" as const, + suffix: { tag: "Left" as const, value: 0 }, }; const RING_LOCATION = { chainId: GENESIS, @@ -189,7 +189,7 @@ describe("createWasmRawCallbacks", () => { case "CreateTransaction": return ( review.value.tag === "Product" && - review.value.value.signer.derivationIndex === 0 && + review.value.value.signer.derivationIndex.tag === "Left" && review.value.value.callData === "0x0506" ); case "AccountAlias": @@ -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/js/packages/truapi/README.md b/js/packages/truapi/README.md index dae112d2..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", derivationIndex: 0 }, + 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 5d971886..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", derivationIndex: 0 }, + 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", derivationIndex: 0 }, + 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 3626b6f6..abda4f8f 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_index]`. +//! 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: //! @@ -48,6 +49,32 @@ 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, @@ -57,13 +84,11 @@ 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_index)? { + keypair = keypair.derived_key_simple(ChainCode(chain_code), []).0; } Ok(keypair) } @@ -72,21 +97,33 @@ pub fn derive_product_keypair( pub fn derive_product_public_key( root_public_key: [u8; 32], product_id: &str, - derivation_index: u32, + derivation_index: [u8; 32], ) -> 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_index)? { + 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_index]`. The 32-byte derivation index +/// is used directly as its junction's chain code. +fn product_chain_codes( + product_id: &str, + derivation_index: [u8; 32], +) -> Result<[[u8; 32]; 3], ProductAccountError> { + Ok([ + create_chain_code(PRODUCT_JUNCTION)?, + create_chain_code(product_id)?, + derivation_index, + ]) +} + /// Encode a product account public key as a generic Substrate SS58 address. /// /// Delegates to subxt's `AccountId32` Display, which is the generic-substrate @@ -110,14 +147,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)] @@ -131,20 +172,25 @@ mod tests { ]; #[test] - fn derives_dotli_product_account_vector() { - let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", 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", 1).unwrap(); + let derived = + derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", index_bytes(1)).unwrap(); assert_eq!( hex::encode(derived), - "ec8a80808b46e44c1351b68e295eb975c55bda4855e5ea9fc1325be7296a2a4e" + "20cce591a5e5306591de475e3c2efec3d94c6a00b8f52d3703a21f132555ee44" ); } @@ -153,27 +199,29 @@ mod tests { let derived = derive_product_public_key( ROOT_PUBLIC_KEY, "w-credentialless-staticblitz-com.local-credentialless.webcontainer-api.io", - 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", 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", 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)); @@ -187,17 +235,56 @@ mod tests { 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)] { + 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, index).unwrap(); let public = derive_product_public_key(root_public, product_id, index).unwrap(); assert_eq!( keypair.public.to_bytes(), public, - "{product_id}#{index} secret vs public derivation", + "{product_id}#{index:02x?} secret vs public derivation", ); } } + #[test] + 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 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] fn root_keypair_from_entropy_regression_pin() { // Regression pin for the entropy -> mini-secret -> sr25519 root path @@ -219,7 +306,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", 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 e1aa3938..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, } @@ -405,8 +406,6 @@ pub enum SsoAllocatedResource { SmartContractAllowance, /// Auto-signing material for the product subtree. AutoSigning { - /// Secret component of the per-product soft-derivation path. - product_derivation_secret: String, /// Private key of the product subtree root. product_root_private_key: Vec, }, @@ -795,7 +794,7 @@ mod tests { fn account() -> ProductAccountId { ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 7, + derivation_index: DerivationIndex::Left(7), } } @@ -893,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, @@ -908,18 +907,18 @@ mod tests { assert_host_papp_0_8_8_fixture( message, - "0x286d2d7265736f757263650005547472756170692d706c617967726f756e642e646f7410000102090000000301", + "0x286d2d7265736f757263650005547472756170692d706c617967726f756e642e646f741000010200090000000301", ); } #[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_index: DerivationIndex::Left(0), }, genesis_hash: sequential_bytes(32), call_data: vec![0, 0], @@ -934,18 +933,18 @@ mod tests { assert_host_papp_0_8_8_fixture( message, - "0x306d2d70726f647563742d7478000700547472756170692d706c617967726f756e642e646f7400000000202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f0800000428436865636b4e6f6e6365040108020300", + "0x306d2d70726f647563742d7478000700547472756170692d706c617967726f756e642e646f740000000000202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f0800000428436865636b4e6f6e6365040108020300", ); } #[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_index: DerivationIndex::Left(0), }, genesis_hash: [ 0xbf, 0x04, 0x88, 0xdb, 0xe9, 0xda, 0xa1, 0xde, 0x1c, 0x08, 0xc5, 0xf7, 0x43, @@ -960,7 +959,7 @@ mod tests { assert_host_papp_0_8_8_fixture( message, - "0x506372656174652d7472616e73616374696f6e2d31000700547472756170692d706c617967726f756e642e646f7400000000bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f0800000000", + "0x506372656174652d7472616e73616374696f6e2d31000700547472756170692d706c617967726f756e642e646f740000000000bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f0800000000", ); } @@ -1052,7 +1051,7 @@ mod tests { vec![ AllocatableResource::StatementStoreAllowance, AllocatableResource::BulletinAllowance, - AllocatableResource::SmartContractAllowance(9), + AllocatableResource::SmartContractAllowance(DerivationIndex::Left(9)), AllocatableResource::AutoSigning, ], OnExistingAllowancePolicy::Increase, @@ -1068,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/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 26dede40..08e44cde 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -39,7 +39,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; @@ -436,7 +438,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(), index_bytes(0)) .map_err(|err| err.to_string()) } @@ -847,7 +849,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, + derivation_index_bytes(&product_account_id.derivation_index), ) .map_err(|err| { CallError::Domain(HostAccountGetError::V1(v01::HostAccountGetError::Unknown { @@ -989,8 +991,8 @@ 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| { + 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(), @@ -1358,7 +1360,7 @@ impl Signing for ProductRuntimeHost { SignPayloadAuthorityRequest::LegacyAccount { product_account: v01::ProductAccountId { dot_ns_identifier: self.product_id(), - derivation_index: 0, + derivation_index: v01::DerivationIndex::Left(0), }, request: inner, }, @@ -1413,7 +1415,7 @@ impl Signing for ProductRuntimeHost { LegacySigner::Product => SignRawAuthorityRequest::Product(v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: self.product_id(), - derivation_index: 0, + derivation_index: v01::DerivationIndex::Left(0), }, payload: inner.payload, }), @@ -1496,7 +1498,7 @@ impl Signing for ProductRuntimeHost { CreateTransactionAuthorityRequest::LegacyAccount { product_account: v01::ProductAccountId { dot_ns_identifier: self.product_id(), - derivation_index: 0, + derivation_index: v01::DerivationIndex::Left(0), }, request: inner, }, @@ -2330,7 +2332,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_index: v01::DerivationIndex::Left(0), }, }); let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); @@ -2351,7 +2353,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_index: v01::DerivationIndex::Left(0), }, }); let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); @@ -2376,7 +2378,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_index: v01::DerivationIndex::Left(0), }, }); let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); @@ -2412,7 +2414,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_index: v01::DerivationIndex::Left(0), }, }); let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); @@ -2437,14 +2439,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_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", 0) + derive_product_public_key(session.public_key, "other.dot", index_bytes(0)) .unwrap() .to_vec() ); @@ -2459,14 +2461,14 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + 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" ); } @@ -2479,14 +2481,14 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "MyApp.DOT".to_string(), - derivation_index: 0, + 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" ); } @@ -2505,14 +2507,14 @@ mod tests { let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + 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" ); } @@ -2722,7 +2724,7 @@ mod tests { assert_eq!(inner.accounts[0].name.as_deref(), Some("alice")); assert_eq!( hex::encode(&inner.accounts[0].public_key), - "1c822b488297fde8c60d9cbc5585839f70a69fb2c5c69daa66b6043c75184467" + "b8de3e1888d4f0c37313a1fc8309b3c0fbaee7dd09346173ede41c5a507cc049" ); } @@ -3681,7 +3683,7 @@ mod tests { let cx = CallContext::default(); 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)) @@ -3714,7 +3716,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 = @@ -3736,7 +3738,7 @@ mod tests { request.product_account_id, v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_index: v01::DerivationIndex::Left(0), } ); assert!(matches!( @@ -3749,7 +3751,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", 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( @@ -3789,7 +3792,7 @@ mod tests { request.product_account_id, v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_index: v01::DerivationIndex::Left(0), } ); } @@ -3889,7 +3892,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", 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( @@ -3943,7 +3947,7 @@ mod tests { payload.signer, v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + 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 d81113f0..f4584729 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; @@ -87,8 +87,12 @@ impl SigningHost { reason: err.to_string(), } })?; - derive_product_keypair(&root, &product_id, account.derivation_index) - .map_err(product_authority_error) + derive_product_keypair( + &root, + &product_id, + derivation_index_bytes(&account.derivation_index), + ) + .map_err(product_authority_error) } } @@ -390,7 +394,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}; @@ -464,7 +468,7 @@ mod tests { let request = HostSignRawRequest::V1(v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_index: v01::DerivationIndex::Left(0), }, payload: v01::RawPayload::Bytes { bytes: b"hello world".to_vec(), @@ -475,7 +479,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", index_bytes(0)).unwrap(); let signature = schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); assert!( @@ -495,7 +499,7 @@ mod tests { let request = HostSignRawRequest::V1(v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_index: v01::DerivationIndex::Left(0), }, payload: v01::RawPayload::Bytes { bytes: vec![1, 2, 3], @@ -509,7 +513,7 @@ mod tests { fn product_account(index: u32) -> v01::ProductAccountId { v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: index, + derivation_index: v01::DerivationIndex::Left(index), } } @@ -546,7 +550,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", index_bytes(0)).unwrap(); assert_eq!(account, keypair.public.to_bytes()); // Payload = call_data ++ extra ++ additional_signed (call first). @@ -615,7 +619,7 @@ mod tests { let cx = CallContext::default(); 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", index_bytes(0)).unwrap(); let request = CreateTransactionAuthorityRequest::LegacyAccount { product_account: product_account(0), @@ -694,7 +698,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_index: v01::DerivationIndex::Left(0), }, }); let err = futures::executor::block_on(runtime.get_account(&cx, request)) @@ -770,7 +774,7 @@ mod tests { let request = HostSignRawRequest::V1(v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_index: v01::DerivationIndex::Left(0), }, payload: v01::RawPayload::Bytes { bytes: b"hi".to_vec(), @@ -779,7 +783,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", index_bytes(0)).unwrap(); let signature = schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); assert!( @@ -822,7 +826,7 @@ mod tests { let request = v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + derivation_index: v01::DerivationIndex::Left(0), }, payload: v01::RawPayload::Bytes { bytes: vec![1, 2, 3], @@ -851,7 +855,7 @@ mod tests { let request = v01::HostSignRawRequest { account: v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 0, + 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 3d485c05..67501edd 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_index, + 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", 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::default(); 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 812ab54c..ebca4b3a 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -613,11 +613,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_index: v01::DerivationIndex::Left(index), } } @@ -632,7 +633,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..4ce82193 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], @@ -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/account.rs b/rust/crates/truapi/src/api/account.rs index aa3f3799..41795115 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, + /// 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", - /// derivationIndex: 0, + /// derivationIndex: { tag: "Left", value: 0 }, /// }, /// }); /// assert(otherProduct.isOk(), "cross-product getAccount was denied or failed:", otherProduct); @@ -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/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/api/signing.rs b/rust/crates/truapi/src/api/signing.rs index f688f5ed..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", - /// derivationIndex: 0, + /// 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", - /// derivationIndex: 0, + /// 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", derivationIndex: 0 }, + /// 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", derivationIndex: 0 }, + /// 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 1110155c..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", - /// derivationIndex: 0, + /// derivationIndex: { tag: "Left", value: 0 }, /// }, /// statement, /// }); diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index f698474f..b9e74199 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -28,11 +28,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, }; /// Latest payload type of a versioned envelope. diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index 9ad83d05..e22d8735 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -1,14 +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 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, - /// Key derivation index for generating product-specific accounts. - pub derivation_index: u32, + /// Account selector within the product subtree. + pub derivation_index: DerivationIndex, } /// A user-imported (legacy) account: public key plus an optional user-chosen @@ -68,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. 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, }