Files
ihasmail/server/src/totp.test.ts
T
jcoffey-dev 0f1fbcff93 Manage your own password, app passwords and 2FA
Settings › Security grows three working sections instead of a note telling
people to use Stalwart's own portal.

Stalwart moved this API between releases, so ihasmail speaks both: 0.16+ has
the x:AccountPassword singleton and x:AppPassword registry objects over JMAP,
while 0.15.x has the /api/account/auth REST endpoint. Which one answers the
probe is the only reliable way to tell them apart, and the result is cached
per session. The built-in `user` role already grants sysAccountPassword* and
sysAppPassword*, so no administrator setup is needed.

Two problems are worth calling out, because both would bite a user hard:

Stalwart validates the credentials already on the account when 2FA is turned
on and never checks the new secret, so an authenticator that was mistyped or
out of step would lock someone out of their mailbox at the next sign-in. We
verify a code against the new secret ourselves first (RFC 6238, tested against
the spec's vectors) and only then ask the server to store anything.

Every proxied call re-authenticates with the credential sealed into the
session, and from the moment 2FA is on Stalwart wants a fresh TOTP code with
it — which we cannot produce between requests. Turning 2FA on would therefore
sign the user out of the browser they just turned it on in. App passwords
authenticate without a second factor, so the session is moved onto one minted
for this browser, and the session cookie is re-sealed with it. The order
matters: it is minted while the old credential still works, and revoked again
if enabling then fails.

Password changes re-seal this session too and drop the others, whose sealed
copies of the old password would fail on their next call.

The mock now enforces what a real server does — current password, password
policy, a TOTP code on every request once 2FA is on, app passwords exempt —
so the whole flow is exercised in tests rather than only by hand.
2026-08-24 08:18:47 -07:00

86 lines
4.0 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { base32Decode, base32Encode, generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
/** RFC 6238 Appendix B seeds. */
const SHA1_SECRET = base32Encode(Buffer.from("12345678901234567890", "ascii"));
const SHA256_SECRET = base32Encode(Buffer.from("12345678901234567890123456789012", "ascii"));
test("base32 matches the RFC 4648 alphabet and round-trips", () => {
assert.equal(SHA1_SECRET, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ");
assert.equal(base32Encode(Buffer.from("f", "ascii")), "MY");
assert.equal(base32Encode(Buffer.from("foobar", "ascii")), "MZXW6YTBOI");
assert.deepEqual(base32Decode("MZXW6YTBOI"), Buffer.from("foobar", "ascii"));
// Users paste secrets with spaces, lowercase and padding.
assert.deepEqual(base32Decode("mzxw 6ytb-oi==="), Buffer.from("foobar", "ascii"));
assert.equal(base32Decode("not base32!"), null);
});
test("verifyTotp accepts the RFC 6238 SHA-1 test vectors", () => {
const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 };
for (const [time, code] of [
[59, "94287082"],
[1111111109, "07081804"],
[1111111111, "14050471"],
[1234567890, "89005924"],
[2000000000, "69279037"],
[20000000000, "65353130"],
] as const) {
assert.equal(verifyTotp(params, code, { window: 0, now: time * 1000 }), true, `t=${time}`);
}
});
test("verifyTotp accepts the RFC 6238 SHA-256 test vectors", () => {
const params = { secret: SHA256_SECRET, algorithm: "SHA256" as const, digits: 8, period: 30 };
for (const [time, code] of [
[59, "46119246"],
[1111111109, "68084774"],
[1234567890, "91819424"],
] as const) {
assert.equal(verifyTotp(params, code, { window: 0, now: time * 1000 }), true, `t=${time}`);
}
});
test("verifyTotp rejects wrong, malformed and mis-sized codes", () => {
const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 };
const at = { window: 0, now: 59_000 };
assert.equal(verifyTotp(params, "94287083", at), false);
assert.equal(verifyTotp(params, "9428708", at), false, "too short");
assert.equal(verifyTotp(params, "942870822", at), false, "too long");
assert.equal(verifyTotp(params, "abcdefgh", at), false);
assert.equal(verifyTotp(params, "", at), false);
assert.equal(verifyTotp({ ...params, secret: "!!!" }, "94287082", at), false, "bad secret");
});
test("the skew window covers a step either side and no further", () => {
const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 };
// 94287082 is the code for the step containing t=59.
assert.equal(verifyTotp(params, "94287082", { window: 1, now: 89_000 }), true, "one step late");
assert.equal(verifyTotp(params, "94287082", { window: 1, now: 29_000 }), true, "one step early");
assert.equal(verifyTotp(params, "94287082", { window: 1, now: 119_000 }), false, "two steps late");
});
test("otpauth URLs round-trip through the parser", () => {
const secret = generateSecret();
const url = otpauthUrl({ secret, account: "[email protected]", issuer: "ihasmail" });
assert.match(url, /^otpauth:\/\/totp\/ihasmail:ann%40example\.org\?/);
const parsed = parseOtpauthUrl(url);
assert.deepEqual(parsed, { secret, algorithm: "SHA1", digits: 6, period: 30 });
});
test("generated secrets are 160-bit and distinct", () => {
const a = generateSecret();
const b = generateSecret();
assert.equal(base32Decode(a)?.length, 20);
assert.notEqual(a, b);
});
test("parseOtpauthUrl rejects anything that is not a usable TOTP URL", () => {
assert.equal(parseOtpauthUrl("https://example.org"), null);
assert.equal(parseOtpauthUrl("otpauth://hotp/a?secret=GEZDGNBV"), null, "counter-based");
assert.equal(parseOtpauthUrl("otpauth://totp/a"), null, "no secret");
assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=!!!"), null, "unusable secret");
assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=GEZDGNBV&algorithm=MD5"), null);
assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=GEZDGNBV&digits=99"), null);
});