UNPKG

12.3 kBtext/coffeescriptView Raw
1###*
2 * @fileoverview Read and write 1Password 4 Cloud Keychain files. Based on the
3 * documentation at {@link http://learn.agilebits.com/1Password4/Security/keychain-design.html Agile Bits}
4 * and {@link https://github.com/Roguelazer/onepasswordpy OnePasswordPy}
5 *
6 * @author George Czabania
7 * @version 0.1
8###
9
10
11# Load modules
12fs = require 'fs'
13Crypto = require './crypto'
14Opdata = require './opdata'
15Item = require './item'
16
17
18# Constants
19BAND_PREFIX = 'ld('
20BAND_SUFFIX = ');'
21PROFILE_PREFIX = 'var profile='
22PROFILE_SUFFIX = ';'
23
24
25###*
26 * @class The Keychain stores all the items and profile data.
27###
28
29class Keychain
30
31
32 ###*
33 * Create a new keychain
34 * @param {String} password The master password for the keychain.
35 * @param {String} [hint] The master password hint.
36 * @param {String} [profileName=default] The name of the keychain profile.
37 * @return {Keychain} Returns a new Keychain object
38 ###
39
40 @create: (password, hint, profileName='default') ->
41
42 timeNow = Math.floor Date.now() / 1000
43
44 keychain = new Keychain
45 uuid: Crypto.generateUuid()
46 salt: Crypto.randomBytes(16)
47 createdAt: timeNow
48 updatedAt: timeNow
49 iterations: 20000
50 profileName: profileName
51 passwordHint: hint ? ''
52 lastUpdatedBy: 'Dropbox'
53
54 raw =
55 master: Crypto.randomBytes(256)
56 overview: Crypto.randomBytes(64)
57
58 keys =
59 master: Crypto.hash(raw.master, 512)
60 overview: Crypto.hash(raw.overview, 512)
61
62 superKey = keychain._deriveKeys(password)
63
64 keychain.encrypted =
65 masterKey: superKey.encrypt('profileKey', raw.master)
66 overviewKey: superKey.encrypt('profileKey', raw.overview)
67
68 keychain.master =
69 encryption: Crypto.toBuffer keys.master[0...64]
70 hmac: Crypto.toBuffer keys.master[64..]
71
72 keychain.overview =
73 encryption: Crypto.toBuffer keys.overview[0...64]
74 hmac: Crypto.toBuffer keys.overview[64..]
75
76 return keychain
77
78
79 ###*
80 * Expose Item.create so you only have to include this one file
81 * @type {Function}
82 ###
83 @createItem: Item.create
84
85
86 ###*
87 * Constructs a new Keychain
88 * @constructor
89 * @param {Object} [items={}] Load items
90 ###
91 constructor: (attrs) ->
92 @AUTOLOCK_LENGTH = 1 * 60 * 1000 # 1 minute
93 @profileName = 'default'
94 @_events = {}
95 @items = {}
96 if attrs then @loadAttrs(attrs)
97
98
99 ###*
100 * Easy way to load data into a keychain
101 * @param {Object} attrs The attributes you want to load
102 * @return {this}
103 ###
104 loadAttrs: (attrs) ->
105 for key, attr of attrs
106 @[key] = attr
107 return this
108
109
110 ###*
111 * Derive super keys from password using PBKDF2
112 * @private
113 * @param {String} password The master password.
114 * @return {Opdata} The derived keys as an opdata object.
115 ###
116 _deriveKeys: (password) ->
117 keys = Crypto.pbkdf2(password, @salt, @iterations)
118 @super =
119 encryption: Crypto.toBuffer(keys[0...64])
120 hmac: Crypto.toBuffer(keys[64..])
121 return new Opdata(@super.encryption, @super.hmac)
122
123
124 ###*
125 * Trigger an event.
126 * @private
127 * @param {String} event The event name
128 * @param {Splat} [args] Any optional arguments you want to send with the
129 * event.
130 ###
131 _trigger: (event, args...) ->
132 return unless event of @_events
133 for id, fn of @_events[event]
134 continue unless typeof(fn) is 'function'
135 if id[0..2] is "__"
136 fnArgs = args.slice(0)
137 fnArgs.unshift(id)
138 fn(fnArgs...)
139 else
140 fn(args...)
141
142
143 ###*
144 * Listen for an event, and run a function when it is triggered.
145 * @param {String} event The event name
146 * @param {String} [id] The id of the listener
147 * @param {Function} fn The function to run when the event is triggered
148 * @param {Boolean} [once] Run once, and then remove the listener
149 * @return {String} The event id
150 ###
151 on: (event, id, fn, once) ->
152 @_events[event] ?= {index: 0}
153 if typeof(id) is 'function'
154 fn = id
155 id = "__" + ++@_events[event].index
156 if once
157 @_events[event][id] = (args...) =>
158 fn(args...)
159 @off(event, id)
160 else
161 @_events[event][id] = fn
162 return id
163
164
165 ###*
166 * Unbind an event listener
167 * @param {String} event The event name
168 * @param {String} [id] The id of the event. If left blank, then all events
169 * will be removed
170 ###
171 off: (event, id) ->
172 if id?
173 delete @_events[event][id]
174 else
175 for id of @_events[event]
176 delete @_events[event][id]
177
178
179 ###*
180 * Listen to an event, but only fire the listener once
181 * @see this.on()
182 ###
183 one: (event, id, fn) ->
184 @on(event, id, fn, true)
185
186
187 ###*
188 * Load data from a .cloudKeychain folder
189 * @param {String} filepath The filepath of the .cloudKeychain file
190 * @throws {Error} If profile.js can't be found
191 ###
192 load: (@keychainPath) ->
193
194 @profileFolder = "#{@keychainPath}/#{@profileName}"
195 folderContents = fs.readdirSync(@profileFolder)
196 profile = null
197 folder = null
198 bands = []
199 attachments = []
200
201 for filename in folderContents
202 if filename is "profile.js"
203 profile = "#{@profileFolder}/profile.js"
204 else if filename is "folders.js"
205 folders = "#{@profileFolder}/folders.js"
206 else if filename.match(/^band_[0-9A-F]\.js$/)
207 bands.push("#{@profileFolder}/#{filename}")
208 else if filename.match(/^[0-9A-F]{32}_[0-9A-F]{32}\.attachment$/)
209 attachments.push(filename)
210
211 if profile?
212 @loadProfile(profile)
213 else
214 throw new Error 'Couldn\'t find profile.js'
215
216 if folders? then @loadFolders(folders)
217 if bands.length > 0 then @loadBands(bands)
218 if attachments.length > 0 then @loadAttachment(attachments)
219
220 return this
221
222
223 ###*
224 * Load data from profile.js into keychain.
225 * @param {String} filepath The path to the profile.js file.
226 ###
227 loadProfile: (filepath) ->
228
229 profile = fs.readFileSync(filepath).toString()
230 profile = profile[PROFILE_PREFIX.length...-PROFILE_SUFFIX.length]
231 profile = JSON.parse(profile)
232
233 @loadAttrs
234 uuid: profile.uuid
235 salt: Crypto.fromBase64(profile.salt)
236 createdAt: profile.createdAt
237 updatedAt: profile.updatedAt
238 iterations: profile.iterations
239 profileName: profile.profileName
240 passwordHint: profile.passwordHint
241 lastUpdatedBy: profile.lastUpdatedBy
242
243 @encrypted =
244 masterKey: Crypto.fromBase64(profile.masterKey)
245 overviewKey: Crypto.fromBase64(profile.overviewKey)
246
247 return this
248
249
250 ###*
251 * Load folders
252 * @param {String} filepath The path to the folders.js file.
253 ###
254 loadFolders: (filepath) ->
255 # TODO: Implements folders ...
256
257
258 ###*
259 * This loads the item data from a band file into the keychain.
260 * @param {Array} bands An array of filepaths to each band file
261 ###
262 loadBands: (bands) ->
263
264 for filepath in bands
265
266 # Load file
267 band = fs.readFileSync(filepath).toString('utf8')
268 band = band[BAND_PREFIX.length...-BAND_SUFFIX.length]
269 band = JSON.parse(band)
270
271 # Add items
272 @addItem(item) for uuid, item of band
273
274 return this
275
276
277 ###*
278 * Load attachments
279 * @param {Array} attachments An array of filepaths to each attachment file
280 ###
281 loadAttachment: (attachments) ->
282
283 # TODO: Implement attachments ...
284
285
286 ###*
287 * Runs the master password through PBKDF2 to derive the super keys, and then
288 * decrypt the masterKey and overviewKey. The master password and super keys
289 * are then forgotten as they are no longer needed and keeping them in memory
290 * will only be a security risk.
291 *
292 * @param {String} password The master password to unlock the keychain
293 * with.
294 * @return {Boolean} Whether or not the keychain was unlocked successfully.
295 * Which is an easy way to see if the master password was
296 * correct.
297 ###
298 unlock: (password) ->
299
300 # Derive keys
301 profileKey = @_deriveKeys(password)
302
303 # Decrypt profile keys
304 master = profileKey.decrypt('profileKey', @encrypted.masterKey)
305 if not master.length
306 console.error "Could not decrypt master key"
307 return false
308
309 overview = profileKey.decrypt('profileKey', @encrypted.overviewKey)
310 if not overview.length
311 console.error "Could not decrypt overview key"
312 return false
313
314 @master = new Opdata(master[0], master[1])
315 @overview = new Opdata(overview[0], overview[1])
316
317 # Decrypt overview data
318 @eachItem (item) =>
319 item.decryptOverview(@overview)
320
321 @unlocked = true
322
323 @rescheduleAutoLock()
324 setTimeout (=> @_autolock()), 1000
325
326 return this
327
328
329 ###*
330 * Lock the keychain. This discards all currently decrypted keys, overview
331 * data and any decrypted item details.
332 * @param {Boolean} autolock Whether the keychain was locked automatically.
333 ###
334 lock: (autolock) ->
335 @_trigger 'lock', autolock
336 @super = undefined
337 @master = undefined
338 @overview = undefined
339 @items = {}
340 @unlocked = false
341
342
343 ###*
344 * Reschedule when the keychain is locked. Should be called only when the
345 * user performs an important action, such as unlocking the keychain,
346 * selecting an item or copying a password, so that it doesn't lock when
347 * they are using it.
348 ###
349 rescheduleAutoLock: ->
350 @autoLockTime = Date.now() + @AUTOLOCK_LENGTH
351
352
353 ###*
354 * This is run every second, to check to see if the timer has expired. If it
355 * has it then locks the keychain.
356 * @private
357 ###
358 _autolock: =>
359 return unless @unlocked
360 now = Date.now()
361 if now < @autoLockTime
362 setTimeout @_autolock, 1000
363 return
364 @lock(true)
365
366
367 ###*
368 * Add an item to the keychain
369 * @param {Object} item The item to add to the keychain
370 ###
371 addItem: (item) ->
372 if not (item instanceof Item)
373 item = new Item().load(item)
374 @items[item.uuid] = item
375 return this
376
377
378 ###*
379 * Decrypt an item's details. The details are not saved to the item.
380 * @param {String} uuid The item UUID
381 * @return {Object} The items details
382 ###
383 decryptItem: (uuid) ->
384 item = @getItem(uuid)
385 item.decryptDetails(@master)
386
387
388 ###*
389 * Generate the profile.js file
390 * @return {String} The profile.js file
391 ###
392 exportProfile: ->
393 data =
394 lastUpdatedBy: @lastUpdatedBy
395 updatedAt: @updatedAt
396 profileName: @profileName
397 salt: @salt.toString('base64')
398 passwordHint: @passwordHint
399 masterKey: @encrypted.masterKey.toString('base64')
400 iterations: @iterations
401 uuid: @uuid
402 overviewKey: @encrypted.overviewKey.toString('base64')
403 createdAt: @createdAt
404 PROFILE_PREFIX + JSON.stringify(data) + PROFILE_SUFFIX
405
406
407 ###*
408 * This exports all the items currently in the keychain into band files.
409 * @return {Object} The band files
410 ###
411 exportBands: ->
412
413 bands = {}
414
415 for uuid, item of @items
416 id = uuid[0...1]
417 bands[id] ?= []
418 bands[id].push(item)
419
420 files = {}
421 for id, items of bands
422 data = {}
423 for item in items
424 data[item.uuid] = item.toJSON()
425 data = BAND_PREFIX + JSON.stringify(data, null, 2) + BAND_SUFFIX
426 files["band_#{id}.js"] = data
427
428 return files
429
430
431 ###*
432 * This returns an item with the matching UUID
433 * @param {String} uuid The UUID to find the Item of
434 * @return {Item} The item matching the UUID
435 ###
436 getItem: (uuid) ->
437 return @items[uuid]
438
439
440 ###*
441 * Search through all items
442 ###
443 findItem: (query) ->
444 for uuid, item of @items
445 if item.match(query) is null then continue
446 item
447
448
449 ###*
450 * Loop through all the items in the keychain, and pass each one to a
451 * function.
452 * @param {Function} fn The function to pass each item to
453 ###
454 eachItem: (fn) ->
455 for uuid, item of @items
456 fn(item)
457
458
459module.exports = Keychain