Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,31 @@ jobs:

- name: Build Firefox extension
run: npm run build:firefox

typed-data-renderer:
# The minimal core runner cannot install Chromium's system libraries.
runs-on: ubuntu-24.04
timeout-minutes: 10
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository) }}
env:
CI: true
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
persist-credentials: false

- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: "24"
cache: "npm"

- name: Install dependencies
run: npm ci

- name: Run typed-data approval renderer tests
run: |
npx --no-install playwright install --with-deps chromium
npm run e2e -- typed-data-preview.spec.js --workers=1
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht

### Added

- Added a full escaped typed-data request view alongside the bounded approval preview. ([#113])
- Displayed the installed extension version in Settings. ([#80])
- Added `dusk_signTypedData`, letting dApps request a signature over structured data the wallet renders — domain, primary type, and bounded, potentially truncated message previews with declared field types — instead of an opaque digest. The wallet injects the requesting origin into the digest and echoes it in the result. ([#22])

Expand All @@ -25,6 +26,8 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht

### Fixed

- Disabled typed-data approval when complete disclosure cannot match the pending digest within display limits. ([#113])
- Flagged typed-data formatting controls, line separators and non-NFC text without normalizing signed values. ([#113])
- Displayed empty typed-data structs with their paths and types, including array elements and row-limit disclosure. ([#22])
- Replaced Arabic Letter Mark in typed-data domain and message previews with a visible placeholder and warning. ([#22])
- Counted completed typed-data signing requests as auto-lock activity. ([#22])
Expand All @@ -41,6 +44,7 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
- Generated fresh discovery UUIDv4 values per page/provider, separate from the stable bridge routing identifier. ([dusk-network/connect#42](https://github.com/dusk-network/connect/issues/42))

[#22]: https://github.com/dusk-network/wallet/issues/22
[#113]: https://github.com/dusk-network/wallet/issues/113

## [0.3.0] - 2026-06-23

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,11 @@ wallet.on("chainChanged", console.log);
`dusk_signTypedData` signs structured, wallet-rendered data rather than an opaque
digest — the Dusk analogue of `eth_signTypedData_v4`, not of `eth_sign`. The approval
screen shows the domain, primary type, message previews and digest. Previews may be
truncated; fully inspectable disclosure is tracked in [#113](https://github.com/dusk-network/wallet/issues/113).
truncated; **Full signing request (escaped JSON)** exposes the complete request
without normalizing its values. Sign is disabled if the full disclosure cannot
match the pending digest within display limits. See the [disclosure behavior and
resource limits](docs/provider-api.md#dusk_signtypeddata) and
[#113](https://github.com/dusk-network/wallet/issues/113).

```js
const result = await wallet.request("dusk_signTypedData", {
Expand Down
31 changes: 25 additions & 6 deletions docs/provider-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,13 +457,32 @@ The result:

Field names must match `/^[A-Za-z_][A-Za-z0-9_]*$/`. String values must be
well-formed Unicode: unpaired UTF-16 surrogates are rejected, not replaced with
U+FFFD. Valid Unicode is hashed without normalization. Approval previews replace
hidden control and Unicode `Bidi_Control` characters (including U+061C Arabic
Letter Mark) in domain and message strings with visible placeholders and a warning.
Clipping is disclosed; neither safeguard changes the original signed value.
U+FFFD. Valid Unicode is hashed without normalization. The compact approval
preview replaces control and Unicode `Bidi_Control` characters, visibly escapes
Unicode formatting controls and line separators, and flags non-NFC sequences.
These are review notices, not claims that legitimate shaping or emoji are malicious.
Empty structs are shown as `{}` with their paths and declared types, including
array elements; they count toward the same disclosed row limit. Byte previews
describe the decoded hex bytes, including uppercase-prefixed and prefixless input.
array elements. The preview remains bounded to 200 rows, depth 8 and 2048 source
code points per string; clipping is disclosed. Byte previews summarize decoded
hex bytes, including uppercase-prefixed and prefixless input.

**Full signing request (escaped JSON)** expands a read-only, keyboard-scrollable
view of the complete domain, schema, message and wallet-injected origin, including
omitted preview values and original bytes. Non-ASCII characters use `\uXXXX`
escapes (surrogate pairs for supplementary characters); parsing the JSON recovers
the original strings without normalization. The implicit `verifyingContract`
default is disclosed as 32 zero bytes. Unused types and extra metadata do not
contribute to the digest.

Both views use a snapshot whose digest is checked by the shared library against
the pending signing digest. If serialization fails, that digest differs, the
approximate early construction-work budget is exceeded, or the final escaped
text exceeds the strict 2 MiB limit (2,097,152 ASCII characters), Sign is disabled
with an explanation and Reject remains available. The early counter is not exact
output-size accounting: serialization and escaping can temporarily construct text
larger than the final limit before it is rejected. Neither check is a peak-memory
guarantee. These are local signer display limits, not new hash/verification
validity rules. Neither view is fed back into signing.

The wallet advertises supported versions as an array via `dusk_getCapabilities().features.signTypedDataVersions` (currently `[1]`), not a single scalar, so a caller can pick the highest version it understands and detect when a version it relies on is deprecated.

Expand Down
137 changes: 61 additions & 76 deletions src/shared/typedDataDisplay.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
/**
* Display-only flattening of a `dusk_signTypedData` message for the approval
* popup. This module never validates signing-hash correctness (that lives in
* @dusk/typed-data) and never throws on malformed input - the whole `types`
* table and `message` value come straight from the requesting dApp, and a
* thrown error here would blank the approval screen instead of showing it.
*
* Design note: nested typed-data values are not rendered with
* JSON.stringify. Pretty-printed JSON blows up vertical space in a small
* popup and encourages scrolling past content without reading it - the exact
* failure this screen exists to prevent. Instead every leaf value is
* flattened to one row keyed by a dotted/bracketed path, mirroring how
* signMessagePreview.js presents untrusted bytes safely rather than raw.
* Wallet-owned typed-data disclosure, never signing input. A bounded leaf
* preview accompanies a lossless escaped JSON view, checked against the pending
* digest by @dusk/typed-data. Approval must fail closed if preparation throws.
* The flattener remains tolerant of missing/wrong-typed values; its dotted paths
* are unambiguous only for schema names accepted by the shared validator.
*/
import { hashTypedDataHex } from "@dusk/typed-data";
import { isUnsafeC0ControlCodePoint } from "./signMessagePreview.js";
import { hexToBytes, sha256Hex } from "./bytes.js";

Expand All @@ -26,13 +20,40 @@ const ARRAY_FIXED = /^(.+)\[([1-9][0-9]*)\]$/;
const RESERVED_FIELD_NAMES = new Set(["__proto__", "constructor", "prototype"]);
const REPLACEMENT_CHAR = "�";

// U+202A-U+202E (LRE/RLE/PDF/LRO/RLO), U+2066-U+2069 (LRI/RLI/FSI/PDI),
// U+200E/U+200F (LRM/RLM), U+061C (ALM). A right-to-left override can make
// "send 1 DUSK" paint as something else entirely on a signing screen, so
// these are always neutralised, never passed through raw.
const BIDI_CONTROL_CODEPOINTS = new Set([
0x061c, 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, 0x2066, 0x2067, 0x2068, 0x2069, 0x200e, 0x200f,
]);
const BIDI_CONTROL = /\p{Bidi_Control}/u;
const FORMAT_CONTROL = /\p{Cf}/u;
const LINE_SEPARATOR = /[\n\r\u2028\u2029]/u;
// ponytail: one 2 MiB text view; use pagination if legitimate requests exceed it.
const MAX_DISCLOSURE_CHARS = 2 * 1024 * 1024;

function escapeCodeUnit(char) {
return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`;
}

/**
* Prepare a JSON snapshot and its full ASCII-only disclosure. JSON escapes
* preserve original Unicode, including combining sequences and literal escapes.
* Throws on size/serialization/validation failure or a mismatch with the digest
* already computed by the signer. Callers must not enable signing on failure.
*/
export function prepareTypedDataDisclosure(input, digestHex) {
let budget = MAX_DISCLOSURE_CHARS;
const parents = [];
const json = JSON.stringify(input, function (key, value) {
// Approximate construction-work guard, including unused schema metadata.
// It undercounts JSON syntax, closing indentation, numbers and escaping.
// Not a strict size/memory bound: keep the final escaped-text length check.
while (parents.length && parents.at(-1) !== this) parents.pop();
budget -= key.length + (typeof value === "string" ? value.length : 1) + 2 * parents.length;
if (budget < 0) throw new Error("Signing request is too large to disclose in full");
if (value && typeof value === "object") parents.push(value);
return value;
}, 2).replace(/[\u007f-\uffff]/g, escapeCodeUnit);
if (json.length > MAX_DISCLOSURE_CHARS) throw new Error("Signing request is too large to disclose in full");
const snapshot = JSON.parse(json);
if (hashTypedDataHex(snapshot) !== digestHex) throw new Error("Signing request does not match its digest");
return { json, input: snapshot };
}

function isC1ControlCodePoint(code) {
return code === 0x7f || (code >= 0x80 && code <= 0x9f);
Expand All @@ -55,71 +76,35 @@ function makeRow(path, type, display, flags) {
}

/**
* Neutralise and flag anything in a string leaf that a signing screen must
* not render raw: control characters, bidi overrides, and lone (unpaired)
* UTF-16 surrogates. Unsafe code units are replaced with U+FFFD rather than
* dropped, so the displayed length still roughly tracks the source and the
* substitution itself is visible to the user.
* Bound a readable preview by source code points without splitting pairs.
* Replace unsafe controls and visibly escape formatting/line separators.
* Non-NFC sequences are flagged, never normalized. Originals remain available
* in the full JSON view; these display substitutions must never be signed.
*/
export function sanitizeStringForDisplay(raw, maxChars = TYPED_DATA_DISPLAY_MAX_STRING_CHARS) {
const flags = [];
let hasControl = false;
let hasBidi = false;
let hasInvalidSurrogate = false;

const flags = new Set();
const out = [];
let i = 0;
while (i < raw.length) {
const code = raw.charCodeAt(i);

if (code >= 0xd800 && code <= 0xdbff) {
const next = i + 1 < raw.length ? raw.charCodeAt(i + 1) : 0;
if (next >= 0xdc00 && next <= 0xdfff) {
out.push(raw.slice(i, i + 2));
i += 2;
continue;
}
hasInvalidSurrogate = true;
out.push(REPLACEMENT_CHAR);
i += 1;
continue;
}
if (code >= 0xdc00 && code <= 0xdfff) {
hasInvalidSurrogate = true;
for (const char of raw) {
const code = char.codePointAt(0);
if (code >= 0xd800 && code <= 0xdfff) {
flags.add("invalid_surrogate");
out.push(REPLACEMENT_CHAR);
i += 1;
continue;
}

if (BIDI_CONTROL_CODEPOINTS.has(code)) {
hasBidi = true;
} else if (BIDI_CONTROL.test(char)) {
flags.add("bidi_control");
out.push(REPLACEMENT_CHAR);
i += 1;
continue;
}

if (isUnsafeC0ControlCodePoint(code) || isC1ControlCodePoint(code)) {
hasControl = true;
} else if (FORMAT_CONTROL.test(char) || LINE_SEPARATOR.test(char)) {
flags.add(LINE_SEPARATOR.test(char) ? "line_separator" : "invisible_format");
out.push(char.replace(/[\s\S]/g, escapeCodeUnit));
} else if (code === 0x09 || isUnsafeC0ControlCodePoint(code) || isC1ControlCodePoint(code)) {
flags.add("control_chars");
out.push(REPLACEMENT_CHAR);
i += 1;
continue;
} else {
out.push(char);
}

out.push(raw[i]);
i += 1;
}

if (hasControl) flags.push("control_chars");
if (hasBidi) flags.push("bidi_control");
if (hasInvalidSurrogate) flags.push("invalid_surrogate");

let chars = out;
if (chars.length > maxChars) {
chars = chars.slice(0, maxChars);
flags.push("truncated");
}

return { display: chars.join(""), flags };
if (raw.normalize("NFC") !== raw) flags.add("non_nfc");
if (out.length > maxChars) flags.add("truncated");
return { display: out.slice(0, maxChars).join(""), flags: [...flags] };
}

function describeStringLeaf(value, type, path, limits) {
Expand Down
Loading
Loading