diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/README.md b/README.md index 795f935..c221ce3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,10 @@ KeepassX format implementation in pure dart. -Very much based on https://github.com/keeweb/kdbxweb/ +## Resources + +* Code is very much based on https://github.com/keeweb/kdbxweb/ +* https://gist.github.com/msmuenchen/9318327 ## Usage diff --git a/bin/kdbx.dart b/bin/kdbx.dart new file mode 100644 index 0000000..2a12b9a --- /dev/null +++ b/bin/kdbx.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:kdbx/src/crypto/protected_value.dart'; +import 'package:kdbx/src/kdbx_format.dart'; +import 'package:kdbx/src/kdbx_header.dart'; +import 'package:logging/logging.dart'; +import 'package:logging_appenders/logging_appenders.dart'; +import 'package:prompts/prompts.dart' as prompts; + +final _logger = Logger('kdbx'); + +void main(List arguments) { + exitCode = 0; + + final runner = KdbxCommandRunner('kdbx', 'Kdbx Utility'); + runner.run(arguments).catchError((dynamic error, StackTrace stackTrace) { + if (error is! UsageException) { + return Future.error(error, stackTrace); + } + print(error); + exit(64); + return null; + }); + + // final inputFile = args['input'] as String; +// if (inputFile == null) { +// print('Missing Argument --input'); +// print(parser.usage); +// exitCode = 1; +// return; +// } + _logger.info('done.'); +} + +class KdbxCommandRunner extends CommandRunner { + KdbxCommandRunner(String executableName, String description) : super(executableName, description) { + argParser.addFlag('verbose', abbr: 'v'); + addCommand(CatCommand()); + } + + @override + Future runCommand(ArgResults topLevelResults) { + PrintAppender().attachToLogger(Logger.root); + Logger.root.level = Level.INFO; + if (topLevelResults['verbose'] as bool) { + Logger.root.level = Level.ALL; + } + return super.runCommand(topLevelResults); + } +} + +abstract class KdbxFileCommand extends Command { + KdbxFileCommand() { + argParser.addOption( + 'input', + abbr: 'i', + help: 'Input kdbx file', + valueHelp: 'foo.kdbx', + ); + } + + @override + FutureOr run() async { + final inputFile = argResults['input'] as String; + if (inputFile == null) { + usageException('Required argument: --input'); + } + final bytes = await File(inputFile).readAsBytes(); + final password = prompts.get('Password for $inputFile', conceal: true, validate: (str) => str.isNotEmpty); + final file = await KdbxFormat.read(bytes, Credentials(ProtectedValue.fromString(password))); + return runWithFile(file); + } + + Future runWithFile(KdbxFile file); +} + +class CatCommand extends KdbxFileCommand { + @override + String get description => 'outputs all entries from file.'; + + @override + String get name => 'cat'; + + @override + Future runWithFile(KdbxFile file) async { + _logger.severe('running'); + } +} diff --git a/lib/src/crypto/protected_salt_generator.dart b/lib/src/crypto/protected_salt_generator.dart new file mode 100644 index 0000000..265f8a7 --- /dev/null +++ b/lib/src/crypto/protected_salt_generator.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:pointycastle/export.dart'; + +class ProtectedSaltGenerator { + factory ProtectedSaltGenerator(Uint8List key) { + final hash = sha256.convert(key).bytes as Uint8List; + final cipher = Salsa20Engine()..init(false, ParametersWithIV(KeyParameter(hash), SalsaNonce)); + return ProtectedSaltGenerator._(cipher); + } + + ProtectedSaltGenerator._(this.cipher); + + static final SalsaNonce = Uint8List.fromList([0xE8, 0x30, 0x09, 0x4B, 0x97, 0x20, 0x5D, 0x2A]); + final StreamCipher cipher; + + String decryptBase64(String protectedValue) { + final bytes = base64.decode(protectedValue); + final result = cipher.process(bytes); + final decrypted = utf8.decode(result); + return decrypted; + } +} diff --git a/lib/src/internal/crypto_utils.dart b/lib/src/internal/crypto_utils.dart index f0e2fc6..5e79bb1 100644 --- a/lib/src/internal/crypto_utils.dart +++ b/lib/src/internal/crypto_utils.dart @@ -4,10 +4,6 @@ import 'dart:typed_data'; import 'package:pointycastle/export.dart'; -class CryptoUtils { - -} - /// https://gist.github.com/proteye/e54eef1713e1fe9123d1eb04c0a5cf9b class AesHelper { static const CBC_MODE = 'CBC'; diff --git a/lib/src/kdbx_entry.dart b/lib/src/kdbx_entry.dart new file mode 100644 index 0000000..249a875 --- /dev/null +++ b/lib/src/kdbx_entry.dart @@ -0,0 +1,5 @@ + + +class KdbxEntry { + +} \ No newline at end of file diff --git a/lib/src/kdbx_format.dart b/lib/src/kdbx_format.dart index 9ec1b5c..657f0f6 100644 --- a/lib/src/kdbx_format.dart +++ b/lib/src/kdbx_format.dart @@ -3,23 +3,48 @@ import 'dart:io'; import 'dart:typed_data'; 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/byte_utils.dart'; import 'package:kdbx/src/internal/crypto_utils.dart'; import 'package:kdbx/src/kdbx_header.dart'; import 'package:logging/logging.dart'; import 'package:pointycastle/export.dart'; +import 'package:xml/xml.dart' as xml; final _logger = Logger('kdbx.format'); +class KdbxFile { + KdbxFile(this.credentials, this.header, this.body); + + static final protectedValues = Expando(); + + final Credentials credentials; + final KdbxHeader header; + final KdbxBody body; +} + +class KdbxBody { + KdbxBody(this.xmlDocument, this.meta); + + final xml.XmlDocument xmlDocument; + final KdbxMeta meta; +} + +class KdbxMeta { + +} class KdbxFormat { - static Future read(Uint8List input, Credentials credentials) async { + + static Future read(Uint8List input, Credentials credentials) async { final reader = ReaderHelper(input); final header = await KdbxHeader.read(reader); - _loadV3(header, reader, credentials); + return _loadV3(header, reader, credentials); } - static void _loadV3(KdbxHeader header, ReaderHelper reader, Credentials credentials) { + static KdbxFile _loadV3( + KdbxHeader header, ReaderHelper reader, Credentials credentials) { // _getMasterKeyV3(header, credentials); final masterKey = _generateMasterKeyV3(header, credentials); final encryptedPayload = reader.readRemaining(); @@ -30,54 +55,80 @@ class KdbxFormat { if (header.compression == Compression.gzip) { final xml = GZipCodec().decode(blocks); final string = utf8.decode(xml); - print('xml: $string'); + return KdbxFile(credentials, header, _loadXml(header, string)); + } else { + return KdbxFile(credentials, header, _loadXml(header, utf8.decode(blocks))); + } + } + + static KdbxBody _loadXml(KdbxHeader header, String xmlString) { + final protectedValueEncryption = header.innerRandomStreamEncryption; + if (protectedValueEncryption != PotectedValueEncryption.salsa20) { + throw KdbxUnsupportedException( + 'Inner encryption: $protectedValueEncryption'); } + final streamKey = + header.fields[HeaderFields.ProtectedStreamKey].bytes.asUint8List(); + final gen = ProtectedSaltGenerator(streamKey); + + final document = xml.parse(xmlString); -// final result = utf8.decode(decrypted); -// final aesEngine = AESFastEngine(); -// aesEngine.init(true, KeyParameter(seed)); -// final key = AesHelper.deriveKey(keyComposite.bytes as Uint8List, salt: seed, iterationCount: rounds, derivedKeyLength: 32); -// final masterKey = Uint8List.fromList(key + masterSeed.asUint8List()); -// print('key length: ${key.length} + ${masterSeed.lengthInBytes} = ${masterKey.lengthInBytes} (${masterKey.lengthInBytes} bytes)'); + for (final el in document + .findAllElements('Value') + .where((el) => el.getAttribute('Protected')?.toLowerCase() == 'true')) { + final pw = gen.decryptBase64(el.text.trim()); + KdbxFile.protectedValues[el] = ProtectedValue.fromString(pw); + } -// final result = AesHelper.decrypt(masterKey, reader.readRemaining()); -// print('before : ${_toHexList(encryptedPayload)}'); + final keePassFile = document.findElements('KeePassFile').single; + final meta = keePassFile.findElements('Meta').single; + final groupRoot = keePassFile.findElements('Root').single; + _logger.fine('got meta: ${meta.toXmlString(pretty: true)}'); + return KdbxBody(document, KdbxMeta()); } - static Uint8List _decryptContent(KdbxHeader header, Uint8List masterKey, Uint8List encryptedPayload) { + static Uint8List _decryptContent( + KdbxHeader header, Uint8List masterKey, Uint8List encryptedPayload) { final encryptionIv = header.fields[HeaderFields.EncryptionIV].bytes; final decryptCipher = CBCBlockCipher(AESFastEngine()); - decryptCipher.init(false, ParametersWithIV(KeyParameter(masterKey), encryptionIv.asUint8List())); + decryptCipher.init(false, + ParametersWithIV(KeyParameter(masterKey), encryptionIv.asUint8List())); final decrypted = AesHelper.processBlocks(decryptCipher, encryptedPayload); final streamStart = header.fields[HeaderFields.StreamStartBytes].bytes; - _logger.finest('streamStart: ${ByteUtils.toHexList(streamStart.asUint8List())}'); - _logger.finest('actual : ${ByteUtils.toHexList(decrypted.sublist(0, streamStart.lengthInBytes))}'); + _logger.finest( + 'streamStart: ${ByteUtils.toHexList(streamStart.asUint8List())}'); + _logger.finest( + 'actual : ${ByteUtils.toHexList(decrypted.sublist(0, streamStart.lengthInBytes))}'); - if (!ByteUtils.eq(streamStart.asUint8List(), decrypted.sublist(0, streamStart.lengthInBytes))) { + if (!ByteUtils.eq(streamStart.asUint8List(), + decrypted.sublist(0, streamStart.lengthInBytes))) { throw KdbxInvalidKeyException(); } final content = decrypted.sublist(streamStart.lengthInBytes); return content; } - static Uint8List _generateMasterKeyV3(KdbxHeader header, Credentials credentials) { - final rounds = header.fields[HeaderFields.TransformRounds].bytes.asUint64List().first; + static Uint8List _generateMasterKeyV3( + KdbxHeader header, Credentials credentials) { + final rounds = + header.fields[HeaderFields.TransformRounds].bytes.asUint64List().first; final seed = header.fields[HeaderFields.TransformSeed].bytes.asUint8List(); final masterSeed = header.fields[HeaderFields.MasterSeed].bytes; _logger.finer('Rounds: $rounds'); - final cipher = ECBBlockCipher(AESFastEngine())..init(true, KeyParameter(seed)); + final cipher = ECBBlockCipher(AESFastEngine()) + ..init(true, KeyParameter(seed)); final pwHash = credentials.getHash(); var transformedKey = pwHash; for (int i = 0; i < rounds; i++) { transformedKey = AesHelper.processBlocks(cipher, transformedKey); } transformedKey = crypto.sha256.convert(transformedKey).bytes as Uint8List; - final masterKey = - crypto.sha256.convert(Uint8List.fromList(masterSeed.asUint8List() + transformedKey)).bytes as Uint8List; + final masterKey = crypto.sha256 + .convert(Uint8List.fromList(masterSeed.asUint8List() + transformedKey)) + .bytes as Uint8List; return masterKey; } - } diff --git a/lib/src/kdbx_group.dart b/lib/src/kdbx_group.dart new file mode 100644 index 0000000..01fb46a --- /dev/null +++ b/lib/src/kdbx_group.dart @@ -0,0 +1,4 @@ + +class KdbxGroup { + +} \ No newline at end of file diff --git a/lib/src/kdbx_header.dart b/lib/src/kdbx_header.dart index 0c49e05..b883e97 100644 --- a/lib/src/kdbx_header.dart +++ b/lib/src/kdbx_header.dart @@ -1,5 +1,3 @@ -import 'dart:convert'; -import 'dart:io'; import 'dart:typed_data'; import 'package:convert/convert.dart' as convert; @@ -7,7 +5,6 @@ import 'package:crypto/crypto.dart' as crypto; import 'package:kdbx/src/crypto/protected_value.dart'; import 'package:kdbx/src/internal/byte_utils.dart'; import 'package:logging/logging.dart'; -import 'package:pointycastle/export.dart'; final _logger = Logger('kdbx.header'); @@ -25,6 +22,9 @@ enum Compression { gzip, } +/// how protected values are encrypted in the xml. +enum PotectedValueEncryption { plainText, arc4variant, salsa20 } + enum HeaderFields { EndOfHeader, Comment, @@ -79,9 +79,9 @@ class KdbxHeader { static Iterable readField(ReaderHelper reader, int versionMajor) sync* { while (true) { final headerId = reader.readUint8(); - int size = versionMajor >= 4 ? reader.readUint32() : reader.readUint16(); + final int bodySize = versionMajor >= 4 ? reader.readUint32() : reader.readUint16(); _logger.finer('Read header ${HeaderFields.values[headerId]}'); - final bodyBytes = size > 0 ? reader.readBytes(size) : null; + final bodyBytes = bodySize > 0 ? reader.readBytes(bodySize) : null; if (headerId > 0) { yield HeaderField(HeaderFields.values[headerId], bodyBytes); } else { @@ -106,6 +106,9 @@ class KdbxHeader { throw KdbxUnsupportedException('compression'); } } + + PotectedValueEncryption get innerRandomStreamEncryption => + PotectedValueEncryption.values[fields[HeaderFields.InnerRandomStreamID].bytes.asUint32List().single]; } class Credentials { @@ -134,8 +137,6 @@ class KdbxUnsupportedException implements KdbxException { final String hint; } - - class HashedBlockReader { static Uint8List readBlocks(ReaderHelper reader) => Uint8List.fromList(readNextBlock(reader).expand((x) => x).toList()); @@ -176,4 +177,3 @@ class ReaderHelper { Uint8List readRemaining() => data.sublist(pos); } - diff --git a/pubspec.yaml b/pubspec.yaml index 3af2f8a..fe5a52e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,10 +11,14 @@ dependencies: # path: ^1.6.0 logging: '>=0.11.3+2 <1.0.0' crypto: '>=2.0.0 <3.0.0' - pointycastle: ^1.0.1 - xml: ^3.5.0 + pointycastle: '>=1.0.1 <2.0.0' + xml: '>=3.5.0 <4.0.0' -dev_dependencies: + # required for bin/ + args: '>1.5.0 <2.0.0' + prompts: '>=1.3.0 <2.0.0' logging_appenders: '>=0.1.0 <1.0.0' + +dev_dependencies: pedantic: ^1.7.0 test: ^1.6.0 diff --git a/test/FooBar.2.content.xml b/test/FooBar.2.content.xml new file mode 100644 index 0000000..6d3ecd8 --- /dev/null +++ b/test/FooBar.2.content.xml @@ -0,0 +1,122 @@ + + + + KdbxWeb + r9uD96EiudcSeXq62ma/OqtBBPf+EYX7iMrBaNJBRwo= + FooBar + 2019-08-20T13:16:06Z + + 2019-08-20T13:15:47Z + + 2019-08-20T13:15:47Z + 365 + + 2019-08-20T13:16:03Z + -1 + -1 + True + dVSBC/BAx70qcsy6XkrGJA== + 2019-08-20T13:15:47Z + AAAAAAAAAAAAAAAAAAAAAA== + 2019-08-20T13:15:47Z + 10 + 6291456 + + + + False + False + True + False + False + + + + + + + + LAQMkihXTkxhA2D2tE40Fg== + FooBar + + 49 + + 2019-08-20T13:15:47Z + 2019-08-20T13:16:06Z + 2019-08-20T13:16:06Z + 2019-08-20T13:15:47Z + False + 0 + 2019-08-20T13:15:47Z + + True + + null + null + AAAAAAAAAAAAAAAAAAAAAA== + + dVSBC/BAx70qcsy6XkrGJA== + Recycle Bin + + 43 + + 2019-08-20T13:15:47Z + 2019-08-20T13:15:47Z + 2019-08-20T13:15:47Z + 2019-08-20T13:15:47Z + False + 0 + 2019-08-20T13:15:47Z + + True + + False + False + AAAAAAAAAAAAAAAAAAAAAA== + + + RMUeiV24MDcrIY8qQYRAlg== + 0 + + + + + + 2019-08-21T00:17:25Z + 2019-08-21T00:17:36Z + 2019-08-21T00:17:36Z + 2019-08-21T00:17:25Z + False + 0 + 2019-08-21T00:17:25Z + + + Title + loremipsum.com + + + UserName + foo + + + Password + QcvZ + + + URL + + + + Notes + + + + True + 0 + + + + + + + \ No newline at end of file