diff --git a/lib/src/credentials/keyfile.dart b/lib/src/credentials/keyfile.dart index b0e3f05..aa1a81f 100644 --- a/lib/src/credentials/keyfile.dart +++ b/lib/src/credentials/keyfile.dart @@ -3,15 +3,23 @@ import 'dart:typed_data'; import 'package:collection/collection.dart' show IterableExtension; import 'package:convert/convert.dart' as convert; +import 'package:crypto/crypto.dart' as crypto; import 'package:kdbx/src/credentials/credentials.dart'; import 'package:kdbx/src/crypto/protected_value.dart'; -import 'package:xml/xml.dart' as xml; -import 'package:crypto/crypto.dart' as crypto; - +import 'package:kdbx/src/utils/byte_utils.dart'; import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:xml/xml.dart' as xml; final _logger = Logger('keyfile'); +const _nodeVersion = 'Version'; +const _nodeKey = 'Key'; +const _nodeData = 'Data'; +const _nodeMeta = 'Meta'; +const _nodeKeyFile = 'KeyFile'; +const _nodeHash = 'Hash'; + class KeyFileCredentials implements CredentialsPart { factory KeyFileCredentials(Uint8List keyFileContents) { try { @@ -22,13 +30,27 @@ class KeyFileCredentials implements CredentialsPart { } final xmlContent = xml.XmlDocument.parse(keyFileAsString); final metaVersion = - xmlContent.findAllElements('Version').singleOrNull?.text; - final key = xmlContent.findAllElements('Key').single; - final dataString = key.findElements('Data').single; + xmlContent.findAllElements(_nodeVersion).singleOrNull?.text; + final key = xmlContent.findAllElements(_nodeKey).single; + final dataString = key.findElements(_nodeData).single; final encoded = dataString.text.replaceAll(RegExp(r'\s'), ''); Uint8List dataBytes; if (metaVersion != null && metaVersion.startsWith('2.')) { dataBytes = convert.hex.decode(encoded) as Uint8List; + assert((() { + final hash = dataString.getAttribute(_nodeHash); + if (hash == null) { + throw const FormatException('Keyfile must contain a hash.'); + } + final expectedHashBytes = convert.hex.decode(hash); + final actualHash = + crypto.sha256.convert(dataBytes).bytes.sublist(0, 4) as Uint8List; + if (!ByteUtils.eq(expectedHashBytes, actualHash)) { + throw const FormatException( + 'Corrupted keyfile. Hash does not match'); + } + return true; + })()); } else { dataBytes = base64.decode(encoded); } @@ -42,6 +64,13 @@ class KeyFileCredentials implements CredentialsPart { } } + /// Creates a new random (32 bytes) keyfile value. + factory KeyFileCredentials.random() => KeyFileCredentials._( + ProtectedValue.fromBinary(ByteUtils.randomBytes(32))); + + factory KeyFileCredentials.fromBytes(Uint8List bytes) => + KeyFileCredentials._(ProtectedValue.fromBinary(bytes)); + KeyFileCredentials._(this._keyFileValue); static final RegExp _hexValuePattern = @@ -54,6 +83,56 @@ class KeyFileCredentials implements CredentialsPart { return _keyFileValue.binaryValue; // return crypto.sha256.convert(_keyFileValue.binaryValue).bytes as Uint8List; } + + /// Generates a `.keyx` file as described for Keepass keyfile: + /// https://keepass.info/help/base/keys.html#keyfiles + Uint8List toXmlV2() { + return utf8.encode(toXmlV2String()) as Uint8List; + } + + /// Generates a `.keyx` file as described for Keepass keyfile: + /// https://keepass.info/help/base/keys.html#keyfiles + @visibleForTesting + String toXmlV2String() { + final hash = + (crypto.sha256.convert(_keyFileValue.binaryValue).bytes as Uint8List) + .sublist(0, 4); + final hashHexString = hexFormatLikeKeepass(convert.hex.encode(hash)); + final keyHexString = + hexFormatLikeKeepass(convert.hex.encode(_keyFileValue.binaryValue)); + + final builder = xml.XmlBuilder() + ..processing('xml', 'version="1.0" encoding="utf-8"'); + builder.element(_nodeKeyFile, nest: () { + builder.element(_nodeMeta, nest: () { + builder.element(_nodeVersion, nest: () { + builder.text('2.0'); + }); + }); + builder.element(_nodeKey, nest: () { + builder.element(_nodeData, nest: () { + builder.attribute(_nodeHash, hashHexString); + builder.text(keyHexString); + }); + }); + }); + return builder.buildDocument().toXmlString(pretty: true); + } + + /// keypass has all-uppercase letters in pairs of 4 bytes (8 characters). + @visibleForTesting + static String hexFormatLikeKeepass(final String hexString) { + final hex = hexString.toUpperCase(); + const _groups = 8; + final remaining = hex.length % _groups; + return [ + for (var i = 0; i < hex.length ~/ _groups; i++) + hex.substring(i * _groups, i * _groups + _groups), + if (remaining != 0) hex.substring(hex.length - remaining) + ].join(' '); + // range(0, hexString.length / 8).map((i) => hexString.substring(i*_groups, i*_groups + _groups)); + // hexString.toUpperCase().chara + } } class KeyFileComposite implements Credentials { diff --git a/lib/src/crypto/protected_value.dart b/lib/src/crypto/protected_value.dart index 1bfee3f..4a97cc0 100644 --- a/lib/src/crypto/protected_value.dart +++ b/lib/src/crypto/protected_value.dart @@ -46,7 +46,7 @@ class ProtectedValue implements StringValue { return ProtectedValue(_xor(value, salt), salt); } - static final random = Random.secure(); + static final _random = Random.secure(); final Uint8List _value; final Uint8List _salt; @@ -57,7 +57,7 @@ class ProtectedValue implements StringValue { static Uint8List _randomBytes(int length) { return Uint8List.fromList( - List.generate(length, (i) => random.nextInt(0xff))); + List.generate(length, (i) => _random.nextInt(0xff))); } static Uint8List _xor(Uint8List a, Uint8List b) { diff --git a/test/keyfile/keyfile_create_test.dart b/test/keyfile/keyfile_create_test.dart new file mode 100644 index 0000000..5d3dbf2 --- /dev/null +++ b/test/keyfile/keyfile_create_test.dart @@ -0,0 +1,45 @@ +import 'dart:typed_data'; + +import 'package:kdbx/kdbx.dart'; +import 'package:logging/logging.dart'; +import 'package:quiver/iterables.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +import '../internal/test_utils.dart'; + +final _logger = Logger('keyfile_create_test'); + +void main() { + // ignore: unused_local_variable + final testUtils = TestUtil.instance; + final exampleBytes = Uint8List.fromList( + range(0, 16).expand((element) => [0xca, 0xfe]).toList()); + group('creating keyfile', () { + test('Create keyfile', () { + final keyFile = KeyFileCredentials.fromBytes(exampleBytes); + final output = keyFile.toXmlV2String(); + _logger.info(output); + expect(output, contains('Hash="4CA06E29"')); + expect(output, contains('CAFECAFE CAFECAFE')); + }); + test('hex format', () { + final toTest = { + 'abcd': 'ABCD', + 'abcdefgh': 'ABCDEFGH', + 'abcdef': 'ABCDEF', + '1234567812345678': '12345678 12345678', + '12345678123456': '12345678 123456', + }; + for (final e in toTest.entries) { + expect(KeyFileCredentials.hexFormatLikeKeepass(e.key), e.value); + } + }); + test('create and load', () { + final keyFile = KeyFileCredentials.fromBytes(exampleBytes); + final output = keyFile.toXmlV2(); + final read = KeyFileCredentials(output); + expect(read.getBinary(), equals(exampleBytes)); + }); + }); +}