diff options
Diffstat (limited to 'packages/meshbay-hub')
4 files changed, 192 insertions, 12 deletions
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"} |