1password
Version:
Work With 1Password Keychains
453 lines (357 loc) • 11.5 kB
JavaScript
'use strict';
/**
* Read and write 1Password 4 Cloud Keychain files. Based on the documentation
* at http://learn.agilebits.com/1Password4/Security/keychain-design.html
* and https://github.com/Roguelazer/onepasswordpy
*/
// Dependencies
var fs = require('fs');
var _ = require('lodash');
var EventEmitter = require('events').EventEmitter;
var Crypto = require('./crypto');
var Opdata = require('./opdata');
var Item = require('./item');
// Constants
var BAND_PREFIX = 'ld(';
var BAND_SUFFIX = ');';
var PROFILE_PREFIX = 'var profile=';
var PROFILE_SUFFIX = ';';
/**
* Constructs a new Keychain
* - [attrs] {object} : Load items
*/
function Keychain (attrs) {
this.profileName = 'default';
this.items = {};
this.unlocked = false;
this.event = new EventEmitter();
if (attrs) {
_.assign(this, attrs);
}
}
/**
* Create a new keychain
* - password {string} : The master password for the keychain.
* - [options] {object} : Extra options for the keychain, such as the hint
* and number of iterations
* > Keychain - a new Keychain object
*/
Keychain.create = function (password, options) {
var currentTime = Math.floor(Date.now() / 1000);
var defaults = {
uuid: Crypto.generateUuid(),
salt: Crypto.randomBytes(16),
createdAt: currentTime,
updatedAt: currentTime,
iterations: 10000,
profileName: 'default',
passwordHint: '',
lastUpdatedBy: 'Dropbox'
};
// Merge user specified settings with default options
_.defaults(options, (defaults || {}));
var keychain = new Keychain(options);
var raw = {
master: Crypto.randomBytes(256),
overview: Crypto.randomBytes(64)
};
var superKey = keychain._deriveKeys(password);
keychain.encrypted = {
masterKey: superKey.encrypt('profileKey', raw.master),
overviewKey: superKey.encrypt('profileKey', raw.overview)
};
var keys = {
master: Crypto.hash(raw.master, 512),
overview: Crypto.hash(raw.overview, 512)
};
keychain.master = new Opdata(
keys.master.slice(0, 32), // master encryption key
keys.master.slice(32) // master hmac key
);
keychain.overview = new Opdata(
keys.overview.slice(0, 32), // overview encryption key
keys.overview.slice(32) // overview hmac key
);
return keychain;
};
/**
* Derive the 'super' keys from password using PBKDF2
* - {string} password The master password.
* > Opdata - the derived keys.
*/
Keychain.prototype._deriveKeys = function (password) {
var keys = Crypto.pbkdf2(password, this.salt, this.iterations);
return new Opdata(
keys.slice(0, 32), // encryption key
keys.slice(32) // hmac key
);
};
/*
* Load data from a .cloudKeychain folder
* - filepath {string} : The filepath of the .cloudKeychain file
* - [callback] {function} : Called when the keychain has loaded
* ! if profile.js can't be found
* > this
*/
Keychain.prototype.load = function (keychainPath, callback) {
if (callback == null) { callback = _.noop; }
this.keychainPath = keychainPath;
this.profileFolder = this.keychainPath + '/' + this.profileName;
var self = this;
fs.readdir(this.profileFolder, function (err, folderContents) {
if (err != null) { callback(err); }
var profile = null;
var folders = null;
var bands = [];
var attachments = [];
for (var i = 0, len = folderContents.length; i < len; i += 1) {
var filename = folderContents[i];
if (filename === 'profile.js') {
profile = self.profileFolder + '/profile.js';
} else if (filename === 'folders.js') {
folders = self.profileFolder + '/folders.js';
} else if (filename.match(/^band_[0-9A-F]\.js$/)) {
bands.push(self.profileFolder + '/' + filename);
} else if (filename.match(/^[0-9A-F]{32}_[0-9A-F]{32}\.attachment$/)) {
attachments.push(filename);
}
}
if (profile != null) {
self.loadProfile(profile);
} else {
callback(new Error('Couldn\'t find profile.js'));
}
if (folders != null) { self.loadFolders(folders); }
if (bands.length > 0) { self.loadBands(bands); }
if (attachments.length > 0) { self.loadAttachment(attachments); }
callback(null);
});
return this;
};
/**
* Load data from profile.js into keychain.
* - filepath {string} : The path to the profile.js file.
* - [rawData=false] {boolean} : If set to true, 'filepath' will be
* considered the actual profile data to load from.
* > this
*/
Keychain.prototype.loadProfile = function (filepath, rawData) {
var data = rawData ? filepath : fs.readFileSync(filepath).toString();
var json = data.slice(PROFILE_PREFIX.length, -PROFILE_SUFFIX.length);
var profile = JSON.parse(json);
_.assign(this, {
uuid: profile.uuid,
salt: Crypto.fromBase64(profile.salt),
createdAt: profile.createdAt,
updatedAt: profile.updatedAt,
iterations: profile.iterations,
profileName: profile.profileName,
passwordHint: profile.passwordHint,
lastUpdatedBy: profile.lastUpdatedBy
});
this.encrypted = {
masterKey: Crypto.fromBase64(profile.masterKey),
overviewKey: Crypto.fromBase64(profile.overviewKey)
};
return this;
};
/**
* Load folders
* - filepath {string} : The path to the folders.js file.
*/
Keychain.prototype.loadFolders = function (filepath) {
// TODO: Implement folders ...
};
/**
* This loads the item data from a band file into the keychain.
* - bands {array} : An array of filepaths to each band file
* > this
*/
Keychain.prototype.loadBands = function (bands) {
for (var i = 0, len = bands.length; i < len; i += 1) {
var filepath = bands[i];
// Load file
var band = fs.readFileSync(filepath).toString('utf8');
band = band.slice(BAND_PREFIX.length, -BAND_SUFFIX.length);
band = JSON.parse(band);
// Add items
for (var uuid in band) {
this.addItem(band[uuid]);
}
}
return this;
};
/**
* Load attachments
* - attachments {Array} : An array of filepaths to each attachment file
*/
Keychain.prototype.loadAttachment = function (attachments) {
// TODO: Implement attachments ...
};
/**
* Change the keychain master password. Since the derived keys and raw key
* data aren't stored, the current password must be supplied to decrypt this
* data again. Though slower, this is more secure than keeping this data in
* memory.
* - currentPassword {string} : The current master password.
* - newPassword {string} : The password to change to.
* > this
*/
Keychain.prototype.changePassword = function (currentPassword, newPassword) {
var currentKey = this._deriveKeys(currentPassword);
var masterKey = currentKey.decrypt('buffer', this.encrypted.masterKey);
var overviewKey = currentKey.decrypt('buffer', this.encrypted.overviewKey);
var newKey = this._deriveKeys(newPassword);
this.encrypted.masterKey = newKey.encrypt('profileKey', masterKey);
this.encrypted.overviewKey = newKey.encrypt('profileKey', overviewKey);
return this;
};
/**
* Runs the master password through PBKDF2 to derive the super keys, and then
* decrypt the masterKey and overviewKey. The master password and super keys
* are then forgotten as they are no longer needed and keeping them in memory
* will only be a security risk.
* Use @unlocked to check if it was the right password.
* - password {string} : The master password to unlock the keychain with.
* > this
*/
Keychain.prototype.unlock = function (password) {
if (this.unlocked === true) {
console.log('Keychain already unlocked...');
return this;
}
// Derive keys
var profileKey = this._deriveKeys(password);
// Decrypt master key
var master = profileKey.decrypt('profileKey', this.encrypted.masterKey);
if (! master.length) {
console.error('Could not decrypt master key');
this.unlocked = false;
return this;
}
// Decrypt overview key
var overview = profileKey.decrypt('profileKey', this.encrypted.overviewKey);
if (! overview.length) {
console.error('Could not decrypt overview key');
this.unlocked = false;
return this;
}
// Store keys
this.master = new Opdata(master[0], master[1]);
this.overview = new Opdata(overview[0], overview[1]);
// Decrypt each item
_.forIn(this.items, function (item) {
return item.unlock('overview');
});
// Unlock has been successful
this.unlocked = true;
this.event.emit('unlock');
return this;
};
/**
* Lock the keychain. This discards all currently decrypted keys, overview
* data and any decrypted item details.
* > this
*/
Keychain.prototype.lock = function () {
delete this["super"];
delete this.master;
delete this.overview;
_.forIn(this.items, function (item) {
item.lock('all');
});
this.unlocked = false;
return this;
};
/**
* Expose Item.create so you only have to include this one file
* - data {Object} : Item data.
* > object - An item instance.
*/
Keychain.prototype.createItem = function (data) {
return Item.create(this, data);
};
/**
* Add an item to the keychain
* - item {Object} : The item to add to the keychain
* > this
*/
Keychain.prototype.addItem = function (item) {
if (!(item instanceof Item)) {
item = new Item(this).load(item);
}
this.items[item.uuid] = item;
return this;
};
/**
* This returns an item with the matching UUID
* - uuid {string} : The UUID to find the Item of
* > item
*/
Keychain.prototype.getItem = function (uuid) {
return this.items[uuid];
};
/**
* Search through all items, does not include deleted items
* - query {string} - the search query
* > array - items that match the query
*/
Keychain.prototype.findItems = function (query) {
var items = [];
for (var uuid in this.items) {
var item = this.items[uuid];
if ((! item.trashed) && item.match(query) !== null) {
items.push(item);
}
}
return items;
};
/**
* Generate the profile.js file
* > string - the profile.js file contents as json
*/
Keychain.prototype.exportProfile = function () {
var data = {
lastUpdatedBy: this.lastUpdatedBy,
updatedAt: this.updatedAt,
profileName: this.profileName,
salt: this.salt.toString('base64'),
passwordHint: this.passwordHint,
masterKey: this.encrypted.masterKey.toString('base64'),
iterations: this.iterations,
uuid: this.uuid,
overviewKey: this.encrypted.overviewKey.toString('base64'),
createdAt: this.createdAt
};
return PROFILE_PREFIX + JSON.stringify(data) + PROFILE_SUFFIX;
};
/**
* This exports all the items currently in the keychain into band files.
* > object - the band files as { filename: contents }
*/
Keychain.prototype.exportBands = function () {
var bands = {};
var id;
// Sort items into groups based on the first char of its UUID
for (var uuid in this.items) {
id = uuid.slice(0, 1);
if (! bands.hasOwnProperty(id)) {
bands[id] = [];
}
bands[id].push(this.items[uuid]);
}
var files = {};
// generate band files and filenames
for (id in bands) {
var items = bands[id];
var data = {};
for (var i = 0, len = items.length; i < len; i += 1) {
var item = items[i];
data[item.uuid] = item.toJSON();
}
data = BAND_PREFIX + JSON.stringify(data, null, 2) + BAND_SUFFIX;
files["band_" + id + ".js"] = data;
}
return files;
};
module.exports = Keychain;