<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-node/src/meshbay_node/daemon.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-15T08:17:02Z</updated>
<entry>
<title>feat(node): meshbay-node group add — host another of your groups</title>
<updated>2026-08-15T08:17:02Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T08:17:02Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=0503682c0e2add135b88c2a1fadfe07455680a71'/>
<id>urn:sha1:0503682c0e2add135b88c2a1fadfe07455680a71</id>
<content type='text'>
Attaching a group to a node meant hand-editing node.toml with a UUID
copied from a browser URL, restarting, and knowing that gek-init exists.
Nothing in the CLI said so, and on a node reached over SSH there is no
paste buffer to carry a UUID across in the first place.

    meshbay-node group add grenet --dir ~/grenet-share

The name is resolved against the operator's groups on the hub by the
daemon, which is the process holding the session. The [[groups]] block is
appended to node.toml as text rather than round-tripped through a TOML
writer: the file is hand-written and its comments explain decisions worth
keeping. The directory is created, and the command says what remains —
restart, then gek-init for that group.

It refuses a name it cannot find by printing the groups it can, with
their ids. That listing is the useful half of the answer and it was
missing everywhere: _daemon_api now renders an `available` list from any
endpoint that offers one.

The key is per group and pairing is not, which is the part that reads as
a gap until it is written down: one paired browser covers every group the
node hosts, while each group's key admits only its own members. §4 of the
user guide now says all three of those in one place.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(cli): --group takes a name, and operator pair says it takes none</title>
<updated>2026-08-15T07:50:53Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T07:50:53Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=4c4fe55bc17ea2223a52b31d2be5b762af8d75bf'/>
<id>urn:sha1:4c4fe55bc17ea2223a52b31d2be5b762af8d75bf</id>
<content type='text'>
Two ways the CLI misled someone attaching a second group to a node.

`meshbay-node operator pair --group grenet` accepted the flag and ignored
it: pairing is node-wide and always was. That invites exactly the wrong
reading — that a code belongs to a group, and that pairing had failed
because the group did not change. It now refuses the flag and says one
paired browser covers every group the node hosts.

`--group` also only ever accepted a UUID. A name went through untouched
and the daemon answered as though the group did not exist, which is not
what happened. It now resolves a name against node.toml, and when there
is no match it prints the groups there are, with their ids — the missing
half of the answer.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(groups): editable description, and one source of operator authority</title>
<updated>2026-08-15T00:34:19Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T00:34:19Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=84b032c65e17267d41e04605e79eea82a6f5a59f'/>
<id>urn:sha1:84b032c65e17267d41e04605e79eea82a6f5a59f</id>
<content type='text'>
A description could only be set the moment a group was created, so every
group made before anyone thought of one stayed blank for good. The owner
can now edit it from the group's page, and PATCH /v1/groups/{id} takes it.

That endpoint takes the description and nothing else, deliberately. The
name, the visibility and the join policy are the terms members joined on;
a private group that can quietly become public is not the group they
agreed to be in. Changing those needs a decision about who gets told, not
a field on a form — there is a test saying so.

Separately, the legacy operator key is gone. `admin_pk_ed25519` in
node.toml named the operator before the roster existed and was kept so
that an existing deployment would keep working; nothing uses it, and a
second source of node authority is not something to carry around out of
politeness. Authority is the roster, read fresh on every check.

It is removed rather than ignored: a config that still names the key gets
a warning at startup pointing at the file. Dropping it in silence would
refuse invites and file deletion with a signature error that looks like a
bug somewhere else — which is exactly how finding M3 presented.

Two tests were verifying admin operations by naming a key in the context,
which was the legacy path. They now pair an operator into a roster, the
way an operator does. The authority test anchored on the deleted function
and passed vacuously once it disappeared; it states the invariant against
the verifier and the daemon instead.

Also defined .btn-secondary, used in four places and styled in none.

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): 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(node): CLI for headless operators — status, ui, gek-init</title>
<updated>2026-08-13T17:31:29Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T17:31:29Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=3e9c94a420b6c427eff4ee13aac80cb1430d7a7c'/>
<id>urn:sha1:3e9c94a420b6c427eff4ee13aac80cb1430d7a7c</id>
<content type='text'>
Every operator action lived behind a web UI on the node's own loopback
interface. For the normal deployment — a node on a server reached over SSH —
that is unusable: no browser on the host, and 11.5.3 added a per-run token that
had to be copied out of a log to get in.

  status    hub, node public key, daemon state, groups, admin-key pinning.
            Reads the keystore directly so it works while the daemon is STOPPED,
            which is exactly when it is needed: the daemon cannot stay up before
            its key is linked or before a group exists.
  ui        prints the URL and the ssh -L line. It does not open a browser —
            that was an assumption about the environment, and a wrong one.
  gek-init  initialises a group key through the daemon's loopback API. Same
            operation as the admin UI button, no browser involved.

Also fixes a latent bug in QE/deploy/deploy-node.sh: the pkill pattern was
unanchored, so it matched any shell whose command line merely mentioned the
daemon — including the one running the script. It killed a session three times
before being pinned down. Anchored to the end of the command line.

Verified against the live deployment. grenet and cbesson both connect over
WebRTC through real NAT and can browse, download, stream, upload and chat. The
node audit log confirms the security properties in production: uploads land in
.uploads/{user_id}/ (C5a), the invite required the operator's signature over an
admin transcript (C5b, H5), the pre-proof bundle window is bounded and audited
(C4), and a non-member handshake was refused.

Docs updated: Phase 14 marked partially delivered with the reason, draft-v5 §5.3
records the two operator personas, QE/deploy/README.md documents the commands
and the remaining browser-only gaps (invite, delete).

Tests: 121 node.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(node): keep the daemon alive when the hub rejects credentials</title>
<updated>2026-08-13T13:40:06Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T13:40:06Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=78639260932dbd679d0de1f3132c645440962f46'/>
<id>urn:sha1:78639260932dbd679d0de1f3132c645440962f46</id>
<content type='text'>
A node whose owner has not registered yet got a plain 401 from /v1/nodes/auth,
which _login_with_retry re-raised — so the daemon exited and took its local
admin UI down with it.

That UI is where the operator reads the node's public key in order to link it,
so exiting strands them: no daemon, no key, no way forward without digging the
keystore open by hand. The daemon already parks on "No node key" for exactly
this reason; it now parks on any 401, reporting waiting_for_account with a
message naming the account and hub, and keeps retrying every 30s.

The intended order remains: register on the hub, install the node, copy its key
from the local UI, paste it into Settings &gt; Link Node. The daemon now survives
being started out of order instead of failing with a traceback.

Adds QE/deploy/ — generic deployment (deploy-hub.sh, deploy-node.sh) kept
separate from the demo scenario (demo.py, demo.env, README.md). Credentials live
in QE/, which is gitignored; verified with git check-ignore.

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>
</feed>
