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().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().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(), ); } }); 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().having( (refused) => refused.reason, 'reason', TotpRefusal.reused, ), ); expect( checkTotp( secret: secret, code: codeAt(step - 1), now: now, lastAcceptedStep: step, ), isA().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().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().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().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(), ); }); test('another secret makes other codes', () { expect( checkTotp( secret: newTotpSecret(), code: codeAt(step), now: now, lastAcceptedStep: null, ), isA(), ); }); }); 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); } }); }); }