aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-client/src/keyring.js20
-rw-r--r--packages/meshbay-client/src/main.js4
-rw-r--r--packages/meshbay-client/src/preload.js4
-rw-r--r--packages/meshbay-client/src/transcripts.js159
-rw-r--r--packages/meshbay-common/src/meshbay_common/device.py25
-rw-r--r--packages/meshbay-common/tests/test_js_python_parity.py101
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js54
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js24
-rw-r--r--packages/meshbay-hub/tests/harness/chat_send_probe.py4
-rw-r--r--packages/meshbay-hub/tests/test_desktop_keyring.py106
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py13
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py18
-rw-r--r--packages/meshbay-node/tests/test_device_linking.py110
-rw-r--r--packages/meshbay-node/tests/test_group_roster.py7
17 files changed, 636 insertions, 66 deletions
diff --git a/packages/meshbay-client/src/keyring.js b/packages/meshbay-client/src/keyring.js
index 1df2860..4fb6eac 100644
--- a/packages/meshbay-client/src/keyring.js
+++ b/packages/meshbay-client/src/keyring.js
@@ -19,6 +19,7 @@
'use strict';
const crypto = require('node:crypto');
+const { transcriptFor } = require('./transcripts.js');
// keyderive.js: the same numbers, or no bundle opens across the two.
const ARGON2 = { memory: 131072, passes: 3, parallelism: 1, tagLength: 32 };
@@ -110,6 +111,14 @@ function createKeyring({ load, save, argon2 }) {
pkEdB64: b64(rawPublic(privateFrom(id.ed))),
pkXB64: b64(rawPublic(privateFrom(id.x))),
});
+ // A bundle is a copy of an identity for a browser to open with the
+ // passphrase. With browser access off none may exist, whatever the page asks:
+ // the page is where hostile content is parsed, and one bundle left on a node
+ // is all a passphrase-only sign-in on the web needs.
+ const accessOn = (userId) => state().access[userId] !== false;
+ const needAccess = (userId) => {
+ if (!accessOn(userId)) throw new Error('Refused: browser access is off for this account');
+ };
const keep = (userId, nodePk, id) => {
const s = state();
s.identities[userId] = s.identities[userId] || {};
@@ -195,6 +204,7 @@ function createKeyring({ load, save, argon2 }) {
/** The identity sealed for its node, under `M` (or the pending one). */
sealBundle(userId, nodePk, { pending: usePending = false } = {}) {
+ needAccess(userId);
const { m, v } = master(userId, { usePending });
const bundle = seal(stored(userId, nodePk), hkdf(m, `meshbay:bundle:v3|node|${nodePk}`),
userId, nodePk, v);
@@ -202,6 +212,7 @@ function createKeyring({ load, save, argon2 }) {
},
/** The recovery copy: sealed under the recovery key, owing nothing to `M`. */
sealRecovery(userId, nodePk, mnemonic, username) {
+ needAccess(userId);
const rk = hkdf(fromMnemonic(mnemonic), `meshbay:recovery:v1:${username}`);
return seal(stored(userId, nodePk), rk, userId, nodePk, 0);
},
@@ -212,8 +223,11 @@ function createKeyring({ load, save, argon2 }) {
},
currentFingerprint: (userId) => fingerprint(master(userId).m),
- sign(userId, nodePk, bytesB64) {
- return b64(crypto.sign(null, unb64(bytesB64), privateFrom(stored(userId, nodePk).ed)));
+ /** Sign what `kind` names, built from `fields` (transcripts.js). */
+ signAs(userId, nodePk, kind, fields) {
+ const id = stored(userId, nodePk);
+ const transcript = transcriptFor(kind, fields, { userId, nodePk, ...publicOf(id) });
+ return b64(crypto.sign(null, transcript, privateFrom(id.ed)));
},
shared(userId, nodePk, peerPkB64) {
const publicKey = crypto.createPublicKey({
@@ -228,7 +242,7 @@ function createKeyring({ load, save, argon2 }) {
// Whether this account leaves bundles on nodes for a browser to open. An
// account created here says no until the person says yes (natively, in
// main.js); any other account keeps what it always had.
- browserAccess: (userId) => state().access[userId] !== false,
+ browserAccess: accessOn,
setBrowserAccess(userId, on) {
const s = state();
s.access[userId] = Boolean(on);
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 9a08e96..309d5f6 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -1037,7 +1037,9 @@ function registerBridge() {
keyring.sealRecovery(uid(u), npk(n), String(mnemonic || ''), String(username || '')));
handle('keys:mark-sealed', (_e, u, n, fp) => keyring.markSealed(uid(u), npk(n), String(fp || '')));
handle('keys:fingerprint', (_e, u) => keyring.currentFingerprint(uid(u)));
- handle('keys:sign', (_e, u, n, bytes) => keyring.sign(uid(u), npk(n), String(bytes || '')));
+ // By kind and fields: the page never names the bytes (transcripts.js).
+ handle('keys:sign', (_e, u, n, kind, fields) => keyring.signAs(
+ uid(u), npk(n), String(kind || ''), fields && typeof fields === 'object' ? fields : {}));
handle('keys:shared', (_e, u, n, peer) => keyring.shared(uid(u), npk(n), String(peer || '')));
handle('keys:playlist-key', (_e, u) => keyring.playlistKey(uid(u)));
handle('keys:browser-access', (_e, u) => keyring.browserAccess(uid(u)));
diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js
index 469bc48..e9c34d2 100644
--- a/packages/meshbay-client/src/preload.js
+++ b/packages/meshbay-client/src/preload.js
@@ -98,7 +98,9 @@ contextBridge.exposeInMainWorld('meshbay', {
sealRecovery: (u, n, m, name) => ipcRenderer.invoke('keys:seal-recovery', u, n, m, name),
markSealed: (u, n, fp) => ipcRenderer.invoke('keys:mark-sealed', u, n, fp),
fingerprint: (u) => ipcRenderer.invoke('keys:fingerprint', u),
- sign: (u, n, bytes) => ipcRenderer.invoke('keys:sign', u, n, bytes),
+ // A kind and its fields, never bytes: the main process builds what it
+ // signs (transcripts.js).
+ sign: (u, n, kind, fields) => ipcRenderer.invoke('keys:sign', u, n, kind, fields),
shared: (u, n, peer) => ipcRenderer.invoke('keys:shared', u, n, peer),
playlistKey: (u) => ipcRenderer.invoke('keys:playlist-key', u),
browserAccess: (u) => ipcRenderer.invoke('keys:browser-access', u),
diff --git a/packages/meshbay-client/src/transcripts.js b/packages/meshbay-client/src/transcripts.js
new file mode 100644
index 0000000..0b6d0c0
--- /dev/null
+++ b/packages/meshbay-client/src/transcripts.js
@@ -0,0 +1,159 @@
+/**
+ * What a node identity signs, built here from named fields — never bytes the
+ * page chose.
+ *
+ * The page parses content from nodes, which is attacker-controlled input
+ * (docs/MESHBAY_DESIGN.md §8.2). Were it able to hand this process bytes to
+ * sign, a script there would get a signature over anything: the approval of a
+ * device key of its own, which outlives every session, or an operation this
+ * application would otherwise have asked the person about. So the page names a
+ * kind and gives the fields, the bytes are built here — with this identity's
+ * own public keys wherever a transcript names them — and a kind outside this
+ * list is not signed at all.
+ *
+ * Byte for byte the transcripts of meshbay_common (join.py, device.py,
+ * adminop.py, chatbox.py) and of the page (crypto.js, transcriptFor);
+ * test_desktop_keyring.py holds the three together.
+ */
+
+'use strict';
+
+const enc = (s) => Buffer.from(String(s), 'utf8');
+
+function lenPrefixed(prefix, parts) {
+ const chunks = [Buffer.from(prefix)];
+ for (const p of parts) {
+ const len = Buffer.alloc(4);
+ len.writeUInt32BE(p.length, 0);
+ chunks.push(len, Buffer.from(p));
+ }
+ return Buffer.concat(chunks);
+}
+
+// ── Field checks ──────────────────────────────────────────────────────────
+//
+// Shapes, not trust: what is checked here is that a field is what its name
+// says, so that nothing unexpected reaches a transcript or a dialog.
+
+function refuse(what) { throw new Error(`Refused: ${what}`); }
+
+function bytes(v, what, { min = 1, max = 64 } = {}) {
+ const s = String(v ?? '');
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(s)) refuse(`${what} is not base64`);
+ const b = Buffer.from(s, 'base64');
+ if (b.length < min || b.length > max) refuse(`${what} has the wrong length`);
+ return b;
+}
+
+function key32(v, what) {
+ bytes(v, what, { min: 32, max: 32 });
+ return String(v);
+}
+
+function text(v, what, max = 256) {
+ const s = String(v ?? '');
+ if (s.length > max) refuse(`${what} is too long`);
+ return s;
+}
+
+function groupId(v) {
+ const s = String(v ?? '');
+ if (s && !/^[A-Za-z0-9_-]{1,64}$/.test(s)) refuse('not a group id');
+ return s;
+}
+
+// The node's clock and ours: a signature for a moment far from now is one to
+// keep for later.
+const TS_SLACK_S = 600;
+function timestamp(v) {
+ const n = Number(v);
+ if (!Number.isInteger(n) || Math.abs(n - Date.now() / 1000) > TS_SLACK_S) {
+ refuse('the timestamp is not now');
+ }
+ return n;
+}
+
+// ── Transcripts ───────────────────────────────────────────────────────────
+
+const PREFIX = {
+ join: 'meshbay:join:v1',
+ device_request: 'meshbay:device_req:v1',
+ device_add: 'meshbay:device_add:v1',
+ device_revoke: 'meshbay:device_revoke:v1',
+ device_hello: 'meshbay:device_hello:v1',
+ chat: 'meshbay:chat:v1',
+ admin: 'meshbay:admin:v1',
+};
+
+/**
+ * `ctx`: what this process knows and the page does not get to say — the
+ * account (`userId`), the node (`nodePk`) and this identity's public keys
+ * (`pkEdB64`, `pkXB64`). A field naming another account or another node is
+ * refused rather than signed.
+ */
+function transcriptFor(kind, f, ctx) {
+ const fields = f && typeof f === 'object' ? f : {};
+ const sameNode = () => {
+ if (String(fields.nodePk ?? '') !== ctx.nodePk) refuse('another node');
+ return ctx.nodePk;
+ };
+ const sameUser = () => {
+ if (String(fields.userId ?? '') !== ctx.userId) refuse('another account');
+ return ctx.userId;
+ };
+ const nonceNode = () => bytes(fields.nonceNode, 'the node nonce', { min: 16, max: 64 });
+
+ switch (kind) {
+ case 'join':
+ return lenPrefixed(PREFIX.join, [
+ enc(sameNode()), enc(groupId(fields.groupId)), enc(sameUser()),
+ enc(ctx.pkEdB64), enc(ctx.pkXB64), nonceNode(), enc(timestamp(fields.ts)),
+ ]);
+ case 'device_hello':
+ return lenPrefixed(PREFIX.device_hello, [
+ enc(sameNode()), enc(groupId(fields.groupId)), enc(sameUser()),
+ enc(ctx.pkEdB64), nonceNode(), enc(timestamp(fields.ts)),
+ ]);
+ case 'device_request': {
+ const codeHash = String(fields.codeHash ?? '');
+ if (!/^[0-9a-f]{64}$/.test(codeHash)) refuse('not a request hash');
+ return lenPrefixed(PREFIX.device_request, [
+ enc(sameNode()), enc(sameUser()), enc(ctx.pkEdB64), enc(ctx.pkXB64),
+ enc(codeHash), nonceNode(), enc(timestamp(fields.ts)),
+ ]);
+ }
+ case 'device_add':
+ return lenPrefixed(PREFIX.device_add, [
+ enc(sameNode()), enc(sameUser()), enc(key32(fields.pkEd, 'the device key')),
+ enc(key32(fields.pkX, 'the device key')), nonceNode(), enc(timestamp(fields.ts)),
+ ]);
+ case 'device_revoke':
+ return lenPrefixed(PREFIX.device_revoke, [
+ enc(sameNode()), enc(sameUser()), enc(key32(fields.pkEd, 'the device key')),
+ nonceNode(), enc(timestamp(fields.ts)),
+ ]);
+ case 'chat': {
+ const epoch = Number(fields.epoch);
+ if (!Number.isInteger(epoch) || epoch < 0) refuse('not an epoch');
+ return lenPrefixed(PREFIX.chat, [
+ enc(groupId(fields.groupId)), enc(epoch), Buffer.from(ctx.pkEdB64, 'base64'),
+ bytes(fields.nonce, 'the message nonce', { min: 12, max: 24 }),
+ bytes(fields.ct, 'the message', { min: 1, max: 8 * 1024 * 1024 }),
+ ]);
+ }
+ case 'admin': {
+ const op = String(fields.op ?? '');
+ if (!/^[a-z_]{1,32}$/.test(op)) refuse('not an operation');
+ return lenPrefixed(PREFIX.admin, [
+ enc(op), enc(sameNode()), enc(groupId(fields.groupId)),
+ enc(text(fields.subject, 'the subject', 16384)),
+ bytes(fields.nonce, 'the challenge nonce', { min: 16, max: 64 }),
+ enc(timestamp(fields.ts)),
+ ]);
+ }
+ default:
+ return refuse(`nothing is signed as "${String(kind).slice(0, 32)}"`);
+ }
+}
+
+module.exports = { transcriptFor };
diff --git a/packages/meshbay-common/src/meshbay_common/device.py b/packages/meshbay-common/src/meshbay_common/device.py
index 3a598d0..2d6747f 100644
--- a/packages/meshbay-common/src/meshbay_common/device.py
+++ b/packages/meshbay-common/src/meshbay_common/device.py
@@ -37,6 +37,7 @@ import hashlib
DEVICE_REQUEST_PREFIX = b"meshbay:device_req:v1"
DEVICE_ADD_PREFIX = b"meshbay:device_add:v1"
DEVICE_HELLO_PREFIX = b"meshbay:device_hello:v1"
+DEVICE_REVOKE_PREFIX = b"meshbay:device_revoke:v1"
# Same as the join and admin transcripts: interactive exchanges that complete in
# milliseconds, so anything older is a replay.
@@ -126,6 +127,30 @@ def device_add_transcript(
])
+def device_revoke_transcript(
+ node_pk_b64: str,
+ user_id: str,
+ pk_ed25519_b64: str,
+ nonce_node: bytes,
+ ts: int,
+) -> bytes:
+ """
+ Signed by a pinned device, retiring one of the account's devices.
+
+ A prefix of its own, never the admission transcript: a signature given to
+ retire a key would otherwise admit that same key on a node where it is not
+ pinned yet, and whoever asks a device to sign a retirement could turn it
+ into an addition.
+ """
+ return _pack(DEVICE_REVOKE_PREFIX, [
+ node_pk_b64.encode(),
+ user_id.encode(),
+ pk_ed25519_b64.encode(),
+ nonce_node,
+ str(ts).encode(),
+ ])
+
+
def device_hello_transcript(
node_pk_b64: str,
group_id: str,
diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py
index 5bf34aa..f75d60a 100644
--- a/packages/meshbay-common/tests/test_js_python_parity.py
+++ b/packages/meshbay-common/tests/test_js_python_parity.py
@@ -625,3 +625,104 @@ def test_a_message_does_not_open_under_another_devices_key(chatbox_js):
open_message(CHAT_EPOCH_KEY, group_id, epoch, vectors[1]["device_b64"],
bytes.fromhex(vectors[0]["nonce"]),
bytes.fromhex(vectors[0]["ct"]))
+
+
+# ── Every kind an identity signs, through `transcriptFor` ───────────────────
+#
+# The page signs with an identity only by kind (`transcriptFor`), and the
+# desktop application builds the same bytes in its main process. The device
+# transcripts had no parity check of their own; the retirement one differs
+# from the admission one only by its prefix, which is the whole point of it.
+
+_KINDS_HARNESS = r"""
+const fs = require('fs');
+globalThis.window = {};
+const src = fs.readFileSync(process.argv[2], 'utf8');
+const M = new Function(src + '\nreturn { transcriptFor };')();
+const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
+const toHex = (u8) => Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join('');
+const out = {};
+for (const [kind, f] of Object.entries(input.fields)) {
+ out[kind] = toHex(M.transcriptFor(kind, f, input.own));
+}
+try { M.transcriptFor('raw', {}, input.own); out.raw = 'signed'; }
+catch (e) { out.raw = String(e.message); }
+process.stdout.write(JSON.stringify(out));
+"""
+
+_OWN = {"pkEdB64": base64.b64encode(b"E" * 32).decode(),
+ "pkXB64": base64.b64encode(b"X" * 32).decode()}
+_OTHER = base64.b64encode(b"O" * 32).decode()
+_NONCE = base64.b64encode(b"\x07" * 32).decode()
+_KIND_FIELDS = {
+ "join": {"nodePk": "Tk9ERVBL", "groupId": "g" * 32, "userId": "grenet",
+ "nonceNode": _NONCE, "ts": 1_700_000_000},
+ "device_hello": {"nodePk": "Tk9ERVBL", "groupId": "g" * 32, "userId": "grenet",
+ "nonceNode": _NONCE, "ts": 1_700_000_000},
+ "device_request": {"nodePk": "Tk9ERVBL", "userId": "grenet", "codeHash": "ab" * 32,
+ "nonceNode": _NONCE, "ts": 1_700_000_000},
+ "device_add": {"nodePk": "Tk9ERVBL", "userId": "grenet", "pkEd": _OTHER,
+ "pkX": _OTHER, "nonceNode": _NONCE, "ts": 1_700_000_000},
+ "device_revoke": {"nodePk": "Tk9ERVBL", "userId": "grenet", "pkEd": _OTHER,
+ "nonceNode": _NONCE, "ts": 1_700_000_000},
+ "chat": {"groupId": "g" * 32, "epoch": 4,
+ "nonce": base64.b64encode(b"\x01" * 12).decode(),
+ "ct": base64.b64encode(b"ciphertext").decode()},
+ "admin": {"op": "root_add", "nodePk": "Tk9ERVBL", "groupId": "g" * 32,
+ "subject": '{"path":"/café"}', "nonce": _NONCE, "ts": 1_700_000_000},
+}
+
+
+@pytest.fixture(scope="module")
+def kinds_js(tmp_path_factory):
+ d = tmp_path_factory.mktemp("kinds")
+ (d / "harness.js").write_text(_KINDS_HARNESS)
+ (d / "input.json").write_text(json.dumps({"fields": _KIND_FIELDS, "own": _OWN}))
+ proc = subprocess.run(["node", str(d / "harness.js"), str(CRYPTO_JS), str(d / "input.json")],
+ capture_output=True, text=True, timeout=60)
+ if proc.returncode != 0:
+ pytest.fail(f"node harness failed:\n{proc.stderr}")
+ return json.loads(proc.stdout)
+
+
+def _python_kind(kind):
+ from meshbay_common.chatbox import signing_transcript
+ from meshbay_common.device import (
+ device_add_transcript,
+ device_hello_transcript,
+ device_request_transcript,
+ device_revoke_transcript,
+ )
+ f = _KIND_FIELDS[kind]
+ nonce = base64.b64decode(f.get("nonceNode", ""))
+ ed, x = _OWN["pkEdB64"], _OWN["pkXB64"]
+ return {
+ "join": lambda: join_transcript(f["nodePk"], f["groupId"], f["userId"], ed, x,
+ nonce, f["ts"]),
+ "device_hello": lambda: device_hello_transcript(f["nodePk"], f["groupId"], f["userId"],
+ ed, nonce, f["ts"]),
+ "device_request": lambda: device_request_transcript(f["nodePk"], f["userId"], ed, x,
+ f["codeHash"], nonce, f["ts"]),
+ "device_add": lambda: device_add_transcript(f["nodePk"], f["userId"], f["pkEd"],
+ f["pkX"], nonce, f["ts"]),
+ "device_revoke": lambda: device_revoke_transcript(f["nodePk"], f["userId"], f["pkEd"],
+ nonce, f["ts"]),
+ "chat": lambda: signing_transcript(f["groupId"], f["epoch"], base64.b64decode(ed),
+ base64.b64decode(f["nonce"]),
+ base64.b64decode(f["ct"])),
+ "admin": lambda: admin_transcript(f["op"], f["nodePk"], f["groupId"], f["subject"],
+ base64.b64decode(f["nonce"]), f["ts"]),
+ }[kind]()
+
+
+@pytest.mark.parametrize("kind", sorted(_KIND_FIELDS))
+def test_every_signed_kind_is_byte_identical(kind, kinds_js):
+ assert kinds_js[kind] == _python_kind(kind).hex(), kind
+
+
+def test_a_retirement_is_not_an_admission(kinds_js):
+ assert kinds_js["device_revoke"] != kinds_js["device_add"]
+
+
+def test_an_unknown_kind_is_not_signed(kinds_js):
+ assert "nothing is signed as" in kinds_js["raw"]
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index 27e97d3..c239cc8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -451,6 +451,7 @@ 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');
const DEVICE_HELLO_PREFIX = new TextEncoder().encode('meshbay:device_hello:v1');
+const DEVICE_REVOKE_PREFIX = new TextEncoder().encode('meshbay:device_revoke:v1');
function joinTranscript(nodePkB64, groupId, userId, pkEdB64, pkXB64, nonceNode, ts) {
const enc = new TextEncoder();
@@ -503,6 +504,22 @@ function deviceAddTranscript(nodePkB64, userId, pkEdB64, pkXB64, nonceNode, ts)
}
/**
+ * Retiring one of the account's devices. A prefix of its own: were it the
+ * admission transcript, a signature given to retire a key would admit it.
+ */
+function deviceRevokeTranscript(nodePkB64, userId, pkEdB64, nonceNode, ts) {
+ const enc = new TextEncoder();
+ const body = _lenPrefixed([
+ enc.encode(nodePkB64), enc.encode(userId), enc.encode(pkEdB64),
+ nonceNode, enc.encode(String(ts)),
+ ]);
+ const out = new Uint8Array(DEVICE_REVOKE_PREFIX.length + body.length);
+ out.set(DEVICE_REVOKE_PREFIX, 0);
+ out.set(body, DEVICE_REVOKE_PREFIX.length);
+ return out;
+}
+
+/**
* "Which of this account's devices am I?", mirroring
* `meshbay_common/device.py:device_hello_transcript`.
*
@@ -560,6 +577,42 @@ function constantTimeEqual(a, b) {
return diff === 0;
}
+/**
+ * What an identity signs, by kind. The only way anything here gets signed with
+ * an identity: a caller names what it is signing and gives the fields, and the
+ * bytes are built from them — with the identity's own public keys wherever a
+ * transcript names them. The desktop application builds the same bytes in its
+ * main process (meshbay-client/src/transcripts.js) and signs nothing else,
+ * which is what makes a signature from it mean what its kind says.
+ *
+ * Bytes cross as base64: `nonceNode`, `nonce`, `ct`.
+ */
+function transcriptFor(kind, f, own) {
+ const nonceNode = () => b64decode(f.nonceNode);
+ switch (kind) {
+ case 'join':
+ return joinTranscript(f.nodePk, f.groupId || '', f.userId, own.pkEdB64, own.pkXB64,
+ nonceNode(), f.ts);
+ case 'device_hello':
+ return deviceHelloTranscript(f.nodePk, f.groupId || '', f.userId, own.pkEdB64,
+ nonceNode(), f.ts);
+ case 'device_request':
+ return deviceRequestTranscript(f.nodePk, f.userId, own.pkEdB64, own.pkXB64,
+ f.codeHash, nonceNode(), f.ts);
+ case 'device_add':
+ return deviceAddTranscript(f.nodePk, f.userId, f.pkEd, f.pkX, nonceNode(), f.ts);
+ case 'device_revoke':
+ return deviceRevokeTranscript(f.nodePk, f.userId, f.pkEd, nonceNode(), f.ts);
+ case 'chat':
+ return chatSigningTranscript(f.groupId || '', f.epoch, b64decode(own.pkEdB64),
+ b64decode(f.nonce), b64decode(f.ct));
+ case 'admin':
+ return adminTranscript(f.op, f.nodePk, f.groupId || '', f.subject, f.nonce, f.ts);
+ default:
+ throw new Error(`Refused: nothing is signed as "${kind}"`);
+ }
+}
+
/** Verify the node's Ed25519 signature over the handshake transcript (C3). */
async function verifyNodeSignature(nodePkB64, sigB64, transcript) {
const raw = b64decode(nodePkB64);
@@ -576,6 +629,7 @@ window.MeshBayCrypto = {
inviteCreateSubject, tmdbConfigSubject, handshakeTranscript, handshakeProof, webrtcBinding,
challengeTranscript, joinTranscript, verifyNodeSignature, constantTimeEqual,
deviceRequestTranscript, deviceAddTranscript, deviceHelloTranscript,
+ deviceRevokeTranscript, transcriptFor,
deviceCodeHash,
sealChat, openChat, chatSigningTranscript, verifyChatSignature,
normalizeCode,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
index d5226fc..1a5a4c0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
@@ -32,9 +32,10 @@ extendTransport(class {
const ts = Math.floor(Date.now() / 1000);
// group_id is empty: operator authority is node-wide, not per group.
- const transcript = C.joinTranscript(
- this.nodePk, '', userId, pkEdB64, pkXB64, this._nonceNode, ts);
- const sig = await this._identity.sign(transcript);
+ const sig = await this._identity.signAs('join', {
+ nodePk: this.nodePk, groupId: '', userId,
+ nonceNode: C.b64encode(this._nonceNode), ts,
+ });
const resp = await this._sendAndWait({
type: 'join_request',
@@ -107,11 +108,12 @@ extendTransport(class {
}
if (!signFn) throw new Error('Admin challenge received but no signing key available');
- const transcript = window.MeshBayCrypto.adminTranscript(
- challenge.op, challenge.node_pk, challenge.group_id,
- challenge.subject, challenge.nonce, challenge.ts);
-
- const signature = await signFn(transcript);
+ // The fields, not the bytes: whatever holds the key builds the transcript
+ // from them (crypto.js, transcriptFor).
+ const signature = await signFn({
+ op: challenge.op, nodePk: challenge.node_pk, groupId: challenge.group_id,
+ subject: challenge.subject, nonce: challenge.nonce, ts: challenge.ts,
+ });
console.log('[MeshBay] _authorizeAdminOp: signed', challenge.op, 'op_id=', challenge.op_id,
'— sending admin_response');
const ack = await this._sendAndWait({
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
index f0604ce..e49eb4c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
@@ -172,8 +172,11 @@ extendTransport(class {
const { nonce, ct } = await C.sealChat(
epochKey, gid, epoch, this.devicePk, plaintext);
const device = C.b64decode(this.devicePk);
- const sig = C.b64decode(await this._identity.sign(
- C.chatSigningTranscript(gid, epoch, device, nonce, ct)));
+ // The transcript names the signing device as this identity's own key,
+ // which is what `devicePk` is once the node has been told (device_hello).
+ const sig = C.b64decode(await this._identity.signAs('chat', {
+ groupId: gid, epoch, nonce: C.b64encode(nonce), ct: C.b64encode(ct),
+ }));
const msg = await this._sendAndWait({
type: 'chat_msg',
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 1cb248a..1c6fc78 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
@@ -105,9 +105,10 @@ extendTransport(class {
const { pkEdB64, pkXB64 } = this._identity;
const ts = Math.floor(Date.now() / 1000);
- const transcript = C.joinTranscript(
- this.nodePk, groupId || '', userId, pkEdB64, pkXB64, this._nonceNode, ts);
- const sig = await this._identity.sign(transcript);
+ const sig = await this._identity.signAs('join', {
+ nodePk: this.nodePk, groupId: groupId || '', userId,
+ nonceNode: C.b64encode(this._nonceNode), ts,
+ });
const resp = await this._sendAndWait({
type: 'join_request',
@@ -171,9 +172,10 @@ extendTransport(class {
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 this._identity.sign(transcript);
+ const sig = await this._identity.signAs('device_request', {
+ nodePk: this.nodePk, userId, codeHash,
+ nonceNode: C.b64encode(this._nonceNode), ts,
+ });
const resp = await this._sendAndWait({
type: 'device_add_request', v: '0.1',
@@ -225,9 +227,10 @@ extendTransport(class {
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 this._identity.sign(transcript);
+ const sig = await this._identity.signAs('device_add', {
+ nodePk: this.nodePk, userId, pkEd: pkEdB64, pkX: pkXB64,
+ nonceNode: C.b64encode(this._nonceNode), ts,
+ });
const resp = await this._sendAndWait({
type: 'device_add', v: '0.1',
pk_ed25519: pkEdB64, pk_x25519: pkXB64, code_hash: codeHash, ts, sig,
@@ -246,9 +249,10 @@ extendTransport(class {
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 this._identity.sign(transcript);
+ const sig = await this._identity.signAs('device_revoke', {
+ nodePk: this.nodePk, userId, pkEd: pkEdB64,
+ nonceNode: C.b64encode(this._nonceNode), ts,
+ });
const resp = await this._sendAndWait({
type: 'device_revoke', v: '0.1', pk_ed25519: pkEdB64, ts, sig,
});
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 17a8e5d..9b86921 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -52,10 +52,13 @@ async function _pkEdFromSk(skPkcs8B64) {
*/
async function _identityFromKeys(skEdB64, skXB64) {
const skXRaw = Uint8Array.from(atob(skXB64), c => c.charCodeAt(0));
+ const own = { pkEdB64: await _pkEdFromSk(skEdB64), pkXB64: await _pkFromSk(skXB64) };
return {
- pkEdB64: await _pkEdFromSk(skEdB64),
- pkXB64: await _pkFromSk(skXB64),
- sign: (bytes) => window.MeshBayKeys.signBytes(skEdB64, bytes),
+ ...own,
+ // By kind and fields, never over bytes a caller chose (crypto.js,
+ // transcriptFor) — the same contract the desktop's main process keeps.
+ signAs: (kind, fields) => window.MeshBayKeys.signBytes(
+ skEdB64, window.MeshBayCrypto.transcriptFor(kind, fields, own)),
async shared(peerPkRaw) {
const sk = await crypto.subtle.importKey(
'pkcs8', skXRaw, { name: 'X25519' }, false, ['deriveBits']);
@@ -77,7 +80,8 @@ function _nativeIdentityHandle(keys, userId, nodePk, pub) {
pkXB64: pub.pkXB64,
sealedWith: pub.sealedWith || null,
native: true,
- sign: (bytes) => keys.sign(userId, nodePk, b64(bytes)),
+ // The main process builds the bytes from the kind and the fields.
+ signAs: (kind, fields) => keys.sign(userId, nodePk, kind, fields),
async shared(peerPkRaw) {
const out = await keys.shared(userId, nodePk, b64(peerPkRaw));
return Uint8Array.from(atob(out), c => c.charCodeAt(0)).buffer;
@@ -672,10 +676,10 @@ class MeshBayTransport {
set onNeedToken(fn) { this._onNeedToken = fn; }
get identity() { return this._identity; }
- /** Signs with this node's identity, or null when there is none yet. */
+ /** Signs an admin operation with this node's identity, or null when there is none yet. */
get signFn() {
const id = this._identity;
- return id ? (transcript) => id.sign(transcript) : null;
+ return id ? (fields) => id.signAs('admin', fields) : null;
}
/** Set on a first join: the identity created for this node, still to be left with it. */
@@ -1343,10 +1347,10 @@ class MeshBayTransport {
// substitution this mechanism exists to close.
const pkEdB64 = this._identity.pkEdB64;
const ts = Math.floor(Date.now() / 1000);
- const transcript = C.deviceHelloTranscript(
- this.nodePk, this._groupId || '', this._userId, pkEdB64,
- this._nonceNode, ts);
- const sig = await this._identity.sign(transcript);
+ const sig = await this._identity.signAs('device_hello', {
+ nodePk: this.nodePk, groupId: this._groupId || '', userId: this._userId,
+ nonceNode: C.b64encode(this._nonceNode), ts,
+ });
const resp = await this._sendAndWait({
type: 'device_hello', v: '2.0', pk_ed25519: pkEdB64, ts, sig,
diff --git a/packages/meshbay-hub/tests/harness/chat_send_probe.py b/packages/meshbay-hub/tests/harness/chat_send_probe.py
index e5cfc8b..e7f1dae 100644
--- a/packages/meshbay-hub/tests/harness/chat_send_probe.py
+++ b/packages/meshbay-hub/tests/harness/chat_send_probe.py
@@ -172,7 +172,9 @@ function makeTransport(name, chatReply) {
tp._gekRaw = hex('__GEK_HEX__');
tp.chatEpoch = 1;
tp._identity = { pkEdB64: DEVICE_PK_B64,
- sign: (bytes) => window.MeshBayKeys.signBytes(SK_ED_B64, bytes) };
+ signAs: (kind, fields) => window.MeshBayKeys.signBytes(SK_ED_B64,
+ window.MeshBayCrypto.transcriptFor(kind, fields,
+ { pkEdB64: DEVICE_PK_B64 })) };
tp.devicePk = DEVICE_PK_B64;
tp._send = (obj) => {
log.push('sent ' + obj.type);
diff --git a/packages/meshbay-hub/tests/test_desktop_keyring.py b/packages/meshbay-hub/tests/test_desktop_keyring.py
index 673a00e..963ee55 100644
--- a/packages/meshbay-hub/tests/test_desktop_keyring.py
+++ b/packages/meshbay-hub/tests/test_desktop_keyring.py
@@ -62,9 +62,39 @@ const v = JSON.parse(fs.readFileSync(input, 'utf8'));
out.sealed_here = ring.sealBundle(v.userId, v.node).bundle;
out.playlist_key = ring.playlistKey(v.userId);
- // 2. a signature and an X25519 agreement that the other side can check.
- const msg = Buffer.from('a transcript');
- out.sig = ring.sign(v.userId, v.node, msg.toString('base64'));
+ // 2. signatures by kind, built here from fields, for the reference to check;
+ // and an X25519 agreement that the other side can check.
+ const ts = Math.floor(Date.now() / 1000);
+ const nonceNode = Buffer.alloc(32, 7).toString('base64');
+ const other = require('crypto').generateKeyPairSync('ed25519').publicKey
+ .export({ format: 'der', type: 'spki' }).subarray(12).toString('base64');
+ const F = {
+ join: { nodePk: v.node, groupId: 'grp-1', userId: v.userId, nonceNode, ts },
+ device_hello: { nodePk: v.node, groupId: 'grp-1', userId: v.userId, nonceNode, ts },
+ device_request: { nodePk: v.node, userId: v.userId, codeHash: 'ab'.repeat(32), nonceNode, ts },
+ device_add: { nodePk: v.node, userId: v.userId, pkEd: other, pkX: other, nonceNode, ts },
+ device_revoke: { nodePk: v.node, userId: v.userId, pkEd: other, nonceNode, ts },
+ chat: { groupId: 'grp-1', epoch: 3, nonce: Buffer.alloc(12, 1).toString('base64'),
+ ct: Buffer.from('ciphertext').toString('base64') },
+ admin: { op: 'file_delete', nodePk: v.node, groupId: 'grp-1', subject: 'file-9',
+ nonce: Buffer.alloc(32, 2).toString('base64'), ts },
+ };
+ out.fields = F; out.signed = {};
+ for (const [kind, f] of Object.entries(F)) {
+ out.signed[kind] = ring.signAs(v.userId, v.node, kind, f);
+ }
+
+ const refusal = async (kind, f) => {
+ try { await ring.signAs(v.userId, v.node, kind, f); return null; }
+ catch (e) { return e.code || e.message; }
+ };
+ out.refused = {
+ bytes: await refusal('raw', { bytes: 'YQ==' }),
+ other_node: await refusal('join', { ...F.join, nodePk: 'T3RoZXJOb2Rl' }),
+ other_account: await refusal('join', { ...F.join, userId: 'someone-else' }),
+ stale: await refusal('join', { ...F.join, ts: ts - 3600 }),
+ };
+
const eph = require('crypto').generateKeyPairSync('x25519');
const ephPub = eph.publicKey.export({ format: 'der', type: 'spki' }).subarray(12);
out.shared_here = ring.shared(v.userId, v.node, ephPub.toString('base64'));
@@ -94,10 +124,16 @@ const v = JSON.parse(fs.readFileSync(input, 'utf8'));
out.retired = (() => { try { ring.openBundle(v.userId, 'NODE-R',
{ bundleEnc: Buffer.from('MBK2' + 'x'.repeat(40)).toString('base64') }); }
catch (e) { return e.code; } })();
+ out.access_default = ring.browserAccess('someone-else');
+ ring.setBrowserAccess(v.userId, false);
+ const refusedSeal = (fn) => { try { fn(); return null; } catch (e) { return e.message; } };
+ out.sealing_while_access_off = {
+ bundle: refusedSeal(() => ring.sealBundle(v.userId, v.node)),
+ recovery: refusedSeal(() => ring.sealRecovery(v.userId, v.node, 'A'.repeat(56), v.user)),
+ };
ring.forgetSession(v.userId);
out.after_sign_out = { session: ring.hasSession(v.userId),
identity_kept: !!ring.identity(v.userId, v.node) };
- out.access_default = ring.browserAccess('someone-else');
ring.setBrowserAccess(v.userId, false);
out.access_after_off = ring.browserAccess(v.userId);
out.stored_json = JSON.stringify(store);
@@ -155,10 +191,66 @@ def test_the_playlist_key_is_the_pages(out):
_reference_master(), "meshbay:playlists:v2")
-def test_signatures_and_agreements_check_out_with_the_public_keys(out):
+def _reference_transcript(kind, f, pub):
+ from meshbay_common.adminop import admin_transcript
+ from meshbay_common.chatbox import signing_transcript
+ from meshbay_common.device import (
+ device_add_transcript,
+ device_hello_transcript,
+ device_request_transcript,
+ device_revoke_transcript,
+ )
+ from meshbay_common.join import join_transcript
+ nonce_node = base64.b64decode(f.get("nonceNode", ""))
+ ed, x = pub["pkEdB64"], pub["pkXB64"]
+ return {
+ "join": lambda: join_transcript(f["nodePk"], f["groupId"], f["userId"], ed, x,
+ nonce_node, f["ts"]),
+ "device_hello": lambda: device_hello_transcript(f["nodePk"], f["groupId"],
+ f["userId"], ed, nonce_node, f["ts"]),
+ "device_request": lambda: device_request_transcript(
+ f["nodePk"], f["userId"], ed, x, f["codeHash"], nonce_node, f["ts"]),
+ "device_add": lambda: device_add_transcript(f["nodePk"], f["userId"], f["pkEd"],
+ f["pkX"], nonce_node, f["ts"]),
+ "device_revoke": lambda: device_revoke_transcript(f["nodePk"], f["userId"], f["pkEd"],
+ nonce_node, f["ts"]),
+ "chat": lambda: signing_transcript(f["groupId"], f["epoch"], base64.b64decode(ed),
+ base64.b64decode(f["nonce"]),
+ base64.b64decode(f["ct"])),
+ "admin": lambda: admin_transcript(f["op"], f["nodePk"], f["groupId"], f["subject"],
+ base64.b64decode(f["nonce"]), f["ts"]),
+ }[kind]()
+
+
+def test_every_kind_signs_the_specifications_bytes(out):
+ """
+ The keyring builds the transcript itself from fields; what it signs must be
+ exactly what meshbay_common builds, or the node refuses every join, chat
+ line and admin operation from the desktop application.
+ """
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
- Ed25519PublicKey.from_public_bytes(base64.b64decode(out["minted_pub"]["pkEdB64"])).verify(
- base64.b64decode(out["sig"]), b"a transcript")
+ pub = out["minted_pub"]
+ key = Ed25519PublicKey.from_public_bytes(base64.b64decode(pub["pkEdB64"]))
+ assert set(out["signed"]) == {"join", "device_hello", "device_request", "device_add",
+ "device_revoke", "chat", "admin"}
+ for kind, sig in out["signed"].items():
+ key.verify(base64.b64decode(sig), _reference_transcript(kind, out["fields"][kind], pub))
+
+
+def test_the_page_names_a_kind_and_never_the_bytes(out):
+ r = out["refused"]
+ assert "nothing is signed as" in r["bytes"]
+ assert "another node" in r["other_node"]
+ assert "another account" in r["other_account"]
+ assert "not now" in r["stale"]
+
+
+def test_nothing_is_sealed_for_a_browser_while_browser_access_is_off(out):
+ for what, err in out["sealing_while_access_off"].items():
+ assert err and "browser access is off" in err, what
+
+
+def test_agreements_check_out_with_the_public_keys(out):
assert out["shared_here"] == out["shared_there"]
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index 8144373..0116aaa 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -512,19 +512,22 @@ class Roster:
await self._db.commit()
return expires
- async def take_device_request(self, code_hash: str,
- user_id: str) -> dict | None:
+ async def take_device_request(self, code_hash: str, user_id: str,
+ pk_ed25519: str, pk_x25519: str) -> dict | None:
"""
- Claim a pending request by its hash, for this account only.
+ Claim a pending request by its hash, for this account and these keys.
Single use and scoped to the account: a request filed for one person
cannot be redeemed by another even with the code, and a code that has
- been spent is gone.
+ been spent is gone. Scoped to the keys too: the request is what the new
+ device filed and signed, so an approval redeeming it admits those keys
+ and no others.
"""
assert self._db
async with self._db.execute(
"SELECT * FROM device_requests WHERE code_hash = ? AND user_id = ? "
- "AND expires_at > ?", (code_hash, user_id, _now())
+ "AND pk_ed25519 = ? AND pk_x25519 = ? AND expires_at > ?",
+ (code_hash, user_id, pk_ed25519, pk_x25519, _now())
) as cur:
row = await cur.fetchone()
if row is None:
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py
index f94cade..20ebc79 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py
@@ -20,6 +20,7 @@ from meshbay_common.device import (
device_add_transcript,
device_hello_transcript,
device_request_transcript,
+ device_revoke_transcript,
)
from meshbay_common.join import JOIN_TTL, ROLE_MEMBER, ROLE_OPERATOR, join_transcript
from meshbay_common.protocol import MNP
@@ -516,10 +517,17 @@ class AdmissionMixin:
pk_ed_b64 = str(msg.get("pk_ed25519", ""))
pk_x_b64 = str(msg.get("pk_x25519", ""))
+ code_hash = str(msg.get("code_hash", ""))
ts = int(msg.get("ts", 0))
if not (pk_ed_b64 and pk_x_b64):
self._send({"type": "error", "detail": "Missing device keys"})
return
+ # An approval answers a request the new device filed and signed with
+ # these keys. Without one, a countersignature alone — obtained however
+ # it was — would admit any key its holder chose.
+ if not code_hash:
+ self._send({"type": "error", "detail": "No pending request named"})
+ return
if abs(time.time() - ts) > DEVICE_TTL:
self._send({"type": "error", "detail": "Approval expired"})
return
@@ -543,9 +551,8 @@ class AdmissionMixin:
# Spend the request. Single use: an approval cannot be replayed, and a
# code that was used is gone whatever else happens next.
- code_hash = str(msg.get("code_hash", ""))
- if code_hash and not await roster.take_device_request(
- code_hash, self._user_id):
+ if not await roster.take_device_request(
+ code_hash, self._user_id, pk_ed_b64, pk_x_b64):
self._send({"type": "error",
"detail": "That request is no longer pending"})
return
@@ -696,10 +703,9 @@ class AdmissionMixin:
self._send({"type": "error", "detail": "No such device"})
return
- transcript = device_add_transcript(
+ transcript = device_revoke_transcript(
node_pk_b64=self._node_pk_b64(), user_id=self._user_id,
- pk_ed25519_b64=target, pk_x25519_b64=victim["pk_x25519"],
- nonce_node=self._nonce_node, ts=ts)
+ pk_ed25519_b64=target, nonce_node=self._nonce_node, ts=ts)
signer = await self._verify_device_signer(roster, transcript,
msg.get("sig", ""))
if signer is None:
diff --git a/packages/meshbay-node/tests/test_device_linking.py b/packages/meshbay-node/tests/test_device_linking.py
index 53387ca..344c252 100644
--- a/packages/meshbay-node/tests/test_device_linking.py
+++ b/packages/meshbay-node/tests/test_device_linking.py
@@ -30,6 +30,7 @@ from meshbay_common.device import (
device_add_transcript,
device_code_hash,
device_request_transcript,
+ device_revoke_transcript,
)
from meshbay_common.join import ROLE_MEMBER
from meshbay_common.protocol import MNP
@@ -227,8 +228,8 @@ async def test_the_new_device_cannot_approve_itself(tmp_path, roster):
sk_new, pk_new_ed, pk_new_x = _keys()
session = await _session(tmp_path, roster)
- await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code())
- await _approve(session, sk_new, pk_new_ed, pk_new_x)
+ code_hash = await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code())
+ await _approve(session, sk_new, pk_new_ed, pk_new_x, code_hash=code_hash)
assert _last(session)["type"] == "error"
assert await roster.find_device("alice", pk_new_ed) is None
@@ -240,10 +241,11 @@ async def test_a_stranger_cannot_approve(tmp_path, roster):
await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code")
sk_bob, pk_bob_ed, pk_bob_x = _keys()
await roster.pin_identity("bob", "bob", pk_bob_ed, pk_bob_x, "code")
- _, pk_new_ed, pk_new_x = _keys()
+ sk_new, pk_new_ed, pk_new_x = _keys()
session = await _session(tmp_path, roster)
- await _approve(session, sk_bob, pk_new_ed, pk_new_x)
+ code_hash = await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code())
+ await _approve(session, sk_bob, pk_new_ed, pk_new_x, code_hash=code_hash)
assert _last(session)["type"] == "error"
assert await roster.find_device("alice", pk_new_ed) is None
@@ -260,9 +262,10 @@ async def test_a_revoked_device_cannot_admit_its_replacement(tmp_path, roster):
await roster.pin_identity("alice", "alice", pk_keep_ed, pk_keep_x, "device")
await roster.revoke_device("alice", pk_lost_ed)
- _, pk_new_ed, pk_new_x = _keys()
+ sk_new, pk_new_ed, pk_new_x = _keys()
session = await _session(tmp_path, roster)
- await _approve(session, sk_lost, pk_new_ed, pk_new_x)
+ code_hash = await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code())
+ await _approve(session, sk_lost, pk_new_ed, pk_new_x, code_hash=code_hash)
assert _last(session)["type"] == "error"
assert await roster.find_device("alice", pk_new_ed) is None
@@ -416,10 +419,9 @@ async def test_your_last_device_cannot_be_revoked(tmp_path, roster):
session = await _session(tmp_path, roster)
ts = int(time.time())
- transcript = device_add_transcript(
+ transcript = device_revoke_transcript(
node_pk_b64=session._node_pk_b64(), user_id="alice",
- pk_ed25519_b64=pk_only_ed, pk_x25519_b64=pk_only_x,
- nonce_node=NONCE, ts=ts)
+ pk_ed25519_b64=pk_only_ed, nonce_node=NONCE, ts=ts)
await session._do_device_revoke({
"pk_ed25519": pk_only_ed, "ts": ts,
"sig": base64.b64encode(sk_only.sign(transcript)).decode()})
@@ -438,3 +440,93 @@ async def test_unpinning_an_account_takes_every_device(tmp_path, roster):
assert len(await roster.list_devices("alice")) == 3
await roster.unpin("alice")
assert await roster.list_devices("alice") == []
+
+
+# ── An approval is an answer to a request, and nothing else ─────────────────
+
+async def test_a_countersignature_without_a_request_admits_nothing(tmp_path, roster):
+ """
+ A pinned device's signature over keys nobody asked to add. Whoever obtained
+ it — a page that got a device to sign — must not be able to admit a key of
+ their choosing with it.
+ """
+ sk_old, pk_old_ed, pk_old_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code")
+ _, pk_new_ed, pk_new_x = _keys()
+
+ session = await _session(tmp_path, roster)
+ await _approve(session, sk_old, pk_new_ed, pk_new_x)
+
+ assert _last(session)["type"] == "error"
+ assert await roster.find_device("alice", pk_new_ed) is None
+
+
+async def test_a_request_admits_only_the_keys_that_filed_it(tmp_path, roster):
+ """One device's pending request is not a ticket for another key."""
+ sk_old, pk_old_ed, pk_old_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code")
+ sk_asking, pk_asking_ed, pk_asking_x = _keys()
+ _, pk_other_ed, pk_other_x = _keys()
+
+ session = await _session(tmp_path, roster)
+ code_hash = await _file_request(session, sk_asking, pk_asking_ed, pk_asking_x,
+ generate_code())
+ await _approve(session, sk_old, pk_other_ed, pk_other_x, code_hash=code_hash)
+
+ assert _last(session)["type"] == "error"
+ assert await roster.find_device("alice", pk_other_ed) is None
+ # And the request was not spent by the attempt.
+ await _approve(session, sk_old, pk_asking_ed, pk_asking_x, code_hash=code_hash)
+ assert _last(session)["type"] == MNP.DEVICE_ADD_ACK
+
+
+async def test_a_retirement_signature_admits_nothing(tmp_path, roster):
+ """
+ Retiring and admitting are signed under different prefixes: a signature
+ given to retire a key cannot be presented as the approval of that key.
+ """
+ sk_old, pk_old_ed, pk_old_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_old_ed, pk_old_x, "code")
+ sk_new, pk_new_ed, pk_new_x = _keys()
+
+ session = await _session(tmp_path, roster)
+ code_hash = await _file_request(session, sk_new, pk_new_ed, pk_new_x, generate_code())
+ ts = int(time.time())
+ retire = device_revoke_transcript(
+ node_pk_b64=session._node_pk_b64(), user_id="alice",
+ pk_ed25519_b64=pk_new_ed, nonce_node=NONCE, ts=ts)
+ await session._do_device_add({
+ "pk_ed25519": pk_new_ed, "pk_x25519": pk_new_x, "ts": ts,
+ "code_hash": code_hash,
+ "sig": base64.b64encode(sk_old.sign(retire)).decode(),
+ })
+
+ assert _last(session)["type"] == "error"
+ assert await roster.find_device("alice", pk_new_ed) is None
+
+
+async def test_a_device_is_retired_with_the_retirement_signature(tmp_path, roster):
+ sk_a, pk_a_ed, pk_a_x = _keys()
+ _, pk_b_ed, pk_b_x = _keys()
+ await roster.pin_identity("alice", "alice", pk_a_ed, pk_a_x, "code")
+ await roster.pin_identity("alice", "alice", pk_b_ed, pk_b_x, "device")
+ session = await _session(tmp_path, roster)
+
+ ts = int(time.time())
+ admit = device_add_transcript(
+ node_pk_b64=session._node_pk_b64(), user_id="alice",
+ pk_ed25519_b64=pk_b_ed, pk_x25519_b64=pk_b_x, nonce_node=NONCE, ts=ts)
+ await session._do_device_revoke({
+ "pk_ed25519": pk_b_ed, "ts": ts,
+ "sig": base64.b64encode(sk_a.sign(admit)).decode()})
+ assert _last(session)["type"] == "error", "an admission signature retired a device"
+ assert await roster.find_device("alice", pk_b_ed) is not None
+
+ retire = device_revoke_transcript(
+ node_pk_b64=session._node_pk_b64(), user_id="alice",
+ pk_ed25519_b64=pk_b_ed, nonce_node=NONCE, ts=ts)
+ await session._do_device_revoke({
+ "pk_ed25519": pk_b_ed, "ts": ts,
+ "sig": base64.b64encode(sk_a.sign(retire)).decode()})
+ assert _last(session)["type"] != "error", _last(session)
+ assert await roster.find_device("alice", pk_b_ed) is None
diff --git a/packages/meshbay-node/tests/test_group_roster.py b/packages/meshbay-node/tests/test_group_roster.py
index 74908e7..8028bed 100644
--- a/packages/meshbay-node/tests/test_group_roster.py
+++ b/packages/meshbay-node/tests/test_group_roster.py
@@ -81,8 +81,13 @@ async def _add_device(session, roster, approver_sk, approver_pk, new_pk, new_px,
node_pk_b64=session._node_pk_b64(), user_id=user_id,
pk_ed25519_b64=new_pk, pk_x25519_b64=new_px, nonce_node=NONCE, ts=ts)
session._user_id = user_id
+ # The request the new device files first; an approval answers one.
+ code_hash = "c" * 64
+ await roster.file_device_request(user_id=user_id, username=user_id,
+ pk_ed25519=new_pk, pk_x25519=new_px,
+ code_hash=code_hash, ttl=600)
await session._do_device_add({
- "pk_ed25519": new_pk, "pk_x25519": new_px, "ts": ts,
+ "pk_ed25519": new_pk, "pk_x25519": new_px, "ts": ts, "code_hash": code_hash,
"sig": base64.b64encode(approver_sk.sign(transcript)).decode(),
})
return ts