aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/keyderive.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js112
1 files changed, 91 insertions, 21 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index 3d9c3c6..afa5d27 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -67,11 +67,34 @@ async function generateKeypairs() {
// ── 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) {
+// Argon2id parameters for the keypair bundle.
+//
+// This is the one KDF in the browser that guards something an adversary can take
+// away and attack at leisure: the bundle is stored on every node whose group its
+// owner joins (finding C4). PBKDF2 was the wrong tool — it is compute-only, which
+// is exactly what a GPU is good at, so 600k iterations bought far less than the
+// wall-clock time suggested.
+//
+// 64 MB / t=3 / p=1 measured at ~320 ms through this WASM build on a desktop, so
+// roughly a second on a modest phone — the most that belongs in a login. Memory
+// is what matters here: at 64 MB per guess, a 24 GB card holds a few hundred in
+// parallel instead of the effectively unbounded number PBKDF2 allows.
+const ARGON2_MEM_KIB = 65536; // 64 MB
+const ARGON2_TIME = 3;
+const ARGON2_LANES = 1;
+
+// Bundles written before this carry no marker and are read with the old KDF.
+// They are re-encrypted the first time their owner signs in (see upgradeBundle).
+const BUNDLE_V2_MAGIC = 'MBK2';
+
+function _argon2() {
+ const a = (typeof window !== 'undefined' && window.argon2) || globalThis.argon2;
+ if (!a) throw new Error('Argon2 unavailable — vendor/argon2.min.js did not load');
+ return a;
+}
+
+/** Legacy: PBKDF2-SHA512. Kept to read bundles written before the change. */
+async function deriveEncryptionKeyV1(password, username) {
const enc = new TextEncoder();
const km = await crypto.subtle.importKey(
'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']);
@@ -86,6 +109,27 @@ async function deriveEncryptionKey(password, username) {
);
}
+/**
+ * Derive the bundle key with Argon2id.
+ *
+ * The salt stays deterministic and domain-separated per user, as before: it is
+ * what lets the key be derived once at sign-in and kept, instead of holding the
+ * passphrase in memory to re-derive it whenever a bundle turns up. It is unique
+ * per account, so it does what a salt is for — no shared precomputation.
+ */
+async function deriveEncryptionKey(password, username) {
+ const enc = new TextEncoder();
+ const salt = new Uint8Array(await crypto.subtle.digest(
+ 'SHA-256', enc.encode(`meshbay:bundle:v2:${username}`))).slice(0, 16);
+ const out = await _argon2().hash({
+ pass: password, salt,
+ time: ARGON2_TIME, mem: ARGON2_MEM_KIB, parallelism: ARGON2_LANES,
+ hashLen: 32, type: _argon2().ArgonType.Argon2id,
+ });
+ return crypto.subtle.importKey(
+ 'raw', out.hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
+}
+
// ── Bundle encryption ─────────────────────────────────────────────────────────
/**
@@ -94,29 +138,42 @@ async function deriveEncryptionKey(password, username) {
*/
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({
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);
+ // base64( "MBK2" || nonce || ciphertext ). The marker is what tells a reader
+ // which KDF produced the key, so old bundles stay readable and new ones are
+ // never fed to the old derivation.
+ const magic = new TextEncoder().encode(BUNDLE_V2_MAGIC);
+ const out = new Uint8Array(magic.length + nonce.length + ct.byteLength);
+ out.set(magic);
+ out.set(nonce, magic.length);
+ out.set(new Uint8Array(ct), magic.length + nonce.length);
return btoa(String.fromCharCode(...out));
}
+function bundleVersion(bundleB64) {
+ try {
+ return atob(bundleB64).startsWith(BUNDLE_V2_MAGIC) ? 2 : 1;
+ } catch { return 1; }
+}
+
/**
* 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));
+ const key = bundleVersion(bundleB64) === 2
+ ? await deriveEncryptionKey(password, username)
+ : await deriveEncryptionKeyV1(password, username);
+ return decryptBundleWithKey(bundleB64, key);
}
// ── Registration ──────────────────────────────────────────────────────────────
@@ -165,11 +222,19 @@ async function registerUser(username, email, password) {
* Decrypt a keypair bundle using a pre-derived AES-256 CryptoKey.
* Used when the bundle is fetched from the node (bundleKey was derived at login).
*/
-async function decryptBundleWithKey(bundleB64, aesKey) {
+async function decryptBundleWithKey(bundleB64, aesKeyOrPair) {
+ const v2 = bundleVersion(bundleB64) === 2;
+ // Callers derive both keys at sign-in and pass the pair, because which one a
+ // bundle needs is only known once it has been read — and the passphrase is
+ // deliberately not kept around to derive the other one later.
+ const key = (aesKeyOrPair && aesKeyOrPair.v2)
+ ? (v2 ? aesKeyOrPair.v2 : aesKeyOrPair.v1)
+ : aesKeyOrPair;
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);
+ const off = v2 ? BUNDLE_V2_MAGIC.length : 0;
+ const nonce = raw.slice(off, off + 12);
+ const ct = raw.slice(off + 12);
+ const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct);
return JSON.parse(new TextDecoder().decode(plain));
}
@@ -195,7 +260,12 @@ async function loginAndRecover(username, password) {
const result = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
- bundleKey: await deriveEncryptionKey(password, username),
+ // Both, so a bundle written before the KDF changed can still be opened —
+ // and re-written with the new one on the next backup.
+ bundleKey: {
+ v2: await deriveEncryptionKey(password, username),
+ v1: await deriveEncryptionKeyV1(password, username),
+ },
};
// localStorage bundle = new registration, not yet pushed to node
@@ -263,5 +333,5 @@ async function signBytes(skEdPkcs8B64, message) {
window.MeshBayKeys = {
registerUser, loginAndRecover, regenerateKeys, generateKeypairs, signBytes,
- deriveAuthKey, decryptBundleWithKey,
+ deriveAuthKey, decryptBundleWithKey, encryptBundleWithKey, bundleVersion,
};