summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/QUICKSTART.md499
-rw-r--r--packages/meshbay-common/src/meshbay_common/keyderive.py128
-rw-r--r--packages/meshbay-common/tests/test_keyderive.py57
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js164
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 };