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.
86 lines
4.0 KiB
TypeScript
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);
|
|
});
|