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>
This commit is contained in:
parent
bc499713b8
commit
470a0b633c
7 changed files with 690 additions and 1 deletions
|
|
@ -127,6 +127,24 @@ compared in constant time. RFC 5054 Appendix B's vectors check both halves
|
|||
(`SrpGroup.rfc5054TestVectorGroup`, SHA-1), and `test/unit/srp/srp_interop_test.dart` runs
|
||||
the client against the register, every refusal included.
|
||||
|
||||
## TOTP and recovery codes (ADR 0017)
|
||||
|
||||
The second factor, in `package:halkyon_base_server/base.dart`:
|
||||
|
||||
- `newTotpSecret()`: 160 random bits, shown to the person in RFC 4648 Base32
|
||||
(`base32Encode`, `base32Decode`) through `otpauthUri(issuer:, account:, secret:)`, its
|
||||
label and issuer escaped (a space as `%20`).
|
||||
- `checkTotp(secret:, code:, now:, lastAcceptedStep:)`: RFC 6238 with HMAC-SHA1, 6 digits,
|
||||
30-second steps, one step either side of now. The account's last accepted step is a
|
||||
required argument, and a code from that step or an earlier one is refused (`reused`),
|
||||
so no code is accepted twice; `TotpAccepted.step` is the step to keep before answering.
|
||||
Refusals: `malformed`, `wrong`, `reused`. `totpCode` makes a code (SHA-256 and SHA-512
|
||||
for RFC 6238's vectors).
|
||||
- `RecoveryCodes.generate()`: ten codes of eight Crockford characters (`K7QX-M2PD`, as
|
||||
email sign-in's codes), shown once, kept as SHA-256 hashes; `useRecoveryCode(typed,
|
||||
hashes)` compares every hash in constant time and gives the hashes left
|
||||
(`RecoveryCodeAccepted.remaining`), so each code works once.
|
||||
|
||||
## Taking it into a host
|
||||
|
||||
1. Depend on both packages by path, within the repository's workspace (an app's
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
/// (`HalkyonBase.configure`), how people authenticate, the account's
|
||||
/// deletion and the pruning, email sign-in's helpers, the instance's
|
||||
/// basics, people's public profiles and their pages, the instance's register
|
||||
/// (ADR 0016), SRP's register side (ADR 0017), and the module's models.
|
||||
/// (ADR 0016), SRP's register side, TOTP and recovery codes (ADR 0017), and
|
||||
/// the module's models.
|
||||
library;
|
||||
|
||||
export 'src/auth/account.dart';
|
||||
|
|
@ -47,3 +48,6 @@ export 'src/srp/srp_server.dart'
|
|||
SrpVerification,
|
||||
srpChallenge,
|
||||
srpVerify;
|
||||
export 'src/totp/base32.dart';
|
||||
export 'src/totp/recovery_codes.dart';
|
||||
export 'src/totp/totp.dart';
|
||||
|
|
|
|||
65
packages/halkyon_base_server/lib/src/totp/base32.dart
Normal file
65
packages/halkyon_base_server/lib/src/totp/base32.dart
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
/// RFC 4648's base 32 (section 6), as authenticator apps read a TOTP secret.
|
||||
|
||||
const _alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
/// [bytes] in base 32, with the `=` padding RFC 4648 adds unless [padding]
|
||||
/// is false (an `otpauth://` URI's secret goes without).
|
||||
String base32Encode(List<int> bytes, {bool padding = true}) {
|
||||
final out = StringBuffer();
|
||||
var buffer = 0;
|
||||
var bits = 0;
|
||||
for (final byte in bytes) {
|
||||
buffer = (buffer << 8) | (byte & 0xff);
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out.write(_alphabet[(buffer >> (bits - 5)) & 31]);
|
||||
bits -= 5;
|
||||
}
|
||||
buffer &= (1 << bits) - 1;
|
||||
}
|
||||
if (bits > 0) {
|
||||
out.write(_alphabet[(buffer << (5 - bits)) & 31]);
|
||||
}
|
||||
if (padding) {
|
||||
while (out.length % 8 != 0) {
|
||||
out.write('=');
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
/// The bytes [text] stands for in base 32, with its padding or without;
|
||||
/// a [FormatException] for anything else (another letter, padding in the
|
||||
/// middle, bits left over that aren't zero, a length no bytes give).
|
||||
Uint8List base32Decode(String text) {
|
||||
final trimmed = text.replaceFirst(RegExp(r'=+$'), '');
|
||||
if (trimmed.contains('=')) {
|
||||
throw FormatException('Padding inside base 32', text);
|
||||
}
|
||||
// Lengths of whole bytes: 8 characters per 5 bytes, a tail of 2, 4, 5 or 7.
|
||||
if (const {1, 3, 6}.contains(trimmed.length % 8)) {
|
||||
throw FormatException('No bytes have this length in base 32', text);
|
||||
}
|
||||
final out = <int>[];
|
||||
var buffer = 0;
|
||||
var bits = 0;
|
||||
for (final character in trimmed.split('')) {
|
||||
final value = _alphabet.indexOf(character);
|
||||
if (value < 0) {
|
||||
throw FormatException('Not base 32: $character', text);
|
||||
}
|
||||
buffer = (buffer << 5) | value;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.add((buffer >> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
buffer &= (1 << bits) - 1;
|
||||
}
|
||||
if (buffer != 0) {
|
||||
throw FormatException('Base 32 with bits left over', text);
|
||||
}
|
||||
return Uint8List.fromList(out);
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_base_server/src/common/short_code.dart';
|
||||
import 'package:halkyon_base_server/src/srp/srp_math.dart'
|
||||
show constantTimeEquals;
|
||||
|
||||
/// Recovery codes (ADR 0017): ten one-time codes shown once when the second
|
||||
/// factor is set up, each standing in for a TOTP code when the authenticator
|
||||
/// is lost. Short codes as email sign-in mails them (`K7QX-M2PD`), kept only
|
||||
/// as SHA-256 hashes.
|
||||
|
||||
/// How many codes a set has.
|
||||
const recoveryCodeCount = 10;
|
||||
|
||||
/// A new set: the [codes] to show the person once, and the [hashes] to keep.
|
||||
final class RecoveryCodes {
|
||||
RecoveryCodes._(this.codes, this.hashes);
|
||||
|
||||
/// A fresh set of [recoveryCodeCount] codes.
|
||||
factory RecoveryCodes.generate() {
|
||||
final codes = [for (var i = 0; i < recoveryCodeCount; i++) newShortCode()];
|
||||
return RecoveryCodes._(codes, [for (final code in codes) _hashOf(code)]);
|
||||
}
|
||||
|
||||
/// The codes as the person sees them; never kept.
|
||||
final List<String> codes;
|
||||
|
||||
/// Their hashes, in hex: what the register keeps.
|
||||
final List<String> hashes;
|
||||
}
|
||||
|
||||
/// Uses the code [typed] (as the person typed it: case, spaces, dashes and
|
||||
/// O for 0 don't matter) against the account's unused [hashes]. Every hash
|
||||
/// is compared, whether one matched or not. Accepted, the result holds the
|
||||
/// hashes left, the used one gone: keep those before answering.
|
||||
RecoveryCodeUse useRecoveryCode(String typed, List<String> hashes) {
|
||||
final hash = _hashOf(typed);
|
||||
int? matched;
|
||||
for (var i = 0; i < hashes.length; i++) {
|
||||
if (constantTimeEquals(utf8.encode(hashes[i]), utf8.encode(hash)) &&
|
||||
matched == null) {
|
||||
matched = i;
|
||||
}
|
||||
}
|
||||
return matched == null
|
||||
? const RecoveryCodeRefused()
|
||||
: RecoveryCodeAccepted([
|
||||
for (var i = 0; i < hashes.length; i++)
|
||||
if (i != matched) hashes[i],
|
||||
]);
|
||||
}
|
||||
|
||||
String _hashOf(String code) =>
|
||||
sha256.convert(utf8.encode(normalizeShortCode(code))).toString();
|
||||
|
||||
/// What a recovery code turned out to be.
|
||||
sealed class RecoveryCodeUse {
|
||||
const RecoveryCodeUse();
|
||||
}
|
||||
|
||||
/// The code was one of the set: [remaining] are the hashes still unused.
|
||||
final class RecoveryCodeAccepted extends RecoveryCodeUse {
|
||||
const RecoveryCodeAccepted(this.remaining);
|
||||
|
||||
/// The hashes to keep from now on.
|
||||
final List<String> remaining;
|
||||
}
|
||||
|
||||
/// The code isn't one of the set, or was used before.
|
||||
final class RecoveryCodeRefused extends RecoveryCodeUse {
|
||||
const RecoveryCodeRefused();
|
||||
}
|
||||
171
packages/halkyon_base_server/lib/src/totp/totp.dart
Normal file
171
packages/halkyon_base_server/lib/src/totp/totp.dart
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
import 'package:halkyon_base_server/src/srp/srp_math.dart'
|
||||
show constantTimeEquals;
|
||||
import 'package:halkyon_base_server/src/totp/base32.dart';
|
||||
|
||||
/// TOTP (RFC 6238, on HOTP, RFC 4226), the second factor every identified
|
||||
/// account has (ADR 0017): codes of [totpDigits] digits from HMAC-SHA1 over
|
||||
/// [totpPeriod]-long steps of Unix time, as authenticator apps make them.
|
||||
/// The register keeps the secret (encrypted with the instance's `totpKey`),
|
||||
/// and the last step it accepted, so no code is accepted twice.
|
||||
|
||||
/// How many digits a code has.
|
||||
const totpDigits = 6;
|
||||
|
||||
/// How long one step lasts.
|
||||
const totpPeriod = Duration(seconds: 30);
|
||||
|
||||
/// How many steps either side of now a code may be from: clocks and people
|
||||
/// take a moment.
|
||||
const totpWindow = 1;
|
||||
|
||||
/// How many random bits a new secret has: SHA-1's block (RFC 4226 asks for
|
||||
/// at least 128, recommends 160).
|
||||
const totpSecretBits = 160;
|
||||
|
||||
/// The HMAC a TOTP is made with: SHA-1 as authenticator apps use it; SHA-256
|
||||
/// and SHA-512 for RFC 6238's other vectors.
|
||||
enum TotpHash {
|
||||
sha1('SHA1'),
|
||||
sha256('SHA256'),
|
||||
sha512('SHA512');
|
||||
|
||||
TotpHash(this.name);
|
||||
|
||||
/// Its name in an `otpauth://` URI.
|
||||
final String name;
|
||||
|
||||
crypto.Hash get _hash => switch (this) {
|
||||
TotpHash.sha1 => crypto.sha1,
|
||||
TotpHash.sha256 => crypto.sha256,
|
||||
TotpHash.sha512 => crypto.sha512,
|
||||
};
|
||||
}
|
||||
|
||||
/// A new secret of [totpSecretBits] random bits.
|
||||
Uint8List newTotpSecret({Random? random}) {
|
||||
final source = random ?? Random.secure();
|
||||
return Uint8List.fromList([
|
||||
for (var i = 0; i < totpSecretBits ~/ 8; i++) source.nextInt(256),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The step [time] falls in: whole [totpPeriod]s since 1970 (UTC).
|
||||
int totpStepAt(DateTime time) =>
|
||||
time.toUtc().millisecondsSinceEpoch ~/ totpPeriod.inMilliseconds;
|
||||
|
||||
/// The code for [step] (RFC 4226's dynamic truncation), [digits] long with
|
||||
/// leading zeros.
|
||||
String totpCode(
|
||||
List<int> secret, {
|
||||
required int step,
|
||||
int digits = totpDigits,
|
||||
TotpHash hash = TotpHash.sha1,
|
||||
}) {
|
||||
final counter = ByteData(8)..setInt64(0, step);
|
||||
final mac = crypto.Hmac(
|
||||
hash._hash,
|
||||
secret,
|
||||
).convert(counter.buffer.asUint8List()).bytes;
|
||||
final offset = mac.last & 0x0f;
|
||||
final binary =
|
||||
((mac[offset] & 0x7f) << 24) |
|
||||
(mac[offset + 1] << 16) |
|
||||
(mac[offset + 2] << 8) |
|
||||
mac[offset + 3];
|
||||
return (binary % pow(10, digits).toInt()).toString().padLeft(digits, '0');
|
||||
}
|
||||
|
||||
/// Checks [code] against the account's [secret] at [now]. [lastAcceptedStep]
|
||||
/// is the step of the last code the account's register accepted (null for
|
||||
/// none yet): a code of that step or before is refused, so a code seen over
|
||||
/// someone's shoulder can't be used again. Accepted, the result names the
|
||||
/// step to keep as the new last one; keep it before answering.
|
||||
TotpCheck checkTotp({
|
||||
required List<int> secret,
|
||||
required String code,
|
||||
required DateTime now,
|
||||
required int? lastAcceptedStep,
|
||||
}) {
|
||||
final typed = code.replaceAll(RegExp(r'\s'), '');
|
||||
if (!RegExp('^[0-9]{$totpDigits}\$').hasMatch(typed)) {
|
||||
return const TotpRefused(TotpRefusal.malformed);
|
||||
}
|
||||
final current = totpStepAt(now);
|
||||
// Every step of the window is computed and compared, so how long it takes
|
||||
// says nothing of which (if any) matched. A code may match two steps (one
|
||||
// chance in a million): the earliest not yet used counts.
|
||||
int? fresh;
|
||||
var matchedAny = false;
|
||||
for (var step = current - totpWindow; step <= current + totpWindow; step++) {
|
||||
final matches = constantTimeEquals(
|
||||
totpCode(secret, step: step).codeUnits,
|
||||
typed.codeUnits,
|
||||
);
|
||||
matchedAny |= matches;
|
||||
if (matches &&
|
||||
fresh == null &&
|
||||
(lastAcceptedStep == null || step > lastAcceptedStep)) {
|
||||
fresh = step;
|
||||
}
|
||||
}
|
||||
return switch ((fresh, matchedAny)) {
|
||||
(final int step, _) => TotpAccepted(step),
|
||||
(null, true) => const TotpRefused(TotpRefusal.reused),
|
||||
(null, false) => const TotpRefused(TotpRefusal.wrong),
|
||||
};
|
||||
}
|
||||
|
||||
/// The `otpauth://totp/…` URI an authenticator app reads from a QR code:
|
||||
/// the [account] at the [issuer] (the instance, `learn.halkyon.eu`), each
|
||||
/// escaped (a space as `%20`, which authenticators read, never `+`), with
|
||||
/// the secret in base 32 and how codes are made.
|
||||
Uri otpauthUri({
|
||||
required String issuer,
|
||||
required String account,
|
||||
required List<int> secret,
|
||||
}) => Uri.parse(
|
||||
'otpauth://totp/'
|
||||
'${Uri.encodeComponent(issuer)}:${Uri.encodeComponent(account)}'
|
||||
'?secret=${base32Encode(secret, padding: false)}'
|
||||
'&issuer=${Uri.encodeComponent(issuer)}'
|
||||
'&algorithm=${TotpHash.sha1.name}'
|
||||
'&digits=$totpDigits'
|
||||
'&period=${totpPeriod.inSeconds}',
|
||||
);
|
||||
|
||||
/// What a code turned out to be.
|
||||
sealed class TotpCheck {
|
||||
const TotpCheck();
|
||||
}
|
||||
|
||||
/// The code was right: [step] is now the account's last accepted one.
|
||||
final class TotpAccepted extends TotpCheck {
|
||||
const TotpAccepted(this.step);
|
||||
|
||||
/// The step to keep as the last accepted.
|
||||
final int step;
|
||||
}
|
||||
|
||||
/// The code wasn't accepted.
|
||||
final class TotpRefused extends TotpCheck {
|
||||
const TotpRefused(this.reason);
|
||||
|
||||
/// Why.
|
||||
final TotpRefusal reason;
|
||||
}
|
||||
|
||||
/// Why a code wasn't accepted.
|
||||
enum TotpRefusal {
|
||||
/// Not six digits.
|
||||
malformed,
|
||||
|
||||
/// No step of the window makes it.
|
||||
wrong,
|
||||
|
||||
/// Its step was accepted before.
|
||||
reused,
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import 'package:halkyon_base_server/src/totp/recovery_codes.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
test('a set has ten codes, as short codes are shown, and only their hashes '
|
||||
'are kept', () {
|
||||
final set = RecoveryCodes.generate();
|
||||
expect(set.codes, hasLength(recoveryCodeCount));
|
||||
expect(set.hashes, hasLength(recoveryCodeCount));
|
||||
for (final code in set.codes) {
|
||||
expect(
|
||||
code,
|
||||
matches(RegExp(r'^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$')),
|
||||
);
|
||||
}
|
||||
for (final hash in set.hashes) {
|
||||
expect(hash, matches(RegExp(r'^[0-9a-f]{64}$')));
|
||||
}
|
||||
// No code is among the kept hashes, as itself.
|
||||
expect(set.hashes.toSet().intersection(set.codes.toSet()), isEmpty);
|
||||
expect(set.codes.toSet(), hasLength(recoveryCodeCount));
|
||||
});
|
||||
|
||||
test('a code is accepted once: the hash left out of what to keep', () {
|
||||
final set = RecoveryCodes.generate();
|
||||
final use = useRecoveryCode(set.codes[3], set.hashes);
|
||||
expect(use, isA<RecoveryCodeAccepted>());
|
||||
final remaining = (use as RecoveryCodeAccepted).remaining;
|
||||
expect(remaining, hasLength(recoveryCodeCount - 1));
|
||||
expect(remaining, isNot(contains(set.hashes[3])));
|
||||
expect(
|
||||
useRecoveryCode(set.codes[3], remaining),
|
||||
isA<RecoveryCodeRefused>(),
|
||||
);
|
||||
// The others still count.
|
||||
expect(
|
||||
useRecoveryCode(set.codes[4], remaining),
|
||||
isA<RecoveryCodeAccepted>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('typed as people type: case, spaces, no dash, O for 0 and I or L for '
|
||||
'1 count alike', () {
|
||||
final set = RecoveryCodes.generate();
|
||||
final code = set.codes.first;
|
||||
final typed = code
|
||||
.toLowerCase()
|
||||
.replaceAll('-', ' ')
|
||||
.replaceAll('0', 'o')
|
||||
.replaceAll('1', 'l');
|
||||
expect(useRecoveryCode(typed, set.hashes), isA<RecoveryCodeAccepted>());
|
||||
});
|
||||
|
||||
test("a code of another set, or no code at all, isn't accepted", () {
|
||||
final set = RecoveryCodes.generate();
|
||||
final other = RecoveryCodes.generate();
|
||||
expect(
|
||||
useRecoveryCode(other.codes.first, set.hashes),
|
||||
isA<RecoveryCodeRefused>(),
|
||||
);
|
||||
expect(useRecoveryCode('', set.hashes), isA<RecoveryCodeRefused>());
|
||||
expect(
|
||||
useRecoveryCode(set.codes.first, const []),
|
||||
isA<RecoveryCodeRefused>(),
|
||||
);
|
||||
// A hash typed as a code is no code.
|
||||
expect(
|
||||
useRecoveryCode(set.hashes.first, set.hashes),
|
||||
isA<RecoveryCodeRefused>(),
|
||||
);
|
||||
});
|
||||
}
|
||||
286
packages/halkyon_base_server/test/unit/totp/totp_test.dart
Normal file
286
packages/halkyon_base_server/test/unit/totp/totp_test.dart
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue