From 2bd0fcfbf829f42c1778ebcdc2fef3560dbe9b5b Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:13:36 +0100 Subject: [PATCH] feat(crypto): Argon2id KDF foundation (vendored, not yet adopted) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of CODE_AUDIT §1.2 — additive, no live account uses Argon2id yet. - Vendor @noble/hashes@2.2.0 argon2id as js/argon2.js (esbuild IIFE exposing globalThis.NobleArgon2). Pure-JS, not WASM: CSP is script-src 'self' with no wasm-unsafe-eval, so WASM would require weakening it. Verified against the RFC 9106 §5.3 test vector. Server needs zero Argon2 (zero-knowledge: it only ever SHA256-wraps the client verifier). - app.js: deriveKeyBytes(pwd, salt, algo, iters, argonParams) branches Argon2id vs PBKDF2; deriveKeyAndVerifier refactored around it. New markers HASH_ALGO_ARGON2='argon2id-v2' + ARGON2_DEFAULT_PARAMS (OWASP m=19MiB,t=2,p=1, ~0.65s/unlock). isDecoupledVerifierAlgo() generalises the decoupled-verifier rule to any '-v2' scheme so argon2id-v2 inherits it. AES key is still ALWAYS the raw KDF output → entries decryptable, legacy accounts untouched. - index.html loads js/argon2.js before app.js; added to BuildAssets whitelist; test harness loads it into the sandbox first. - Tests: +5 (RFC 9106 vector via vendored bundle, argon2 branch derives Argon2 key not PBKDF2, decoupled verifier, AES round-trip under Argon2 key). 40/40. Co-Authored-By: Claude Opus 4.8 --- delphi-backend/assets/BuildAssets.ps1 | 1 + index.html | 1 + js/app.js | 55 +- js/argon2.js | 957 ++++++++++++++++++++++++++ js/tests/crypto.test.js | 61 ++ js/tests/harness.js | 10 +- 6 files changed, 1071 insertions(+), 14 deletions(-) create mode 100644 js/argon2.js diff --git a/delphi-backend/assets/BuildAssets.ps1 b/delphi-backend/assets/BuildAssets.ps1 index 5c2f67e..8cf7eea 100644 --- a/delphi-backend/assets/BuildAssets.ps1 +++ b/delphi-backend/assets/BuildAssets.ps1 @@ -51,6 +51,7 @@ Log "Web root: $WebRoot" # Use exact paths (not wildcards) to avoid embedding *-legacy.* backups. $patterns = @( 'index.html', + 'js\argon2.js', 'js\app.js', 'css\style.css' ) diff --git a/index.html b/index.html index 938deb0..82c7cdb 100644 --- a/index.html +++ b/index.html @@ -1192,6 +1192,7 @@ + diff --git a/js/app.js b/js/app.js index d9c7665..59836df 100644 --- a/js/app.js +++ b/js/app.js @@ -714,46 +714,75 @@ function bytesToHex(arr) { return hex; } -// Decoupled-verifier scheme marker + domain separator. When the account's -// hash_algo is HASH_ALGO_V2, the verifier sent to the server is a one-way +// Decoupled-verifier scheme markers + domain separator. When the account's +// hash_algo ends in '-v2', the verifier sent to the server is a one-way // SHA-256 of the key hex (domain-separated), NOT the key hex itself — so // intercepting the /login body no longer hands over the AES vault key. -// The AES key (cryptoKey) is ALWAYS the raw PBKDF2 output regardless, so +// The AES key (cryptoKey) is ALWAYS the raw KDF output regardless of algo, so // entries stay decryptable and legacy accounts are unaffected. -const HASH_ALGO_V2 = 'pbkdf2-sha256-v2'; +const HASH_ALGO_V2 = 'pbkdf2-sha256-v2'; // PBKDF2 KDF + decoupled verifier +const HASH_ALGO_ARGON2 = 'argon2id-v2'; // Argon2id KDF + decoupled verifier const AUTH_VERIFIER_DOMAIN = 'pmserver/auth-verifier/v2'; +// OWASP-recommended Argon2id baseline (m = 19 MiB, t = 2, p = 1). Stored +// per-account (like kdfIterations for PBKDF2) so it's tunable later without +// breaking existing accounts. dkLen is fixed at 32 (AES-256 key). +const ARGON2_DEFAULT_PARAMS = { m: 19456, t: 2, p: 1 }; + +// True for any scheme whose transmitted verifier is decoupled from the key +// (all '-v2' markers: pbkdf2-sha256-v2, argon2id-v2). endsWith keeps it +// future-proof for any later '-v2' KDF. +function isDecoupledVerifierAlgo(algo) { + return typeof algo === 'string' && algo.endsWith('-v2'); +} + async function sha256Hex(str) { const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)); return bytesToHex(new Uint8Array(buf)); } -// Map the raw PBKDF2 key hex → the verifier to transmit, per account algo. -// v2 → domain-separated SHA-256 (decoupled from the key). Anything else → -// the key hex verbatim (legacy behaviour, unchanged for existing accounts). +// Map the raw KDF key hex → the verifier to transmit, per account algo. +// Decoupled ('-v2') → domain-separated SHA-256. Anything else → the key hex +// verbatim (legacy behaviour, unchanged for existing pre-v2 accounts). async function verifierFromKeyHex(keyHex, algo) { - if (algo === HASH_ALGO_V2) return await sha256Hex(keyHex + AUTH_VERIFIER_DOMAIN); + if (isDecoupledVerifierAlgo(algo)) return await sha256Hex(keyHex + AUTH_VERIFIER_DOMAIN); return keyHex; } -async function deriveKeyAndVerifier(pwd, saltHex, iterations, algo) { - iterations = iterations || 100000; +// Derive the 32 raw key bytes from the master password, per account KDF. +// Argon2id (memory-hard) for argon2id-* accounts, else PBKDF2-SHA256. Both +// feed the salt HEX STRING's UTF-8 bytes as the salt (historical quirk kept +// identical across KDFs so a given pw+salt maps to one deterministic key). +async function deriveKeyBytes(pwd, saltHex, algo, iterations, argonParams) { const enc = new TextEncoder(); + if (algo === HASH_ALGO_ARGON2) { + if (typeof NobleArgon2 === 'undefined' || !NobleArgon2 || !NobleArgon2.argon2id) + throw new Error('Argon2 library not loaded (js/argon2.js missing?)'); + const p = argonParams || ARGON2_DEFAULT_PARAMS; + return NobleArgon2.argon2id(enc.encode(pwd), enc.encode(saltHex), + { t: p.t, m: p.m, p: p.p, dkLen: 32, version: 0x13 }); + } + // PBKDF2-SHA256 (default / legacy). + iterations = iterations || 100000; const km = await crypto.subtle.importKey( 'raw', enc.encode(pwd), 'PBKDF2', false, ['deriveBits']); const bits = await crypto.subtle.deriveBits( { name: 'PBKDF2', salt: enc.encode(saltHex), iterations: iterations, hash: 'SHA-256' }, km, 256); // 256 bits = 32 bytes — matches PBKDF2_SHA256_Hex output - const keyBytes = new Uint8Array(bits); + return new Uint8Array(bits); +} + +async function deriveKeyAndVerifier(pwd, saltHex, iterations, algo, argonParams) { + const keyBytes = await deriveKeyBytes(pwd, saltHex, algo, iterations, argonParams); const cryptoKey = await crypto.subtle.importKey( 'raw', keyBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); const verifier = await verifierFromKeyHex(bytesToHex(keyBytes), algo); return { cryptoKey, verifier }; } -async function computeVerifier(pwd, saltHex, iterations, algo) { - const r = await deriveKeyAndVerifier(pwd, saltHex, iterations, algo); +async function computeVerifier(pwd, saltHex, iterations, algo, argonParams) { + const r = await deriveKeyAndVerifier(pwd, saltHex, iterations, algo, argonParams); return r.verifier; } diff --git a/js/argon2.js b/js/argon2.js new file mode 100644 index 0000000..865040b --- /dev/null +++ b/js/argon2.js @@ -0,0 +1,957 @@ +/* @noble/hashes argon2id v2.2.0 — vendored bundle (esbuild IIFE). Verified against RFC 9106 §5.3. Do not edit by hand; re-bundle from @noble/hashes@2.2.0. */ +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // node_modules/@noble/hashes/_u64.js + var U32_MASK64 = /* @__PURE__ */ BigInt(2 ** 32 - 1); + var _32n = /* @__PURE__ */ BigInt(32); + function fromBig(n, le = false) { + if (le) + return { h: Number(n & U32_MASK64), l: Number(n >> _32n & U32_MASK64) }; + return { h: Number(n >> _32n & U32_MASK64) | 0, l: Number(n & U32_MASK64) | 0 }; + } + var rotrSH = (h, l, s) => h >>> s | l << 32 - s; + var rotrSL = (h, l, s) => h << 32 - s | l >>> s; + var rotrBH = (h, l, s) => h << 64 - s | l >>> s - 32; + var rotrBL = (h, l, s) => h >>> s - 32 | l << 64 - s; + var rotr32H = (_h, l) => l; + var rotr32L = (h, _l) => h; + function add(Ah, Al, Bh, Bl) { + const l = (Al >>> 0) + (Bl >>> 0); + return { h: Ah + Bh + (l / 2 ** 32 | 0) | 0, l: l | 0 }; + } + var add3L = (Al, Bl, Cl) => (Al >>> 0) + (Bl >>> 0) + (Cl >>> 0); + var add3H = (low, Ah, Bh, Ch) => Ah + Bh + Ch + (low / 2 ** 32 | 0) | 0; + + // node_modules/@noble/hashes/utils.js + function isBytes(a) { + return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array" && "BYTES_PER_ELEMENT" in a && a.BYTES_PER_ELEMENT === 1; + } + function anumber(n, title = "") { + if (typeof n !== "number") { + const prefix = title && `"${title}" `; + throw new TypeError(`${prefix}expected number, got ${typeof n}`); + } + if (!Number.isSafeInteger(n) || n < 0) { + const prefix = title && `"${title}" `; + throw new RangeError(`${prefix}expected integer >= 0, got ${n}`); + } + } + function abytes(value, length, title = "") { + const bytes = isBytes(value); + const len = value?.length; + const needsLen = length !== void 0; + if (!bytes || needsLen && len !== length) { + const prefix = title && `"${title}" `; + const ofLen = needsLen ? ` of length ${length}` : ""; + const got = bytes ? `length=${len}` : `type=${typeof value}`; + const message = prefix + "expected Uint8Array" + ofLen + ", got " + got; + if (!bytes) + throw new TypeError(message); + throw new RangeError(message); + } + return value; + } + function aexists(instance, checkFinished = true) { + if (instance.destroyed) + throw new Error("Hash instance has been destroyed"); + if (checkFinished && instance.finished) + throw new Error("Hash#digest() has already been called"); + } + function aoutput(out, instance) { + abytes(out, void 0, "digestInto() output"); + const min = instance.outputLen; + if (out.length < min) { + throw new RangeError('"digestInto() output" expected to be of length >=' + min); + } + } + function u8(arr) { + return new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength); + } + function u32(arr) { + return new Uint32Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 4)); + } + function clean(...arrays) { + for (let i = 0; i < arrays.length; i++) { + arrays[i].fill(0); + } + } + var isLE = /* @__PURE__ */ (() => new Uint8Array(new Uint32Array([287454020]).buffer)[0] === 68)(); + function byteSwap(word) { + return word << 24 & 4278190080 | word << 8 & 16711680 | word >>> 8 & 65280 | word >>> 24 & 255; + } + var swap8IfBE = isLE ? (n) => n : (n) => byteSwap(n) >>> 0; + function byteSwap32(arr) { + for (let i = 0; i < arr.length; i++) { + arr[i] = byteSwap(arr[i]); + } + return arr; + } + var swap32IfBE = isLE ? (u) => u : byteSwap32; + function utf8ToBytes(str) { + if (typeof str !== "string") + throw new TypeError("string expected"); + return new Uint8Array(new TextEncoder().encode(str)); + } + function kdfInputToBytes(data, errorTitle = "") { + if (typeof data === "string") + return utf8ToBytes(data); + return abytes(data, void 0, errorTitle); + } + function createHasher(hashCons, info = {}) { + const hashC = (msg, opts) => hashCons(opts).update(msg).digest(); + const tmp = hashCons(void 0); + hashC.outputLen = tmp.outputLen; + hashC.blockLen = tmp.blockLen; + hashC.canXOF = tmp.canXOF; + hashC.create = (opts) => hashCons(opts); + Object.assign(hashC, info); + return Object.freeze(hashC); + } + + // node_modules/@noble/hashes/_blake.js + var BSIGMA = /* @__PURE__ */ Uint8Array.from([ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 14, + 10, + 4, + 8, + 9, + 15, + 13, + 6, + 1, + 12, + 0, + 2, + 11, + 7, + 5, + 3, + 11, + 8, + 12, + 0, + 5, + 2, + 15, + 13, + 10, + 14, + 3, + 6, + 7, + 1, + 9, + 4, + 7, + 9, + 3, + 1, + 13, + 12, + 11, + 14, + 2, + 6, + 5, + 10, + 4, + 0, + 15, + 8, + 9, + 0, + 5, + 7, + 2, + 4, + 10, + 15, + 14, + 1, + 11, + 12, + 6, + 8, + 3, + 13, + 2, + 12, + 6, + 10, + 0, + 11, + 8, + 3, + 4, + 13, + 7, + 5, + 15, + 14, + 1, + 9, + 12, + 5, + 1, + 15, + 14, + 13, + 4, + 10, + 0, + 7, + 6, + 3, + 9, + 2, + 8, + 11, + 13, + 11, + 7, + 14, + 12, + 1, + 3, + 9, + 5, + 0, + 15, + 4, + 8, + 6, + 2, + 10, + 6, + 15, + 14, + 9, + 11, + 3, + 0, + 8, + 12, + 2, + 13, + 7, + 1, + 4, + 10, + 5, + 10, + 2, + 8, + 4, + 7, + 6, + 1, + 5, + 15, + 11, + 9, + 14, + 3, + 12, + 13, + 0, + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 14, + 10, + 4, + 8, + 9, + 15, + 13, + 6, + 1, + 12, + 0, + 2, + 11, + 7, + 5, + 3, + // Blake1, unused in others + 11, + 8, + 12, + 0, + 5, + 2, + 15, + 13, + 10, + 14, + 3, + 6, + 7, + 1, + 9, + 4, + 7, + 9, + 3, + 1, + 13, + 12, + 11, + 14, + 2, + 6, + 5, + 10, + 4, + 0, + 15, + 8, + 9, + 0, + 5, + 7, + 2, + 4, + 10, + 15, + 14, + 1, + 11, + 12, + 6, + 8, + 3, + 13, + 2, + 12, + 6, + 10, + 0, + 11, + 8, + 3, + 4, + 13, + 7, + 5, + 15, + 14, + 1, + 9 + ]); + + // node_modules/@noble/hashes/blake2.js + var B2B_IV = /* @__PURE__ */ Uint32Array.from([ + 4089235720, + 1779033703, + 2227873595, + 3144134277, + 4271175723, + 1013904242, + 1595750129, + 2773480762, + 2917565137, + 1359893119, + 725511199, + 2600822924, + 4215389547, + 528734635, + 327033209, + 1541459225 + ]); + var BBUF = /* @__PURE__ */ new Uint32Array(32); + function G1b(a, b, c, d, msg, x) { + const Xl = msg[x], Xh = msg[x + 1]; + let Al = BBUF[2 * a], Ah = BBUF[2 * a + 1]; + let Bl = BBUF[2 * b], Bh = BBUF[2 * b + 1]; + let Cl = BBUF[2 * c], Ch = BBUF[2 * c + 1]; + let Dl = BBUF[2 * d], Dh = BBUF[2 * d + 1]; + let ll = add3L(Al, Bl, Xl); + Ah = add3H(ll, Ah, Bh, Xh); + Al = ll | 0; + ({ Dh, Dl } = { Dh: Dh ^ Ah, Dl: Dl ^ Al }); + ({ Dh, Dl } = { Dh: rotr32H(Dh, Dl), Dl: rotr32L(Dh, Dl) }); + ({ h: Ch, l: Cl } = add(Ch, Cl, Dh, Dl)); + ({ Bh, Bl } = { Bh: Bh ^ Ch, Bl: Bl ^ Cl }); + ({ Bh, Bl } = { Bh: rotrSH(Bh, Bl, 24), Bl: rotrSL(Bh, Bl, 24) }); + BBUF[2 * a] = Al, BBUF[2 * a + 1] = Ah; + BBUF[2 * b] = Bl, BBUF[2 * b + 1] = Bh; + BBUF[2 * c] = Cl, BBUF[2 * c + 1] = Ch; + BBUF[2 * d] = Dl, BBUF[2 * d + 1] = Dh; + } + function G2b(a, b, c, d, msg, x) { + const Xl = msg[x], Xh = msg[x + 1]; + let Al = BBUF[2 * a], Ah = BBUF[2 * a + 1]; + let Bl = BBUF[2 * b], Bh = BBUF[2 * b + 1]; + let Cl = BBUF[2 * c], Ch = BBUF[2 * c + 1]; + let Dl = BBUF[2 * d], Dh = BBUF[2 * d + 1]; + let ll = add3L(Al, Bl, Xl); + Ah = add3H(ll, Ah, Bh, Xh); + Al = ll | 0; + ({ Dh, Dl } = { Dh: Dh ^ Ah, Dl: Dl ^ Al }); + ({ Dh, Dl } = { Dh: rotrSH(Dh, Dl, 16), Dl: rotrSL(Dh, Dl, 16) }); + ({ h: Ch, l: Cl } = add(Ch, Cl, Dh, Dl)); + ({ Bh, Bl } = { Bh: Bh ^ Ch, Bl: Bl ^ Cl }); + ({ Bh, Bl } = { Bh: rotrBH(Bh, Bl, 63), Bl: rotrBL(Bh, Bl, 63) }); + BBUF[2 * a] = Al, BBUF[2 * a + 1] = Ah; + BBUF[2 * b] = Bl, BBUF[2 * b + 1] = Bh; + BBUF[2 * c] = Cl, BBUF[2 * c + 1] = Ch; + BBUF[2 * d] = Dl, BBUF[2 * d + 1] = Dh; + } + function checkBlake2Opts(outputLen, opts = {}, keyLen, saltLen, persLen) { + anumber(keyLen); + if (outputLen <= 0 || outputLen > keyLen) + throw new Error("outputLen bigger than keyLen"); + const { key, salt, personalization } = opts; + if (key !== void 0 && (key.length < 1 || key.length > keyLen)) + throw new Error('"key" expected to be undefined or of length=1..' + keyLen); + if (salt !== void 0) + abytes(salt, saltLen, "salt"); + if (personalization !== void 0) + abytes(personalization, persLen, "personalization"); + } + var _BLAKE2 = class { + constructor(blockLen, outputLen) { + __publicField(this, "buffer"); + __publicField(this, "buffer32"); + __publicField(this, "finished", false); + __publicField(this, "destroyed", false); + __publicField(this, "length", 0); + __publicField(this, "pos", 0); + __publicField(this, "blockLen"); + __publicField(this, "outputLen"); + __publicField(this, "canXOF", false); + anumber(blockLen); + anumber(outputLen); + this.blockLen = blockLen; + this.outputLen = outputLen; + this.buffer = new Uint8Array(blockLen); + this.buffer32 = u32(this.buffer); + } + update(data) { + aexists(this); + abytes(data); + const { blockLen, buffer, buffer32 } = this; + const len = data.length; + const offset = data.byteOffset; + const buf = data.buffer; + for (let pos = 0; pos < len; ) { + if (this.pos === blockLen) { + swap32IfBE(buffer32); + this.compress(buffer32, 0, false); + swap32IfBE(buffer32); + this.pos = 0; + } + const take = Math.min(blockLen - this.pos, len - pos); + const dataOffset = offset + pos; + if (take === blockLen && !(dataOffset % 4) && pos + take < len) { + const data32 = new Uint32Array(buf, dataOffset, Math.floor((len - pos) / 4)); + swap32IfBE(data32); + for (let pos32 = 0; pos + blockLen < len; pos32 += buffer32.length, pos += blockLen) { + this.length += blockLen; + this.compress(data32, pos32, false); + } + swap32IfBE(data32); + continue; + } + buffer.set(data.subarray(pos, pos + take), this.pos); + this.pos += take; + this.length += take; + pos += take; + } + return this; + } + digestInto(out) { + aexists(this); + aoutput(out, this); + const { pos, buffer32 } = this; + this.finished = true; + clean(this.buffer.subarray(pos)); + swap32IfBE(buffer32); + this.compress(buffer32, 0, true); + swap32IfBE(buffer32); + if (out.byteOffset & 3) + throw new RangeError('"digestInto() output" expected 4-byte aligned byteOffset, got ' + out.byteOffset); + const state = this.get(); + const out32 = u32(out); + const full = Math.floor(this.outputLen / 4); + for (let i = 0; i < full; i++) + out32[i] = swap8IfBE(state[i]); + const tail = this.outputLen % 4; + if (!tail) + return; + const off = full * 4; + const word = state[full]; + for (let i = 0; i < tail; i++) + out[off + i] = word >>> 8 * i; + } + digest() { + const { buffer, outputLen } = this; + this.digestInto(buffer); + const res = buffer.slice(0, outputLen); + this.destroy(); + return res; + } + _cloneInto(to) { + const { buffer, length, finished, destroyed, outputLen, pos } = this; + to || (to = new this.constructor({ dkLen: outputLen })); + to.set(...this.get()); + to.buffer.set(buffer); + to.destroyed = destroyed; + to.finished = finished; + to.length = length; + to.pos = pos; + to.outputLen = outputLen; + return to; + } + clone() { + return this._cloneInto(); + } + }; + var _BLAKE2b = class extends _BLAKE2 { + constructor(opts = {}) { + const olen = opts.dkLen === void 0 ? 64 : opts.dkLen; + super(128, olen); + // Same IV words as SHA-512 / BLAKE2b, encoded as LE u32 low/high halves. + __publicField(this, "v0l", B2B_IV[0] | 0); + __publicField(this, "v0h", B2B_IV[1] | 0); + __publicField(this, "v1l", B2B_IV[2] | 0); + __publicField(this, "v1h", B2B_IV[3] | 0); + __publicField(this, "v2l", B2B_IV[4] | 0); + __publicField(this, "v2h", B2B_IV[5] | 0); + __publicField(this, "v3l", B2B_IV[6] | 0); + __publicField(this, "v3h", B2B_IV[7] | 0); + __publicField(this, "v4l", B2B_IV[8] | 0); + __publicField(this, "v4h", B2B_IV[9] | 0); + __publicField(this, "v5l", B2B_IV[10] | 0); + __publicField(this, "v5h", B2B_IV[11] | 0); + __publicField(this, "v6l", B2B_IV[12] | 0); + __publicField(this, "v6h", B2B_IV[13] | 0); + __publicField(this, "v7l", B2B_IV[14] | 0); + __publicField(this, "v7h", B2B_IV[15] | 0); + checkBlake2Opts(olen, opts, 64, 16, 16); + let { key, personalization, salt } = opts; + let keyLength = 0; + if (key !== void 0) { + abytes(key, void 0, "key"); + keyLength = key.length; + } + this.v0l ^= this.outputLen | keyLength << 8 | 1 << 16 | 1 << 24; + if (salt !== void 0) { + abytes(salt, void 0, "salt"); + const slt = u32(salt); + this.v4l ^= swap8IfBE(slt[0]); + this.v4h ^= swap8IfBE(slt[1]); + this.v5l ^= swap8IfBE(slt[2]); + this.v5h ^= swap8IfBE(slt[3]); + } + if (personalization !== void 0) { + abytes(personalization, void 0, "personalization"); + const pers = u32(personalization); + this.v6l ^= swap8IfBE(pers[0]); + this.v6h ^= swap8IfBE(pers[1]); + this.v7l ^= swap8IfBE(pers[2]); + this.v7h ^= swap8IfBE(pers[3]); + } + if (key !== void 0) { + const tmp = new Uint8Array(this.blockLen); + tmp.set(key); + this.update(tmp); + } + } + // prettier-ignore + get() { + let { v0l, v0h, v1l, v1h, v2l, v2h, v3l, v3h, v4l, v4h, v5l, v5h, v6l, v6h, v7l, v7h } = this; + return [v0l, v0h, v1l, v1h, v2l, v2h, v3l, v3h, v4l, v4h, v5l, v5h, v6l, v6h, v7l, v7h]; + } + // prettier-ignore + set(v0l, v0h, v1l, v1h, v2l, v2h, v3l, v3h, v4l, v4h, v5l, v5h, v6l, v6h, v7l, v7h) { + this.v0l = v0l | 0; + this.v0h = v0h | 0; + this.v1l = v1l | 0; + this.v1h = v1h | 0; + this.v2l = v2l | 0; + this.v2h = v2h | 0; + this.v3l = v3l | 0; + this.v3h = v3h | 0; + this.v4l = v4l | 0; + this.v4h = v4h | 0; + this.v5l = v5l | 0; + this.v5h = v5h | 0; + this.v6l = v6l | 0; + this.v6h = v6h | 0; + this.v7l = v7l | 0; + this.v7h = v7h | 0; + } + compress(msg, offset, isLast) { + this.get().forEach((v, i) => BBUF[i] = v); + BBUF.set(B2B_IV, 16); + let { h, l } = fromBig(BigInt(this.length)); + BBUF[24] = B2B_IV[8] ^ l; + BBUF[25] = B2B_IV[9] ^ h; + if (isLast) { + BBUF[28] = ~BBUF[28]; + BBUF[29] = ~BBUF[29]; + } + let j = 0; + const s = BSIGMA; + for (let i = 0; i < 12; i++) { + G1b(0, 4, 8, 12, msg, offset + 2 * s[j++]); + G2b(0, 4, 8, 12, msg, offset + 2 * s[j++]); + G1b(1, 5, 9, 13, msg, offset + 2 * s[j++]); + G2b(1, 5, 9, 13, msg, offset + 2 * s[j++]); + G1b(2, 6, 10, 14, msg, offset + 2 * s[j++]); + G2b(2, 6, 10, 14, msg, offset + 2 * s[j++]); + G1b(3, 7, 11, 15, msg, offset + 2 * s[j++]); + G2b(3, 7, 11, 15, msg, offset + 2 * s[j++]); + G1b(0, 5, 10, 15, msg, offset + 2 * s[j++]); + G2b(0, 5, 10, 15, msg, offset + 2 * s[j++]); + G1b(1, 6, 11, 12, msg, offset + 2 * s[j++]); + G2b(1, 6, 11, 12, msg, offset + 2 * s[j++]); + G1b(2, 7, 8, 13, msg, offset + 2 * s[j++]); + G2b(2, 7, 8, 13, msg, offset + 2 * s[j++]); + G1b(3, 4, 9, 14, msg, offset + 2 * s[j++]); + G2b(3, 4, 9, 14, msg, offset + 2 * s[j++]); + } + this.v0l ^= BBUF[0] ^ BBUF[16]; + this.v0h ^= BBUF[1] ^ BBUF[17]; + this.v1l ^= BBUF[2] ^ BBUF[18]; + this.v1h ^= BBUF[3] ^ BBUF[19]; + this.v2l ^= BBUF[4] ^ BBUF[20]; + this.v2h ^= BBUF[5] ^ BBUF[21]; + this.v3l ^= BBUF[6] ^ BBUF[22]; + this.v3h ^= BBUF[7] ^ BBUF[23]; + this.v4l ^= BBUF[8] ^ BBUF[24]; + this.v4h ^= BBUF[9] ^ BBUF[25]; + this.v5l ^= BBUF[10] ^ BBUF[26]; + this.v5h ^= BBUF[11] ^ BBUF[27]; + this.v6l ^= BBUF[12] ^ BBUF[28]; + this.v6h ^= BBUF[13] ^ BBUF[29]; + this.v7l ^= BBUF[14] ^ BBUF[30]; + this.v7h ^= BBUF[15] ^ BBUF[31]; + clean(BBUF); + } + destroy() { + this.destroyed = true; + clean(this.buffer32); + this.set(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + } + }; + var blake2b = /* @__PURE__ */ createHasher((opts) => new _BLAKE2b(opts)); + + // node_modules/@noble/hashes/argon2.js + var AT = { Argond2d: 0, Argon2i: 1, Argon2id: 2 }; + var ARGON2_SYNC_POINTS = 4; + var abytesOrZero = (buf, errorTitle = "") => { + if (buf === void 0) + return Uint8Array.of(); + return kdfInputToBytes(buf, errorTitle); + }; + function mul(a, b) { + const aL = a & 65535; + const aH = a >>> 16; + const bL = b & 65535; + const bH = b >>> 16; + const ll = Math.imul(aL, bL); + const hl = Math.imul(aH, bL); + const lh = Math.imul(aL, bH); + const hh = Math.imul(aH, bH); + const carry = (ll >>> 16) + (hl & 65535) + lh; + const high = hh + (hl >>> 16) + (carry >>> 16) | 0; + const low = carry << 16 | ll & 65535; + return { h: high, l: low }; + } + function mul2(a, b) { + const { h, l } = mul(a, b); + return { h: (h << 1 | l >>> 31) & 4294967295, l: l << 1 & 4294967295 }; + } + function blamka(Ah, Al, Bh, Bl) { + const { h: Ch, l: Cl } = mul2(Al, Bl); + const Rll = add3L(Al, Bl, Cl); + return { h: add3H(Rll, Ah, Bh, Ch), l: Rll | 0 }; + } + var A2_BUF = new Uint32Array(256); + function G(a, b, c, d) { + let Al = A2_BUF[2 * a], Ah = A2_BUF[2 * a + 1]; + let Bl = A2_BUF[2 * b], Bh = A2_BUF[2 * b + 1]; + let Cl = A2_BUF[2 * c], Ch = A2_BUF[2 * c + 1]; + let Dl = A2_BUF[2 * d], Dh = A2_BUF[2 * d + 1]; + ({ h: Ah, l: Al } = blamka(Ah, Al, Bh, Bl)); + ({ Dh, Dl } = { Dh: Dh ^ Ah, Dl: Dl ^ Al }); + ({ Dh, Dl } = { Dh: rotr32H(Dh, Dl), Dl: rotr32L(Dh, Dl) }); + ({ h: Ch, l: Cl } = blamka(Ch, Cl, Dh, Dl)); + ({ Bh, Bl } = { Bh: Bh ^ Ch, Bl: Bl ^ Cl }); + ({ Bh, Bl } = { Bh: rotrSH(Bh, Bl, 24), Bl: rotrSL(Bh, Bl, 24) }); + ({ h: Ah, l: Al } = blamka(Ah, Al, Bh, Bl)); + ({ Dh, Dl } = { Dh: Dh ^ Ah, Dl: Dl ^ Al }); + ({ Dh, Dl } = { Dh: rotrSH(Dh, Dl, 16), Dl: rotrSL(Dh, Dl, 16) }); + ({ h: Ch, l: Cl } = blamka(Ch, Cl, Dh, Dl)); + ({ Bh, Bl } = { Bh: Bh ^ Ch, Bl: Bl ^ Cl }); + ({ Bh, Bl } = { Bh: rotrBH(Bh, Bl, 63), Bl: rotrBL(Bh, Bl, 63) }); + A2_BUF[2 * a] = Al, A2_BUF[2 * a + 1] = Ah; + A2_BUF[2 * b] = Bl, A2_BUF[2 * b + 1] = Bh; + A2_BUF[2 * c] = Cl, A2_BUF[2 * c + 1] = Ch; + A2_BUF[2 * d] = Dl, A2_BUF[2 * d + 1] = Dh; + } + function P(v00, v01, v02, v03, v04, v05, v06, v07, v08, v09, v10, v11, v12, v13, v14, v15) { + G(v00, v04, v08, v12); + G(v01, v05, v09, v13); + G(v02, v06, v10, v14); + G(v03, v07, v11, v15); + G(v00, v05, v10, v15); + G(v01, v06, v11, v12); + G(v02, v07, v08, v13); + G(v03, v04, v09, v14); + } + function block(x, xPos, yPos, outPos, needXor) { + for (let i = 0; i < 256; i++) + A2_BUF[i] = x[xPos + i] ^ x[yPos + i]; + for (let i = 0; i < 128; i += 16) { + P(i, i + 1, i + 2, i + 3, i + 4, i + 5, i + 6, i + 7, i + 8, i + 9, i + 10, i + 11, i + 12, i + 13, i + 14, i + 15); + } + for (let i = 0; i < 16; i += 2) { + P(i, i + 1, i + 16, i + 17, i + 32, i + 33, i + 48, i + 49, i + 64, i + 65, i + 80, i + 81, i + 96, i + 97, i + 112, i + 113); + } + if (needXor) + for (let i = 0; i < 256; i++) + x[outPos + i] ^= A2_BUF[i] ^ x[xPos + i] ^ x[yPos + i]; + else + for (let i = 0; i < 256; i++) + x[outPos + i] = A2_BUF[i] ^ x[xPos + i] ^ x[yPos + i]; + clean(A2_BUF); + } + function Hp(A, dkLen) { + const A8 = u8(A); + const T = new Uint32Array(1); + const T8 = u8(T); + T[0] = swap8IfBE(dkLen); + if (dkLen <= 64) + return blake2b.create({ dkLen }).update(T8).update(A8).digest(); + const out = new Uint8Array(dkLen); + let V = blake2b.create({}).update(T8).update(A8).digest(); + let pos = 0; + out.set(V.subarray(0, 32)); + pos += 32; + for (; dkLen - pos > 64; pos += 32) { + const Vh = blake2b.create({}).update(V); + Vh.digestInto(V); + Vh.destroy(); + out.set(V.subarray(0, 32), pos); + } + out.set(blake2b(V, { dkLen: dkLen - pos }), pos); + clean(V, T); + return out; + } + function indexAlpha(r, s, laneLen, segmentLen, index, randL, sameLane = false) { + let area; + if (r === 0) { + if (s === 0) + area = index - 1; + else if (sameLane) + area = s * segmentLen + index - 1; + else + area = s * segmentLen + (index == 0 ? -1 : 0); + } else if (sameLane) + area = laneLen - segmentLen + index - 1; + else + area = laneLen - segmentLen + (index == 0 ? -1 : 0); + const startPos = r !== 0 && s !== ARGON2_SYNC_POINTS - 1 ? (s + 1) * segmentLen : 0; + const rel = area - 1 - mul(area, mul(randL, randL).h).h; + return (startPos + rel) % laneLen; + } + var maxUint32 = Math.pow(2, 32); + function isU32(num) { + return Number.isSafeInteger(num) && num >= 0 && num < maxUint32; + } + function argon2Opts(opts) { + const merged = { + version: 19, + dkLen: 32, + maxmem: maxUint32 - 1, + asyncTick: 10 + }; + for (let [k, v] of Object.entries(opts)) + if (v !== void 0) + merged[k] = v; + const { dkLen, p, m, t, version, onProgress, asyncTick } = merged; + if (!isU32(dkLen) || dkLen < 4) + throw new Error('"dkLen" must be 4..'); + if (!isU32(p) || p < 1 || p >= Math.pow(2, 24)) + throw new Error('"p" must be 1..2^24'); + if (!isU32(m)) + throw new Error('"m" must be 0..2^32'); + if (!isU32(t) || t < 1) + throw new Error('"t" (iterations) must be 1..2^32'); + if (onProgress !== void 0 && typeof onProgress !== "function") + throw new Error('"progressCb" must be a function'); + anumber(asyncTick, "asyncTick"); + if (!isU32(m) || m < 8 * p) + throw new Error('"m" (memory) must be at least 8*p bytes'); + if (version !== 16 && version !== 19) + throw new Error('"version" must be 0x10 or 0x13, got ' + version); + return merged; + } + function argon2Init(password, salt, type, opts) { + password = kdfInputToBytes(password, "password"); + salt = kdfInputToBytes(salt, "salt"); + if (!isU32(password.length)) + throw new Error('"password" must be less of length 1..4Gb'); + if (!isU32(salt.length) || salt.length < 8) + throw new Error('"salt" must be of length 8..4Gb'); + if (!Object.values(AT).includes(type)) + throw new Error('"type" was invalid'); + let { p, dkLen, m, t, version, key, personalization, maxmem, onProgress, asyncTick } = argon2Opts(opts); + key = abytesOrZero(key, "key"); + personalization = abytesOrZero(personalization, "personalization"); + const h = blake2b.create(); + const BUF = new Uint32Array(1); + const BUF8 = u8(BUF); + for (let item of [p, dkLen, m, t, version, type]) { + BUF[0] = swap8IfBE(item); + h.update(BUF8); + } + for (let i of [password, salt, key, personalization]) { + BUF[0] = swap8IfBE(i.length); + h.update(BUF8).update(i); + } + const H0 = new Uint32Array(18); + const H0_8 = u8(H0); + h.digestInto(H0_8); + const lanes = p; + const mP = 4 * p * Math.floor(m / (ARGON2_SYNC_POINTS * p)); + const laneLen = Math.floor(mP / p); + const segmentLen = Math.floor(laneLen / ARGON2_SYNC_POINTS); + const memUsed = mP * 1024; + if (!isU32(maxmem)) + throw new Error('"maxmem" expected <2**32, got ' + maxmem); + if (memUsed > maxmem) + throw new Error('"maxmem" limit was hit: memUsed(mP*1024)=' + memUsed + ", maxmem=" + maxmem); + const B = new Uint32Array(memUsed / 4); + for (let l = 0; l < p; l++) { + const i = 256 * laneLen * l; + H0[17] = swap8IfBE(l); + H0[16] = swap8IfBE(0); + B.set(swap32IfBE(u32(Hp(H0, 1024))), i); + H0[16] = swap8IfBE(1); + B.set(swap32IfBE(u32(Hp(H0, 1024))), i + 256); + } + let perBlock = () => { + }; + if (onProgress) { + const totalBlock = t * ARGON2_SYNC_POINTS * p * segmentLen - 2 * p; + const callbackPer = Math.max(Math.floor(totalBlock / 1e4), 1); + let blockCnt = 0; + perBlock = () => { + blockCnt++; + if (onProgress && (!(blockCnt % callbackPer) || blockCnt === totalBlock)) + onProgress(blockCnt / totalBlock); + }; + } + clean(BUF, H0); + return { type, mP, p, t, version, B, laneLen, lanes, segmentLen, dkLen, perBlock, asyncTick }; + } + function argon2Output(B, p, laneLen, dkLen) { + const B_final = new Uint32Array(256); + for (let l = 0; l < p; l++) + for (let j = 0; j < 256; j++) + B_final[j] ^= B[256 * (laneLen * l + laneLen - 1) + j]; + const res = Hp(swap32IfBE(B_final), dkLen); + clean(B, B_final); + return res; + } + function processBlock(B, address, l, r, s, index, laneLen, segmentLen, lanes, offset, prev, dataIndependent, needXor) { + if (offset % laneLen) + prev = offset - 1; + let randL, randH; + if (dataIndependent) { + let i128 = index % 128; + if (i128 === 0) { + address[256 + 12]++; + block(address, 256, 2 * 256, 0, false); + block(address, 0, 2 * 256, 0, false); + } + randL = address[2 * i128]; + randH = address[2 * i128 + 1]; + } else { + const T = 256 * prev; + randL = B[T]; + randH = B[T + 1]; + } + const refLane = r === 0 && s === 0 ? l : randH % lanes; + const refPos = indexAlpha(r, s, laneLen, segmentLen, index, randL, refLane == l); + const refBlock = laneLen * refLane + refPos; + block(B, 256 * prev, 256 * refBlock, offset * 256, needXor); + } + function argon2(type, password, salt, opts) { + const { mP, p, t, version, B, laneLen, lanes, segmentLen, dkLen, perBlock } = argon2Init(password, salt, type, opts); + const address = new Uint32Array(3 * 256); + address[256 + 6] = mP; + address[256 + 8] = t; + address[256 + 10] = type; + for (let r = 0; r < t; r++) { + const needXor = r !== 0 && version === 19; + address[256 + 0] = r; + for (let s = 0; s < ARGON2_SYNC_POINTS; s++) { + address[256 + 4] = s; + const dataIndependent = type == AT.Argon2i || type == AT.Argon2id && r === 0 && s < 2; + for (let l = 0; l < p; l++) { + address[256 + 2] = l; + address[256 + 12] = 0; + let startPos = 0; + if (r === 0 && s === 0) { + startPos = 2; + if (dataIndependent) { + address[256 + 12]++; + block(address, 256, 2 * 256, 0, false); + block(address, 0, 2 * 256, 0, false); + } + } + let offset = l * laneLen + s * segmentLen + startPos; + let prev = offset % laneLen ? offset - 1 : offset + laneLen - 1; + for (let index = startPos; index < segmentLen; index++, offset++, prev++) { + perBlock(); + processBlock(B, address, l, r, s, index, laneLen, segmentLen, lanes, offset, prev, dataIndependent, needXor); + } + } + } + } + clean(address); + return argon2Output(B, p, laneLen, dkLen); + } + var argon2id = (password, salt, opts) => argon2(AT.Argon2id, password, salt, opts); + + // entry.js + globalThis.NobleArgon2 = { argon2id }; +})(); diff --git a/js/tests/crypto.test.js b/js/tests/crypto.test.js index 327b0f7..1fcb289 100644 --- a/js/tests/crypto.test.js +++ b/js/tests/crypto.test.js @@ -69,6 +69,67 @@ test('deriveKeyAndVerifier: v2 verifier is decoupled SHA-256(keyHex + domain)', assert.notEqual(verifier, keyHex, 'v2 verifier must not leak the AES key'); }); +// ---- Argon2id KDF (js/argon2.js — vendored @noble/hashes) ------------------- + +test('vendored argon2id matches the RFC 9106 §5.3 test vector', () => { + // Independent cross-check that the bundled library is correct (not just + // self-consistent). Same vector used to validate the vendored bundle. + const out = T.NobleArgon2.argon2id( + new Uint8Array(32).fill(1), new Uint8Array(16).fill(2), + { t: 3, m: 32, p: 4, dkLen: 32, + key: new Uint8Array(8).fill(3), + personalization: new Uint8Array(12).fill(4), version: 0x13 }); + assert.equal(T.bytesToHex(out), + '0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659'); +}); + +test('isDecoupledVerifierAlgo: all -v2 markers decouple, legacy does not', () => { + assert.equal(T.isDecoupledVerifierAlgo(T.HASH_ALGO_V2), true); + assert.equal(T.isDecoupledVerifierAlgo(T.HASH_ALGO_ARGON2), true); + assert.equal(T.isDecoupledVerifierAlgo('pbkdf2-sha256'), false); + assert.equal(T.isDecoupledVerifierAlgo('pbkdf2'), false); + assert.equal(T.isDecoupledVerifierAlgo(''), false); +}); + +test('deriveKeyAndVerifier: argon2id branch derives an Argon2 key, NOT PBKDF2', async () => { + const pwd = 'correct horse battery staple'; + const saltHex = 'a1b2c3d4e5f6a1b2'; + + // The raw key bytes must equal argon2id(pwd, saltHex-utf8, params) — cross + // checked against the vendored lib directly (small params for test speed). + const params = { m: 256, t: 1, p: 1 }; + const expectKey = T.bytesToHex(T.NobleArgon2.argon2id( + new TextEncoder().encode(pwd), new TextEncoder().encode(saltHex), + { t: params.t, m: params.m, p: params.p, dkLen: 32, version: 0x13 })); + + const { cryptoKey, verifier } = await T.deriveKeyAndVerifier( + pwd, saltHex, 0, T.HASH_ALGO_ARGON2, params); + const raw = await ctx.crypto.subtle.exportKey('raw', cryptoKey); + assert.equal(T.bytesToHex(new Uint8Array(raw)), expectKey, 'AES key must be the Argon2id output'); + + // Differs from what PBKDF2 would give for the same pw/salt (different KDF). + assert.notEqual(T.bytesToHex(new Uint8Array(raw)), refKeyHex(pwd, saltHex, 100000)); + + // argon2id-v2 is a '-v2' scheme → verifier is decoupled SHA-256, not the key. + assert.equal(verifier, refSha256Hex(expectKey + T.AUTH_VERIFIER_DOMAIN)); + assert.notEqual(verifier, expectKey, 'argon2id-v2 verifier must not leak the key'); +}); + +test('deriveKeyAndVerifier: argon2id uses ARGON2_DEFAULT_PARAMS when none passed', async () => { + // OWASP baseline is the default (m=19456, t=2, p=1). Just assert the + // default object is what we expect; deriving at 19 MiB is left to one + // explicit round-trip below to keep the suite fast. + assert.deepEqual({ ...T.ARGON2_DEFAULT_PARAMS }, { m: 19456, t: 2, p: 1 }); +}); + +test('encrypt/decrypt round-trips under an Argon2id-derived key', async () => { + const { cryptoKey } = await T.deriveKeyAndVerifier( + 'master', 'saltsaltsalt', 0, T.HASH_ALGO_ARGON2, { m: 512, t: 1, p: 1 }); + T.state.cryptoKey = cryptoKey; + const { encrypted, iv } = await T.encryptPwd('argon-secret 🔐'); + assert.equal(await T.decryptPwd(encrypted, iv), 'argon-secret 🔐'); +}); + test('verifierFromKeyHex: pure mapping matches deriveKeyAndVerifier', async () => { const keyHex = 'ab'.repeat(32); assert.equal(await T.verifierFromKeyHex(keyHex, 'anything-legacy'), keyHex); diff --git a/js/tests/harness.js b/js/tests/harness.js index 6b93914..771c975 100644 --- a/js/tests/harness.js +++ b/js/tests/harness.js @@ -113,6 +113,12 @@ function loadApp(overrides = {}) { vm.createContext(sandbox); + // Load the vendored Argon2 bundle first (index.html loads it before + // app.js). It assigns globalThis.NobleArgon2 — needed by the argon2id + // KDF branch in deriveKeyBytes. + const ARGON2_JS = path.join(__dirname, '..', 'argon2.js'); + vm.runInContext(fs.readFileSync(ARGON2_JS, 'utf8'), sandbox, { filename: 'argon2.js' }); + let src = fs.readFileSync(APP_JS, 'utf8'); // Export epilogue — surface the lexical (const) symbols we test, plus a @@ -124,8 +130,10 @@ function loadApp(overrides = {}) { // crypto bytesToHex, hexToBytes: (typeof hexToBytes !== 'undefined' ? hexToBytes : undefined), verifierFromKeyHex, deriveKeyAndVerifier, computeVerifier, + deriveKeyBytes, isDecoupledVerifierAlgo, encryptPwd, decryptPwd, sha256Hex, - HASH_ALGO_V2, AUTH_VERIFIER_DOMAIN, + HASH_ALGO_V2, HASH_ALGO_ARGON2, AUTH_VERIFIER_DOMAIN, ARGON2_DEFAULT_PARAMS, + NobleArgon2: (typeof NobleArgon2 !== 'undefined' ? NobleArgon2 : undefined), // csv parseCSV, findColumn, parseEntriesFromCSV, // strength