This commit is contained in:
Dain Nilsson 2022-06-02 09:53:07 +02:00
commit 9e28512dcb
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
25 changed files with 272 additions and 136 deletions

View File

@ -3,6 +3,7 @@ package com.yubico.authenticator.oath
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ByteArraySerializer
import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor
@ -15,10 +16,19 @@ fun Model.Credential.isInteractive(): Boolean {
class Model { class Model {
@Serializable(with = VersionSerializer::class)
data class Version(
val major: Byte,
val minor: Byte,
val micro: Byte
)
@Serializable @Serializable
data class Session( data class Session(
@SerialName("device_id") @SerialName("device_id")
val deviceId: String = "", val deviceId: String = "",
@SerialName("version")
val version: Version = Version(0, 0, 0),
@SerialName("has_key") @SerialName("has_key")
val isAccessKeySet: Boolean = false, val isAccessKeySet: Boolean = false,
@SerialName("remembered") @SerialName("remembered")
@ -94,7 +104,26 @@ class Model {
} }
private var _credentials = mutableMapOf<Credential, Code?>(); private set object VersionSerializer : KSerializer<Version> {
override val descriptor: SerialDescriptor = ByteArraySerializer().descriptor
override fun serialize(encoder: Encoder, value: Version) {
encoder.encodeSerializableValue(
ByteArraySerializer(),
byteArrayOf(value.major, value.minor, value.micro)
)
}
override fun deserialize(decoder: Decoder): Version {
val byteArray = decoder.decodeSerializableValue(ByteArraySerializer())
val major = if (byteArray.isNotEmpty()) byteArray[0] else 0
val minor = if (byteArray.size > 1) byteArray[1] else 0
val micro = if (byteArray.size > 2) byteArray[2] else 0
return Version(major, minor, micro)
}
}
private var _credentials = mutableMapOf<Credential, Code?>()
var session = Session() var session = Session()
val credentials: List<CredentialWithCode> val credentials: List<CredentialWithCode>

View File

@ -438,6 +438,11 @@ class OathManager(
_model.session = Model.Session( _model.session = Model.Session(
oathSession.deviceId, oathSession.deviceId,
Model.Version(
oathSession.version.major,
oathSession.version.minor,
oathSession.version.micro
),
oathSession.isAccessKeySet, oathSession.isAccessKeySet,
isRemembered, isRemembered,
oathSession.isLocked oathSession.isLocked

View File

@ -91,6 +91,7 @@ class OathNode(RpcNode):
def __init__(self, connection): def __init__(self, connection):
super().__init__() super().__init__()
self.session = OathSession(connection) self.session = OathSession(connection)
self._key_verifier = None
if self.session.locked: if self.session.locked:
key = self._get_access_key(self.session.device_id) key = self._get_access_key(self.session.device_id)
@ -162,12 +163,15 @@ class OathNode(RpcNode):
return decode_bytes(params.pop("key")) return decode_bytes(params.pop("key"))
raise ValueError("One of 'key' and 'password' must be provided.") raise ValueError("One of 'key' and 'password' must be provided.")
def _do_validate(self, key): def _set_key_verifier(self, key):
self.session.validate(key)
salt = os.urandom(32) salt = os.urandom(32)
digest = hmac.new(salt, key, "sha256").digest() digest = hmac.new(salt, key, "sha256").digest()
self._key_verifier = (salt, digest) self._key_verifier = (salt, digest)
def _do_validate(self, key):
self.session.validate(key)
self._set_key_verifier(key)
@action @action
def validate(self, params, event, signal): def validate(self, params, event, signal):
remember = params.pop("remember", False) remember = params.pop("remember", False)
@ -181,7 +185,7 @@ class OathNode(RpcNode):
valid = False valid = False
else: else:
raise e raise e
elif hasattr(self, "_key_verifier"): elif self._key_verifier:
salt, digest = self._key_verifier salt, digest = self._key_verifier
verify = hmac.new(salt, key, "sha256").digest() verify = hmac.new(salt, key, "sha256").digest()
valid = hmac.compare_digest(digest, verify) valid = hmac.compare_digest(digest, verify)
@ -198,18 +202,21 @@ class OathNode(RpcNode):
remember = params.pop("remember", False) remember = params.pop("remember", False)
key = self._get_key(params) key = self._get_key(params)
self.session.set_key(key) self.session.set_key(key)
self._set_key_verifier(key)
remember &= self._remember_key(key if remember else None) remember &= self._remember_key(key if remember else None)
return dict(remembered=remember) return dict(remembered=remember)
@action(condition=lambda self: self.session.has_key) @action(condition=lambda self: self.session.has_key)
def unset_key(self, params, event, signal): def unset_key(self, params, event, signal):
self.session.unset_key() self.session.unset_key()
self._key_verifier = None
self._remember_key(None) self._remember_key(None)
return dict() return dict()
@action @action
def reset(self, params, event, signal): def reset(self, params, event, signal):
self.session.reset() self.session.reset()
self._key_verifier = None
self._remember_key(None) self._remember_key(None)
return dict() return dict()

View File

@ -31,11 +31,11 @@ VSVersionInfo(
'040904b0', '040904b0',
[StringStruct('CompanyName', 'Yubico'), [StringStruct('CompanyName', 'Yubico'),
StringStruct('FileDescription', 'Yubico Authenticator Helper'), StringStruct('FileDescription', 'Yubico Authenticator Helper'),
StringStruct('FileVersion', '4.1.0.0'), StringStruct('FileVersion', '6.0.0-alpha.3'),
StringStruct('LegalCopyright', 'Copyright (c) 2022 Yubico AB'), StringStruct('LegalCopyright', 'Copyright (c) 2022 Yubico AB'),
StringStruct('OriginalFilename', 'authenticator-helper.exe'), StringStruct('OriginalFilename', 'authenticator-helper.exe'),
StringStruct('ProductName', 'Yubico Authenticator'), StringStruct('ProductName', 'Yubico Authenticator'),
StringStruct('ProductVersion', '4.1.0.0')]) StringStruct('ProductVersion', '6.0.0-alpha.3')])
]), ]),
VarFileInfo([VarStruct('Translation', [1033, 1200])]) VarFileInfo([VarStruct('Translation', [1033, 1200])])
] ]

View File

@ -36,7 +36,9 @@ class AboutPage extends ConsumerWidget {
const LoggingPanel(), const LoggingPanel(),
if (isDesktop) ...[ if (isDesktop) ...[
const Divider(), const Divider(),
OutlinedButton( OutlinedButton.icon(
icon: const Icon(Icons.healing),
label: const Text('Run diagnostics...'),
onPressed: () async { onPressed: () async {
_log.info('Running diagnostics...'); _log.info('Running diagnostics...');
final response = final response =
@ -51,7 +53,6 @@ class AboutPage extends ConsumerWidget {
}, },
); );
}, },
child: const Text('Run diagnostics...'),
), ),
] ]
], ],
@ -85,8 +86,9 @@ class LoggingPanel extends ConsumerWidget {
}, },
), ),
const SizedBox(width: 8.0), const SizedBox(width: 8.0),
OutlinedButton( OutlinedButton.icon(
child: const Text('Copy log'), icon: const Icon(Icons.copy),
label: const Text('Copy log'),
onPressed: () async { onPressed: () async {
_log.info('Copying log to clipboard ($version)...'); _log.info('Copying log to clipboard ($version)...');
final logs = await ref.read(logLevelProvider.notifier).getLogs(); final logs = await ref.read(logLevelProvider.notifier).getLogs();

View File

@ -4,6 +4,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
String _pad(int value, int zeroes) => value.toString().padLeft(zeroes, '0');
extension DateTimeFormat on DateTime {
String get logFormat =>
'${_pad(hour, 2)}:${_pad(minute, 2)}:${_pad(second, 2)}.${_pad(millisecond, 3)}';
}
class Levels { class Levels {
/// Key for tracing information ([value] = 500). /// Key for tracing information ([value] = 500).
static const Level TRAFFIC = Level('TRAFFIC', 500); static const Level TRAFFIC = Level('TRAFFIC', 500);
@ -45,7 +52,8 @@ class LogLevelNotifier extends StateNotifier<Level> {
final List<String> _buffer = []; final List<String> _buffer = [];
LogLevelNotifier() : super(Logger.root.level) { LogLevelNotifier() : super(Logger.root.level) {
Logger.root.onRecord.listen((record) { Logger.root.onRecord.listen((record) {
_buffer.add('[${record.loggerName}] ${record.level}: ${record.message}'); _buffer.add(
'${record.time.logFormat} [${record.loggerName}] ${record.level}: ${record.message}');
if (record.error != null) { if (record.error != null) {
_buffer.add('${record.error}'); _buffer.add('${record.error}');
} }

0
lib/app/models.freezed.dart Normal file → Executable file
View File

View File

@ -105,8 +105,14 @@ enum UsbPid {
} }
@freezed @freezed
class Version with _$Version { class Version with _$Version implements Comparable<Version> {
const Version._(); const Version._();
@Assert('major >= 0')
@Assert('major < 256')
@Assert('minor >= 0')
@Assert('minor < 256')
@Assert('patch >= 0')
@Assert('patch < 256')
const factory Version(int major, int minor, int patch) = _Version; const factory Version(int major, int minor, int patch) = _Version;
factory Version.fromJson(List<dynamic> values) { factory Version.fromJson(List<dynamic> values) {
@ -119,6 +125,16 @@ class Version with _$Version {
String toString() { String toString() {
return '$major.$minor.$patch'; return '$major.$minor.$patch';
} }
bool isAtLeast(int major, [int minor = 0, int patch = 0]) =>
compareTo(Version(major, minor, patch)) >= 0;
@override
int compareTo(Version other) {
final a = major << 16 | minor << 8 | patch;
final b = other.major << 16 | other.minor << 8 | other.patch;
return a - b;
}
} }
@freezed @freezed

9
lib/core/models.freezed.dart Normal file → Executable file
View File

@ -106,7 +106,14 @@ class __$$_VersionCopyWithImpl<$Res> extends _$VersionCopyWithImpl<$Res>
/// @nodoc /// @nodoc
class _$_Version extends _Version { class _$_Version extends _Version {
const _$_Version(this.major, this.minor, this.patch) : super._(); const _$_Version(this.major, this.minor, this.patch)
: assert(major >= 0),
assert(major < 256),
assert(minor >= 0),
assert(minor < 256),
assert(patch >= 0),
assert(patch < 256),
super._();
@override @override
final int major; final int major;

0
lib/core/models.g.dart Normal file → Executable file
View File

View File

@ -77,7 +77,7 @@ Future<Widget> initialize(List<String> argv) async {
final arm64exe = Uri.file(exe) final arm64exe = Uri.file(exe)
.resolve('../helper-arm64/authenticator-helper') .resolve('../helper-arm64/authenticator-helper')
.toFilePath(); .toFilePath();
if (await Directory(arm64exe).exists()) { if (await File(arm64exe).exists()) {
exe = arm64exe; exe = arm64exe;
} }
} }
@ -114,13 +114,25 @@ Future<Widget> initialize(List<String> argv) async {
fingerprintProvider.overrideWithProvider(desktopFingerprintProvider), fingerprintProvider.overrideWithProvider(desktopFingerprintProvider),
credentialProvider.overrideWithProvider(desktopCredentialProvider), credentialProvider.overrideWithProvider(desktopCredentialProvider),
], ],
child: const YubicoAuthenticatorApp(page: MainPage()), child: YubicoAuthenticatorApp(
page: Consumer(
builder: ((_, ref, child) {
// keep RPC log level in sync with app
ref.listen<Level>(logLevelProvider, (_, level) {
rpc.setLogLevel(level);
});
return const MainPage();
}),
),
),
); );
} }
void _initLogging(List<String> argv) { void _initLogging(List<String> argv) {
Logger.root.onRecord.listen((record) { Logger.root.onRecord.listen((record) {
stderr.writeln('[${record.loggerName}] ${record.level}: ${record.message}'); stderr.writeln(
'${record.time.logFormat} [${record.loggerName}] ${record.level}: ${record.message}');
if (record.error != null) { if (record.error != null) {
stderr.writeln(record.error); stderr.writeln(record.error);
} }

0
lib/desktop/models.freezed.dart Normal file → Executable file
View File

0
lib/desktop/models.g.dart Normal file → Executable file
View File

0
lib/fido/models.freezed.dart Normal file → Executable file
View File

0
lib/fido/models.g.dart Normal file → Executable file
View File

View File

@ -24,107 +24,104 @@ class FidoUnlockedPage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
List<Widget> children = [ List<Widget> children = [];
if (state.credMgmt) if (state.credMgmt) {
...ref.watch(credentialProvider(node.path)).maybeWhen( final data = ref.watch(credentialProvider(node.path)).asData;
data: (creds) => creds.isNotEmpty if (data == null) {
? [ return _buildLoadingPage();
const ListTile(title: Text('Credentials')), }
...creds.map((cred) => ListTile( final creds = data.value;
leading: CircleAvatar( if (creds.isNotEmpty) {
foregroundColor: children.add(const ListTile(title: Text('Credentials')));
Theme.of(context).colorScheme.onPrimary, children.addAll(
backgroundColor: creds.map(
Theme.of(context).colorScheme.primary, (cred) => ListTile(
child: const Icon(Icons.person), leading: CircleAvatar(
), foregroundColor: Theme.of(context).colorScheme.onPrimary,
title: Text( backgroundColor: Theme.of(context).colorScheme.primary,
cred.userName, child: const Icon(Icons.person),
softWrap: false, ),
overflow: TextOverflow.fade, title: Text(
), cred.userName,
subtitle: Text( softWrap: false,
cred.rpId, overflow: TextOverflow.fade,
softWrap: false, ),
overflow: TextOverflow.fade, subtitle: Text(
), cred.rpId,
trailing: Row( softWrap: false,
mainAxisSize: MainAxisSize.min, overflow: TextOverflow.fade,
children: [ ),
IconButton( trailing: Row(
onPressed: () { mainAxisSize: MainAxisSize.min,
showDialog( children: [
context: context, IconButton(
builder: (context) => onPressed: () {
DeleteCredentialDialog( showDialog(
node.path, cred), context: context,
); builder: (context) =>
}, DeleteCredentialDialog(node.path, cred),
icon: const Icon(Icons.delete_outline)), );
], },
), icon: const Icon(Icons.delete_outline)),
)), ],
] ),
: [],
orElse: () => [],
), ),
if (state.bioEnroll != null) ),
...ref.watch(fingerprintProvider(node.path)).maybeWhen( );
data: (fingerprints) => fingerprints.isNotEmpty }
? [ }
const ListTile(title: Text('Fingerprints')),
...fingerprints.map((fp) => ListTile( if (state.bioEnroll != null) {
leading: CircleAvatar( final data = ref.watch(fingerprintProvider(node.path)).asData;
foregroundColor: if (data == null) {
Theme.of(context).colorScheme.onSecondary, return _buildLoadingPage();
backgroundColor: }
Theme.of(context).colorScheme.secondary, final fingerprints = data.value;
child: const Icon(Icons.fingerprint), if (fingerprints.isNotEmpty) {
), children.add(const ListTile(title: Text('Fingerprints')));
title: Text( children.addAll(fingerprints.map((fp) => ListTile(
fp.label, leading: CircleAvatar(
softWrap: false, foregroundColor: Theme.of(context).colorScheme.onSecondary,
overflow: TextOverflow.fade, backgroundColor: Theme.of(context).colorScheme.secondary,
), child: const Icon(Icons.fingerprint),
trailing: Row( ),
mainAxisSize: MainAxisSize.min, title: Text(
children: [ fp.label,
IconButton( softWrap: false,
onPressed: () { overflow: TextOverflow.fade,
showDialog( ),
context: context, trailing: Row(
builder: (context) => mainAxisSize: MainAxisSize.min,
RenameFingerprintDialog( children: [
node.path, fp), IconButton(
); onPressed: () {
}, showDialog(
icon: const Icon(Icons.edit_outlined)), context: context,
IconButton( builder: (context) =>
onPressed: () { RenameFingerprintDialog(node.path, fp),
showDialog( );
context: context, },
builder: (context) => icon: const Icon(Icons.edit_outlined)),
DeleteFingerprintDialog( IconButton(
node.path, fp), onPressed: () {
); showDialog(
}, context: context,
icon: const Icon(Icons.delete_outline)), builder: (context) =>
], DeleteFingerprintDialog(node.path, fp),
), );
)) },
] icon: const Icon(Icons.delete_outline)),
: [], ],
orElse: () => [], ),
), )));
]; }
}
if (children.isNotEmpty) { if (children.isNotEmpty) {
return AppPage( return AppPage(
title: const Text('WebAuthn'), title: const Text('WebAuthn'),
actions: _buildActions(context), actions: _buildActions(context),
child: Column( child: Column(children: children),
children: children,
),
); );
} }
@ -147,6 +144,12 @@ class FidoUnlockedPage extends ConsumerWidget {
); );
} }
Widget _buildLoadingPage() => AppPage(
title: const Text('WebAuthn'),
centered: true,
child: const CircularProgressIndicator(),
);
List<Widget> _buildActions(BuildContext context, List<Widget> _buildActions(BuildContext context,
{bool fingerprintPrimary = false}) => {bool fingerprintPrimary = false}) =>
[ [

0
lib/management/models.freezed.dart Normal file → Executable file
View File

0
lib/management/models.g.dart Normal file → Executable file
View File

View File

@ -1,5 +1,7 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import '../core/models.dart';
part 'models.freezed.dart'; part 'models.freezed.dart';
part 'models.g.dart'; part 'models.g.dart';
@ -61,7 +63,8 @@ class OathPair with _$OathPair {
@freezed @freezed
class OathState with _$OathState { class OathState with _$OathState {
factory OathState( factory OathState(
String deviceId, { String deviceId,
Version version, {
required bool hasKey, required bool hasKey,
required bool remembered, required bool remembered,
required bool locked, required bool locked,

37
lib/oath/models.freezed.dart Normal file → Executable file
View File

@ -615,6 +615,7 @@ OathState _$OathStateFromJson(Map<String, dynamic> json) {
/// @nodoc /// @nodoc
mixin _$OathState { mixin _$OathState {
String get deviceId => throw _privateConstructorUsedError; String get deviceId => throw _privateConstructorUsedError;
Version get version => throw _privateConstructorUsedError;
bool get hasKey => throw _privateConstructorUsedError; bool get hasKey => throw _privateConstructorUsedError;
bool get remembered => throw _privateConstructorUsedError; bool get remembered => throw _privateConstructorUsedError;
bool get locked => throw _privateConstructorUsedError; bool get locked => throw _privateConstructorUsedError;
@ -632,10 +633,13 @@ abstract class $OathStateCopyWith<$Res> {
_$OathStateCopyWithImpl<$Res>; _$OathStateCopyWithImpl<$Res>;
$Res call( $Res call(
{String deviceId, {String deviceId,
Version version,
bool hasKey, bool hasKey,
bool remembered, bool remembered,
bool locked, bool locked,
KeystoreState keystore}); KeystoreState keystore});
$VersionCopyWith<$Res> get version;
} }
/// @nodoc /// @nodoc
@ -649,6 +653,7 @@ class _$OathStateCopyWithImpl<$Res> implements $OathStateCopyWith<$Res> {
@override @override
$Res call({ $Res call({
Object? deviceId = freezed, Object? deviceId = freezed,
Object? version = freezed,
Object? hasKey = freezed, Object? hasKey = freezed,
Object? remembered = freezed, Object? remembered = freezed,
Object? locked = freezed, Object? locked = freezed,
@ -659,6 +664,10 @@ class _$OathStateCopyWithImpl<$Res> implements $OathStateCopyWith<$Res> {
? _value.deviceId ? _value.deviceId
: deviceId // ignore: cast_nullable_to_non_nullable : deviceId // ignore: cast_nullable_to_non_nullable
as String, as String,
version: version == freezed
? _value.version
: version // ignore: cast_nullable_to_non_nullable
as Version,
hasKey: hasKey == freezed hasKey: hasKey == freezed
? _value.hasKey ? _value.hasKey
: hasKey // ignore: cast_nullable_to_non_nullable : hasKey // ignore: cast_nullable_to_non_nullable
@ -677,6 +686,13 @@ class _$OathStateCopyWithImpl<$Res> implements $OathStateCopyWith<$Res> {
as KeystoreState, as KeystoreState,
)); ));
} }
@override
$VersionCopyWith<$Res> get version {
return $VersionCopyWith<$Res>(_value.version, (value) {
return _then(_value.copyWith(version: value));
});
}
} }
/// @nodoc /// @nodoc
@ -687,10 +703,14 @@ abstract class _$$_OathStateCopyWith<$Res> implements $OathStateCopyWith<$Res> {
@override @override
$Res call( $Res call(
{String deviceId, {String deviceId,
Version version,
bool hasKey, bool hasKey,
bool remembered, bool remembered,
bool locked, bool locked,
KeystoreState keystore}); KeystoreState keystore});
@override
$VersionCopyWith<$Res> get version;
} }
/// @nodoc /// @nodoc
@ -706,6 +726,7 @@ class __$$_OathStateCopyWithImpl<$Res> extends _$OathStateCopyWithImpl<$Res>
@override @override
$Res call({ $Res call({
Object? deviceId = freezed, Object? deviceId = freezed,
Object? version = freezed,
Object? hasKey = freezed, Object? hasKey = freezed,
Object? remembered = freezed, Object? remembered = freezed,
Object? locked = freezed, Object? locked = freezed,
@ -716,6 +737,10 @@ class __$$_OathStateCopyWithImpl<$Res> extends _$OathStateCopyWithImpl<$Res>
? _value.deviceId ? _value.deviceId
: deviceId // ignore: cast_nullable_to_non_nullable : deviceId // ignore: cast_nullable_to_non_nullable
as String, as String,
version == freezed
? _value.version
: version // ignore: cast_nullable_to_non_nullable
as Version,
hasKey: hasKey == freezed hasKey: hasKey == freezed
? _value.hasKey ? _value.hasKey
: hasKey // ignore: cast_nullable_to_non_nullable : hasKey // ignore: cast_nullable_to_non_nullable
@ -739,7 +764,7 @@ class __$$_OathStateCopyWithImpl<$Res> extends _$OathStateCopyWithImpl<$Res>
/// @nodoc /// @nodoc
@JsonSerializable() @JsonSerializable()
class _$_OathState implements _OathState { class _$_OathState implements _OathState {
_$_OathState(this.deviceId, _$_OathState(this.deviceId, this.version,
{required this.hasKey, {required this.hasKey,
required this.remembered, required this.remembered,
required this.locked, required this.locked,
@ -751,6 +776,8 @@ class _$_OathState implements _OathState {
@override @override
final String deviceId; final String deviceId;
@override @override
final Version version;
@override
final bool hasKey; final bool hasKey;
@override @override
final bool remembered; final bool remembered;
@ -761,7 +788,7 @@ class _$_OathState implements _OathState {
@override @override
String toString() { String toString() {
return 'OathState(deviceId: $deviceId, hasKey: $hasKey, remembered: $remembered, locked: $locked, keystore: $keystore)'; return 'OathState(deviceId: $deviceId, version: $version, hasKey: $hasKey, remembered: $remembered, locked: $locked, keystore: $keystore)';
} }
@override @override
@ -770,6 +797,7 @@ class _$_OathState implements _OathState {
(other.runtimeType == runtimeType && (other.runtimeType == runtimeType &&
other is _$_OathState && other is _$_OathState &&
const DeepCollectionEquality().equals(other.deviceId, deviceId) && const DeepCollectionEquality().equals(other.deviceId, deviceId) &&
const DeepCollectionEquality().equals(other.version, version) &&
const DeepCollectionEquality().equals(other.hasKey, hasKey) && const DeepCollectionEquality().equals(other.hasKey, hasKey) &&
const DeepCollectionEquality() const DeepCollectionEquality()
.equals(other.remembered, remembered) && .equals(other.remembered, remembered) &&
@ -782,6 +810,7 @@ class _$_OathState implements _OathState {
int get hashCode => Object.hash( int get hashCode => Object.hash(
runtimeType, runtimeType,
const DeepCollectionEquality().hash(deviceId), const DeepCollectionEquality().hash(deviceId),
const DeepCollectionEquality().hash(version),
const DeepCollectionEquality().hash(hasKey), const DeepCollectionEquality().hash(hasKey),
const DeepCollectionEquality().hash(remembered), const DeepCollectionEquality().hash(remembered),
const DeepCollectionEquality().hash(locked), const DeepCollectionEquality().hash(locked),
@ -799,7 +828,7 @@ class _$_OathState implements _OathState {
} }
abstract class _OathState implements OathState { abstract class _OathState implements OathState {
factory _OathState(final String deviceId, factory _OathState(final String deviceId, final Version version,
{required final bool hasKey, {required final bool hasKey,
required final bool remembered, required final bool remembered,
required final bool locked, required final bool locked,
@ -811,6 +840,8 @@ abstract class _OathState implements OathState {
@override @override
String get deviceId => throw _privateConstructorUsedError; String get deviceId => throw _privateConstructorUsedError;
@override @override
Version get version => throw _privateConstructorUsedError;
@override
bool get hasKey => throw _privateConstructorUsedError; bool get hasKey => throw _privateConstructorUsedError;
@override @override
bool get remembered => throw _privateConstructorUsedError; bool get remembered => throw _privateConstructorUsedError;

2
lib/oath/models.g.dart Normal file → Executable file
View File

@ -61,6 +61,7 @@ Map<String, dynamic> _$$_OathPairToJson(_$_OathPair instance) =>
_$_OathState _$$_OathStateFromJson(Map<String, dynamic> json) => _$_OathState( _$_OathState _$$_OathStateFromJson(Map<String, dynamic> json) => _$_OathState(
json['device_id'] as String, json['device_id'] as String,
Version.fromJson(json['version'] as List<dynamic>),
hasKey: json['has_key'] as bool, hasKey: json['has_key'] as bool,
remembered: json['remembered'] as bool, remembered: json['remembered'] as bool,
locked: json['locked'] as bool, locked: json['locked'] as bool,
@ -70,6 +71,7 @@ _$_OathState _$$_OathStateFromJson(Map<String, dynamic> json) => _$_OathState(
Map<String, dynamic> _$$_OathStateToJson(_$_OathState instance) => Map<String, dynamic> _$$_OathStateToJson(_$_OathState instance) =>
<String, dynamic>{ <String, dynamic>{
'device_id': instance.deviceId, 'device_id': instance.deviceId,
'version': instance.version,
'has_key': instance.hasKey, 'has_key': instance.hasKey,
'remembered': instance.remembered, 'remembered': instance.remembered,
'locked': instance.locked, 'locked': instance.locked,

View File

@ -185,8 +185,7 @@ mixin AccountMixin {
ref.read(favoritesProvider.notifier).toggleFavorite(credential.id); ref.read(favoritesProvider.notifier).toggleFavorite(credential.id);
}, },
), ),
if (deviceData.info.version.major >= 5 && if (deviceData.info.version.isAtLeast(5, 3))
deviceData.info.version.minor >= 3)
MenuAction( MenuAction(
icon: const Icon(Icons.edit_outlined), icon: const Icon(Icons.edit_outlined),
text: 'Rename account', text: 'Rename account',

View File

@ -25,8 +25,9 @@ enum _QrScanState { none, scanning, success, failed }
class OathAddAccountPage extends ConsumerStatefulWidget { class OathAddAccountPage extends ConsumerStatefulWidget {
final DevicePath devicePath; final DevicePath devicePath;
final OathState state;
final bool openQrScanner; final bool openQrScanner;
const OathAddAccountPage(this.devicePath, const OathAddAccountPage(this.devicePath, this.state,
{super.key, required this.openQrScanner}); {super.key, required this.openQrScanner});
@override @override
@ -287,15 +288,16 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
spacing: 4.0, spacing: 4.0,
runSpacing: 8.0, runSpacing: 8.0,
children: [ children: [
FilterChip( if (widget.state.version.isAtLeast(4, 2))
label: const Text('Require touch'), FilterChip(
selected: _touch, label: const Text('Require touch'),
onSelected: (value) { selected: _touch,
setState(() { onSelected: (value) {
_touch = value; setState(() {
}); _touch = value;
}, });
), },
),
Chip( Chip(
backgroundColor: ChipTheme.of(context).selectedColor, backgroundColor: ChipTheme.of(context).selectedColor,
label: DropdownButtonHideUnderline( label: DropdownButtonHideUnderline(
@ -327,6 +329,9 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
isDense: true, isDense: true,
underline: null, underline: null,
items: HashAlgorithm.values items: HashAlgorithm.values
.where((alg) =>
alg != HashAlgorithm.sha512 ||
widget.state.version.isAtLeast(4, 3, 1))
.map((e) => DropdownMenuItem( .map((e) => DropdownMenuItem(
value: e, value: e,
child: Text(e.name.toUpperCase()), child: Text(e.name.toUpperCase()),

View File

@ -158,6 +158,7 @@ class _UnlockedView extends ConsumerWidget {
context: context, context: context,
builder: (context) => OathAddAccountPage( builder: (context) => OathAddAccountPage(
devicePath, devicePath,
oathState,
openQrScanner: Platform.isAndroid, openQrScanner: Platform.isAndroid,
), ),
); );

View File

@ -11,6 +11,12 @@ lib_version_pattern = rf"const\s+String\s+version\s+=\s+'({version_pattern})';"
lib_build_pattern = rf"const\s+int\s+build\s+=\s+(\d+);" lib_build_pattern = rf"const\s+int\s+build\s+=\s+(\d+);"
def sub1(pattern, repl, string):
buf, n = re.subn(pattern, repl, string)
if n != 1:
raise ValueError(f"Did not find string matching {pattern} to replace")
return buf
def update_file(fname, func): def update_file(fname, func):
with open(fname) as f: with open(fname) as f:
orig = f.read() orig = f.read()
@ -42,13 +48,13 @@ def read_lib_version():
def update_lib(buf): def update_lib(buf):
buf = re.sub( buf = sub1(
lib_version_pattern, lib_version_pattern,
f"const String version = '{version}';", f"const String version = '{version}';",
buf, buf,
) )
buf = re.sub( buf = sub1(
lib_build_pattern, lib_build_pattern,
f"const int build = {build};", f"const int build = {build};",
buf, buf,
@ -78,7 +84,7 @@ short_version = re.search("(\d+\.\d+\.\d+)", version).group()
# pubspec.yaml # pubspec.yaml
def update_pubspec(buf): def update_pubspec(buf):
return re.sub( return sub1(
r'version:\s+\d+\.\d+\.\d+\+\d+', r'version:\s+\d+\.\d+\.\d+\+\d+',
f'version: {short_version}+{build}', f'version: {short_version}+{build}',
buf, buf,
@ -86,14 +92,14 @@ def update_pubspec(buf):
# Windows Runner.rc # Windows Runner.rc
def update_runner_rc(buf): def update_runner_rc(buf):
buf = re.sub( buf = sub1(
rf'#define VERSION_AS_STRING "{version_pattern}"', rf'#define VERSION_AS_STRING "{version_pattern}"',
f'#define VERSION_AS_STRING "{version}"', f'#define VERSION_AS_STRING "{version}"',
buf, buf,
) )
version_as_number = short_version.replace(".", ",") version_as_number = short_version.replace(".", ",")
buf = re.sub( buf = sub1(
r"#define VERSION_AS_NUMBER \d+,\d+,\d+", r"#define VERSION_AS_NUMBER \d+,\d+,\d+",
f"#define VERSION_AS_NUMBER {version_as_number}", f"#define VERSION_AS_NUMBER {version_as_number}",
buf, buf,
@ -103,22 +109,22 @@ def update_runner_rc(buf):
# Helper version_info # Helper version_info
def update_helper_version(buf): def update_helper_version(buf):
version_tuple = repr(tuple(int(d) for d in short_version.split(".")) + (0,)) version_tuple = repr(tuple(int(d) for d in short_version.split(".")) + (0,))
buf = re.sub( buf = sub1(
rf'filevers=\(\d+, \d+, \d+, \d+\)', rf'filevers=\(\d+, \d+, \d+, \d+\)',
f'filevers={version_tuple}', f'filevers={version_tuple}',
buf, buf,
) )
buf = re.sub( buf = sub1(
rf'prodvers=\(\d+, \d+, \d+, \d+\)', rf'prodvers=\(\d+, \d+, \d+, \d+\)',
f'prodvers={version_tuple}', f'prodvers={version_tuple}',
buf, buf,
) )
buf = re.sub( buf = sub1(
rf"'FileVersion', '{version_pattern}'", rf"'FileVersion', '{version_pattern}'",
f"'FileVersion', '{version}'", f"'FileVersion', '{version}'",
buf, buf,
) )
buf = re.sub( buf = sub1(
rf"'ProductVersion', '{version_pattern}'", rf"'ProductVersion', '{version_pattern}'",
f"'ProductVersion', '{version}'", f"'ProductVersion', '{version}'",
buf, buf,