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.
- data/README.md +113 -0
- data/bin/tome +5 -0
- data/lib/tome.rb +4 -0
- data/lib/tome/command.rb +595 -0
- data/lib/tome/crypt.rb +56 -0
- data/lib/tome/tome.rb +264 -0
- data/lib/tome/version.rb +1 -0
- metadata +101 -0
data/README.md
ADDED
@@ -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*
|
data/bin/tome
ADDED
data/lib/tome.rb
ADDED
data/lib/tome/command.rb
ADDED
@@ -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
|
data/lib/tome/crypt.rb
ADDED
@@ -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
|
data/lib/tome/tome.rb
ADDED
@@ -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
|
data/lib/tome/version.rb
ADDED
@@ -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: []
|