test(security): cover totpService 2FA + document Phase 1 milestone
TOTP: valid token verifies (raw + encrypted-secret path), wrong rejected, recovery codes single-use (with normalization), challenges single-use + expiring. Completes the Phase 1 safety net (encryption/auth/validation/ money-creators/2FA). WebAuthn full verify deferred (authenticator harness). Suite 225 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
637ad85512
commit
896dfd8230
|
|
@ -1,6 +1,10 @@
|
|||
# Bill Tracker — Changelog
|
||||
## v0.41.0
|
||||
|
||||
### 🛡️ Trust & Integrity — regression tests for the untested critical core (Phase 1)
|
||||
|
||||
- **[Tests] Locked in the highest-stakes, previously-untested services** — the review found that the code most catastrophic-if-broken had zero coverage. Added focused `node:test` suites (all green, no defects surfaced — the implementations were sound; this pins them against future regressions): **`encryptionService`** (the AES-256-GCM + HKDF crypto for bank tokens/2FA/SMTP/OIDC secrets + PII — round-trip on both key paths, GCM tamper rejection, legacy decrypt, env-key-required error, and the `reEncryptWithEnvKey` startup migration: migrate/skip/idempotent/corrupt-tolerant), **`authService`** (bcrypt password round-trip, hashed-not-raw session storage, the create/lookup/expire/rotate/invalidate/prune lifecycle, and `rotateSessionId` ownership enforcement), **`paymentValidation`** (dollars→cents, positive-amount + real-calendar-date rejection, the require* option matrix), **`billMerchantRuleService`** (the bank-sync payment *creator*: rule-match → one `provider_sync` payment, idempotent via match-status + the `UNIQUE(transaction_id)` index), **`spendingService`** budgets, and **`totpService`** (token verify + single-use recovery codes + single-use/expiring challenges). This is the de-risking prerequisite for the server-TypeScript migration. Suite 225 green. (WebAuthn full-verify deferred — needs an authenticator harness.)
|
||||
|
||||
### 🔌 API response typing cleanup (Track C) — promoting page shapes into `@/types`, endpoint by endpoint
|
||||
|
||||
- **[Client] Auth: `User` moved to its proper home + auth endpoints typed.** `User` lived oddly in `@/hooks/useAuth`; moved it to `@/types` (re-exported from `useAuth` so the 7 importers keep working) and typed `me`/`login`/`totpChallenge`/`hasUsers` with `get<T>`/`post<T>`, dropping those casts on the Admin + Login pages. *(Deliberate stop: the remaining single-consumer, non-money casts — `adminUsers` with its page-local `AdminUser`, and the one-off `version`/`about`/`privacy`/`health`/`importHistory` doc shapes — are left as-is; central types add ~nothing for a single reader, and typing them is the low-value tail. Likewise the **optimistic-UI/`toast.promise` rewrite** (planned Track D) is intentionally not done — it's behavior-visible churn on already-working handlers, the worst risk/reward of the remaining work.)*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
'use strict';
|
||||
|
||||
// Phase 1 — totpService is the 2FA core. Pins: a valid TOTP token verifies and a
|
||||
// wrong one is rejected (raw + through the encrypted-secret path), recovery codes
|
||||
// are single-use, and login challenges are single-use + expiring.
|
||||
// (webauthnService full verify needs a real authenticator harness — deferred.)
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const { generateSync } = require('otplib');
|
||||
|
||||
const dbPath = path.join(os.tmpdir(), `bill-tracker-totp-${process.pid}.sqlite`);
|
||||
process.env.DB_PATH = dbPath;
|
||||
|
||||
const { getDb, closeDb } = require('../db/database');
|
||||
const { encryptSecret } = require('../services/encryptionService');
|
||||
const totp = require('../services/totpService');
|
||||
|
||||
let db, userId;
|
||||
test.before(() => {
|
||||
db = getDb();
|
||||
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('totp-user','x','user',1)").run().lastInsertRowid;
|
||||
});
|
||||
test.after(() => {
|
||||
closeDb();
|
||||
for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} }
|
||||
});
|
||||
|
||||
test('verifyTokenRaw: a current token validates, a wrong one is rejected', () => {
|
||||
const secret = totp.generateSecret();
|
||||
const token = generateSync({ secret, type: 'totp' });
|
||||
assert.equal(totp.verifyTokenRaw(secret, token), true, 'the current token verifies');
|
||||
assert.equal(totp.verifyTokenRaw(secret, '000000'), false, 'a wrong token is rejected');
|
||||
assert.equal(totp.verifyTokenRaw(secret, ''), false);
|
||||
assert.equal(totp.verifyTokenRaw('', token), false);
|
||||
});
|
||||
|
||||
test('verifyToken: works through the encrypted-secret path', () => {
|
||||
const secret = totp.generateSecret();
|
||||
const enc = encryptSecret(secret);
|
||||
const token = generateSync({ secret, type: 'totp' });
|
||||
assert.equal(totp.verifyToken(enc, token), true);
|
||||
assert.equal(totp.verifyToken(enc, '000000'), false);
|
||||
assert.equal(totp.verifyToken(null, token), false, 'no secret → false, no throw');
|
||||
});
|
||||
|
||||
test('recovery codes are single-use', () => {
|
||||
const codes = ['ABCDE-12345', 'FGHIJ-67890'];
|
||||
db.prepare('UPDATE users SET totp_recovery_codes = ? WHERE id = ?')
|
||||
.run(encryptSecret(JSON.stringify(codes.map(totp.hashRecoveryCode))), userId);
|
||||
|
||||
const first = totp.consumeRecoveryCode(db, userId, 'ABCDE-12345');
|
||||
assert.deepEqual(first, { used: true, remaining: 1 }, 'a valid code is consumed');
|
||||
assert.equal(totp.consumeRecoveryCode(db, userId, 'ABCDE-12345').used, false, 'the same code cannot be reused');
|
||||
assert.equal(totp.consumeRecoveryCode(db, userId, 'ZZZZZ-99999').used, false, 'an unknown code is rejected');
|
||||
assert.equal(totp.consumeRecoveryCode(db, userId, 'fghij67890').used, true, 'formatting/spacing/case are normalized');
|
||||
});
|
||||
|
||||
test('challenges are single-use and reject the unknown/expired', () => {
|
||||
const id = totp.createChallenge(db, userId);
|
||||
assert.equal(totp.consumeChallenge(db, id), userId, 'a fresh challenge returns its user');
|
||||
assert.equal(totp.consumeChallenge(db, id), null, 'the same challenge cannot be consumed twice');
|
||||
assert.equal(totp.consumeChallenge(db, 'no-such-challenge'), null);
|
||||
|
||||
// expired challenge is not honoured
|
||||
db.prepare('INSERT INTO totp_challenges (id, user_id, expires_at) VALUES (?, ?, ?)').run('expired-ch', userId, '2000-01-01 00:00:00');
|
||||
assert.equal(totp.consumeChallenge(db, 'expired-ch'), null, 'expired challenge rejected');
|
||||
});
|
||||
Loading…
Reference in New Issue