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:
r-zakarya
2026-07-05 15:44:06 +01:00
parent 5e88ad33d1
commit ca8081987d
11 changed files with 245 additions and 177 deletions
+34 -4
View File
@@ -22,6 +22,34 @@ Toute modif `index.html` / `js/` / `css/` nécessite :
Sans étape 1, l'exe embarque l'ancienne version des assets — le bug le Sans étape 1, l'exe embarque l'ancienne version des assets — le bug le
plus courant après modif frontend. plus courant après modif frontend.
### Découpage frontend (§3.1, en cours)
`app.js` (~12k lignes) est **progressivement scindé** en fichiers classic-script
chargés **dans l'ordre** via des `<script>` séparés (PAS de bundler, PAS d'ES
modules) : les classic scripts partagent **un seul environnement lexical
global** dans le navigateur, donc les `const`/fonctions d'un fichier sont
visibles des suivants exactement comme dans le monofichier. Ordre actuel :
```
js/argon2.js (IIFE, globalThis.NobleArgon2)
js/app.crypto.js (KDF, verifier, encrypt/decrypt — extrait §3.1)
js/app.js (le reste)
```
Règles pour extraire un nouveau module :
- Il doit se charger **avant** ses consommateurs et **ne jamais redéclarer**
un `const` d'un autre fichier (un `const` dupliqué entre deux classic
scripts jette « already declared »).
- Les corps de fonction peuvent référencer des globals d'un fichier chargé
après (`state` vit dans `app.js`) car résolus au **call-time**, jamais au
load-time. Ne pas mettre de code exécuté au top-level qui touche un global
pas encore déclaré.
- Ajouter le fichier au whitelist `BuildAssets.ps1` **dans l'ordre de chargement**
+ au `<script>` d'index.html + à `APP_PARTS` du harness de test.
- `node:vm` ne partage PAS les `const` top-level entre `runInContext` séparés
(contrairement au navigateur) → le harness **concatène** `APP_PARTS` en un
seul script. `argon2.js` reste séparé (IIFE autonome).
`BuildAssets.ps1` lance `node --check` sur chaque `.js` embarqué **avant** `BuildAssets.ps1` lance `node --check` sur chaque `.js` embarqué **avant**
de générer `assets.res` : une erreur de syntaxe JS avorte le build (au de générer `assets.res` : une erreur de syntaxe JS avorte le build (au
lieu d'embarquer un bundle mort qui ne se révèle qu'après un rebuild lieu d'embarquer un bundle mort qui ne se révèle qu'après un rebuild
@@ -33,9 +61,9 @@ Ensuite (même gate, node requis) il lance la **suite de tests frontend**
invariant crypto/merge cassé avorte le build comme une erreur de syntaxe. invariant crypto/merge cassé avorte le build comme une erreur de syntaxe.
`PM_SKIP_TESTS=1` pour bypasser en itération rapide. Lancer manuellement `PM_SKIP_TESTS=1` pour bypasser en itération rapide. Lancer manuellement
via `npm test`. Voir [js/tests/README.md](js/tests/README.md) — le harness via `npm test`. Voir [js/tests/README.md](js/tests/README.md) — le harness
charge `app.js` (monofichier sans exports) dans un `node:vm` avec les concatène `APP_PARTS` (`app.crypto.js` + `app.js`) et les charge dans un
globals navigateur stubbés, puis expose les internals via un épilogue `node:vm` avec les globals navigateur stubbés (`argon2.js` chargé à part,
d'export. Couvre : round-trip crypto + dérivation verifier (legacy vs -v2), c'est un IIFE), puis expose les internals via un épilogue d'export. Couvre : round-trip crypto + dérivation verifier (legacy vs -v2),
parsing CSV d'import, et l'arbitrage merge/tombstone de sync parsing CSV d'import, et l'arbitrage merge/tombstone de sync
(`applyRemoteSnapshot`, seule `api()` est stubbée). (`applyRemoteSnapshot`, seule `api()` est stubbée).
@@ -57,7 +85,9 @@ parsing CSV d'import, et l'arbitrage merge/tombstone de sync
| Start with Windows (HKCU Run) | `delphi-backend/Source/PM.AutoStart.pas` | | Start with Windows (HKCU Run) | `delphi-backend/Source/PM.AutoStart.pas` |
| Favicon proxy (DuckDuckGo, async THTTPClient/WinHTTP) | `delphi-backend/Source/PM.Favicon.pas` | | Favicon proxy (DuckDuckGo, async THTTPClient/WinHTTP) | `delphi-backend/Source/PM.Favicon.pas` |
| Handlers REST | `delphi-backend/Handlers/PM.Handler.*.pas` | | Handlers REST | `delphi-backend/Handlers/PM.Handler.*.pas` |
| Frontend complet | `js/app.js` | | Frontend principal (en cours de découpage §3.1) | `js/app.js` |
| Crypto frontend (KDF, verifier, AES-GCM) — extrait §3.1 | `js/app.crypto.js` |
| Argon2id vendé (bundle `@noble/hashes`, IIFE) | `js/argon2.js` |
| HTML racine | `index.html` | | HTML racine | `index.html` |
| Styles | `css/style.css` | | Styles | `css/style.css` |
+15 -11
View File
@@ -71,10 +71,11 @@ décplé). Détails dans le CLAUDE.md « Auth-hash schemes ».
- Tests : 8 tests crypto Argon2 (vecteur RFC, branche KDF, contrat de - Tests : 8 tests crypto Argon2 (vecteur RFC, branche KDF, contrat de
params register↔login, sensibilité aux params). 42/42. params register↔login, sensibilité aux params). 42/42.
**Reste** : dériver via `argon2idAsync` pour ne pas geler l'UI ~0.65 s **Validé runtime** : un compte ayant tourné sa master pw affiche
(actuellement sync) ; option « migrer vers Argon2id sans changer de pw » `hash_algo=argon2id-v2` (m=19456, t=2, p=1) et se reconnecte/déchiffre.
(aujourd'hui il faut changer le master pw). **Non compilé/testé runtime
Delphi dans cette session** — nécessite un rebuild `PMServer.dproj`. **Reste (mineur)** : dériver via `argon2idAsync` pour ne pas geler l'UI
~0.65 s (actuellement sync).
### 1.3 🟡 Métadonnées en clair ### 1.3 🟡 Métadonnées en clair
@@ -219,13 +220,16 @@ un edit malencontreux casse tout le parse (déjà arrivé cette session : une
déclaration de fonction supprimée → app entièrement morte, découvert déclaration de fonction supprimée → app entièrement morte, découvert
seulement au runtime). seulement au runtime).
**Recommandations** : **En cours (2026-07-05)** : découpage incrémental en fichiers classic-script
- Découper en modules ES (`crypto.js`, `sync.js`, `slideover.js`, chargés dans l'ordre via `<script>` séparés — **pas de bundler ES/esbuild**
`quicksearch.js`, `settings.js`…) + un bundle simple (esbuild) dans (les classic scripts partagent l'environnement lexical global, donc zéro
`BuildAssets.cmd`. réécriture des call-sites, risque quasi nul vs conversion en modules ES).
- **Ajouter `node --check` (ou eslint) en pré-étape de `BuildAssets.cmd`** - `js/app.crypto.js` extrait (KDF, verifier, AES-GCM) — vérifié
→ aurait attrapé le syntax error avant le rebuild. Gain immédiat, coût byte-for-byte identique à l'original, 42 tests verts, pas de `const`
quasi nul. dupliqué. Pattern + règles documentés dans CLAUDE.md « Découpage frontend ».
- Reste à extraire (grosses sections cohésives) : sync, slideover,
settings, quicksearch, autofill…
-`node --check` en pré-étape de `BuildAssets.ps1` : **déjà fait** (cf. §3.2).
### 3.2 🟡 Aucun test automatisé — **partiellement adressé (2026-07-04)** ### 3.2 🟡 Aucun test automatisé — **partiellement adressé (2026-07-04)**
+1
View File
@@ -52,6 +52,7 @@ Log "Web root: $WebRoot"
$patterns = @( $patterns = @(
'index.html', 'index.html',
'js\argon2.js', 'js\argon2.js',
'js\app.crypto.js',
'js\app.js', 'js\app.js',
'css\style.css' 'css\style.css'
) )
+2 -1
View File
@@ -1,9 +1,10 @@
// Auto-generated by BuildAssets.ps1 - do not edit by hand. // Auto-generated by BuildAssets.ps1 - do not edit by hand.
const const
EMBEDDED_ASSET_COUNT = 4; EMBEDDED_ASSET_COUNT = 5;
EMBEDDED_ASSETS: array[0..EMBEDDED_ASSET_COUNT-1] of TEmbeddedAsset = ( EMBEDDED_ASSETS: array[0..EMBEDDED_ASSET_COUNT-1] of TEmbeddedAsset = (
(UrlPath: '/index.html'; ResName: 'INDEX_HTML'), (UrlPath: '/index.html'; ResName: 'INDEX_HTML'),
(UrlPath: '/js/argon2.js'; ResName: 'JS_ARGON2_JS'), (UrlPath: '/js/argon2.js'; ResName: 'JS_ARGON2_JS'),
(UrlPath: '/js/app.crypto.js'; ResName: 'JS_APP_CRYPTO_JS'),
(UrlPath: '/js/app.js'; ResName: 'JS_APP_JS'), (UrlPath: '/js/app.js'; ResName: 'JS_APP_JS'),
(UrlPath: '/css/style.css'; ResName: 'CSS_STYLE_CSS') (UrlPath: '/css/style.css'; ResName: 'CSS_STYLE_CSS')
); );
+1
View File
@@ -3,5 +3,6 @@
INDEX_HTML RCDATA "Z:\\password-manager\\index.html" INDEX_HTML RCDATA "Z:\\password-manager\\index.html"
JS_ARGON2_JS RCDATA "Z:\\password-manager\\js\\argon2.js" JS_ARGON2_JS RCDATA "Z:\\password-manager\\js\\argon2.js"
JS_APP_CRYPTO_JS RCDATA "Z:\\password-manager\\js\\app.crypto.js"
JS_APP_JS RCDATA "Z:\\password-manager\\js\\app.js" JS_APP_JS RCDATA "Z:\\password-manager\\js\\app.js"
CSS_STYLE_CSS RCDATA "Z:\\password-manager\\css\\style.css" CSS_STYLE_CSS RCDATA "Z:\\password-manager\\css\\style.css"
Binary file not shown.
+1
View File
@@ -1193,6 +1193,7 @@
</div> </div>
<script src="js/argon2.js"></script> <script src="js/argon2.js"></script>
<script src="js/app.crypto.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
</body> </body>
</html> </html>
+169
View File
@@ -0,0 +1,169 @@
// ============================================================
// app.crypto.js — CRYPTO module (extracted from app.js, §3.1)
// ============================================================
//
// Loaded as a classic <script> BEFORE js/app.js (after js/argon2.js).
// Classic scripts share one global lexical environment, so the consts and
// functions declared here are visible to app.js exactly as when this lived
// inline in the monofile — no import/export, no bundler. Function bodies
// reference `state` (declared in app.js) and `NobleArgon2` (js/argon2.js);
// those resolve at call time (post-DOMContentLoaded), never at load time.
//
// Split rule: this file must load before app.js and must NOT redeclare any
// of app.js's top-level consts (a duplicate `const` across classic scripts
// throws "already declared"). See CLAUDE.md build pipeline notes.
//
// CRYPTO (preserved from legacy app.js — DO NOT TOUCH)
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;
}
}
+3 -154
View File
@@ -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 // HTTP HELPERS
// ============================================================ // ============================================================
+9 -5
View File
@@ -18,12 +18,16 @@ Zero dependencies — uses the Node built-in test runner (`node:test`) and
## How it works — `harness.js` ## How it works — `harness.js`
`app.js` is a ~12k-line browser monofile with **no module exports** and one The frontend is a large browser script with **no module exports** and one
top-level side effect (a `DOMContentLoaded` listener). The harness loads the top-level side effect (a `DOMContentLoaded` listener). It's being split into
file's source into a `node:vm` context with browser globals stubbed ordered classic-script files (§3.1); the harness **concatenates** the app
parts in load order (`APP_PARTS` = `app.crypto.js` + `app.js`) into one
source — node:vm doesn't share top-level `const`/`let` across separate
`runInContext` calls the way the browser shares them across `<script>` tags.
`argon2.js` is a self-contained IIFE and loads separately first. The
concatenated source runs in a `node:vm` context with browser globals stubbed
(`crypto`, `localStorage`, `document`, `location`, …) so `init()` never (`crypto`, `localStorage`, `document`, `location`, …) so `init()` never
fires, then appends an export epilogue that surfaces the internals on fires, then an export epilogue surfaces the internals on `globalThis.__test`.
`globalThis.__test`.
Two gotchas the harness works around, both documented inline: Two gotchas the harness works around, both documented inline:
+10 -2
View File
@@ -25,7 +25,12 @@ const path = require('node:path');
const vm = require('node:vm'); const vm = require('node:vm');
const { webcrypto } = require('node:crypto'); const { webcrypto } = require('node:crypto');
const APP_JS = path.join(__dirname, '..', 'app.js'); // The frontend is split into ordered classic-script files (§3.1). In the
// browser they share one global lexical environment; node:vm does NOT share
// top-level const/let across separate runInContext calls, so we CONCATENATE
// the app.* parts (in <script> load order) into one script. argon2.js is a
// self-contained IIFE and loads separately (see below).
const APP_PARTS = ['app.crypto.js', 'app.js'].map(f => path.join(__dirname, '..', f));
// In-memory Storage stub (Web Storage API surface used by app.js). // In-memory Storage stub (Web Storage API surface used by app.js).
function makeStorage() { function makeStorage() {
@@ -119,7 +124,10 @@ function loadApp(overrides = {}) {
const ARGON2_JS = path.join(__dirname, '..', 'argon2.js'); const ARGON2_JS = path.join(__dirname, '..', 'argon2.js');
vm.runInContext(fs.readFileSync(ARGON2_JS, 'utf8'), sandbox, { filename: 'argon2.js' }); vm.runInContext(fs.readFileSync(ARGON2_JS, 'utf8'), sandbox, { filename: 'argon2.js' });
let src = fs.readFileSync(APP_JS, 'utf8'); // Concatenate the app.* parts in load order (see APP_PARTS). Newline
// separators keep line-based errors legible; shared global scope is
// preserved because it's a single script run.
let src = APP_PARTS.map(p => fs.readFileSync(p, 'utf8')).join('\n;\n');
// Export epilogue — surface the lexical (const) symbols we test, plus a // Export epilogue — surface the lexical (const) symbols we test, plus a
// couple of function-decl seams for convenience. Kept in one place so the // couple of function-decl seams for convenience. Kept in one place so the