<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-node/tests/test_security_regressions.py, branch 0.3</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.3</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.3'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-08-14T20:01:51Z</updated>
<entry>
<title>feat(files): one uploads/ directory, for files and chat alike</title>
<updated>2026-08-14T20:01:51Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T20:01:51Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=38f91818f876c51dcd7eb7911b65fc7bf5154c83'/>
<id>urn:sha1:38f91818f876c51dcd7eb7911b65fc7bf5154c83</id>
<content type='text'>
Correction to the previous commit. Uploads went wherever the member happened to
be looking, which spreads chat attachments through the tree and makes the
destination a client-supplied path — surface that had to be defended. Everything
a member sends now lands in `uploads/` at the root of the shared directory:
visible, one place, easy for the operator to look into or empty.

Chat attachments go there too, so the separate out-of-tree thumbs directory is
not needed and is not built. They were already ordinary uploads; now they are
ordinary uploads that land somewhere sensible.

The destination is chosen by the node, so a client naming somewhere else changes
nothing — the traversal surface simply is not there on this path. safe_subdir()
remains for dir_create, where the path genuinely does come from the client, and
keeps its tests.

One shared directory means name collisions are ordinary rather than adversarial:
every camera produces IMG_1234.jpg. The node finds a free name — "IMG_1234 (2).jpg"
— and reports it in the ack, because a chat message has to point at the file that
was actually written and not at someone else's. Nothing is ever replaced, which
is the property the per-user quarantine existed for (C5a) and the one the tests
assert; they fail if the free-name search is removed.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(files): upload into the current directory, and create folders</title>
<updated>2026-08-14T19:39:42Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T19:39:42Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=b3ef2aff738cc4efd974efab9315a6ce6c3de493'/>
<id>urn:sha1:b3ef2aff738cc4efd974efab9315a6ce6c3de493</id>
<content type='text'>
The per-user quarantine is gone. `.uploads/{user_id}/` was the fix for C5a, and
it worked, but it made the shared directory something nobody could organise:
every file landed under a uuid nobody recognises. Files now go where the member
is looking, most often the root.

What the quarantine actually bought is kept, and is now what the tests assert
rather than the location:

  - an existing file is never replaced. That was the real defect — overwriting a
    file also made the attacker its recorded uploader, and therefore able to
    delete it through the uploader path
  - the name allowlist is unchanged
  - the destination is confined under the shared root

That last one is new surface: the directory arrives from the client. safe_subdir()
is the single place that decides, with two independent guards — every segment
against the name allowlist, and the resolved result under the root — because one
of them will eventually be refactored by someone who does not know why it is
there. Ten traversal cases are covered, and they fail if both guards go.

Also adds `dir_create` (any member may organise a shared directory; audited like
anything that writes to the operator's disk) and makes the node report its real
directory list in index_sync — folders were inferred from file paths, so a new
empty one, or one that had been emptied, simply did not exist as far as the UI
was concerned.

Two C5a tests changed their assertions deliberately, as C5b's did before: they
encoded the quarantine path, which is the thing being removed. The property they
existed for is asserted more directly than before.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(node): operator surface — member list, invite, revoke, unpin over SSH</title>
<updated>2026-08-13T23:27:57Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T23:27:57Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=8f6e2f724fd24a077de11d4a3b3ae069d369324d'/>
<id>urn:sha1:8f6e2f724fd24a077de11d4a3b3ae069d369324d</id>
<content type='text'>
A node admits people from its own roster, and until now a headless operator had
no way to put anyone on it: pairing worked from the CLI, everything else needed a
browser on a machine that does not have one. Absorbs milestones 14.3/14.4.

  member list                 who is admitted, role, status, when and how pinned
  member invite &lt;username&gt;     one-time code; the node wraps the key when they
                               connect, so nobody has to be online then
  member revoke &lt;username&gt;     stop serving them the key
  member unpin &lt;username&gt;      forget the pin so they can pair again after a reset

All of it goes through the daemon's loopback API with the per-run session token
(11.5.3) — _daemon_api() in daemon.py, which also replaced three hand-rolled
urllib blocks. `status` deliberately still reads the keystore, config and roster
directly, so it works while the daemon is stopped.

Two things the commands say out loud, because getting them wrong is silent:

  - revoke ends by telling the operator to rotate the key. The ex-member stops
    receiving it on their next connection, but they hold the current one, and
    "revoked" reads like it took the key back.
  - revoke/unpin refuse a username the roster does not know instead of acting on
    nobody. A typo must not look like success.

Code lifetimes now differ by what the act is: 7 days for an invitation, which
crosses a human conversation and gets answered whenever someone reads their
messages, and 24 h for operator pairing, which is typed during the SSH session
that printed it. Both configurable ([node] invite_ttl_hours, pair_ttl_hours). A
day was long enough for the second and not for the first — a code that dies over
a weekend means finding a browser to issue another one.

The roster is also in the local admin UI, escaped: usernames come from the hub
and land on the page that can re-key groups and read the audit log, so H2's rule
covers them exactly as it covers filenames.

Verified by driving the real CLI against a stub daemon over a socket, which is
how the "known: &lt;nothing&gt;" bug in the not-found path turned up.

Tests: 89 node here (roster, endpoints, CLI routing, TTL config).

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>
<entry>
<title>fix: resource limits, signaling authz, node admin UI token</title>
<updated>2026-08-13T09:18:24Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T09:18:24Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=b86be704df752f2fd3086fcca43b7f4de78389d1'/>
<id>urn:sha1:b86be704df752f2fd3086fcca43b7f4de78389d1</id>
<content type='text'>
Phase 11.5 — findings H6, C4 (partial), and milestone 11.5.3.

H6 — resource exhaustion. Several paths let one peer degrade or stall a node:

  * the DataChannel frame limit was a flat 64 MB applied BEFORE authentication,
    so an unauthenticated peer could announce a huge frame and dribble bytes
    into it. Unauthenticated peers now get 64 KB; the large budget is granted
    only after the GEK proof, where it is needed for uploads.
  * _do_stream_segment ran subprocess.run(..., timeout=30) directly in the event
    loop, stalling the entire daemon — every peer, every group — for up to
    thirty seconds per request. Now async, with a timeout and process kill.
  * ffmpeg was spawned per stream request with no cap. Both streaming paths now
    share a transport-wide semaphore.
  * POST /v1/nodes/{id}/webrtc/offer was reachable by any authenticated user for
    any node, with no membership check and no rate limit, making the target node
    allocate an aiortc PeerConnection and gather ICE on demand — remote resource
    exhaustion against a third party's machine. Now rate limited, capped per
    user, SDP size bounded, and the caller must share an active group with the
    node. That also closes the H4 gap where signaling ignored group status.
  * POST /v1/nodes/{id}/incoming took peer_ip verbatim, so any user could make an
    arbitrary node emit UDP packets to an address of their choosing. The probe
    target must now match the caller's own source address.

C4 (partial) — the pre-proof bundle window. GEK and keypair bundle fetches are
served before the GEK proof by necessity: the client needs its wrapped bundle in
order to compute the proof. That window is a disclosure surface a hub can reach
by forging a JWT. Bounded to 4 fetches per session and audited as
"pre_proof_fetch". The real fix is removing remote keypair bundles entirely,
which belongs to the native client (Phase 13.3).

11.5.3 — the node admin UI was unauthenticated because it binds loopback. But
any local process can reach it, and so can a page in the operator's browser via
DNS rebinding — and this API re-initialises group keys and reads the audit log.
H2 showed script execution there equals full control. Now gated by a per-run
token, printed at startup, accepted as ?t= or X-MeshBay-Token.

One test needed rewriting rather than adding: the first version asserted
"subprocess.run(" was absent from the source, which also matched the comment
documenting the old behaviour. It now parses the AST and checks the property.

Tests: 121 node, 142 hub+common. Regression suite 47 node + 10 hub.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix: swarm privacy, revocation persistence, keystore KDF, audit integrity</title>
<updated>2026-08-13T09:00:39Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T09:00:39Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=9df71bd1e5244743fae8c1b2bda41143f0748d9d'/>
<id>urn:sha1:9df71bd1e5244743fae8c1b2bda41143f0748d9d</id>
<content type='text'>
Phase 11.5 hardening batch — H7, H4, M2, M6, M7, L1, L3, L6.

H7 — private content hashes leaked to the hub. The daemon registered blake3
hashes for every group it hosted, private ones included, giving the hub a
content fingerprint of every private file and letting anyone confirm whether a
known file exists in the network. The leak was dormant only because the routes
were declared on the groups router with a full path and mounted at
/v1/groups/v1/swarm/* — the node's calls 404'd into a swallowed exception.
Fixing the path alone would have activated the leak, so both land together:
registration is gated on group visibility, the routes moved to a real
/v1/swarm router, and the lookup now requires authentication.

H4 — revocation was advisory. Group revocations were signed and broadcast by
the hub and then dropped by the node, whose handler understood only "user" and
"jti", so "suspend a group" enforced nothing. The denylist was also in-memory
only, so a restart silently un-revoked everyone. Now persisted to
data_dir/denylist.json, group targets honoured on both transports, and live
sessions for a revoked group are closed.

M2 — the node keystore, which protects the node's Ed25519 and X25519 private
keys, was still deriving at 64 MB long after the hub's password verifier moved
to 256 MB; the docs recorded the bump as done, true for the hub only. Raising
the constant alone would have made every existing keystore permanently
undecryptable, so envelopes now record the parameters they were written with
and pre-M2 files continue to open under the legacy profile.

M6 — registration inserted its audit row with a NULL user_id and then ran
UPDATE ip_logs SET user_id=&lt;new&gt; WHERE user_id IS NULL, claiming every
unattributed row in the table: failed logins for other usernames, concurrent
registrations. In logs retained a year for legal requests, that attributed
other people's connections to the wrong account.

M7 — X-Forwarded-For was trusted unconditionally at four call sites, so anyone
could forge the IP written to the compliance log and evade per-IP rate limits.
New netutil.client_ip honours the header only from a trusted proxy and takes
the rightmost hop (the one our proxy appended); no direct header reads remain.

L1 dead GEK_REQUEST/GEK_RESPONSE constants removed; L3 peer errors no longer
echo exception text (paths, internal state); L6 email sanity-checked instead of
accepting any string — deliberately not RFC 5322, to avoid a new dependency.

test_daemon_index_change_pushes_to_peers asserted that a PRIVATE group's hashes
are registered with the hub. Split: private asserts not-called (index push to
members still asserted), and a new test proves public groups still register.
That is the fourth pre-existing test found asserting a vulnerability as
intended behaviour, after gek auto-activation, the transport-wide chat_store
and the blind admin challenge.

Tests: 116 node, 132 hub+common. Regression suite now 43.

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>
