import Backbone from 'backbone'; import kdbxweb from 'kdbxweb'; import demoFileData from 'demo.kdbx'; import { GroupCollection } from 'collections/group-collection'; import { KdbxToHtml } from 'comp/format/kdbx-to-html'; import { GroupModel } from 'models/group-model'; import { IconUrlFormat } from 'util/formatting/icon-url-format'; import { Logger } from 'util/logger'; const logger = new Logger('file'); const FileModel = Backbone.Model.extend({ defaults: { id: '', uuid: '', name: '', keyFileName: '', passwordLength: 0, path: '', opts: null, storage: null, modified: false, dirty: false, open: false, created: false, demo: false, groups: null, oldPasswordLength: 0, oldKeyFileName: '', passwordChanged: false, keyFileChanged: false, keyChangeForce: -1, syncing: false, syncError: null, syncDate: null, backup: null }, db: null, entryMap: null, groupMap: null, initialize() { this.entryMap = {}; this.groupMap = {}; }, open(password, fileData, keyFileData, callback) { try { const credentials = new kdbxweb.Credentials(password, keyFileData); const ts = logger.ts(); kdbxweb.Kdbx.load(fileData, credentials) .then(db => { this.db = db; this.readModel(); this.setOpenFile({ passwordLength: password ? password.textLength : 0 }); if (keyFileData) { kdbxweb.ByteUtils.zeroBuffer(keyFileData); } logger.info( 'Opened file ' + this.get('name') + ': ' + logger.ts(ts) + ', ' + this.kdfArgsToString(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) { const password = kdbxweb.ProtectedValue.fromString(''); const credentials = new kdbxweb.Credentials(password); this.db = kdbxweb.Kdbx.create(credentials, name); this.set('name', name); this.readModel(); this.set({ open: true, created: true, name }); }, 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; this.readModel(); this.set({ open: true, created: true }); logger.info('Imported file ' + this.get('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; this.set('name', 'Demo'); this.readModel(); this.setOpenFile({ passwordLength: 4, demo: true }); callback(); }); }, setOpenFile(props) { _.extend(props, { open: true, oldKeyFileName: this.get('keyFileName'), oldPasswordLength: props.passwordLength, passwordChanged: false, keyFileChanged: false }); this.set(props); 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.add(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.Argon2: return 'Argon2'; 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.Argon2: 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.trigger('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.get('modified')) { try { if (remoteKey && remoteDb.meta.keyChanged > this.db.meta.keyChanged) { this.db.credentials = remoteDb.credentials; this.set('keyFileName', remoteKey.keyFileName || ''); if (remoteKey.password) { this.set('passwordLength', remoteKey.password.textLength); } } this.db.merge(remoteDb); } catch (e) { logger.error('File merge error', e); return callback(e); } } else { this.db = remoteDb; } this.set('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, open: false, created: false, groups: null, passwordChanged: false, keyFileChanged: false, syncing: false }); }, 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.get('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.get('groups').first(); const templatesGroup = GroupModel.newGroup(rootGroup, this); templatesGroup.setName('Templates'); this.db.meta.entryTemplatesGroup = templatesGroup.group.uuid; this.reload(); return templatesGroup; }, setModified() { if (!this.get('demo')) { this.set({ modified: true, dirty: true }); Backbone.trigger('file-modified'); } }, 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.get('name'), err); cb(undefined, err); }); }, getXml(cb) { this.db.saveXml(true).then(xml => { cb(xml); }); }, getHtml(cb) { cb( KdbxToHtml.convert(this.db, { name: this.get('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, savedToCache) { if (!error) { this.db.removeLocalEditState(); } const modified = this.get('modified') && !!error; const dirty = this.get('dirty') && !savedToCache; this.set({ created: false, path: path || this.get('path'), storage: storage || this.get('storage'), modified, dirty, syncing: false, syncError: error }); const shouldResetFingerprint = this.get('passwordChanged') && this.has('fingerprint'); if (shouldResetFingerprint && !error) { this.set({ fingerprint: null }); } if (!this.get('open')) { return; } this.setOpenFile({ passwordLength: this.get('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.get('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() { const keyFile = kdbxweb.Credentials.createRandomKeyFile(); 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.get('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 }); Backbone.trigger('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; }, setKeyChange(force, days) { if (isNaN(days) || !days || days < 0) { days = -1; } const prop = force ? 'keyChangeForce' : 'keyChangeRec'; this.db.meta[prop] = days; this.set(prop, days); this.setModified(); }, setName(name) { this.db.meta.name = name; this.db.meta.nameChanged = new Date(); this.set('name', name); this.get('groups') .first() .setName(name); this.setModified(); this.reload(); }, setDefaultUser(defaultUser) { this.db.meta.defaultUser = defaultUser; this.db.meta.defaultUserChanged = new Date(); this.set('defaultUser', defaultUser); this.setModified(); }, setRecycleBinEnabled(enabled) { enabled = !!enabled; this.db.meta.recycleBinEnabled = enabled; if (enabled) { this.db.createRecycleBin(); } this.set('setRecycleBinEnabled', enabled); this.setModified(); }, setHistoryMaxItems(count) { this.db.meta.historyMaxItems = count; this.set('historyMaxItems', count); this.setModified(); }, setHistoryMaxSize(size) { this.db.meta.historyMaxSize = size; this.set('historyMaxSize', size); this.setModified(); }, setKeyEncryptionRounds(rounds) { this.db.header.keyEncryptionRounds = rounds; this.set('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.set('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.get('items').reset(); trashGroup.get('entries').reset(); if (modified) { this.setModified(); } } }, getCustomIcons() { return _.mapObject(this.db.meta.customIcons, customIcon => IconUrlFormat.toDataUrl(customIcon) ); }, addCustomIcon(iconData) { const uuid = kdbxweb.KdbxUuid.random(); this.db.meta.customIcons[uuid] = kdbxweb.ByteUtils.arrayToBuffer( kdbxweb.ByteUtils.base64ToBytes(iconData) ); 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 'Argon2': this.db.setKdf(kdbxweb.Consts.KdfId.Argon2); break; default: throw new Error('Bad KDF name'); } this.setModified(); this.readModel(); } }); FileModel.createKeyFileWithHash = function(hash) { return kdbxweb.Credentials.createKeyFileWithHash(hash); }; export { FileModel };