1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 | fs = require 'fs'
|
13 | Crypto = require './crypto'
|
14 | Opdata = require './opdata'
|
15 | Item = require './item'
|
16 |
|
17 |
|
18 |
|
19 | BAND_PREFIX = 'ld('
|
20 | BAND_SUFFIX = ');'
|
21 | PROFILE_PREFIX = 'var profile='
|
22 | PROFILE_SUFFIX = ';'
|
23 |
|
24 |
|
25 |
|
26 |
|
27 |
|
28 |
|
29 | class Keychain
|
30 |
|
31 |
|
32 | |
33 |
|
34 |
|
35 |
|
36 |
|
37 |
|
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 |
|
81 |
|
82 |
|
83 | @createItem: Item.create
|
84 |
|
85 |
|
86 | |
87 |
|
88 |
|
89 |
|
90 |
|
91 | constructor: (attrs) ->
|
92 | @AUTOLOCK_LENGTH = 1 * 60 * 1000
|
93 | @profileName = 'default'
|
94 | @_events = {}
|
95 | @items = {}
|
96 | if attrs then @loadAttrs(attrs)
|
97 |
|
98 |
|
99 | |
100 |
|
101 |
|
102 |
|
103 |
|
104 | loadAttrs: (attrs) ->
|
105 | for key, attr of attrs
|
106 | @[key] = attr
|
107 | return this
|
108 |
|
109 |
|
110 | |
111 |
|
112 |
|
113 |
|
114 |
|
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 |
|
126 |
|
127 |
|
128 |
|
129 |
|
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 |
|
145 |
|
146 |
|
147 |
|
148 |
|
149 |
|
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 |
|
167 |
|
168 |
|
169 |
|
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 |
|
181 |
|
182 |
|
183 | one: (event, id, fn) ->
|
184 | @on(event, id, fn, true)
|
185 |
|
186 |
|
187 | |
188 |
|
189 |
|
190 |
|
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 |
|
225 |
|
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 |
|
252 |
|
253 |
|
254 | loadFolders: (filepath) ->
|
255 |
|
256 |
|
257 |
|
258 | |
259 |
|
260 |
|
261 |
|
262 | loadBands: (bands) ->
|
263 |
|
264 | for filepath in bands
|
265 |
|
266 |
|
267 | band = fs.readFileSync(filepath).toString('utf8')
|
268 | band = band[BAND_PREFIX.length...-BAND_SUFFIX.length]
|
269 | band = JSON.parse(band)
|
270 |
|
271 |
|
272 | @addItem(item) for uuid, item of band
|
273 |
|
274 | return this
|
275 |
|
276 |
|
277 | |
278 |
|
279 |
|
280 |
|
281 | loadAttachment: (attachments) ->
|
282 |
|
283 |
|
284 |
|
285 |
|
286 | |
287 |
|
288 |
|
289 |
|
290 |
|
291 |
|
292 |
|
293 |
|
294 |
|
295 |
|
296 |
|
297 |
|
298 | unlock: (password) ->
|
299 |
|
300 |
|
301 | profileKey = @_deriveKeys(password)
|
302 |
|
303 |
|
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 |
|
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 |
|
331 |
|
332 |
|
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 |
|
345 |
|
346 |
|
347 |
|
348 |
|
349 | rescheduleAutoLock: ->
|
350 | @autoLockTime = Date.now() + @AUTOLOCK_LENGTH
|
351 |
|
352 |
|
353 | |
354 |
|
355 |
|
356 |
|
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 |
|
369 |
|
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 |
|
380 |
|
381 |
|
382 |
|
383 | decryptItem: (uuid) ->
|
384 | item = @getItem(uuid)
|
385 | item.decryptDetails(@master)
|
386 |
|
387 |
|
388 | |
389 |
|
390 |
|
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 |
|
409 |
|
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 |
|
433 |
|
434 |
|
435 |
|
436 | getItem: (uuid) ->
|
437 | return @items[uuid]
|
438 |
|
439 |
|
440 | |
441 |
|
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 |
|
451 |
|
452 |
|
453 |
|
454 | eachItem: (fn) ->
|
455 | for uuid, item of @items
|
456 | fn(item)
|
457 |
|
458 |
|
459 | module.exports = Keychain
|