From 840f459f2a5becb3e17e3cc22f00e37103123806 Mon Sep 17 00:00:00 2001 From: sol Date: Mon, 7 Sep 2026 15:35:45 -0400 Subject: [PATCH 1/2] feat/message_signing --- CHANGELOG.md | 36 +++++ README.md | 53 ++++++- package.json | 2 +- src/assets/sign-message.svg | 5 + .../siteIntegrationLayout.js | 116 +++++++++++++- src/layouts/tabsLayout/tabsLayout.js | 3 + src/manifest.json | 2 +- src/pages/settings/settings/settings.js | 6 + .../settings/sign-message/sign-message.js | 150 ++++++++++++++++++ src/sections/Background/index.js | 66 +++++++- src/sections/Background/requests.js | 44 +++++ src/sections/Inpage/index.js | 9 ++ src/sections/Popup/Popup.scss | 62 ++++++++ src/services/wallet/signMessage.js | 96 +++++++++++ utils/dapp-test.js | 38 +++++ utils/dev-harness.js | 9 +- 16 files changed, 676 insertions(+), 21 deletions(-) create mode 100644 src/assets/sign-message.svg create mode 100644 src/pages/settings/sign-message/sign-message.js create mode 100644 src/services/wallet/signMessage.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a4c547b..14b9cfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## 0.3.1 + +### Message signing + +Desktop Syrius signs messages for a paired dApp over WalletConnect +(`znn_sign` in `lib/blocs/wallet_connect/chains/nom_service.dart`). The +extension has neither WalletConnect nor a way to sign anything that is not an +account block, so the same capability arrives here over the transport it +already has. + +- **`zenon.signMessage(message)`** on the injected provider, and `znn_sign` for + anything speaking the desktop method name — the bare-string `params` desktop + sends is accepted alongside this extension's `{message}`. It resolves to + `{message, address, publicKey, signature}`, the last two hex, which is the + pair desktop answers with. Restricted to connected origins and prompted every + time, like every other signature. +- **An approval screen** that shows the message verbatim, wrapped and + unescaped, with the address that will sign it. Nothing is broadcast and no + plasma is generated, so it settles as fast as an Ed25519 signature. +- **Settings → Sign message**, for proving an address to something that cannot + ask the wallet itself — a forum post, a support ticket, an exchange's + ownership form. Type the message, copy back the public key and the signature. +- Two deliberate differences from desktop, both in + `services/wallet/signMessage.js`: the message is encoded as **UTF-8** rather + than desktop's UTF-16 code units narrowed to bytes (identical for ASCII), and + a message whose encoding is **exactly 32 bytes** is refused. 32 bytes is the + size of an account block hash, and `BlockUtils._getTransactionSignature` + signs exactly those bytes — so raw signing at that one length would let a + site have a transfer signed by calling it a message. Prefixing the message + would close it too, at the cost of every signature being unverifiable by + anything written for desktop. +- `utils/dapp-test.js` drives the new method end to end and checks the shape of + what comes back. + ## 0.3.0 Everything below is the difference between this tree and @@ -171,3 +205,5 @@ explorer settings. Sentinels (excluded by request), Accelerator-Z, P2P/HTLC swaps and WalletConnect — desktop-shaped features that do not fit a 360px popup. +(WalletConnect's `znn_sign` is covered since, over this extension's own +transport; see Unreleased.) diff --git a/README.md b/README.md index cca6e89..f4207c4 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ through `znn-ts-sdk`) and never leave the machine. The extension talks to a Zenon node of your choosing over a websocket, and to web pages through an injected provider that cannot do anything without being asked first. -Current version: **0.3.0**, Manifest V3. What changed against the published +Current version: **0.3.1**, Manifest V3. What changed against the published `MichZNN/syrius-extension` build is in [CHANGELOG.md](CHANGELOG.md); the working notes behind it are in [REFACTOR.md](REFACTOR.md). @@ -29,6 +29,9 @@ notes behind it are in [REFACTOR.md](REFACTOR.md). identifier the wallet signs for (detected from the node it is connected to). - **dApp bridge** — a `window.zenon` provider with per-origin permissions and a Connected Sites screen. +- **Message signing** — sign a message by hand under Settings, or answer a + connected site's `znn_sign` request from the approval window. Byte-compatible + with desktop Syrius, so the same verifier accepts both. - **Settings** — auto-lock timer, auto-receive, explorer choice, address labels, backup phrase export. @@ -106,8 +109,9 @@ written to `.dev-harness/wallet.json` on first run — edit that file to point t harness at a different mnemonic, node, chain or address index. `node utils/dapp-test.js` drives a real web page against the injected provider -end to end — connect prompt, approval, and reconnecting without a second prompt -— against the Chrome the harness is already running. +end to end — connect prompt, approval, reconnecting without a second prompt, and +a message signed through the approval window — against the Chrome the harness is +already running. The auto-unlock only exists in builds made by the harness: it needs `SYRIUS_DEV_WALLET=true`, which nothing else sets, it refuses to run in a @@ -122,7 +126,7 @@ tag, and fails the tag if it does not match `version` in `src/manifest.json`. Bump `src/manifest.json` and `package.json` together, then: ```bash -git tag v0.3.0 && git push origin v0.3.0 +git tag v0.3.1 && git push origin v0.3.1 ``` The extension ID is derived from the signing key. Set a `CRX_PRIVATE_KEY` repo @@ -163,6 +167,12 @@ const { hash } = await zenon.sendTransaction({ // anything is signed. await zenon.sendAccountBlock(block); +// A signature over a message, for a login challenge or a proof of ownership. +// Prompted every time; nothing is broadcast and nothing is spent. +const { publicKey, signature } = await zenon.signMessage( + `Sign in to example.com at ${new Date().toISOString()}` +); + zenon.on('accountsChanged', (accounts) => {}); zenon.on('chainChanged', (chainId) => {}); zenon.on('nodeChanged', (nodeUrl) => {}); @@ -171,7 +181,37 @@ await zenon.disconnect(); ``` Errors follow EIP-1193 numbering: `4001` the person declined, `4100` the origin -is not connected, `4200` unknown method, `4900` the wallet is locked. +is not connected, `4200` unknown method, `4900` the wallet is locked, `-32602` +the parameters were malformed. + +### Verifying a signature + +`signMessage` is desktop Syrius' `znn_sign` under another name — it signs the +message bytes directly with the account's Ed25519 key and answers with the +signature and public key as hex, so one verifier covers both wallets. The +`address` field is a convenience — it is derived from the same public key, and +a verifier that cares should re-derive it rather than take it on trust: + +```js +const bytes = (hex) => Uint8Array.from(hex.match(/../g), (b) => parseInt(b, 16)); + +const ok = await crypto.subtle.verify( + 'Ed25519', + await crypto.subtle.importKey('raw', bytes(publicKey), 'Ed25519', false, ['verify']), + bytes(signature), + new TextEncoder().encode(message) +); +``` + +Two things worth knowing: + +- The message is encoded as **UTF-8**. Desktop passes UTF-16 code units narrowed + to bytes, which agrees for ASCII — what a login challenge is made of — and + differs for anything else. +- A message whose UTF-8 encoding is **exactly 32 bytes** is refused. That is the + size of an account block hash, and signing raw bytes of that length would let + a site have a transaction signed by calling it a message. Pad the challenge to + any other length. The flat `window.postMessage({method: 'znn.requestWalletAccess'})` protocol the 2023 build used is still relayed, so sites written against it keep working. @@ -190,6 +230,9 @@ The flat `window.postMessage({method: 'znn.requestWalletAccess'})` protocol the the node URL. It is never permission to move anything: signing and sending are prompted every time, and connected origins can be revoked under Settings → Connected sites. +- Signing a message is prompted every time too, and the message is shown + verbatim before the key touches it. It always signs as the address the person + has selected — a site cannot choose which one answers. ## License diff --git a/package.json b/package.json index 3680f19..2286294 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "syrius-extension", - "version": "0.3.0", + "version": "0.3.1", "description": "Syrius extension provides a non-custodial wallet solution for Zenon - Network of Momentum that can run in the browser.", "license": "MIT", "repository": { diff --git a/src/assets/sign-message.svg b/src/assets/sign-message.svg new file mode 100644 index 0000000..f66d2f9 --- /dev/null +++ b/src/assets/sign-message.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/layouts/siteIntegrationLayout/siteIntegrationLayout.js b/src/layouts/siteIntegrationLayout/siteIntegrationLayout.js index dbe64ce..3f63a01 100644 --- a/src/layouts/siteIntegrationLayout/siteIntegrationLayout.js +++ b/src/layouts/siteIntegrationLayout/siteIntegrationLayout.js @@ -6,6 +6,7 @@ import { Primitives, Zenon, utils as sdkUtils } from 'znn-ts-sdk'; import useAccount from '../../services/hooks/useAccount'; import useBlockSender from '../../services/hooks/useBlockSender'; import vault from '../../services/wallet/vault'; +import { signMessage } from '../../services/wallet/signMessage'; import { sendInternal } from '../../services/utils/messaging'; import { formatAmount, @@ -70,6 +71,7 @@ const SiteIntegrationLayout = () => { const [request, setRequest] = useState(undefined); const [preview, setPreview] = useState(null); const [isBusy, setIsBusy] = useState(false); + const [isWaitingForMore, setIsWaitingForMore] = useState(false); // A locked wallet cannot answer anything. The password screen is told where // to come back to so the request is not lost. @@ -82,18 +84,48 @@ const SiteIntegrationLayout = () => { } }, [isUnlocked, navigate]); + // How long to hold the window open on an empty queue before closing it. + // + // A site almost never asks for one thing. Connecting is the prelude to + // whatever it actually wanted — a signature, a block — and it sends that the + // instant the connect is answered, because being answered is what it was + // waiting for. Closing on the spot meant the second request always arrived to + // a window that had already gone: a visible flash as another one opened, and, + // until the close handler learned which requests were its own, an outright + // rejection of a prompt nobody had seen. + // + // So the window waits a beat and looks again. Long enough to cover the round + // trip out to the page and back, short enough that a genuinely finished queue + // does not leave an empty window sitting there. + const CLOSE_GRACE_MS = 1200; + const loadNext = useCallback(async () => { try { const next = await sendInternal('approvals.next'); - setRequest(next || null); + if (next) { + setRequest(next); + return next; + } + setRequest(null); + setIsWaitingForMore(true); + await new Promise((resolve) => { + setTimeout(resolve, CLOSE_GRACE_MS); + }); + + const late = await sendInternal('approvals.next'); + setIsWaitingForMore(false); + + if (late) { + setRequest(late); + return late; + } // Nothing left to answer means this window was only ever open for the // queue, and the queue is empty. - if (!next) { - window.close(); - } - return next; + window.close(); + return null; } catch (err) { + setIsWaitingForMore(false); setRequest(null); return null; } @@ -205,6 +237,33 @@ const SiteIntegrationLayout = () => { } }; + // + // Sign a message + // + // The only approval here that does not touch the network: no plasma, no + // block, nothing to broadcast. It is over as fast as an Ed25519 signature, + // and the site gets the answer the moment the button is pressed. + // + const approveSignMessage = async () => { + setIsBusy(true); + + try { + const signed = await signMessage(request.params.message); + + await finish(request.id, signed); + notify.success('Message signed'); + } catch (err) { + notify.error(err); + await sendInternal('approvals.reject', { + id: request.id, + error: { code: -32603, message: readableError(err) }, + }); + await loadNext(); + } finally { + setIsBusy(false); + } + }; + // // Sign and send an arbitrary block // @@ -243,7 +302,12 @@ const SiteIntegrationLayout = () => { if (!request) { return (
-

Nothing to approve.

+ {/* The queue is empty, but a site that has just been answered usually + has one more thing to ask. Saying so beats flashing "Nothing to + approve" at somebody for a second on the way to the next prompt. */} +

+ {isWaitingForMore ? 'Waiting for the site…' : 'Nothing to approve.'} +

); } @@ -399,6 +463,46 @@ const SiteIntegrationLayout = () => { )} + {request.type === 'signMessage' && ( + <> +
+

Sign this message?

+

+ A signature proves this address is yours. It moves nothing, costs + no plasma and is never published — but only sign what you can + read, and only for a site you meant to sign in to. +

+ + {/* Verbatim, wrapped, and never interpreted: the point of this + panel is that what gets signed is what is on screen. */} +
{request.params.message}
+ +
+
Signing as
+
{truncateAddress(address, 10, 6)}
+
+
+ +
+ + +
+ + )} + {request.type === 'signAndSendBlock' && ( <>
diff --git a/src/layouts/tabsLayout/tabsLayout.js b/src/layouts/tabsLayout/tabsLayout.js index cfed722..75d0bde 100644 --- a/src/layouts/tabsLayout/tabsLayout.js +++ b/src/layouts/tabsLayout/tabsLayout.js @@ -18,6 +18,7 @@ import ExportMnemonic from '../../pages/settings/export-mnemonic/export-mnemonic import ChangeAddress from '../../pages/settings/change-address/change-address'; import ChangePassword from '../../pages/settings/change-password/change-password'; import ConnectedSites from '../../pages/settings/connected-sites/connected-sites'; +import SignMessage from '../../pages/settings/sign-message/sign-message'; import ResetWallet from '../../pages/settings/reset-wallet/reset-wallet'; // Which screens are somewhere you go into, rather than somewhere you are. Those @@ -32,6 +33,7 @@ const subScreens = { '/tabs/settings/export-mnemonic': 'Backup phrase', '/tabs/settings/change-password': 'Password', '/tabs/settings/connected-sites': 'Connected sites', + '/tabs/settings/sign-message': 'Sign message', '/tabs/settings/reset-wallet': 'Remove wallet', }; @@ -61,6 +63,7 @@ const TabsLayout = () => { } /> } /> } /> + } /> } /> diff --git a/src/manifest.json b/src/manifest.json index 37752a9..7ef78ce 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,5 +1,5 @@ { - "version": "0.3.0", + "version": "0.3.1", "manifest_version": 3, "name": "Syrius — Zenon Wallet", "description": "A non-custodial wallet for Zenon, Network of Momentum, that runs in the browser.", diff --git a/src/pages/settings/settings/settings.js b/src/pages/settings/settings/settings.js index a466d73..5accff1 100644 --- a/src/pages/settings/settings/settings.js +++ b/src/pages/settings/settings/settings.js @@ -112,6 +112,12 @@ const Settings = () => { description="Sites that can see this address" url="connected-sites" /> +
+ +
{value}
+ +); + +const SignMessage = () => { + const { address } = useSelector((state) => state.wallet); + + const [message, setMessage] = useState(''); + const [signed, setSigned] = useState(null); + const [isSigning, setIsSigning] = useState(false); + + // Only complained about once there is something to complain about — an empty + // box is not an error, it is a box nobody has typed in yet. + const problem = message.length ? messageProblem(message) : null; + + const sign = async () => { + setIsSigning(true); + + try { + setSigned(await signMessage(message)); + } catch (err) { + notify.error(err); + } finally { + setIsSigning(false); + } + }; + + if (signed) { + return ( +
+

+ Signed by {truncateAddress(address, 10, 6)}. Whoever asked for this + needs the message, the public key and the signature. +

+ +
{signed.message}
+ + + + +
+ + +
+
+ ); + } + + return ( +
+

+ Signs a message with the key behind {truncateAddress(address, 10, 6)}. + Nothing is sent to the network and nothing is spent. +

+ +
+