diff --git a/bin/kdbx.dart b/bin/kdbx.dart index a7d119a..aa87460 100644 --- a/bin/kdbx.dart +++ b/bin/kdbx.dart @@ -127,6 +127,6 @@ class DumpXmlCommand extends KdbxFileCommand { @override Future runWithFile(KdbxFile file) async { - print(file.body.xmlDocument.toXmlString(pretty: true)); + print(file.body.node.toXmlString(pretty: true)); } } diff --git a/lib/src/internal/byte_utils.dart b/lib/src/internal/byte_utils.dart index a625efd..dbcfedd 100644 --- a/lib/src/internal/byte_utils.dart +++ b/lib/src/internal/byte_utils.dart @@ -1,3 +1,7 @@ +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; + class ByteUtils { static bool eq(List a, List b) { if (a.length != b.length) { @@ -15,3 +19,47 @@ class ByteUtils { static String toHexList(List list) => list.map((val) => toHex(val)).join(' '); } + +class ReaderHelper { + ReaderHelper(this.data); + + final Uint8List data; + int pos = 0; + + ByteBuffer _nextByteBuffer(int byteCount) => + (data.sublist(pos, pos += byteCount) as Uint8List).buffer; + + int readUint32() => _nextByteBuffer(4).asUint32List().first; + + int readUint16() => _nextByteBuffer(2).asUint16List().first; + + int readUint8() => data[pos++]; + + ByteBuffer readBytes(int size) => _nextByteBuffer(size); + + Uint8List readRemaining() => data.sublist(pos) as Uint8List; +} + +class WriterHelper { + WriterHelper(this.output); + + final BytesBuilder output; + + void writeBytes(Uint8List bytes) { + output.add(bytes); +// output.asUint8List().addAll(bytes); + } + + void writeUint32(int value) { + output.add(Uint32List.fromList([value]).buffer.asUint8List()); +// output.asUint32List().add(value); + } + + void writeUint16(int value) { + output.add(Uint32List.fromList([value]).buffer.asUint32List()); + } + + void writeUint8(int value) { + output.addByte(value); + } +} diff --git a/lib/src/internal/consts.dart b/lib/src/internal/consts.dart new file mode 100644 index 0000000..7d6128b --- /dev/null +++ b/lib/src/internal/consts.dart @@ -0,0 +1,13 @@ +import 'package:kdbx/src/kdbx_object.dart'; + +enum Cipher { + aes, + chaCha20, +} + +class CryptoConsts { + static const CIPHER_IDS = { + Cipher.aes: KdbxUuid('McHy5r9xQ1C+WAUhavxa/w=='), + Cipher.chaCha20: KdbxUuid('1gOKK4tvTLWlJDOaMdu1mg=='), + }; +} diff --git a/lib/src/kdbx_entry.dart b/lib/src/kdbx_entry.dart index 24e2507..c3d72db 100644 --- a/lib/src/kdbx_entry.dart +++ b/lib/src/kdbx_entry.dart @@ -7,10 +7,25 @@ import 'package:xml/xml.dart'; String _canonicalizeKey(String key) => key?.toLowerCase(); +/// Represents a case insensitive (but case preserving) key. +class KdbxKey { + KdbxKey(this.key) : _canonicalKey = key.toLowerCase(); + + final String key; + final String _canonicalKey; + + @override + bool operator ==(Object other) => + other is KdbxKey && _canonicalKey == other._canonicalKey; + + @override + int get hashCode => _canonicalKey.hashCode; +} + class KdbxEntry extends KdbxObject { KdbxEntry.read(this.parent, XmlElement node) : super.read(node) { strings.addEntries(node.findElements('String').map((el) { - final key = el.findElements('Key').single.text; + final key = KdbxKey(el.findElements('Key').single.text); final valueNode = el.findElements('Value').single; if (valueNode.getAttribute('Protected')?.toLowerCase() == 'true') { return MapEntry(key, KdbxFile.protectedValueForNode(valueNode)); @@ -21,10 +36,9 @@ class KdbxEntry extends KdbxObject { } KdbxGroup parent; - Map strings = - CanonicalizedMap(_canonicalizeKey); + Map strings = {}; - String _plainValue(String key) { + String _plainValue(KdbxKey key) { final value = strings[key]; if (value is PlainValue) { return value.getText(); @@ -32,5 +46,5 @@ class KdbxEntry extends KdbxObject { return value?.toString(); } - String get label => _plainValue('Title'); + String get label => _plainValue(KdbxKey('Title')); } diff --git a/lib/src/kdbx_format.dart b/lib/src/kdbx_format.dart index b17b471..55415f6 100644 --- a/lib/src/kdbx_format.dart +++ b/lib/src/kdbx_format.dart @@ -10,7 +10,9 @@ import 'package:kdbx/src/internal/byte_utils.dart'; import 'package:kdbx/src/internal/crypto_utils.dart'; import 'package:kdbx/src/kdbx_group.dart'; import 'package:kdbx/src/kdbx_header.dart'; +import 'package:kdbx/src/kdbx_xml.dart'; import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; import 'package:pointycastle/export.dart'; import 'package:xml/xml.dart' as xml; @@ -44,23 +46,69 @@ class KdbxFile { final Credentials credentials; final KdbxHeader header; final KdbxBody body; + + Uint8List save() { + final output = BytesBuilder(); + final writer = WriterHelper(output); + header.generateSalts(); + header.write(writer); + body.write(writer, this); + return output.toBytes(); + } } -class KdbxBody { - KdbxBody(this.xmlDocument, this.meta, this.rootGroup); +class KdbxBody extends KdbxNode { + KdbxBody.create(this.meta, this.rootGroup) : super.create('KeePassFile') { + node.children.add(meta.node); + final rootNode = xml.XmlElement(xml.XmlName('Root')); + node.children.add(rootNode); + rootNode.children.add(rootGroup.node); + } + KdbxBody.read(xml.XmlElement node, this.meta, this.rootGroup) : super.read(node); - final xml.XmlDocument xmlDocument; +// final xml.XmlDocument xmlDocument; final KdbxMeta meta; final KdbxGroup rootGroup; + + void write(WriterHelper writer, KdbxFile kdbxFile) { + assert(kdbxFile.header.versionMajor == 3); + _writeV3(writer, kdbxFile); + } + + void _writeV3(WriterHelper writer, KdbxFile kdbxFile) { + meta.headerHash.set((crypto.sha256.convert(writer.output.toBytes()).bytes as Uint8List).buffer); + } + + xml.XmlDocument toXml() { + final doc = xml.XmlDocument(); + doc.children.add(xml.XmlProcessing('xml', 'version="1.0" encoding="utf-8" standalone="yes"')); + doc.children.add(node.copy()); + return doc; + } } class KdbxMeta extends KdbxNode { + KdbxMeta.create({@required String databaseName}) : super.create('Meta') { + this.databaseName.set(databaseName); + } KdbxMeta.read(xml.XmlElement node) : super.read(node); - String get databaseName => text('DatabaseName'); + StringNode get databaseName => StringNode(this, 'DatabaseName'); + Base64Node get headerHash => Base64Node(this, 'HeaderHash'); + } class KdbxFormat { + + static KdbxFile create(Credentials credentials, String name) { + final header = KdbxHeader.create(); + final meta = KdbxMeta.create(databaseName: name); + final rootGroup = KdbxGroup.create(parent: null, name: name); + final body = KdbxBody.create(meta, rootGroup); + return KdbxFile(credentials, header, body); + } + + static KdbxFile read(Uint8List input, Credentials credentials) { final reader = ReaderHelper(input); final header = KdbxHeader.read(reader); @@ -88,7 +136,7 @@ class KdbxFormat { static KdbxBody _loadXml(KdbxHeader header, String xmlString) { final protectedValueEncryption = header.innerRandomStreamEncryption; - if (protectedValueEncryption != PotectedValueEncryption.salsa20) { + if (protectedValueEncryption != ProtectedValueEncryption.salsa20) { throw KdbxUnsupportedException( 'Inner encryption: $protectedValueEncryption'); } @@ -110,7 +158,7 @@ class KdbxFormat { final root = keePassFile.findElements('Root').single; final rootGroup = KdbxGroup.read(null, root.findElements('Group').single); _logger.fine('got meta: ${meta.toXmlString(pretty: true)}'); - return KdbxBody(document, KdbxMeta.read(meta), rootGroup); + return KdbxBody.read(keePassFile, KdbxMeta.read(meta), rootGroup); } static Uint8List _decryptContent( diff --git a/lib/src/kdbx_group.dart b/lib/src/kdbx_group.dart index 93a7288..5678f06 100644 --- a/lib/src/kdbx_group.dart +++ b/lib/src/kdbx_group.dart @@ -1,10 +1,14 @@ import 'package:kdbx/src/kdbx_entry.dart'; +import 'package:kdbx/src/kdbx_xml.dart'; +import 'package:meta/meta.dart'; import 'package:xml/xml.dart'; import 'kdbx_object.dart'; class KdbxGroup extends KdbxObject { - KdbxGroup(this.parent) : super.create('Group'); + KdbxGroup.create({@required this.parent, @required String name}) : super.create('Group') { + this.name.set(name); + } KdbxGroup.read(this.parent, XmlElement node) : super.read(node) { node @@ -31,5 +35,6 @@ class KdbxGroup extends KdbxObject { final List groups = []; final List entries = []; - String get name => text('Name') ?? ''; + StringNode get name => StringNode(this, 'Name'); +// String get name => text('Name') ?? ''; } diff --git a/lib/src/kdbx_header.dart b/lib/src/kdbx_header.dart index 352493e..2d21a9f 100644 --- a/lib/src/kdbx_header.dart +++ b/lib/src/kdbx_header.dart @@ -1,8 +1,12 @@ +import 'dart:math'; import 'dart:typed_data'; import 'package:crypto/crypto.dart' as crypto; import 'package:kdbx/src/internal/byte_utils.dart'; +import 'package:kdbx/src/internal/consts.dart'; import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:pointycastle/api.dart'; final _logger = Logger('kdbx.header'); @@ -21,7 +25,7 @@ enum Compression { } /// how protected values are encrypted in the xml. -enum PotectedValueEncryption { plainText, arc4variant, salsa20 } +enum ProtectedValueEncryption { plainText, arc4variant, salsa20 } enum HeaderFields { EndOfHeader, @@ -34,7 +38,7 @@ enum HeaderFields { EncryptionIV, ProtectedStreamKey, StreamStartBytes, - InnerRandomStreamID, + InnerRandomStreamID, // crsAlgorithm KdfParameters, PublicCustomData, } @@ -49,12 +53,122 @@ class HeaderField { } class KdbxHeader { - KdbxHeader( - {this.sig1, - this.sig2, - this.versionMinor, - this.versionMajor, - this.fields}); + KdbxHeader({ + @required this.sig1, + @required this.sig2, + @required this.versionMinor, + @required this.versionMajor, + @required this.fields, + }); + + KdbxHeader.create() + : this( + sig1: Consts.FileMagic, + sig2: Consts.Sig2Kdbx, + versionMinor: 1, + versionMajor: 3, + fields: _defaultFieldValues(), + ); + + static ByteBuffer _intAsUintBytes(int val) => + Uint8List.fromList([val]).buffer; + + static List _requiredFields(int majorVersion) { + if (majorVersion < 3) { + throw KdbxUnsupportedException('Unsupported version: $majorVersion'); + } + final baseHeaders = [ + HeaderFields.CipherID, + HeaderFields.CompressionFlags, + HeaderFields.MasterSeed, + HeaderFields.EncryptionIV + ]; + if (majorVersion < 4) { + return baseHeaders + + [ + HeaderFields.TransformSeed, + HeaderFields.TransformRounds, + HeaderFields.ProtectedStreamKey, + HeaderFields.StreamStartBytes, + HeaderFields.InnerRandomStreamID + ]; + } else { + // TODO kdbx 4 support + throw KdbxUnsupportedException('We do not support kdbx 4.x right now'); + return baseHeaders + [HeaderFields.KdfParameters]; // ignore: dead_code + } + } + + void _validate() { + for (HeaderFields required in _requiredFields(versionMajor)) { + if (fields[required] == null) { + throw KdbxCorruptedFileException('Missing header $required'); + } + } + } + + void _setHeaderField(HeaderFields field, ByteBuffer bytes) { + fields[field] = HeaderField(field, bytes); + } + + void generateSalts() { + // TODO make sure default algorithm is "secure" engouh. Or whether we should + // use [Random.secure]? + final random = SecureRandom(); + _setHeaderField(HeaderFields.MasterSeed, random.nextBytes(32).buffer); + if (versionMajor < 4) { + _setHeaderField(HeaderFields.TransformSeed, random.nextBytes(32).buffer); + _setHeaderField(HeaderFields.StreamStartBytes, random.nextBytes(32).buffer); + _setHeaderField(HeaderFields.ProtectedStreamKey, random.nextBytes(32).buffer); + _setHeaderField(HeaderFields.EncryptionIV, random.nextBytes(16).buffer); + } else { + throw KdbxUnsupportedException('We do not support Kdbx 4.x right now. ($versionMajor.$versionMinor)'); + } + } + + void write(WriterHelper writer) { + _validate(); + // write signature + writer.writeUint32(Consts.FileMagic); + writer.writeUint32(Consts.Sig2Kdbx); + // write version + writer.writeUint16(versionMinor); + writer.writeUint16(versionMajor); + for (final field + in HeaderFields.values.where((f) => f != HeaderFields.EndOfHeader)) { + _writeField(writer, field); + } + _writeField(writer, HeaderFields.EndOfHeader); + } + + void _writeField(WriterHelper writer, HeaderFields field) { + final value = fields[field]; + if (value == null) { + return; + } + _writeFieldSize(writer, value.bytes.lengthInBytes); + writer.writeBytes(value.bytes.asUint8List()); + } + + void _writeFieldSize(WriterHelper writer, int size) { + if (versionMajor >= 4) { + writer.writeUint32(size); + } else { + writer.writeUint16(size); + } + } + + static Map _defaultFieldValues() => + Map.fromEntries([ + HeaderField(HeaderFields.CipherID, + CryptoConsts.CIPHER_IDS[Cipher.aes].toBytes()), + HeaderField(HeaderFields.CompressionFlags, _intAsUintBytes(1)), + HeaderField(HeaderFields.TransformRounds, _intAsUintBytes(6000)), + HeaderField( + HeaderFields.InnerRandomStreamID, + _intAsUintBytes(ProtectedValueEncryption.values + .indexOf(ProtectedValueEncryption.salsa20))), + ].map((f) => MapEntry(f.field, f))); static KdbxHeader read(ReaderHelper reader) { // reading signature @@ -115,8 +229,8 @@ class KdbxHeader { } } - PotectedValueEncryption get innerRandomStreamEncryption => - PotectedValueEncryption.values[ + ProtectedValueEncryption get innerRandomStreamEncryption => + ProtectedValueEncryption.values[ fields[HeaderFields.InnerRandomStreamID].bytes.asUint32List().single]; } @@ -124,7 +238,11 @@ class KdbxException implements Exception {} class KdbxInvalidKeyException implements KdbxException {} -class KdbxCorruptedFileException implements KdbxException {} +class KdbxCorruptedFileException implements KdbxException { + KdbxCorruptedFileException([this.message]); + + final String message; +} class KdbxUnsupportedException implements KdbxException { KdbxUnsupportedException(this.hint); @@ -154,23 +272,3 @@ class HashedBlockReader { } } } - -class ReaderHelper { - ReaderHelper(this.data); - - final Uint8List data; - int pos = 0; - - ByteBuffer _nextByteBuffer(int byteCount) => - (data.sublist(pos, pos += byteCount) as Uint8List).buffer; - - int readUint32() => _nextByteBuffer(4).asUint32List().first; - - int readUint16() => _nextByteBuffer(2).asUint16List().first; - - int readUint8() => data[pos++]; - - ByteBuffer readBytes(int size) => _nextByteBuffer(size); - - Uint8List readRemaining() => data.sublist(pos) as Uint8List; -} diff --git a/lib/src/kdbx_object.dart b/lib/src/kdbx_object.dart index f92224c..6a7b3d3 100644 --- a/lib/src/kdbx_object.dart +++ b/lib/src/kdbx_object.dart @@ -1,4 +1,7 @@ -import 'package:meta/meta.dart'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:kdbx/src/kdbx_xml.dart'; import 'package:uuid/uuid.dart'; import 'package:xml/xml.dart'; @@ -15,25 +18,41 @@ class KdbxTimes { abstract class KdbxNode { KdbxNode.create(String nodeName) : node = XmlElement(XmlName(nodeName)); + KdbxNode.read(this.node); final XmlElement node; - @protected - String text(String nodeName) => _opt(nodeName)?.text; +// @protected +// String text(String nodeName) => _opt(nodeName)?.text; + + KdbxSubTextNode textNode(String nodeName) => StringNode(this, nodeName); - XmlElement _opt(String nodeName) => - node.findElements(nodeName).singleWhere((x) => true, orElse: () => null); } + abstract class KdbxObject extends KdbxNode { KdbxObject.create(String nodeName) - : uuid = Uuid().v4(), - super.create(nodeName); - - KdbxObject.read(XmlElement node) : super.read(node) { - uuid = node.findElements('UUID').single.text; + : super.create(nodeName) { + _uuid.set(KdbxUuid.random()); } - String uuid; + KdbxObject.read(XmlElement node) : super.read(node); + + KdbxUuid get uuid => _uuid.get(); + UuidNode get _uuid => UuidNode(this, 'UUID'); +} + +class KdbxUuid { + const KdbxUuid(this.uuid); + + KdbxUuid.random() : this(Uuid().v4()); + + /// base64 representation of uuid. + final String uuid; + + ByteBuffer toBytes() => base64.decode(uuid).buffer; + + @override + String toString() => uuid; } diff --git a/lib/src/kdbx_xml.dart b/lib/src/kdbx_xml.dart new file mode 100644 index 0000000..ca4a739 --- /dev/null +++ b/lib/src/kdbx_xml.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:kdbx/kdbx.dart'; +import 'package:meta/meta.dart'; +import 'package:xml/xml.dart'; + +abstract class KdbxSubNode { + KdbxSubNode(this.node, this.name); + + final KdbxNode node; + final String name; + + T get(); + + void set(T value); +} + +abstract class KdbxSubTextNode extends KdbxSubNode { + KdbxSubTextNode(KdbxNode node, String name) : super(node, name); + + @protected + String encode(T value); + + @protected + T decode(String value); + + XmlElement _opt(String nodeName) => + node.node.findElements(nodeName).singleWhere((x) => true, orElse: () => null); + + @override + T get() { + return decode(_opt(name)?.text); + } + + @override + void set(T value) { + final stringValue = encode(value); + final el = + node.node.findElements(name).singleWhere((x) => true, orElse: () { + final el = XmlElement(XmlName(name)); + node.node.children.add(el); + return el; + }); + el.children.clear(); + el.children.add(XmlText(stringValue)); + } +} + +class StringNode extends KdbxSubTextNode { + StringNode(KdbxNode node, String name) : super(node, name); + + @override + String decode(String value) => value; + + @override + String encode(String value) => value; +} + +class Base64Node extends KdbxSubTextNode { + Base64Node(KdbxNode node, String name) : super(node, name); + + @override + ByteBuffer decode(String value) => base64.decode(value).buffer; + + @override + String encode(ByteBuffer value) => base64.encode(value.asUint8List()); +} + +class UuidNode extends KdbxSubTextNode { + UuidNode(KdbxNode node, String name) : super(node, name); + + @override + KdbxUuid decode(String value) => KdbxUuid(value); + + @override + String encode(KdbxUuid value) => value.uuid; +} diff --git a/test/internal/byte_utils_test.dart b/test/internal/byte_utils_test.dart new file mode 100644 index 0000000..23a0677 --- /dev/null +++ b/test/internal/byte_utils_test.dart @@ -0,0 +1,17 @@ + +import 'dart:io'; + +import 'package:kdbx/src/internal/byte_utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('WriteHelper', () { + test('writing bytes', () { + final bytesBuilder = BytesBuilder(); + final writer = WriterHelper(bytesBuilder); + writer.writeUint32(1); + print('result: ' + ByteUtils.toHexList(writer.output.toBytes())); + expect(writer.output.toBytes(), hasLength(4)); + }); + }); +} diff --git a/test/kdbx_test.dart b/test/kdbx_test.dart index 1acbcc2..18e2f0b 100644 --- a/test/kdbx_test.dart +++ b/test/kdbx_test.dart @@ -12,12 +12,23 @@ import 'package:test/test.dart'; void main() { Logger.root.level = Level.ALL; PrintAppender().attachToLogger(Logger.root); - group('A group of tests', () { + group('Reading', () { setUp(() {}); test('First Test', () async { final data = await File('test/FooBar.kdbx').readAsBytes() as Uint8List; - await KdbxFormat.read(data, Credentials(ProtectedValue.fromString('FooBar'))); + KdbxFormat.read(data, Credentials(ProtectedValue.fromString('FooBar'))); + }); + }); + + group('Creating', () { + test('Simple create', () { + final kdbx = KdbxFormat.create(Credentials(ProtectedValue.fromString('FooBar')), 'CreateTest'); + expect(kdbx, isNotNull); + expect(kdbx.body.rootGroup, isNotNull); + expect(kdbx.body.rootGroup.name.get(), 'CreateTest'); + expect(kdbx.body.meta.databaseName.get(), 'CreateTest'); + print(kdbx.body.toXml().toXmlString(pretty: true)); }); }); }