100 lines
4.7 KiB
TypeScript
100 lines
4.7 KiB
TypeScript
/**
|
|
* Morphit — TOTP (2FA) enroll + verify + survives-password-change smoke.
|
|
*
|
|
* TOTP needs no device — the code is a pure function of the secret + the
|
|
* clock — so the whole 2FA path is testable here: the RFC 6238 algorithm,
|
|
* the keystore-level enroll + verify gate, the backup-code recovery path,
|
|
* and (the claim the old REVISIT note got wrong) that TOTP SURVIVES a
|
|
* password change. totpSecret/backup codes live INSIDE the encrypted
|
|
* identity blob, so re-encrypting under a new password — exactly what
|
|
* changePassword does for a simple-passphrase (TOTP) keystore — preserves
|
|
* them.
|
|
*
|
|
* Asserts:
|
|
* 1. RFC 6238 round-trip: computeCode(secret, now) verifies via verifyCode.
|
|
* 2. enrollTotp -> envelope decrypts to an identity carrying totpSecret.
|
|
* 3. verifyTotpOrBackup accepts a freshly-computed code (kind 'ok').
|
|
* 4. a wrong code is rejected (throws).
|
|
* 5. SURVIVES PASSWORD CHANGE: decrypt-with-old -> re-encrypt-with-new
|
|
* (the changePassword path) -> the new envelope still verifies a TOTP code.
|
|
* 6. a backup code redeems (kind 'backup_redeemed').
|
|
*/
|
|
import sodium from 'libsodium-wrappers-sumo';
|
|
|
|
let passes = 0;
|
|
let failures = 0;
|
|
function ok(cond: boolean, msg: string): void {
|
|
if (cond) {
|
|
passes++;
|
|
console.log(` ✓ ${msg}`);
|
|
} else {
|
|
failures++;
|
|
console.log(` ✗ ${msg}`);
|
|
}
|
|
}
|
|
|
|
await sodium.ready;
|
|
const { generateSecret, computeCode, verifyCode } = await import('../src/lib/auth/totp.ts');
|
|
const { generatePlaintextCodes } = await import('../src/lib/auth/backupCodes.ts');
|
|
const { enrollTotp } = await import('../src/lib/crypto/keystoreTotpEnroll.ts');
|
|
const { verifyTotpOrBackup } = await import('../src/lib/crypto/keystoreTotp.ts');
|
|
const { encryptIdentity, decryptIdentity } = await import('../src/lib/crypto/keystore.ts');
|
|
const { generateFullIdentity } = await import('../src/lib/crypto/keygen.ts');
|
|
|
|
// A single wall-clock sample for the RFC round-trip below. Note: the
|
|
// keystore-gate verifies (`verifyTotpOrBackup`, steps 3 and 5) deliberately do
|
|
// NOT pass this — they let `computeCode` default to the current time. Those
|
|
// verifies run AFTER slow Argon2id encrypt/decrypt work, so on a loaded box the
|
|
// wall clock can advance past the TOTP step boundary between here and there;
|
|
// a code pinned to this `now` would then fall outside the ±1-step (90s) window
|
|
// and fail intermittently. The round-trip at step 1 is safe with `now` only
|
|
// because its compute+verify are microseconds apart with no slow op between.
|
|
const now = Math.floor(Date.now() / 1000);
|
|
|
|
// 1. RFC 6238 algorithm round-trip.
|
|
const secret = generateSecret();
|
|
const code = await computeCode(secret, now);
|
|
const algoOk = await verifyCode(secret, code);
|
|
ok(/^\d{6}$/.test(code) && algoOk.valid === true, 'computeCode produces a 6-digit code that verifyCode accepts');
|
|
|
|
// 2-3. Enroll TOTP on a fresh identity, then verify through the keystore gate.
|
|
const OLD_PW = 'old-passphrase-1';
|
|
const NEW_PW = 'new-passphrase-2';
|
|
const backupCodes = generatePlaintextCodes();
|
|
const full = await generateFullIdentity();
|
|
const enrolled = await enrollTotp(full, OLD_PW, secret, backupCodes);
|
|
const id1 = await decryptIdentity(enrolled.envelope as never, OLD_PW);
|
|
ok(!!id1.totpSecret, 'enrolled envelope decrypts to an identity carrying totpSecret');
|
|
|
|
const gate1 = await verifyTotpOrBackup(id1, await computeCode(id1.totpSecret!));
|
|
ok(gate1.kind === 'ok', 'verifyTotpOrBackup accepts a freshly-computed code');
|
|
|
|
// 4. Wrong code rejected.
|
|
let wrongRejected = false;
|
|
try {
|
|
// A code that is not valid for the current step (offset far outside the ±1
|
|
// window). Sampled at live time, not the stale `now` above: the wrong-code
|
|
// property must hold no matter how long the preceding Argon2id work took.
|
|
const wrong = await computeCode(id1.totpSecret!, Math.floor(Date.now() / 1000) + 10_000);
|
|
await verifyTotpOrBackup(id1, wrong);
|
|
wrongRejected = false;
|
|
} catch {
|
|
wrongRejected = true;
|
|
}
|
|
ok(wrongRejected, 'a wrong/out-of-window code is rejected');
|
|
|
|
// 5. SURVIVES a password change: mirror changePassword (decrypt old -> re-encrypt new).
|
|
const reDecrypted = await decryptIdentity(enrolled.envelope as never, OLD_PW);
|
|
const newEnv = await encryptIdentity(reDecrypted, NEW_PW);
|
|
const id2 = await decryptIdentity(newEnv as never, NEW_PW);
|
|
ok(!!id2.totpSecret, 'after a password change, totpSecret is still present');
|
|
const gate2 = await verifyTotpOrBackup(id2, await computeCode(id2.totpSecret!));
|
|
ok(gate2.kind === 'ok', 'after a password change, a TOTP code still verifies');
|
|
|
|
// 6. Backup code redemption.
|
|
const redeem = await verifyTotpOrBackup(id1, backupCodes[0]);
|
|
ok(redeem.kind === 'backup_redeemed', 'a backup code redeems');
|
|
|
|
console.log(`\n${passes} passed, ${failures} failed`);
|
|
if (failures > 0) process.exit(1);
|
|
console.log(`✓ all ${passes} totp-2fa-enroll-verify scenarios passed`);
|