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>
286 lines
8.1 KiB
Dart
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);
|
|
}
|
|
});
|
|
});
|
|
}
|