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);