2022-10-04 13:12:54 +03:00
|
|
|
/*
|
2024-01-24 11:36:33 +03:00
|
|
|
* Copyright (C) 2022-2024 Yubico.
|
2022-10-04 13:12:54 +03:00
|
|
|
*
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
*
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
*
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
* limitations under the License.
|
|
|
|
*/
|
|
|
|
|
2023-05-12 12:38:56 +03:00
|
|
|
import 'dart:convert';
|
2023-11-27 13:41:05 +03:00
|
|
|
import 'dart:typed_data';
|
|
|
|
|
2023-05-12 12:38:56 +03:00
|
|
|
import 'package:base32/base32.dart';
|
2023-03-02 16:49:02 +03:00
|
|
|
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
2023-11-27 13:41:05 +03:00
|
|
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
2021-11-19 17:05:57 +03:00
|
|
|
|
2022-05-25 19:24:45 +03:00
|
|
|
import '../core/models.dart';
|
|
|
|
|
2021-11-19 17:05:57 +03:00
|
|
|
part 'models.freezed.dart';
|
|
|
|
part 'models.g.dart';
|
|
|
|
|
2022-01-28 19:05:05 +03:00
|
|
|
const defaultPeriod = 30;
|
|
|
|
const defaultDigits = 6;
|
|
|
|
const defaultCounter = 0;
|
|
|
|
const defaultOathType = OathType.totp;
|
|
|
|
const defaultHashAlgorithm = HashAlgorithm.sha1;
|
|
|
|
|
2021-11-19 17:05:57 +03:00
|
|
|
enum HashAlgorithm {
|
|
|
|
@JsonValue(0x01)
|
2022-06-07 15:12:41 +03:00
|
|
|
sha1('SHA-1'),
|
2021-11-19 17:05:57 +03:00
|
|
|
@JsonValue(0x02)
|
2022-06-07 15:12:41 +03:00
|
|
|
sha256('SHA-256'),
|
2021-11-19 17:05:57 +03:00
|
|
|
@JsonValue(0x03)
|
2022-06-07 15:12:41 +03:00
|
|
|
sha512('SHA-512');
|
|
|
|
|
|
|
|
final String displayName;
|
|
|
|
const HashAlgorithm(this.displayName);
|
2021-11-19 17:05:57 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
enum OathType {
|
|
|
|
@JsonValue(0x10)
|
2023-03-02 16:49:02 +03:00
|
|
|
hotp,
|
2021-11-19 17:05:57 +03:00
|
|
|
@JsonValue(0x20)
|
2023-03-02 16:49:02 +03:00
|
|
|
totp;
|
2022-06-07 15:12:41 +03:00
|
|
|
|
2023-03-02 16:49:02 +03:00
|
|
|
const OathType();
|
|
|
|
|
2023-05-22 12:52:49 +03:00
|
|
|
String getDisplayName(AppLocalizations l10n) => switch (this) {
|
|
|
|
OathType.hotp => l10n.s_counter_based,
|
|
|
|
OathType.totp => l10n.s_time_based
|
|
|
|
};
|
2021-11-19 17:05:57 +03:00
|
|
|
}
|
|
|
|
|
2022-02-22 17:22:41 +03:00
|
|
|
enum KeystoreState { unknown, allowed, failed }
|
|
|
|
|
2021-11-19 17:05:57 +03:00
|
|
|
@freezed
|
|
|
|
class OathCredential with _$OathCredential {
|
|
|
|
factory OathCredential(
|
|
|
|
String deviceId,
|
|
|
|
String id,
|
2024-01-24 11:36:33 +03:00
|
|
|
@_IssuerConverter() String? issuer,
|
2021-11-19 17:05:57 +03:00
|
|
|
String name,
|
|
|
|
OathType oathType,
|
|
|
|
int period,
|
|
|
|
bool touchRequired) = _OathCredential;
|
|
|
|
|
2024-01-24 11:36:33 +03:00
|
|
|
factory OathCredential.fromJson(Map<String, dynamic> json) =>
|
|
|
|
_$OathCredentialFromJson(json);
|
|
|
|
}
|
|
|
|
|
|
|
|
class _IssuerConverter implements JsonConverter<String?, String?> {
|
|
|
|
const _IssuerConverter();
|
|
|
|
|
|
|
|
@override
|
|
|
|
String? fromJson(String? json) => json != null && json.isEmpty ? null : json;
|
|
|
|
|
|
|
|
@override
|
|
|
|
String? toJson(String? object) => object;
|
2021-11-19 17:05:57 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
@freezed
|
|
|
|
class OathCode with _$OathCode {
|
|
|
|
factory OathCode(String value, int validFrom, int validTo) = _OathCode;
|
|
|
|
|
|
|
|
factory OathCode.fromJson(Map<String, dynamic> json) =>
|
|
|
|
_$OathCodeFromJson(json);
|
|
|
|
}
|
|
|
|
|
|
|
|
@freezed
|
|
|
|
class OathPair with _$OathPair {
|
|
|
|
factory OathPair(OathCredential credential, OathCode? code) = _OathPair;
|
2022-05-06 15:27:33 +03:00
|
|
|
|
|
|
|
factory OathPair.fromJson(Map<String, dynamic> json) =>
|
|
|
|
_$OathPairFromJson(json);
|
2021-11-19 17:05:57 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
@freezed
|
|
|
|
class OathState with _$OathState {
|
2024-02-02 15:52:22 +03:00
|
|
|
const OathState._();
|
|
|
|
|
2024-03-12 20:00:06 +03:00
|
|
|
factory OathState(String deviceId, Version version,
|
|
|
|
{required bool hasKey,
|
|
|
|
required bool remembered,
|
|
|
|
required bool locked,
|
|
|
|
required KeystoreState keystore}) = _OathState;
|
2021-11-19 17:05:57 +03:00
|
|
|
|
2024-02-02 15:52:22 +03:00
|
|
|
int? get capacity =>
|
|
|
|
version.isAtLeast(4) ? (version.isAtLeast(5, 7) ? 64 : 32) : null;
|
|
|
|
|
2021-11-19 17:05:57 +03:00
|
|
|
factory OathState.fromJson(Map<String, dynamic> json) =>
|
|
|
|
_$OathStateFromJson(json);
|
|
|
|
}
|
|
|
|
|
|
|
|
@freezed
|
|
|
|
class CredentialData with _$CredentialData {
|
|
|
|
const CredentialData._();
|
|
|
|
|
|
|
|
factory CredentialData({
|
|
|
|
String? issuer,
|
|
|
|
required String name,
|
|
|
|
required String secret,
|
2022-01-28 19:05:05 +03:00
|
|
|
@Default(defaultOathType) OathType oathType,
|
|
|
|
@Default(defaultHashAlgorithm) HashAlgorithm hashAlgorithm,
|
|
|
|
@Default(defaultDigits) int digits,
|
|
|
|
@Default(defaultPeriod) int period,
|
|
|
|
@Default(defaultCounter) int counter,
|
2021-11-19 17:05:57 +03:00
|
|
|
}) = _CredentialData;
|
|
|
|
|
|
|
|
factory CredentialData.fromJson(Map<String, dynamic> json) =>
|
|
|
|
_$CredentialDataFromJson(json);
|
|
|
|
|
2023-08-08 17:21:56 +03:00
|
|
|
static List<CredentialData> fromUri(Uri uri) {
|
|
|
|
if (uri.scheme.toLowerCase() == 'otpauth-migration') {
|
|
|
|
return CredentialData.fromMigration(uri);
|
|
|
|
} else if (uri.scheme.toLowerCase() == 'otpauth') {
|
|
|
|
return [CredentialData.fromOtpauth(uri)];
|
|
|
|
} else {
|
|
|
|
throw ArgumentError('Invalid scheme');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-11 09:55:38 +03:00
|
|
|
static List<CredentialData> fromMigration(Uri uri) {
|
2023-08-11 12:59:32 +03:00
|
|
|
// Parse single protobuf encoded integer
|
|
|
|
(int value, Uint8List rem) protoInt(Uint8List data) {
|
|
|
|
final extras = data.takeWhile((b) => b & 0x80 != 0).length;
|
|
|
|
int value = 0;
|
|
|
|
for (int i = extras; i >= 0; i--) {
|
|
|
|
value = (value << 7) | (data[i] & 0x7F);
|
|
|
|
}
|
|
|
|
return (value, data.sublist(1 + extras));
|
|
|
|
}
|
|
|
|
|
2023-08-11 09:55:38 +03:00
|
|
|
// Parse a single protobuf value from a buffer
|
|
|
|
(int tag, dynamic value, Uint8List rem) protoValue(Uint8List data) {
|
|
|
|
final first = data[0];
|
2023-08-11 12:59:32 +03:00
|
|
|
final int len;
|
|
|
|
(len, data) = protoInt(data.sublist(1));
|
2023-08-11 09:55:38 +03:00
|
|
|
final index = first >> 3;
|
|
|
|
switch (first & 0x07) {
|
|
|
|
case 0:
|
2023-08-11 12:59:32 +03:00
|
|
|
return (index, len, data);
|
2023-08-11 09:55:38 +03:00
|
|
|
case 2:
|
2023-08-11 12:59:32 +03:00
|
|
|
return (index, data.sublist(0, len), data.sublist(len));
|
2023-06-13 14:48:41 +03:00
|
|
|
}
|
2023-08-11 09:55:38 +03:00
|
|
|
throw ArgumentError('Unsupported value type!');
|
|
|
|
}
|
2023-05-12 12:38:56 +03:00
|
|
|
|
2023-08-11 09:55:38 +03:00
|
|
|
// Parse a protobuf message into map of tags and values
|
|
|
|
Map<int, dynamic> protoMap(Uint8List data) {
|
|
|
|
Map<int, dynamic> values = {};
|
|
|
|
while (data.isNotEmpty) {
|
|
|
|
final (tag, value, rem) = protoValue(data);
|
|
|
|
values[tag] = value;
|
|
|
|
data = rem;
|
2023-05-12 12:38:56 +03:00
|
|
|
}
|
2023-08-11 09:55:38 +03:00
|
|
|
return values;
|
|
|
|
}
|
2023-05-12 12:38:56 +03:00
|
|
|
|
2023-08-11 09:55:38 +03:00
|
|
|
// Parse encoded credentials from data (tag 1) ignoring trailing extra data
|
|
|
|
Iterable<Map<int, dynamic>> splitCreds(Uint8List rem) sync* {
|
|
|
|
Uint8List credrem;
|
|
|
|
while (rem[0] == 0x0a) {
|
|
|
|
(_, credrem, rem) = protoValue(rem);
|
|
|
|
yield protoMap(credrem);
|
2023-06-13 14:48:41 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-08-11 09:55:38 +03:00
|
|
|
// Convert parsed credential values into CredentialData objects
|
|
|
|
return splitCreds(base64.decode(uri.queryParameters['data']!))
|
2024-01-10 19:02:41 +03:00
|
|
|
.map((values) {
|
2024-01-11 13:14:52 +03:00
|
|
|
String? issuer = values[3] != null
|
|
|
|
? utf8.decode(values[3], allowMalformed: true)
|
|
|
|
: null;
|
2024-01-10 19:02:41 +03:00
|
|
|
String name = utf8.decode(values[2], allowMalformed: true);
|
|
|
|
final nameIndex = name.indexOf(':');
|
2024-01-11 13:14:52 +03:00
|
|
|
if (nameIndex >= 0 && issuer == null) {
|
2024-01-10 19:02:41 +03:00
|
|
|
issuer = name.substring(0, nameIndex);
|
|
|
|
name = name.substring(nameIndex + 1);
|
|
|
|
}
|
|
|
|
return CredentialData(
|
|
|
|
secret: base32.encode(values[1]),
|
|
|
|
name: name,
|
2024-01-11 13:14:52 +03:00
|
|
|
issuer: issuer,
|
2024-01-10 19:02:41 +03:00
|
|
|
hashAlgorithm: switch (values[4]) {
|
|
|
|
2 => HashAlgorithm.sha256,
|
|
|
|
3 => HashAlgorithm.sha512,
|
|
|
|
_ => HashAlgorithm.sha1,
|
|
|
|
},
|
|
|
|
digits: values[5] == 2 ? 8 : defaultDigits,
|
|
|
|
oathType: values[6] == 1 ? OathType.hotp : OathType.totp,
|
|
|
|
counter: values[7] ?? defaultCounter,
|
|
|
|
);
|
|
|
|
}).toList();
|
2023-06-13 14:48:41 +03:00
|
|
|
}
|
|
|
|
|
2023-08-08 17:21:56 +03:00
|
|
|
factory CredentialData.fromOtpauth(Uri uri) {
|
2022-02-11 16:56:35 +03:00
|
|
|
final oathType = OathType.values.byName(uri.host.toLowerCase());
|
|
|
|
final params = uri.queryParameters;
|
|
|
|
String? issuer;
|
|
|
|
String name = uri.pathSegments.join('/');
|
|
|
|
final nameIndex = name.indexOf(':');
|
|
|
|
if (nameIndex >= 0) {
|
|
|
|
issuer = name.substring(0, nameIndex);
|
|
|
|
name = name.substring(nameIndex + 1);
|
|
|
|
}
|
|
|
|
return CredentialData(
|
|
|
|
issuer: params['issuer'] ?? issuer,
|
|
|
|
name: name,
|
|
|
|
oathType: oathType,
|
2022-02-21 14:38:13 +03:00
|
|
|
hashAlgorithm: HashAlgorithm.values
|
|
|
|
.byName(params['algorithm']?.toLowerCase() ?? 'sha1'),
|
2022-02-11 16:56:35 +03:00
|
|
|
secret: params['secret']!,
|
|
|
|
digits: int.tryParse(params['digits'] ?? '') ?? defaultDigits,
|
|
|
|
period: int.tryParse(params['period'] ?? '') ?? defaultPeriod,
|
|
|
|
counter: int.tryParse(params['counter'] ?? '') ?? defaultCounter,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2022-07-06 11:12:30 +03:00
|
|
|
Uri toUri() => Uri(
|
|
|
|
scheme: 'otpauth',
|
|
|
|
host: oathType.name,
|
|
|
|
path: issuer != null ? '$issuer:$name' : name,
|
|
|
|
queryParameters: {
|
|
|
|
'secret': secret,
|
|
|
|
if (oathType == OathType.totp) 'period': period.toString(),
|
|
|
|
if (oathType == OathType.hotp) 'counter': counter.toString(),
|
|
|
|
if (issuer != null) 'issuer': issuer!,
|
|
|
|
if (digits != 6) 'digits': digits.toString(),
|
|
|
|
if (hashAlgorithm != HashAlgorithm.sha1)
|
|
|
|
'algorithm': hashAlgorithm.name,
|
|
|
|
},
|
|
|
|
);
|
2021-11-19 17:05:57 +03:00
|
|
|
}
|
2024-07-01 17:21:21 +03:00
|
|
|
|
|
|
|
enum OathLayout { list, grid, mixed }
|