<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-common/src/meshbay_common/protocol.py, branch 0.5</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.5</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.5'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-08-16T13:28:53Z</updated>
<entry>
<title>feat(common): MNP 0.2 — liveness, and chat history read the way it is written</title>
<updated>2026-08-16T13:28:53Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-16T13:28:53Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=fd770dc6293be67298f582de5800f2ca6fe24a8b'/>
<id>urn:sha1:fd770dc6293be67298f582de5800f2ca6fe24a8b</id>
<content type='text'>
`get_messages` pages forward from the oldest message. That is the right shape
for "what happened since I last looked" and the wrong one for opening a
conversation, and the browser asked it for `since=0, limit=200` — so a group
with more than two hundred messages showed its first two hundred and the
exchange anyone came for was unreachable. Demonstrated on 300 messages: the
newest was simply absent from the answer.

`get_recent` and `get_before` page backwards, cursored on the row id rather than
the timestamp. Nothing makes a `time.time()` float unique, and a cursor on a
value two rows can share eventually skips a message or repeats it.

PING/PONG covers liveness on an already-open channel: a DataChannel whose peer
vanished without closing still reads as connected, and nothing noticed until a
real request hung. It is not a discovery mechanism — opening a connection to
ping costs a full ICE/DTLS handshake, measured at 0.6-7 s across two ISPs — so
presence in the group list comes from the hub's registry instead.

Both additions are backward compatible: an 0.1 peer sends no `before` and is
answered with the newest page, which is what it wanted.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix: stop a stream on close, count only real users, record where a node is</title>
<updated>2026-08-15T17:01:08Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T17:01:08Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=05f4feab641740c944d636f29a03f8c0dd1328c7'/>
<id>urn:sha1:05f4feab641740c944d636f29a03f8c0dd1328c7</id>
<content type='text'>
**Closing the viewer left the node working.** Nothing told it to stop:
the player dropped its handlers, which only made the browser deaf. ffmpeg
kept running and held one of the node's two transcode slots until the
credit timeout expired two minutes later — which is why the next video
answered "server busy". `stream_stop` ends it at once, and the viewer
also drops its queue, ends the MediaSource and revokes the object URL on
the way out, any of which could be holding megabytes of decrypted video.

While there: `file_chunk` replies were matched to their requests by
arrival order, which was true by luck rather than by construction. The
reply now names the file it belongs to and is matched on that and the
chunk index; a chunk nobody is waiting for is dropped instead of being
handed to whatever request happens to be oldest.

**The administration panel counted its own history.** A deleted account
is tombstoned so the connection log stays readable, and every count and
list treated that row as a user — including a group's member count, and
the member list of the group itself. They do not any more.

**Where a node is.** `endpoint_hint` is what a node believes its address
to be, learned from a STUN server and sent to us: useful for reaching it,
and a claim. The announcement that carries it is signed with the node key
over a fresh timestamp, so the address that request *arrives from* is the
address of whoever holds that key — that is now recorded on the node row
and shown in a Nodes tab, next to the hint, with the difference spelled
out. Clients get the same treatment: `webrtc_offer` is logged with the
address the hub saw when a browser starts a peer connection.

Verified against the live deployment: the node's row reads 90.112.206.172
after a restart, and in e2e a stopped stream goes quiet in one message
and the next one starts immediately instead of being refused.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(groups): remove a member, and keep gigabytes out of the tab</title>
<updated>2026-08-15T15:23:10Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T15:23:10Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=dd3927a661273734493f65a593755b95aecf5f09'/>
<id>urn:sha1:dd3927a661273734493f65a593755b95aecf5f09</id>
<content type='text'>
**Removing a member.** The owner can do it from the Members tab, and it
is two halves in the order that fails safe: the node stops serving the
group key first (an operator-signed request, so a paired browser only),
then the hub drops the membership row. The other order would leave
someone able to reach a node that still serves them.

It is a membership, not an account. The user row is never written: their
other groups, their files and their pinned identity survive, because one
group's owner must not be able to erase someone from the hub. It is also
per group — a node hosting two loses them from one — and it does not take
back the key they already unwrapped, which is what rotating the GEK is
for. The confirmation and the panel both say so.

**Downloads and streaming through the disk, in both browsers.** The audit
this started as found two ways to put gigabytes in a tab.

Firefox and Safari have no File System Access API, so every download
there was collected in memory. A service worker fixes it: the page keeps
the writable half of a transferred stream, the worker answers a made-up
URL with the readable half and a Content-Disposition header, and the
browser writes it to disk as it arrives, with real backpressure. The
worker caches nothing and falls through on every request that is not one
of these downloads. A zip announces no Content-Length, since the archive
is larger than the files in it and a length we miss truncates the file.

Video was worse and affected both browsers. The node pushed ffmpeg's
whole output as fast as it was produced while the player consumed a
segment at a time, so the queue held the film — and appending all of it
hit the SourceBuffer's cap, where the handler logged the error and
dropped the segment, leaving a hole in the middle of the film with
nothing to show for it. Streaming is credit-based now, 24 segments of
256 KB in flight, verified against the live node: three credits, three
segments, then silence until more are granted. The player evicts what is
more than a minute behind the playhead and retries a refused segment
rather than dropping it.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(files): download a folder as a zip, and remove an empty one</title>
<updated>2026-08-15T10:19:22Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-15T10:19:22Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=8cd7e467ebec987f66c4fe93a8d87dfbc57304d2'/>
<id>urn:sha1:8cd7e467ebec987f66c4fe93a8d87dfbc57304d2</id>
<content type='text'>
Two things a Files panel needs and did not have.

**Removing a directory** is privileged, where creating one is not: it
acts on a name other members are using, on the operator's disk. It is
refused unless the directory is empty, and that rule is the safety
property — whatever the browser sends, this cannot destroy content. The
check runs twice, once before the challenge and once after the signature
comes back, because a file can land during the round trip. A file also
accepts its uploader's key; a directory has no uploader, so only the
operator's key will do.

**Downloading a folder** produces a zip built in the browser, written
straight to disk as the chunks arrive. An archive of a group folder is
routinely tens of gigabytes, so nothing is held: peak memory is one chunk
plus a small record per file. The node is not involved at all — it serves
the same encrypted chunks as any other download, holds no temporary
files, and cannot be asked to compress anything.

zipstream.js is store-only. Group content is video and images, already
compressed, so deflate would spend CPU on every byte to save nothing, in
the thread that is also decrypting. Sizes and CRCs go in a data
descriptor after each file because a stream cannot seek back to patch a
header, and zip64 kicks in per entry past 4 GiB and for the archive
itself. Because none of that can be checked from the Python side of the
house, test_zipstream.py runs the real module under Node and reads what
it produces with zipfile — CRCs, UTF-8 names, zip64 records and all. The
archives also pass `unzip -t`.

Firefox and Safari have no File System Access API, so there is nowhere to
stream to: the fallback builds the archive in memory and says so, with
the size, before starting rather than after failing.

One mistake worth recording: the first version of deleteDirectory passed
the node's own answer as the value to check the challenge against, which
turns the comparison into a tautology. It checks the path we asked for.

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(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>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: 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>feat: Phase 12 — P2P crypto material, password split, node Ed25519 auth</title>
<updated>2026-08-13T01:56:30Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-13T01:56:30Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=f0248975908ad670fa8a820f865bf22ea8d0172d'/>
<id>urn:sha1:f0248975908ad670fa8a820f865bf22ea8d0172d</id>
<content type='text'>
Baseline commit capturing in-progress Phase 12 work that was already present
in the working tree (uncommitted) before the Phase 11.5 security remediation
begins. Committed as-is, without review or modification, so that remediation
changes arrive as a separable diff.

Contents: BundleStore (P2P GEK + keypair bundles), password split
(auth_key / bundle_key), node Ed25519 auth (POST /v1/nodes/auth, node-scoped
JWT), GEK-HMAC handshake proof with DTLS channel binding, Ed25519 admin
challenge-response, node local admin UI rewrite, browser key persistence.

Not authored in this session — captured to establish a baseline.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat: Phase 10c — MSE video streaming (real-time playback)</title>
<updated>2026-08-11T14:47:01Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-11T14:47:01Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=e608b95bf1fe225915eaeafca2a933f687844733'/>
<id>urn:sha1:e608b95bf1fe225915eaeafca2a933f687844733</id>
<content type='text'>
Replace download-then-play VideoPlayer with MSE (MediaSource Extensions)
streaming. Node remuxes to fMP4 via ffmpeg, probes codecs with ffprobe,
and sends encrypted segments over DataChannel. Browser decrypts and
appends to SourceBuffer — playback starts within seconds.

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