Wire Product frontend key signatures - #112
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-v1proof 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
useCatalogrequired a viemWalletClient. 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
createEvmClientuntil the Product CDM transaction path has real host-signed evidence.Architecture and key concepts
The change splits two capabilities that were previously coupled:
createEvmClient, still required for contract writes;keyRequestSigner, now available to Product-host wallets.productHost.tsexposes the Product public key and a raw string-message signer backed byPolkadotSigner.signBytes.useWalletattaches that askeyRequestSigneronly for Product-host connections.WalletProvidernow treats Product-host wallets as protected-playback-capable for listener identity/session cleanup, whilegetActiveWalletClientstill rejects Product-only wallets for transaction actions.How it works
dotify-test01.dotaccount index0, derives the H160 requester, and stores the Product public key.requestContentKeywithkeyRequestSignerwhen present.signBytes(utf8(message)).signatureScheme: product-sr25519-v1andproductPublicKey; EVM/passkey requests keep the existing default EIP-191 body shape.musicAccCanAccessbefore 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-sr25519-v1scheme and includeproductPublicKey.getActiveWalletClient.Review guide
Suggested order
web/src/services/keyService.ts- verify the new signer abstraction preserves EIP-191 request bodies and adds Product fields only for Product signers.web/src/features/productHost/productHost.ts- verify Product public key export andsignBytes(utf8(message))behavior.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.web/src/services/keyService.test.ts,web/src/features/productHost/productHost.test.ts- verify session and fallback proof bodies.Verify carefully
createEvmClient.signatureSchemefields.Validation
cd web && npm run test:unit -- --run src/services/keyService.test.ts src/features/productHost/productHost.test.tscd web && npm run test:unitcd web && npm run lintApp.tsxandArtistShell.tsx.cd web && npm run buildcd web && npm run build:product-devnetgit diff --checknode scripts/backlog-sync.mjs --check --offlineKnown 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
Dotify sprints) - blocked by current local GitHub Project mutation accessReview 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-v1path rested on unverified wire-format guessesPolkadotSigner.signBytesdoes not sign raw bytes. It routes throughtruapi.signing.signRaw({ payload: { tag: "Bytes", ... } })(
@parity/product-sdk-host/dist/index.js:671), while the API verifiedTextEncoder().encode(message)unwrapped. Three assumptions had no checkbehind them:
<Bytes>wrapping - the wrapping happens host-side, outside this SDK, sonothing in the dependency tree settles it.
HostSignPayloadResponse.signatureis an untaggedHexString.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/sr25519overunwrapped bytes, and
productHost.test.tsmockedsignBytesentirely. Bothsides 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 sr25519signature. 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 fromSIGNATURE_INVALID, so an envelope problem is separable from a wrong-accountproblem in Fly logs.
2. Three places claimed Product identity could not get protected keys
useCatalogroutes protected key requests through the Product signer andcanRequestProtectedPlaybacktreats a Product-host wallet as capable - but thewallet modal, runbook validation step 4, and the architecture capability matrix
all still described the pre-
aaffb9dbehaviour. The architecture doccontradicted 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
0xeebranch let a caller name any EVM address as requesterderiveProductAccountH160returnspublicKey[0..20]verbatim when bytes 20-32are all
0xee. Confirmed empirically:<victim H160> || 0xee x12passes thePRODUCT_ADDRESS_MISMATCHcheck for that address.Not exploitable as shipped - the forged value is not a valid ristretto255
point, so
verifythrows and is caught. The problem was structural: on thenative 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, soproduct-sr25519-v1now rejects theEVM-derived shape outright with
PRODUCT_KEY_NOT_NATIVE.4. Unbounded allocation from contract-supplied counts
Array.from({ length: Number(trackCount) })in both adapters allocated beforeany 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/sr25519exactly to match@noble/hashes, and marked thetwo unverified spots in the CDM adapter (
waitForTransactionreturningimmediately where the viem writer awaits a receipt; the inferred
payForAccessvalue-transfer shape) that must be settled before it can be selected.
What the review verified rather than assumed
deriveH160against theAlice vector hardcoded in
signatures.test.ts; both produce0x9621dde636de098b43efb0fa9b61facfe328f99d. The backend implementation isbyte-for-byte equivalent to
@parity/product-sdk-address.site; the 69 kB SDK chunk is lazily referenced and never fetched when
VITE_DOTIFY_HOST_MODE=off. No standalone regression.Round 2 validation
cd services/api && npm run typecheckcd services/api && npm test0xeerejection, the distinct rejection code, and the tagged-signature route path)cd services/api && npm run buildcd web && npm run test:unitcd web && npm run lintApp.tsx/ArtistShell.tsxwarnings remaincd web && npm run buildcd web && npm run build:product-devnetcd web && npm run smoke:production-envnode scripts/backlog-sync.mjs --check --offlinegit diff --checkResidual 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:e2eandnpm run test:signalwere not run: no signaling or room-flow behaviour changedbeyond the
publicAppUrllink base, whichroomState.test.tscovers.