file, group, entry

This commit is contained in:
antelle 2021-05-25 18:44:53 +02:00
parent 89f3741f17
commit 45e1f87d07
No known key found for this signature in database
GPG Key ID: 63C9777AAB7C563C
14 changed files with 2224 additions and 1875 deletions

View File

@ -0,0 +1,205 @@
import * as kdbxweb from 'kdbxweb';
import { BuiltInFields } from 'const/entry-fields';
import { AdvancedFilter, Filter } from 'models/filter';
import { Entry } from 'models/entry';
interface SearchContext {
matches: string[];
}
export const EntrySearch = { matches };
const BuiltInFieldsSet = new Set<string>(BuiltInFields);
function matches(entry: Entry, filter: Filter): boolean {
if (filter.tagLower) {
if (entry.searchTags && entry.searchTags.indexOf(filter.tagLower) < 0) {
return false;
}
}
if (filter.textLower) {
if (filter.advanced) {
if (!matchesAdv(entry, filter)) {
return false;
}
} else if (filter.textLowerParts) {
const parts = filter.textLowerParts;
for (let i = 0; i < parts.length; i++) {
if (!entry.searchText?.includes(parts[i])) {
return false;
}
}
} else if (!entry.searchText?.includes(filter.textLower)) {
return false;
}
}
if (filter.color) {
if (filter.color === '*') {
if (!entry.searchColor) {
return false;
}
} else if (entry.searchColor !== filter.color) {
return false;
}
}
if (filter.autoType) {
if (!entry.autoTypeEnabled) {
return false;
}
}
if (filter.otp) {
if (
!entry.fields?.has('otp') &&
!entry.fields?.has('TOTP Seed') &&
entry.backend !== 'otp-device'
) {
return false;
}
}
return true;
}
function matchesAdv(entry: Entry, filter: Filter): boolean {
const adv = filter.advanced;
if (!adv || !filter.text || !filter.textLower) {
return false;
}
let compare: (val: kdbxweb.KdbxEntryField) => boolean;
const context: SearchContext = { matches: [] };
if (adv.regex) {
try {
const regex = new RegExp(filter.text, adv.cs ? '' : 'i');
compare = (val) => matchRegex(val, regex);
} catch (e) {
return false;
}
} else if (adv.cs) {
if (filter.textParts) {
const textParts = filter.textParts;
compare = (val) => matchStringMulti(val, textParts, context, false);
} else {
const text = filter.text;
compare = (val) => matchString(val, text);
}
} else if (filter.textLowerParts) {
const textLowerParts = filter.textLowerParts;
compare = (val) => matchStringMultiLower(val, textLowerParts, context);
} else {
const textLower = filter.textLower;
compare = (val) => matchStringLower(val, textLower);
}
if (matchFields(entry.getAllFields(), adv, compare)) {
return true;
}
if (adv.history && entry.getAllHistoryEntriesFields) {
for (const historyEntryFields of entry.getAllHistoryEntriesFields()) {
if (matchFields(historyEntryFields, adv, compare)) {
return true;
}
}
}
return false;
}
function matchString(str: kdbxweb.KdbxEntryField, find: string): boolean {
if (str instanceof kdbxweb.ProtectedValue) {
return str.includes(find);
}
return str.indexOf(find) >= 0;
}
function matchStringLower(str: kdbxweb.KdbxEntryField, findLower: string): boolean {
if (str instanceof kdbxweb.ProtectedValue) {
return str.includesLower(findLower);
}
return str.toLowerCase().indexOf(findLower) >= 0;
}
function matchStringMulti(
str: kdbxweb.KdbxEntryField,
find: string[],
context: SearchContext,
lower: boolean
): boolean {
for (let i = 0; i < find.length; i++) {
const item = find[i];
let strMatches;
if (lower) {
strMatches =
str instanceof kdbxweb.ProtectedValue
? str.includesLower(item)
: str.includes(item);
} else {
strMatches =
str instanceof kdbxweb.ProtectedValue ? str.includes(item) : str.includes(item);
}
if (strMatches) {
if (context.matches) {
if (!context.matches.includes(item)) {
context.matches.push(item);
}
} else {
context.matches = [item];
}
}
}
return context.matches && context.matches.length === find.length;
}
function matchStringMultiLower(
str: kdbxweb.KdbxEntryField,
find: string[],
context: SearchContext
): boolean {
return matchStringMulti(str, find, context, true);
}
function matchRegex(str: kdbxweb.KdbxEntryField, regex: RegExp): boolean {
if (str instanceof kdbxweb.ProtectedValue) {
str = str.getText();
}
return regex.test(str);
}
function matchFields(
fields: Map<string, kdbxweb.KdbxEntryField>,
adv: AdvancedFilter,
compare: (val: kdbxweb.KdbxEntryField) => boolean
): boolean {
if (adv.user && matchField(fields.get('UserName'), compare)) {
return true;
}
if (adv.url && matchField(fields.get('URL'), compare)) {
return true;
}
if (adv.notes && matchField(fields.get('Notes'), compare)) {
return true;
}
if (adv.pass && matchField(fields.get('Password'), compare)) {
return true;
}
if (adv.title && matchField(fields.get('Title'), compare)) {
return true;
}
if (adv.other || adv.protect) {
for (const [field, value] of fields) {
if (BuiltInFieldsSet.has(field)) {
if (typeof value === 'string') {
if (adv.other && matchField(value, compare)) {
return true;
}
} else if (adv.protect && matchField(value, compare)) {
return true;
}
}
}
}
return false;
}
function matchField(
val: kdbxweb.KdbxEntryField | undefined,
compare: (val: kdbxweb.KdbxEntryField) => boolean
): boolean {
return val ? compare(val) : false;
}

View File

@ -7,4 +7,4 @@ export const BuiltInFields = [
'TOTP Seed',
'TOTP Settings',
'_etm_template_uuid'
] as const;
];

View File

@ -10,7 +10,7 @@ import { Timeouts } from 'const/timeouts';
import { AppSettingsModel } from 'models/app-settings-model';
import { EntryModel } from 'models/entry-model';
import { FileInfoModel } from 'models/file-info-model';
import { FileModel } from 'models/file-model';
import { File } from 'models/file-model';
import { GroupModel } from 'models/group-model';
import { YubiKeyOtpModel } from 'models/otp-device/yubikey-otp-model';
import { Menu } from 'models/menu/menu-model';
@ -399,7 +399,7 @@ class AppModel {
createDemoFile() {
if (!this.files.getByName('Demo')) {
const demoFile = new FileModel({ id: IdGenerator.uuid() });
const demoFile = new File({ id: IdGenerator.uuid() });
demoFile.openDemo(() => {
this.addFile(demoFile);
});
@ -418,7 +418,7 @@ class AppModel {
}
}
}
const newFile = new FileModel({ id: IdGenerator.uuid() });
const newFile = new File({ id: IdGenerator.uuid() });
newFile.create(name, () => {
this.addFile(newFile);
callback?.(newFile);
@ -598,7 +598,7 @@ class AppModel {
if (!params.keyFileData && fileInfo && fileInfo.keyFileName) {
params.keyFileName = fileInfo.keyFileName;
if (this.settings.rememberKeyFiles === 'data' && fileInfo.keyFileHash) {
params.keyFileData = FileModel.createKeyFileWithHash(fileInfo.keyFileHash);
params.keyFileData = File.createKeyFileWithHash(fileInfo.keyFileHash);
} else if (this.settings.rememberKeyFiles === 'path' && fileInfo.keyFilePath) {
params.keyFilePath = fileInfo.keyFilePath;
if (Storage.file.enabled) {
@ -608,7 +608,7 @@ class AppModel {
} else if (params.keyFilePath && !params.keyFileData && !fileInfo) {
needLoadKeyFile = true;
}
const file = new FileModel({
const file = new File({
id: fileInfo ? fileInfo.id : IdGenerator.uuid(),
name: params.name,
storage: params.storage,
@ -672,7 +672,7 @@ class AppModel {
importFileWithXml(params, callback) {
const logger = new Logger('import', params.name);
logger.info('File import request with supplied xml');
const file = new FileModel({
const file = new File({
id: IdGenerator.uuid(),
name: params.name,
storage: params.storage,

View File

@ -1,705 +0,0 @@
import * as kdbxweb from 'kdbxweb';
import { Model } from 'framework/model';
import { AppSettingsModel } from 'models/app-settings-model';
import { KdbxToHtml } from 'comp/format/kdbx-to-html';
import { IconMap } from 'const/icon-map';
import { BuiltInFields } from 'const/entry-fields';
import { Attachment } from 'models/attachment-model';
import { Color } from 'util/data/color';
import { Otp } from 'util/data/otp';
import { Ranking } from 'util/data/ranking';
import { IconUrlFormat } from 'util/formatting/icon-url-format';
import { omit } from 'util/fn';
import { EntrySearch } from 'util/entry-search';
const UrlRegex = /^https?:\/\//i;
const FieldRefRegex = /^\{REF:([TNPAU])@I:(\w{32})}$/;
const FieldRefFields = ['title', 'password', 'user', 'url', 'notes'];
const FieldRefIds = { T: 'Title', U: 'UserName', P: 'Password', A: 'URL', N: 'Notes' };
const ExtraUrlFieldName = 'KP2A_URL';
class EntryModel extends Model {
constructor(props) {
super(props);
this._search = new EntrySearch(this);
}
setEntry(entry, group, file) {
this.entry = entry;
this.group = group;
this.file = file;
if (this.uuid === entry.uuid.id) {
this._checkUpdatedEntry();
}
// we cannot calculate field references now because database index has not yet been built
this.hasFieldRefs = false;
this._fillByEntry();
this.hasFieldRefs = true;
}
_fillByEntry() {
const entry = this.entry;
this.set({ id: this.file.subId(entry.uuid.id), uuid: entry.uuid.id }, { silent: true });
this.fileName = this.file.name;
this.groupName = this.group.title;
this.title = this._getFieldString('Title');
this.password = this._getPassword();
this.notes = this._getFieldString('Notes');
this.url = this._getFieldString('URL');
this.displayUrl = this._getDisplayUrl(this._getFieldString('URL'));
this.user = this._getFieldString('UserName');
this.iconId = entry.icon;
this.icon = this._iconFromId(entry.icon);
this.tags = entry.tags;
this.color = this._colorToModel(entry.bgColor) || this._colorToModel(entry.fgColor);
this.fields = this._fieldsToModel();
this.attachments = this._attachmentsToModel(entry.binaries);
this.created = entry.times.creationTime;
this.updated = entry.times.lastModTime;
this.expires = entry.times.expires ? entry.times.expiryTime : undefined;
this.expired = entry.times.expires && entry.times.expiryTime <= new Date();
this.historyLength = entry.history.length;
this.titleUserLower = `${this.title}:${this.user}`.toLowerCase();
this._buildCustomIcon();
this._buildSearchText();
this._buildSearchTags();
this._buildSearchColor();
this._buildAutoType();
if (this.hasFieldRefs) {
this.resolveFieldReferences();
}
}
_getPassword() {
const password = this.entry.fields.get('Password') || kdbxweb.ProtectedValue.fromString('');
if (!password.isProtected) {
return kdbxweb.ProtectedValue.fromString(password);
}
return password;
}
_getFieldString(field) {
const val = this.entry.fields.get(field);
if (!val) {
return '';
}
if (val.isProtected) {
return val.getText();
}
return val.toString();
}
_checkUpdatedEntry() {
if (this.isJustCreated) {
this.isJustCreated = false;
}
if (this.canBeDeleted) {
this.canBeDeleted = false;
}
if (this.unsaved && +this.updated !== +this.entry.times.lastModTime) {
this.unsaved = false;
}
}
_buildSearchText() {
let text = '';
for (const value of this.entry.fields.values()) {
if (typeof value === 'string') {
text += value.toLowerCase() + '\n';
}
}
this.entry.tags.forEach((tag) => {
text += tag.toLowerCase() + '\n';
});
this.attachments.forEach((att) => {
text += att.title.toLowerCase() + '\n';
});
this.searchText = text;
}
_buildCustomIcon() {
this.customIcon = null;
this.customIconId = null;
if (this.entry.customIcon) {
this.customIcon = IconUrlFormat.toDataUrl(
this.file.db.meta.customIcons.get(this.entry.customIcon.id)?.data
);
this.customIconId = this.entry.customIcon.toString();
}
}
_buildSearchTags() {
this.searchTags = this.entry.tags.map((tag) => tag.toLowerCase());
}
_buildSearchColor() {
this.searchColor = this.color;
}
_buildAutoType() {
this.autoTypeEnabled = this.entry.autoType.enabled;
this.autoTypeObfuscation =
this.entry.autoType.obfuscation ===
kdbxweb.Consts.AutoTypeObfuscationOptions.UseClipboard;
this.autoTypeSequence = this.entry.autoType.defaultSequence;
this.autoTypeWindows = this.entry.autoType.items.map(this._convertAutoTypeItem);
}
_convertAutoTypeItem(item) {
return { window: item.window, sequence: item.keystrokeSequence };
}
_iconFromId(id) {
return IconMap[id];
}
_getDisplayUrl(url) {
if (!url) {
return '';
}
return url.replace(UrlRegex, '');
}
_colorToModel(color) {
return color ? Color.getNearest(color) : null;
}
_fieldsToModel() {
return omit(this.getAllFields(), BuiltInFields);
}
_attachmentsToModel(binaries) {
const att = [];
for (let [title, data] of binaries) {
if (data && data.ref) {
data = data.value;
}
if (data) {
att.push(Attachment.fromAttachment({ data, title }));
}
}
return att;
}
_entryModified() {
if (!this.unsaved) {
this.unsaved = true;
if (this.file.historyMaxItems !== 0) {
this.entry.pushHistory();
}
this.file.setModified();
}
if (this.isJustCreated) {
this.isJustCreated = false;
this.file.reload();
}
this.entry.times.update();
}
setSaved() {
if (this.unsaved) {
this.unsaved = false;
}
if (this.canBeDeleted) {
this.canBeDeleted = false;
}
}
matches(filter) {
return this._search.matches(filter);
}
getAllFields() {
const fields = {};
for (const [key, value] of this.entry.fields) {
fields[key] = value;
}
return fields;
}
getHistoryEntriesForSearch() {
return this.entry.history;
}
resolveFieldReferences() {
this.hasFieldRefs = false;
FieldRefFields.forEach((field) => {
const fieldValue = this[field];
const refValue = this._resolveFieldReference(fieldValue);
if (refValue !== undefined) {
this[field] = refValue;
this.hasFieldRefs = true;
}
});
}
getFieldValue(field) {
field = field.toLowerCase();
let resolvedField;
[...this.entry.fields.keys()].some((entryField) => {
if (entryField.toLowerCase() === field) {
resolvedField = entryField;
return true;
}
return false;
});
if (resolvedField) {
let fieldValue = this.entry.fields.get(resolvedField);
const refValue = this._resolveFieldReference(fieldValue);
if (refValue !== undefined) {
fieldValue = refValue;
}
return fieldValue;
}
}
_resolveFieldReference(fieldValue) {
if (!fieldValue) {
return;
}
if (fieldValue.isProtected && fieldValue.isFieldReference()) {
fieldValue = fieldValue.getText();
}
if (typeof fieldValue !== 'string') {
return;
}
const match = fieldValue.match(FieldRefRegex);
if (!match) {
return;
}
return this._getReferenceValue(match[1], match[2]);
}
_getReferenceValue(fieldRefId, idStr) {
const id = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
id[i] = parseInt(idStr.substr(i * 2, 2), 16);
}
const uuid = new kdbxweb.KdbxUuid(id);
const entry = this.file.getEntry(this.file.subId(uuid.id));
if (!entry) {
return;
}
return entry.entry.fields.get(FieldRefIds[fieldRefId]);
}
setColor(color) {
this._entryModified();
this.entry.bgColor = Color.getKnownBgColor(color);
this._fillByEntry();
}
setIcon(iconId) {
this._entryModified();
this.entry.icon = iconId;
this.entry.customIcon = undefined;
this._fillByEntry();
}
setCustomIcon(customIconId) {
this._entryModified();
this.entry.customIcon = new kdbxweb.KdbxUuid(customIconId);
this._fillByEntry();
}
setExpires(dt) {
this._entryModified();
this.entry.times.expiryTime = dt instanceof Date ? dt : undefined;
this.entry.times.expires = !!dt;
this._fillByEntry();
}
setTags(tags) {
this._entryModified();
this.entry.tags = tags;
this._fillByEntry();
}
renameTag(from, to) {
const ix = this.entry.tags.findIndex((tag) => tag.toLowerCase() === from.toLowerCase());
if (ix < 0) {
return;
}
this._entryModified();
this.entry.tags.splice(ix, 1);
if (to) {
this.entry.tags.push(to);
}
this._fillByEntry();
}
setField(field, val, allowEmpty) {
const hasValue = val && (typeof val === 'string' || (val.isProtected && val.byteLength));
if (hasValue || allowEmpty || BuiltInFields.indexOf(field) >= 0) {
this._entryModified();
val = this.sanitizeFieldValue(val);
this.entry.fields.set(field, val);
} else if (this.entry.fields.has(field)) {
this._entryModified();
this.entry.fields.delete(field);
}
this._fillByEntry();
}
sanitizeFieldValue(val) {
if (val && !val.isProtected) {
// https://github.com/keeweb/keeweb/issues/910
// eslint-disable-next-line no-control-regex
val = val.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\uFFF0-\uFFFF]/g, '');
}
return val;
}
hasField(field) {
return this.entry.fields.has(field);
}
addAttachment(name, data) {
this._entryModified();
return this.file.db.createBinary(data).then((binaryRef) => {
this.entry.binaries.set(name, binaryRef);
this._fillByEntry();
});
}
removeAttachment(name) {
this._entryModified();
this.entry.binaries.delete(name);
this._fillByEntry();
}
getHistory() {
const history = this.entry.history.map(function (rec) {
return EntryModel.fromEntry(rec, this.group, this.file);
}, this);
history.push(this);
history.sort((x, y) => x.updated - y.updated);
return history;
}
deleteHistory(historyEntry) {
const ix = this.entry.history.indexOf(historyEntry);
if (ix >= 0) {
this.entry.removeHistory(ix);
this.file.setModified();
}
this._fillByEntry();
}
revertToHistoryState(historyEntry) {
const ix = this.entry.history.indexOf(historyEntry);
if (ix < 0) {
return;
}
this.entry.pushHistory();
this.unsaved = true;
this.file.setModified();
this.entry.fields = new Map();
this.entry.binaries = new Map();
this.entry.copyFrom(historyEntry);
this._entryModified();
this._fillByEntry();
}
discardUnsaved() {
if (this.unsaved && this.entry.history.length) {
this.unsaved = false;
const historyEntry = this.entry.history[this.entry.history.length - 1];
this.entry.removeHistory(this.entry.history.length - 1);
this.entry.fields = new Map();
this.entry.binaries = new Map();
this.entry.copyFrom(historyEntry);
this._fillByEntry();
}
}
moveToTrash() {
this.file.setModified();
if (this.isJustCreated) {
this.isJustCreated = false;
}
this.file.db.remove(this.entry);
this.file.reload();
}
deleteFromTrash() {
this.file.setModified();
this.file.db.move(this.entry, null);
this.file.reload();
}
removeWithoutHistory() {
if (this.canBeDeleted) {
const ix = this.group.group.entries.indexOf(this.entry);
if (ix >= 0) {
this.group.group.entries.splice(ix, 1);
}
this.file.reload();
}
}
detach() {
this.file.setModified();
this.file.db.move(this.entry, null);
this.file.reload();
return this.entry;
}
moveToFile(file) {
if (this.canBeDeleted) {
this.removeWithoutHistory();
this.group = file.groups[0];
this.file = file;
this._fillByEntry();
this.entry.times.update();
this.group.group.entries.push(this.entry);
this.group.addEntry(this);
this.isJustCreated = true;
this.unsaved = true;
this.file.setModified();
}
}
initOtpGenerator() {
let otpUrl;
if (this.fields.otp) {
otpUrl = this.fields.otp;
if (otpUrl.isProtected) {
otpUrl = otpUrl.getText();
}
if (Otp.isSecret(otpUrl.replace(/\s/g, ''))) {
otpUrl = Otp.makeUrl(otpUrl.replace(/\s/g, '').toUpperCase());
} else if (otpUrl.toLowerCase().lastIndexOf('otpauth:', 0) !== 0) {
// KeeOTP plugin format
const args = {};
otpUrl.split('&').forEach((part) => {
const parts = part.split('=', 2);
args[parts[0]] = decodeURIComponent(parts[1]).replace(/=/g, '');
});
if (args.key) {
otpUrl = Otp.makeUrl(args.key, args.step, args.size);
}
}
} else if (this.entry.fields.get('TOTP Seed')) {
// TrayTOTP plugin format
let secret = this.entry.fields.get('TOTP Seed');
if (secret.isProtected) {
secret = secret.getText();
}
if (secret) {
let settings = this.entry.fields.get('TOTP Settings');
if (settings && settings.isProtected) {
settings = settings.getText();
}
let period, digits;
if (settings) {
settings = settings.split(';');
if (settings.length > 0 && settings[0] > 0) {
period = settings[0];
}
if (settings.length > 1 && settings[1] > 0) {
digits = settings[1];
}
}
otpUrl = Otp.makeUrl(secret, period, digits);
this.fields.otp = kdbxweb.ProtectedValue.fromString(otpUrl);
}
}
if (otpUrl) {
if (this.otpGenerator && this.otpGenerator.url === otpUrl) {
return;
}
try {
this.otpGenerator = Otp.parseUrl(otpUrl);
} catch {
this.otpGenerator = null;
}
} else {
this.otpGenerator = null;
}
}
setOtp(otp) {
this.otpGenerator = otp;
this.setOtpUrl(otp.url);
}
setOtpUrl(url) {
this.setField('otp', url ? kdbxweb.ProtectedValue.fromString(url) : undefined);
this.entry.fields.delete('TOTP Seed');
this.entry.fields.delete('TOTP Settings');
}
getEffectiveEnableAutoType() {
if (typeof this.entry.autoType.enabled === 'boolean') {
return this.entry.autoType.enabled;
}
return this.group.getEffectiveEnableAutoType();
}
getEffectiveAutoTypeSeq() {
return this.entry.autoType.defaultSequence || this.group.getEffectiveAutoTypeSeq();
}
setEnableAutoType(enabled) {
this._entryModified();
this.entry.autoType.enabled = enabled;
this._buildAutoType();
}
setAutoTypeObfuscation(enabled) {
this._entryModified();
this.entry.autoType.obfuscation = enabled
? kdbxweb.Consts.AutoTypeObfuscationOptions.UseClipboard
: kdbxweb.Consts.AutoTypeObfuscationOptions.None;
this._buildAutoType();
}
setAutoTypeSeq(seq) {
this._entryModified();
this.entry.autoType.defaultSequence = seq || undefined;
this._buildAutoType();
}
getGroupPath() {
let group = this.group;
const groupPath = [];
while (group) {
groupPath.unshift(group.title);
group = group.parentGroup;
}
return groupPath;
}
cloneEntry(nameSuffix) {
const newEntry = EntryModel.newEntry(this.group, this.file);
const uuid = newEntry.entry.uuid;
newEntry.entry.copyFrom(this.entry);
newEntry.entry.uuid = uuid;
newEntry.entry.times.update();
newEntry.entry.times.creationTime = newEntry.entry.times.lastModTime;
newEntry.entry.fields.set('Title', this.title + nameSuffix);
newEntry._fillByEntry();
this.file.reload();
return newEntry;
}
copyFromTemplate(templateEntry) {
const uuid = this.entry.uuid;
this.entry.copyFrom(templateEntry.entry);
this.entry.uuid = uuid;
this.entry.times.update();
this.entry.times.creationTime = this.entry.times.lastModTime;
this.entry.fields.set('Title', '');
this._fillByEntry();
}
getRank(filter) {
const searchString = filter.textLower;
if (!searchString) {
// no search string given, so rank all items the same
return 0;
}
const checkProtectedFields = filter.advanced && filter.advanced.protect;
const fieldWeights = {
'Title': 10,
'URL': 8,
'UserName': 5,
'Notes': 2
};
const defaultFieldWeight = 2;
const allFields = Object.keys(fieldWeights).concat(Object.keys(this.fields));
return allFields.reduce((rank, fieldName) => {
const val = this.entry.fields.get(fieldName);
if (!val) {
return rank;
}
if (val.isProtected && (!checkProtectedFields || !val.length)) {
return rank;
}
const stringRank = Ranking.getStringRank(searchString, val);
const fieldWeight = fieldWeights[fieldName] || defaultFieldWeight;
return rank + stringRank * fieldWeight;
}, 0);
}
getHtml() {
return KdbxToHtml.entryToHtml(this.file.db, this.entry);
}
canCheckPasswordIssues() {
return !this.entry.customData?.has('IgnorePwIssues');
}
setIgnorePasswordIssues() {
if (!this.entry.customData) {
this.entry.customData = new Map();
}
this.entry.customData.set('IgnorePwIssues', '1');
this._entryModified();
}
getNextUrlFieldName() {
const takenFields = new Set(
[...this.entry.fields.keys()].filter((f) => f.startsWith(ExtraUrlFieldName))
);
for (let i = 0; ; i++) {
const fieldName = i ? `${ExtraUrlFieldName}_${i}` : ExtraUrlFieldName;
if (!takenFields.has(fieldName)) {
return fieldName;
}
}
}
getAllUrls() {
const urls = this.url ? [this.url] : [];
const extraUrls = Object.entries(this.fields)
.filter(([field]) => field.startsWith(ExtraUrlFieldName))
.map(([, value]) => (value.isProtected ? value.getText() : value))
.filter((value) => value);
return urls.concat(extraUrls);
}
static fromEntry(entry, group, file) {
const model = new EntryModel();
model.setEntry(entry, group, file);
return model;
}
static newEntry(group, file, opts) {
const model = new EntryModel();
const entry = file.db.createEntry(group.group);
if (AppSettingsModel.useGroupIconForEntries && group.icon && group.iconId) {
entry.icon = group.iconId;
}
if (opts && opts.tag) {
entry.tags = [opts.tag];
}
model.setEntry(entry, group, file);
model.entry.times.update();
model.unsaved = true;
model.isJustCreated = true;
model.canBeDeleted = true;
group.addEntry(model);
file.setModified();
return model;
}
static newEntryWithFields(group, fields) {
const entry = EntryModel.newEntry(group, group.file);
for (const [field, value] of Object.entries(fields)) {
entry.setField(field, value);
}
return entry;
}
}
EntryModel.defineModelProperties({}, { extensions: true });
export { EntryModel, ExtraUrlFieldName };

795
app/scripts/models/entry.ts Normal file
View File

@ -0,0 +1,795 @@
import * as kdbxweb from 'kdbxweb';
import { Model } from 'util/model';
import { IconMap } from 'const/icon-map';
import { BuiltInFields } from 'const/entry-fields';
import { Attachment } from 'models/attachment';
import { Color } from 'util/data/color';
import { Otp } from 'util/data/otp';
import { IconUrlFormat } from 'util/formatting/icon-url-format';
import { Group } from './group';
import { File } from './file';
import { isEqual } from 'util/fn';
import { AppSettings } from './app-settings';
import { Filter } from './filter';
import { EntrySearch } from 'comp/search/entry-search';
import { Ranking } from 'util/data/ranking';
const UrlRegex = /^https?:\/\//i;
const FieldRefRegex = /^\{REF:([TNPAU])@I:(\w{32})}$/;
const FieldRefFields = ['title', 'user', 'url', 'notes'] as const;
const FieldRefIds = new Map<string, string>([
['T', 'Title'],
['U', 'UserName'],
['P', 'Password'],
['A', 'URL'],
['N', 'Notes']
]);
const FieldRankWeights = new Map<string, number>([
['Title', 10],
['URL', 8],
['UserName', 5],
['Notes', 2]
]);
const ExtraUrlFieldName = 'KP2A_URL';
const BuiltInFieldsSet = new Set<string>(BuiltInFields);
class Entry extends Model {
readonly id: string;
readonly uuid: string;
entry: kdbxweb.KdbxEntry;
group: Group;
file: File;
hasFieldRefs?: boolean;
isJustCreated?: boolean;
canBeDeleted?: boolean;
unsaved?: boolean;
created?: Date;
updated?: Date;
expires?: Date;
expired?: boolean;
fileName?: string;
groupName?: string;
title?: string;
password?: kdbxweb.ProtectedValue;
notes?: string;
url?: string;
displayUrl?: string;
user?: string;
iconId?: number;
icon?: string;
tags?: string[];
color?: string;
fields?: Map<string, kdbxweb.KdbxEntryField>;
attachments?: Attachment[];
historyLength?: number;
titleUserLower?: string;
searchText?: string;
searchTags?: string[];
searchColor?: string;
customIcon?: string;
customIconId?: string;
autoTypeEnabled?: boolean | null;
autoTypeObfuscation?: boolean;
autoTypeSequence?: string;
otpGenerator?: Otp;
backend?: string;
constructor(entry: kdbxweb.KdbxEntry, group: Group, file: File) {
super();
this.id = file.subId(entry.uuid.id);
this.uuid = entry.uuid.id;
this.entry = entry;
this.group = group;
this.file = file;
this.setEntry(entry, group, file);
}
setEntry(entry: kdbxweb.KdbxEntry, group: Group, file: File): void {
if (entry.uuid.id !== this.uuid) {
throw new Error('Cannot change entry uuid');
}
this.entry = entry;
this.group = group;
this.file = file;
this.checkUpdatedEntry();
// we cannot calculate field references now because database index has not yet been built
this.fillByEntry({ skipFieldReferences: true });
}
private fillByEntry(opts?: { skipFieldReferences?: boolean }) {
const entry = this.entry;
this.fileName = this.file.name;
this.groupName = this.group.title;
this.title = this.getFieldString('Title');
this.password = this.getPassword();
this.notes = this.getFieldString('Notes');
this.url = this.getFieldString('URL');
this.displayUrl = this.getDisplayUrl(this.getFieldString('URL'));
this.user = this.getFieldString('UserName');
this.iconId = entry.icon;
this.icon = this.iconFromId(entry.icon);
this.tags = entry.tags;
this.color = this.colorToModel(entry.bgColor) || this.colorToModel(entry.fgColor);
this.fields = this.fieldsToModel();
this.attachments = this.attachmentsToModel(entry.binaries);
this.created = entry.times.creationTime;
this.updated = entry.times.lastModTime;
this.expires = entry.times.expires ? entry.times.expiryTime : undefined;
this.expired = !!(
entry.times.expires &&
entry.times.expiryTime &&
entry.times.expiryTime <= new Date()
);
this.historyLength = entry.history.length;
this.titleUserLower = `${this.title}:${this.user}`.toLowerCase();
this.buildCustomIcon();
this.buildSearchText();
this.buildSearchTags();
this.buildSearchColor();
this.buildAutoType();
if (!opts?.skipFieldReferences && this.hasFieldRefs !== false) {
this.resolveFieldReferences();
}
}
private getPassword(): kdbxweb.ProtectedValue {
const password = this.entry.fields.get('Password') ?? kdbxweb.ProtectedValue.fromString('');
if (password instanceof kdbxweb.ProtectedValue) {
return password;
}
return kdbxweb.ProtectedValue.fromString(password);
}
private getFieldString(field: string): string {
const val = this.entry.fields.get(field);
if (!val) {
return '';
}
if (val instanceof kdbxweb.ProtectedValue) {
return val.getText();
}
return val.toString();
}
private checkUpdatedEntry() {
if (this.isJustCreated) {
this.isJustCreated = false;
}
if (this.canBeDeleted) {
this.canBeDeleted = false;
}
if (this.unsaved && !isEqual(this.updated, this.entry.times.lastModTime)) {
this.unsaved = false;
}
}
private buildSearchText(): void {
let text = '';
for (const value of this.entry.fields.values()) {
if (typeof value === 'string') {
text += value.toLowerCase() + '\n';
}
}
for (const tag of this.entry.tags) {
text += tag.toLowerCase() + '\n';
}
for (const title of this.entry.binaries.keys()) {
text += title.toLowerCase() + '\n';
}
this.searchText = text;
}
private buildCustomIcon(): void {
this.customIcon = undefined;
this.customIconId = undefined;
if (this.entry.customIcon) {
const icon = this.file.db.meta.customIcons.get(this.entry.customIcon.id);
if (icon) {
this.customIcon = IconUrlFormat.toDataUrl(icon.data) ?? undefined;
}
this.customIconId = this.entry.customIcon.toString();
}
}
private buildSearchTags(): void {
this.searchTags = this.entry.tags.map((tag) => tag.toLowerCase());
}
private buildSearchColor(): void {
this.searchColor = this.color;
}
private buildAutoType(): void {
this.autoTypeEnabled = this.entry.autoType.enabled;
this.autoTypeObfuscation =
this.entry.autoType.obfuscation ===
kdbxweb.Consts.AutoTypeObfuscationOptions.UseClipboard;
this.autoTypeSequence = this.entry.autoType.defaultSequence;
}
private iconFromId(id: number | undefined): string | undefined {
return id === undefined ? undefined : IconMap[id];
}
private getDisplayUrl(url: string): string {
if (!url) {
return '';
}
return url.replace(UrlRegex, '');
}
private colorToModel(color: string | undefined): string | undefined {
return color ? Color.getNearest(color) : undefined;
}
private fieldsToModel(): Map<string, kdbxweb.KdbxEntryField> {
const fields = new Map<string, kdbxweb.KdbxEntryField>();
for (const [field, value] of this.entry.fields) {
if (!BuiltInFieldsSet.has(field)) {
fields.set(field, value);
}
}
return fields;
}
private attachmentsToModel(
binaries: Map<string, kdbxweb.KdbxBinary | kdbxweb.KdbxBinaryWithHash>
): Attachment[] {
const att: Attachment[] = [];
for (const [title, data] of binaries) {
att.push(new Attachment(title, data));
}
return att;
}
private entryModified() {
if (!this.unsaved) {
this.unsaved = true;
if (this.file.historyMaxItems !== 0) {
this.entry.pushHistory();
}
this.file.setModified();
}
if (this.isJustCreated) {
this.isJustCreated = false;
this.file.reload();
}
this.entry.times.update();
}
setSaved(): void {
if (this.unsaved) {
this.unsaved = false;
}
if (this.canBeDeleted) {
this.canBeDeleted = false;
}
}
matches(filter: Filter): boolean {
return EntrySearch.matches(this, filter);
}
getAllFields(): Map<string, kdbxweb.KdbxEntryField> {
return this.entry.fields;
}
*getAllHistoryEntriesFields(): Generator<Map<string, kdbxweb.KdbxEntryField>> {
for (const historyEntry of this.entry.history) {
yield historyEntry.fields;
}
}
resolveFieldReferences(): void {
this.hasFieldRefs = false;
FieldRefFields.forEach((field) => {
const fieldValue = this[field];
const refValue = this.resolveFieldReference(fieldValue);
if (refValue !== undefined) {
if (refValue instanceof kdbxweb.ProtectedValue) {
this[field] = refValue.getText();
} else {
this[field] = refValue;
}
this.hasFieldRefs = true;
}
});
const refValue = this.resolveFieldReference(this.password);
if (refValue !== undefined) {
if (refValue instanceof kdbxweb.ProtectedValue) {
this.password = refValue;
} else {
this.password = kdbxweb.ProtectedValue.fromString(refValue);
}
this.hasFieldRefs = true;
}
}
getFieldValue(field: string): kdbxweb.KdbxEntryField | undefined {
field = field.toLowerCase();
let resolvedField;
[...this.entry.fields.keys()].some((entryField) => {
if (entryField.toLowerCase() === field) {
resolvedField = entryField;
return true;
}
return false;
});
if (resolvedField) {
let fieldValue = this.entry.fields.get(resolvedField);
const refValue = this.resolveFieldReference(fieldValue);
if (refValue !== undefined) {
fieldValue = refValue;
}
return fieldValue;
}
}
private resolveFieldReference(fieldValue: kdbxweb.KdbxEntryField | undefined) {
if (!fieldValue) {
return;
}
if (fieldValue instanceof kdbxweb.ProtectedValue && fieldValue.isFieldReference()) {
fieldValue = fieldValue.getText();
}
if (typeof fieldValue !== 'string') {
return;
}
const match = FieldRefRegex.exec(fieldValue);
if (!match) {
return;
}
return this.getReferenceValue(match[1], match[2]);
}
private getReferenceValue(
fieldRefId: string,
idStr: string
): kdbxweb.KdbxEntryField | undefined {
const id = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
id[i] = parseInt(idStr.substr(i * 2, 2), 16);
}
const uuid = new kdbxweb.KdbxUuid(id);
const entry = this.file.getEntry(this.file.subId(uuid.id));
if (!entry) {
return undefined;
}
const refField = FieldRefIds.get(fieldRefId);
if (!refField) {
return undefined;
}
return entry.entry.fields.get(refField);
}
setColor(color: string): void {
this.entryModified();
this.entry.bgColor = Color.getKnownBgColor(color);
this.fillByEntry();
}
setIcon(iconId: number): void {
this.entryModified();
this.entry.icon = iconId;
this.entry.customIcon = undefined;
this.fillByEntry();
}
setCustomIcon(customIconId: string): void {
this.entryModified();
this.entry.customIcon = new kdbxweb.KdbxUuid(customIconId);
this.fillByEntry();
}
setExpires(dt: Date | undefined): void {
this.entryModified();
this.entry.times.expiryTime = dt;
this.entry.times.expires = !!dt;
this.fillByEntry();
}
setTags(tags: string[]): void {
this.entryModified();
this.entry.tags = tags;
this.fillByEntry();
}
renameTag(from: string, to: string): void {
const ix = this.entry.tags.findIndex((tag) => tag.toLowerCase() === from.toLowerCase());
if (ix < 0) {
return;
}
this.entryModified();
this.entry.tags.splice(ix, 1);
if (to) {
this.entry.tags.push(to);
}
this.fillByEntry();
}
setField(field: string, val: kdbxweb.KdbxEntryField | undefined, allowEmpty?: boolean): void {
const hasValue = val && (typeof val === 'string' || (val.isProtected && val.byteLength));
if (hasValue || allowEmpty || BuiltInFields.indexOf(field) >= 0) {
this.entryModified();
if (typeof val === 'string') {
val = this.sanitizeFieldValue(val);
}
if (val === undefined) {
val = '';
}
this.entry.fields.set(field, val);
} else if (this.entry.fields.has(field)) {
this.entryModified();
this.entry.fields.delete(field);
}
this.fillByEntry();
}
private sanitizeFieldValue(val: string): string {
// https://github.com/keeweb/keeweb/issues/910
// eslint-disable-next-line no-control-regex
return val.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\uFFF0-\uFFFF]/g, '');
}
hasField(field: string): boolean {
return this.entry.fields.has(field);
}
async addAttachment(name: string, data: ArrayBuffer): Promise<void> {
this.entryModified();
const binaryRef = await this.file.db.createBinary(data);
this.entry.binaries.set(name, binaryRef);
this.fillByEntry();
}
removeAttachment(name: string): void {
this.entryModified();
this.entry.binaries.delete(name);
this.fillByEntry();
}
getHistory(): Entry[] {
const history = this.entry.history.map((rec) => new Entry(rec, this.group, this.file));
history.push(this);
history.sort((x, y) => (x.updated?.getTime() ?? 0) - (y.updated?.getTime() ?? 0));
return history;
}
deleteHistory(historyEntry: kdbxweb.KdbxEntry): void {
const ix = this.entry.history.indexOf(historyEntry);
if (ix >= 0) {
this.entry.removeHistory(ix);
this.file.setModified();
}
this.fillByEntry();
}
revertToHistoryState(historyEntry: kdbxweb.KdbxEntry): void {
const ix = this.entry.history.indexOf(historyEntry);
if (ix < 0) {
return;
}
this.entry.pushHistory();
this.unsaved = true;
this.file.setModified();
this.entry.fields = new Map<string, kdbxweb.KdbxEntryField>();
this.entry.binaries = new Map<string, kdbxweb.KdbxBinary | kdbxweb.KdbxBinaryWithHash>();
this.entry.copyFrom(historyEntry);
this.entryModified();
this.fillByEntry();
}
discardUnsaved(): void {
if (this.unsaved && this.entry.history.length) {
this.unsaved = false;
const historyEntry = this.entry.history[this.entry.history.length - 1];
this.entry.removeHistory(this.entry.history.length - 1);
this.entry.fields = new Map<string, kdbxweb.KdbxEntryField>();
this.entry.binaries = new Map<
string,
kdbxweb.KdbxBinary | kdbxweb.KdbxBinaryWithHash
>();
this.entry.copyFrom(historyEntry);
this.fillByEntry();
}
}
moveToTrash(): void {
this.file.setModified();
if (this.isJustCreated) {
this.isJustCreated = false;
}
this.file.db.remove(this.entry);
this.file.reload();
}
deleteFromTrash(): void {
this.file.setModified();
this.file.db.move(this.entry, null);
this.file.reload();
}
removeWithoutHistory(): void {
if (this.canBeDeleted) {
const ix = this.group.group.entries.indexOf(this.entry);
if (ix >= 0) {
this.group.group.entries.splice(ix, 1);
}
this.file.reload();
}
}
detach(): kdbxweb.KdbxEntry {
this.file.setModified();
this.file.db.move(this.entry, null);
this.file.reload();
return this.entry;
}
moveToFile(file: File): void {
if (this.canBeDeleted) {
this.removeWithoutHistory();
this.group = file.groups[0];
this.file = file;
this.fillByEntry();
this.entry.times.update();
this.group.group.entries.push(this.entry);
this.group.addEntry(this);
this.isJustCreated = true;
this.unsaved = true;
this.file.setModified();
}
}
initOtpGenerator(): void {
let otpUrl = this.fields?.get('otp');
if (otpUrl) {
if (otpUrl instanceof kdbxweb.ProtectedValue) {
otpUrl = otpUrl.getText();
}
if (Otp.isSecret(otpUrl.replace(/\s/g, ''))) {
otpUrl = Otp.makeUrl(otpUrl.replace(/\s/g, '').toUpperCase());
} else if (otpUrl.toLowerCase().lastIndexOf('otpauth:', 0) !== 0) {
// KeeOTP plugin format
let key: string | undefined;
let step: number | undefined;
let size: number | undefined;
otpUrl.split('&').forEach((part) => {
const parts = part.split('=', 2);
const val = decodeURIComponent(parts[1]).replace(/=/g, '');
switch (parts[0]) {
case 'key':
key = val;
break;
case 'step':
step = +val || undefined;
break;
case 'size':
size = +val || undefined;
break;
}
});
if (key) {
otpUrl = Otp.makeUrl(key, step, size);
}
}
} else if (this.entry.fields.get('TOTP Seed')) {
// TrayTOTP plugin format
let secret = this.entry.fields.get('TOTP Seed');
if (secret instanceof kdbxweb.ProtectedValue) {
secret = secret.getText();
}
if (secret) {
let settings = this.entry.fields.get('TOTP Settings');
if (settings && settings instanceof kdbxweb.ProtectedValue) {
settings = settings.getText();
}
let period: number | undefined;
let digits: number | undefined;
if (settings) {
const parts = settings.split(';');
if (parts.length > 0 && parts[0]) {
period = +parts[0] ?? undefined;
}
if (parts.length > 1 && parts[1]) {
digits = +parts[1] ?? undefined;
}
}
otpUrl = Otp.makeUrl(secret, period, digits);
this.fields?.set('otp', kdbxweb.ProtectedValue.fromString(otpUrl));
}
}
if (otpUrl) {
if (this.otpGenerator && this.otpGenerator.url === otpUrl) {
return;
}
try {
this.otpGenerator = Otp.parseUrl(otpUrl);
} catch {
this.otpGenerator = undefined;
}
} else {
this.otpGenerator = undefined;
}
}
setOtp(otp: Otp): void {
this.otpGenerator = otp;
this.setOtpUrl(otp.url);
}
setOtpUrl(url: string): void {
this.setField('otp', url ? kdbxweb.ProtectedValue.fromString(url) : undefined);
this.entry.fields.delete('TOTP Seed');
this.entry.fields.delete('TOTP Settings');
}
getEffectiveEnableAutoType(): boolean {
if (typeof this.entry.autoType.enabled === 'boolean') {
return this.entry.autoType.enabled;
}
return this.group.getEffectiveEnableAutoType();
}
getEffectiveAutoTypeSeq(): string {
return this.entry.autoType.defaultSequence || this.group.getEffectiveAutoTypeSeq();
}
setEnableAutoType(enabled: boolean): void {
this.entryModified();
this.entry.autoType.enabled = enabled;
this.buildAutoType();
}
setAutoTypeObfuscation(enabled: boolean): void {
this.entryModified();
this.entry.autoType.obfuscation = enabled
? kdbxweb.Consts.AutoTypeObfuscationOptions.UseClipboard
: kdbxweb.Consts.AutoTypeObfuscationOptions.None;
this.buildAutoType();
}
setAutoTypeSeq(seq: string): void {
this.entryModified();
this.entry.autoType.defaultSequence = seq || undefined;
this.buildAutoType();
}
getGroupPath(): string[] {
let group: Group | undefined = this.group;
const groupPath: string[] = [];
while (group?.title) {
groupPath.unshift(group.title);
group = group.parentGroup;
}
return groupPath;
}
cloneEntry(nameSuffix: string): Entry {
const newEntry = Entry.newEntry(this.group, this.file);
const uuid = newEntry.entry.uuid;
newEntry.entry.copyFrom(this.entry);
newEntry.entry.uuid = uuid;
newEntry.entry.times.update();
newEntry.entry.times.creationTime = newEntry.entry.times.lastModTime;
newEntry.entry.fields.set('Title', `${this.title || ''}${nameSuffix}`);
newEntry.fillByEntry();
this.file.reload();
return newEntry;
}
copyFromTemplate(templateEntry: Entry): void {
const uuid = this.entry.uuid;
this.entry.copyFrom(templateEntry.entry);
this.entry.uuid = uuid;
this.entry.times.update();
this.entry.times.creationTime = this.entry.times.lastModTime;
this.entry.fields.set('Title', '');
this.fillByEntry();
}
getRank(filter: Filter): number {
const searchString = filter.textLower;
if (!searchString) {
// no search string given, so rank all items the same
return 0;
}
const checkProtectedFields = filter.advanced && filter.advanced.protect;
const defaultFieldWeight = 2;
let rank = 0;
for (const [field, val] of this.entry.fields) {
if (!val) {
continue;
}
if (val instanceof kdbxweb.ProtectedValue && (!checkProtectedFields || !val.length)) {
continue;
}
const stringRank = Ranking.getStringRank(searchString, val);
const fieldWeight = FieldRankWeights.get(field) ?? defaultFieldWeight;
rank += stringRank * fieldWeight;
}
return rank;
}
canCheckPasswordIssues(): boolean {
if (typeof this.entry.qualityCheck === 'boolean') {
return this.entry.qualityCheck;
}
return !this.entry.customData?.has('IgnorePwIssues');
}
setIgnorePasswordIssues(): void {
if (this.file.db.versionIsAtLeast(4, 1)) {
this.entry.qualityCheck = true;
} else {
if (!this.entry.customData) {
this.entry.customData = new Map();
}
this.entry.customData.set('IgnorePwIssues', { value: '1' });
}
this.entryModified();
}
getNextUrlFieldName(): string {
const takenFields = new Set(
[...this.entry.fields.keys()].filter((f) => f.startsWith(ExtraUrlFieldName))
);
for (let i = 0; ; i++) {
const fieldName = i ? `${ExtraUrlFieldName}_${i}` : ExtraUrlFieldName;
if (!takenFields.has(fieldName)) {
return fieldName;
}
}
}
getAllUrls(): string[] {
const urls = this.url ? [this.url] : [];
for (const [field, value] of this.fields || []) {
if (field.startsWith(ExtraUrlFieldName)) {
const url = value instanceof kdbxweb.ProtectedValue ? value.getText() : value;
if (url) {
urls.push(url);
}
}
}
return urls;
}
static newEntry(group: Group, file: File, opts?: { tag?: string }): Entry {
const entry = file.db.createEntry(group.group);
const model = new Entry(entry, group, file);
if (AppSettings.useGroupIconForEntries && group.icon && group.iconId) {
entry.icon = group.iconId;
}
if (opts?.tag) {
entry.tags = [opts.tag];
}
model.setEntry(entry, group, file);
model.entry.times.update();
model.unsaved = true;
model.isJustCreated = true;
model.canBeDeleted = true;
group.addEntry(model);
file.setModified();
return model;
}
static newEntryWithFields(group: Group, fields: Map<string, kdbxweb.KdbxEntryField>): Entry {
const entry = Entry.newEntry(group, group.file);
for (const [field, value] of fields) {
entry.setField(field, value);
}
return entry;
}
}
export { Entry, ExtraUrlFieldName };

View File

@ -1,776 +0,0 @@
import * as kdbxweb from 'kdbxweb';
import demoFileData from 'demo.kdbx';
import { Model } from 'framework/model';
import { Events } from 'framework/events';
import { GroupCollection } from 'collections/group-collection';
import { KdbxToHtml } from 'comp/format/kdbx-to-html';
import { GroupModel } from 'models/group-model';
import { AppSettingsModel } from 'models/app-settings-model';
import { IconUrlFormat } from 'util/formatting/icon-url-format';
import { Logger } from 'util/logger';
import { Locale } from 'util/locale';
import { StringFormat } from 'util/formatting/string-format';
import { ChalRespCalculator } from 'comp/app/chal-resp-calculator';
const logger = new Logger('file');
class FileModel extends Model {
constructor(data) {
super({
entryMap: {},
groupMap: {},
...data
});
}
open(password, fileData, keyFileData, callback) {
try {
const challengeResponse = ChalRespCalculator.build(this.chalResp);
const credentials = new kdbxweb.Credentials(password, keyFileData, challengeResponse);
const ts = logger.ts();
kdbxweb.Kdbx.load(fileData, credentials)
.then((db) => {
this.db = db;
})
.then(() => {
this.readModel();
this.setOpenFile({ passwordLength: password ? password.textLength : 0 });
if (keyFileData) {
kdbxweb.ByteUtils.zeroBuffer(keyFileData);
}
logger.info(
'Opened file ' +
this.name +
': ' +
logger.ts(ts) +
', ' +
this.kdfArgsToString(this.db.header) +
', ' +
Math.round(fileData.byteLength / 1024) +
' kB'
);
callback();
})
.catch((err) => {
if (
err.code === kdbxweb.Consts.ErrorCodes.InvalidKey &&
password &&
!password.byteLength
) {
logger.info(
'Error opening file with empty password, try to open with null password'
);
return this.open(null, fileData, keyFileData, callback);
}
logger.error('Error opening file', err.code, err.message, err);
callback(err);
});
} catch (e) {
logger.error('Error opening file', e, e.code, e.message, e);
callback(e);
}
}
kdfArgsToString(header) {
if (header.kdfParameters) {
return header.kdfParameters
.keys()
.map((key) => {
const val = header.kdfParameters.get(key);
if (val instanceof ArrayBuffer) {
return undefined;
}
return key + '=' + val;
})
.filter((p) => p)
.join('&');
} else if (header.keyEncryptionRounds) {
return header.keyEncryptionRounds + ' rounds';
} else {
return '?';
}
}
create(name, callback) {
const password = kdbxweb.ProtectedValue.fromString('');
const credentials = new kdbxweb.Credentials(password);
this.db = kdbxweb.Kdbx.create(credentials, name);
this.name = name;
this.readModel();
this.set({ active: true, created: true, name });
callback();
}
importWithXml(fileXml, callback) {
try {
const ts = logger.ts();
const password = kdbxweb.ProtectedValue.fromString('');
const credentials = new kdbxweb.Credentials(password);
kdbxweb.Kdbx.loadXml(fileXml, credentials)
.then((db) => {
this.db = db;
})
.then(() => {
this.readModel();
this.set({ active: true, created: true });
logger.info('Imported file ' + this.name + ': ' + logger.ts(ts));
callback();
})
.catch((err) => {
logger.error('Error importing file', err.code, err.message, err);
callback(err);
});
} catch (e) {
logger.error('Error importing file', e, e.code, e.message, e);
callback(e);
}
}
openDemo(callback) {
const password = kdbxweb.ProtectedValue.fromString('demo');
const credentials = new kdbxweb.Credentials(password);
const demoFile = kdbxweb.ByteUtils.arrayToBuffer(
kdbxweb.ByteUtils.base64ToBytes(demoFileData)
);
kdbxweb.Kdbx.load(demoFile, credentials)
.then((db) => {
this.db = db;
})
.then(() => {
this.name = 'Demo';
this.readModel();
this.setOpenFile({ passwordLength: 4, demo: true });
callback();
});
}
setOpenFile(props) {
this.set({
...props,
active: true,
oldKeyFileName: this.keyFileName,
oldPasswordLength: props.passwordLength,
passwordChanged: false,
keyFileChanged: false
});
this.oldPasswordHash = this.db.credentials.passwordHash;
this.oldKeyFileHash = this.db.credentials.keyFileHash;
this.oldKeyChangeDate = this.db.meta.keyChanged;
}
readModel() {
const groups = new GroupCollection();
this.set(
{
uuid: this.db.getDefaultGroup().uuid.toString(),
groups,
formatVersion: this.db.header.versionMajor,
defaultUser: this.db.meta.defaultUser,
recycleBinEnabled: this.db.meta.recycleBinEnabled,
historyMaxItems: this.db.meta.historyMaxItems,
historyMaxSize: this.db.meta.historyMaxSize,
keyEncryptionRounds: this.db.header.keyEncryptionRounds,
keyChangeForce: this.db.meta.keyChangeForce,
kdfName: this.readKdfName(),
kdfParameters: this.readKdfParams()
},
{ silent: true }
);
this.db.groups.forEach(function (group) {
let groupModel = this.getGroup(this.subId(group.uuid.id));
if (groupModel) {
groupModel.setGroup(group, this);
} else {
groupModel = GroupModel.fromGroup(group, this);
}
groups.push(groupModel);
}, this);
this.buildObjectMap();
this.resolveFieldReferences();
}
readKdfName() {
if (this.db.header.versionMajor === 4 && this.db.header.kdfParameters) {
const kdfParameters = this.db.header.kdfParameters;
let uuid = kdfParameters.get('$UUID');
if (uuid) {
uuid = kdbxweb.ByteUtils.bytesToBase64(uuid);
switch (uuid) {
case kdbxweb.Consts.KdfId.Argon2d:
return 'Argon2d';
case kdbxweb.Consts.KdfId.Argon2id:
return 'Argon2id';
case kdbxweb.Consts.KdfId.Aes:
return 'Aes';
}
}
return 'Unknown';
} else {
return 'Aes';
}
}
readKdfParams() {
const kdfParameters = this.db.header.kdfParameters;
if (!kdfParameters) {
return undefined;
}
let uuid = kdfParameters.get('$UUID');
if (!uuid) {
return undefined;
}
uuid = kdbxweb.ByteUtils.bytesToBase64(uuid);
switch (uuid) {
case kdbxweb.Consts.KdfId.Argon2d:
case kdbxweb.Consts.KdfId.Argon2id:
return {
parallelism: kdfParameters.get('P').valueOf(),
iterations: kdfParameters.get('I').valueOf(),
memory: kdfParameters.get('M').valueOf()
};
case kdbxweb.Consts.KdfId.Aes:
return {
rounds: kdfParameters.get('R').valueOf()
};
default:
return undefined;
}
}
subId(id) {
return this.id + ':' + id;
}
buildObjectMap() {
const entryMap = {};
const groupMap = {};
this.forEachGroup(
(group) => {
groupMap[group.id] = group;
group.forEachOwnEntry(null, (entry) => {
entryMap[entry.id] = entry;
});
},
{ includeDisabled: true }
);
this.entryMap = entryMap;
this.groupMap = groupMap;
}
resolveFieldReferences() {
const entryMap = this.entryMap;
Object.keys(entryMap).forEach((e) => {
entryMap[e].resolveFieldReferences();
});
}
reload() {
this.buildObjectMap();
this.readModel();
this.emit('reload', this);
}
mergeOrUpdate(fileData, remoteKey, callback) {
let credentials;
let credentialsPromise = Promise.resolve();
if (remoteKey) {
credentials = new kdbxweb.Credentials(kdbxweb.ProtectedValue.fromString(''));
credentialsPromise = credentials.ready.then(() => {
const promises = [];
if (remoteKey.password) {
promises.push(credentials.setPassword(remoteKey.password));
} else {
credentials.passwordHash = this.db.credentials.passwordHash;
}
if (remoteKey.keyFileName) {
if (remoteKey.keyFileData) {
promises.push(credentials.setKeyFile(remoteKey.keyFileData));
} else {
credentials.keyFileHash = this.db.credentials.keyFileHash;
}
}
return Promise.all(promises);
});
} else {
credentials = this.db.credentials;
}
credentialsPromise.then(() => {
kdbxweb.Kdbx.load(fileData, credentials)
.then((remoteDb) => {
if (this.modified) {
try {
if (remoteKey && remoteDb.meta.keyChanged > this.db.meta.keyChanged) {
this.db.credentials = remoteDb.credentials;
this.keyFileName = remoteKey.keyFileName || '';
if (remoteKey.password) {
this.passwordLength = remoteKey.password.textLength;
}
}
this.db.merge(remoteDb);
} catch (e) {
logger.error('File merge error', e);
return callback(e);
}
} else {
this.db = remoteDb;
}
this.dirty = true;
this.reload();
callback();
})
.catch((err) => {
logger.error('Error opening file to merge', err.code, err.message, err);
callback(err);
});
});
}
getLocalEditState() {
return this.db.getLocalEditState();
}
setLocalEditState(editState) {
this.db.setLocalEditState(editState);
}
close() {
this.set({
keyFileName: '',
passwordLength: 0,
modified: false,
dirty: false,
active: false,
created: false,
groups: null,
passwordChanged: false,
keyFileChanged: false,
syncing: false
});
if (this.chalResp && !AppSettingsModel.yubiKeyRememberChalResp) {
ChalRespCalculator.clearCache(this.chalResp);
}
}
getEntry(id) {
return this.entryMap[id];
}
getGroup(id) {
return this.groupMap[id];
}
forEachEntry(filter, callback) {
let top = this;
if (filter.trash) {
top = this.getGroup(
this.db.meta.recycleBinUuid ? this.subId(this.db.meta.recycleBinUuid.id) : null
);
} else if (filter.group) {
top = this.getGroup(filter.group);
}
if (top) {
if (top.forEachOwnEntry) {
top.forEachOwnEntry(filter, callback);
}
if (!filter.group || filter.subGroups) {
top.forEachGroup((group) => {
group.forEachOwnEntry(filter, callback);
}, filter);
}
}
}
forEachGroup(callback, filter) {
this.groups.forEach((group) => {
if (callback(group) !== false) {
group.forEachGroup(callback, filter);
}
});
}
getTrashGroup() {
return this.db.meta.recycleBinEnabled
? this.getGroup(this.subId(this.db.meta.recycleBinUuid.id))
: null;
}
getEntryTemplatesGroup() {
return this.db.meta.entryTemplatesGroup
? this.getGroup(this.subId(this.db.meta.entryTemplatesGroup.id))
: null;
}
createEntryTemplatesGroup() {
const rootGroup = this.groups[0];
const templatesGroup = GroupModel.newGroup(rootGroup, this);
templatesGroup.setName(StringFormat.capFirst(Locale.templates));
this.db.meta.entryTemplatesGroup = templatesGroup.group.uuid;
this.reload();
return templatesGroup;
}
setModified() {
if (!this.demo) {
this.set({ modified: true, dirty: true });
}
}
getData(cb) {
this.db.cleanup({
historyRules: true,
customIcons: true,
binaries: true
});
this.db.cleanup({ binaries: true });
this.db
.save()
.then((data) => {
cb(data);
})
.catch((err) => {
logger.error('Error saving file', this.name, err);
cb(undefined, err);
});
}
getXml(cb) {
this.db.saveXml(true).then((xml) => {
cb(xml);
});
}
getHtml(cb) {
cb(
KdbxToHtml.convert(this.db, {
name: this.name
})
);
}
getKeyFileHash() {
const hash = this.db.credentials.keyFileHash;
return hash ? kdbxweb.ByteUtils.bytesToBase64(hash.getBinary()) : null;
}
forEachEntryTemplate(callback) {
if (!this.db.meta.entryTemplatesGroup) {
return;
}
const group = this.getGroup(this.subId(this.db.meta.entryTemplatesGroup.id));
if (!group) {
return;
}
group.forEachOwnEntry({}, callback);
}
setSyncProgress() {
this.set({ syncing: true });
}
setSyncComplete(path, storage, error) {
if (!error) {
this.db.removeLocalEditState();
}
const modified = this.modified && !!error;
this.set({
created: false,
path: path || this.path,
storage: storage || this.storage,
modified,
dirty: error ? this.dirty : false,
syncing: false,
syncError: error
});
if (!error && this.passwordChanged && this.encryptedPassword) {
this.set({
encryptedPassword: null,
encryptedPasswordDate: null
});
}
if (!this.open) {
return;
}
this.setOpenFile({ passwordLength: this.passwordLength });
this.forEachEntry({ includeDisabled: true }, (entry) => entry.setSaved());
}
setPassword(password) {
this.db.credentials.setPassword(password);
this.db.meta.keyChanged = new Date();
this.set({ passwordLength: password.textLength, passwordChanged: true });
this.setModified();
}
resetPassword() {
this.db.credentials.passwordHash = this.oldPasswordHash;
if (this.db.credentials.keyFileHash === this.oldKeyFileHash) {
this.db.meta.keyChanged = this.oldKeyChangeDate;
}
this.set({ passwordLength: this.oldPasswordLength, passwordChanged: false });
}
setKeyFile(keyFile, keyFileName) {
this.db.credentials.setKeyFile(keyFile);
this.db.meta.keyChanged = new Date();
this.set({ keyFileName, keyFileChanged: true });
this.setModified();
}
generateAndSetKeyFile() {
return kdbxweb.Credentials.createRandomKeyFile().then((keyFile) => {
const keyFileName = 'Generated';
this.setKeyFile(keyFile, keyFileName);
return keyFile;
});
}
resetKeyFile() {
this.db.credentials.keyFileHash = this.oldKeyFileHash;
if (this.db.credentials.passwordHash === this.oldPasswordHash) {
this.db.meta.keyChanged = this.oldKeyChangeDate;
}
this.set({ keyFileName: this.oldKeyFileName, keyFileChanged: false });
}
removeKeyFile() {
this.db.credentials.keyFileHash = null;
const changed = !!this.oldKeyFileHash;
if (!changed && this.db.credentials.passwordHash === this.oldPasswordHash) {
this.db.meta.keyChanged = this.oldKeyChangeDate;
}
this.set({ keyFileName: '', keyFilePath: '', keyFileChanged: changed });
Events.emit('unset-keyfile', this.id);
this.setModified();
}
isKeyChangePending(force) {
if (!this.db.meta.keyChanged) {
return false;
}
const expiryDays = force ? this.db.meta.keyChangeForce : this.db.meta.keyChangeRec;
if (!expiryDays || expiryDays < 0 || isNaN(expiryDays)) {
return false;
}
const daysDiff = (Date.now() - this.db.meta.keyChanged) / 1000 / 3600 / 24;
return daysDiff > expiryDays;
}
setChallengeResponse(chalResp) {
if (this.chalResp && !AppSettingsModel.yubiKeyRememberChalResp) {
ChalRespCalculator.clearCache(this.chalResp);
}
this.db.credentials.setChallengeResponse(ChalRespCalculator.build(chalResp));
this.db.meta.keyChanged = new Date();
this.chalResp = chalResp;
this.setModified();
}
setKeyChange(force, days) {
if (isNaN(days) || !days || days < 0) {
days = -1;
}
const prop = force ? 'keyChangeForce' : 'keyChangeRec';
this.db.meta[prop] = days;
this[prop] = days;
this.setModified();
}
setName(name) {
this.db.meta.name = name;
this.db.meta.nameChanged = new Date();
this.name = name;
this.groups[0].setName(name);
this.setModified();
this.reload();
}
setDefaultUser(defaultUser) {
this.db.meta.defaultUser = defaultUser;
this.db.meta.defaultUserChanged = new Date();
this.defaultUser = defaultUser;
this.setModified();
}
setRecycleBinEnabled(enabled) {
enabled = !!enabled;
this.db.meta.recycleBinEnabled = enabled;
if (enabled) {
this.db.createRecycleBin();
}
this.recycleBinEnabled = enabled;
this.setModified();
}
setHistoryMaxItems(count) {
this.db.meta.historyMaxItems = count;
this.historyMaxItems = count;
this.setModified();
}
setHistoryMaxSize(size) {
this.db.meta.historyMaxSize = size;
this.historyMaxSize = size;
this.setModified();
}
setKeyEncryptionRounds(rounds) {
this.db.header.keyEncryptionRounds = rounds;
this.keyEncryptionRounds = rounds;
this.setModified();
}
setKdfParameter(field, value) {
const ValueType = kdbxweb.VarDictionary.ValueType;
switch (field) {
case 'memory':
this.db.header.kdfParameters.set('M', ValueType.UInt64, kdbxweb.Int64.from(value));
break;
case 'iterations':
this.db.header.kdfParameters.set('I', ValueType.UInt64, kdbxweb.Int64.from(value));
break;
case 'parallelism':
this.db.header.kdfParameters.set('P', ValueType.UInt32, value);
break;
case 'rounds':
this.db.header.kdfParameters.set('R', ValueType.UInt32, value);
break;
default:
return;
}
this.kdfParameters = this.readKdfParams();
this.setModified();
}
emptyTrash() {
const trashGroup = this.getTrashGroup();
if (trashGroup) {
let modified = false;
trashGroup
.getOwnSubGroups()
.slice()
.forEach(function (group) {
this.db.move(group, null);
modified = true;
}, this);
trashGroup.group.entries.slice().forEach(function (entry) {
this.db.move(entry, null);
modified = true;
}, this);
trashGroup.items.length = 0;
trashGroup.entries.length = 0;
if (modified) {
this.setModified();
}
}
}
getCustomIcons() {
const customIcons = {};
for (const [id, icon] of this.db.meta.customIcons) {
customIcons[id] = IconUrlFormat.toDataUrl(icon.data);
}
return customIcons;
}
addCustomIcon(iconData) {
const uuid = kdbxweb.KdbxUuid.random();
this.db.meta.customIcons.set(uuid.id, {
data: kdbxweb.ByteUtils.arrayToBuffer(kdbxweb.ByteUtils.base64ToBytes(iconData)),
lastModified: new Date()
});
return uuid.toString();
}
renameTag(from, to) {
this.forEachEntry({}, (entry) => entry.renameTag(from, to));
}
setFormatVersion(version) {
this.db.setVersion(version);
this.setModified();
this.readModel();
}
setKdf(kdfName) {
const kdfParameters = this.db.header.kdfParameters;
if (!kdfParameters) {
throw new Error('Cannot set KDF on this version');
}
switch (kdfName) {
case 'Aes':
this.db.setKdf(kdbxweb.Consts.KdfId.Aes);
break;
case 'Argon2d':
this.db.setKdf(kdbxweb.Consts.KdfId.Argon2d);
break;
case 'Argon2id':
this.db.setKdf(kdbxweb.Consts.KdfId.Argon2id);
break;
default:
throw new Error('Bad KDF name');
}
this.setModified();
this.readModel();
}
static createKeyFileWithHash(hash) {
const hashData = kdbxweb.ByteUtils.base64ToBytes(hash);
const hexHash = kdbxweb.ByteUtils.bytesToHex(hashData);
return kdbxweb.ByteUtils.stringToBytes(hexHash);
}
}
FileModel.defineModelProperties({
id: '',
uuid: '',
name: '',
db: null,
entryMap: null,
groupMap: null,
keyFileName: '',
keyFilePath: null,
chalResp: null,
passwordLength: 0,
path: '',
opts: null,
storage: null,
modified: false,
dirty: false,
active: false,
created: false,
demo: false,
groups: null,
oldPasswordLength: 0,
oldKeyFileName: '',
passwordChanged: false,
keyFileChanged: false,
keyChangeForce: -1,
syncing: false,
syncError: null,
syncDate: null,
backup: null,
formatVersion: null,
defaultUser: null,
recycleBinEnabled: null,
historyMaxItems: null,
historyMaxSize: null,
keyEncryptionRounds: null,
kdfName: null,
kdfParameters: null,
fingerprint: null, // obsolete
oldPasswordHash: null,
oldKeyFileHash: null,
oldKeyChangeDate: null,
encryptedPassword: null,
encryptedPasswordDate: null,
supportsTags: true,
supportsColors: true,
supportsIcons: true,
supportsExpiration: true,
defaultGroupHash: ''
});
export { FileModel };

801
app/scripts/models/file.ts Normal file
View File

@ -0,0 +1,801 @@
import * as kdbxweb from 'kdbxweb';
import { Model } from 'util/model';
import { Entry } from 'models/entry';
import { Group } from 'models/group';
import { IconUrlFormat } from 'util/formatting/icon-url-format';
import { Logger } from 'util/logger';
import { Locale } from 'util/locale';
import { StringFormat } from 'util/formatting/string-format';
import { StorageFileOptions } from 'storage/types';
import { FileBackupConfig } from './file-info';
import { IdGenerator } from 'util/generators/id-generator';
import { Filter } from 'models/filter';
// import { ChalRespCalculator } from 'comp/app/chal-resp-calculator';
const DemoFileData = require('demo.kdbx') as { default: string };
const logger = new Logger('file');
type KdfName = 'AES' | 'Argon2d' | 'Argon2id';
interface FileKdfParamsAes {
type: 'AES';
name: 'AES';
rounds: number;
}
interface FileKdfParamsArgon2 {
type: 'Argon2';
name: 'Argon2d' | 'Argon2id';
parallelism: number;
iterations: number;
memory: number;
}
type FileKdfParams = FileKdfParamsAes | FileKdfParamsArgon2;
interface FileEvents {
'reload': () => void;
}
const FilterIncludingDisabled = new Filter({ includeDisabled: true });
class File extends Model<FileEvents> {
readonly id: string;
db: kdbxweb.Kdbx;
name: string;
uuid?: string;
keyFileName?: string;
keyFilePath?: string;
// chalResp = null; // TODO(ts): chal-resp
passwordLength?: number;
path?: string;
opts?: StorageFileOptions;
storage?: string;
modified = false;
dirty = false;
created = false;
demo = false;
groups: Group[] = [];
oldPasswordLength?: number;
oldKeyFileName?: string;
passwordChanged = false;
keyFileChanged = false;
keyChangeForce?: number | undefined;
syncing = false;
syncError?: string;
syncDate?: Date;
backup?: FileBackupConfig;
formatVersion?: number;
defaultUser?: string;
recycleBinEnabled?: boolean;
historyMaxItems?: number;
historyMaxSize?: number;
keyEncryptionRounds?: number;
kdfParameters?: FileKdfParams;
oldPasswordHash?: kdbxweb.ProtectedValue;
oldKeyFileHash?: kdbxweb.ProtectedValue;
oldKeyChangeDate?: Date;
encryptedPassword?: string;
encryptedPasswordDate?: Date;
supportsTags = true;
supportsColors = true;
supportsIcons = true;
supportsExpiration = true;
private readonly _entryMap = new Map<string, Entry>();
private readonly _groupMap = new Map<string, Group>();
constructor(id: string, name: string, db: kdbxweb.Kdbx) {
super();
this.id = id;
this.name = name;
this.db = db;
}
static async open(
id: string,
name: string,
password: kdbxweb.ProtectedValue,
fileData: ArrayBuffer,
keyFileData?: ArrayBuffer
): Promise<File> {
try {
// const challengeResponse = ChalRespCalculator.build(this.chalResp); // TODO(ts): chal-resp
const credentials = new kdbxweb.Credentials(
password,
keyFileData
// , challengeResponse // TODO(ts): chal-resp
);
const ts = logger.ts();
let db: kdbxweb.Kdbx;
try {
db = await kdbxweb.Kdbx.load(fileData, credentials);
} catch (err) {
if (
err instanceof kdbxweb.KdbxError &&
err.code === kdbxweb.Consts.ErrorCodes.InvalidKey &&
password &&
!password.byteLength
) {
logger.info(
'Error opening file with empty password, try to open with null password'
);
db = await kdbxweb.Kdbx.load(fileData, credentials);
} else {
throw err;
}
}
const file = new File(id, name, db);
file.readModel();
file.setOpenFile({ passwordLength: password ? password.textLength : 0 });
if (keyFileData) {
kdbxweb.ByteUtils.zeroBuffer(keyFileData);
}
const kdfStr = file.kdfArgsToString();
const fileSizeKb = Math.round(fileData.byteLength / 1024);
logger.info(`Opened file ${name}: ${logger.ts(ts)}, ${kdfStr}, ${fileSizeKb} kB`);
return file;
} catch (e) {
logger.error('Error opening file', e);
throw e;
}
}
static create(id: string, name: string): Promise<File> {
const password = kdbxweb.ProtectedValue.fromString('');
const credentials = new kdbxweb.Credentials(password);
const db = kdbxweb.Kdbx.create(credentials, name);
const file = new File(id, name, db);
file.created = true;
file.readModel();
return Promise.resolve(file);
}
static async importWithXml(id: string, name: string, xml: string): Promise<File> {
try {
const ts = logger.ts();
const password = kdbxweb.ProtectedValue.fromString('');
const credentials = new kdbxweb.Credentials(password);
const db = await kdbxweb.Kdbx.loadXml(xml, credentials);
const file = new File(id, name, db);
file.readModel();
file.created = true;
logger.info(`Imported file ${name}: ${logger.ts(ts)}`);
return file;
} catch (e) {
logger.error('Error importing XML', e);
throw e;
}
}
static async openDemo(): Promise<File> {
const password = kdbxweb.ProtectedValue.fromString('demo');
const credentials = new kdbxweb.Credentials(password);
const demoFile = kdbxweb.ByteUtils.arrayToBuffer(
kdbxweb.ByteUtils.base64ToBytes(DemoFileData.default)
);
const db = await kdbxweb.Kdbx.load(demoFile, credentials);
const file = new File(IdGenerator.uuid(), 'Demo', db);
file.demo = true;
file.readModel();
file.setOpenFile({ passwordLength: 4 });
return file;
}
private kdfArgsToString(): string {
if (this.db.header.kdfParameters) {
const kdfParameters = this.db.header.kdfParameters;
return kdfParameters
.keys()
.map((key) => {
const val = kdfParameters.get(key);
if (val instanceof ArrayBuffer) {
return undefined;
}
return `${key}=${String(val)}`;
})
.filter((p) => p)
.join('&');
} else if (this.db.header.keyEncryptionRounds) {
return `${this.db.header.keyEncryptionRounds} rounds`;
} else {
return '?';
}
}
private setOpenFile({ passwordLength }: { passwordLength: number }) {
this.batchSet(() => {
this.oldKeyFileName = this.keyFileName;
this.oldPasswordLength = passwordLength;
this.passwordChanged = false;
this.keyFileChanged = false;
});
this.oldPasswordHash = this.db.credentials.passwordHash;
this.oldKeyFileHash = this.db.credentials.keyFileHash;
this.oldKeyChangeDate = this.db.meta.keyChanged;
}
private readModel() {
const groups: Group[] = [];
this.batchSet(() => {
this.uuid = this.db.getDefaultGroup().uuid.toString();
this.groups = groups;
this.formatVersion = this.db.header.versionMajor;
this.defaultUser = this.db.meta.defaultUser;
this.recycleBinEnabled = this.db.meta.recycleBinEnabled;
this.historyMaxItems = this.db.meta.historyMaxItems;
this.historyMaxSize = this.db.meta.historyMaxSize;
this.keyEncryptionRounds = this.db.header.keyEncryptionRounds;
this.keyChangeForce = this.db.meta.keyChangeForce;
this.kdfParameters = this.readKdfParams();
});
for (const group of this.db.groups) {
let groupModel = this.getGroup(this.subId(group.uuid.id));
if (groupModel) {
groupModel.setGroup(group, this, undefined);
} else {
groupModel = new Group(group, this, undefined);
}
groups.push(groupModel);
}
this.buildObjectMap();
this.resolveFieldReferences();
}
private readKdfParams(): FileKdfParams | undefined {
const kdfParameters = this.db.header.kdfParameters;
if (!kdfParameters) {
return undefined;
}
let uuid = kdfParameters.get('$UUID');
if (!(uuid instanceof ArrayBuffer)) {
return undefined;
}
uuid = kdbxweb.ByteUtils.bytesToBase64(uuid);
switch (uuid) {
case kdbxweb.Consts.KdfId.Argon2d:
case kdbxweb.Consts.KdfId.Argon2id:
return {
type: 'Argon2',
name: uuid === kdbxweb.Consts.KdfId.Argon2d ? 'Argon2d' : 'Argon2id',
parallelism: File.getKdfNumber(kdfParameters, 'P'),
iterations: File.getKdfNumber(kdfParameters, 'I'),
memory: File.getKdfNumber(kdfParameters, 'M')
};
case kdbxweb.Consts.KdfId.Aes:
return {
type: 'AES',
name: 'AES',
rounds: File.getKdfNumber(kdfParameters, 'R')
};
default:
return undefined;
}
}
private static getKdfNumber(kdfParameters: kdbxweb.VarDictionary, key: string): number {
const value = kdfParameters.get(key);
if (value instanceof kdbxweb.Int64) {
return value.valueOf();
}
if (typeof value === 'number') {
return value;
}
return -1;
}
subId(id: string): string {
return this.id + ':' + id;
}
private buildObjectMap(): void {
this._entryMap.clear();
this._groupMap.clear();
for (const group of this.allGroupsMatching(FilterIncludingDisabled)) {
this._groupMap.set(group.id, group);
for (const entry of group.ownEntriesMatching(FilterIncludingDisabled)) {
this._entryMap.set(entry.id, entry);
}
}
}
private resolveFieldReferences(): void {
for (const entry of this._entryMap.values()) {
entry.resolveFieldReferences();
}
}
reload(): void {
this.buildObjectMap();
this.readModel();
this.emit('reload');
}
async mergeOrUpdate(
fileData: ArrayBuffer,
remoteKey?: {
password?: kdbxweb.ProtectedValue;
keyFileName?: string;
keyFileData?: ArrayBuffer;
}
): Promise<void> {
let credentials: kdbxweb.Credentials;
if (remoteKey) {
// TODO(ts): chall-resp
credentials = new kdbxweb.Credentials(null);
await credentials.ready;
if (remoteKey.password) {
await credentials.setPassword(remoteKey.password);
} else {
credentials.passwordHash = this.db.credentials.passwordHash;
}
if (remoteKey.keyFileName) {
if (remoteKey.keyFileData) {
await credentials.setKeyFile(remoteKey.keyFileData);
} else {
credentials.keyFileHash = this.db.credentials.keyFileHash;
}
}
} else {
credentials = this.db.credentials;
}
let remoteDb: kdbxweb.Kdbx;
try {
remoteDb = await kdbxweb.Kdbx.load(fileData, credentials);
} catch (err) {
logger.error('Error opening file to merge', err);
throw err;
}
if (this.modified) {
try {
if (
remoteKey &&
remoteDb.meta.keyChanged &&
this.db.meta.keyChanged &&
remoteDb.meta.keyChanged.getTime() > this.db.meta.keyChanged.getTime()
) {
this.db.credentials = remoteDb.credentials;
this.keyFileName = remoteKey.keyFileName;
if (remoteKey.password) {
this.passwordLength = remoteKey.password.textLength;
}
}
this.db.merge(remoteDb);
} catch (e) {
logger.error('File merge error', e);
throw e;
}
} else {
this.db = remoteDb;
}
this.dirty = true;
this.reload();
}
getLocalEditState(): kdbxweb.KdbxEditState {
return this.db.getLocalEditState();
}
setLocalEditState(editState: kdbxweb.KdbxEditState): void {
this.db.setLocalEditState(editState);
}
close(): void {
this.batchSet(() => {
this.keyFileName = '';
this.passwordLength = 0;
this.modified = false;
this.dirty = false;
this.created = false;
this.groups = [];
this.passwordChanged = false;
this.keyFileChanged = false;
this.syncing = false;
});
// TODO(ts): chal-resp
// if (this.chalResp && !AppSettings.yubiKeyRememberChalResp) {
// ChalRespCalculator.clearCache(this.chalResp);
// }
}
getEntry(id: string): Entry | undefined {
return this._entryMap.get(id);
}
getGroup(id: string): Group | undefined {
return this._groupMap.get(id);
}
*entriesMatching(filter: Filter): Generator<Entry> {
let top: Group | undefined; // = this
if (filter.trash) {
const recycleBinUuid = this.db.meta.recycleBinUuid?.id;
if (recycleBinUuid) {
top = this.getGroup(recycleBinUuid);
}
} else if (filter.group) {
top = this.getGroup(filter.group);
}
if (top) {
yield* top.ownEntriesMatching(filter);
if (!filter.group || filter.subGroups) {
for (const group of top.allGroupsMatching(filter)) {
yield* group.ownEntriesMatching(filter);
}
}
} else {
for (const group of this.allGroupsMatching(filter)) {
yield* group.ownEntriesMatching(filter);
}
}
}
*allGroupsMatching(filter: Filter): Generator<Group> {
for (const group of this.groups) {
if (group.matches(filter)) {
yield group;
yield* group.allGroupsMatching(filter);
}
}
}
*allEntryTemplates(): Generator<Entry> {
if (!this.db.meta.entryTemplatesGroup) {
return;
}
const group = this.getGroup(this.subId(this.db.meta.entryTemplatesGroup.id));
if (!group) {
return;
}
yield* group.ownEntriesMatching(new Filter());
}
getTrashGroup(): Group | undefined {
return this.db.meta.recycleBinEnabled && this.db.meta.recycleBinUuid
? this.getGroup(this.subId(this.db.meta.recycleBinUuid.id))
: undefined;
}
getEntryTemplatesGroup(): Group | undefined {
return this.db.meta.entryTemplatesGroup
? this.getGroup(this.subId(this.db.meta.entryTemplatesGroup.id))
: undefined;
}
createEntryTemplatesGroup(): Group {
const rootGroup = this.groups[0];
const templatesGroup = Group.newGroup(rootGroup, this);
templatesGroup.setName(StringFormat.capFirst(Locale.templates));
this.db.meta.entryTemplatesGroup = templatesGroup.group.uuid;
this.reload();
return templatesGroup;
}
setModified(): void {
if (!this.demo) {
this.batchSet(() => {
this.modified = true;
this.dirty = true;
});
}
}
getData(): Promise<ArrayBuffer> {
this.db.cleanup({
historyRules: true,
customIcons: true,
binaries: true
});
return this.db.save();
}
getXml(): Promise<string> {
return this.db.saveXml(true);
}
getKeyFileHash(): string | undefined {
const hash = this.db.credentials.keyFileHash;
return hash ? kdbxweb.ByteUtils.bytesToBase64(hash.getBinary()) : undefined;
}
setSyncProgress(): void {
this.syncing = true;
}
setSyncComplete(
path: string | undefined,
storage: string | undefined,
error: string | undefined
): void {
if (!error) {
this.db.removeLocalEditState();
}
this.batchSet(() => {
this.created = false;
this.path = path || this.path;
this.storage = storage || this.storage;
this.modified = this.modified && !!error;
this.dirty = error ? this.dirty : false;
this.syncing = false;
this.syncError = error;
if (!error && this.passwordChanged && this.encryptedPassword) {
this.encryptedPassword = undefined;
this.encryptedPasswordDate = undefined;
}
});
this.setOpenFile({ passwordLength: this.passwordLength || 0 });
for (const entry of this.entriesMatching(FilterIncludingDisabled)) {
entry.setSaved();
}
}
async setPassword(password: kdbxweb.ProtectedValue): Promise<void> {
await this.db.credentials.setPassword(password);
this.db.meta.keyChanged = new Date();
this.batchSet(() => {
this.passwordLength = password.textLength;
this.passwordChanged = true;
});
this.setModified();
}
resetPassword(): void {
this.db.credentials.passwordHash = this.oldPasswordHash;
if (this.db.credentials.keyFileHash === this.oldKeyFileHash) {
this.db.meta.keyChanged = this.oldKeyChangeDate;
}
this.batchSet(() => {
this.passwordLength = this.oldPasswordLength;
this.passwordChanged = false;
});
}
async setKeyFile(keyFile: Uint8Array | null, keyFileName: string | undefined): Promise<void> {
await this.db.credentials.setKeyFile(keyFile);
this.db.meta.keyChanged = new Date();
this.batchSet(() => {
this.keyFileName = keyFileName;
this.keyFileChanged = true;
});
this.setModified();
}
async generateAndSetKeyFile(): Promise<Uint8Array> {
const keyFile = await kdbxweb.Credentials.createRandomKeyFile();
const keyFileName = 'Generated';
await this.setKeyFile(keyFile, keyFileName);
return keyFile;
}
resetKeyFile(): void {
this.db.credentials.keyFileHash = this.oldKeyFileHash;
if (this.db.credentials.passwordHash === this.oldPasswordHash) {
this.db.meta.keyChanged = this.oldKeyChangeDate;
}
this.batchSet(() => {
this.keyFileName = this.oldKeyFileName;
this.keyFileChanged = false;
});
}
removeKeyFile(): void {
this.db.credentials.keyFileHash = undefined;
const changed = !!this.oldKeyFileHash;
if (!changed && this.db.credentials.passwordHash === this.oldPasswordHash) {
this.db.meta.keyChanged = this.oldKeyChangeDate;
}
this.batchSet(() => {
this.keyFileName = undefined;
this.keyFilePath = undefined;
this.keyFileChanged = changed;
});
this.setModified();
}
isKeyChangePending(force: boolean): boolean {
if (!this.db.meta.keyChanged) {
return false;
}
const expiryDays = force ? this.db.meta.keyChangeForce : this.db.meta.keyChangeRec;
if (!expiryDays || expiryDays < 0 || isNaN(expiryDays)) {
return false;
}
const daysDiff = (Date.now() - this.db.meta.keyChanged.getTime()) / 1000 / 3600 / 24;
return daysDiff > expiryDays;
}
// setChallengeResponse(chalResp) { // TODO(ts): chal-resp
// if (this.chalResp && !AppSettingsModel.yubiKeyRememberChalResp) {
// ChalRespCalculator.clearCache(this.chalResp);
// }
// this.db.credentials.setChallengeResponse(ChalRespCalculator.build(chalResp));
// this.db.meta.keyChanged = new Date();
// this.chalResp = chalResp;
// this.setModified();
// }
setKeyChange(force: boolean, days: number): void {
if (isNaN(days) || !days || days < 0) {
days = -1;
}
const prop = force ? 'keyChangeForce' : 'keyChangeRec';
this.db.meta[prop] = days;
if (force) {
this.db.meta.keyChangeForce = days;
} else {
this.db.meta.keyChangeRec = days;
}
this.setModified();
}
setName(name: string): void {
this.db.meta.name = name;
this.db.meta.nameChanged = new Date();
this.name = name;
this.groups[0].setName(name);
this.setModified();
this.reload();
}
setDefaultUser(defaultUser: string): void {
this.db.meta.defaultUser = defaultUser;
this.db.meta.defaultUserChanged = new Date();
this.defaultUser = defaultUser;
this.setModified();
}
setRecycleBinEnabled(enabled: boolean): void {
this.db.meta.recycleBinEnabled = enabled;
if (enabled) {
this.db.createRecycleBin();
}
this.recycleBinEnabled = enabled;
this.setModified();
}
setHistoryMaxItems(count: number): void {
this.db.meta.historyMaxItems = count;
this.historyMaxItems = count;
this.setModified();
}
setHistoryMaxSize(size: number): void {
this.db.meta.historyMaxSize = size;
this.historyMaxSize = size;
this.setModified();
}
setKeyEncryptionRounds(rounds: number): void {
this.db.header.keyEncryptionRounds = rounds;
this.keyEncryptionRounds = rounds;
this.setModified();
}
setKdfParameter(
field: keyof FileKdfParamsAes | keyof FileKdfParamsArgon2,
value: number
): void {
const ValueType = kdbxweb.VarDictionary.ValueType;
const kdfParameters = this.db.header.kdfParameters;
if (!kdfParameters) {
throw new Error('No kdf parameters');
}
switch (field) {
case 'memory':
kdfParameters.set('M', ValueType.UInt64, kdbxweb.Int64.from(value));
break;
case 'iterations':
kdfParameters.set('I', ValueType.UInt64, kdbxweb.Int64.from(value));
break;
case 'parallelism':
kdfParameters.set('P', ValueType.UInt32, value);
break;
case 'rounds':
kdfParameters.set('R', ValueType.UInt32, value);
break;
default:
return;
}
this.kdfParameters = this.readKdfParams();
this.setModified();
}
emptyTrash(): void {
const trashGroup = this.getTrashGroup();
if (trashGroup) {
let modified = false;
trashGroup.group.groups.slice().forEach((group) => {
this.db.move(group, null);
modified = true;
});
trashGroup.group.entries.slice().forEach((entry) => {
this.db.move(entry, null);
modified = true;
});
trashGroup.items.length = 0;
trashGroup.entries.length = 0;
if (modified) {
this.setModified();
}
}
}
getCustomIcons(): Map<string, string> {
const customIcons = new Map<string, string>();
for (const [id, icon] of this.db.meta.customIcons) {
const iconData = IconUrlFormat.toDataUrl(icon.data);
if (iconData) {
customIcons.set(id, iconData);
}
}
return customIcons;
}
addCustomIcon(iconData: string): string {
const uuid = kdbxweb.KdbxUuid.random();
this.db.meta.customIcons.set(uuid.id, {
data: kdbxweb.ByteUtils.arrayToBuffer(kdbxweb.ByteUtils.base64ToBytes(iconData)),
lastModified: new Date()
});
return uuid.toString();
}
renameTag(from: string, to: string): void {
for (const entry of this.entriesMatching(FilterIncludingDisabled)) {
entry.renameTag(from, to);
}
}
setFormatVersion(version: 3 | 4): void {
this.db.setVersion(version);
this.setModified();
this.readModel();
}
setKdf(kdfName: KdfName): void {
const kdfParameters = this.db.header.kdfParameters;
if (!kdfParameters) {
throw new Error('Cannot set KDF on this version');
}
switch (kdfName) {
case 'AES':
this.db.setKdf(kdbxweb.Consts.KdfId.Aes);
break;
case 'Argon2d':
this.db.setKdf(kdbxweb.Consts.KdfId.Argon2d);
break;
case 'Argon2id':
this.db.setKdf(kdbxweb.Consts.KdfId.Argon2id);
break;
default:
throw new Error('Bad KDF name');
}
this.setModified();
this.readModel();
}
static createKeyFileWithHash(hash: string): Uint8Array {
const hashData = kdbxweb.ByteUtils.base64ToBytes(hash);
const hexHash = kdbxweb.ByteUtils.bytesToHex(hashData);
return kdbxweb.ByteUtils.stringToBytes(hexHash);
}
}
export { File };

View File

@ -0,0 +1,36 @@
import { Model } from 'util/model';
import { InitWithFieldsOf } from 'util/types';
export interface AdvancedFilter {
cs?: boolean;
regex?: boolean;
user?: boolean;
url?: boolean;
notes?: boolean;
pass?: boolean;
title?: boolean;
other?: boolean;
protect?: boolean;
history?: boolean;
}
export class Filter extends Model {
text?: string;
textParts?: string[];
textLower?: string;
textLowerParts?: string[];
tagLower?: string;
advanced?: AdvancedFilter;
color?: string;
autoType?: boolean;
otp?: boolean;
includeDisabled?: boolean;
trash?: boolean;
group?: string;
subGroups?: boolean;
constructor(values?: InitWithFieldsOf<Filter>) {
super();
Object.assign(this, values);
}
}

View File

@ -1,377 +0,0 @@
import * as kdbxweb from 'kdbxweb';
import { IconMap } from 'const/icon-map';
import { EntryModel } from 'models/entry-model';
import { MenuItem } from 'models/menu/menu-item-model';
import { IconUrlFormat } from 'util/formatting/icon-url-format';
import { GroupCollection } from 'collections/group-collection';
import { EntryCollection } from 'collections/entry-collection';
const KdbxIcons = kdbxweb.Consts.Icons;
const DefaultAutoTypeSequence = '{USERNAME}{TAB}{PASSWORD}{ENTER}';
class GroupModel extends MenuItem {
setGroup(group, file, parentGroup) {
const isRecycleBin = group.uuid.equals(file.db.meta.recycleBinUuid);
const id = file.subId(group.uuid.id);
this.set(
{
id,
uuid: group.uuid.id,
expanded: group.expanded,
visible: !isRecycleBin,
items: new GroupCollection(),
entries: new EntryCollection(),
filterValue: id,
enableSearching: group.enableSearching,
enableAutoType: group.enableAutoType,
autoTypeSeq: group.defaultAutoTypeSeq,
top: !parentGroup,
drag: !!parentGroup,
collapsible: !!parentGroup
},
{ silent: true }
);
this.group = group;
this.file = file;
this.parentGroup = parentGroup;
this._fillByGroup(true);
const items = this.items;
const entries = this.entries;
const itemsArray = group.groups.map((subGroup) => {
let g = file.getGroup(file.subId(subGroup.uuid.id));
if (g) {
g.setGroup(subGroup, file, this);
} else {
g = GroupModel.fromGroup(subGroup, file, this);
}
return g;
}, this);
items.push(...itemsArray);
const entriesArray = group.entries.map((entry) => {
let e = file.getEntry(file.subId(entry.uuid.id));
if (e) {
e.setEntry(entry, this, file);
} else {
e = EntryModel.fromEntry(entry, this, file);
}
return e;
}, this);
entries.push(...entriesArray);
}
_fillByGroup(silent) {
this.set(
{
title: this.parentGroup ? this.group.name : this.file.name,
iconId: this.group.icon,
icon: this._iconFromId(this.group.icon),
customIcon: this._buildCustomIcon(),
customIconId: this.group.customIcon ? this.group.customIcon.toString() : null,
expanded: this.group.expanded !== false
},
{ silent }
);
}
_iconFromId(id) {
if (id === KdbxIcons.Folder || id === KdbxIcons.FolderOpen) {
return undefined;
}
return IconMap[id];
}
_buildCustomIcon() {
this.customIcon = null;
if (this.group.customIcon) {
return IconUrlFormat.toDataUrl(
this.file.db.meta.customIcons.get(this.group.customIcon.id)?.data
);
}
return null;
}
_groupModified() {
if (this.isJustCreated) {
this.isJustCreated = false;
}
this.file.setModified();
this.group.times.update();
}
forEachGroup(callback, filter) {
let result = true;
this.items.forEach((group) => {
if (group.matches(filter)) {
result =
callback(group) !== false && group.forEachGroup(callback, filter) !== false;
}
});
return result;
}
forEachOwnEntry(filter, callback) {
this.entries.forEach(function (entry) {
if (entry.matches(filter)) {
callback(entry, this);
}
});
}
matches(filter) {
return (
((filter && filter.includeDisabled) ||
(this.group.enableSearching !== false &&
!this.group.uuid.equals(this.file.db.meta.entryTemplatesGroup))) &&
(!filter || !filter.autoType || this.group.enableAutoType !== false)
);
}
getOwnSubGroups() {
return this.group.groups;
}
addEntry(entry) {
this.entries.push(entry);
}
addGroup(group) {
this.items.push(group);
}
setName(name) {
this._groupModified();
this.group.name = name;
this._fillByGroup();
}
setIcon(iconId) {
this._groupModified();
this.group.icon = iconId;
this.group.customIcon = undefined;
this._fillByGroup();
}
setCustomIcon(customIconId) {
this._groupModified();
this.group.customIcon = new kdbxweb.KdbxUuid(customIconId);
this._fillByGroup();
}
setExpanded(expanded) {
// this._groupModified(); // it's not good to mark the file as modified when a group is collapsed
this.group.expanded = expanded;
this.expanded = expanded;
}
setEnableSearching(enabled) {
this._groupModified();
let parentEnableSearching = true;
let parentGroup = this.parentGroup;
while (parentGroup) {
if (typeof parentGroup.enableSearching === 'boolean') {
parentEnableSearching = parentGroup.enableSearching;
break;
}
parentGroup = parentGroup.parentGroup;
}
if (enabled === parentEnableSearching) {
enabled = null;
}
this.group.enableSearching = enabled;
this.enableSearching = this.group.enableSearching;
}
getEffectiveEnableSearching() {
let grp = this;
while (grp) {
if (typeof grp.enableSearching === 'boolean') {
return grp.enableSearching;
}
grp = grp.parentGroup;
}
return true;
}
setEnableAutoType(enabled) {
this._groupModified();
let parentEnableAutoType = true;
let parentGroup = this.parentGroup;
while (parentGroup) {
if (typeof parentGroup.enableAutoType === 'boolean') {
parentEnableAutoType = parentGroup.enableAutoType;
break;
}
parentGroup = parentGroup.parentGroup;
}
if (enabled === parentEnableAutoType) {
enabled = null;
}
this.group.enableAutoType = enabled;
this.enableAutoType = this.group.enableAutoType;
}
getEffectiveEnableAutoType() {
let grp = this;
while (grp) {
if (typeof grp.enableAutoType === 'boolean') {
return grp.enableAutoType;
}
grp = grp.parentGroup;
}
return true;
}
setAutoTypeSeq(seq) {
this._groupModified();
this.group.defaultAutoTypeSeq = seq || undefined;
this.autoTypeSeq = this.group.defaultAutoTypeSeq;
}
getEffectiveAutoTypeSeq() {
let grp = this;
while (grp) {
if (grp.autoTypeSeq) {
return grp.autoTypeSeq;
}
grp = grp.parentGroup;
}
return DefaultAutoTypeSequence;
}
getParentEffectiveAutoTypeSeq() {
return this.parentGroup
? this.parentGroup.getEffectiveAutoTypeSeq()
: DefaultAutoTypeSequence;
}
isEntryTemplatesGroup() {
return this.group.uuid.equals(this.file.db.meta.entryTemplatesGroup);
}
moveToTrash() {
this.file.setModified();
this.file.db.remove(this.group);
if (this.group.uuid.equals(this.file.db.meta.entryTemplatesGroup)) {
this.file.db.meta.entryTemplatesGroup = undefined;
}
this.file.reload();
}
deleteFromTrash() {
this.file.db.move(this.group, null);
this.file.reload();
}
removeWithoutHistory() {
const ix = this.parentGroup.group.groups.indexOf(this.group);
if (ix >= 0) {
this.parentGroup.group.groups.splice(ix, 1);
}
this.file.reload();
}
moveHere(object) {
if (!object || object.id === this.id) {
return;
}
if (object.file === this.file) {
this.file.setModified();
if (object instanceof GroupModel) {
for (let parent = this; parent; parent = parent.parentGroup) {
if (object === parent) {
return;
}
}
if (this.group.groups.indexOf(object.group) >= 0) {
return;
}
this.file.db.move(object.group, this.group);
this.file.reload();
} else if (object instanceof EntryModel) {
if (this.group.entries.indexOf(object.entry) >= 0) {
return;
}
this.file.db.move(object.entry, this.group);
this.file.reload();
}
} else {
if (object instanceof EntryModel) {
this.file.setModified();
const detachedEntry = object.detach();
this.file.db.importEntry(detachedEntry, this.group, object.file.db);
this.file.reload();
} else {
// moving groups between files is not supported for now
}
}
}
moveToTop(object) {
if (
!object ||
object.id === this.id ||
object.file !== this.file ||
!(object instanceof GroupModel)
) {
return;
}
this.file.setModified();
for (let parent = this; parent; parent = parent.parentGroup) {
if (object === parent) {
return;
}
}
let atIndex = this.parentGroup.group.groups.indexOf(this.group);
const selfIndex = this.parentGroup.group.groups.indexOf(object.group);
if (selfIndex >= 0 && selfIndex < atIndex) {
atIndex--;
}
if (atIndex >= 0) {
this.file.db.move(object.group, this.parentGroup.group, atIndex);
}
this.file.reload();
}
static fromGroup(group, file, parentGroup) {
const model = new GroupModel();
model.setGroup(group, file, parentGroup);
return model;
}
static newGroup(group, file) {
const model = new GroupModel();
const grp = file.db.createGroup(group.group);
model.setGroup(grp, file, group);
model.group.times.update();
model.isJustCreated = true;
group.addGroup(model);
file.setModified();
file.reload();
return model;
}
}
GroupModel.defineModelProperties({
id: '',
uuid: '',
iconId: 0,
entries: null,
filterKey: 'group',
editable: true,
top: false,
drag: true,
drop: true,
enableSearching: true,
enableAutoType: null,
autoTypeSeq: null,
group: null,
file: null,
parentGroup: null,
customIconId: null,
isJustCreated: false
});
export { GroupModel };

370
app/scripts/models/group.ts Normal file
View File

@ -0,0 +1,370 @@
import * as kdbxweb from 'kdbxweb';
import { IconMap } from 'const/icon-map';
import { Entry } from 'models/entry';
import { File } from 'models/file';
import { MenuItem } from 'models/menu/menu-item';
import { IconUrlFormat } from 'util/formatting/icon-url-format';
import { Filter } from 'models/filter';
const KdbxIcons = kdbxweb.Consts.Icons;
const DefaultAutoTypeSequence = '{USERNAME}{TAB}{PASSWORD}{ENTER}';
class Group extends MenuItem {
readonly id: string;
readonly uuid: string;
iconId?: number;
entries: Entry[] = [];
items: Group[] = [];
filterKey = 'group';
editable = true;
top = false;
drag = true;
drop = true;
enableSearching?: boolean | null;
enableAutoType?: boolean | null;
autoTypeSeq?: string;
group: kdbxweb.KdbxGroup;
file: File;
parentGroup?: Group;
customIcon?: string;
customIconId?: string;
isJustCreated = false;
constructor(group: kdbxweb.KdbxGroup, file: File, parentGroup: Group | undefined) {
super();
this.id = file.subId(group.uuid.id);
this.uuid = group.uuid.id;
this.group = group;
this.file = file;
this.parentGroup = parentGroup;
this.setGroup(group, file, parentGroup);
}
setGroup(group: kdbxweb.KdbxGroup, file: File, parentGroup: Group | undefined): void {
if (group.uuid.id !== this.uuid) {
throw new Error('Cannot change group uuid');
}
const isRecycleBin = group.uuid.equals(file.db.meta.recycleBinUuid);
const id = file.subId(group.uuid.id);
this.batchSet(() => {
this.expanded = group.expanded ?? true;
this.visible = !isRecycleBin;
this.items = [];
this.entries = [];
this.filterValue = id;
this.enableSearching = group.enableSearching;
this.enableAutoType = group.enableAutoType;
this.autoTypeSeq = group.defaultAutoTypeSeq;
this.top = !parentGroup;
this.drag = !!parentGroup;
this.collapsible = !!parentGroup;
});
this.group = group;
this.file = file;
this.parentGroup = parentGroup;
this.fillByGroup();
for (const subGroup of group.groups) {
let g = file.getGroup(file.subId(subGroup.uuid.id));
if (g) {
g.setGroup(subGroup, file, this);
} else {
g = new Group(subGroup, file, this);
}
this.items.push(g);
}
for (const entry of group.entries) {
let e = file.getEntry(file.subId(entry.uuid.id));
if (e) {
e.setEntry(entry, this, file);
} else {
e = new Entry(entry, this, file);
}
this.entries.push(e);
}
}
private fillByGroup() {
this.batchSet(() => {
this.title = this.parentGroup ? this.group.name : this.file.name;
this.iconId = this.group.icon;
this.icon = Group.iconFromId(this.group.icon);
this.customIcon = this.buildCustomIcon() ?? undefined;
this.customIconId = this.group.customIcon?.id;
this.expanded = this.group.expanded !== false;
});
}
private static iconFromId(id: number | undefined): string | undefined {
if (id === undefined || id === KdbxIcons.Folder || id === KdbxIcons.FolderOpen) {
return undefined;
}
return IconMap[id];
}
private buildCustomIcon(): string | null {
if (this.group.customIcon) {
const customIcon = this.file.db.meta.customIcons.get(this.group.customIcon.id);
if (customIcon) {
return IconUrlFormat.toDataUrl(customIcon.data);
}
}
return null;
}
private groupModified() {
if (this.isJustCreated) {
this.isJustCreated = false;
}
this.file.setModified();
this.group.times.update();
}
*parentGroups(): Generator<Group> {
let group = this.parentGroup;
while (group) {
yield group;
group = group.parentGroup;
}
}
*parentGroupsIncludingSelf(): Generator<Group> {
yield this;
yield* this.parentGroups();
}
*allGroupsMatching(filter: Filter): Generator<Group> {
for (const group of this.items) {
if (group.matches(filter)) {
yield group;
yield* group.allGroupsMatching(filter);
}
}
}
*ownEntriesMatching(filter: Filter): Generator<Entry> {
for (const entry of this.entries) {
if (entry.matches(filter)) {
yield entry;
}
}
}
matches(filter: Filter): boolean {
return (
((filter && filter.includeDisabled) ||
(this.group.enableSearching !== false &&
!this.group.uuid.equals(this.file.db.meta.entryTemplatesGroup))) &&
(!filter || !filter.autoType || this.group.enableAutoType !== false)
);
}
addEntry(entry: Entry): void {
this.entries.push(entry);
}
addGroup(group: Group): void {
this.items.push(group);
}
setName(name: string): void {
this.groupModified();
this.group.name = name;
this.fillByGroup();
}
setIcon(iconId: number): void {
this.groupModified();
this.group.icon = iconId;
this.group.customIcon = undefined;
this.fillByGroup();
}
setCustomIcon(customIconId: string): void {
this.groupModified();
this.group.customIcon = new kdbxweb.KdbxUuid(customIconId);
this.fillByGroup();
}
setExpanded(expanded: boolean): void {
// this._groupModified(); // it's not good to mark the file as modified when a group is collapsed
this.group.expanded = expanded;
this.expanded = expanded;
}
setEnableSearching(enabled: boolean | null): void {
this.groupModified();
let parentEnableSearching = true;
for (const parentGroup of this.parentGroups()) {
if (typeof parentGroup.enableSearching === 'boolean') {
parentEnableSearching = parentGroup.enableSearching;
break;
}
}
if (enabled === parentEnableSearching) {
enabled = null;
}
this.group.enableSearching = enabled;
this.enableSearching = this.group.enableSearching;
}
getEffectiveEnableSearching(): boolean {
for (const grp of this.parentGroupsIncludingSelf()) {
if (typeof grp.enableSearching === 'boolean') {
return grp.enableSearching;
}
}
return true;
}
setEnableAutoType(enabled: boolean | null): void {
this.groupModified();
let parentEnableAutoType = true;
for (const parentGroup of this.parentGroups()) {
if (typeof parentGroup.enableAutoType === 'boolean') {
parentEnableAutoType = parentGroup.enableAutoType;
break;
}
}
if (enabled === parentEnableAutoType) {
enabled = null;
}
this.group.enableAutoType = enabled;
this.enableAutoType = this.group.enableAutoType;
}
getEffectiveEnableAutoType(): boolean {
for (const grp of this.parentGroupsIncludingSelf()) {
if (typeof grp.enableAutoType === 'boolean') {
return grp.enableAutoType;
}
}
return true;
}
setAutoTypeSeq(seq: string | undefined): void {
this.groupModified();
this.group.defaultAutoTypeSeq = seq || undefined;
this.autoTypeSeq = this.group.defaultAutoTypeSeq;
}
getEffectiveAutoTypeSeq(): string {
for (const grp of this.parentGroupsIncludingSelf()) {
if (grp.autoTypeSeq) {
return grp.autoTypeSeq;
}
}
return DefaultAutoTypeSequence;
}
getParentEffectiveAutoTypeSeq(): string {
return this.parentGroup
? this.parentGroup.getEffectiveAutoTypeSeq()
: DefaultAutoTypeSequence;
}
isEntryTemplatesGroup(): boolean {
return this.group.uuid.equals(this.file.db.meta.entryTemplatesGroup);
}
moveToTrash(): void {
this.file.setModified();
this.file.db.remove(this.group);
if (this.group.uuid.equals(this.file.db.meta.entryTemplatesGroup)) {
this.file.db.meta.entryTemplatesGroup = undefined;
}
this.file.reload();
}
deleteFromTrash(): void {
this.file.db.move(this.group, null);
this.file.reload();
}
removeWithoutHistory(): void {
if (!this.parentGroup) {
return;
}
const ix = this.parentGroup.group.groups.indexOf(this.group);
if (ix >= 0) {
this.parentGroup.group.groups.splice(ix, 1);
}
this.file.reload();
}
moveHere(object: Group | Entry): void {
if (!object || object.id === this.id) {
return;
}
if (object.file === this.file) {
this.file.setModified();
if (object instanceof Group) {
for (const parent of this.parentGroupsIncludingSelf()) {
if (object === parent) {
return;
}
}
if (this.group.groups.indexOf(object.group) >= 0) {
return;
}
this.file.db.move(object.group, this.group);
this.file.reload();
} else {
if (this.group.entries.indexOf(object.entry) >= 0) {
return;
}
this.file.db.move(object.entry, this.group);
this.file.reload();
}
} else if (object instanceof Entry) {
this.file.setModified();
const detachedEntry = object.detach();
this.file.db.importEntry(detachedEntry, this.group, object.file.db);
this.file.reload();
} else {
// moving groups between files is not supported for now
}
}
moveToTop(object: Group): void {
if (!object || object.id === this.id || object.file !== this.file || !this.parentGroup) {
return;
}
this.file.setModified();
for (const parent of this.parentGroupsIncludingSelf()) {
if (object === parent) {
return;
}
}
let atIndex = this.parentGroup.group.groups.indexOf(this.group);
const selfIndex = this.parentGroup.group.groups.indexOf(object.group);
if (selfIndex >= 0 && selfIndex < atIndex) {
atIndex--;
}
if (atIndex >= 0) {
this.file.db.move(object.group, this.parentGroup.group, atIndex);
}
this.file.reload();
}
static newGroup(parentGroup: Group, file: File): Group {
const grp = file.db.createGroup(parentGroup.group, '');
const model = new Group(grp, file, parentGroup);
model.group.times.update();
model.isJustCreated = true;
parentGroup.addGroup(model);
file.setModified();
file.reload();
return model;
}
}
export { Group };

View File

@ -31,7 +31,7 @@ class MenuItem extends Model {
// file: null; // TODO(ts): files in the menu
section?: string;
constructor(values: InitWithFieldsOf<MenuItem>) {
constructor(values?: InitWithFieldsOf<MenuItem>) {
super();
Object.assign(this, values);

View File

@ -211,7 +211,7 @@ class Otp {
return !!Otp.fromBase32(str);
}
static makeUrl(secret: string, period: number, digits: number): string {
static makeUrl(secret: string, period?: number, digits?: number): string {
const periodParam = period ? `&period=${period}` : '';
const digitsParam = digits ? `&digits=${digits}` : '';
return `otpauth://totp/default?secret=${secret}${periodParam}${digitsParam}`;

14
package-lock.json generated
View File

@ -77,7 +77,7 @@
"jsdom": "^16.5.3",
"json-loader": "^0.5.7",
"jsqrcode": "github:antelle/jsqrcode#0.1.3",
"kdbxweb": "^2.0.4",
"kdbxweb": "^2.0.5",
"load-grunt-tasks": "5.1.0",
"lodash": "^4.17.21",
"marked": "^2.0.3",
@ -13170,9 +13170,9 @@
}
},
"node_modules/kdbxweb": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/kdbxweb/-/kdbxweb-2.0.4.tgz",
"integrity": "sha512-Ckb3shx9E+VX6WZSvT6kJJknTA10T21M4HP8Z/xanZ65mwyQf7Mv0UreK/NUsa9BaejWEiB/QJxkTzrQZRn9Lg==",
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/kdbxweb/-/kdbxweb-2.0.5.tgz",
"integrity": "sha512-UWFkMOFaBUNo3vceJnCu/icNZTjEeDdqtFxAUuHI/uUBhbJZztWPozjfV/zVUBdfF3JFkS+cVNWQfgPV8EFXtQ==",
"dependencies": {
"pako": "github:keeweb/pako#653c0b00d8941c89d09ed4546d2179001ec44efc",
"xmldom": "github:keeweb/xmldom#ec8f61f723e2f403adaf7a1bbf55ced4ff1ea0c6"
@ -33231,9 +33231,9 @@
}
},
"kdbxweb": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/kdbxweb/-/kdbxweb-2.0.4.tgz",
"integrity": "sha512-Ckb3shx9E+VX6WZSvT6kJJknTA10T21M4HP8Z/xanZ65mwyQf7Mv0UreK/NUsa9BaejWEiB/QJxkTzrQZRn9Lg==",
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/kdbxweb/-/kdbxweb-2.0.5.tgz",
"integrity": "sha512-UWFkMOFaBUNo3vceJnCu/icNZTjEeDdqtFxAUuHI/uUBhbJZztWPozjfV/zVUBdfF3JFkS+cVNWQfgPV8EFXtQ==",
"requires": {
"pako": "github:keeweb/pako#653c0b00d8941c89d09ed4546d2179001ec44efc",
"xmldom": "github:keeweb/xmldom#ec8f61f723e2f403adaf7a1bbf55ced4ff1ea0c6"

View File

@ -79,7 +79,7 @@
"jsdom": "^16.5.3",
"json-loader": "^0.5.7",
"jsqrcode": "github:antelle/jsqrcode#0.1.3",
"kdbxweb": "^2.0.4",
"kdbxweb": "^2.0.5",
"load-grunt-tasks": "5.1.0",
"lodash": "^4.17.21",
"marked": "^2.0.3",