halkyon/packages/halkyon_base_server/test/unit/totp/totp_test.dart
Tizian 「ティツィアーン」 470a0b633c
feat(crypto): TOTP and recovery codes
ADR 0017's second factor, in halkyon_base_server (base.dart):
- RFC 6238 with HMAC-SHA1, 6 digits, 30-second steps, one step either
  way. checkTotp takes the account's last accepted step as a required
  argument and refuses a code of that step or before, so no code is
  accepted twice; every step of the window is computed and compared.
- 160-bit secrets in RFC 4648 Base32 (strict decoding); the otpauth
  URI with issuer and account escaped.
- Ten recovery codes of eight Crockford characters (short_code.dart),
  kept as SHA-256 hashes, each used once, every hash compared in
  constant time.
- Tests: RFC 6238 Appendix B (8 digits, SHA-1, SHA-256, SHA-512), the
  window, replay, RFC 4648's Base32 vectors, the recovery codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-22 18:11:09 +02:00

286 lines
8.1 KiB
Dart

import 'dart:convert';
import 'package:halkyon_base_server/src/totp/base32.dart';
import 'package:halkyon_base_server/src/totp/totp.dart';
import 'package:test/test.dart';
void main() {
group("RFC 6238's test vectors (Appendix B), 8 digits", () {
// The seeds of RFC 6238's reference code (Appendix A): ASCII
// "1234567890" repeated to the hash's length.
final seeds = {
TotpHash.sha1: ascii.encode('12345678901234567890'),
TotpHash.sha256: ascii.encode('12345678901234567890123456789012'),
TotpHash.sha512: ascii.encode(
'1234567890123456789012345678901234567890123456789012345678901234',
),
};
const vectors = [
(59, '94287082', '46119246', '90693936'),
(1111111109, '07081804', '68084774', '25091201'),
(1111111111, '14050471', '67062674', '99943326'),
(1234567890, '89005924', '91819424', '93441116'),
(2000000000, '69279037', '90698825', '38618901'),
(20000000000, '65353130', '77737706', '47863826'),
];
for (final (seconds, withSha1, withSha256, withSha512) in vectors) {
test('at $seconds s', () {
final step = totpStepAt(
DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true),
);
for (final (hash, expected) in [
(TotpHash.sha1, withSha1),
(TotpHash.sha256, withSha256),
(TotpHash.sha512, withSha512),
]) {
expect(
totpCode(seeds[hash]!, step: step, digits: 8, hash: hash),
expected,
reason: hash.name,
);
}
});
}
});
group('checking a code', () {
final secret = newTotpSecret();
final now = DateTime.utc(2026, 10, 20, 12, 0, 15);
final step = totpStepAt(now);
String codeAt(int at) => totpCode(secret, step: at);
test("the current step's code is accepted, and names its step to keep", () {
expect(
checkTotp(
secret: secret,
code: codeAt(step),
now: now,
lastAcceptedStep: null,
),
isA<TotpAccepted>().having((accepted) => accepted.step, 'step', step),
);
});
test('one step either side is accepted, two are not', () {
for (final at in [step - 1, step + 1]) {
expect(
checkTotp(
secret: secret,
code: codeAt(at),
now: now,
lastAcceptedStep: null,
),
isA<TotpAccepted>().having((accepted) => accepted.step, 'step', at),
);
}
for (final at in [step - 2, step + 2]) {
expect(
checkTotp(
secret: secret,
code: codeAt(at),
now: now,
lastAcceptedStep: null,
),
isA<TotpRefused>(),
);
}
});
test('a code accepted once is refused again, and so is an earlier one', () {
expect(
checkTotp(
secret: secret,
code: codeAt(step),
now: now,
lastAcceptedStep: step,
),
isA<TotpRefused>().having(
(refused) => refused.reason,
'reason',
TotpRefusal.reused,
),
);
expect(
checkTotp(
secret: secret,
code: codeAt(step - 1),
now: now,
lastAcceptedStep: step,
),
isA<TotpRefused>().having(
(refused) => refused.reason,
'reason',
TotpRefusal.reused,
),
);
// The next step's code still counts after the current one was used.
expect(
checkTotp(
secret: secret,
code: codeAt(step + 1),
now: now,
lastAcceptedStep: step,
),
isA<TotpAccepted>().having(
(accepted) => accepted.step,
'step',
step + 1,
),
);
});
test('a wrong code, and one that is no six digits', () {
final right = codeAt(step);
final wrong = '${(int.parse(right[0]) + 1) % 10}${right.substring(1)}';
expect(
checkTotp(
secret: secret,
code: wrong,
now: now,
lastAcceptedStep: null,
),
isA<TotpRefused>().having(
(refused) => refused.reason,
'reason',
TotpRefusal.wrong,
),
);
for (final malformed in ['', '12345', '1234567', '12345a', '١٢٣٤٥٦']) {
expect(
checkTotp(
secret: secret,
code: malformed,
now: now,
lastAcceptedStep: null,
),
isA<TotpRefused>().having(
(refused) => refused.reason,
'reason',
TotpRefusal.malformed,
),
reason: malformed,
);
}
});
test('a code typed with spaces, as apps show it, counts', () {
final right = codeAt(step);
expect(
checkTotp(
secret: secret,
code: '${right.substring(0, 3)} ${right.substring(3)}',
now: now,
lastAcceptedStep: null,
),
isA<TotpAccepted>(),
);
});
test('another secret makes other codes', () {
expect(
checkTotp(
secret: newTotpSecret(),
code: codeAt(step),
now: now,
lastAcceptedStep: null,
),
isA<TotpRefused>(),
);
});
});
test('a new secret has 160 random bits, never the same twice', () {
final secret = newTotpSecret();
expect(secret, hasLength(20));
expect(newTotpSecret(), isNot(secret));
});
test('steps are 30 s of Unix time, in UTC', () {
expect(totpStepAt(DateTime.utc(1970, 1, 1, 0, 0, 29)), 0);
expect(totpStepAt(DateTime.utc(1970, 1, 1, 0, 0, 30)), 1);
expect(
totpStepAt(DateTime.utc(2026, 10, 20, 12)),
totpStepAt(DateTime.utc(2026, 10, 20, 12).toLocal()),
);
});
test('the otpauth URI names the issuer and account, escaped, and how codes '
'are made', () {
final uri = otpauthUri(
issuer: 'learn.halkyon.eu',
account: 'Ünal & Co: alice',
secret: ascii.encode('12345678901234567890'),
);
expect(uri.scheme, 'otpauth');
expect(uri.host, 'totp');
expect(
uri.toString(),
'otpauth://totp/learn.halkyon.eu:%C3%9Cnal%20%26%20Co%3A%20alice'
'?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=learn.halkyon.eu'
'&algorithm=SHA1&digits=6&period=30',
);
expect(uri.queryParameters['issuer'], 'learn.halkyon.eu');
expect(
base32Decode(uri.queryParameters['secret']!),
ascii.encode('12345678901234567890'),
);
});
test("an issuer's spaces are escaped as %20 in the label and the query "
"alike, as authenticators read them, never as '+'", () {
final uri = otpauthUri(
issuer: 'Halkyon Learn & Co',
account: 'alice',
secret: ascii.encode('12345678901234567890'),
);
expect(
uri.toString(),
'otpauth://totp/Halkyon%20Learn%20%26%20Co:alice'
'?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'
'&issuer=Halkyon%20Learn%20%26%20Co'
'&algorithm=SHA1&digits=6&period=30',
);
expect(uri.queryParameters['issuer'], 'Halkyon Learn & Co');
});
group("RFC 4648's base 32 (section 10)", () {
const vectors = {
'': '',
'f': 'MY======',
'fo': 'MZXQ====',
'foo': 'MZXW6===',
'foob': 'MZXW6YQ=',
'fooba': 'MZXW6YTB',
'foobar': 'MZXW6YTBOI======',
};
for (final MapEntry(key: plain, value: encoded) in vectors.entries) {
test('"$plain"', () {
expect(base32Encode(ascii.encode(plain)), encoded);
expect(base32Decode(encoded), ascii.encode(plain));
expect(
base32Decode(encoded.replaceAll('=', '')),
ascii.encode(plain),
reason: 'without its padding',
);
expect(
base32Encode(ascii.encode(plain), padding: false),
encoded.replaceAll('=', ''),
);
});
}
test('refuses what is no base 32', () {
for (final text in [
'MY=Y',
'M',
'MZX',
'MZXW6Y',
'mzxw6ytb',
'MZXW1YTB',
'MZ',
]) {
expect(() => base32Decode(text), throwsFormatException, reason: text);
}
});
});
}