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.
394 lines
12 KiB
394 lines
12 KiB
import 'dart:typed_data'; |
|
|
|
import 'package:kdbx/src/crypto/protected_value.dart'; |
|
import 'package:kdbx/src/internal/extension_utils.dart'; |
|
import 'package:kdbx/src/kdbx_binary.dart'; |
|
import 'package:kdbx/src/kdbx_consts.dart'; |
|
import 'package:kdbx/src/kdbx_file.dart'; |
|
import 'package:kdbx/src/kdbx_format.dart'; |
|
import 'package:kdbx/src/kdbx_group.dart'; |
|
import 'package:kdbx/src/kdbx_header.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:path/path.dart' as path; |
|
import 'package:quiver/check.dart'; |
|
import 'package:xml/xml.dart'; |
|
|
|
final _logger = Logger('kdbx.kdbx_entry'); |
|
|
|
/// Represents a case insensitive (but case preserving) key. |
|
class KdbxKey { |
|
KdbxKey(this.key) : _canonicalKey = key.toLowerCase(); |
|
|
|
final String key; |
|
final String _canonicalKey; |
|
|
|
@override |
|
bool operator ==(Object other) => |
|
other is KdbxKey && _canonicalKey == other._canonicalKey; |
|
|
|
@override |
|
int get hashCode => _canonicalKey.hashCode; |
|
|
|
@override |
|
String toString() { |
|
return 'KdbxKey{key: $key}'; |
|
} |
|
} |
|
|
|
extension KdbxEntryInternal on KdbxEntry { |
|
KdbxEntry cloneInto(KdbxGroup otherGroup, {bool toHistoryEntry = false}) => |
|
KdbxEntry.create( |
|
otherGroup.file, |
|
otherGroup, |
|
isHistoryEntry: toHistoryEntry, |
|
) |
|
..forceSetUuid(uuid) |
|
..let(toHistoryEntry ? (x) => null : otherGroup.addEntry) |
|
.._overwriteFrom( |
|
OverwriteContext.noop, |
|
this, |
|
includeHistory: !toHistoryEntry, |
|
); |
|
|
|
List<KdbxSubNode> get _overwriteNodes => [ |
|
...objectNodes, |
|
foregroundColor, |
|
backgroundColor, |
|
overrideURL, |
|
tags, |
|
]; |
|
|
|
void _overwriteFrom( |
|
OverwriteContext overwriteContext, |
|
KdbxEntry other, { |
|
bool includeHistory = false, |
|
}) { |
|
// we only support overwriting history, if it is empty. |
|
checkArgument(!includeHistory || history.isEmpty, |
|
message: |
|
'We can only overwrite with history, if local history is empty.'); |
|
assertSameUuid(other, 'overwrite'); |
|
overwriteSubNodesFrom( |
|
overwriteContext, |
|
_overwriteNodes, |
|
other._overwriteNodes, |
|
); |
|
// overwrite all strings |
|
final stringsDiff = _diffMap(_strings, other._strings); |
|
if (stringsDiff.isNotEmpty) { |
|
overwriteContext.trackChange(this, |
|
node: 'strings', debug: 'changed: ${stringsDiff.join(',')}'); |
|
} |
|
_strings.clear(); |
|
_strings.addAll(other._strings); |
|
// overwrite all binaries |
|
final newBinaries = other._binaries.map((key, value) => MapEntry( |
|
key, |
|
ctx.findBinaryByValue(value) ?? |
|
(value..let((that) => ctx.addBinary(that))), |
|
)); |
|
_binaries.clear(); |
|
_binaries.addAll(newBinaries); |
|
times.overwriteFrom(other.times); |
|
if (includeHistory) { |
|
for (final historyEntry in other.history) { |
|
history.add(historyEntry.cloneInto(parent, toHistoryEntry: false)); |
|
} |
|
} |
|
} |
|
|
|
List<String> _diffMap(Map<Object, Object> a, Map<Object, Object> b) { |
|
final keys = {...a.keys, ...b.keys}; |
|
final ret = <String>[]; |
|
for (final key in keys) { |
|
if (a[key] != b[key]) { |
|
ret.add(key.toString()); |
|
} |
|
} |
|
return ret; |
|
} |
|
} |
|
|
|
class KdbxEntry extends KdbxObject { |
|
/// Creates a new entry in the given parent group. |
|
/// callers are still responsible for calling [parent.addEntry(..)]! |
|
/// |
|
/// FIXME: this makes no sense, we should automatically attach this to the parent. |
|
KdbxEntry.create( |
|
KdbxFile file, |
|
KdbxGroup parent, { |
|
this.isHistoryEntry = false, |
|
}) : history = [], |
|
super.create(file.ctx, file, 'Entry', parent) { |
|
icon.set(KdbxIcon.Key); |
|
} |
|
|
|
KdbxEntry.read(KdbxReadWriteContext ctx, KdbxGroup parent, XmlElement node, |
|
{this.isHistoryEntry = false}) |
|
: history = [], |
|
super.read(ctx, parent, node) { |
|
_strings.addEntries(node.findElements(KdbxXml.NODE_STRING).map((el) { |
|
final key = KdbxKey(el.findElements(KdbxXml.NODE_KEY).single.text); |
|
final valueNode = el.findElements(KdbxXml.NODE_VALUE).single; |
|
if (valueNode.getAttribute(KdbxXml.ATTR_PROTECTED)?.toLowerCase() == |
|
'true') { |
|
return MapEntry(key, KdbxFile.protectedValueForNode(valueNode)); |
|
} else { |
|
return MapEntry(key, PlainValue(valueNode.text)); |
|
} |
|
})); |
|
_binaries.addEntries(node.findElements(KdbxXml.NODE_BINARY).map((el) { |
|
final key = KdbxKey(el.findElements(KdbxXml.NODE_KEY).single.text); |
|
final valueNode = el.findElements(KdbxXml.NODE_VALUE).single; |
|
final ref = valueNode.getAttribute(KdbxXml.ATTR_REF); |
|
if (ref != null) { |
|
final refId = int.parse(ref); |
|
final binary = ctx.binaryById(refId); |
|
if (binary == null) { |
|
throw KdbxCorruptedFileException( |
|
'Unable to find binary with id $refId'); |
|
} |
|
return MapEntry(key, binary); |
|
} |
|
|
|
return MapEntry(key, KdbxBinary.readBinaryXml(valueNode, isInline: true)); |
|
})); |
|
history.addAll(node |
|
.findElements(KdbxXml.NODE_HISTORY) |
|
.singleOrNull |
|
?.findElements('Entry') |
|
?.map((entry) => |
|
KdbxEntry.read(ctx, parent, entry, isHistoryEntry: true)) |
|
?.toList() ?? |
|
[]); |
|
} |
|
|
|
final bool isHistoryEntry; |
|
|
|
final List<KdbxEntry> history; |
|
|
|
ColorNode get foregroundColor => ColorNode(this, 'ForegroundColor'); |
|
ColorNode get backgroundColor => ColorNode(this, 'BackgroundColor'); |
|
StringNode get overrideURL => StringNode(this, 'OverrideURL'); |
|
StringNode get tags => StringNode(this, 'Tags'); |
|
|
|
@override |
|
set file(KdbxFile file) { |
|
super.file = file; |
|
// TODO this looks like some weird workaround, get rid of the |
|
// `file` reference. |
|
for (final historyEntry in history) { |
|
historyEntry.file = file; |
|
} |
|
} |
|
|
|
@override |
|
void onBeforeModify() { |
|
super.onBeforeModify(); |
|
history.add(KdbxEntry.read( |
|
ctx, |
|
parent, |
|
toXml(), |
|
isHistoryEntry: true, |
|
)..file = file); |
|
} |
|
|
|
@override |
|
XmlElement toXml() { |
|
final el = super.toXml(); |
|
XmlUtils.removeChildrenByName(el, KdbxXml.NODE_STRING); |
|
XmlUtils.removeChildrenByName(el, KdbxXml.NODE_HISTORY); |
|
XmlUtils.removeChildrenByName(el, KdbxXml.NODE_BINARY); |
|
el.children.addAll(stringEntries.map((stringEntry) { |
|
final value = XmlElement(XmlName(KdbxXml.NODE_VALUE)); |
|
if (stringEntry.value is ProtectedValue) { |
|
value.attributes.add( |
|
XmlAttribute(XmlName(KdbxXml.ATTR_PROTECTED), KdbxXml.VALUE_TRUE)); |
|
KdbxFile.setProtectedValueForNode( |
|
value, stringEntry.value as ProtectedValue); |
|
} else if (stringEntry.value is StringValue) { |
|
value.children.add(XmlText(stringEntry.value.getText())); |
|
} |
|
return XmlElement(XmlName(KdbxXml.NODE_STRING)) |
|
..children.addAll([ |
|
XmlElement(XmlName(KdbxXml.NODE_KEY)) |
|
..children.add(XmlText(stringEntry.key.key)), |
|
value, |
|
]); |
|
})); |
|
el.children.addAll(binaryEntries.map((binaryEntry) { |
|
final key = binaryEntry.key; |
|
final binary = binaryEntry.value; |
|
final value = XmlElement(XmlName(KdbxXml.NODE_VALUE)); |
|
if (binary.isInline) { |
|
binary.saveToXml(value); |
|
} else { |
|
final binaryIndex = ctx.findBinaryId(binary); |
|
value.addAttribute(KdbxXml.ATTR_REF, binaryIndex.toString()); |
|
} |
|
return XmlElement(XmlName(KdbxXml.NODE_BINARY)) |
|
..children.addAll([ |
|
XmlElement(XmlName(KdbxXml.NODE_KEY))..children.add(XmlText(key.key)), |
|
value, |
|
]); |
|
})); |
|
if (!isHistoryEntry) { |
|
el.children.add( |
|
XmlElement(XmlName(KdbxXml.NODE_HISTORY)) |
|
..children.addAll(history.map((e) => e.toXml())), |
|
); |
|
} |
|
return el; |
|
} |
|
|
|
final Map<KdbxKey, StringValue> _strings = {}; |
|
|
|
final Map<KdbxKey, KdbxBinary> _binaries = {}; |
|
|
|
Iterable<MapEntry<KdbxKey, KdbxBinary>> get binaryEntries => |
|
_binaries.entries; |
|
|
|
// Map<KdbxKey, StringValue> get strings => UnmodifiableMapView(_strings); |
|
|
|
Iterable<MapEntry<KdbxKey, StringValue>> get stringEntries => |
|
_strings.entries; |
|
|
|
StringValue getString(KdbxKey key) => _strings[key]; |
|
|
|
void setString(KdbxKey key, StringValue value) { |
|
assert(key != null); |
|
if (_strings[key] == value) { |
|
_logger.finest('Value did not change for $key'); |
|
return; |
|
} |
|
modify(() { |
|
if (value == null) { |
|
_strings.remove(key); |
|
} else { |
|
_strings[key] = value; |
|
} |
|
}); |
|
} |
|
|
|
void renameKey(KdbxKey oldKey, KdbxKey newKey) { |
|
final value = _strings[oldKey]; |
|
removeString(oldKey); |
|
_strings[newKey] = value; |
|
} |
|
|
|
void removeString(KdbxKey key) => setString(key, null); |
|
|
|
String _plainValue(KdbxKey key) { |
|
final value = _strings[key]; |
|
if (value is PlainValue) { |
|
return value.getText(); |
|
} |
|
return value?.toString(); |
|
} |
|
|
|
String get label => |
|
_plainValue(KdbxKey('Title')) ?? _plainValue(KdbxKey('URL')); |
|
|
|
set label(String label) => setString(KdbxKey('Title'), PlainValue(label)); |
|
|
|
/// Creates a new binary and adds it to this entry. |
|
KdbxBinary createBinary({ |
|
@required bool isProtected, |
|
@required String name, |
|
@required Uint8List bytes, |
|
}) { |
|
assert(isProtected != null); |
|
assert(bytes != null); |
|
assert(name != null); |
|
// make sure we don't have a path, just the file name. |
|
final key = _uniqueBinaryName(path.basename(name)); |
|
final binary = KdbxBinary( |
|
isInline: false, |
|
isProtected: isProtected, |
|
value: bytes, |
|
); |
|
modify(() { |
|
file.ctx.addBinary(binary); |
|
_binaries[key] = binary; |
|
}); |
|
return binary; |
|
} |
|
|
|
void removeBinary(KdbxKey binaryKey) { |
|
modify(() { |
|
final binary = _binaries.remove(binaryKey); |
|
if (binary == null) { |
|
throw StateError( |
|
'Trying to remove binary key $binaryKey does not exist.'); |
|
} |
|
// binary will not be removed (yet) from file, because it will |
|
// be referenced in history. |
|
}); |
|
} |
|
|
|
KdbxKey _uniqueBinaryName(String fileName) { |
|
final lastIndex = fileName.lastIndexOf('.'); |
|
final baseName = |
|
lastIndex > -1 ? fileName.substring(0, lastIndex) : fileName; |
|
final ext = lastIndex > -1 ? fileName.substring(lastIndex + 1) : 'ext'; |
|
for (var i = 0; i < 1000; i++) { |
|
final k = i == 0 ? KdbxKey(fileName) : KdbxKey('$baseName$i.$ext'); |
|
if (!_binaries.containsKey(k)) { |
|
return k; |
|
} |
|
} |
|
throw StateError('Unable to find unique name for $fileName'); |
|
} |
|
|
|
static KdbxEntry _findHistoryEntry( |
|
List<KdbxEntry> history, DateTime lastModificationTime) => |
|
history.firstWhere( |
|
(history) => |
|
history.times.lastModificationTime.get() == lastModificationTime, |
|
orElse: () => null); |
|
|
|
@override |
|
void merge(MergeContext mergeContext, KdbxEntry other) { |
|
assertSameUuid(other, 'merge'); |
|
if (other.wasModifiedAfter(this)) { |
|
_logger.finest('$this has incoming changes.'); |
|
// other object is newer, create new history entry and copy fields. |
|
modify(() => _overwriteFrom(mergeContext, other)); |
|
} else if (wasModifiedAfter(other)) { |
|
_logger.finest('$this has outgoing changes.'); |
|
// we are newer. check if the old revision lives on in our history. |
|
final ourLastModificationTime = times.lastModificationTime.get(); |
|
final historyEntry = _findHistoryEntry(history, ourLastModificationTime); |
|
if (historyEntry == null) { |
|
// it seems like we don't know about that state, so we have to add |
|
// it to history. |
|
history.add(other.cloneInto(parent, toHistoryEntry: true)); |
|
} |
|
} else { |
|
_logger.finest('$this has no changes.'); |
|
} |
|
// copy missing history entries. |
|
for (final otherHistoryEntry in other.history) { |
|
final meHistoryEntry = _findHistoryEntry( |
|
history, otherHistoryEntry.times.lastModificationTime.get()); |
|
if (meHistoryEntry == null) { |
|
mergeContext.trackChange( |
|
this, |
|
debug: 'merge in history ' |
|
'${otherHistoryEntry.times.lastModificationTime.get()}', |
|
); |
|
history.add(otherHistoryEntry.cloneInto(parent, toHistoryEntry: true)); |
|
} |
|
} |
|
mergeContext.markAsMerged(this); |
|
} |
|
|
|
@override |
|
String toString() { |
|
return 'KdbxEntry{uuid=$uuid,' |
|
'name=${label ?? _plainValue(KdbxKey('UserName'))}}'; |
|
} |
|
}
|
|
|