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:
@@ -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
@@ -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)**
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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')
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user