diff --git a/README.md b/README.md index 4f6a928c..e4e8e9d1 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/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 2cea5a29..d9a5c1e6 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 @@ -127,14 +164,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)] @@ -148,20 +189,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" ); } @@ -170,27 +216,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)); @@ -204,17 +252,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 @@ -236,7 +323,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 d4473131..05945ab6 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -23,9 +23,9 @@ use parity_scale_codec::{Decode, Encode, OptionBool}; use truapi::latest::{ - AccountId, AllocatableResource, HostAccountCreateProofResponse, HostAccountGetAliasResponse, - LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, ProductProofContext, - RawPayload, RingLocation, + AccountId, AllocatableResource, DerivationIndex, HostAccountCreateProofResponse, + HostAccountGetAliasResponse, LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, + ProductProofContext, RawPayload, RingLocation, }; use crate::host_logic::session::SsoSessionInfo; @@ -413,9 +413,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, } @@ -479,8 +479,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, }, @@ -1002,7 +1000,7 @@ mod tests { fn account() -> ProductAccountId { ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: 7, + derivation_index: DerivationIndex::Left(7), } } @@ -1090,10 +1088,10 @@ mod tests { } #[test] - fn ring_vrf_messages_match_host_papp_0_8_11_fixtures() { + fn ring_vrf_messages_wire_shape_pin() { let context = ProductProofContext { product_id: "voting.dot".to_string(), - suffix: vec![0, 1, 2, 3], + suffix: DerivationIndex::Left(0), }; let ring_location = RingLocation { chain_id: [0x11; 32], @@ -1116,6 +1114,19 @@ mod tests { ring_location, b"vote".to_vec(), ); + + assert_host_papp_0_8_11_fixture( + alias, + "0x1c6d2d616c69617300032863616c6c65722e646f7428766f74696e672e646f7400000000001111111111111111111111111111111111111111111111111111111111111111080043010c706f70", + ); + assert_host_papp_0_8_11_fixture( + proof, + "0x1c6d2d70726f6f66000c2863616c6c65722e646f7428766f74696e672e646f7400000000001111111111111111111111111111111111111111111111111111111111111111080043010c706f7010766f7465", + ); + } + + #[test] + fn ring_vrf_response_messages_match_host_papp_0_8_11_fixtures() { let contextual_alias = HostAccountGetAliasResponse { context: [0x22; 32], alias: vec![0x33, 0x44], @@ -1144,14 +1155,6 @@ mod tests { )), }; - assert_host_papp_0_8_11_fixture( - alias, - "0x1c6d2d616c69617300032863616c6c65722e646f7428766f74696e672e646f7410000102031111111111111111111111111111111111111111111111111111111111111111080043010c706f70", - ); - assert_host_papp_0_8_11_fixture( - proof, - "0x1c6d2d70726f6f66000c2863616c6c65722e646f7428766f74696e672e646f7410000102031111111111111111111111111111111111111111111111111111111111111111080043010c706f7010766f7465", - ); assert_host_papp_0_8_11_fixture( alias_response, "0x1c722d616c69617300041c6d2d616c696173002222222222222222222222222222222222222222222222222222222222222222083344", @@ -1174,14 +1177,14 @@ mod tests { } #[test] - fn resource_allocation_message_matches_host_papp_0_8_11_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, @@ -1189,18 +1192,18 @@ mod tests { assert_host_papp_0_8_11_fixture( message, - "0x286d2d7265736f757263650005547472756170692d706c617967726f756e642e646f7410000102090000000301", + "0x286d2d7265736f757263650005547472756170692d706c617967726f756e642e646f741000010200090000000301", ); } #[test] - fn create_transaction_message_matches_host_papp_0_8_11_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], @@ -1215,18 +1218,18 @@ mod tests { assert_host_papp_0_8_11_fixture( message, - "0x306d2d70726f647563742d7478000700547472756170692d706c617967726f756e642e646f7400000000202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f0800000428436865636b4e6f6e6365040108020300", + "0x306d2d70726f647563742d7478000700547472756170692d706c617967726f756e642e646f740000000000202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f0800000428436865636b4e6f6e6365040108020300", ); } #[test] - fn playground_create_transaction_message_matches_host_papp_0_8_11_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, @@ -1241,7 +1244,7 @@ mod tests { assert_host_papp_0_8_11_fixture( message, - "0x506372656174652d7472616e73616374696f6e2d31000700547472756170692d706c617967726f756e642e646f7400000000bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f0800000000", + "0x506372656174652d7472616e73616374696f6e2d31000700547472756170692d706c617967726f756e642e646f740000000000bf0488dbe9daa1de1c08c5f743e26fdc2a4ecd74cf87dd1b4b1eeb99ae4ef19f0800000000", ); } @@ -1333,7 +1336,7 @@ mod tests { vec![ AllocatableResource::StatementStoreAllowance, AllocatableResource::BulletinAllowance, - AllocatableResource::SmartContractAllowance(9), + AllocatableResource::SmartContractAllowance(DerivationIndex::Left(9)), AllocatableResource::AutoSigning, ], OnExistingAllowancePolicy::Increase, @@ -1349,7 +1352,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 90601b3a..095b4c9c 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -42,7 +42,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; @@ -442,7 +444,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()) } @@ -916,7 +918,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 { @@ -1368,7 +1370,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, }, @@ -1423,7 +1425,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, }), @@ -1493,7 +1495,7 @@ impl Signing for ProductRuntimeHost { LegacySigner::Product => CreateTransactionAuthorityRequest::LegacyAccount { product_account: v01::ProductAccountId { dot_ns_identifier: self.product_id(), - derivation_index: 0, + derivation_index: v01::DerivationIndex::Left(0), }, request: inner, }, @@ -2366,7 +2368,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(); @@ -2387,7 +2389,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(); @@ -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(); @@ -2448,7 +2450,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(); @@ -2473,14 +2475,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() ); @@ -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_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" ); } @@ -2515,14 +2517,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" ); } @@ -2541,14 +2543,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" ); } @@ -3691,7 +3693,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)) @@ -3724,7 +3726,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 = @@ -3746,7 +3748,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!( @@ -3759,7 +3761,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( @@ -3799,7 +3802,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), } ); } @@ -3928,7 +3931,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( @@ -3982,7 +3986,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 74969145..42d06090 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -37,7 +37,7 @@ use crate::host_logic::extrinsic::{ Sr25519Signer, build_signed_extrinsic_v4, build_signed_extrinsic_v4_with_signature, }; 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, derive_sr25519_hard_path, }; use crate::host_logic::session::SessionState; @@ -139,8 +139,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) } fn identity_keypair(&self) -> Result { @@ -670,6 +674,7 @@ mod tests { use crate::host_logic::extrinsic::tests::split_v4; use crate::host_logic::product_account::{ derive_product_keypair, derive_root_keypair_from_entropy, derive_sr25519_hard_path, + index_bytes, }; use crate::host_logic::transaction::{ extrinsic_payload_extensions, extrinsic_payload_preimage, @@ -790,7 +795,7 @@ mod tests { let cx = CallContext::default(); let context = v01::ProductProofContext { product_id: "myapp.dot".to_string(), - suffix: b"account".to_vec(), + suffix: v01::DerivationIndex::Left(0), }; let ring_location = v01::RingLocation { chain_id: [0x22; 32], @@ -841,7 +846,7 @@ mod tests { let cx = CallContext::default(); let context = v01::ProductProofContext { product_id: "other.dot".to_string(), - suffix: b"account".to_vec(), + suffix: v01::DerivationIndex::Left(0), }; let ring_location = v01::RingLocation { chain_id: [0x22; 32], @@ -896,7 +901,7 @@ mod tests { calling_product_id: "myapp.dot".to_string(), context: v01::ProductProofContext { product_id: "other.dot".to_string(), - suffix: b"account".to_vec(), + suffix: v01::DerivationIndex::Left(0), }, ring_location: v01::RingLocation { chain_id: [0x22; 32], @@ -946,7 +951,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(), @@ -957,7 +962,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!( @@ -999,7 +1004,7 @@ mod tests { .expect("product payload signing succeeds"); 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!(product_response.signature.len(), 65); assert_eq!(product_response.signature[0], 1); let signature = @@ -1097,7 +1102,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], @@ -1111,7 +1116,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), } } @@ -1148,7 +1153,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). @@ -1217,7 +1222,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), @@ -1296,7 +1301,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)) @@ -1372,7 +1377,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(), @@ -1381,7 +1386,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!( @@ -1424,7 +1429,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], @@ -1453,7 +1458,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] }, }; @@ -1489,7 +1494,7 @@ mod tests { "myapp.dot".to_string(), v01::HostRequestResourceAllocationRequest { resources: vec![ - v01::AllocatableResource::SmartContractAllowance(0), + v01::AllocatableResource::SmartContractAllowance(v01::DerivationIndex::Left(0)), v01::AllocatableResource::AutoSigning, ], }, diff --git a/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs b/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs index a5976955..c14c477a 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs @@ -17,6 +17,7 @@ use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; use zeroize::Zeroizing; use crate::chain_runtime::ChainRuntime; +use crate::host_logic::product_account::derivation_index_bytes; use crate::host_logic::sso::messages::RingVrfError; const MEMBERS_PALLET: &str = "Members"; @@ -225,11 +226,12 @@ impl RingResolver for ChainRingResolver { } pub(super) fn context_bytes(context: &ProductProofContext) -> [u8; 32] { - let mut input = Vec::with_capacity(9 + context.product_id.len() + context.suffix.len()); + let suffix = derivation_index_bytes(&context.suffix); + let mut input = Vec::with_capacity(9 + context.product_id.len() + suffix.len()); input.extend_from_slice(b"product/"); input.extend_from_slice(context.product_id.as_bytes()); input.push(b'/'); - input.extend_from_slice(&context.suffix); + input.extend_from_slice(&suffix); blake2b_256(&input, None) } @@ -390,8 +392,10 @@ struct RingRoot { mod tests { use parity_scale_codec::Encode; use scale_info::TypeInfo; + use truapi::v01::DerivationIndex; use super::*; + use crate::host_logic::product_account::index_bytes; fn decode_as(source: A) -> B where @@ -404,28 +408,36 @@ mod tests { B::decode_as_type(&mut &*source.encode(), type_id, &types).expect("dynamic decode succeeds") } + /// The alias context hashes `product/{product_id}/` followed by the + /// suffix's 32-byte derivation index, so the alias and the product account + /// it belongs to are selected by the same value. #[test] - fn context_matches_rfc_0004_vector() { + fn context_bytes_pins_derivation_index_preimage() { let context = ProductProofContext { - product_id: "example.dot".to_string(), - suffix: b"login".to_vec(), + product_id: "voting.dot".to_string(), + suffix: DerivationIndex::Left(0), }; - assert_eq!( - hex::encode(context_bytes(&context)), - "be397823154bdcc0f4d86938af932cd4d5c49d0793e0138663ccdb3d8e0062eb" - ); + + let mut expected = b"product/voting.dot/".to_vec(); + expected.extend_from_slice(&index_bytes(0)); + + assert_eq!(context_bytes(&context), blake2b_256(&expected, None)); } + /// A raw 32-byte selector is used verbatim, and never collides with a + /// plain index. #[test] - fn context_matches_ios_host_vector() { - let context = ProductProofContext { + fn context_bytes_distinguishes_selector_forms() { + let indexed = ProductProofContext { product_id: "voting.dot".to_string(), - suffix: vec![0, 1, 2, 3], + suffix: DerivationIndex::Left(0), }; - assert_eq!( - hex::encode(context_bytes(&context)), - "03fba4e4f9ce1b2eb228e79b8aabef71213cfc53bec6dcae9d24a075a2d5a89e" - ); + let raw = ProductProofContext { + product_id: "voting.dot".to_string(), + suffix: DerivationIndex::Right([0; 32]), + }; + + assert_ne!(context_bytes(&indexed), context_bytes(&raw)); } #[test] diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index 46c2131f..ca4e99fe 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -810,7 +810,7 @@ fn public_allocatable_resource(resource: &SsoAllocatableResource) -> api::Alloca } SsoAllocatableResource::BulletinAllowance => api::AllocatableResource::BulletinAllowance, SsoAllocatableResource::SmartContractAllowance(index) => { - api::AllocatableResource::SmartContractAllowance(*index) + api::AllocatableResource::SmartContractAllowance(index.clone()) } SsoAllocatableResource::AutoSigning => api::AllocatableResource::AutoSigning, } @@ -1225,7 +1225,7 @@ mod tests { fn product_account(product_id: &str) -> api::ProductAccountId { api::ProductAccountId { dot_ns_identifier: product_id.to_string(), - derivation_index: 0, + derivation_index: api::DerivationIndex::Left(0), } } @@ -1350,7 +1350,7 @@ mod tests { calling_product_id: "myapp.dot".to_string(), context: api::ProductProofContext { product_id: "other.dot".to_string(), - suffix: vec![], + suffix: api::DerivationIndex::Left(0), }, ring_location: api::RingLocation { chain_id: [0; 32], diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index 4f01189d..85e5f96b 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -12,7 +12,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, @@ -312,7 +312,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) @@ -425,6 +425,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, @@ -501,7 +502,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 dc2291b0..787c61d1 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -620,11 +620,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), } } @@ -639,7 +640,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 f839ab1a..dcb28c78 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -39,7 +39,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); @@ -48,7 +48,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); @@ -72,7 +72,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: [ @@ -102,7 +102,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 55f53b19..107825f7 100644 --- a/rust/crates/truapi/src/api/payment.rs +++ b/rust/crates/truapi/src/api/payment.rs @@ -41,7 +41,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); /// @@ -70,7 +70,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); /// @@ -107,7 +107,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 3de1003b..02c72d0c 100644 --- a/rust/crates/truapi/src/api/resource_allocation.rs +++ b/rust/crates/truapi/src/api/resource_allocation.rs @@ -17,7 +17,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 234d9a51..273e848d 100644 --- a/rust/crates/truapi/src/api/signing.rs +++ b/rust/crates/truapi/src/api/signing.rs @@ -26,7 +26,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", @@ -54,7 +54,7 @@ pub trait Signing: Send + Sync { /// const accountResult = await truapi.account.getAccount({ /// productAccountId: { /// dotNsIdentifier: "truapi-playground.dot", - /// derivationIndex: 0, + /// derivationIndex: { tag: "Left", value: 0 }, /// }, /// }); /// assert(accountResult.isOk(), "getAccount failed:", accountResult); @@ -62,7 +62,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", @@ -122,7 +122,7 @@ pub trait Signing: Send + Sync { /// const accountResult = await truapi.account.getAccount({ /// productAccountId: { /// dotNsIdentifier: "truapi-playground.dot", - /// derivationIndex: 0, + /// derivationIndex: { tag: "Left", value: 0 }, /// }, /// }); /// assert(accountResult.isOk(), "getAccount failed:", accountResult); @@ -162,7 +162,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: { @@ -188,7 +188,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 38b16fb7..aa241588 100644 --- a/rust/crates/truapi/src/api/statement_store.rs +++ b/rust/crates/truapi/src/api/statement_store.rs @@ -81,7 +81,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 5165ea55..a138df03 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -31,13 +31,14 @@ pub mod latest { use crate::versioned::{self, Versioned}; pub use crate::v01::{ - AccountId, AllocatableResource, AllocationOutcome, ContextualAlias, GenericError, - HostSignPayloadData, NotificationId, OperationStartedResult, ProductAccountId, - ProductProofContext, RawPayload, RemotePermission, RemoteStatementStoreCreateProofError, - RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, - RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, RingLocation, - RuntimeApi, RuntimeSpec, RuntimeType, SignedStatement, Statement, StatementProof, - StorageQueryItem, StorageQueryType, StorageResultItem, ThemeVariant, TxPayloadExtension, + AccountId, AllocatableResource, AllocationOutcome, ContextualAlias, DerivationIndex, + GenericError, HostSignPayloadData, NotificationId, OperationStartedResult, + ProductAccountId, ProductProofContext, RawPayload, RemotePermission, + RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, + RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, + RemoteStatementStoreSubscribeRequest, RingLocation, RuntimeApi, RuntimeSpec, RuntimeType, + SignedStatement, Statement, StatementProof, 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, }