aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 21:04:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 21:04:39 +0200
commit0378e8e0912a1a7e6cea4424e69d524e7afecbf8 (patch)
tree4ae94e32d6638b4c2cc1ae4f74cbe5d00c940636 /packages/meshbay-node
parent0ed56d3a1b4f71cf622d3e27edc87a15ef33c185 (diff)
downloadmeshbay-0378e8e0912a1a7e6cea4424e69d524e7afecbf8.tar.gz
fix: an identity signs a named kind, and a device approval answers a request
The desktop main process builds every transcript itself from fields (transcripts.js) and signs no raw bytes; the page's identity has the same contract (crypto.js transcriptFor). The keyring seals no bundle while browser access is off. On the node, device_add must redeem a pending request filed by the same keys, and device_revoke is signed under its own prefix (meshbay:device_revoke:v1), so a retirement signature admits nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node')
-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
4 files changed, 127 insertions, 21 deletions
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