Skip to content

Wire Product frontend key signatures - #112

Merged
knzeng-e merged 9 commits into
devfrom
feat/product-key-signature-client
Aug 3, 2026
Merged

Wire Product frontend key signatures#112
knzeng-e merged 9 commits into
devfrom
feat/product-key-signature-client

Conversation

@knzeng-e

@knzeng-e knzeng-e commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Outcome

Product-host users can now use the app-scoped Product account for Dotify protected playback identity. The frontend signs Dotify session and key-request messages with the Product host signer, sends the existing product-sr25519-v1 proof shape, and still keeps contract writes on the passkey/EVM path.

Issue and context

Refs #85. Local scope: docs/backlog/polkadot-product-readiness-and-killer-dapp-roadmap.md.

The previous PR added API verification for Product sr25519 session/key requests, but the shipped Product frontend could not submit that proof. A Product-connected listener still failed the protected playback path because useCatalog required a viem WalletClient. That made Product identity useful for presence, but not for backend-held content-key delivery.

Dotify's constraint is narrow: Product identity may prove the requester for backend access checks, but it must not become a hidden EVM transaction signer. Artist publishing, Classic unlock payment, and runtime writes remain explicitly gated on createEvmClient until the Product CDM transaction path has real host-signed evidence.

Architecture and key concepts

The change splits two capabilities that were previously coupled:

  • transaction signing: createEvmClient, still required for contract writes;
  • protected playback identity signing: keyRequestSigner, now available to Product-host wallets.
flowchart LR
  Host[Product Host account] --> ProductHost[productHost.ts]
  ProductHost --> Wallet[ConnectedWallet.keyRequestSigner]
  Wallet --> Catalog[useCatalog protected playback]
  Catalog --> KeyService[keyService requestContentKey]
  KeyService --> API[Fly API product-sr25519-v1]
  API --> Runtime[musicAccCanAccess]
Loading

productHost.ts exposes the Product public key and a raw string-message signer backed by PolkadotSigner.signBytes. useWallet attaches that as keyRequestSigner only for Product-host connections. WalletProvider now treats Product-host wallets as protected-playback-capable for listener identity/session cleanup, while getActiveWalletClient still rejects Product-only wallets for transaction actions.

How it works

  1. The user explicitly chooses the Product host account path.
  2. Dotify requests dotify-test01.dot account index 0, derives the H160 requester, and stores the Product public key.
  3. Protected playback calls requestContentKey with keyRequestSigner when present.
  4. The key-service client signs either the one-per-session sign-in message or the legacy per-track key message with signBytes(utf8(message)).
  5. Product requests include signatureScheme: product-sr25519-v1 and productPublicKey; EVM/passkey requests keep the existing default EIP-191 body shape.
  6. The backend remains authoritative for nonce, chain, expiry, requester binding, and musicAccCanAccess before releasing any key.

Design decisions and tradeoffs

The main design choice is a small capability field instead of making Product-host wallets pretend to have a viem wallet client. That keeps the current production spine intact: Product can unlock backend key delivery without gaining contract-write authority.

I kept the canonical Dotify message text unchanged because API verification already depends on byte-identical frontend/backend builders. I also kept session storage keyed by requester H160, matching the current backend session identity model.

The alternative was to wait for full Product CDM runtime transactions before enabling protected playback. That would block a useful and bounded Product proof path even though key delivery already has a server-side trust boundary.

Security, failure, and operations

  • Product key requests only work through the explicit product-sr25519-v1 scheme and include productPublicKey.
  • The Product public key still has to derive to the requester H160 on the backend before nonce consumption or access checks.
  • Product-only wallets still cannot publish, pay, or mutate runtime state through getActiveWalletClient.
  • No Netlify, Fly, or Product env var changes are introduced.
  • If Product signing, nonce, API, RPC, or access checks fail, playback fails closed and no browser content secret fallback is introduced for production mode.
  • Real Product Host smoke evidence is still required after publication; this local branch cannot prove host UI signing inside the live container.

Review guide

Suggested order

  1. web/src/services/keyService.ts - verify the new signer abstraction preserves EIP-191 request bodies and adds Product fields only for Product signers.
  2. web/src/features/productHost/productHost.ts - verify Product public key export and signBytes(utf8(message)) behavior.
  3. web/src/hooks/useWallet.ts, web/src/app/providers/WalletProvider.tsx, web/src/hooks/useCatalog.ts - verify protected playback capability is separate from transaction signing.
  4. web/src/services/keyService.test.ts, web/src/features/productHost/productHost.test.ts - verify session and fallback proof bodies.
  5. Product docs and runbooks - verify the delivered/pending status is honest.

Verify carefully

  • Product-host wallets cannot reach contract writes without createEvmClient.
  • EVM/passkey key requests do not start sending unexpected signatureScheme fields.
  • Product key/session messages remain byte-identical to the backend canonical builders.
  • A failed Product host signer cannot fall back to demo secrets or zero-address access.
  • Docs do not overclaim real Host smoke evidence.

Validation

Evidence What it proves
cd web && npm run test:unit -- --run src/services/keyService.test.ts src/features/productHost/productHost.test.ts Product session and key request bodies include the required scheme/public key; Product host signer wraps message bytes correctly.
cd web && npm run test:unit The shared frontend unit suite still passes with the new wallet capability field.
cd web && npm run lint ESLint passes; existing hook dependency warnings remain in App.tsx and ArtistShell.tsx.
cd web && npm run build Standalone production build type-checks and bundles; existing Vite chunk warnings remain.
cd web && npm run build:product-devnet Product DevNet build profile type-checks and bundles; existing Vite chunk warnings remain.
git diff --check No whitespace errors.
node scripts/backlog-sync.mjs --check --offline Backlog sync passes offline; existing warnings remain for unmapped active items and duplicate 08 docs.

Known limitations and follow-ups

Real Product Host smoke testing was not run locally. After this PR is published to Product DevNet, validate protected playback inside the Host and capture the Product sr25519 request evidence.

Contract writes still require passkey/EVM signing. The next Product slices remain generated CDM manifest/types, host transaction smoke tests, and Product presence/personhood work.

Metadata checklist

  • Backlog issue linked with correct close/reference semantics
  • Local backlog document linked
  • Added to Project 5 (Dotify sprints) - blocked by current local GitHub Project mutation access
  • Project Priority, Track, Phase, Type, and Backlog doc mirror the issue - blocked with Project 5 mutation access
  • Workflow status matches draft/review state
  • Assignee set
  • Applicable labels set
  • Applicable milestone set, or confirmed none exists
  • Reviewers requested when ownership is known
  • Draft/ready state is intentional

Review round 2 - findings and fixes

An extensive review of the Product DevNet track found four issues in this
branch and its stack. All are fixed here (cb17803, 65e7650, 9164b2c)
rather than restacked, so #110/#111 are unchanged and the stack is correct once
this PR lands.

1. The product-sr25519-v1 path rested on unverified wire-format guesses

PolkadotSigner.signBytes does not sign raw bytes. It routes through
truapi.signing.signRaw({ payload: { tag: "Bytes", ... } })
(@parity/product-sdk-host/dist/index.js:671), while the API verified
TextEncoder().encode(message) unwrapped. Three assumptions had no check
behind them:

  • <Bytes> wrapping - the wrapping happens host-side, outside this SDK, so
    nothing in the dependency tree settles it.
  • Curve - HostSignPayloadResponse.signature is an untagged HexString.
  • Length - the route schema required exactly 128 hex, so a 65-byte
    MultiSignature-tagged value was rejected as a generic 400 before reaching the
    verifier.

The tests did not close this: the API test signed with @scure/sr25519 over
unwrapped bytes, and productHost.test.ts mocked signBytes entirely. Both
sides asserted the assumption they shared.

Verification now accepts a bounded set - the canonical message verbatim or
<Bytes>-wrapped, and a bare 64-byte or MultiSignature-tagged 65-byte sr25519
signature. This is not a weakening: every variant carries the identical
domain-bound message (app, action, purpose, content hash, requester, chain,
nonce, expiry), so no new replay surface exists, and a non-sr25519 tag still
fails closed. A key that parses and derives to the requester but verifies under
no variant returns the new PRODUCT_SIGNATURE_REJECTED, distinct from
SIGNATURE_INVALID, so an envelope problem is separable from a wrong-account
problem in Fly logs.

2. Three places claimed Product identity could not get protected keys

useCatalog routes protected key requests through the Product signer and
canRequestProtectedPlayback treats a Product-host wallet as capable - but the
wallet modal, runbook validation step 4, and the architecture capability matrix
all still described the pre-aaffb9d behaviour. The architecture doc
contradicted itself two sections apart.

This mattered beyond tidiness: given finding 1, the observed result would have
been a denial, which the stale runbook step told the operator to sign off on as
correct. Step 4 is now an evidence capture that records which envelope the live
host produced.

3. The 0xee branch let a caller name any EVM address as requester

deriveProductAccountH160 returns publicKey[0..20] verbatim when bytes 20-32
are all 0xee. Confirmed empirically: <victim H160> || 0xee x12 passes the
PRODUCT_ADDRESS_MISMATCH check for that address.

Not exploitable as shipped - the forged value is not a valid ristretto255
point, so verify throws and is caught. The problem was structural: on the
native branch keccak preimage resistance means an attacker cannot even aim at a
victim's address; on this branch only the curve check remained. A real Product
account is a native AccountId32, so product-sr25519-v1 now rejects the
EVM-derived shape outright with PRODUCT_KEY_NOT_NATIVE.

4. Unbounded allocation from contract-supplied counts

Array.from({ length: Number(trackCount) }) in both adapters allocated before
any check could reject the value, over counts read from runtimes the directory
enumerates but Dotify does not control. Counts are now validated first, and
throw rather than truncate - a silent cap would present a partial catalog as
complete. The catalog loader already isolates per-runtime failures
(useCatalog.ts:1165), so one bad runtime degrades to a missing artist.

Also pinned @scure/sr25519 exactly to match @noble/hashes, and marked the
two unverified spots in the CDM adapter (waitForTransaction returning
immediately where the viem writer awaits a receipt; the inferred payForAccess
value-transfer shape) that must be settled before it can be selected.

What the review verified rather than assumed

  • H160 derivation is correct. Ran the SDK's own deriveH160 against the
    Alice vector hardcoded in signatures.test.ts; both produce
    0x9621dde636de098b43efb0fa9b61facfe328f99d. The backend implementation is
    byte-for-byte equivalent to @parity/product-sdk-address.
  • Code-splitting works. The standalone entry chunk holds only Dotify's call
    site; the 69 kB SDK chunk is lazily referenced and never fetched when
    VITE_DOTIFY_HOST_MODE=off. No standalone regression.

Round 2 validation

Evidence Result
cd services/api && npm run typecheck pass
cd services/api && npm test 92 pass (was 86; +6 covering both envelopes, both signature shapes, the non-sr25519 tag, the 0xee rejection, the distinct rejection code, and the tagged-signature route path)
cd services/api && npm run build pass
cd web && npm run test:unit 199 pass (was 197; +2 covering the count bounds)
cd web && npm run lint 0 errors; the 3 pre-existing App.tsx/ArtistShell.tsx warnings remain
cd web && npm run build pass
cd web && npm run build:product-devnet pass
cd web && npm run smoke:production-env pass
node scripts/backlog-sync.mjs --check --offline pass; pre-existing warnings remain
git diff --check clean

Residual risk unchanged by this round

Real Product Host smoke evidence is still not captured - that remains the
gating validation, and it is what narrows the accepted envelope set back down to
the one shape the host actually produces. npm run test:e2e and
npm run test:signal were not run: no signaling or room-flow behaviour changed
beyond the publicAppUrl link base, which roomState.test.ts covers.

@knzeng-e knzeng-e added dotify-backlog Tracked by docs/backlog/backlog.json and Project 5 product-sdk Polkadot Product SDK / Host / Playground integration labels Jul 28, 2026 — with ChatGPT Codex Connector
@knzeng-e knzeng-e self-assigned this Jul 28, 2026
knzeng-e and others added 3 commits July 29, 2026 02:48
The Host signRaw wire format is not pinned by the SDK: HostSignPayloadResponse
carries an untagged signature, and a Substrate host may sign a raw payload
verbatim or inside the conventional <Bytes> envelope. Verification assumed one
shape, so a wrong guess would have failed every Product key request with an
error indistinguishable from a wrong signer.

Accept a bounded set instead: the canonical message verbatim or <Bytes>-wrapped,
and a bare 64-byte or MultiSignature-tagged 65-byte sr25519 signature. Every
variant carries the identical domain-bound message, so this adds no replay,
cross-app, cross-chain, or cross-track surface; a non-sr25519 tag still fails
closed. Route schemas widen to 128 or 130 hex so the tag is checked by the
verifier rather than rejected before it.

Reject EVM-derived account ids for product-sr25519-v1. A 20-byte H160 padded
with 0xee derives back to the H160 it contains, so accepting that shape let a
caller name any paying EVM listener as the requester and rested the boundary on
the curve check alone. A real Product account is a native AccountId32.

A key that parses and derives to the requester but verifies under no variant now
returns PRODUCT_SIGNATURE_REJECTED, kept distinct from SIGNATURE_INVALID so an
envelope problem is separable from a wrong-account problem in logs.

Pin @scure/sr25519 exactly, matching @noble/hashes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wiring the Product key signatures changed the behaviour but three places still
described the old one. The wallet modal told Product-host users that protected
playback required an EVM signer, the runbook asked operators to confirm no key
is released through the Product identity, and the architecture matrix said the
shipped UI used EIP-191 or a session token - contradicted by its own prose two
sections later.

All three now describe what ships: a connected Product account requests
protected keys through product-sr25519-v1, and paid access plus artist
publishing remain on the EVM signer. This matters beyond tidiness - the stale
runbook step would have had an operator sign off on a denial as correct
behaviour, hiding a real signing failure.

Record the signing envelope decision and the EVM-derived key rejection, and turn
the runbook step into an evidence capture that names which envelope the live
host actually produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Track and split counts come from contract storage and the directory enumerates
runtimes Dotify does not control, so Array.from({ length: Number(count) })
allocated before anything could reject a malformed or hostile value. Both
adapters now validate counts first and throw rather than truncate, since a
silent cap would present a partial catalog as complete. The catalog loader
already isolates per-runtime failures, so one bad runtime degrades to a missing
artist.

Mark the two unverified spots in the CDM adapter that must be settled before it
can be selected: waitForTransaction returns immediately where the viem writer
awaits a receipt, and the payForAccess value-transfer shape is inferred rather
than confirmed against generated contract types.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@knzeng-e
knzeng-e changed the base branch from feat/product-key-signature-scheme to dev August 3, 2026 13:33
@knzeng-e
knzeng-e marked this pull request as ready for review August 3, 2026 13:33
@knzeng-e
knzeng-e merged commit 3da0a5b into dev Aug 3, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dotify-backlog Tracked by docs/backlog/backlog.json and Project 5 product-sdk Polkadot Product SDK / Host / Playground integration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant