From 3131630d900aa4a72bf880bfa9c5232bff64d6d6 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 14 Oct 2026 23:02:49 +0200 Subject: fix(client): link a device from the application; approve one match only "Get a linking code" called the connection the page had just closed on the node's unknown_device refusal, so it never filed anything. It now retries with the request filed on that connection, before the proof (MNP 6.3), and the page tries the group again after each refusal until a linked device approves the code. Offered only in the desktop application: a browser's minted identity is gone by its next connection. Approving refuses a code that matches more than one pending request: filing needs only the account's token, so somebody holding the passphrase who saw a code could file their own keys under it. Co-Authored-By: Claude Opus 5.5 --- docs/MESHBAY_DESIGN.md | 6 +- docs/MESHBAY_NODE_PROTOCOL.md | 7 + packages/meshbay-cli/tests/test_e2e_local.py | 178 ++++++++++++++++++++- .../src/meshbay_hub/static/group-page.js | 68 ++++++-- .../src/meshbay_hub/static/transport-devices.js | 15 +- .../src/meshbay_hub/static/transport.js | 15 ++ packages/meshbay-hub/tests/test_device_approval.py | 106 ++++++++++++ 7 files changed, 380 insertions(+), 15 deletions(-) create mode 100644 packages/meshbay-hub/tests/test_device_approval.py 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, `} - ${needsDevice && html` + ${needsDevice && nativeKeys && html`

${t('device.add_title')}

${t('device.add_hint')}

${!deviceCode && html` - `} ${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"} -- cgit v1.2.3