KeepassX format implementation in pure dart.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

413 lines
14 KiB

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
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/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_meta.dart';
import 'package:kdbx/src/kdbx_object.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;
import 'package:kdbx/src/internal/extension_utils.dart';
final _logger = Logger('kdbx.format');
abstract class Credentials {
factory Credentials(ProtectedValue password) =>
Credentials.composite(password, null); //PasswordCredentials(password);
factory Credentials.composite(ProtectedValue password, Uint8List keyFile) =>
KeyFileComposite(
password: password == null ? null : PasswordCredentials(password),
keyFile: keyFile == null ? null : KeyFileCredentials(keyFile),
);
factory Credentials.fromHash(Uint8List hash) => HashCredentials(hash);
Uint8List getHash();
}
class KeyFileComposite implements Credentials {
KeyFileComposite({@required this.password, @required this.keyFile});
PasswordCredentials password;
KeyFileCredentials keyFile;
@override
Uint8List getHash() {
final buffer = [...?password?.getBinary(), ...?keyFile?.getBinary()];
return crypto.sha256.convert(buffer).bytes as Uint8List;
// final output = convert.AccumulatorSink<crypto.Digest>();
// final input = crypto.sha256.startChunkedConversion(output);
//// input.add(password.getHash());
// input.add(buffer);
// input.close();
// return output.events.single.bytes as Uint8List;
}
}
abstract class CredentialsPart {
Uint8List getBinary();
}
class KeyFileCredentials implements CredentialsPart {
factory KeyFileCredentials(Uint8List keyFileContents) {
final keyFileAsString = utf8.decode(keyFileContents);
try {
if (_hexValuePattern.hasMatch(keyFileAsString)) {
return KeyFileCredentials._(ProtectedValue.fromBinary(
convert.hex.decode(keyFileAsString) as Uint8List));
}
final xmlContent = xml.parse(keyFileAsString);
final key = xmlContent.findAllElements('Key').single;
final dataString = key.findElements('Data').single;
final dataBytes = base64.decode(dataString.text);
_logger.finer('Decoded base64 of keyfile.');
return KeyFileCredentials._(ProtectedValue.fromBinary(dataBytes));
} catch (e, stackTrace) {
_logger.warning(
'Unable to parse key file as hex or XML, use as is.', e, stackTrace);
final bytes = crypto.sha256.convert(keyFileContents).bytes as Uint8List;
return KeyFileCredentials._(ProtectedValue.fromBinary(bytes));
}
}
KeyFileCredentials._(this._keyFileValue);
static final RegExp _hexValuePattern = RegExp(r'/^[a-f\d]{64}$/i');
final ProtectedValue _keyFileValue;
@override
Uint8List getBinary() {
return _keyFileValue.binaryValue;
// return crypto.sha256.convert(_keyFileValue.binaryValue).bytes as Uint8List;
}
}
class PasswordCredentials implements CredentialsPart {
PasswordCredentials(this._password);
final ProtectedValue _password;
@override
Uint8List getBinary() {
return _password.hash;
}
}
class HashCredentials implements Credentials {
HashCredentials(this.hash);
final Uint8List hash;
@override
Uint8List getHash() => hash;
}
class KdbxFile {
KdbxFile(this.credentials, this.header, this.body) {
for (final obj in _allObjects) {
obj.file = this;
}
}
static final protectedValues = Expando<ProtectedValue>();
static ProtectedValue protectedValueForNode(xml.XmlElement node) {
return protectedValues[node];
}
static void setProtectedValueForNode(
xml.XmlElement node, ProtectedValue value) {
protectedValues[node] = value;
}
final Credentials credentials;
final KdbxHeader header;
final KdbxBody body;
final Set<KdbxObject> dirtyObjects = {};
final StreamController<Set<KdbxObject>> _dirtyObjectsChanged =
StreamController<Set<KdbxObject>>.broadcast();
Stream<Set<KdbxObject>> get dirtyObjectsChanged =>
_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);
dirtyObjects.clear();
_dirtyObjectsChanged.add(dirtyObjects);
return output.toBytes();
}
Iterable<KdbxObject> get _allObjects => body.rootGroup
.getAllGroups()
.cast<KdbxObject>()
.followedBy(body.rootGroup.getAllEntries());
void dirtyObject(KdbxObject kdbxObject) {
dirtyObjects.add(kdbxObject);
dirtyObjects.clear();
}
void dispose() {
_dirtyObjectsChanged.close();
}
// 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 {
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 KdbxMeta meta;
final KdbxGroup rootGroup;
void writeV3(WriterHelper writer, KdbxFile kdbxFile,
ProtectedSaltGenerator saltGenerator) {
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 encrypted = _encryptV3(kdbxFile, compressedBytes);
writer.writeBytes(encrypted);
}
Uint8List _encryptV3(KdbxFile kdbxFile, Uint8List compressedBytes) {
final byteWriter = WriterHelper();
byteWriter.writeBytes(
kdbxFile.header.fields[HeaderFields.StreamStartBytes].bytes);
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);
return encrypted;
}
xml.XmlDocument generateXml(ProtectedSaltGenerator saltGenerator) {
final rootGroupNode = rootGroup.toXml();
// update protected values...
for (final el in rootGroupNode.findAllElements(KdbxXml.NODE_VALUE).where(
(el) =>
el.getAttribute(KdbxXml.ATTR_PROTECTED)?.toLowerCase() == 'true')) {
final pv = KdbxFile.protectedValues[el];
if (pv != null) {
final newValue = saltGenerator.encryptToBase64(pv.getText());
el.children.clear();
el.children.add(xml.XmlText(newValue));
} else {
// assert((() {
// _logger.severe('Unable to find protected value for $el ${el.parent.parent} (children: ${el.children})');
// return false;
// })());
// this is always an error, not just during debug.
throw StateError('Unable to find protected value for $el ${el.parent}');
}
}
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),
],
);
// final doc = xml.XmlDocument();
// doc.children.add(xml.XmlProcessing(
// 'xml', 'version="1.0" encoding="utf-8" standalone="yes"'));
final node = builder.build() as xml.XmlDocument;
return node;
}
}
class KdbxFormat {
static KdbxFile create(Credentials credentials, String name) {
final header = KdbxHeader.create();
final meta = KdbxMeta.create(
databaseName: name,
generator: 'AuthPass',
);
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);
if (header.versionMajor != 3) {
_logger.finer('Unsupported version for $header');
throw KdbxUnsupportedException('Unsupported kdbx version '
'${header.versionMajor}.${header.versionMinor}.'
' Only 3.x is supported.');
}
return _loadV3(header, reader, credentials);
}
static KdbxFile _loadV3(
KdbxHeader header, ReaderHelper reader, Credentials credentials) {
// _getMasterKeyV3(header, credentials);
final masterKey = _generateMasterKeyV3(header, credentials);
final encryptedPayload = reader.readRemaining();
final content = _decryptContent(header, masterKey, encryptedPayload);
final blocks = HashedBlockReader.readBlocks(ReaderHelper(content));
_logger.finer('compression: ${header.compression}');
if (header.compression == Compression.gzip) {
final xml = GZipCodec().decode(blocks);
final string = utf8.decode(xml);
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 != ProtectedValueEncryption.salsa20) {
throw KdbxUnsupportedException(
'Inner encryption: $protectedValueEncryption');
}
final streamKey = header.fields[HeaderFields.ProtectedStreamKey].bytes;
final gen = ProtectedSaltGenerator(streamKey);
final document = xml.parse(xmlString);
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 keePassFile = document.findElements('KeePassFile').single;
final meta = keePassFile.findElements('Meta').single;
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.read(keePassFile, KdbxMeta.read(meta), rootGroup);
}
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));
final paddedDecrypted =
AesHelper.processBlocks(decryptCipher, encryptedPayload);
final streamStart = header.fields[HeaderFields.StreamStartBytes].bytes;
if (paddedDecrypted.lengthInBytes < streamStart.lengthInBytes) {
_logger.warning(
'decrypted content was shorter than expected stream start block.');
throw KdbxInvalidKeyException();
}
_logger.finest('streamStart: ${ByteUtils.toHexList(streamStart)}');
_logger.finest(
'actual : ${ByteUtils.toHexList(paddedDecrypted.sublist(0, streamStart.lengthInBytes))}');
if (!ByteUtils.eq(
streamStart, paddedDecrypted.sublist(0, streamStart.lengthInBytes))) {
throw KdbxInvalidKeyException();
}
final decrypted = AesHelper.unpad(paddedDecrypted);
// ignore: unnecessary_cast
final content = decrypted.sublist(streamStart.lengthInBytes) as Uint8List;
return content;
}
static Uint8List _generateMasterKeyV3(
KdbxHeader header, Credentials credentials) {
final rounds = ReaderHelper.singleUint64(
header.fields[HeaderFields.TransformRounds].bytes);
final seed = header.fields[HeaderFields.TransformSeed].bytes;
final masterSeed = header.fields[HeaderFields.MasterSeed].bytes;
_logger.finer(
'Rounds: $rounds (${ByteUtils.toHexList(header.fields[HeaderFields.TransformRounds].bytes)})');
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 + transformedKey))
.bytes as Uint8List;
return masterKey;
}
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));
}
}