tome 0.1.0.pre

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,113 @@
1
+ ## Tome
2
+
3
+ Tome is a lightweight password manager with a humane command-line interface. It is meant to be simple and secure.
4
+
5
+ *Disclaimer* I am not a security expert. I've only had limited formal training in security and cryptography.
6
+ Now that I've scared off all but the bravest, feel free to look [under the hood](#under-the-hood) or
7
+ at the security bits in [crypt.rb](https://github.com/schmich/tome/blob/master/lib/tome/crypt.rb).
8
+
9
+ ## Installation
10
+
11
+ * Requires [Ruby 1.9.3](http://www.ruby-lang.org/en/downloads/) or newer.
12
+ * *Coming soon* `gem install tome`
13
+
14
+ ## Usage
15
+
16
+ The first time you run `tome`, you'll be asked to create a master password for your encrypted password database.
17
+ Any operations involving your password database will require this master password.
18
+
19
+ > tome set linkedin.com
20
+ Creating tome database.
21
+ Master password:
22
+ Master password (verify):
23
+ Password:
24
+ Password (verify):
25
+ Created password for linkedin.com.
26
+
27
+ Recalling a password is easy:
28
+
29
+ > tome get linkedin.com
30
+ Master password:
31
+ p4ssw0rd
32
+
33
+ In fact, it's even simpler than that. `tome get` does substring pattern matching to recall a password,
34
+ so this works, too:
35
+
36
+ > tome get linked
37
+ Master password:
38
+ p4ssw0rd
39
+
40
+ You can also generate and copy complex passwords without having to remember anything:
41
+
42
+ > tome generate last.fm
43
+ Master password:
44
+ Generated password for last.fm.
45
+
46
+ > tome get last
47
+ Master password:
48
+ Password for last.fm:
49
+ kizWy76F2@G(21c11(9Tf?f@43B!kq
50
+
51
+ > tome copy last
52
+ Master password:
53
+ Password for last.fm copied to clipboard.
54
+
55
+ If you want, you can specify a username with your domain:
56
+
57
+ > tome set foo@bar.com baz
58
+ Master password:
59
+ Created password for foo@bar.com.
60
+
61
+ > tome get bar
62
+ Master password:
63
+ Password for foo@bar.com:
64
+ baz
65
+
66
+ See `tome help` for advanced commands and usage.
67
+
68
+ ## Philosophy
69
+
70
+ Tome is meant to be simple and secure. Instead of having blind trust in the secure coding practices
71
+ of every website you sign up with, you can use tome to help mitigate your risk and exposure.
72
+
73
+ **Benefits**
74
+ * Easily maintain unique per-site passwords.
75
+ * Have complex passwords without having to remember them (see `tome generate`).
76
+ * If a website leaks your password or its hash, you can quickly generate another unique complex password.
77
+ * You can keep track of all of the various websites you have accounts with.
78
+
79
+ **Drawbacks**
80
+ * Single point of failure: if your `.tome` file is compromised, all of your passwords are potentially at risk.
81
+ The encryption on the `.tome` file is meant to mitigate this danger. Brute-force decryption should take significant
82
+ computing power and time. To further reduce risk, don't store usernames (e.g. do `tome set gmail.com` instead of `tome set foo@gmail.com`).
83
+ * Dependence on the `.tome` file: if your `.tome` file is lost or corrupt and you forget your passwords, you'll have to reset them.
84
+ * If you want access to your passwords on multiple machines, you'll have to sync the `.tome` file between machines.
85
+ * Trust in *my* secure coding practices: I encourage you to look at the source yourself.
86
+
87
+ ## Under the hood
88
+
89
+ All account and password information is stored in a single `.tome` file in the user's home directory. This file is
90
+ YAML-formatted and stores the encrypted account and password information as well as the encryption parameters.
91
+ These encryption parameters, along with the master password, are used to decrypt the password information.
92
+
93
+ Each time the `.tome` file is modified, new encryption parameters (i.e. the salt and IV) are randomly generated
94
+ and used for encryption.
95
+
96
+ **Password database encryption**
97
+ * Encryption algorithm: symmetric [AES-256](http://en.wikipedia.org/wiki/Advanced_Encryption_Standard)
98
+ [CBC](http://en.wikipedia.org/wiki/Block_cipher_modes_of_operation#Cipher-block_chaining_.28CBC.29)
99
+ using the [Ruby OpenSSL library](http://www.ruby-doc.org/stdlib-1.9.3/libdoc/openssl/rdoc/index.html).
100
+ * Key derivation:
101
+ * [PBKDF2](http://en.wikipedia.org/wiki/PBKDF2)/[HMAC-SHA-512](http://en.wikipedia.org/wiki/SHA-2) with a master password.
102
+ * [UUID](http://en.wikipedia.org/wiki/UUID)-based random, probabilistically unique [salt](http://en.wikipedia.org/wiki/Salt_%28cryptography%29)
103
+ from [SecureRandom#uuid](http://www.ruby-doc.org/stdlib-1.9.3/libdoc/securerandom/rdoc/SecureRandom.html#method-c-uuid).
104
+ * Randomly-generated [IV](http://en.wikipedia.org/wiki/Initialization_vector) from [OpenSSL::Cipher#random_iv](http://www.ruby-doc.org/stdlib-1.9.3/libdoc/openssl/rdoc/OpenSSL/Cipher.html#method-i-random_iv).
105
+ * 100,000 [key stretch](http://en.wikipedia.org/wiki/Key_stretching) iterations.
106
+
107
+ ## Contributing
108
+
109
+ *TODO*
110
+
111
+ ## License
112
+
113
+ *TODO*
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'tome'
4
+ tome_filename = File.join(Dir.home, '.tome')
5
+ exit(Tome::Command.run(tome_filename, ARGV))
@@ -0,0 +1,4 @@
1
+ require 'tome/tome'
2
+ require 'tome/command'
3
+ require 'tome/crypt'
4
+ require 'tome/version'
@@ -0,0 +1,595 @@
1
+ require 'io/console'
2
+ require 'passgen'
3
+ require 'clipboard'
4
+
5
+ module Tome
6
+ class CommandError < RuntimeError
7
+ end
8
+
9
+ class Command
10
+ private_class_method :new
11
+
12
+ def self.run(tome_filename, args, stdout = $stdout, stderr = $stderr, stdin = $stdin)
13
+ command = new()
14
+ return command.send(:run, tome_filename, args, stdout, stderr, stdin)
15
+ end
16
+
17
+ private
18
+ def run(tome_filename, args, stdout, stderr, stdin)
19
+ @out = stdout
20
+ @err = stderr
21
+ @in = stdin
22
+ @tome_filename = tome_filename
23
+
24
+ if args.length < 1
25
+ usage()
26
+ return 1
27
+ end
28
+
29
+ begin
30
+ handle_command(args)
31
+ rescue CommandError => error
32
+ @err.puts error.message
33
+ return 1
34
+ end
35
+
36
+ return 0
37
+ end
38
+
39
+ def handle_command(args)
40
+ # TODO: Handle 'command --help', e.g. 'tome set --help'.
41
+
42
+ command = command_from_arg(args[0])
43
+
44
+ if command.nil?
45
+ raise CommandError, "Unrecognized command: #{args[0]}.\n\n#{$usage}"
46
+ end
47
+
48
+ args.shift
49
+ send(command, args)
50
+ end
51
+
52
+ def command_from_arg(arg)
53
+ commands = {
54
+ /\A(help|-h|--help)\z/i => :help,
55
+ /\A(version|ver|-v|--version)\z/i => :version,
56
+ /\A(set|s)\z/i => :set,
57
+ /\A(get|g|show)\z/i => :get,
58
+ /\A(delete|del|d|rm|remove)\z/i => :delete,
59
+ /\A(generate|gen)\z/i => :generate,
60
+ /\A(copy|cp)\z/i => :copy,
61
+ /\A(rename|ren|rn)\z/i => :rename,
62
+ /\A(list|ls)\z/i => :list
63
+ }
64
+
65
+ commands.each { |pattern, command|
66
+ return command if arg =~ pattern
67
+ }
68
+
69
+ return nil
70
+ end
71
+
72
+ def help(args)
73
+ if args.length > 1
74
+ raise CommandError, "Invalid arguments.\n\n#{$usage}"
75
+ end
76
+
77
+ if args.empty?
78
+ usage()
79
+ return
80
+ end
81
+
82
+ command = command_from_arg(args[0])
83
+
84
+ if command.nil?
85
+ raise CommandError, "No help for unrecognized command: #{args[0]}.\n\n#{$usage}"
86
+ end
87
+
88
+ help = {
89
+ :help => $help_usage,
90
+ :set => $set_usage,
91
+ :get => $get_usage,
92
+ :delete => $delete_usage,
93
+ :generate => $generate_usage,
94
+ :copy => $copy_usage,
95
+ :rename => $rename_usage,
96
+ :list => $list_usage
97
+ }
98
+
99
+ usage = help[command]
100
+ if usage.nil?
101
+ raise CommandError, "No help available for command: #{args[0]}."
102
+ end
103
+
104
+ @out.puts usage
105
+ end
106
+
107
+ def version(args)
108
+ @out.puts "tome version #{$version}"
109
+ end
110
+
111
+ def set(args)
112
+ if args.length < 1 || args.length > 2
113
+ raise CommandError, "Invalid arguments.\n\n#{$set_usage}"
114
+ end
115
+
116
+ tome = tome_create_connect()
117
+
118
+ case args.length
119
+ # TODO: Validate that first argument is in [username@]domain form.
120
+
121
+ # tome set bar.com
122
+ # tome set foo@bar.com
123
+ when 1
124
+ id = args[0]
125
+ password = prompt_password()
126
+
127
+ # tome set bar.com p4ssw0rd
128
+ # tome set foo@bar.com p4ssw0rd
129
+ when 2
130
+ id = args[0]
131
+ password = args[1]
132
+ end
133
+
134
+ exists = !tome.get(id).nil?
135
+ if exists
136
+ confirm = prompt_confirm("A password already exists for #{id}. Overwrite (y/n)? ")
137
+ if !confirm
138
+ raise CommandError, 'Aborted.'
139
+ end
140
+ end
141
+
142
+ created = tome.set(id, password)
143
+ if created
144
+ @out.print 'Created '
145
+ else
146
+ @out.print 'Updated '
147
+ end
148
+
149
+ @out.puts "password for #{id}."
150
+ end
151
+
152
+ def get(args)
153
+ if args.length != 1
154
+ raise CommandError, "Invalid arguments.\n\n#{$get_usage}"
155
+ end
156
+
157
+ # tome get bar.com
158
+ # tome get foo@bar.com
159
+ pattern = args[0]
160
+
161
+ tome = tome_connect()
162
+ matches = tome.find(pattern)
163
+
164
+ if matches.empty?
165
+ raise CommandError, "No password found for #{pattern}."
166
+ elsif matches.count == 1
167
+ match = matches.first
168
+ @out.puts "Password for #{match.first}:"
169
+ @out.puts match.last
170
+ else
171
+ @out.puts "Multiple matches for #{pattern}:"
172
+ matches.each { |key, password|
173
+ @out.puts "#{key}: #{password}"
174
+ }
175
+ end
176
+ end
177
+
178
+ def delete(args)
179
+ if args.length != 1
180
+ raise CommandError, "Invalid arguments.\n\n#{$delete_usage}"
181
+ end
182
+
183
+ tome = tome_connect()
184
+
185
+ # tome del bar.com
186
+ # tome del foo@bar.com
187
+ id = args[0]
188
+
189
+ exists = !tome.get(id).nil?
190
+ if exists
191
+ confirmed = prompt_confirm("Are you sure you want to delete the password for #{id} (y/n)? ")
192
+ if !confirmed
193
+ raise CommandError, 'Aborted.'
194
+ end
195
+ end
196
+
197
+ deleted = tome.delete(id)
198
+
199
+ if deleted
200
+ @out.puts "Deleted password for #{id}."
201
+ else
202
+ @out.puts "No password found for #{id}."
203
+ end
204
+ end
205
+
206
+ def generate(args)
207
+ if args.length != 1
208
+ raise CommandError, "Invalid arguments.\n\n#{$generate_usage}"
209
+ end
210
+
211
+ tome = tome_create_connect()
212
+
213
+ # tome gen bar.com
214
+ # tome gen foo@bar.com
215
+ id = args[0]
216
+ password = generate_password()
217
+
218
+ exists = !tome.get(id).nil?
219
+ if exists
220
+ confirm = prompt_confirm("A password already exists for #{id}. Overwrite (y/n)? ")
221
+ if !confirm
222
+ raise CommandError, 'Aborted.'
223
+ end
224
+ end
225
+
226
+ created = tome.set(id, password)
227
+
228
+ if created
229
+ @out.puts "Generated password for #{id}."
230
+ else
231
+ @out.puts "Updated #{id} with the generated password."
232
+ end
233
+ end
234
+
235
+ def copy(args)
236
+ if args.length != 1
237
+ raise CommandError, "Invalid arguments.\n\n#{$copy_usage}"
238
+ end
239
+
240
+ # tome cp bar.com
241
+ # tome cp foo@bar.com
242
+ pattern = args[0]
243
+
244
+ tome = tome_connect()
245
+ matches = tome.find(pattern)
246
+
247
+ if matches.empty?
248
+ raise CommandError, "No password found for #{pattern}."
249
+ elsif matches.count > 1
250
+ message = "Found multiple matches for #{pattern}. Did you mean one of the following?\n\n"
251
+ error.matches.each { |match|
252
+ message += "\t#{match}\n"
253
+ }
254
+
255
+ raise CommandError, message
256
+ else
257
+ match = matches.first
258
+ password = match.last
259
+
260
+ Clipboard.copy password
261
+ if Clipboard.paste == password
262
+ @out.puts "Password for #{match.first} copied to clipboard."
263
+ else
264
+ @err.puts "Failed to copy password for #{match.first} to clipboard."
265
+ end
266
+ end
267
+ end
268
+
269
+ def list(args)
270
+ if !args.empty?
271
+ raise CommandError, "Invalid arguments.\n\n#{$list_usage}"
272
+ end
273
+
274
+ tome = tome_connect()
275
+
276
+ count = 0
277
+ tome.each_password { |id, password|
278
+ @out.puts "#{id}: #{password}"
279
+ count += 1
280
+ }
281
+
282
+ if count == 0
283
+ @out.puts 'No passwords stored.'
284
+ end
285
+ end
286
+
287
+ def rename(args)
288
+ if args.count != 2
289
+ raise CommandError, "Invalid arguments.\n\n#{$rename_usage}"
290
+ end
291
+
292
+ tome = tome_connect()
293
+
294
+ old_id = args[0]
295
+ new_id = args[1]
296
+
297
+ overwriting = !tome.get(new_id).nil?
298
+ if overwriting
299
+ confirm = prompt_confirm("A password already exists for #{new_id}. Overwrite (y/n)? ")
300
+ if !confirm
301
+ raise CommandError, 'Aborted.'
302
+ end
303
+ end
304
+
305
+ renamed = tome.rename(old_id, new_id)
306
+
307
+ if !renamed
308
+ raise CommandError, "#{old_id} does not exist."
309
+ else
310
+ @out.puts "#{old_id} renamed to #{new_id}."
311
+ end
312
+ end
313
+
314
+ def generate_password
315
+ Passgen.generate(:length => 30, :symbols => true)
316
+ end
317
+
318
+ def prompt_password(prompt = 'Password')
319
+ begin
320
+ @err.print "#{prompt}: "
321
+ password = input_password()
322
+
323
+ if password.empty?
324
+ @err.puts 'Password cannot be blank.'
325
+ raise
326
+ end
327
+
328
+ @err.print "#{prompt} (verify): "
329
+ verify = input_password()
330
+
331
+ if verify != password
332
+ @err.puts 'Passwords do not match.'
333
+ raise
334
+ end
335
+ rescue
336
+ retry
337
+ end
338
+
339
+ return password
340
+ end
341
+
342
+ def input_password
343
+ @in.noecho { |stdin|
344
+ password = stdin.gets.sub(/[\r\n]+\z/, '')
345
+ @err.puts
346
+
347
+ return password
348
+ }
349
+ end
350
+
351
+ def prompt_confirm(prompt)
352
+ begin
353
+ @out.print prompt
354
+
355
+ confirm = @in.gets.strip
356
+
357
+ if confirm =~ /\Ay/i
358
+ return true
359
+ elsif confirm =~ /\An/i
360
+ return false
361
+ end
362
+ rescue
363
+ retry
364
+ end
365
+ end
366
+
367
+ def usage
368
+ @err.puts "tome version #{$version}"
369
+ @err.puts
370
+ @err.puts $usage
371
+ end
372
+
373
+ def tome_connect
374
+ if !Tome.exists?(@tome_filename)
375
+ raise CommandError, "Tome database does not exist. Use 'tome set' or 'tome generate' to create a password first."
376
+ end
377
+
378
+ begin
379
+ @err.print 'Master password: '
380
+ master_password = input_password()
381
+ tome = Tome.new(@tome_filename, master_password)
382
+ rescue MasterPasswordError
383
+ @err.puts 'Incorrect master password.'
384
+ retry
385
+ end
386
+
387
+ return tome
388
+ end
389
+
390
+ def tome_create_connect
391
+ if !Tome.exists?(@tome_filename)
392
+ @out.puts 'Creating tome database.'
393
+ master_password = prompt_password('Master password')
394
+ tome = Tome.create!(@tome_filename, master_password)
395
+ else
396
+ tome = tome_connect()
397
+ end
398
+ end
399
+ end
400
+
401
+ # TODO: Complete these.
402
+ $usage = <<END
403
+ Usage:
404
+
405
+ tome set [user@]<domain> [password]
406
+
407
+ Create or update the password for an account.
408
+ Example: tome set foo@gmail.com
409
+
410
+ tome generate [user@]<domain>
411
+
412
+ Generate a random password for an account.
413
+ Example: tome generate reddit.com
414
+
415
+ tome get <pattern>
416
+
417
+ Show the passwords for all accounts matching the pattern.
418
+ Example: tome get youtube
419
+
420
+ tome copy <pattern>
421
+
422
+ Copy the password for the account matching the pattern.
423
+ Example: tome copy news.ycombinator.com
424
+
425
+ tome list
426
+
427
+ Show all stored accounts and passwords.
428
+ Example: tome list
429
+
430
+ tome delete [user@]<domain>
431
+
432
+ Delete the password for an account.
433
+ Example: tome delete foo@slashdot.org
434
+
435
+ tome rename <old> <new>
436
+
437
+ Rename the account information stored.
438
+ Example: tome rename twitter.com foo@twitter.com
439
+
440
+ tome help
441
+
442
+ Shows help for a specific command.
443
+ Example: tome help set
444
+
445
+ tome version
446
+
447
+ Shows the version of tome.
448
+ Example: tome version
449
+ END
450
+
451
+ $help_usage = <<END
452
+ tome help
453
+
454
+ Shows help for a specific command.
455
+
456
+ Usage:
457
+
458
+ tome help
459
+ tome help <command>
460
+
461
+ Examples:
462
+
463
+ tome help
464
+ tome help set
465
+ tome help help (so meta)
466
+
467
+ Alias: help, --help, -h
468
+ END
469
+
470
+ $set_usage = <<END
471
+ tome set
472
+
473
+ Create or update the password for an account. The user is optional.
474
+ If you do not specify a password, you will be prompted for one.
475
+
476
+ Usage:
477
+
478
+ tome set [user@]<domain> [password]
479
+
480
+ Examples:
481
+
482
+ tome set gmail.com
483
+ tome set gmail.com p4ssw0rd
484
+ tome set foo@gmail.com
485
+ tome set foo@gmail.com p4ssw0rd
486
+
487
+ Alias: set, s
488
+ END
489
+
490
+ $get_usage = <<END
491
+ tome get
492
+
493
+ Show the passwords for all accounts matching the pattern.
494
+ Matching is done with substring search. Wildcards are not supported.
495
+
496
+ Usage:
497
+
498
+ tome get <pattern>
499
+
500
+ Examples:
501
+
502
+ tome get gmail
503
+ tome get foo@
504
+ tome get foo@gmail.com
505
+
506
+ Alias: get, g, show
507
+ END
508
+
509
+ $delete_usage = <<END
510
+ tome delete
511
+
512
+ Delete the password for an account.
513
+
514
+ Usage:
515
+
516
+ tome delete [user@]<domain>
517
+
518
+ Examples:
519
+
520
+ tome delete gmail.com
521
+ tome delete foo@gmail.com
522
+
523
+ Alias: delete, del, d, remove, rm
524
+ END
525
+
526
+ $generate_usage = <<END
527
+ tome generate
528
+
529
+ Generate a random password for an account. The user is optional.
530
+
531
+ Usage:
532
+
533
+ tome generate [user@]<domain>
534
+
535
+ Examples:
536
+
537
+ tome generate gmail.com
538
+ tome generate foo@gmail.com
539
+
540
+ Alias: generate, gen
541
+ END
542
+
543
+ $copy_usage = <<END
544
+ tome copy
545
+
546
+ Copy the password for the account matching the pattern.
547
+ If more than one account matches the pattern, nothing happens.
548
+ Matching is done with substring search. Wildcards are not supported.
549
+
550
+ Usage:
551
+
552
+ tome copy <pattern>
553
+
554
+ Examples:
555
+
556
+ tome copy gmail
557
+ tome copy foo@
558
+ tome copy foo@gmail.com
559
+
560
+ Alias: copy, cp
561
+ END
562
+
563
+ $list_usage = <<END
564
+ tome list
565
+
566
+ Show all stored accounts and passwords.
567
+
568
+ Usage:
569
+
570
+ tome list
571
+
572
+ Examples:
573
+
574
+ tome list
575
+
576
+ Alias: list, ls
577
+ END
578
+
579
+ $rename_usage = <<END
580
+ tome rename
581
+
582
+ Rename the account information stored.
583
+
584
+ Usage:
585
+
586
+ tome rename <old> <new>
587
+
588
+ Examples:
589
+
590
+ tome rename gmail.com foo@gmail.com
591
+ tome rename foo@gmail.com bar@gmail.com
592
+
593
+ Alias: rename, ren, rn
594
+ END
595
+ end
@@ -0,0 +1,56 @@
1
+ require 'openssl'
2
+ require 'securerandom'
3
+
4
+ module Tome
5
+ class Crypt
6
+ def self.encrypt(opts = {})
7
+ crypt :encrypt, opts
8
+ end
9
+
10
+ def self.decrypt(opts = {})
11
+ crypt :decrypt, opts
12
+ end
13
+
14
+ def self.new_iv
15
+ new_cipher.random_iv
16
+ end
17
+
18
+ def self.new_salt
19
+ SecureRandom.uuid
20
+ end
21
+
22
+ private
23
+ def self.new_cipher
24
+ OpenSSL::Cipher::AES.new(256, :CBC)
25
+ end
26
+
27
+ def self.crypt(method, opts)
28
+ raise ArgumentError if
29
+ opts.nil? || opts.empty? || opts[:value].nil? ||
30
+ opts[:password].nil? || opts[:password].empty? ||
31
+ opts[:salt].nil? || opts[:salt].empty? ||
32
+ opts[:iv].nil? || opts[:iv].empty? ||
33
+ opts[:stretch].nil? || opts[:stretch].nil?
34
+
35
+ cipher = new_cipher
36
+ cipher.send(method)
37
+
38
+ cipher.key = crypt_key(opts)
39
+ cipher.iv = opts[:iv]
40
+
41
+ result = cipher.update(opts[:value])
42
+ result << cipher.final
43
+ return result
44
+ end
45
+
46
+ def self.crypt_key(opts)
47
+ password = opts[:password]
48
+ salt = opts[:salt]
49
+ iterations = opts[:stretch]
50
+ key_length = 32 # 256 bits
51
+ hash = OpenSSL::Digest::SHA512.new
52
+
53
+ return OpenSSL::PKCS5.pbkdf2_hmac(password, salt, iterations, key_length, hash)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,264 @@
1
+ require 'yaml'
2
+ require 'fileutils'
3
+
4
+ module Tome
5
+ class MasterPasswordError < RuntimeError
6
+ end
7
+
8
+ class Tome
9
+ def self.exists?(tome_filename)
10
+ return !load_tome(tome_filename).nil?
11
+ end
12
+
13
+ def self.create!(tome_filename, master_password, stretch = 100_000)
14
+ save_tome(tome_filename, new_tome(stretch), {}, master_password)
15
+ return Tome.new(tome_filename, master_password)
16
+ end
17
+
18
+ def initialize(tome_filename, master_password)
19
+ @tome_filename = tome_filename
20
+ @master_password = master_password
21
+
22
+ # TODO: This is suboptimal. We are loading the store
23
+ # twice for most operations because of this authentication.
24
+ authenticate()
25
+ end
26
+
27
+ def set(id, password)
28
+ if id.nil? || id.empty? || password.nil? || password.empty?
29
+ raise ArgumentError
30
+ end
31
+
32
+ return writable_store do |store|
33
+ set_by_id(store, id, password)
34
+ end
35
+ end
36
+
37
+ def get(id)
38
+ if id.nil? || id.empty?
39
+ raise ArgumentError
40
+ end
41
+
42
+ return readable_store do |store|
43
+ get_by_id(store, id)
44
+ end
45
+ end
46
+
47
+ def find(pattern)
48
+ if pattern.nil? || pattern.empty?
49
+ raise ArgumentError
50
+ end
51
+
52
+ return readable_store do |store|
53
+ get_by_pattern(store, pattern)
54
+ end
55
+ end
56
+
57
+ def delete(id)
58
+ if id.nil? || id.empty?
59
+ raise ArgumentError
60
+ end
61
+
62
+ return writable_store do |store|
63
+ delete_by_id(store, id)
64
+ end
65
+ end
66
+
67
+ def rename(old_id, new_id)
68
+ if old_id.nil? || old_id.empty? || new_id.nil? || new_id.empty?
69
+ raise ArgumentError
70
+ end
71
+
72
+ return writable_store do |store|
73
+ rename_by_id(store, old_id, new_id)
74
+ end
75
+ end
76
+
77
+ def each_password
78
+ if !block_given?
79
+ raise ArgumentError
80
+ end
81
+
82
+ readable_store do |store|
83
+ store.each { |id, info|
84
+ yield id, info[:password]
85
+ }
86
+ end
87
+ end
88
+
89
+ private
90
+ def set_by_id(store, id, password)
91
+ created = !store.include?(id)
92
+
93
+ store[id] = {}
94
+ store[id][:password] = password
95
+
96
+ return created
97
+ end
98
+
99
+ def get_by_pattern(store, pattern)
100
+ find_by_pattern(store, pattern).map { |key, value|
101
+ { key => value[:password] }
102
+ }.inject { |hash, item|
103
+ hash.merge!(item)
104
+ } || {}
105
+ end
106
+
107
+ def get_by_id(store, id)
108
+ store[id]
109
+ end
110
+
111
+ def delete_by_id(store, id)
112
+ same = store.reject! { |key, info|
113
+ key.casecmp(id) == 0
114
+ }.nil?
115
+
116
+ return !same
117
+ end
118
+
119
+ def find_by_pattern(store, pattern)
120
+ return {} if pattern.nil?
121
+
122
+ # TODO: Better matching. Should allow separated
123
+ # substring matching. Exact match > solid substrings > separated substrings.
124
+
125
+ exact = store.select { |key, info|
126
+ key.casecmp(pattern) == 0
127
+ }
128
+
129
+ return exact if !exact.empty?
130
+
131
+ return store.select { |key, info|
132
+ key =~ /#{pattern}/i
133
+ }
134
+ end
135
+
136
+ def rename_by_id(store, old_id, new_id)
137
+ if store[old_id].nil?
138
+ return false
139
+ else
140
+ values = store[old_id]
141
+ store.delete(old_id)
142
+ store[new_id] = values
143
+ return true
144
+ end
145
+ end
146
+
147
+ def self.load_tome(tome_filename)
148
+ return nil if !File.exist?(tome_filename)
149
+
150
+ contents = File.open(tome_filename, 'rb') { |file| file.read }
151
+ return nil if contents.length == 0
152
+
153
+ values = YAML.load(contents)
154
+ return nil if !values
155
+
156
+ # TODO: Throw if these values are nil.
157
+ # TODO: Verify version number, raise if incompatible.
158
+ return {
159
+ :version => values[:version],
160
+ :salt => values[:salt],
161
+ :iv => values[:iv],
162
+ :stretch => values[:stretch],
163
+ :store => values[:store]
164
+ }
165
+ end
166
+
167
+ def load_store(tome)
168
+ if tome.nil?
169
+ raise ArgumentError
170
+ end
171
+
172
+ begin
173
+ store_yaml = Crypt.decrypt(
174
+ :value => tome[:store],
175
+ :password => @master_password,
176
+ :stretch => tome[:stretch],
177
+ :salt => tome[:salt],
178
+ :iv => tome[:iv]
179
+ )
180
+ rescue ArgumentError
181
+ # TODO: Should probably be raising an error here.
182
+ return {}
183
+ rescue OpenSSL::Cipher::CipherError
184
+ raise MasterPasswordError
185
+ end
186
+
187
+ store = YAML.load(store_yaml)
188
+ return store || {}
189
+ end
190
+
191
+ def self.save_tome(tome_filename, tome, store, master_password)
192
+ if tome.nil? || store.nil? || master_password.nil? || master_password.empty?
193
+ raise ArgumentError
194
+ end
195
+
196
+ store_yaml = YAML.dump(store)
197
+
198
+ new_salt = Crypt.new_salt
199
+ new_iv = Crypt.new_iv
200
+
201
+ encrypted_store = Crypt.encrypt(
202
+ :value => store_yaml,
203
+ :password => master_password,
204
+ :salt => new_salt,
205
+ :iv => new_iv,
206
+ :stretch => tome[:stretch]
207
+ )
208
+
209
+ contents = tome.merge({
210
+ :version => FILE_VERSION,
211
+ :store => encrypted_store,
212
+ :salt => new_salt,
213
+ :iv => new_iv
214
+ })
215
+
216
+ File.open(tome_filename, 'wb') do |file|
217
+ YAML.dump(contents, file)
218
+ end
219
+ end
220
+
221
+ def readable_store()
222
+ tome = Tome.load_tome(@tome_filename)
223
+ store = load_store(tome)
224
+
225
+ result = yield store
226
+
227
+ store = nil
228
+ GC.start
229
+
230
+ return result
231
+ end
232
+
233
+ def writable_store()
234
+ tome = Tome.load_tome(@tome_filename)
235
+ store = load_store(tome)
236
+
237
+ result = yield store
238
+
239
+ Tome.save_tome(@tome_filename, tome, store, @master_password)
240
+ store = nil
241
+
242
+ GC.start
243
+
244
+ return result
245
+ end
246
+
247
+ def self.new_tome(stretch)
248
+ {
249
+ :store => {},
250
+ :salt => Crypt.new_salt,
251
+ :iv => Crypt.new_iv,
252
+ :stretch => stretch
253
+ }
254
+ end
255
+
256
+ def authenticate
257
+ # Force a read.
258
+ # If the master password is invalid, the access exception will propagate.
259
+ readable_store { }
260
+ end
261
+
262
+ FILE_VERSION = 1
263
+ end
264
+ end
@@ -0,0 +1 @@
1
+ $version = '0.1.0.pre'
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tome
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0.pre
5
+ prerelease: 6
6
+ platform: ruby
7
+ authors:
8
+ - Chris Schmich
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-06-12 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: passgen
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: '1.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: '1.0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: clipboard
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ~>
36
+ - !ruby/object:Gem::Version
37
+ version: '1.0'
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ~>
44
+ - !ruby/object:Gem::Version
45
+ version: '1.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: ffi
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ~>
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ~>
60
+ - !ruby/object:Gem::Version
61
+ version: '1.0'
62
+ description: Lightweight password manager with a humane command-line interface. Manage
63
+ your passwords with a single master password.
64
+ email: schmch@gmail.com
65
+ executables:
66
+ - tome
67
+ extensions: []
68
+ extra_rdoc_files: []
69
+ files:
70
+ - lib/tome/command.rb
71
+ - lib/tome/crypt.rb
72
+ - lib/tome/tome.rb
73
+ - lib/tome/version.rb
74
+ - lib/tome.rb
75
+ - bin/tome
76
+ - README.md
77
+ homepage: https://github.com/schmich/tome
78
+ licenses: []
79
+ post_install_message:
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ none: false
85
+ requirements:
86
+ - - ! '>='
87
+ - !ruby/object:Gem::Version
88
+ version: 1.9.3
89
+ required_rubygems_version: !ruby/object:Gem::Requirement
90
+ none: false
91
+ requirements:
92
+ - - ! '>'
93
+ - !ruby/object:Gem::Version
94
+ version: 1.3.1
95
+ requirements: []
96
+ rubyforge_project:
97
+ rubygems_version: 1.8.24
98
+ signing_key:
99
+ specification_version: 3
100
+ summary: Lightweight command-line password manager.
101
+ test_files: []