Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions product-sdk/packages/terminal/src/entropy.ts
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).
Comment on lines +20 to +23

Copy link
Copy Markdown
Collaborator

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's RealDeriveEntropyUseCase.kt, iOS's ProductRootEntropyDeriver.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 — nothing terminal already depends on exports the derivation (host-papp carries rootEntropySource, host-api only the error type), so it trades the novasama import we're shedding for a new one.

keys/src/product-account.ts already 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):

  • blake2b256Keyedcrypto/src/hashing.ts: the missing sibling of the blake2b256 already there, and we're the only one of the five platforms without a keyed BLAKE2b in its crypto layer. (utils/src/hashing.ts has a byte-identical copy of those three, so worth settling which is canonical - I'd say crypto, where keys already imports blake2b256 from.)
  • the pure derivation → keys/src/product-entropy.ts, beside product-account.ts (same species: client-side mirror of a wallet-side derivation, header listing its mirrors, frozen-vector test). terminal already depends on keys, 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.

*
* @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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deriveEntropy reads nothing account-identifying, and terminal supports multiple paired sessions (waitForSessions). Pass the wrong one and you get valid entropy for a different wallet: the intended user can't decrypt, the other wallet's holder can. Explicit session is the right design - could you add a @remarks telling callers to pin identity via the exported sessionRootPublicKey(session) before deriving long-lived keys? Same block could note that the return value is raw key material - don't log it or persist it unwrapped.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#262 made productId an optional option defaulting to adapter.appId; here it's a required positional. Required is correct for entropy — a wrong default produces undecryptable data rather than a loud error, and #262 notes d3pot's appId differs. Could you add one JSDoc line saying it's deliberate, so nobody "fixes" it into a default?

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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", () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 >= 32 would pass the suite while rejecting the max-length key the host allows. Same for rootEntropySource.length !== 32 on line 60: only the missing-field case is covered. Note @noble accepts keys up to 64 bytes, so line 65 is the only thing enforcing the host's bound — a compatibility contract, not defensive boilerplate.

Worth knowing that the implementations already disagree at exactly this boundary: iOS and Android only check <= 32 and accept an empty key, where you, truapi's Rust and host-container all reject it. So these two bounds are the one place a cross-platform mismatch is already live, which is more reason to assert both ends rather than just outside them.

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/);
});
});
}
6 changes: 6 additions & 0 deletions product-sdk/packages/terminal/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ export type {
// Session helpers
export { waitForSessions } from "./sessions.js";

// Entropy derivation (RFC-0007). Client-side product-entropy derivation from a
// paired session, at parity with `@parity/product-sdk-host`'s `deriveEntropy`
// (same bytes for the same wallet + context) but computed locally — no host
// round-trip — since the session already carries `rootEntropySource`.
export { deriveEntropy } from "./entropy.js";

// QR Encoding
export { renderQrCode } from "./qr-encode.js";
export type { QrRenderOptions } from "./qr-encode.js";
Expand Down
14 changes: 14 additions & 0 deletions product-sdk/pending-changesets/terminal-derive-entropy.md
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)
Loading