diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-18 03:24:55 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-18 03:24:55 +0200 |
| commit | 768e07046368819b8a8f15c8b21e5a8bbfcdf282 (patch) | |
| tree | fba4fa5f85e3963b2281004b503be05f552aff2c /packages/meshbay-hub/src/meshbay_hub/static | |
| parent | e9d5e979fdab9a1cc3c729d602e6f27207b9480c (diff) | |
| download | meshbay-768e07046368819b8a8f15c8b21e5a8bbfcdf282.tar.gz | |
feat: device linking, and signing in to the hub with a device key
Stage C. Identity keys are per node, so a browser and a desktop client are two
keys on one account there — and the node refused the second where it accepted
the first. Without this, an account created natively could never be opened in a
browser without an operator code per node, and "a native client must not prevent
web use" would have been dead on arrival.
Device linking (node)
---------------------
`identities` is keyed by `(user_id, pk_ed25519)` instead of `user_id` alone. The
old shape did `INSERT OR REPLACE`, so a second device overwrote the first
silently; SQLite cannot change a primary key in place, so the table is rebuilt.
Existing pins are carried over — verified against a live roster with 10 of them,
nobody re-pairs.
A new device files a request bound by `sha256(code ‖ its own keys)`, and a key
the node **already pinned** countersigns it. The hub cannot: it has stored no
user keys since 2026-08-14, which is what makes this safe to do without an
operator in the loop.
**The code never reaches the node.** It lists this account's pending requests
with their stored hashes; the approver recomputes and keeps the match. A node
offering fabricated keys would have to produce a hash over a code it has never
seen. Nothing rests on a human comparing digits — that ritual was dropped in
12.1 as "correct, unusable as the default" and must not return by the back door.
The design document had the approver look a request up *by* its hash, which is
circular: computing it needs the keys being asked about. Corrected in both.
Revocation marks rather than deletes, because a deleted row is a key the node
would happily pin again — which is the laptop somebody just reported lost. Your
last device cannot be revoked: coming back would need an operator's code.
Hub — the only change in the whole plan
---------------------------------------
`POST /v1/users/auth` signs in with a device Ed25519 key, on the same pattern as
`/v1/nodes/auth`, plus `/v1/users/devices` to register, list and retire. New
`user_devices` table with an Alembic migration, because `create_all()` is not
one.
This is **not** the key directory that was H3, and the tests say so: nothing
reads it but the hub, no group key is ever wrapped for one, and it is a
different key from the per-node identities. What it does cost is metadata — the
hub now knows how many devices an account has and when each last signed in.
Also `client.minimum` / `client.recommended` in `GET /v1/hub/version`: an
installed client meets a newer hub the day the interface ships in a package, and
that is cheap now and awkward to retrofit.
Browser
-------
The `key_changed` refusal becomes `unknown_device` and offers a linking code
instead of telling someone to find their operator. The Members panel lists this
account's devices here, approves one by code, and retires one.
773 tests pass. `e2e.py` gained a step that links a device end to end against
the live deployment — file, list, recompute, countersign, then open the group
with the new keys and no code — and it also gained `recv_type`, because a step
that assumes the next message is its own answer reads an ack left by the step
before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
13 files changed, 419 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 4a4e712..a48535a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -1252,6 +1252,10 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, // the second one should make the pairing form go away. const [operatorPaired, setOperatorPaired] = useState(false); const [needsCode, setNeedsCode] = useState(false); + // This browser holds a key the node does not know, for an account it does. + // Not the operator's problem: a device already paired here can admit it. + const [needsDevice, setNeedsDevice] = useState(false); + const [deviceCode, setDeviceCode] = useState(''); const [codeInput, setCodeInput] = useState(''); const [retryKey, setRetryKey] = useState(0); const transportRef = useRef(null); @@ -1382,6 +1386,11 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, // one-time code from the operator before it will hand over the group // key. Not an error to shout about — a step in joining. if (err.reason === 'code_required') setNeedsCode(true); + // A key this node has never pinned, for an account it knows. The way in + // is a device already trusted here, not an operator — which is the + // 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); setStatus('error'); // A refusal means the node answered, so it is up; only a failure to @@ -1865,6 +1874,27 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, `} </div> ${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`} + ${needsDevice && 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); } + }}>${t('device.add_btn')}</button> + `} + ${deviceCode && html` + <p class="settings-hint">${t('device.add_show')}</p> + <p style="font-family:monospace;font-size:1.6em;letter-spacing:2px"> + ${deviceCode} + </p> + `} + </div> + `} ${needsCode && html` <form class="invite-form" style="margin-bottom:12px" onSubmit=${submitJoinCode}> <h4>${t('group.join_code_title')}</h4> @@ -2191,11 +2221,50 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, const [pairCode, setPairCode] = useState(''); const [pairStatus, setPairStatus] = useState(''); const [pairing, setPairing] = useState(false); + // Your own devices on this node. Not a members feature — it is beside them + // because this is where a live connection to the node exists. + const [devices, setDevices] = useState([]); + const [approveCode, setApproveCode] = useState(''); + const [deviceMsg, setDeviceMsg] = useState(''); // Pairing lives here rather than in Settings because this is where a live // connection to the node exists — and it is offered only when the node itself // says this account is its operator (is_node_admin comes from the authenticated // handshake_ack, not from the hub). + const loadDevices = useCallback(async () => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const out = await transport.listDevices(); + setDevices(out.devices); + } catch { /* a node that has none says so by listing none */ } + }, [transportRef]); + + useEffect(() => { loadDevices(); }, [loadDevices]); + + const approveDevice = useCallback(async (e) => { + e.preventDefault(); + const code = approveCode.trim(); + if (!code) return; + setDeviceMsg(''); + try { + await transportRef.current.approveDevice(userId, code); + setApproveCode(''); + setDeviceMsg(t('device.approved')); + await loadDevices(); + } catch (err) { setDeviceMsg(err.message); } + }, [approveCode, userId, transportRef, loadDevices]); + + const revokeDevice = useCallback(async (device) => { + if (!confirm(t('device.revoke_confirm'))) return; + setDeviceMsg(''); + try { + await transportRef.current.revokeDevice( + userId, device.pk_ed25519, device.pk_x25519 || ''); + await loadDevices(); + } catch (err) { setDeviceMsg(err.message); } + }, [userId, transportRef, loadDevices]); + const doPair = useCallback(async (e) => { e.preventDefault(); const code = pairCode.trim(); @@ -2398,6 +2467,36 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef, </div> </form> `} + + <div class="invite-form" style="margin-top:16px"> + <h4>${t('device.mine_title')}</h4> + <p class="settings-hint">${t('device.mine_hint')}</p> + ${deviceMsg && html`<p class="settings-hint">${deviceMsg}</p>`} + ${devices.length === 0 && html` + <p class="settings-hint">${t('device.mine_empty')}</p> + `} + ${devices.map(d => html` + <div key=${d.pk_ed25519} + style="display:flex;align-items:center;gap:8px;margin:4px 0"> + <span style="font-family:monospace">${d.pk_ed25519.slice(0, 16)}…</span> + ${d.is_this_one && html`<span class="badge">${t('device.this_one')}</span>`} + <span class="settings-hint">${d.pinned_via}${d.label ? ' · ' + d.label : ''}</span> + ${!d.is_this_one && devices.length > 1 && html` + <button class="admin-btn" onClick=${() => revokeDevice(d)}> + ${t('device.revoke')} + </button> + `} + </div> + `)} + <form onSubmit=${approveDevice} style="margin-top:12px"> + <p class="settings-hint">${t('device.approve_hint')}</p> + <div style="display:flex;gap:8px"> + <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace" + value=${approveCode} onInput=${e => setApproveCode(e.target.value)} /> + <button class="admin-btn" type="submit">${t('device.approve_btn')}</button> + </div> + </form> + </div> </div> `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js index 21bf05d..1ddaa50 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -321,6 +321,8 @@ async function handshakeProof(gekRaw, role, groupId, nonceClient, nonceNode, bin // wrap the group key for a key that came over the wire instead of one fetched // from the hub's directory (H3). nonce_node ties it to this connection. const JOIN_PREFIX = new TextEncoder().encode('meshbay:join:v1'); +const DEVICE_REQ_PREFIX = new TextEncoder().encode('meshbay:device_req:v1'); +const DEVICE_ADD_PREFIX = new TextEncoder().encode('meshbay:device_add:v1'); function joinTranscript(nodePkB64, groupId, userId, pkEdB64, pkXB64, nonceNode, ts) { const enc = new TextEncoder(); @@ -339,6 +341,69 @@ function joinTranscript(nodePkB64, groupId, userId, pkEdB64, pkXB64, nonceNode, return out; } +/** + * Device linking transcripts, mirroring meshbay_common/device.py. + * + * Two signatures admit a device: the new one proves it holds the keys it is + * presenting, and a key the node already pinned countersigns them. The hub can + * produce neither — it has stored no user keys since 2026-08-14 — which is what + * makes this safe to do without an operator. + */ +function deviceRequestTranscript(nodePkB64, userId, pkEdB64, pkXB64, codeHash, + nonceNode, ts) { + const enc = new TextEncoder(); + const body = _lenPrefixed([ + enc.encode(nodePkB64), enc.encode(userId), enc.encode(pkEdB64), + enc.encode(pkXB64), enc.encode(codeHash), nonceNode, enc.encode(String(ts)), + ]); + const out = new Uint8Array(DEVICE_REQ_PREFIX.length + body.length); + out.set(DEVICE_REQ_PREFIX, 0); + out.set(body, DEVICE_REQ_PREFIX.length); + return out; +} + +function deviceAddTranscript(nodePkB64, userId, pkEdB64, pkXB64, nonceNode, ts) { + const enc = new TextEncoder(); + const body = _lenPrefixed([ + enc.encode(nodePkB64), enc.encode(userId), enc.encode(pkEdB64), + enc.encode(pkXB64), nonceNode, enc.encode(String(ts)), + ]); + const out = new Uint8Array(DEVICE_ADD_PREFIX.length + body.length); + out.set(DEVICE_ADD_PREFIX, 0); + out.set(body, DEVICE_ADD_PREFIX.length); + return out; +} + +/** + * sha256(code ‖ pk_ed ‖ pk_x), hex — the lookup key for a pending request. + * + * The keys go in with the code, so the hash identifies *this device asking with + * this code* rather than *this code*. That is what stops the node answering an + * approver with a substituted key: the approver recomputes this from what they + * typed and what they were handed, and a substitution finds nothing. Nothing + * here rests on a human comparing digits. + */ +async function deviceCodeHash(code, pkEdB64, pkXB64) { + const enc = new TextEncoder(); + const payload = enc.encode([code, pkEdB64, pkXB64].join('\x1f')); + const digest = await crypto.subtle.digest('SHA-256', payload); + return Array.from(new Uint8Array(digest)) + .map(b => b.toString(16).padStart(2, '0')).join(''); +} + +/** Crockford folding, mirroring roster.normalize_code. */ +function normalizeCode(code) { + let out = ''; + for (const ch of code.toUpperCase()) { + if (ch === '-' || ch === ' ' || ch === '\t') continue; + if (ch === 'I' || ch === 'L') out += '1'; + else if (ch === 'O') out += '0'; + else if (ch === 'U') out += 'V'; + else out += ch; + } + return out; +} + function constantTimeEqual(a, b) { if (a.length !== b.length) return false; let diff = 0; @@ -359,4 +424,6 @@ window.MeshBayCrypto = { generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode, adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding, joinTranscript, verifyNodeSignature, constantTimeEqual, + deviceRequestTranscript, deviceAddTranscript, deviceCodeHash, + normalizeCode, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index aaed0d1..5a946f7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -92,6 +92,19 @@ export default { 'group.upload': 'Hochladen', 'group.mkdir': 'Neuer Ordner', 'group.mkdir_prompt': 'Name des neuen Ordners:', + 'device.add_title': 'This browser is not linked to this node yet', + 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', + 'device.add_btn': 'Get a linking code', + 'device.add_show': 'Type this code into a device already linked to this node, within the hour:', + 'device.mine_title': 'Your devices on this node', + 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.', + 'device.mine_empty': 'No device is linked here yet.', + 'device.this_one': 'this one', + 'device.revoke': 'Remove', + 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.', + 'device.approve_hint': 'Linking a new device? Enter the code it is showing.', + 'device.approve_btn': 'Approve', + 'device.approved': 'Device linked.', 'group.root_unavailable': '(nicht verfügbar — Laufwerk getrennt)', 'group.view': 'Ansehen', 'group.delete': 'Löschen', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 02532d6..fd2d6ec 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -92,6 +92,19 @@ export default { 'group.upload': 'Upload', 'group.mkdir': 'New folder', 'group.mkdir_prompt': 'Name of the new folder:', + 'device.add_title': 'This browser is not linked to this node yet', + 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', + 'device.add_btn': 'Get a linking code', + 'device.add_show': 'Type this code into a device already linked to this node, within the hour:', + 'device.mine_title': 'Your devices on this node', + 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.', + 'device.mine_empty': 'No device is linked here yet.', + 'device.this_one': 'this one', + 'device.revoke': 'Remove', + 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.', + 'device.approve_hint': 'Linking a new device? Enter the code it is showing.', + 'device.approve_btn': 'Approve', + 'device.approved': 'Device linked.', 'group.root_unavailable': '(unavailable — the drive is disconnected)', 'group.view': 'View', 'group.delete': 'Delete', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 49bfc1c..e564164 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -90,6 +90,19 @@ export default { 'group.upload': 'Subir', 'group.mkdir': 'Nueva carpeta', 'group.mkdir_prompt': 'Nombre de la nueva carpeta:', + 'device.add_title': 'This browser is not linked to this node yet', + 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', + 'device.add_btn': 'Get a linking code', + 'device.add_show': 'Type this code into a device already linked to this node, within the hour:', + 'device.mine_title': 'Your devices on this node', + 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.', + 'device.mine_empty': 'No device is linked here yet.', + 'device.this_one': 'this one', + 'device.revoke': 'Remove', + 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.', + 'device.approve_hint': 'Linking a new device? Enter the code it is showing.', + 'device.approve_btn': 'Approve', + 'device.approved': 'Device linked.', 'group.root_unavailable': '(no disponible — la unidad está desconectada)', 'group.view': 'Ver', 'group.delete': 'Eliminar', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index 1afc5b1..5848f9d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -91,6 +91,19 @@ export default { 'group.upload': 'Envoyer', 'group.mkdir': 'Nouveau dossier', 'group.mkdir_prompt': 'Nom du nouveau dossier :', + 'device.add_title': 'Ce navigateur n’est pas encore lié à ce nœud', + 'device.add_hint': 'Votre compte est connu ici, mais ce navigateur détient une autre clé. Approuvez-le depuis un appareil déjà lié — sans passer par l’opérateur.', + 'device.add_btn': 'Obtenir un code de liaison', + 'device.add_show': 'Saisissez ce code sur un appareil déjà lié à ce nœud, dans l’heure :', + 'device.mine_title': 'Vos appareils sur ce nœud', + 'device.mine_hint': 'Chaque navigateur ou client que vous utilisez ici détient sa propre clé. Ils sont distincts de vos appareils sur les autres nœuds.', + 'device.mine_empty': 'Aucun appareil lié ici pour le moment.', + 'device.this_one': 'celui-ci', + 'device.revoke': 'Retirer', + 'device.revoke_confirm': 'Retirer cet appareil ? Il perdra l’accès à ce nœud jusqu’à une nouvelle liaison.', + 'device.approve_hint': 'Vous liez un nouvel appareil ? Saisissez le code qu’il affiche.', + 'device.approve_btn': 'Approuver', + 'device.approved': 'Appareil lié.', 'group.root_unavailable': '(indisponible — le disque est déconnecté)', 'group.view': 'Afficher', 'group.delete': 'Supprimer', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 44b4353..61913e3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -91,6 +91,19 @@ export default { 'group.upload': 'Carica', 'group.mkdir': 'Nuova cartella', 'group.mkdir_prompt': 'Nome della nuova cartella:', + 'device.add_title': 'This browser is not linked to this node yet', + 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', + 'device.add_btn': 'Get a linking code', + 'device.add_show': 'Type this code into a device already linked to this node, within the hour:', + 'device.mine_title': 'Your devices on this node', + 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.', + 'device.mine_empty': 'No device is linked here yet.', + 'device.this_one': 'this one', + 'device.revoke': 'Remove', + 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.', + 'device.approve_hint': 'Linking a new device? Enter the code it is showing.', + 'device.approve_btn': 'Approve', + 'device.approved': 'Device linked.', 'group.root_unavailable': '(non disponibile — l’unità è scollegata)', 'group.view': 'Visualizza', 'group.delete': 'Elimina', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 33fdee3..aa656ee 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -89,6 +89,19 @@ export default { 'group.upload': 'アップロード', 'group.mkdir': '新しいフォルダー', 'group.mkdir_prompt': '新しいフォルダーの名前:', + 'device.add_title': 'This browser is not linked to this node yet', + 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', + 'device.add_btn': 'Get a linking code', + 'device.add_show': 'Type this code into a device already linked to this node, within the hour:', + 'device.mine_title': 'Your devices on this node', + 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.', + 'device.mine_empty': 'No device is linked here yet.', + 'device.this_one': 'this one', + 'device.revoke': 'Remove', + 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.', + 'device.approve_hint': 'Linking a new device? Enter the code it is showing.', + 'device.approve_btn': 'Approve', + 'device.approved': 'Device linked.', 'group.root_unavailable': '(利用不可 — ドライブが切断されています)', 'group.view': '表示', 'group.delete': '削除', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index aa0385e..dae903f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -92,6 +92,19 @@ export default { 'group.upload': 'Uploaden', 'group.mkdir': 'Nieuwe map', 'group.mkdir_prompt': 'Naam van de nieuwe map:', + 'device.add_title': 'This browser is not linked to this node yet', + 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', + 'device.add_btn': 'Get a linking code', + 'device.add_show': 'Type this code into a device already linked to this node, within the hour:', + 'device.mine_title': 'Your devices on this node', + 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.', + 'device.mine_empty': 'No device is linked here yet.', + 'device.this_one': 'this one', + 'device.revoke': 'Remove', + 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.', + 'device.approve_hint': 'Linking a new device? Enter the code it is showing.', + 'device.approve_btn': 'Approve', + 'device.approved': 'Device linked.', 'group.root_unavailable': '(niet beschikbaar — de schijf is losgekoppeld)', 'group.view': 'Bekijken', 'group.delete': 'Verwijderen', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index e66dd12..65c81ca 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -95,6 +95,19 @@ export default { 'group.upload': 'Wyślij', 'group.mkdir': 'Nowy folder', 'group.mkdir_prompt': 'Nazwa nowego folderu:', + 'device.add_title': 'This browser is not linked to this node yet', + 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', + 'device.add_btn': 'Get a linking code', + 'device.add_show': 'Type this code into a device already linked to this node, within the hour:', + 'device.mine_title': 'Your devices on this node', + 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.', + 'device.mine_empty': 'No device is linked here yet.', + 'device.this_one': 'this one', + 'device.revoke': 'Remove', + 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.', + 'device.approve_hint': 'Linking a new device? Enter the code it is showing.', + 'device.approve_btn': 'Approve', + 'device.approved': 'Device linked.', 'group.root_unavailable': '(niedostępne — dysk jest odłączony)', 'group.view': 'Podgląd', 'group.delete': 'Usuń', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 03de040..729a7fc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -92,6 +92,19 @@ export default { 'group.upload': 'Enviar', 'group.mkdir': 'Nova pasta', 'group.mkdir_prompt': 'Nome da nova pasta:', + 'device.add_title': 'This browser is not linked to this node yet', + 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', + 'device.add_btn': 'Get a linking code', + 'device.add_show': 'Type this code into a device already linked to this node, within the hour:', + 'device.mine_title': 'Your devices on this node', + 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.', + 'device.mine_empty': 'No device is linked here yet.', + 'device.this_one': 'this one', + 'device.revoke': 'Remove', + 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.', + 'device.approve_hint': 'Linking a new device? Enter the code it is showing.', + 'device.approve_btn': 'Approve', + 'device.approved': 'Device linked.', 'group.root_unavailable': '(indisponível — a unidade está desconectada)', 'group.view': 'Visualizar', 'group.delete': 'Excluir', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 72995dd..0ff5a49 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -88,6 +88,19 @@ export default { 'group.upload': '上传', 'group.mkdir': '新建文件夹', 'group.mkdir_prompt': '新文件夹的名称:', + 'device.add_title': 'This browser is not linked to this node yet', + 'device.add_hint': 'Your account is known here, but this browser holds a different key. Approve it from a device already linked — no operator needed.', + 'device.add_btn': 'Get a linking code', + 'device.add_show': 'Type this code into a device already linked to this node, within the hour:', + 'device.mine_title': 'Your devices on this node', + 'device.mine_hint': 'Each browser or client you use here holds its own key. They are separate from your devices on other nodes.', + 'device.mine_empty': 'No device is linked here yet.', + 'device.this_one': 'this one', + 'device.revoke': 'Remove', + 'device.revoke_confirm': 'Remove this device? It will lose access to this node until it is linked again.', + 'device.approve_hint': 'Linking a new device? Enter the code it is showing.', + 'device.approve_btn': 'Approve', + 'device.approved': 'Device linked.', 'group.root_unavailable': '(不可用 — 驱动器已断开连接)', 'group.view': '查看', 'group.delete': '删除', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 4ee5810..769114e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -787,6 +787,129 @@ class MeshBayTransport { return gekRaw; } + // ── Device linking ───────────────────────────────────────────────────── + // + // Identity keys are per node, so a browser and a desktop client are two keys + // on one account here. A new one is admitted by a key this node already + // pinned — never by the hub, which holds no user keys and so cannot + // countersign anything. See docs/desktop-client-v1.md §4. + + /** + * Ask to be added, and return the code to show the person. + * + * They read it off this screen and type it into a device already paired with + * this node. The code is hashed together with our own keys, so that other + * device cannot be handed a substituted key and sign for it by mistake. + */ + async requestDeviceAdd(userId) { + if (!this._sessionKeys || !this._sessionKeys.skEdB64) { + throw new Error('Identity keys unavailable in this browser — sign in again'); + } + if (!this._nonceNode || !this.nodePk) { + throw new Error('Handshake incomplete — reconnect and retry'); + } + const C = window.MeshBayCrypto; + const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64); + const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64); + + // 40 bits from the platform CSPRNG, in the same alphabet as a pairing code + // so it reads and types the same way. + const alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + const bytes = crypto.getRandomValues(new Uint8Array(8)); + const raw = Array.from(bytes, b => alphabet[b % alphabet.length]).join(''); + const code = `${raw.slice(0, 4)}-${raw.slice(4)}`; + + const codeHash = await C.deviceCodeHash( + C.normalizeCode(code), pkEdB64, pkXB64); + const ts = Math.floor(Date.now() / 1000); + const transcript = C.deviceRequestTranscript( + this.nodePk, userId, pkEdB64, pkXB64, codeHash, this._nonceNode, ts); + const sig = await window.MeshBayKeys.signBytes( + this._sessionKeys.skEdB64, transcript); + + const resp = await this._sendAndWait({ + type: 'device_add_request', v: '0.1', + pk_ed25519: pkEdB64, pk_x25519: pkXB64, code_hash: codeHash, ts, sig, + }); + if (resp.type === 'error') throw new Error(resp.detail || 'Refused'); + return { code, expiresAt: resp.expires_at }; + } + + /** + * Approve a device waiting with this code. + * + * The node is a mailbox: it is asked for a request matching + * sha256(code ‖ keys), and the keys in that hash came from the device that + * filed it. A node returning something else produces no match, so there is + * nothing to sign and nothing for a person to misread. + */ + async approveDevice(userId, code) { + if (!this._sessionKeys || !this._sessionKeys.skEdB64) { + throw new Error('Identity keys unavailable in this browser — sign in again'); + } + if (!this._nonceNode || !this.nodePk) { + throw new Error('Handshake incomplete — reconnect and retry'); + } + const C = window.MeshBayCrypto; + const normalized = C.normalizeCode(code); + + // The code never leaves this browser. The node lists what is pending, each + // with the hash the requesting device computed over the code and its own + // keys; we recompute and keep the one that matches. A node offering + // fabricated keys would have to produce a hash matching sha256(code ‖ + // fabricated) — and it does not know the code. + 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; + 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 (!match) { + throw new Error('No device is waiting with that code'); + } + return this._countersign(userId, match.code_hash, + match.pk_ed25519, match.pk_x25519); + } + + async _countersign(userId, codeHash, pkEdB64, pkXB64) { + const C = window.MeshBayCrypto; + const ts = Math.floor(Date.now() / 1000); + const transcript = C.deviceAddTranscript( + this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts); + const sig = await window.MeshBayKeys.signBytes( + this._sessionKeys.skEdB64, transcript); + const resp = await this._sendAndWait({ + type: 'device_add', v: '0.1', + pk_ed25519: pkEdB64, pk_x25519: pkXB64, code_hash: codeHash, ts, sig, + }); + if (resp.type === 'error') throw new Error(resp.detail || 'Refused'); + return resp; + } + + async listDevices() { + const resp = await this._sendAndWait({ type: 'device_list', v: '0.1' }); + if (resp.type === 'error') throw new Error(resp.detail || 'Refused'); + return { devices: resp.devices || [], pending: resp.pending || 0 }; + } + + /** Retire a device — a lost laptop. Countersigned like an addition. */ + async revokeDevice(userId, pkEdB64, pkXB64) { + const C = window.MeshBayCrypto; + const ts = Math.floor(Date.now() / 1000); + const transcript = C.deviceAddTranscript( + this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts); + const sig = await window.MeshBayKeys.signBytes( + this._sessionKeys.skEdB64, transcript); + const resp = await this._sendAndWait({ + type: 'device_revoke', v: '0.1', pk_ed25519: pkEdB64, ts, sig, + }); + if (resp.type === 'error') throw new Error(resp.detail || 'Refused'); + return resp; + } + /** * Withdraw our key backup from this node. * |