diff --git a/lib/src/internal/async_utils.dart b/lib/src/internal/async_utils.dart new file mode 100644 index 0000000..3ee99fc --- /dev/null +++ b/lib/src/internal/async_utils.dart @@ -0,0 +1,29 @@ +import 'dart:async'; + +/// Base class which can be used as a mixin directly, but you have to call `cancelSubscriptions`. +/// If used inside a [State], use [StreamSubscriberMixin]. +mixin StreamSubscriberBase { + final List> _subscriptions = >[]; + + /// Listens to a stream and saves it to the list of subscriptions. + void listen(Stream stream, void onData(dynamic data), {Function onError}) { + if (stream != null) { + _subscriptions.add(stream.listen(onData, onError: onError)); + } + } + + void handle(StreamSubscription subscription) { + _subscriptions.add(subscription); + } + + /// Cancels all streams that were previously added with listen(). + void cancelSubscriptions() { + _subscriptions.forEach(_cancelSubscription); + _subscriptions.clear(); + } + + Future _cancelSubscription(StreamSubscription subscription) => + subscription.cancel(); +} + +class StreamSubscriptions with StreamSubscriberBase {} diff --git a/lib/src/kdbx_format.dart b/lib/src/kdbx_format.dart index 4bc77ee..dbf7550 100644 --- a/lib/src/kdbx_format.dart +++ b/lib/src/kdbx_format.dart @@ -6,6 +6,7 @@ import 'package:convert/convert.dart' as convert; import 'package:crypto/crypto.dart' as crypto; import 'package:kdbx/src/crypto/protected_salt_generator.dart'; import 'package:kdbx/src/crypto/protected_value.dart'; +import 'package:kdbx/src/internal/async_utils.dart'; import 'package:kdbx/src/internal/byte_utils.dart'; import 'package:kdbx/src/internal/crypto_utils.dart'; import 'package:kdbx/src/kdbx_group.dart'; @@ -34,8 +35,10 @@ class Credentials { } } -class KdbxFile { - KdbxFile(this.credentials, this.header, this.body); +class KdbxFile with Changeable { + KdbxFile(this.credentials, this.header, this.body) { + _subscribeToChildren(); + } static final protectedValues = Expando(); @@ -43,23 +46,57 @@ class KdbxFile { return protectedValues[node]; } - static void setProtectedValueForNode(xml.XmlElement node, ProtectedValue value) { + static void setProtectedValueForNode( + xml.XmlElement node, ProtectedValue value) { protectedValues[node] = value; } + final StreamSubscriptions _subscriptions = StreamSubscriptions(); + final Credentials credentials; final KdbxHeader header; final KdbxBody body; Uint8List save() { + assert(header.versionMajor == 3); final output = BytesBuilder(); final writer = WriterHelper(output); header.generateSalts(); header.write(writer); - body.write(writer, this); + + final streamKey = + header.fields[HeaderFields.ProtectedStreamKey].bytes.asUint8List(); + final gen = ProtectedSaltGenerator(streamKey); + + body.meta.headerHash.set( + (crypto.sha256.convert(writer.output.toBytes()).bytes as Uint8List) + .buffer); + body.writeV3(writer, this, gen); return output.toBytes(); } + Iterable get _allObjects => body.rootGroup + .getAllGroups() + .cast() + .followedBy(body.rootGroup.getAllEntries()); + + void _subscribeToChildren() { + final allObjects = _allObjects; + for (final obj in allObjects) { + _subscriptions.handle(obj.changes.listen((event) { + if (event.isDirty) { + isDirty = true; + if (event.object is KdbxGroup) { + Future(() { + // resubscribe, just in case some child groups/entries have changed. + _subscriptions.cancelSubscriptions(); + _subscribeToChildren(); + }); + } + } + })); + } + } } class KdbxBody extends KdbxNode { @@ -77,35 +114,32 @@ class KdbxBody extends KdbxNode { final KdbxMeta meta; final KdbxGroup rootGroup; - void write(WriterHelper writer, KdbxFile kdbxFile) { - assert(kdbxFile.header.versionMajor == 3); - final streamKey = kdbxFile - .header.fields[HeaderFields.ProtectedStreamKey].bytes - .asUint8List(); - final gen = ProtectedSaltGenerator(streamKey); - - _writeV3(writer, kdbxFile, gen); - } - - void _writeV3(WriterHelper writer, KdbxFile kdbxFile, + void writeV3(WriterHelper writer, KdbxFile kdbxFile, ProtectedSaltGenerator saltGenerator) { - meta.headerHash.set( - (crypto.sha256.convert(writer.output.toBytes()).bytes as Uint8List) - .buffer); final xml = generateXml(saltGenerator); final xmlBytes = utf8.encode(xml.toXmlString()); - final Uint8List compressedBytes = (kdbxFile.header.compression == Compression.gzip ? - GZipCodec().encode(xmlBytes) : xmlBytes) as Uint8List; + final Uint8List compressedBytes = + (kdbxFile.header.compression == Compression.gzip + ? GZipCodec().encode(xmlBytes) + : xmlBytes) as Uint8List; + final encrypted = _encryptV3(kdbxFile, compressedBytes); + writer.writeBytes(encrypted); + } + + Uint8List _encryptV3(KdbxFile kdbxFile, Uint8List compressedBytes) { final byteWriter = WriterHelper(); - byteWriter.writeBytes(kdbxFile.header.fields[HeaderFields.StreamStartBytes].bytes.asUint8List()); + byteWriter.writeBytes(kdbxFile + .header.fields[HeaderFields.StreamStartBytes].bytes + .asUint8List()); HashedBlockReader.writeBlocks(ReaderHelper(compressedBytes), byteWriter); final bytes = byteWriter.output.toBytes(); - final masterKey = KdbxFormat._generateMasterKeyV3(kdbxFile.header, kdbxFile.credentials); - final encrypted = KdbxFormat._encryptDataAes(masterKey, bytes, kdbxFile.header.fields[HeaderFields.EncryptionIV].bytes.asUint8List()); -// writer.writeBytes(kdbxFile.header.fields[HeaderFields.StreamStartBytes].bytes.asUint8List()); - writer.writeBytes(encrypted); + final masterKey = + KdbxFormat._generateMasterKeyV3(kdbxFile.header, kdbxFile.credentials); + final encrypted = KdbxFormat._encryptDataAes(masterKey, bytes, + kdbxFile.header.fields[HeaderFields.EncryptionIV].bytes.asUint8List()); + return encrypted; } xml.XmlDocument generateXml(ProtectedSaltGenerator saltGenerator) { @@ -124,12 +158,16 @@ class KdbxBody extends KdbxNode { } } - final builder = xml.XmlBuilder(); - builder.processing('xml', 'version="1.0" encoding="utf-8" standalone="yes"'); - builder.element('KeePassFile', nest: [ - meta.toXml(), - () => builder.element('Root', nest: rootGroupNode),],); + builder.processing( + 'xml', 'version="1.0" encoding="utf-8" standalone="yes"'); + builder.element( + 'KeePassFile', + nest: [ + meta.toXml(), + () => builder.element('Root', nest: rootGroupNode), + ], + ); // final doc = xml.XmlDocument(); // doc.children.add(xml.XmlProcessing( // 'xml', 'version="1.0" encoding="utf-8" standalone="yes"')); @@ -224,7 +262,8 @@ class KdbxFormat { final decryptCipher = CBCBlockCipher(AESFastEngine()); decryptCipher.init(false, ParametersWithIV(KeyParameter(masterKey), encryptionIv.asUint8List())); - final paddedDecrypted = AesHelper.processBlocks(decryptCipher, encryptedPayload); + final paddedDecrypted = + AesHelper.processBlocks(decryptCipher, encryptedPayload); final decrypted = AesHelper.unpad(paddedDecrypted); final streamStart = header.fields[HeaderFields.StreamStartBytes].bytes; @@ -265,11 +304,12 @@ class KdbxFormat { return masterKey; } - static Uint8List _encryptDataAes(Uint8List masterKey, Uint8List payload, Uint8List encryptionIv) { + static Uint8List _encryptDataAes( + Uint8List masterKey, Uint8List payload, Uint8List encryptionIv) { final encryptCipher = CBCBlockCipher(AESFastEngine()); - encryptCipher.init(true, - ParametersWithIV(KeyParameter(masterKey), encryptionIv)); - return AesHelper.processBlocks(encryptCipher, AesHelper.pad(payload, encryptCipher.blockSize)); - + encryptCipher.init( + true, ParametersWithIV(KeyParameter(masterKey), encryptionIv)); + return AesHelper.processBlocks( + encryptCipher, AesHelper.pad(payload, encryptCipher.blockSize)); } } diff --git a/lib/src/kdbx_group.dart b/lib/src/kdbx_group.dart index 180ea49..3a4d7f1 100644 --- a/lib/src/kdbx_group.dart +++ b/lib/src/kdbx_group.dart @@ -1,3 +1,4 @@ +import 'package:kdbx/src/internal/async_utils.dart'; import 'package:kdbx/src/kdbx_consts.dart'; import 'package:kdbx/src/kdbx_entry.dart'; import 'package:kdbx/src/kdbx_xml.dart'; @@ -7,7 +8,8 @@ import 'package:xml/xml.dart'; import 'kdbx_object.dart'; class KdbxGroup extends KdbxObject { - KdbxGroup.create({@required this.parent, @required String name}) : super.create('Group') { + KdbxGroup.create({@required this.parent, @required String name}) + : super.create('Group') { this.name.set(name); icon.set(KdbxIcon.Folder); expanded.set(true); @@ -23,7 +25,9 @@ class KdbxGroup extends KdbxObject { .map((el) => KdbxEntry.read(this, el)) .forEach(_entries.add); } - + + final StreamSubscriptions _subscriptions = StreamSubscriptions(); + @override XmlElement toXml() { final el = super.toXml(); @@ -46,18 +50,22 @@ class KdbxGroup extends KdbxObject { /// null if this is the root group. final KdbxGroup parent; final List groups = []; + List get entries => List.unmodifiable(_entries); final List _entries = []; void addEntry(KdbxEntry entry) { if (entry.parent != this) { - throw StateError('Invalid operation. Trying to add entry which is already in another group.'); + throw StateError( + 'Invalid operation. Trying to add entry which is already in another group.'); } _entries.add(entry); node.children.add(entry.node); + isDirty = true; } StringNode get name => StringNode(this, 'Name'); + // String get name => text('Name') ?? ''; BooleanNode get expanded => BooleanNode(this, 'IsExpanded'); } diff --git a/lib/src/kdbx_object.dart b/lib/src/kdbx_object.dart index 37b009f..78ea8af 100644 --- a/lib/src/kdbx_object.dart +++ b/lib/src/kdbx_object.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; @@ -8,8 +9,29 @@ import 'package:uuid/uuid.dart'; import 'package:uuid/uuid_util.dart'; import 'package:xml/xml.dart'; -abstract class KdbxNode { - KdbxNode.create(String nodeName) : node = XmlElement(XmlName(nodeName)); +class ChangeEvent { + ChangeEvent({this.object, this.isDirty}); + + final T object; + final bool isDirty; +} + +mixin Changeable { + final _controller = StreamController>.broadcast(); + Stream> get changes => _controller.stream; + + bool _isDirty = false; + set isDirty(bool dirty) { + _isDirty = dirty; + _controller.add(ChangeEvent(object: this as T, isDirty: dirty)); + } + bool get isDirty => _isDirty; +} + +abstract class KdbxNode with Changeable { + KdbxNode.create(String nodeName) : node = XmlElement(XmlName(nodeName)) { + isDirty = true; + } KdbxNode.read(this.node); @@ -18,8 +40,6 @@ abstract class KdbxNode { // @protected // String text(String nodeName) => _opt(nodeName)?.text; - KdbxSubTextNode textNode(String nodeName) => StringNode(this, nodeName); - @mustCallSuper XmlElement toXml() { final el = node.copy() as XmlElement; @@ -42,6 +62,12 @@ abstract class KdbxObject extends KdbxNode { IconNode get icon => IconNode(this, 'IconID'); + @override + set isDirty(bool dirty) { + super.isDirty = dirty; + times.modifiedNow(); + } + @override XmlElement toXml() { final el = super.toXml(); diff --git a/lib/src/kdbx_times.dart b/lib/src/kdbx_times.dart index 888a665..598e118 100644 --- a/lib/src/kdbx_times.dart +++ b/lib/src/kdbx_times.dart @@ -27,6 +27,11 @@ class KdbxTimes extends KdbxNode { DateTimeUtcNode get locationChanged => DateTimeUtcNode(this, 'LocationChanged'); void accessedNow() { - lastAccessTime.set(clock.now()); + lastAccessTime.set(clock.now().toUtc()); + } + + void modifiedNow() { + accessedNow(); + lastModificationTime.set(clock.now().toUtc()); } } diff --git a/lib/src/kdbx_xml.dart b/lib/src/kdbx_xml.dart index 90231e7..adcfbc6 100644 --- a/lib/src/kdbx_xml.dart +++ b/lib/src/kdbx_xml.dart @@ -41,6 +41,7 @@ abstract class KdbxSubTextNode extends KdbxSubNode { @override void set(T value) { + node.isDirty = true; final el = node.node.findElements(name).singleWhere((x) => true, orElse: () { final el = XmlElement(XmlName(name)); diff --git a/test/FooBar.kdbx b/test/FooBar.kdbx index 465440a..1898136 100644 Binary files a/test/FooBar.kdbx and b/test/FooBar.kdbx differ