diff --git a/.idea/dictionaries/herbert.xml b/.idea/dictionaries/herbert.xml index 7e2bf04..c96d2ea 100644 --- a/.idea/dictionaries/herbert.xml +++ b/.idea/dictionaries/herbert.xml @@ -4,6 +4,7 @@ consts derivator encrypter + hmac kdbx diff --git a/lib/src/crypto/key_encrypter_kdf.dart b/lib/src/crypto/key_encrypter_kdf.dart index dc902f0..9a98e6a 100644 --- a/lib/src/crypto/key_encrypter_kdf.dart +++ b/lib/src/crypto/key_encrypter_kdf.dart @@ -19,6 +19,7 @@ class KdfField { final String field; final ValueType type; + static final uuid = KdfField('\$UUID', ValueType.typeBytes); static final salt = KdfField('S', ValueType.typeBytes); static final parallelism = KdfField('P', ValueType.typeUInt32); static final memory = KdfField('M', ValueType.typeUInt64); @@ -45,6 +46,10 @@ class KdfField { } T read(VarDictionary dict) => dict.get(type, field); + void write(VarDictionary dict, T value) => dict.set(type, field, value); + VarDictionaryItem item(T value) => + VarDictionaryItem(field, type, value); + String debug(VarDictionary dict) { final value = dict.get(type, field); final strValue = type == ValueType.typeBytes @@ -61,6 +66,11 @@ class KeyEncrypterKdf { '72Nt34wpREuR96mkA+MKDA==': KdfType.Argon2, 'ydnzmmKKRGC/dA0IwYpP6g==': KdfType.Aes, }; + static KdbxUuid kdfUuidForType(KdfType type) { + String uuid = + kdfUuids.entries.firstWhere((element) => element.value == type).key; + return KdbxUuid(uuid); + } final Argon2 argon2; diff --git a/lib/src/kdbx_format.dart b/lib/src/kdbx_format.dart index 2d62e24..cd41715 100644 --- a/lib/src/kdbx_format.dart +++ b/lib/src/kdbx_format.dart @@ -119,7 +119,7 @@ class HashCredentials implements Credentials { } class KdbxFile { - KdbxFile(this.credentials, this.header, this.body) { + KdbxFile(this.kdbxFormat, this.credentials, this.header, this.body) { for (final obj in _allObjects) { obj.file = this; } @@ -136,6 +136,7 @@ class KdbxFile { protectedValues[node] = value; } + final KdbxFormat kdbxFormat; final Credentials credentials; final KdbxHeader header; final KdbxBody body; @@ -147,19 +148,30 @@ class KdbxFile { _dirtyObjectsChanged.stream; Uint8List save() { - assert(header.versionMajor == 3); final output = BytesBuilder(); final writer = WriterHelper(output); header.generateSalts(); header.write(writer); - - final streamKey = header.fields[HeaderFields.ProtectedStreamKey].bytes; - final gen = ProtectedSaltGenerator(streamKey); - - body.meta.headerHash.set( - (crypto.sha256.convert(writer.output.toBytes()).bytes as Uint8List) - .buffer); - body.writeV3(writer, this, gen); + final headerHash = + (crypto.sha256.convert(writer.output.toBytes()).bytes as Uint8List); + + if (header.versionMajor <= 3) { + final streamKey = header.fields[HeaderFields.ProtectedStreamKey].bytes; + final gen = ProtectedSaltGenerator(streamKey); + + body.meta.headerHash.set(headerHash.buffer); + body.writeV3(writer, this, gen); + } else if (header.versionMajor <= 4) { + final headerBytes = writer.output.toBytes(); + writer.writeBytes(headerHash); + final gen = kdbxFormat._createProtectedSaltGenerator(header); + final keys = kdbxFormat._computeKeysV4(header, credentials); + final headerHmac = kdbxFormat._getHeaderHmac(headerBytes, keys.hmacKey); + writer.writeBytes(headerHmac.bytes as Uint8List); + body.writeV4(writer, this, gen, keys); + } else { + throw UnsupportedError('Unsupported version ${header.versionMajor}'); + } dirtyObjects.clear(); _dirtyObjectsChanged.add(dirtyObjects); return output.toBytes(); @@ -226,6 +238,24 @@ class KdbxBody extends KdbxNode { writer.writeBytes(encrypted); } + void writeV4(WriterHelper writer, KdbxFile kdbxFile, + ProtectedSaltGenerator saltGenerator, _KeysV4 keys) { + final bodyWriter = WriterHelper(); + final xml = generateXml(saltGenerator); + kdbxFile.header.writeInnerHeader(bodyWriter); + bodyWriter.writeBytes(utf8.encode(xml.toXmlString()) as Uint8List); + final Uint8List compressedBytes = + (kdbxFile.header.compression == Compression.gzip + ? GZipCodec().encode(bodyWriter.output.toBytes()) + : bodyWriter.output.toBytes()) as Uint8List; + final encrypted = _encryptV4( + kdbxFile, + compressedBytes, + keys.cipherKey, + ); + writer.writeBytes(encrypted); + } + Uint8List _encryptV3(KdbxFile kdbxFile, Uint8List compressedBytes) { final byteWriter = WriterHelper(); byteWriter.writeBytes( @@ -240,6 +270,23 @@ class KdbxBody extends KdbxNode { return encrypted; } + Uint8List _encryptV4( + KdbxFile kdbxFile, Uint8List compressedBytes, Uint8List cipherKey) { + final header = kdbxFile.header; + final cipherId = base64.encode(header.fields[HeaderFields.CipherID].bytes); + if (cipherId == CryptoConsts.CIPHER_IDS[Cipher.aes].uuid) { + _logger.fine('We need AES'); + final result = kdbxFile.kdbxFormat + ._encryptContentV4Aes(header, cipherKey, compressedBytes); + _logger.fine('Result: ${ByteUtils.toHexList(result)}'); + return result; + } else if (cipherId == CryptoConsts.CIPHER_IDS[Cipher.chaCha20].uuid) { + _logger.fine('We need chacha20'); + } else { + throw UnsupportedError('Unsupported cipherId $cipherId'); + } + } + xml.XmlDocument generateXml(ProtectedSaltGenerator saltGenerator) { final rootGroupNode = rootGroup.toXml(); // update protected values... @@ -280,6 +327,13 @@ class KdbxBody extends KdbxNode { } } +class _KeysV4 { + _KeysV4(this.hmacKey, this.cipherKey); + + final Uint8List hmacKey; + final Uint8List cipherKey; +} + class KdbxFormat { KdbxFormat([this.argon2]); @@ -297,7 +351,7 @@ class KdbxFormat { ); final rootGroup = KdbxGroup.create(parent: null, name: name); final body = KdbxBody.create(meta, rootGroup); - return KdbxFile(credentials, header, body); + return KdbxFile(this, credentials, header, body); } KdbxFile read(Uint8List input, Credentials credentials) { @@ -327,10 +381,10 @@ class KdbxFormat { if (header.compression == Compression.gzip) { final xml = GZipCodec().decode(blocks); final string = utf8.decode(xml); - return KdbxFile(credentials, header, _loadXml(header, string)); + return KdbxFile(this, credentials, header, _loadXml(header, string)); } else { return KdbxFile( - credentials, header, _loadXml(header, utf8.decode(blocks))); + this, credentials, header, _loadXml(header, utf8.decode(blocks))); } } @@ -347,27 +401,12 @@ class KdbxFormat { _logger .finest('KdfParameters: ${header.readKdfParameters.toDebugString()}'); _logger.finest('Header hash matches.'); - final key = _computeKeysV4(header, credentials); - final masterSeed = header.fields[HeaderFields.MasterSeed].bytes; - if (masterSeed.length != 32) { - throw const FormatException('Master seed must be 32 bytes.'); - } -// final keyWithSeed = Uint8List(65); -// keyWithSeed.replaceRange(0, masterSeed.length, masterSeed); -// keyWithSeed.replaceRange( -// masterSeed.length, masterSeed.length + key.length, key); -// keyWithSeed[64] = 1; - _logger.fine('masterSeed: ${ByteUtils.toHexList(masterSeed)}'); - final keyWithSeed = masterSeed + key + Uint8List.fromList([1]); - assert(keyWithSeed.length == 65); - final cipher = crypto.sha256.convert(keyWithSeed.sublist(0, 64)); - final hmacKey = crypto.sha512.convert(keyWithSeed); - _logger.fine('hmacKey: ${ByteUtils.toHexList(hmacKey.bytes)}'); + final keys = _computeKeysV4(header, credentials); final headerHmac = - _getHeaderHmac(header, reader, hmacKey.bytes as Uint8List); + _getHeaderHmac(reader.byteData.sublist(0, header.endPos), keys.hmacKey); final expectedHmac = reader.readBytes(headerHmac.bytes.length); - _logger.fine('Expected: ${ByteUtils.toHexList(expectedHmac)}'); - _logger.fine('Actual : ${ByteUtils.toHexList(headerHmac.bytes)}'); +// _logger.fine('Expected: ${ByteUtils.toHexList(expectedHmac)}'); +// _logger.fine('Actual : ${ByteUtils.toHexList(headerHmac.bytes)}'); if (!ByteUtils.eq(hash, actualHash)) { throw KdbxInvalidKeyException(); } @@ -375,17 +414,16 @@ class KdbxFormat { // final blockreader.readBytes(32); final bodyStuff = hmacBlockTransformer(reader); _logger.fine('body decrypt: ${ByteUtils.toHexList(bodyStuff)}'); - final decrypted = decrypt(header, bodyStuff, cipher.bytes as Uint8List); + final decrypted = decrypt(header, bodyStuff, keys.cipherKey); _logger.finer('compression: ${header.compression}'); if (header.compression == Compression.gzip) { final content = GZipCodec().decode(decrypted) as Uint8List; final contentReader = ReaderHelper(content); final headerFields = KdbxHeader.readInnerHeaderFields(contentReader, 4); - _logger.fine('inner header fields: $headerFields'); +// _logger.fine('inner header fields: $headerFields'); header.innerFields.addAll(headerFields); final xml = utf8.decode(contentReader.readRemaining()); - _logger.fine('content: $xml'); - return KdbxFile(credentials, header, _loadXml(header, xml)); + return KdbxFile(this, credentials, header, _loadXml(header, xml)); } return null; } @@ -414,6 +452,7 @@ class KdbxFormat { return result; } else if (cipherId == CryptoConsts.CIPHER_IDS[Cipher.chaCha20].uuid) { _logger.fine('We need chacha20'); + throw UnsupportedError('chacha20 not yet supported $cipherId'); } else { throw UnsupportedError('Unsupported cipherId $cipherId'); } @@ -422,30 +461,45 @@ class KdbxFormat { // Uint8List _transformDataV4Aes() { // } - crypto.Digest _getHeaderHmac( - KdbxHeader header, ReaderHelper reader, Uint8List key) { + crypto.Digest _getHeaderHmac(Uint8List headerBytes, Uint8List key) { final writer = WriterHelper() ..writeUint32(0xffffffff) ..writeUint32(0xffffffff) ..writeBytes(key); final hmacKey = crypto.sha512.convert(writer.output.toBytes()).bytes; - final src = reader.byteData.sublist(0, header.endPos); + final src = headerBytes; final hmacKeyStuff = crypto.Hmac(crypto.sha256, hmacKey); _logger.fine('keySha: ${ByteUtils.toHexList(hmacKey)}'); _logger.fine('src: ${ByteUtils.toHexList(src)}'); return hmacKeyStuff.convert(src); } - Uint8List _computeKeysV4(KdbxHeader header, Credentials credentials) { + _KeysV4 _computeKeysV4(KdbxHeader header, Credentials credentials) { final masterSeed = header.fields[HeaderFields.MasterSeed].bytes; final kdfParameters = header.readKdfParameters; - assert(masterSeed.length == 32); + if (masterSeed.length != 32) { + throw const FormatException('Master seed must be 32 bytes.'); + } + final credentialHash = credentials.getHash(); _logger.fine('MasterSeed: ${ByteUtils.toHexList(masterSeed)}'); _logger.fine('credentialHash: ${ByteUtils.toHexList(credentialHash)}'); - final ret = KeyEncrypterKdf(argon2).encrypt(credentialHash, kdfParameters); - _logger.fine('keyv4: ${ByteUtils.toHexList(ret)}'); - return ret; + final key = KeyEncrypterKdf(argon2).encrypt(credentialHash, kdfParameters); + _logger.fine('keyv4: ${ByteUtils.toHexList(key)}'); + +// final keyWithSeed = Uint8List(65); +// keyWithSeed.replaceRange(0, masterSeed.length, masterSeed); +// keyWithSeed.replaceRange( +// masterSeed.length, masterSeed.length + key.length, key); +// keyWithSeed[64] = 1; + _logger.fine('masterSeed: ${ByteUtils.toHexList(masterSeed)}'); + final keyWithSeed = masterSeed + key + Uint8List.fromList([1]); + assert(keyWithSeed.length == 65); + final cipher = crypto.sha256.convert(keyWithSeed.sublist(0, 64)); + final hmacKey = crypto.sha512.convert(keyWithSeed); + _logger.fine('hmacKey: ${ByteUtils.toHexList(hmacKey.bytes)}'); + + return _KeysV4(hmacKey.bytes as Uint8List, cipher.bytes as Uint8List); } ProtectedSaltGenerator _createProtectedSaltGenerator(KdbxHeader header) { @@ -515,11 +569,11 @@ class KdbxFormat { } Uint8List _decryptContentV4( - KdbxHeader header, Uint8List masterKey, Uint8List encryptedPayload) { + KdbxHeader header, Uint8List cipherKey, Uint8List encryptedPayload) { final encryptionIv = header.fields[HeaderFields.EncryptionIV].bytes; final decryptCipher = CBCBlockCipher(AESFastEngine()); decryptCipher.init( - false, ParametersWithIV(KeyParameter(masterKey), encryptionIv)); + false, ParametersWithIV(KeyParameter(cipherKey), encryptionIv)); final paddedDecrypted = AesHelper.processBlocks(decryptCipher, encryptedPayload); @@ -527,6 +581,19 @@ class KdbxFormat { return decrypted; } + /// TODO combine this with [_decryptContentV4] + Uint8List _encryptContentV4Aes( + KdbxHeader header, Uint8List cipherKey, Uint8List bytes) { + final encryptionIv = header.fields[HeaderFields.EncryptionIV].bytes; + final decryptCipher = CBCBlockCipher(AESFastEngine()); + decryptCipher.init( + true, ParametersWithIV(KeyParameter(cipherKey), encryptionIv)); + final paddedDecrypted = AesHelper.processBlocks(decryptCipher, bytes); + + final decrypted = AesHelper.unpad(paddedDecrypted); + return decrypted; + } + static Uint8List _generateMasterKeyV3( KdbxHeader header, Credentials credentials) { final rounds = ReaderHelper.singleUint64( diff --git a/lib/src/kdbx_header.dart b/lib/src/kdbx_header.dart index aa07b04..88c8033 100644 --- a/lib/src/kdbx_header.dart +++ b/lib/src/kdbx_header.dart @@ -1,11 +1,14 @@ +import 'dart:convert'; import 'dart:typed_data'; 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_var_dictionary.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; +import 'package:kdbx/src/utils/scope_functions.dart'; final _logger = Logger('kdbx.header'); @@ -13,6 +16,11 @@ class Consts { static const FileMagic = 0x9AA2D903; static const Sig2Kdbx = 0xB54BFB67; + static const DefaultKdfSaltLength = 32; + static const DefaultKdfParallelism = 1; + static const DefaultKdfIterations = 2; + static const DefaultKdfMemory = 1024 * 1024; + static const DefaultKdfVersion = 0x13; } enum Compression { @@ -81,7 +89,8 @@ class KdbxHeader { @required this.versionMajor, @required this.fields, @required this.endPos, - }); + Map innerFields, + }) : innerFields = innerFields ?? {}; KdbxHeader.create() : this( @@ -93,6 +102,17 @@ class KdbxHeader { endPos: null, ); + KdbxHeader.createV4() + : this( + sig1: Consts.FileMagic, + sig2: Consts.Sig2Kdbx, + versionMinor: 1, + versionMajor: 4, + fields: _defaultFieldValuesV4(), + innerFields: _defaultInnerFieldValuesV4(), + endPos: null, + ); + static List _requiredFields(int majorVersion) { if (majorVersion < 3) { throw KdbxUnsupportedException('Unsupported version: $majorVersion'); @@ -113,12 +133,22 @@ class KdbxHeader { // 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 + return baseHeaders + [HeaderFields.KdfParameters]; } } + static VarDictionary _createKdfDefaultParameters() { + return VarDictionary([ + KdfField.uuid + .item(KeyEncrypterKdf.kdfUuidForType(KdfType.Argon2).toBytes()), + KdfField.salt.item(ByteUtils.randomBytes(Consts.DefaultKdfSaltLength)), + KdfField.parallelism.item(Consts.DefaultKdfParallelism), + KdfField.iterations.item(Consts.DefaultKdfIterations), + KdfField.memory.item(Consts.DefaultKdfMemory), + KdfField.version.item(Consts.DefaultKdfVersion), + ]); + } + void _validate() { for (HeaderFields required in _requiredFields(versionMajor)) { if (fields[required] == null) { @@ -127,6 +157,18 @@ class KdbxHeader { } } + void _validateInner() { + final requiredFields = [ + InnerHeaderFields.InnerRandomStreamID, + InnerHeaderFields.InnerRandomStreamKey + ]; + for (final field in requiredFields) { + if (innerFields[field] == null) { + throw KdbxCorruptedFileException('Missing inner header $field'); + } + } + } + void _setHeaderField(HeaderFields field, Uint8List bytes) { fields[field] = HeaderField(field, bytes); } @@ -145,9 +187,22 @@ class KdbxHeader { _setHeaderField( HeaderFields.ProtectedStreamKey, ByteUtils.randomBytes(32)); _setHeaderField(HeaderFields.EncryptionIV, ByteUtils.randomBytes(16)); + } else if (versionMajor < 5) { + _setInnerHeaderField( + InnerHeaderFields.InnerRandomStreamKey, ByteUtils.randomBytes(64)); + final kdfParameters = readKdfParameters; + KdfField.salt.write( + kdfParameters, ByteUtils.randomBytes(Consts.DefaultKdfSaltLength)); + // var ivLength = this.dataCipherUuid.toString() === Consts.CipherId.ChaCha20 ? 12 : 16; + // this.encryptionIV = Random.getBytes(ivLength); + final cipherId = base64.encode(fields[HeaderFields.CipherID].bytes); + final ivLength = + cipherId == CryptoConsts.CIPHER_IDS[Cipher.chaCha20].uuid ? 12 : 16; + _setHeaderField( + HeaderFields.EncryptionIV, ByteUtils.randomBytes(ivLength)); } else { throw KdbxUnsupportedException( - 'We do not support Kdbx 4.x right now. ($versionMajor.$versionMinor)'); + 'We do not support Kdbx 3.x and 4.x right now. ($versionMajor.$versionMinor)'); } } @@ -168,6 +223,26 @@ class KdbxHeader { _writeField(writer, HeaderFields.EndOfHeader); } + void writeInnerHeader(WriterHelper writer) { + _validateInner(); + for (final field in InnerHeaderFields.values + .where((f) => f != InnerHeaderFields.EndOfHeader)) { + _writeInnerField(writer, field); + } + _writeInnerField(writer, InnerHeaderFields.EndOfHeader); + } + + void _writeInnerField(WriterHelper writer, InnerHeaderFields field) { + final value = innerFields[field]; + if (value == null) { + return; + } + _logger.finer('Writing header $field (${value.bytes.lengthInBytes})'); + writer.writeUint8(field.index); + _writeFieldSize(writer, value.bytes.lengthInBytes); + writer.writeBytes(value.bytes); + } + void _writeField(WriterHelper writer, HeaderFields field) { final value = fields[field]; if (value == null) { @@ -201,6 +276,25 @@ class KdbxHeader { .indexOf(ProtectedValueEncryption.salsa20))), ].map((f) => MapEntry(f.field, f))); + static Map _defaultFieldValuesV4() => + _defaultFieldValues() + ..remove(HeaderFields.TransformRounds) + ..remove(HeaderFields.InnerRandomStreamID) + ..remove(HeaderFields.ProtectedStreamKey) + ..also((fields) { + fields[HeaderFields.KdfParameters] = HeaderField( + HeaderFields.KdfParameters, + _createKdfDefaultParameters().write()); + }); + + static Map + _defaultInnerFieldValuesV4() => Map.fromEntries([ + InnerHeaderField( + InnerHeaderFields.InnerRandomStreamID, + WriterHelper.singleUint32Bytes(ProtectedValueEncryption.values + .indexOf(ProtectedValueEncryption.chaCha20))) + ].map((f) => MapEntry(f.field, f))); + static KdbxHeader read(ReaderHelper reader) { // reading signature final sig1 = reader.readUint32(); @@ -281,7 +375,7 @@ class KdbxHeader { final int versionMinor; final int versionMajor; final Map fields; - final Map innerFields = {}; + final Map innerFields; /// end position of the header, if we have been reading from a stream. final int endPos; @@ -313,6 +407,9 @@ class KdbxHeader { VarDictionary get readKdfParameters => VarDictionary.read( ReaderHelper(fields[HeaderFields.KdfParameters].bytes)); + void writeKdfParameters(VarDictionary kdfParameters) => + _setHeaderField(HeaderFields.KdfParameters, kdfParameters.write()); + @override String toString() { return 'KdbxHeader{sig1: $sig1, sig2: $sig2, versionMajor: $versionMajor, versionMinor: $versionMinor}'; diff --git a/lib/src/kdbx_var_dictionary.dart b/lib/src/kdbx_var_dictionary.dart index b1ff948..3f01e01 100644 --- a/lib/src/kdbx_var_dictionary.dart +++ b/lib/src/kdbx_var_dictionary.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:kdbx/src/internal/byte_utils.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; @@ -103,10 +105,23 @@ class VarDictionary { return VarDictionary(items); } + static const DEFAULT_VERSION = 0x0100; final List> _items; final Map> _dict; + Uint8List write() { + final writer = WriterHelper(); + writer.writeUint16(DEFAULT_VERSION); + for (final item in _items) { + item._valueType.encoder(writer, item._value); + } + writer.writeUint8(0); + return writer.output.toBytes(); + } + T get(ValueType type, String key) => _dict[key]?._value as T; + void set(ValueType type, String key, T value) => + _dict[key] = VarDictionaryItem(key, type, value); static VarDictionaryItem _readItem(ReaderHelper reader) { final type = reader.readUint8(); diff --git a/lib/src/utils/scope_functions.dart b/lib/src/utils/scope_functions.dart new file mode 100644 index 0000000..44f8089 --- /dev/null +++ b/lib/src/utils/scope_functions.dart @@ -0,0 +1,17 @@ +/// https://github.com/YusukeIwaki/dart-kotlin_flavor/blob/74593dada94bdd8ca78946ad005d3a2624dc833f/lib/scope_functions.dart +/// MIT license: https://github.com/YusukeIwaki/dart-kotlin_flavor/blob/74593dada94bdd8ca78946ad005d3a2624dc833f/LICENSE + +ReturnType run(ReturnType operation()) { + return operation(); +} + +extension ScopeFunctionsForObject on T { + ReturnType let(ReturnType operationFor(T self)) { + return operationFor(this); + } + + T also(void operationFor(T self)) { + operationFor(this); + return this; + } +} diff --git a/libargon2_ffi.dylib b/libargon2_ffi.dylib index bf06d71..4c38cd6 100755 Binary files a/libargon2_ffi.dylib and b/libargon2_ffi.dylib differ diff --git a/test/kdbx4_test.dart b/test/kdbx4_test.dart index d7c1af8..aba9315 100644 --- a/test/kdbx4_test.dart +++ b/test/kdbx4_test.dart @@ -110,8 +110,22 @@ void main() { kdbxFormat.read(data, Credentials(ProtectedValue.fromString('asdf'))); final firstEntry = file.body.rootGroup.entries.first; final pwd = firstEntry.getString(KdbxKey('Password')).getText(); - _logger.info('password: $pwd'); expect(pwd, 'MyPassword'); }); }); + group('Writing', () { + test('Create and save', () { + final credentials = Credentials(ProtectedValue.fromString('asdf')); + final kdbx = kdbxFormat.create(credentials, 'Test Keystore'); + final rootGroup = kdbx.body.rootGroup; + final entry = KdbxEntry.create(kdbx, rootGroup); + rootGroup.addEntry(entry); + entry.setString( + KdbxKey('Password'), ProtectedValue.fromString('LoremIpsum')); + final saved = kdbx.save(); + + final loadedKdbx = kdbxFormat.read(saved, credentials); + File('test_v4.kdbx').writeAsBytesSync(saved); + }); + }); }