tome 0.1.0.pre

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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: []