diff options
| -rw-r--r-- | docs/MESHBAY_DESIGN.md | 6 | ||||
| -rw-r--r-- | docs/MESHBAY_NODE_PROTOCOL.md | 7 | ||||
| -rw-r--r-- | packages/meshbay-cli/tests/test_e2e_local.py | 178 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/group-page.js | 68 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js | 15 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/transport.js | 15 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_device_approval.py | 106 |
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"} |