refactor(js): extract crypto module from app.js monofile (§3.1 start)
First slice of the app.js split. Approach: ordered classic-script files loaded via separate <script> tags (argon2.js → app.crypto.js → app.js), NOT ES modules / a bundler. Classic scripts share one global lexical environment, so consts/functions cross-reference across files exactly as in the monofile — zero call-site rewrites, near-zero risk. Chosen over the audit's esbuild/ES-module suggestion because the code is written entirely in global scope (functions call each other by bare name everywhere). - js/app.crypto.js: KDF (PBKDF2 + Argon2id), verifier, AES-GCM encrypt/ decrypt, key persist/restore. Verified byte-for-byte identical to the original block before removal; no duplicate const across the two scripts. - index.html + BuildAssets whitelist + test harness updated for the load order. Harness CONCATENATES app.crypto.js + app.js (node:vm doesn't share top-level const across separate runInContext calls the way browsers share it across <script> tags); argon2.js stays a separate IIFE. - Runtime-validated: rebuilt exe unlocks via quick-unlock and loads/decrypts entries — the extracted crypto (restoreCryptoKey, verifierFromKeyHex, decryptPwd) works from the separate file. 42/42 tests green. - Docs: CLAUDE.md "Découpage frontend" (pattern + rules), file map, tests README, CODE_AUDIT §3.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -681,162 +681,11 @@ const state = {
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// CRYPTO (preserved from legacy app.js — DO NOT TOUCH)
|
||||
// CRYPTO — extracted to js/app.crypto.js (§3.1), loaded as a
|
||||
// separate <script> before this file. Kept out of the monofile
|
||||
// so the crypto core can be navigated + syntax-checked alone.
|
||||
// ============================================================
|
||||
|
||||
async function deriveKey(pwd, saltHex, iterations) {
|
||||
// Iterations parameter is the per-user value returned by the server in
|
||||
// the /login response (legacy users = 100000, modern = 600000). Falling
|
||||
// back to 100000 keeps backwards compatibility with old code paths that
|
||||
// didn't pass the value, but every new caller should pass it explicitly.
|
||||
iterations = iterations || 100000;
|
||||
const enc = new TextEncoder();
|
||||
const km = await crypto.subtle.importKey('raw', enc.encode(pwd), 'PBKDF2', false, ['deriveKey']);
|
||||
// saltHex is the same string that PHP/Delphi passed to PBKDF2 — use its bytes.
|
||||
const sb = enc.encode(saltHex);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt: sb, iterations: iterations, hash: 'SHA-256' },
|
||||
km,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true, ['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Zero-knowledge auth helpers --------------------------------
|
||||
//
|
||||
// Single PBKDF2 → both outputs at once:
|
||||
// - cryptoKey: the AES-GCM key used to encrypt entries (= raw PBKDF2 bytes)
|
||||
// - verifier: the same 32 bytes in hex form, sent to the server in place
|
||||
// of the plaintext master password. Server then SHA-256-wraps
|
||||
// it (HASH_ALGO_CURRENT) or compares directly (LEGACY) without
|
||||
// ever seeing the plaintext.
|
||||
//
|
||||
// Doing it together avoids running PBKDF2 twice. computeVerifier() is for
|
||||
// places that only need the hex (re-auth, current-pw verification on change,
|
||||
// etc.) and skips the AES-GCM importKey work.
|
||||
|
||||
function bytesToHex(arr) {
|
||||
if (arr instanceof ArrayBuffer) arr = new Uint8Array(arr);
|
||||
let hex = '';
|
||||
for (let i = 0; i < arr.length; i++)
|
||||
hex += arr[i].toString(16).padStart(2, '0');
|
||||
return hex;
|
||||
}
|
||||
|
||||
// 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 KDF output regardless of algo, so
|
||||
// entries stay decryptable and legacy accounts are unaffected.
|
||||
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 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 (isDecoupledVerifierAlgo(algo)) return await sha256Hex(keyHex + AUTH_VERIFIER_DOMAIN);
|
||||
return keyHex;
|
||||
}
|
||||
|
||||
// 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
|
||||
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, argonParams) {
|
||||
const r = await deriveKeyAndVerifier(pwd, saltHex, iterations, algo, argonParams);
|
||||
return r.verifier;
|
||||
}
|
||||
|
||||
async function encryptPwd(plain) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const enc = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv }, state.cryptoKey,
|
||||
new TextEncoder().encode(plain)
|
||||
);
|
||||
return {
|
||||
encrypted: btoa(String.fromCharCode(...new Uint8Array(enc))),
|
||||
iv: btoa(String.fromCharCode(...iv)),
|
||||
};
|
||||
}
|
||||
|
||||
async function decryptPwd(encB64, ivB64) {
|
||||
try {
|
||||
const enc = Uint8Array.from(atob(encB64), c => c.charCodeAt(0));
|
||||
const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0));
|
||||
const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, state.cryptoKey, enc);
|
||||
return new TextDecoder().decode(dec);
|
||||
} catch (e) {
|
||||
return '[ERROR]';
|
||||
}
|
||||
}
|
||||
|
||||
async function persistCryptoKey() {
|
||||
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey);
|
||||
sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw))));
|
||||
}
|
||||
|
||||
async function restoreCryptoKey() {
|
||||
const saved = sessionStorage.getItem('cryptoKey');
|
||||
if (!saved) return false;
|
||||
try {
|
||||
const raw = Uint8Array.from(atob(saved), c => c.charCodeAt(0));
|
||||
state.cryptoKey = await crypto.subtle.importKey(
|
||||
'raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HTTP HELPERS
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user