aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 03:24:55 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 03:24:55 +0200
commit768e07046368819b8a8f15c8b21e5a8bbfcdf282 (patch)
treefba4fa5f85e3963b2281004b503be05f552aff2c /packages/meshbay-hub/src/meshbay_hub/static/transport.js
parente9d5e979fdab9a1cc3c729d602e6f27207b9480c (diff)
downloadmeshbay-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/transport.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js123
1 files changed, 123 insertions, 0 deletions
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.
*