diff options
26 files changed, 14 insertions, 797 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 23c587e..6daa778 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -1278,8 +1278,7 @@ new client, and the hub, node and SPA deploy together. with each MAJOR, so `check_version` refuses at the handshake any peer that cannot meet one: an upload is sealed or it is not sent; a transfer has a real lease or it does not run; there is one app-directories op and no wrappers behind it. The client -records the version its peer declared, for diagnostics, and **branches on none of -it**. +checks the version its peer declared and **branches on none of it**. > A capability flag on a peer whose floor already guarantees the capability is a > branch that can only ever take one path — until somebody lowers the floor, at @@ -1979,10 +1978,6 @@ and is refused when public groups are off.** An unauthenticated endpoint that blocklists a content hash after two reports is a network-wide censorship and DoS primitive for anyone who learns a public file's id. -The exact-hash CSAM check is **structural, not yet functional** — production -databases are perceptual — and is stated as such so it is not relied on -operationally. - ### 7.6 Federation (MHP) > **Federation is closed in the code, and every MHP route refuses with a stated @@ -3182,7 +3177,7 @@ be understood, not so the incident can be retold. | **M4** *(third review)* | Federation binds a pushed row's source to the signer, checks the token audience, caps the push, rejects replays, and scopes revocation to the peer's own entries (§7.6) | | **M5** *(third review)* | A CSP and security headers apply to the hub-served application, verified against the running app — a mis-tuned CSP shows as a blank page | | **M6** *(third review)* | **Withdrawn.** It misread the node registering a hub membership during the CLI invite flow — which is deliberate — as authorization drift | -| **L1–L11** *(third review)* | Opportunistic hardening: relay-registry proof of possession; delete orphaned modules rather than leaving them to be rewired; decide and document account enumeration; an aggregate upload quota; header-only control-API tokens; a freshness bound on revocation replay; state that the exact-hash content check is structural; validate group-name length and charset; require `exp` and bind an audience on token decode; key the rate limiter through the same client-address helper as everything else; keep diagnostic logging truncated | +| **L1–L11** *(third review)* | Opportunistic hardening: relay-registry proof of possession; delete orphaned modules rather than leaving them to be rewired; decide and document account enumeration; an aggregate upload quota; header-only control-API tokens; a freshness bound on revocation replay; validate group-name length and charset; require `exp` and bind an audience on token decode; key the rate limiter through the same client-address helper as everything else; keep diagnostic logging truncated | Two structural recommendations from that review stand as rules: @@ -3443,7 +3438,6 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows. | **A signed upload transcript** | Ownership is recorded by the node and verifiable by nobody else (§5.4). Making it provable is a transcript the uploader signs, stored with the entry — designed in outline, not built | | Forward secrecy in group chat | **Given up deliberately and on the record** (§4.5). If it becomes a requirement it belongs in 1:1 DM | | Metadata at the hub | Membership, and who posted in which group and when. A known leak, not a solved problem (§7.1) | -| The exact-hash content check | Structural, not functional (§7.5) | | **QUIC** | Off by default, and **not at parity**: it serves the index and file chunks with no transfer lease, no leaseless ceiling and no root-availability check, does its file I/O on the event loop, and returns exception text to the peer (**L3**). No client speaks it. Either it comes to parity or it goes; until then §5.1's "chat is the only gap" is the one sentence here that overstates the code | | **The relay registry** | **Closed in the code**: `relay.RELAYS_ENABLED` is False and every `/v1/relays` route answers 503, as federation does. Nothing in the tree calls them, node or client, and §11.1 measured two ISPs with no TURN relay needed. Kept code that nothing calls is what **L7** says not to keep; it stays only as the proof-of-possession design (**AV6**) until a node needs a relay or it is deleted | | **A very high bitrate wedges the player against a small buffer ceiling** | Where even the *floor* read-ahead does not fit — ninety seconds plus the minute kept behind, at the file's bitrate, above what the engine will hold — the film stalls: measured on the harness at 9.3 Mbit/s against a 100 MB ceiling, 100.8 s of film played in 900 s of wall clock. **Predates the byte budget and is unchanged by it**, to the tenth of a second; what the budget did change there is the refusal count, 1560 → 2. The fix is not a bound at all, it is a second stage of buffer outside the SourceBuffer, which means gating the append path — the riskiest change in this area and not one to make alongside another | diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md index 07b179c..e4219b5 100644 --- a/docs/MESHBAY_NODE_PROTOCOL.md +++ b/docs/MESHBAY_NODE_PROTOCOL.md @@ -2030,7 +2030,6 @@ it back (§3.5). | `stream_more` / `stream_stop` | C→N | auth | grant credit / abandon the stream | | `chat_msg` | C→N, N⇒C | auth | send and fan out a message | | `chat_hist` / `chat_hist_resp` | C→N / N→C | auth | paged history | -| `chat_attach` | — | auth | attachment metadata (declared, unused on the wire) | | `link_preview_req` / `_resp` | C→N / N→C | auth | OpenGraph unfurl | | `media_meta_req` / `_resp` | C→N / N→C | auth | TMDB metadata for one file | | `season_meta_req` / `_resp` | C→N / N→C | auth | per-season TMDB fields | @@ -2068,7 +2067,6 @@ it back (§3.5). | `denylist_clear` / `_ack` | C→N / N→C | auth (operator) | remove entries | | `node_settings_set` / `_ack` | C→N / N→C | auth (operator) | change daemon settings | | `node_reload` / `_ack` | C→N / N→C | auth (operator) | re-read `node.toml` | -| `ephemeral_stream` | — | — | reserved, mobile live push | | `error` | N→C | any | refusal, with `detail` and optionally `code`, `req_id`, and the `upload_id` / `tr` / `file_id` it is about | | `ack` | N→C | auth | generic acknowledgement (chat, keypair bundle store) | diff --git a/docs/playlists.md b/docs/playlists.md index f4a2c28..c41c71f 100644 --- a/docs/playlists.md +++ b/docs/playlists.md @@ -154,9 +154,8 @@ result with no hub change at all. ### 3.2 Not node-to-node -Refused, and not on cost grounds. Nodes do not know each other, share no -authenticated channel, and `replication.py` is legacy public-content code that -has nothing to do with this. Beyond the protocol that would have to be +Refused, and not on cost grounds. Nodes do not know each other and share no +authenticated channel. Beyond the protocol that would have to be invented, it leaks the thing this architecture is most careful about: node A would learn that this account also uses node B — that two unrelated operators host the same person. Per-node identity exists precisely so that this diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 9116d1a..f8bb3f4 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -850,16 +850,6 @@ function registerBridge() { return out; }); - ipcMain.handle('hub:probe', async (_e, url) => { - const target = String(url || config.hubBase || '').replace(/\/+$/, ''); - if (!target) return null; - try { - const r = await fetch(`${target}/v1/hub/version`, - { signal: AbortSignal.timeout(10000) }); - return r.ok ? await r.json() : null; - } catch { return null; } - }); - // Hide, never close: `window-all-closed` quits the app, and closing here would // make "minimise to tray" mean "exit". A hidden window keeps the session, the // transfers and the node connection exactly as they were. diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 5e666e9..af2d93b 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -30,7 +30,6 @@ number — it is a label chosen by the peer, and the only thing it decides is which local promise a reply belongs to. """ -import os from dataclasses import dataclass, field # The wire versions live in meshbay_common/__init__.py — one source, because a @@ -75,7 +74,6 @@ class MNP: # plaintext form on the wire (`chatbox.py`, docs/MESHBAY_DESIGN.md §4.5); # `format` distinguishes a *stored* pre-2.0 row, which is still served. CHAT_MESSAGE = "chat_msg" # one chat message, sealed and signed - CHAT_ATTACHMENT = "chat_attach" # attachment metadata CHAT_HISTORY = "chat_hist" # request message history (newest, or before a cursor) CHAT_HISTORY_RESPONSE = "chat_hist_resp" # history response with messages # Link unfurl: the node fetches a URL a member pasted and returns an @@ -123,7 +121,6 @@ class MNP: STREAM_END = "stream_end" # node signals end of stream STREAM_MORE = "stream_more" # client → node: room for N more segments STREAM_STOP = "stream_stop" # client → node: nobody is watching any more - EPHEMERAL_STREAM = "ephemeral_stream" # reserved — mobile live push HANDSHAKE_CHALLENGE = "handshake_challenge" # node → client: GEK proof nonce HANDSHAKE_RESPONSE = "handshake_response" # client → node: HMAC(GEK, nonce) ADMIN_CHALLENGE = "admin_challenge" # node → client: Ed25519 sign challenge @@ -458,11 +455,6 @@ def file_chunk_plaintext( UPLOAD_ID_LEN = 16 # 128 bits of client-chosen correlation, hex on the wire -def new_upload_id() -> str: - """A fresh correlation id for one upload.""" - return os.urandom(UPLOAD_ID_LEN).hex() - - def file_upload_wire( gek: bytes, group_id: str, diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 3a11345..52d9f60 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -255,10 +255,6 @@ async def swarm_register( Availability: the endpoint is a port, not an address, and the number of hashes one account may claim is bounded. See the two constants above. """ - from meshbay_hub.csam import check_content_hash - if check_content_hash(body.content_hash): - raise HTTPException(status_code=451, detail="Content blocked") - m = _SWARM_ENDPOINT.match(body.endpoint or "") if not m or not (0 < int(m.group(2)) < 65536): raise HTTPException( diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py index fc40204..6c9699b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py @@ -171,7 +171,7 @@ async def webrtc_offer( Finding H4: it also ignored group status, so "suspend a group" did not stop new connections from being brokered to nodes hosting it. """ - from meshbay_hub.api.revocation import _connected_nodes, _node_groups + from meshbay_hub.api.revocation import _connected_nodes if len(body.sdp) > MAX_SDP_BYTES: raise HTTPException(status_code=413, detail="SDP too large") diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index 7ccf598..cca1e6b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -41,7 +41,6 @@ from meshbay_hub.api.webapp import configure as webapp_configure from meshbay_hub.api.webapp import router as webapp_router from meshbay_hub.auth import generate_hub_keypair, load_hub_keypair from meshbay_hub.config import HubConfig -from meshbay_hub.csam import csam_router from meshbay_hub.db.engine import close_db, init_db @@ -129,9 +128,6 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: if cfg.identity.admin_usernames: await _sync_admin_roles(cfg.identity.admin_usernames) - from meshbay_hub.csam import get_csam_checker - get_csam_checker().load() - from meshbay_hub.db.engine import get_session_factory from meshbay_hub.tasks.cleanup import cleanup_loop cleanup_task = asyncio.create_task(cleanup_loop(get_session_factory())) @@ -207,7 +203,6 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: app.include_router(revocation_router) app.include_router(moderation_router) app.include_router(federation_router) - app.include_router(csam_router) app.include_router(health_router) app.include_router(relay_router) app.include_router(signaling_router) diff --git a/packages/meshbay-hub/src/meshbay_hub/csam.py b/packages/meshbay-hub/src/meshbay_hub/csam.py deleted file mode 100644 index b8e8d04..0000000 --- a/packages/meshbay-hub/src/meshbay_hub/csam.py +++ /dev/null @@ -1,158 +0,0 @@ -""" -MeshBay Hub — CSAM hash matching. - -Checks public content hashes against known CSAM (Child Sexual Abuse Material) -hash databases before allowing content to be registered or served publicly. - -Production integration: - - NCMEC (National Center for Missing & Exploited Children): PhotoDNA hash database - Access requires formal application: https://www.missingkids.org/gethelpnow/cybertipline - - IWF (Internet Watch Foundation): URL and hash list (UK-based) - Access via IWF membership: https://www.iwf.org.uk/our-technology/our-products/hash-list/ - -This module provides: - 1. A local CSAM hash database (SQLite file, populated from official sources) - 2. A check function used before content registration - 3. An admin endpoint to update the hash list - -IMPORTANT: Never log matched hashes or file contents. CSAM detection -must be reported to NCMEC (US law) or relevant authority immediately. -""" - -import logging -from pathlib import Path - -from fastapi import APIRouter, Depends, HTTPException - -from meshbay_hub.api.deps import require_admin -from meshbay_hub.db.models import User - -log = logging.getLogger(__name__) - -# Default path for the CSAM hash database (blake3 hex hashes, one per line) -DEFAULT_CSAM_DB_PATH = Path("/var/lib/meshbay/hub/csam_hashes.txt") - - -class CSAMChecker: - """ - Checks content hashes against a known CSAM hash database. - - Usage: - checker = CSAMChecker() - checker.load() - if checker.is_known_csam(blake3_hex): - # refuse to serve, report to authority - pass - """ - - def __init__(self, db_path: Path = DEFAULT_CSAM_DB_PATH): - self._db_path = db_path - self._hashes: set[str] = set() - self._loaded = False - - def load(self, db_path: Path | None = None) -> int: - """ - Load CSAM hashes from the hash database file. - Returns the number of hashes loaded. - - File format: one blake3 hex hash per line (64 chars), comments with #. - """ - path = db_path or self._db_path - if not path.exists(): - log.warning("CSAM hash database not found: %s. " - "Contact NCMEC (US) or IWF (EU) for access.", path) - self._loaded = True - return 0 - - count = 0 - with open(path) as f: - for line in f: - line = line.strip() - if line and not line.startswith("#") and len(line) == 64: - self._hashes.add(line.lower()) - count += 1 - - self._loaded = True - log.info("CSAM hash database loaded: %d hashes from %s", count, path) - return count - - def is_known_csam(self, content_hash_hex: str) -> bool: - """ - Return True if the hash matches a known CSAM hash. - NEVER logs the hash or any file information. - """ - if not self._loaded: - self.load() - return content_hash_hex.lower() in self._hashes - - @property - def hash_count(self) -> int: - return len(self._hashes) - - def add_hash(self, hash_hex: str) -> None: - """Add a hash to the in-memory set (and optionally persist).""" - self._hashes.add(hash_hex.lower()) - - def update_from_file(self, new_db_path: Path) -> int: - """Hot-reload from a new hash database file.""" - old_count = len(self._hashes) - self._hashes.clear() - count = self.load(new_db_path) - log.info("CSAM database updated: %d → %d hashes", old_count, count) - return count - - -# Module-level singleton (initialised in hub lifespan) -_checker = CSAMChecker() - - -def get_csam_checker() -> CSAMChecker: - return _checker - - -def check_content_hash(blake3_hex: str) -> bool: - """ - Check a content hash against the CSAM database. - Returns True if the content is KNOWN CSAM — block immediately. - - Callers MUST: - 1. Refuse to serve the content - 2. Log the event (without the hash) for legal audit purposes - 3. Report to NCMEC CyberTipline if operating in the US: - https://www.missingkids.org/gethelpnow/cybertipline - """ - return _checker.is_known_csam(blake3_hex) - - -# ── Hub API integration ─────────────────────────────────────────────────────── - -csam_router = APIRouter(prefix="/v1/admin/csam", tags=["csam"]) - - -@csam_router.get("/status") -async def csam_status(current_user: User = Depends(require_admin)): - """Return CSAM checker status (hash count, database path).""" - return { - "hash_count": _checker.hash_count, - "db_path": str(_checker._db_path), - "loaded": _checker._loaded, - "note": "Contact NCMEC or IWF for hash database access.", - } - - -@csam_router.post("/check") -async def check_hash( - body: dict, - current_user: User = Depends(require_admin), -): - """ - Check a single hash. Admin use only. - Returns True/False WITHOUT logging the hash (legal requirement). - """ - hash_hex = body.get("hash", "") - if len(hash_hex) != 64: - raise HTTPException(status_code=422, detail="hash must be 64 hex chars") - matched = check_content_hash(hash_hex) - # Do NOT log whether a match was found — only log the check attempt - log.info("CSAM check performed by admin %s", current_user.username) - return {"matched": matched} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 3101be7..bcd5999 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -3,7 +3,7 @@ import { clearPending, loadPending } from './invite-link.js'; import { html, render, useState, useEffect, useLayoutEffect, useCallback, useRef, - createContext, useContext, + createContext, } from './vendor/htm-preact.js'; import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js'; import { ZipStream, entriesUnder } from './zipstream.js'; @@ -72,7 +72,6 @@ function useRoute() { // ── Context ────────────────────────────────────────────────────────────────── const AuthContext = createContext(null); -function useAuth() { return useContext(AuthContext); } // The M of the wordmark is a picture; the rest is text. Resolved from this // module's own URL so the hub's fingerprinted path and the application's @@ -645,10 +644,6 @@ function LazyCreateGroupPage(props) { return html`<${_CreateGroupPage} ...${props} />`; } -// ── Settings Page ─────────────────────────────────────────────────────────── - -const THEME_OPTIONS = ['light', 'dark', 'system']; - // ── Profile Page ──────────────────────────────────────────────────────────── // // ── Lazy-loaded Admin page (admin/moderator only) ───────────────────────── diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js index a3680ce..b74732c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js @@ -1,20 +1,12 @@ /** - * MeshBay Browser Crypto — AES-256-GCM private group decryption. - * Uses WebCrypto SubtleCrypto API (available in all modern browsers). - * - * Handles groups with cipher="aes-256-gcm" (browser-accessible groups). - * ChaCha20-Poly1305 groups (cipher="chacha20-poly1305") require the - * native client (node) for decryption — not supported in browser. + * MeshBay Browser Crypto — AES-256-GCM, through WebCrypto's SubtleCrypto API. + * The one content cipher, for every client (meshbay_common/webcrypto.py). * * Usage: * const gek = await importGEK(gekB64); * const plaintext = await decryptChunkBin(gek, fileHashHex, chunkIndex, nonce, ct); */ -const CIPHER_INFO_PREFIX = new TextEncoder().encode('file:'); -const CIPHER_INFO_SUFFIX_AES = new TextEncoder().encode(':aes'); - - // ── Key derivation ──────────────────────────────────────────────────────────── /** @@ -284,38 +276,7 @@ async function verifyChatSignature(deviceRaw, groupId, epoch, nonce, ct, sig) { } -// ── GEK generation + ECIES wrapping ────────────────────────────────────────── - -function generateGEK() { - return crypto.getRandomValues(new Uint8Array(32)); -} - -async function wrapGEK(gek, pkXRaw) { - const skEph = await crypto.subtle.generateKey({ name: 'X25519' }, true, ['deriveBits']); - const pkEphRaw = new Uint8Array(await crypto.subtle.exportKey('raw', skEph.publicKey)); - - const pkRecip = await crypto.subtle.importKey('raw', pkXRaw, { name: 'X25519' }, false, []); - const sharedBits = await crypto.subtle.deriveBits( - { name: 'X25519', public: pkRecip }, skEph.privateKey, 256); - - const sharedKey = await crypto.subtle.importKey( - 'raw', sharedBits, 'HKDF', false, ['deriveKey']); - const wrapKey = await crypto.subtle.deriveKey( - { name: 'HKDF', hash: 'SHA-256', salt: pkEphRaw, - info: new TextEncoder().encode('meshbay:gek_wrap:v1:aes') }, - sharedKey, - { name: 'AES-GCM', length: 256 }, false, ['encrypt']); - - const nonce = crypto.getRandomValues(new Uint8Array(12)); - const ct = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv: nonce, additionalData: pkXRaw }, wrapKey, gek); - - return { - pk_eph_b64: btoa(String.fromCharCode(...pkEphRaw)), - nonce_b64: btoa(String.fromCharCode(...nonce)), - wrapped_b64: btoa(String.fromCharCode(...new Uint8Array(ct))), - }; -} +// ── GEK unwrapping (ECIES) ───────────────────────────────────────────────────── async function unwrapGEK(bundle, skXPkcs8, pkXRaw) { const pkEphRaw = b64decode(bundle.pk_eph_b64); @@ -342,16 +303,6 @@ async function unwrapGEK(bundle, skXPkcs8, pkXRaw) { return new Uint8Array(plain); } -// ── Chunk encryption (for upload) ──────────────────────────────────────────── - -async function encryptChunk(gek, fileHashHex, chunkIndex, plaintext) { - const chunkKey = await deriveChunkKey(gek, fileHashHex, chunkIndex); - const nonce = crypto.getRandomValues(new Uint8Array(12)); - const ct = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv: nonce }, chunkKey, plaintext); - return { nonce, ct: new Uint8Array(ct) }; -} - function b64encode(bytes) { return btoa(String.fromCharCode(...bytes)); } @@ -583,7 +534,7 @@ async function verifyNodeSignature(nodePkB64, sigB64, transcript) { window.MeshBayCrypto = { importGEK, deriveChunkKey, decryptChunkBin, openGroup, sealGroup, - generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode, + unwrapGEK, b64encode, b64decode, adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding, challengeTranscript, joinTranscript, verifyNodeSignature, constantTimeEqual, deviceRequestTranscript, deviceAddTranscript, deviceHelloTranscript, diff --git a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js index 9c23d3c..50bca46 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/downloads.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/downloads.js @@ -193,13 +193,6 @@ export async function openTarget(filename) { }; } -/** - * Below this, a download with no granted folder and no service worker is - * collected in memory and handed to the browser. Above it that would mean - * holding gigabytes in a tab, so it is worth one Save As dialog instead. - */ -export const BLOB_LIMIT = 512 * 1024 * 1024; - // ── Streaming to disk without the File System Access API ──────────────────── const SW_PATH = '/sw.js'; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js index ac978e2..775e2e3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -893,7 +893,6 @@ function FilesPanel({ // ── File Preview (text, images) ───────────────────────────────────────── -const TEXT_EXTS = /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i; const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i; function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) { diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index 56d35f7..bc78266 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js @@ -131,10 +131,6 @@ export function setLocale(code) { return true; } -export function addLocale(code, strings) { - _strings[code] = strings; -} - function _pluralRules(locale) { if (!_plurals[locale]) _plurals[locale] = new Intl.PluralRules(locale); return _plurals[locale]; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index 879f56f..33b1cf2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -256,15 +256,9 @@ async function deriveRecoveryKey(R, username) { // ── Bundle encryption ───────────────────────────────────────────────────────── /** - * Encrypt the keypair bundle with the password-derived AES key. - * Bundle format: JSON { skEd: base64(pkcs8), skX: base64(pkcs8) } + * Encrypt the keypair bundle with a bundle key derived at sign-in. Always + * writes v2. Bundle format: JSON { skEd: base64(pkcs8), skX: base64(pkcs8) } */ -async function encryptBundle(skEdRaw, skXRaw, password, username) { - const aesKey = await deriveEncryptionKey(password, username); - return encryptBundleWithKey(skEdRaw, skXRaw, aesKey); -} - -/** Same, when the key was already derived at sign-in. Always writes v2. */ async function encryptBundleWithKey(skEdRaw, skXRaw, aesKey) { const nonce = crypto.getRandomValues(new Uint8Array(12)); const data = new TextEncoder().encode(JSON.stringify({ @@ -289,16 +283,6 @@ function bundleVersion(bundleB64) { } catch { return 1; } } -/** - * Decrypt a keypair bundle. Throws if password is wrong. - */ -async function decryptBundle(bundleB64, password, username) { - const key = bundleVersion(bundleB64) === 2 - ? await deriveEncryptionKey(password, username) - : await deriveEncryptionKeyV1(password, username); - return decryptBundleWithKey(bundleB64, key); -} - // ── Registration ────────────────────────────────────────────────────────────── /** diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js index 9a1455a..bde382f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js @@ -29,13 +29,6 @@ function _live(status) { || status === 'paused'; } -/** Raised by `run` when it stopped because the transfer was paused. */ -function _pausedError() { - const err = new Error('Paused'); - err.name = 'PausedError'; - return err; -} - function _abortError() { const err = new Error('Cancelled'); err.name = 'AbortError'; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js index d6d733f..270c76e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js @@ -212,7 +212,6 @@ extendTransport(class { if (msg.epoch) this.chatEpoch = msg.epoch; this._chatKeys = null; this._chatKeysInFlight = null; - if (this._onChatEpoch) this._onChatEpoch(this.chatEpoch); } /** diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 97b2293..0f3f3b8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -571,9 +571,6 @@ class MeshBayTransport { set onIndexDelta(fn) { this._onIndexDelta = fn; } set onRootsChanged(fn) { this._onRootsChanged = fn; } - /** The MNP version the connected node declared, or '' before a handshake. */ - get nodeVersion() { return this._nodeVersion || ''; } - /** This member's own caps in this group, or null when the node said nothing. */ get transferLimits() { return this._transferLimits; } @@ -582,7 +579,6 @@ class MeshBayTransport { set onChatDirectory(fn) { this._onChatDirectory = fn; } set onChatLinkPreview(fn) { this._onChatLinkPreview = fn; } set onSearchListed(fn) { this._onSearchListed = fn; } - set onChatEpoch(fn) { this._onChatEpoch = fn; } set onTmdbConfig(fn) { this._onTmdbConfig = fn; } set onTmdbEnabled(fn) { this._onTmdbEnabled = fn; } set onMusicbrainzEnabled(fn) { this._onMusicbrainzEnabled = fn; } @@ -941,12 +937,9 @@ class MeshBayTransport { if (reply.type === 'handshake_challenge') { // The node's half of the range. Checked before anything else in this // block, because everything below — the join, the proof, the sealed ack - // — assumes both sides mean the same thing by each message. + // — assumes both sides mean the same thing by each message. Nothing else + // reads the node's version: a peer this admits speaks every message here. _checkNodeVersion(reply); - // Kept for diagnostics only. Nothing branches on it: the range check - // above is what decides whether these two can talk at all, and a peer it - // admits speaks every message in this file. - this._nodeVersion = String(reply.v || ''); if (!window.MeshBayCrypto) { throw new Error('Node requires GEK proof but no crypto available'); } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js index 2b274b5..fa10d15 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-player.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-player.js @@ -270,12 +270,6 @@ function reconnectPlan(playhead, range, ended, castActive) { return { mode: 'seek', at: playhead }; } -function _mseSupported(codec) { - if (!window.MediaSource) return false; - const mime = `video/mp4; codecs="${codec}"`; - return MediaSource.isTypeSupported(mime); -} - /** Seconds as h:mm:ss, or m:ss under an hour. */ function formatClock(seconds) { const s = Math.max(0, Math.floor(seconds || 0)); diff --git a/packages/meshbay-hub/tests/test_memory_ceiling.py b/packages/meshbay-hub/tests/test_memory_ceiling.py index 9ea6ad3..48aac1a 100644 --- a/packages/meshbay-hub/tests/test_memory_ceiling.py +++ b/packages/meshbay-hub/tests/test_memory_ceiling.py @@ -84,7 +84,6 @@ const platform = {{ bridgeMessage: (e) => String(e), }}; const downloads = {{ - BLOB_LIMIT: 512 * 1024 * 1024, // Called by the refusal to name why the streamed path declined -- absent // from this stub, the error constructor threw TypeError and the test saw the // wrong failure entirely. diff --git a/packages/meshbay-node/src/meshbay_node/audit.py b/packages/meshbay-node/src/meshbay_node/audit.py index b382107..81ee600 100644 --- a/packages/meshbay-node/src/meshbay_node/audit.py +++ b/packages/meshbay-node/src/meshbay_node/audit.py @@ -33,20 +33,6 @@ CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id); CREATE INDEX IF NOT EXISTS idx_audit_event ON audit_log(event); """ -EVENTS = { - "connect", - "disconnect", - "handshake", - "file_download", - "file_upload", - "file_delete", - "stream_video", - "chat_message", - "chat_history", - "index_sync", - "auth_failed", -} - RETENTION_DAYS = 365 diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index 563e395..2cd8a8f 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -505,10 +505,3 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: "MESHBAY_MAX_CONCURRENT_STREAMS") return cfg - - -def write_example_config(path: Path = DEFAULT_CONFIG_PATH) -> None: - """Write an example config file if none exists.""" - if not path.exists(): - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(EXAMPLE_CONFIG, encoding="utf-8", newline="\n") diff --git a/packages/meshbay-node/src/meshbay_node/modules/__init__.py b/packages/meshbay-node/src/meshbay_node/modules/__init__.py deleted file mode 100644 index e69de29..0000000 --- a/packages/meshbay-node/src/meshbay_node/modules/__init__.py +++ /dev/null diff --git a/packages/meshbay-node/src/meshbay_node/musicbrainz.py b/packages/meshbay-node/src/meshbay_node/musicbrainz.py index ce2d233..e3681ea 100644 --- a/packages/meshbay-node/src/meshbay_node/musicbrainz.py +++ b/packages/meshbay-node/src/meshbay_node/musicbrainz.py @@ -183,10 +183,6 @@ class MusicBrainzClient: results = (data or {}).get("releases", []) return _best_match_release(artist, album, results) - async def release_details(self, mbid: str) -> dict | None: - """Full release details, including recordings (tracklist).""" - return await self._get(_BASE_URL + f"release/{mbid}", {"inc": "recordings+artist-credits"}) - async def fetch_cover_art(self, mbid: str) -> bytes | None: """ The release's front cover, or None if Cover Art Archive has nothing diff --git a/packages/meshbay-node/src/meshbay_node/replication.py b/packages/meshbay-node/src/meshbay_node/replication.py deleted file mode 100644 index 297ed0b..0000000 --- a/packages/meshbay-node/src/meshbay_node/replication.py +++ /dev/null @@ -1,140 +0,0 @@ -""" -MeshBay Node — content replication (node-to-node, admin-authorized). - -A replication node downloads files from a source node and stores them -locally, then registers itself as an additional swarm source in the hub. -This provides redundancy and improves availability for public content. - -Only public content is replicated (no GEK needed). -Private content replication requires the GEK and is admin-controlled. - -Usage: - replicator = ContentReplicator( - hub_url=..., access_token=..., source_endpoint=..., - local_dir=Path("/data/replicated"), node_pk_b64=..., - ) - await replicator.replicate_file(file_id, file_name, file_size) -""" - -import logging -from pathlib import Path - -import blake3 -import httpx - -log = logging.getLogger(__name__) - -CHUNK_SIZE = 1024 * 1024 # 1 MB - - -class ContentReplicator: - """ - Downloads public files from a source node and registers as swarm source. - """ - - def __init__( - self, - hub_url: str, - access_token: str, - source_endpoint: str, # "http://ip:port" of source node HTTP API - local_dir: Path, - node_pk_b64: str, - ): - self._hub_url = hub_url.rstrip("/") - self._access_token = access_token - self._source_endpoint = source_endpoint.rstrip("/") - self._local_dir = local_dir - self._node_pk_b64 = node_pk_b64 - self._local_dir.mkdir(parents=True, exist_ok=True) - - @property - def _auth_headers(self) -> dict: - return {"Authorization": f"Bearer {self._access_token}"} - - async def fetch_index(self) -> list[dict]: - """Fetch the public Mesh Group Index from the source node.""" - async with httpx.AsyncClient(timeout=30) as c: - r = await c.get(f"{self._source_endpoint}/index") - r.raise_for_status() - return r.json()["entries"] - - async def replicate_file( - self, - file_id: str, - file_name: str, - file_size: int, - progress_cb=None, - ) -> Path: - """ - Download a public file from the source node, verify integrity, - save locally, and register as swarm source in the hub. - Returns the local file path. - """ - local_path = self._local_dir / file_name - if local_path.exists(): - # Verify hash - existing_hash = blake3.blake3(local_path.read_bytes()).hexdigest() - if existing_hash == file_id: - log.info("Already have %s, skipping", file_name) - await self._register_swarm(file_id, local_path) - return local_path - - log.info("Replicating %s (%d bytes) from %s", file_name, file_size, self._source_endpoint) - - # Stream download chunk by chunk - n_chunks = max(1, (file_size + CHUNK_SIZE - 1) // CHUNK_SIZE) - with open(local_path, "wb") as f: - async with httpx.AsyncClient(timeout=60) as c: - for chunk_idx in range(n_chunks): - # Download full file (simpler for public content) - if chunk_idx == 0: - r = await c.get( - f"{self._source_endpoint}/file/{file_id}", - headers=self._auth_headers, - ) - r.raise_for_status() - f.write(r.content) - if progress_cb: - progress_cb(len(r.content), file_size) - break # full file downloaded in one request - - # Verify hash - actual_hash = blake3.blake3(local_path.read_bytes()).hexdigest() - if actual_hash != file_id: - local_path.unlink(missing_ok=True) - raise ValueError(f"Hash mismatch: expected {file_id[:16]}, got {actual_hash[:16]}") - - log.info("Replicated %s (hash OK)", file_name) - await self._register_swarm(file_id, local_path) - return local_path - - async def _register_swarm(self, content_hash: str, local_path: Path) -> None: - """Register this node as a swarm source for the content hash in the hub.""" - try: - async with httpx.AsyncClient(timeout=10) as c: - r = await c.post( - f"{self._hub_url}/v1/swarm/register", - json={"content_hash": content_hash, "endpoint": self._source_endpoint}, - headers=self._auth_headers, - ) - r.raise_for_status() - log.debug("Registered as swarm source for %s", content_hash[:16]) - except Exception as e: - log.warning("Failed to register swarm source: %s", e) - - async def replicate_all(self, progress_cb=None) -> list[Path]: - """Replicate all public files from the source node.""" - entries = await self.fetch_index() - results = [] - for entry in entries: - try: - path = await self.replicate_file( - file_id=entry["id"], - file_name=entry["name"], - file_size=entry["size"], - progress_cb=progress_cb, - ) - results.append(path) - except Exception as e: - log.error("Failed to replicate %s: %s", entry["name"], e) - return results diff --git a/packages/meshbay-node/tests/golden/dispatch.json b/packages/meshbay-node/tests/golden/dispatch.json index 5d15057..4767996 100644 --- a/packages/meshbay-node/tests/golden/dispatch.json +++ b/packages/meshbay-node/tests/golden/dispatch.json @@ -1874,166 +1874,6 @@ "sent": [], "spawned": [] }, - "chat_attach | challenged | bare": { - "audit": [], - "log": [], - "sent": [ - { - "detail": "Handshake required", - "req_id": 4242, - "type": "error" - } - ], - "spawned": [] - }, - "chat_attach | challenged | lists": { - "audit": [], - "log": [], - "sent": [ - { - "detail": "Handshake required", - "req_id": 4242, - "type": "error" - } - ], - "spawned": [] - }, - "chat_attach | challenged | numbers": { - "audit": [], - "log": [], - "sent": [ - { - "detail": "Handshake required", - "req_id": 4242, - "type": "error" - } - ], - "spawned": [] - }, - "chat_attach | challenged | strings": { - "audit": [], - "log": [], - "sent": [ - { - "detail": "Handshake required", - "req_id": 4242, - "type": "error" - } - ], - "spawned": [] - }, - "chat_attach | fresh | bare": { - "audit": [], - "log": [], - "sent": [ - { - "detail": "Handshake required", - "req_id": 4242, - "type": "error" - } - ], - "spawned": [] - }, - "chat_attach | fresh | lists": { - "audit": [], - "log": [], - "sent": [ - { - "detail": "Handshake required", - "req_id": 4242, - "type": "error" - } - ], - "spawned": [] - }, - "chat_attach | fresh | numbers": { - "audit": [], - "log": [], - "sent": [ - { - "detail": "Handshake required", - "req_id": 4242, - "type": "error" - } - ], - "spawned": [] - }, - "chat_attach | fresh | strings": { - "audit": [], - "log": [], - "sent": [ - { - "detail": "Handshake required", - "req_id": 4242, - "type": "error" - } - ], - "spawned": [] - }, - "chat_attach | member | bare": { - "audit": [], - "log": [ - "WARNING Unknown MNP message type on DataChannel: %s" - ], - "sent": [], - "spawned": [] - }, - "chat_attach | member | lists": { - "audit": [], - "log": [ - "WARNING Unknown MNP message type on DataChannel: %s" - ], - "sent": [], - "spawned": [] - }, - "chat_attach | member | numbers": { - "audit": [], - "log": [ - "WARNING Unknown MNP message type on DataChannel: %s" - ], - "sent": [], - "spawned": [] - }, - "chat_attach | member | strings": { - "audit": [], - "log": [ - "WARNING Unknown MNP message type on DataChannel: %s" - ], - "sent": [], - "spawned": [] - }, - "chat_attach | operator | bare": { - "audit": [], - "log": [ - "WARNING Unknown MNP message type on DataChannel: %s" - ], - "sent": [], - "spawned": [] - }, - "chat_attach | operator | lists": { - "audit": [], - "log": [ - "WARNING Unknown MNP message type on DataChannel: %s" - ], - "sent": [], - "spawned": [] - }, - "chat_attach | operator | numbers": { - "audit": [], - "log": [ - "WARNING Unknown MNP message type on DataChannel: %s" - ], - "sent": [], - "spawned": [] - }, - "chat_attach | operator | strings": { - "audit": [], - "log": [ - "WARNING Unknown MNP message type on DataChannel: %s" - ], - "sent": [], - "spawned": [] - }, "chat_directory | challenged | bare": { "audit": [], "log": [], @@ -7229,166 +7069,6 @@ "sent": [], "spawned": [] }, - "ephemeral_stream | challenged | bare": { - "audit": [], - "log": [], - "sent": [ - { - "detail": "Handshake required", - "req_id": 4242, - "type": "error" - } - ], - "spawned": [] - }, - "ephemeral_stream | challenged | lists": { - "audit": [], - "log": [], - "sent": [ - { - "detail": "Handshake required", - "req_id": 4242, - "type": "error" - } - ], - "spawned": [] - }, - "ephemeral_stream | challenged | numbers": { - "audit": [], - "log": [], - "sent": [ - { - "detail": "Handshake required", - "req_id": 4242, - "type": "error" - } - ], - "spawned": [] - }, - "ephemeral_stream | challenged | strings": { - "audit": [], - "log": [], - "sent": [ - { - "detail": "Handshake required", - "req_id": 4242, - "type": "error" - } - ], - "spawned": [] - }, - "ephemeral_stream | fresh | bare": { - "audit": [], - "log": [], - "sent": [ - { - "detail": "Handshake required", - "req_id": 4242, - "type": "error" - } - ], - "spawned": [] - }, - "ephemeral_stream | fresh | lists": { - "audit": [], - "log": [], - "sent": [ - { - "detail": "Handshake required", - "req_id": 4242, - "type": "error" - } - ], - "spawned": [] - }, - "ephemeral_stream | fresh | numbers": { - "audit": [], - "log": [], - "sent": [ - { - "detail": "Handshake required", - "req_id": 4242, - "type": "error" - } - ], - "spawned": [] - }, - "ephemeral_stream | fresh | strings": { - "audit": [], - "log": [], - "sent": [ - { - "detail": "Handshake required", - "req_id": 4242, - "type": "error" - } - ], - "spawned": [] - }, - "ephemeral_stream | member | bare": { - "audit": [], - "log": [ - "WARNING Unknown MNP message type on DataChannel: %s" - ], - "sent": [], - "spawned": [] - }, - "ephemeral_stream | member | lists": { - "audit": [], - "log": [ - "WARNING Unknown MNP message type on DataChannel: %s" - ], - "sent": [], - "spawned": [] - }, - "ephemeral_stream | member | numbers": { - "audit": [], - "log": [ - "WARNING Unknown MNP message type on DataChannel: %s" - ], - "sent": [], - "spawned": [] - }, - "ephemeral_stream | member | strings": { - "audit": [], - "log": [ - "WARNING Unknown MNP message type on DataChannel: %s" - ], - "sent": [], - "spawned": [] - }, - "ephemeral_stream | operator | bare": { - "audit": [], - "log": [ - "WARNING Unknown MNP message type on DataChannel: %s" - ], - "sent": [], - "spawned": [] - }, - "ephemeral_stream | operator | lists": { - "audit": [], - "log": [ - "WARNING Unknown MNP message type on DataChannel: %s" - ], - "sent": [], - "spawned": [] - }, - "ephemeral_stream | operator | numbers": { - "audit": [], - "log": [ - "WARNING Unknown MNP message type on DataChannel: %s" - ], - "sent": [], - "spawned": [] - }, - "ephemeral_stream | operator | strings": { - "audit": [], - "log": [ - "WARNING Unknown MNP message type on DataChannel: %s" - ], - "sent": [], - "spawned": [] - }, "file_chunk | challenged | bare": { "audit": [], "log": [], |