diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 11:37:58 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 11:37:58 +0200 |
| commit | ad8a08c713dea0e46800ea0aa6bfcf185a69e70e (patch) | |
| tree | a1f9289b3a55e30853d22a95a5ec858cf04ab272 /packages/meshbay-hub/src/meshbay_hub/static/keyderive.js | |
| parent | cde57423e04812fe2c939fdf983e3b45a77fd82d (diff) | |
| download | meshbay-ad8a08c713dea0e46800ea0aa6bfcf185a69e70e.tar.gz | |
playlists: merge rules, sealing, and the key
playlist-merge.js and playlist-crypto.js have no imports and are run by
their tests, which is the only real evidence this feature can have.
Two revision counters per playlist, not one: a rename on one device and
a track added on another both write n+1, and a single counter makes two
edits that do not overlap collide.
One Argon2 run at sign-in, two handles. The AES handle is imported
non-extractably, so nothing can be derived from it — hence a second
import of the same bytes as HKDF rather than a derivation.
Measured: ~270 bytes a track, deflate worth 4.5x on realistic data, so
the 1 MB body cap holds about 17000 tracks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/keyderive.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/keyderive.js | 55 |
1 files changed, 52 insertions, 3 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index 918ade3..edfa109 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -142,7 +142,7 @@ async function deriveEncryptionKeyV1(password, username) { * passphrase in memory to re-derive it whenever a bundle turns up. It is unique * per account, so it does what a salt is for — no shared precomputation. */ -async function deriveEncryptionKey(password, username) { +async function _bundleKeyBytes(password, username) { const enc = new TextEncoder(); const salt = new Uint8Array(await crypto.subtle.digest( 'SHA-256', enc.encode(`meshbay:bundle:v2:${username}`))).slice(0, 16); @@ -151,8 +151,42 @@ async function deriveEncryptionKey(password, username) { time: ARGON2_TIME, mem: ARGON2_MEM_KIB, parallelism: ARGON2_LANES, hashLen: 32, type: _argon2().ArgonType.Argon2id, }); + return out.hash; +} + +async function deriveEncryptionKey(password, username) { return crypto.subtle.importKey( - 'raw', out.hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); + 'raw', await _bundleKeyBytes(password, username), + { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); +} + +/** + * 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). + * + * 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. + * + * 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. + */ +async function deriveBundleKeys(password, username) { + const raw = await _bundleKeyBytes(password, username); + 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']), + }; } // ── Account recovery key ───────────────────────────────────────────────────── @@ -393,7 +427,7 @@ async function loginAndRecover(username, password) { // Both, so a bundle written before the KDF changed can still be opened — // and re-written with the new one on the next backup. bundleKey: { - v2: await deriveEncryptionKey(password, username), + ...(await _bundleKeyPairFields(password, username)), v1: await deriveEncryptionKeyV1(password, username), }, }; @@ -417,6 +451,17 @@ 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, @@ -424,6 +469,10 @@ window.MeshBayKeys = { // 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, // Account recovery key (docs/auth-confirm.md §4.3). generateRecoveryKey, deriveRecoveryKey, }; |