<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/src/meshbay_hub/static/crypto.js, branch 0.16</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.16</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.16'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-09-23T15:14:26Z</updated>
<entry>
<title>feat: the node signs its handshake challenge (MNP 3.4)</title>
<updated>2026-09-23T15:14:26Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-23T15:14:26Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=339cb427f886a0177014126bb684335837eff067'/>
<id>urn:sha1:339cb427f886a0177014126bb684335837eff067</id>
<content type='text'>
node_pk in handshake_challenge is now signed over the channel binding
and both nonces, so a client can check the node key before a join
rather than only at the ack. Both transports; the browser and the QUIC
client refuse a wrong signature and treat an absent one as an older node.

Co-Authored-By: Claude Opus 5.5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(chat): Tier 2 — a member verifies another member's device itself</title>
<updated>2026-09-07T19:04:56Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-07T19:04:56Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=3bd31db9d4fa2352f1095dcc630c77915d0774b8'/>
<id>urn:sha1:3bd31db9d4fa2352f1095dcc630c77915d0774b8</id>
<content type='text'>
Chat messages have been signed by the sending device since MNP 2.0, but a
reader had no way to know that the device belonged to the account the node
named: the signature proved *a device*, and `sender_id` was still the node's
word. This closes that for any account a client has already seen.

**What was blocking it was not effort — the evidence was not being kept.**
`_do_device_add` verified the countersignature that admits a second device and
stored only `added_by_pk`: *which* key approved, never the proof. And
`device_add_transcript` binds `nonce_node`, the approving connection's handshake
nonce, so even a stored signature was unverifiable by anyone who had not been on
that connection. `identities` gains `add_sig`, `add_nonce` and `add_ts`, added
before the migration's early return — which fires on every roster widened since
2026-08-18, i.e. all of them, so putting them inside it would have meant they
never arrived.

`group_roster_req`/`resp` relays, sealed under a new groupbox purpose and
answered to **any member of the group**, every live device of every active
member with the evidence that admitted it. The node decides nothing: it hands
over evidence and the client walks the chain from each account's root outwards
(`_verifyRoster`). That is deliberate — the node is the party the property holds
against, so it is not asked to assert trust.

Two holes the tests caught while this was being built:

  - "no signature" was being treated as a trust root, so a node that writes the
    roster could put any key in an account's row and have it laundered straight
    into the verified set. A root is a device that names **no** countersigner.
  - pinning only the verified subset at first sight raised "key changed" on
    legitimate second devices whose countersignature predates this change. First
    sight pins everything the node says, because that is what trust-on-first-use
    means and an alarm that fires on normal events stops being read.

The property, and it must not be rounded up: **once a client has seen an
account, a node that later substitutes a key for it is detected. Nothing is
gained at first sight**, where there is nothing to compare against — the same
boundary `per-node-identity-v1.md` draws, unmoved.

The cost, stated because it is real: the roster is member-visible, so every
member learns how many devices the others hold and their public keys. It stays
inside the group, the hub is not involved, and it is scoped per group. A member
who cannot see the keys cannot check them.

User-visible surface: one notice, "this account is using a key you have not seen
before", in ten languages. Nothing else.

16 tests — 7 on the node (the evidence is stored, it verifies from the roster
alone, a fabricated device carries none, another group's members are not
disclosed), 9 running the shipped `_verifyRoster` under node against rosters
built by the shipped Python: a chain of three in any order, a signature by the
wrong key, one for another node, one for another account, and two fabricated
devices signing each other admitting nothing.

Tier 3 (operator-signed roster attestation) stays deferred, with nothing
depending on it.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
</content>
</entry>
<entry>
<title>Merge origin/main into the chat encryption work</title>
<updated>2026-09-07T16:03:52Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-07T16:03:52Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=e1383e1d545b994f4ad61694f868339defb0bdef'/>
<id>urn:sha1:e1383e1d545b994f4ad61694f868339defb0bdef</id>
<content type='text'>
Both sides landed a breaking MNP change and both called it 2.0, which is right:
the sealed upload, the removal of `stream_seg` and mandatory chat encryption
share one flag day. They are recorded as one version in `__init__.py` rather
than as a race between two.

The resolutions that were decisions rather than mechanics:

* **`MNP_MIN_SUPPORTED` moves to "2.0".** The sealed upload alone was a
  *confined* break — a 1.x peer could still connect, browse, download, stream
  and chat, with only its uploads refused by `upload_not_sealed` — so the floor
  deliberately stayed at "1.0". Mandatory chat encryption ends that
  confinement: a 1.x peer can neither produce a sealed chat message nor read
  one, so it would connect, look fine, and be unable to say anything. Refusing
  it at the handshake is the honest form. The per-message `upload_not_sealed`
  path is untouched and still right if the floor is ever lowered.
* **`sendChat` throws on an `error` reply**, from origin, applied to the sealed
  send. It matters more after this change, not less: the node now refuses a
  stale epoch, a malformed envelope and a device claim that is not the
  connection's own, so there are three new ways for a message to be rejected
  and none of them may look like a message that was sent.
* **`req_id` supersedes the per-type routing** this branch added for
  `chat_keys_resp` and `device_hello_ack`. Both blocks are kept beside the
  existing `chat_hist_resp` one, for the same stated reason — a node too old to
  stamp — and their comments no longer claim to be the mechanism that closes
  the class. `req_id` is.
* **`chat_send_probe.py` is rebuilt on origin's structure**, not beside it: two
  scenarios, a stub that stamps `req_id`, `music_meta_req` as the older pending
  request. The encrypted path is layered on — a real Ed25519 device key
  generated in the page, and a `chat_keys_resp` sealed by the shipped Python,
  because a payload the page built itself would prove only that the page agrees
  with the page.
* **`test_reply_correlation.py` now sends a sealed message.** Its subject is
  which of the two messages leaving that handler carries the id; plaintext chat
  was only the fixture, and the node refuses one now.
* `groupbox` keeps both new purposes (`upload`, `chat_keys`); `protocol.py`
  keeps origin's removal of `STREAM_SEGMENT` and this branch's correction of
  the "Double Ratchet message" comment on `CHAT_MESSAGE`, which was wrong when
  it was written and is wrong differently now.

Full suite on the merged tree: 1993 passed, 11 failed — the same 11 that fail
on a pristine checkout (2 Windows service tests, 1 apps-enabled policy, 7
transcode tests that pass in isolation, and the WebRTC invite test that hangs
on its own).

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
</content>
</entry>
<entry>
<title>feat(chat): encrypt group chat under per-device epoch keys (MNP 2.0)</title>
<updated>2026-09-07T15:50:28Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-07T15:50:28Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=36cebf25d0e0f24cf63be4380ccb5d03da726a74'/>
<id>urn:sha1:36cebf25d0e0f24cf63be4380ccb5d03da726a74</id>
<content type='text'>
Chat messages are sealed with AES-256-GCM under a key derived per group, per
epoch, per *device*, and signed over the ciphertext with the device key the
node pinned. The node relays and archives; it cannot read a message.

There is no switch. MNP goes to 2.0 and MNP_MIN_SUPPORTED moves with it, so a
1.x peer is refused at the handshake with `version_too_old` rather than
admitted and then unable to speak. An opt-in flag was designed and rejected:
every node is a test node, so it would have bought nothing and left a plaintext
branch reachable — C6's lesson one feature later. A test reads the source and
refuses any code that consults a `chat_encrypted` setting.

Not Sender Keys, and `senderkeys.py` is now documented as unused. With
distribution under the group key and a node that serves history to devices
which were not present, the node must retain each chain's earliest key, and a
chain key at iteration i yields every message key from i on by pure HKDF —
forward secrecy is zero either way. What the ratchet was left buying was
stateful client code with silent failure modes, three of them reproduced: any
member could sign as any other, a second device dropped the first's chain, and
the skipped-key cache grew without bound. The reasoning is in
docs/chat-sender-keys.md, which is the specification and the decision record.

Epochs, not rotation: the epoch key is wrapped under the group key at delivery
and never stored under it, so `gek_rotate` is a re-wrap. A group-key-derived
archive key would have made every message ever sent unreadable on the first
`member unpin`, which is the documented step after removing a member. A new
epoch opens on member revoke/unpin, device revoke and `gek_rotate`; old epochs
are kept and still delivered, so history stays readable to everyone who could
already read it, and nothing anywhere deletes one.

Three prerequisites this needed, each a live defect on its own:

* The peer registry was keyed by user_id, so one account's second device
  evicted the first and the broadcast skipped recipients by account — a
  person's phone never saw what they typed on their laptop.
* The handshake authenticated an account, never a device. `device_hello`
  (additive, signed, refused unless the key is a live device of this account in
  the node's own roster) is what lets the node refuse a member claiming
  somebody else's key.
* `_admin_exec_file_delete` authorized against the exact uploading key, so
  device linking had already broken deleting your own file from your other
  device. It now authorizes against any non-revoked device of `uploader_id`.

Found by driving the real panel over the real transport, not by reading source:
`chat_keys_resp` was routed by arrival order and handed to an unanswered
`media_meta_req` — the original frozen-tab defect in a message type that did
not exist when that probe was written. And `_asText` had been deleted with an
unrelated helper beside it; its only caller sits inside a promise the panel
catches, so every conversation rendered empty with nothing in the console.

Existing node data is migrated by QE/migration/migrate_chat_encryption.py
(not versioned, per the QE rule), run with the node stopped.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
</content>
</entry>
<entry>
<title>feat(mnp)!: seal the upload under the group key</title>
<updated>2026-09-07T15:46:33Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-07T15:46:33Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=8980a8e42d94ab7c0bc9739283d39f938f8402b0'/>
<id>urn:sha1:8980a8e42d94ab7c0bc9739283d39f938f8402b0</id>
<content type='text'>
Downloads have been encrypted under a GEK-derived key since the beginning:
`file_chunk` and `stream_data` both go through `chunk_ciphertext`. Uploads
never were. `file_upload` carried the filename and the raw bytes in plain
msgpack, and `file_upload_ack` carried the name the node stored them under —
so the same file was ciphertext leaving a node and plaintext arriving at one.
There was no threat model behind that asymmetry.

Both halves now travel sealed under a third groupbox purpose,
HKDF(GEK, info="meshbay:upload:v1"). The filename, the destination folder and
the bytes are all inside the seal; only `upload_id` and `chunk_index` stay in
clear, because the node routes and orders on them before it can decrypt. This
direction seals *towards* the node — it holds the GEK for its own group — and
it opens the payload before it picks a destination or touches the disk.

What that forced, and why none of it is optional:

  - `filename` was the correlation key on both sides. It cannot be: matching an
    ack to its request by name would hand back exactly what the seal hides.
    `upload_id` replaces it — client-drawn, opaque to the node, unique within a
    connection, never an authorization input. The property it guarded (one
    refusal fails one upload, not every upload in flight) is unchanged.
  - Refusals can no longer quote what they refused. `No directory named 'X'`
    becomes `No such directory in this group` plus the `code` that was already
    there; the client knows what it sent.
  - No plaintext fallback. A path that still accepts plaintext is not a sealed
    path, so an unsealed `file_upload` is refused with `upload_not_sealed`.

Hardened while here, because what comes out of a seal is authenticated but not
validated — a member can seal anything: `filename` and `data` have their types
checked before any upload state is created, and `chunk_index`/`total_chunks`,
which are outside the seal by necessity, can no longer raise where a refusal
was meant.

Tests. `test_upload_sealed.py` pins the node half: nothing identifying on the
wire, tamper/wrong-key/wrong-group all refused with nothing written, and
multi-chunk reassembly unchanged. `test_upload_seal_client.py` drives the
shipped `uploadFile` over the shipped `crypto.js` under node and feeds its real
frames to the real `_do_file_upload` — the file lands intact, and the ack the
node actually produced comes back with the name it chose for a collision, which
is the half a source-reading test cannot see. Both upload purposes join the
JS/Python groupbox parity vectors.

BREAKING CHANGE: MNP 2.0. `file_upload`/`file_upload_ack` change shape on the
wire every deployed client speaks, which is MAJOR by the same rule 1.0 was —
but the break is confined to uploads. `MNP_MIN_SUPPORTED` stays at "1.0", so a
1.x peer still connects, browses, downloads, streams and chats; only its
uploads are refused, with a message saying which side is old. The client checks
the node's version before sending a chunk, so neither side meets this as a
timeout. This is the version negotiation shipped in 1.0 earning its keep: 1.0
cost a flag day, 2.0 costs a refusal code.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
</content>
</entry>
<entry>
<title>feat!: MNP 1.0 — seal index and handshake_ack under the group key</title>
<updated>2026-09-03T14:16:55Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-03T14:16:55Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=675beed6ff688733a9598f9d82d41578f48316be'/>
<id>urn:sha1:675beed6ff688733a9598f9d82d41578f48316be</id>
<content type='text'>
`index_sync`, `index_delta` and the `handshake_ack` config payload now travel
sealed under a GEK-derived subkey (`meshbay_common/groupbox.py`, mirrored by
`sealGroup`/`openGroup` in `crypto.js`). Only `type`, `v`, `group_id` and the
ack's `node_pk`/`proof`/`sig` stay in clear — a receiver must route and
authenticate before it would trust a decryption. Verify, then decrypt.

The ack line is integrity, not confidentiality: the signed handshake transcript
names no ack field, so `is_node_admin`, `enabled_apps`, `video_root` and the
rest were authenticated by the DTLS channel alone. The index line is defence in
depth against a repeat of C1/C6 — a peer served before the handshake completes
now gets ciphertext, not filenames. Nothing against an observer, the hub, or a
member; that is the whole claim. `index_progress` stays clear (D3, counters
only). Chat is out of scope.

Failure is fatal: a payload that does not open ends the session naming the
message type — never an empty index or an empty `enabled_apps`, both of which
are legitimate states.

Version negotiation ships here too (phase 15.6, brought forward): `v` + `v_min`
on `handshake` and `handshake_challenge`, refused with `version_too_old` /
`version_too_new` / `version_unreadable`. The flag day was already being paid
for; the next breaking change now costs a refusal message.

BREAKING CHANGE: breaks the WebRTC wire every deployed client speaks. Hub and
every node must deploy together; the SPA is served by the hub, so a browser
picks up the new client on reload. See MESHBAY_NODE_PROTOCOL.md §11.1a, §13.1.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01HkzbhmMmK8PqQBtGz5zCvY
</content>
</entry>
<entry>
<title>refactor(hub): drop the base64 chunk fallback and the dead HTTP file client</title>
<updated>2026-09-03T13:20:52Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-03T13:20:52Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=0b3ffdb8d7f07aee817ece24fd44a24ac60e6714'/>
<id>urn:sha1:0b3ffdb8d7f07aee817ece24fd44a24ac60e6714</id>
<content type='text'>
With MNP 0.15 no node can emit a base64 `file_chunk`, so the browser's fallback
for that shape is unreachable. Three things go with it:

- `file-utils.js` kept a third branch below the fallback that base64-decoded
  `chunkMsg.ct_b64 || chunkMsg.data_b64` when neither was present, i.e. decoded
  `undefined` and wrote the result into the file the user was saving. A chunk we
  cannot decrypt now stops the download with an error naming the file and
  suggesting the node is older than the page. Deliberately not in
  `_isRetryableTransportError`: this is a version mismatch, not a bad moment on
  the link.
- `crypto.js` `decryptChunk` (base64) was the real path until Phase 9.15 and has
  had no caller since.
- `crypto.js` `decryptFile` was never called in any commit. It fetched
  `${nodeUrl}/file/${id}/${chunk}?token=` in a loop — the node's unauthenticated
  HTTP file API, which is finding C1 and was deleted in Phase 11.5. A client for
  an endpoint that no longer exists, kept alive by being exported.

`decryptChunkBin` — every file download and every video segment — is untouched.

`packages/meshbay-client/ui/` was resynchronised with `npm run sync-ui`.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
</content>
</entry>
<entry>
<title>feat: device linking, and signing in to the hub with a device key</title>
<updated>2026-08-18T01:24:55Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-18T01:24:55Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=768e07046368819b8a8f15c8b21e5a8bbfcdf282'/>
<id>urn:sha1:768e07046368819b8a8f15c8b21e5a8bbfcdf282</id>
<content type='text'>
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 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(node)!: the node wraps the group key — closes H3 and M3</title>
<updated>2026-08-13T23:27:21Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T23:27:21Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=f15efd23f66c521ca9206789482bb38e7326eeb4'/>
<id>urn:sha1:f15efd23f66c521ca9206789482bb38e7326eeb4</id>
<content type='text'>
The invite flow fetched the invitee's pk_x25519 from the hub and wrapped the
GEK for whatever came back (app.js:1466, and gek-init did the same server-side).
The hub is the key directory, so a hub answering with its own key was handed the
group key by an honest member following the protocol exactly. No forgery, no
injection, nothing for the client to notice. That was H3.

The fix is not safety numbers. Nobody reads the directory any more:

  - the node holds the GEK and wraps it itself, on every connection, for the
    X25519 key the joiner signed with their Ed25519 identity in one transcript
    (meshbay:join:v1), so the identity key vouches for the encryption key;
  - identities are bound to accounts by a one-time code the hub never sees —
    40 bits, single use, one account, bounded per connection AND node-wide;
  - the node's own roster decides who may receive the key. Hub membership lets
    someone reach a node; it no longer gets them anything. A hub that invents an
    account and mints it a token is answered not_authorized_for_group.

Safety numbers would have made substitution detectable by a human who checks, at
the moment there is nothing to check against — first contact. Removing the lookup
makes it impossible, and costs the user one code to pass along.

M3 falls out of the same work. The daemon auto-pinned its own keystore key as
admin_pk_ed25519 while the browser signs with the user identity key, so every
privileged operation failed closed with a signature error that looked like a bug
somewhere else; the demo only worked because a deploy script overwrote the value.
Authority now comes from the roster, established locally by `operator pair`.
Asking the hub for the operator's key — the obvious-looking fix — would have let
the hub install itself as node administrator.

BREAKING: gek_bundle_store is deleted, not gated. No member hands the node key
material at all, so C5b becomes structural rather than an authorization to check.
Existing stored bundles are still served, so current deployments keep working.

Also:
  - join_policy (invite|open) is read from node.toml, never from the hub — a hub
    able to declare a group open would be handed its key. Unknown group ⇒ invite.
  - admin signatures are verified against the roster on every check, so unpinning
    takes effect without a restart. admin_pk_ed25519 stays readable as legacy.
  - two C5b tests were rewritten, deliberately: they asserted that
    gek_bundle_store demanded an operator signature, and the message is gone. They
    now assert the stronger property. The file says not to fix these tests, so
    this is the record of why they changed.
  - a slice-1 bug found while writing slice 2: connect() never passed skEdB64, so
    pairing would have failed at runtime with no test able to catch it.

Tests: 152 node+common here, including an end-to-end DataChannel run where a
member who has never held the group key redeems a code in the pre-proof window
and receives the key wrapped for a key only they can open.

Design: docs/invite-pairing-v1.md

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(mnp): unified handshake with mutual authentication</title>
<updated>2026-08-13T09:46:12Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T09:46:12Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=e13659f8f3166b5a9a4155314941bc149fec2721'/>
<id>urn:sha1:e13659f8f3166b5a9a4155314941bc149fec2721</id>
<content type='text'>
Phase 11.5.4/5/7/8 — findings C6 (WebRTC half), C3, L4, M1, M9.

New meshbay_common/handshake.py is the single implementation of authorization
and proof: JWT verify, scope, denylist, mandatory group_id, membership, hosting.
The handshake previously existed three times over and only the newest copy
enforced the GEK proof.

C3 — mutual authentication. Authentication ran one way: the client proved
itself, the node proved nothing. handshake_ack.node_pk was never verified
against anything and per-chunk signatures had been dropped in Phase 9.15, so a
peer that had hijacked signaling (C2) or been substituted by the hub could
accept the client's proof, ignore it, and serve a forged index, forged chat
history and a forged is_node_admin flag. The client now sends a nonce; the node
answers with its own GEK proof over that nonce AND an Ed25519 signature over
the transcript; the browser verifies both and refuses otherwise. It also
refuses an unchallenged handshake_ack, which previously let a peer skip proving
anything at all.

L4 — the proof was nonce ‖ offer_fp ‖ answer_fp: bare concatenation, and a
missing fingerprint silently degraded it to nonce-only, dropping MitM detection
(NS5). Every field is now length-prefixed and domain-separated, the role is
bound so a client proof cannot be replayed as a node proof, and an absent
channel binding is refused rather than tolerated.

M1 — group_id was optional; omitting it skipped the membership check entirely
and fell back to the node's first group. Now mandatory.
M9 — node-scoped daemon tokens are refused on the client path.

NOT DONE: quic_server.py still runs its own JWT-only handshake, so C6 remains
open — a forged or stolen token reaches a node over QUIC and can inject chat
without holding the GEK. quic_binding() is written and unit-tested but unwired.
11.5.6 (whether the certificate-hash anchor works with aioquic, or an RFC 5705
exporter is reachable) is unproven. 11.5.8 TOFU pinning of pk_node is not done:
the client verifies the node's signature but does not yet remember which key it
saw last.

Adds packages/meshbay-common/tests/test_handshake.py (18 tests) covering the
properties every transport must inherit. WebRTC test helpers rewritten around
the shared module; _make_jwt now defaults to the test group, since group_id is
mandatory.

Tests: 24 webrtc, 176+ node+common.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
</feed>
