<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages, 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-14T18:54:47Z</updated>
<entry>
<title>chore: release 0.2.0</title>
<updated>2026-08-14T18:54:47Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T18:54:47Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=e525f7e123b621deec10a9719efa10ccafa93ad0'/>
<id>urn:sha1:e525f7e123b621deec10a9719efa10ccafa93ad0</id>
<content type='text'>
All three packages together, as the conventions require, plus the RPM and DEB
metadata and their changelogs.

The tag said 0.2 while every package announced 0.1.0, which would have shipped an
RPM claiming to be the reviewed build while containing a different protocol: the
hub schema lost the user identity keys, tokens lost pk_user, and
gek_bundle_store left the wire. Pre-1.0, a breaking change bumps MINOR.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(node): an operator keeps their role when reconnecting</title>
<updated>2026-08-14T17:25:39Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T17:25:39Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=d324792d68503109ab99616af6c85ee37045e169'/>
<id>urn:sha1:d324792d68503109ab99616af6c85ee37045e169</id>
<content type='text'>
An operator's roster row is node-wide, so looking it up by the group they happen
to be opening found nothing and the client was told it had no role on a node it
administers. Falls back to the node-wide row.

Surfaced by running the live workflow twice: the first pass pins, the second is
recognised — and only the second exercised this path.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<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>perf(client): bundle KDF to 128 MB, and derive it once per sign-in</title>
<updated>2026-08-14T12:25:44Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T12:25:44Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=1c96aceb54d66cae1b48aa0eb8887f68e53f9e24'/>
<id>urn:sha1:1c96aceb54d66cae1b48aa0eb8887f68e53f9e24</id>
<content type='text'>
Argon2id memory 64 → 128 MB. Memory is the lever, not time: it caps how many
guesses a card can hold at once, so the ceiling on one high-end GPU moves from
roughly 4k to roughly 2k guesses/s and its 24 GB fits ~187 lanes instead of ~375.
Measured through the vendored build: 640 ms, against 322 ms at 64 MB.

While measuring the real cost of a sign-in, found the SPA deriving the bundle key
twice — once for the key pair kept for the session, then again inside
decryptBundle() for the local bundle. At these parameters that is 0.6 s of pure
waste. Measured now, end to end:

    auth_key   (PBKDF2 600k)     239 ms
    bundle v1  (PBKDF2 600k)     240 ms   legacy, until every bundle is upgraded
    bundle v2  (Argon2id 128MB)  650 ms
    -----------------------------------
    sign-in                    1 129 ms   (889 ms once no v1 bundles remain)

Once per sign-in, and only then: reopening a group, downloading, streaming and
reloading the page all reuse the key, which lives in IndexedDB from login.

Also bounds two waits in the node's hub WebSocket, found because the node went
silent again mid-deploy. It had reconnected after the hub restart, sent its auth
frame, and waited for a reply that never came — `ws.recv()` had no timeout, so a
hub that accepts a socket and then says nothing for a few seconds while starting
up parks the task forever: node running, logging nothing, invisible to everyone.
The auth exchange now times out at 15 s, connect at 15 s, and a refused auth
retries with a fresh token instead of ending the task for good.

QE harness signs in once per account and reuses the token — several clients there
stand for several browsers of one person, and what tells them apart is which keys
they hold, not which token, while the hub quite rightly rate-limits repeated
logins from one address.

Tests: 341, plus the live workflow.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(client): Argon2id for the keypair bundle, and remove the backup toggle</title>
<updated>2026-08-14T10:42:51Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T10:42:51Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=86563d5db0ae19fc58336b71a5c29a8712973590'/>
<id>urn:sha1:86563d5db0ae19fc58336b71a5c29a8712973590</id>
<content type='text'>
Two corrections to yesterday's judgement, in the order they matter.

**The toggle is gone.** Asked to make the remote key backup optional, I shipped a
setting whose "off" position meant: no second browser, ever, and clearing your
storage destroys the account. I wrote the warning that says so without drawing
the conclusion. A control whose only effect is to break the ordinary case is not
a control, and removing an exposure by removing the feature is not a fix. Every
browser backs its keys up again, unconditionally.

**The exposure is fixed where it actually lives: the KDF.** The keypair bundle
rests on every node whose group its owner joins, protected by the passphrase
alone (finding C4). It used PBKDF2-SHA512 at 600k — compute-only, which is
exactly what a GPU eats. Measured on this machine: PBKDF2 600k costs 241 ms and
Argon2id 64 MB/t=3 costs 322 ms, near enough the same honest work, except only
one of them forces an attacker to find 64 MB per guess.

So the bundle key is now Argon2id 64 MB / t=3 / p=1, via a vendored WebAssembly
build (no external host — the CSP forbids one, and 12.2 will tighten it further).
Parameters chosen by measurement through that build: 19 MB is OWASP's floor at
118 ms, 256 MB is 1.3 s and too slow for a phone, 64 MB sits where a login should.

What this buys, stated honestly: cracking a bundle yields the owner's identity
keys, and with them content on OTHER nodes and the ability to sign as them — not
the content on the operator's own node, which they host in the clear by design.
Argon2id raises that price steeply; it does not remove it, and a weak passphrase
still loses. Hence the floor raised to 12 characters and ~60 bits in the same
breath, which can only be enforced client-side: with the password split (T1) the
hub never sees a passphrase.

Migration is automatic and invisible. Bundles carry an "MBK2" marker; the old
form is still readable, and is re-encrypted the first time a browser backs it up.
Both keys are derived at sign-in, because which one a bundle needs is only known
once it is read and the passphrase is deliberately not kept around.

Two implementations of the KDF now exist — the browser's WASM and argon2-cffi in
QE — so a parity test holds them byte-identical. A disagreement would not look
like an error; it would look like an account nobody can open.

keypair_bundle_delete stays, without a UI. It is the mechanism behind withdrawing
your data from a node, exercised end to end, and it will belong to a deliberate
"forget me on this node" action rather than a setting that quietly disables
multi-device.

Verified against the live deployment: the full workflow passes, including
recovering keys on a second client from the passphrase alone.

Tests: 341.

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>test: prove a second browser works after pairing</title>
<updated>2026-08-14T09:15:27Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T09:15:27Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=6309894d019421f54cff311e630f3897f7eba93e'/>
<id>urn:sha1:6309894d019421f54cff311e630f3897f7eba93e</id>
<content type='text'>
The mechanism was already there — the encrypted keypair bundle goes to the node
after a first successful connection, and any client holding the password can
recover it — but nothing exercised it. e2e.py never pushed a bundle, so the case
that matters to an ordinary user was the one case never tested.

It now does what app.js does: backs the member's keys up to the node, then opens
a second client carrying nothing but a username and a password. Against the live
deployment that client recovers its identity keys, is recognised as the same
person with no second code, gets the same group key, and browses the group.

Also guards the ordering this depends on: the keypair bundle must be fetched
before joinGroup() runs, or a browser that did not register has no key to sign
the join with — invisible on the browser that did register, broken on every
other one.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>fix(client): recover identity keys from what the browser already has</title>
<updated>2026-08-14T02:02:20Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-08-14T02:02:20Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=8f9ac4d931e43358cb0e4e82085f49e916419a8b'/>
<id>urn:sha1:8f9ac4d931e43358cb0e4e82085f49e916419a8b</id>
<content type='text'>
Registering in Firefox and coming back to it said "This browser does not hold
your keys" — while both halves of those keys were on disk a few bytes apart.

_sessionKeys lives in sessionStorage, which dies with the tab. The encrypted
keypair bundle is in localStorage from registration, and the key that opens it is
in IndexedDB from login, but nothing ever put the two together again: only the
login path did, and a returning user is restored from stored auth without logging
in. So closing a tab looked identical to never having registered there.

Recovery now happens before connecting: bundle from localStorage, key from
IndexedDB, public half derived from our own secret rather than read back from the
hub. The bundle is also queued for backup to the node, which is what lets a
second browser recover the same keys with the password.

Not hardening, and not from the invite redesign — an oversight in session
restore that the redesign made visible, because joining is now the first thing
that needs those keys.

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