Skip to content

feat(terminal): add deriveEntropy(session, productId, key) (RFC-0007) - #260

Open
ReinhardHatko wants to merge 1 commit into
mainfrom
reinhard/terminal-derive-entropy
Open

feat(terminal): add deriveEntropy(session, productId, key) (RFC-0007)#260
ReinhardHatko wants to merge 1 commit into
mainfrom
reinhard/terminal-derive-entropy

Conversation

@ReinhardHatko

@ReinhardHatko ReinhardHatko commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Closes #254.

What

Adds deriveEntropy(session, productId, key): Uint8Array to @parity/product-sdk-terminal — client-side RFC-0007 product-entropy derivation for QR/SSO (out-of-container) sessions, at parity with @parity/product-sdk-host's deriveEntropy.

Why

The host package derives entropy in-container via truApi.entropy.derive. Terminal apps run out-of-container and had no equivalent, even though the paired UserSession already carries rootEntropySource (RFC-0007 layer 1). Consumers were forced to import @novasamatech/host-papp internals and re-implement the derivation.

How

The session's rootEntropySource (layer 1) lets layers 2 and 3 be computed locally, with no host round-trip:

perProduct = blake2b256(rootEntropySource, key = blake2b256(utf8(productId)))
entropy    = blake2b256(perProduct,        key = key)

This is 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. Keys therefore interoperate across web (in-container) and terminal (QR/SSO) clients. Deterministic from the wallet (not the device), so derived keys survive device loss as long as the wallet is recoverable.

  • productId is the calling product's dotNS identifier (e.g. my-app.dot) — the host scopes entropy per product.
  • key is the caller key, 1..32 bytes (the layer-3 BLAKE2b key).

Notes

  • rootEntropySource is read as the real typed UserSession field (an upstream rename is a compile error here), with a runtime guard for older V1 persisted sessions that predate RFC-0007.
  • In-source tests (import.meta.vitest) cover determinism, 32-byte length, product/key scoping, the missing-field guard, key-length bounds, and a golden-vector assertion pinning the output to @novasamatech/host-container@0.8.9's deriveProductEntropyFromSource.
  • Motivating consumer: d3pot (decentralized git host) needs per-repo, wallet-recoverable encryption identities under host-only auth.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown

📦 Bundle size impact

Comparing 2026-07-22T07:45:00.778Z2026-07-22T07:44:46.937Z

Package Entry Bundled before Bundled after Δ Ship gzip Δ Shake ratio
🟢 @parity/product-sdk-terminal . 719.8 KB 720.2 KB +418 B (+0.1%) +679 B 30% (was 30%)

Thresholds — warn: ≥10% or ≥5.0 KB · fail: ≥20% or ≥15.0 KB (bundled). Percentage only applies once the baseline is ≥ 10 KB.

@ReinhardHatko

Copy link
Copy Markdown
Contributor Author

Closing — the implementation here is incorrect and should not merge.

It derives entropy as khash(rootEntropySource, context) (blake2b keyed by rootEntropySource), on the assumption that this reproduces the host's host_derive_entropy. Empirical testing (d3pot wallet-derived repo encryption, CLI ↔ web) shows it does not: for the same wallet + context, the terminal-derived x25519 pubkey differs from the one derived in-container via @parity/product-sdk-host's deriveEntropy.

Constructions ruled out (both mismatch the host):

  • blake2b(context, key=rootEntropySource) (this PR)
  • blake2b(rootEntropySource, key=context) (the swap — the host validates context as a ≤32-byte key, which suggested this)

Root problem: host_derive_entropy is a host-side RPC (@novasamatech/host-apitransport.request('host_derive_entropy', …)); its algorithm isn't in any published SDK package or the specs, so a client-side reimplementation can't be verified to match and is a silent-mismatch footgun.

The underlying need still stands — see #254, which I'm reframing accordingly.

@ReinhardHatko ReinhardHatko reopened this Jul 21, 2026
@ReinhardHatko
ReinhardHatko marked this pull request as draft July 21, 2026 13:25
@ReinhardHatko

Copy link
Copy Markdown
Contributor Author

Reopened as a draft — parking, not abandoning.

The public surface (deriveEntropy(session, context) on product-sdk-terminal) is still what we want; only the derivation internals are wrong (the local khash doesn't match the host — details above). Once we have the exact host_derive_entropy construction, or decide to invoke it as a session RPC (see #254 option a), this branch is where the fix lands. Keeping it in draft so the branch and discussion stay put.

@ReinhardHatko

Copy link
Copy Markdown
Contributor Author

Updated with the correct derivation (still draft).

The original khash(rootEntropySource, context) was a single keyed-BLAKE2b layer and does not match the host — verified empirically in d3pot (CLI-derived vs web/host-derived x25519 pubkeys differed). The real host_derive_entropy (per @novasamatech/host-container's deriveProductEntropyFromSource, and the host handler in dotli-community packages/ui/src/container.ts) is two layers, scoped by the calling product:

perProduct = blake2b256(rootEntropySource, key = blake2b256(utf8(productId)))
entropy    = blake2b256(perProduct,        key = key)

Changes:

  • New signature deriveEntropy(session, productId, key) (the out-of-container analogue of deriveProductEntropyFromSource; key must be 1..32 bytes — it's the layer-3 key).
  • Verified byte-identical to host-container and end-to-end against a real host (web now decodes a CLI-created private repo with no pasted key).
  • Golden-vector test pins the construction so it can't silently drift.

Open question for reviewers: this re-derives the RFC-0007 scheme client-side. To avoid two implementations drifting, deriveProductEntropyFromSource ideally moves to a shared crypto package that both host-container and terminal import — happy to go that route instead. Leaving as draft pending that call.

@ReinhardHatko ReinhardHatko changed the title terminal: add deriveEntropy(session, context) (RFC-0007) terminal: add deriveEntropy(session, productId, key) (RFC-0007) Jul 22, 2026
@ReinhardHatko
ReinhardHatko force-pushed the reinhard/terminal-derive-entropy branch from 1ed6ade to 3cc06b8 Compare July 22, 2026 07:25
@ReinhardHatko ReinhardHatko changed the title terminal: add deriveEntropy(session, productId, key) (RFC-0007) feat(terminal): add deriveEntropy(session, productId, key) (RFC-0007) Jul 22, 2026
Client-side RFC-0007 product-entropy derivation for terminal (QR/SSO,
out-of-container) sessions, at parity with @parity/product-sdk-host's
deriveEntropy. The paired UserSession carries rootEntropySource (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)

Byte-for-byte identical to the host's host_derive_entropy handler
(@novasamatech/host-container's deriveProductEntropyFromSource), so keys
derived here interoperate with in-container apps for the same wallet +
product + key. Deterministic from the wallet, so derived keys survive
device loss. In-source golden-vector test pins the construction to
host-container@0.8.9.

Closes #254.
@ReinhardHatko
ReinhardHatko force-pushed the reinhard/terminal-derive-entropy branch from 3cc06b8 to 334b44b Compare July 22, 2026 07:41
@ReinhardHatko
ReinhardHatko marked this pull request as ready for review July 23, 2026 12:28

@Imod7 Imod7 left a comment

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.

Three more notes on top of the inline comments — none of them blocking.

1. dist ships the test suites. Pre-existing, but this PR adds to it: terminal/dist/index.js ships all 14 in-source describe blocks, including entropy.ts's golden-vector hex. The define: {"import.meta.vitest": "undefined"} rewrites the condition, but esbuild only drops the dead block with treeshake: true - packages/signer has the flag and ships 0 of them. fix/tsup-treeshake-toplevelawait adds the flag to 14 packages but not terminal.

Action: add treeshake: true to packages/terminal/tsup.config.ts, or fold terminal into that branch.

2. "Closes #254" overstates what ships. This adds deriveEntropy but not #254's getEncrSecret / getEncrPublicKey / createSharedSecret. Fine as a cut - entropy is the drift-prone part, and @parity/product-sdk-crypto already has boxEncrypt/sealedBoxEncrypt for the rest. But as written, the issue closes with its encryption helpers undelivered.

Action: either narrow the description to "Refs #254" and open a follow-up for the three helpers, or add them here.

3. The RFC-0007 product-entropy derivation is specified, not just published. host-spec §C.8 defines it - the three keyed BLAKE2b-256 layers over session secret, productId and caller key - at spec/C-account-derivation.md, which truapi's Rust implementation cites at a pinned commit. Worth pointing at the spec rather than at host-container: mirrors drift, and these five already disagree on whether an empty caller key is legal.

Recommendation: cite host-spec §C.8 instead of the implementation in the two places that name it - the changeset (pending-changesets/terminal-derive-entropy.md: "byte-for-byte identical to the host's host_derive_entropy", "pins the construction against host-container"), which ships to the published changelog, and the module header comment.

Comment on lines +48 to +49
* @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.

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.

Comment on lines +81 to +87
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",
);
});

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.

Comment on lines +53 to +54
*/
export function deriveEntropy(

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.

);
});

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.

* @returns 32 bytes of derived entropy.
* @throws if the session lacks `rootEntropySource`, or `key` is not 1..32 bytes.
*/
export function deriveEntropy(

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?

Comment on lines +20 to +23
* 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).

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

product-sdk-terminal: expose entropy / encryption-secret derivation (RFC-0007) from a UserSession

2 participants