refactor(js): extract TOTP module + add RFC 6238 tests (§3.1)
Fourth slice of the app.js split. Moves TOTP (base32Decode, generateTOTP, parseOtpAuthUri) plus the TOTP-secret and custom-field AES-GCM wrappers to js/app.totp.js. Loads before app.js (pure declarations), after app.crypto.js (uses encryptPwd/decryptPwd). Also called by app.import.js and app.sync.js via shared global scope. - Byte-for-byte identical extraction; no duplicate const; syntax OK on all five app parts. - NEW: js/tests/totp.test.js — 13 tests including the 5 RFC 6238 Appendix B reference vectors (generateTOTP reads Date.now(), so each case stubs the sandbox clock to the vector's fixed time), base32 decode edge cases, and parseOtpAuthUri. Extraction AND new coverage in one slice. - Suite: 42 → 55 tests, all green. - Assets regenerated (manifest now embeds all 6 ordered JS files: argon2 → crypto → totp → import → app → sync); also fixes the previous import commit's not-yet-rebuilt manifest. - Delphi build artifacts (*.vrc, *.$manifest) gitignored. app.js: 11936 → 10138 lines (4 modules extracted, ~1800 lines). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -56,3 +56,7 @@ delphi-backend/vault - Copie*.db
|
|||||||
*.png
|
*.png
|
||||||
*.jpg
|
*.jpg
|
||||||
*.jpeg
|
*.jpeg
|
||||||
|
|
||||||
|
# Delphi build artifacts
|
||||||
|
delphi-backend/*.vrc
|
||||||
|
delphi-backend/*.$manifest
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ visibles des suivants exactement comme dans le monofichier. Ordre actuel :
|
|||||||
```
|
```
|
||||||
js/argon2.js (IIFE, globalThis.NobleArgon2)
|
js/argon2.js (IIFE, globalThis.NobleArgon2)
|
||||||
js/app.crypto.js (KDF, verifier, encrypt/decrypt — extrait §3.1)
|
js/app.crypto.js (KDF, verifier, encrypt/decrypt — extrait §3.1)
|
||||||
|
js/app.totp.js (TOTP RFC 6238 + TOTP/custom-field crypto — extrait §3.1)
|
||||||
js/app.import.js (export container + import CSV/JSON — extrait §3.1)
|
js/app.import.js (export container + import CSV/JSON — extrait §3.1)
|
||||||
js/app.js (le reste : state, Bridge, api, UI…)
|
js/app.js (le reste : state, Bridge, api, UI…)
|
||||||
js/app.sync.js (WebDAV + merge — extrait §3.1, APRÈS app.js car
|
js/app.sync.js (WebDAV + merge — extrait §3.1, APRÈS app.js car
|
||||||
@@ -75,9 +76,10 @@ invariant crypto/merge cassé avorte le build comme une erreur de syntaxe.
|
|||||||
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
|
||||||
concatène `APP_PARTS` (`app.crypto.js` + `app.js`) et les charge dans un
|
concatène `APP_PARTS` (`app.crypto.js` + `app.js`) et les charge dans un
|
||||||
`node:vm` avec les globals navigateur stubbés (`argon2.js` chargé à part,
|
`node:vm` avec les globals navigateur stubbés (`argon2.js` chargé à part,
|
||||||
c'est un IIFE), puis expose les internals via un épilogue 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
|
+ Argon2id (vecteur RFC 9106), TOTP (vecteurs RFC 6238), parsing CSV
|
||||||
(`applyRemoteSnapshot`, seule `api()` est stubbée).
|
d'import, et l'arbitrage merge/tombstone de sync (`applyRemoteSnapshot`,
|
||||||
|
seule `api()` est stubbée).
|
||||||
|
|
||||||
## Carte des fichiers
|
## Carte des fichiers
|
||||||
|
|
||||||
@@ -99,6 +101,7 @@ parsing CSV d'import, et l'arbitrage merge/tombstone de sync
|
|||||||
| Handlers REST | `delphi-backend/Handlers/PM.Handler.*.pas` |
|
| Handlers REST | `delphi-backend/Handlers/PM.Handler.*.pas` |
|
||||||
| Frontend principal (en cours de découpage §3.1) | `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` |
|
| Crypto frontend (KDF, verifier, AES-GCM) — extrait §3.1 | `js/app.crypto.js` |
|
||||||
|
| TOTP (RFC 6238) + TOTP/custom-field crypto — extrait §3.1 | `js/app.totp.js` |
|
||||||
| Import/export frontend (CSV/JSON parse, export container) — extrait §3.1 | `js/app.import.js` |
|
| Import/export frontend (CSV/JSON parse, export container) — extrait §3.1 | `js/app.import.js` |
|
||||||
| Sync frontend (WebDAV, snapshot, merge) — extrait §3.1 | `js/app.sync.js` |
|
| Sync frontend (WebDAV, snapshot, merge) — extrait §3.1 | `js/app.sync.js` |
|
||||||
| Argon2id vendé (bundle `@noble/hashes`, IIFE) | `js/argon2.js` |
|
| Argon2id vendé (bundle `@noble/hashes`, IIFE) | `js/argon2.js` |
|
||||||
|
|||||||
+6
-2
@@ -233,9 +233,13 @@ réécriture des call-sites, risque quasi nul vs conversion en modules ES).
|
|||||||
`doImport`/`doExport`) — byte-for-byte identique, couvert par les 14 tests
|
`doImport`/`doExport`) — byte-for-byte identique, couvert par les 14 tests
|
||||||
CSV, chargé AVANT app.js (pures déclarations). Pattern + règles d'ordre
|
CSV, chargé AVANT app.js (pures déclarations). Pattern + règles d'ordre
|
||||||
documentés dans CLAUDE.md « Découpage frontend ».
|
documentés dans CLAUDE.md « Découpage frontend ».
|
||||||
- `app.js` : 11 936 → **10 253 lignes** (crypto + sync + import sortis).
|
- ✅ `js/app.totp.js` extrait (TOTP RFC 6238 + crypto TOTP/custom-fields) —
|
||||||
|
byte-for-byte identique, chargé AVANT app.js. **+13 tests TOTP (vecteurs
|
||||||
|
RFC 6238)** ajoutés en même temps → extraction *et* nouvelle couverture.
|
||||||
|
- `app.js` : 11 936 → **10 138 lignes** (crypto + totp + sync + import sortis).
|
||||||
|
- Suite de tests : 42 → **55 tests**.
|
||||||
- Reste à extraire (grosses sections cohésives) : slideover, settings,
|
- Reste à extraire (grosses sections cohésives) : slideover, settings,
|
||||||
quicksearch, autofill, auto-backup…
|
quicksearch, autofill, auto-backup, favicons…
|
||||||
- ✅ `node --check` en pré-étape de `BuildAssets.ps1` : **déjà fait** (cf. §3.2).
|
- ✅ `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)**
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ $patterns = @(
|
|||||||
'index.html',
|
'index.html',
|
||||||
'js\argon2.js',
|
'js\argon2.js',
|
||||||
'js\app.crypto.js',
|
'js\app.crypto.js',
|
||||||
|
'js\app.totp.js',
|
||||||
'js\app.import.js',
|
'js\app.import.js',
|
||||||
'js\app.js',
|
'js\app.js',
|
||||||
'js\app.sync.js',
|
'js\app.sync.js',
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// 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 = 6;
|
EMBEDDED_ASSET_COUNT = 8;
|
||||||
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.crypto.js'; ResName: 'JS_APP_CRYPTO_JS'),
|
||||||
|
(UrlPath: '/js/app.totp.js'; ResName: 'JS_APP_TOTP_JS'),
|
||||||
|
(UrlPath: '/js/app.import.js'; ResName: 'JS_APP_IMPORT_JS'),
|
||||||
(UrlPath: '/js/app.js'; ResName: 'JS_APP_JS'),
|
(UrlPath: '/js/app.js'; ResName: 'JS_APP_JS'),
|
||||||
(UrlPath: '/js/app.sync.js'; ResName: 'JS_APP_SYNC_JS'),
|
(UrlPath: '/js/app.sync.js'; ResName: 'JS_APP_SYNC_JS'),
|
||||||
(UrlPath: '/css/style.css'; ResName: 'CSS_STYLE_CSS')
|
(UrlPath: '/css/style.css'; ResName: 'CSS_STYLE_CSS')
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
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_CRYPTO_JS RCDATA "Z:\\password-manager\\js\\app.crypto.js"
|
||||||
|
JS_APP_TOTP_JS RCDATA "Z:\\password-manager\\js\\app.totp.js"
|
||||||
|
JS_APP_IMPORT_JS RCDATA "Z:\\password-manager\\js\\app.import.js"
|
||||||
JS_APP_JS RCDATA "Z:\\password-manager\\js\\app.js"
|
JS_APP_JS RCDATA "Z:\\password-manager\\js\\app.js"
|
||||||
JS_APP_SYNC_JS RCDATA "Z:\\password-manager\\js\\app.sync.js"
|
JS_APP_SYNC_JS RCDATA "Z:\\password-manager\\js\\app.sync.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.
@@ -1194,6 +1194,7 @@
|
|||||||
|
|
||||||
<script src="js/argon2.js"></script>
|
<script src="js/argon2.js"></script>
|
||||||
<script src="js/app.crypto.js"></script>
|
<script src="js/app.crypto.js"></script>
|
||||||
|
<script src="js/app.totp.js"></script>
|
||||||
<script src="js/app.import.js"></script>
|
<script src="js/app.import.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
<script src="js/app.sync.js"></script>
|
<script src="js/app.sync.js"></script>
|
||||||
|
|||||||
@@ -713,124 +713,9 @@ async function api(path, opts) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// TOTP (RFC 6238) — 6-digit time-based codes
|
// TOTP + TOTP/custom-field crypto — extracted to js/app.totp.js
|
||||||
|
// (§3.1), loaded as a separate <script> before this file.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
//
|
|
||||||
// Implementation is pure crypto.subtle (HMAC-SHA1) + a small base32
|
|
||||||
// decoder. No external library. The secret is stored encrypted with the
|
|
||||||
// vault's AES-GCM key (same flow as passwords), so the server never sees
|
|
||||||
// the plaintext base32 secret.
|
|
||||||
|
|
||||||
// Decode an RFC 4648 base32 string (Google Authenticator format) to bytes.
|
|
||||||
// Tolerates lowercase, spaces, and padding. Throws on invalid characters.
|
|
||||||
function base32Decode(s) {
|
|
||||||
const ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
||||||
const clean = String(s).toUpperCase().replace(/[\s=]/g, '');
|
|
||||||
let bits = 0, buffer = 0;
|
|
||||||
const out = [];
|
|
||||||
for (const ch of clean) {
|
|
||||||
const v = ALPH.indexOf(ch);
|
|
||||||
if (v < 0) throw new Error('Invalid base32 character: ' + ch);
|
|
||||||
buffer = (buffer << 5) | v;
|
|
||||||
bits += 5;
|
|
||||||
if (bits >= 8) {
|
|
||||||
bits -= 8;
|
|
||||||
out.push((buffer >> bits) & 0xFF);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Uint8Array(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a TOTP code per RFC 6238. Returns the 6-digit code as a string
|
|
||||||
// (zero-padded) along with how many seconds remain in the current 30s window.
|
|
||||||
// Throws if the secret can't be decoded.
|
|
||||||
async function generateTOTP(secretBase32, period, digits) {
|
|
||||||
period = period || 30;
|
|
||||||
digits = digits || 6;
|
|
||||||
const keyBytes = base32Decode(secretBase32);
|
|
||||||
// Counter = floor(unix_time / period), encoded as 8-byte big-endian.
|
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
|
||||||
let counter = Math.floor(nowSec / period);
|
|
||||||
const counterBytes = new Uint8Array(8);
|
|
||||||
for (let i = 7; i >= 0; i--) {
|
|
||||||
counterBytes[i] = counter & 0xFF;
|
|
||||||
counter = Math.floor(counter / 256);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cryptoKey = await crypto.subtle.importKey(
|
|
||||||
'raw', keyBytes,
|
|
||||||
{ name: 'HMAC', hash: 'SHA-1' },
|
|
||||||
false, ['sign']
|
|
||||||
);
|
|
||||||
const sigBuf = await crypto.subtle.sign('HMAC', cryptoKey, counterBytes);
|
|
||||||
const sig = new Uint8Array(sigBuf);
|
|
||||||
|
|
||||||
// Dynamic truncation: low nibble of last byte = offset into HMAC output.
|
|
||||||
const offset = sig[sig.length - 1] & 0x0F;
|
|
||||||
const truncated =
|
|
||||||
((sig[offset] & 0x7F) << 24) |
|
|
||||||
((sig[offset + 1] & 0xFF) << 16) |
|
|
||||||
((sig[offset + 2] & 0xFF) << 8) |
|
|
||||||
( sig[offset + 3] & 0xFF);
|
|
||||||
const mod = Math.pow(10, digits);
|
|
||||||
const code = String(truncated % mod).padStart(digits, '0');
|
|
||||||
|
|
||||||
return {
|
|
||||||
code: code,
|
|
||||||
period: period,
|
|
||||||
secondsLeft: period - (nowSec % period),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse a Google Authenticator-style otpauth:// URI and extract the secret.
|
|
||||||
// Example: otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example
|
|
||||||
// Returns the secret alone (we don't yet honor issuer/algorithm/digits/period
|
|
||||||
// overrides — assume SHA-1 / 6 digits / 30s, which covers ~all real services).
|
|
||||||
function parseOtpAuthUri(raw) {
|
|
||||||
raw = String(raw || '').trim();
|
|
||||||
if (!raw.toLowerCase().startsWith('otpauth://')) return null;
|
|
||||||
try {
|
|
||||||
const u = new URL(raw);
|
|
||||||
const sec = u.searchParams.get('secret');
|
|
||||||
return sec ? sec.trim() : null;
|
|
||||||
} catch (e) { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt a TOTP secret with the vault key. Returns { encrypted, iv } in
|
|
||||||
// the same base64 shape as encryptPwd, ready to send to the server.
|
|
||||||
async function encryptTotpSecret(secretBase32) {
|
|
||||||
return await encryptPwd(secretBase32); // same crypto, just different field
|
|
||||||
}
|
|
||||||
async function decryptTotpSecret(encB64, ivB64) {
|
|
||||||
return await decryptPwd(encB64, ivB64);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Custom fields (per-entry encrypted JSON array) ----------------
|
|
||||||
//
|
|
||||||
// Stored as:
|
|
||||||
// vault_entries.custom_fields = base64 AES-GCM ciphertext of JSON
|
|
||||||
// vault_entries.custom_fields_iv = base64 12-byte IV
|
|
||||||
// Plaintext shape:
|
|
||||||
// [{ "label": "PIN", "value": "1234", "is_secret": true }, ...]
|
|
||||||
//
|
|
||||||
// Same crypto pipeline as encrypted_password (reuses encryptPwd /
|
|
||||||
// decryptPwd over the JSON string) so the master-pw rotation logic
|
|
||||||
// works without any special-casing — it just sees one more ciphertext
|
|
||||||
// blob per entry to re-encrypt.
|
|
||||||
async function encryptCustomFields(fieldsArray) {
|
|
||||||
if (!Array.isArray(fieldsArray) || fieldsArray.length === 0)
|
|
||||||
return { encrypted: '', iv: '' };
|
|
||||||
return await encryptPwd(JSON.stringify(fieldsArray));
|
|
||||||
}
|
|
||||||
async function decryptCustomFields(encB64, ivB64) {
|
|
||||||
if (!encB64 || !ivB64) return [];
|
|
||||||
const plain = await decryptPwd(encB64, ivB64);
|
|
||||||
if (plain === '[ERROR]' || !plain) return [];
|
|
||||||
try {
|
|
||||||
const arr = JSON.parse(plain);
|
|
||||||
return Array.isArray(arr) ? arr : [];
|
|
||||||
} catch (e) { return []; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// FAVICONS (opt-in, cached server-side as base64 data URI)
|
// FAVICONS (opt-in, cached server-side as base64 data URI)
|
||||||
|
|||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
// ============================================================
|
||||||
|
// app.totp.js — TOTP + TOTP-secret/custom-field crypto (extracted §3.1)
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// RFC 6238 TOTP generation (base32Decode, generateTOTP, parseOtpAuthUri) plus
|
||||||
|
// the small AES-GCM wrappers for TOTP secrets and custom fields (they mirror
|
||||||
|
// encryptPwd/decryptPwd from app.crypto.js). Pure declarations, no top-level
|
||||||
|
// side effects → loads BEFORE app.js, AFTER app.crypto.js (uses encryptPwd/
|
||||||
|
// decryptPwd). Also used by app.import.js (encryptCustomFields) and
|
||||||
|
// app.sync.js (decryptTotpSecret/decryptCustomFields) via shared global scope.
|
||||||
|
//
|
||||||
|
// ============================================================
|
||||||
|
// TOTP (RFC 6238) — 6-digit time-based codes
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// Implementation is pure crypto.subtle (HMAC-SHA1) + a small base32
|
||||||
|
// decoder. No external library. The secret is stored encrypted with the
|
||||||
|
// vault's AES-GCM key (same flow as passwords), so the server never sees
|
||||||
|
// the plaintext base32 secret.
|
||||||
|
|
||||||
|
// Decode an RFC 4648 base32 string (Google Authenticator format) to bytes.
|
||||||
|
// Tolerates lowercase, spaces, and padding. Throws on invalid characters.
|
||||||
|
function base32Decode(s) {
|
||||||
|
const ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
const clean = String(s).toUpperCase().replace(/[\s=]/g, '');
|
||||||
|
let bits = 0, buffer = 0;
|
||||||
|
const out = [];
|
||||||
|
for (const ch of clean) {
|
||||||
|
const v = ALPH.indexOf(ch);
|
||||||
|
if (v < 0) throw new Error('Invalid base32 character: ' + ch);
|
||||||
|
buffer = (buffer << 5) | v;
|
||||||
|
bits += 5;
|
||||||
|
if (bits >= 8) {
|
||||||
|
bits -= 8;
|
||||||
|
out.push((buffer >> bits) & 0xFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Uint8Array(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a TOTP code per RFC 6238. Returns the 6-digit code as a string
|
||||||
|
// (zero-padded) along with how many seconds remain in the current 30s window.
|
||||||
|
// Throws if the secret can't be decoded.
|
||||||
|
async function generateTOTP(secretBase32, period, digits) {
|
||||||
|
period = period || 30;
|
||||||
|
digits = digits || 6;
|
||||||
|
const keyBytes = base32Decode(secretBase32);
|
||||||
|
// Counter = floor(unix_time / period), encoded as 8-byte big-endian.
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
let counter = Math.floor(nowSec / period);
|
||||||
|
const counterBytes = new Uint8Array(8);
|
||||||
|
for (let i = 7; i >= 0; i--) {
|
||||||
|
counterBytes[i] = counter & 0xFF;
|
||||||
|
counter = Math.floor(counter / 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cryptoKey = await crypto.subtle.importKey(
|
||||||
|
'raw', keyBytes,
|
||||||
|
{ name: 'HMAC', hash: 'SHA-1' },
|
||||||
|
false, ['sign']
|
||||||
|
);
|
||||||
|
const sigBuf = await crypto.subtle.sign('HMAC', cryptoKey, counterBytes);
|
||||||
|
const sig = new Uint8Array(sigBuf);
|
||||||
|
|
||||||
|
// Dynamic truncation: low nibble of last byte = offset into HMAC output.
|
||||||
|
const offset = sig[sig.length - 1] & 0x0F;
|
||||||
|
const truncated =
|
||||||
|
((sig[offset] & 0x7F) << 24) |
|
||||||
|
((sig[offset + 1] & 0xFF) << 16) |
|
||||||
|
((sig[offset + 2] & 0xFF) << 8) |
|
||||||
|
( sig[offset + 3] & 0xFF);
|
||||||
|
const mod = Math.pow(10, digits);
|
||||||
|
const code = String(truncated % mod).padStart(digits, '0');
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: code,
|
||||||
|
period: period,
|
||||||
|
secondsLeft: period - (nowSec % period),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a Google Authenticator-style otpauth:// URI and extract the secret.
|
||||||
|
// Example: otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example
|
||||||
|
// Returns the secret alone (we don't yet honor issuer/algorithm/digits/period
|
||||||
|
// overrides — assume SHA-1 / 6 digits / 30s, which covers ~all real services).
|
||||||
|
function parseOtpAuthUri(raw) {
|
||||||
|
raw = String(raw || '').trim();
|
||||||
|
if (!raw.toLowerCase().startsWith('otpauth://')) return null;
|
||||||
|
try {
|
||||||
|
const u = new URL(raw);
|
||||||
|
const sec = u.searchParams.get('secret');
|
||||||
|
return sec ? sec.trim() : null;
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt a TOTP secret with the vault key. Returns { encrypted, iv } in
|
||||||
|
// the same base64 shape as encryptPwd, ready to send to the server.
|
||||||
|
async function encryptTotpSecret(secretBase32) {
|
||||||
|
return await encryptPwd(secretBase32); // same crypto, just different field
|
||||||
|
}
|
||||||
|
async function decryptTotpSecret(encB64, ivB64) {
|
||||||
|
return await decryptPwd(encB64, ivB64);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Custom fields (per-entry encrypted JSON array) ----------------
|
||||||
|
//
|
||||||
|
// Stored as:
|
||||||
|
// vault_entries.custom_fields = base64 AES-GCM ciphertext of JSON
|
||||||
|
// vault_entries.custom_fields_iv = base64 12-byte IV
|
||||||
|
// Plaintext shape:
|
||||||
|
// [{ "label": "PIN", "value": "1234", "is_secret": true }, ...]
|
||||||
|
//
|
||||||
|
// Same crypto pipeline as encrypted_password (reuses encryptPwd /
|
||||||
|
// decryptPwd over the JSON string) so the master-pw rotation logic
|
||||||
|
// works without any special-casing — it just sees one more ciphertext
|
||||||
|
// blob per entry to re-encrypt.
|
||||||
|
async function encryptCustomFields(fieldsArray) {
|
||||||
|
if (!Array.isArray(fieldsArray) || fieldsArray.length === 0)
|
||||||
|
return { encrypted: '', iv: '' };
|
||||||
|
return await encryptPwd(JSON.stringify(fieldsArray));
|
||||||
|
}
|
||||||
|
async function decryptCustomFields(encB64, ivB64) {
|
||||||
|
if (!encB64 || !ivB64) return [];
|
||||||
|
const plain = await decryptPwd(encB64, ivB64);
|
||||||
|
if (plain === '[ERROR]' || !plain) return [];
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(plain);
|
||||||
|
return Array.isArray(arr) ? arr : [];
|
||||||
|
} catch (e) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
+3
-1
@@ -30,7 +30,7 @@ const { webcrypto } = require('node:crypto');
|
|||||||
// top-level const/let across separate runInContext calls, so we CONCATENATE
|
// 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
|
// the app.* parts (in <script> load order) into one script. argon2.js is a
|
||||||
// self-contained IIFE and loads separately (see below).
|
// self-contained IIFE and loads separately (see below).
|
||||||
const APP_PARTS = ['app.crypto.js', 'app.import.js', 'app.js', 'app.sync.js'].map(f => path.join(__dirname, '..', f));
|
const APP_PARTS = ['app.crypto.js', 'app.totp.js', 'app.import.js', 'app.js', 'app.sync.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() {
|
||||||
@@ -142,6 +142,8 @@ function loadApp(overrides = {}) {
|
|||||||
encryptPwd, decryptPwd, sha256Hex,
|
encryptPwd, decryptPwd, sha256Hex,
|
||||||
HASH_ALGO_V2, HASH_ALGO_ARGON2, AUTH_VERIFIER_DOMAIN, ARGON2_DEFAULT_PARAMS,
|
HASH_ALGO_V2, HASH_ALGO_ARGON2, AUTH_VERIFIER_DOMAIN, ARGON2_DEFAULT_PARAMS,
|
||||||
NobleArgon2: (typeof NobleArgon2 !== 'undefined' ? NobleArgon2 : undefined),
|
NobleArgon2: (typeof NobleArgon2 !== 'undefined' ? NobleArgon2 : undefined),
|
||||||
|
// totp
|
||||||
|
base32Decode, generateTOTP, parseOtpAuthUri,
|
||||||
// csv
|
// csv
|
||||||
parseCSV, findColumn, parseEntriesFromCSV,
|
parseCSV, findColumn, parseEntriesFromCSV,
|
||||||
// strength
|
// strength
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// TOTP tests (js/app.totp.js) — cross-checked against the RFC 6238 Appendix B
|
||||||
|
// reference vectors. generateTOTP reads Date.now() internally, so each case
|
||||||
|
// stubs the sandbox clock to the vector's fixed time.
|
||||||
|
//
|
||||||
|
// RFC 6238 secret = ASCII "12345678901234567890" (20 bytes) → base32
|
||||||
|
// "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ". Appendix B lists 8-digit SHA-1 codes;
|
||||||
|
// generateTOTP defaults to 6 digits, so the expectations are the last 6.
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { loadApp } = require('./harness.js');
|
||||||
|
|
||||||
|
const ctx = loadApp();
|
||||||
|
const T = ctx.__test;
|
||||||
|
|
||||||
|
const RFC_SECRET = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';
|
||||||
|
|
||||||
|
// Run generateTOTP as if the wall clock were `unixSec`. node --test isolates
|
||||||
|
// each test file in its own process, so mutating the shared Date here can't
|
||||||
|
// leak into other suites; we still restore to be tidy.
|
||||||
|
async function codeAt(unixSec, period = 30, digits = 6) {
|
||||||
|
const orig = ctx.Date.now;
|
||||||
|
ctx.Date.now = () => unixSec * 1000;
|
||||||
|
try { return await T.generateTOTP(RFC_SECRET, period, digits); }
|
||||||
|
finally { ctx.Date.now = orig; }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('base32Decode: RFC 6238 secret decodes to ASCII "12345678901234567890"', () => {
|
||||||
|
const bytes = T.base32Decode(RFC_SECRET);
|
||||||
|
assert.equal(new TextDecoder().decode(bytes), '12345678901234567890');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('base32Decode: tolerates lowercase, spaces and padding', () => {
|
||||||
|
const a = T.base32Decode('JBSWY3DPEHPK3PXP');
|
||||||
|
const b = T.base32Decode('jbsw y3dp ehpk 3pxp==');
|
||||||
|
assert.equal(T.bytesToHex(a), T.bytesToHex(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('base32Decode: throws on an invalid character', () => {
|
||||||
|
assert.throws(() => T.base32Decode('JBSW0189'), /Invalid base32/); // 0/1/8/9 not in alphabet
|
||||||
|
});
|
||||||
|
|
||||||
|
// RFC 6238 Appendix B (SHA-1), 8-digit → last 6 digits for our 6-digit default.
|
||||||
|
const VECTORS = [
|
||||||
|
[59, '287082'],
|
||||||
|
[1111111109, '081804'],
|
||||||
|
[1111111111, '050471'],
|
||||||
|
[1234567890, '005924'],
|
||||||
|
[2000000000, '279037'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [t, expected] of VECTORS) {
|
||||||
|
test(`generateTOTP: RFC 6238 vector at t=${t} → ${expected}`, async () => {
|
||||||
|
const r = await codeAt(t);
|
||||||
|
assert.equal(r.code, expected);
|
||||||
|
assert.equal(r.period, 30);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('generateTOTP: secondsLeft counts down within the 30s window', async () => {
|
||||||
|
// t=45 → 15s into the second window → 15 left.
|
||||||
|
const r = await codeAt(45);
|
||||||
|
assert.equal(r.secondsLeft, 15);
|
||||||
|
// t=59 → 29s into the window → 1 left.
|
||||||
|
assert.equal((await codeAt(59)).secondsLeft, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateTOTP: same window → same code (deterministic per period)', async () => {
|
||||||
|
const a = await codeAt(1234567890);
|
||||||
|
const b = await codeAt(1234567890 + 5); // still the same 30s window
|
||||||
|
assert.equal(a.code, b.code);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseOtpAuthUri: extracts the secret from an otpauth:// URI', () => {
|
||||||
|
assert.equal(
|
||||||
|
T.parseOtpAuthUri('otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example'),
|
||||||
|
'JBSWY3DPEHPK3PXP');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseOtpAuthUri: returns null for non-otpauth input', () => {
|
||||||
|
assert.equal(T.parseOtpAuthUri('https://example.com'), null);
|
||||||
|
assert.equal(T.parseOtpAuthUri('JBSWY3DPEHPK3PXP'), null);
|
||||||
|
assert.equal(T.parseOtpAuthUri(''), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseOtpAuthUri → generateTOTP: parsed secret produces a valid 6-digit code', async () => {
|
||||||
|
const secret = T.parseOtpAuthUri('otpauth://totp/x?secret=' + RFC_SECRET);
|
||||||
|
const orig = ctx.Date.now;
|
||||||
|
ctx.Date.now = () => 59 * 1000;
|
||||||
|
try {
|
||||||
|
const r = await T.generateTOTP(secret, 30, 6);
|
||||||
|
assert.equal(r.code, '287082');
|
||||||
|
} finally { ctx.Date.now = orig; }
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user