diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-09 14:50:22 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-09 14:50:22 +0200 |
| commit | aed220d9f0bab42efd57b56851319e840ab8ae26 (patch) | |
| tree | e8b72fbe9016635438e6b7046a35e47ec3dbe93a | |
| parent | 608d3a705d065d6b378f4889322ff9d1bc41d147 (diff) | |
| download | meshbay-aed220d9f0bab42efd57b56851319e840ab8ae26.tar.gz | |
feat: password-based key derivation + operational QUICKSTART
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>
| -rw-r--r-- | docs/QUICKSTART.md | 499 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/keyderive.py | 128 | ||||
| -rw-r--r-- | packages/meshbay-common/tests/test_keyderive.py | 57 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/users.py | 11 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/db/models.py | 5 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/keyderive.js | 164 |
6 files changed, 501 insertions, 363 deletions
diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 9840ee1..2f1a728 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -1,434 +1,217 @@ -# MeshBay Quickstart +# MeshBay — Quickstart -MeshBay is a peer-to-peer file sharing, streaming, and group messaging platform. Your files live on your node — the hub at `meshbay.org` handles identity and group membership only, and never sees your file content or encryption keys. This guide gets you sharing a file in about 15 minutes. - -## Prerequisites - -- Python 3.12 or newer -- `pip` (or `uv` — see Step 2) -- `git` -- A public IP address with one TCP port open (or a port-forward on your router). NAT traversal without manual port configuration is coming in v2. +MeshBay partage des fichiers entre utilisateurs d'un groupe via un réseau pair-à-pair. +Le hub (`meshbay.org`) gère les identités et les clés — il ne voit jamais vos fichiers. +Le node tourne sur votre machine et héberge vos fichiers. --- -## Step 1: Accounts - -### Try it now with the demo accounts - -Two test accounts are pre-configured with a running demo node. You can skip to Step 6 to try a download immediately. - -| Account | Password | Role | -|-------------|-----------------|---------------------------------------------| -| alice_test | AliceTest2026! | Node operator (node at http://meshbay.org:19001) | -| bob_test | BobTest2026! | Group member | - -Demo group ID: `10484cb7-e45a-4cdc-8b06-f8ccdacc04d2` (name: `demo-group`, private) -Files: `README.txt` (59 bytes), `sample_data.bin` (5 MB) - -### Register a new account - -There is no web registration form yet. Registration is done via the API. Your Ed25519 and X25519 keypairs must be generated and saved to disk **before** calling the hub — if they are regenerated later, your GEK bundles will be undecryptable. - -**Generate your keypairs:** - -```python -# save as generate_keys.py — run once -import base64, json, pathlib -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey -from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey -from cryptography.hazmat.primitives import serialization - -def raw_b64(key, kind="private"): - if kind == "private": - return base64.b64encode(key.private_bytes( - serialization.Encoding.Raw, serialization.PrivateFormat.Raw, - serialization.NoEncryption())).decode() - return base64.b64encode(key.public_bytes( - serialization.Encoding.Raw, serialization.PublicFormat.Raw)).decode() - -sk_ed = Ed25519PrivateKey.generate() -sk_x = X25519PrivateKey.generate() +## Ce qu'il faut -keys = { - "sk_ed25519_b64": raw_b64(sk_ed, "private"), - "sk_x25519_b64": raw_b64(sk_x, "private"), - "pk_ed25519_b64": raw_b64(sk_ed.public_key(), "public"), - "pk_x25519_b64": raw_b64(sk_x.public_key(), "public"), -} - -out = pathlib.Path("my_keys.json") -out.write_text(json.dumps(keys, indent=2)) -out.chmod(0o600) -print(f"Keys saved to {out}. Keep this file secret.") -print(f" pk_ed25519: {keys['pk_ed25519_b64']}") -print(f" pk_x25519: {keys['pk_x25519_b64']}") -``` +- Python 3.12+ +- Le dépôt MeshBay (en local) +- Un accès à `https://meshbay.org` ```bash -python3 generate_keys.py +git clone <url-du-repo> ~/meshbay +cd ~/meshbay +python3 -m venv .venv && source .venv/bin/activate +pip install -e packages/meshbay-common -e packages/meshbay-node +pip install httpx blake3 uvicorn ``` -**Register with the hub:** - -```bash -# Load your keys -PK_ED=$(python3 -c "import json; d=json.load(open('my_keys.json')); print(d['pk_ed25519_b64'])") -PK_X=$(python3 -c "import json; d=json.load(open('my_keys.json')); print(d['pk_x25519_b64'])") - -curl -s -X POST https://meshbay.org/v1/users/register \ - -H "Content-Type: application/json" \ - -d "{ - \"username\": \"yourname\", - \"password\": \"YourPassword123!\", - \"pk_user_ed25519\": \"$PK_ED\", - \"pk_user_x25519\": \"$PK_X\" - }" -``` - -Expected response (HTTP 201): -```json -{"user_id": "3f8a1b2c-..."} -``` - -If you get HTTP 409, the username is taken. - --- -## Step 2: Install meshbay-node +## Étape 1 — Setup (alice crée le groupe et invite bob) -```bash -git clone https://github.com/meshbay-org/meshbay.git -cd meshbay -pip install -e packages/meshbay-node -``` - -With `uv` (recommended — installs all three packages in editable mode): +Un seul script fait tout : créer les comptes, générer les clés depuis les mots de passe, +créer le groupe, distribuer la clé de chiffrement. ```bash -uv sync +python QE/demo-v1/setup_demo.py \ + --hub https://meshbay.org \ + --alice-user alice_demo --alice-pass "AliceDemo2026!" \ + --bob-user bob_demo --bob-pass "BobDemo2026!" ``` -Verify the install: - -```bash -meshbay-node --version +Sortie attendue : ``` +[1/6] Génération des clés d'alice depuis son mot de passe... + Ed25519 public: VyVUcjPXwJfGhzr44Cb5... +[2/6] Inscription d'alice sur le hub... + OK — user_id=9b50a8c2... +[3/6] Génération des clés de bob + inscription... + OK +[4/6] Alice se connecte au hub... + JWT reçu (424 chars) +[5/6] Alice crée le groupe 'demo-group'... + group_id=e358fb8b-5b3f-44... +[6/6] Génération et distribution de la clé de groupe (GEK)... + GEK → alice: 201 + GEK → bob: 201 ---- - -## Step 3: Configure the node - -Create the config directory and a minimal `node.toml`: - -```bash -mkdir -p ~/.config/meshbay +✓ Setup terminé. + Creds: QE/demo-v1/creds.json ``` -```toml -# ~/.config/meshbay/node.toml - -[hub] -url = "https://meshbay.org" - -[auth] -username = "yourname" -password = "YourPassword123!" +Les credentials sont sauvegardés dans `QE/demo-v1/creds.json` (clés privées incluses — +ce fichier ne doit pas être partagé ni versionné, il est dans `.gitignore`). -[node] -# Directory the node will watch and serve -shared_dir = "/home/yourname/meshbay-files" +**Pourquoi les clés sont dérivées du mot de passe ?** +La commande `derive_keys_from_password(username, password)` génère toujours les mêmes +clés Ed25519 et X25519 à partir des mêmes identifiants. Pas besoin de stocker ou +transporter un fichier de clés séparé — le mot de passe suffit pour retrouver les clés +sur n'importe quelle machine. -# Port that group members will connect to — must be reachable from the internet -listen_port = 19001 - -# Group this node hosts -# Create a group first: POST /v1/groups (see User Guide §3) -group_id = "PASTE-YOUR-GROUP-UUID-HERE" +--- -[keystore] -# How to unlock the key store at startup: -# "secure" — prompt for password at startup (default, safest) -# "lazy_file" — read from ~/.config/meshbay/unlock.key (chmod 600) -# "service" — read from MESHBAY_UNLOCK_KEY env var (for systemd) -unlock_mode = "secure" -``` +## Étape 2 — Démarrer le node d'alice -Create the shared directory: +Le node indexe un répertoire et le rend accessible aux membres du groupe. +Il crée automatiquement `QE/demo-v1/shared/` avec un fichier exemple. ```bash -mkdir -p ~/meshbay-files +# Terminal 1 — node d'alice (écoute en local) +python QE/demo-v1/run_node.py --host 127.0.0.1 --port 19001 ``` ---- - -## Step 4: Start the node - -```bash -meshbay-node --config ~/.config/meshbay/node.toml +Sortie : ``` +=== Node d'alice — répertoire partagé : QE/demo-v1/shared === +Fichiers disponibles : + README.txt 93 octets -On first startup the node will: - -1. Generate your Ed25519 and X25519 keypairs and write them to the encrypted keystore (if `my_keys.json` exists in the current directory, it uses those; otherwise generates new ones) -2. Register and announce itself to the hub -3. Scan and index `shared_dir` -4. Start listening on `listen_port` +1 fichier(s) indexé(s) -You will be prompted for a keystore password (choose a strong one — you only enter it once per restart). +✓ Node actif sur http://127.0.0.1:19001 + Info: http://localhost:19001/ + Index: http://localhost:19001/index -Verify the node is running and reachable: +CTRL+C pour arrêter. +``` +Vérification rapide dans un autre terminal : ```bash -curl -s http://YOUR-PUBLIC-IP:19001/ -``` +curl http://localhost:19001/ +# {"node_version":"0.1.0","file_count":1,"group_name":"demo-group",...} -Expected response: -```json -{ - "file_count": 0, - "pk_node": "base64-encoded-ed25519-public-key", - "group_id": "your-group-uuid" -} +curl http://localhost:19001/index +# {"entries":[{"name":"README.txt","size":93,...}],...} ``` -The alice_test demo node is already running. You can verify it: - +**Ajouter vos propres fichiers :** ```bash -curl -s http://meshbay.org:19001/ +cp ~/Videos/ma_video.mp4 QE/demo-v1/shared/ +# Le node le détecte automatiquement (watchdog) ``` --- -## Step 5: Share a file +## Étape 3 — Bob télécharge un fichier -Copy a file into your shared directory. The node detects it automatically (via filesystem watch) and adds it to the group index within a few seconds. No command needed. +Bob se connecte au hub, récupère sa clé chiffrée (GEK), la déchiffre localement, +puis télécharge et déchiffre le fichier depuis le node d'alice. ```bash -cp my-document.pdf ~/meshbay-files/ +# Terminal 2 — client de bob +python QE/demo-v1/download.py --node http://localhost:19001 ``` -Confirm it appears in the index: - -```bash -# Get a token first -TOKEN=$(curl -s -X POST https://meshbay.org/v1/users/login \ - -H "Content-Type: application/json" \ - -d '{"username":"yourname","password":"YourPassword123!"}' \ - | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") - -# Query the node index -curl -s -H "Authorization: Bearer $TOKEN" http://YOUR-PUBLIC-IP:19001/index +Sortie complète : ``` +[1/5] Bob se connecte au hub https://meshbay.org... + ✓ JWT reçu +[2/5] Bob récupère son bundle GEK depuis le hub... + ✓ Bundle chiffré reçu (hub ne peut pas le lire) +[3/5] Bob déchiffre la GEK localement (X25519)... + ✓ GEK récupérée (32 octets) +[4/5] Bob browse le node d'alice (http://localhost:19001)... + ✓ 1 fichier(s) dans 'demo-group': + [document] README.txt 93 octets +[5/5] Bob télécharge et déchiffre 'README.txt'... + chunk 0: 93o réseau=10ms decrypt=0.0ms ✓ -Example response: - -```json -[ - { - "id": "a1b2c3d4e5f6...", - "name": "my-document.pdf", - "size": 204800, - "type": "document" - } -] +✓ 'README.txt' sauvegardé dans QE/demo-v1/downloads/README.txt + Total : 93 octets en 1 chunk(s) ``` -The demo node (alice_test) already has two files indexed: - +Télécharger un fichier spécifique : ```bash -TOKEN=$(curl -s -X POST https://meshbay.org/v1/users/login \ - -H "Content-Type: application/json" \ - -d '{"username":"alice_test","password":"AliceTest2026!"}' \ - | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") - -curl -s -H "Authorization: Bearer $TOKEN" http://meshbay.org:19001/index -``` - -```json -[ - {"id": "...", "name": "README.txt", "size": 59, "type": "document"}, - {"id": "...", "name": "sample_data.bin", "size": 5242880, "type": "other"} -] +python QE/demo-v1/download.py --node http://localhost:19001 --file ma_video.mp4 ``` --- -## Step 6: Download a file as another user - -This is the complete flow: login to the hub, fetch the GEK bundle, unwrap it with your private key, browse the node index, fetch an encrypted chunk, and decrypt it locally. - -Save this script and run it. Before running, replace `SK_BOB_X_B64` with bob_test's X25519 private key from `my_keys.json` (or `bob_state.json` if you ran the POC spikes). - -```python -#!/usr/bin/env python3 -""" -download_demo.py — full MeshBay download and decrypt flow as bob_test - -Dependencies (all in the project venv): - pip install httpx cryptography blake3 -""" -import base64, sys -import httpx -from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey -from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 -from cryptography.hazmat.primitives.kdf.hkdf import HKDF -from cryptography.hazmat.primitives import hashes, serialization - -HUB = "https://meshbay.org" -NODE = "http://meshbay.org:19001" -GROUP_ID = "10484cb7-e45a-4cdc-8b06-f8ccdacc04d2" - -# Bob's X25519 private key (base64, raw 32 bytes) — from my_keys.json -# Replace this with the actual value from bob_test's keystore. -SK_BOB_X_B64 = "REPLACE_WITH_BOB_SK_X25519_B64" - - -# ── 1. Login to the hub ─────────────────────────────────────────────────────── - -r = httpx.post(f"{HUB}/v1/users/login", - json={"username": "bob_test", "password": "BobTest2026!"}) -r.raise_for_status() -data = r.json() -token = data["access_token"] -headers = {"Authorization": f"Bearer {token}"} -print(f"[1] Logged in as bob_test. Token valid for 1 hour.") - - -# ── 2. Fetch the GEK bundle from the hub ───────────────────────────────────── -# -# The hub stores an opaque encrypted blob per member per group. -# It cannot decrypt it — only the member's X25519 private key can. - -r = httpx.get(f"{HUB}/v1/groups/{GROUP_ID}/gek", headers=headers) -r.raise_for_status() -bundle = r.json() -print(f"[2] GEK bundle fetched from hub (opaque to hub).") - - -# ── 3. Unwrap the GEK with Bob's X25519 private key ────────────────────────── -# -# Protocol: ephemeral X25519 ECDH + HKDF(SHA-256, salt=pk_eph, -# info="meshbay:gek_wrap:v1") → 32-byte wrap key -# ChaCha20-Poly1305 decrypt(nonce, wrapped_gek, aad=pk_bob) - -sk_bob_x_raw = base64.b64decode(SK_BOB_X_B64) -sk_bob_x = X25519PrivateKey.from_private_bytes(sk_bob_x_raw) -pk_bob_x_raw = sk_bob_x.public_key().public_bytes( - serialization.Encoding.Raw, serialization.PublicFormat.Raw) +## Étape 4 — Tester depuis une autre machine -pk_eph_raw = base64.b64decode(bundle["pk_eph_b64"]) -nonce = base64.b64decode(bundle["nonce_b64"]) -wrapped = base64.b64decode(bundle["wrapped_b64"]) +Si le node d'alice est sur une machine avec IP publique (ou port ouvert sur le routeur), +bob peut télécharger depuis n'importe où : -pk_eph = X25519PublicKey.from_public_bytes(pk_eph_raw) -shared = sk_bob_x.exchange(pk_eph) -wrap_key = HKDF( - algorithm=hashes.SHA256(), length=32, - salt=pk_eph_raw, info=b"meshbay:gek_wrap:v1" -).derive(shared) - -try: - gek = ChaCha20Poly1305(wrap_key).decrypt(nonce, wrapped, pk_bob_x_raw) -except Exception: - print("ERROR: GEK decryption failed. Wrong private key or corrupted bundle.") - sys.exit(1) - -print(f"[3] GEK unwrapped successfully ({len(gek)} bytes).") - - -# ── 4. Browse the node index ────────────────────────────────────────────────── -# -# The node verifies the JWT offline (hub's Ed25519 public key, no hub roundtrip). -# JWT verification takes ~884 µs on the node side. - -r = httpx.get(f"{NODE}/index", headers=headers) -r.raise_for_status() -index = r.json() -print(f"\n[4] Files in group ({len(index)} entries):") -for entry in index: - print(f" {entry['id'][:12]}... {entry['name']:<28} {entry['size']:>10} bytes") - - -# ── 5. Download and decrypt README.txt ─────────────────────────────────────── -# -# /file/{id}/0 returns one encrypted chunk as JSON: -# ct_b64, nonce_b64, file_hash_b64, chunk_index, plaintext_size -# -# Chunk key is derived from the GEK: -# HKDF(GEK, salt=None, info="file:<file_hash>:chunk:<index_4be>") - -file_entry = next((e for e in index if e["name"] == "README.txt"), None) -if not file_entry: - print("README.txt not found in index.") - sys.exit(1) +```bash +# Alice — démarrer le node sur toutes les interfaces +python QE/demo-v1/run_node.py --host 0.0.0.0 --port 19001 -file_id = file_entry["id"] -print(f"\n[5] Fetching encrypted chunk 0 of README.txt (file_id={file_id[:12]}...)...") +# Bob — depuis une autre machine +python QE/demo-v1/download.py --node http://<IP-D-ALICE>:19001 +``` -r = httpx.get(f"{NODE}/file/{file_id}/0", headers=headers) -r.raise_for_status() -chunk = r.json() +> **NAT résidentiel :** si alice est derrière une box internet, il faut soit +> ouvrir le port 19001 dans les règles NAT de la box, soit utiliser un tunnel +> (cloudflared, ngrok). La traversée NAT automatique par STUN/ICE est prévue +> pour la v2 du protocole. -ct = base64.b64decode(chunk["ct_b64"]) -chunk_nonce = base64.b64decode(chunk["nonce_b64"]) -file_hash = base64.b64decode(chunk["file_hash_b64"]) -chunk_idx = chunk["chunk_index"] # 0 +--- -chunk_key = HKDF( - algorithm=hashes.SHA256(), length=32, salt=None, - info=b"file:" + file_hash + b":chunk:" + chunk_idx.to_bytes(4, "big") -).derive(gek) +## Ce qui se passe sous le capot -plaintext = ChaCha20Poly1305(chunk_key).decrypt(chunk_nonce, ct, None) -print(f"[5] Decrypted {len(plaintext)} bytes:") -print() -print(plaintext.decode()) ``` - -Run it: - -```bash -python3 download_demo.py +alice génère ses clés depuis son mot de passe (Argon2id) + ↓ +alice s'inscrit sur le hub (envoie les clés publiques seulement) + ↓ +alice génère une GEK (clé symétrique 256 bits) pour le groupe + ↓ +alice envoie à bob sa GEK chiffrée avec la clé publique X25519 de bob + ↓ +bob récupère son bundle GEK depuis le hub (opaque, hub ne peut pas lire) + ↓ +bob déchiffre la GEK localement avec sa clé privée X25519 + ↓ +bob télécharge les chunks chiffrés depuis le node d'alice + ↓ +bob déchiffre les chunks avec la GEK → fichier en clair ``` -Expected output: +Le hub ne voit jamais la GEK ni les fichiers. Il stocke uniquement les clés +publiques et les bundles GEK chiffrés qu'il ne peut pas déchiffrer. -``` -[1] Logged in as bob_test. Token valid for 1 hour. -[2] GEK bundle fetched from hub (opaque to hub). -[3] GEK unwrapped successfully (32 bytes). +--- -[4] Files in group (2 entries): - a1b2c3d4e5f6... README.txt 59 bytes - d7e8f9a0b1c2... sample_data.bin 5242880 bytes +## Scripts disponibles dans `QE/demo-v1/` -[5] Fetching encrypted chunk 0 of README.txt (file_id=a1b2c3d4e5f6...)... -[5] Decrypted 59 bytes: +| Script | Rôle | +|---|---| +| `setup_demo.py` | Créer comptes + groupe + distribuer GEK | +| `run_node.py` | Démarrer le node HTTP d'alice | +| `download.py` | Télécharger un fichier comme bob | -Welcome to the MeshBay demo group. This file is encrypted. -``` +Tous les paramètres ont des valeurs par défaut ; lancer avec `--help` pour les options. -**Measured timings on the demo group:** -- Login round-trip: ~80ms -- GEK bundle fetch: ~40ms -- JWT verification on node: ~884µs (offline, no hub call) -- README.txt (59 bytes) full download: ~23ms -- 1 MB encrypted chunk: ~275ms receive, ~2.4ms decrypt +--- -For small files or public groups, you can also download the full file in one request without chunk-level decryption: +## Dépannage rapide -```bash -curl -s -H "Authorization: Bearer $TOKEN" \ - http://meshbay.org:19001/file/<file_id> \ - -o README.txt -``` +**`ModuleNotFoundError: No module named 'meshbay_common'`** +→ Activer le venv : `source .venv/bin/activate` ---- +**`ERREUR: creds.json introuvable`** +→ Lancer d'abord `setup_demo.py` + +**`HTTPStatusError: 409 Conflict`** lors du setup +→ Les comptes existent déjà. Soit changer les noms (`--alice-user`), soit continuer normalement — le script gère le 409 et continue. -## What's next +**`Connection refused` sur le node** +→ Vérifier que `run_node.py` tourne dans un autre terminal. -- **Create your own group:** see the [User Guide](USERGUIDE.md) §3 — Groups -- **Video streaming:** the node exposes an HLS endpoint at `/stream/{file_id}` for MP4 and MKV files -- **Invite a member:** wrap the GEK for them and POST it to `/v1/groups/{group_id}/members/{username}/gek` -- **Multi-group nodes:** a single node can host multiple groups — configure additional `[[groups]]` blocks in `node.toml` -- **Full API reference:** [User Guide](USERGUIDE.md) §11 +**`InvalidTag` lors du déchiffrement** +→ Le bundle GEK du hub ne correspond pas aux clés locales. Relancer `setup_demo.py` pour régénérer les bundles. diff --git a/packages/meshbay-common/src/meshbay_common/keyderive.py b/packages/meshbay-common/src/meshbay_common/keyderive.py new file mode 100644 index 0000000..497f877 --- /dev/null +++ b/packages/meshbay-common/src/meshbay_common/keyderive.py @@ -0,0 +1,128 @@ +""" +MeshBay — Key derivation from username + password. + +Allows Ed25519 + X25519 keypairs to be derived deterministically +from credentials. Same inputs → same keys on any device. + +Algorithm: Argon2id (Python CLI / native clients) + salt = SHA-256("meshbay:v1:" + username) + seed = Argon2id(password, salt, length=64, ...) + sk_ed = Ed25519PrivateKey.from_private_bytes(seed[:32]) + sk_x25519 = X25519PrivateKey.from_private_bytes(seed[32:]) + +Browser alternative (keyderive.js): uses PBKDF2-SHA512 because +WebCrypto does not support Argon2. The two algorithms produce +DIFFERENT keys from the same password — a user registered via Python +CLI and via web browser will have different keypairs. + +Resolution: the web client generates RANDOM keypairs on first login +(WebCrypto, stored encrypted in hub), and uses derive_keys_from_password +only to encrypt/decrypt the stored keypair bundle. This avoids the +algorithm mismatch problem entirely. + +See keyderive.js for the browser-side implementation. +""" + +import hashlib +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from cryptography.hazmat.primitives.kdf.argon2 import Argon2id + + +# Argon2id parameters — same as keystore (see crypto.py) +_ITERATIONS = 3 +_MEMORY_COST = 65536 # 64 MB — increase to 262144 for production +_LANES = 4 +_SEED_LENGTH = 64 # 32 bytes Ed25519 + 32 bytes X25519 + + +def _derive_salt(username: str) -> bytes: + """Deterministic salt: SHA-256 of 'meshbay:v1:<username>'.""" + return hashlib.sha256(f"meshbay:v1:{username}".encode()).digest() + + +def derive_keys_from_password( + username: str, + password: str, +) -> tuple[Ed25519PrivateKey, X25519PrivateKey]: + """ + Derive Ed25519 + X25519 keypairs deterministically from username + password. + + Properties: + - Same credentials always produce the same keypairs + - Different usernames produce different keys (even with same password) + - Password cannot be recovered from the public keys + - Changing the password invalidates all GEK bundles stored on the hub + + Use for: + - CLI / native node registration (Argon2id available) + - Recovery of lost keypairs from credentials + + Do NOT use for: + - Web browser registration (use random keypairs + encrypted bundle instead) + """ + salt = _derive_salt(username) + kdf = Argon2id( + salt=salt, length=_SEED_LENGTH, + iterations=_ITERATIONS, lanes=_LANES, memory_cost=_MEMORY_COST, + ) + seed = kdf.derive(password.encode()) + return ( + Ed25519PrivateKey.from_private_bytes(seed[:32]), + X25519PrivateKey.from_private_bytes(seed[32:]), + ) + + +def encrypt_keypair_bundle( + sk_ed: Ed25519PrivateKey, + sk_x: X25519PrivateKey, + password: str, + username: str, +) -> bytes: + """ + Encrypt a keypair bundle with a password-derived key (for hub storage). + Used by web clients: random keypairs encrypted with password, stored on hub. + Returns: AES-256-GCM ciphertext (nonce prepended). + """ + import os + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from meshbay_common.crypto import sk_to_raw + import msgpack + + # Derive an AES key from the password (different info string from key derivation) + salt = hashlib.sha256(f"meshbay:bundle:v1:{username}".encode()).digest() + kdf = Argon2id(salt=salt, length=32, iterations=_ITERATIONS, + lanes=_LANES, memory_cost=_MEMORY_COST) + aes_key = kdf.derive(password.encode()) + + payload = msgpack.packb({ + "sk_ed": sk_to_raw(sk_ed), + "sk_x": sk_to_raw(sk_x), + }, use_bin_type=True) + + nonce = os.urandom(12) + ct = AESGCM(aes_key).encrypt(nonce, payload, None) + return nonce + ct + + +def decrypt_keypair_bundle( + bundle: bytes, + password: str, + username: str, +) -> tuple[Ed25519PrivateKey, X25519PrivateKey]: + """Decrypt a keypair bundle. Raises on wrong password.""" + import msgpack + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + salt = hashlib.sha256(f"meshbay:bundle:v1:{username}".encode()).digest() + kdf = Argon2id(salt=salt, length=32, iterations=_ITERATIONS, + lanes=_LANES, memory_cost=_MEMORY_COST) + aes_key = kdf.derive(password.encode()) + + nonce, ct = bundle[:12], bundle[12:] + payload = AESGCM(aes_key).decrypt(nonce, ct, None) + data = msgpack.unpackb(payload, raw=False) + return ( + Ed25519PrivateKey.from_private_bytes(data["sk_ed"]), + X25519PrivateKey.from_private_bytes(data["sk_x"]), + ) diff --git a/packages/meshbay-common/tests/test_keyderive.py b/packages/meshbay-common/tests/test_keyderive.py new file mode 100644 index 0000000..0aa6201 --- /dev/null +++ b/packages/meshbay-common/tests/test_keyderive.py @@ -0,0 +1,57 @@ +"""Tests for password-based key derivation.""" + +import pytest +from meshbay_common.keyderive import ( + derive_keys_from_password, + encrypt_keypair_bundle, + decrypt_keypair_bundle, +) +from meshbay_common.crypto import pk_to_b64 + + +def test_deterministic(): + """Same credentials → same keys.""" + sk_ed1, sk_x1 = derive_keys_from_password("alice", "correct-horse") + sk_ed2, sk_x2 = derive_keys_from_password("alice", "correct-horse") + assert pk_to_b64(sk_ed1.public_key()) == pk_to_b64(sk_ed2.public_key()) + assert pk_to_b64(sk_x1.public_key()) == pk_to_b64(sk_x2.public_key()) + + +def test_different_users_different_keys(): + sk_ed_a, _ = derive_keys_from_password("alice", "samepassword") + sk_ed_b, _ = derive_keys_from_password("bob", "samepassword") + assert pk_to_b64(sk_ed_a.public_key()) != pk_to_b64(sk_ed_b.public_key()) + + +def test_different_passwords_different_keys(): + sk_ed1, _ = derive_keys_from_password("alice", "password1") + sk_ed2, _ = derive_keys_from_password("alice", "password2") + assert pk_to_b64(sk_ed1.public_key()) != pk_to_b64(sk_ed2.public_key()) + + +def test_ed_and_x_keys_independent(): + sk_ed, sk_x = derive_keys_from_password("user", "pass12345") + from meshbay_common.crypto import sk_to_raw + assert sk_to_raw(sk_ed) != sk_to_raw(sk_x) + + +def test_bundle_encrypt_decrypt(): + sk_ed, sk_x = derive_keys_from_password("alice", "strongpass!") + bundle = encrypt_keypair_bundle(sk_ed, sk_x, "password123", "alice") + sk_ed2, sk_x2 = decrypt_keypair_bundle(bundle, "password123", "alice") + assert pk_to_b64(sk_ed.public_key()) == pk_to_b64(sk_ed2.public_key()) + assert pk_to_b64(sk_x.public_key()) == pk_to_b64(sk_x2.public_key()) + + +def test_bundle_wrong_password_rejected(): + sk_ed, sk_x = derive_keys_from_password("alice", "correctpass") + bundle = encrypt_keypair_bundle(sk_ed, sk_x, "correctpass", "alice") + with pytest.raises(Exception): + decrypt_keypair_bundle(bundle, "wrongpass", "alice") + + +def test_bundle_wrong_username_rejected(): + sk_ed, sk_x = derive_keys_from_password("alice", "pass12345") + bundle = encrypt_keypair_bundle(sk_ed, sk_x, "pass12345", "alice") + with pytest.raises(Exception): + decrypt_keypair_bundle(bundle, "pass12345", "bob") # wrong username salt diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 0b615a4..5a7a3b4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -42,8 +42,9 @@ class RegisterRequest(BaseModel): username: str email: str password: str - pk_user_ed25519: str # base64 raw 32B - pk_user_x25519: str # base64 raw 32B + pk_user_ed25519: str # base64 raw 32B + pk_user_x25519: str # base64 raw 32B + keypair_bundle: str | None = None # AES-GCM encrypted bundle (web clients) @field_validator("username") @classmethod @@ -95,6 +96,7 @@ async def register( pk_ed25519=body.pk_user_ed25519, pk_x25519=body.pk_user_x25519, hub_id=hub_id, + keypair_bundle=body.keypair_bundle, ) db.add(user) db.add(IPLog( @@ -142,12 +144,15 @@ async def login( db.add(IPLog(user_id=user.id, event="login", ip_address=ip)) await db.commit() - return { + resp = { "access_token": access_token, "refresh_token": raw_rt, "token_type": "bearer", "expires_in": _ttl(), } + if user.keypair_bundle: + resp["keypair_bundle"] = user.keypair_bundle # encrypted, for web clients + return resp @router.post("/token/refresh") diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index b76870d..2aeae41 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -44,8 +44,9 @@ class User(Base): pw_salt: Mapped[bytes] = mapped_column(nullable=False) pk_ed25519: Mapped[str] = mapped_column(String(64), nullable=False) # base64 raw 32B pk_x25519: Mapped[str] = mapped_column(String(64), nullable=False) # base64 raw 32B - hub_id: Mapped[str] = mapped_column(String(128), nullable=False) - status: Mapped[str] = mapped_column(String(16), default="active") # active|suspended|revoked + hub_id: Mapped[str] = mapped_column(String(128), nullable=False) + keypair_bundle: Mapped[str | None] = mapped_column(Text) # AES-GCM encrypted, web clients only + status: Mapped[str] = mapped_column(String(16), default="active") # active|suspended|revoked created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) nodes: Mapped[list["Node"]] = relationship(back_populates="user") diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js new file mode 100644 index 0000000..63baff5 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -0,0 +1,164 @@ +/** + * MeshBay Browser Key Management — keyderive.js + * + * Web registration flow (avoids algorithm mismatch with Python Argon2id): + * + * REGISTRATION: + * 1. Browser generates RANDOM Ed25519 + X25519 keypairs via WebCrypto + * 2. Bundle (sk_ed || sk_x) is encrypted with AES-256-GCM + * using a key derived from password via PBKDF2-SHA512 + * 3. Encrypted bundle + public keys sent to hub for storage + * + * LOGIN (new device): + * 1. Hub returns the encrypted bundle + * 2. Browser decrypts it locally with the password + * 3. Private keys loaded into memory (never leave the browser) + * + * Password change: re-encrypt bundle with new password-derived key. + * + * Keys never leave the browser in cleartext. + * Hub stores: public keys + encrypted bundle (cannot read private keys). + */ + +const PBKDF2_ITERATIONS = 600000; // OWASP 2023 recommendation for PBKDF2-SHA512 +const HUB = ''; // same origin + +// ── Key generation ──────────────────────────────────────────────────────────── + +/** + * Generate random Ed25519 + X25519 keypairs using WebCrypto. + * Returns raw bytes for both (not CryptoKey objects, for easier serialisation). + */ +async function generateKeypairs() { + // Ed25519 (signing) + const edKey = await crypto.subtle.generateKey( + { name: 'Ed25519' }, true, ['sign', 'verify']); + const skEdRaw = await crypto.subtle.exportKey('pkcs8', edKey.privateKey); + const pkEdRaw = await crypto.subtle.exportKey('spki', edKey.publicKey); + + // X25519 (key agreement) + const xKey = await crypto.subtle.generateKey( + { name: 'X25519' }, true, ['deriveBits']); + const skXRaw = await crypto.subtle.exportKey('pkcs8', xKey.privateKey); + const pkXRaw = await crypto.subtle.exportKey('spki', xKey.publicKey); + + return { skEdRaw, pkEdRaw, skXRaw, pkXRaw }; +} + +// ── Password → AES key ──────────────────────────────────────────────────────── + +/** + * Derive an AES-256 key from password + username using PBKDF2-SHA512. + * Used for encrypting the keypair bundle. + */ +async function deriveEncryptionKey(password, username) { + const enc = new TextEncoder(); + const km = await crypto.subtle.importKey( + 'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']); + const salt = await crypto.subtle.digest( + 'SHA-256', enc.encode(`meshbay:bundle:v1:${username}`)); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', hash: 'SHA-512', salt, iterations: PBKDF2_ITERATIONS }, + km, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'], + ); +} + +// ── Bundle encryption ───────────────────────────────────────────────────────── + +/** + * Encrypt the keypair bundle with the password-derived AES key. + * Bundle format: JSON { skEd: base64(pkcs8), skX: base64(pkcs8) } + */ +async function encryptBundle(skEdRaw, skXRaw, password, username) { + const aesKey = await deriveEncryptionKey(password, username); + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const data = new TextEncoder().encode(JSON.stringify({ + skEd: btoa(String.fromCharCode(...new Uint8Array(skEdRaw))), + skX: btoa(String.fromCharCode(...new Uint8Array(skXRaw))), + })); + const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, aesKey, data); + // Return base64(nonce || ciphertext) + const out = new Uint8Array(nonce.length + ct.byteLength); + out.set(nonce); + out.set(new Uint8Array(ct), nonce.length); + return btoa(String.fromCharCode(...out)); +} + +/** + * Decrypt a keypair bundle. Throws if password is wrong. + */ +async function decryptBundle(bundleB64, password, username) { + const aesKey = await deriveEncryptionKey(password, username); + const raw = Uint8Array.from(atob(bundleB64), c => c.charCodeAt(0)); + const nonce = raw.slice(0, 12); + const ct = raw.slice(12); + const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, aesKey, ct); + return JSON.parse(new TextDecoder().decode(plain)); +} + +// ── Registration ────────────────────────────────────────────────────────────── + +/** + * Full registration flow: + * 1. Generate random keypairs + * 2. Encrypt bundle with password + * 3. POST to hub (public keys + encrypted bundle) + * + * Returns the raw private keys for immediate use after registration. + */ +async function registerUser(username, email, password) { + const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs(); + + // Convert SPKI public keys to raw 32-byte format expected by hub + const pkEdCrypto = await crypto.subtle.importKey('spki', pkEdRaw, 'Ed25519', true, ['verify']); + const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, 'X25519', true, []); + const pkEdBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkEdCrypto)); + const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto)); + + const encBundle = await encryptBundle(skEdRaw, skXRaw, password, username); + + const resp = await fetch(`${HUB}/v1/users/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + email, + password, + pk_user_ed25519: btoa(String.fromCharCode(...pkEdBytes)), + pk_user_x25519: btoa(String.fromCharCode(...pkXBytes)), + keypair_bundle: encBundle, // encrypted, hub stores but cannot read + }), + }); + + if (!resp.ok) throw new Error(`Registration failed: ${await resp.text()}`); + return { skEdRaw, skXRaw, pkEdBytes, pkXBytes }; +} + +/** + * Login and recover private keys from the encrypted bundle. + */ +async function loginAndRecover(username, password) { + const resp = await fetch(`${HUB}/v1/users/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + if (!resp.ok) throw new Error(`Login failed: ${await resp.text()}`); + + const data = await resp.json(); + const bundle = data.keypair_bundle; + if (!bundle) throw new Error('No keypair bundle in response — account may have been created via CLI'); + + const keys = await decryptBundle(bundle, password, username); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + skEdB64: keys.skEd, + skXB64: keys.skX, + }; +} + +window.MeshBayKeys = { registerUser, loginAndRecover, generateKeypairs }; |