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.'}
+
+ 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. */}
+