| Commit message (Collapse) | Author | Age | Files | Lines |
| ... | |
| |
|
|
|
|
|
|
|
|
|
|
| |
test_daemon.py started the real admin UI on a fixed port, so every test file
that also brought up a node collided with it. Each file passed on its own and
the full node suite failed with EADDRINUSE on test_daemon_creates_chat_store —
which reads as a flaky regression rather than a test-isolation bug.
Confirmed against a clean worktree at HEAD before touching anything: the
failure predates the invite work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 > 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 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The handshake proof and admin signature transcripts are built independently in
crypto.js and in meshbay_common, and compared by producing identical bytes.
Nothing on the wire carries the transcript — that is the design — but it means a
one-byte disagreement between the two implementations is invisible to every
other test while causing a total outage: no browser could complete a handshake
with any node, and every file deletion would be rejected.
Nothing else in the suite crosses this boundary. The 278 Python tests would all
still pass.
Drives the real crypto.js under node (stubbing window and crypto, which the
module body touches but these functions do not) and compares against the real
Python for the same vectors: both roles, short and empty group ids, non-ASCII
group names and filenames — TextEncoder and str.encode must agree on UTF-8 —
and field splits that would collide under naive concatenation.
Verified to actually catch a mismatch rather than trusted for passing: removing
one length prefix from the JS fails 6 vectors, and changing a single byte of the
domain-separation prefix fails 6. crypto.js restored byte-identical afterwards.
Skips when node is absent, which is a coverage gap rather than a pass — worth
making a hard failure in CI (18.4).
Tests: 168 hub+common.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The client verified the node's Ed25519 signature but did not remember which key
it had seen, so a substituted node was caught only by its lack of the GEK.
Trust On First Use: the node's public key is recorded per node_id on the first
successful handshake and compared on every later one. A change is refused
outright — strict, per operator decision. A warning users can click through is
decorative, and this is the SSH known-hosts tradeoff taken deliberately.
Scope, stated honestly: with C6 closed this is defence in depth, not the primary
control. A substituted node already fails the GEK proof. Pinning covers the case
where an attacker HAS the group key — an ex-member, or a leaked GEK — and swaps
the node underneath, which the proof alone cannot distinguish from the real one.
Strict refusal needs an escape hatch or it is a dead end: a node operator who
reinstalls and loses their keystore generates a new pk_node and would otherwise
lock out every member. Settings gains a "Node identities" section showing the
pin count and clearing them, with copy telling the user to verify out of band
first. Also exposed as MeshBayTransport.clearNodePin() for the native client.
Tests: hub+common green; all five static JS files syntax-checked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Phase 11.5.10.
POST /v1/nodes/announce accepted any pk_node with no proof the announcer held
the matching private key, so a user could register a node record carrying
someone else's node key, and records accumulated without limit.
The announcer now signs a domain-separated message binding the key to their
account — meshbay:node_announce:{user_id}:{pk_node}:{timestamp} — reusing the
shape already proven by /v1/nodes/auth, so a signature for one can never
satisfy the other. Same 60-second window.
Re-announcing the same key now updates the existing record in place instead of
creating a new row.
Three test helpers had to be taught to sign, which is the useful part: nothing
in the suite had ever exercised announce with an attacker's key. The new tests
cover the missing proof, a foreign key, a stale timestamp, and idempotence.
Note for the record: the node key is independent of the user's identity key.
Two hub tests asserted the announced pk_node equalled the user's pk_ed, which
happened to be true only because the daemon announces its keystore key. They
now assert against the announced key itself.
Tests: 157 hub+common, node suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Phase 11.5.4/5/6 — finding C6, the last open critical finding.
QUIC ran a JWT-only handshake: a forged or stolen token reached the node and
could inject chat without ever holding the group key. It now runs the same
challenge/response as WebRTC through meshbay_common.handshake — client nonce,
role-bound length-prefixed transcript, GEK proof, and the node proving itself
with a GEK proof plus an Ed25519 signature over the transcript (C3).
11.5.6 channel binding, resolved by spike and then by two findings the spike
could not predict:
* aioquic 1.3.0 exposes no RFC 5705 exporter, and the peer certificate only
via a private attribute. The server reads its own certificate from disk, so
no internals are touched on that side; the client's access is guarded and
fails loudly if an upgrade moves it.
* A RESUMED TLS session carries no certificate — aioquic does not re-send it,
so there is nothing live to bind to. The anchor therefore travels with the
session ticket, which is sound because the ticket is cryptographically
derived from the handshake where that certificate was presented.
* The anchor had to travel with the ticket rather than live on the client
object: resumption constructs a fresh client, so an instance-level cache
was silently useless. Caught by the resumption test, not by inspection.
Both paths refuse rather than degrade. No certificate and no cached anchor
means the handshake fails; it never falls back to an unbound proof, which would
silently drop MitM detection (L4).
QuicChunkClient gains a peer_cert_der property and constructor argument,
mirroring how session_ticket is already carried by the caller.
Tests: 9 quic/multi-group, full node+common suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Phase 11.5.4 — QUIC half. Findings M1, M9 on this transport.
quic_server._do_handshake_sync was a second, weaker copy of the WebRTC logic:
group_id was optional, so omitting it skipped the membership check entirely and
fell back to the node's first group (M1); node-scoped daemon tokens were
accepted as client tokens (M9); and the checks could drift from the WebRTC path
independently, which is how they diverged in the first place. Authorization now
comes from meshbay_common.handshake, shared with WebRTC.
C6 IS STILL OPEN ON THIS TRANSPORT. There is no GEK proof here yet: a forged or
stolen token still reaches the node over QUIC and can inject chat without
holding the group key. What remains is the challenge/response and the mutual
node proof — quic_binding() is written and unit-tested for exactly this, and
11.5.6 (whether a certificate hash is the right anchor, or an RFC 5705 exporter
is reachable from aioquic) is still unproven. This commit narrows the gap to
the proof itself; it does not close the finding.
QUIC tests updated: default tokens are members of the test group, and clients
pass group_id, since it is mandatory now.
Tests: 9 quic/multi-group, full node+common suite green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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=<new> 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 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Phase 11.5 — finding C2 (see second-review.md).
/v1/nodes/ws took node_id and group_ids straight from the client's first
message with no ownership check:
node_id = msg.get("node_id") or decoded.get("sub", "unknown")
_connected_nodes[node_id] = ws
Any registered user could connect with an ordinary browser token, claim a
victim node's id and overwrite its entry. Every WebRTC offer for that node was
then relayed to the attacker, who answered with their own SDP — full node
impersonation. The DTLS channel binding does not help, because the attacker is
the endpoint rather than a relay: the browser sends its GEK proof to the
attacker, who ignores it and replies handshake_ack. The attacker received the
victim's encrypted keypair bundle, chat and uploads, and could serve a forged
index.
Registration now requires scope == "node", verifies Node.user_id against the
token subject, checks the account is active, and refuses to displace a live
registration instead of silently overwriting it.
group_ids are intersected with the operator's actual membership: a node may
narrow the set to what it hosts but cannot widen it, so it cannot advertise
itself as an online source for arbitrary groups.
Authorization uses a short-lived session rather than Depends(get_db): a node
WebSocket lives for hours and a request-scoped dependency would pin a
PostgreSQL connection for its whole lifetime.
BEHAVIOUR: a node hosting a group whose hub membership was never recorded for
the operator's account will stop appearing in GET /v1/groups/{id}/nodes.
Adds tests/test_node_ws_auth.py (7 tests). The node WebSocket had no test
coverage at all, which is why this went unnoticed.
Tests: 109 node, 139 hub+common.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Phase 11.5.A — findings C1 and C6 (see second-review.md).
C1: the per-group HTTP file API bound 0.0.0.0 for every configured group,
private ones included, and served two endpoints with no authentication at all:
GET /index (full Mesh Group Index) and GET /file/{id} (raw plaintext file via
FileResponse). Anyone able to reach the port — LAN, forwarded port, permissive
IPv6 — read every private file. This bypassed the entire GEK-proof and node
sovereignty layer. Deleted rather than patched: it duplicated MNP without any
of its controls.
C6: the TCP+TLS chunk server accepted a bare JWT with no GEK proof, leaving a
second non-compliant handshake path. Deleted; QUIC remains and will be brought
to parity with WebRTC by the unified handshake in 11.5.4.
Transport decision recorded in transport/__init__.py: WebRTC/ICE is primary for
browser and native clients (the only NAT traversal validated here — 2 ISPs,
IPv4 STUN + IPv6, 4G CGNAT); QUIC is kept for LAN, port-forwarded and hub-less
group:// access. punch_nat() is a direct-connection helper, not a traversal
stack.
Also removed server_ssl_context()/client_ssl_context() from tls_cert.py (no
remaining callers) and a dead import of the former in quic_server.py.
generate_self_signed_cert() stays: QUIC uses it, and the certificate hash is
the intended channel-binding anchor for 11.5.6, since QUIC has no DTLS
fingerprint to bind the GEK proof to.
BREAKING CHANGE: node.toml keys `port` and `http_port` are gone. Regenerate
config with `meshbay-node init`. Env var MESHBAY_PORT -> MESHBAY_QUIC_PORT.
Tests: 198 passed (209 - 7 test_http_server - 4 test_transport). No other test
changed status. Net -1300 lines.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Add SQLite audit store for legal compliance (LCEN/DSA): logs user IP,
actions (handshake, file download/upload/delete, stream, chat), and
timestamps. Retention: 1 year, with cleanup method.
WebRTC transport now logs all user actions to the audit store with
remote IP extraction from the ICE transport.
Local web UI rewritten as a proper admin dashboard:
- Stats cards (groups, files, peers)
- Connected peers table with IP, username, group, state
- Group cards with file listings and shared directory info
- Audit log page with event/user filtering
- Dark theme, responsive, auto-refresh
- JSON API: /api/status, /api/groups, /api/peers, /api/audit, /api/config
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
| |
When watchdog detects file changes, the daemon now:
- Pushes INDEX_SYNC to all connected WebRTC peers in that group
- Registers file hashes with hub /v1/swarm/register endpoint
Also registers all file hashes on startup for initial discovery.
hub_client: add register_swarm() method for bulk hash registration.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The node daemon was previously a skeleton that only started QUIC/TCP
servers and the local web UI. All browser-facing functionality (WebRTC,
hub WebSocket, chat store, HTTP file API) lived in QE demo scripts.
This rewrites daemon.py to be fully self-contained:
- WebRTC transport for browser clients (aiortc DataChannel)
- Hub WebSocket task (signaling, revocations, WebRTC offers)
- ChatStore per group (SQLite in ~/.local/share/meshbay/)
- HTTP file API per group (create_http_app on configured port)
- Graceful shutdown (all transports, stores, tasks)
- hub_client: _ws tracking + send_ws() for chat notifications
- config: data_dir field for persistent state
- systemd: security hardening (ProtectSystem, StateDirectory)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
| |
aiortc advertises maxMessageSize=65536 in SDP. A 64KB data chunk +
msgpack envelope + 4-byte length prefix exceeds this limit, causing
"Trying to send message larger than max-message-size" on upload.
48KB data + overhead stays well under 65536 bytes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two fixes for the MSE video player:
1. Set mediaSource.duration from the ffprobe-reported duration on
sourceopen, so the seek bar shows the correct video length instead
of NaN/infinite.
2. Use SourceBuffer mode='sequence' for sequential append without
timestamp gaps. Add a seeking handler that clamps currentTime to
the buffered range — seeking beyond buffered data snaps back
instead of freezing the video.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
1. "Connecting..." was shown in 3 places simultaneously (status badge,
cached files area, and general status). Now only the badge shows it
when cached files are visible — the redundant messages are removed.
2. The floating stream progress bar caused constant re-renders during
video streaming (every 256KB segment triggered setState). The fMP4
remux size also differs from the original file size, making the
progress inaccurate. Removed the overlay bar entirely — the video
element's native buffered range indicator is sufficient.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
| |
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 <noreply@anthropic.com>
|
| |
|
|
|
|
|
| |
The function was placed between the decorator and node_websocket,
breaking the WebSocket endpoint registration (403 on all WS connects).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
inline thumbnails
Backend:
- Chat store persists sender_name (SQLite migration, no more UUID display)
- FILE_DELETE / FILE_DELETE_ACK MNP types — node admin can delete files
- Node sends chat_notify to hub WS — hub creates notifications for offline members
- Hub revocation.py handles chat_notify, creates per-member notifications
Frontend:
- Upload chunk size 64KB (was 1MB) — fixes WebRTC DataChannel max-message-size
- Show cached files immediately while WebRTC connects
- ChatImage component — inline image thumbnails in chat (download+decrypt)
- File delete action in menu (group admin, with confirm dialog)
- Member panel: "Owner" label instead of confusing "Group admin"
- Create group page: hint about needing a node
- Refresh index after chat attachment upload
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
inline thumbnails
- Upload chunks capped at 64KB to avoid WebRTC DataChannel max-message-size
- Show cached files immediately while WebRTC connects (tabs visible during connection)
- Persist sender_name in chat store (SQLite) — no more UUID display in history
- File delete action in menu (node admin only, enforced server-side)
- FILE_DELETE / FILE_DELETE_ACK MNP message types
- Inline image thumbnails in chat attachments (download+decrypt, Signal-style)
- Member panel: "Owner" label instead of "Group admin" to avoid hub/group admin confusion
- Create group page: hint about needing a node
- Refresh index after chat file attachment upload
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
| |
Concurrent peer disconnects could mutate _peers/_sessions during
iteration, causing RuntimeError: dictionary changed size during iteration.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Bug fixes:
- Group creation "[object Object]" error: removed dead pkcs8 import code
that threw before GEK wrapping, added array detail handling in hubFetch
- Chat shows usernames instead of UUIDs (sender_name passed through node)
- Join button: navigate to group on "Already a member" instead of error
UI improvements:
- Loading spinner animation for async states (connecting, fetching)
- File action menu (3-dot dropdown: View, Download, Play)
- Click filename to preview inline (images, text/code files)
- FilePreview overlay for images and text files
- Chat file attachment button (upload to node + structured message)
- Chat attachment display (icon, filename, size)
- Member panel: "Group admin" badge instead of plain "Admin" text
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
search)
Six self-service features for the web SPA:
- Group creation UI with GEK auto-generation (AES-256-GCM ECIES)
- Member management + invite by username (GEK wrapping for invitee)
- Open group self-join flow (POST /v1/groups/{id}/join)
- File upload client→node (FILE_UPLOAD MNP type, .uploads/ staging)
- IndexedDB caching of group file indexes (instant display on revisit)
- Cross-group file search (SearchPage, pure client-side on cached indexes)
11 new tests (166 total): 8 group self-service + 3 AES GEK wrap/unwrap.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
- 10.5: Notification model + CRUD API (list, mark read, mark all read)
Triggered on: group invite, role change, suspend/unsuspend
- 10.6: SettingsPage shows role, per-group notification mute (localStorage)
- 10.7: GET /v1/groups?q= search filter (ilike on name)
- 10.8: NotificationFeed on home page + bell with unread badge in navbar
- 10.10: GET /v1/hub/version endpoint for client update checks
- 8 new tests (test_notifications.py), 155 total
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
- Site overlay: landing page, /about, /downloads (dark/light, responsive)
- User role column (user/moderator/admin) with config-based admin sync
- require_moderator dependency + admin API (8 endpoints: stats, users,
groups, audit logs)
- Admin SPA panel at #/admin with 5 tabs (stats, users, groups, logs,
blocklist) — visible only to moderators/admins
- SPA also served at /app/ for Caddy site overlay integration
- GET /v1/users/me returns current user role
- 15 new tests, 147 total passing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Complete browser-based client: Preact SPA with login, group file browser,
encrypted download, video playback, group chat, i18n, and dark/light theme.
Browser connects P2P to nodes behind residential NAT via WebRTC DataChannel
(aiortc). Hub handles signaling only — all data flows E2E.
Performance: pipelined downloads (8-chunk sliding window), binary msgpack
wire format (no base64), redundant I/O elimination. Large file downloads
stream to disk via File System Access API (showSaveFilePicker).
Validated on SFR + Orange residential NATs, Chrome + Firefox, IPv4/IPv6.
132 tests passing. Deployed to meshbay.org + Orange node.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Browser clients can now connect P2P to nodes behind residential NAT via
WebRTC DataChannel with ICE/STUN. Validated on SFR Port-Restricted Cone
NAT + 4G CGNAT across three scenarios (WiFi LAN, 4G IPv6, 4G IPv4 STUN).
No TURN relay needed. Hub serves only as signaling relay (<1 KB).
New files:
- webrtc_server.py: aiortc-based WebRTC transport (node side)
- signaling.py: SDP/ICE relay endpoint (hub side)
- transport.js: browser WebRTC client with msgpack framing
- webrtc-test.html: spike test page for browser→NAT→node validation
- test_webrtc_transport.py: 4 tests (handshake, file transfer, auth, guard)
- meshbay-draft-v4.md: architecture spec updated for web client
Modified:
- hub_client.py: WebRTC offer handling via hub WebSocket
- revocation.py: node_id from WS auth + webrtc_answer routing
- pyproject.toml: aiortc>=1.9 dependency
123 tests passing (117 existing + 6 new).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
8.1 Config-based admin authz (require_admin on all admin endpoints)
8.2 Email encrypted at rest (AES-256-GCM, HKDF from hub Ed25519 key)
8.3 Refresh token rotation with family-based reuse detection
8.4 Federation persistence (HubPeer model replaces in-memory dict)
8.5 Federation token verification now async (DB-backed)
8.6 CSAM hash check wired into swarm registration flow
8.7 Rate limiting on auth endpoints (5/10/20 per minute)
8.8 Healthcheck endpoint (GET /v1/health, no auth)
8.9 IP log cleanup background task (365-day retention)
8.10 Argon2id params bumped to 256 MB (pw_version, rehash on login)
Deployed to meshbay.org — schema migrated, existing emails encrypted.
117 tests pass (29 hub, 88 common+node).
Resolves security review items S1, S2, S5.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Implements all 8 milestones (7.0-7.7):
- 7.0: JWT carries `groups` claim; node verifies group membership at
MNP handshake (QUIC + TCP+TLS). Resolves security review C2.
- 7.1: QUIC 0-RTT session resumption via stored session tickets
(17-21ms reconnect vs 47ms cold).
- 7.2: Hub→node WebSocket signaling for NAT punch coordination
(`client_incoming`/`punch_ready`) + jti denylist push. Denylist
class blocks revoked users/jtis at handshake.
- 7.3: Multi-group daemon — one QUIC port serves N groups with
per-group GEK, shared_root, and index routing.
- 7.4: HLS streaming via QUIC (STREAM_SEGMENT message type, ffmpeg
segment extraction).
- 7.5: Sender Keys protocol for group chat (Signal Groups approach).
Each member has own sending chain key, HKDF chain ratchet, AES-256-GCM
encryption, Ed25519 signing. Resolves security review C1.
- 7.6: Chat store (SQLite via aiosqlite), CHAT_MESSAGE MNP wire type
with peer broadcast, web UI with WebSocket push.
- 7.7: Argon2id calibration CLI.
First security review included (first-review.md). 109 tests, demo-v3
validated against meshbay.org production hub.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
SFR residential NAT is Port-Restricted Cone: inbound is only allowed
from (peer_ip, peer_port) if the node previously sent a packet TO
(peer_ip, peer_port) from the SAME socket.
punch_nat(peer_ip, peer_port): sends a probe UDP packet from the
QUIC server's own transport (_transport.sendto), creating the correct
NAT entry. Used after server.start() to enable direct QUIC connections
through SFR NAT without UPnP or relay.
demo-v2 result: QUIC/UDP direct Fedora→SFR NAT→meshbay.org validated.
Connection time 12.7s (QUIC handshake through NAT). File transfer 700B.
QuicChunkClient local_port param: ensures client binds to same port
as punch_nat destination (Port-Restricted Cone requirement).
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
| |
local_port=0 param on QuicChunkClient — specify for Port-Restricted Cone NAT
hole punching (client must send from the same port the node probed to).
QuicChunkServer default host '::' for IPv4+IPv6 dual-stack on Linux.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
1. indexer.py: streaming blake3 (8MB chunks) instead of read_bytes().
Large files (initrd.img, ISOs, VM images) no longer load into RAM.
2. QE/demo-v1/run_node.py: call indexer.start() not initial_scan().
initial_scan() alone never starts the watchdog observer — files added
after startup were silently ignored. Added indexer.stop() on shutdown.
3. USERGUIDE.md §8: clarify symmetric vs asymmetric.
Ed25519/X25519 = asymmetric (key pairs). ChaCha20-Poly1305 and
AES-256-GCM = symmetric AEAD 256-bit (content encryption).
ChaCha20 is PRIMARY; AES-GCM is optional browser-compat variant only.
4. pyproject.toml: aioquic, websockets, aiosqlite, slowapi added to
proper package deps (were installed manually, now declared).
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
meshbay-node/pyproject.toml: add aioquic>=1.0 (was commented 'v2'),
websockets>=12.0 (revocation push). Both are production code since Phase 5.
meshbay-hub/pyproject.toml: add aiosqlite (tests without PostgreSQL),
slowapi (rate limiting), websockets (revocation push), PyJWT (explicit).
transport/__init__.py: QUIC imports wrapped in try/except — node works
without aioquic (TCP+TLS + HTTP fallback). QUIC_AVAILABLE flag exported.
QUICKSTART.md: replace manual pip list with 'pip install -e' that pulls
all deps from pyproject.toml automatically. Add dependency table.
CLAUDE.md: clarify that all deps go in pyproject.toml, not manual installs.
81/81 tests.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
CLAUDE.md: add QE/ to structure, key modules table, server state reference,
security rule updated (QE/ not keypair files), meshbay.org inventory pointer.
devel-phases.md: add milestones 6.6-6.9 (keyderive, bundle, demo scripts,
QUICKSTART rewrite). 81/81 tests.
docs/meshbay-draft-v3.md §6.1.1: new section documenting 3 key generation
strategies (Argon2id CLI, WebCrypto browser+bundle, keystore file) and the
algorithm mismatch caveat between CLI and web registration paths.
docs/USERGUIDE.md §2 Register+Login: replace "generate and persist before
registering" warning with the two clean strategies (derive_keys_from_password
for CLI, keyderive.js + keypair_bundle for browser). Login response updated
with keypair_bundle field.
hub/models.py + users.py + Alembic migration: keypair_bundle column on User,
stored at registration, returned at login (web clients only).
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
keyderive.py: derive Ed25519+X25519 from username+password via Argon2id.
Same credentials → same keys on any device. Encrypt/decrypt keypair
bundle (AES-256-GCM) for hub storage (web clients).
7/7 tests. Full suite: 81/81.
keyderive.js: browser counterpart using PBKDF2-SHA512 + random keypairs
encrypted for hub storage. Avoids algorithm mismatch with Python.
hub/models.py + users.py: keypair_bundle field added to User, stored on
registration, returned in login response for web client key recovery.
QUICKSTART.md: fully rewritten. 3 operational scripts in QE/demo-v1/:
setup_demo.py — create accounts, group, distribute GEK
run_node.py — start HTTP node (watches shared/ directory)
download.py — bob login → GEK fetch → decrypt → save
All tested locally end-to-end. No invented URLs.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
6.1 Double Ratchet (meshbay_common/ratchet.py):
Forward secrecy, break-in recovery, out-of-order delivery.
Signal-spec KDF_RK/KDF_CK via HKDF-SHA256. 11/11 tests.
6.2 Multi-group node (config.py):
[[groups]] TOML array, per-group ports, back-compat [group].
6.3 MHP federation persistence (db/models.py FederatedGroup + SwarmSource):
receive_directory() now persists to federated_groups table.
list_public_groups() includes federated results with source attribution.
6.4 Content replication (node/replication.py + hub SwarmSource):
ContentReplicator: fetch-index, download, hash-verify, register-swarm.
Hub: POST /v1/swarm/register, GET /v1/swarm/{hash} for multi-source.
6.5 Browser private group (webcrypto.py + static/crypto.js):
AES-256-GCM variant of GEK for WebCrypto-compatible groups.
crypto.js: SubtleCrypto importGEK + deriveChunkKey + decryptChunk.
Keys distinct from ChaCha20 via :aes HKDF info suffix. 4/4 tests.
74/74 tests total.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
| |
Config now supports [[groups]] array (N groups) alongside back-compat
[group] single section. Each group has independent port/quic_port/http_port
and visibility. GroupConfig gains quic_port, http_port, visibility fields.
NodeConfig gains quic_port and http_port defaults. 70/70 tests.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
RatchetState: full Signal-spec Double Ratchet (DH ratchet + symmetric
ratchet). KDF_RK/KDF_CK via HKDF-SHA256. AES-256-GCM message encryption.
MKSKIP for out-of-order delivery (max 1000 skipped keys).
ChatMessage dataclass with to_dict/from_dict for wire serialisation.
Properties validated by tests:
✓ Forward secrecy (consumed keys unreplayable)
✓ Out-of-order delivery
✓ Associated data binding
✓ Break-in recovery (post-ratchet keys independent)
✓ 100-message stress test
11/11 tests in 0.06s.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
CSAMChecker: loads blake3 hash database from file (NCMEC/IWF format).
check_content_hash(): used before serving public content.
/v1/admin/csam/status: hash count + DB path.
/v1/admin/csam/check: admin-only hash check (no hash logged).
Hash database NOT included — hub operators must obtain access
from NCMEC (US) or IWF (EU). Instructions in csam.py header.
59/59 tests.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
Federation (MHP 0.1): /mhp/info, /mhp/directory (GET=export, POST=receive),
/mhp/revoke (propagation), /mhp/peers (admin registration).
Peer auth: JWT EdDSA signed by requesting hub's key.
Explicit peer allowlist — no auto-discovery.
Relay (5.3): /v1/relays (GET=list), /v1/relays/register (relay keepalive),
/v1/relays/approve (admin pre-approval). Relays pre-approved by admin,
then self-register with signed endpoint. Nodes query when hole punch fails.
59/59 tests.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
| |
DB: ContentReport + ContentBlocklist tables.
POST /v1/reports: public endpoint, auto-blocks after 2 reports.
GET /v1/blocklist/check: node sync check before serving public content.
GET /v1/blocklist: full list for node startup sync.
GET|POST|DELETE /v1/admin/blocklist: admin management.
6/6 tests. Full suite: 59/59.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
Hub: /v1/nodes/ws WebSocket endpoint for persistent node connections.
/v1/admin/revoke marks user/group revoked in DB, signs JWT revocation
token (EdDSA), broadcasts to all connected nodes.
Node: RevocationSubscriber maintains WS connection, verifies
incoming revocation tokens offline (hub Ed25519 PK), adds to
local blocklist (_revoked_users/_revoked_groups sets).
53/53 tests.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
QuicChunkServer/QuicChunkClient: same MNP protocol over QUIC/UDP.
Enables hole-punching (Spike 4 Cone NAT validated). Uses aioquic 1.3.0.
Bug found+fixed: asyncio.Event race condition in client recv loop
(quic_event_received overwrote _stream_events[0] after _recv
created it). Fixed with asyncio.Queue (no shared mutable state).
Server uses synchronous handlers in quic_event_received (avoids
ensure_future transmit timing issue). 3/3 tests. Full suite: 50/50.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
| |
webapp.py: serves / (HTML SPA) and /app.js (JS client).
app.js: login, hub API calls, node HTTP API integration,
file browser, HLS video streaming via <video> tag.
groups.py: GET /v1/groups — public group listing (no auth).
Deployed on https://meshbay.org — 200 OK. 47/47 tests.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
FastAPI app on port 19001: GET / (node info), GET /index (public
group index JSON), GET /file/{id} (full download), GET /file/{id}/{n}
(encrypted or plaintext chunk), GET /hls/{id}/playlist.m3u8 +
GET /hls/{id}/{n}.ts (HLS streaming via ffmpeg).
Public groups: index browsable without auth, files downloadable.
Private groups: chunks encrypted with GEK, auth required.
7/7 tests passing. Full suite: 47/47.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|