aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js5
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/hub-client.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js262
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/playlists.js61
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/profile-page.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js41
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js28
-rwxr-xr-xpackages/meshbay-hub/tests/harness/playlist_store_probe.py51
-rw-r--r--packages/meshbay-hub/tests/harness/playlist_ui_probe.py7
-rw-r--r--packages/meshbay-hub/tests/test_bundle_kdf_parity.py102
-rw-r--r--packages/meshbay-hub/tests/test_bundle_key.py183
-rw-r--r--packages/meshbay-hub/tests/test_bundle_pepper.py12
-rw-r--r--packages/meshbay-hub/tests/test_playlist_key.py221
-rw-r--r--packages/meshbay-hub/tests/test_playlist_store.py21
-rw-r--r--packages/meshbay-hub/tests/test_recovery_key.py7
-rw-r--r--packages/meshbay-hub/tests/test_rewrap_fanout.py61
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py17
30 files changed, 682 insertions, 479 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 72ac68f..88e6d9e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -368,8 +368,9 @@ async def _login_failed(db: AsyncSession, username: str, ip: str,
#
# Half of what opens this account's keypair bundles on nodes; the passphrase is
# the other half. Handed out only where the caller proved the passphrase or a
-# device key — sign-in, device sign-in, a passphrase change — or already holds a
-# session that did (`GET /me/bundle-pepper`). Never on a token refresh, which
+# device key — sign-in, device sign-in — or already holds a session that did
+# (`GET /me/bundle-pepper`, which a passphrase change uses: it re-seals every
+# bundle before the hub is asked to accept the new passphrase). Never on a token refresh, which
# proves only possession of a refresh token; never to a node token; never in a
# token or a log line.
@@ -1084,9 +1085,6 @@ async def change_password(
))
db.add(IPLog(user_id=current_user.id, event="password_change",
ip_address=client_ip(request)))
- # The new passphrase makes a new bundle key, and the client does not keep
- # the pepper; this call proved the old passphrase, so it carries it.
- pepper = _bundle_pepper(current_user)
await db.commit()
return {
@@ -1095,7 +1093,6 @@ async def change_password(
"refresh_token": raw_rt,
"token_type": "bearer",
"expires_in": _ttl(),
- **pepper,
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
index 09c4acf..8e67e20 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -683,9 +683,12 @@ export function ResetPasswordPage({ onLogin }) {
await _storeRecoveryKey(session.recoveryKey);
setPhase('working');
const auth = loadAuth() || {};
+ // The sign-in above derived the key for the new passphrase; the bundles
+ // are still sealed under the old one, so connect opens the recovery copy
+ // and they are sealed again under this key.
const r = await window.MeshBayTransport.rewrapAllNodes({
hubUrl: HUB, token: auth.token, username: name, userId: auth.userId,
- newPassphrase: password, recoveryKey: mnemonic,
+ bundleKey: session.bundleKey, recoveryKey: mnemonic,
onProgress: setProgress,
});
setResult(r);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index b6f3d37..b18c811 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -245,12 +245,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
setError('');
try {
// Same derivation as sign-in — the token is already ours, only the key
- // that opens node bundles is missing here. Persisted so this browser is
- // set up from now on.
- session.bundleKey = {
- ...(await window.MeshBayKeys.bundleKeyPairFields(pass, username)),
- v1: await window.MeshBayKeys.deriveEncryptionKeyV1(pass, username),
- };
+ // that opens node bundles is missing here, and the pepper that goes into
+ // it is asked for with that token. Persisted so this browser is set up
+ // from now on.
+ const { pepper, version } = await window.MeshBayKeys.fetchBundlePepper(token);
+ session.bundleKey = await window.MeshBayKeys.deriveBundleSessionKey(
+ pass, username, userId, pepper, version);
await _storeBundleKey(session.bundleKey);
setPassInput('');
setNeedsPass(false);
@@ -260,7 +260,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
} finally {
setPassBusy(false);
}
- }, [passInput, username]);
+ }, [passInput, username, userId, token]);
// Everything one handshake ack tells this page, applied in one place.
//
@@ -650,6 +650,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// The node has no bundle for us and this browser derived no key to make
// one — the passphrase form below is the way in, not a support request.
if (err.reason === 'no_keys') setNeedsPass(true);
+ // An identity sealed before the pepper: only the operator can clear it.
+ if (err.reason === 'bundle_format_retired') err.message = t('group.bundle_format_retired');
// A key this node has never pinned, for an account it knows. The way in
// is a device already trusted here, not an operator — which is the
// whole point of device linking: a second browser or a native client
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
index ba91f00..3081ad8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/hub-client.js
@@ -159,7 +159,12 @@ async function _loadKey(slot) {
// only groups joined in the unbroken session that generated it. Cleared with
// everything else on sign-out.
const _storeBundleKey = (key) => _storeKey('bk', key);
-const _loadBundleKey = () => _loadKey('bk');
+// A key stored before the pepper (`{v2, v1, …}`) opens nothing any more: it is
+// no key at all, and the group page asks for the passphrase again.
+const _loadBundleKey = async () => {
+ const k = await _loadKey('bk');
+ return k && k.v3 ? k : null;
+};
const _storeRecoveryKey = (key) => _storeKey('rk', key);
const _loadRecoveryKey = () => _loadKey('rk');
async function _clearKeyDB() {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index 33b1cf2..8f820ec 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -1,23 +1,14 @@
/**
* MeshBay Browser Key Management — keyderive.js
*
- * Web registration flow (avoids algorithm mismatch with Python Argon2id):
+ * Two things come from the passphrase, kept apart by their salts:
+ * - `auth_key` (PBKDF2), the hub credential — the passphrase never leaves;
+ * - `A` (Argon2id), which with the hub-held pepper gives the session's bundle
+ * key `M`, from which each node's bundle key and the playlist key derive.
*
- * 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).
+ * Identity keys are per node: generated on a first join, sealed for that node
+ * and that account (`MBK3`), and left with that node. The hub stores no user
+ * key. See docs/MESHBAY_DESIGN.md §3.1, §3.7.
*/
const PBKDF2_ITERATIONS = 600000; // OWASP 2023 recommendation for PBKDF2-SHA512
@@ -52,7 +43,7 @@ function hubCall(path, init) {
/**
* Derive an auth key from password + username using PBKDF2-SHA512.
* This key is sent to the hub for authentication — the raw password never leaves the browser.
- * Uses a different salt domain than deriveEncryptionKey (bundle key), so the two
+ * Uses a different salt domain than the bundle key's Argon2 run, so the two
* derived values are cryptographically independent.
*/
async function deriveAuthKey(password, username) {
@@ -108,9 +99,11 @@ const ARGON2_MEM_KIB = 131072; // 128 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';
+// What a bundle is written as. Nothing else is read: a bundle in an earlier
+// format was sealed under the passphrase alone, which is exactly what an
+// operator holding it could attack offline, and there is no migration window —
+// such an identity is re-created on that node after the operator unpins it.
+const BUNDLE_MAGIC = 'MBK3';
function _argon2() {
const a = (typeof window !== 'undefined' && window.argon2) || globalThis.argon2;
@@ -118,29 +111,10 @@ function _argon2() {
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']);
- 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'],
- );
-}
-
/**
- * 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.
+ * `A`, the passphrase's half of the bundle key: Argon2id with a deterministic,
+ * per-account salt. Deterministic so it can be derived once at sign-in and the
+ * passphrase dropped; unique per account, so no shared precomputation.
*/
async function _bundleKeyBytes(password, username) {
const enc = new TextEncoder();
@@ -154,41 +128,75 @@ async function _bundleKeyBytes(password, username) {
return out.hash;
}
-async function deriveEncryptionKey(password, username) {
- return crypto.subtle.importKey(
- 'raw', await _bundleKeyBytes(password, username),
- { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
-}
+const _b64bytes = (b64) => Uint8Array.from(atob(b64), c => c.charCodeAt(0));
+const _hkdf = (info) => ({
+ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
+ info: new TextEncoder().encode(info),
+});
/**
- * The bundle key as **two handles over one Argon2 run**.
- *
- * `aes` is what has always been returned: the key that opens a node's identity
- * bundle. `hkdf` is the same 32 bytes imported a second time as an HKDF key,
- * from which purpose-separated subkeys can be derived — playlists are the
- * first (docs/playlists.md §3.4).
+ * The session's bundle key: `M = HKDF(A ‖ pepper, "…master:v3|" + user id)`.
*
- * It has to be a second import of the same bytes, and not a derivation from
- * `aes`: that one is imported non-extractably with `['encrypt','decrypt']`, so
- * nothing can be derived from it at all. And it has to be one Argon2 run: a
- * second call would put another ~650 ms on the sign-in path for a key that is
- * mathematically identical.
+ * The pepper is held by the hub and handed only to a session that proved the
+ * passphrase or a device key (docs/MESHBAY_DESIGN.md §3.7). Without it a bundle
+ * cannot be opened however good the guess, so the operator of a node holding
+ * one has nothing to test offline. `M` is what a session keeps — imported as a
+ * non-extractable HKDF key, in IndexedDB until sign-out — and every key that
+ * opens something is derived from it: one per node, one for playlists. The
+ * pepper itself and `A` are not kept.
*
- * A subkey rather than the bundle key reused with a different AAD, for the
- * reason `groupbox.py` already writes down for chunk keys — purpose separation
- * is what stops one use's mistake becoming every use's.
+ * One Argon2 run: the ~650 ms on the sign-in path is the whole budget.
*/
-async function deriveBundleKeys(password, username) {
- const raw = await _bundleKeyBytes(password, username);
+async function deriveBundleSessionKey(password, username, userId, pepperB64, pepperVersion) {
+ if (!userId || !pepperB64) throw new Error('the hub did not provide the bundle pepper');
+ const a = new Uint8Array(await _bundleKeyBytes(password, username));
+ const pepper = _b64bytes(pepperB64);
+ const ikm = new Uint8Array(a.length + pepper.length);
+ ikm.set(a);
+ ikm.set(pepper, a.length);
+ const base = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
+ const m = await crypto.subtle.deriveBits(
+ _hkdf(`meshbay:bundle-master:v3|${userId}`), base, 256);
return {
- aes: await crypto.subtle.importKey(
- 'raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']),
- // HKDF keys are non-extractable by specification; `false` is the only
- // value this accepts.
- hkdf: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']),
+ // HKDF keys are non-extractable by specification.
+ v3: await crypto.subtle.importKey('raw', m, 'HKDF', false, ['deriveKey', 'deriveBits']),
+ pepperVersion: pepperVersion || 1,
};
}
+/**
+ * The key that seals this account's identity on ONE node. A leaked one opens
+ * that node's bundle and no other.
+ */
+async function nodeBundleKey(sessionKey, nodePkB64) {
+ if (!sessionKey || !sessionKey.v3) throw new Error('no bundle key in this session');
+ if (!nodePkB64) throw new Error("the node's key is not known yet");
+ return crypto.subtle.deriveKey(
+ _hkdf(`meshbay:bundle:v3|node|${nodePkB64}`), sessionKey.v3,
+ { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
+}
+
+/**
+ * GET the pepper for a session that is already open — a stored session from
+ * before the pepper existed, a passphrase change. Sign-in carries it already.
+ */
+async function fetchBundlePepper(token) {
+ const resp = await hubCall('/v1/users/me/bundle-pepper', {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!resp.ok) throw new Error(`bundle pepper: ${resp.status}`);
+ const data = await resp.json();
+ return { pepper: data.bundle_pepper, version: data.bundle_pepper_version };
+}
+
+/** The account id a token of ours names — what the master key is bound to. */
+function _subOf(token) {
+ try {
+ const part = String(token).split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
+ return JSON.parse(atob(part)).sub || null;
+ } catch { return null; }
+}
+
// ── Account recovery key ─────────────────────────────────────────────────────
//
// docs/MESHBAY_DESIGN.md §3.6. A full-entropy secret the user keeps outside the
@@ -255,32 +263,40 @@ async function deriveRecoveryKey(R, username) {
// ── Bundle encryption ─────────────────────────────────────────────────────────
+// Binds a bundle to its account and its node: a bundle copied to another node,
+// or served for another account, does not open.
+const _bundleAad = (userId, nodePkB64) =>
+ new TextEncoder().encode(`meshbay:bundle:v3|${userId}|${nodePkB64}`);
+
/**
- * Encrypt the keypair bundle with a bundle key derived at sign-in. Always
- * writes v2. Bundle format: JSON { skEd: base64(pkcs8), skX: base64(pkcs8) }
+ * Seal a keypair bundle for one node:
+ * base64( "MBK3" ‖ pepper version (1 byte) ‖ nonce (12) ‖ AES-GCM(plaintext, aad) ).
+ * `pepperVersion` is 0 for a recovery copy, which is sealed under the recovery
+ * key and owes nothing to the pepper. Plaintext: JSON { skEd, skX } (pkcs8, b64).
*/
-async function encryptBundleWithKey(skEdRaw, skXRaw, aesKey) {
- const nonce = crypto.getRandomValues(new Uint8Array(12));
- const data = new TextEncoder().encode(JSON.stringify({
+async function encryptBundle(skEdRaw, skXRaw, aesKey, { userId, nodePk, pepperVersion }) {
+ if (!userId || !nodePk) throw new Error('a bundle is sealed for one account on one node');
+ 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);
- // 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);
+ const ct = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv: nonce, additionalData: _bundleAad(userId, nodePk) }, aesKey, data);
+ const magic = new TextEncoder().encode(BUNDLE_MAGIC);
+ const out = new Uint8Array(magic.length + 1 + nonce.length + ct.byteLength);
out.set(magic);
- out.set(nonce, magic.length);
- out.set(new Uint8Array(ct), magic.length + nonce.length);
+ out[magic.length] = pepperVersion & 0xff;
+ out.set(nonce, magic.length + 1);
+ out.set(new Uint8Array(ct), magic.length + 1 + nonce.length);
return btoa(String.fromCharCode(...out));
}
-function bundleVersion(bundleB64) {
+/** 'current', or 'retired' for anything written before MBK3. */
+function bundleFormat(bundleB64) {
try {
- return atob(bundleB64).startsWith(BUNDLE_V2_MAGIC) ? 2 : 1;
- } catch { return 1; }
+ return atob(bundleB64).startsWith(BUNDLE_MAGIC) ? 'current' : 'retired';
+ } catch { return 'retired'; }
}
// ── Registration ──────────────────────────────────────────────────────────────
@@ -325,15 +341,15 @@ async function registerUser(username, email, password, recoveryMnemonic, captcha
}
/**
- * A fresh identity for one node, encrypted under the passphrase-derived key.
+ * A fresh identity for one node, sealed for that node only.
*
* Returns { skEdB64, skXB64, pkXB64, bundleEnc, bundleEncRecovery? } — the
* bundle goes to that node and nowhere else, and is what any other browser
* fetches to become the same person there. When `recoveryKey` is supplied a
- * second copy wrapped under it rides along, so a forgotten passphrase does not
+ * second copy sealed under it rides along, so a forgotten passphrase does not
* strand this identity (docs/MESHBAY_DESIGN.md §3.6).
*/
-async function generateNodeIdentity(bundleKey, recoveryKey) {
+async function generateNodeIdentity(sessionKey, recoveryKey, { userId, nodePk }) {
const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs();
const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, { name: 'X25519' }, true, []);
@@ -342,31 +358,33 @@ async function generateNodeIdentity(bundleKey, recoveryKey) {
skEdB64: b64(skEdRaw),
skXB64: b64(skXRaw),
pkXB64: b64(pkXBytes),
- bundleEnc: await encryptBundleWithKey(skEdRaw, skXRaw, bundleKey.v2 || bundleKey),
+ bundleEnc: await encryptBundle(skEdRaw, skXRaw, await nodeBundleKey(sessionKey, nodePk),
+ { userId, nodePk, pepperVersion: sessionKey.pepperVersion }),
};
if (recoveryKey) {
- out.bundleEncRecovery = await encryptBundleWithKey(skEdRaw, skXRaw, recoveryKey);
+ out.bundleEncRecovery = await encryptBundle(skEdRaw, skXRaw, recoveryKey,
+ { userId, nodePk, pepperVersion: 0 });
}
return out;
}
/**
- * 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).
+ * Open a bundle fetched from a node. A bundle in a retired format is refused
+ * with `code = 'bundle_format_retired'` — never opened, and never quietly
+ * replaced by a new identity: the caller says so.
*/
-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 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);
+async function decryptBundle(bundleB64, aesKey, { userId, nodePk }) {
+ if (bundleFormat(bundleB64) !== 'current') {
+ const err = new Error('bundle_format_retired');
+ err.code = 'bundle_format_retired';
+ throw err;
+ }
+ const raw = _b64bytes(bundleB64);
+ const off = BUNDLE_MAGIC.length + 1;
+ const plain = await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: raw.slice(off, off + 12),
+ additionalData: _bundleAad(userId, nodePk) },
+ aesKey, raw.slice(off + 12));
return JSON.parse(new TextDecoder().decode(plain));
}
@@ -408,12 +426,11 @@ async function loginAndRecover(username, password) {
const result = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
- // 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: {
- ...(await _bundleKeyPairFields(password, username)),
- v1: await deriveEncryptionKeyV1(password, username),
- },
+ // The pepper rides on the sign-in response, so this costs no extra call;
+ // it is folded into the key here and not kept.
+ bundleKey: await deriveBundleSessionKey(
+ password, username, _subOf(data.access_token),
+ data.bundle_pepper, data.bundle_pepper_version),
};
// Nothing else to recover at sign-in. Identity keys belong to a node, so they
@@ -435,28 +452,13 @@ async function signBytes(skEdPkcs8B64, message) {
return btoa(String.fromCharCode(...new Uint8Array(sig)));
}
-/**
- * `{ v2, v2hkdf }` — the two fields every `session.bundleKey` carries for the
- * current KDF. One helper because there are two places that build that object
- * and they must not drift: a `v2hkdf` missing from one of them is a playlist
- * store that silently does nothing on whichever sign-in path skipped it.
- */
-async function _bundleKeyPairFields(password, username) {
- const { aes, hkdf } = await deriveBundleKeys(password, username);
- return { v2: aes, v2hkdf: hkdf };
-}
-
window.MeshBayKeys = {
registerUser, loginAndRecover, generateNodeIdentity, generateKeypairs, signBytes,
- deriveAuthKey, decryptBundleWithKey, encryptBundleWithKey, bundleVersion,
- // Exposed for the passphrase change (docs/MESHBAY_DESIGN.md §3.6): re-wrapping a
- // node's identity bundle needs the old key (a {v2,v1} pair, since an old
- // bundle may be v1) to read it and the new v2 key to write it back.
- deriveEncryptionKey, deriveEncryptionKeyV1,
- // One Argon2 run, an AES handle and an HKDF handle. Whatever builds a
- // `session.bundleKey` uses this, so `v2hkdf` is never the field one sign-in
- // path forgot (docs/playlists.md §3.4).
- deriveBundleKeys, bundleKeyPairFields: _bundleKeyPairFields,
+ deriveAuthKey,
+ // 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, nodeBundleKey, fetchBundlePepper,
+ encryptBundle, decryptBundle, bundleFormat,
// Account recovery key (docs/MESHBAY_DESIGN.md §3.6).
generateRecoveryKey, deriveRecoveryKey,
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index e2363eb..3190f9d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -1241,6 +1241,8 @@ export default {
'hosts.refuse': "Ablehnen",
'hosts.online': "online",
+ 'group.bundle_format_retired': 'Dieser Node speichert Ihre Identität in einem Format, das diese Version nicht mehr liest. Bitten Sie den Betreiber, „meshbay-node member unpin" für Ihr Konto auszuführen und Ihnen einen neuen Einladungscode zu senden.',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': 'Die Gruppe „{name}" auf diesem Computer hosten und den Ordner {path} mit ihren Mitgliedern teilen?',
'native.folder_confirm': 'Den Ordner {path} mit den Mitgliedern einer auf diesem Computer gehosteten Gruppe teilen? Er wurde nicht in der Ordnerauswahl gewählt.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 2fdda50..ea31e81 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -1222,6 +1222,8 @@ export default {
'hosts.refuse': "Refuse",
'hosts.online': "online",
+ 'group.bundle_format_retired': 'This node holds your identity in a format this version no longer reads. Ask its operator to run “meshbay-node member unpin” for your account and send you a new invitation code.',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': 'Host the group "{name}" on this computer and share the folder {path} with its members?',
'native.folder_confirm': 'Share the folder {path} with the members of a group hosted on this computer? It was not chosen in the folder picker.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 498cba3..2621632 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -1235,6 +1235,8 @@ export default {
'hosts.refuse': "Rechazar",
'hosts.online': "en línea",
+ 'group.bundle_format_retired': 'Este node guarda su identidad en un formato que esta versión ya no lee. Pida a su operador que ejecute «meshbay-node member unpin» para su cuenta y le envíe un nuevo código de invitación.',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': '¿Alojar el grupo «{name}» en este ordenador y compartir la carpeta {path} con sus miembros?',
'native.folder_confirm': '¿Compartir la carpeta {path} con los miembros de un grupo alojado en este ordenador? No se eligió en el selector de carpetas.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index c62ed98..bdb89bd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -1250,6 +1250,8 @@ export default {
'hosts.refuse': "Refuser",
'hosts.online': "en ligne",
+ 'group.bundle_format_retired': 'Ce node détient votre identité dans un format que cette version ne lit plus. Demandez à son opérateur d\'exécuter « meshbay-node member unpin » pour votre compte et de vous envoyer un nouveau code d\'invitation.',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': 'Héberger le groupe « {name} » sur cet ordinateur et partager le dossier {path} avec ses membres ?',
'native.folder_confirm': 'Partager le dossier {path} avec les membres d\'un groupe hébergé sur cet ordinateur ? Il n\'a pas été choisi dans le sélecteur de dossier.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 9f1d9f6..f87df9a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -1249,6 +1249,8 @@ export default {
'hosts.refuse': "Rifiuta",
'hosts.online': "online",
+ 'group.bundle_format_retired': 'Questo node conserva la tua identità in un formato che questa versione non legge più. Chiedi al suo operatore di eseguire «meshbay-node member unpin» per il tuo account e di inviarti un nuovo codice di invito.',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': 'Ospitare il gruppo «{name}» su questo computer e condividere la cartella {path} con i suoi membri?',
'native.folder_confirm': 'Condividere la cartella {path} con i membri di un gruppo ospitato su questo computer? Non è stata scelta nel selettore di cartelle.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index a4bb5ca..3ca369f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -1233,6 +1233,8 @@ export default {
'hosts.refuse': "拒否",
'hosts.online': "オンライン",
+ 'group.bundle_format_retired': 'この node は、このバージョンでは読めなくなった形式であなたの ID を保持しています。運用者に、あなたのアカウントに対して「meshbay-node member unpin」を実行し、新しい招待コードを送るよう依頼してください。',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': 'このコンピューターでグループ「{name}」をホストし、フォルダー {path} をメンバーと共有しますか?',
'native.folder_confirm': 'このコンピューターでホストしているグループのメンバーとフォルダー {path} を共有しますか?このフォルダーはフォルダー選択画面で選ばれたものではありません。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 2fdf236..a733fde 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -1251,6 +1251,8 @@ export default {
'hosts.refuse': "Weigeren",
'hosts.online': "online",
+ 'group.bundle_format_retired': 'Deze node bewaart je identiteit in een formaat dat deze versie niet meer leest. Vraag de beheerder om "meshbay-node member unpin" voor je account uit te voeren en je een nieuwe uitnodigingscode te sturen.',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': 'De groep "{name}" op deze computer hosten en de map {path} met de leden delen?',
'native.folder_confirm': 'De map {path} delen met de leden van een groep die op deze computer wordt gehost? Hij is niet gekozen in de mapkiezer.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 0530b79..2537bc1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -1277,6 +1277,8 @@ export default {
'hosts.refuse': "Odrzuć",
'hosts.online': "online",
+ 'group.bundle_format_retired': 'Ten node przechowuje Twoją tożsamość w formacie, którego ta wersja już nie odczytuje. Poproś jego operatora o uruchomienie „meshbay-node member unpin" dla Twojego konta i przesłanie nowego kodu zaproszenia.',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': 'Hostować grupę „{name}" na tym komputerze i udostępnić jej członkom folder {path}?',
'native.folder_confirm': 'Udostępnić folder {path} członkom grupy hostowanej na tym komputerze? Nie został wybrany w oknie wyboru folderu.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index d2be432..73d3e21 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -1236,6 +1236,8 @@ export default {
'hosts.refuse': "Recusar",
'hosts.online': "online",
+ 'group.bundle_format_retired': 'Este node guarda sua identidade em um formato que esta versão não lê mais. Peça ao operador que execute "meshbay-node member unpin" para sua conta e envie um novo código de convite.',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': 'Hospedar o grupo "{name}" neste computador e compartilhar a pasta {path} com os membros?',
'native.folder_confirm': 'Compartilhar a pasta {path} com os membros de um grupo hospedado neste computador? Ela não foi escolhida no seletor de pastas.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index c994780..f0440b8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -1222,6 +1222,8 @@ export default {
'hosts.refuse': "拒绝",
'hosts.online': "在线",
+ 'group.bundle_format_retired': '此 node 以本版本不再读取的格式保存您的身份。请其运营者为您的账户运行“meshbay-node member unpin”,并向您发送新的邀请码。',
+
// Worded by the desktop main process for its own dialogs (main.js).
'native.attach_confirm': '在这台电脑上托管群组“{name}”,并与其成员共享文件夹 {path}?',
'native.folder_confirm': '与这台电脑上托管的群组成员共享文件夹 {path}?该文件夹不是在文件夹选择器中选择的。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js
index 0f41d1e..6323b96 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/playlist-crypto.js
@@ -47,21 +47,23 @@ const PAD_TO = 4096;
const NONCE_BYTES = 12;
/**
- * The playlist key, from the HKDF handle over the bundle key.
+ * The playlist key, from the session's bundle master key `M`
+ * (keyderive.js `deriveBundleSessionKey`).
*
- * A purpose-separated subkey rather than the bundle key reused with a different
+ * A purpose-separated subkey rather than a bundle key reused with a different
* AAD — the rule `groupbox.py` writes down for chunk keys, for the same reason.
- * v2 only: playlists are new, so there is no legacy blob and no v1 branch to
- * take by mistake.
+ * `v2`: the v1 key came from the passphrase alone, so a blob sealed under it
+ * was a second offline oracle for the passphrase on every node. Such a blob no
+ * longer opens, and the local copy is sealed again over it (playlists.js).
*/
-async function derivePlaylistKey(hkdfHandle) {
+async function derivePlaylistKey(masterKey) {
return crypto.subtle.deriveKey(
{
name: 'HKDF', hash: 'SHA-256',
salt: new Uint8Array(0),
- info: new TextEncoder().encode('meshbay:playlists:v1'),
+ info: new TextEncoder().encode('meshbay:playlists:v2'),
},
- hkdfHandle, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
+ masterKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}
/**
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js
index bac9b3d..eec94e8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/playlists.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/playlists.js
@@ -92,14 +92,14 @@ let _key = null;
let _keyFor = null;
/**
- * The playlist key, derived once per sign-in from the HKDF handle that rides
- * alongside the bundle key (`keyderive.js`'s deriveBundleKeys).
+ * The playlist key, derived once per sign-in from the session's bundle master
+ * key (`keyderive.js`'s deriveBundleSessionKey).
*
- * `v2hkdf` is absent when this browser's session predates it — a stored bundle
- * key from before the change, loaded out of IndexedDB. There is nothing to do
- * about that here and nothing to fall back to: the passphrase is not in memory
- * to re-derive from. Playlists stay local until the next sign-in, which is a
- * degradation rather than a failure and is reported as one.
+ * Absent when this browser holds no such key — a session stored before the
+ * pepper, whose key `_loadBundleKey` no longer returns. There is nothing to
+ * fall back to: the passphrase is not in memory to re-derive from. Playlists
+ * stay local until the passphrase is entered again, which is a degradation
+ * rather than a failure and is reported as one.
*/
async function playlistKey(userId) {
if (_key && _keyFor === userId) return _key;
@@ -108,8 +108,8 @@ async function playlistKey(userId) {
bundleKey = await _loadBundleKey();
if (bundleKey) session.bundleKey = bundleKey;
}
- if (!bundleKey || !bundleKey.v2hkdf) return null;
- _key = await derivePlaylistKey(bundleKey.v2hkdf);
+ if (!bundleKey || !bundleKey.v3) return null;
+ _key = await derivePlaylistKey(bundleKey.v3);
_keyFor = userId;
return _key;
}
@@ -574,8 +574,8 @@ async function syncWith(transport, userId) {
}
const key = await playlistKey(userId);
if (!key) {
- // No HKDF handle: a session from before it existed. Nothing to fall back
- // to, and silently doing nothing would be the worse answer.
+ // No bundle key in this browser (a session from before the pepper). Nothing
+ // to fall back to, and silently doing nothing would be the worse answer.
result.reason = 'no_key';
return result;
}
@@ -597,6 +597,7 @@ async function syncWith(transport, userId) {
}
let theirs = null;
+ let unreadableManifest = false;
if (have.has(MANIFEST_KIND)) {
let row = null;
try {
@@ -620,6 +621,7 @@ async function syncWith(transport, userId) {
console.warn('[MeshBay] playlist manifest on this node will not open:',
err.message, '— overwriting it with the local copy');
result.unreadable = true;
+ unreadableManifest = true;
theirs = null;
}
}
@@ -647,8 +649,15 @@ async function syncWith(transport, userId) {
const kind = bodyKind(p.id);
const nodeRev = have.has(kind) ? (have.get(kind) || 0) : -1;
const localRev = local.rev || 0;
+ // A body that will not open is not a newer copy of anything, and the local
+ // copy goes over it now — at a revision no lower than the node's, so every
+ // device still sees the node as current. Waiting for the next write left
+ // it unreadable indefinitely whenever the revisions happened to be equal,
+ // which after the playlist key changed is every body on every node. A
+ // manifest that would not open says the same of every body behind it.
+ let overwrite = unreadableManifest && have.has(kind);
- if (nodeRev > localRev) {
+ if (nodeRev > localRev && !overwrite) {
try {
const row = await transport.fetchUserBlob(kind);
if (row && row.blob_enc) {
@@ -660,12 +669,17 @@ async function syncWith(transport, userId) {
}
}
} catch {
- // Same reasoning as the manifest above, and already the right shape:
- // one body that will not open must not stop the rest, and the local
- // copy is pushed over it on the next write to that playlist.
+ // Same reasoning as the manifest above: one body that will not open
+ // must not stop the rest.
result.unreadable = true;
+ overwrite = true;
}
- } else if (localRev > nodeRev && localRev > 0) {
+ }
+ if ((overwrite || localRev > nodeRev) && localRev > 0) {
+ const pushRev = Math.max(localRev, nodeRev);
+ // The local copy takes the revision it is pushed under, or the next sync
+ // would see the node ahead and fetch it back every time.
+ if (pushRev > localRev) await _saveBody(st, { ...local, rev: pushRev });
// Was: one `catch {}` covering both of the cases below. A node that went
// away mid-sweep and a playlist that can never be sent are not the same
// event, and swallowing the second is the silent loss this whole design
@@ -673,7 +687,7 @@ async function syncWith(transport, userId) {
// stopped leaving the browser, and nothing anywhere says so.
let sealed;
try {
- sealed = await seal(local, kind, userId, key);
+ sealed = await seal({ ...local, rev: pushRev }, kind, userId, key);
} catch {
result.failed.push(p.name);
continue;
@@ -683,7 +697,7 @@ async function syncWith(transport, userId) {
continue;
}
try {
- await transport.storeUserBlob(kind, localRev, sealed);
+ await transport.storeUserBlob(kind, pushRev, sealed);
result.pushed += 1;
} catch {
// A node that went away mid-sweep: the next sync pushes this, because
@@ -704,6 +718,17 @@ async function syncWith(transport, userId) {
try { await transport.deleteUserBlob(kind); } catch { /* next time round */ }
}
+ // Behind a manifest that would not open, a body this browser has no playlist
+ // for is unreadable and unknown: nothing can merge it, and it only fills the
+ // account's quota on this node.
+ if (unreadableManifest) {
+ const known = new Set(Object.keys(merged.playlists).map(bodyKind));
+ for (const kind of have.keys()) {
+ if (kind === MANIFEST_KIND || known.has(kind)) continue;
+ try { await transport.deleteUserBlob(kind); } catch { /* next time round */ }
+ }
+ }
+
// The manifest goes last, so a node never advertises a body it has not been
// given: a reader on a third device would fetch a watermark, ask for the
// body behind it and be told there is none.
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
index d3d06d7..7a309a9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/profile-page.js
@@ -141,10 +141,18 @@ export function ProfilePage({ user, onLogout }) {
try {
// Re-wrap every reachable node's identity bundle first — if this cannot
// run at all the account is left untouched.
+ // Both keys from the passphrases, with the pepper this open session asks
+ // for: the old one opens the bundles as they are, the new one seals them.
+ const K = window.MeshBayKeys;
+ const { pepper, version } = await K.fetchBundlePepper(user.token);
+ const oldKey = await K.deriveBundleSessionKey(
+ cpOld, user.username, user.userId, pepper, version);
+ const newKey = await K.deriveBundleSessionKey(
+ cpNew, user.username, user.userId, pepper, version);
const result = await window.MeshBayTransport.rewrapAllNodes({
hubUrl: HUB, token: user.token,
username: user.username, userId: user.userId,
- oldPassphrase: cpOld, newPassphrase: cpNew,
+ bundleKey: oldKey, newBundleKey: newKey,
onProgress: setCpProgress,
});
@@ -158,8 +166,8 @@ export function ProfilePage({ user, onLogout }) {
// Keep this tab signed in with the fresh pair, and move the session's
// bundle key forward so the next node connection opens the new bundles.
setAuth({ ...user, token: resp.access_token, refreshToken: resp.refresh_token });
- session.bundleKey = result.newBundleKey;
- _storeBundleKey(result.newBundleKey);
+ session.bundleKey = newKey;
+ _storeBundleKey(newKey);
setCpResult(result);
setCpPhase('done');
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
index 18ad9d4..a9229dc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js
@@ -43,8 +43,11 @@ function _acWithTimeout(promise, ms, label) {
* @param {string} o.token a fresh access token
* @param {string} o.username
* @param {string} o.userId
- * @param {string} [o.oldPassphrase] omit in Flow B — connect falls back to the recovery copy
- * @param {string} o.newPassphrase
+ * @param {object} o.bundleKey the session key that opens the bundles as they are
+ * (keyderive.js `deriveBundleSessionKey`). In Flow B it
+ * opens nothing and connect falls back to the recovery copy.
+ * @param {object} [o.newBundleKey] the key to seal them under; defaults to `bundleKey`
+ * (the Profile backfill: same key, a recovery copy added)
* @param {string} [o.recoveryKey] the recovery mnemonic (Flow B,
* docs/MESHBAY_DESIGN.md §3.6).
* When given, the recovery-wrapped copy is read where the
@@ -54,28 +57,14 @@ function _acWithTimeout(promise, ms, label) {
*/
async function rewrapAllNodes(o) {
const K = window.MeshBayKeys;
- if (!K || !K.deriveEncryptionKey) {
+ if (!K || !K.encryptBundle) {
throw new Error('key module unavailable');
}
- let oldKey, newKey;
- if (o.bundleKey) {
- // "Keep the current passphrase key, just add / refresh the recovery copy"
- // — the Profile backfill (docs/MESHBAY_DESIGN.md §3.6). `o.bundleKey` is the
- // live {v2,v1} session key, so no passphrase is needed.
- oldKey = newKey = o.bundleKey;
- } else {
- // Flow B has no old passphrase; connect will fail the passphrase decrypt and
- // fall back to the recovery copy, so a placeholder key is fine for `oldKey`.
- const oldPass = o.oldPassphrase || o.newPassphrase;
- oldKey = {
- v2: await K.deriveEncryptionKey(oldPass, o.username),
- v1: await K.deriveEncryptionKeyV1(oldPass, o.username),
- };
- newKey = {
- v2: await K.deriveEncryptionKey(o.newPassphrase, o.username),
- v1: await K.deriveEncryptionKeyV1(o.newPassphrase, o.username),
- };
- }
+ if (!o.bundleKey) throw new Error('no bundle key in this session');
+ // Keys, never passphrases: each caller has derived them already, with the
+ // pepper only the hub holds (docs/MESHBAY_DESIGN.md §3.7).
+ const oldKey = o.bundleKey;
+ const newKey = o.newBundleKey || o.bundleKey;
const recoveryKey = o.recoveryKey
? await K.deriveRecoveryKey(o.recoveryKey, o.username)
: null;
@@ -125,11 +114,15 @@ async function rewrapAllNodes(o) {
if (!sk) { lastErr = new Error('identity not recovered'); continue; }
const skEd = Uint8Array.from(atob(sk.skEdB64), c => c.charCodeAt(0));
const skX = Uint8Array.from(atob(sk.skXB64), c => c.charCodeAt(0));
- const reEnc = await K.encryptBundleWithKey(skEd, skX, newKey.v2);
+ // Sealed for this account on the node just connected to — the key
+ // that node proved during the handshake.
+ const sealedFor = { userId: o.userId, nodePk: tp.nodePk };
+ const reEnc = await K.encryptBundle(skEd, skX, await K.nodeBundleKey(newKey, tp.nodePk),
+ { ...sealedFor, pepperVersion: newKey.pepperVersion });
// In Flow B, refresh the recovery copy too (same R) so the node's
// passphrase copy and recovery copy stay in step.
const reRecovery = recoveryKey
- ? await K.encryptBundleWithKey(skEd, skX, recoveryKey)
+ ? await K.encryptBundle(skEd, skX, recoveryKey, { ...sealedFor, pepperVersion: 0 })
: null;
await tp.storeKeypairBundle(reEnc, reRecovery);
anyOk = true;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 04c2d23..b504a28 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -966,15 +966,31 @@ class MeshBayTransport {
// that opens nothing anywhere else.
let fresh = false;
if (!this._sessionKeys && this._bundleKey && window.MeshBayKeys) {
+ const K = window.MeshBayKeys;
+ // A bundle is sealed for this account on this node — the key the node
+ // just proved above, and no other.
+ const sealedFor = { userId: this._userId, nodePk: this.nodePk };
const kpResp = await this._sendAndWait({
type: 'keypair_bundle_fetch', v: '0.1',
});
let keys = null;
let openErr = null;
+ if (kpResp.type === 'keypair_bundle_resp' && kpResp.found
+ && K.bundleFormat(kpResp.bundle_enc) === 'retired') {
+ // Sealed under the passphrase alone, before the pepper. Not opened,
+ // and not replaced by a new identity behind the member's back: that
+ // would leave the node pinning a key nobody holds. The operator
+ // unpins them (which drops this bundle) and sends a new code.
+ const err = new Error('This node holds your identity in a format this version '
+ + 'no longer reads. Ask its operator to run "meshbay-node member unpin" '
+ + 'for your account and send you a new invitation code.');
+ err.reason = 'bundle_format_retired';
+ throw err;
+ }
if (kpResp.type === 'keypair_bundle_resp' && kpResp.found) {
try {
- keys = await window.MeshBayKeys.decryptBundleWithKey(
- kpResp.bundle_enc, this._bundleKey);
+ keys = await K.decryptBundle(kpResp.bundle_enc,
+ await K.nodeBundleKey(this._bundleKey, this.nodePk), sealedFor);
} catch (e) {
openErr = e;
// The passphrase key did not open the bundle. If we hold a recovery
@@ -983,8 +999,8 @@ class MeshBayTransport {
// passphrase, before re-wrapping it under the new one.
if (this._recoveryKey && kpResp.bundle_enc_recovery) {
try {
- keys = await window.MeshBayKeys.decryptBundleWithKey(
- kpResp.bundle_enc_recovery, this._recoveryKey);
+ keys = await K.decryptBundle(
+ kpResp.bundle_enc_recovery, this._recoveryKey, sealedFor);
this._recoveredFromRecovery = true;
} catch { /* recovery copy did not open either */ }
}
@@ -1014,8 +1030,8 @@ class MeshBayTransport {
// behind). Mint a fresh identity and let the join path take over; a
// successful join overwrites whatever was stored. A recovery-wrapped
// copy is left too when a recovery key is in hand (§4.3).
- const id = await window.MeshBayKeys.generateNodeIdentity(
- this._bundleKey, this._recoveryKey);
+ const id = await K.generateNodeIdentity(
+ this._bundleKey, this._recoveryKey, sealedFor);
this._sessionKeys = {
skEdB64: id.skEdB64, skXB64: id.skXB64, pkXB64: id.pkXB64,
};
diff --git a/packages/meshbay-hub/tests/harness/playlist_store_probe.py b/packages/meshbay-hub/tests/harness/playlist_store_probe.py
index 9a54a0d..857cf1e 100755
--- a/packages/meshbay-hub/tests/harness/playlist_store_probe.py
+++ b/packages/meshbay-hub/tests/harness/playlist_store_probe.py
@@ -80,15 +80,15 @@ function fakeNode() {
(async () => {
const fail = (why) => parent.postMessage({ error: why, logs: LOGS.slice(0, 10) }, '*');
try {
- // A real HKDF handle over fixed bytes, as `deriveBundleKeys` would produce
- // — the point is that playlists.js gets its key the way it really does.
+ // A real master key over fixed bytes, the shape `deriveBundleSessionKey`
+ // produces — the point is that playlists.js gets its key the way it
+ // really does.
const raw = new Uint8Array(32).fill(5);
session.bundleKey = {
- v2: await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false,
- ['encrypt', 'decrypt']),
- v2hkdf: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']),
+ v3: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey', 'deriveBits']),
+ pepperVersion: 1,
};
- const key = await derivePlaylistKey(session.bundleKey.v2hkdf);
+ const key = await derivePlaylistKey(session.bundleKey.v3);
// ── local editing ──────────────────────────────────────────────────────
const eveningId = await P.createPlaylist(USER, 'Soirée');
@@ -364,6 +364,39 @@ function fakeNode() {
names: readBack ? Object.values(readBack.playlists).map((p) => p.name).sort() : [],
});
+ // ── a node sealed under the previous playlist key ─────────────────────
+ //
+ // The key changed, not the playlists: every row on such a node carries the
+ // revision the local copy has, so "push only when the node is behind"
+ // would leave all of it unreadable for ever. Built from what a clean sync
+ // stores, then every row resealed under another key, plus a body for a
+ // playlist this browser has never heard of.
+ const clean = fakeNode();
+ await P.syncWith(clean, USER);
+ const oldKeyNode = fakeNode();
+ for (const [kind, r] of clean.rows) {
+ oldKeyNode.rows.set(kind, { rev: r.rev, blob: await seal(
+ kind === MANIFEST_KIND ? { v: 1, rev: r.rev, playlists: {} }
+ : { id: kind, rev: r.rev, tracks: [] },
+ kind, USER, junkKey) });
+ }
+ oldKeyNode.rows.set(bodyKind('never-seen-here'), { rev: 3, blob: await seal(
+ { id: 'never-seen-here', rev: 3, tracks: [] }, bodyKind('never-seen-here'), USER, junkKey) });
+ const rekeyResult = await P.syncWith(oldKeyNode, USER);
+ const readable = [];
+ for (const [kind, r] of oldKeyNode.rows) {
+ try { await open(r.blob, kind, USER, key); readable.push(kind); } catch { /* not */ }
+ }
+ steps.push({
+ step: 'a node sealed under the previous key',
+ result: rekeyResult,
+ kinds: [...clean.rows.keys()].sort(),
+ readable: readable.sort(),
+ remaining: [...oldKeyNode.rows.keys()].sort(),
+ revsNotLowered: [...clean.rows].every(([k, r]) =>
+ (oldKeyNode.rows.get(k) || { rev: -1 }).rev >= r.rev),
+ });
+
// ── what a playlist costs, sealed ─────────────────────────────────────
//
// The cap below is in bytes, but the only number a reader can act on is a
@@ -449,12 +482,12 @@ function fakeNode() {
// playlist is not a broken sync.
stored: bigNode.stored.map((e) => ({ kind: e.kind, bytes: e.bytes })) });
- // ── a session with no HKDF handle degrades rather than failing ─────────
+ // ── a session with no bundle key degrades rather than failing ──────────
P.setPlaylistTransport(null);
P.forgetPlaylistKey();
- session.bundleKey = { v2: session.bundleKey.v2 }; // pre-change session
+ session.bundleKey = { v2: 'a key from before the pepper' }; // pre-change session
const r3 = await P.syncWith(fakeNode(), USER);
- steps.push({ step: 'a session from before the HKDF handle', result: r3 });
+ steps.push({ step: 'a session from before the pepper', result: r3 });
parent.postMessage({ steps, logs: LOGS.slice(0, 8) }, '*');
} catch (err) {
diff --git a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
index c705244..6e3be1e 100644
--- a/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
+++ b/packages/meshbay-hub/tests/harness/playlist_ui_probe.py
@@ -173,12 +173,11 @@ const clickMenu = async (i) => {
try {
await initLocale();
- // A real HKDF handle, so the store derives its key the way it really does.
+ // A real master key, so the store derives its key the way it really does.
const raw = new Uint8Array(32).fill(3);
session.bundleKey = {
- v2: await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false,
- ['encrypt', 'decrypt']),
- v2hkdf: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey']),
+ v3: await crypto.subtle.importKey('raw', raw, 'HKDF', false, ['deriveKey', 'deriveBits']),
+ pepperVersion: 1,
};
// Deleting a playlist asks, in the page (ask.js) — so the probe answers the
// dialog the way a person would, by clicking its OK button.
diff --git a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py
index c62aace..a4bee43 100644
--- a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py
+++ b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py
@@ -129,3 +129,105 @@ def test_parameters_still_match_the_client():
assert f"ARGON2_TIME = {TIME_COST}" in source
assert f"ARGON2_LANES = {LANES}" in source
assert "meshbay:bundle:v2:" in source
+
+
+# ── The whole chain: A, the pepper, M, the node key, the playlist key ─────────
+#
+# The real keyderive.js and playlist-crypto.js, over the real WebAssembly
+# Argon2, against a reference written from the specification with nothing
+# shared: argon2-cffi, `cryptography`'s HKDF and AES-GCM. Down to opening an
+# MBK3 bundle, so the format and its associated data agree too.
+
+_CHAIN_HARNESS = r"""
+const fs = require('fs'), path = require('path'), url = require('url');
+const webcrypto = require('crypto').webcrypto;
+global.self = global; global.window = global; global.crypto = webcrypto;
+global.Module = { wasmBinary: fs.readFileSync(process.argv[2]) };
+global.argon2 = require(process.argv[3]);
+eval(fs.readFileSync(process.argv[4], 'utf8'));
+const K = window.MeshBayKeys;
+const fp = async (key) => Buffer.from(await webcrypto.subtle.encrypt(
+ { name: 'AES-GCM', iv: new Uint8Array(12) }, key, new Uint8Array(16))).toString('hex');
+(async () => {
+ const { derivePlaylistKey } = await import(url.pathToFileURL(process.argv[5]).href);
+ const out = [];
+ for (const v of JSON.parse(fs.readFileSync(process.argv[6], 'utf8'))) {
+ const sk = await K.deriveBundleSessionKey(v.password, v.username, v.user_id, v.pepper, 1);
+ const kNode = await K.nodeBundleKey(sk, v.node_pk);
+ const bundle = await K.encryptBundle(
+ Buffer.from('ed-private'), Buffer.from('x-private'), kNode,
+ { userId: v.user_id, nodePk: v.node_pk, pepperVersion: 1 });
+ out.push({ node: await fp(kNode), playlists: await fp(await derivePlaylistKey(sk.v3)),
+ bundle });
+ }
+ process.stdout.write(JSON.stringify(out));
+})().catch((e) => { console.error(e); process.exit(1); });
+"""
+
+CHAIN = [
+ {"username": "alice", "password": "correct horse battery staple",
+ "user_id": "0b4f6f0e-5d7e-4e8a-9d2b-6a1c1b9e2f11", "node_pk": "Tm9kZUtleUE="},
+ {"username": "utilisateur-é", "password": "üñïçø∂é ✓ 🔐",
+ "user_id": "7d1e0c2a-3b4c-4d5e-8f60-718293a4b5c6", "node_pk": "Tm9kZUtleUI="},
+]
+
+
+def _hkdf(ikm: bytes, info: str) -> bytes:
+ from cryptography.hazmat.primitives.hashes import SHA256
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+ return HKDF(algorithm=SHA256(), length=32, salt=None, info=info.encode()).derive(ikm)
+
+
+def _fp(key: bytes) -> str:
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+ return AESGCM(key).encrypt(bytes(12), bytes(16), None).hex()
+
+
+@pytest.fixture(scope="module")
+def chain(tmp_path_factory):
+ import base64
+ d = tmp_path_factory.mktemp("chain")
+ vectors = [{**v, "pepper": base64.b64encode(bytes([i + 1]) * 32).decode()}
+ for i, v in enumerate(CHAIN)]
+ (d / "harness.cjs").write_text(_CHAIN_HARNESS, encoding="utf-8")
+ (d / "vectors.json").write_text(json.dumps(vectors), encoding="utf-8")
+ proc = subprocess.run(
+ ["node", str(d / "harness.cjs"), str(VENDOR / "argon2.wasm"),
+ str(VENDOR / "argon2.min.js"), str(STATIC / "keyderive.js"),
+ str(STATIC / "playlist-crypto.js"), str(d / "vectors.json")],
+ capture_output=True, text=True, encoding="utf-8", timeout=300)
+ if proc.returncode != 0:
+ pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}")
+ return vectors, json.loads(proc.stdout)
+
+
+def _reference(v: dict) -> tuple[bytes, bytes]:
+ import base64
+ a = bytes.fromhex(_python_hash(v["username"], v["password"]))
+ m = _hkdf(a + base64.b64decode(v["pepper"]), f"meshbay:bundle-master:v3|{v['user_id']}")
+ return (_hkdf(m, f"meshbay:bundle:v3|node|{v['node_pk']}"),
+ _hkdf(m, "meshbay:playlists:v2"))
+
+
+def test_the_node_and_playlist_keys_match_across_languages(chain):
+ vectors, js = chain
+ for v, got in zip(vectors, js):
+ k_node, k_pl = _reference(v)
+ assert got["node"] == _fp(k_node), f"node key disagrees for {v['username']!r}"
+ assert got["playlists"] == _fp(k_pl), f"playlist key disagrees for {v['username']!r}"
+
+
+def test_an_mbk3_bundle_opens_from_the_specification(chain):
+ """Magic, pepper version, nonce, AES-GCM with the account and node as
+ associated data — read back by code that shares nothing with the writer."""
+ import base64
+
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+ vectors, js = chain
+ for v, got in zip(vectors, js):
+ raw = base64.b64decode(got["bundle"])
+ assert raw[:4] == b"MBK3" and raw[4] == 1
+ aad = f"meshbay:bundle:v3|{v['user_id']}|{v['node_pk']}".encode()
+ plain = json.loads(AESGCM(_reference(v)[0]).decrypt(raw[5:17], raw[17:], aad))
+ assert base64.b64decode(plain["skEd"]) == b"ed-private"
+ assert base64.b64decode(plain["skX"]) == b"x-private"
diff --git a/packages/meshbay-hub/tests/test_bundle_key.py b/packages/meshbay-hub/tests/test_bundle_key.py
new file mode 100644
index 0000000..333f8f8
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_bundle_key.py
@@ -0,0 +1,183 @@
+"""
+The bundle key: one Argon2 run, the hub's pepper, one key per node.
+
+What a node stores — an identity bundle, a playlist blob — is sealed under keys
+derived from `M = HKDF(A ‖ pepper, account id)`, where `A` is the passphrase's
+Argon2id and the pepper is held by the hub (docs/MESHBAY_DESIGN.md §3.7). Each
+of these is quiet when wrong:
+
+ - **One Argon2id run per sign-in.** The budget is the ~650 ms already on that
+ path; a second run doubles it and nothing on screen says so.
+ - **The pepper and the account are in the key.** Without the pepper, the
+ operator holding a bundle can test passphrase guesses again.
+ - **One key per node, and a bundle bound to its node and account.** A leaked
+ node key, or a bundle copied elsewhere, opens nothing else.
+ - **The playlist key is the same on every device of one account**, and is not
+ any node's key.
+ - **An earlier format is refused, by name** — never opened, never guessed at.
+
+Node's WebCrypto is the real implementation here; only Argon2 is stubbed, and
+stubbed precisely so the calls can be counted.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+KEYDERIVE = STATIC / "keyderive.js"
+PLAYLIST_CRYPTO = STATIC / "playlist-crypto.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not KEYDERIVE.exists(),
+ reason="node or the SPA sources are not available")
+
+# keyderive.js assigns `window.MeshBayKeys` and reads `window.argon2`; node has
+# neither, and a counted stub is the whole point.
+PRELUDE = """
+globalThis.window = globalThis;
+let argonCalls = 0;
+globalThis.argon2 = {
+ ArgonType: { Argon2id: 2 },
+ async hash(opts) {
+ argonCalls++;
+ // Deterministic, and a function of what was actually passed, so a changed
+ // salt domain or cost parameter shows up as different bytes rather than
+ // silently agreeing.
+ const seed = new TextEncoder().encode(
+ opts.pass + ':' + Array.from(opts.salt).join(',') + ':' + opts.time);
+ const digest = new Uint8Array(
+ await crypto.subtle.digest('SHA-256', seed));
+ return { hash: digest };
+ },
+};
+"""
+
+
+def _run(tmp_path, body):
+ src = KEYDERIVE.read_text(encoding="utf-8")
+ script = tmp_path / "case.mjs"
+ helpers = (
+ "const K = () => window.MeshBayKeys;\n"
+ "const PEPPER = btoa(String.fromCharCode(...new Uint8Array(32).fill(7)));\n"
+ "const OTHER_PEPPER = btoa(String.fromCharCode(...new Uint8Array(32).fill(9)));\n"
+ "// Same key <=> same bytes out of a fixed encryption.\n"
+ "const fp = async (key) => btoa(String.fromCharCode(...new Uint8Array(\n"
+ " await crypto.subtle.encrypt({ name: 'AES-GCM', iv: new Uint8Array(12) }, key,\n"
+ " new Uint8Array(16)))));\n"
+ f"const {{ derivePlaylistKey }} = await import('{PLAYLIST_CRYPTO.as_uri()}');\n"
+ )
+ script.write_text(f"{PRELUDE}\n{src}\n{helpers}\n{body}\n", encoding="utf-8")
+ out = subprocess.run(["node", str(script)],
+ capture_output=True, text=True, encoding="utf-8", timeout=60)
+ assert out.returncode == 0, out.stderr
+ return json.loads(out.stdout)
+
+
+def test_a_sign_in_runs_argon2_exactly_once(tmp_path):
+ out = _run(tmp_path, """
+ argonCalls = 0;
+ const key = await K().deriveBundleSessionKey('passphrase', 'someone', 'uid-1', PEPPER, 1);
+ console.log(JSON.stringify({
+ calls: argonCalls, alg: key.v3.algorithm.name,
+ extractable: key.v3.extractable, version: key.pepperVersion,
+ }));
+ """)
+ assert out["calls"] == 1, "a second Argon2id run doubles the sign-in cost"
+ assert out["alg"] == "HKDF" and out["extractable"] is False
+ assert out["version"] == 1
+
+
+def test_without_the_pepper_there_is_no_key(tmp_path):
+ out = _run(tmp_path, """
+ let refused = false;
+ try { await K().deriveBundleSessionKey('p', 'someone', 'uid-1', null, 1); }
+ catch { refused = true; }
+ console.log(JSON.stringify({ refused }));
+ """)
+ assert out["refused"], "a key derived from the passphrase alone is what a node could attack"
+
+
+def test_the_pepper_and_the_account_are_part_of_the_key(tmp_path):
+ out = _run(tmp_path, """
+ const node = async (pepper, uid) => fp(await K().nodeBundleKey(
+ await K().deriveBundleSessionKey('same passphrase', 'someone', uid, pepper, 1), 'NODE'));
+ const base = await node(PEPPER, 'uid-1');
+ console.log(JSON.stringify({
+ again: base === await node(PEPPER, 'uid-1'),
+ other_pepper: base !== await node(OTHER_PEPPER, 'uid-1'),
+ other_account: base !== await node(PEPPER, 'uid-2'),
+ }));
+ """)
+ assert out == {"again": True, "other_pepper": True, "other_account": True}
+
+
+def test_a_bundle_opens_on_its_node_for_its_account_only(tmp_path):
+ out = _run(tmp_path, """
+ const sk = await K().deriveBundleSessionKey('p', 'someone', 'uid-1', PEPPER, 1);
+ const kA = await K().nodeBundleKey(sk, 'NODE-A');
+ const kB = await K().nodeBundleKey(sk, 'NODE-B');
+ const sealed = await K().encryptBundle(new Uint8Array([1]), new Uint8Array([2]), kA,
+ { userId: 'uid-1', nodePk: 'NODE-A', pepperVersion: 1 });
+ const opens = async (key, meta) => {
+ try { await K().decryptBundle(sealed, key, meta); return true; } catch { return false; }
+ };
+ console.log(JSON.stringify({
+ format: K().bundleFormat(sealed),
+ magic: atob(sealed).slice(0, 4), version: atob(sealed).charCodeAt(4),
+ right: await opens(kA, { userId: 'uid-1', nodePk: 'NODE-A' }),
+ other_node_key: await opens(kB, { userId: 'uid-1', nodePk: 'NODE-B' }),
+ moved_to_other_node: await opens(kA, { userId: 'uid-1', nodePk: 'NODE-B' }),
+ served_for_other_account: await opens(kA, { userId: 'uid-2', nodePk: 'NODE-A' }),
+ }));
+ """)
+ assert out["format"] == "current" and out["magic"] == "MBK3" and out["version"] == 1
+ assert out["right"] is True
+ assert out["other_node_key"] is False
+ assert out["moved_to_other_node"] is False
+ assert out["served_for_other_account"] is False
+
+
+def test_an_earlier_format_is_refused_by_name(tmp_path):
+ """Sealed under the passphrase alone. Never opened, and the refusal says why
+ — the caller must not take it for an absent bundle and mint a new one."""
+ out = _run(tmp_path, """
+ const sk = await K().deriveBundleSessionKey('p', 'someone', 'uid-1', PEPPER, 1);
+ const k = await K().nodeBundleKey(sk, 'NODE');
+ const results = [];
+ for (const old of [btoa('MBK2' + 'x'.repeat(40)), btoa('y'.repeat(44))]) {
+ let code = null;
+ try { await K().decryptBundle(old, k, { userId: 'uid-1', nodePk: 'NODE' }); }
+ catch (e) { code = e.code || null; }
+ results.push([K().bundleFormat(old), code]);
+ }
+ console.log(JSON.stringify(results));
+ """)
+ assert out == [["retired", "bundle_format_retired"], ["retired", "bundle_format_retired"]]
+
+
+def test_two_devices_of_one_account_derive_the_same_playlist_key(tmp_path):
+ """The one key an account must hold everywhere: node keys differ per node,
+ and a playlist is read from any of them."""
+ out = _run(tmp_path, """
+ const pl = async (uid) => fp(await derivePlaylistKey((await K().deriveBundleSessionKey(
+ 'same passphrase', 'someone', uid, PEPPER, 1)).v3));
+ const a = await pl('uid-1');
+ console.log(JSON.stringify({ same: a === await pl('uid-1'),
+ other_account: a !== await pl('uid-2') }));
+ """)
+ assert out == {"same": True, "other_account": True}
+
+
+def test_the_playlist_key_is_no_node_key(tmp_path):
+ out = _run(tmp_path, """
+ const sk = await K().deriveBundleSessionKey('p', 'someone', 'uid-1', PEPPER, 1);
+ console.log(JSON.stringify({
+ distinct: await fp(await derivePlaylistKey(sk.v3))
+ !== await fp(await K().nodeBundleKey(sk, 'NODE')),
+ }));
+ """)
+ assert out["distinct"]
diff --git a/packages/meshbay-hub/tests/test_bundle_pepper.py b/packages/meshbay-hub/tests/test_bundle_pepper.py
index e7b126f..e99799f 100644
--- a/packages/meshbay-hub/tests/test_bundle_pepper.py
+++ b/packages/meshbay-hub/tests/test_bundle_pepper.py
@@ -105,18 +105,6 @@ async def test_an_open_session_may_ask_and_a_node_may_not(client):
@pytest.mark.asyncio
-async def test_a_passphrase_change_carries_it(client):
- """The new passphrase makes a new bundle key, and the client does not keep
- the pepper to re-seal under it."""
- await _register(client, "pepper_chg")
- login = await _login(client, "pepper_chg")
- r = await client.post("/v1/users/password", headers=_bearer(login["access_token"]),
- json={"old_auth_key": KEY, "new_auth_key": "n" * 44})
- assert r.status_code == 200, r.text
- assert r.json()["bundle_pepper"] == login["bundle_pepper"]
-
-
-@pytest.mark.asyncio
async def test_it_is_sealed_at_rest_and_bound_to_its_account(client, db_session):
await _register(client, "pepper_rest")
login = await _login(client, "pepper_rest")
diff --git a/packages/meshbay-hub/tests/test_playlist_key.py b/packages/meshbay-hub/tests/test_playlist_key.py
deleted file mode 100644
index 261cc32..0000000
--- a/packages/meshbay-hub/tests/test_playlist_key.py
+++ /dev/null
@@ -1,221 +0,0 @@
-"""
-The playlist key: one Argon2 run, two handles, one subkey.
-
-Identity keys are per node, so a blob encrypted under one is unreadable from
-every other node — the precise opposite of what a playlist needs. The only
-secret an account holds *everywhere* is the bundle key, so the playlist key is
-derived from it with HKDF (docs/playlists.md §3.4).
-
-Three things have to hold, and getting any of them wrong is quiet:
-
- - **One Argon2id run per sign-in.** The budget is the ~650 ms already on that
- path. A second call is mathematically pointless and doubles it, and nothing
- on screen would say so.
- - **The HKDF handle is a second import of the same bytes**, not a derivation
- from the AES one — that is imported non-extractably with
- `['encrypt','decrypt']`, from which nothing can be derived at all.
- - **A purpose-separated subkey**, not the bundle key with a different AAD.
- `groupbox.py` writes that rule down for chunk keys; it is the same rule.
-
-Node's WebCrypto is the real implementation here; only Argon2 is stubbed, and
-stubbed precisely so the calls can be counted.
-"""
-
-import json
-import shutil
-import subprocess
-from pathlib import Path
-
-import pytest
-
-STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
-KEYDERIVE = STATIC / "keyderive.js"
-
-pytestmark = pytest.mark.skipif(
- shutil.which("node") is None or not KEYDERIVE.exists(),
- reason="node or the SPA sources are not available")
-
-# keyderive.js assigns `window.MeshBayKeys` and reads `window.argon2`; node has
-# neither, and a counted stub is the whole point.
-PRELUDE = """
-globalThis.window = globalThis;
-let argonCalls = 0;
-globalThis.argon2 = {
- ArgonType: { Argon2id: 2 },
- async hash(opts) {
- argonCalls++;
- // Deterministic, and a function of what was actually passed, so a changed
- // salt domain or cost parameter shows up as different bytes rather than
- // silently agreeing.
- const seed = new TextEncoder().encode(
- opts.pass + ':' + Array.from(opts.salt).join(',') + ':' + opts.time);
- const digest = new Uint8Array(
- await crypto.subtle.digest('SHA-256', seed));
- return { hash: digest };
- },
-};
-"""
-
-
-def _run(tmp_path, body):
- src = KEYDERIVE.read_text(encoding="utf-8")
- script = tmp_path / "case.mjs"
- script.write_text(f"{PRELUDE}\n{src}\n{body}\n", encoding="utf-8")
- out = subprocess.run(["node", str(script)],
- capture_output=True, text=True, encoding="utf-8", timeout=60)
- assert out.returncode == 0, out.stderr
- return json.loads(out.stdout)
-
-
-def test_a_sign_in_runs_argon2_exactly_once(tmp_path):
- """The budget is the 650 ms already on the sign-in path. Two handles over
- one run is the whole point of `deriveBundleKeys`."""
- out = _run(tmp_path, """
- argonCalls = 0;
- const keys = await deriveBundleKeys('passphrase', 'someone');
- console.log(JSON.stringify({
- calls: argonCalls,
- aes: keys.aes.algorithm.name,
- hkdf: keys.hkdf.algorithm.name,
- }));
- """)
- assert out["calls"] == 1, "a second Argon2id run doubles the sign-in cost"
- assert out["aes"] == "AES-GCM"
- assert out["hkdf"] == "HKDF"
-
-
-def test_the_bundle_key_fields_a_session_carries_are_built_in_one_run(tmp_path):
- """Both places that build a `session.bundleKey` go through this, so
- `v2hkdf` cannot be the field one sign-in path forgot."""
- out = _run(tmp_path, """
- argonCalls = 0;
- const fields = await window.MeshBayKeys.bundleKeyPairFields('p', 'someone');
- console.log(JSON.stringify({
- calls: argonCalls,
- keys: Object.keys(fields).sort(),
- v2: fields.v2.algorithm.name,
- v2hkdf: fields.v2hkdf.algorithm.name,
- }));
- """)
- assert out["calls"] == 1
- assert out["keys"] == ["v2", "v2hkdf"]
- assert out["v2"] == "AES-GCM" and out["v2hkdf"] == "HKDF"
-
-
-def test_the_aes_handle_is_unchanged_by_the_hkdf_one(tmp_path):
- """`deriveEncryptionKey` still returns exactly what it always did — every
- identity bundle already written is opened with it."""
- out = _run(tmp_path, """
- const legacy = await deriveEncryptionKey('p', 'someone');
- const paired = (await deriveBundleKeys('p', 'someone')).aes;
- const data = new TextEncoder().encode('a keypair bundle');
- const iv = new Uint8Array(12);
- const ct = await crypto.subtle.encrypt({name:'AES-GCM', iv}, legacy, data);
- const back = await crypto.subtle.decrypt({name:'AES-GCM', iv}, paired, ct);
- console.log(JSON.stringify({
- same: new TextDecoder().decode(back) === 'a keypair bundle',
- extractable: legacy.extractable,
- }));
- """)
- assert out["same"], "the paired AES handle is not the same key as before"
- assert out["extractable"] is False
-
-
-def test_the_playlist_key_is_a_subkey_and_not_the_bundle_key(tmp_path):
- """Derived under its own `info`, so what opens a playlist opens nothing
- else — and cannot be produced from the AES handle at all."""
- out = _run(tmp_path, """
- const { aes, hkdf } = await deriveBundleKeys('p', 'someone');
- const playlistKey = await crypto.subtle.deriveKey(
- { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
- info: new TextEncoder().encode('meshbay:playlists:v1') },
- hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
-
- const iv = new Uint8Array(12);
- const ct = await crypto.subtle.encrypt(
- { name: 'AES-GCM', iv }, playlistKey, new TextEncoder().encode('tracks'));
-
- // The bundle key must not open what the playlist key sealed.
- let bundleOpens = true;
- try { await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aes, ct); }
- catch { bundleOpens = false; }
-
- // And nothing can be derived from the AES handle, which is why the HKDF
- // one has to be a second import rather than a derivation.
- let derivable = true;
- try {
- await crypto.subtle.deriveKey(
- { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
- info: new Uint8Array(0) },
- aes, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
- } catch { derivable = false; }
-
- console.log(JSON.stringify({ bundleOpens, derivable }));
- """)
- assert out["bundleOpens"] is False, (
- "the playlist key is the bundle key — purpose separation is gone")
- assert out["derivable"] is False, (
- "if the AES handle were derivable the second import would be needless; "
- "it is not, which is exactly why deriveBundleKeys imports twice")
-
-
-def test_a_different_info_gives_a_different_key(tmp_path):
- """What makes it a *purpose*-separated subkey rather than a rename."""
- out = _run(tmp_path, """
- const { hkdf } = await deriveBundleKeys('p', 'someone');
- const mk = (info) => crypto.subtle.deriveKey(
- { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
- info: new TextEncoder().encode(info) },
- hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
- const a = await mk('meshbay:playlists:v1');
- const b = await mk('meshbay:something-else:v1');
- const iv = new Uint8Array(12);
- const ct = await crypto.subtle.encrypt(
- { name: 'AES-GCM', iv }, a, new TextEncoder().encode('x'));
- let opens = true;
- try { await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, b, ct); }
- catch { opens = false; }
- console.log(JSON.stringify({ opens }));
- """)
- assert out["opens"] is False
-
-
-def test_two_devices_of_one_account_derive_the_same_playlist_key(tmp_path):
- """The whole point, and the reason the nonce must be random rather than a
- counter: two devices derive the *same* key, so a counter would repeat."""
- out = _run(tmp_path, """
- const mk = async () => {
- const { hkdf } = await deriveBundleKeys('same passphrase', 'someone');
- return crypto.subtle.deriveKey(
- { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
- info: new TextEncoder().encode('meshbay:playlists:v1') },
- hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
- };
- const iv = crypto.getRandomValues(new Uint8Array(12));
- const ct = await crypto.subtle.encrypt(
- { name: 'AES-GCM', iv }, await mk(), new TextEncoder().encode('Evening'));
- const back = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, await mk(), ct);
- console.log(JSON.stringify({ text: new TextDecoder().decode(back) }));
- """)
- assert out["text"] == "Evening"
-
-
-def test_a_different_account_derives_a_different_key(tmp_path):
- """The salt is domain-separated per user; this is what that buys."""
- out = _run(tmp_path, """
- const mk = async (user) => {
- const { hkdf } = await deriveBundleKeys('p', user);
- return crypto.subtle.deriveKey(
- { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
- info: new TextEncoder().encode('meshbay:playlists:v1') },
- hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
- };
- const iv = new Uint8Array(12);
- const ct = await crypto.subtle.encrypt(
- { name: 'AES-GCM', iv }, await mk('alice'), new TextEncoder().encode('x'));
- let opens = true;
- try { await crypto.subtle.decrypt({ name:'AES-GCM', iv }, await mk('bob'), ct); }
- catch { opens = false; }
- console.log(JSON.stringify({ opens }));
- """)
- assert out["opens"] is False
diff --git a/packages/meshbay-hub/tests/test_playlist_store.py b/packages/meshbay-hub/tests/test_playlist_store.py
index 701de41..3c1d109 100644
--- a/packages/meshbay-hub/tests/test_playlist_store.py
+++ b/packages/meshbay-hub/tests/test_playlist_store.py
@@ -272,16 +272,25 @@ def test_a_node_holding_something_unreadable_does_not_wedge_the_sync(steps):
assert "Depuis le menu" in s["names"]
-def test_a_session_from_before_the_hkdf_handle_degrades_rather_than_failing(steps):
- """A bundle key loaded out of IndexedDB from before `deriveBundleKeys`
- existed has no HKDF handle, and the passphrase is not in memory to
- re-derive from. Playlists stay local until the next sign-in — reported,
- rather than silently doing nothing."""
- r = steps["a session from before the HKDF handle"]["result"]
+def test_a_session_from_before_the_pepper_degrades_rather_than_failing(steps):
+ """A bundle key from before the pepper opens nothing, and the passphrase
+ is not in memory to re-derive from. Playlists stay local until it is
+ entered again — reported, rather than silently doing nothing."""
+ r = steps["a session from before the pepper"]["result"]
assert r["ok"] is False and r["reason"] == "no_key"
assert r["pushed"] == 0
+def test_a_node_sealed_under_the_previous_key_is_sealed_again(steps):
+ """After the playlist key changed, every row on a node has the revision the
+ local copy has. All of it is sealed again under the current key, no
+ revision goes down, and a body nothing here can name is dropped."""
+ s = steps["a node sealed under the previous key"]
+ assert s["readable"] == s["kinds"], "a row is still sealed under the old key"
+ assert s["remaining"] == s["kinds"], "the unknown body was not dropped"
+ assert s["revsNotLowered"] is True
+
+
def test_what_a_playlist_costs_sealed_is_measured_not_quoted(steps):
"""The ceiling the UI promises comes from here, not from the design doc.
diff --git a/packages/meshbay-hub/tests/test_recovery_key.py b/packages/meshbay-hub/tests/test_recovery_key.py
index e43e6de..c574597 100644
--- a/packages/meshbay-hub/tests/test_recovery_key.py
+++ b/packages/meshbay-hub/tests/test_recovery_key.py
@@ -84,10 +84,11 @@ const fp = async (key) => hex(await webcrypto.subtle.encrypt(
const kB = await K.deriveRecoveryKey(raw, 'acc-B');
const skEd = new Uint8Array([1, 2, 3]);
const skX = new Uint8Array([4, 5, 6]);
- const blob = await K.encryptBundleWithKey(skEd, skX, kA);
+ const sealedFor = { userId: 'acc-A', nodePk: 'node-key' };
+ const blob = await K.encryptBundle(skEd, skX, kA, { ...sealedFor, pepperVersion: 0 });
let wrongRejected = false;
- try { await K.decryptBundleWithKey(blob, kB); } catch { wrongRejected = true; }
- const opened = await K.decryptBundleWithKey(blob, kA);
+ try { await K.decryptBundle(blob, kB, sealedFor); } catch { wrongRejected = true; }
+ const opened = await K.decryptBundle(blob, kA, sealedFor);
out.recovery_wrap_isolates = wrongRejected
&& opened.skEd === btoa(String.fromCharCode(1, 2, 3))
&& opened.skX === btoa(String.fromCharCode(4, 5, 6));
diff --git a/packages/meshbay-hub/tests/test_rewrap_fanout.py b/packages/meshbay-hub/tests/test_rewrap_fanout.py
index b278d0c..79a5bf5 100644
--- a/packages/meshbay-hub/tests/test_rewrap_fanout.py
+++ b/packages/meshbay-hub/tests/test_rewrap_fanout.py
@@ -46,12 +46,13 @@ eval(process.argv[2].split(require('path').delimiter)
.map((p) => fs.readFileSync(p, 'utf8')).join('\n'));
const T = window.MeshBayTransport;
-let deriveEncCalls = 0;
+// Keys are opaque tags here; what is checked is which key sealed what, for
+// which account on which node, under which pepper version.
window.MeshBayKeys = {
- deriveEncryptionKey: async (p) => { deriveEncCalls++; return { kind: 'enc', p }; },
- deriveEncryptionKeyV1: async (p) => ({ kind: 'encv1', p }),
- deriveRecoveryKey: async (r) => ({ kind: 'rec', r }),
- encryptBundleWithKey: async (_skEd, _skX, key) => 'wrapped:' + key.kind,
+ deriveRecoveryKey: async (r) => ({ kind: 'rec', r }),
+ nodeBundleKey: async (key, nodePk) => ({ kind: `${key.kind}@${nodePk}` }),
+ encryptBundle: async (_skEd, _skX, key, m) =>
+ `wrapped:${key.kind}|${m.userId}|${m.nodePk}|v${m.pepperVersion}`,
};
const b64 = (s) => Buffer.from(s).toString('base64');
@@ -65,8 +66,11 @@ const NODES = {
const stored = [];
const rewrapOnlySeen = [];
-T.prototype.connect = async function (nodeId) {
+const openedWith = [];
+T.prototype.connect = async function (nodeId, _t, _g, _gek, _sk, bundleKey) {
this._nodeId = nodeId;
+ this.nodePk = 'pk-' + nodeId; // what the handshake proves
+ openedWith.push(bundleKey && bundleKey.kind);
rewrapOnlySeen.push(this._rewrapOnly === true);
const s = NODES[nodeId] || {};
if (s.throws) throw new Error(s.throws);
@@ -110,38 +114,47 @@ global.fetch = async (url) => {
const names = (a) => a.map((x) => x.name).sort();
(async () => {
+ // Flow A — passphrase change: opened with the old key, sealed with the new.
const A = await T.rewrapAllNodes({
hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
- oldPassphrase: 'old', newPassphrase: 'new',
+ bundleKey: { kind: 'old', pepperVersion: 1 },
+ newBundleKey: { kind: 'new', pepperVersion: 1 },
});
const storeA = stored.splice(0);
+ const openedA = openedWith.splice(0);
+ // Flow B — reset: the session key of the new passphrase opens nothing, the
+ // recovery copy does, and both copies are sealed again.
const B = await T.rewrapAllNodes({
hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
- newPassphrase: 'new', recoveryKey: 'A RECOVERY MNEMONIC',
+ bundleKey: { kind: 'cur', pepperVersion: 1 }, recoveryKey: 'A RECOVERY MNEMONIC',
});
const storeB = stored.splice(0);
- // Flow C — Profile backfill: keep the live passphrase key, just add the
- // recovery copy. No passphrase strings, so deriveEncryptionKey is not called.
- deriveEncCalls = 0;
+ // Flow C — Profile backfill: keep the live key, just add the recovery copy.
const C = await T.rewrapAllNodes({
hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid',
- bundleKey: { v2: { kind: 'bk' }, v1: { kind: 'bkv1' } },
+ bundleKey: { kind: 'bk', pepperVersion: 1 },
recoveryKey: 'A RECOVERY MNEMONIC',
});
const storeC = stored.splice(0);
+ let refusedWithoutKey = false;
+ try {
+ await T.rewrapAllNodes({ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid' });
+ } catch { refusedWithoutKey = true; }
+
process.stdout.write(JSON.stringify({
a_updated: names(A.updated),
a_unreachable: names(A.unreachable),
a_failed: names(A.failed),
a_stored_nodes: storeA.map((s) => s.nodeId).sort(),
a_recovery_always_null: storeA.every((s) => s.rec === null),
- a_new_bundle_key_kind: A.newBundleKey && A.newBundleKey.v2 && A.newBundleKey.v2.kind,
+ a_stored: storeA.map((s) => s.enc),
+ a_opened_with: [...new Set(openedA)],
b_stored: storeB.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })),
c_stored: storeC.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })),
- c_derive_enc_calls: deriveEncCalls,
+ refused_without_key: refusedWithoutKey,
// Every transport the fan-out builds is flagged rewrap-only, so a stored
// bundle it cannot open is reported, not silently replaced with a new one.
all_rewrap_only: rewrapOnlySeen.length > 0 && rewrapOnlySeen.every(Boolean),
@@ -189,24 +202,30 @@ def test_a_node_that_returns_no_identity_is_a_failure(result):
assert "f@ann" in result["a_failed"]
-def test_flow_a_writes_only_the_passphrase_copy(result):
+def test_flow_a_opens_with_the_old_key_and_seals_with_the_new_for_that_node(result):
assert result["a_recovery_always_null"] is True
- assert result["a_new_bundle_key_kind"] == "enc"
+ assert result["a_opened_with"] == ["old"]
+ assert result["a_stored"] == ["wrapped:new@pk-n-ok|uid|pk-n-ok|v1"]
def test_flow_b_writes_both_the_passphrase_and_the_recovery_copy(result):
+ # The recovery copy owes nothing to the pepper: version 0.
assert result["b_stored"] == [
- {"node": "n-ok", "enc": "wrapped:enc", "rec": "wrapped:rec"},
+ {"node": "n-ok", "enc": "wrapped:cur@pk-n-ok|uid|pk-n-ok|v1",
+ "rec": "wrapped:rec|uid|pk-n-ok|v0"},
]
def test_profile_backfill_keeps_the_live_key_and_adds_the_recovery_copy(result):
- # bundleKey mode: the passphrase copy is re-wrapped with the same live key
- # (kind "bk"), the recovery copy is added, and no passphrase is derived.
assert result["c_stored"] == [
- {"node": "n-ok", "enc": "wrapped:bk", "rec": "wrapped:rec"},
+ {"node": "n-ok", "enc": "wrapped:bk@pk-n-ok|uid|pk-n-ok|v1",
+ "rec": "wrapped:rec|uid|pk-n-ok|v0"},
]
- assert result["c_derive_enc_calls"] == 0
+
+
+def test_there_is_no_passphrase_path_left(result):
+ """Keys only: a caller that has not derived one with the pepper is refused."""
+ assert result["refused_without_key"] is True
def test_every_fanout_transport_is_rewrap_only(result):
diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py
index fdedaf8..6f28aaa 100644
--- a/packages/meshbay-hub/tests/test_spa_ordering.py
+++ b/packages/meshbay-hub/tests/test_spa_ordering.py
@@ -91,6 +91,23 @@ def test_keys_are_recovered_before_the_join_is_attempted():
"browser that did not register has no key to sign the join with")
+def test_a_bundle_is_opened_with_the_key_the_node_has_already_proved():
+ """
+ A bundle is sealed for one account on one node: its key derives from the
+ node's public key and its associated data names both. That key has to be
+ the one the challenge signature proved — recorded before the bundle is
+ fetched — or a bundle would be opened, or a new one sealed, for nothing.
+ """
+ proved, user, sealed_for, fetch = _positions(
+ "this.nodePk = reply.node_pk",
+ "this._userId = userId",
+ "const sealedFor = { userId: this._userId, nodePk: this.nodePk }",
+ "type: 'keypair_bundle_fetch'",
+ )
+ assert proved < sealed_for < fetch
+ assert user < sealed_for
+
+
def test_the_ack_still_verifies_the_announced_node_key():
"""
Taking node_pk from the challenge is only safe because the ack proves it and