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.
490 lines
16 KiB
490 lines
16 KiB
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'); |
|
|
|
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 { |
|
/// id: 0 |
|
none, |
|
|
|
/// id: 1 |
|
gzip, |
|
} |
|
|
|
/// how protected values are encrypted in the xml. |
|
enum ProtectedValueEncryption { plainText, arc4variant, salsa20, chaCha20 } |
|
|
|
enum HeaderFields { |
|
EndOfHeader, |
|
Comment, |
|
CipherID, |
|
CompressionFlags, |
|
MasterSeed, |
|
TransformSeed, |
|
TransformRounds, |
|
EncryptionIV, |
|
ProtectedStreamKey, |
|
StreamStartBytes, |
|
InnerRandomStreamID, // crsAlgorithm |
|
KdfParameters, |
|
PublicCustomData, |
|
} |
|
|
|
enum InnerHeaderFields { |
|
EndOfHeader, |
|
InnerRandomStreamID, |
|
InnerRandomStreamKey, |
|
Binary, |
|
} |
|
|
|
abstract class HeaderFieldBase<T> { |
|
T get field; |
|
} |
|
|
|
class HeaderField implements HeaderFieldBase<HeaderFields> { |
|
HeaderField(this.field, this.bytes); |
|
|
|
@override |
|
final HeaderFields field; |
|
final Uint8List bytes; |
|
|
|
String get name => field.toString(); |
|
} |
|
|
|
class InnerHeaderField implements HeaderFieldBase<InnerHeaderFields> { |
|
InnerHeaderField(this.field, this.bytes); |
|
|
|
@override |
|
final InnerHeaderFields field; |
|
final Uint8List bytes; |
|
|
|
String get name => field.toString(); |
|
} |
|
|
|
class KdbxHeader { |
|
KdbxHeader({ |
|
@required this.sig1, |
|
@required this.sig2, |
|
@required this.versionMinor, |
|
@required this.versionMajor, |
|
@required this.fields, |
|
@required this.endPos, |
|
Map<InnerHeaderFields, InnerHeaderField> innerFields, |
|
}) : innerFields = innerFields ?? {}; |
|
|
|
KdbxHeader.create() |
|
: this( |
|
sig1: Consts.FileMagic, |
|
sig2: Consts.Sig2Kdbx, |
|
versionMinor: 1, |
|
versionMajor: 3, |
|
fields: _defaultFieldValues(), |
|
endPos: null, |
|
); |
|
|
|
KdbxHeader.createV4() |
|
: this( |
|
sig1: Consts.FileMagic, |
|
sig2: Consts.Sig2Kdbx, |
|
versionMinor: 1, |
|
versionMajor: 4, |
|
fields: _defaultFieldValuesV4(), |
|
innerFields: _defaultInnerFieldValuesV4(), |
|
endPos: null, |
|
); |
|
|
|
static List<HeaderFields> _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 { |
|
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) { |
|
throw KdbxCorruptedFileException('Missing header $required'); |
|
} |
|
} |
|
} |
|
|
|
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); |
|
} |
|
|
|
void _setInnerHeaderField(InnerHeaderFields field, Uint8List bytes) { |
|
innerFields[field] = InnerHeaderField(field, bytes); |
|
} |
|
|
|
void generateSalts() { |
|
// TODO make sure default algorithm is "secure" engouh. Or whether we should |
|
// use like [SecureRandom] from PointyCastle? |
|
_setHeaderField(HeaderFields.MasterSeed, ByteUtils.randomBytes(32)); |
|
if (versionMajor < 4) { |
|
_setHeaderField(HeaderFields.TransformSeed, ByteUtils.randomBytes(32)); |
|
_setHeaderField(HeaderFields.StreamStartBytes, ByteUtils.randomBytes(32)); |
|
_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 3.x and 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); |
|
} |
|
fields[HeaderFields.EndOfHeader] = |
|
HeaderField(HeaderFields.EndOfHeader, Uint8List(0)); |
|
_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) { |
|
return; |
|
} |
|
_logger.finer('Writing header $field (${value.bytes.lengthInBytes})'); |
|
writer.writeUint8(field.index); |
|
_writeFieldSize(writer, value.bytes.lengthInBytes); |
|
writer.writeBytes(value.bytes); |
|
} |
|
|
|
void _writeFieldSize(WriterHelper writer, int size) { |
|
if (versionMajor >= 4) { |
|
writer.writeUint32(size); |
|
} else { |
|
writer.writeUint16(size); |
|
} |
|
} |
|
|
|
static Map<HeaderFields, HeaderField> _defaultFieldValues() => |
|
Map.fromEntries([ |
|
HeaderField(HeaderFields.CipherID, |
|
CryptoConsts.CIPHER_IDS[Cipher.aes].toBytes()), |
|
HeaderField( |
|
HeaderFields.CompressionFlags, WriterHelper.singleUint32Bytes(1)), |
|
HeaderField( |
|
HeaderFields.TransformRounds, WriterHelper.singleUint64Bytes(6000)), |
|
HeaderField( |
|
HeaderFields.InnerRandomStreamID, |
|
WriterHelper.singleUint32Bytes(ProtectedValueEncryption.values |
|
.indexOf(ProtectedValueEncryption.salsa20))), |
|
].map((f) => MapEntry(f.field, f))); |
|
|
|
static Map<HeaderFields, HeaderField> _defaultFieldValuesV4() => |
|
_defaultFieldValues() |
|
..remove(HeaderFields.TransformRounds) |
|
..remove(HeaderFields.InnerRandomStreamID) |
|
..remove(HeaderFields.ProtectedStreamKey) |
|
..also((fields) { |
|
fields[HeaderFields.KdfParameters] = HeaderField( |
|
HeaderFields.KdfParameters, |
|
_createKdfDefaultParameters().write()); |
|
}); |
|
|
|
static Map<InnerHeaderFields, InnerHeaderField> |
|
_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(); |
|
final sig2 = reader.readUint32(); |
|
if (!(sig1 == Consts.FileMagic && sig2 == Consts.Sig2Kdbx)) { |
|
throw UnsupportedError( |
|
'Unsupported file structure. ${ByteUtils.toHex(sig1)}, ' |
|
'${ByteUtils.toHex(sig2)}'); |
|
} |
|
|
|
// reading version |
|
final versionMinor = reader.readUint16(); |
|
final versionMajor = reader.readUint16(); |
|
|
|
_logger.finer('Reading version: $versionMajor.$versionMinor'); |
|
final headerFields = readAllFields( |
|
reader, |
|
versionMajor, |
|
HeaderFields.values, |
|
(HeaderFields field, value) => HeaderField(field, value)); |
|
|
|
return KdbxHeader( |
|
sig1: sig1, |
|
sig2: sig2, |
|
versionMinor: versionMinor, |
|
versionMajor: versionMajor, |
|
fields: headerFields, |
|
endPos: reader.pos, |
|
); |
|
} |
|
|
|
static Map<HeaderFields, HeaderField> readHeaderFields( |
|
ReaderHelper reader, int versionMajor) => |
|
readAllFields(reader, versionMajor, HeaderFields.values, |
|
(HeaderFields field, value) => HeaderField(field, value)); |
|
|
|
static Map<InnerHeaderFields, InnerHeaderField> readInnerHeaderFields( |
|
ReaderHelper reader, int versionMajor) => |
|
readAllFields(reader, versionMajor, InnerHeaderFields.values, |
|
(InnerHeaderFields field, value) => InnerHeaderField(field, value)); |
|
|
|
static Map<TE, T> readAllFields<T extends HeaderFieldBase<TE>, TE>( |
|
ReaderHelper reader, |
|
int versionMajor, |
|
List<TE> fields, |
|
T createField(TE field, Uint8List bytes)) => |
|
Map<TE, T>.fromEntries( |
|
readField(reader, versionMajor, fields, createField) |
|
.map((field) => MapEntry(field.field, field))); |
|
|
|
static Iterable<T> readField<T, TE>(ReaderHelper reader, int versionMajor, |
|
List<TE> fields, T createField(TE field, Uint8List bytes)) sync* { |
|
while (true) { |
|
final headerId = reader.readUint8(); |
|
final int bodySize = |
|
versionMajor >= 4 ? reader.readUint32() : reader.readUint16(); |
|
final bodyBytes = bodySize > 0 ? reader.readBytes(bodySize) : null; |
|
_logger.finer( |
|
'Read header ${fields[headerId]}: ${ByteUtils.toHexList(bodyBytes)}'); |
|
if (headerId > 0) { |
|
final TE field = fields[headerId]; |
|
yield createField(field, bodyBytes); |
|
/* else { |
|
if (field == InnerHeaderFields.InnerRandomStreamID) { |
|
yield HeaderField(HeaderFields.InnerRandomStreamID, bodyBytes); |
|
} else if (field == InnerHeaderFields.InnerRandomStreamKey) { |
|
yield HeaderField(HeaderFields.ProtectedStreamKey, bodyBytes); |
|
} |
|
}*/ |
|
} else { |
|
break; |
|
} |
|
} |
|
} |
|
|
|
final int sig1; |
|
final int sig2; |
|
final int versionMinor; |
|
final int versionMajor; |
|
final Map<HeaderFields, HeaderField> fields; |
|
final Map<InnerHeaderFields, InnerHeaderField> innerFields; |
|
|
|
/// end position of the header, if we have been reading from a stream. |
|
final int endPos; |
|
|
|
Compression get compression { |
|
switch (ReaderHelper.singleUint32( |
|
fields[HeaderFields.CompressionFlags].bytes)) { |
|
case 0: |
|
return Compression.none; |
|
case 1: |
|
return Compression.gzip; |
|
default: |
|
throw KdbxUnsupportedException('compression'); |
|
} |
|
} |
|
|
|
ProtectedValueEncryption get innerRandomStreamEncryption => |
|
ProtectedValueEncryption |
|
.values[ReaderHelper.singleUint32(_innerRandomStreamEncryptionBytes)]; |
|
|
|
Uint8List get _innerRandomStreamEncryptionBytes => versionMajor >= 4 |
|
? innerFields[InnerHeaderFields.InnerRandomStreamID].bytes |
|
: fields[HeaderFields.InnerRandomStreamID].bytes; |
|
|
|
Uint8List get protectedStreamKey => versionMajor >= 4 |
|
? innerFields[InnerHeaderFields.InnerRandomStreamKey].bytes |
|
: fields[HeaderFields.ProtectedStreamKey].bytes; |
|
|
|
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}'; |
|
} |
|
} |
|
|
|
class KdbxException implements Exception {} |
|
|
|
class KdbxInvalidKeyException implements KdbxException {} |
|
|
|
class KdbxCorruptedFileException implements KdbxException { |
|
KdbxCorruptedFileException([this.message]); |
|
|
|
final String message; |
|
} |
|
|
|
class KdbxUnsupportedException implements KdbxException { |
|
KdbxUnsupportedException(this.hint); |
|
|
|
final String hint; |
|
|
|
@override |
|
String toString() { |
|
return 'KdbxUnsupportedException{hint: $hint}'; |
|
} |
|
} |
|
|
|
class HashedBlockReader { |
|
static const BLOCK_SIZE = 1024 * 1024; |
|
static const HASH_SIZE = 32; |
|
|
|
static Uint8List readBlocks(ReaderHelper reader) => |
|
Uint8List.fromList(readNextBlock(reader).expand((x) => x).toList()); |
|
|
|
static Iterable<Uint8List> readNextBlock(ReaderHelper reader) sync* { |
|
int expectedBlockIndex = 0; |
|
while (true) { |
|
// ignore: unused_local_variable |
|
final blockIndex = reader.readUint32(); |
|
assert(blockIndex == expectedBlockIndex++); |
|
final blockHash = reader.readBytes(HASH_SIZE); |
|
final blockSize = reader.readUint32(); |
|
if (blockSize > 0) { |
|
final blockData = reader.readBytes(blockSize); |
|
if (!ByteUtils.eq( |
|
crypto.sha256.convert(blockData).bytes as Uint8List, blockHash)) { |
|
throw KdbxCorruptedFileException(); |
|
} |
|
yield blockData; |
|
} else { |
|
break; |
|
} |
|
} |
|
} |
|
|
|
// static Uint8List writeBlocks(WriterHelper writer) => |
|
|
|
static void writeBlocks(ReaderHelper reader, WriterHelper writer) { |
|
for (int blockIndex = 0;; blockIndex++) { |
|
final block = reader.readBytesUpTo(BLOCK_SIZE); |
|
if (block.lengthInBytes == 0) { |
|
// written all data, write a last empty block. |
|
writer.writeUint32(blockIndex); |
|
writer.writeBytes(Uint8List.fromList( |
|
List.generate(HASH_SIZE, (i) => 0))); // hash 32 ** 0x0 |
|
writer.writeUint32(0); // block size = 0 |
|
return; |
|
} |
|
final blockSize = block.lengthInBytes; |
|
final blockHash = crypto.sha256.convert(block); |
|
assert(blockHash.bytes.length == HASH_SIZE); |
|
writer.writeUint32(blockIndex); |
|
writer.writeBytes(blockHash.bytes as Uint8List); |
|
writer.writeUint32(blockSize); |
|
writer.writeBytes(block); |
|
} |
|
} |
|
}
|
|
|