Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build-and-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
push:
branches:
- main
- master
- development
- manifest-v3
tags:
Expand Down
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.)
49 changes: 46 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {});
Expand All @@ -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.
Expand All @@ -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

Expand Down
5 changes: 5 additions & 0 deletions src/assets/sign-message.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
116 changes: 110 additions & 6 deletions src/layouts/siteIntegrationLayout/siteIntegrationLayout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
//
Expand Down Expand Up @@ -243,7 +302,12 @@ const SiteIntegrationLayout = () => {
if (!request) {
return (
<div className="page approval-screen">
<p className="empty-note">Nothing to approve.</p>
{/* 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. */}
<p className="empty-note">
{isWaitingForMore ? 'Waiting for the site…' : 'Nothing to approve.'}
</p>
</div>
);
}
Expand Down Expand Up @@ -399,6 +463,46 @@ const SiteIntegrationLayout = () => {
</>
)}

{request.type === 'signMessage' && (
<>
<div className="approval-body">
<h2 className="approval-title">Sign this message?</h2>
<p className="approval-note">
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.
</p>

{/* Verbatim, wrapped, and never interpreted: the point of this
panel is that what gets signed is what is on screen. */}
<pre className="message-preview">{request.params.message}</pre>

<dl className="confirm-details">
<dt>Signing as</dt>
<dd title={address}>{truncateAddress(address, 10, 6)}</dd>
</dl>
</div>

<div className="action-row sticky-actions">
<button
type="button"
className="button secondary w-100"
onClick={reject}
>
Reject
</button>
<button
type="button"
className="button primary w-100 text-white"
onClick={approveSignMessage}
disabled={busy}
>
{isBusy ? 'Signing…' : 'Sign'}
</button>
</div>
</>
)}

{request.type === 'signAndSendBlock' && (
<>
<div className="approval-body">
Expand Down
3 changes: 3 additions & 0 deletions src/layouts/tabsLayout/tabsLayout.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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',
};

Expand Down Expand Up @@ -61,6 +63,7 @@ const TabsLayout = () => {
<Route path="settings/export-mnemonic" element={<ExportMnemonic />} />
<Route path="settings/change-password" element={<ChangePassword />} />
<Route path="settings/connected-sites" element={<ConnectedSites />} />
<Route path="settings/sign-message" element={<SignMessage />} />
<Route path="settings/reset-wallet" element={<ResetWallet />} />
</Routes>
</main>
Expand Down
6 changes: 6 additions & 0 deletions src/pages/settings/settings/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ const Settings = () => {
description="Sites that can see this address"
url="connected-sites"
/>
<SettingsItem
icon="sign-message"
title="Sign message"
description="Prove this address is yours, without sending anything"
url="sign-message"
/>
</div>

<button
Expand Down
Loading