Compare commits

..

31 Commits

Author SHA1 Message Date
Сергей Марков 8a95283931 add aurora platform 12 months ago
Марков Сергей Викторович 676c162770 new platform (aurora) is add 12 months ago
Herbert Poul 2968ec486b migrate to latest dart version. 1 year ago
Herbert Poul e0774d502c fix coverage script. 3 years ago
Herbert Poul 48da32961f upgrade depencencies. release 2.3.0 3 years ago
Herbert Poul caa1be954c update analysis to use flutter_lints instead of pedantic package. 3 years ago
Herbert Poul 50718adc32 make bytes in InnerHeaderField non-nullable. 3 years ago
Herbert Poul 5cf6b8db93 fix nullability for KdbxBinary 3 years ago
Herbert Poul 36007bbe4a add support for CustomData in entries. 3 years ago
Herbert Poul 96793a5206 Imlement generating of keyfiles (keyx format) 3 years ago
Herbert Poul e2fd1686c4 move credentials classes into their own file. 3 years ago
Herbert Poul cb11abef41 make credentials changable. 3 years ago
Herbert Poul 5694c6c468 allow save methods to return a object 3 years ago
Herbert Poul 157a85acbc - Mark objects only as clean when saving was successful. 3 years ago
Herbert Poul 90bc4d3138 If argon2 ffi implementation is not available, fallback to pointycastle (dart-only) implementation. 3 years ago
Herbert Poul e7e342abb1 move exceptions into their own package. 3 years ago
Herbert Poul 3570757fae Throw KdbxInvalidFileStructure for invalid files 3 years ago
Herbert Poul bc46c500ac fix missing curly brace. 3 years ago
Herbert Poul 65bf16bfcd improve merge debug summary 3 years ago
Herbert Poul 2edd3b57ba export MergeContext 3 years ago
Herbert Poul 011c40d31b better debugging output for merging. 3 years ago
Herbert Poul 70c80ee527 add easy way to check whether a given object is located in the recycle bin. 3 years ago
Herbert Poul 09fd6878b1 kdbx4.1: keep track of PreviousParentGroup 3 years ago
Herbert Poul 36563e84c7 fix merging of incoming deleted objects. 3 years ago
Herbert Poul f5030aba1a pubspec cleanup. 3 years ago
Herbert Poul 59078d6ec8 implement permanently deleting entries and groups. 3 years ago
Herbert Poul 6511ce973e improve nnbd, add debugging to aes decryption 3 years ago
Herbert Poul 1353eea3d1 trigger event after all modifications, not just after the first one. 3 years ago
Herbert Poul 248ca9b1db Upgrade dependencies. 3 years ago
Herbert Poul d3c024c7f5 make KdbxCustomIcon properties non-nullable. 3 years ago
Herbert Poul 6c477ec240 never return null for kdfType 3 years ago
  1. 4
      .github/workflows/dart.yml
  2. 31
      CHANGELOG.md
  3. 6
      _tool/test-coverage.sh
  4. 194
      analysis_options.yaml
  5. 192
      example/pubspec.lock
  6. 2
      example/pubspec.yaml
  7. 23
      lib/kdbx.dart
  8. 43
      lib/src/credentials/credentials.dart
  9. 156
      lib/src/credentials/keyfile.dart
  10. 15
      lib/src/crypto/key_encrypter_kdf.dart
  11. 4
      lib/src/crypto/protected_value.dart
  12. 2
      lib/src/internal/crypto_utils.dart
  13. 37
      lib/src/internal/pointycastle_argon2.dart
  14. 28
      lib/src/kdbx_binary.dart
  15. 16
      lib/src/kdbx_custom_data.dart
  16. 23
      lib/src/kdbx_dao.dart
  17. 9
      lib/src/kdbx_deleted_object.dart
  18. 22
      lib/src/kdbx_entry.dart
  19. 36
      lib/src/kdbx_exceptions.dart
  20. 40
      lib/src/kdbx_file.dart
  21. 231
      lib/src/kdbx_format.dart
  22. 9
      lib/src/kdbx_group.dart
  23. 62
      lib/src/kdbx_header.dart
  24. 20
      lib/src/kdbx_meta.dart
  25. 92
      lib/src/kdbx_object.dart
  26. 14
      lib/src/kdbx_var_dictionary.dart
  27. 4
      lib/src/kdbx_xml.dart
  28. 12
      lib/src/utils/byte_utils.dart
  29. 7
      lib/src/utils/print_utils.dart
  30. 28
      lib/src/utils/sequence.dart
  31. 23
      pubspec.yaml
  32. 61
      test/deleted_objects_test.dart
  33. 4
      test/icon/kdbx_customicon_test.dart
  34. 2
      test/internal/byte_utils_test.dart
  35. 39
      test/internal/test_utils.dart
  36. 20
      test/kdbx4_test.dart
  37. 59
      test/kdbx4_test_pointycastle.dart
  38. 48
      test/kdbx_binaries_test.dart
  39. 57
      test/kdbx_dirty_save_test.dart
  40. 8
      test/kdbx_history_test.dart
  41. 16
      test/kdbx_test.dart
  42. 10
      test/kdbx_upgrade_test.dart
  43. 45
      test/keyfile/keyfile_create_test.dart
  44. 58
      test/merge/kdbx_merge_test.dart

4
.github/workflows/dart.yml

@ -22,9 +22,9 @@ jobs:
codesign --remove-signature $(which dart)
if: startsWith(matrix.os, 'macos')
- name: Install dependencies
run: pub get
run: dart pub get
- name: Run tests
run: pub run test
run: dart run test
coverage:
runs-on: ubuntu-latest
steps:

31
CHANGELOG.md

@ -1,3 +1,34 @@
## 2.4.0
- Migrate to latest dart version.
## 2.3.0
- Mark objects only as clean when saving was successful.
- Only mark objects as clean if they have not been modified since we started saving.
- Make credentials changeable.
- Add support for CustomData in entries.
- Upgrade dependencies.
## 2.2.0
- If argon2 ffi implementation is not available, fallback to pointycastle (dart-only)
implementation.
## 2.1.1
- Throw KdbxInvalidFileStructure for invalid files.
## 2.1.0
- Implement permanently removing entries and groups.
- Fix merging of files with incoming deleted objects.
## 2.0.0+1
- Small Null-safety improvement.
- add debugging to AES decryption.
## 2.0.0
- Null-safety migration

6
_tool/test-coverage.sh

@ -14,8 +14,8 @@ set -xeu
cd "${0%/*}"/..
pub get
pub global activate coverage
dart pub get
dart pub global activate coverage
fail=false
dart test --coverage coverage || fail=true
@ -25,7 +25,7 @@ echo "fail=$fail"
# shellcheck disable=SC2046
jq -s '{coverage: [.[].coverage] | flatten}' $(find coverage -name '*.json' | xargs) > coverage/merged_json.cov
pub global run coverage:format_coverage --packages=.packages -i coverage/merged_json.cov -l --report-on lib --report-on test > coverage/lcov.info
dart pub global run coverage:format_coverage -i coverage/merged_json.cov -l --report-on lib --report-on test > coverage/lcov.info
bash <(curl -s https://codecov.io/bash) -f coverage/lcov.info

194
analysis_options.yaml

@ -1,12 +1,9 @@
# Defines a default set of lint rules enforced for
# projects at Google. For details and rationale,
# see https://github.com/dart-lang/pedantic#enabled-lints.
include: package:pedantic/analysis_options.yaml
# I kind of prefer the flutter lints, so use it for now,
# instead of manually copying it over 🤷
include: package:flutter_lints/flutter.yaml
analyzer:
strong-mode:
implicit-casts: false
implicit-dynamic: false
errors:
# treat missing required parameters as a warning (not a hint)
missing_required_param: warning
@ -14,159 +11,42 @@ analyzer:
missing_return: warning
# allow having TODOs in the code
todo: ignore
language:
strict-casts: true
strict-raw-types: true
linter:
rules:
# these rules are documented on and in the same order as
# the Dart Lint rules page to make maintenance easier
# http://dart-lang.github.io/linter/lints/
avoid_print: false
# TODO: rename constants.
constant_identifier_names: false
# HP mostly in sync with https://github.com/flutter/flutter/blob/master/analysis_options.yaml
always_declare_return_types: true
prefer_single_quotes: true
unawaited_futures: true
unsafe_html: true
- always_declare_return_types
- always_put_control_body_on_new_line
# - always_put_required_named_parameters_first # we prefer having parameters in the same order as fields https://github.com/flutter/flutter/issues/10219
- always_require_non_null_named_parameters
#- always_specify_types
- annotate_overrides
# - avoid_annotating_with_dynamic # not yet tested
# - avoid_as
- avoid_bool_literals_in_conditional_expressions
# - avoid_catches_without_on_clauses # not yet tested
# - avoid_catching_errors # not yet tested
# - avoid_classes_with_only_static_members # not yet tested
# - avoid_double_and_int_checks # only useful when targeting JS runtime
- avoid_empty_else
- avoid_field_initializers_in_const_classes
- avoid_function_literals_in_foreach_calls
# - avoid_implementing_value_types # not yet tested
- avoid_init_to_null
# - avoid_js_rounded_ints # only useful when targeting JS runtime
- avoid_null_checks_in_equality_operators
# - avoid_positional_boolean_parameters # not yet tested
# - avoid_private_typedef_functions # we prefer having typedef (discussion in https://github.com/flutter/flutter/pull/16356)
- avoid_relative_lib_imports
- avoid_renaming_method_parameters
- avoid_return_types_on_setters
# - avoid_returning_null # not yet tested
# - avoid_returning_null_for_future # not yet tested
- avoid_returning_null_for_void
# - avoid_returning_this # not yet tested
# - avoid_setters_without_getters # not yet tested
# - avoid_shadowing_type_parameters # not yet tested
# - avoid_single_cascade_in_expression_statements # not yet tested
- avoid_slow_async_io
- avoid_types_as_parameter_names
# - avoid_types_on_closure_parameters # not yet tested
- avoid_unused_constructor_parameters
- avoid_void_async
- await_only_futures
- camel_case_types
- cancel_subscriptions
# - cascade_invocations # not yet tested
# - close_sinks # not reliable enough
# - comment_references # blocked on https://github.com/flutter/flutter/issues/20765
# - constant_identifier_names # https://github.com/dart-lang/linter/issues/204
- control_flow_in_finally
- curly_braces_in_flow_control_structures
# - diagnostic_describe_all_properties # not yet tested
- directives_ordering
- empty_catches
- empty_constructor_bodies
- empty_statements
# - file_names # not yet tested
# - flutter_style_todos TODO(HP)
- hash_and_equals
- implementation_imports
# - invariant_booleans # too many false positives: https://github.com/dart-lang/linter/issues/811
- iterable_contains_unrelated_type
# - join_return_with_assignment # not yet tested
- library_names
- library_prefixes
# - lines_longer_than_80_chars # not yet tested
- list_remove_unrelated_type
# - literal_only_boolean_expressions # too many false positives: https://github.com/dart-lang/sdk/issues/34181
- no_adjacent_strings_in_list
- no_duplicate_case_values
- non_constant_identifier_names
# - null_closures # not yet tested
# - omit_local_variable_types # opposite of always_specify_types
# - one_member_abstracts # too many false positives
# - only_throw_errors # https://github.com/flutter/flutter/issues/5792
- overridden_fields
- package_api_docs
- package_names
- package_prefixed_library_names
# - parameter_assignments # we do this commonly
- prefer_adjacent_string_concatenation
- prefer_asserts_in_initializer_lists
# - prefer_asserts_with_message # not yet tested
- prefer_collection_literals
- prefer_conditional_assignment
- prefer_const_constructors
- prefer_const_constructors_in_immutables
- prefer_const_declarations
- prefer_const_literals_to_create_immutables
# - prefer_constructors_over_static_methods # not yet tested
- prefer_contains
# - prefer_double_quotes # opposite of prefer_single_quotes
- prefer_equal_for_default_values
# - prefer_expression_function_bodies # conflicts with https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#consider-using--for-short-functions-and-methods
- prefer_final_fields
# - prefer_final_in_for_each # not yet tested
- prefer_final_locals
# - prefer_for_elements_to_map_fromIterable # not yet tested
- prefer_foreach
# - prefer_function_declarations_over_variables # not yet tested
- prefer_generic_function_type_aliases
# - prefer_if_elements_to_conditional_expressions # not yet tested
- prefer_if_null_operators
- prefer_initializing_formals
- prefer_inlined_adds
# - prefer_int_literals # not yet tested
# - prefer_interpolation_to_compose_strings # not yet tested
- prefer_is_empty
- prefer_is_not_empty
- prefer_iterable_whereType
# - prefer_mixin # https://github.com/dart-lang/language/issues/32
# - prefer_null_aware_operators # disable until NNBD, see https://github.com/flutter/flutter/pull/32711#issuecomment-492930932
- prefer_single_quotes
- prefer_spread_collections
- prefer_typing_uninitialized_variables
- prefer_void_to_null
# - provide_deprecation_message # not yet tested
# - public_member_api_docs # enabled on a case-by-case basis; see e.g. packages/analysis_options.yaml
- recursive_getters
- slash_for_doc_comments
# - sort_child_properties_last # not yet tested
- sort_constructors_first
#- sort_pub_dependencies
- sort_unnamed_constructors_first
- test_types_in_equals
- throw_in_finally
# - type_annotate_public_apis # subset of always_specify_types
- type_init_formals
# - unawaited_futures # https://github.com/flutter/flutter/issues/5793
# - unnecessary_await_in_return # not yet tested
- unnecessary_brace_in_string_interps
- unnecessary_const
- unnecessary_getters_setters
# - unnecessary_lambdas # https://github.com/dart-lang/linter/issues/498
- unnecessary_new
- unnecessary_null_aware_assignments
- unnecessary_null_in_if_null_operators
- unnecessary_overrides
#- unnecessary_parenthesis HP: I like parenthesis :-)
- unnecessary_statements
- unnecessary_this
- unrelated_type_equality_checks
# - unsafe_html # not yet tested
- use_full_hex_values_for_flutter_colors
# - use_function_type_syntax_for_parameters # not yet tested
- use_rethrow_when_possible
# - use_setters_to_change_properties # not yet tested
# - use_string_buffers # https://github.com/dart-lang/linter/pull/664
# - use_to_and_as_if_applicable # has false positives, so we prefer to catch this by code-review
- valid_regexps
# - void_checks # not yet tested
always_put_control_body_on_new_line: true
avoid_bool_literals_in_conditional_expressions: true
avoid_field_initializers_in_const_classes: true
avoid_function_literals_in_foreach_calls: true
avoid_slow_async_io: true
avoid_unused_constructor_parameters: true
avoid_void_async: true
cancel_subscriptions: true
directives_ordering: true
no_adjacent_strings_in_list: true
package_api_docs: true
prefer_asserts_in_initializer_lists: true
prefer_final_in_for_each: true
prefer_final_locals: true
prefer_foreach: true
sort_constructors_first: true
sort_unnamed_constructors_first: true
test_types_in_equals: true
throw_in_finally: true
unnecessary_null_aware_assignments: true
unnecessary_statements: true
unrelated_type_equality_checks: true

192
example/pubspec.lock

@ -5,225 +5,313 @@ packages:
dependency: transitive
description:
name: archive
url: "https://pub.dartlang.org"
sha256: a92e39b291073bb840a72cf43d96d2a63c74e9a485d227833e8ea0054d16ad16
url: "https://pub.dev"
source: hosted
version: "3.1.2"
argon2_ffi_base:
dependency: transitive
description:
name: argon2_ffi_base
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0+2"
path: "../../argon2_ffi_base"
relative: true
source: path
version: "1.1.1"
args:
dependency: transitive
description:
name: args
url: "https://pub.dartlang.org"
sha256: "3d82ff8620ec576fd38f6cec0df45a7c088b8704eb1c63d4c336392e5efca6ca"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
characters:
dependency: transitive
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
version: "1.3.0"
charcode:
dependency: transitive
description:
name: charcode
url: "https://pub.dartlang.org"
sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "1.3.1"
clock:
dependency: transitive
description:
name: clock
url: "https://pub.dartlang.org"
sha256: "6021e0172ab6e6eaa1d391afed0a99353921f00c54385c574dc53e55d67c092c"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
collection:
dependency: transitive
description:
name: collection
url: "https://pub.dartlang.org"
sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687
url: "https://pub.dev"
source: hosted
version: "1.15.0"
version: "1.17.2"
convert:
dependency: transitive
description:
name: convert
url: "https://pub.dartlang.org"
sha256: f08428ad63615f96a27e34221c65e1a451439b5f26030f78d790f461c686d65d
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "3.0.1"
crypto:
dependency: transitive
description:
name: crypto
url: "https://pub.dartlang.org"
sha256: cf75650c66c0316274e21d7c43d3dea246273af5955bd94e8184837cd577575c
url: "https://pub.dev"
source: hosted
version: "3.0.1"
dio:
dependency: transitive
description:
name: dio
url: "https://pub.dartlang.org"
sha256: bf173c8bc66b776e3c2892b6ac56ac1a5ad73d21dd06d337f9fe656f63612947
url: "https://pub.dev"
source: hosted
version: "4.0.0"
ffi:
dependency: transitive
description:
name: ffi
url: "https://pub.dartlang.org"
sha256: "35d0f481d939de0d640b3db9a7aa36a52cd22054a798a73b4f50bdad5ce12678"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.1.2"
flutter:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: b543301ad291598523947dc534aaddc5aaad597b709d2426d3a0e0d44c5cb493
url: "https://pub.dev"
source: hosted
version: "1.0.4"
http_parser:
dependency: transitive
description:
name: http_parser
url: "https://pub.dartlang.org"
sha256: e362d639ba3bc07d5a71faebb98cde68c05bfbcfbbb444b60b6f60bb67719185
url: "https://pub.dev"
source: hosted
version: "4.0.0"
intl:
dependency: transitive
description:
name: intl
url: "https://pub.dartlang.org"
sha256: "910f85bce16fb5c6f614e117efa303e85a1731bb0081edf3604a2ae6e9a3cc91"
url: "https://pub.dev"
source: hosted
version: "0.17.0"
isolates:
dependency: transitive
description:
name: isolates
url: "https://pub.dartlang.org"
sha256: ce89e4141b27b877326d3715be2dceac7a7ba89f3229785816d2d318a75ddf28
url: "https://pub.dev"
source: hosted
version: "3.0.3+8"
js:
dependency: transitive
description:
name: js
sha256: d9bdfd70d828eeb352390f81b18d6a354ef2044aa28ef25682079797fa7cd174
url: "https://pub.dev"
source: hosted
version: "0.6.3"
kdbx:
dependency: "direct main"
description:
path: ".."
relative: true
source: path
version: "2.0.0"
version: "2.4.0"
lints:
dependency: transitive
description:
name: lints
sha256: a2c3d198cb5ea2e179926622d433331d8b58374ab8f29cdda6e863bd62fd369c
url: "https://pub.dev"
source: hosted
version: "1.0.1"
logging:
dependency: transitive
description:
name: logging
url: "https://pub.dartlang.org"
sha256: "0520a4826042a8a5d09ddd4755623a50d37ee536d79a70452aff8c8ad7bb6c27"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
logging_appenders:
dependency: transitive
description:
name: logging_appenders
url: "https://pub.dartlang.org"
sha256: "013e8548b79e3b8dc0333f3efae706184356b5926c6bea59150efa126c91598c"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
matcher:
dependency: transitive
description:
name: matcher
url: "https://pub.dartlang.org"
sha256: "2e2c34e631f93410daa3ee3410250eadc77ac6befc02a040eda8a123f34e6f5a"
url: "https://pub.dev"
source: hosted
version: "0.12.11"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41"
url: "https://pub.dev"
source: hosted
version: "0.12.10"
version: "0.5.0"
meta:
dependency: transitive
description:
name: meta
url: "https://pub.dartlang.org"
sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.9.1"
path:
dependency: transitive
description:
name: path
url: "https://pub.dartlang.org"
sha256: "2ad4cddff7f5cc0e2d13069f2a3f7a73ca18f66abd6f5ecf215219cdb3638edb"
url: "https://pub.dev"
source: hosted
version: "1.8.0"
pedantic:
dependency: "direct dev"
description:
name: pedantic
url: "https://pub.dartlang.org"
source: hosted
version: "1.11.0"
petitparser:
dependency: transitive
description:
name: petitparser
url: "https://pub.dartlang.org"
sha256: "3abc4a0f06dccb2348ebdab9f5b9cc88bb64bfc830bed6351040ca42722044a6"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
version: "4.2.0"
pointycastle:
dependency: transitive
description:
name: pointycastle
url: "https://pub.dartlang.org"
sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.7.3"
quiver:
dependency: transitive
description:
name: quiver
url: "https://pub.dartlang.org"
sha256: "5e592c348a6c528fb8deb7cc7d85a7097ce65bf2349121ad004d1fc5d5905eaa"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.99"
source_span:
dependency: transitive
description:
name: source_span
url: "https://pub.dartlang.org"
sha256: d5f89a9e52b36240a80282b3dc0667dd36e53459717bb17b8fb102d30496606a
url: "https://pub.dev"
source: hosted
version: "1.8.1"
stack_trace:
dependency: transitive
description:
name: stack_trace
url: "https://pub.dartlang.org"
sha256: f8d9f247e2f9f90e32d1495ff32dac7e4ae34ffa7194c5ff8fcc0fd0e52df774
url: "https://pub.dev"
source: hosted
version: "1.10.0"
string_scanner:
dependency: transitive
description:
name: string_scanner
url: "https://pub.dartlang.org"
sha256: dd11571b8a03f7cadcf91ec26a77e02bfbd6bbba2a512924d3116646b4198fc4
url: "https://pub.dev"
source: hosted
version: "1.1.0"
supercharged_dart:
dependency: transitive
description:
name: supercharged_dart
url: "https://pub.dartlang.org"
sha256: "9d6d4fa1736d07f0506ce2713e5f9815b20bcd741c0d53e9b56c265458c3ce05"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
synchronized:
dependency: transitive
description:
name: synchronized
url: "https://pub.dartlang.org"
sha256: "271977ff1e9e82ceefb4f08424b8839f577c1852e0726b5ce855311b46d3ef83"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
url: "https://pub.dartlang.org"
sha256: a88162591b02c1f3a3db3af8ce1ea2b374bd75a7bb8d5e353bcfbdc79d719830
url: "https://pub.dev"
source: hosted
version: "1.2.0"
typed_data:
dependency: transitive
description:
name: typed_data
url: "https://pub.dartlang.org"
sha256: "53bdf7e979cfbf3e28987552fd72f637e63f3c8724c9e56d9246942dc2fa36ee"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
uuid:
dependency: transitive
description:
name: uuid
url: "https://pub.dartlang.org"
sha256: "0ea20bfc625477e17f08a92d112272a071609b275ce4ca10ad853e1426ca3758"
url: "https://pub.dev"
source: hosted
version: "3.0.4"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
web:
dependency: transitive
description:
name: web
sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10
url: "https://pub.dev"
source: hosted
version: "0.1.4-beta"
xml:
dependency: transitive
description:
name: xml
url: "https://pub.dartlang.org"
sha256: "925e1d7923773fef2f90c5c9ad0f496630f63e03f974a0aaa5fb50c60640c570"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
version: "5.2.0"
sdks:
dart: ">=2.12.0 <3.0.0"
dart: ">=3.1.0-185.0.dev <4.0.0"

2
example/pubspec.yaml

@ -12,4 +12,4 @@ dependencies:
path: ../
dev_dependencies:
pedantic: '>=1.7.0 <2.0.0'
flutter_lints: ">=1.0.4 <2.0.0"

23
lib/kdbx.dart

@ -1,6 +1,9 @@
/// dart library for reading keepass file format (kdbx).
library kdbx;
export 'src/credentials/credentials.dart'
show Credentials, CredentialsPart, HashCredentials, PasswordCredentials;
export 'src/credentials/keyfile.dart' show KeyFileComposite, KeyFileCredentials;
export 'src/crypto/key_encrypter_kdf.dart'
show KeyEncrypterKdf, KdfType, KdfField;
export 'src/crypto/protected_value.dart'
@ -10,25 +13,11 @@ 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;
export 'src/kdbx_exceptions.dart';
export 'src/kdbx_file.dart';
export 'src/kdbx_format.dart'
show
KdbxBody,
Credentials,
CredentialsPart,
HashCredentials,
KdbxFormat,
KeyFileComposite,
KeyFileCredentials,
PasswordCredentials;
export 'src/kdbx_format.dart' show KdbxBody, MergeContext, KdbxFormat;
export 'src/kdbx_group.dart' show KdbxGroup;
export 'src/kdbx_header.dart'
show
KdbxException,
KdbxInvalidKeyException,
KdbxCorruptedFileException,
KdbxUnsupportedException,
KdbxVersion;
export 'src/kdbx_header.dart' show KdbxVersion;
export 'src/kdbx_meta.dart';
export 'src/kdbx_object.dart'
show

43
lib/src/credentials/credentials.dart

@ -0,0 +1,43 @@
import 'dart:typed_data';
import 'package:kdbx/src/credentials/keyfile.dart';
import 'package:kdbx/src/crypto/protected_value.dart';
import 'package:kdbx/src/internal/extension_utils.dart';
abstract class CredentialsPart {
Uint8List getBinary();
}
abstract class Credentials {
factory Credentials(ProtectedValue password) =>
Credentials.composite(password, null); //PasswordCredentials(password);
factory Credentials.composite(ProtectedValue? password, Uint8List? keyFile) =>
KeyFileComposite(
password: password?.let((that) => PasswordCredentials(that)),
keyFile: keyFile == null ? null : KeyFileCredentials(keyFile),
);
factory Credentials.fromHash(Uint8List hash) => HashCredentials(hash);
Uint8List getHash();
}
class PasswordCredentials implements CredentialsPart {
PasswordCredentials(this._password);
final ProtectedValue _password;
@override
Uint8List getBinary() {
return _password.hash;
}
}
class HashCredentials implements Credentials {
HashCredentials(this.hash);
final Uint8List hash;
@override
Uint8List getHash() => hash;
}

156
lib/src/credentials/keyfile.dart

@ -0,0 +1,156 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:convert/convert.dart' as convert;
import 'package:crypto/crypto.dart' as crypto;
import 'package:kdbx/src/credentials/credentials.dart';
import 'package:kdbx/src/crypto/protected_value.dart';
import 'package:kdbx/src/utils/byte_utils.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:xml/xml.dart' as xml;
final _logger = Logger('keyfile');
const _nodeVersion = 'Version';
const _nodeKey = 'Key';
const _nodeData = 'Data';
const _nodeMeta = 'Meta';
const _nodeKeyFile = 'KeyFile';
const _nodeHash = 'Hash';
class KeyFileCredentials implements CredentialsPart {
factory KeyFileCredentials(Uint8List keyFileContents) {
try {
final keyFileAsString = utf8.decode(keyFileContents);
if (_hexValuePattern.hasMatch(keyFileAsString)) {
return KeyFileCredentials._(ProtectedValue.fromBinary(
convert.hex.decode(keyFileAsString) as Uint8List));
}
final xmlContent = xml.XmlDocument.parse(keyFileAsString);
final metaVersion =
xmlContent.findAllElements(_nodeVersion).singleOrNull?.text;
final key = xmlContent.findAllElements(_nodeKey).single;
final dataString = key.findElements(_nodeData).single;
final encoded = dataString.text.replaceAll(RegExp(r'\s'), '');
Uint8List dataBytes;
if (metaVersion != null && metaVersion.startsWith('2.')) {
dataBytes = convert.hex.decode(encoded) as Uint8List;
assert((() {
final hash = dataString.getAttribute(_nodeHash);
if (hash == null) {
throw const FormatException('Keyfile must contain a hash.');
}
final expectedHashBytes = convert.hex.decode(hash);
final actualHash =
crypto.sha256.convert(dataBytes).bytes.sublist(0, 4) as Uint8List;
if (!ByteUtils.eq(expectedHashBytes, actualHash)) {
throw const FormatException(
'Corrupted keyfile. Hash does not match');
}
return true;
})());
} else {
dataBytes = base64.decode(encoded);
}
_logger.finer('Decoded base64 of keyfile.');
return KeyFileCredentials._(ProtectedValue.fromBinary(dataBytes));
} catch (e, stackTrace) {
_logger.warning(
'Unable to parse key file as hex or XML, use as is.', e, stackTrace);
final bytes = crypto.sha256.convert(keyFileContents).bytes as Uint8List;
return KeyFileCredentials._(ProtectedValue.fromBinary(bytes));
}
}
/// Creates a new random (32 bytes) keyfile value.
factory KeyFileCredentials.random() => KeyFileCredentials._(
ProtectedValue.fromBinary(ByteUtils.randomBytes(32)));
factory KeyFileCredentials.fromBytes(Uint8List bytes) =>
KeyFileCredentials._(ProtectedValue.fromBinary(bytes));
KeyFileCredentials._(this._keyFileValue);
static final RegExp _hexValuePattern =
RegExp(r'^[a-f\d]{64}', caseSensitive: false);
final ProtectedValue _keyFileValue;
@override
Uint8List getBinary() {
return _keyFileValue.binaryValue;
// return crypto.sha256.convert(_keyFileValue.binaryValue).bytes as Uint8List;
}
/// Generates a `.keyx` file as described for Keepass keyfile:
/// https://keepass.info/help/base/keys.html#keyfiles
Uint8List toXmlV2() {
return utf8.encode(toXmlV2String()) as Uint8List;
}
/// Generates a `.keyx` file as described for Keepass keyfile:
/// https://keepass.info/help/base/keys.html#keyfiles
@visibleForTesting
String toXmlV2String() {
final hash =
(crypto.sha256.convert(_keyFileValue.binaryValue).bytes as Uint8List)
.sublist(0, 4);
final hashHexString = hexFormatLikeKeepass(convert.hex.encode(hash));
final keyHexString =
hexFormatLikeKeepass(convert.hex.encode(_keyFileValue.binaryValue));
final builder = xml.XmlBuilder()
..processing('xml', 'version="1.0" encoding="utf-8"');
builder.element(_nodeKeyFile, nest: () {
builder.element(_nodeMeta, nest: () {
builder.element(_nodeVersion, nest: () {
builder.text('2.0');
});
});
builder.element(_nodeKey, nest: () {
builder.element(_nodeData, nest: () {
builder.attribute(_nodeHash, hashHexString);
builder.text(keyHexString);
});
});
});
return builder.buildDocument().toXmlString(pretty: true);
}
/// keypass has all-uppercase letters in pairs of 4 bytes (8 characters).
@visibleForTesting
static String hexFormatLikeKeepass(final String hexString) {
final hex = hexString.toUpperCase();
const groups = 8;
final remaining = hex.length % groups;
return [
for (var i = 0; i < hex.length ~/ groups; i++)
hex.substring(i * groups, i * groups + groups),
if (remaining != 0) hex.substring(hex.length - remaining)
].join(' ');
// range(0, hexString.length / 8).map((i) => hexString.substring(i*_groups, i*_groups + _groups));
// hexString.toUpperCase().chara
}
}
class KeyFileComposite implements Credentials {
KeyFileComposite({required this.password, required this.keyFile});
PasswordCredentials? password;
KeyFileCredentials? keyFile;
@override
Uint8List getHash() {
final buffer = [...?password?.getBinary(), ...?keyFile?.getBinary()];
return crypto.sha256.convert(buffer).bytes as Uint8List;
// final output = convert.AccumulatorSink<crypto.Digest>();
// final input = crypto.sha256.startChunkedConversion(output);
//// input.add(password.getHash());
// input.add(buffer);
// input.close();
// return output.events.single.bytes as Uint8List;
}
}

15
lib/src/crypto/key_encrypter_kdf.dart

@ -17,7 +17,7 @@ enum KdfType {
Aes,
}
class KdfField<T> {
class KdfField<T extends Object> {
KdfField(this.field, this.type);
final String field;
@ -76,18 +76,15 @@ class KeyEncrypterKdf {
return KdbxUuid(uuid);
}
static KdfType? kdfTypeFor(VarDictionary kdfParameters) {
static KdfType kdfTypeFor(VarDictionary kdfParameters) {
final uuid = KdfField.uuid.read(kdfParameters);
if (uuid == null) {
throw KdbxCorruptedFileException('No Kdf UUID');
}
final kdfUuid = base64.encode(uuid);
try {
return kdfUuids[kdfUuid];
} catch (e) {
throw KdbxCorruptedFileException(
'Invalid KDF UUID ${uuid.encodeBase64()}');
}
return kdfUuids[kdfUuid] ??
(() => throw KdbxCorruptedFileException(
'Invalid KDF UUID ${uuid.encodeBase64()}'))();
}
final Argon2 argon2;
@ -146,7 +143,7 @@ class KeyEncrypterKdf {
}
static Uint8List _encryptAesSync(EncryptAesArgs args) {
final cipher = ECBBlockCipher(AESFastEngine())
final cipher = ECBBlockCipher(AESEngine())
..init(true, KeyParameter(args.encryptionKey!));
var out1 = Uint8List.fromList(args.key);
var out2 = Uint8List(args.key.length);

4
lib/src/crypto/protected_value.dart

@ -46,7 +46,7 @@ class ProtectedValue implements StringValue {
return ProtectedValue(_xor(value, salt), salt);
}
static final random = Random.secure();
static final _random = Random.secure();
final Uint8List _value;
final Uint8List _salt;
@ -57,7 +57,7 @@ class ProtectedValue implements StringValue {
static Uint8List _randomBytes(int length) {
return Uint8List.fromList(
List.generate(length, (i) => random.nextInt(0xff)));
List.generate(length, (i) => _random.nextInt(0xff)));
}
static Uint8List _xor(Uint8List a, Uint8List b) {

2
lib/src/internal/crypto_utils.dart

@ -32,7 +32,7 @@ class AesHelper {
{String mode = CBC_MODE}) {
// Uint8List derivedKey = deriveKey(password);
final KeyParameter keyParam = KeyParameter(derivedKey);
final BlockCipher aes = AESFastEngine();
final BlockCipher aes = AESEngine();
// Uint8List cipherIvBytes = base64.decode(ciphertext);
final Uint8List iv = Uint8List(aes.blockSize)

37
lib/src/internal/pointycastle_argon2.dart

@ -0,0 +1,37 @@
import 'dart:typed_data';
import 'package:argon2_ffi_base/argon2_ffi_base.dart';
import 'package:pointycastle/export.dart' as pc;
/// Dart-only implementation using pointycastle's Argon KDF.
class PointyCastleArgon2 extends Argon2 {
const PointyCastleArgon2();
@override
bool get isFfi => false;
@override
bool get isImplemented => true;
pc.KeyDerivator argon2Kdf() => pc.Argon2BytesGenerator();
@override
Uint8List argon2(Argon2Arguments args) {
final kdf = argon2Kdf();
kdf.init(pc.Argon2Parameters(
args.type,
args.salt,
desiredKeyLength: args.length,
iterations: args.iterations,
memory: args.memory,
lanes: args.parallelism,
version: args.version,
));
return kdf.process(args.key);
}
@override
Future<Uint8List> argon2Async(Argon2Arguments args) {
return Future.value(argon2(args));
}
}

28
lib/src/kdbx_binary.dart

@ -9,16 +9,20 @@ import 'package:quiver/core.dart';
import 'package:xml/xml.dart';
class KdbxBinary {
KdbxBinary({this.isInline, this.isProtected, this.value});
final bool? isInline;
final bool? isProtected;
final Uint8List? value;
KdbxBinary({
required this.isInline,
required this.isProtected,
required this.value,
});
final bool isInline;
final bool isProtected;
final Uint8List value;
int? _valueHashCode;
static KdbxBinary readBinaryInnerHeader(InnerHeaderField field) {
final flags = field.bytes![0];
final flags = field.bytes[0];
final isProtected = flags & 0x01 == 0x01;
final value = Uint8List.sublistView(field.bytes!, 1);
final value = Uint8List.sublistView(field.bytes, 1);
return KdbxBinary(
isInline: false,
isProtected: isProtected,
@ -26,16 +30,16 @@ class KdbxBinary {
);
}
int get valueHashCode => _valueHashCode ??= hashObjects(value!);
int get valueHashCode => _valueHashCode ??= hashObjects(value);
bool valueEqual(KdbxBinary other) =>
valueHashCode == other.valueHashCode && ByteUtils.eq(value!, value!);
valueHashCode == other.valueHashCode && ByteUtils.eq(value, other.value);
InnerHeaderField writeToInnerHeader() {
final writer = WriterHelper();
final flags = isProtected! ? 0x01 : 0x00;
final flags = isProtected ? 0x01 : 0x00;
writer.writeUint8(flags);
writer.writeBytes(value!);
writer.writeBytes(value);
return InnerHeaderField(
InnerHeaderFields.Binary, writer.output.takeBytes());
}
@ -56,8 +60,8 @@ class KdbxBinary {
}
void saveToXml(XmlElement valueNode) {
final content = base64.encode(gzip.encode(value!));
valueNode.addAttributeBool(KdbxXml.ATTR_PROTECTED, isProtected!);
final content = base64.encode(gzip.encode(value));
valueNode.addAttributeBool(KdbxXml.ATTR_PROTECTED, isProtected);
valueNode.addAttributeBool(KdbxXml.ATTR_COMPRESSED, true);
valueNode.children.add(XmlText(content));
}

16
lib/src/kdbx_custom_data.dart

@ -17,7 +17,7 @@ class KdbxCustomData extends KdbxNode {
})),
super.read(node);
static const String TAG_NAME = 'CustomData';
static const String TAG_NAME = KdbxXml.NODE_CUSTOM_DATA;
final Map<String, String> _data;
@ -43,4 +43,18 @@ class KdbxCustomData extends KdbxNode {
);
return el;
}
void merge(KdbxCustomData other, bool otherIsNewer) {
// merge custom data
for (final otherCustomDataEntry in other.entries) {
if (otherIsNewer || !containsKey(otherCustomDataEntry.key)) {
this[otherCustomDataEntry.key] = otherCustomDataEntry.value;
}
}
}
void overwriteFrom(KdbxCustomData other) {
_data.clear();
_data.addAll(other._data);
}
}

23
lib/src/kdbx_dao.dart

@ -1,3 +1,4 @@
import 'package:clock/clock.dart';
import 'package:kdbx/src/kdbx_entry.dart';
import 'package:kdbx/src/kdbx_file.dart';
import 'package:kdbx/src/kdbx_group.dart';
@ -40,4 +41,26 @@ extension KdbxDao on KdbxFile {
toGroup.addEntry(kdbxObject);
}
}
void deletePermanently(KdbxObject kdbxObject) {
final parent = kdbxObject.parent;
if (parent == null) {
throw StateError(
'Unable to delete object. Object as no parent, already deleted?');
}
final now = clock.now().toUtc();
if (kdbxObject is KdbxGroup) {
for (final object in kdbxObject.getAllGroupsAndEntries()) {
ctx.addDeletedObject(object.uuid, now);
}
parent.internalRemoveGroup(kdbxObject);
} else if (kdbxObject is KdbxEntry) {
ctx.addDeletedObject(kdbxObject.uuid, now);
parent.internalRemoveEntry(kdbxObject);
} else {
throw StateError('Invalid object type. ${kdbxObject.runtimeType}');
}
kdbxObject.times.locationChanged.set(now);
kdbxObject.internalChangeParent(null);
}
}

9
lib/src/kdbx_deleted_object.dart

@ -1,12 +1,14 @@
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) : super.create(NODE_NAME) {
KdbxDeletedObject.create(this.ctx, KdbxUuid? uuid, [DateTime? now])
: super.create(NODE_NAME) {
_uuid.set(uuid);
deletionTime.setToNow();
deletionTime.set(now ?? clock.now().toUtc());
}
KdbxDeletedObject.read(XmlElement node, this.ctx) : super.read(node);
@ -16,7 +18,8 @@ class KdbxDeletedObject extends KdbxNode implements KdbxNodeContext {
@override
final KdbxReadWriteContext ctx;
KdbxUuid? get uuid => _uuid.get();
// all objects have to have a UUID.
KdbxUuid get uuid => _uuid.get()!;
UuidNode get _uuid => UuidNode(this, KdbxXml.NODE_UUID);
DateTimeUtcNode get deletionTime => DateTimeUtcNode(this, 'DeletionTime');
}

22
lib/src/kdbx_entry.dart

@ -5,10 +5,11 @@ import 'package:kdbx/src/crypto/protected_value.dart';
import 'package:kdbx/src/internal/extension_utils.dart';
import 'package:kdbx/src/kdbx_binary.dart';
import 'package:kdbx/src/kdbx_consts.dart';
import 'package:kdbx/src/kdbx_custom_data.dart';
import 'package:kdbx/src/kdbx_exceptions.dart';
import 'package:kdbx/src/kdbx_file.dart';
import 'package:kdbx/src/kdbx_format.dart';
import 'package:kdbx/src/kdbx_group.dart';
import 'package:kdbx/src/kdbx_header.dart';
import 'package:kdbx/src/kdbx_object.dart';
import 'package:kdbx/src/kdbx_xml.dart';
import 'package:logging/logging.dart';
@ -56,6 +57,7 @@ bool kdbxKeyCommonAssertConsistency() {
class KdbxKey {
KdbxKey(this.key) : _canonicalKey = key.toLowerCase();
const KdbxKey._(this.key, this._canonicalKey);
const KdbxKey.origin(this.key, this._canonicalKey);
final String key;
final String _canonicalKey;
@ -76,7 +78,7 @@ class KdbxKey {
extension KdbxEntryInternal on KdbxEntry {
KdbxEntry cloneInto(KdbxGroup otherGroup, {bool toHistoryEntry = false}) =>
KdbxEntry.create(
otherGroup.file!,
otherGroup.file,
otherGroup,
isHistoryEntry: toHistoryEntry,
)
@ -127,6 +129,7 @@ extension KdbxEntryInternal on KdbxEntry {
));
_binaries.clear();
_binaries.addAll(newBinaries);
customData.overwriteFrom(other.customData);
times.overwriteFrom(other.times);
if (includeHistory) {
for (final historyEntry in other.history) {
@ -157,6 +160,7 @@ class KdbxEntry extends KdbxObject {
KdbxGroup parent, {
this.isHistoryEntry = false,
}) : history = [],
customData = KdbxCustomData.create(),
super.create(file.ctx, file, 'Entry', parent) {
icon.set(KdbxIcon.Key);
}
@ -164,6 +168,10 @@ class KdbxEntry extends KdbxObject {
KdbxEntry.read(KdbxReadWriteContext ctx, KdbxGroup? parent, XmlElement node,
{this.isHistoryEntry = false})
: history = [],
customData = node
.singleElement(KdbxXml.NODE_CUSTOM_DATA)
?.let((e) => KdbxCustomData.read(e)) ??
KdbxCustomData.create(),
super.read(ctx, parent, node) {
_strings.addEntries(node.findElements(KdbxXml.NODE_STRING).map((el) {
final key = KdbxKey(el.findElements(KdbxXml.NODE_KEY).single.text);
@ -210,8 +218,10 @@ class KdbxEntry extends KdbxObject {
StringNode get overrideURL => StringNode(this, 'OverrideURL');
StringNode get tags => StringNode(this, 'Tags');
final KdbxCustomData customData;
@override
set file(KdbxFile? file) {
set file(KdbxFile file) {
super.file = file;
// TODO this looks like some weird workaround, get rid of the
// `file` reference.
@ -233,7 +243,7 @@ class KdbxEntry extends KdbxObject {
@override
XmlElement toXml() {
final el = super.toXml();
final el = super.toXml()..replaceSingle(customData.toXml());
XmlUtils.removeChildrenByName(el, KdbxXml.NODE_STRING);
XmlUtils.removeChildrenByName(el, KdbxXml.NODE_HISTORY);
XmlUtils.removeChildrenByName(el, KdbxXml.NODE_BINARY);
@ -258,7 +268,7 @@ class KdbxEntry extends KdbxObject {
final key = binaryEntry.key;
final binary = binaryEntry.value;
final value = XmlElement(XmlName(KdbxXml.NODE_VALUE));
if (binary.isInline!) {
if (binary.isInline) {
binary.saveToXml(value);
} else {
final binaryIndex = ctx.findBinaryId(binary);
@ -345,7 +355,7 @@ class KdbxEntry extends KdbxObject {
value: bytes,
);
modify(() {
file!.ctx.addBinary(binary);
file.ctx.addBinary(binary);
_binaries[key] = binary;
});
return binary;

36
lib/src/kdbx_exceptions.dart

@ -0,0 +1,36 @@
class KdbxException implements Exception {}
class KdbxInvalidKeyException implements KdbxException {}
class KdbxCorruptedFileException implements KdbxException {
KdbxCorruptedFileException([this.message]);
final String? message;
@override
String toString() {
return 'KdbxCorruptedFileException{message: $message}';
}
}
class KdbxUnsupportedException implements KdbxException {
KdbxUnsupportedException(this.hint);
final String hint;
@override
String toString() {
return 'KdbxUnsupportedException{hint: $hint}';
}
}
class KdbxInvalidFileStructure implements KdbxException {
KdbxInvalidFileStructure(this.message);
final String message;
@override
String toString() {
return 'KdbxInvalidFileStructure{$message}';
}
}

40
lib/src/kdbx_file.dart

@ -2,13 +2,16 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:collection/collection.dart';
import 'package:kdbx/src/credentials/credentials.dart';
import 'package:kdbx/src/crypto/protected_value.dart';
import 'package:kdbx/src/kdbx_consts.dart';
import 'package:kdbx/src/kdbx_dao.dart';
import 'package:kdbx/src/kdbx_exceptions.dart';
import 'package:kdbx/src/kdbx_format.dart';
import 'package:kdbx/src/kdbx_group.dart';
import 'package:kdbx/src/kdbx_header.dart';
import 'package:kdbx/src/kdbx_object.dart';
import 'package:kdbx/src/utils/sequence.dart';
import 'package:logging/logging.dart';
import 'package:quiver/check.dart';
import 'package:synchronized/synchronized.dart';
@ -16,9 +19,16 @@ import 'package:xml/xml.dart' as xml;
final _logger = Logger('kdbx_file');
typedef FileSaveCallback<T> = Future<T> Function(Uint8List bytes);
class KdbxFile {
KdbxFile(
this.ctx, this.kdbxFormat, this.credentials, this.header, this.body) {
this.ctx,
this.kdbxFormat,
this._credentials,
this.header,
this.body,
) {
for (final obj in _allObjects) {
obj.file = this;
}
@ -37,7 +47,12 @@ class KdbxFile {
final KdbxFormat kdbxFormat;
final KdbxReadWriteContext ctx;
final Credentials credentials;
Credentials get credentials => _credentials;
set credentials(Credentials credentials) {
body.meta.modify(() => _credentials = credentials);
}
Credentials _credentials;
final KdbxHeader header;
final KdbxBody body;
final Set<KdbxObject> dirtyObjects = {};
@ -53,14 +68,27 @@ class KdbxFile {
Stream<Set<KdbxObject>> get dirtyObjectsChanged =>
_dirtyObjectsChanged.stream;
// ignore: prefer_function_declarations_over_variables
static final FileSaveCallback<Uint8List> _saveToBytes = (bytes) async {
return bytes;
};
// @Deprecated('Use [saveTo] instead.')
Future<Uint8List> save() async {
return kdbxFormat.save(this);
return kdbxFormat.save(this, _saveToBytes);
}
Future<T> saveTo<T>(FileSaveCallback<T> saveBytes) {
return kdbxFormat.save(this, saveBytes);
}
/// Marks all dirty objects as clean. Called by [KdbxFormat.save].
void onSaved() {
dirtyObjects.clear();
_dirtyObjectsChanged.add(const {});
void onSaved(TimeSequence savedAt) {
final cleanedObjects = dirtyObjects.where((e) => e.clean(savedAt)).toList();
dirtyObjects.removeAll(cleanedObjects);
_logger.finer('Saved. Remaining dirty objects: ${dirtyObjects.length}');
_dirtyObjectsChanged.add(dirtyObjects);
}
Iterable<KdbxObject> get _allObjects => body.rootGroup

231
lib/src/kdbx_format.dart

@ -6,7 +6,6 @@ import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:argon2_ffi_base/argon2_ffi_base.dart';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:convert/convert.dart' as convert;
import 'package:crypto/crypto.dart' as crypto;
import 'package:kdbx/kdbx.dart';
import 'package:kdbx/src/crypto/key_encrypter_kdf.dart';
@ -14,11 +13,14 @@ import 'package:kdbx/src/crypto/protected_salt_generator.dart';
import 'package:kdbx/src/internal/consts.dart';
import 'package:kdbx/src/internal/crypto_utils.dart';
import 'package:kdbx/src/internal/extension_utils.dart';
import 'package:kdbx/src/internal/pointycastle_argon2.dart';
import 'package:kdbx/src/kdbx_deleted_object.dart';
import 'package:kdbx/src/kdbx_entry.dart';
import 'package:kdbx/src/kdbx_group.dart';
import 'package:kdbx/src/kdbx_header.dart';
import 'package:kdbx/src/kdbx_xml.dart';
import 'package:kdbx/src/utils/byte_utils.dart';
import 'package:kdbx/src/utils/sequence.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:pointycastle/export.dart';
@ -28,40 +30,6 @@ import 'package:xml/xml.dart' as xml;
final _logger = Logger('kdbx.format');
abstract class Credentials {
factory Credentials(ProtectedValue password) =>
Credentials.composite(password, null); //PasswordCredentials(password);
factory Credentials.composite(ProtectedValue? password, Uint8List? keyFile) =>
KeyFileComposite(
password: password?.let((that) => PasswordCredentials(that)),
keyFile: keyFile == null ? null : KeyFileCredentials(keyFile),
);
factory Credentials.fromHash(Uint8List hash) => HashCredentials(hash);
Uint8List getHash();
}
class KeyFileComposite implements Credentials {
KeyFileComposite({required this.password, required this.keyFile});
PasswordCredentials? password;
KeyFileCredentials? keyFile;
@override
Uint8List getHash() {
final buffer = [...?password?.getBinary(), ...?keyFile?.getBinary()];
return crypto.sha256.convert(buffer).bytes as Uint8List;
// final output = convert.AccumulatorSink<crypto.Digest>();
// final input = crypto.sha256.startChunkedConversion(output);
//// input.add(password.getHash());
// input.add(buffer);
// input.close();
// return output.events.single.bytes as Uint8List;
}
}
/// Context used during reading and writing.
class KdbxReadWriteContext {
KdbxReadWriteContext({
@ -121,11 +89,11 @@ class KdbxReadWriteContext {
/// finds the ID of the given binary.
/// if it can't be found, [KdbxCorruptedFileException] is thrown.
int findBinaryId(KdbxBinary binary) {
assert(!binary.isInline!);
assert(!binary.isInline);
final id = _binaries.indexOf(binary);
if (id < 0) {
throw KdbxCorruptedFileException('Unable to find binary.'
' (${binary.value!.length},${binary.isInline})');
' (${binary.value.length},${binary.isInline})');
}
return id;
}
@ -138,76 +106,12 @@ class KdbxReadWriteContext {
'Tried to remove binary which is not in this file.');
}
}
}
abstract class CredentialsPart {
Uint8List getBinary();
}
class KeyFileCredentials implements CredentialsPart {
factory KeyFileCredentials(Uint8List keyFileContents) {
try {
final keyFileAsString = utf8.decode(keyFileContents);
if (_hexValuePattern.hasMatch(keyFileAsString)) {
return KeyFileCredentials._(ProtectedValue.fromBinary(
convert.hex.decode(keyFileAsString) as Uint8List));
}
final xmlContent = xml.XmlDocument.parse(keyFileAsString);
final metaVersion =
xmlContent.findAllElements('Version').singleOrNull?.text;
final key = xmlContent.findAllElements('Key').single;
final dataString = key.findElements('Data').single;
final encoded = dataString.text.replaceAll(RegExp(r'\s'), '');
Uint8List dataBytes;
if (metaVersion != null && metaVersion.startsWith('2.')) {
dataBytes = convert.hex.decode(encoded) as Uint8List;
} else {
dataBytes = base64.decode(encoded);
}
_logger.finer('Decoded base64 of keyfile.');
return KeyFileCredentials._(ProtectedValue.fromBinary(dataBytes));
} catch (e, stackTrace) {
_logger.warning(
'Unable to parse key file as hex or XML, use as is.', e, stackTrace);
final bytes = crypto.sha256.convert(keyFileContents).bytes as Uint8List;
return KeyFileCredentials._(ProtectedValue.fromBinary(bytes));
}
}
KeyFileCredentials._(this._keyFileValue);
static final RegExp _hexValuePattern =
RegExp(r'^[a-f\d]{64}', caseSensitive: false);
final ProtectedValue _keyFileValue;
@override
Uint8List getBinary() {
return _keyFileValue.binaryValue;
// return crypto.sha256.convert(_keyFileValue.binaryValue).bytes as Uint8List;
}
}
class PasswordCredentials implements CredentialsPart {
PasswordCredentials(this._password);
final ProtectedValue _password;
@override
Uint8List getBinary() {
return _password.hash;
void addDeletedObject(KdbxUuid uuid, [DateTime? now]) {
_deletedObjects.add(KdbxDeletedObject.create(this, uuid));
}
}
class HashCredentials implements Credentials {
HashCredentials(this.hash);
final Uint8List hash;
@override
Uint8List getHash() => hash;
}
class KdbxBody extends KdbxNode {
KdbxBody.create(this.meta, this.rootGroup) : super.create('KeePassFile') {
node.children.add(meta.node);
@ -265,14 +169,14 @@ class KdbxBody extends KdbxNode {
KdbxFile kdbxFile, Uint8List? compressedBytes) async {
final byteWriter = WriterHelper();
byteWriter.writeBytes(
kdbxFile.header.fields[HeaderFields.StreamStartBytes]!.bytes!);
kdbxFile.header.fields[HeaderFields.StreamStartBytes]!.bytes);
HashedBlockReader.writeBlocks(ReaderHelper(compressedBytes), byteWriter);
final bytes = byteWriter.output.toBytes();
final masterKey = await KdbxFormat._generateMasterKeyV3(
kdbxFile.header, kdbxFile.credentials);
final encrypted = KdbxFormat._encryptDataAes(masterKey, bytes,
kdbxFile.header.fields[HeaderFields.EncryptionIV]!.bytes!);
kdbxFile.header.fields[HeaderFields.EncryptionIV]!.bytes);
return encrypted;
}
@ -326,7 +230,7 @@ class KdbxBody extends KdbxNode {
if (ctx.findBinaryByValue(binary) == null) {
ctx.addBinary(binary);
mergeContext.trackChange(this,
debug: 'adding new binary ${binary.value!.length}');
debug: 'adding new binary ${binary.value.length}');
}
}
meta.merge(other.meta);
@ -334,7 +238,25 @@ class KdbxBody extends KdbxNode {
// remove deleted objects
for (final incomingDelete in incomingDeleted.values) {
final object = mergeContext.objectIndex![incomingDelete.uuid!];
final object = mergeContext.objectIndex[incomingDelete.uuid];
if (object == null) {
mergeContext.trackWarning(
'Incoming deleted object not found locally ${incomingDelete.uuid}');
continue;
}
final parent = object.parent;
if (parent == null) {
mergeContext.trackWarning('Unable to delete object $object - '
'already deleted? (${incomingDelete.uuid})');
continue;
}
if (object is KdbxGroup) {
parent.internalRemoveGroup(object);
} else if (object is KdbxEntry) {
parent.internalRemoveEntry(object);
} else {
throw StateError('Invalid object type $object');
}
mergeContext.trackChange(object, debug: 'was deleted.');
}
@ -343,11 +265,11 @@ class KdbxBody extends KdbxNode {
_logger.info('Finished merging:\n${mergeContext.debugChanges()}');
final incomingObjects = other._createObjectIndex();
_logger.info('Merged: ${mergeContext.merged} vs. '
'(local objects: ${mergeContext.objectIndex!.length}, '
'(local objects: ${mergeContext.objectIndex.length}, '
'incoming objects: ${incomingObjects.length})');
// sanity checks
if (mergeContext.merged.keys.length != mergeContext.objectIndex!.length) {
if (mergeContext.merged.keys.length != mergeContext.objectIndex.length) {
// TODO figure out what went wrong.
}
return mergeContext;
@ -425,12 +347,28 @@ class MergeChange {
}
}
class MergeWarning {
MergeWarning(this.debug);
final String debug;
@override
String toString() {
return debug;
}
}
class MergeContext implements OverwriteContext {
MergeContext({this.objectIndex, this.deletedObjects});
final Map<KdbxUuid, KdbxObject>? objectIndex;
final Map<KdbxUuid?, KdbxDeletedObject>? deletedObjects;
MergeContext({required this.objectIndex, required this.deletedObjects});
final Map<KdbxUuid, KdbxObject> objectIndex;
final Map<KdbxUuid?, KdbxDeletedObject> deletedObjects;
final Map<KdbxUuid, KdbxObject> merged = {};
final List<MergeChange> changes = [];
final List<MergeWarning> warnings = [];
int totalChanges() {
return deletedObjects.length + changes.length;
}
void markAsMerged(KdbxObject object) {
if (merged.containsKey(object.uuid)) {
@ -449,6 +387,11 @@ class MergeContext implements OverwriteContext {
));
}
void trackWarning(String warning) {
_logger.warning(warning, StackTrace.current);
warnings.add(MergeWarning(warning));
}
String debugChanges() {
final group =
changes.groupBy((element) => element.object, valueTransform: (x) => x);
@ -459,6 +402,17 @@ class MergeContext implements OverwriteContext {
].join('\n '))
.join('\n');
}
String debugSummary() {
return 'Changes: ${changes.length}, '
'Deleted: ${deletedObjects.length}, '
'Warnings: ${warnings.isEmpty ? 'None' : warnings.join(', ')}';
}
@override
String toString() {
return '$runtimeType{${debugSummary()}}';
}
}
class _KeysV4 {
@ -469,9 +423,13 @@ class _KeysV4 {
}
class KdbxFormat {
KdbxFormat([this.argon2]) : assert(kdbxKeyCommonAssertConsistency());
KdbxFormat([Argon2? argon2])
: assert(kdbxKeyCommonAssertConsistency()),
argon2 = argon2 == null || !argon2.isImplemented
? const PointyCastleArgon2()
: argon2;
final Argon2? argon2;
final Argon2 argon2;
static bool dartWebWorkaround = false;
/// Creates a new, empty [KdbxFile] with default settings.
@ -482,7 +440,7 @@ class KdbxFormat {
String? generator,
KdbxHeader? header,
}) {
header ??= argon2 == null ? KdbxHeader.createV3() : KdbxHeader.createV4();
header ??= KdbxHeader.createV4();
final ctx = KdbxReadWriteContext(binaries: [], header: header);
final meta = KdbxMeta.create(
databaseName: name,
@ -516,10 +474,20 @@ class KdbxFormat {
}
/// Saves the given file.
Future<Uint8List> save(KdbxFile file) async {
Future<T> save<T>(KdbxFile file, FileSaveCallback<T> saveBytes) async {
_logger.finer('Saving ${file.body.rootGroup.uuid} '
'(locked: ${file.saveLock.locked})');
return file.saveLock.synchronized(() => _saveSynchronized(file));
return file.saveLock.synchronized(() async {
final savedAt = TimeSequence.now();
final bytes = await _saveSynchronized(file);
_logger.fine('Saving bytes.');
final ret = await saveBytes(bytes);
_logger.fine('Saved bytes.');
file.onSaved(savedAt);
return ret;
});
}
Future<Uint8List> _saveSynchronized(KdbxFile file) async {
@ -537,7 +505,7 @@ class KdbxFormat {
throw UnsupportedError('Unsupported version ${header.version}');
} else if (file.header.version < KdbxVersion.V4) {
final streamKey =
file.header.fields[HeaderFields.ProtectedStreamKey]!.bytes!;
file.header.fields[HeaderFields.ProtectedStreamKey]!.bytes;
final gen = ProtectedSaltGenerator(streamKey);
body.meta.headerHash.set(headerHash.buffer);
@ -553,7 +521,6 @@ class KdbxFormat {
} else {
throw UnsupportedError('Unsupported version ${header.version}');
}
file.onSaved();
return output.toBytes();
}
@ -703,7 +670,7 @@ class KdbxFormat {
Uint8List transformContentV4ChaCha20(
KdbxHeader header, Uint8List encrypted, Uint8List cipherKey) {
final encryptionIv = header.fields[HeaderFields.EncryptionIV]!.bytes!;
final encryptionIv = header.fields[HeaderFields.EncryptionIV]!.bytes;
final chaCha = ChaCha7539Engine()
..init(true, ParametersWithIV(KeyParameter(cipherKey), encryptionIv));
return chaCha.process(encrypted);
@ -726,7 +693,7 @@ class KdbxFormat {
Future<_KeysV4> _computeKeysV4(
KdbxHeader header, Credentials credentials) async {
final masterSeed = header.fields[HeaderFields.MasterSeed]!.bytes!;
final masterSeed = header.fields[HeaderFields.MasterSeed]!.bytes;
final kdfParameters = header.readKdfParameters;
if (masterSeed.length != 32) {
throw const FormatException('Master seed must be 32 bytes.');
@ -734,7 +701,7 @@ class KdbxFormat {
final credentialHash = credentials.getHash();
final key =
await KeyEncrypterKdf(argon2!).encrypt(credentialHash, kdfParameters);
await KeyEncrypterKdf(argon2).encrypt(credentialHash, kdfParameters);
// final keyWithSeed = Uint8List(65);
// keyWithSeed.replaceRange(0, masterSeed.length, masterSeed);
@ -823,14 +790,16 @@ class KdbxFormat {
Uint8List _decryptContent(
KdbxHeader header, Uint8List masterKey, Uint8List encryptedPayload) {
final encryptionIv = header.fields[HeaderFields.EncryptionIV]!.bytes!;
final decryptCipher = CBCBlockCipher(AESFastEngine());
final encryptionIv = header.fields[HeaderFields.EncryptionIV]!.bytes;
final decryptCipher = CBCBlockCipher(AESEngine());
decryptCipher.init(
false, ParametersWithIV(KeyParameter(masterKey), encryptionIv));
_logger.finer('decrypting ${encryptedPayload.length} with block size '
'${decryptCipher.blockSize}');
final paddedDecrypted =
AesHelper.processBlocks(decryptCipher, encryptedPayload);
final streamStart = header.fields[HeaderFields.StreamStartBytes]!.bytes!;
final streamStart = header.fields[HeaderFields.StreamStartBytes]!.bytes;
if (paddedDecrypted.lengthInBytes < streamStart.lengthInBytes) {
_logger.warning(
@ -852,9 +821,9 @@ class KdbxFormat {
Uint8List _decryptContentV4(
KdbxHeader header, Uint8List cipherKey, Uint8List encryptedPayload) {
final encryptionIv = header.fields[HeaderFields.EncryptionIV]!.bytes!;
final encryptionIv = header.fields[HeaderFields.EncryptionIV]!.bytes;
final decryptCipher = CBCBlockCipher(AESFastEngine());
final decryptCipher = CBCBlockCipher(AESEngine());
decryptCipher.init(
false, ParametersWithIV(KeyParameter(cipherKey), encryptionIv));
final paddedDecrypted =
@ -867,8 +836,8 @@ class KdbxFormat {
/// TODO combine this with [_decryptContentV4] (or [_encryptDataAes]?)
Uint8List _encryptContentV4Aes(
KdbxHeader header, Uint8List cipherKey, Uint8List bytes) {
final encryptionIv = header.fields[HeaderFields.EncryptionIV]!.bytes!;
final encryptCypher = CBCBlockCipher(AESFastEngine());
final encryptionIv = header.fields[HeaderFields.EncryptionIV]!.bytes;
final encryptCypher = CBCBlockCipher(AESEngine());
encryptCypher.init(
true, ParametersWithIV(KeyParameter(cipherKey), encryptionIv));
final paddedBytes = AesHelper.pad(bytes, encryptCypher.blockSize);
@ -879,7 +848,7 @@ class KdbxFormat {
KdbxHeader header, Credentials credentials) async {
final rounds = header.v3KdfTransformRounds;
final seed = header.fields[HeaderFields.TransformSeed]!.bytes;
final masterSeed = header.fields[HeaderFields.MasterSeed]!.bytes!;
final masterSeed = header.fields[HeaderFields.MasterSeed]!.bytes;
_logger.finer(
'Rounds: $rounds (${ByteUtils.toHexList(header.fields[HeaderFields.TransformRounds]!.bytes)})');
final transformedKey = await KeyEncrypterKdf.encryptAesAsync(
@ -893,7 +862,7 @@ class KdbxFormat {
static Uint8List _encryptDataAes(
Uint8List masterKey, Uint8List payload, Uint8List encryptionIv) {
final encryptCipher = CBCBlockCipher(AESFastEngine());
final encryptCipher = CBCBlockCipher(AESEngine());
encryptCipher.init(
true, ParametersWithIV(KeyParameter(masterKey), encryptionIv));
return AesHelper.processBlocks(

9
lib/src/kdbx_group.dart

@ -57,6 +57,11 @@ class KdbxGroup extends KdbxObject {
List<KdbxEntry> getAllEntries() =>
getAllGroups().expand((g) => g.entries).toList(growable: false);
/// Returns all groups and entries. (Including the group itself).
Iterable<KdbxObject> getAllGroupsAndEntries() => <KdbxObject>[this]
.followedBy(entries)
.followedBy(groups.expand((g) => g.getAllGroupsAndEntries()));
List<KdbxGroup> get groups => List.unmodifiable(_groups);
final List<KdbxGroup> _groups = [];
@ -144,7 +149,7 @@ class KdbxGroup extends KdbxObject {
if (meObj == null) {
// moved or deleted.
final movedObj = mergeContext.objectIndex![otherObj.uuid];
final movedObj = mergeContext.objectIndex[otherObj.uuid];
if (movedObj == null) {
// item was created in the other file. we have to import it
final newMeObject = importToHere(otherObj);
@ -154,7 +159,7 @@ class KdbxGroup extends KdbxObject {
// item was moved.
if (otherObj.wasMovedAfter(movedObj)) {
// item was moved in the other file, so we have to move it here.
file!.move(movedObj, this);
file.move(movedObj, this);
mergeContext.trackChange(movedObj, debug: 'moved to another group');
} else {
// item was moved in this file, so nothing to do.

62
lib/src/kdbx_header.dart

@ -4,6 +4,7 @@ import 'package:crypto/crypto.dart' as crypto;
import 'package:kdbx/src/crypto/key_encrypter_kdf.dart';
import 'package:kdbx/src/internal/consts.dart';
import 'package:kdbx/src/kdbx_binary.dart';
import 'package:kdbx/src/kdbx_exceptions.dart';
import 'package:kdbx/src/kdbx_var_dictionary.dart';
import 'package:kdbx/src/utils/byte_utils.dart';
import 'package:logging/logging.dart';
@ -71,6 +72,7 @@ class KdbxVersion {
static const V3 = KdbxVersion._(3, 0);
static const V3_1 = KdbxVersion._(3, 1);
static const V4 = KdbxVersion._(4, 0);
static const V4_1 = KdbxVersion._(4, 1);
final int major;
final int minor;
@ -133,7 +135,7 @@ class HeaderField implements HeaderFieldBase<HeaderFields> {
@override
final HeaderFields field;
final Uint8List? bytes;
final Uint8List bytes;
String get name => field.toString();
}
@ -143,7 +145,7 @@ class InnerHeaderField implements HeaderFieldBase<InnerHeaderFields> {
@override
final InnerHeaderFields field;
final Uint8List? bytes;
final Uint8List bytes;
String get name => field.toString();
}
@ -321,10 +323,10 @@ class KdbxHeader {
void _writeInnerField(WriterHelper writer, InnerHeaderField value) {
final field = value.field;
_logger.finer(
'Writing header $field (${field.index}) (${value.bytes!.lengthInBytes})');
'Writing header $field (${field.index}) (${value.bytes.lengthInBytes})');
writer.writeUint8(field.index);
_writeFieldSize(writer, value.bytes!.lengthInBytes);
writer.writeBytes(value.bytes!);
_writeFieldSize(writer, value.bytes.lengthInBytes);
writer.writeBytes(value.bytes);
}
void _writeField(WriterHelper writer, HeaderFields field) {
@ -332,10 +334,10 @@ class KdbxHeader {
if (value == null) {
return;
}
_logger.finer('Writing header $field (${value.bytes!.lengthInBytes})');
_logger.finer('Writing header $field (${value.bytes.lengthInBytes})');
writer.writeUint8(field.index);
_writeFieldSize(writer, value.bytes!.lengthInBytes);
writer.writeBytes(value.bytes!);
_writeFieldSize(writer, value.bytes.lengthInBytes);
writer.writeBytes(value.bytes);
}
void _writeFieldSize(WriterHelper writer, int size) {
@ -384,7 +386,7 @@ class KdbxHeader {
final sig1 = reader.readUint32();
final sig2 = reader.readUint32();
if (!(sig1 == Consts.FileMagic && sig2 == Consts.Sig2Kdbx)) {
throw UnsupportedError(
throw KdbxInvalidFileStructure(
'Unsupported file structure. ${ByteUtils.toHex(sig1)}, '
'${ByteUtils.toHex(sig2)}');
}
@ -427,7 +429,7 @@ class KdbxHeader {
ReaderHelper reader,
KdbxVersion version,
List<TE> fields,
T Function(TE field, Uint8List? bytes) createField) =>
T Function(TE field, Uint8List bytes) createField) =>
Map<TE, T>.fromEntries(readField(reader, version, fields, createField)
.map((field) => MapEntry(field.field, field)));
@ -435,12 +437,13 @@ class KdbxHeader {
ReaderHelper reader,
KdbxVersion version,
List<TE> fields,
T Function(TE field, Uint8List? bytes) createField) sync* {
T Function(TE field, Uint8List bytes) createField) sync* {
while (true) {
final headerId = reader.readUint8();
final bodySize =
version >= KdbxVersion.V4 ? reader.readUint32() : reader.readUint16();
final bodyBytes = bodySize > 0 ? reader.readBytes(bodySize) : null;
final bodyBytes =
bodySize > 0 ? reader.readBytes(bodySize) : Uint8List(0);
// _logger.finer(
// 'Read header ${fields[headerId]}: ${ByteUtils.toHexList(bodyBytes)}');
if (headerId > 0) {
@ -476,22 +479,21 @@ class KdbxHeader {
Cipher? get cipher {
if (version < KdbxVersion.V4) {
assert(
CryptoConsts.cipherFromBytes(fields[HeaderFields.CipherID]!.bytes!) ==
CryptoConsts.cipherFromBytes(fields[HeaderFields.CipherID]!.bytes) ==
Cipher.aes);
return Cipher.aes;
}
try {
return CryptoConsts.cipherFromBytes(
fields[HeaderFields.CipherID]!.bytes!);
return CryptoConsts.cipherFromBytes(fields[HeaderFields.CipherID]!.bytes);
} catch (e, stackTrace) {
_logger.warning(
'Unable to find cipher. '
'${fields[HeaderFields.CipherID]?.bytes?.encodeBase64()}',
'${fields[HeaderFields.CipherID]?.bytes.encodeBase64()}',
e,
stackTrace);
throw KdbxCorruptedFileException(
'Invalid cipher. '
'${fields[HeaderFields.CipherID]?.bytes?.encodeBase64()}',
'${fields[HeaderFields.CipherID]?.bytes.encodeBase64()}',
);
}
}
@ -556,32 +558,6 @@ class KdbxHeader {
}
}
class KdbxException implements Exception {}
class KdbxInvalidKeyException implements KdbxException {}
class KdbxCorruptedFileException implements KdbxException {
KdbxCorruptedFileException([this.message]);
final String? message;
@override
String toString() {
return 'KdbxCorruptedFileException{message: $message}';
}
}
class KdbxUnsupportedException implements KdbxException {
KdbxUnsupportedException(this.hint);
final String hint;
@override
String toString() {
return 'KdbxUnsupportedException{hint: $hint}';
}
}
class HashedBlockReader {
static const BLOCK_SIZE = 1024 * 1024;
static const HASH_SIZE = 32;

20
lib/src/kdbx_meta.dart

@ -5,6 +5,7 @@ import 'package:collection/collection.dart';
import 'package:kdbx/src/internal/extension_utils.dart';
import 'package:kdbx/src/kdbx_binary.dart';
import 'package:kdbx/src/kdbx_custom_data.dart';
import 'package:kdbx/src/kdbx_exceptions.dart';
import 'package:kdbx/src/kdbx_format.dart';
import 'package:kdbx/src/kdbx_header.dart';
import 'package:kdbx/src/kdbx_object.dart';
@ -38,7 +39,7 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext {
KdbxMeta.read(xml.XmlElement node, this.ctx)
: customData = node
.singleElement('CustomData')
.singleElement(KdbxXml.NODE_CUSTOM_DATA)
?.let((e) => KdbxCustomData.read(e)) ??
KdbxCustomData.create(),
binaries = node
@ -171,8 +172,8 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext {
XmlElement(XmlName(KdbxXml.NODE_CUSTOM_ICONS))
..children.addAll(customIcons.values.map(
(e) => XmlUtils.createNode(KdbxXml.NODE_ICON, [
XmlUtils.createTextNode(KdbxXml.NODE_UUID, e.uuid!.uuid),
XmlUtils.createTextNode(KdbxXml.NODE_DATA, base64.encode(e.data!))
XmlUtils.createTextNode(KdbxXml.NODE_UUID, e.uuid.uuid),
XmlUtils.createTextNode(KdbxXml.NODE_DATA, base64.encode(e.data))
]),
)),
);
@ -205,13 +206,8 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext {
recycleBinChanged.set(other.recycleBinChanged.get());
}
final otherIsNewer = other.settingsChanged.isAfter(settingsChanged);
// merge custom data
for (final otherCustomDataEntry in other.customData.entries) {
if (otherIsNewer || !customData.containsKey(otherCustomDataEntry.key)) {
customData[otherCustomDataEntry.key] = otherCustomDataEntry.value;
}
}
customData.merge(other.customData, otherIsNewer);
// merge custom icons
for (final otherCustomIcon in other._customIcons.values) {
_customIcons[otherCustomIcon.uuid] ??= otherCustomIcon;
@ -222,11 +218,11 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext {
}
class KdbxCustomIcon {
KdbxCustomIcon({this.uuid, this.data});
KdbxCustomIcon({required this.uuid, required this.data});
/// uuid of the icon, must be unique within each file.
final KdbxUuid? uuid;
final KdbxUuid uuid;
/// Encoded png data of the image. will be base64 encoded into the kdbx file.
final Uint8List? data;
final Uint8List data;
}

92
lib/src/kdbx_object.dart

@ -10,6 +10,7 @@ import 'package:kdbx/src/kdbx_group.dart';
import 'package:kdbx/src/kdbx_meta.dart';
import 'package:kdbx/src/kdbx_times.dart';
import 'package:kdbx/src/kdbx_xml.dart';
import 'package:kdbx/src/utils/sequence.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
import 'package:quiver/iterables.dart';
@ -37,7 +38,7 @@ mixin Changeable<T> {
Stream<ChangeEvent<T>> get changes => _controller.stream;
bool _isDirty = false;
TimeSequence? _isDirty;
/// allow recursive calls to [modify]
bool _isInModify = false;
@ -54,31 +55,49 @@ mixin Changeable<T> {
@mustCallSuper
void onAfterModify() {}
/// Called after the all modifications
@protected
@mustCallSuper
void onAfterAnyModify() {}
RET modify<RET>(RET Function() modify) {
if (_isDirty || _isInModify) {
if (isDirty || _isInModify) {
try {
return modify();
} finally {
_isDirty = TimeSequence.now();
onAfterAnyModify();
}
}
_isInModify = true;
onBeforeModify();
try {
return modify();
} finally {
_isDirty = true;
_isDirty = TimeSequence.now();
_isInModify = false;
onAfterModify();
_controller.add(ChangeEvent(object: this as T, isDirty: _isDirty));
onAfterAnyModify();
_controller.add(ChangeEvent(object: this as T, isDirty: isDirty));
}
}
void clean() {
if (!_isDirty) {
return;
bool clean(TimeSequence savedAt) {
final dirty = _isDirty;
if (dirty == null) {
_logger.warning('clean() called, even though we are not even dirty.');
return false;
}
_isDirty = false;
_controller.add(ChangeEvent(object: this as T, isDirty: _isDirty));
if (savedAt.isBefore(dirty)) {
_logger.fine('We got dirty after save was invoked. so we are not clean.');
return false;
}
_isDirty = null;
_controller.add(ChangeEvent(object: this as T, isDirty: isDirty));
return true;
}
bool get isDirty => _isDirty;
bool get isDirty => _isDirty != null;
}
abstract class KdbxNodeContext implements KdbxNode {
@ -87,7 +106,7 @@ abstract class KdbxNodeContext implements KdbxNode {
abstract class KdbxNode with Changeable<KdbxNode> {
KdbxNode.create(String nodeName) : node = XmlElement(XmlName(nodeName)) {
_isDirty = true;
_isDirty = TimeSequence.now();
}
KdbxNode.read(this.node);
@ -101,10 +120,8 @@ abstract class KdbxNode with Changeable<KdbxNode> {
// String text(String nodeName) => _opt(nodeName)?.text;
/// must only be called to save this object.
/// will mark this object as not dirty.
@mustCallSuper
XmlElement toXml() {
clean();
return node.copy();
}
}
@ -148,7 +165,7 @@ extension KdbxObjectInternal on KdbxObject {
abstract class KdbxObject extends KdbxNode {
KdbxObject.create(
this.ctx,
this.file,
this._file,
String nodeName,
KdbxGroup? parent,
) : times = KdbxTimes.create(ctx),
@ -163,8 +180,11 @@ abstract class KdbxObject extends KdbxNode {
super.read(node);
/// the file this object is part of. will be set AFTER loading, etc.
KdbxFile get file => _file!;
set file(KdbxFile file) => _file = file;
/// TODO: We should probably get rid of this `file` reference.
KdbxFile? file;
KdbxFile? _file;
final KdbxReadWriteContext ctx;
@ -182,24 +202,35 @@ abstract class KdbxObject extends KdbxNode {
KdbxGroup? _parent;
late final UuidNode previousParentGroup =
UuidNode(this, 'PreviousParentGroup');
KdbxCustomIcon? get customIcon =>
customIconUuid.get()?.let((uuid) => file!.body.meta.customIcons[uuid]);
customIconUuid.get()?.let((uuid) => file.body.meta.customIcons[uuid]);
set customIcon(KdbxCustomIcon? icon) {
if (icon != null) {
file!.body.meta.addCustomIcon(icon);
file.body.meta.addCustomIcon(icon);
customIconUuid.set(icon.uuid);
} else {
customIconUuid.set(null);
}
}
// @override
// void onAfterModify() {
// super.onAfterModify();
// times.modifiedNow();
// // during initial `create` the file will be null.
// file?.dirtyObject(this);
// }
@override
void onAfterModify() {
super.onAfterModify();
void onAfterAnyModify() {
super.onAfterAnyModify();
times.modifiedNow();
// during initial `create` the file will be null.
file?.dirtyObject(this);
_file?.dirtyObject(this);
}
bool wasModifiedAfter(KdbxObject other) => times.lastModificationTime
@ -217,11 +248,28 @@ abstract class KdbxObject extends KdbxNode {
return el;
}
void internalChangeParent(KdbxGroup parent) {
modify(() => _parent = parent);
@internal
void internalChangeParent(KdbxGroup? parent) {
modify(() {
previousParentGroup.set(_parent?.uuid);
_parent = parent;
});
}
void merge(MergeContext mergeContext, covariant KdbxObject other);
bool isInRecycleBin() {
final targetGroup = file.recycleBin;
if (targetGroup == null) {
return false;
}
return isInGroup(targetGroup);
}
bool isInGroup(KdbxGroup group) {
final parent = this.parent;
return parent != null && (parent == group || parent.isInGroup(group));
}
}
class KdbxUuid {

14
lib/src/kdbx_var_dictionary.dart

@ -20,37 +20,37 @@ class ValueType<T> {
final Decoder<T> decoder;
final Encoder<T>? encoder;
static final typeUInt32 = ValueType(
static final typeUInt32 = ValueType<int>(
0x04,
(reader, _) => reader.readUint32(),
(writer, value) => writer.writeUint32(value, writer._lengthWriter()),
);
static final typeUInt64 = ValueType(
static final typeUInt64 = ValueType<int>(
0x05,
(reader, _) => reader.readUint64(),
(writer, value) => writer.writeUint64(value, writer._lengthWriter()),
);
static final typeBool = ValueType(
static final typeBool = ValueType<bool>(
0x08,
(reader, _) => reader.readUint8() != 0,
(writer, value) => writer.writeUint8(value ? 1 : 0, writer._lengthWriter()),
);
static final typeInt32 = ValueType(
static final typeInt32 = ValueType<int>(
0x0C,
(reader, _) => reader.readInt32(),
(writer, value) => writer.writeInt32(value, writer._lengthWriter()),
);
static final typeInt64 = ValueType(
static final typeInt64 = ValueType<int>(
0x0D,
(reader, _) => reader.readInt64(),
(writer, value) => writer.writeInt64(value, writer._lengthWriter()),
);
static final typeString = ValueType(
static final typeString = ValueType<String>(
0x18,
(reader, length) => reader.readString(length),
(writer, value) => writer.writeString(value, writer._lengthWriter()),
);
static final typeBytes = ValueType(
static final typeBytes = ValueType<Uint8List>(
0x42,
(reader, length) => reader.readBytes(length),
(writer, value) => writer.writeBytes(value, writer._lengthWriter()),

4
lib/src/kdbx_xml.dart

@ -4,8 +4,8 @@ import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:kdbx/src/kdbx_consts.dart';
import 'package:kdbx/src/kdbx_exceptions.dart';
import 'package:kdbx/src/kdbx_format.dart';
import 'package:kdbx/src/kdbx_header.dart';
import 'package:kdbx/src/kdbx_object.dart';
import 'package:kdbx/src/utils/byte_utils.dart';
import 'package:logging/logging.dart';
@ -28,7 +28,9 @@ class KdbxXml {
static const ATTR_ID = 'ID';
static const NODE_BINARY = 'Binary';
static const ATTR_REF = 'Ref';
static const NODE_PREVIOUS_PARENT_GROUP = 'PreviousParentGroup';
static const NODE_CUSTOM_ICONS = 'CustomIcons';
static const NODE_CUSTOM_DATA = 'CustomData';
/// CustomIcons >> Icon
static const NODE_ICON = 'Icon';

12
lib/src/utils/byte_utils.dart

@ -190,16 +190,16 @@ class WriterHelperDartWeb extends WriterHelper {
void writeUint64(int value, [LengthWriter? lengthWriter]) {
lengthWriter?.call(8);
final _endian = Endian.little;
const endian = Endian.little;
final highBits = value >> 32;
final lowBits = value & mask32;
final byteData = ByteData(8);
if (_endian == Endian.big) {
byteData.setUint32(0, highBits, _endian);
byteData.setUint32(0 + bytesPerWord, lowBits, _endian);
if (endian == Endian.big) {
byteData.setUint32(0, highBits, endian);
byteData.setUint32(0 + bytesPerWord, lowBits, endian);
} else {
byteData.setUint32(0, lowBits, _endian);
byteData.setUint32(0 + bytesPerWord, highBits, _endian);
byteData.setUint32(0, lowBits, endian);
byteData.setUint32(0 + bytesPerWord, highBits, endian);
}
_write(byteData);
}

7
lib/src/utils/print_utils.dart

@ -20,8 +20,9 @@ class KdbxPrintUtils {
for (final group in group.groups) {
catGroup(buf, group, depth: depth + 1);
}
final valueToSting = (StringValue? value) =>
forceDecrypt! ? value?.getText() : value?.toString();
String? valueToSting(StringValue? value) {
return forceDecrypt! ? value?.getText() : value?.toString();
}
for (final entry in group.entries) {
final value = entry.getString(KdbxKeyCommon.PASSWORD);
@ -34,7 +35,7 @@ class KdbxPrintUtils {
.join('\n'));
}
buf.writeln(entry.binaryEntries
.map((b) => '$indent `- file: ${b.key} - ${b.value.value!.length}')
.map((b) => '$indent `- file: ${b.key} - ${b.value.value.length}')
.join('\n'));
}
}

28
lib/src/utils/sequence.dart

@ -0,0 +1,28 @@
import 'package:clock/clock.dart';
/// Simple class to assign a unique integer for any point in time.
/// This is basically to ensure that even if two events happen at the
/// same millisecond we know which came first.
/// (realistically this will only make a difference in tests).
class TimeSequence {
TimeSequence._(this._sequenceIndex);
factory TimeSequence.now() => TimeSequence._(_sequenceCounter++);
static int _sequenceCounter = 0;
final int _sequenceIndex;
final DateTime _date = clock.now();
bool isAfter(TimeSequence other) {
return _sequenceIndex > other._sequenceIndex;
}
bool isBefore(TimeSequence other) {
return _sequenceIndex < other._sequenceIndex;
}
@override
String toString() {
return '{Sequence: $_sequenceIndex time: $_date}';
}
}

23
pubspec.yaml

@ -1,25 +1,21 @@
name: kdbx
description: KeepassX format implementation in pure dart. (kdbx 3.x and 4.x support).
version: 2.0.0
version: 2.4.0
homepage: https://github.com/authpass/kdbx.dart
publish_to: none
environment:
sdk: '>=2.12.0 <3.0.0'
sdk: '>=2.12.0 <4.0.0'
dependencies:
# flutter:
# sdk: flutter
# path: ^1.6.0
logging: '>=0.11.3+2 <2.0.0'
crypto: '>=2.0.0 <4.0.0'
pointycastle: '>=3.0.0 <4.0.0'
xml: '>=4.4.0 <6.0.0'
pointycastle: '>=3.4.0 <4.0.0'
xml: '>=4.4.0 <7.0.0'
uuid: ">=3.0.0 <5.0.0"
meta: '>=1.0.0 <2.0.0'
clock: '>=1.0.0 <2.0.0'
convert: '>=2.0.0 <4.0.0'
#isolate: '>=2.0.3 <3.0.0'
# using forked null safety release until it is merged https://github.com/dart-lang/isolate/pull/45
isolates: '>=3.0.0 <4.0.0'
path: '>=1.6.0 <2.0.0'
quiver: '>=2.1.0 <4.0.0'
@ -27,14 +23,15 @@ dependencies:
supercharged_dart: '>=1.2.0 <4.0.0'
synchronized: '>=2.2.0 <4.0.0'
collection: ^1.15.0-nullsafety.4
collection: '>=1.15.0 <2.0.0'
# required for bin/
args: '>1.5.0 <3.0.0'
logging_appenders: '>=0.1.0 <2.0.0'
argon2_ffi_base: ^1.0.0
argon2_ffi_base:
path: ../argon2_ffi_base
dev_dependencies:
pedantic: '>=1.7.0 <2.0.0'
flutter_lints: '>=2.0.0 <3.0.0'
test: '>=1.6.0 <2.0.0'
fake_async: ^1.1.0
fake_async: ^1.2.0

61
test/deleted_objects_test.dart

@ -1,24 +1,79 @@
@Tags(['kdbx4'])
import 'package:kdbx/kdbx.dart';
import 'package:kdbx/src/kdbx_xml.dart';
import 'package:logging/logging.dart';
import 'package:test/test.dart';
import 'package:xml/xml.dart';
import 'internal/test_utils.dart';
import 'kdbx_test.dart';
final _logger = Logger('deleted_objects_test');
void main() {
TestUtil.setupLogging();
final testUtil = TestUtil();
_logger.finest('Running deleted objects tests.');
group('read tombstones', () {
test('load/save keeps deleted objects.', () async {
final orig =
await TestUtil.readKdbxFile('test/test_files/tombstonetest.kdbx');
await testUtil.readKdbxFile('test/test_files/tombstonetest.kdbx');
expect(orig.body.deletedObjects, hasLength(1));
final dt = orig.body.deletedObjects.first.deletionTime.get()!;
expect([dt.year, dt.month, dt.day], [2020, 8, 30]);
final reload = await TestUtil.saveAndRead(orig);
final reload = await testUtil.saveAndRead(orig);
expect(reload.body.deletedObjects, hasLength(1));
});
});
group('delete to trash', () {
test('move to trash, read previous parent', () {
final file = testUtil.createEmptyFile();
final g = file.body.rootGroup;
final entry = testUtil.createEntry(file, g, 'foo', 'bar');
expect(g.getAllGroupsAndEntries(), hasLength(2));
file.deleteEntry(entry);
// root group, entry and trash group.
expect(g.getAllGroupsAndEntries(), hasLength(3));
expect(entry.previousParentGroup.get(), g.uuid);
});
});
group('delete permanently', () {
test('delete entry', () async {
final file = testUtil.createEmptyFile();
final g = file.body.rootGroup;
final entry = testUtil.createEntry(file, g, 'foo', 'bar');
expect(g.getAllGroupsAndEntries().length, 2);
file.deleteEntry(entry);
// moved into trash bin
expect(g.getAllGroupsAndEntries().length, 3);
// now delete from trash
file.deletePermanently(entry);
expect(g.getAllGroupsAndEntries().length, 2);
final xml = file.body.generateXml(FakeProtectedSaltGenerator());
final objects = xml.findAllElements(KdbxXml.NODE_DELETED_OBJECT);
expect(objects.length, 1);
expect(objects.first.findElements(KdbxXml.NODE_UUID).first.text,
entry.uuid.uuid);
});
test('delete group', () async {
final file = testUtil.createEmptyFile();
final rootGroup = file.body.rootGroup;
final g = file.createGroup(parent: rootGroup, name: 'group');
final objs = [
g,
testUtil.createEntry(file, g, 'foo', 'bar'),
testUtil.createEntry(file, g, 'foo2', 'bar2'),
testUtil.createEntry(file, g, 'foo3', 'bar3'),
];
expect(rootGroup.getAllGroupsAndEntries().length, 5);
file.deletePermanently(g);
expect(rootGroup.getAllGroupsAndEntries().length, 1);
final xml = file.body.generateXml(FakeProtectedSaltGenerator());
final objects = xml.findAllElements(KdbxXml.NODE_DELETED_OBJECT);
expect(objects.length, 4);
expect(objects.map((e) => e.findElements(KdbxXml.NODE_UUID).first.text),
objs.map((o) => o.uuid.uuid));
});
});
}

4
test/icon/kdbx_customicon_test.dart

@ -3,9 +3,9 @@ import 'package:test/test.dart';
import '../internal/test_utils.dart';
void main() {
TestUtil.setupLogging();
final testUtil = TestUtil();
test('load custom icons from file', () async {
final file = await TestUtil.readKdbxFile('test/icon/icontest.kdbx');
final file = await testUtil.readKdbxFile('test/icon/icontest.kdbx');
final entry = file.body.rootGroup.entries.first;
expect(entry.customIcon!.data, isNotNull);
});

2
test/internal/byte_utils_test.dart

@ -9,7 +9,7 @@ void main() {
final bytesBuilder = BytesBuilder();
final writer = WriterHelper(bytesBuilder);
writer.writeUint32(1);
print('result: ' + ByteUtils.toHexList(writer.output.toBytes()));
print('result: ${ByteUtils.toHexList(writer.output.toBytes())}');
expect(writer.output.toBytes(), hasLength(4));
});
test('uint64', () {

39
test/internal/test_utils.dart

@ -10,12 +10,21 @@ import 'package:logging_appenders/logging_appenders.dart';
final _logger = Logger('test_utils');
class TestUtil {
factory TestUtil() => instance;
TestUtil._() {
setupLogging();
}
static final instance = TestUtil._();
static final keyTitle = KdbxKey('Title');
static void setupLogging() =>
PrintAppender.setupLogging(stderrLevel: Level.WARNING);
static KdbxFormat kdbxFormat() {
late final kdbxFormat = _kdbxFormat();
static KdbxFormat _kdbxFormat() {
Argon2.resolveLibraryForceDynamic = true;
return KdbxFormat(Argon2FfiFlutter(resolveLibrary: (path) {
final cwd = Directory('.').absolute.uri;
@ -26,42 +35,54 @@ class TestUtil {
}));
}
static Future<KdbxFile> readKdbxFile(
Future<KdbxFile> readKdbxFile(
String filePath, {
String password = 'asdf',
}) async {
final kdbxFormat = TestUtil.kdbxFormat();
final data = await File(filePath).readAsBytes();
final file = await kdbxFormat.read(
data, Credentials(ProtectedValue.fromString(password)));
return file;
}
static Future<KdbxFile> readKdbxFileBytes(Uint8List data,
Future<KdbxFile> readKdbxFileBytes(Uint8List data,
{String password = 'asdf', Credentials? credentials}) async {
final kdbxFormat = TestUtil.kdbxFormat();
final file = await kdbxFormat.read(
data, credentials ?? Credentials(ProtectedValue.fromString(password)));
return file;
}
static Future<KdbxFile> saveAndRead(KdbxFile file) async {
Future<KdbxFile> saveAndRead(KdbxFile file) async {
return await readKdbxFileBytes(await file.save(),
credentials: file.credentials);
}
static Future<void> saveTestOutput(String name, KdbxFile file) async {
Future<void> saveTestOutput(String name, KdbxFile file) async {
final bytes = await file.save();
final outFile = File('test_output_$name.kdbx');
await outFile.writeAsBytes(bytes);
_logger.info('Written to $outFile');
}
static KdbxFile createEmptyFile() {
final file = kdbxFormat().create(
KdbxFile createEmptyFile() {
final file = kdbxFormat.create(
Credentials.composite(ProtectedValue.fromString('asdf'), null),
'example');
return file;
}
KdbxEntry createEntry(
KdbxFile file,
KdbxGroup group,
String username,
String password,
) {
final entry = KdbxEntry.create(file, group);
group.addEntry(entry);
entry.setString(KdbxKeyCommon.USER_NAME, PlainValue(username));
entry.setString(
KdbxKeyCommon.PASSWORD, ProtectedValue.fromString(password));
return entry;
}
}

20
test/kdbx4_test.dart

@ -1,11 +1,9 @@
@Tags(['kdbx4'])
import 'dart:io';
import 'package:kdbx/kdbx.dart';
import 'package:kdbx/src/kdbx_header.dart';
import 'package:logging/logging.dart';
import 'package:logging_appenders/logging_appenders.dart';
import 'package:test/test.dart';
import 'internal/test_utils.dart';
@ -15,9 +13,11 @@ final _logger = Logger('kdbx4_test');
// ignore_for_file: non_constant_identifier_names
void main() {
Logger.root.level = Level.ALL;
PrintAppender().attachToLogger(Logger.root);
final kdbxFormat = TestUtil.kdbxFormat();
final testUtil = TestUtil();
final kdbxFormat = testUtil.kdbxFormat;
if (!kdbxFormat.argon2.isFfi) {
throw StateError('Expected ffi!');
}
group('Reading', () {
test('bubb', () async {
final data = await File('test/keepassxcpasswords.kdbx').readAsBytes();
@ -36,7 +36,7 @@ void main() {
expect(pwd, 'def');
});
test('Reading kdbx4_keeweb modification time', () async {
final file = await TestUtil.readKdbxFile('test/kdbx4_keeweb.kdbx');
final file = await testUtil.readKdbxFile('test/kdbx4_keeweb.kdbx');
final firstEntry = file.body.rootGroup.entries.first;
final createTime = firstEntry.times.creationTime.get();
expect(createTime, DateTime.utc(2020, 2, 26, 13, 40, 48));
@ -44,13 +44,13 @@ void main() {
expect(modTime, DateTime.utc(2020, 2, 26, 13, 40, 54));
});
test('Change kdbx4 modification time', () async {
final file = await TestUtil.readKdbxFile('test/kdbx4_keeweb.kdbx');
final file = await testUtil.readKdbxFile('test/kdbx4_keeweb.kdbx');
final firstEntry = file.body.rootGroup.entries.first;
final d = DateTime.utc(2020, 4, 5, 10, 0);
firstEntry.times.lastModificationTime.set(d);
final saved = await file.save();
{
final file2 = await TestUtil.readKdbxFileBytes(saved);
final file2 = await testUtil.readKdbxFileBytes(saved);
final firstEntry = file2.body.rootGroup.entries.first;
expect(firstEntry.times.lastModificationTime.get(), d);
}
@ -116,12 +116,12 @@ void main() {
});
group('recycle bin test', () {
test('empty recycle bin with "zero" uuid', () async {
final file = await TestUtil.readKdbxFile('test/keepass2test.kdbx');
final file = await testUtil.readKdbxFile('test/keepass2test.kdbx');
final recycleBin = file.recycleBin;
expect(recycleBin, isNull);
});
test('check deleting item', () async {
final file = await TestUtil.readKdbxFile('test/keepass2test.kdbx');
final file = await testUtil.readKdbxFile('test/keepass2test.kdbx');
expect(file.recycleBin, isNull);
final entry = file.body.rootGroup.getAllEntries().first;
file.deleteEntry(entry);

59
test/kdbx4_test_pointycastle.dart

@ -0,0 +1,59 @@
import 'dart:io';
import 'package:kdbx/kdbx.dart';
import 'package:kdbx/src/kdbx_header.dart';
import 'package:logging/logging.dart';
import 'package:test/test.dart';
import 'internal/test_utils.dart';
final _logger = Logger('kdbx4_test_pointycastle');
void main() {
// ignore: unused_local_variable
final testUtil = TestUtil();
final kdbxFormat = KdbxFormat();
if (kdbxFormat.argon2.isFfi) {
throw StateError('Expected non-ffi implementation.');
}
_logger.fine('argon2 implementation: ${kdbxFormat.argon2}');
group('Reading pointycastle argon2', () {
test('pc: Reading kdbx4_keeweb', () async {
final data = await File('test/kdbx4_keeweb.kdbx').readAsBytes();
final file = await kdbxFormat.read(
data, Credentials(ProtectedValue.fromString('asdf')));
final firstEntry = file.body.rootGroup.entries.first;
final pwd = firstEntry.getString(KdbxKeyCommon.PASSWORD)!.getText();
expect(pwd, 'def');
});
});
group('Writing pointycastle argon2', () {
test('Create and save', () async {
final credentials = Credentials(ProtectedValue.fromString('asdf'));
final kdbx = kdbxFormat.create(
credentials,
'Test Keystore',
header: KdbxHeader.createV4(),
);
final rootGroup = kdbx.body.rootGroup;
_createEntry(kdbx, rootGroup, 'user1', 'LoremIpsum');
_createEntry(kdbx, rootGroup, 'user2', 'Second Password');
final saved = await kdbx.save();
final loadedKdbx = await kdbxFormat.read(
saved, Credentials(ProtectedValue.fromString('asdf')));
_logger.fine('Successfully loaded kdbx $loadedKdbx');
File('test_v4x.kdbx').writeAsBytesSync(saved);
});
});
}
KdbxEntry _createEntry(
KdbxFile file, KdbxGroup group, String username, String password) {
final entry = KdbxEntry.create(file, group);
group.addEntry(entry);
entry.setString(KdbxKeyCommon.USER_NAME, PlainValue(username));
entry.setString(KdbxKeyCommon.PASSWORD, ProtectedValue.fromString(password));
return entry;
}

48
test/kdbx_binaries_test.dart

@ -2,8 +2,6 @@ import 'dart:convert';
import 'dart:typed_data';
import 'package:kdbx/kdbx.dart';
import 'package:logging/logging.dart';
import 'package:logging_appenders/logging_appenders.dart';
import 'package:test/test.dart';
import 'internal/test_utils.dart';
@ -18,7 +16,7 @@ void expectBinary(KdbxEntry entry, String key, dynamic matcher) {
Future<void> _testAddNewAttachment(String filePath) async {
final saved = await (() async {
final f = await TestUtil.readKdbxFile(filePath);
final f = await TestUtil().readKdbxFile(filePath);
final entry = KdbxEntry.create(f, f.body.rootGroup);
entry.label = 'addattachment';
f.body.rootGroup.addEntry(entry);
@ -34,7 +32,7 @@ Future<void> _testAddNewAttachment(String filePath) async {
return await f.save();
})();
{
final file = await TestUtil.readKdbxFileBytes(saved);
final file = await TestUtil().readKdbxFileBytes(saved);
final entry = file.body.rootGroup.entries
.firstWhere((e) => e.label == 'addattachment');
final binaries = entry.binaryEntries.toList();
@ -48,8 +46,7 @@ Future<void> _testAddNewAttachment(String filePath) async {
}
void main() {
Logger.root.level = Level.ALL;
PrintAppender().attachToLogger(Logger.root);
final testUtil = TestUtil();
group('kdbx3 attachment', () {
void expectKeepass2binariesContents(KdbxEntry entry) {
@ -58,10 +55,10 @@ void main() {
for (final binary in binaries) {
switch (binary.key.key) {
case 'example1.txt':
expect(utf8.decode(binary.value.value!), 'content1 example\n\n');
expect(utf8.decode(binary.value.value), 'content1 example\n\n');
break;
case 'example2.txt':
expect(utf8.decode(binary.value.value!), 'content2 example\n\n');
expect(utf8.decode(binary.value.value), 'content2 example\n\n');
break;
case 'keepasslogo.jpeg':
expect(binary.value.value, hasLength(7092));
@ -73,28 +70,29 @@ void main() {
}
test('read binary', () async {
final file = await TestUtil.readKdbxFile('test/keepass2binaries.kdbx');
final file = await testUtil.readKdbxFile('test/keepass2binaries.kdbx');
final entry = file.body.rootGroup.entries.first;
expectKeepass2binariesContents(entry);
});
test('read write read', () async {
final fileRead =
await TestUtil.readKdbxFile('test/keepass2binaries.kdbx');
await testUtil.readKdbxFile('test/keepass2binaries.kdbx');
final saved = await fileRead.save();
final file = await TestUtil.readKdbxFileBytes(saved);
final file = await testUtil.readKdbxFileBytes(saved);
final entry = file.body.rootGroup.entries.first;
expectKeepass2binariesContents(entry);
});
test('modify file with binary in history', () async {
final fileRead =
await TestUtil.readKdbxFile('test/keepass2binaries.kdbx');
final updateEntry = (KdbxFile file) {
await testUtil.readKdbxFile('test/keepass2binaries.kdbx');
void updateEntry(KdbxFile file) {
final entry = fileRead.body.rootGroup.entries.first;
entry.setString(KdbxKeyCommon.TITLE, PlainValue('example'));
};
}
updateEntry(fileRead);
final saved = await fileRead.save();
final file = await TestUtil.readKdbxFileBytes(saved);
final file = await testUtil.readKdbxFileBytes(saved);
await file.save();
});
test('Add new attachment', () async {
@ -102,7 +100,7 @@ void main() {
});
test('Remove attachment', () async {
final saved = await (() async {
final file = await TestUtil.readKdbxFile('test/keepass2binaries.kdbx');
final file = await testUtil.readKdbxFile('test/keepass2binaries.kdbx');
final entry = file.body.rootGroup.entries.first;
expectKeepass2binariesContents(entry);
expect(file.ctx.binariesIterable, hasLength(3));
@ -110,7 +108,7 @@ void main() {
expect(file.ctx.binariesIterable, hasLength(3));
return await file.save();
})();
final file = await TestUtil.readKdbxFileBytes(saved);
final file = await testUtil.readKdbxFileBytes(saved);
final entry = file.body.rootGroup.entries.first;
expect(entry.binaryEntries, hasLength(2));
expect(entry.binaryEntries.map((e) => (e.key.key)),
@ -124,12 +122,12 @@ void main() {
});
test('keepassxc compatibility', () async {
// keepass has files in arbitrary sort order.
final file = await TestUtil.readKdbxFile(
'test/test_files/binarytest-keepassxc.kdbx');
final file = await testUtil
.readKdbxFile('test/test_files/binarytest-keepassxc.kdbx');
final entry = file.body.rootGroup.entries.first;
for (final name in ['a', 'b', 'c', 'd', 'e']) {
expect(
utf8.decode(entry.getBinary(KdbxKey('$name.txt'))!.value!).trim(),
utf8.decode(entry.getBinary(KdbxKey('$name.txt'))!.value).trim(),
name,
);
}
@ -138,7 +136,7 @@ void main() {
group('kdbx4 attachment', () {
test('read binary', () async {
final file =
await TestUtil.readKdbxFile('test/keepass2kdbx4binaries.kdbx');
await testUtil.readKdbxFile('test/keepass2kdbx4binaries.kdbx');
expect(file.body.rootGroup.entries, hasLength(2));
expectBinary(file.body.rootGroup.entries.first, 'example2.txt',
@ -148,9 +146,9 @@ void main() {
});
test('read, write, read kdbx4', () async {
final fileRead =
await TestUtil.readKdbxFile('test/keepass2kdbx4binaries.kdbx');
await testUtil.readKdbxFile('test/keepass2kdbx4binaries.kdbx');
final saved = await fileRead.save();
final file = await TestUtil.readKdbxFileBytes(saved);
final file = await testUtil.readKdbxFileBytes(saved);
expect(file.body.rootGroup.entries, hasLength(2));
expectBinary(file.body.rootGroup.entries.first, 'example2.txt',
IsUtf8String('content2 example\n\n'));
@ -160,7 +158,7 @@ void main() {
test('remove attachment kdbx4', () async {
final saved = await (() async {
final file =
await TestUtil.readKdbxFile('test/keepass2kdbx4binaries.kdbx');
await testUtil.readKdbxFile('test/keepass2kdbx4binaries.kdbx');
final entry = file.body.rootGroup.entries.first;
expectBinary(file.body.rootGroup.entries.first, 'example2.txt',
IsUtf8String('content2 example\n\n'));
@ -173,7 +171,7 @@ void main() {
expect(file.dirtyObjects, [entry]);
return await file.save();
})();
final file = await TestUtil.readKdbxFileBytes(saved);
final file = await testUtil.readKdbxFileBytes(saved);
final entry = file.body.rootGroup.entries.first;
expect(entry.binaryEntries, hasLength(0));
expectBinary(file.body.rootGroup.entries.last, 'keepasslogo.jpeg',

57
test/kdbx_dirty_save_test.dart

@ -0,0 +1,57 @@
import 'package:kdbx/kdbx.dart';
import 'package:test/test.dart';
import 'internal/test_utils.dart';
void main() {
final testUtil = TestUtil();
group('test save with dirty objects', () {
test('modify object after save', () async {
final file = testUtil.createEmptyFile();
final group = file.body.rootGroup;
final entry = testUtil.createEntry(file, group, 'user', 'pass');
final entry2 = testUtil.createEntry(file, group, 'user', 'pass');
await file.save();
const value1 = 'new';
entry.setString(TestUtil.keyTitle, PlainValue(value1));
entry2.setString(TestUtil.keyTitle, PlainValue(value1));
expect(file.isDirty, isTrue);
await file.saveTo((bytes) async {
// must still be dirty as long as we are not finished saving.
expect(file.isDirty, isTrue);
expect(entry.isDirty, isTrue);
expect(entry2.isDirty, isTrue);
return 1;
});
expect(file.isDirty, isFalse);
expect(entry.isDirty, isFalse);
expect(entry2.isDirty, isFalse);
});
test('parallel modify', () async {
final file = testUtil.createEmptyFile();
final group = file.body.rootGroup;
final entry = testUtil.createEntry(file, group, 'user', 'pass');
final entry2 = testUtil.createEntry(file, group, 'user', 'pass');
await file.save();
const value1 = 'new';
const value2 = 'new2';
entry.setString(TestUtil.keyTitle, PlainValue(value2));
entry2.setString(TestUtil.keyTitle, PlainValue(value2));
await file.saveTo((bytes) async {
// must still be dirty as long as we are not finished saving.
expect(file.isDirty, isTrue);
expect(entry.isDirty, isTrue);
expect(entry2.isDirty, isTrue);
entry2.setString(TestUtil.keyTitle, PlainValue(value1));
return 1;
});
expect(file.isDirty, isTrue);
expect(entry.isDirty, isFalse);
expect(entry2.isDirty, isTrue);
});
});
}

8
test/kdbx_history_test.dart

@ -46,10 +46,10 @@ class StreamExpect<T> {
}
void main() {
TestUtil.setupLogging();
final testUtil = TestUtil();
group('test history for values', () {
test('check history creation', () async {
final file = await TestUtil.readKdbxFile('test/keepass2test.kdbx');
final file = await testUtil.readKdbxFile('test/keepass2test.kdbx');
const valueOrig = 'Sample Entry';
const value1 = 'new';
const value2 = 'new2';
@ -64,7 +64,7 @@ void main() {
}
expect(file.dirtyObjects, hasLength(1));
final f2 = await dirtyExpect
.expectNext({}, () async => TestUtil.saveAndRead(file));
.expectNext({}, () async => testUtil.saveAndRead(file));
expect(file.dirtyObjects, isEmpty);
{
final first = f2.body.rootGroup.entries.first;
@ -81,7 +81,7 @@ void main() {
() async => first.setString(TestUtil.keyTitle, PlainValue(value2)));
}
final f3 = await dirtyExpect
.expectNext({}, () async => TestUtil.saveAndRead(file));
.expectNext({}, () async => testUtil.saveAndRead(file));
expect(file.dirtyObjects, isEmpty);
{
final first = f3.body.rootGroup.entries.first;

16
test/kdbx_test.dart

@ -5,7 +5,6 @@ import 'dart:typed_data';
import 'package:kdbx/kdbx.dart';
import 'package:kdbx/src/crypto/protected_salt_generator.dart';
import 'package:logging/logging.dart';
import 'package:logging_appenders/logging_appenders.dart';
import 'package:synchronized/synchronized.dart';
import 'package:test/test.dart';
@ -22,9 +21,8 @@ class FakeProtectedSaltGenerator implements ProtectedSaltGenerator {
}
void main() {
Logger.root.level = Level.ALL;
PrintAppender().attachToLogger(Logger.root);
final kdbxFormat = KdbxFormat();
final testUtil = TestUtil();
final kdbxFormat = testUtil.kdbxFormat;
group('Reading', () {
setUp(() {});
@ -94,7 +92,7 @@ void main() {
group('times', () {
test('read mod date time', () async {
final file = await TestUtil.readKdbxFile('test/keepass2test.kdbx');
final file = await testUtil.readKdbxFile('test/keepass2test.kdbx');
final first = file.body.rootGroup.entries.first;
expect(file.header.version.major, 3);
expect(first.getString(KdbxKeyCommon.TITLE)!.getText(), 'Sample Entry');
@ -103,7 +101,7 @@ void main() {
});
test('update mod date time', () async {
final newModDate = DateTime.utc(2020, 1, 2, 3, 4, 5);
final file = await TestUtil.readKdbxFile('test/keepass2test.kdbx');
final file = await testUtil.readKdbxFile('test/keepass2test.kdbx');
{
final first = file.body.rootGroup.entries.first;
expect(file.header.version.major, 3);
@ -112,7 +110,7 @@ void main() {
}
final saved = await file.save();
{
final file = await TestUtil.readKdbxFileBytes(saved);
final file = await testUtil.readKdbxFileBytes(saved);
final first = file.body.rootGroup.entries.first;
final modTime = first.times.lastModificationTime.get();
expect(modTime, newModDate);
@ -144,7 +142,7 @@ void main() {
File('test.kdbx').writeAsBytesSync(saved);
});
test('concurrent save test', () async {
final file = await TestUtil.readKdbxFile('test/keepass2test.kdbx');
final file = await testUtil.readKdbxFile('test/keepass2test.kdbx');
final readLock = Lock();
Future<KdbxFile> doSave(
Future<Uint8List> byteFuture, String debug) async {
@ -152,7 +150,7 @@ void main() {
final bytes = await byteFuture;
return await readLock.synchronized(() {
try {
final ret = TestUtil.readKdbxFileBytes(bytes);
final ret = testUtil.readKdbxFileBytes(bytes);
_logger.fine('$debug FINISHED: success');
return ret;
} catch (e, stackTrace) {

10
test/kdbx_upgrade_test.dart

@ -6,17 +6,17 @@ import 'package:test/test.dart';
import 'internal/test_utils.dart';
void main() {
TestUtil.setupLogging();
final testUtil = TestUtil();
group('Test upgrade from v3 to v4', () {
final format = TestUtil.kdbxFormat();
final format = testUtil.kdbxFormat;
test('Read v3, write v4', () async {
final file =
await TestUtil.readKdbxFile('test/FooBar.kdbx', password: 'FooBar');
await testUtil.readKdbxFile('test/FooBar.kdbx', password: 'FooBar');
expect(file.header.version, KdbxVersion.V3_1);
file.upgrade(KdbxVersion.V4.major);
final v4 = await TestUtil.saveAndRead(file);
final v4 = await testUtil.saveAndRead(file);
expect(v4.header.version, KdbxVersion.V4);
await TestUtil.saveTestOutput('kdbx4upgrade', v4);
await testUtil.saveTestOutput('kdbx4upgrade', v4);
}, tags: 'kdbx3');
test('kdbx4 is the new default', () async {
final file =

45
test/keyfile/keyfile_create_test.dart

@ -0,0 +1,45 @@
import 'dart:typed_data';
import 'package:kdbx/kdbx.dart';
import 'package:logging/logging.dart';
import 'package:quiver/iterables.dart';
import 'package:test/expect.dart';
import 'package:test/scaffolding.dart';
import '../internal/test_utils.dart';
final _logger = Logger('keyfile_create_test');
void main() {
// ignore: unused_local_variable
final testUtils = TestUtil.instance;
final exampleBytes = Uint8List.fromList(
range(0, 16).expand((element) => [0xca, 0xfe]).toList());
group('creating keyfile', () {
test('Create keyfile', () {
final keyFile = KeyFileCredentials.fromBytes(exampleBytes);
final output = keyFile.toXmlV2String();
_logger.info(output);
expect(output, contains('Hash="4CA06E29"'));
expect(output, contains('CAFECAFE CAFECAFE'));
});
test('hex format', () {
final toTest = {
'abcd': 'ABCD',
'abcdefgh': 'ABCDEFGH',
'abcdef': 'ABCDEF',
'1234567812345678': '12345678 12345678',
'12345678123456': '12345678 123456',
};
for (final e in toTest.entries) {
expect(KeyFileCredentials.hexFormatLikeKeepass(e.key), e.value);
}
});
test('create and load', () {
final keyFile = KeyFileCredentials.fromBytes(exampleBytes);
final output = keyFile.toXmlV2();
final read = KeyFileCredentials(output);
expect(read.getBinary(), equals(exampleBytes));
});
});
}

58
test/merge/kdbx_merge_test.dart

@ -1,15 +1,18 @@
import 'package:clock/clock.dart';
import 'package:kdbx/kdbx.dart';
import 'package:kdbx/src/kdbx_xml.dart';
import 'package:kdbx/src/utils/print_utils.dart';
import 'package:logging/logging.dart';
import 'package:test/test.dart';
import 'package:xml/xml.dart';
import '../internal/test_utils.dart';
import '../kdbx_test.dart';
final _logger = Logger('kdbx_merge_test');
void main() {
TestUtil.setupLogging();
final testUtil = TestUtil();
var now = DateTime.fromMillisecondsSinceEpoch(0);
final fakeClock = Clock(() => now);
@ -22,18 +25,18 @@ void main() {
});
group('Simple merges', () {
Future<KdbxFile> createSimpleFile() async {
final file = TestUtil.createEmptyFile();
final file = testUtil.createEmptyFile();
_createEntry(file, file.body.rootGroup, 'test1', 'test1');
final subGroup =
file.createGroup(parent: file.body.rootGroup, name: 'Sub Group');
_createEntry(file, subGroup, 'test2', 'test2');
proceedSeconds(10);
return await TestUtil.saveAndRead(file);
return await testUtil.saveAndRead(file);
}
test('Noop merge', () async {
final file = await createSimpleFile();
final file2 = await TestUtil.saveAndRead(file);
final file2 = await testUtil.saveAndRead(file);
final merge = file.merge(file2);
final set = Set<KdbxUuid>.from(merge.merged.keys);
expect(set, hasLength(4));
@ -43,15 +46,13 @@ void main() {
await withClock(fakeClock, () async {
final file = await createSimpleFile();
final fileMod = await TestUtil.saveAndRead(file);
final fileMod = await testUtil.saveAndRead(file);
fileMod.body.rootGroup.entries.first
.setString(KdbxKeyCommon.USER_NAME, PlainValue('changed.'));
_logger.info('mod date: ' +
fileMod.body.rootGroup.entries.first.times.lastModificationTime
.get()
.toString());
final file2 = await TestUtil.saveAndRead(fileMod);
_logger.info('mod date: ${fileMod.body.rootGroup.entries.first.times.lastModificationTime
.get()}');
final file2 = await testUtil.saveAndRead(fileMod);
_logger.info('\n\n\nstarting merge.\n');
final merge = file.merge(file2);
@ -66,10 +67,10 @@ void main() {
() async => await withClock(fakeClock, () async {
final file = await createSimpleFile();
final fileMod = await TestUtil.saveAndRead(file);
final fileMod = await testUtil.saveAndRead(file);
fileMod.body.rootGroup.groups.first.name.set('Sub Group New Name.');
final file2 = await TestUtil.saveAndRead(fileMod);
final file2 = await testUtil.saveAndRead(fileMod);
final merge = file.merge(file2);
final set = Set<KdbxUuid>.from(merge.merged.keys);
expect(set, hasLength(4));
@ -81,21 +82,48 @@ void main() {
() async => await withClock(fakeClock, () async {
final file = await createSimpleFile();
final fileMod = await TestUtil.saveAndRead(file);
final fileMod = await testUtil.saveAndRead(file);
expect(fileMod.recycleBin, isNull);
fileMod.deleteEntry(fileMod.body.rootGroup.entries.first);
expect(fileMod.recycleBin, isNotNull);
final file2 = await TestUtil.saveAndRead(fileMod);
final file2 = await testUtil.saveAndRead(fileMod);
final merge = file.merge(file2);
_logger.info('Merged file:\n'
'${KdbxPrintUtils().catGroupToString(file.body.rootGroup)}');
final set = Set<KdbxUuid>.from(merge.merged.keys);
expect(set, hasLength(5));
expect(Set<KdbxNode>.from(merge.changes.map<KdbxNode?>((e) => e.object)),
expect(
Set<KdbxNode>.from(merge.changes.map<KdbxNode?>((e) => e.object)),
hasLength(2));
}),
);
test(
'permanently delete an entry',
() async => await withClock(fakeClock, () async {
final file = await createSimpleFile();
final objCount = file.body.rootGroup.getAllGroupsAndEntries().length;
final fileMod = await testUtil.saveAndRead(file);
final entryDelete = fileMod.body.rootGroup.entries.first;
fileMod.deletePermanently(entryDelete);
expect(fileMod.body.rootGroup.getAllGroupsAndEntries(),
hasLength(objCount - 1));
final file2 = await testUtil.saveAndRead(fileMod);
final merge = file.merge(file2);
_logger.info('Merged file:\n'
'${KdbxPrintUtils().catGroupToString(file.body.rootGroup)}');
expect(merge.deletedObjects, hasLength(1));
expect(
file.body.rootGroup.getAllGroupsAndEntries().length, objCount - 1);
final xml = file.body.generateXml(FakeProtectedSaltGenerator());
final deleted = xml.findAllElements(KdbxXml.NODE_DELETED_OBJECT);
expect(deleted, hasLength(1));
expect(
deleted.first.findAllElements(KdbxXml.NODE_UUID).map((e) => e.text),
[entryDelete.uuid.uuid]);
}),
);
});
}

Loading…
Cancel
Save