import 'dart:convert'; import 'package:ente_auth/models/code_display.dart'; import 'package:ente_auth/utils/totp_util.dart'; import 'package:logging/logging.dart'; class Code { static const defaultDigits = 6; static const steamDigits = 5; static const defaultPeriod = 30; int? generatedID; final String account; final String issuer; final int digits; final int period; final String secret; final Algorithm algorithm; final Type type; /// otpauth url in the code final String rawData; final int counter; bool? hasSynced; final CodeDisplay display; bool get isPinned => display.pinned; bool get isTrashed => display.trashed; final Object? err; bool get hasError => err != null; Code( this.account, this.issuer, this.digits, this.period, this.secret, this.algorithm, this.type, this.counter, this.rawData, { this.generatedID, required this.display, this.err, }); factory Code.withError(Object error, String rawData) { return Code( "", "", 0, 0, "", Algorithm.sha1, Type.totp, 0, rawData, err: error, display: CodeDisplay(), ); } Code copyWith({ String? account, String? issuer, int? digits, int? period, String? secret, Algorithm? algorithm, Type? type, int? counter, CodeDisplay? display, }) { final String updateAccount = account ?? this.account; final String updateIssuer = issuer ?? this.issuer; final int updatedDigits = digits ?? this.digits; final int updatePeriod = period ?? this.period; final String updatedSecret = secret ?? this.secret; final Algorithm updatedAlgo = algorithm ?? this.algorithm; final Type updatedType = type ?? this.type; final int updatedCounter = counter ?? this.counter; final CodeDisplay updatedDisplay = display ?? this.display; final String encodedIssuer = Uri.encodeQueryComponent(updateIssuer); return Code( updateAccount, updateIssuer, updatedDigits, updatePeriod, updatedSecret, updatedAlgo, updatedType, updatedCounter, "otpauth://${updatedType.name}/$updateIssuer:$updateAccount?algorithm=${updatedAlgo.name}" "&digits=$updatedDigits&issuer=$encodedIssuer" "&period=$updatePeriod&secret=$updatedSecret${updatedType == Type.hotp ? "&counter=$updatedCounter" : ""}", generatedID: generatedID, display: updatedDisplay, ); } static Code fromAccountAndSecret( Type type, String account, String issuer, String secret, CodeDisplay? display, int digits, ) { final String encodedIssuer = Uri.encodeQueryComponent(issuer); return Code( account, issuer, digits, defaultPeriod, secret, Algorithm.sha1, type, 0, "otpauth://${type.name}/$issuer:$account?algorithm=SHA1&digits=$digits&issuer=$encodedIssuer&period=30&secret=$secret", display: display ?? CodeDisplay(), ); } static Code fromOTPAuthUrl(String rawData, {CodeDisplay? display}) { Uri uri = Uri.parse(rawData); final issuer = _getIssuer(uri); final account = _getAccount(uri); try { final code = Code( account, issuer, _getDigits(uri), _getPeriod(uri), getSanitizedSecret(uri.queryParameters['secret']!), _getAlgorithm(uri), _getType(uri), _getCounter(uri), rawData, display: CodeDisplay.fromUri(uri) ?? CodeDisplay(), ); return code; } catch (e) { // if account name contains # without encoding, // rest of the url are treated as url fragment if (rawData.contains("#")) { return Code.fromOTPAuthUrl(rawData.replaceAll("#", '%23')); } else { Logger("Code").warning( 'Error while parsing code for issuer $issuer, $account', e, ); rethrow; } } } static String _getAccount(Uri uri) { try { String path = Uri.decodeComponent(uri.path); if (path.startsWith("/")) { path = path.substring(1, path.length); } // Parse account name from documented auth URI // otpauth://totp/ACCOUNT?secret=SUPERSECRET&issuer=SERVICE if (uri.queryParameters.containsKey("issuer") && !path.contains(":")) { return path; } return path.split(':')[1]; } catch (e) { return ""; } } static Code fromExportJson(Map rawJson) { Code resultCode = Code.fromOTPAuthUrl( rawJson['rawData'], display: CodeDisplay.fromJson(rawJson['display']), ); return resultCode; } String toOTPAuthUrlFormat() { final uri = Uri.parse(rawData.replaceAll("#", '%23')); final query = {...uri.queryParameters}; query["codeDisplay"] = jsonEncode(display.toJson()); final newUri = uri.replace(queryParameters: query); return jsonEncode(newUri.toString()); } static String _getIssuer(Uri uri) { try { if (uri.queryParameters.containsKey("issuer")) { String issuerName = uri.queryParameters['issuer']!; // Handle issuer name with period // See https://github.com/ente-io/ente/pull/77 if (issuerName.contains("period=")) { return issuerName.substring(0, issuerName.indexOf("period=")); } return issuerName; } final String path = Uri.decodeComponent(uri.path); return path.split(':')[0].substring(1); } catch (e) { return ""; } } static int _getDigits(Uri uri) { try { return int.parse(uri.queryParameters['digits']!); } catch (e) { if (uri.host == "steam") { return steamDigits; } return defaultDigits; } } static int _getPeriod(Uri uri) { try { return int.parse(uri.queryParameters['period']!); } catch (e) { return defaultPeriod; } } static int _getCounter(Uri uri) { try { final bool hasCounterKey = uri.queryParameters.containsKey('counter'); if (!hasCounterKey) { return 0; } return int.parse(uri.queryParameters['counter']!); } catch (e) { return defaultPeriod; } } static Algorithm _getAlgorithm(Uri uri) { try { final algorithm = uri.queryParameters['algorithm'].toString().toLowerCase(); if (algorithm == "sha256") { return Algorithm.sha256; } else if (algorithm == "sha512") { return Algorithm.sha512; } } catch (e) { // nothing } return Algorithm.sha1; } static Type _getType(Uri uri) { if (uri.host == "totp") { return Type.totp; } else if (uri.host == "steam") { return Type.steam; } else if (uri.host == "hotp") { return Type.hotp; } throw UnsupportedError("Unsupported format with host ${uri.host}"); } @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is Code && other.account == account && other.issuer == issuer && other.digits == digits && other.period == period && other.secret == secret && other.counter == counter && other.type == type && other.rawData == rawData; } @override int get hashCode { return account.hashCode ^ issuer.hashCode ^ digits.hashCode ^ period.hashCode ^ secret.hashCode ^ type.hashCode ^ counter.hashCode ^ rawData.hashCode; } } enum Type { totp, hotp, steam; bool get isTOTPCompatible => this == totp || this == steam; } enum Algorithm { sha1, sha256, sha512, }