summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md6
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md7
-rw-r--r--packages/meshbay-cli/tests/test_e2e_local.py178
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js68
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js15
-rw-r--r--packages/meshbay-hub/tests/test_device_approval.py106
7 files changed, 380 insertions, 15 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 6dd1b2e..49baa6a 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -283,7 +283,11 @@ new_pk_x25519)`. The approving device asks the node for this account's pending
requests **with their stored hashes** and recomputes the hash for each until one
matches. **An approval answers a pending request**: it names the request's hash,
and the node admits the keys only if that request was filed by those same keys. A
-countersignature alone — however it was obtained — admits nothing. Retiring a
+countersignature alone — however it was obtained — admits nothing. **A code that
+matches more than one pending request approves none**: filing needs only the
+account's token, so somebody holding the passphrase who saw the code could file
+their own keys under it, and the first match would then be whoever filed first.
+The new device shows a new code instead. Retiring a
device is signed under a prefix of its own, never the admission transcript: a
signature given to retire a key would otherwise admit it.
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index 8372ee7..b1f7644 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -994,6 +994,7 @@ answered with the group key, like any pinned device's.
| | A recomputes sha256(code||keys)
| | for each candidate and keeps the match.
| | No match -> refuse before signing.
+ | | More than one -> refuse: approve none.
| | |
| |<--- device_add --------------|
| | {pk_ed25519, pk_x25519, |
@@ -1006,6 +1007,12 @@ answered with the group key, like any pinned device's.
| | {pk_ed25519} |
```
+A request is filed with the account's token alone, before any proof (§7). So
+anyone holding the passphrase who has seen a code on D's screen can file a second
+request with that code and keys of their own, and both hashes verify. That is why
+A approves only a code that matches **exactly one** pending request; D then shows
+a new code.
+
### 9.2 Transcripts
```
diff --git a/packages/meshbay-cli/tests/test_e2e_local.py b/packages/meshbay-cli/tests/test_e2e_local.py
index 4283819..d6f6d52 100644
--- a/packages/meshbay-cli/tests/test_e2e_local.py
+++ b/packages/meshbay-cli/tests/test_e2e_local.py
@@ -17,7 +17,8 @@ What it sets up, as an operator and a browser would:
because the CLI does not join groups.
Then the CLI: login, ls of groups, of a root, recursive, JSON, a second run
-from the identity it kept, and logout.
+from the identity it kept, and logout. And, where google-chrome is installed,
+the application's group page linking itself as a device against the same node.
MESHBAY_CLI_E2E=1 pytest packages/meshbay-cli/tests/test_e2e_local.py -v
@@ -31,6 +32,7 @@ import json
import os
import re
import secrets
+import shutil
import socket
import sqlite3
import subprocess
@@ -186,6 +188,20 @@ def _build(root: Path, procs: list) -> dict:
"share": share, "app": app}
+async def _signed_in(account) -> None:
+ """Signed in, waiting out the hub's limit of 10 sign-ins a minute: this
+ module signs in more often than that, as the application and as the CLI."""
+ from meshbay_cli.errors import CliError
+ deadline = time.monotonic() + 90
+ while True:
+ try:
+ return await account.sign_in(remember=False)
+ except CliError as e:
+ if "Too many attempts" not in str(e) or time.monotonic() > deadline:
+ raise
+ await asyncio.sleep(5)
+
+
def _try(fn, *args) -> str:
try:
return fn(*args)
@@ -223,7 +239,7 @@ class App:
hub = Hub(self.hub_url)
account = Account(hub, USERNAME, lambda: PASSPHRASE)
- await account.sign_in(remember=False)
+ await _signed_in(account)
token = await account.token()
node = (await hub.group_nodes(token, self.group_id))[0]
mnp = await hub.mnp_token(token, node["pk_node"], self.group_id)
@@ -568,3 +584,161 @@ async def test_the_interactive_mode_against_the_real_node(world, monkeypatch, tm
await pilot.press("f5")
await until(pilot, lambda: (tmp_path / "notes.txt").exists()
and (tmp_path / "notes.txt").read_text() == "hello\n")
+
+
+# ── The application's half of linking, in a real Chrome ──────────────────────
+#
+# Here because this is where a real hub and node stand: the group page, served
+# by the hub, linking a device in the desktop application — the request filed
+# before the proof (MNP 6.3) on the connection the node refused, then the group
+# opening by itself once a linked device approves. The application's key store
+# (keyring.js, in its main process) is stood in for by a page script that keeps
+# the identity in memory and signs through the page's own transcripts.
+
+_KEYS_STUB = r"""
+(() => {
+ const ids = {};
+ const b64 = (b) => btoa(String.fromCharCode(...new Uint8Array(b)));
+ const k = (u, n) => `${u}|${n}`;
+ window.__minted = 0;
+ const keys = {
+ available: async () => true,
+ hasSession: async () => true,
+ identity: async (u, n) => (ids[k(u, n)] ? { ...ids[k(u, n)].pub, sealedWith: null } : null),
+ async mint(u, n) {
+ window.__minted += 1;
+ const ed = await crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']);
+ const x = await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']);
+ const pub = { pkEdB64: b64(await crypto.subtle.exportKey('raw', ed.publicKey)),
+ pkXB64: b64(await crypto.subtle.exportKey('raw', x.publicKey)) };
+ ids[k(u, n)] = { skEd: b64(await crypto.subtle.exportKey('pkcs8', ed.privateKey)),
+ skX: x.privateKey, pub };
+ return pub;
+ },
+ async sign(u, n, kind, fields) {
+ const id = ids[k(u, n)];
+ return window.MeshBayKeys.signBytes(
+ id.skEd, window.MeshBayCrypto.transcriptFor(kind, fields, id.pub));
+ },
+ async shared(u, n, peerB64) {
+ const raw = Uint8Array.from(atob(peerB64), (c) => c.charCodeAt(0));
+ const pk = await crypto.subtle.importKey('raw', raw, { name: 'X25519' }, false, []);
+ return b64(await crypto.subtle.deriveBits(
+ { name: 'X25519', public: pk }, ids[k(u, n)].skX, 256));
+ },
+ browserAccess: async () => false,
+ fingerprint: async () => 'fp',
+ openBundle: async () => { throw new Error('the node keeps no bundle here'); },
+ commitPending: async () => true, dropPending: async () => true,
+ forgetSession: async () => true, createdHere: async () => true,
+ };
+ window.meshbay = { hubBase: () => location.origin, setHubBase: async () => true, keys };
+ localStorage.setItem('mb_auth', JSON.stringify(__AUTH__));
+})();
+"""
+
+_LINK_FORM = r"""(() => {
+ const page = document.body ? document.body.innerText : ''; // null mid-navigation
+ const form = [...document.querySelectorAll('.invite-form')]
+ .find((f) => f.querySelector('button, p') && /linked/i.test(f.innerText));
+ const code = form && [...form.querySelectorAll('p')].map((p) => p.innerText.trim())
+ .find((t) => /^[0-9A-Z]{4}-[0-9A-Z]{4}$/.test(t));
+ return { form: Boolean(form), button: Boolean(form && form.querySelector('button')),
+ code: code || '', minted: window.__minted,
+ error: (document.querySelector('.error-msg') || {}).innerText || '',
+ opened: /No messages yet/.test(page), page: page.slice(-400) };
+})()"""
+
+
+class _Chrome:
+ """Chrome, headless, driven over the DevTools protocol."""
+
+ def __init__(self, init_script: str):
+ import tempfile
+
+ from websockets.sync.client import connect
+ self.profile = tempfile.TemporaryDirectory(prefix="meshbay-e2e-chrome-")
+ port = _free_port()
+ self.proc = subprocess.Popen(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--remote-debugging-port={port}", f"--user-data-dir={self.profile.name}",
+ "--lang=en-US", "about:blank"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ deadline = time.monotonic() + 30
+ while True:
+ try:
+ pages = httpx.get(f"http://127.0.0.1:{port}/json/list").json()
+ target = next(p for p in pages if p["type"] == "page")
+ break
+ except (httpx.HTTPError, StopIteration):
+ assert time.monotonic() < deadline, "Chrome did not come up"
+ time.sleep(0.2)
+ self.ws = connect(target["webSocketDebuggerUrl"], max_size=None)
+ self.n = 0
+ self.call("Page.enable")
+ self.call("Page.addScriptToEvaluateOnNewDocument", source=init_script)
+
+ def call(self, method: str, **params):
+ self.n += 1
+ self.ws.send(json.dumps({"id": self.n, "method": method, "params": params}))
+ while True:
+ msg = json.loads(self.ws.recv())
+ if msg.get("id") == self.n:
+ assert "error" not in msg, msg
+ return msg.get("result", {})
+
+ def js(self, expr: str):
+ r = self.call("Runtime.evaluate", expression=expr, awaitPromise=True,
+ returnByValue=True)
+ assert "exceptionDetails" not in r, r["exceptionDetails"]
+ return r["result"].get("value")
+
+ def until(self, expr: str, pred, timeout: float = 90):
+ deadline = time.monotonic() + timeout
+ while not pred(state := self.js(expr)):
+ assert time.monotonic() < deadline, f"timed out: {state}"
+ time.sleep(0.5)
+ return state
+
+ def close(self):
+ self.ws.close()
+ self.proc.terminate()
+ self.proc.wait(10)
+ self.profile.cleanup()
+
+
+@pytest.mark.skipif(not shutil.which("google-chrome"),
+ reason="google-chrome is not available")
+def test_the_application_links_itself_from_its_group_page(world):
+ from meshbay_cli.client.keys import auth_key
+ app, hub_url = world["app"], world["hub_url"]
+ asyncio.run(app.withdraw_bundle())
+ deadline = time.monotonic() + 90
+ while (r := httpx.post(f"{hub_url}/v1/users/login", json={
+ "username": USERNAME, "auth_key": auth_key(USERNAME, PASSPHRASE)})
+ ).status_code == 429 and time.monotonic() < deadline:
+ time.sleep(5) # the hub's 10 sign-ins a minute (see _signed_in)
+ assert r.status_code == 200, r.text
+ tokens = r.json()
+ me = httpx.get(f"{hub_url}/v1/users/me",
+ headers={"Authorization": f"Bearer {tokens['access_token']}"}).json()
+ auth = {"username": USERNAME, "userId": me["user_id"], "role": "user",
+ "token": tokens["access_token"], "refreshToken": tokens.get("refresh_token")}
+
+ chrome = _Chrome(_KEYS_STUB.replace("__AUTH__", json.dumps(auth)))
+ try:
+ chrome.call("Page.navigate", url=f"{hub_url}/#/group/{world['group_id']}")
+ # Refused as an unknown device: the form, and nothing shown as an error.
+ s = chrome.until(_LINK_FORM, lambda s: s["button"])
+ assert not s["error"], s
+ chrome.js("""[...document.querySelectorAll('.invite-form button')]
+ .find((b) => /linking code/i.test(b.innerText)).click()""")
+ s = chrome.until(_LINK_FORM, lambda s: s["code"] or s["error"])
+ assert s["code"] and not s["error"], s
+ asyncio.run(app.approve(s["code"]))
+ # Nobody clicks anything: the page tries again and the group opens.
+ s = chrome.until(_LINK_FORM, lambda s: s["opened"] and not s["form"], timeout=60)
+ assert not s["error"], s
+ assert s["minted"] == 1, "the identity linked is not the one kept"
+ finally:
+ chrome.close()
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 3e3b483..422d90b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -26,6 +26,17 @@ const GroupSettingsPanel = lazy(() => import('./group-settings.js'), 'GroupSetti
import { reportIndexPush } from './index-dock.js';
import { clearPending, nodePkFromLink, pendingFor } from './invite-link.js';
+// While a linking code is shown, how long after a refused try the group is
+// tried again: a person typing the code on another device takes longer, and
+// each try is a connection.
+const DEVICE_RETRY_MS = 5000;
+
+/** The node's `expires_at` (ISO) in epoch seconds; 0 when it gave none. */
+function _untilSeconds(iso) {
+ const ms = Date.parse(iso || '');
+ return Number.isFinite(ms) ? ms / 1000 : 0;
+}
+
/**
* The group shell: everything a group's "applications" (Chat, Files, and
* whatever registers in apps.js next) share — the WebRTC connection, the file
@@ -218,6 +229,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
}, []);
const browserAccessOff = !nativeKeys && userPrefs && userPrefs.browser_access === 'off';
const [deviceCode, setDeviceCode] = useState('');
+ // While a code is shown: until when the node keeps its request (epoch
+ // seconds). The group is tried again after each refusal until then.
+ const deviceWaitRef = useRef(0);
const [codeInput, setCodeInput] = useState('');
// This browser has never derived the passphrase-bundle key (fresh browser,
// cleared storage, or a device-key sign-in). Ask for the passphrase here
@@ -236,9 +250,15 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// set to `true` while looking at one group would otherwise silently disable
// the retry for every group opened afterward in the same session, forever.
const refreshedRef = useRef(false);
+ // Set by "Get a linking code" for the one connection that files the request.
+ const fileDeviceRef = useRef(false);
useEffect(() => {
refreshedRef.current = false;
+ fileDeviceRef.current = false;
setNeedsPass(false);
+ deviceWaitRef.current = 0;
+ setNeedsDevice(false);
+ setDeviceCode('');
}, [groupId]);
const submitJoinCode = useCallback((e) => {
@@ -367,6 +387,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// Dropped by the teardown below, so a transport handed on to a running
// download (`releaseWhenIdle`) stops driving a page that is gone.
let offReconnect = null;
+ let deviceRetry = null;
// The cache is written here and read only by the search page. It used to
// seed this list too, which put a stale index on screen and then raced the
@@ -453,6 +474,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
transport.onDeviceIdentity = (ok) => {
if (!cancelled) setDeviceReady(ok);
};
+ // "Get a linking code" retries with this set: the request is filed
+ // on the connection the node refused, which is closed right after.
+ transport.fileDeviceRequest = fileDeviceRef.current;
try {
ack = await transport.connect(
n.node_id, live, groupId, null, identity, session.bundleKey,
@@ -615,8 +639,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
})();
});
- // We are in: an invitation to this group has served its purpose.
+ // We are in: an invitation to this group has served its purpose, and a
+ // linking code shown before has been approved.
if (onJoined) onJoined(groupId);
+ deviceWaitRef.current = 0;
+ setNeedsDevice(false);
+ setDeviceCode('');
const indexMsg = await transport.fetchIndex();
if (cancelled) return;
@@ -680,7 +708,30 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// whole point of device linking: a second browser or a native client
// must not cost anyone a support request.
if (err.reason === 'unknown_device') setNeedsDevice(true);
- setError(err.message);
+ fileDeviceRef.current = false;
+ if (err.deviceCode) {
+ setDeviceCode(err.deviceCode);
+ deviceWaitRef.current = _untilSeconds(err.deviceExpiresAt)
+ || Date.now() / 1000 + 3600;
+ }
+ // A code is shown: the group opens by itself once a linked device
+ // approves it, as the command-line client goes on by itself. Tried
+ // again only once this try is over, so a slow connection is never cut
+ // short by the next; past the request's life the code is dropped and
+ // the button comes back for a new one.
+ if (deviceWaitRef.current && err.reason === 'unknown_device') {
+ if (Date.now() / 1000 < deviceWaitRef.current) {
+ deviceRetry = setTimeout(() => setRetryKey(k => k + 1), DEVICE_RETRY_MS);
+ } else {
+ deviceWaitRef.current = 0;
+ setDeviceCode('');
+ }
+ }
+ // In the application the linking form says all there is to say, and
+ // while a code is shown this is the refusal of each retry, not news.
+ const quiet = err.reason === 'unknown_device' && await platform.nativeKeys();
+ if (cancelled) return;
+ if (!quiet) setError(err.message);
setStatus('error');
if (transportRef.current) {
try { transportRef.current.close(); } catch { /* already gone */ }
@@ -704,6 +755,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
return () => {
cancelled = true;
if (offReconnect) { offReconnect(); offReconnect = null; }
+ clearTimeout(deviceRetry);
// Nothing will update this group's dock row once the page lets go of it.
reportIndexPush(groupId, null);
if (transportRef.current) {
@@ -967,17 +1019,15 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
<button class="admin-btn" style="margin-left:8px;font-size:0.9em"
onClick=${() => setRetryKey(k => k + 1)}>${t('group.retry')}</button>
</div>`}
- ${needsDevice && html`
+ ${needsDevice && nativeKeys && html`
<div class="invite-form" style="margin-bottom:12px">
<h4>${t('device.add_title')}</h4>
<p class="settings-hint">${t('device.add_hint')}</p>
${!deviceCode && html`
- <button class="admin-btn" onClick=${async () => {
- try {
- const transport = transportRef.current;
- const out = await transport.requestDeviceAdd(userId);
- setDeviceCode(out.code);
- } catch (err) { setError(err.message); }
+ <button class="admin-btn" onClick=${() => {
+ // The refused connection is closed: a new one files the request.
+ fileDeviceRef.current = true;
+ setRetryKey(k => k + 1);
}}>${t('device.add_btn')}</button>
`}
${deviceCode && html`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
index 3d56f69..5c6adef 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
@@ -227,15 +227,24 @@ extendTransport(class {
const listed = await this._sendAndWait({ type: 'device_lookup', v: '0.1' });
if (listed.type === 'error') throw new Error(listed.detail || 'Not found');
- let match = null;
+ // Every match, not the first: a request is filed with the account's token
+ // alone, so somebody holding the passphrase who saw the code on screen can
+ // file a second one with the same code and keys of their own. Neither is
+ // approved then — the person's device asks again, with a new code.
+ const matches = [];
for (const req of listed.requests || []) {
const expect = await C.deviceCodeHash(
normalized, req.pk_ed25519, req.pk_x25519);
- if (expect === req.code_hash) { match = req; break; }
+ if (expect === req.code_hash) matches.push(req);
}
- if (!match) {
+ if (!matches.length) {
throw new Error('No device is waiting with that code');
}
+ if (matches.length > 1) {
+ throw new Error('More than one device is waiting with that code, so none was '
+ + 'approved. Get a new code on the device you are linking.');
+ }
+ const match = matches[0];
return this._countersign(userId, match.code_hash,
match.pk_ed25519, match.pk_x25519);
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index c86d668..a07f431 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -1178,6 +1178,21 @@ class MeshBayTransport {
} catch (e) {
// The UI turns this into "ask the operator for an invite code".
this._joinError = e;
+ // Asked for by the page ("Get a linking code"): filed on this same
+ // connection, still before the proof (MNP 6.3), because the page
+ // closes a connection that did not finish. Only for an identity the
+ // application keeps: one a browser minted is gone by its next
+ // connection, and approving it would link nothing.
+ if (e.reason === 'unknown_device' && this.fileDeviceRequest
+ && this._identity.native) {
+ try {
+ const filed = await this.requestDeviceAdd(userId);
+ e.deviceCode = filed.code;
+ e.deviceExpiresAt = filed.expiresAt;
+ } catch (fe) {
+ e.message = fe.message;
+ }
+ }
}
}
diff --git a/packages/meshbay-hub/tests/test_device_approval.py b/packages/meshbay-hub/tests/test_device_approval.py
new file mode 100644
index 0000000..529f7c6
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_device_approval.py
@@ -0,0 +1,106 @@
+"""
+Approving a device by its code, in the shipped `approveDevice` (transport-devices.js).
+
+A device request is filed before the proof, with the account's token alone
+(docs/MESHBAY_DESIGN.md §3.3, MNP 6.3). So somebody holding the passphrase who
+saw a code on screen can file a second request with that code and keys of their
+own, and both hashes are right. The approving device must then approve neither:
+taking the first match would hand the choice to whoever filed first, or pushed
+the other out.
+
+Run under node against requests hashed by the shipped Python
+(`meshbay_common.device.device_code_hash`), with the node's answer and the
+countersignature stood in for.
+"""
+
+import base64
+import json
+import secrets
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+from meshbay_common.device import device_code_hash
+from spa_source import transport_argv
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+CRYPTO = STATIC / "crypto.js"
+
+pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node is not available")
+
+CODE = "K7P2-9WQX"
+
+_HARNESS = r"""
+const fs = require('fs');
+globalThis.window = globalThis;
+globalThis.addEventListener = () => {};
+globalThis.removeEventListener = () => {};
+globalThis.location = { hash: '' };
+globalThis.document = {
+ addEventListener() {}, removeEventListener() {}, visibilityState: 'visible',
+};
+globalThis.localStorage = { getItem() { return null; }, setItem() {} };
+new Function(fs.readFileSync(process.argv[2], 'utf8'))();
+const T = new Function(
+ process.argv[3].split(require('path').delimiter)
+ .map((p) => fs.readFileSync(p, 'utf8')).join('\n')
+ + '\nreturn { MeshBayTransport };')();
+
+(async () => {
+ const input = JSON.parse(fs.readFileSync(process.argv[4], 'utf8'));
+ const t = Object.create(T.MeshBayTransport.prototype);
+ t._identity = { pkEdB64: 'me', pkXB64: 'me' };
+ t._nonceNode = new Uint8Array(32);
+ t.nodePk = 'node';
+ t._sendAndWait = async () => ({ type: 'device_lookup_result', requests: input.requests });
+ t._countersign = async (userId, codeHash, pkEd) => ({ approved: pkEd });
+ try {
+ process.stdout.write(JSON.stringify(await t.approveDevice('u-1', input.code)));
+ } catch (e) {
+ process.stdout.write(JSON.stringify({ refused: e.message }));
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
+"""
+
+
+def _keys() -> tuple[str, str]:
+ return (base64.b64encode(secrets.token_bytes(32)).decode(),
+ base64.b64encode(secrets.token_bytes(32)).decode())
+
+
+def _request(code: str) -> dict:
+ ed, x = _keys()
+ return {"pk_ed25519": ed, "pk_x25519": x, "created_at": "2026-10-14T20:00:00+00:00",
+ "code_hash": device_code_hash(code.replace("-", ""), ed, x)}
+
+
+def _approve(tmp_path, requests: list[dict], code: str = CODE) -> dict:
+ h = tmp_path / "h.js"
+ h.write_text(_HARNESS, encoding="utf-8")
+ payload = tmp_path / "in.json"
+ payload.write_text(json.dumps({"requests": requests, "code": code}), encoding="utf-8")
+ run = subprocess.run(["node", str(h), str(CRYPTO), transport_argv(), str(payload)],
+ capture_output=True, timeout=60)
+ assert run.returncode == 0, run.stderr.decode()[-2000:]
+ return json.loads(run.stdout.decode())
+
+
+def test_the_one_request_with_the_code_is_approved(tmp_path):
+ mine = _request(CODE)
+ out = _approve(tmp_path, [_request("ZZZZ-ZZZZ"), mine, _request("AAAA-1111")])
+ assert out == {"approved": mine["pk_ed25519"]}
+
+
+def test_two_requests_with_the_same_code_are_both_refused(tmp_path):
+ """Whichever came first: the person's own, or one filed by somebody who saw it."""
+ first, second = _request(CODE), _request(CODE)
+ for requests in ([first, second], [second, first]):
+ out = _approve(tmp_path, requests)
+ assert "approved" not in out, out
+ assert "More than one device" in out["refused"]
+
+
+def test_no_request_with_the_code_approves_nothing(tmp_path):
+ out = _approve(tmp_path, [_request("ZZZZ-ZZZZ")])
+ assert out == {"refused": "No device is waiting with that code"}