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.js77
1 files changed, 73 insertions, 4 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index 6bd5896..7bbcac5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -161,6 +161,12 @@ async function deriveBundleSessionKey(password, username, userId, pepperB64, pep
// HKDF keys are non-extractable by specification.
v3: await crypto.subtle.importKey('raw', m, 'HKDF', false, ['deriveKey', 'deriveBits']),
pepperVersion: pepperVersion || 1,
+ // TRANSITIONAL — the key MBK2 bundles were sealed under, which this same
+ // Argon2 run produces anyway. Kept for the session so a node still holding
+ // one has it opened and replaced by MBK3 on the account's next visit,
+ // rather than the member being re-invited. Decrypt only; nothing is sealed
+ // under it. Remove once no MBK2 bundle is left on any node.
+ legacy: await crypto.subtle.importKey('raw', a, { name: 'AES-GCM' }, false, ['decrypt']),
};
}
@@ -321,13 +327,52 @@ async function encryptBundle(skEdRaw, skXRaw, aesKey, { userId, nodePk, pepperVe
return btoa(String.fromCharCode(...out));
}
-/** 'current', or 'retired' for anything written before MBK3. */
+// TRANSITIONAL — the format before MBK3: "MBK2" ‖ nonce (12) ‖ AES-GCM under
+// the passphrase's Argon2 key alone, no associated data. Read once to be
+// replaced; never written.
+const LEGACY_MAGIC = 'MBK2';
+
+/**
+ * 'current'; 'legacy' for MBK2, opened once with the session's legacy key and
+ * replaced; 'retired' for anything older, which is not read at all.
+ */
function bundleFormat(bundleB64) {
try {
- return atob(bundleB64).startsWith(BUNDLE_MAGIC) ? 'current' : 'retired';
+ const head = atob(bundleB64).slice(0, 4);
+ if (head === BUNDLE_MAGIC) return 'current';
+ return head === LEGACY_MAGIC ? 'legacy' : 'retired';
} catch { return 'retired'; }
}
+/** TRANSITIONAL — open an MBK2 bundle (passphrase or recovery copy). */
+async function decryptLegacyBundle(bundleB64, aesKey) {
+ if (bundleFormat(bundleB64) !== 'legacy') throw new Error('not an MBK2 bundle');
+ const raw = _b64bytes(bundleB64);
+ const off = LEGACY_MAGIC.length;
+ const plain = await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: raw.slice(off, off + 12) }, aesKey, raw.slice(off + 12));
+ return JSON.parse(new TextDecoder().decode(plain));
+}
+
+/**
+ * TRANSITIONAL — an identity read from an MBK2 bundle, sealed again as MBK3
+ * for the same node (and the recovery copy too, when a recovery key is in
+ * hand), for the caller to store in place of the old one.
+ */
+async function resealLegacyIdentity(keys, sessionKey, recoveryKey, { userId, nodePk }) {
+ const skEd = _b64bytes(keys.skEd);
+ const skX = _b64bytes(keys.skX);
+ const out = {
+ bundleEnc: await encryptBundle(skEd, skX, await nodeBundleKey(sessionKey, nodePk),
+ { userId, nodePk, pepperVersion: sessionKey.pepperVersion }),
+ };
+ if (recoveryKey) {
+ out.bundleEncRecovery = await encryptBundle(skEd, skX, recoveryKey,
+ { userId, nodePk, pepperVersion: 0 });
+ }
+ return out;
+}
+
// ── Registration ──────────────────────────────────────────────────────────────
/**
@@ -426,13 +471,36 @@ async function decryptBundle(bundleB64, aesKey, { userId, nodePk }) {
* decrypts it and returns the keys + encrypted bundle for push to node.
* Otherwise returns bundleKey so the caller can fetch from node during handshake.
*/
+// The token the hub gave this browser at an earlier sign-in, per account. It
+// is not a credential — the passphrase is still asked — but a sign-in that
+// presents it has a failure counter of its own, so a stranger who keeps
+// failing on this account's name locks only browsers it has never used.
+// Kept across sign-outs on purpose: forgetting it would be the lockout again.
+const KNOWN_BROWSERS = 'mb_known_browsers';
+
+function _knownBrowser(username) {
+ try { return (JSON.parse(localStorage.getItem(KNOWN_BROWSERS)) || {})[username] || null; }
+ catch { return null; }
+}
+
+function _rememberBrowser(username, token) {
+ if (!token) return;
+ try {
+ const all = JSON.parse(localStorage.getItem(KNOWN_BROWSERS)) || {};
+ all[username] = token;
+ localStorage.setItem(KNOWN_BROWSERS, JSON.stringify(all));
+ } catch { /* storage refused: this browser stays an unknown one */ }
+}
+
async function loginAndRecover(username, password) {
const authKey = await deriveAuthKey(password, username);
+ const known = _knownBrowser(username);
const resp = await hubCall('/v1/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ username, auth_key: authKey }),
+ body: JSON.stringify({ username, auth_key: authKey,
+ ...(known ? { known_browser: known } : {}) }),
});
if (!resp.ok) {
@@ -454,6 +522,7 @@ async function loginAndRecover(username, password) {
}
const data = await resp.json();
+ _rememberBrowser(username, data.known_browser);
const result = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
@@ -492,7 +561,7 @@ window.MeshBayKeys = {
// The bundle key (docs/MESHBAY_DESIGN.md §3.1, §3.7): one session key per
// sign-in, one derived key per node, one format.
deriveBundleSessionKey, sessionBundleKey, nodeBundleKey, fetchBundlePepper,
- encryptBundle, decryptBundle, bundleFormat,
+ encryptBundle, decryptBundle, bundleFormat, decryptLegacyBundle, resealLegacyIdentity,
// Account recovery key (docs/MESHBAY_DESIGN.md §3.6).
generateRecoveryKey, deriveRecoveryKey,
};