summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-09 14:50:22 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-09 14:50:22 +0200
commitaed220d9f0bab42efd57b56851319e840ab8ae26 (patch)
treee8b72fbe9016635438e6b7046a35e47ec3dbe93a /packages/meshbay-hub/src
parent608d3a705d065d6b378f4889322ff9d1bc41d147 (diff)
downloadmeshbay-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>
Diffstat (limited to 'packages/meshbay-hub/src')
-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
3 files changed, 175 insertions, 5 deletions
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 };