diff --git a/example/pubspec.lock b/example/pubspec.lock index b51bd78..de62edf 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -132,6 +132,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.2" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.6" meta: dependency: transitive description: @@ -167,6 +174,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.1" + quiver: + dependency: transitive + description: + name: quiver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" rxdart: dependency: transitive description: @@ -186,6 +200,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.5.5" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.3" string_scanner: dependency: transitive description: diff --git a/lib/src/kdbx_binary.dart b/lib/src/kdbx_binary.dart index f737df6..a880cbd 100644 --- a/lib/src/kdbx_binary.dart +++ b/lib/src/kdbx_binary.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'package:kdbx/src/internal/byte_utils.dart'; import 'package:kdbx/src/kdbx_header.dart'; import 'package:kdbx/src/kdbx_xml.dart'; import 'package:meta/meta.dart'; @@ -24,6 +25,15 @@ class KdbxBinary { ); } + InnerHeaderField writeToInnerHeader() { + final writer = WriterHelper(); + final flags = isProtected ? 0x01 : 0x00; + writer.writeUint8(flags); + writer.writeBytes(value); + return InnerHeaderField( + InnerHeaderFields.Binary, writer.output.takeBytes()); + } + static KdbxBinary readBinaryXml(XmlElement valueNode, {@required bool isInline}) { assert(isInline != null); @@ -39,4 +49,11 @@ class KdbxBinary { value: value, ); } + + void saveToXml(XmlElement valueNode) { + final content = base64.encode(gzip.encode(value)); + valueNode.addAttributeBool(KdbxXml.ATTR_PROTECTED, isProtected); + valueNode.addAttributeBool(KdbxXml.ATTR_COMPRESSED, true); + valueNode.children.add(XmlText(content)); + } } diff --git a/lib/src/kdbx_entry.dart b/lib/src/kdbx_entry.dart index 8c66847..ef1c9c8 100644 --- a/lib/src/kdbx_entry.dart +++ b/lib/src/kdbx_entry.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:kdbx/kdbx.dart'; import 'package:kdbx/src/crypto/protected_value.dart'; import 'package:kdbx/src/kdbx_binary.dart'; @@ -8,6 +10,8 @@ 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'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as path; import 'package:xml/xml.dart'; final _logger = Logger('kdbx.kdbx_entry'); @@ -103,13 +107,12 @@ class KdbxEntry extends KdbxObject { final el = super.toXml(); XmlUtils.removeChildrenByName(el, KdbxXml.NODE_STRING); XmlUtils.removeChildrenByName(el, KdbxXml.NODE_HISTORY); - el.children.removeWhere( - (e) => e is XmlElement && e.name.local == KdbxXml.NODE_STRING); + XmlUtils.removeChildrenByName(el, KdbxXml.NODE_BINARY); el.children.addAll(stringEntries.map((stringEntry) { final value = XmlElement(XmlName(KdbxXml.NODE_VALUE)); if (stringEntry.value is ProtectedValue) { - value.attributes - .add(XmlAttribute(XmlName(KdbxXml.ATTR_PROTECTED), 'True')); + value.attributes.add( + XmlAttribute(XmlName(KdbxXml.ATTR_PROTECTED), KdbxXml.VALUE_TRUE)); KdbxFile.setProtectedValueForNode( value, stringEntry.value as ProtectedValue); } else if (stringEntry.value is StringValue) { @@ -122,6 +125,22 @@ class KdbxEntry extends KdbxObject { value, ]); })); + el.children.addAll(binaryEntries.map((binaryEntry) { + final key = binaryEntry.key; + final binary = binaryEntry.value; + final value = XmlElement(XmlName(KdbxXml.NODE_VALUE)); + if (binary.isInline) { + binary.saveToXml(value); + } else { + final binaryIndex = file.ctx.findBinaryId(binary); + value.addAttribute(KdbxXml.ATTR_REF, binaryIndex.toString()); + } + return XmlElement(XmlName(KdbxXml.NODE_BINARY)) + ..children.addAll([ + XmlElement(XmlName(KdbxXml.NODE_KEY))..children.add(XmlText(key.key)), + value, + ]); + })); if (!isHistoryEntry) { el.children.add( XmlElement(XmlName(KdbxXml.NODE_HISTORY)) @@ -177,6 +196,43 @@ class KdbxEntry extends KdbxObject { String get label => _plainValue(KdbxKey('Title')); + set label(String label) => setString(KdbxKey('Title'), PlainValue(label)); + + KdbxBinary createBinary({ + @required bool isProtected, + @required String name, + @required Uint8List bytes, + }) { + assert(isProtected != null); + assert(bytes != null); + assert(name != null); + // make sure we don't have a path, just the file name. + final key = _uniqueBinaryName(path.basename(name)); + final binary = KdbxBinary( + isInline: false, + isProtected: isProtected, + value: bytes, + ); + file.ctx.addBinary(binary); + _binaries[key] = binary; + isDirty = true; + return binary; + } + + KdbxKey _uniqueBinaryName(String fileName) { + final lastIndex = fileName.lastIndexOf('.'); + final baseName = + lastIndex > -1 ? fileName.substring(0, lastIndex) : fileName; + final ext = lastIndex > -1 ? fileName.substring(lastIndex + 1) : 'ext'; + for (var i = 0; i < 1000; i++) { + final k = i == 0 ? KdbxKey(fileName) : KdbxKey('$baseName$i.$ext'); + if (!_binaries.containsKey(k)) { + return k; + } + } + throw StateError('Unable to find unique name for $fileName'); + } + @override String toString() { return 'KdbxGroup{uuid=$uuid,name=$label}'; diff --git a/lib/src/kdbx_format.dart b/lib/src/kdbx_format.dart index a0b796e..d2a5cb3 100644 --- a/lib/src/kdbx_format.dart +++ b/lib/src/kdbx_format.dart @@ -64,11 +64,15 @@ class KeyFileComposite implements Credentials { /// Context used during reading and writing. class KdbxReadWriteContext { - KdbxReadWriteContext({@required this.binaries, @required this.header}) - : assert(binaries != null), - assert(header != null); + KdbxReadWriteContext({ + @required List binaries, + @required this.header, + }) : assert(binaries != null), + assert(header != null), + _binaries = binaries; static final kdbxContext = Expando(); + static KdbxReadWriteContext kdbxContextForNode(xml.XmlParent node) { final ret = kdbxContext[node.document]; if (ret == null) { @@ -83,17 +87,36 @@ class KdbxReadWriteContext { } @protected - final List binaries; + final List _binaries; + + Iterable get binariesIterable => _binaries; final KdbxHeader header; int get versionMajor => header.versionMajor; KdbxBinary binaryById(int id) { - if (id >= binaries.length) { + if (id >= _binaries.length) { return null; } - return binaries[id]; + return _binaries[id]; + } + + void addBinary(KdbxBinary binary) { + _binaries.add(binary); + } + + /// finds the ID of the given binary. + /// if it can't be found, [KdbxCorruptedFileException] is thrown. + int findBinaryId(KdbxBinary binary) { + assert(binary != null); + assert(!binary.isInline); + final id = _binaries.indexOf(binary); + if (id < 0) { + throw KdbxCorruptedFileException('Unable to find binary.' + ' (${binary.value.length},${binary.isInline})'); + } + return id; } } @@ -191,6 +214,7 @@ class KdbxBody extends KdbxNode { ProtectedSaltGenerator saltGenerator, _KeysV4 keys) { final bodyWriter = WriterHelper(); final xml = generateXml(saltGenerator); + kdbxFile.header.innerHeader.updateBinaries(kdbxFile.ctx.binariesIterable); kdbxFile.header.writeInnerHeader(bodyWriter); bodyWriter.writeBytes(utf8.encode(xml.toXmlString()) as Uint8List); final compressedBytes = (kdbxFile.header.compression == Compression.gzip @@ -590,9 +614,9 @@ class KdbxFormat { final kdbxMeta = KdbxMeta.read(meta, ctx); if (kdbxMeta.binaries?.isNotEmpty == true) { - ctx.binaries.addAll(kdbxMeta.binaries); + ctx._binaries.addAll(kdbxMeta.binaries); } else if (header.innerHeader.binaries.isNotEmpty) { - ctx.binaries.addAll(header.innerHeader.binaries + ctx._binaries.addAll(header.innerHeader.binaries .map((e) => KdbxBinary.readBinaryInnerHeader(e))); } diff --git a/lib/src/kdbx_header.dart b/lib/src/kdbx_header.dart index 06454d0..52842da 100644 --- a/lib/src/kdbx_header.dart +++ b/lib/src/kdbx_header.dart @@ -5,10 +5,11 @@ import 'package:crypto/crypto.dart' as crypto; import 'package:kdbx/src/crypto/key_encrypter_kdf.dart'; import 'package:kdbx/src/internal/byte_utils.dart'; import 'package:kdbx/src/internal/consts.dart'; +import 'package:kdbx/src/kdbx_binary.dart'; import 'package:kdbx/src/kdbx_var_dictionary.dart'; +import 'package:kdbx/src/utils/scope_functions.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; -import 'package:kdbx/src/utils/scope_functions.dart'; final _logger = Logger('kdbx.header'); @@ -224,6 +225,7 @@ class KdbxHeader { } void writeInnerHeader(WriterHelper writer) { + assert(versionMajor >= 4); _validateInner(); for (final field in InnerHeaderFields.values .where((f) => f != InnerHeaderFields.EndOfHeader)) { @@ -541,4 +543,9 @@ class InnerHeader { binaries.clear(); binaries.addAll(other.binaries); } + + void updateBinaries(Iterable newBinaries) { + binaries.clear(); + binaries.addAll(newBinaries.map((binary) => binary.writeToInnerHeader())); + } } diff --git a/lib/src/kdbx_meta.dart b/lib/src/kdbx_meta.dart index cfbc859..be7a85f 100644 --- a/lib/src/kdbx_meta.dart +++ b/lib/src/kdbx_meta.dart @@ -6,7 +6,9 @@ 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'; +import 'package:quiver/iterables.dart'; import 'package:xml/xml.dart' as xml; +import 'package:xml/xml.dart'; class KdbxMeta extends KdbxNode implements KdbxNodeContext { KdbxMeta.create({ @@ -61,5 +63,23 @@ class KdbxMeta extends KdbxNode implements KdbxNodeContext { DateTimeUtcNode(this, 'RecycleBinChanged'); @override - xml.XmlElement toXml() => super.toXml()..replaceSingle(customData.toXml()); + xml.XmlElement toXml() { + final ret = super.toXml()..replaceSingle(customData.toXml()); + XmlUtils.removeChildrenByName(ret, KdbxXml.NODE_BINARIES); + // with kdbx >= 4 we assume the binaries were already written in the header. + if (ctx.versionMajor < 4) { + ret.children.add( + XmlElement(XmlName(KdbxXml.NODE_BINARIES)) + ..children.addAll( + enumerate(ctx.binariesIterable).map((indexed) { + final xmlBinary = XmlUtils.createNode(KdbxXml.NODE_BINARY) + ..addAttribute(KdbxXml.ATTR_ID, indexed.index.toString()); + indexed.value.saveToXml(xmlBinary); + return xmlBinary; + }), + ), + ); + } + return ret; + } } diff --git a/lib/src/kdbx_xml.dart b/lib/src/kdbx_xml.dart index e3b823c..5426e37 100644 --- a/lib/src/kdbx_xml.dart +++ b/lib/src/kdbx_xml.dart @@ -21,11 +21,20 @@ class KdbxXml { static const ATTR_REF = 'Ref'; static const NODE_CUSTOM_DATA_ITEM = 'Item'; + + static const String VALUE_TRUE = 'True'; + static const String VALUE_FALSE = 'False'; } extension XmlElementKdbx on XmlElement { bool getAttributeBool(String name) => getAttribute(name)?.toLowerCase() == 'true'; + + void addAttribute(String name, String value) => + attributes.add(XmlAttribute(XmlName(name), value)); + + void addAttributeBool(String name, bool value) => + addAttribute(name, value ? KdbxXml.VALUE_TRUE : KdbxXml.VALUE_FALSE); } abstract class KdbxSubNode { diff --git a/pubspec.yaml b/pubspec.yaml index bb4b9fb..b01f7f1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,8 @@ dependencies: clock: '>=1.0.0 <2.0.0' convert: '>=2.0.0 <3.0.0' isolate: '>=2.0.3 <3.0.0' + path: '>=1.6.0 <2.0.0' + quiver: '>=2.1.0 <3.0.0' collection: '>=1.14.0 <2.0.0' diff --git a/test/kdbx_binaries_test.dart b/test/kdbx_binaries_test.dart index b05a8ab..e474877 100644 --- a/test/kdbx_binaries_test.dart +++ b/test/kdbx_binaries_test.dart @@ -16,6 +16,37 @@ void expectBinary(KdbxEntry entry, String key, dynamic matcher) { expect(binary.value.value, matcher); } +Future _testAddNewAttachment(String filePath) async { + final saved = await (() async { + final f = await TestUtil.readKdbxFile(filePath); + final entry = KdbxEntry.create(f, f.body.rootGroup); + entry.label = 'addattachment'; + f.body.rootGroup.addEntry(entry); + expect(entry.binaryEntries, hasLength(0)); + entry.createBinary( + isProtected: false, + name: 'test.txt', + bytes: utf8.encode('Content1') as Uint8List); + entry.createBinary( + isProtected: false, + name: 'test.txt', + bytes: utf8.encode('Content2') as Uint8List); + return await f.save(); + })(); + { + final file = await TestUtil.readKdbxFileBytes(saved); + final entry = file.body.rootGroup.entries + .firstWhere((e) => e.label == 'addattachment'); + final binaries = entry.binaryEntries.toList(); + expect(entry.binaryEntries, hasLength(2)); + expect(binaries[0].key.key, 'test.txt'); + expect(binaries[0].value.value, IsUtf8String('Content1')); + // must have been renamed. + expect(binaries[1].key.key, 'test1.txt'); + expect(binaries[1].value.value, IsUtf8String('Content2')); + } +} + void main() { Logger.root.level = Level.ALL; PrintAppender().attachToLogger(Logger.root); @@ -54,6 +85,9 @@ void main() { final entry = file.body.rootGroup.entries.first; expectKeepass2binariesContents(entry); }); + test('Add new attachment', () async { + await _testAddNewAttachment('test/keepass2binaries.kdbx'); + }); }); group('kdbx4 attachment', () { test('read binary', () async { @@ -66,17 +100,20 @@ void main() { expectBinary(file.body.rootGroup.entries.last, 'keepasslogo.jpeg', hasLength(7092)); }); - }); - test('read, write, read', () async { - final fileRead = - await TestUtil.readKdbxFile('test/keepass2kdbx4binaries.kdbx'); - final saved = await fileRead.save(); - 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')); - expectBinary( - file.body.rootGroup.entries.last, 'keepasslogo.jpeg', hasLength(7092)); + test('read, write, read kdbx4', () async { + final fileRead = + await TestUtil.readKdbxFile('test/keepass2kdbx4binaries.kdbx'); + final saved = await fileRead.save(); + 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')); + expectBinary(file.body.rootGroup.entries.last, 'keepasslogo.jpeg', + hasLength(7092)); + }); + test('Add new attachment kdbx4', () async { + await _testAddNewAttachment('test/keepass2kdbx4binaries.kdbx'); + }); }); }