yubioath-flutter/lib/oath/models.dart
Elias Bonnici 87b463f4e8
Fix parsing of otpauth-migration Uri.
This solves the issue regarding duplicate accounts not being
recognized.
2024-01-10 17:02:41 +01:00

255 lines
7.5 KiB
Dart
Executable File

/*
* Copyright (C) 2022 Yubico.
*
* 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.
*/
import 'dart:convert';
import 'dart:typed_data';
import 'package:base32/base32.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../core/models.dart';
part 'models.freezed.dart';
part 'models.g.dart';
const defaultPeriod = 30;
const defaultDigits = 6;
const defaultCounter = 0;
const defaultOathType = OathType.totp;
const defaultHashAlgorithm = HashAlgorithm.sha1;
enum HashAlgorithm {
@JsonValue(0x01)
sha1('SHA-1'),
@JsonValue(0x02)
sha256('SHA-256'),
@JsonValue(0x03)
sha512('SHA-512');
final String displayName;
const HashAlgorithm(this.displayName);
}
enum OathType {
@JsonValue(0x10)
hotp,
@JsonValue(0x20)
totp;
const OathType();
String getDisplayName(AppLocalizations l10n) => switch (this) {
OathType.hotp => l10n.s_counter_based,
OathType.totp => l10n.s_time_based
};
}
enum KeystoreState { unknown, allowed, failed }
@freezed
class OathCredential with _$OathCredential {
factory OathCredential(
String deviceId,
String id,
String? issuer,
String name,
OathType oathType,
int period,
bool touchRequired) = _OathCredential;
factory OathCredential.fromJson(Map<String, dynamic> json) {
final value = _$OathCredentialFromJson(json);
// Replace empty issuer string with null
return switch (value.issuer) {
(String issuer) when issuer.isEmpty => value.copyWith(issuer: null),
_ => value,
};
}
}
@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;
factory OathPair.fromJson(Map<String, dynamic> json) =>
_$OathPairFromJson(json);
}
@freezed
class OathState with _$OathState {
factory OathState(
String deviceId,
Version version, {
required bool hasKey,
required bool remembered,
required bool locked,
required KeystoreState keystore,
}) = _OathState;
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,
@Default(defaultOathType) OathType oathType,
@Default(defaultHashAlgorithm) HashAlgorithm hashAlgorithm,
@Default(defaultDigits) int digits,
@Default(defaultPeriod) int period,
@Default(defaultCounter) int counter,
}) = _CredentialData;
factory CredentialData.fromJson(Map<String, dynamic> json) =>
_$CredentialDataFromJson(json);
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');
}
}
static List<CredentialData> fromMigration(Uri uri) {
// 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));
}
// Parse a single protobuf value from a buffer
(int tag, dynamic value, Uint8List rem) protoValue(Uint8List data) {
final first = data[0];
final int len;
(len, data) = protoInt(data.sublist(1));
final index = first >> 3;
switch (first & 0x07) {
case 0:
return (index, len, data);
case 2:
return (index, data.sublist(0, len), data.sublist(len));
}
throw ArgumentError('Unsupported value type!');
}
// 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;
}
return values;
}
// 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);
}
}
// Convert parsed credential values into CredentialData objects
return splitCreds(base64.decode(uri.queryParameters['data']!))
.map((values) {
String? issuer;
String name = utf8.decode(values[2], allowMalformed: true);
final nameIndex = name.indexOf(':');
if (nameIndex >= 0) {
issuer = name.substring(0, nameIndex);
name = name.substring(nameIndex + 1);
}
return CredentialData(
secret: base32.encode(values[1]),
name: name,
issuer: values[3] != null
? utf8.decode(values[3], allowMalformed: true)
: issuer,
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();
}
factory CredentialData.fromOtpauth(Uri uri) {
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,
hashAlgorithm: HashAlgorithm.values
.byName(params['algorithm']?.toLowerCase() ?? 'sha1'),
secret: params['secret']!,
digits: int.tryParse(params['digits'] ?? '') ?? defaultDigits,
period: int.tryParse(params['period'] ?? '') ?? defaultPeriod,
counter: int.tryParse(params['counter'] ?? '') ?? defaultCounter,
);
}
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,
},
);
}