diff --git a/bin/kdbx.dart b/bin/kdbx.dart index 97d26d8..67fe04b 100644 --- a/bin/kdbx.dart +++ b/bin/kdbx.dart @@ -33,8 +33,7 @@ void main(List arguments) { } class KdbxCommandRunner extends CommandRunner { - KdbxCommandRunner(String executableName, String description) - : super(executableName, description) { + KdbxCommandRunner(super.executableName, super.description) { argParser.addFlag('verbose', abbr: 'v'); addCommand(CatCommand()); addCommand(DumpXmlCommand()); diff --git a/lib/kdbx.dart b/lib/kdbx.dart index abddd6d..03ecebb 100644 --- a/lib/kdbx.dart +++ b/lib/kdbx.dart @@ -8,20 +8,12 @@ export 'src/crypto/key_encrypter_kdf.dart' show KeyEncrypterKdf, KdfType, KdfField; export 'src/crypto/protected_value.dart' show ProtectedValue, StringValue, PlainValue; -export 'src/field.dart' show BrowserFieldModel, FormFieldType, FieldStorage; export 'src/internal/kdf_cache.dart' show KdfCache; export 'src/kdbx_binary.dart' show KdbxBinary; export 'src/kdbx_consts.dart'; export 'src/kdbx_custom_data.dart'; export 'src/kdbx_dao.dart' show KdbxDao; -export 'src/kdbx_entry.dart' - show - KdbxEntry, - KdbxKey, - KdbxKeyCommon, - BrowserEntrySettings, - BrowserAutoFillBehaviour, - MatchAccuracy; +export 'src/kdbx_entry.dart' show KdbxEntry, KdbxKey, KdbxKeyCommon; export 'src/kdbx_exceptions.dart'; export 'src/kdbx_file.dart'; export 'src/kdbx_format.dart' show KdbxBody, MergeContext, KdbxFormat; @@ -37,4 +29,12 @@ export 'src/kdbx_object.dart' ChangeEvent, KdbxNodeContext; export 'src/kdbx_var_dictionary.dart' show VarDictionary; +export 'src/kee_vault_model/browser_entry_settings.dart' + show BrowserEntrySettings; +export 'src/kee_vault_model/entry_matcher.dart'; +export 'src/kee_vault_model/entry_matcher_config.dart'; +export 'src/kee_vault_model/enums.dart'; +export 'src/kee_vault_model/field.dart' show Field; +export 'src/kee_vault_model/field_matcher.dart' show FieldMatcher; +export 'src/kee_vault_model/field_matcher_config.dart'; export 'src/utils/byte_utils.dart' show ByteUtils; diff --git a/lib/src/crypto/protected_salt_generator.dart b/lib/src/crypto/protected_salt_generator.dart index d108dcf..ccea8e3 100644 --- a/lib/src/crypto/protected_salt_generator.dart +++ b/lib/src/crypto/protected_salt_generator.dart @@ -42,7 +42,7 @@ class ProtectedSaltGenerator { } class ChachaProtectedSaltGenerator extends ProtectedSaltGenerator { - ChachaProtectedSaltGenerator._(StreamCipher state) : super._(state); + ChachaProtectedSaltGenerator._(super.state) : super._(); factory ChachaProtectedSaltGenerator.create(Uint8List key) { final hash = sha512.convert(key); diff --git a/lib/src/crypto/protected_value.dart b/lib/src/crypto/protected_value.dart index dc12aba..747a2d7 100644 --- a/lib/src/crypto/protected_value.dart +++ b/lib/src/crypto/protected_value.dart @@ -30,7 +30,7 @@ class PlainValue implements StringValue { } class ProtectedValue extends PlainValue { - ProtectedValue(String text) : super(text); + ProtectedValue(super.text); factory ProtectedValue.fromString(String value) { return ProtectedValue(value); diff --git a/lib/src/kdbx_custom_data.dart b/lib/src/kdbx_custom_data.dart index eb7dd0c..7c06908 100644 --- a/lib/src/kdbx_custom_data.dart +++ b/lib/src/kdbx_custom_data.dart @@ -8,14 +8,14 @@ class KdbxObjectCustomData extends KdbxNode { : _data = {}, super.create(KdbxXml.NODE_CUSTOM_DATA); - KdbxObjectCustomData.read(xml.XmlElement node) + KdbxObjectCustomData.read(super.node) : _data = Map.fromEntries( node.findElements(KdbxXml.NODE_CUSTOM_DATA_ITEM).map((el) { final key = el.singleTextNode(KdbxXml.NODE_KEY); final value = el.singleTextNode(KdbxXml.NODE_VALUE); return MapEntry(key, value); })), - super.read(node); + super.read(); final Map _data; @@ -59,7 +59,7 @@ class KdbxMetaCustomData extends KdbxNode { : _data = {}, super.create(KdbxXml.NODE_CUSTOM_DATA); - KdbxMetaCustomData.read(xml.XmlElement node) + KdbxMetaCustomData.read(super.node) : _data = Map.fromEntries( node.findElements(KdbxXml.NODE_CUSTOM_DATA_ITEM).map((el) { final key = el.singleTextNode(KdbxXml.NODE_KEY); @@ -73,7 +73,7 @@ class KdbxMetaCustomData extends KdbxNode { : null )); })), - super.read(node); + super.read(); final Map _data; diff --git a/lib/src/kdbx_deleted_object.dart b/lib/src/kdbx_deleted_object.dart index d100f82..050d536 100644 --- a/lib/src/kdbx_deleted_object.dart +++ b/lib/src/kdbx_deleted_object.dart @@ -2,7 +2,6 @@ import 'package:clock/clock.dart'; import 'package:kdbx/src/kdbx_format.dart'; import 'package:kdbx/src/kdbx_object.dart'; import 'package:kdbx/src/kdbx_xml.dart'; -import 'package:xml/xml.dart'; class KdbxDeletedObject extends KdbxNode implements KdbxNodeContext { KdbxDeletedObject.create(this.ctx, KdbxUuid uuid, {DateTime? deletionTime}) @@ -11,7 +10,7 @@ class KdbxDeletedObject extends KdbxNode implements KdbxNodeContext { this.deletionTime.set(deletionTime ?? clock.now().toUtc()); } - KdbxDeletedObject.read(XmlElement node, this.ctx) : super.read(node); + KdbxDeletedObject.read(super.node, this.ctx) : super.read(); static const NODE_NAME = KdbxXml.NODE_DELETED_OBJECT; diff --git a/lib/src/kdbx_entry.dart b/lib/src/kdbx_entry.dart index 429c0c9..c5b7bdf 100644 --- a/lib/src/kdbx_entry.dart +++ b/lib/src/kdbx_entry.dart @@ -1,15 +1,19 @@ import 'dart:collection'; import 'dart:convert'; import 'dart:typed_data'; + import 'package:collection/collection.dart'; import 'package:kdbx/src/internal/extension_utils.dart'; import 'package:kdbx/src/kdbx_format.dart'; import 'package:kdbx/src/kdbx_object.dart'; import 'package:kdbx/src/kdbx_xml.dart'; +import 'package:kdbx/src/kee_vault_model/browser_entry_settings_v1.dart'; +import 'package:kdbx/src/utils/guid_service.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as path; import 'package:quiver/check.dart'; import 'package:xml/xml.dart'; + import '../kdbx.dart'; final _logger = Logger('kdbx.kdbx_entry'); @@ -72,302 +76,6 @@ class KdbxKey { } } -class BrowserEntrySettings { - BrowserEntrySettings({ - this.version = 1, - this.behaviour = BrowserAutoFillBehaviour.Default, - required this.minimumMatchAccuracy, - this.priority = 0, - this.hide = false, - this.realm = '', - List? includeUrls, - List? excludeUrls, - List? fields, - }) : includeUrls = includeUrls ?? [], - excludeUrls = excludeUrls ?? [], - fields = fields ?? []; - - factory BrowserEntrySettings.fromMap(Map? map, - {required MatchAccuracy minimumMatchAccuracy}) { - if (map == null) { - return BrowserEntrySettings(minimumMatchAccuracy: minimumMatchAccuracy); - } - - return BrowserEntrySettings( - version: map['version'] as int? ?? 1, - behaviour: getBehaviour(map), - minimumMatchAccuracy: getMam(map), - priority: map['priority'] as int? ?? 0, - hide: map['hide'] as bool? ?? false, - realm: map['hTTPRealm'] as String?, - includeUrls: getIncludeUrls(map), - excludeUrls: getExcludeUrls(map), - fields: List.from((map['formFieldList'] - as List?) - ?.cast>() - .map((x) => BrowserFieldModel.fromMap(x)) ?? - []), - ); - } - - factory BrowserEntrySettings.fromJson(String source, - {required MatchAccuracy minimumMatchAccuracy}) => - BrowserEntrySettings.fromMap(json.decode(source) as Map?, - minimumMatchAccuracy: minimumMatchAccuracy); - - int version; - // enum - BrowserAutoFillBehaviour behaviour; - // enum - MatchAccuracy minimumMatchAccuracy; - int priority; // always 0 - bool hide; - String? realm; - List includeUrls; - List excludeUrls; - List fields; - - BrowserEntrySettings copyWith({ - int? version, - BrowserAutoFillBehaviour? behaviour, - MatchAccuracy? minimumMatchAccuracy, - int? priority, - bool? hide, - String? realm, - List? includeUrls, - List? excludeUrls, - List? fields, - }) { - return BrowserEntrySettings( - version: version ?? this.version, - behaviour: behaviour ?? this.behaviour, - minimumMatchAccuracy: minimumMatchAccuracy ?? this.minimumMatchAccuracy, - priority: priority ?? this.priority, - hide: hide ?? this.hide, - realm: realm ?? this.realm, - includeUrls: includeUrls ?? this.includeUrls, - excludeUrls: excludeUrls ?? this.excludeUrls, - fields: fields ?? this.fields, - ); - } - - static BrowserAutoFillBehaviour getBehaviour(Map map) { - if (map['neverAutoFill'] as bool? ?? false) { - return BrowserAutoFillBehaviour.NeverAutoFillNeverAutoSubmit; - } else if (map['alwaysAutoSubmit'] as bool? ?? false) { - return BrowserAutoFillBehaviour.AlwaysAutoFillAlwaysAutoSubmit; - } else if ((map['alwaysAutoFill'] as bool? ?? false) && - (map['neverAutoSubmit'] as bool? ?? false)) { - return BrowserAutoFillBehaviour.AlwaysAutoFillNeverAutoSubmit; - } else if (map['neverAutoSubmit'] as bool? ?? false) { - return BrowserAutoFillBehaviour.NeverAutoSubmit; - } else if (map['alwaysAutoFill'] as bool? ?? false) { - return BrowserAutoFillBehaviour.AlwaysAutoFill; - } else { - return BrowserAutoFillBehaviour.Default; - } - } - - static MatchAccuracy getMam(Map map) { - if (map['blockHostnameOnlyMatch'] as bool? ?? false) { - return MatchAccuracy.Exact; - } else if (map['blockDomainOnlyMatch'] as bool? ?? false) { - return MatchAccuracy.Hostname; - } else { - return MatchAccuracy.Domain; - } - } - - static Map parseBehaviour(BrowserAutoFillBehaviour behaviour) { - switch (behaviour) { - case BrowserAutoFillBehaviour.AlwaysAutoFill: - return { - 'alwaysAutoFill': true, - 'alwaysAutoSubmit': false, - 'neverAutoFill': false, - 'neverAutoSubmit': false, - }; - case BrowserAutoFillBehaviour.NeverAutoSubmit: - return { - 'alwaysAutoFill': false, - 'alwaysAutoSubmit': false, - 'neverAutoFill': false, - 'neverAutoSubmit': true, - }; - case BrowserAutoFillBehaviour.AlwaysAutoFillAlwaysAutoSubmit: - return { - 'alwaysAutoFill': true, - 'alwaysAutoSubmit': true, - 'neverAutoFill': false, - 'neverAutoSubmit': false, - }; - case BrowserAutoFillBehaviour.NeverAutoFillNeverAutoSubmit: - return { - 'alwaysAutoFill': false, - 'alwaysAutoSubmit': false, - 'neverAutoFill': true, - 'neverAutoSubmit': true, - }; - case BrowserAutoFillBehaviour.AlwaysAutoFillNeverAutoSubmit: - return { - 'alwaysAutoFill': true, - 'alwaysAutoSubmit': false, - 'neverAutoFill': false, - 'neverAutoSubmit': true, - }; - case BrowserAutoFillBehaviour.Default: - return { - 'alwaysAutoFill': false, - 'alwaysAutoSubmit': false, - 'neverAutoFill': false, - 'neverAutoSubmit': false, - }; - } - } - - static Map parseMam(MatchAccuracy mam) { - switch (mam) { - case MatchAccuracy.Domain: - return { - 'blockDomainOnlyMatch': false, - 'blockHostnameOnlyMatch': false, - }; - case MatchAccuracy.Hostname: - return { - 'blockDomainOnlyMatch': true, - 'blockHostnameOnlyMatch': false, - }; - default: - return { - 'blockDomainOnlyMatch': false, - 'blockHostnameOnlyMatch': true, - }; - } - } - - static Map> parseUrls( - List includeUrls, List excludeUrls) { - final altURLs = []; - final regExURLs = []; - final blockedURLs = []; - final regExBlockedURLs = []; - for (final p in includeUrls) { - if (p is RegExp) { - regExURLs.add(p.pattern); - } else if (p is String) { - altURLs.add(p); - } - } - for (final p in excludeUrls) { - if (p is RegExp) { - regExBlockedURLs.add(p.pattern); - } else if (p is String) { - blockedURLs.add(p); - } - } - return >{ - 'altURLs': altURLs, - 'regExURLs': regExURLs, - 'blockedURLs': blockedURLs, - 'regExBlockedURLs': regExBlockedURLs, - }; - } - - static List getIncludeUrls(Map map) { - final includeUrls = []; - final altUrls = (map['altURLs'] as List?)?.cast(); - final regExURLs = (map['regExURLs'] as List?)?.cast(); - if (altUrls != null) { - altUrls.forEach(includeUrls.add); - } - if (regExURLs != null) { - for (final url in regExURLs) { - includeUrls.add(RegExp(url)); - } - } - return includeUrls; - } - - static List getExcludeUrls(Map map) { - final excludeUrls = []; - final blockedURLs = (map['blockedURLs'] as List?)?.cast(); - final regExBlockedURLs = - (map['regExBlockedURLs'] as List?)?.cast(); - if (blockedURLs != null) { - blockedURLs.forEach(excludeUrls.add); - } - if (regExBlockedURLs != null) { - for (final url in regExBlockedURLs) { - excludeUrls.add(RegExp(url)); - } - } - return excludeUrls; - } - - Map toMap() { - return { - 'version': version, - 'priority': priority, - 'hide': hide, - 'hTTPRealm': realm, - 'formFieldList': fields.map((x) => x.toMap()).toList(), - ...parseBehaviour(behaviour), - ...parseMam(minimumMatchAccuracy), - ...parseUrls(includeUrls, excludeUrls), - }; - } - - String toJson() => json.encode(toMap()); - - @override - String toString() { - return 'BrowserSettingsModel(version: $version, behaviour: $behaviour, minimumMatchAccuracy: $minimumMatchAccuracy, priority: $priority, hide: $hide, realm: $realm, includeUrls: $includeUrls, excludeUrls: $excludeUrls, fields: $fields)'; - } - - @override - // ignore: avoid_renaming_method_parameters - bool operator ==(Object o) { - if (identical(this, o)) { - return true; - } - final unOrdDeepEq = const DeepCollectionEquality.unordered().equals; - return o is BrowserEntrySettings && - o.version == version && - o.behaviour == behaviour && - o.minimumMatchAccuracy == minimumMatchAccuracy && - o.priority == priority && - o.hide == hide && - o.realm == realm && - unOrdDeepEq(o.includeUrls, includeUrls) && - unOrdDeepEq(o.excludeUrls, excludeUrls) && - unOrdDeepEq(o.fields, fields); - } - - @override - int get hashCode { - return version.hashCode ^ - behaviour.hashCode ^ - minimumMatchAccuracy.hashCode ^ - priority.hashCode ^ - hide.hashCode ^ - realm.hashCode ^ - includeUrls.hashCode ^ - excludeUrls.hashCode ^ - fields.hashCode; - } -} - -enum BrowserAutoFillBehaviour { - Default, - AlwaysAutoFill, - NeverAutoSubmit, - AlwaysAutoFillNeverAutoSubmit, - AlwaysAutoFillAlwaysAutoSubmit, - NeverAutoFillNeverAutoSubmit -} - -enum MatchAccuracy { Exact, Hostname, Domain } - extension KdbxEntryInternal on KdbxEntry { KdbxEntry cloneInto(KdbxGroup otherGroup, {bool toHistoryEntry = false, bool withNewUuid = false}) => @@ -462,8 +170,11 @@ class KdbxEntry extends KdbxObject { super.create(file.ctx, file, 'Entry', parent) { icon.set(KdbxIcon.Key); _browserSettings = BrowserEntrySettings( - minimumMatchAccuracy: - file.body.meta.browserSettings.defaultMatchAccuracy); + matcherConfigs: [ + EntryMatcherConfig.forDefaultUrlMatchBehaviour( + file.body.meta.browserSettings.defaultMatchAccuracy) + ], + ); } KdbxEntry.read(KdbxReadWriteContext ctx, KdbxGroup? parent, XmlElement node, @@ -526,26 +237,45 @@ class KdbxEntry extends KdbxObject { BrowserEntrySettings? _browserSettings; BrowserEntrySettings get browserSettings { if (_browserSettings == null) { - final tempJson = stringEntries - .firstWhereOrNull((s) => s.key.key == 'KPRPC JSON') - ?.value; + final cdJson = getCustomData('KPRPC JSON'); - if (tempJson != null) { - _browserSettings = BrowserEntrySettings.fromJson(tempJson.getText(), + if (cdJson != null) { + _browserSettings = BrowserEntrySettings.fromJson(cdJson, minimumMatchAccuracy: file!.body.meta.browserSettings.defaultMatchAccuracy); } else { - _browserSettings = BrowserEntrySettings( - minimumMatchAccuracy: - file!.body.meta.browserSettings.defaultMatchAccuracy); + final stringJson = stringEntries + .firstWhereOrNull((s) => s.key.key == 'KPRPC JSON') + ?.value + ?.getText(); + + if (stringJson != null) { + final v1 = BrowserEntrySettingsV1.fromJson(stringJson, + minimumMatchAccuracy: + file!.body.meta.browserSettings.defaultMatchAccuracy); + _browserSettings = v1.convertToV2(GuidService()); + } else { + _browserSettings = BrowserEntrySettings( + matcherConfigs: [ + EntryMatcherConfig.forDefaultUrlMatchBehaviour( + file!.body.meta.browserSettings.defaultMatchAccuracy) + ], + ); + } } } return _browserSettings!; } set browserSettings(BrowserEntrySettings settings) { - setString( - KdbxKey('KPRPC JSON'), ProtectedValue.fromString(settings.toJson())); + setCustomData('KPRPC JSON', settings.toJson()); + try { + final v1 = settings.convertToV1(); + setString(KdbxKey('KPRPC JSON'), ProtectedValue.fromString(v1.toJson())); + } catch (ex) { + _logger.severe( + 'String KPRPC JSON failed to convert or write. This may indicate a newer version of Kee Vault was used to create this configuration.'); + } _browserSettings = null; } @@ -586,7 +316,8 @@ class KdbxEntry extends KdbxObject { browserSettings.includeUrls.add(newUrl); } } - browserSettings.hide = false; + browserSettings.matcherConfigs + .removeWhere((mc) => mc.matcherType == EntryMatcherType.Hide); browserSettings = browserSettings; return; } @@ -596,7 +327,8 @@ class KdbxEntry extends KdbxObject { final updatedList = androidPackageNames..add(name); androidPackageNames = updatedList; } - browserSettings.hide = false; + browserSettings.matcherConfigs + .removeWhere((mc) => mc.matcherType == EntryMatcherType.Hide); browserSettings = browserSettings; return; } diff --git a/lib/src/kdbx_file.dart b/lib/src/kdbx_file.dart index 16b3885..28fd4cc 100644 --- a/lib/src/kdbx_file.dart +++ b/lib/src/kdbx_file.dart @@ -151,6 +151,13 @@ class KdbxFile { ? header.upgradeMinor(majorVersion, minorVersion) : header.upgrade(majorVersion, minorVersion); + // Do this even if we were already in v4.x since some KDBX software (including + // earlier versions of Kee Vault) skipped this step when upgrading from KdbxMetaCustomData.read(e)) ?? @@ -91,7 +91,7 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext { .map((e) => MapEntry(e.uuid, e)) .let((that) => Map.fromEntries(that)) ?? {}, - super.read(node); + super.read(); @override final KdbxReadWriteContext ctx; @@ -463,8 +463,8 @@ class KeeVaultEmbeddedConfig { int get hashCode { return version.hashCode ^ randomId.hashCode ^ - addon.hashCode ^ - vault.hashCode; + const MapEquality().hash(addon) ^ + const MapEquality().hash(vault); } } @@ -582,7 +582,7 @@ class BrowserDbSettings { defaultPlaceholderHandling.hashCode ^ displayPriorityField.hashCode ^ displayGlobalPlaceholderOption.hashCode ^ - matchedURLAccuracyOverrides.hashCode; + const MapEquality().hash(matchedURLAccuracyOverrides); } } diff --git a/lib/src/kdbx_times.dart b/lib/src/kdbx_times.dart index b1094b1..5acb1f6 100644 --- a/lib/src/kdbx_times.dart +++ b/lib/src/kdbx_times.dart @@ -4,7 +4,6 @@ import 'package:kdbx/src/kdbx_object.dart'; import 'package:kdbx/src/kdbx_xml.dart'; import 'package:logging/logging.dart'; import 'package:quiver/iterables.dart'; -import 'package:xml/xml.dart'; final _logger = Logger('kdbx_times'); @@ -19,7 +18,7 @@ class KdbxTimes extends KdbxNode implements KdbxNodeContext { usageCount.set(0); locationChanged.set(now); } - KdbxTimes.read(XmlElement node, this.ctx) : super.read(node) { + KdbxTimes.read(super.node, this.ctx) : super.read() { // backward compatibility - there was a bug setting/reading // modification, lastAccess and expiryTime. Make sure they are defined. final checkDates = { diff --git a/lib/src/kdbx_xml.dart b/lib/src/kdbx_xml.dart index 2a6fb5f..33e5c49 100644 --- a/lib/src/kdbx_xml.dart +++ b/lib/src/kdbx_xml.dart @@ -92,7 +92,7 @@ extension on List { } abstract class KdbxSubTextNode extends KdbxSubNode { - KdbxSubTextNode(KdbxNode node, String name) : super(node, name); + KdbxSubTextNode(super.node, super.name); void Function()? _onModify; @@ -158,7 +158,7 @@ abstract class KdbxSubTextNode extends KdbxSubNode { } class IntNode extends KdbxSubTextNode { - IntNode(KdbxNode node, String name) : super(node, name); + IntNode(super.node, super.name); @override int? decode(String value) => int.tryParse(value); @@ -168,7 +168,7 @@ class IntNode extends KdbxSubTextNode { } class StringNode extends KdbxSubTextNode { - StringNode(KdbxNode node, String name) : super(node, name); + StringNode(super.node, super.name); @override String decode(String value) => value; @@ -178,7 +178,7 @@ class StringNode extends KdbxSubTextNode { } class StringListNode extends KdbxSubTextNode> { - StringListNode(KdbxNode node, String name) : super(node, name); + StringListNode(super.node, super.name); @override List decode(String value) { @@ -195,7 +195,7 @@ class StringListNode extends KdbxSubTextNode> { } class Base64Node extends KdbxSubTextNode { - Base64Node(KdbxNode node, String name) : super(node, name); + Base64Node(super.node, super.name); @override ByteBuffer decode(String value) => base64.decode(value).buffer; @@ -205,7 +205,7 @@ class Base64Node extends KdbxSubTextNode { } class UuidNode extends KdbxSubTextNode { - UuidNode(KdbxNode node, String name) : super(node, name); + UuidNode(super.node, super.name); @override KdbxUuid decode(String value) => KdbxUuid(value); @@ -215,7 +215,7 @@ class UuidNode extends KdbxSubTextNode { } class IconNode extends KdbxSubTextNode { - IconNode(KdbxNode node, String name) : super(node, name); + IconNode(super.node, super.name); @override KdbxIcon decode(String value) => KdbxIcon.values[int.tryParse(value) ?? 0]; @@ -243,7 +243,7 @@ class KdbxColor { // Tolerates 6 digit hex strings but outputs more-compatible 7 char strings with leading # class ColorNode extends KdbxSubTextNode { - ColorNode(KdbxNode node, String name) : super(node, name); + ColorNode(super.node, super.name); @override KdbxColor decode(String value) => KdbxColor.parse(value); @@ -253,7 +253,7 @@ class ColorNode extends KdbxSubTextNode { } class NullableBooleanNode extends KdbxSubTextNode { - NullableBooleanNode(KdbxNode node, String name) : super(node, name); + NullableBooleanNode(super.node, super.name); @override bool? decode(String value) { @@ -284,7 +284,7 @@ class NullableBooleanNode extends KdbxSubTextNode { } class DateTimeUtcNode extends KdbxSubTextNode { - DateTimeUtcNode(KdbxNodeContext node, String name) : super(node, name); + DateTimeUtcNode(KdbxNodeContext super.node, super.name); KdbxReadWriteContext get _ctx => (node as KdbxNodeContext).ctx; static final minDate = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); diff --git a/lib/src/kee_vault_model/browser_entry_settings.dart b/lib/src/kee_vault_model/browser_entry_settings.dart new file mode 100644 index 0000000..a8d45fd --- /dev/null +++ b/lib/src/kee_vault_model/browser_entry_settings.dart @@ -0,0 +1,216 @@ +import 'dart:convert'; +import 'package:collection/collection.dart'; +import 'package:kdbx/src/kee_vault_model/browser_entry_settings_v1.dart'; +import 'package:kdbx/src/kee_vault_model/entry_matcher_config.dart'; +import 'package:kdbx/src/kee_vault_model/enums.dart'; +import 'package:kdbx/src/kee_vault_model/field.dart'; + +class BrowserEntrySettings { + BrowserEntrySettings({ + this.version = 2, + List? includeUrls, + List? excludeUrls, + this.realm = '', + List? authenticationMethods, + this.behaviour = BrowserAutoFillBehaviour.Default, + required this.matcherConfigs, + List? fields, + }) : authenticationMethods = authenticationMethods ?? [], + includeUrls = includeUrls ?? [], + excludeUrls = excludeUrls ?? [], + fields = fields ?? []; + + factory BrowserEntrySettings.fromMap(Map? map, + {required MatchAccuracy minimumMatchAccuracy}) { + if (map == null) { + return BrowserEntrySettings(matcherConfigs: [ + EntryMatcherConfig.forDefaultUrlMatchBehaviour(minimumMatchAccuracy) + ]); + } + + return BrowserEntrySettings( + version: map['version'] as int? ?? 2, + includeUrls: getIncludeUrls(map), + excludeUrls: getExcludeUrls(map), + realm: map['httpRealm'] as String?, + authenticationMethods: + (map['authenticationMethods'] as List?)?.cast() ?? + [], + behaviour: BrowserAutoFillBehaviour.values + .firstWhereOrNull((v) => v.name == map['behaviour']), + matcherConfigs: List.from((map['matcherConfigs'] + as List?) + ?.cast>() + .map((x) => EntryMatcherConfig.fromMap(x)) ?? + []), + fields: List.from((map['fields'] as List?) + ?.cast>() + .map((x) => Field.fromMap(x)) ?? + []), + ); + } + + factory BrowserEntrySettings.fromJson(String source, + {required MatchAccuracy minimumMatchAccuracy}) => + BrowserEntrySettings.fromMap(json.decode(source) as Map?, + minimumMatchAccuracy: minimumMatchAccuracy); + + int version; + List includeUrls; + List excludeUrls; + String? realm; + List? authenticationMethods; + // enum + BrowserAutoFillBehaviour? behaviour; + List matcherConfigs; + List? fields; + + BrowserEntrySettings copyWith({ + int? version, + List? includeUrls, + List? excludeUrls, + String? realm, + List? authenticationMethods, + BrowserAutoFillBehaviour? behaviour, + List? matcherConfigs, + List? fields, + }) { + return BrowserEntrySettings( + version: version ?? this.version, + behaviour: behaviour ?? this.behaviour, + authenticationMethods: + authenticationMethods ?? this.authenticationMethods, + realm: realm ?? this.realm, + includeUrls: includeUrls ?? this.includeUrls, + excludeUrls: excludeUrls ?? this.excludeUrls, + fields: fields ?? this.fields, + matcherConfigs: matcherConfigs ?? this.matcherConfigs, + ); + } + + BrowserEntrySettingsV1 convertToV1() { + return BrowserEntrySettingsV1( + minimumMatchAccuracy: matcherConfigs + .firstWhereOrNull( + (element) => element.matcherType == EntryMatcherType.Url) + ?.urlMatchMethod ?? + MatchAccuracy.Domain, + realm: realm ?? '', + fields: fields?.map((f) => f.convertToV1()).nonNulls.toList(), + behaviour: behaviour ?? BrowserAutoFillBehaviour.Default, + excludeUrls: excludeUrls, + includeUrls: includeUrls, + priority: 0, + hide: matcherConfigs + .any((element) => element.matcherType == EntryMatcherType.Hide)); + } + + static Map> parseUrls( + List includeUrls, List excludeUrls) { + final altUrls = []; + final regExUrls = []; + final blockedUrls = []; + final regExBlockedUrls = []; + for (final p in includeUrls) { + if (p is RegExp) { + regExUrls.add(p.pattern); + } else if (p is String) { + altUrls.add(p); + } + } + for (final p in excludeUrls) { + if (p is RegExp) { + regExBlockedUrls.add(p.pattern); + } else if (p is String) { + blockedUrls.add(p); + } + } + return >{ + if (altUrls.isNotEmpty) 'altUrls': altUrls, + if (regExUrls.isNotEmpty) 'regExUrls': regExUrls, + if (blockedUrls.isNotEmpty) 'blockedUrls': blockedUrls, + if (regExBlockedUrls.isNotEmpty) 'regExBlockedUrls': regExBlockedUrls, + }; + } + + static List getIncludeUrls(Map map) { + final includeUrls = []; + final altUrls = (map['altUrls'] as List?)?.cast(); + final regExURLs = (map['regExUrls'] as List?)?.cast(); + if (altUrls != null) { + altUrls.forEach(includeUrls.add); + } + if (regExURLs != null) { + for (final url in regExURLs) { + includeUrls.add(RegExp(url)); + } + } + return includeUrls; + } + + static List getExcludeUrls(Map map) { + final excludeUrls = []; + final blockedURLs = (map['blockedUrls'] as List?)?.cast(); + final regExBlockedURLs = + (map['regExBlockedUrls'] as List?)?.cast(); + if (blockedURLs != null) { + blockedURLs.forEach(excludeUrls.add); + } + if (regExBlockedURLs != null) { + for (final url in regExBlockedURLs) { + excludeUrls.add(RegExp(url)); + } + } + return excludeUrls; + } + + Map toMap() { + return { + 'version': version, + 'authenticationMethods': authenticationMethods, + if (realm?.isNotEmpty ?? false) 'httpRealm': realm, + 'matcherConfigs': matcherConfigs.map((x) => x.toMap()).toList(), + if (fields != null) 'fields': fields?.map((x) => x.toMap()).toList(), + if (behaviour != null && behaviour != BrowserAutoFillBehaviour.Default) + 'behaviour': behaviour?.name, + ...parseUrls(includeUrls, excludeUrls), + }; + } + + String toJson() => json.encode(toMap()); + + @override + String toString() { + return 'BrowserSettingsModel(version: $version, behaviour: $behaviour, realm: $realm, includeUrls: $includeUrls, excludeUrls: $excludeUrls, fields: $fields)'; + } + + @override + // ignore: avoid_renaming_method_parameters + bool operator ==(Object o) { + if (identical(this, o)) { + return true; + } + final unOrdDeepEq = const DeepCollectionEquality.unordered().equals; + return o is BrowserEntrySettings && + o.version == version && + o.behaviour == behaviour && + unOrdDeepEq(o.authenticationMethods, authenticationMethods) && + o.realm == realm && + unOrdDeepEq(o.matcherConfigs, matcherConfigs) && + unOrdDeepEq(o.includeUrls, includeUrls) && + unOrdDeepEq(o.excludeUrls, excludeUrls) && + unOrdDeepEq(o.fields, fields); + } + + @override + int get hashCode { + return version.hashCode ^ + behaviour.hashCode ^ + const ListEquality().hash(authenticationMethods) ^ + realm.hashCode ^ + const ListEquality().hash(matcherConfigs) ^ + const ListEquality().hash(includeUrls) ^ + const ListEquality().hash(excludeUrls) ^ + const ListEquality().hash(fields); + } +} diff --git a/lib/src/kee_vault_model/browser_entry_settings_v1.dart b/lib/src/kee_vault_model/browser_entry_settings_v1.dart new file mode 100644 index 0000000..57849f4 --- /dev/null +++ b/lib/src/kee_vault_model/browser_entry_settings_v1.dart @@ -0,0 +1,424 @@ +import 'dart:convert'; +import 'dart:math'; +import 'package:collection/collection.dart'; +import 'package:kdbx/src/kee_vault_model/browser_entry_settings.dart'; +import 'package:kdbx/src/kee_vault_model/browser_field_model_v1.dart'; +import 'package:kdbx/src/kee_vault_model/entry_matcher_config.dart'; +import 'package:kdbx/src/kee_vault_model/enums.dart'; +import 'package:kdbx/src/kee_vault_model/field.dart'; + +import '../utils/field_type_utils.dart'; +import '../utils/guid_service.dart'; +import 'field_matcher_config.dart'; +import 'form_field_type.dart'; + +class BrowserEntrySettingsV1 { + BrowserEntrySettingsV1({ + this.version = 1, + this.behaviour = BrowserAutoFillBehaviour.Default, + required this.minimumMatchAccuracy, + this.priority = 0, + this.hide = false, + this.realm = '', + List? includeUrls, + List? excludeUrls, + List? fields, + }) : includeUrls = includeUrls ?? [], + excludeUrls = excludeUrls ?? [], + fields = fields ?? []; + + factory BrowserEntrySettingsV1.fromMap(Map? map, + {required MatchAccuracy minimumMatchAccuracy}) { + if (map == null) { + return BrowserEntrySettingsV1(minimumMatchAccuracy: minimumMatchAccuracy); + } + + return BrowserEntrySettingsV1( + version: map['version'] as int? ?? 1, + behaviour: getBehaviour(map), + minimumMatchAccuracy: getMam(map), + priority: map['priority'] as int? ?? 0, + hide: map['hide'] as bool? ?? false, + realm: map['hTTPRealm'] as String?, + includeUrls: getIncludeUrls(map), + excludeUrls: getExcludeUrls(map), + fields: List.from( + (map['formFieldList'] as List?) + ?.cast>() + .map( + (x) => BrowserFieldModelV1.fromMap(x)) ?? + []), + ); + } + + factory BrowserEntrySettingsV1.fromJson(String source, + {required MatchAccuracy minimumMatchAccuracy}) { + if (source.isEmpty) { + return BrowserEntrySettingsV1(minimumMatchAccuracy: minimumMatchAccuracy); + } + return BrowserEntrySettingsV1.fromMap( + json.decode(source) as Map?, + minimumMatchAccuracy: minimumMatchAccuracy); + } + + int version; + // enum + BrowserAutoFillBehaviour behaviour; + // enum + MatchAccuracy minimumMatchAccuracy; + int priority; // always 0 + bool hide; + String? realm; + List includeUrls; + List excludeUrls; + List fields; + + BrowserEntrySettingsV1 copyWith({ + int? version, + BrowserAutoFillBehaviour? behaviour, + MatchAccuracy? minimumMatchAccuracy, + int? priority, + bool? hide, + String? realm, + List? includeUrls, + List? excludeUrls, + List? fields, + }) { + return BrowserEntrySettingsV1( + version: version ?? this.version, + behaviour: behaviour ?? this.behaviour, + minimumMatchAccuracy: minimumMatchAccuracy ?? this.minimumMatchAccuracy, + priority: priority ?? this.priority, + hide: hide ?? this.hide, + realm: realm ?? this.realm, + includeUrls: includeUrls ?? this.includeUrls, + excludeUrls: excludeUrls ?? this.excludeUrls, + fields: fields ?? this.fields, + ); + } + + static BrowserAutoFillBehaviour getBehaviour(Map map) { + if (map['neverAutoFill'] as bool? ?? false) { + return BrowserAutoFillBehaviour.NeverAutoFillNeverAutoSubmit; + } else if (map['alwaysAutoSubmit'] as bool? ?? false) { + return BrowserAutoFillBehaviour.AlwaysAutoFillAlwaysAutoSubmit; + } else if ((map['alwaysAutoFill'] as bool? ?? false) && + (map['neverAutoSubmit'] as bool? ?? false)) { + return BrowserAutoFillBehaviour.AlwaysAutoFillNeverAutoSubmit; + } else if (map['neverAutoSubmit'] as bool? ?? false) { + return BrowserAutoFillBehaviour.NeverAutoSubmit; + } else if (map['alwaysAutoFill'] as bool? ?? false) { + return BrowserAutoFillBehaviour.AlwaysAutoFill; + } else { + return BrowserAutoFillBehaviour.Default; + } + } + + static MatchAccuracy getMam(Map map) { + if (map['blockHostnameOnlyMatch'] as bool? ?? false) { + return MatchAccuracy.Exact; + } else if (map['blockDomainOnlyMatch'] as bool? ?? false) { + return MatchAccuracy.Hostname; + } else { + return MatchAccuracy.Domain; + } + } + + static Map parseBehaviour(BrowserAutoFillBehaviour behaviour) { + switch (behaviour) { + case BrowserAutoFillBehaviour.AlwaysAutoFill: + return { + 'alwaysAutoFill': true, + 'alwaysAutoSubmit': false, + 'neverAutoFill': false, + 'neverAutoSubmit': false, + }; + case BrowserAutoFillBehaviour.NeverAutoSubmit: + return { + 'alwaysAutoFill': false, + 'alwaysAutoSubmit': false, + 'neverAutoFill': false, + 'neverAutoSubmit': true, + }; + case BrowserAutoFillBehaviour.AlwaysAutoFillAlwaysAutoSubmit: + return { + 'alwaysAutoFill': true, + 'alwaysAutoSubmit': true, + 'neverAutoFill': false, + 'neverAutoSubmit': false, + }; + case BrowserAutoFillBehaviour.NeverAutoFillNeverAutoSubmit: + return { + 'alwaysAutoFill': false, + 'alwaysAutoSubmit': false, + 'neverAutoFill': true, + 'neverAutoSubmit': true, + }; + case BrowserAutoFillBehaviour.AlwaysAutoFillNeverAutoSubmit: + return { + 'alwaysAutoFill': true, + 'alwaysAutoSubmit': false, + 'neverAutoFill': false, + 'neverAutoSubmit': true, + }; + case BrowserAutoFillBehaviour.Default: + return { + 'alwaysAutoFill': false, + 'alwaysAutoSubmit': false, + 'neverAutoFill': false, + 'neverAutoSubmit': false, + }; + } + } + + static Map parseMam(MatchAccuracy mam) { + switch (mam) { + case MatchAccuracy.Domain: + return { + 'blockDomainOnlyMatch': false, + 'blockHostnameOnlyMatch': false, + }; + case MatchAccuracy.Hostname: + return { + 'blockDomainOnlyMatch': true, + 'blockHostnameOnlyMatch': false, + }; + default: + return { + 'blockDomainOnlyMatch': false, + 'blockHostnameOnlyMatch': true, + }; + } + } + + static Map> parseUrls( + List includeUrls, List excludeUrls) { + final altURLs = []; + final regExURLs = []; + final blockedURLs = []; + final regExBlockedURLs = []; + for (final p in includeUrls) { + if (p is RegExp) { + regExURLs.add(p.pattern); + } else if (p is String) { + altURLs.add(p); + } + } + for (final p in excludeUrls) { + if (p is RegExp) { + regExBlockedURLs.add(p.pattern); + } else if (p is String) { + blockedURLs.add(p); + } + } + return >{ + 'altURLs': altURLs, + 'regExURLs': regExURLs, + 'blockedURLs': blockedURLs, + 'regExBlockedURLs': regExBlockedURLs, + }; + } + + static List getIncludeUrls(Map map) { + final includeUrls = []; + final altUrls = (map['altURLs'] as List?)?.cast(); + final regExURLs = (map['regExURLs'] as List?)?.cast(); + if (altUrls != null) { + altUrls.forEach(includeUrls.add); + } + if (regExURLs != null) { + for (final url in regExURLs) { + includeUrls.add(RegExp(url)); + } + } + return includeUrls; + } + + static List getExcludeUrls(Map map) { + final excludeUrls = []; + final blockedURLs = (map['blockedURLs'] as List?)?.cast(); + final regExBlockedURLs = + (map['regExBlockedURLs'] as List?)?.cast(); + if (blockedURLs != null) { + blockedURLs.forEach(excludeUrls.add); + } + if (regExBlockedURLs != null) { + for (final url in regExBlockedURLs) { + excludeUrls.add(RegExp(url)); + } + } + return excludeUrls; + } + + Map toMap() { + return { + 'version': version, + 'priority': priority, + 'hide': hide, + 'hTTPRealm': realm, + 'formFieldList': fields.map((x) => x.toMap()).toList(), + ...parseBehaviour(behaviour), + ...parseMam(minimumMatchAccuracy), + ...parseUrls(includeUrls, excludeUrls), + }; + } + + String toJson() => json.encode(toMap()); + + @override + String toString() { + return 'BrowserSettingsModelV1(version: $version, behaviour: $behaviour, minimumMatchAccuracy: $minimumMatchAccuracy, priority: $priority, hide: $hide, realm: $realm, includeUrls: $includeUrls, excludeUrls: $excludeUrls, fields: $fields)'; + } + + @override + // ignore: avoid_renaming_method_parameters + bool operator ==(Object o) { + if (identical(this, o)) { + return true; + } + final unOrdDeepEq = const DeepCollectionEquality.unordered().equals; + return o is BrowserEntrySettingsV1 && + o.version == version && + o.behaviour == behaviour && + o.minimumMatchAccuracy == minimumMatchAccuracy && + o.priority == priority && + o.hide == hide && + o.realm == realm && + unOrdDeepEq(o.includeUrls, includeUrls) && + unOrdDeepEq(o.excludeUrls, excludeUrls) && + unOrdDeepEq(o.fields, fields); + } + + @override + int get hashCode { + return version.hashCode ^ + behaviour.hashCode ^ + minimumMatchAccuracy.hashCode ^ + priority.hashCode ^ + hide.hashCode ^ + realm.hashCode ^ + const ListEquality().hash(includeUrls) ^ + const ListEquality().hash(excludeUrls) ^ + const ListEquality().hash(fields); + } + + BrowserEntrySettings convertToV2(IGuidService guidService) { + final List mcList = [ + EntryMatcherConfig.forDefaultUrlMatchBehaviour(minimumMatchAccuracy), + if (hide) EntryMatcherConfig(matcherType: EntryMatcherType.Hide) + ]; + + final conf2 = BrowserEntrySettings( + behaviour: behaviour, + authenticationMethods: ['password'], + matcherConfigs: mcList, + includeUrls: includeUrls, + excludeUrls: excludeUrls, + realm: realm, + fields: convertFields(fields, guidService), + ); + + return conf2; + } + + List convertFields( + List formFieldList, IGuidService guidService) { + final List fields = []; + bool usernameFound = false; + bool passwordFound = false; + for (final ff in formFieldList) { + if (ff.value == '{USERNAME}') { + usernameFound = true; + final mc = !((ff.fieldId?.isNotEmpty ?? false) || + (ff.name?.isNotEmpty ?? false)) + ? FieldMatcherConfig( + matcherType: FieldMatcherType.UsernameDefaultHeuristic) + : FieldMatcherConfig.forSingleClientMatch( + FormFieldType.USERNAME, + id: ff.fieldId, + name: ff.name, + ); + final f = Field( + valuePath: 'UserName', + page: max(ff.page, 1), + uuid: guidService.newGuidAsBase64(), + type: FieldType.Text, + matcherConfigs: [mc], + ); + if (ff.placeholderHandling != PlaceholderHandling.Default.name) { + f.placeholderHandling = PlaceholderHandling.values + .firstWhereOrNull((v) => v.name == ff.placeholderHandling); + } + fields.add(f); + } else if (ff.value == '{PASSWORD}') { + passwordFound = true; + final mc = !((ff.fieldId?.isNotEmpty ?? false) || + (ff.name?.isNotEmpty ?? false)) + ? FieldMatcherConfig( + matcherType: FieldMatcherType.PasswordDefaultHeuristic) + : FieldMatcherConfig.forSingleClientMatch( + FormFieldType.PASSWORD, + id: ff.fieldId, + name: ff.name, + ); + final f = Field( + valuePath: 'Password', + page: max(ff.page, 1), + uuid: guidService.newGuidAsBase64(), + type: FieldType.Password, + matcherConfigs: [mc]); + if (ff.placeholderHandling != PlaceholderHandling.Default.name) { + f.placeholderHandling = PlaceholderHandling.values + .firstWhereOrNull((v) => v.name == ff.placeholderHandling); + } + fields.add(f); + } else { + final mc = FieldMatcherConfig.forSingleClientMatch( + ff.type ?? FormFieldType.TEXT, + id: ff.fieldId, + name: ff.name, + ); + final newUniqueId = guidService.newGuidAsBase64(); + final f = Field( + name: (ff.displayName?.isNotEmpty ?? false) + ? ff.displayName + : newUniqueId, + valuePath: '.', + page: max(ff.page, 1), + uuid: newUniqueId, + type: Utilities.formFieldTypeToFieldType( + ff.type ?? FormFieldType.TEXT), + matcherConfigs: [mc], + value: ff.value); + if (ff.placeholderHandling != PlaceholderHandling.Default.name) { + f.placeholderHandling = PlaceholderHandling.values + .firstWhereOrNull((v) => v.name == ff.placeholderHandling); + } + fields.add(f); + } + } + + if (!usernameFound) { + fields.add(Field( + valuePath: 'UserName', + uuid: guidService.newGuidAsBase64(), + type: FieldType.Text, + matcherConfigs: [ + FieldMatcherConfig( + matcherType: FieldMatcherType.UsernameDefaultHeuristic) + ])); + } + if (!passwordFound) { + fields.add(Field( + valuePath: 'Password', + uuid: guidService.newGuidAsBase64(), + type: FieldType.Password, + matcherConfigs: [ + FieldMatcherConfig( + matcherType: FieldMatcherType.PasswordDefaultHeuristic) + ])); + } + + return fields; + } +} diff --git a/lib/src/field.dart b/lib/src/kee_vault_model/browser_field_model_v1.dart similarity index 57% rename from lib/src/field.dart rename to lib/src/kee_vault_model/browser_field_model_v1.dart index 9b89737..6a1a564 100644 --- a/lib/src/field.dart +++ b/lib/src/kee_vault_model/browser_field_model_v1.dart @@ -1,18 +1,8 @@ import 'dart:convert'; +import 'package:kdbx/src/kee_vault_model/form_field_type.dart'; -enum FieldStorage { CUSTOM, JSON, BOTH } - -class FormFieldType { - static const String USERNAME = 'FFTusername'; - static const String PASSWORD = 'FFTpassword'; - static const String TEXT = 'FFTtext'; - static const String RADIO = 'FFTradio'; - static const String CHECKBOX = 'FFTcheckbox'; - static const String SELECT = 'FFTselect'; -} - -class BrowserFieldModel { - BrowserFieldModel({ +class BrowserFieldModelV1 { + BrowserFieldModelV1({ this.displayName, this.name = '', this.type = FormFieldType.TEXT, @@ -22,16 +12,16 @@ class BrowserFieldModel { this.value = '', }); - factory BrowserFieldModel.fromMap(Map? map) { + factory BrowserFieldModelV1.fromMap(Map? map) { if (map == null) { - return BrowserFieldModel(); + return BrowserFieldModelV1(); } - return BrowserFieldModel( + return BrowserFieldModelV1( displayName: map['displayName'] as String?, name: map['name'] as String?, type: map['type'] as String?, - // Should have been persisted as id for KPRPC.plgx compatability but + // Should have been persisted as id for KPRPC.plgx compatibility but // PWA sometimes or always persists as fieldId by mistake. fieldId: map['id'] as String? ?? map['fieldId'] as String?, page: map['page'] as int? ?? -1, @@ -39,8 +29,8 @@ class BrowserFieldModel { value: map['value'] as String?, ); } - factory BrowserFieldModel.fromJson(String source) => - BrowserFieldModel.fromMap(json.decode(source) as Map?); + factory BrowserFieldModelV1.fromJson(String source) => + BrowserFieldModelV1.fromMap(json.decode(source) as Map?); String? displayName; String? name; @@ -57,7 +47,7 @@ class BrowserFieldModel { return true; } - return o is BrowserFieldModel && + return o is BrowserFieldModelV1 && o.displayName == displayName && o.name == name && o.type == type && @@ -78,7 +68,7 @@ class BrowserFieldModel { value.hashCode; } - BrowserFieldModel copyWith({ + BrowserFieldModelV1 copyWith({ String? displayName, String? name, String? type, @@ -87,7 +77,7 @@ class BrowserFieldModel { String? placeholderHandling, String? value, }) { - return BrowserFieldModel( + return BrowserFieldModelV1( displayName: displayName ?? this.displayName, name: name ?? this.name, type: type ?? this.type, @@ -117,30 +107,3 @@ class BrowserFieldModel { return 'BrowserFieldModel(displayName: $displayName, name: $name, type: $type, fieldId: $fieldId, page: $page, placeholderHandling: $placeholderHandling, value: $value)'; } } - -// defaults... -// class BrowserFieldModel( -// String displayName: this.getBrowserFieldDisplayNameDefault(), -// String name: '', -// String type: this.getBrowserFieldTypeDefault(), -// String fieldId = ''; -// int page = -1; -// String placeholderHandling: 'Default' - -/* - -for when outputting to json (persistence or kepassrpc): -$Password etc is old way of identifying the user and pass common fields in KeeWeb . probably useless now. - - getBrowserFieldDisplayNameDefault: function() { - if (this.model.name === '$Password') return 'KeePass password'; - else if (this.model.name === '$UserName') return 'KeePass username'; - else return ''; - }, - - getBrowserFieldTypeDefault: function() { - if (this.model.name === '$Password') return 'FFTpassword'; - else if (this.model.name === '$UserName') return 'FFTusername'; - else return 'FFTtext'; - }, - */ diff --git a/lib/src/kee_vault_model/entry_matcher.dart b/lib/src/kee_vault_model/entry_matcher.dart new file mode 100644 index 0000000..e9bb2d2 --- /dev/null +++ b/lib/src/kee_vault_model/entry_matcher.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; +import 'package:collection/collection.dart'; +import 'package:kdbx/src/kee_vault_model/enums.dart'; + +class EntryMatcher { + EntryMatcher({ + this.matchLogic, + this.queries = const [], + this.pageTitles = const [], + }); + + factory EntryMatcher.fromMap(Map? map) { + if (map == null) { + return EntryMatcher(); + } + + return EntryMatcher( + matchLogic: MatcherLogic.values + .firstWhereOrNull((v) => v.name == map['matchLogic']), + queries: (map['queries'] as List?)?.cast() ?? [], + pageTitles: (map['pageTitles'] as List?)?.cast() ?? [], + ); + } + + factory EntryMatcher.fromJson(String source) => + EntryMatcher.fromMap(json.decode(source) as Map?); + + EntryMatcher copyWith({ + MatcherLogic? matchLogic, + List? queries, + List? pageTitles, + }) { + return EntryMatcher( + matchLogic: matchLogic ?? this.matchLogic, + queries: queries ?? this.queries, + pageTitles: pageTitles ?? this.pageTitles, + ); + } + + Map toMap() { + return { + 'matchLogic': matchLogic?.name, + 'queries': queries, + 'pageTitles': pageTitles, + }; + } + + String toJson() => json.encode(toMap()); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + final unOrdDeepEq = const DeepCollectionEquality.unordered().equals; + + return other is EntryMatcher && + other.matchLogic == matchLogic && + unOrdDeepEq(other.queries, queries) && + unOrdDeepEq(other.pageTitles, pageTitles); + } + + @override + int get hashCode { + return matchLogic.hashCode ^ + const ListEquality().hash(queries) ^ + const ListEquality().hash(pageTitles); + } + + MatcherLogic? matchLogic; // default to Client initially + List queries; // HTML DOM select query + List pageTitles; // HTML Page title contains +} diff --git a/lib/src/kee_vault_model/entry_matcher_config.dart b/lib/src/kee_vault_model/entry_matcher_config.dart new file mode 100644 index 0000000..2f75246 --- /dev/null +++ b/lib/src/kee_vault_model/entry_matcher_config.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:kdbx/src/kee_vault_model/entry_matcher.dart'; +import 'package:kdbx/src/kee_vault_model/enums.dart'; + +class EntryMatcherConfig { + EntryMatcherConfig({ + this.matcherType, + this.customMatcher, + this.urlMatchMethod, + this.weight, // 0 = client decides or ignores locator + this.actionOnMatch, + this.actionOnNoMatch, + }); + + factory EntryMatcherConfig.fromMap(Map? map) { + if (map == null) { + return EntryMatcherConfig(); + } + + return EntryMatcherConfig( + matcherType: EntryMatcherType.values + .firstWhereOrNull((v) => v.name == map['matcherType']), + customMatcher: map['customMatcher'] != null + ? EntryMatcher.fromMap(map['customMatcher'] as Map) + : null, + urlMatchMethod: MatchAccuracy.values + .firstWhereOrNull((v) => v.name == map['urlMatchMethod']), + weight: map['weight'] as int?, + actionOnMatch: MatchAction.values + .firstWhereOrNull((v) => v.name == map['actionOnMatch']), + actionOnNoMatch: MatchAction.values + .firstWhereOrNull((v) => v.name == map['actionOnNoMatch']), + ); + } + + factory EntryMatcherConfig.fromJson(String source) => + EntryMatcherConfig.fromMap(json.decode(source) as Map); + + EntryMatcherConfig.forDefaultUrlMatchBehaviour(MatchAccuracy ma) + : this( + matcherType: EntryMatcherType.Url, + urlMatchMethod: ma, + ); + + EntryMatcherConfig copyWith({ + EntryMatcherType? matcherType, + EntryMatcher? customMatcher, + MatchAccuracy? urlMatchMethod, + num? weight, + MatchAction? actionOnMatch, + MatchAction? actionOnNoMatch, + }) { + return EntryMatcherConfig( + matcherType: matcherType ?? this.matcherType, + customMatcher: customMatcher ?? this.customMatcher, + urlMatchMethod: urlMatchMethod ?? this.urlMatchMethod, + weight: weight ?? this.weight, + actionOnMatch: actionOnMatch ?? this.actionOnMatch, + actionOnNoMatch: actionOnNoMatch ?? this.actionOnNoMatch, + ); + } + + Map toMap() { + return { + 'matcherType': matcherType?.name, + if (customMatcher != null) 'customMatcher': customMatcher?.toMap(), + if (urlMatchMethod != null && urlMatchMethod != MatchAccuracy.Domain) + 'urlMatchMethod': urlMatchMethod?.name, + if (weight != null) 'weight': weight, + if (actionOnMatch != null) 'actionOnMatch': actionOnMatch?.name, + if (actionOnNoMatch != null) 'actionOnNoMatch': actionOnNoMatch?.name, + }; + } + + String toJson() => json.encode(toMap()); + + @override + String toString() { + return 'EntryMatcherConfig(matcherType: $matcherType, customMatcher: $customMatcher, urlMatchMethod: $urlMatchMethod, weight: $weight, actionOnMatch: $actionOnMatch, actionOnNoMatch: $actionOnNoMatch)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is EntryMatcherConfig && + other.matcherType == matcherType && + other.customMatcher == customMatcher && + other.urlMatchMethod == urlMatchMethod && + other.weight == weight && + other.actionOnMatch == actionOnMatch && + other.actionOnNoMatch == actionOnNoMatch; + } + + @override + int get hashCode { + return matcherType.hashCode ^ + customMatcher.hashCode ^ + urlMatchMethod.hashCode ^ + weight.hashCode ^ + actionOnMatch.hashCode ^ + actionOnNoMatch.hashCode; + } + + EntryMatcherType? matcherType; + EntryMatcher? customMatcher; + MatchAccuracy? urlMatchMethod; + num? weight; // 0 = client decides or ignores locator + MatchAction? actionOnMatch; + MatchAction? + actionOnNoMatch; // critical to use TotalBlock here for Url match type +} diff --git a/lib/src/kee_vault_model/enums.dart b/lib/src/kee_vault_model/enums.dart new file mode 100644 index 0000000..0cb8d90 --- /dev/null +++ b/lib/src/kee_vault_model/enums.dart @@ -0,0 +1,32 @@ +enum BrowserAutoFillBehaviour { + Default, + AlwaysAutoFill, + NeverAutoSubmit, + AlwaysAutoFillNeverAutoSubmit, + AlwaysAutoFillAlwaysAutoSubmit, + NeverAutoFillNeverAutoSubmit +} + +enum MatchAccuracy { Exact, Hostname, Domain } + +enum FieldStorage { CUSTOM, JSON, BOTH } + +enum FieldType { Text, Password, Existing, Toggle, Otp, SomeChars } + +enum FieldMatcherType { + Custom, + UsernameDefaultHeuristic, + PasswordDefaultHeuristic, +} + +enum EntryMatcherType { + Custom, + Hide, + Url, // magic type that uses primary URL + the 4 URL data arrays and current urlmatchconfig to determine a match +} + +enum MatchAction { TotalMatch, TotalBlock, WeightedMatch, WeightedBlock } + +enum MatcherLogic { Client, All, Any } + +enum PlaceholderHandling { Default, Enabled, Disabled } diff --git a/lib/src/kee_vault_model/field.dart b/lib/src/kee_vault_model/field.dart new file mode 100644 index 0000000..9bead61 --- /dev/null +++ b/lib/src/kee_vault_model/field.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; +import 'package:collection/collection.dart'; +import 'package:kdbx/src/kee_vault_model/browser_field_model_v1.dart'; +import 'package:kdbx/src/kee_vault_model/enums.dart'; +import 'package:kdbx/src/kee_vault_model/field_matcher_config.dart'; +import 'package:kdbx/src/kee_vault_model/form_field_type.dart'; +import 'package:kdbx/src/utils/field_type_utils.dart'; + +class Field { + Field({ + this.uuid, + this.name, + this.valuePath, + this.value, + this.page = 1, + this.type, + this.placeholderHandling, + this.matcherConfigs, + }); + + factory Field.fromMap(Map? map) { + if (map == null) { + return Field(); + } + return Field( + uuid: map['uuid'] as String?, + name: map['name'] as String?, + valuePath: map['valuePath'] as String?, + value: map['value'] as String?, + page: map['page'] as int? ?? 1, + type: FieldType.values.firstWhereOrNull((v) => v.name == map['type']), + placeholderHandling: PlaceholderHandling.values + .firstWhereOrNull((v) => v.name == map['placeholderHandling']), + matcherConfigs: List.from((map['matcherConfigs'] + as List?) + ?.cast>() + .map((x) => FieldMatcherConfig.fromMap(x)) ?? + []), + ); + } + + factory Field.fromJson(String source) => + Field.fromMap(json.decode(source) as Map?); + + Field copyWith({ + String? uuid, + String? name, + String? valuePath, + String? value, + int? page, + FieldType? type, + PlaceholderHandling? placeholderHandling, + List? matcherConfigs, + }) { + return Field( + uuid: uuid ?? this.uuid, + name: name ?? this.name, + valuePath: valuePath ?? this.valuePath, + value: value ?? this.value, + page: page ?? this.page, + type: type ?? this.type, + placeholderHandling: placeholderHandling ?? this.placeholderHandling, + matcherConfigs: matcherConfigs ?? this.matcherConfigs, + ); + } + + Map toMap() { + return { + 'page': page, + 'valuePath': valuePath, + 'uuid': uuid, + 'type': type?.name, + 'matcherConfigs': matcherConfigs?.map((x) => x.toMap()).toList(), + if (name?.isNotEmpty ?? false) 'name': name, + if (value?.isNotEmpty ?? false) 'value': value, + if (placeholderHandling != null && + placeholderHandling != PlaceholderHandling.Default) + 'placeholderHandling': placeholderHandling?.name, + }; + } + + String toJson() => json.encode(toMap()); + + @override + String toString() { + return 'Field(uuid: $uuid, name: $name, valuePath: $valuePath, value: $value, page: $page, type: $type, placeholderHandling: $placeholderHandling, matcherConfigs: $matcherConfigs)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + final unOrdDeepEq = const DeepCollectionEquality.unordered().equals; + + return other is Field && + other.uuid == uuid && + other.name == name && + other.valuePath == valuePath && + other.value == value && + other.page == page && + other.type == type && + other.placeholderHandling == placeholderHandling && + unOrdDeepEq(other.matcherConfigs, matcherConfigs); + } + + @override + int get hashCode { + return uuid.hashCode ^ + name.hashCode ^ + valuePath.hashCode ^ + value.hashCode ^ + page.hashCode ^ + type.hashCode ^ + placeholderHandling.hashCode ^ + const ListEquality().hash(matcherConfigs); + } + + String? uuid; + String? name; + String? valuePath; + String? value; + int page = 1; + FieldType? type; + PlaceholderHandling? placeholderHandling; + List? matcherConfigs; + + BrowserFieldModelV1? convertToV1() { + var displayName = name; + var ffValue = value; + var htmlName = ''; + var htmlId = ''; + var htmlType = Utilities.fieldTypeToFormFieldType(type ?? FieldType.Text); + + // Currently we can only have one custommatcher. If that changes and someone tries + // to use this old version with a newer DB things will break so they will have to + // upgrade again to fix it. + final customMatcherConfig = + matcherConfigs?.firstWhereOrNull((mc) => mc.customMatcher != null); + if (customMatcherConfig != null) { + htmlName = + customMatcherConfig.customMatcher?.names.elementAtOrNull(0) ?? ''; + htmlId = customMatcherConfig.customMatcher?.ids.elementAtOrNull(0) ?? ''; + + if (customMatcherConfig.customMatcher?.types != null) { + htmlType = Utilities.formFieldTypeFromHtmlTypeOrFieldType( + customMatcherConfig.customMatcher!.types.elementAtOrNull(0) ?? '', + type ?? FieldType.Text); + } + } + + if (type == FieldType.Password && valuePath == 'Password') { + displayName = 'KeePass password'; + htmlType = FormFieldType.PASSWORD; + ffValue = '{PASSWORD}'; + } else if (type == FieldType.Text && valuePath == 'UserName') { + displayName = 'KeePass username'; + htmlType = FormFieldType.USERNAME; + ffValue = '{USERNAME}'; + } + + if (displayName?.isEmpty ?? true) { + displayName = uuid; + } + + if (ffValue != '') { + return BrowserFieldModelV1( + name: htmlName, + displayName: displayName, + value: ffValue, + type: htmlType, + fieldId: htmlId, + page: page, + placeholderHandling: + (placeholderHandling ?? PlaceholderHandling.Default).name, + ); + } + return null; + } +} diff --git a/lib/src/kee_vault_model/field_matcher.dart b/lib/src/kee_vault_model/field_matcher.dart new file mode 100644 index 0000000..49e4148 --- /dev/null +++ b/lib/src/kee_vault_model/field_matcher.dart @@ -0,0 +1,127 @@ +import 'dart:convert'; +import 'package:collection/collection.dart'; +import 'package:kdbx/src/kee_vault_model/enums.dart'; + +class FieldMatcher { + FieldMatcher({ + this.matchLogic, + this.ids = const [], + this.names = const [], + this.types = const [], + this.queries = const [], + this.labels = const [], + this.autocompleteValues = const [], + this.maxLength, + this.minLength, + }); + + factory FieldMatcher.fromMap(Map? map) { + if (map == null) { + return FieldMatcher(); + } + + return FieldMatcher( + matchLogic: MatcherLogic.values + .firstWhereOrNull((v) => v.name == map['matchLogic']), + ids: (map['ids'] as List?)?.cast() ?? [], + names: (map['names'] as List?)?.cast() ?? [], + types: (map['types'] as List?)?.cast() ?? [], + queries: (map['queries'] as List?)?.cast() ?? [], + labels: (map['labels'] as List?)?.cast() ?? [], + autocompleteValues: + (map['autocompleteValues'] as List?)?.cast() ?? [], + maxLength: map['maxLength'] as int?, + minLength: map['minLength'] as int?, + ); + } + + factory FieldMatcher.fromJson(String source) => + FieldMatcher.fromMap(json.decode(source) as Map?); + + FieldMatcher copyWith({ + MatcherLogic? matchLogic, + List? ids, + List? names, + List? types, + List? queries, + List? labels, + List? autocompleteValues, + int? maxLength, + int? minLength, + }) { + return FieldMatcher( + matchLogic: matchLogic ?? this.matchLogic, + ids: ids ?? this.ids, + names: names ?? this.names, + types: types ?? this.types, + queries: queries ?? this.queries, + labels: labels ?? this.labels, + autocompleteValues: autocompleteValues ?? this.autocompleteValues, + maxLength: maxLength ?? this.maxLength, + minLength: minLength ?? this.minLength, + ); + } + + Map toMap() { + return { + if (matchLogic != null && matchLogic != MatcherLogic.Client) + 'matchLogic': matchLogic?.name, + if (ids.isNotEmpty) 'ids': ids, + if (names.isNotEmpty) 'names': names, + if (types.isNotEmpty) 'types': types, + if (queries.isNotEmpty) 'queries': queries, + if (labels.isNotEmpty) 'labels': labels, + if (autocompleteValues.isNotEmpty) + 'autocompleteValues': autocompleteValues, + if (maxLength != null) 'maxLength': maxLength, + if (minLength != null) 'minLength': minLength, + }; + } + + String toJson() => json.encode(toMap()); + + @override + String toString() { + return 'FieldMatcher(matchLogic: $matchLogic, ids: $ids, names: $names, types: $types, queries: $queries, labels: $labels, autocompleteValues: $autocompleteValues, maxLength: $maxLength, minLength: $minLength)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + final unOrdDeepEq = const DeepCollectionEquality.unordered().equals; + + return other is FieldMatcher && + other.matchLogic == matchLogic && + unOrdDeepEq(other.ids, ids) && + unOrdDeepEq(other.names, names) && + unOrdDeepEq(other.types, types) && + unOrdDeepEq(other.queries, queries) && + unOrdDeepEq(other.labels, labels) && + unOrdDeepEq(other.autocompleteValues, autocompleteValues) && + other.maxLength == maxLength && + other.minLength == minLength; + } + + @override + int get hashCode { + return matchLogic.hashCode ^ + const ListEquality().hash(ids) ^ + const ListEquality().hash(names) ^ + const ListEquality().hash(types) ^ + const ListEquality().hash(queries) ^ + const ListEquality().hash(labels) ^ + const ListEquality().hash(autocompleteValues) ^ + maxLength.hashCode ^ + minLength.hashCode; + } + + MatcherLogic? matchLogic; // default to Client initially + List ids; // HTML id attribute + List names; // HTML name attribute + List types; // HTML input type + List queries; // HTML DOM select query + List labels; // HTML Label or otherwise visible UI label + List autocompleteValues; // HTML autocomplete attribute values + int? maxLength; // max chars allowed in a candidate field for this to match + int? minLength; // min chars allowed in a candidate field for this to match +} diff --git a/lib/src/kee_vault_model/field_matcher_config.dart b/lib/src/kee_vault_model/field_matcher_config.dart new file mode 100644 index 0000000..d972f7e --- /dev/null +++ b/lib/src/kee_vault_model/field_matcher_config.dart @@ -0,0 +1,117 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:kdbx/src/kee_vault_model/enums.dart'; +import 'package:kdbx/src/kee_vault_model/field_matcher.dart'; +import 'package:kdbx/src/utils/field_type_utils.dart'; + +class FieldMatcherConfig { + FieldMatcherConfig({ + this.matcherType, + this.customMatcher, + this.weight, // 0 = client decides or ignores locator + this.actionOnMatch, + }); + + factory FieldMatcherConfig.fromMap(Map? map) { + if (map == null) { + return FieldMatcherConfig(); + } + + return FieldMatcherConfig( + matcherType: FieldMatcherType.values + .firstWhereOrNull((v) => v.name == map['matcherType']), + customMatcher: map['customMatcher'] != null + ? FieldMatcher.fromMap(map['customMatcher'] as Map) + : null, + weight: map['weight'] as int?, + actionOnMatch: MatchAction.values + .firstWhereOrNull((v) => v.name == map['actionOnMatch']), + ); + } + + factory FieldMatcherConfig.fromJson(String source) => + FieldMatcherConfig.fromMap(json.decode(source) as Map?); + + FieldMatcherConfig.forSingleClientMatch( + String fft, { + String? id, + String? name, + }) : this( + customMatcher: FieldMatcher( + ids: id == null ? [] : [id], + names: name == null ? [] : [name], + types: [Utilities.formFieldTypeToHtmlType(fft)], + queries: [], + ), + ); + + FieldMatcherConfig.forSingleClientMatchHtmlType({ + String? id, + String? name, + String? htmlType, + String? domSelector, + }) : this( + customMatcher: FieldMatcher( + ids: id == null ? [] : [id], + names: name == null ? [] : [name], + types: htmlType == null ? [] : [htmlType], + queries: domSelector == null ? [] : [domSelector], + ), + ); + + FieldMatcherConfig copyWith({ + FieldMatcherType? matcherType, + FieldMatcher? customMatcher, + num? weight, + MatchAction? actionOnMatch, + }) { + return FieldMatcherConfig( + matcherType: matcherType ?? this.matcherType, + customMatcher: customMatcher ?? this.customMatcher, + weight: weight ?? this.weight, + actionOnMatch: actionOnMatch ?? this.actionOnMatch, + ); + } + + Map toMap() { + return { + if (matcherType != null && matcherType != FieldMatcherType.Custom) + 'matcherType': matcherType?.name, + if (customMatcher != null) 'customMatcher': customMatcher?.toMap(), + if (weight != null) 'weight': weight, + if (actionOnMatch != null) 'actionOnMatch': actionOnMatch?.name, + }; + } + + String toJson() => json.encode(toMap()); + + @override + String toString() { + return 'FieldMatcherConfig(matcherType: $matcherType, customMatcher: $customMatcher, weight: $weight, actionOnMatch: $actionOnMatch)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is FieldMatcherConfig && + other.matcherType == matcherType && + other.customMatcher == customMatcher && + other.weight == weight && + other.actionOnMatch == actionOnMatch; + } + + @override + int get hashCode { + return matcherType.hashCode ^ + customMatcher.hashCode ^ + weight.hashCode ^ + actionOnMatch.hashCode; + } + + FieldMatcherType? matcherType; + FieldMatcher? customMatcher; + num? weight; // 0 = client decides or ignores locator + MatchAction? actionOnMatch; +} diff --git a/lib/src/kee_vault_model/form_field_type.dart b/lib/src/kee_vault_model/form_field_type.dart new file mode 100644 index 0000000..b1e9bc1 --- /dev/null +++ b/lib/src/kee_vault_model/form_field_type.dart @@ -0,0 +1,8 @@ +class FormFieldType { + static const String USERNAME = 'FFTusername'; + static const String PASSWORD = 'FFTpassword'; + static const String TEXT = 'FFTtext'; + static const String RADIO = 'FFTradio'; + static const String CHECKBOX = 'FFTcheckbox'; + static const String SELECT = 'FFTselect'; +} diff --git a/lib/src/utils/byte_utils.dart b/lib/src/utils/byte_utils.dart index 9fa7c4a..589beff 100644 --- a/lib/src/utils/byte_utils.dart +++ b/lib/src/utils/byte_utils.dart @@ -105,7 +105,7 @@ class ReaderHelper { } class ReaderHelperDartWeb extends ReaderHelper { - ReaderHelperDartWeb(Uint8List byteData) : super._(byteData); + ReaderHelperDartWeb(super.byteData) : super._(); @override int readUint64() { @@ -184,7 +184,7 @@ class WriterHelper { } class WriterHelperDartWeb extends WriterHelper { - WriterHelperDartWeb([BytesBuilder? output]) : super._(output); + WriterHelperDartWeb([super.output]) : super._(); @override void writeUint64(int value, [LengthWriter? lengthWriter]) { diff --git a/lib/src/utils/field_type_utils.dart b/lib/src/utils/field_type_utils.dart new file mode 100644 index 0000000..637891e --- /dev/null +++ b/lib/src/utils/field_type_utils.dart @@ -0,0 +1,95 @@ +import 'package:kdbx/src/kee_vault_model/enums.dart'; +import 'package:kdbx/src/kee_vault_model/form_field_type.dart'; + +class Utilities { + static String formFieldTypeToHtmlType(String fft) { + if (fft == FormFieldType.PASSWORD) { + return 'password'; + } + if (fft == FormFieldType.SELECT) { + return 'select-one'; + } + if (fft == FormFieldType.RADIO) { + return 'radio'; + } + if (fft == FormFieldType.CHECKBOX) { + return 'checkbox'; + } + return 'text'; + } + + static FieldType formFieldTypeToFieldType(String fft) { + FieldType type = FieldType.Text; + if (fft == FormFieldType.PASSWORD) { + type = FieldType.Password; + } else if (fft == FormFieldType.SELECT) { + type = FieldType.Existing; + } else if (fft == FormFieldType.RADIO) { + type = FieldType.Existing; + } else if (fft == FormFieldType.USERNAME) { + type = FieldType.Text; + } else if (fft == FormFieldType.CHECKBOX) { + type = FieldType.Toggle; + } + return type; + } + + static String fieldTypeToDisplay(FieldType type, bool titleCase) { + String typeD = 'Text'; + if (type == FieldType.Password) { + typeD = 'Password'; + } else if (type == FieldType.Existing) { + typeD = 'Existing'; + } else if (type == FieldType.Text) { + typeD = 'Text'; + } else if (type == FieldType.Toggle) { + typeD = 'Toggle'; + } + if (!titleCase) { + return typeD.toLowerCase(); + } + return typeD; + } + + static String fieldTypeToHtmlType(FieldType ft) { + switch (ft) { + case FieldType.Password: + return 'password'; + case FieldType.Existing: + return 'radio'; + case FieldType.Toggle: + return 'checkbox'; + default: + return 'text'; + } + } + + static String fieldTypeToFormFieldType(FieldType ft) { + switch (ft) { + case FieldType.Password: + return FormFieldType.PASSWORD; + case FieldType.Existing: + return FormFieldType.RADIO; + case FieldType.Toggle: + return FormFieldType.CHECKBOX; + default: + return FormFieldType.TEXT; + } + } + + // Assumes funky Username type has already been determined so all textual stuff is type text by now + static String formFieldTypeFromHtmlTypeOrFieldType(String t, FieldType ft) { + switch (t) { + case 'password': + return FormFieldType.PASSWORD; + case 'radio': + return FormFieldType.RADIO; + case 'checkbox': + return FormFieldType.CHECKBOX; + case 'select-one': + return FormFieldType.SELECT; + default: + return Utilities.fieldTypeToFormFieldType(ft); + } + } +} diff --git a/lib/src/utils/guid_service.dart b/lib/src/utils/guid_service.dart new file mode 100644 index 0000000..a6b5b21 --- /dev/null +++ b/lib/src/utils/guid_service.dart @@ -0,0 +1,17 @@ +import 'dart:typed_data'; + +import 'package:kdbx/src/utils/byte_utils.dart'; +import 'package:uuid/uuid.dart'; + +abstract class IGuidService { + String newGuidAsBase64(); +} + +class GuidService implements IGuidService { + @override + String newGuidAsBase64() { + final buf = Uint8List(16); + const Uuid().v4buffer(buf); + return buf.encodeBase64(); + } +} diff --git a/lib/src/utils/scope_functions.dart b/lib/src/utils/scope_functions.dart index a18fa5c..37ef4bc 100644 --- a/lib/src/utils/scope_functions.dart +++ b/lib/src/utils/scope_functions.dart @@ -1,5 +1,6 @@ /// https://github.com/YusukeIwaki/dart-kotlin_flavor/blob/74593dada94bdd8ca78946ad005d3a2624dc833f/lib/scope_functions.dart /// MIT license: https://github.com/YusukeIwaki/dart-kotlin_flavor/blob/74593dada94bdd8ca78946ad005d3a2624dc833f/LICENSE +library; ReturnType run(ReturnType Function() operation) { return operation(); diff --git a/pubspec.yaml b/pubspec.yaml index 3a65dda..659eab2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: kdbx description: KeepassX format implementation in pure dart. (kdbx 3.x and 4.x support). -version: 0.6.0+0 +version: 0.6.0+4 homepage: https://github.com/kee-org/kdbx.dart publish_to: none diff --git a/test/browser_entry_settings_test.dart b/test/browser_entry_settings_test.dart new file mode 100644 index 0000000..4666065 --- /dev/null +++ b/test/browser_entry_settings_test.dart @@ -0,0 +1,122 @@ +import 'package:clock/clock.dart'; +import 'package:kdbx/src/kee_vault_model/browser_entry_settings.dart'; +import 'package:kdbx/src/kee_vault_model/browser_entry_settings_v1.dart'; +import 'package:kdbx/src/kee_vault_model/enums.dart'; +import 'package:kdbx/src/utils/guid_service.dart'; +import 'package:logging/logging.dart'; +import 'package:logging_appenders/logging_appenders.dart'; +import 'package:test/test.dart'; + +import 'internal/test_utils.dart'; + +final _logger = Logger('browser_entry_settings_test'); + +class MockGuidService implements IGuidService { + @override + String newGuidAsBase64() { + return 'AAAAAAAAAAAAAAAAAAAAAA=='; + } +} + +void main() { + Logger.root.level = Level.ALL; + PrintAppender().attachToLogger(Logger.root); + final kdbxFormat = TestUtil.kdbxFormat(); + if (!kdbxFormat.argon2.isFfi) { + throw StateError('Expected ffi!'); + } + var now = DateTime.fromMillisecondsSinceEpoch(0); + + final fakeClock = Clock(() => now); + void proceedSeconds(int seconds) { + now = now.add(Duration(seconds: seconds)); + } + + setUp(() { + now = DateTime.fromMillisecondsSinceEpoch(0); + }); + + void testCase(String persistedV2, String expectedResult) { + final bes = BrowserEntrySettings.fromJson(persistedV2, + minimumMatchAccuracy: MatchAccuracy.Domain); + final configV1 = bes.convertToV1(); + final sut = configV1.toJson(); + + expect(sut, expectedResult); + } + + void testCaseToV2(String persistedV1, String expectedResult) { + final bes = BrowserEntrySettingsV1.fromJson(persistedV1, + minimumMatchAccuracy: MatchAccuracy.Domain); + final configV2 = bes.convertToV2(MockGuidService()); + final sut = configV2.toJson(); + + expect(sut, expectedResult); + } + + void testCaseRT(String persistedV2) { + final bes = BrowserEntrySettings.fromJson(persistedV2, + minimumMatchAccuracy: MatchAccuracy.Domain); + final configV1 = bes.convertToV1(); + final jsonV1 = configV1.toJson(); + final besV1 = BrowserEntrySettingsV1.fromJson(jsonV1, + minimumMatchAccuracy: MatchAccuracy.Domain); + final configV2 = besV1.convertToV2(MockGuidService()); + final sut = configV2.toJson(); + + expect(sut, persistedV2); + } + + group('BrowserEntrySettings', () { + test('config v2->v1', () async { + testCase( + '{"version":2,"altUrls":[],"authenticationMethods":["password"],"matcherConfigs":[{"matcherType":"Url"},{"matcherType":"Hide"}],"fields":[{"page":-1,"valuePath":"Password","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Password","matcherConfigs":[{"customMatcher":{"ids":["password"],"names":["password"],"types":["password"],"queries":[]}}]},{"page":-1,"valuePath":"UserName","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Text","matcherConfigs":[{"customMatcher":{"ids":["username"],"names":["username"],"types":["text"],"queries":[]}}]}]}', + '{"version":1,"priority":0,"hide":true,"hTTPRealm":"","formFieldList":[{"displayName":"KeePass password","name":"password","type":"FFTpassword","id":"password","page":-1,"placeholderHandling":"Default","value":"{PASSWORD}"},{"displayName":"KeePass username","name":"username","type":"FFTusername","id":"username","page":-1,"placeholderHandling":"Default","value":"{USERNAME}"}],"alwaysAutoFill":false,"alwaysAutoSubmit":false,"neverAutoFill":false,"neverAutoSubmit":false,"blockDomainOnlyMatch":false,"blockHostnameOnlyMatch":false,"altURLs":[],"regExURLs":[],"blockedURLs":[],"regExBlockedURLs":[]}'); + + testCase( + '{"version":2,"altUrls":[],"authenticationMethods":["password"],"matcherConfigs":[{"matcherType":"Url"}],"fields":[{"page":-1,"valuePath":"Password","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Password","matcherConfigs":[{"customMatcher":{"ids":["password"],"names":["password"],"types":["password"],"queries":[]}}]},{"page":-1,"valuePath":"UserName","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Text","matcherConfigs":[{"customMatcher":{"ids":["username"],"names":["username"],"types":["text"],"queries":[]}}]}]}', + '{"version":1,"priority":0,"hide":false,"hTTPRealm":"","formFieldList":[{"displayName":"KeePass password","name":"password","type":"FFTpassword","id":"password","page":-1,"placeholderHandling":"Default","value":"{PASSWORD}"},{"displayName":"KeePass username","name":"username","type":"FFTusername","id":"username","page":-1,"placeholderHandling":"Default","value":"{USERNAME}"}],"alwaysAutoFill":false,"alwaysAutoSubmit":false,"neverAutoFill":false,"neverAutoSubmit":false,"blockDomainOnlyMatch":false,"blockHostnameOnlyMatch":false,"altURLs":[],"regExURLs":[],"blockedURLs":[],"regExBlockedURLs":[]}'); + + testCase( + '{"version":2,"httpRealm":"re","altUrls":[],"authenticationMethods":["password"],"matcherConfigs":[{"matcherType":"Url"}],"fields":[{"page":-1,"valuePath":"Password","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Password","matcherConfigs":[{"customMatcher":{"ids":["password"],"names":["password"],"types":["password"],"queries":[]}}]},{"page":-1,"valuePath":"UserName","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Text","matcherConfigs":[{"customMatcher":{"ids":["username"],"names":["username"],"types":["text"],"queries":[]}}]}]}', + '{"version":1,"priority":0,"hide":false,"hTTPRealm":"re","formFieldList":[{"displayName":"KeePass password","name":"password","type":"FFTpassword","id":"password","page":-1,"placeholderHandling":"Default","value":"{PASSWORD}"},{"displayName":"KeePass username","name":"username","type":"FFTusername","id":"username","page":-1,"placeholderHandling":"Default","value":"{USERNAME}"}],"alwaysAutoFill":false,"alwaysAutoSubmit":false,"neverAutoFill":false,"neverAutoSubmit":false,"blockDomainOnlyMatch":false,"blockHostnameOnlyMatch":false,"altURLs":[],"regExURLs":[],"blockedURLs":[],"regExBlockedURLs":[]}'); + + testCase( + '{"version":2,"authenticationMethods":["password"],"matcherConfigs":[{"matcherType":"Url"}],"fields":[{"page":-1,"valuePath":"UserName","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Text","matcherConfigs":[{"matcherType":"UsernameDefaultHeuristic"}]},{"page":-1,"valuePath":"Password","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Password","matcherConfigs":[{"matcherType":"PasswordDefaultHeuristic"}]}]}', + '{"version":1,"priority":0,"hide":false,"hTTPRealm":"","formFieldList":[{"displayName":"KeePass username","name":"","type":"FFTusername","id":"","page":-1,"placeholderHandling":"Default","value":"{USERNAME}"},{"displayName":"KeePass password","name":"","type":"FFTpassword","id":"","page":-1,"placeholderHandling":"Default","value":"{PASSWORD}"}],"alwaysAutoFill":false,"alwaysAutoSubmit":false,"neverAutoFill":false,"neverAutoSubmit":false,"blockDomainOnlyMatch":false,"blockHostnameOnlyMatch":false,"altURLs":[],"regExURLs":[],"blockedURLs":[],"regExBlockedURLs":[]}'); + + testCase( + '{"version":2,"altUrls":["http://test.com/1","http://test.com/2"],"regExUrls":["3","4"],"blockedUrls":["5","6"],"regExBlockedUrls":["7","8"],"authenticationMethods":["password"],"matcherConfigs":[{"matcherType":"Url"}],"fields":[{"page":-1,"valuePath":"Password","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Password","matcherConfigs":[{"customMatcher":{"ids":["password"],"names":["password"],"types":["password"],"queries":[]}}]},{"page":-1,"valuePath":"UserName","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Text","matcherConfigs":[{"customMatcher":{"ids":["username"],"names":["username"],"types":["text"],"queries":[]}}]}]}', + '{"version":1,"priority":0,"hide":false,"hTTPRealm":"","formFieldList":[{"displayName":"KeePass password","name":"password","type":"FFTpassword","id":"password","page":-1,"placeholderHandling":"Default","value":"{PASSWORD}"},{"displayName":"KeePass username","name":"username","type":"FFTusername","id":"username","page":-1,"placeholderHandling":"Default","value":"{USERNAME}"}],"alwaysAutoFill":false,"alwaysAutoSubmit":false,"neverAutoFill":false,"neverAutoSubmit":false,"blockDomainOnlyMatch":false,"blockHostnameOnlyMatch":false,"altURLs":["http://test.com/1","http://test.com/2"],"regExURLs":["3","4"],"blockedURLs":["5","6"],"regExBlockedURLs":["7","8"]}'); + + testCase( + '{"version":2,"authenticationMethods":["password"],"matcherConfigs":[{"matcherType":"Url"}],"fields":[{"page":0,"valuePath":"UserName","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Text","matcherConfigs":[{"matcherType":"UsernameDefaultHeuristic"}]},{"page":-1,"valuePath":"Password","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Password","matcherConfigs":[{"matcherType":"PasswordDefaultHeuristic"}]},{"uuid":"AAAAAAAAAAAAAAAAAAAAAA==","name":"dis Name","valuePath":".","value":"KEEFOX_CHECKED_FLAG_TRUE","page":3,"type":"Toggle","placeholderHandling":"Disabled","matcherConfigs":[{"customMatcher":{"ids":["www"],"names":["rrr"],"types":["checkbox"],"queries":[]}}]},{"uuid":"AAAAAAAAAAAAAAAAAAAAAA==","name":"","valuePath":".","value":"RadioValue","page":1,"type":"Existing","matcherConfigs":[{"customMatcher":{"ids":["radid"],"names":["radname"],"types":["radio"],"queries":[]}}]}]}', + '{"version":1,"priority":0,"hide":false,"hTTPRealm":"","formFieldList":[{"displayName":"KeePass username","name":"","type":"FFTusername","id":"","page":0,"placeholderHandling":"Default","value":"{USERNAME}"},{"displayName":"KeePass password","name":"","type":"FFTpassword","id":"","page":-1,"placeholderHandling":"Default","value":"{PASSWORD}"},{"displayName":"dis Name","name":"rrr","type":"FFTcheckbox","id":"www","page":3,"placeholderHandling":"Disabled","value":"KEEFOX_CHECKED_FLAG_TRUE"},{"displayName":"AAAAAAAAAAAAAAAAAAAAAA==","name":"radname","type":"FFTradio","id":"radid","page":1,"placeholderHandling":"Default","value":"RadioValue"}],"alwaysAutoFill":false,"alwaysAutoSubmit":false,"neverAutoFill":false,"neverAutoSubmit":false,"blockDomainOnlyMatch":false,"blockHostnameOnlyMatch":false,"altURLs":[],"regExURLs":[],"blockedURLs":[],"regExBlockedURLs":[]}'); + }); + + test('config v1->v2', () async { + testCaseToV2( + '{"version":1,"hTTPRealm":"","formFieldList":[{"name":"password","displayName":"KeePass password","value":"{PASSWORD}","type":"FFTpassword","id":"password","page":-1,"placeholderHandling":"Default"},{"name":"username","displayName":"KeePass username","value":"{USERNAME}","type":"FFTradio","id":"username","page":-1,"placeholderHandling":"Default"}],"alwaysAutoFill":false,"neverAutoFill":false,"alwaysAutoSubmit":false,"neverAutoSubmit":false,"priority":0,"altURLs":[],"hide":true,"blockHostnameOnlyMatch":false,"blockDomainOnlyMatch":false}', + '{"version":2,"authenticationMethods":["password"],"matcherConfigs":[{"matcherType":"Url"},{"matcherType":"Hide"}],"fields":[{"page":1,"valuePath":"Password","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Password","matcherConfigs":[{"customMatcher":{"ids":["password"],"names":["password"],"types":["password"]}}]},{"page":1,"valuePath":"UserName","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Text","matcherConfigs":[{"customMatcher":{"ids":["username"],"names":["username"],"types":["text"]}}]}]}'); + + testCaseToV2( + '{"version":1,"hTTPRealm":"","formFieldList":[{"name":"password","displayName":"KeePass password","value":"{PASSWORD}","type":"FFTpassword","id":"password","page":-1,"placeholderHandling":"Default"},{"name":"username","displayName":"KeePass username","value":"{USERNAME}","type":"FFTradio","id":"username","page":-1,"placeholderHandling":"Default"}],"alwaysAutoFill":false,"neverAutoFill":false,"alwaysAutoSubmit":false,"neverAutoSubmit":false,"priority":0,"altURLs":[],"hide":false,"blockHostnameOnlyMatch":false,"blockDomainOnlyMatch":false}', + '{"version":2,"authenticationMethods":["password"],"matcherConfigs":[{"matcherType":"Url"}],"fields":[{"page":1,"valuePath":"Password","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Password","matcherConfigs":[{"customMatcher":{"ids":["password"],"names":["password"],"types":["password"]}}]},{"page":1,"valuePath":"UserName","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Text","matcherConfigs":[{"customMatcher":{"ids":["username"],"names":["username"],"types":["text"]}}]}]}'); + + testCaseToV2( + '{"version":1,"hTTPRealm":"re","formFieldList":[{"name":"password","displayName":"KeePass password","value":"{PASSWORD}","type":"FFTpassword","id":"password","page":-1,"placeholderHandling":"Default"},{"name":"username","displayName":"KeePass username","value":"{USERNAME}","type":"FFTusername","id":"username","page":-1,"placeholderHandling":"Default"}],"alwaysAutoFill":false,"neverAutoFill":false,"alwaysAutoSubmit":false,"neverAutoSubmit":false,"priority":0,"altURLs":[],"hide":false,"blockHostnameOnlyMatch":false,"blockDomainOnlyMatch":false}', + '{"version":2,"authenticationMethods":["password"],"httpRealm":"re","matcherConfigs":[{"matcherType":"Url"}],"fields":[{"page":1,"valuePath":"Password","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Password","matcherConfigs":[{"customMatcher":{"ids":["password"],"names":["password"],"types":["password"]}}]},{"page":1,"valuePath":"UserName","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Text","matcherConfigs":[{"customMatcher":{"ids":["username"],"names":["username"],"types":["text"]}}]}]}'); + + testCaseToV2( + '{"version":1,"hTTPRealm":"","formFieldList":[{"name":"password","displayName":"KeePass password","value":"{PASSWORD}","type":"FFTpassword","id":"password","page":-1,"placeholderHandling":"Default"},{"name":"username","displayName":"KeePass username","value":"{USERNAME}","type":"FFTusername","id":"username","page":-1,"placeholderHandling":"Default"}],"alwaysAutoFill":false,"neverAutoFill":false,"alwaysAutoSubmit":false,"neverAutoSubmit":false,"priority":0,"altURLs":["http://test.com/1","http://test.com/2"],"regExURLs":["3","4"],"blockedURLs":["5","6"],"regExBlockedURLs":["7","8"],"hide":false,"blockHostnameOnlyMatch":false,"blockDomainOnlyMatch":false}', + '{"version":2,"authenticationMethods":["password"],"matcherConfigs":[{"matcherType":"Url"}],"fields":[{"page":1,"valuePath":"Password","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Password","matcherConfigs":[{"customMatcher":{"ids":["password"],"names":["password"],"types":["password"]}}]},{"page":1,"valuePath":"UserName","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Text","matcherConfigs":[{"customMatcher":{"ids":["username"],"names":["username"],"types":["text"]}}]}],"altUrls":["http://test.com/1","http://test.com/2"],"regExUrls":["3","4"],"blockedUrls":["5","6"],"regExBlockedUrls":["7","8"]}'); + + testCaseToV2('', + '{"version":2,"authenticationMethods":["password"],"matcherConfigs":[{"matcherType":"Url"}],"fields":[{"page":1,"valuePath":"UserName","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Text","matcherConfigs":[{"matcherType":"UsernameDefaultHeuristic"}]},{"page":1,"valuePath":"Password","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Password","matcherConfigs":[{"matcherType":"PasswordDefaultHeuristic"}]}]}'); + + testCaseToV2( + '{"version":1,"hTTPRealm":"","formFieldList":[{"name":"password","displayName":"KeePass password","value":"{PASSWORD}","type":"FFTpassword","id":"password","page":-1,"placeholderHandling":"Default"},{"name":"username","displayName":"KeePass username","value":"{USERNAME}","type":"FFTusername","id":"username","page":-1,"placeholderHandling":"Default"},{"displayName":"dis Name","name":"rrr","type":"FFTcheckbox","id":"www","page":3,"placeholderHandling":"Disabled","value":"KEEFOX_CHECKED_FLAG_TRUE"},{"displayName":"","name":"radname","type":"FFTradio","id":"radid","page":1,"placeholderHandling":"Default","value":"RadioValue"}],"alwaysAutoFill":false,"neverAutoFill":false,"alwaysAutoSubmit":false,"neverAutoSubmit":false,"priority":0,"altURLs":[],"hide":false,"blockHostnameOnlyMatch":false,"blockDomainOnlyMatch":false}', + '{"version":2,"authenticationMethods":["password"],"matcherConfigs":[{"matcherType":"Url"}],"fields":[{"page":1,"valuePath":"Password","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Password","matcherConfigs":[{"customMatcher":{"ids":["password"],"names":["password"],"types":["password"]}}]},{"page":1,"valuePath":"UserName","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Text","matcherConfigs":[{"customMatcher":{"ids":["username"],"names":["username"],"types":["text"]}}]},{"page":3,"valuePath":".","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Toggle","matcherConfigs":[{"customMatcher":{"ids":["www"],"names":["rrr"],"types":["checkbox"]}}],"name":"dis Name","value":"KEEFOX_CHECKED_FLAG_TRUE","placeholderHandling":"Disabled"},{"page":1,"valuePath":".","uuid":"AAAAAAAAAAAAAAAAAAAAAA==","type":"Existing","matcherConfigs":[{"customMatcher":{"ids":["radid"],"names":["radname"],"types":["radio"]}}],"name":"AAAAAAAAAAAAAAAAAAAAAA==","value":"RadioValue"}]}'); + }); + }); +} diff --git a/test/internal/test_utils.dart b/test/internal/test_utils.dart index c993dba..1291788 100644 --- a/test/internal/test_utils.dart +++ b/test/internal/test_utils.dart @@ -105,12 +105,18 @@ class TestUtil { Function proceedSeconds) async { final file = TestUtil.createEmptyFile(); final entry = createEntry(file, file.body.rootGroup, 'test1', 'test1'); - entry.browserSettings.fields.add(BrowserFieldModel( - displayName: 'test name', - fieldId: 'id', - name: 'form field name', - value: 'value1')); - // Would be nice to find a way to not have to do this to persist into a custom string entry + entry.browserSettings.fields = [ + Field( + name: 'test name', + valuePath: '.', + matcherConfigs: [ + FieldMatcherConfig.forSingleClientMatchHtmlType( + id: 'id', name: 'form field name') + ], + value: 'value1', + ) + ]; + // Would be nice to find a way to not have to do this to persist into a custom data and string entry entry.browserSettings = entry.browserSettings; await TestUtil.saveAndRead(file); proceedSeconds(1); diff --git a/test/kdbx_history_test.dart b/test/kdbx_history_test.dart index 79bedba..36f46e0 100644 --- a/test/kdbx_history_test.dart +++ b/test/kdbx_history_test.dart @@ -203,7 +203,7 @@ void main() { await TestUtil.createFileWithJsonFieldHistory(proceedSeconds); proceedSeconds(10); final entry = file.body.rootGroup.entries.values.toList()[0]; - expect(entry.browserSettings.fields.length, 0); + expect(entry.browserSettings.fields?.length ?? 0, 0); entry.revertToHistoryEntry(entry.history.length - 1); final history = entry.history; proceedSeconds(10); @@ -215,7 +215,7 @@ void main() { expect( history_2.getString(KdbxKeyCommon.USER_NAME)!.getText(), 'test1'); expect(entry.getString(KdbxKeyCommon.USER_NAME)!.getText(), 'test1'); - expect(entry.browserSettings.fields.length, 1); + expect(entry.browserSettings.fields?.length ?? 0, 1); }), ); }, tags: ['kdbx3']); diff --git a/test/kdbx_upgrade_test.dart b/test/kdbx_upgrade_test.dart index e90b790..a6ecc9c 100644 --- a/test/kdbx_upgrade_test.dart +++ b/test/kdbx_upgrade_test.dart @@ -1,4 +1,3 @@ - import 'package:kdbx/kdbx.dart'; import 'package:kdbx/src/internal/extension_utils.dart'; import 'package:kdbx/src/kdbx_xml.dart'; @@ -37,6 +36,7 @@ void main() { file.upgrade(KdbxVersion.V4.major, 1); final v4 = await TestUtil.saveAndRead(file); expect(v4.header.version, KdbxVersion.V4_1); + checkDateValues(v4); await TestUtil.saveTestOutput('kdbx4upgrade4-41', v4); }, tags: 'kdbx4'); @@ -53,30 +53,49 @@ void main() { file.upgrade(KdbxVersion.V4.major, 1); final v4 = await TestUtil.saveAndRead(await TestUtil.saveAndRead(file)); expect(v4.header.version, KdbxVersion.V4_1); + checkDateValues(v4); + }, tags: 'kdbx4'); - final metaValues = [ - v4.body.meta.node.singleElement('DatabaseNameChanged')?.text, - v4.body.meta.node.singleElement('DatabaseDescriptionChanged')?.text, - v4.body.meta.node.singleElement('DefaultUserNameChanged')?.text, - v4.body.meta.node.singleElement('MasterKeyChanged')?.text, - v4.body.meta.node.singleElement('RecycleBinChanged')?.text, - v4.body.meta.node.singleElement('EntryTemplatesGroupChanged')?.text, - v4.body.meta.node.singleElement('SettingsChanged')?.text, - ]; - metaValues.forEach(checkIsBase64Date); + test('Forced upgrade from v4.1 with bad dates transforms date format', + () async { + final file = await TestUtil.readKdbxFile( + 'test/test_files/v4_1-invalid-dates.kdbx', + password: 'asdf'); + expect(file.header.version, KdbxVersion.V4_1); + file.upgrade(KdbxVersion.V4.major, 1); + final v4 = await TestUtil.saveAndRead(await TestUtil.saveAndRead(file)); + expect(v4.header.version, KdbxVersion.V4_1); + checkDateValues(v4); + }, tags: 'kdbx4'); - v4.body.rootGroup - .getAllEntries() - .values - .forEach(checkObjectHasBase64Dates); - v4.body.rootGroup - .getAllGroups() - .values - .forEach(checkObjectHasBase64Dates); + test('Upgrade from v4.0 with good dates', () async { + final file = await TestUtil.readKdbxFile('test/test_files/v4.0.kdbx', + password: 'FooBar'); + expect(file.header.version, KdbxVersion.V4); + file.upgrade(KdbxVersion.V4.major, 1); + final v4 = await TestUtil.saveAndRead(await TestUtil.saveAndRead(file)); + expect(v4.header.version, KdbxVersion.V4_1); + checkDateValues(v4); }, tags: 'kdbx4'); }, tags: ['kdbx4']); } +void checkDateValues(KdbxFile v4) { + final metaValues = [ + v4.body.meta.node.singleElement('DatabaseNameChanged')?.text, + v4.body.meta.node.singleElement('DatabaseDescriptionChanged')?.text, + v4.body.meta.node.singleElement('DefaultUserNameChanged')?.text, + v4.body.meta.node.singleElement('MasterKeyChanged')?.text, + v4.body.meta.node.singleElement('RecycleBinChanged')?.text, + v4.body.meta.node.singleElement('EntryTemplatesGroupChanged')?.text, + v4.body.meta.node.singleElement('SettingsChanged')?.text, + ]; + metaValues.forEach(checkIsBase64Date); + + v4.body.rootGroup.getAllEntries().values.forEach(checkObjectHasBase64Dates); + v4.body.rootGroup.getAllGroups().values.forEach(checkObjectHasBase64Dates); +} + // Sometimes the nodes can contain an XmlNodeList with a single element, rather than directly containing an XmlText node. Bug in XML lib? // Have to work around by using deprecated text property which works no matter which approach the library decides to take this time. void checkObjectHasBase64Dates(KdbxObject? obj) { diff --git a/test/test_files/v4.0.kdbx b/test/test_files/v4.0.kdbx new file mode 100644 index 0000000..9c0724b Binary files /dev/null and b/test/test_files/v4.0.kdbx differ diff --git a/test/test_files/v4_1-invalid-dates.kdbx b/test/test_files/v4_1-invalid-dates.kdbx new file mode 100644 index 0000000..fa5d919 Binary files /dev/null and b/test/test_files/v4_1-invalid-dates.kdbx differ