<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/src/meshbay_hub/static/transport.js, branch 0.2</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.2</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.2'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-08-14T15:51:48Z</updated>
<entry>
<title>feat!: identity keys per node — C4's blast radius drops to one operator</title>
<updated>2026-08-14T15:51:48Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T15:51:48Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=f0984e86d9cb596a282ce6feb7cfc2f075b2794b'/>
<id>urn:sha1:f0984e86d9cb596a282ce6feb7cfc2f075b2794b</id>
<content type='text'>
One keypair was copied to every node its owner joined, so cracking the bundle on
any single node yielded the identity used on all of them: their content on other
operators' machines, and the ability to sign as them anywhere. That lateral reach
was the part of C4 worth attacking.

Each node now gets its own keypair, generated the first time its owner joins it
and left with that node alone. An operator who cracks what sits on their own disk
holds a key that is a stranger to every other node — and on their own node, one
that unlocks nothing they did not already hold: they serve the content, the index
and every byte of it by design.

Nothing changes for the user. A first contact with a node already needed that
operator's code, and the key is created in the same step; a second browser still
recovers it from the node with the passphrase alone. Two operators can also no
longer tell they host the same person by comparing keys.

BREAKING, and deliberately without a compatibility path — the deployment is wiped
for the next demo:

  - users.pk_ed25519 / pk_x25519 dropped (migration a7c31f9e40b2)
  - registration no longer sends or stores a key
  - PUT /v1/users/me/keys and regenerateKeys() gone; rotation is now
    `member unpin` plus a fresh code, decided on the machine that pinned it
  - /pubkeys returns an account id and a node's linking key. It was the directory
    H3 read, and nothing wraps for it any more
  - the pk_user JWT claim is gone

That last one closed a live defect the inventory turned up: the node recorded
pk_user as the uploader's identity and authorized deletion against it, so a hub
issuing a token naming its own key could delete anyone's uploads on any node.
Attribution now uses the key the node itself pinned.

A simplification falls out. Registration generates nothing, so a scripted signup
is a real account: `demo.py bootstrap` takes a wiped hub and node to a working
demo with no browser, which was impossible while keys were born in one.

Also fixes, found by running it on a wiped deployment: the key handed back on a
join now belongs to the group the connection is for, not the group named in the
invitation — an operator pairs node-wide but redeems the code while opening a
group, and expects to read it.

Tests: 343, including the two that state the property — a key pinned by one node
is refused at another, and someone else's code does not admit it. Verified end to
end against a wiped hub and node: bootstrap, pair, invite, join, download,
stream, second browser, revoke.

Design: docs/per-node-identity-v1.md

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(client): make the key backup a choice, and raise the passphrase floor</title>
<updated>2026-08-14T10:06:31Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T10:06:31Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=2caa93dbc06161b5d3f776a204ac8d921df92126'/>
<id>urn:sha1:2caa93dbc06161b5d3f776a204ac8d921df92126</id>
<content type='text'>
Two things the multi-browser story made obvious.

**The backup is now opt-out.** Keys are kept, encrypted with the passphrase, on
every node whose group you join — that is what lets a second browser recover
them, and it is finding C4: a PBKDF2-protected blob on other people's disks,
attackable offline at the speed of PBKDF2, which is memory-light and therefore
cheap on a GPU. Until now everybody paid that cost, including people who will
only ever use one browser and get nothing back for it.

Settings → "Use this account on other devices". Turning it off does not merely
stop future uploads: the next connection to each node withdraws what that node
already holds (new keypair_bundle_delete, which only ever deletes the caller's
own, taken from the authenticated session and never from the message). The
warning says plainly what it costs — clearing the browser then loses everything
encrypted for that account, with no recovery, which is the point of choosing it.

Default is on. Silent, unrecoverable key loss is worse for an ordinary user than
an exposure the roadmap already tracks, but that is a judgement call and it is
now visible and reversible instead of implicit.

**Passphrase floor 8 → 12 characters, plus a strength estimate** shown while
typing, with a refusal below ~60 bits. This number matters more here than in
most applications: it is what stands between a node operator and your identity
keys. It has to live in the client — with the password split (T1) the hub never
sees a password and cannot enforce anything about one — so the UI says why it
is asking, rather than nagging.

The estimator is deliberately conservative and dependency-free: character
classes and length, penalised for repetition and for the handful of patterns
everyone tries.

Verified against the live deployment: withdrawing the backup leaves a second
browser unable to recover anything, which is exactly what it promises, and
re-enabling restores it.

Tests: 338.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(client): capture the challenge values before joining, not after</title>
<updated>2026-08-14T01:51:55Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T01:51:55Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=9f0904247116005bba8e32b9428dc6a2b994e705'/>
<id>urn:sha1:9f0904247116005bba8e32b9428dc6a2b994e705</id>
<content type='text'>
join_request signs a transcript over the node key and the node nonce, and runs
before the GEK proof — a first-time member has no key to prove with. Both values
were read further down, beside the proof that also uses them, so by the time
joinGroup() ran neither was set and every invited member got "Handshake
incomplete — reconnect and retry".

They are now recorded the moment the challenge arrives.

Third bug of the same shape found in a browser, and the reason is worth writing
down: QE/deploy/e2e.py cannot catch any of them. It is a second implementation of
the client, written in the right order by construction, so it passes while the
SPA fails. It proves the protocol; it proves nothing about app.js.

So this adds ordering guards over transport.js — source-level, which is not how
one would normally test behaviour, but it is what sees this class of mistake:

  - node_pk and nonce_node are captured before joinGroup() runs
  - the join happens before the GEK proof
  - the ack still verifies the key the challenge announced

Verified the way the suite requires: each fails against the source as it was, on
the ordering assertion rather than on a missing marker.

e2e.py also waits for the node to re-register rather than reporting "no nodes" at
whoever just restarted the hub.

Tests: 337 across the three packages.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(client): refresh the token when the node says "not a member"</title>
<updated>2026-08-14T01:40:43Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T01:40:43Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=9a483774e97f8612b00e3d92c4d5ebc00c21980a'/>
<id>urn:sha1:9a483774e97f8612b00e3d92c4d5ebc00c21980a</id>
<content type='text'>
A member added to a group after they signed in was refused by the node, told
"Not a member of this group", and had no way forward but to log out and back in.
The hub bakes `groups` into the access token at login and never pushes updates,
so the token said they were in nothing while the database said otherwise.

This lands on every newly invited member, at their first action, and the message
tells them the opposite of the truth — toto2 was a member of newdemo on the hub
and read that they were not.

The refusal now carries a code the client can act on (`not_a_member`) rather than
prose it would have to string-match, and the SPA refreshes the access token once
and retries. Refreshing re-reads membership from the database, so the retry
succeeds. Once per mount: if a fresh token still says not a member, that is the
truth and it gets shown.

The SPA had stored a refresh token since Phase 8 and never used it. It does now.

Found in a browser, doing the ordinary thing — the automated run never sees it,
because e2e.py logs in after being added to the group.

Tests: 233 node+common, including a handshake test that the refusal carries the
code, and the full e2e run against the live deployment.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(node): keep reading the hub socket while negotiating WebRTC</title>
<updated>2026-08-14T01:13:29Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T01:13:29Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=48124ac249b0bff42c84ab6aee9270cf7ee134d8'/>
<id>urn:sha1:48124ac249b0bff42c84ab6aee9270cf7ee134d8</id>
<content type='text'>
A node could be running, healthy in its own logs, and invisible to the hub with
nothing to say why. That is what "No nodes available" looked like from a browser,
and restarting the daemon was the only way out.

maintain_ws awaited the WebRTC offer handler inline, inside the loop that reads
the hub socket. One negotiation that did not finish — a client that closed its
tab mid-ICE is enough — stopped the node reading that socket at all: pings
unanswered, close frame never seen, later offers never served. The socket sat in
CLOSE-WAIT with the hub's goodbye unread in the receive queue, which is how this
was finally pinned down.

Offers are now answered in their own task, so the read loop keeps draining
whatever happens to any one peer. With that in place the existing reconnect logic
works: a hub restart is seen (1012), retried through the 502 while it comes back
up, and reconnected unattended — 19 seconds in the run that verified this.

Also:
  - explicit ping_interval/ping_timeout. This connection is how a node stays
    reachable, and a half-open socket looks exactly like a working one.
  - a clean close ended `async for` without raising and reconnected in silence;
    it now says so, because a node that stops being reachable should leave a trace.
  - a failed negotiation logs the peer instead of taking the loop down with it.

Predates this branch (Phase 11), and independent of the invite work — surfaced
while testing it, because deploying the hub mid-session is exactly the trigger.

Tests: 232 node+common, plus the full QE/deploy/e2e.py run against the live
deployment after a deliberate hub restart.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(node): announce the node key in the challenge, and keep names in the roster</title>
<updated>2026-08-14T00:18:07Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T00:17:45Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=71df5857b213be893025c562977558ba79009c09'/>
<id>urn:sha1:71df5857b213be893025c562977558ba79009c09</id>
<content type='text'>
Both found by deploying the thing and running the workflow end to end. Neither
was reachable from the test suite, for the same reason in each case: the tests
knew something a real client cannot.

1. A first-time joiner had no way to learn node_pk.

   join_request signs a transcript naming the node, and the node key was only
   sent in handshake_ack — which an invited member cannot reach, having no GEK to
   prove. joinGroup() therefore threw "handshake incomplete" and the browser path
   for an invited member was broken. Every test built the transcript from a node
   key it already had, so nothing noticed.

   The challenge now carries node_pk. It is unverified at that point and never a
   substitute for the ack: the ack still proves possession and signs the
   transcript, the client checks the two values match and refuses a peer that
   changed identity mid-handshake, and TOFU pinning is unchanged. A wrong value
   only makes our own verification fail.

   test_invite_then_join_delivers_the_gek now takes the key from the challenge
   instead of from sk_node, so it proves a real client can learn it.

2. The roster pinned everyone without a name.

   `_do_join_request` took the username from the session, which takes it from the
   JWT — and the hub puts no username claim in a token. So identities were pinned
   with an empty name and `member revoke &lt;name&gt;` could never match: the live node
   answered "known: , ,". Invitations now carry the name (new invites.username
   column, with a migration for the roster DBs already out there), and the CLI
   resolves a name through the daemon: its own roster first, the hub as fallback
   for identities pinned before this.

The harness that found them is QE/deploy/e2e.py — gitignored with the rest of
QE/, so it is not in this commit. It does the SPA's job in Python against the
live deployment: hub login, WebRTC via hub signaling, the unified handshake,
joining with a code, index, chunk download and MSE segments.

Verified against meshbay.org and the local node: an account registered from
scratch is invited by code, receives the group key wrapped for a key it proved it
holds, downloads and decrypts a file, streams 5 encrypted fMP4 segments,
reconnects with no code, and is refused after `member revoke`. The node audit log
shows invite_create → join_pinned(via=code) → gek_wrapped → handshake, then
join_no_gek once revoked.

Tests: 232 node+common.

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>fix(hub): require proof of possession on node announce — closes M8</title>
<updated>2026-08-13T12:30:51Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T12:30:51Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=8c5227365118383540a5e77b1885aef7e62bf6ec'/>
<id>urn:sha1:8c5227365118383540a5e77b1885aef7e62bf6ec</id>
<content type='text'>
Phase 11.5.10.

POST /v1/nodes/announce accepted any pk_node with no proof the announcer held
the matching private key, so a user could register a node record carrying
someone else's node key, and records accumulated without limit.

The announcer now signs a domain-separated message binding the key to their
account — meshbay:node_announce:{user_id}:{pk_node}:{timestamp} — reusing the
shape already proven by /v1/nodes/auth, so a signature for one can never
satisfy the other. Same 60-second window.

Re-announcing the same key now updates the existing record in place instead of
creating a new row.

Three test helpers had to be taught to sign, which is the useful part: nothing
in the suite had ever exercised announce with an attacker's key. The new tests
cover the missing proof, a foreign key, a stale timestamp, and idempotence.

Note for the record: the node key is independent of the user's identity key.
Two hub tests asserted the announced pk_node equalled the user's pk_ed, which
happened to be true only because the daemon announces its keystore key. They
now assert against the announced key itself.

Tests: 157 hub+common, node suite green.

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>
<entry>
<title>fix(node): group isolation, upload confinement, GEK seizure, admin challenge</title>
<updated>2026-08-13T08:43:16Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T08:42:20Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=3ce051e134a432417fcaca4e8b5775d98f614a31'/>
<id>urn:sha1:3ce051e134a432417fcaca4e8b5775d98f614a31</id>
<content type='text'>
Phase 11.5 — findings H1, C5a, H2, C5b, H5 (see second-review.md).

Batched together because the node-side changes share webrtc_server.py and
cannot be separated into working commits.

H1 — cross-group chat leak. chat_store, the peer registry and the display-name
cache were read from the shared transport context, and daemon.py hoisted the
FIRST group's chat store onto it. On a node hosting several groups every
group's messages went to one database, chat_history served them back to members
of every other group, and chat broadcast reached all peers regardless of group.
All three now resolve through _group_ctx().

C5a — upload confinement. Uploads landed in the shared root under a
client-chosen name and overwrote whatever was there. Any member could destroy
the operator's files, and by becoming the recorded uploader of the replaced
file could then delete it through the uploader path, bypassing the Ed25519
admin challenge. Uploads now go to a per-user quarantine (.uploads/{user_id}/),
refuse to overwrite, and enforce chunk ordering, a filename allowlist and a
size cap.

H2 — stored XSS in the node admin UI. Filenames chosen by any group member were
interpolated raw into the localhost UI, which has no authentication, so script
execution there equals control of the node admin API. Now html.escape()
throughout, textContent in the audit table, plus CSP/nosniff/no-referrer. The
CSP contains exfiltration but cannot stop injected inline script — escaping is
the fix.

C5b — group key seizure. gek_bundle_store wrote whatever any member sent and
auto-activated bundles addressed to the node operator. The operator's X25519
public key is public (the node publishes it in handshake_ack), so any member
could wrap a key of their choosing for it and take over the group, locking
every legitimate member out. Storing now requires an operator signature and
_try_activate_gek is removed: nothing arriving over MNP can set a live GEK.

H5 — unbound signing oracle. The node challenged with 32 raw random bytes and
the client signed them blind, so a signature named no operation, subject, node
or time. New meshbay_common/adminop.py defines a length-prefixed,
domain-separated transcript; both sides build it independently and the client
refuses to sign when the announced op/subject do not match its request.

BREAKING: a group admin who does not operate the node can no longer store GEK
bundles on it. Invites must be performed by the node operator.

Adds tests/test_security_regressions.py. Verified against pre-fix source via
git stash. Three pre-existing tests asserted the vulnerable behaviour as a
feature and were inverted: gek auto-activation, and the transport-wide
chat_store in test_daemon.

Tests: 109 node, 132 hub+common.

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