diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0482ea3..1592095 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 85cab48..b583208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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]) @@ -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]) @@ -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 diff --git a/README.md b/README.md index 45194d8..f62faf3 100644 --- a/README.md +++ b/README.md @@ -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", { diff --git a/docs/provider-api.md b/docs/provider-api.md index 8fa1c9a..8c6da40 100644 --- a/docs/provider-api.md +++ b/docs/provider-api.md @@ -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. diff --git a/src/shared/typedDataDisplay.js b/src/shared/typedDataDisplay.js index cd92f07..047bea4 100644 --- a/src/shared/typedDataDisplay.js +++ b/src/shared/typedDataDisplay.js @@ -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"; @@ -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); @@ -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) { diff --git a/src/shared/typedDataDisplay.test.js b/src/shared/typedDataDisplay.test.js index 2f1cb78..264360c 100644 --- a/src/shared/typedDataDisplay.test.js +++ b/src/shared/typedDataDisplay.test.js @@ -2,12 +2,102 @@ import { createHash } from "node:crypto"; import { hashTypedDataHex } from "@dusk/typed-data"; import { checkPolicyLimits } from "@dusk/typed-data/policy"; import { describe, expect, it } from "vitest"; +import * as display from "./typedDataDisplay.js"; import { TYPED_DATA_DISPLAY_MAX_STRING_CHARS, flattenTypedMessage, sanitizeStringForDisplay, } from "./typedDataDisplay.js"; +function disclosureInput(message, fields) { + return { + domain: { name: "Disclosure", version: "1", chainId: "dusk:0" }, + origin: "https://dapp.example", + types: { + DuskTypedDataDomain: [ + { name: "name", type: "string" }, { name: "version", type: "string" }, + { name: "chainId", type: "string" }, { name: "verifyingContract", type: "bytes32" }, + ], + Message: fields, Empty: [], + }, + primaryType: "Message", message, + }; +} + +describe("complete typed-data disclosure", () => { + it("exposes omitted array entries, long tails, empty structs and complete bytes without changing signed input", async () => { + const input = disclosureInput({ + amounts: Array.from({ length: 210 }, (_, i) => i), + text: "A".repeat(2048) + " ORIGINAL TAIL", blob: "0X00AB", marker: {}, + }, [ + { name: "amounts", type: "uint64[210]" }, { name: "text", type: "string" }, + { name: "blob", type: "bytes" }, { name: "marker", type: "Empty" }, + ]); + const original = structuredClone(input); + checkPolicyLimits(input); + const digest = hashTypedDataHex(input); + expect((await flattenTypedMessage(input)).rows).toHaveLength(200); + const full = display.prepareTypedDataDisclosure(input, digest); + expect(JSON.parse(full.json)).toEqual(original); + expect(full.input).toEqual(original); + expect(full.json).toContain("ORIGINAL TAIL"); + expect(full.input.message.amounts[209]).toBe(209); + expect(input).toEqual(original); + expect(hashTypedDataHex(full.input)).toBe(digest); + }); + + it("keeps Unicode, line breaks, formatting controls and literal escape spellings distinguishable", () => { + const values = ["é", "e\u0301", "\\u00e9", "Alice", "Ali\u200bce", "👩‍💻", "A\nB\rC\u2028D\u2029E", "x\u061c\u202ey\u202c", ""]; + const serialized = new Set(); + for (const text of values) { + const input = disclosureInput({ text }, [{ name: "text", type: "string" }]); + checkPolicyLimits(input); + const digest = hashTypedDataHex(input); + const full = display.prepareTypedDataDisclosure(input, digest); + expect(full.json).not.toMatch(/[\u007f-\uffff]/); + expect(JSON.parse(full.json)).toEqual(input); + expect(hashTypedDataHex(full.input)).toBe(digest); + serialized.add(full.json); + } + expect(serialized.size).toBe(values.length); + }); + + it("rejects a digest mismatch or a serialization that changes the signing input", () => { + const input = disclosureInput({ text: "Alice" }, [{ name: "text", type: "string" }]); + const digest = hashTypedDataHex(input); + expect(() => display.prepareTypedDataDisclosure({ ...input, origin: "https://other.example" }, digest)).toThrow(/digest/i); + input.message = Object.assign(Object.create({ toJSON: () => ({ text: "Mallory" }) }), input.message); + expect(hashTypedDataHex(input)).toBe(digest); + expect(() => display.prepareTypedDataDisclosure(input, digest)).toThrow(/digest/i); + input.message = {}; + Object.defineProperty(input.message, "text", { value: "Alice", enumerable: false }); + expect(hashTypedDataHex(input)).toBe(digest); + expect(() => display.prepareTypedDataDisclosure(input, digest)).toThrow(); + }); + + it("does not authorize malformed names through the full-view path", () => { + const input = disclosureInput({ "a.b": "value" }, [{ name: "a.b", type: "string" }]); + expect(() => display.prepareTypedDataDisclosure(input, `0x${"00".repeat(32)}`)).toThrow(/field definition/); + }); + + it("supports the string resource floor but refuses a full view exceeding its display budget", () => { + const input = disclosureInput({ text: "A".repeat(65_536) }, [{ name: "text", type: "string" }]); + checkPolicyLimits(input); + expect(display.prepareTypedDataDisclosure(input, hashTypedDataHex(input)).input).toEqual(input); + // Unused schema metadata is not hashed or depth-limited by protocol policy. + // Its compact transport fits policy, but pretty-printing it must stay bounded. + let unused = []; + for (let i = 0; i < 32; i++) unused = [unused]; + for (const count of [1000, 2000]) { + input.types.Unused = Array(count).fill(unused); + checkPolicyLimits(input); + const digest = hashTypedDataHex(input); + // Exercise both final text size and the earlier construction budget. + expect(() => display.prepareTypedDataDisclosure(input, digest)).toThrow(/too large/i); + } + }); +}); + function rowsByPath(rows) { const out = {}; for (const r of rows) out[r.path] = r; @@ -306,6 +396,32 @@ describe("flattenTypedMessage", () => { expect(sanitizeStringForDisplay(plain)).toEqual({ display: plain, flags: [] }); }); + it.each([0x200b, 0x200c, 0x200d, 0x2060, 0xfeff, 0xad])("visibly escapes invisible formatting U+%s without losing it", code => { + const raw = `A${String.fromCodePoint(code)}B`; + expect(sanitizeStringForDisplay(raw)).toEqual({ + display: `A\\u${code.toString(16).padStart(4, "0")}B`, flags: ["invisible_format"], + }); + }); + + it.each([0x0a, 0x0d, 0x2028, 0x2029])("visibly escapes a line separator U+%s instead of fabricating a line", code => { + expect(sanitizeStringForDisplay(`A${String.fromCodePoint(code)}B`)).toEqual({ + display: `A\\u${code.toString(16).padStart(4, "0")}B`, flags: ["line_separator"], + }); + }); + + it("flags non-NFC text without normalizing the original preview or emoji", () => { + expect(sanitizeStringForDisplay("e\u0301")).toEqual({ display: "e\u0301", flags: ["non_nfc"] }); + expect(sanitizeStringForDisplay("é 😀")).toEqual({ display: "é 😀", flags: [] }); + expect(sanitizeStringForDisplay("👩‍💻")).toEqual({ display: "👩\\u200d💻", flags: ["invisible_format"] }); + }); + + it("preserves surrogate pairs at the preview cap and escapes supplementary format controls", () => { + expect(sanitizeStringForDisplay("😀X", 1)).toEqual({ display: "😀", flags: ["truncated"] }); + expect(sanitizeStringForDisplay("\ud800X\udfff")).toEqual({ display: "�X�", flags: ["invalid_surrogate"] }); + expect(sanitizeStringForDisplay("A\u{e0001}B")).toEqual({ display: "A\\udb40\\udc01B", flags: ["invisible_format"] }); + expect(sanitizeStringForDisplay("A\tB")).toEqual({ display: "A�B", flags: ["control_chars"] }); + }); + it("neutralises and flags a control character", async () => { const types = { Note: [{ name: "text", type: "string" }] }; const raw = "a\x07b"; diff --git a/src/ui/notification.app.test.js b/src/ui/notification.app.test.js index 68417d6..00a10af 100644 --- a/src/ui/notification.app.test.js +++ b/src/ui/notification.app.test.js @@ -35,16 +35,16 @@ describe("notification approval UI", () => { expect(source).not.toContain("fnArgs: argsBytes"); }); - it("renders sign_typed_data via the shared flattener, not JSON.stringify of the message", async () => { + it("keeps the typed-data preview alongside a digest-checked full disclosure", async () => { const source = await readFile(path.resolve(process.cwd(), "src", "ui", "notification", "app.js"), "utf8"); const block = source.match(/if \(kindNorm === "sign_typed_data"\) \{([\s\S]*?)\n if \(kindNorm === "watch_asset"\)/); expect(block?.[1]).toBeTruthy(); const body = block[1]; - // Must flatten via the shared display module rather than dumping raw JSON. - expect(source).toContain('import { flattenTypedMessage, sanitizeStringForDisplay } from "../../shared/typedDataDisplay.js"'); - expect(body).toContain("flattenTypedMessage("); + // The bounded preview remains separate from the full escaped request. + expect(body).toContain("prepareTypedDataDisclosure("); + expect(body).toContain("flattenTypedMessage(disclosure.input)"); expect(body).not.toContain("JSON.stringify(params?.message"); expect(body).not.toContain("JSON.stringify(message"); @@ -59,8 +59,8 @@ describe("notification approval UI", () => { expect(body).toContain("digestHex"); expect(body).toContain('decisionButtons("Sign")'); - // Verifying contract row is conditional on presence. - expect(body).toContain("verifyingContract\n"); + // Even an implicit domain default is disclosed. + expect(body).toContain("32 zero bytes (default)"); // A text-safety flag on any row must surface a warning to the user. expect(body).toContain("hasTextSafetyWarning"); diff --git a/src/ui/notification/app.js b/src/ui/notification/app.js index 274f239..ccf52d1 100644 --- a/src/ui/notification/app.js +++ b/src/ui/notification/app.js @@ -1,7 +1,7 @@ import { UI_DISPLAY_DECIMALS, formatLuxShort, safeBigInt } from "../../shared/amount.js"; import { bytesToHex, sha256Hex, toBytes } from "../../shared/bytes.js"; import { TX_KIND } from "../../shared/constants.js"; -import { flattenTypedMessage, sanitizeStringForDisplay } from "../../shared/typedDataDisplay.js"; +import { flattenTypedMessage, prepareTypedDataDisclosure, sanitizeStringForDisplay } from "../../shared/typedDataDisplay.js"; import { h } from "../lib/dom.js"; import { passwordInput, submitOnGasEnter, textInput } from "../components/FormControls.js"; import { truncateMiddle } from "../lib/strings.js"; @@ -566,25 +566,34 @@ export async function renderNotification() { } if (kindNorm === "sign_typed_data") { - const domain = params?.domain && typeof params.domain === "object" ? params.domain : {}; - const domainName = sanitizeStringForDisplay(String(domain?.name ?? "") || "—"); - const domainVersion = sanitizeStringForDisplay(String(domain?.version ?? "") || "—"); - const domainChainId = String(domain?.chainId ?? ""); - const verifyingContract = String(domain?.verifyingContract ?? "").trim(); - const primaryType = String(params?.primaryType ?? ""); const digestHex = String(params?.digestHex ?? ""); - - const { rows, truncated } = await flattenTypedMessage({ - types: params?.types, - primaryType, - message: params?.message, - }); - // Only the flags that indicate deceptive *content* warrant the spoofing - // warning. A value cut at the length cap, or a subtree cut for depth, is a - // display limit rather than something hostile about the characters, and - // lumping them together would train users to ignore the real warning. - const unsafeTextFlags = ["control_chars", "bidi_control", "invalid_surrogate"]; - const hasTextSafetyWarning = [domainName, domainVersion, ...rows].some((row) => + let disclosure; + try { + disclosure = prepareTypedDataDisclosure({ + domain: params?.domain, types: params?.types, + primaryType: params?.primaryType, message: params?.message, origin, + }, digestHex); + } catch { + setApp([ + header, + h("div", { class: "err", role: "alert", text: + "Cannot safely disclose the full signing request. Signing is disabled. Reject this request and ask the site for a smaller or corrected payload." }), + decisionButtonsWithState({ approveText: "Sign", approveDisabled: true }).row, + ]); + return; + } + // Both views use the same digest-checked snapshot, not a second read of params. + const { domain, primaryType } = disclosure.input; + const originPreview = sanitizeStringForDisplay(disclosure.input.origin); + const domainName = sanitizeStringForDisplay(domain.name); + const domainVersion = sanitizeStringForDisplay(domain.version); + const domainChainId = sanitizeStringForDisplay(domain.chainId); + const verifyingContract = domain.verifyingContract; + const { rows, truncated } = await flattenTypedMessage(disclosure.input); + // Text shaping and normalization differences are not necessarily malicious; + // distinguish their review notice from ordinary preview-size limits. + const unsafeTextFlags = ["control_chars", "bidi_control", "invalid_surrogate", "invisible_format", "line_separator", "non_nfc"]; + const hasTextSafetyWarning = [originPreview, domainName, domainVersion, domainChainId, ...rows].some((row) => (row.flags ?? []).some((flag) => unsafeTextFlags.includes(flag)) ); @@ -601,7 +610,7 @@ export async function renderNotification() { setApp( [ - header, + displayRow("Request from", originPreview), h("div", { class: "row" }, [ h("div", { class: "muted", text: "Approve typed data signature" }), ]), @@ -611,19 +620,22 @@ export async function renderNotification() { ]), displayRow("Domain name", domainName), displayRow("Domain version", domainVersion), - h("div", { class: "row" }, [ - h("div", { class: "muted", text: "Chain ID" }), - h("div", { class: "box" }, [h("code", { text: domainChainId || "—" })]), - ]), - verifyingContract - ? h("div", { class: "row" }, [ - h("div", { class: "muted", text: "Verifying contract" }), - h("div", { class: "box" }, [h("code", { text: verifyingContract })]), - ]) - : null, + displayRow("Chain ID", domainChainId), + displayRow("Verifying contract", { display: verifyingContract || "32 zero bytes (default)", flags: [] }), h("div", { class: "row" }, [ h("div", { class: "muted", text: "Primary type" }), - h("div", { class: "box" }, [h("code", { text: primaryType || "—" })]), + h("div", { class: "box" }, [h("code", { text: primaryType })]), + ]), + h("details", { class: "box" }, [ + h("summary", { text: "Full signing request (escaped JSON)" }), + h("p", { class: "muted", text: + "Complete values and declared schema, including bytes and omitted preview content. Non-ASCII characters use JSON \\uXXXX escapes; originals are not normalized. Unused types and extra metadata do not contribute to the digest." }), + h("textarea", { + readonly: true, rows: 12, wrap: "off", spellcheck: false, dir: "ltr", + "aria-label": "Full signing request (escaped JSON)", + style: "width:100%;box-sizing:border-box;font-family:monospace;resize:vertical;unicode-bidi:isolate;", + text: disclosure.json, + }), ]), h("div", { class: "muted", text: "Message fields" }), ...rows.map((row) => displayRow(`${row.path} · ${row.type}`, row)), @@ -637,6 +649,7 @@ export async function renderNotification() { truncated.depthLimited ? "Some fields are nested deeper than this screen displays." : "", + "Open Full signing request (escaped JSON) above to inspect every value.", "The digest below covers the whole message.", ] .filter(Boolean) @@ -653,11 +666,11 @@ export async function renderNotification() { ]), hasTextSafetyWarning ? h("div", { class: "callout warn" }, [ - h("div", { class: "callout-title", text: "Hidden characters detected" }), + h("div", { class: "callout-title", text: "Text needs review" }), h("div", { class: "muted", text: - "One or more domain or message fields contain hidden, non-printable, or directional-override characters. They are shown here replaced with a placeholder. Review carefully before signing.", + "Some origin, domain or message text contains controls, formatting characters, line separators or non-NFC sequences. The preview may use placeholders or escapes. Inspect the originals in Full signing request before signing.", }), ]) : null, diff --git a/tests/e2e/typed-data-preview.spec.js b/tests/e2e/typed-data-preview.spec.js index f45ef4b..cd1f41f 100644 --- a/tests/e2e/typed-data-preview.spec.js +++ b/tests/e2e/typed-data-preview.spec.js @@ -1,7 +1,21 @@ import { createHash } from "node:crypto"; import { hashTypedDataHex } from "@dusk/typed-data"; +import { checkPolicyLimits } from "@dusk/typed-data/policy"; import { test, expect } from "@playwright/test"; +async function refreshPreview(page) { + const input = await page.evaluate(() => { + const { domain, types, primaryType, message } = window.pending.params; + return { domain, types, primaryType, message, origin: window.pending.origin }; + }); + checkPolicyLimits(input); + await page.evaluate(async digest => { + window.pending.params.digestHex = digest; + await window.renderPreview(); + }, hashTypedDataHex(input)); + return input; +} + test.beforeEach(async ({ page }) => { // Isolate the real renderer: only the pending-request transport is stubbed. await page.route("**/typed-data-preview?rid=test", route => route.fulfill({ @@ -25,25 +39,26 @@ test.beforeEach(async ({ page }) => { primaryType: "Message", message: { text: "A".repeat(2048) + " END" }, }, }; - window.browser = { runtime: { sendMessage: async ({ type }) => - type === "DUSK_GET_PENDING" ? window.pending : { ok: true }, - } }; + window.decisions = []; + window.browser = { runtime: { sendMessage: async msg => { + if (msg.type === "DUSK_GET_PENDING") return window.pending; + if (msg.type === "DUSK_PENDING_DECISION") window.decisions.push(msg); + return { ok: true }; + } } }; const { renderNotification } = await import("/src/ui/notification/app.js"); window.renderPreview = renderNotification; - await renderNotification(); }); + await refreshPreview(page); }); test("typed-data preview visibly discloses clipped text, but not an uncut value", async ({ page }) => { const notice = page.getByText("Text truncated; the full value is signed.", { exact: true }); await expect(page.locator('code[title="truncated"]')).toHaveText("A".repeat(2048)); await expect(notice).toBeVisible(); - await expect(page.getByText("Hidden characters detected", { exact: true })).toHaveCount(0); + await expect(page.getByText("Text needs review", { exact: true })).toHaveCount(0); - await page.evaluate(async () => { - window.pending.params.message.text = "A".repeat(2048); - await window.renderPreview(); - }); + await page.evaluate(() => { window.pending.params.message.text = "A".repeat(2048); }); + await refreshPreview(page); await expect(notice).toHaveCount(0); await expect(page.locator('code[title="truncated"]')).toHaveCount(0); }); @@ -52,11 +67,11 @@ test("typed-data byte previews match every accepted hex spelling", async ({ page for (const [type, hex] of [["bytes", "00"], ["bytes32", "ab".repeat(32)], ["bytes", ""]]) { const hash = createHash("sha256").update(Buffer.from(hex, "hex")).digest("hex"); for (const value of [`0x${hex}`, `0X${hex.toUpperCase()}`, hex]) { - await page.evaluate(async ({ type, value }) => { - window.pending.params.types = { Message: [{ name: "data", type }] }; + await page.evaluate(({ type, value }) => { + window.pending.params.types.Message = [{ name: "data", type }]; window.pending.params.message = { data: value }; - await window.renderPreview(); }, { type, value }); + await refreshPreview(page); await expect(page.getByText( `${hex.length / 2} bytes · sha256=${hash.slice(0, 12)}…${hash.slice(-8)}`, { exact: true } @@ -102,7 +117,7 @@ test("typed-data arrays of empty structs are visible or explicitly row-limited", await expect(label).toBeVisible(); await expect(label.locator("..").locator("code")).toHaveText("{}"); } - const notice = page.getByText("1 more field(s) not shown. The digest below covers the whole message.", { exact: true }); + const notice = page.getByText("1 more field(s) not shown. Open Full signing request (escaped JSON) above to inspect every value. The digest below covers the whole message.", { exact: true }); await expect(notice).toHaveCount(length > 200 ? 1 : 0); if (length > 200) await expect(notice).toBeVisible(); await expect(page.getByRole("button", { name: "Sign", exact: true })).toBeEnabled(); @@ -110,6 +125,84 @@ test("typed-data arrays of empty structs are visible or explicitly row-limited", } }); +test("a keyboard-accessible full view includes every omitted value, bytes and declared schema", async ({ page }) => { + await page.evaluate(() => { + const p = window.pending.params; + p.origin = "https://spoofed.example"; // Must not replace the signer's origin. + p.types.Empty = []; + p.types.Message = [ + { name: "amounts", type: "uint64[210]" }, { name: "note", type: "string" }, + { name: "blob", type: "bytes" }, { name: "marker", type: "Empty" }, + ]; + p.message = { amounts: Array(210).fill(1), note: "A".repeat(2048) + " ORIGINAL TAIL", blob: "0X00AB", marker: {} }; + }); + const input = await refreshPreview(page); + const summary = page.getByText("Full signing request (escaped JSON)", { exact: true }); + const fullView = page.getByRole("textbox", { name: "Full signing request (escaped JSON)" }); + await expect(summary).toBeVisible(); + await expect(page.locator("textarea")).toBeHidden(); + await summary.focus(); + await page.keyboard.press("Enter"); + await expect(fullView).toBeVisible(); + await expect(fullView).toHaveJSProperty("readOnly", true); + const originalFull = await fullView.inputValue(); + expect(JSON.parse(originalFull)).toEqual(input); + expect(originalFull).not.toContain("spoofed.example"); + await expect(page.getByText("32 zero bytes (default)", { exact: true })).toBeVisible(); + await fullView.focus(); + await page.keyboard.press("Control+End"); + // Native keyboard scrolling completes asynchronously in Chromium. + await expect.poll(() => fullView.evaluate(el => el.scrollHeight - el.clientHeight - el.scrollTop)).toBeLessThanOrEqual(1); + const labels = page.getByText(/^amounts\[\d+\] · uint64$/); + expect(await labels.count()).toBe(200); + const originalRows = await labels.evaluateAll(els => els.map(el => el.parentElement.textContent)); + + // Same bounded preview, but a different full request and digest. + await page.evaluate(() => { window.pending.params.message.amounts[209] = 2; }); + const changed = await refreshPreview(page); + await summary.click(); + expect(await labels.evaluateAll(els => els.map(el => el.parentElement.textContent))).toEqual(originalRows); + expect(await fullView.inputValue()).not.toBe(originalFull); + expect(JSON.parse(await fullView.inputValue())).toEqual(changed); + expect(hashTypedDataHex(changed)).not.toBe(hashTypedDataHex(input)); + await expect(page.getByRole("button", { name: "Sign", exact: true })).toBeEnabled(); +}); + +test("the full view preserves string tails and Unicode originals, not normalized or injected markup", async ({ page }) => { + for (const text of ["A".repeat(2048) + " X", "A".repeat(2048) + " Y", "é", "e\u0301", "\\u00e9", "\namount : uint64 = 5\u202e"]) { + await page.evaluate(text => { window.pending.params.message.text = text; }, text); + const input = await refreshPreview(page); + await page.getByText("Full signing request (escaped JSON)", { exact: true }).click(); + const full = await page.getByRole("textbox", { name: "Full signing request (escaped JSON)" }).inputValue(); + expect(full).not.toMatch(/[\u007f-\uffff]/); + expect(JSON.parse(full)).toEqual(input); + expect(hashTypedDataHex(JSON.parse(full))).toBe(hashTypedDataHex(input)); + await expect(page.locator("#app img")).toHaveCount(0); + await expect(page.getByText("text · string", { exact: true })).toHaveCount(1); + await expect(page.getByRole("button", { name: "Sign", exact: true })).toBeEnabled(); + } +}); + +for (const failure of ["digest mismatch", "display budget"]) { + test(`${failure} prevents approval but leaves rejection available`, async ({ page }) => { + await page.evaluate(async failure => { + if (failure === "digest mismatch") { + window.pending.params.message.text = "Changed after hashing"; + } else { + let unused = []; + for (let i = 0; i < 32; i++) unused = [unused]; + window.pending.params.types.Unused = Array(2000).fill(unused); + } + await window.renderPreview(); + }, failure); + await expect(page.getByRole("alert")).toContainText("Cannot safely disclose the full signing request"); + await expect(page.getByRole("button", { name: "Sign", exact: true })).toBeDisabled(); + await expect(page.locator("textarea")).toHaveCount(0); + await page.getByRole("button", { name: "Reject", exact: true }).click(); + expect(await page.evaluate(() => window.decisions.map(d => d.decision))).toEqual(["reject"]); + }); +} + for (const [section, field] of [["domain", "name"], ["domain", "version"], ["message", "text"]]) { test(`typed-data ${section} ${field} uses display safeguards without changing the digest`, async ({ page }) => { const input = { @@ -126,7 +219,7 @@ for (const [section, field] of [["domain", "name"], ["domain", "version"], ["mes }, primaryType: "Message", message: { text: "Safe message" }, }; - const warning = page.getByText("Hidden characters detected", { exact: true }); + const warning = page.getByText("Text needs review", { exact: true }); const notice = page.getByText("Text truncated; the full value is signed.", { exact: true }); const label = section === "domain" ? `Domain ${field}` : `${field} · string`; const valueRow = page.getByText(label, { exact: true }).locator("..").locator("code"); @@ -135,6 +228,10 @@ for (const [section, field] of [["domain", "name"], ["domain", "version"], ["mes ["x\u0007\u202ey\u202c", "x��y�", true, false], ["A".repeat(2048) + " END", "A".repeat(2048), false, true], ["A".repeat(2048), "A".repeat(2048), false, false], + ["Ali\u200bce", "Ali\\u200bce", true, false], + ["👩‍💻", "👩\\u200d💻", true, false], + ["A\nB\rC\u2028D\u2029E", "A\\u000aB\\u000dC\\u2028D\\u2029E", true, false], + ["e\u0301", "e\u0301", true, false], ]) { input[section][field] = raw; const digestHex = hashTypedDataHex(input);