Kaynağa Gözat

Add TOTP package

Sasan Salamzadeh 1 yıl önce
ebeveyn
işleme
26ec195889

+ 30 - 0
totp/analysis_options.yaml

@@ -0,0 +1,30 @@
+# This file configures the static analysis results for your project (errors,
+# warnings, and lints).
+#
+# This enables the 'recommended' set of lints from `package:lints`.
+# This set helps identify many issues that may lead to problems when running
+# or consuming Dart code, and enforces writing Dart using a single, idiomatic
+# style and format.
+#
+# If you want a smaller set of lints you can change this to specify
+# 'package:lints/core.yaml'. These are just the most critical lints
+# (the recommended set includes the core lints).
+# The core lints are also what is used by pub.dev for scoring packages.
+
+include: package:lints/recommended.yaml
+
+# Uncomment the following section to specify additional rules.
+
+# linter:
+#   rules:
+#     - camel_case_types
+
+# analyzer:
+#   exclude:
+#     - path/to/excluded/files/**
+
+# For more information about the core and recommended set of lints, see
+# https://dart.dev/go/core-lints
+
+# For additional information about configuring this file, see
+# https://dart.dev/guides/language/analysis-options

+ 5 - 0
totp/example/totp_example.dart

@@ -0,0 +1,5 @@
+import 'package:pb_authenticator_totp/totp_algorithm.dart';
+
+void main() {
+  print(Totp.generateCode(1542791843, "JBSWY3DPEHPK3PXP"));
+}

+ 92 - 0
totp/lib/base32.dart

@@ -0,0 +1,92 @@
+import 'dart:typed_data' show Uint8List, ByteData;
+import 'dart:math' show min;
+
+/// Class for decoding base32 String into List<Int>.
+class Base32 {
+  /// Decodes an [input] base32 String into Uint8List.
+  static List<int> decode(String input) {
+    // Map characters to values after removing equal signs
+    var mapped = input
+        .split('')
+        .where((c) => c != '=') // remove '='
+        .map((c) => _base32Map[c])
+        .toList();
+
+    // Initialise list
+    // If completed at current index, floor division is sufficient
+    // If part of previous index, floor division is still sufficient
+    var length = (mapped.length * 5 / 8).floor();
+    var bdata = ByteData(length);
+
+    // For each byte
+    for (int i = 0; i < length; i++) {
+      // Offset of byte in bits
+      int bitOffset = i * 8;
+
+      // Start index and offset
+      int start = (bitOffset / 5).floor();
+      int offset = bitOffset % 5;
+
+      // Bits to take out of 1st, 2nd and 3rd
+      int n1 = 5 - offset;
+      int n2 = min(5, 8 - n1);
+      int n3 = 8 - n1 - n2;
+
+      // print("$start: $n1 $n2 $n3");
+      // print(Uint8List.view(bdata.buffer));
+
+      // Bitwise OR the segments together
+      int value = mapped[start]! << (8 - n1); // Right-most bits of first
+      if (n2 > 0 && mapped.length > start + 1) {
+        value |= (mapped[start + 1]! >> (5 - n2)) << n3; // Left-most and shift
+      }
+      if (n3 > 0 && mapped.length > start + 2) {
+        value |= mapped[start + 2]! >> (5 - n3); // Left-most bits of third
+      }
+      bdata.setUint8(i, value);
+    }
+
+    return Uint8List.view(bdata.buffer);
+  }
+
+  /// Determines whether a given string is a valid base32 string.
+  static bool isBase32(String input) {
+    return input.split('').every((c) => c == '=' || _base32Map.containsKey(c));
+  }
+
+  /// Decode map
+  static const _base32Map = {
+    'A': 0,
+    'B': 1,
+    'C': 2,
+    'D': 3,
+    'E': 4,
+    'F': 5,
+    'G': 6,
+    'H': 7,
+    'I': 8,
+    'J': 9,
+    'K': 10,
+    'L': 11,
+    'M': 12,
+    'N': 13,
+    'O': 14,
+    'P': 15,
+    'Q': 16,
+    'R': 17,
+    'S': 18,
+    'T': 19,
+    'U': 20,
+    'V': 21,
+    'W': 22,
+    'X': 23,
+    'Y': 24,
+    'Z': 25,
+    '2': 26,
+    '3': 27,
+    '4': 28,
+    '5': 29,
+    '6': 30,
+    '7': 31
+  };
+}

+ 75 - 0
totp/lib/otp_uri.dart

@@ -0,0 +1,75 @@
+import 'otpauth_migration.dart';
+import 'totp_algorithm.dart';
+import 'totp_item.dart' show TotpItem;
+
+
+/// Parses TOTP key URI into TOTPItems.
+///
+/// Reference:
+/// * https://github.com/google/google-authenticator/wiki/Key-Uri-Format
+class OtpUri {
+  /// Parses a TOTP key URI and returns a TOTP object
+  static TotpItem fromUri(String uri) {
+    // Use dart-core/Uri to parse
+    var parsed = Uri.parse(uri);
+
+    // Check scheme
+    if (parsed.scheme != "otpauth") {
+      try {
+        final otp_auth_parser = OtpAuthMigration();
+
+        List<String> parseds = otp_auth_parser.decode(uri);
+        parsed = Uri.parse(parseds.first);
+      } catch (e) {
+        throw FormatException("Not OTP URI");
+      }
+
+    }
+    if (parsed.authority != "totp" && parsed.authority != "hotp") {
+      throw FormatException("Unsupported authority");
+    }
+
+    // Extract issuer/account name
+    if (parsed.pathSegments.length > 1) {
+      throw FormatException("Should have more than 1 path segment");
+    }
+    var pathSplit = parsed.pathSegments[0].split(':');
+    var issuer = pathSplit[0];
+    var accountName = pathSplit.length > 1 ? pathSplit[1] : '';
+
+    // Extract algorithm, digits, period and secret
+    var algorithm = "sha1";
+    var digits = 6;
+    var period = 30;
+    var secret = "";
+
+    if (!parsed.queryParameters.containsKey("secret")) {
+      throw FormatException("Query parameter does not contain secret");
+    }
+    secret = parsed.queryParameters["secret"]!;
+
+    if (parsed.queryParameters.containsKey("algorithm")) {
+      algorithm = parsed.queryParameters["algorithm"]!.toLowerCase();
+      if (algorithm != "sha1" &&
+          algorithm != "sha256" &&
+          algorithm != "sha512") {
+        throw FormatException("Unrecognised algorithm");
+      }
+    }
+
+    if (parsed.queryParameters.containsKey("digits")) {
+      digits = int.parse(parsed.queryParameters["digits"]!);
+    }
+
+    if (parsed.queryParameters.containsKey("period")) {
+      period = int.parse(parsed.queryParameters["period"]!);
+    }
+
+    try {
+      return TotpItem.newTotpItem(secret, digits, period,
+          OtpHashAlgorithm.fromString(algorithm), issuer, accountName);
+    } catch (error) {
+      throw FormatException("Incorrect parameters");
+    }
+  }
+}

+ 4 - 0
totp/lib/totp.dart

@@ -0,0 +1,4 @@
+library pb_authenticator_totp;
+
+export 'totp_item.dart';
+export 'base32.dart';

+ 99 - 0
totp/lib/totp_algorithm.dart

@@ -0,0 +1,99 @@
+import 'dart:math' show pow;
+import 'dart:typed_data' show Uint8List, Endian;
+import 'package:crypto/crypto.dart' show Hash, Hmac, sha1, sha256, sha512;
+import 'base32.dart' show Base32;
+
+/// Hash algorithm to OTP
+enum OtpHashAlgorithm {
+  sha1,
+  sha256,
+  sha512;
+
+  static OtpHashAlgorithm fromString(String str) {
+    if (str == "sha1") {
+      return OtpHashAlgorithm.sha1;
+    } else if (str == "sha256") {
+      return OtpHashAlgorithm.sha256;
+    } else if (str == "sha512") {
+      return OtpHashAlgorithm.sha512;
+    }
+    throw Exception("Unknown algorithm");
+  }
+}
+
+extension _StringOperations on OtpHashAlgorithm {
+  Hash toHashFunction() {
+    if (this == OtpHashAlgorithm.sha1) {
+      return sha1;
+    } else if (this == OtpHashAlgorithm.sha256) {
+      return sha256;
+    } else if (this == OtpHashAlgorithm.sha512) {
+      return sha512;
+    }
+    throw Exception("Unknown algorithm");
+  }
+}
+
+/// Static class for generating TOTP codes.
+///
+/// References:
+/// * RFC 4226, 6238
+/// * https://github.com/LanceGin/dotp/blob/master/lib/src/otp.dart
+/// * https://stackoverflow.com/questions/49398437
+class Totp {
+  /// Formats a generated [code] to make it look nice
+  static String prettyValue(String code) {
+    // Length at which to split at
+    int splitLength = code.length == 8 ? 4 : 3;
+    // Combine 2 halves
+    return '${code.substring(0, splitLength)} ${code.substring(splitLength)}';
+  }
+
+  /// Generates a TOTP value for the given attributes.
+  ///
+  /// Note:
+  /// * [secret] should be a base32 string.
+  /// * Currently only supports sha1 and sha256.
+  static String generateCode(int time, String secret,
+      [int digits = 6,
+      int period = 30,
+      OtpHashAlgorithm algorithm = OtpHashAlgorithm.sha1]) {
+    // Calculate number of time steps between T0 (assumed to be Unix Epoch)
+    // and current time
+    int timeCounter = ((time - 0) / period).floor();
+    // TOTP = HOTP(K, T)
+    return _generateHOTP(secret, timeCounter, digits, algorithm);
+  }
+
+  /// Generate multiple TOTP values for the given [times].
+  ///
+  /// Currently only supports sha1 and sha256.
+  static List<String> generateCodes(List<int> times, String secret,
+      [int digits = 6,
+      int period = 30,
+      OtpHashAlgorithm algorithm = OtpHashAlgorithm.sha1]) {
+    return times
+        .map((time) => generateCode(time, secret, digits, period, algorithm))
+        .toList();
+  }
+
+  /// Generates the HOTP code.
+  static String _generateHOTP(
+      String secret, int timeCounter, int digits, OtpHashAlgorithm algorithm) {
+    var key = Base32.decode(secret);
+    var bytes = Uint8List(8)
+      ..buffer.asByteData().setInt64(0, timeCounter, Endian.big);
+
+    // Determine algorithm
+    var hmac = Hmac(algorithm.toHashFunction(), key);
+    var digest = hmac.convert(bytes);
+
+    int offset = digest.bytes[digest.bytes.length - 1] & 0xf;
+    int binary = ((digest.bytes[offset] & 0x7f) << 24) |
+        ((digest.bytes[offset + 1] & 0xff) << 16) |
+        ((digest.bytes[offset + 2] & 0xff) << 8) |
+        (digest.bytes[offset + 3] & 0xff);
+    num otp = binary % (pow(10, digits));
+    return otp.toString().padLeft(digits, "0");
+  }
+}

+ 110 - 0
totp/lib/totp_item.dart

@@ -0,0 +1,110 @@
+import 'base32.dart' show Base32;
+import 'totp_algorithm.dart' show OtpHashAlgorithm, Totp;
+import 'otp_uri.dart' show OtpUri;
+
+/// Represents a TOTP item and associated information.
+///
+/// Has properties of accountName, issuer, secret, digits, period and algorithm,
+/// as well as an id which is randomly assigned on generation.
+class TotpItem {
+  TotpItem(this.secret,
+      [this.digits = 6,
+      this.period = 30,
+      this.algorithm = OtpHashAlgorithm.sha1,
+      this.issuer = "",
+      this.accountName = ""])
+      : assert(Base32.isBase32(secret) && secret != ''),
+        assert(digits == 6 || digits == 8),
+        assert(period > 0);
+
+  /// Account name
+  final String accountName;
+
+  /// Issuer
+  final String issuer;
+
+  /// Secret key (in base32)
+  final String secret;
+
+  /// Number of digits (of code)
+  final int digits;
+
+  /// Time period
+  final int period;
+
+  /// Algorithm (sha1/sha256/sha512)
+  final OtpHashAlgorithm algorithm;
+
+  /// Creates a new TOTP item.
+  static TotpItem newTotpItem(String secret,
+      [int digits = 6,
+      int period = 60,
+      OtpHashAlgorithm algorithm = OtpHashAlgorithm.sha1,
+      String issuer = "",
+      String accountName = ""]) {
+    return TotpItem(secret, digits, period, algorithm, issuer, accountName);
+  }
+
+  /// Parses a TOTP key URI and returns a TOTPItem.
+  static TotpItem fromUri(String uri) {
+    return OtpUri.fromUri(uri);
+  }
+
+  /// Generates a formatted TOTP value for the given [time].
+  String getPrettyCode(int time) {
+    return Totp.prettyValue(
+        Totp.generateCode(time, secret, digits, period, algorithm));
+  }
+
+  /// Generates a TOTP value for the given [time].
+  String getCode(int time) {
+    return Totp.generateCode(time, secret, digits, period, algorithm);
+  }
+
+  /// Returns a placeholder representation of the generated code.
+  String get placeholder {
+    if (digits == 8) {
+      return '···· ····';
+    }
+    return '··· ···';
+  }
+
+  @override
+  bool operator ==(Object other) =>
+      identical(this, other) ||
+      other is TotpItem &&
+          accountName == other.accountName &&
+          issuer == other.issuer &&
+          secret == other.secret &&
+          digits == other.digits &&
+          period == other.period &&
+          algorithm == other.algorithm;
+
+  @override
+  int get hashCode =>
+      accountName.hashCode ^
+      issuer.hashCode ^
+      period.hashCode ^
+      digits.hashCode ^
+      algorithm.hashCode ^
+      secret.hashCode;
+
+  /// Decode item from JSON.
+  TotpItem.fromJSON(Map<String, dynamic> json)
+      : accountName = json['accountName'],
+        issuer = json['issuer'],
+        period = json['period'],
+        digits = json['digits'],
+        algorithm = OtpHashAlgorithm.values.byName(json['algorithm']),
+        secret = json['secret'];
+
+  /// Encode item to JSON.
+  Map<String, dynamic> toJSON() => {
+        'accountName': accountName,
+        'issuer': issuer,
+        'secret': secret,
+        'digits': digits,
+        'period': period,
+        'algorithm': algorithm.name
+      };
+}

+ 418 - 0
totp/pubspec.lock

@@ -0,0 +1,418 @@
+# Generated by pub
+# See https://dart.dev/tools/pub/glossary#lockfile
+packages:
+  _fe_analyzer_shared:
+    dependency: transitive
+    description:
+      name: _fe_analyzer_shared
+      sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab"
+      url: "https://pub.dev"
+    source: hosted
+    version: "76.0.0"
+  _macros:
+    dependency: transitive
+    description: dart
+    source: sdk
+    version: "0.3.3"
+  analyzer:
+    dependency: transitive
+    description:
+      name: analyzer
+      sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e"
+      url: "https://pub.dev"
+    source: hosted
+    version: "6.11.0"
+  args:
+    dependency: transitive
+    description:
+      name: args
+      sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.5.0"
+  async:
+    dependency: transitive
+    description:
+      name: async
+      sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.11.0"
+  boolean_selector:
+    dependency: transitive
+    description:
+      name: boolean_selector
+      sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.1"
+  collection:
+    dependency: transitive
+    description:
+      name: collection
+      sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.19.0"
+  convert:
+    dependency: transitive
+    description:
+      name: convert
+      sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.1.1"
+  coverage:
+    dependency: transitive
+    description:
+      name: coverage
+      sha256: c1fb2dce3c0085f39dc72668e85f8e0210ec7de05345821ff58530567df345a5
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.9.2"
+  crypto:
+    dependency: "direct main"
+    description:
+      name: crypto
+      sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.5"
+  file:
+    dependency: transitive
+    description:
+      name: file
+      sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "7.0.0"
+  fixnum:
+    dependency: "direct main"
+    description:
+      name: fixnum
+      sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.1"
+  frontend_server_client:
+    dependency: transitive
+    description:
+      name: frontend_server_client
+      sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.0.0"
+  glob:
+    dependency: transitive
+    description:
+      name: glob
+      sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.2"
+  http_multi_server:
+    dependency: transitive
+    description:
+      name: http_multi_server
+      sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.2.1"
+  http_parser:
+    dependency: transitive
+    description:
+      name: http_parser
+      sha256: "40f592dd352890c3b60fec1b68e786cefb9603e05ff303dbc4dda49b304ecdf4"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.1.0"
+  io:
+    dependency: transitive
+    description:
+      name: io
+      sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.4"
+  js:
+    dependency: transitive
+    description:
+      name: js
+      sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.7.1"
+  lints:
+    dependency: "direct dev"
+    description:
+      name: lints
+      sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
+      url: "https://pub.dev"
+    source: hosted
+    version: "4.0.0"
+  logging:
+    dependency: transitive
+    description:
+      name: logging
+      sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.0"
+  macros:
+    dependency: transitive
+    description:
+      name: macros
+      sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.1.3-main.0"
+  matcher:
+    dependency: transitive
+    description:
+      name: matcher
+      sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.12.16+1"
+  meta:
+    dependency: transitive
+    description:
+      name: meta
+      sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.16.0"
+  mime:
+    dependency: transitive
+    description:
+      name: mime
+      sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.0.6"
+  node_preamble:
+    dependency: transitive
+    description:
+      name: node_preamble
+      sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.0.2"
+  package_config:
+    dependency: transitive
+    description:
+      name: package_config
+      sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.0"
+  path:
+    dependency: transitive
+    description:
+      name: path
+      sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.9.0"
+  pool:
+    dependency: transitive
+    description:
+      name: pool
+      sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.5.1"
+  protobuf:
+    dependency: "direct main"
+    description:
+      name: protobuf
+      sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.1.0"
+  pub_semver:
+    dependency: transitive
+    description:
+      name: pub_semver
+      sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.4"
+  shelf:
+    dependency: transitive
+    description:
+      name: shelf
+      sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.4.2"
+  shelf_packages_handler:
+    dependency: transitive
+    description:
+      name: shelf_packages_handler
+      sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.2"
+  shelf_static:
+    dependency: transitive
+    description:
+      name: shelf_static
+      sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.3"
+  shelf_web_socket:
+    dependency: transitive
+    description:
+      name: shelf_web_socket
+      sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611"
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.0.0"
+  source_map_stack_trace:
+    dependency: transitive
+    description:
+      name: source_map_stack_trace
+      sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.2"
+  source_maps:
+    dependency: transitive
+    description:
+      name: source_maps
+      sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.10.12"
+  source_span:
+    dependency: transitive
+    description:
+      name: source_span
+      sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.10.0"
+  stack_trace:
+    dependency: transitive
+    description:
+      name: stack_trace
+      sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.12.0"
+  stream_channel:
+    dependency: transitive
+    description:
+      name: stream_channel
+      sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
+      url: "https://pub.dev"
+    source: hosted
+    version: "2.1.2"
+  string_scanner:
+    dependency: transitive
+    description:
+      name: string_scanner
+      sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.0"
+  term_glyph:
+    dependency: transitive
+    description:
+      name: term_glyph
+      sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.1"
+  test:
+    dependency: "direct dev"
+    description:
+      name: test
+      sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.25.8"
+  test_api:
+    dependency: transitive
+    description:
+      name: test_api
+      sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.7.3"
+  test_core:
+    dependency: transitive
+    description:
+      name: test_core
+      sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.6.5"
+  typed_data:
+    dependency: transitive
+    description:
+      name: typed_data
+      sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.3.2"
+  vm_service:
+    dependency: transitive
+    description:
+      name: vm_service
+      sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
+      url: "https://pub.dev"
+    source: hosted
+    version: "14.3.0"
+  watcher:
+    dependency: transitive
+    description:
+      name: watcher
+      sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.0"
+  web:
+    dependency: transitive
+    description:
+      name: web
+      sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.1.0"
+  web_socket:
+    dependency: transitive
+    description:
+      name: web_socket
+      sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83"
+      url: "https://pub.dev"
+    source: hosted
+    version: "0.1.6"
+  web_socket_channel:
+    dependency: transitive
+    description:
+      name: web_socket_channel
+      sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.1"
+  webkit_inspection_protocol:
+    dependency: transitive
+    description:
+      name: webkit_inspection_protocol
+      sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
+      url: "https://pub.dev"
+    source: hosted
+    version: "1.2.1"
+  yaml:
+    dependency: transitive
+    description:
+      name: yaml
+      sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.1.2"
+sdks:
+  dart: ">=3.5.0 <4.0.0"

+ 16 - 0
totp/pubspec.yaml

@@ -0,0 +1,16 @@
+name: pb_authenticator_totp
+description: TOTP library used by pb_authenticator.
+version: 1.0.0
+publish_to: none
+
+environment:
+  sdk: '>=3.3.0 <4.0.0'
+
+dependencies:
+  crypto: ^3.0.5
+  protobuf: ^3.1.0
+  fixnum: ^1.1.1
+
+dev_dependencies:
+  lints: ^4.0.0
+  test: ^1.25.2

+ 113 - 0
totp/test/totp_test.dart

@@ -0,0 +1,113 @@
+import 'package:test/test.dart';
+import 'package:pb_authenticator_totp/totp.dart';
+import 'package:pb_authenticator_totp/totp_algorithm.dart';
+import 'package:pb_authenticator_totp/otp_uri.dart';
+
+void main() {
+  // https://datatracker.ietf.org/doc/html/rfc4648#section-10
+  // https://stackoverflow.com/a/54263961
+  test('Base32', () {
+    expect(Base32.decode("MY======"), "f".codeUnits);
+    expect(Base32.decode("MZXQ===="), "fo".codeUnits);
+    expect(Base32.decode("MZXW6==="), "foo".codeUnits);
+    expect(Base32.decode("MZXW6YQ="), "foob".codeUnits);
+    expect(Base32.decode("MZXW6YTB"), "fooba".codeUnits);
+    expect(Base32.decode("MZXW6YTBOI======"), "foobar".codeUnits);
+    expect(Base32.isBase32("MZXW6YTBOI======"), true);
+    expect(Base32.decode("AE======"), "\x01".codeUnits);
+    expect(Base32.decode("AA======"), "\x00".codeUnits);
+  });
+
+  test('TOTP - Key URI 1', () {
+    var parsed = OtpUri.fromUri(
+        "otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example");
+    expect(parsed.accountName, "alice@example.com");
+    expect(parsed.algorithm, OtpHashAlgorithm.sha1);
+    expect(parsed.digits, 6);
+    expect(parsed.issuer, "Example");
+    expect(parsed.period, 30);
+    expect(parsed.secret, "JBSWY3DPEHPK3PXP");
+  });
+
+  test('TOTP - Key URI Complete', () {
+    var parsed = OtpUri.fromUri(
+        "otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30");
+    expect(parsed.accountName, "john.doe@email.com");
+    expect(parsed.algorithm, OtpHashAlgorithm.sha1);
+    expect(parsed.digits, 6);
+    expect(parsed.issuer, "ACME Co");
+    expect(parsed.period, 30);
+    expect(parsed.secret, "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ");
+  });
+
+  test('TOTP - Bad Key URI', () {
+    expect(
+        () => OtpUri.fromUri(
+            "otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&digits=12"),
+        throwsA(allOf(
+            isFormatException,
+            predicate((e) =>
+                e is FormatException && e.message == "Incorrect parameters"))));
+  });
+
+  test('TOTP - Bad Algorithm', () {
+    expect(
+        () => OtpUri.fromUri(
+            "otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&digits=12&algorithm=sha??"),
+        throwsA(allOf(
+            isFormatException,
+            predicate((e) =>
+                e is FormatException &&
+                e.message == "Unrecognised algorithm"))));
+  });
+
+  test('TOTP - 1542791843', () {
+    expect(Totp.generateCode(1542791843, "JBSWY3DPEHPK3PXP"), "092264");
+  });
+
+  test('TOTP - 59', () {
+    expect(Totp.generateCode(59, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", 8),
+        "94287082");
+  });
+
+  test('TOTP - 20000000000', () {
+    expect(
+        Totp.generateCode(20000000000, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", 8),
+        "65353130");
+  });
+
+  test('TOTP - Multiple', () {
+    expect(
+        Totp.generateCodes(
+            [59, 20000000000], "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", 8),
+        ["94287082", "65353130"]);
+  });
+
+  test('TOTP Item attributes', () {
+    var secret = "A";
+    var digits = 8;
+    var period = 60;
+    var algorithm = OtpHashAlgorithm.sha256;
+    var issuer = "bar";
+    var accountName = "foo@bar";
+    var item = TotpItem(secret, digits, period, algorithm, issuer, accountName);
+    expect(item.secret, secret);
+    expect(item.digits, digits);
+    expect(item.period, period);
+    expect(item.algorithm, algorithm);
+    expect(item.issuer, issuer);
+    expect(item.accountName, accountName);
+  });
+
+  test('TOTP Item equality', () {
+    var item1 = TotpItem("A");
+    var item2 = TotpItem("A");
+    var item3 = TotpItem("B");
+    // Same object
+    expect(item1 == item1, true);
+    // Same secret (and other fields apart from id), so still equal
+    expect(item1 == item2, true);
+    // Secret changed
+    expect(item1 == item3, false);
+  });
+}