Compare commits

..

No commits in common. 'master' and 'null-safety' have entirely different histories.

  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: dart pub get
run: pub get
- name: Run tests
run: dart run test
run: pub run test
coverage:
runs-on: ubuntu-latest
steps:

31
CHANGELOG.md

@ -1,34 +1,3 @@
## 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%/*}"/..
dart pub get
dart pub global activate coverage
pub get
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
dart pub global run coverage:format_coverage -i coverage/merged_json.cov -l --report-on lib --report-on test > coverage/lcov.info
pub global run coverage:format_coverage --packages=.packages -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,9 +1,12 @@
# I kind of prefer the flutter lints, so use it for now,
# instead of manually copying it over 🤷
include: package:flutter_lints/flutter.yaml
# 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
analyzer:
strong-mode:
implicit-casts: false
implicit-dynamic: false
errors:
# treat missing required parameters as a warning (not a hint)
missing_required_param: warning
@ -11,42 +14,159 @@ analyzer:
missing_return: warning
# allow having TODOs in the code
todo: ignore
language:
strict-casts: true
strict-raw-types: true
linter:
rules:
avoid_print: false
# TODO: rename constants.
constant_identifier_names: false
# 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/
always_declare_return_types: true
prefer_single_quotes: true
unawaited_futures: true
unsafe_html: true
# HP mostly in sync with https://github.com/flutter/flutter/blob/master/analysis_options.yaml
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
- 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

192
example/pubspec.lock

@ -5,313 +5,225 @@ packages:
dependency: transitive
description:
name: archive
sha256: a92e39b291073bb840a72cf43d96d2a63c74e9a485d227833e8ea0054d16ad16
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.2"
argon2_ffi_base:
dependency: transitive
description:
path: "../../argon2_ffi_base"
relative: true
source: path
version: "1.1.1"
name: argon2_ffi_base
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0+2"
args:
dependency: transitive
description:
name: args
sha256: "3d82ff8620ec576fd38f6cec0df45a7c088b8704eb1c63d4c336392e5efca6ca"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
characters:
dependency: transitive
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0"
version: "2.1.0"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
version: "1.2.0"
clock:
dependency: transitive
description:
name: clock
sha256: "6021e0172ab6e6eaa1d391afed0a99353921f00c54385c574dc53e55d67c092c"
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
collection:
dependency: transitive
description:
name: collection
sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "1.17.2"
version: "1.15.0"
convert:
dependency: transitive
description:
name: convert
sha256: f08428ad63615f96a27e34221c65e1a451439b5f26030f78d790f461c686d65d
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
version: "3.0.0"
crypto:
dependency: transitive
description:
name: crypto
sha256: cf75650c66c0316274e21d7c43d3dea246273af5955bd94e8184837cd577575c
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
dio:
dependency: transitive
description:
name: dio
sha256: bf173c8bc66b776e3c2892b6ac56ac1a5ad73d21dd06d337f9fe656f63612947
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.0"
ffi:
dependency: transitive
description:
name: ffi
sha256: "35d0f481d939de0d640b3db9a7aa36a52cd22054a798a73b4f50bdad5ce12678"
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
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"
version: "1.0.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: e362d639ba3bc07d5a71faebb98cde68c05bfbcfbbb444b60b6f60bb67719185
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.0"
intl:
dependency: transitive
description:
name: intl
sha256: "910f85bce16fb5c6f614e117efa303e85a1731bb0081edf3604a2ae6e9a3cc91"
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "0.17.0"
isolates:
dependency: transitive
description:
name: isolates
sha256: ce89e4141b27b877326d3715be2dceac7a7ba89f3229785816d2d318a75ddf28
url: "https://pub.dev"
url: "https://pub.dartlang.org"
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.4.0"
lints:
dependency: transitive
description:
name: lints
sha256: a2c3d198cb5ea2e179926622d433331d8b58374ab8f29cdda6e863bd62fd369c
url: "https://pub.dev"
source: hosted
version: "1.0.1"
version: "2.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: "0520a4826042a8a5d09ddd4755623a50d37ee536d79a70452aff8c8ad7bb6c27"
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
logging_appenders:
dependency: transitive
description:
name: logging_appenders
sha256: "013e8548b79e3b8dc0333f3efae706184356b5926c6bea59150efa126c91598c"
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
matcher:
dependency: transitive
description:
name: matcher
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"
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.0"
version: "0.12.10"
meta:
dependency: transitive
description:
name: meta
sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3"
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "1.9.1"
version: "1.3.0"
path:
dependency: transitive
description:
name: path
sha256: "2ad4cddff7f5cc0e2d13069f2a3f7a73ca18f66abd6f5ecf215219cdb3638edb"
url: "https://pub.dev"
url: "https://pub.dartlang.org"
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
sha256: "3abc4a0f06dccb2348ebdab9f5b9cc88bb64bfc830bed6351040ca42722044a6"
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.0"
version: "4.1.0"
pointycastle:
dependency: transitive
description:
name: pointycastle
sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c"
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "3.7.3"
version: "3.0.1"
quiver:
dependency: transitive
description:
name: quiver
sha256: "5e592c348a6c528fb8deb7cc7d85a7097ce65bf2349121ad004d1fc5d5905eaa"
url: "https://pub.dev"
url: "https://pub.dartlang.org"
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
sha256: d5f89a9e52b36240a80282b3dc0667dd36e53459717bb17b8fb102d30496606a
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "1.8.1"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: f8d9f247e2f9f90e32d1495ff32dac7e4ae34ffa7194c5ff8fcc0fd0e52df774
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "1.10.0"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: dd11571b8a03f7cadcf91ec26a77e02bfbd6bbba2a512924d3116646b4198fc4
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
supercharged_dart:
dependency: transitive
description:
name: supercharged_dart
sha256: "9d6d4fa1736d07f0506ce2713e5f9815b20bcd741c0d53e9b56c265458c3ce05"
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: "271977ff1e9e82ceefb4f08424b8839f577c1852e0726b5ce855311b46d3ef83"
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: a88162591b02c1f3a3db3af8ce1ea2b374bd75a7bb8d5e353bcfbdc79d719830
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: "53bdf7e979cfbf3e28987552fd72f637e63f3c8724c9e56d9246942dc2fa36ee"
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "0ea20bfc625477e17f08a92d112272a071609b275ce4ca10ad853e1426ca3758"
url: "https://pub.dev"
url: "https://pub.dartlang.org"
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
sha256: "925e1d7923773fef2f90c5c9ad0f496630f63e03f974a0aaa5fb50c60640c570"
url: "https://pub.dev"
url: "https://pub.dartlang.org"
source: hosted
version: "5.2.0"
version: "5.1.1"
sdks:
dart: ">=3.1.0-185.0.dev <4.0.0"
dart: ">=2.12.0 <3.0.0"

2
example/pubspec.yaml

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

23
lib/kdbx.dart

@ -1,9 +1,6 @@
/// 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'
@ -13,11 +10,25 @@ 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, MergeContext, KdbxFormat;
export 'src/kdbx_format.dart'
show
KdbxBody,
Credentials,
CredentialsPart,
HashCredentials,
KdbxFormat,
KeyFileComposite,
KeyFileCredentials,
PasswordCredentials;
export 'src/kdbx_group.dart' show KdbxGroup;
export 'src/kdbx_header.dart' show KdbxVersion;
export 'src/kdbx_header.dart'
show
KdbxException,
KdbxInvalidKeyException,
KdbxCorruptedFileException,
KdbxUnsupportedException,
KdbxVersion;
export 'src/kdbx_meta.dart';
export 'src/kdbx_object.dart'
show

43
lib/src/credentials/credentials.dart

@ -1,43 +0,0 @@
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

@ -1,156 +0,0 @@
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 extends Object> {
class KdfField<T> {
KdfField(this.field, this.type);
final String field;
@ -76,15 +76,18 @@ 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);
return kdfUuids[kdfUuid] ??
(() => throw KdbxCorruptedFileException(
'Invalid KDF UUID ${uuid.encodeBase64()}'))();
try {
return kdfUuids[kdfUuid];
} catch (e) {
throw KdbxCorruptedFileException(
'Invalid KDF UUID ${uuid.encodeBase64()}');
}
}
final Argon2 argon2;
@ -143,7 +146,7 @@ class KeyEncrypterKdf {
}
static Uint8List _encryptAesSync(EncryptAesArgs args) {
final cipher = ECBBlockCipher(AESEngine())
final cipher = ECBBlockCipher(AESFastEngine())
..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 = AESEngine();
final BlockCipher aes = AESFastEngine();
// Uint8List cipherIvBytes = base64.decode(ciphertext);
final Uint8List iv = Uint8List(aes.blockSize)

37
lib/src/internal/pointycastle_argon2.dart

@ -1,37 +0,0 @@
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,20 +9,16 @@ import 'package:quiver/core.dart';
import 'package:xml/xml.dart';
class KdbxBinary {
KdbxBinary({
required this.isInline,
required this.isProtected,
required this.value,
});
final bool isInline;
final bool isProtected;
final Uint8List value;
KdbxBinary({this.isInline, this.isProtected, 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,
@ -30,16 +26,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, other.value);
valueHashCode == other.valueHashCode && ByteUtils.eq(value!, 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());
}
@ -60,8 +56,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 = KdbxXml.NODE_CUSTOM_DATA;
static const String TAG_NAME = 'CustomData';
final Map<String, String> _data;
@ -43,18 +43,4 @@ 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,4 +1,3 @@
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';
@ -41,26 +40,4 @@ 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,14 +1,12 @@
import 'package:clock/clock.dart';
import 'package:kdbx/src/kdbx_format.dart';
import 'package:kdbx/src/kdbx_object.dart';
import 'package:kdbx/src/kdbx_xml.dart';
import 'package:xml/xml.dart';
class KdbxDeletedObject extends KdbxNode implements KdbxNodeContext {
KdbxDeletedObject.create(this.ctx, KdbxUuid? uuid, [DateTime? now])
: super.create(NODE_NAME) {
KdbxDeletedObject.create(this.ctx, KdbxUuid? uuid) : super.create(NODE_NAME) {
_uuid.set(uuid);
deletionTime.set(now ?? clock.now().toUtc());
deletionTime.setToNow();
}
KdbxDeletedObject.read(XmlElement node, this.ctx) : super.read(node);
@ -18,8 +16,7 @@ class KdbxDeletedObject extends KdbxNode implements KdbxNodeContext {
@override
final KdbxReadWriteContext ctx;
// all objects have to have a UUID.
KdbxUuid get uuid => _uuid.get()!;
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,11 +5,10 @@ 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';
@ -57,7 +56,6 @@ 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;
@ -78,7 +76,7 @@ class KdbxKey {
extension KdbxEntryInternal on KdbxEntry {
KdbxEntry cloneInto(KdbxGroup otherGroup, {bool toHistoryEntry = false}) =>
KdbxEntry.create(
otherGroup.file,
otherGroup.file!,
otherGroup,
isHistoryEntry: toHistoryEntry,
)
@ -129,7 +127,6 @@ 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) {
@ -160,7 +157,6 @@ class KdbxEntry extends KdbxObject {
KdbxGroup parent, {
this.isHistoryEntry = false,
}) : history = [],
customData = KdbxCustomData.create(),
super.create(file.ctx, file, 'Entry', parent) {
icon.set(KdbxIcon.Key);
}
@ -168,10 +164,6 @@ 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);
@ -218,10 +210,8 @@ 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.
@ -243,7 +233,7 @@ class KdbxEntry extends KdbxObject {
@override
XmlElement toXml() {
final el = super.toXml()..replaceSingle(customData.toXml());
final el = super.toXml();
XmlUtils.removeChildrenByName(el, KdbxXml.NODE_STRING);
XmlUtils.removeChildrenByName(el, KdbxXml.NODE_HISTORY);
XmlUtils.removeChildrenByName(el, KdbxXml.NODE_BINARY);
@ -268,7 +258,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);
@ -355,7 +345,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

@ -1,36 +0,0 @@
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,16 +2,13 @@ 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';
@ -19,16 +16,9 @@ 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;
}
@ -47,12 +37,7 @@ class KdbxFile {
final KdbxFormat kdbxFormat;
final KdbxReadWriteContext ctx;
Credentials get credentials => _credentials;
set credentials(Credentials credentials) {
body.meta.modify(() => _credentials = credentials);
}
Credentials _credentials;
final Credentials credentials;
final KdbxHeader header;
final KdbxBody body;
final Set<KdbxObject> dirtyObjects = {};
@ -68,27 +53,14 @@ 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, _saveToBytes);
}
Future<T> saveTo<T>(FileSaveCallback<T> saveBytes) {
return kdbxFormat.save(this, saveBytes);
return kdbxFormat.save(this);
}
/// Marks all dirty objects as clean. Called by [KdbxFormat.save].
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);
void onSaved() {
dirtyObjects.clear();
_dirtyObjectsChanged.add(const {});
}
Iterable<KdbxObject> get _allObjects => body.rootGroup

231
lib/src/kdbx_format.dart

@ -6,6 +6,7 @@ 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';
@ -13,14 +14,11 @@ 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';
@ -30,6 +28,40 @@ 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({
@ -89,11 +121,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;
}
@ -106,12 +138,76 @@ 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);
void addDeletedObject(KdbxUuid uuid, [DateTime? now]) {
_deletedObjects.add(KdbxDeletedObject.create(this, uuid));
final ProtectedValue _password;
@override
Uint8List getBinary() {
return _password.hash;
}
}
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);
@ -169,14 +265,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;
}
@ -230,7 +326,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);
@ -238,25 +334,7 @@ class KdbxBody extends KdbxNode {
// remove deleted objects
for (final incomingDelete in incomingDeleted.values) {
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');
}
final object = mergeContext.objectIndex![incomingDelete.uuid!];
mergeContext.trackChange(object, debug: 'was deleted.');
}
@ -265,11 +343,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;
@ -347,28 +425,12 @@ class MergeChange {
}
}
class MergeWarning {
MergeWarning(this.debug);
final String debug;
@override
String toString() {
return debug;
}
}
class MergeContext implements OverwriteContext {
MergeContext({required this.objectIndex, required this.deletedObjects});
final Map<KdbxUuid, KdbxObject> objectIndex;
final Map<KdbxUuid?, KdbxDeletedObject> deletedObjects;
MergeContext({this.objectIndex, 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)) {
@ -387,11 +449,6 @@ 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);
@ -402,17 +459,6 @@ 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 {
@ -423,13 +469,9 @@ class _KeysV4 {
}
class KdbxFormat {
KdbxFormat([Argon2? argon2])
: assert(kdbxKeyCommonAssertConsistency()),
argon2 = argon2 == null || !argon2.isImplemented
? const PointyCastleArgon2()
: argon2;
KdbxFormat([this.argon2]) : assert(kdbxKeyCommonAssertConsistency());
final Argon2 argon2;
final Argon2? argon2;
static bool dartWebWorkaround = false;
/// Creates a new, empty [KdbxFile] with default settings.
@ -440,7 +482,7 @@ class KdbxFormat {
String? generator,
KdbxHeader? header,
}) {
header ??= KdbxHeader.createV4();
header ??= argon2 == null ? KdbxHeader.createV3() : KdbxHeader.createV4();
final ctx = KdbxReadWriteContext(binaries: [], header: header);
final meta = KdbxMeta.create(
databaseName: name,
@ -474,20 +516,10 @@ class KdbxFormat {
}
/// Saves the given file.
Future<T> save<T>(KdbxFile file, FileSaveCallback<T> saveBytes) async {
Future<Uint8List> save(KdbxFile file) async {
_logger.finer('Saving ${file.body.rootGroup.uuid} '
'(locked: ${file.saveLock.locked})');
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;
});
return file.saveLock.synchronized(() => _saveSynchronized(file));
}
Future<Uint8List> _saveSynchronized(KdbxFile file) async {
@ -505,7 +537,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);
@ -521,6 +553,7 @@ class KdbxFormat {
} else {
throw UnsupportedError('Unsupported version ${header.version}');
}
file.onSaved();
return output.toBytes();
}
@ -670,7 +703,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);
@ -693,7 +726,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.');
@ -701,7 +734,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);
@ -790,16 +823,14 @@ class KdbxFormat {
Uint8List _decryptContent(
KdbxHeader header, Uint8List masterKey, Uint8List encryptedPayload) {
final encryptionIv = header.fields[HeaderFields.EncryptionIV]!.bytes;
final decryptCipher = CBCBlockCipher(AESEngine());
final encryptionIv = header.fields[HeaderFields.EncryptionIV]!.bytes!;
final decryptCipher = CBCBlockCipher(AESFastEngine());
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(
@ -821,9 +852,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(AESEngine());
final decryptCipher = CBCBlockCipher(AESFastEngine());
decryptCipher.init(
false, ParametersWithIV(KeyParameter(cipherKey), encryptionIv));
final paddedDecrypted =
@ -836,8 +867,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(AESEngine());
final encryptionIv = header.fields[HeaderFields.EncryptionIV]!.bytes!;
final encryptCypher = CBCBlockCipher(AESFastEngine());
encryptCypher.init(
true, ParametersWithIV(KeyParameter(cipherKey), encryptionIv));
final paddedBytes = AesHelper.pad(bytes, encryptCypher.blockSize);
@ -848,7 +879,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(
@ -862,7 +893,7 @@ class KdbxFormat {
static Uint8List _encryptDataAes(
Uint8List masterKey, Uint8List payload, Uint8List encryptionIv) {
final encryptCipher = CBCBlockCipher(AESEngine());
final encryptCipher = CBCBlockCipher(AESFastEngine());
encryptCipher.init(
true, ParametersWithIV(KeyParameter(masterKey), encryptionIv));
return AesHelper.processBlocks(

9
lib/src/kdbx_group.dart

@ -57,11 +57,6 @@ 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 = [];
@ -149,7 +144,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);
@ -159,7 +154,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,7 +4,6 @@ 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';
@ -72,7 +71,6 @@ 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;
@ -135,7 +133,7 @@ class HeaderField implements HeaderFieldBase<HeaderFields> {
@override
final HeaderFields field;
final Uint8List bytes;
final Uint8List? bytes;
String get name => field.toString();
}
@ -145,7 +143,7 @@ class InnerHeaderField implements HeaderFieldBase<InnerHeaderFields> {
@override
final InnerHeaderFields field;
final Uint8List bytes;
final Uint8List? bytes;
String get name => field.toString();
}
@ -323,10 +321,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) {
@ -334,10 +332,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) {
@ -386,7 +384,7 @@ class KdbxHeader {
final sig1 = reader.readUint32();
final sig2 = reader.readUint32();
if (!(sig1 == Consts.FileMagic && sig2 == Consts.Sig2Kdbx)) {
throw KdbxInvalidFileStructure(
throw UnsupportedError(
'Unsupported file structure. ${ByteUtils.toHex(sig1)}, '
'${ByteUtils.toHex(sig2)}');
}
@ -429,7 +427,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)));
@ -437,13 +435,12 @@ 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) : Uint8List(0);
final bodyBytes = bodySize > 0 ? reader.readBytes(bodySize) : null;
// _logger.finer(
// 'Read header ${fields[headerId]}: ${ByteUtils.toHexList(bodyBytes)}');
if (headerId > 0) {
@ -479,21 +476,22 @@ 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()}',
);
}
}
@ -558,6 +556,32 @@ 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,7 +5,6 @@ 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';
@ -39,7 +38,7 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext {
KdbxMeta.read(xml.XmlElement node, this.ctx)
: customData = node
.singleElement(KdbxXml.NODE_CUSTOM_DATA)
.singleElement('CustomData')
?.let((e) => KdbxCustomData.read(e)) ??
KdbxCustomData.create(),
binaries = node
@ -172,8 +171,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!))
]),
)),
);
@ -206,8 +205,13 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext {
recycleBinChanged.set(other.recycleBinChanged.get());
}
final otherIsNewer = other.settingsChanged.isAfter(settingsChanged);
// merge custom data
customData.merge(other.customData, otherIsNewer);
for (final otherCustomDataEntry in other.customData.entries) {
if (otherIsNewer || !customData.containsKey(otherCustomDataEntry.key)) {
customData[otherCustomDataEntry.key] = otherCustomDataEntry.value;
}
}
// merge custom icons
for (final otherCustomIcon in other._customIcons.values) {
_customIcons[otherCustomIcon.uuid] ??= otherCustomIcon;
@ -218,11 +222,11 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext {
}
class KdbxCustomIcon {
KdbxCustomIcon({required this.uuid, required this.data});
KdbxCustomIcon({this.uuid, 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,7 +10,6 @@ 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';
@ -38,7 +37,7 @@ mixin Changeable<T> {
Stream<ChangeEvent<T>> get changes => _controller.stream;
TimeSequence? _isDirty;
bool _isDirty = false;
/// allow recursive calls to [modify]
bool _isInModify = false;
@ -55,49 +54,31 @@ mixin Changeable<T> {
@mustCallSuper
void onAfterModify() {}
/// Called after the all modifications
@protected
@mustCallSuper
void onAfterAnyModify() {}
RET modify<RET>(RET Function() modify) {
if (isDirty || _isInModify) {
try {
if (_isDirty || _isInModify) {
return modify();
} finally {
_isDirty = TimeSequence.now();
onAfterAnyModify();
}
}
_isInModify = true;
onBeforeModify();
try {
return modify();
} finally {
_isDirty = TimeSequence.now();
_isDirty = true;
_isInModify = false;
onAfterModify();
onAfterAnyModify();
_controller.add(ChangeEvent(object: this as T, isDirty: isDirty));
_controller.add(ChangeEvent(object: this as T, isDirty: _isDirty));
}
}
bool clean(TimeSequence savedAt) {
final dirty = _isDirty;
if (dirty == null) {
_logger.warning('clean() called, even though we are not even dirty.');
return false;
void clean() {
if (!_isDirty) {
return;
}
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;
_isDirty = false;
_controller.add(ChangeEvent(object: this as T, isDirty: _isDirty));
}
bool get isDirty => _isDirty != null;
bool get isDirty => _isDirty;
}
abstract class KdbxNodeContext implements KdbxNode {
@ -106,7 +87,7 @@ abstract class KdbxNodeContext implements KdbxNode {
abstract class KdbxNode with Changeable<KdbxNode> {
KdbxNode.create(String nodeName) : node = XmlElement(XmlName(nodeName)) {
_isDirty = TimeSequence.now();
_isDirty = true;
}
KdbxNode.read(this.node);
@ -120,8 +101,10 @@ 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();
}
}
@ -165,7 +148,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),
@ -180,11 +163,8 @@ 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;
@ -202,35 +182,24 @@ 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 onAfterAnyModify() {
super.onAfterAnyModify();
void onAfterModify() {
super.onAfterModify();
times.modifiedNow();
// during initial `create` the file will be null.
_file?.dirtyObject(this);
file?.dirtyObject(this);
}
bool wasModifiedAfter(KdbxObject other) => times.lastModificationTime
@ -248,28 +217,11 @@ abstract class KdbxObject extends KdbxNode {
return el;
}
@internal
void internalChangeParent(KdbxGroup? parent) {
modify(() {
previousParentGroup.set(_parent?.uuid);
_parent = parent;
});
void internalChangeParent(KdbxGroup parent) {
modify(() => _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<int>(
static final typeUInt32 = ValueType(
0x04,
(reader, _) => reader.readUint32(),
(writer, value) => writer.writeUint32(value, writer._lengthWriter()),
);
static final typeUInt64 = ValueType<int>(
static final typeUInt64 = ValueType(
0x05,
(reader, _) => reader.readUint64(),
(writer, value) => writer.writeUint64(value, writer._lengthWriter()),
);
static final typeBool = ValueType<bool>(
static final typeBool = ValueType(
0x08,
(reader, _) => reader.readUint8() != 0,
(writer, value) => writer.writeUint8(value ? 1 : 0, writer._lengthWriter()),
);
static final typeInt32 = ValueType<int>(
static final typeInt32 = ValueType(
0x0C,
(reader, _) => reader.readInt32(),
(writer, value) => writer.writeInt32(value, writer._lengthWriter()),
);
static final typeInt64 = ValueType<int>(
static final typeInt64 = ValueType(
0x0D,
(reader, _) => reader.readInt64(),
(writer, value) => writer.writeInt64(value, writer._lengthWriter()),
);
static final typeString = ValueType<String>(
static final typeString = ValueType(
0x18,
(reader, length) => reader.readString(length),
(writer, value) => writer.writeString(value, writer._lengthWriter()),
);
static final typeBytes = ValueType<Uint8List>(
static final typeBytes = ValueType(
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,9 +28,7 @@ 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);
const endian = Endian.little;
final _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,9 +20,8 @@ class KdbxPrintUtils {
for (final group in group.groups) {
catGroup(buf, group, depth: depth + 1);
}
String? valueToSting(StringValue? value) {
return forceDecrypt! ? value?.getText() : value?.toString();
}
final valueToSting = (StringValue? value) =>
forceDecrypt! ? value?.getText() : value?.toString();
for (final entry in group.entries) {
final value = entry.getString(KdbxKeyCommon.PASSWORD);
@ -35,7 +34,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

@ -1,28 +0,0 @@
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,21 +1,25 @@
name: kdbx
description: KeepassX format implementation in pure dart. (kdbx 3.x and 4.x support).
version: 2.4.0
version: 2.0.0
homepage: https://github.com/authpass/kdbx.dart
publish_to: none
environment:
sdk: '>=2.12.0 <4.0.0'
sdk: '>=2.12.0 <3.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.4.0 <4.0.0'
xml: '>=4.4.0 <7.0.0'
pointycastle: '>=3.0.0 <4.0.0'
xml: '>=4.4.0 <6.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'
@ -23,15 +27,14 @@ dependencies:
supercharged_dart: '>=1.2.0 <4.0.0'
synchronized: '>=2.2.0 <4.0.0'
collection: '>=1.15.0 <2.0.0'
collection: ^1.15.0-nullsafety.4
# required for bin/
args: '>1.5.0 <3.0.0'
logging_appenders: '>=0.1.0 <2.0.0'
argon2_ffi_base:
path: ../argon2_ffi_base
argon2_ffi_base: ^1.0.0
dev_dependencies:
flutter_lints: '>=2.0.0 <3.0.0'
pedantic: '>=1.7.0 <2.0.0'
test: '>=1.6.0 <2.0.0'
fake_async: ^1.2.0
fake_async: ^1.1.0

61
test/deleted_objects_test.dart

@ -1,79 +1,24 @@
@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() {
final testUtil = TestUtil();
TestUtil.setupLogging();
_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() {
final testUtil = TestUtil();
TestUtil.setupLogging();
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,21 +10,12 @@ 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);
late final kdbxFormat = _kdbxFormat();
static KdbxFormat _kdbxFormat() {
static KdbxFormat kdbxFormat() {
Argon2.resolveLibraryForceDynamic = true;
return KdbxFormat(Argon2FfiFlutter(resolveLibrary: (path) {
final cwd = Directory('.').absolute.uri;
@ -35,54 +26,42 @@ class TestUtil {
}));
}
Future<KdbxFile> readKdbxFile(
static 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;
}
Future<KdbxFile> readKdbxFileBytes(Uint8List data,
static 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;
}
Future<KdbxFile> saveAndRead(KdbxFile file) async {
static Future<KdbxFile> saveAndRead(KdbxFile file) async {
return await readKdbxFileBytes(await file.save(),
credentials: file.credentials);
}
Future<void> saveTestOutput(String name, KdbxFile file) async {
static 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');
}
KdbxFile createEmptyFile() {
final file = kdbxFormat.create(
static 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,9 +1,11 @@
@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';
@ -13,11 +15,9 @@ final _logger = Logger('kdbx4_test');
// ignore_for_file: non_constant_identifier_names
void main() {
final testUtil = TestUtil();
final kdbxFormat = testUtil.kdbxFormat;
if (!kdbxFormat.argon2.isFfi) {
throw StateError('Expected ffi!');
}
Logger.root.level = Level.ALL;
PrintAppender().attachToLogger(Logger.root);
final kdbxFormat = TestUtil.kdbxFormat();
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

@ -1,59 +0,0 @@
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,6 +2,8 @@ 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';
@ -16,7 +18,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);
@ -32,7 +34,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();
@ -46,7 +48,8 @@ Future<void> _testAddNewAttachment(String filePath) async {
}
void main() {
final testUtil = TestUtil();
Logger.root.level = Level.ALL;
PrintAppender().attachToLogger(Logger.root);
group('kdbx3 attachment', () {
void expectKeepass2binariesContents(KdbxEntry entry) {
@ -55,10 +58,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));
@ -70,29 +73,28 @@ 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');
void updateEntry(KdbxFile file) {
await TestUtil.readKdbxFile('test/keepass2binaries.kdbx');
final 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 {
@ -100,7 +102,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));
@ -108,7 +110,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)),
@ -122,12 +124,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,
);
}
@ -136,7 +138,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',
@ -146,9 +148,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'));
@ -158,7 +160,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'));
@ -171,7 +173,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

@ -1,57 +0,0 @@
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() {
final testUtil = TestUtil();
TestUtil.setupLogging();
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,6 +5,7 @@ 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';
@ -21,8 +22,9 @@ class FakeProtectedSaltGenerator implements ProtectedSaltGenerator {
}
void main() {
final testUtil = TestUtil();
final kdbxFormat = testUtil.kdbxFormat;
Logger.root.level = Level.ALL;
PrintAppender().attachToLogger(Logger.root);
final kdbxFormat = KdbxFormat();
group('Reading', () {
setUp(() {});
@ -92,7 +94,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');
@ -101,7 +103,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);
@ -110,7 +112,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);
@ -142,7 +144,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 {
@ -150,7 +152,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() {
final testUtil = TestUtil();
TestUtil.setupLogging();
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

@ -1,45 +0,0 @@
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,18 +1,15 @@
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() {
final testUtil = TestUtil();
TestUtil.setupLogging();
var now = DateTime.fromMillisecondsSinceEpoch(0);
final fakeClock = Clock(() => now);
@ -25,18 +22,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));
@ -46,13 +43,15 @@ 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()}');
final file2 = await testUtil.saveAndRead(fileMod);
_logger.info('mod date: ' +
fileMod.body.rootGroup.entries.first.times.lastModificationTime
.get()
.toString());
final file2 = await TestUtil.saveAndRead(fileMod);
_logger.info('\n\n\nstarting merge.\n');
final merge = file.merge(file2);
@ -67,10 +66,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));
@ -82,48 +81,21 @@ 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