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:
r-zakarya
2026-07-05 16:35:29 +01:00
parent 97a19836a0
commit 5fc07aed7a
12 changed files with 253 additions and 124 deletions
+3 -1
View File
@@ -30,7 +30,7 @@ const { webcrypto } = require('node:crypto');
// 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.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).
function makeStorage() {
@@ -142,6 +142,8 @@ function loadApp(overrides = {}) {
encryptPwd, decryptPwd, sha256Hex,
HASH_ALGO_V2, HASH_ALGO_ARGON2, AUTH_VERIFIER_DOMAIN, ARGON2_DEFAULT_PARAMS,
NobleArgon2: (typeof NobleArgon2 !== 'undefined' ? NobleArgon2 : undefined),
// totp
base32Decode, generateTOTP, parseOtpAuthUri,
// csv
parseCSV, findColumn, parseEntriesFromCSV,
// strength
+94
View File
@@ -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; }
});