Skip to content

feat(connect): dusk_signTypedData — typed structured signing - #101

Closed
ichbindas wants to merge 21 commits into
dusk-network:mainfrom
ichbindas:feat/typed-data-v1
Closed

ichbindas wants to merge 21 commits into
dusk-network:mainfrom
ichbindas:feat/typed-data-v1

Conversation

@ichbindas

@ichbindas ichbindas commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Addresses #22. Does not implement #90 and takes no position on it — see Relationship to #90.

What this adds

dusk_signTypedData lets 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 of eth_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.

const result = await dusk.request({
  method: "dusk_signTypedData",
  params: {
    domain: { name: "Example", version: "1", chainId: "dusk:1" },
    types: {
      DuskTypedDataDomain: [
        { name: "name", type: "string" },
        { name: "version", type: "string" },
        { name: "chainId", type: "string" },
        { name: "verifyingContract", type: "bytes32" },
      ],
      SignIn: [{ name: "address", type: "string" }],
    },
    primaryType: "SignIn",
    message: { address: profile.account },
  },
});
// → { account, publicKeyHex, origin, chainId, primaryType, digestHex, signature }

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.

Topic Decision
Ownership Connect owns the hash, the spec, and the golden vectors. Verifiers recompute Connect digests.
Request shape Full typed data (domain / types / primaryType / message). Third parties ship new schemas without a wallet release.
Allowlist No primaryType allowlist. SDK presets are convenience, not a wallet gate.
Hash SHA-256, not keccak — matches the on-chain BLS verification path.
Encoding string and bytes encode as sha256(value), so encoded width depends on the type, never the value.
Size limits A floor verifiers must accept, not a ceiling. Signer-side policy, never part of validity.
Origin Injected by the wallet and echoed in the result. dApps must not supply it.
chainId Required CAIP-2; must match the wallet's active chain or the request is rejected before approval.
Signing Direct BLS over SIG_TAG || digest, under the unchanged BlsVersion::V2 DST.
Versioning signTypedDataVersions: [1] — an array, so a future version is additive rather than a flag day.
Wallet hasher A twin implementation, not a runtime @dusk/connect dependency. 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 case in the switch was reachable by any connected dApp regardless of what dusk_getCapabilities advertised. Methods that are deliberately refused (currently dusk_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 a uint64 and a string, 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-struct typeHash, 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-parity checks the corpus against this implementation and runs as part of npm 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:

  • Methods are reachable only if they are deliberately on the canonical surface, so a future raw capability is an explicit decision rather than a side effect of adding a handler.
  • Typed-data signatures cover a domain-separated message, so whatever feat(connect): add RPC to sign a raw 32-byte BLS digest #90 concludes, a signature over a caller-supplied digest cannot be a valid typed-data signature, and vice versa.

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:

  • The digest was checked against an implementation written independently from the specification text, matching on every intermediate value, not just the final digest.
  • Golden vectors are generated in one repository and verified in the other, so the two implementations are compared against each other rather than against themselves.
  • The security-relevant tests were each confirmed to fail when the code they cover is reverted — a passing suite alone was not treated as evidence.

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

  • Golden digests, intermediates, and error codes match the Connect corpus
  • Wrong chainId, unknown version, malformed types, missing primaryType, and over-policy payloads all reject before approval
  • A caller-supplied params.origin does not affect the digest
  • Network switch, lock, or profile change while the approval is pending rejects rather than signing, at both the RPC and engine layers
  • BLS round trip under the V2 DST, plus both cross-rejection directions
  • Approval rendering: nesting, arrays, oversized values, bidi and control characters, depth and row caps

Companion

dusk-network/connect#35 — typed-data hash v1, golden vectors, and @dusk/connect/bls verification.

The vectors here are copied verbatim from that branch, and src/shared/fixtures/typed-data-v1/SOURCE records the exact commit they came from. If the Connect PR is squash-merged, that commit will not exist on Connect's main, so SOURCE should be repointed at the merge commit before this PR lands.

Not claimed

  • Not EIP-712 compatible with Ethereum tooling
  • Not a substitute for dusk_sendTransaction approval
  • Does not decode contract calldata inside the signing RPC
  • Does not migrate existing application-specific digest schemes

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.
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.
@ichbindas

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and reworked the approval path. Summary for anyone reading the diff after the branch moved.

What changed and why. When this branch was cut, dusk_signTypedData carried its own post-approval check: re-read settings, compare the chain, reject on mismatch. #95 has since landed a general mechanism for exactly that concern — captureApprovalContext / assertApprovalContext under the wallet lifecycle lock — and every other signing path now uses it. Two idioms for one concern in one file is worse than either, so the bespoke check is gone and this uses the shared one.

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 domain.chainId, never the wallet's — the wallet's chain is used only to reject a mismatch and to display and echo it. Sourcing that comparison from the approval context changes where the value comes from, not what is hashed.

One behaviour change. 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.

One gap this surfaced. signTypedData was the only engine entry point without the engine-side context guard — the other twelve had it. It went unnoticed because engineCall is mocked in the RPC tests, so the engine function never ran there, and assertApprovalContext returns early when no context is passed, meaning a missing guard fails open silently rather than erroring. The guard is now in place with two tests covering it, and removing it fails the first.

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.
@ichbindas

ichbindas commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

dusk-network/typed-data now exists and holds the extracted protocol package. That changes what this PR should eventually contain.

What the library replaces here

  • src/shared/typedDataHash.js, the twin implementation
  • src/shared/fixtures/typed-data-v1/, the vendored corpus
  • scripts/check-typed-data-fixture-parity.mjs, which exists only to detect drift between the twin and Connect
  • docs/typed-data-v1.md, which currently points at Connect as the normative source and would point at the library instead

src/shared/typedDataDisplay.js and src/shared/blsDigest.js stay. Approval rendering and wallet key derivation are wallet concerns and do not belong in a shared package.

The reason recorded for the twin has changed

docs/typed-data-v1.md states the wallet ships a twin rather than depending on @dusk/connect, because that would pull Connect's transitive tree into a store-reviewed bundle that signs with user keys. That reasoning applied to Connect. It does not transfer to @dusk/typed-data: the package has no dependencies beyond @noble/hashes and @noble/curves, which are two packages with no further tree, and the BLS module is about 27 KB gzipped and already present in this bundle.

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.

ichbindas and others added 2 commits September 11, 2026 13:14
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.
@HDauven

HDauven commented Sep 15, 2026

Copy link
Copy Markdown
Member

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 0.1.0-rc.0. The old twin encoder and vendored-parity setup therefore do not need to be merged. #114 adds digest-checked full escaped disclosure with fail-closed display limits; bounded previews may still coincide, and protocol v1 remains draft.

This closes only this superseded PR, without merging or deleting its branch. The broader #22 work and separate raw-digest proposal #90 remain open.

@HDauven HDauven closed this Sep 15, 2026
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.

feat(connect): add RPC to sign a raw 32-byte BLS digest

2 participants