diff --git a/app/static/wallet.js b/app/static/wallet.js index 3d488805..d9e8ff50 100644 --- a/app/static/wallet.js +++ b/app/static/wallet.js @@ -16,17 +16,37 @@ function hexToBytes(hex) { return bytes; } +function asciiJson(value) { + return JSON.stringify(value).replace(/[\u0080-\uFFFF]/g, (char) => { + return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`; + }); +} + +function compareCodePoints(left, right) { + const leftPoints = Array.from(left); + const rightPoints = Array.from(right); + const limit = Math.min(leftPoints.length, rightPoints.length); + for (let index = 0; index < limit; index += 1) { + const leftPoint = leftPoints[index].codePointAt(0); + const rightPoint = rightPoints[index].codePointAt(0); + if (leftPoint !== rightPoint) { + return leftPoint - rightPoint; + } + } + return leftPoints.length - rightPoints.length; +} + function stableJson(value) { if (Array.isArray(value)) { return `[${value.map(stableJson).join(",")}]`; } if (value && typeof value === "object") { return `{${Object.keys(value) - .sort() - .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .sort(compareCodePoints) + .map((key) => `${asciiJson(key)}:${stableJson(value[key])}`) .join(",")}}`; } - return JSON.stringify(value); + return asciiJson(value); } async function sha256Hex(bytes) { diff --git a/tests/test_wallet_api.py b/tests/test_wallet_api.py index 7ba90675..3802413d 100644 --- a/tests/test_wallet_api.py +++ b/tests/test_wallet_api.py @@ -1,5 +1,8 @@ from __future__ import annotations +import json +import shutil +import subprocess from pathlib import Path from urllib.parse import parse_qs, urlparse @@ -660,12 +663,62 @@ def test_transfer_action_clears_private_key_after_submit_attempt() -> None: transfer_start = wallet_js.find("function setupTransfer()") github_actions_start = wallet_js.find("function setupGithubActions()") transfer_block = wallet_js[transfer_start:github_actions_start] + set_result = 'setText("[data-transfer-result]", transfer);' + set_error = 'setText("[data-transfer-result]", error.message);' + finally_block = "} finally {" + clear_private_key = "clearPrivateKeyField(form);" assert 'form[data-action="submit-transfer"]' in transfer_block - assert 'setText("[data-transfer-result]", transfer);' in transfer_block - assert 'setText("[data-transfer-result]", error.message);' in transfer_block - assert "} finally {" in transfer_block - assert "clearPrivateKeyField(form);" in transfer_block + idx_set_result = transfer_block.find(set_result) + idx_set_error = transfer_block.find(set_error, idx_set_result) + idx_finally = transfer_block.find(finally_block, idx_set_error) + idx_clear_private_key = transfer_block.find(clear_private_key, idx_finally) + + assert -1 not in { + idx_set_result, + idx_set_error, + idx_finally, + idx_clear_private_key, + } + assert idx_set_result < idx_set_error < idx_finally < idx_clear_private_key + + +def test_wallet_js_canonicalizes_unicode_like_server() -> None: + node = shutil.which("node") + if node is None: + pytest.skip("node is required to execute wallet.js canonicalization") + wallet_js = Path("app/static/wallet.js").read_text(encoding="utf-8") + payload = { + "type": "mrwk_transfer_v1", + "from_address": "mrwk1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "to_address": "mrwk1bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "amount_microunits": 1_500_000, + "nonce": 7, + "memo": "café 😀", + "\ue000": 1, + "😀": 2, + } + node_script = f""" +const vm = require("vm"); +const context = {{ + console, + TextEncoder, + Uint8Array, + crypto: {{subtle: {{}}}}, + document: {{querySelector: () => null, getElementById: () => null}}, +}}; +vm.createContext(context); +vm.runInContext({json.dumps(wallet_js)}, context); +console.log(vm.runInContext(`stableJson({json.dumps(payload)})`, context)); +""" + result = subprocess.run( + [node, "-e", node_script], + check=True, + capture_output=True, + text=True, + ) + + assert result.stdout.strip() == canonical_wallet_json(payload) def test_reject_self_transfer(sqlite_url: str) -> None: