From c60602a37c82af6c5012bf5af1a9fd51e3e32545 Mon Sep 17 00:00:00 2001 From: Herbert Poul Date: Sat, 9 May 2020 15:36:06 +0200 Subject: [PATCH] first version of reading binaries. --- bin/kdbx.dart | 3 ++ lib/src/kdbx_binary.dart | 42 +++++++++++++++++++ lib/src/kdbx_entry.dart | 43 +++++++++++++++----- lib/src/kdbx_file.dart | 2 +- lib/src/kdbx_format.dart | 61 ++++++++++++++++++++++------ lib/src/kdbx_group.dart | 7 ++-- lib/src/kdbx_header.dart | 66 +++++++++++++++++++++++------- lib/src/kdbx_meta.dart | 18 +++++++++ lib/src/kdbx_xml.dart | 10 +++++ test/internal/test_utils.dart | 58 +++++++++++++++++++++++++++ test/kdbx4_test.dart | 59 ++------------------------- test/kdbx_binaries_test.dart | 69 ++++++++++++++++++++++++++++++++ test/kdbx_test.dart | 2 +- test/keepass2binaries.kdbx | Bin 0 -> 9470 bytes test/keepass2kdbx4binaries.kdbx | Bin 0 -> 9477 bytes 15 files changed, 344 insertions(+), 96 deletions(-) create mode 100644 lib/src/kdbx_binary.dart create mode 100644 test/internal/test_utils.dart create mode 100644 test/kdbx_binaries_test.dart create mode 100644 test/keepass2binaries.kdbx create mode 100644 test/keepass2kdbx4binaries.kdbx diff --git a/bin/kdbx.dart b/bin/kdbx.dart index 85c285b..18a97c0 100644 --- a/bin/kdbx.dart +++ b/bin/kdbx.dart @@ -131,6 +131,9 @@ class CatCommand extends KdbxFileCommand { final value = entry.getString(KdbxKey('Password')); print( '$indent `- ${entry.getString(KdbxKey('Title'))?.getText()}: ${forceDecrypt ? value?.getText() : value?.toString()}'); + print(entry.binaryEntries + .map((b) => '$indent `- file: ${b.key} - ${b.value.value.length}') + .join('\n')); } } } diff --git a/lib/src/kdbx_binary.dart b/lib/src/kdbx_binary.dart new file mode 100644 index 0000000..f737df6 --- /dev/null +++ b/lib/src/kdbx_binary.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:kdbx/src/kdbx_header.dart'; +import 'package:kdbx/src/kdbx_xml.dart'; +import 'package:meta/meta.dart'; +import 'package:xml/xml.dart'; + +class KdbxBinary { + KdbxBinary({this.isInline, this.isProtected, this.value}); + final bool isInline; + final bool isProtected; + final Uint8List value; + + static KdbxBinary readBinaryInnerHeader(InnerHeaderField field) { + final flags = field.bytes[0]; + final isProtected = flags & 0x01 == 0x01; + final value = Uint8List.sublistView(field.bytes, 1); + return KdbxBinary( + isInline: false, + isProtected: isProtected, + value: value, + ); + } + + static KdbxBinary readBinaryXml(XmlElement valueNode, + {@required bool isInline}) { + assert(isInline != null); + final isProtected = valueNode.getAttributeBool(KdbxXml.ATTR_PROTECTED); + final isCompressed = valueNode.getAttributeBool(KdbxXml.ATTR_COMPRESSED); + var value = base64.decode(valueNode.text.trim()); + if (isCompressed) { + value = gzip.decode(value) as Uint8List; + } + return KdbxBinary( + isInline: isInline, + isProtected: isProtected, + value: value, + ); + } +} diff --git a/lib/src/kdbx_entry.dart b/lib/src/kdbx_entry.dart index c1e32f0..96a7285 100644 --- a/lib/src/kdbx_entry.dart +++ b/lib/src/kdbx_entry.dart @@ -1,7 +1,13 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:kdbx/kdbx.dart'; import 'package:kdbx/src/crypto/protected_value.dart'; +import 'package:kdbx/src/kdbx_binary.dart'; import 'package:kdbx/src/kdbx_consts.dart'; import 'package:kdbx/src/kdbx_file.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'; @@ -36,7 +42,7 @@ class KdbxEntry extends KdbxObject { icon.set(KdbxIcon.Key); } - KdbxEntry.read(KdbxGroup parent, XmlElement node, + KdbxEntry.read(KdbxReadWriteContext ctx, KdbxGroup parent, XmlElement node, {this.isHistoryEntry = false}) : super.read(parent, node) { _strings.addEntries(node.findElements(KdbxXml.NODE_STRING).map((el) { @@ -49,18 +55,32 @@ class KdbxEntry extends KdbxObject { return MapEntry(key, PlainValue(valueNode.text)); } })); + _binaries.addEntries(node.findElements(KdbxXml.NODE_BINARY).map((el) { + final key = KdbxKey(el.findElements(KdbxXml.NODE_KEY).single.text); + final valueNode = el.findElements(KdbxXml.NODE_VALUE).single; + final ref = valueNode.getAttribute(KdbxXml.ATTR_REF); + if (ref != null) { + final refId = int.parse(ref); + final binary = ctx.binaryById(refId); + if (binary == null) { + throw KdbxCorruptedFileException( + 'Unable to find binary with id $refId'); + } + return MapEntry(key, binary); + } + + return MapEntry(key, KdbxBinary.readBinaryXml(valueNode, isInline: true)); + })); + history = _historyElement + .findElements('Entry') + .map( + (entry) => KdbxEntry.read(ctx, parent, entry, isHistoryEntry: true)) + .toList(); } final bool isHistoryEntry; - List _history; - - List get history => _history ??= (() { - return _historyElement - .findElements('Entry') - .map((entry) => KdbxEntry.read(parent, entry, isHistoryEntry: true)) - .toList(); - })(); + List history; XmlElement get _historyElement => node .findElements(KdbxXml.NODE_HISTORY) @@ -114,6 +134,11 @@ class KdbxEntry extends KdbxObject { final Map _strings = {}; + final Map _binaries = {}; + + Iterable> get binaryEntries => + _binaries.entries; + // Map get strings => UnmodifiableMapView(_strings); Iterable> get stringEntries => diff --git a/lib/src/kdbx_file.dart b/lib/src/kdbx_file.dart index 16926c4..eb70523 100644 --- a/lib/src/kdbx_file.dart +++ b/lib/src/kdbx_file.dart @@ -2,13 +2,13 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:kdbx/src/crypto/protected_value.dart'; +import 'package:kdbx/src/kdbx_dao.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:logging/logging.dart'; import 'package:xml/xml.dart' as xml; -import 'package:kdbx/src/kdbx_dao.dart'; final _logger = Logger('kdbx_file'); diff --git a/lib/src/kdbx_format.dart b/lib/src/kdbx_format.dart index ee50301..a5b7011 100644 --- a/lib/src/kdbx_format.dart +++ b/lib/src/kdbx_format.dart @@ -14,6 +14,7 @@ import 'package:kdbx/src/crypto/protected_value.dart'; import 'package:kdbx/src/internal/byte_utils.dart'; import 'package:kdbx/src/internal/consts.dart'; import 'package:kdbx/src/internal/crypto_utils.dart'; +import 'package:kdbx/src/kdbx_binary.dart'; import 'package:kdbx/src/kdbx_file.dart'; import 'package:kdbx/src/kdbx_group.dart'; import 'package:kdbx/src/kdbx_header.dart'; @@ -61,6 +62,21 @@ class KeyFileComposite implements Credentials { } } +/// Context used during reading and writing. +class KdbxReadWriteContext { + KdbxReadWriteContext({@required this.binaries}) : assert(binaries != null); + + @protected + final List binaries; + + KdbxBinary binaryById(int id) { + if (id >= binaries.length) { + return null; + } + return binaries[id]; + } +} + abstract class CredentialsPart { Uint8List getBinary(); } @@ -129,8 +145,11 @@ class KdbxBody extends KdbxNode { rootNode.children.add(rootGroup.node); } - KdbxBody.read(xml.XmlElement node, this.meta, this.rootGroup) - : super.read(node); + KdbxBody.read( + xml.XmlElement node, + this.meta, + this.rootGroup, + ) : super.read(node); // final xml.XmlDocument xmlDocument; final KdbxMeta meta; @@ -330,13 +349,14 @@ class KdbxFormat { final blocks = HashedBlockReader.readBlocks(ReaderHelper(content)); _logger.finer('compression: ${header.compression}'); + final ctx = KdbxReadWriteContext(binaries: []); if (header.compression == Compression.gzip) { final xml = GZipCodec().decode(blocks); final string = utf8.decode(xml); - return KdbxFile(this, credentials, header, _loadXml(header, string)); + return KdbxFile(this, credentials, header, _loadXml(ctx, header, string)); } else { - return KdbxFile( - this, credentials, header, _loadXml(header, utf8.decode(blocks))); + return KdbxFile(this, credentials, header, + _loadXml(ctx, header, utf8.decode(blocks))); } } @@ -370,11 +390,15 @@ class KdbxFormat { if (header.compression == Compression.gzip) { final content = GZipCodec().decode(decrypted) as Uint8List; final contentReader = ReaderHelper(content); - final headerFields = KdbxHeader.readInnerHeaderFields(contentReader, 4); + final innerHeader = KdbxHeader.readInnerHeaderFields(contentReader, 4); + // _logger.fine('inner header fields: $headerFields'); - header.innerFields.addAll(headerFields); +// header.innerFields.addAll(headerFields); + header.innerHeader.updateFrom(innerHeader); final xml = utf8.decode(contentReader.readRemaining()); - return KdbxFile(this, credentials, header, _loadXml(header, xml)); + final context = KdbxReadWriteContext(binaries: []); + return KdbxFile( + this, credentials, header, _loadXml(context, header, xml)); } throw StateError('Kdbx4 without compression is not yet supported.'); } @@ -518,14 +542,15 @@ class KdbxFormat { } } - KdbxBody _loadXml(KdbxHeader header, String xmlString) { + KdbxBody _loadXml( + KdbxReadWriteContext ctx, KdbxHeader header, String xmlString) { final gen = _createProtectedSaltGenerator(header); final document = xml.parse(xmlString); for (final el in document - .findAllElements('Value') - .where((el) => el.getAttribute('Protected')?.toLowerCase() == 'true')) { + .findAllElements(KdbxXml.NODE_VALUE) + .where((el) => el.getAttributeBool(KdbxXml.ATTR_PROTECTED))) { final pw = gen.decryptBase64(el.text.trim()); if (pw == null) { continue; @@ -536,9 +561,19 @@ class KdbxFormat { final keePassFile = document.findElements('KeePassFile').single; final meta = keePassFile.findElements('Meta').single; final root = keePassFile.findElements('Root').single; - final rootGroup = KdbxGroup.read(null, root.findElements('Group').single); + + final kdbxMeta = KdbxMeta.read(meta); + if (kdbxMeta.binaries?.isNotEmpty == true) { + ctx.binaries.addAll(kdbxMeta.binaries); + } else if (header.innerHeader.binaries.isNotEmpty) { + ctx.binaries.addAll(header.innerHeader.binaries + .map((e) => KdbxBinary.readBinaryInnerHeader(e))); + } + + final rootGroup = + KdbxGroup.read(ctx, null, root.findElements('Group').single); _logger.fine('got meta: ${meta.toXmlString(pretty: true)}'); - return KdbxBody.read(keePassFile, KdbxMeta.read(meta), rootGroup); + return KdbxBody.read(keePassFile, kdbxMeta, rootGroup); } Uint8List _decryptContent( diff --git a/lib/src/kdbx_group.dart b/lib/src/kdbx_group.dart index 7768e24..5da2bb8 100644 --- a/lib/src/kdbx_group.dart +++ b/lib/src/kdbx_group.dart @@ -19,14 +19,15 @@ class KdbxGroup extends KdbxObject { expanded.set(true); } - KdbxGroup.read(KdbxGroup parent, XmlElement node) : super.read(parent, node) { + KdbxGroup.read(KdbxReadWriteContext ctx, KdbxGroup parent, XmlElement node) + : super.read(parent, node) { node .findElements('Group') - .map((el) => KdbxGroup.read(this, el)) + .map((el) => KdbxGroup.read(ctx, this, el)) .forEach(_groups.add); node .findElements('Entry') - .map((el) => KdbxEntry.read(this, el)) + .map((el) => KdbxEntry.read(ctx, this, el)) .forEach(_entries.add); } diff --git a/lib/src/kdbx_header.dart b/lib/src/kdbx_header.dart index 8ba2348..2cb7365 100644 --- a/lib/src/kdbx_header.dart +++ b/lib/src/kdbx_header.dart @@ -90,7 +90,7 @@ class KdbxHeader { @required this.fields, @required this.endPos, Map innerFields, - }) : innerFields = innerFields ?? {}; + }) : innerHeader = InnerHeader(fields: innerFields ?? {}); KdbxHeader.create() : this( @@ -163,7 +163,7 @@ class KdbxHeader { InnerHeaderFields.InnerRandomStreamKey ]; for (final field in requiredFields) { - if (innerFields[field] == null) { + if (innerHeader.fields[field] == null) { throw KdbxCorruptedFileException('Missing inner header $field'); } } @@ -174,7 +174,7 @@ class KdbxHeader { } void _setInnerHeaderField(InnerHeaderFields field, Uint8List bytes) { - innerFields[field] = InnerHeaderField(field, bytes); + innerHeader.fields[field] = InnerHeaderField(field, bytes); } void generateSalts() { @@ -229,12 +229,13 @@ class KdbxHeader { .where((f) => f != InnerHeaderFields.EndOfHeader)) { _writeInnerField(writer, field); } + // TODO write attachments _setInnerHeaderField(InnerHeaderFields.EndOfHeader, Uint8List(0)); _writeInnerField(writer, InnerHeaderFields.EndOfHeader); } void _writeInnerField(WriterHelper writer, InnerHeaderFields field) { - final value = innerFields[field]; + final value = innerHeader.fields[field]; if (value == null) { return; } @@ -333,32 +334,41 @@ class KdbxHeader { readAllFields(reader, versionMajor, HeaderFields.values, (HeaderFields field, value) => HeaderField(field, value)); - static Map readInnerHeaderFields( + static InnerHeader readInnerHeaderFields( ReaderHelper reader, int versionMajor) => - readAllFields(reader, versionMajor, InnerHeaderFields.values, - (InnerHeaderFields field, value) => InnerHeaderField(field, value)); + InnerHeader.fromFields( + readField( + reader, + versionMajor, + InnerHeaderFields.values, + (InnerHeaderFields field, value) => + InnerHeaderField(field, value)).toList(growable: false), + ); static Map readAllFields, TE>( ReaderHelper reader, int versionMajor, List fields, - T createField(TE field, Uint8List bytes)) => + T Function(TE field, Uint8List bytes) createField) => Map.fromEntries( readField(reader, versionMajor, fields, createField) .map((field) => MapEntry(field.field, field))); - static Iterable readField(ReaderHelper reader, int versionMajor, - List fields, T createField(TE field, Uint8List bytes)) sync* { + static Iterable readField( + ReaderHelper reader, + int versionMajor, + List fields, + T Function(TE field, Uint8List bytes) createField) sync* { while (true) { final headerId = reader.readUint8(); final bodySize = versionMajor >= 4 ? reader.readUint32() : reader.readUint16(); -// _logger.fine('Reading header with id $headerId (size: $bodySize)}'); final bodyBytes = bodySize > 0 ? reader.readBytes(bodySize) : null; // _logger.finer( // 'Read header ${fields[headerId]}: ${ByteUtils.toHexList(bodyBytes)}'); if (headerId > 0) { final field = fields[headerId]; + _logger.finest('Reading header $field ($headerId) (size: $bodySize)}'); yield createField(field, bodyBytes); /* else { if (field == InnerHeaderFields.InnerRandomStreamID) { @@ -368,6 +378,7 @@ class KdbxHeader { } }*/ } else { + _logger.finest('EndOfHeader ${fields[headerId]}'); break; } } @@ -378,7 +389,7 @@ class KdbxHeader { final int versionMinor; final int versionMajor; final Map fields; - final Map innerFields; + final InnerHeader innerHeader; /// end position of the header, if we have been reading from a stream. final int endPos; @@ -400,11 +411,11 @@ class KdbxHeader { .values[ReaderHelper.singleUint32(_innerRandomStreamEncryptionBytes)]; Uint8List get _innerRandomStreamEncryptionBytes => versionMajor >= 4 - ? innerFields[InnerHeaderFields.InnerRandomStreamID].bytes + ? innerHeader.fields[InnerHeaderFields.InnerRandomStreamID].bytes : fields[HeaderFields.InnerRandomStreamID].bytes; Uint8List get protectedStreamKey => versionMajor >= 4 - ? innerFields[InnerHeaderFields.InnerRandomStreamKey].bytes + ? innerHeader.fields[InnerHeaderFields.InnerRandomStreamKey].bytes : fields[HeaderFields.ProtectedStreamKey].bytes; VarDictionary get readKdfParameters => VarDictionary.read( @@ -491,3 +502,30 @@ class HashedBlockReader { } } } + +class InnerHeader { + InnerHeader({ + @required this.fields, + List binaries, + }) : binaries = binaries ?? [], + assert(fields != null); + + factory InnerHeader.fromFields(Iterable fields) { + final fieldMap = Map.fromEntries(fields + .where((f) => f.field != InnerHeaderFields.Binary) + .map((e) => MapEntry(e.field, e))); + final binaries = + fields.where((f) => f.field == InnerHeaderFields.Binary).toList(); + return InnerHeader(fields: fieldMap, binaries: binaries); + } + + final Map fields; + final List binaries; + + void updateFrom(InnerHeader other) { + fields.clear(); + fields.addAll(other.fields); + binaries.clear(); + binaries.addAll(other.binaries); + } +} diff --git a/lib/src/kdbx_meta.dart b/lib/src/kdbx_meta.dart index d51a6ce..08e8db1 100644 --- a/lib/src/kdbx_meta.dart +++ b/lib/src/kdbx_meta.dart @@ -1,5 +1,7 @@ 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_header.dart'; import 'package:kdbx/src/kdbx_object.dart'; import 'package:kdbx/src/kdbx_xml.dart'; import 'package:meta/meta.dart'; @@ -10,6 +12,7 @@ class KdbxMeta extends KdbxNode { @required String databaseName, String generator, }) : customData = KdbxCustomData.create(), + binaries = [], super.create('Meta') { this.databaseName.set(databaseName); this.generator.set(generator ?? 'kdbx.dart'); @@ -20,10 +23,25 @@ class KdbxMeta extends KdbxNode { .singleElement('CustomData') ?.let((e) => KdbxCustomData.read(e)) ?? KdbxCustomData.create(), + binaries = node.singleElement(KdbxXml.NODE_BINARIES)?.let((el) sync* { + var i = 0; + for (final binaryNode in el.findElements(KdbxXml.NODE_BINARY)) { + final id = int.parse(binaryNode.getAttribute(KdbxXml.ATTR_ID)); + if (id != i) { + throw KdbxCorruptedFileException( + 'Invalid ID for binary. expected $i, but was $id'); + } + i++; + yield KdbxBinary.readBinaryXml(binaryNode, isInline: false); + } + })?.toList(), super.read(node); final KdbxCustomData customData; + /// only used in Kdbx 3 + final List binaries; + StringNode get generator => StringNode(this, 'Generator'); StringNode get databaseName => StringNode(this, 'DatabaseName'); diff --git a/lib/src/kdbx_xml.dart b/lib/src/kdbx_xml.dart index 1c42a15..08f0f85 100644 --- a/lib/src/kdbx_xml.dart +++ b/lib/src/kdbx_xml.dart @@ -13,11 +13,21 @@ class KdbxXml { static const NODE_KEY = 'Key'; static const NODE_VALUE = 'Value'; static const ATTR_PROTECTED = 'Protected'; + static const ATTR_COMPRESSED = 'Compressed'; static const NODE_HISTORY = 'History'; + static const NODE_BINARIES = 'Binaries'; + static const ATTR_ID = 'ID'; + static const NODE_BINARY = 'Binary'; + static const ATTR_REF = 'Ref'; static const NODE_CUSTOM_DATA_ITEM = 'Item'; } +extension XmlElementKdbx on XmlElement { + bool getAttributeBool(String name) => + getAttribute(name)?.toLowerCase() == 'true'; +} + abstract class KdbxSubNode { KdbxSubNode(this.node, this.name); diff --git a/test/internal/test_utils.dart b/test/internal/test_utils.dart new file mode 100644 index 0000000..873d35b --- /dev/null +++ b/test/internal/test_utils.dart @@ -0,0 +1,58 @@ +//typedef HashStuff = Pointer Function(Pointer str); +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +import 'package:kdbx/kdbx.dart'; + +typedef Argon2HashNative = Pointer Function( + Pointer key, + IntPtr keyLen, + Pointer salt, + Uint64 saltlen, + Uint32 m_cost, // memory cost + Uint32 t_cost, // time cost (number iterations) + Uint32 parallelism, + IntPtr hashlen, + Uint8 type, + Uint32 version, +); +typedef Argon2Hash = Pointer Function( + Pointer key, + int keyLen, + Pointer salt, + int saltlen, + int m_cost, // memory cost + int t_cost, // time cost (number iterations) + int parallelism, + int hashlen, + int type, + int version, +); + +class Argon2Test extends Argon2Base { + Argon2Test() { + final argon2lib = Platform.isMacOS + ? DynamicLibrary.open('libargon2_ffi.dylib') + : DynamicLibrary.open('./libargon2_ffi.so'); + argon2hash = argon2lib + .lookup>('hp_argon2_hash') + .asFunction(); + } + + @override + Argon2Hash argon2hash; +} + +class TestUtil { + static Future readKdbxFile( + String filePath, { + String password = 'asdf', + }) async { + final kdbxFormat = KdbxFormat(Argon2Test()); + final data = await File(filePath).readAsBytes(); + final file = await kdbxFormat.read( + data, Credentials(ProtectedValue.fromString(password))); + return file; + } +} diff --git a/test/kdbx4_test.dart b/test/kdbx4_test.dart index b9f76ea..6c8cd72 100644 --- a/test/kdbx4_test.dart +++ b/test/kdbx4_test.dart @@ -1,68 +1,17 @@ -import 'dart:ffi'; import 'dart:io'; -import 'package:ffi/ffi.dart'; 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'; + final _logger = Logger('kdbx4_test'); // ignore_for_file: non_constant_identifier_names -//typedef HashStuff = Pointer Function(Pointer str); -typedef Argon2HashNative = Pointer Function( - Pointer key, - IntPtr keyLen, - Pointer salt, - Uint64 saltlen, - Uint32 m_cost, // memory cost - Uint32 t_cost, // time cost (number iterations) - Uint32 parallelism, - IntPtr hashlen, - Uint8 type, - Uint32 version, -); -typedef Argon2Hash = Pointer Function( - Pointer key, - int keyLen, - Pointer salt, - int saltlen, - int m_cost, // memory cost - int t_cost, // time cost (number iterations) - int parallelism, - int hashlen, - int type, - int version, -); - -class Argon2Test extends Argon2Base { - Argon2Test() { - final argon2lib = Platform.isMacOS - ? DynamicLibrary.open('libargon2_ffi.dylib') - : DynamicLibrary.open('./libargon2_ffi.so'); - argon2hash = argon2lib - .lookup>('hp_argon2_hash') - .asFunction(); - } - - @override - Argon2Hash argon2hash; -} - -Future _readKdbxFile( - String filePath, { - String password = 'asdf', -}) async { - final kdbxFormat = KdbxFormat(Argon2Test()); - final data = await File(filePath).readAsBytes(); - final file = await kdbxFormat.read( - data, Credentials(ProtectedValue.fromString(password))); - return file; -} - void main() { Logger.root.level = Level.ALL; PrintAppender().attachToLogger(Logger.root); @@ -145,12 +94,12 @@ void main() { }); group('recycle bin test', () { test('empty recycle bin with "zero" uuid', () async { - final file = await _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 _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); diff --git a/test/kdbx_binaries_test.dart b/test/kdbx_binaries_test.dart new file mode 100644 index 0000000..1a92ee4 --- /dev/null +++ b/test/kdbx_binaries_test.dart @@ -0,0 +1,69 @@ +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'; + +void main() { + Logger.root.level = Level.ALL; + PrintAppender().attachToLogger(Logger.root); + + group('kdbx3 attachment', () { + test('read binary', () async { + final file = await TestUtil.readKdbxFile('test/keepass2binaries.kdbx'); + final entry = file.body.rootGroup.entries.first; + final binaries = entry.binaryEntries; + expect(binaries, hasLength(3)); + for (final binary in binaries) { + switch (binary.key.key) { + case 'example1.txt': + expect(utf8.decode(binary.value.value), 'content1 example\n\n'); + break; + case 'example2.txt': + expect(utf8.decode(binary.value.value), 'content2 example\n\n'); + break; + case 'keepasslogo.jpeg': + expect(binary.value.value, hasLength(7092)); + break; + default: + fail('invalid key. ${binary.key}'); + } + } + }); + }); + group('kdbx4 attachment', () { + test('read binary', () async { + final file = + await TestUtil.readKdbxFile('test/keepass2kdbx4binaries.kdbx'); + void expectBinary(KdbxEntry entry, String key, dynamic matcher) { + final binaries = entry.binaryEntries; + expect(binaries, hasLength(1)); + final binary = binaries.first; + expect(binary.key.key, key); + expect(binary.value.value, matcher); + } + + expect(file.body.rootGroup.entries, hasLength(2)); + expectBinary(file.body.rootGroup.entries.first, 'example2.txt', + IsUtf8String('content2 example\n\n')); + expectBinary(file.body.rootGroup.entries.last, 'keepasslogo.jpeg', + hasLength(7092)); + }); + }); +} + +class IsUtf8String extends CustomMatcher { + IsUtf8String(dynamic matcher) : super('is utf8 string', 'utf8', matcher); + + @override + Object featureValueOf(dynamic actual) { + if (actual is Uint8List) { + return utf8.decode(actual); + } + return super.featureValueOf(actual); + } +} diff --git a/test/kdbx_test.dart b/test/kdbx_test.dart index 02ee702..bb2f8d5 100644 --- a/test/kdbx_test.dart +++ b/test/kdbx_test.dart @@ -81,7 +81,7 @@ void main() { group('Integration', () { test('Simple save and load', () async { final credentials = Credentials(ProtectedValue.fromString('FooBar')); - final Uint8List saved = await (() async { + final saved = await (() async { final kdbx = kdbxForamt.create(credentials, 'CreateTest'); final rootGroup = kdbx.body.rootGroup; final entry = KdbxEntry.create(kdbx, rootGroup); diff --git a/test/keepass2binaries.kdbx b/test/keepass2binaries.kdbx new file mode 100644 index 0000000000000000000000000000000000000000..b317623ef2233f68f8c3581435fbb60777c92494 GIT binary patch literal 9470 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 Qiqq~x3RBtImI>f9)xWH9PXGV_ literal 0 HcmV?d00001 diff --git a/test/keepass2kdbx4binaries.kdbx b/test/keepass2kdbx4binaries.kdbx new file mode 100644 index 0000000000000000000000000000000000000000..508cb804b7649df37169ed44c6a7571dbb141087 GIT binary patch literal 9477 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 Xsrqyr!v>T3!db&fz%ikB00000)H^lr literal 0 HcmV?d00001