Conversation
The previous branch was a root commit with no ancestor in common with main. Its tree was a partial, stale snapshot, so a diff against main reported 219 files and -32807 lines and silently reverted merged work: the txExecution helpers from dusk-network#76, the internal-helper removal from dusk-network#87, and validateNodeUrl's normalization to url.origin. Four of five test suites could not even import. Rebuild the branch on origin/main (e3e94c2) by re-applying the typed-data files rather than cherry-picking the snapshot: - src/shared/typedDataHash.js and its tests (hash v1 twin) - src/shared/blsDigest.js and its tests (BLS12-381 short signatures, V2 DST) - src/shared/fixtures/typed-data-v1/ golden vectors - scripts/check-typed-data-fixture-parity.mjs - @noble/curves and @noble/hashes dependencies Deliberately not carried over: the version downgrade to 0.2.0, and the dusk_signBlsDigest RPC case. The latter was never on main, and introducing a raw 32-byte signing oracle reachable by any connected dApp only to remove it in the next commit serves no purpose. RPC, engine, and approval-UI wiring follow in later phases. Spec now lives in dusk-network/connect as the single source of truth; docs/typed-data-v1.md is a pointer, and the cross-repo rollout is in docs/typed-data-v1-implementation-plan.md.
handleRpc validated the caller's origin but never the method name, dispatching straight into `switch (method)`. Neither src/inpage.js nor src/contentScript.js filters method names either, so any case in that switch was callable by any connected dApp regardless of what dusk_getCapabilities advertised. Removing a method from the advertised surface affected discoverability, not access. That is fail-open: an internal or test-only case is public the moment it is added. Reject anything outside DAPP_RPC_METHODS before any permission lookup, settings read, or approval prompt, so an unknown method also costs nothing. Introduce DAPP_TOMBSTONED_METHODS for methods that are deliberately refused. dusk_getAddresses exists so dApps learn that enumerating shielded addresses is declined rather than getting a misleading "Unknown method" — the method is known, it is refused. A bare allowlist flattened that distinction, so the two categories are now declared separately and asserted to stay disjoint. The switch keeps its `default:` case as a backstop. Testing note: behavioural tests cannot distinguish the allowlist from `default:`, since both currently answer METHOD_NOT_FOUND — removing the guard leaves every behavioural test green. The allowlist earns its keep on cases added later, which `default:` would not catch because they would match a case. Two conformance tests cover it structurally instead: one asserts the guard precedes the switch, the other asserts every dusk_ case is canonical or tombstoned. Both were verified to fail when the code under test is reverted.
Implements docs/typed-data-v1.md as published in dusk-network/connect. Digests change; no consumer recomputes them yet, so the vectors are regenerated rather than versioned. The substantive change is that `string` and `bytes` now encode as sha256(value) instead of len32(value) || value, making encoded width a function of the type rather than the value. That removes the reason for the 1 MiB encoded-size budget, which was previously part of validity and which the two implementations counted differently: Connect used per-tree budgets and double-counted array bytes, while this twin shared one budget and counted arrays once. Each therefore accepted payloads the other rejected. A payload could be signed here and be unverifiable in Connect. The budget is replaced by `checkPolicyLimits`, a separate exported function the RPC layer calls before signing. It is never called from the hashing path. Validity is now identical everywhere; resource limits are local policy that implementations may differ on safely. Also aligned with the spec: - encodeType puts the primary type before its sorted dependencies. The old form gave equal typeHash to struct types with equal dependency closures, reachable only via mutually recursive types, which admit no finite value. The safety argument was implicit; cycles are now rejected outright. - Field presence uses an own-property test. `in` succeeds through the prototype chain, so a field named __proto__ or constructor could appear present while absent; those names are now rejected outright. - primaryType may not be DuskTypedDataDomain; T[0] and leading-zero array sizes are rejected. - originBind hashes the origin rather than length-prefixing it. - Every reject path carries a stable error code so two implementations can be compared on why they reject, not merely that they do. Vectors carry intermediates (typeHashes, domainSeparator, originBind, structHash) so a future mismatch localizes to a stage instead of only showing a differing final digest. Verified against an implementation written independently from the spec text: all three vectors match on every intermediate.
Adds the sign_typed_data approval screen. Structured values are flattened to one row per leaf with a dotted path, the declared type, and a bounded display value, instead of being dumped as JSON. JSON was the obvious approach and is the wrong one. Pretty-printed output overflows a ~360px popup and pushes the Sign button off-screen, and scroll fatigue produces approvals without reading — the failure this screen exists to prevent. Flat JSON is unreadable once anything nests. Showing the declared type matters independently: `amount: "42"` is identical on screen whether the field is a uint64 or a string, and those sign different bytes. The flattening lives in src/shared/typedDataDisplay.js rather than in the popup because vitest runs with environment: "node" and no DOM implementation is installed, so UI logic embedded in app.js can only be tested by asserting on its source text. The pure module gets real behavioural tests; the render block stays a thin mapping. This mirrors signMessagePreview.js, which already exists for the same reason on the sign_message path. Field values are attacker-controlled and rendered on a signing screen, so strings are capped at 2048 characters and C0/C1 control characters, bidi overrides, and lone surrogates are replaced with a placeholder and flagged. A right-to-left override can make "send 1 DUSK" display as something else entirely. h() sets textContent, so markup injection was never the risk; visual spoofing is. The sign_message path already guarded this and the typed path must not be weaker — the shared control-character definition is now exported from signMessagePreview.js rather than duplicated. Structural limits are separated from content warnings. Depth is capped at 8 and rows at 200, but a subtree cut for depth emits a marker row at the cut point rather than incrementing a counter: a payload nested past the cap at the root would otherwise render an approval with zero visible fields and a "1 more field" notice, showing the user nothing while understating what was hidden. Only deceptive content raises the spoofing warning; a truncated value or an elided subtree reads as a display limit, because a warning that fires on benign input trains users to dismiss it. The digest is shown collapsed so it can be cross-checked against what the dApp claims it asked for.
Adds the typed-data signing method to the dApp surface, per the spec in dusk-network/connect. The wallet validates the request, injects its own view of the requesting origin, hashes, obtains user approval showing the rendered fields, then signs. Validation is ordered so nothing expensive or user-visible happens on a bad request: connection, params shape, version, structural validation, chain match, and policy limits all run before any approval popup appears. Two guarantees that did not exist before: - The origin bound into the digest is always the handler's own, never params.origin. A caller able to set it could obtain a signature attributable to a site it does not control, which would defeat the entire point of origin binding. The hash input is assembled field by field rather than by spreading params, so a request field added later cannot leak in. - The active chain is re-read after approval returns and the request is rejected if it changed. A user can switch networks while the popup is open, and a signature must not outlive the context it was approved in. Validation runs on the assembled hash input rather than on the caller's raw params. Origin is a field of that input and the validator checks it, so validating raw params made a caller-supplied origin mandatory — meaning every dApp following the documented request shape would have received INVALID_PARAMS. The suite did not catch this because the test fixture always supplied a hostile origin to prove it was ignored, so no test ever exercised the documented shape. The fixture now omits origin by default and a regression test pins the documented request. Capabilities advertise signTypedDataVersions as an array rather than a scalar version. A scalar means "the one version I do", which forces a flag day: a v1-only dApp seeing 2 cannot tell whether v1 is still accepted, and a wallet supporting both has no way to say so. Result shape matches the spec and the sibling sign_* methods: `signature` rather than `signatureHex`, `publicKeyHex` rather than `fundsPkHex`, with origin, chainId, and primaryType echoed so a verifier need not hold the original request.
…ail closed Vendors Connect's 13 accept and 20 reject vectors verbatim from commit dbed16d, with SOURCE recording the SHA and the rule that vectors are generated in Connect and copied here, never edited in place. The parity checker was a no-op. It resolved Connect fixtures from ../connect/src/typed-data/fixtures, a path that has never existed, fell back to an undocumented CONNECT_TYPED_DATA_FIXTURES env var, and when neither resolved printed "skip" and exited 0. It ran in neither npm test nor CI. It had already failed silently once: the two repos shipped different sign_in_basic vectors under the same filename and nothing noticed. It now runs against the vendored vectors with no environment setup, and a missing directory, unreadable file, malformed JSON, or zero vectors found is a hard failure. A checker that cannot tell "everything passed" from "nothing ran" is worse than no checker, because it reports success either way. Every intermediate is compared, not just the final digest: per-struct typeHashes, domainSeparator, originBind and structHash, so a mismatch names the stage that diverged instead of only proving two digests differ. Reject vectors assert the exact error code, so the implementations can be compared on why they reject rather than merely that they do. CONNECT_TYPED_DATA_FIXTURES survives as an additive cross-check against a Connect working tree, but no longer decides whether the script runs. Wired in as a vitest file so vitest.config.js's existing src/**/*.test.js include sweeps it into npm run test:run automatically, and failures render inline with everything else. npm run test:typed-data-parity still works standalone. Verified by mutation: deleting the vector directory, corrupting a digest byte, altering only a domainSeparator while leaving the final digest correct, and changing a recorded error code each fail with a message identifying the problem. The domainSeparator case confirms intermediates are really checked; the deleted-directory case confirms the skip path is gone.
The typed-data digest is 32 bytes, which makes it indistinguishable from
every other 32-byte value the same Moonlight BLS key can be asked to sign,
including pay-auth digests used elsewhere under the identical DST. Anything
that signs a caller-supplied 32-byte value with this key could therefore
forge a typed-data signature, and a typed-data signature could be replayed
wherever a bare digest is accepted.
The signature now covers a tagged wrapper (spec 12.1):
SIG_TAG = utf8("DUSK_TYPED_DATA_SIG_V1\0") // 23 bytes
signedMessage = SIG_TAG || digest // 55 bytes
which makes the two message spaces structurally disjoint. The tag is applied
outside the digest because a value inside the SHA-256 preimage constrains the
preimage, not the 32-byte output, and the output is what gets signed.
The DST is deliberately unchanged. A typed-data-specific DST would give the
same separation, but the stock dusk-core verification path pins
BlsVersion::V2 and accepts no caller-supplied DST, so a custom one would make
these signatures unverifiable on-chain — a core goal. hashToCurve takes
arbitrary-length input, so the longer message needs no special handling
anywhere.
Adds the engine wiring the RPC layer already expects: signTypedData in
walletEngine and its dispatcher case in the engine runtime. Only the tagged
path is exported. The public surface has no raw-digest method and must not
grow one; signProfileBlsDigest and verifyBlsDigestSignature remain for
internal use and now carry comments saying they must never be reachable from
a dApp-facing RPC.
Tests pin the exact tag bytes and the 55-byte length, and assert both
directions of the separation: a bare-digest signature is rejected by the
tagged verifier, and a tagged signature is rejected by the bare-digest
verifier. Verified cross-repo with real keys — the wallet signs, Connect
verifies, and both cross-rejections hold.
Adds a dusk_signTypedData example alongside the existing provider example, with the two things an integrator gets wrong otherwise: origin must not be sent, because the wallet injects and returns its own, and domain.chainId must match the active chain or the request is rejected before any approval UI. Also notes that the signature covers a tagged wrapper around digestHex rather than the bare digest, since verifying the bare digest would accept signatures from any raw 32-byte signing path, and links the Connect specification.
Re-vendors the accept vectors now carrying signedMessageHex (Connect a2ca2b6) and teaches the gate to verify it. The gate does not trust the vector's value. It recomputes the signed message from the vector's digest using the wallet's own buildTypedDataSignedMessage, so the check ties the vendored corpus to the wallet's SIG_TAG constant: if the wallet's tag ever drifts from Connect's, every accept vector fails immediately, rather than the wallet quietly producing signatures no verifier accepts. Verified by drifting the constant — all 13 vectors fail, exit 1. A vector missing signedMessageHex is an explicit failure rather than a skipped check. A silently absent field reading as "nothing to verify" is the same class of bug this gate was rewritten to eliminate.
Background notes for dusk-network#22 and dusk-network#90, written for reviewers who want the reasoning behind a rule before changing it. Structured as the questions that shaped the design rather than as a list of changes: what un-advertising a method actually guarantees, whether two implementations agree on validity, what stops a 32-byte digest being reinterpreted, what the approval screen conveys, and what keeps any of it true over time. Each records the alternatives considered and why they were not taken. Also explains why the parts were landed together: origin binding depends on no sibling method signing caller-supplied digests, the approval screen depends on both implementations agreeing on encoding, and that agreement depends on the vector corpus pinning intermediates. Reviewed separately they look like independent improvements, which understates them.
…rview Adds the Unreleased changelog entries and the dusk_signTypedData row in the architecture RPC table. Enforcing that table turned up a second omission: dusk_watchAsset was on the canonical surface and in both provider-api.md and SECURITY.md, but had never been added here, because conformance checked those two documents and not this one. Added the missing row and the missing assertion, so the table cannot drift again. Also removes the implementation plan from the repository. It was scaffolding for carrying out this work and describes a branch state that no longer exists; docs/investigations/typed-data-signing.md covers the reasoning that outlives it.
…etwork#90 open The docs previously proposed a disposition for dusk-network#90, recommending it be closed as superseded. That is a decision for dusk-network#90's own discussion, and stating it here invites a review of this change to become a debate about a different issue. The remaining references describe the technical relationship only: this work removes the coupling between the two, so a raw-digest capability becomes an explicit surface decision rather than a side effect of adding a handler, and whatever dusk-network#90 concludes cannot produce or accept a typed-data signature. Nothing here argues for or against implementing it.
signProfileBlsDigest had no production callers. It is the capability issue dusk-network#90 asks for, and shipping it here would have settled that issue's design — key derivation, DST, result shape — inside a change that is about typed data. dusk-network#90 is getting its own change, so this module offers no ready-made signer for it. The low-level primitive signBlsMessageBytes stays: the typed-data path uses it, and the tests that assert a bare-digest signature is rejected by the typed-data verifier need to construct one. verifyBlsDigestSignature stays too. Verification is not a capability — it puts no key at risk — and application specific 32-byte digest schemes already in use need a verifier regardless of what dusk-network#90 concludes. No coverage is lost. The removed test asserted a raw sign/verify round trip already covered by the primitive-level test, plus a profile-to-derived-key agreement already covered by the typed-data profile test.
Signing with the profile key is a capability, so growing this module's surface should be a deliberate act rather than a side effect of another change. The export list is now pinned: adding one fails the test until someone updates the list, which makes it visible in review. A second assertion states the specific invariant plainly — exactly one profile-level signing path exists, and it is the typed one. Reintroducing a bare-digest profile signer must delete that assertion, so issue dusk-network#90 gets an explicit decision in its own review rather than an implicit one in a diff.
Records that this was written with AI assistance under human direction, and points at the verification that does not depend on the author being correct: the specification was agreed first, and the digest is checked against an implementation written independently from the specification text, agreeing on every intermediate value rather than only the final digest.
The recorded SHA referred to a commit that no longer exists after the branch was rewritten, so following it would have 404'd. Points at the commit that actually produced the vendored vectors.
728a68a to
7d0924e
Compare
dusk_signTypedData was written before the approval-lifecycle work landed and carried its own post-approval check: re-read settings, compare the chain, throw on mismatch. That solved a narrower version of the problem the shared mechanism now solves, and left two idioms for the same concern in one file. Adopt the sibling idiom. captureApprovalContext before the prompt, withStorageLock plus assertApprovalContext after it, and _approvalContext threaded to the engine. This widens the guarantee from chain only to node URL, profile index, permission identity, wallet identity and unlock state, and serializes the signing path against concurrent lifecycle changes. The digest is unaffected. It binds the dApp's domain.chainId, never the wallet's; the wallet's chain is used only to reject a mismatch and to display and echo. Sourcing it from the approval context changes where that comparison value comes from, not what is hashed. A mid-approval change now reports UNAUTHORIZED rather than INVALID_PARAMS. Nothing is wrong with the request — the wallet moved out from under an approval it had already granted, which is what the sibling methods report. Also adds the engine-side guard, which signTypedData was missing while the other twelve engine entry points had it. That check compares the engine's own view rather than the RPC layer's, so it catches drift the outer check cannot see. It was untested because engineCall is mocked in the RPC tests, and assertApprovalContext returns early when no context is passed, so a missing guard fails open silently. Two engine tests now cover it, and removing the guard fails the first.
The numbered step comments had drifted out of sequence — two 5s, two 8s, no 7 — and all eleven in the file were in this one case; no sibling uses them. Numbering rots whenever a step is inserted or removed, which is what happened. The prose is kept, the numbers are not, and one comment that referred to "step 4" now names the thing instead.
|
Rebased onto current What changed and why. When this branch was cut, What it buys. The guarantee widens from chain only to node URL, profile index, permission identity, wallet identity and unlock state, and signing is serialized against concurrent lifecycle changes rather than racing them. What it does not touch. The digest is unaffected. It binds the dApp's One behaviour change. A mid-approval change now reports One gap this surfaced. The PR description has been updated to match; the security properties section now lists this as a fourth guarantee. |
Require identifier field names, reject unpaired surrogates before UTF-8 encoding, and retain own debug hashes for valid __proto__ type names. Test malformed inputs at the hash and pre-approval RPC boundaries while preserving well-formed Unicode. Vendor the two rejection vectors byte-for-byte from Connect 72ac9cd2cedab7e66427520234bd9f43fb4f5c50 and advance SOURCE provenance. Document the validation boundary for dusk-network#22.
|
What the library replaces here
The reason recorded for the twin has changed
The supply-chain concern is still real, but it is now a question of release discipline for a small auditable package — exact pins, publish from CI with provenance attestations, and vendoring the source under a byte-identity check if that is wanted — rather than a reason to maintain a second implementation. The decision that blocks it The same one as Connect: whether the package is published first, or consumed as a pinned git dependency in the interim. |
Field presence is tested with hasOwnProperty, which sees every own property. The extra-key rule used Object.keys, which sees only the enumerable ones. A non-enumerable own property therefore counted as present and was never rejected as undeclared, so the two halves of spec section 6.3 used different notions of "own". Values that arrive by parsing JSON carry only enumerable own properties, so nothing reachable across the RPC boundary changes. An in-process caller that constructs the message object directly now gets E_FIELD_EXTRA where a digest was previously returned. Matches dusk-network/typed-data#1, which makes the same change in the shared package.
Use Reflect.ownKeys so symbol-keyed properties cannot escape the extra-field rule. Retain non-enumerable string coverage, exercise all three hashing entrypoints and preserve declared non-enumerable fields. JSON transport behavior and frozen digests are unchanged. Refs dusk-network#22.
|
Closing as superseded by the merged #112 (shared-library typed-data signing) and #114 (complete signing-request disclosure). Thank you @ichbindas for the original implementation and follow-up review. The retained Wallet-specific commits were ported individually into #112 with their original authorship, author dates, messages and source-commit links; see the provenance record. Wallet keeps ownership of keys, signing, permissions, approval and lifecycle checks. The encoder, specification and vector corpus now belong to dusk-network/typed-data, consumed through the published JSR This closes only this superseded PR, without merging or deleting its branch. The broader #22 work and separate raw-digest proposal #90 remain open. |
Addresses #22. Does not implement #90 and takes no position on it — see Relationship to #90.
What this adds
dusk_signTypedDatalets a dApp request a signature over structured data the wallet renders, instead of an opaque digest the user cannot evaluate. The approval screen shows the requesting origin, the domain, the primary type, and every message field with its declared type.This is the Dusk analogue of
eth_signTypedData_v4, not ofeth_sign. It is deliberately not wire-compatible with EIP-712: different hash (SHA-256), different curve and signature scheme (BLS12-381 short signatures), different type encoding.Flow: validate → inject origin → hash → user approval → sign.
Design decisions
The normative specification is in the companion Connect PR (
docs/typed-data-v1.md). The reasoning behind each decision — including the alternatives that were rejected and why — is in docs/investigations/typed-data-signing.md. Reviewers short on time should read that document rather than this list.domain/types/primaryType/message). Third parties ship new schemas without a wallet release.primaryTypeallowlist. SDK presets are convenience, not a wallet gate.stringandbytesencode assha256(value), so encoded width depends on the type, never the value.chainIdSIG_TAG || digest, under the unchangedBlsVersion::V2DST.signTypedDataVersions: [1]— an array, so a future version is additive rather than a flag day.@dusk/connectdependency. Golden vectors are the contract.Security properties
Four guarantees that did not exist on the dApp surface before:
The origin cannot be influenced by the caller. The wallet injects its own view of the requesting origin into the digest and returns the exact string used. A caller able to set it could obtain a signature attributable to a site it does not control. The hash input is assembled field by field rather than by spreading request params, so a field added later cannot leak into the digest.
The dispatcher fails closed. dApp requests are now gated on the canonical method list before any permission lookup, settings read, or approval prompt. Previously the handler validated the caller's origin but not the method name, so any
casein the switch was reachable by any connected dApp regardless of whatdusk_getCapabilitiesadvertised. Methods that are deliberately refused (currentlydusk_getAddresses) are a declared category, so they keep explaining themselves rather than reporting "Unknown method".The signed message is domain-separated from bare digests. The signature covers
utf8("DUSK_TYPED_DATA_SIG_V1\0") || digest— 55 bytes — not the bare 32-byte digest. A 32-byte digest is indistinguishable from any other 32-byte value the same key signs, so without the tag the separation would rest on the wallet never signing a caller-supplied digest, rather than on the cryptography. Both directions are tested: a bare-digest signature is rejected by the typed-data verifier, and a typed-data signature is rejected by the bare-digest verifier.The approval cannot outlive the context it was granted in. Signing runs under the wallet lifecycle lock, and the context captured before the prompt — node URL, profile index, permission identity, wallet identity, unlock state — is re-verified after it. A user can switch network, lock, or change the connected profile while the popup is open; none of those can result in a signature attributed to the state the user actually saw. The engine re-checks its own view independently, so drift is caught at both layers rather than only at the RPC boundary.
Approval UI
Message values are flattened to one row per leaf with a dotted path, each showing its declared type —
amount: "42"is indistinguishable between auint64and astring, and those sign different bytes. Field values are attacker-controlled text on a signing screen, so strings are capped and control characters, bidi overrides, and lone surrogates are replaced with a placeholder and flagged; a right-to-left override can otherwise make one message display as another. Depth and row caps emit a marker at the cut point rather than silently rendering an empty screen. The digest is available collapsed for cross-checking.Interoperability
Golden vectors are generated in Connect and vendored here verbatim with the source commit recorded in
src/shared/fixtures/typed-data-v1/SOURCE. Every accept vector carries its intermediates (per-structtypeHash,domainSeparator,originBind,structHash,digestHex,signedMessageHex) so a mismatch localises to a stage. Reject vectors carry a stable error code, one for every rule in the specification's validation table.npm run test:typed-data-paritychecks the corpus against this implementation and runs as part ofnpm run test:run. A missing directory, unreadable file, malformed JSON, or zero vectors found is a hard failure — never a skip.Relationship to #90
#90 asked for raw 32-byte digest signing. This PR does not implement it, and does not propose a disposition for it. No raw-digest method is added to the dApp surface, and no opinion on whether one should exist is encoded here.
The scoping rule applied here: this PR adds no new signing capability beyond typed data. There is no profile-level signer for bare digests — the low-level BLS primitives remain, but nothing packages them into a ready-made raw-digest signing path, because choosing that path's shape is #90's decision to make. Verification helpers are unaffected by that rule, since verifying puts no key at risk and existing application-specific digest schemes need one regardless.
Beyond that, this PR removes the coupling between the two issues, so #90 can be designed on its own merits later:
Reviewers should not need to resolve #90 to review this PR.
Provenance
This change was written with AI assistance, under human direction and review throughout. Scope, trade-offs, and the decisions recorded above are human-owned; the specification was agreed before the implementation, and the implementation was written to it rather than the other way round.
Because generated code is easy to make look correct, the verification leans on things that do not depend on the author being right:
Reviewers should apply exactly the scrutiny they would to any other contribution. This note is context for reading the diff, not a claim about its quality.
Testing
chainId, unknown version, malformedtypes, missingprimaryType, and over-policy payloads all reject before approvalparams.origindoes not affect the digestCompanion
dusk-network/connect#35 — typed-data hash v1, golden vectors, and
@dusk/connect/blsverification.The vectors here are copied verbatim from that branch, and
src/shared/fixtures/typed-data-v1/SOURCErecords the exact commit they came from. If the Connect PR is squash-merged, that commit will not exist on Connect'smain, soSOURCEshould be repointed at the merge commit before this PR lands.Not claimed
dusk_sendTransactionapproval