-
Notifications
You must be signed in to change notification settings - Fork 3
feat(terminal): add deriveEntropy(session, productId, key) (RFC-0007) #260
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| // Copyright 2026 Parity Technologies (UK) Ltd. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
| /** | ||
| * Client-side RFC-0007 product-entropy derivation for terminal (QR/SSO) sessions. | ||
| * | ||
| * Byte-for-byte identical to the host's `host_derive_entropy` handler — i.e. | ||
| * `@novasamatech/host-container`'s `deriveProductEntropyFromSource` — so entropy | ||
| * derived here matches what an in-container app gets from | ||
| * `@parity/product-sdk-host`'s `deriveEntropy` for the same wallet + product + | ||
| * key. Derived keys therefore interoperate across web (in-container) and | ||
| * terminal (QR/SSO) clients. | ||
| * | ||
| * The paired {@link UserSession} carries `rootEntropySource` (RFC-0007 layer 1, | ||
| * `blake2b256_keyed(rootAccountSecret, "product-entropy-derivation")`), so | ||
| * layers 2 and 3 are computed locally with no host round-trip: | ||
| * | ||
| * perProduct = blake2b256(rootEntropySource, key = blake2b256(utf8(productId))) | ||
| * entropy = blake2b256(perProduct, key = key) | ||
| * | ||
| * NOTE: this re-derives the RFC-0007 scheme locally and MUST stay byte-identical | ||
| * to `host-container`'s `deriveProductEntropyFromSource`. The golden-vector test | ||
| * below guards against drift, but the derivation ideally belongs in a shared | ||
| * crypto package that both host-container and terminal import (see PR #260). | ||
| * | ||
| * @module | ||
| */ | ||
|
|
||
| import { blake2b } from "@noble/hashes/blake2.js"; | ||
| import type { UserSession } from "@novasamatech/host-papp"; | ||
|
|
||
| const ROOT_ENTROPY_LEN = 32; | ||
| const textEncoder = new TextEncoder(); | ||
|
|
||
| /** BLAKE2b-256, optionally keyed — the RFC-0007 primitive. */ | ||
| const b2 = (message: Uint8Array, key?: Uint8Array): Uint8Array => | ||
| key ? blake2b(message, { dkLen: 32, key }) : blake2b(message, { dkLen: 32 }); | ||
|
|
||
| /** | ||
| * Derive 32 bytes of deterministic entropy for a terminal session, scoped to the | ||
| * calling product and a caller key (RFC-0007 layers 2 + 3). | ||
| * | ||
| * Same wallet + product + key ⇒ same bytes; any difference ⇒ uncorrelated | ||
| * entropy. Because it derives from the wallet (not the device), the entropy is | ||
| * recreatable after device loss as long as the wallet is recoverable. | ||
| * | ||
| * @param session - A QR-paired {@link UserSession}. Must carry | ||
| * `rootEntropySource` (present since host-papp 0.8.6 / RFC-0007). | ||
| * @param productId - The calling product's dotNS identifier, e.g. `"my-app.dot"`. | ||
| * The host scopes entropy per product; pass the same identifier the host would. | ||
|
Comment on lines
+48
to
+49
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please document the productId contract - it's now the only thing between a caller and a silent mismatch. The host doesn't take it from the caller - it computes ${label}.dot from the iframe's deployment label (dotli's packages/ui/src/container.ts passes exactly that into deriveProductEntropyFromSource). So the correct value is that deployment's label, and it differs per deployment: production, each PR preview, and local dev (localhost:5173.dot) all yield a different id. A CLI hardcoding the production id derives valid-but-different bytes against a preview, with no error anywhere. Please spell out that per-deployment rule, add "must match the productId you pass to requestResourceAllocation (#262)", and say why it's required rather than defaulting to adapter.appId. |
||
| * @param key - Caller key, 1..32 bytes (the layer-3 BLAKE2b key). | ||
| * @returns 32 bytes of derived entropy. | ||
| * @throws if the session lacks `rootEntropySource`, or `key` is not 1..32 bytes. | ||
| */ | ||
| export function deriveEntropy( | ||
|
Comment on lines
+53
to
+54
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. #262 made |
||
| session: UserSession, | ||
| productId: string, | ||
| key: Uint8Array, | ||
| ): Uint8Array { | ||
| const rootEntropySource: Uint8Array | undefined = session.rootEntropySource; | ||
| if (!rootEntropySource || rootEntropySource.length !== ROOT_ENTROPY_LEN) { | ||
| throw new Error( | ||
| "deriveEntropy: session is missing rootEntropySource; re-pair with an RFC-0007 host", | ||
| ); | ||
| } | ||
| if (key.length === 0 || key.length > 32) { | ||
| throw new Error(`deriveEntropy: key must be 1..32 bytes, got ${key.length}`); | ||
| } | ||
| const perProduct = b2(rootEntropySource, b2(textEncoder.encode(productId))); | ||
| return b2(perProduct, key); | ||
| } | ||
|
|
||
| if (import.meta.vitest) { | ||
| const { test, expect, describe } = import.meta.vitest; | ||
|
|
||
| describe("deriveEntropy", () => { | ||
| const rootEntropySource = new Uint8Array(32).fill(1); | ||
| const session = { rootEntropySource } as unknown as UserSession; | ||
| const toHex = (b: Uint8Array) => | ||
| Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); | ||
|
|
||
| test("matches host-container deriveProductEntropyFromSource (golden vector)", () => { | ||
| // Computed with @novasamatech/host-container@0.8.9's | ||
| // deriveProductEntropyFromSource(fill(1), "my-app.dot", [1,2,3,4]). | ||
| expect(toHex(deriveEntropy(session, "my-app.dot", new Uint8Array([1, 2, 3, 4])))).toBe( | ||
| "993750d5f3f4b941cef5a8084fdd0bcd6a6946fdc0e1fe87c0c575fe65e7dc03", | ||
| ); | ||
| }); | ||
|
Comment on lines
+81
to
+87
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The golden vector pins this file against itself. Nothing here can reproduce the hex, and it can't catch host-container drift - which is what the NOTE above says we're guarding against. Cheapest fix, and stronger than minting a new hex: reuse the vectors the other implementations already share with each other. truapi's Rust tests carry vectors copied byte-for-byte from polkadot-app-ios-v2's ProductRootEntropyDeriverTests. I ran your math against both sets and got 6/6 matches - e.g. secret = bytes 0..31, productId "myapp.dot", key [1] gives 4bafd6a34182959bad8914dcff88c6b6842d551d6f0067afbd407e9584223404. They start from the raw secret rather than rootEntropySource, so the test needs the layer-1 step first (blake2b256 keyed with "product-entropy-derivation") - one line. No new dependency, and it pins us against Rust and Swift rather than against a single TS package. @novasamatech/host-container is also public on npm, so the devDependency plus an entropy.interop.test.ts asserting equality against the real deriveProductEntropyFromSource is worth having too - same pattern as testing.interop.test.ts. The shared vectors prove we match today; the interop test catches future drift on its own. Keep the golden vector as well; it documents the expected bytes for a human reader. |
||
|
|
||
| test("is 32 bytes, deterministic, and product- and key-scoped", () => { | ||
| const key = new Uint8Array([1, 2, 3, 4]); | ||
| const a = deriveEntropy(session, "my-app.dot", key); | ||
| expect(a).toHaveLength(32); | ||
| expect(deriveEntropy(session, "my-app.dot", key)).toStrictEqual(a); | ||
| expect(deriveEntropy(session, "other.dot", key)).not.toStrictEqual(a); | ||
| expect(deriveEntropy(session, "my-app.dot", new Uint8Array([9]))).not.toStrictEqual(a); | ||
| }); | ||
|
|
||
| test("different root entropy yields uncorrelated entropy", () => { | ||
| const other = { | ||
| rootEntropySource: new Uint8Array(32).fill(2), | ||
| } as unknown as UserSession; | ||
| const key = new Uint8Array([1, 2, 3, 4]); | ||
| expect(deriveEntropy(other, "my-app.dot", key)).not.toStrictEqual( | ||
| deriveEntropy(session, "my-app.dot", key), | ||
| ); | ||
| }); | ||
|
|
||
| test("rejects a key outside 1..32 bytes", () => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nice-to-have]: The 1..32 tests only probe outside the boundary (0 and 33). Could you also assert that 1 and 32 are accepted? An off-by-one to Worth knowing that the implementations already disagree at exactly this boundary: iOS and Android only check |
||
| expect(() => deriveEntropy(session, "my-app.dot", new Uint8Array(0))).toThrow( | ||
| /1\.\.32/, | ||
| ); | ||
| expect(() => deriveEntropy(session, "my-app.dot", new Uint8Array(33))).toThrow( | ||
| /1\.\.32/, | ||
| ); | ||
| }); | ||
|
|
||
| test("throws when the session has no rootEntropySource", () => { | ||
| expect(() => | ||
| deriveEntropy({} as UserSession, "my-app.dot", new Uint8Array([1])), | ||
| ).toThrow(/rootEntropySource/); | ||
| }); | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| --- | ||
| "@parity/product-sdk-terminal": minor | ||
| --- | ||
|
|
||
| Add `deriveEntropy(session, productId, key)` — client-side RFC-0007 product-entropy derivation for terminal (QR/SSO) sessions, byte-for-byte identical to the host's `host_derive_entropy` (i.e. `@novasamatech/host-container`'s `deriveProductEntropyFromSource`). | ||
|
|
||
| The paired `UserSession` carries `rootEntropySource` (RFC-0007 layer 1), so layers 2 and 3 are computed locally with no host round-trip: | ||
|
|
||
| ``` | ||
| perProduct = blake2b256(rootEntropySource, key = blake2b256(utf8(productId))) | ||
| entropy = blake2b256(perProduct, key = key) | ||
| ``` | ||
|
|
||
| Entropy derived here matches what an in-container app gets from `@parity/product-sdk-host`'s `deriveEntropy` for the same wallet + product + key, so entropy-derived keys interoperate across web and terminal clients. A golden-vector test pins the construction against `host-container`. (issue #254) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On your open question: keep the derivation here, but split it in three. Doesn't block the PR.
The shared package the NOTE wants can't exist — this algorithm has five implementations in three languages (host-container, truapi's Rust
host_logic/entropy.rs, Android'sRealDeriveEntropyUseCase.kt, iOS'sProductRootEntropyDeriver.swift, and this one). Swift and Kotlin can't import a TS package, so the shared artifact is the spec (host-spec §C.8) and the safety net is shared test vectors — which Rust and iOS already swap byte-for-byte. Importing host-container here isn't a shortcut either — nothingterminalalready depends on exports the derivation (host-papp carriesrootEntropySource, host-api only the error type), so it trades the novasama import we're shedding for a new one.keys/src/product-account.tsalready answers the in-repo version of the question. Nothing here needs doing in this PR — an issue is enough for now, so the NOTE has something to link to, and the move lands as its own PR later (~40 lines, no behaviour change):blake2b256Keyed→crypto/src/hashing.ts: the missing sibling of theblake2b256already there, and we're the only one of the five platforms without a keyed BLAKE2b in its crypto layer. (utils/src/hashing.tshas a byte-identical copy of those three, so worth settling which is canonical - I'd saycrypto, wherekeysalready importsblake2b256from.)keys/src/product-entropy.ts, besideproduct-account.ts(same species: client-side mirror of a wallet-side derivation, header listing its mirrors, frozen-vector test).terminalalready depends onkeys, so this adds no dependency.deriveEntropy(session, productId, key)stays exactly as you wrote it — the session wrapper. Public API and changeset unchanged.And please turn the NOTE into the decision plus an issue link - an open question in merged code stays open. While rewriting it, cite host-spec C.8 rather than host-container's implementation (mirrors drift; these five already disagree on whether an empty key is legal), and steal iOS's clarification that layer 1's input is raw BIP-39 entropy, not the 64-byte PBKDF2 seed. Happy to open the issue and write up the full reasoning there.