diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index dc126d1..3927eec 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -5,6 +5,7 @@ on: push: branches: - main + - master - development - manifest-v3 tags: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8be2367..143127f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,40 @@ - Fixed delegated plasma balance reporting and the send-form dropdown blur handling. +## 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 @@ -179,3 +213,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 57d95aa..21b779c 100644 --- a/README.md +++ b/README.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. @@ -107,8 +110,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 @@ -166,6 +170,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) => {}); @@ -174,7 +184,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. @@ -193,6 +233,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/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 afbd015..a7d391d 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/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. +

+ +
+