tome 1.0.1 → 1.0.3
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.
- checksums.yaml +7 -0
- data/README.md +128 -121
- data/bin/tome +5 -5
- data/lib/tome.rb +6 -6
- data/lib/tome/command.rb +460 -440
- data/lib/tome/crypt.rb +56 -56
- data/lib/tome/padding.rb +15 -15
- data/lib/tome/tome.rb +327 -327
- data/lib/tome/usage.rb +209 -209
- data/lib/tome/version.rb +3 -1
- metadata +17 -30
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 6043d9572fcacc2fc1ce08e196b43741cb7d7e8f
|
4
|
+
data.tar.gz: 18d884c8f681e3410d08c1dedda61a807a71e00f
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: ba3dfb8372777f1663d9e774fdd619f227b3dec1145c055b2e6199a38803a7f7dcb7ad73f163f72b0677ec1b9f314e869015f8f777a49c22b6baa985dc95d43a
|
7
|
+
data.tar.gz: 140a796fd311a64abe09896b509d761371d9e93d20d9263bec5415ef2e8b81973422fa2fc480fc70f5b4a0c82c49279e0a444e62766bb2b23b9a4579fb3dcaf2
|
data/README.md
CHANGED
@@ -1,121 +1,128 @@
|
|
1
|
-
## Tome
|
2
|
-
|
3
|
-
Tome is a lightweight password manager with a humane command-line interface.
|
4
|
-
|
5
|
-
Tome stores your passwords in an encrypted file which you manage with a single master password.
|
6
|
-
You can keep track of multiple complex passwords without having to remember any of them.
|
7
|
-
|
8
|
-
*Disclaimer* I am not a security expert. I've only had limited formal training in security and cryptography.
|
9
|
-
Now that I've scared off all but the bravest, feel free to look [under the hood](#under-the-hood) or
|
10
|
-
at the security bits in [crypt.rb](https://github.com/schmich/tome/blob/master/lib/tome/crypt.rb).
|
11
|
-
|
12
|
-
[ or
|
10
|
+
at the security bits in [crypt.rb](https://github.com/schmich/tome/blob/master/lib/tome/crypt.rb).
|
11
|
+
|
12
|
+
[](http://rubygems.org/gems/tome)
|
13
|
+
[](http://travis-ci.org/schmich/tome)
|
14
|
+
[](http://gemnasium.com/schmich/tome)
|
15
|
+
[](https://codeclimate.com/github/schmich/tome)
|
16
|
+
|
17
|
+
## Installation
|
18
|
+
|
19
|
+
* Install [Ruby 1.9.3](http://www.ruby-lang.org/en/downloads/) or newer.
|
20
|
+
* `gem install tome`
|
21
|
+
* `tome` should now be available on the command-line. Run `tome help` to get started.
|
22
|
+
|
23
|
+
## Usage
|
24
|
+
|
25
|
+
The first time you run `tome`, you'll be asked to create a master password for your encrypted password database.
|
26
|
+
Any operations involving your password database will require this master password.
|
27
|
+
|
28
|
+
Creating a new password is simple:
|
29
|
+
|
30
|
+
> tome set linkedin.com
|
31
|
+
Creating tome database.
|
32
|
+
Master password:
|
33
|
+
Master password (verify):
|
34
|
+
Password:
|
35
|
+
Password (verify):
|
36
|
+
Created password for linkedin.com.
|
37
|
+
|
38
|
+
Recalling a password is just as easy:
|
39
|
+
|
40
|
+
> tome get linkedin.com
|
41
|
+
Master password:
|
42
|
+
Password for linkedin.com:
|
43
|
+
p4ssw0rd
|
44
|
+
|
45
|
+
In fact, it's even simpler than that. `tome get` does substring pattern matching to recall a password,
|
46
|
+
so this works, too:
|
47
|
+
|
48
|
+
> tome get linked
|
49
|
+
Master password:
|
50
|
+
Password for linkedin.com:
|
51
|
+
p4ssw0rd
|
52
|
+
|
53
|
+
You can also generate and copy complex passwords without having to remember anything:
|
54
|
+
|
55
|
+
> tome generate last.fm
|
56
|
+
Master password:
|
57
|
+
Generated and copied password for last.fm.
|
58
|
+
|
59
|
+
> tome get last
|
60
|
+
Master password:
|
61
|
+
Password for last.fm:
|
62
|
+
kizWy76F2@G(21c11(9Tf?f@43B!kq
|
63
|
+
|
64
|
+
> tome copy last
|
65
|
+
Master password:
|
66
|
+
Password for last.fm copied to clipboard.
|
67
|
+
|
68
|
+
If you want, you can specify a username with your domain:
|
69
|
+
|
70
|
+
> tome set foo@bar.com baz
|
71
|
+
Master password:
|
72
|
+
Created password for foo@bar.com.
|
73
|
+
|
74
|
+
> tome get bar
|
75
|
+
Master password:
|
76
|
+
Password for foo@bar.com:
|
77
|
+
baz
|
78
|
+
|
79
|
+
See `tome help` for advanced commands and usage.
|
80
|
+
|
81
|
+
## Philosophy
|
82
|
+
|
83
|
+
Tome is meant to be simple and secure. Instead of having blind trust in the secure coding practices
|
84
|
+
of every website you sign up with, you can use tome to help mitigate your risk and exposure.
|
85
|
+
|
86
|
+
**Benefits**
|
87
|
+
* Easily maintain unique per-site passwords.
|
88
|
+
* Have complex passwords without having to remember them (see `tome generate`).
|
89
|
+
* If a website leaks your password or its hash, you can quickly generate another unique complex password.
|
90
|
+
* You can keep track of all of the various websites you have accounts with.
|
91
|
+
|
92
|
+
**Drawbacks**
|
93
|
+
* Single point of failure: if your `.tome` file is compromised, all of your passwords are potentially at risk.
|
94
|
+
The encryption on the `.tome` file is meant to mitigate this danger. Brute-force decryption should take significant
|
95
|
+
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`).
|
96
|
+
* Dependence on the `.tome` file: if your `.tome` file is lost or corrupt and you forget your passwords, you'll have to reset them.
|
97
|
+
* If you want access to your passwords on multiple machines, you'll have to sync the `.tome` file between machines.
|
98
|
+
* Trust in *my* secure coding practices. I encourage you to look at the source yourself.
|
99
|
+
|
100
|
+
## Under the hood
|
101
|
+
|
102
|
+
All account and password information is stored in a single `.tome` file in the user's home directory. This file is
|
103
|
+
YAML-formatted and stores the encrypted account and password information as well as the encryption parameters.
|
104
|
+
These encryption parameters, along with the master password, are used to decrypt the password information.
|
105
|
+
|
106
|
+
A randomly-generated 1K-4K block of data is appended to the actual password data to obfuscate the number of passwords
|
107
|
+
stored in the database. This is not a security mechanism, but rather a hindrance to attempts to infer
|
108
|
+
anything from the encrypted data.
|
109
|
+
|
110
|
+
Each time the `.tome` file is modified, new encryption parameters (i.e. the salt and IV) are randomly generated
|
111
|
+
and used for encryption.
|
112
|
+
|
113
|
+
**Password database encryption**
|
114
|
+
* Encryption algorithm: symmetric [AES-256](http://en.wikipedia.org/wiki/Advanced_Encryption_Standard)
|
115
|
+
[CBC](http://en.wikipedia.org/wiki/Block_cipher_modes_of_operation#Cipher-block_chaining_.28CBC.29)
|
116
|
+
using the [Ruby OpenSSL library](http://www.ruby-doc.org/stdlib-1.9.3/libdoc/openssl/rdoc/index.html).
|
117
|
+
* Key derivation:
|
118
|
+
* [PBKDF2](http://en.wikipedia.org/wiki/PBKDF2)/[HMAC-SHA-512](http://en.wikipedia.org/wiki/SHA-2) with a master password.
|
119
|
+
* [UUID](http://en.wikipedia.org/wiki/UUID)-based random, probabilistically unique [salt](http://en.wikipedia.org/wiki/Salt_%28cryptography%29)
|
120
|
+
from [SecureRandom#uuid](http://www.ruby-doc.org/stdlib-1.9.3/libdoc/securerandom/rdoc/SecureRandom.html#method-c-uuid).
|
121
|
+
* 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).
|
122
|
+
* 100,000 [key stretch](http://en.wikipedia.org/wiki/Key_stretching) iterations.
|
123
|
+
|
124
|
+
## License
|
125
|
+
|
126
|
+
Copyright © 2013-2014 Chris Schmich
|
127
|
+
<br />
|
128
|
+
MIT License, see LICENSE for details.
|
data/bin/tome
CHANGED
@@ -1,5 +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))
|
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))
|
data/lib/tome.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
|
-
require 'tome/tome'
|
2
|
-
require 'tome/command'
|
3
|
-
require 'tome/crypt'
|
4
|
-
require 'tome/padding'
|
5
|
-
require 'tome/usage'
|
6
|
-
require 'tome/version'
|
1
|
+
require 'tome/tome'
|
2
|
+
require 'tome/command'
|
3
|
+
require 'tome/crypt'
|
4
|
+
require 'tome/padding'
|
5
|
+
require 'tome/usage'
|
6
|
+
require 'tome/version'
|
data/lib/tome/command.rb
CHANGED
@@ -1,440 +1,460 @@
|
|
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: #{error.message}"
|
33
|
-
return 1
|
34
|
-
rescue FileFormatError => error
|
35
|
-
# Fix file separators for Windows.
|
36
|
-
filename = @tome_filename.gsub(File::SEPARATOR, File::ALT_SEPARATOR || File::SEPARATOR)
|
37
|
-
@err.puts "Error: Cannot read #{filename}: #{error.message}"
|
38
|
-
return 1
|
39
|
-
end
|
40
|
-
|
41
|
-
return 0
|
42
|
-
end
|
43
|
-
|
44
|
-
def handle_command(args)
|
45
|
-
# TODO: Handle 'command --help', e.g. 'tome set --help'.
|
46
|
-
|
47
|
-
command = command_from_arg(args[0])
|
48
|
-
|
49
|
-
if command.nil?
|
50
|
-
raise CommandError, "Unrecognized command: #{args[0]}.\n\n#{$usage}"
|
51
|
-
end
|
52
|
-
|
53
|
-
args.shift
|
54
|
-
send(command, args)
|
55
|
-
end
|
56
|
-
|
57
|
-
def command_from_arg(arg)
|
58
|
-
commands = {
|
59
|
-
/\A(help|-h|--help)\z/i => :help,
|
60
|
-
/\A(version|ver|-v|--version)\z/i => :version,
|
61
|
-
/\A(set|s|add)\z/i => :set,
|
62
|
-
/\A(get|g|show)\z/i => :get,
|
63
|
-
/\A(delete|del|rm|remove)\z/i => :delete,
|
64
|
-
/\A(generate|gen)\z/i => :generate,
|
65
|
-
/\A(copy|cp)\z/i => :copy,
|
66
|
-
/\A(rename|ren|rn)\z/i => :rename,
|
67
|
-
/\A(master)\z/i => :master,
|
68
|
-
/\A(list|ls)\z/i => :list
|
69
|
-
}
|
70
|
-
|
71
|
-
commands.each { |pattern, command|
|
72
|
-
return command if arg =~ pattern
|
73
|
-
}
|
74
|
-
|
75
|
-
return nil
|
76
|
-
end
|
77
|
-
|
78
|
-
def help(args)
|
79
|
-
if args.length > 1
|
80
|
-
raise CommandError, "Invalid arguments.\n\n#{$usage}"
|
81
|
-
end
|
82
|
-
|
83
|
-
if args.empty?
|
84
|
-
usage()
|
85
|
-
return
|
86
|
-
end
|
87
|
-
|
88
|
-
command = command_from_arg(args[0])
|
89
|
-
|
90
|
-
if command.nil?
|
91
|
-
raise CommandError, "No help for unrecognized command: #{args[0]}.\n\n#{$usage}"
|
92
|
-
end
|
93
|
-
|
94
|
-
help = {
|
95
|
-
:help => $help_usage,
|
96
|
-
:set => $set_usage,
|
97
|
-
:get => $get_usage,
|
98
|
-
:delete => $delete_usage,
|
99
|
-
:generate => $generate_usage,
|
100
|
-
:copy => $copy_usage,
|
101
|
-
:rename => $rename_usage,
|
102
|
-
:master => $master_usage,
|
103
|
-
:list => $list_usage
|
104
|
-
}
|
105
|
-
|
106
|
-
usage = help[command]
|
107
|
-
if usage.nil?
|
108
|
-
raise CommandError, "No help available for command: #{args[0]}."
|
109
|
-
end
|
110
|
-
|
111
|
-
@out.puts usage
|
112
|
-
end
|
113
|
-
|
114
|
-
def version(args)
|
115
|
-
@out.puts "tome version #{
|
116
|
-
end
|
117
|
-
|
118
|
-
def set(args)
|
119
|
-
if args.length < 1 || args.length > 2
|
120
|
-
raise CommandError, "Invalid arguments.\n\n#{$set_usage}"
|
121
|
-
end
|
122
|
-
|
123
|
-
created, tome = tome_create_connect()
|
124
|
-
|
125
|
-
case args.length
|
126
|
-
# TODO: Validate that first argument is in [username@]domain form.
|
127
|
-
|
128
|
-
# tome set bar.com
|
129
|
-
# tome set foo@bar.com
|
130
|
-
when 1
|
131
|
-
id = args[0]
|
132
|
-
password = prompt_password()
|
133
|
-
|
134
|
-
# tome set bar.com p4ssw0rd
|
135
|
-
# tome set foo@bar.com p4ssw0rd
|
136
|
-
when 2
|
137
|
-
id = args[0]
|
138
|
-
password = args[1]
|
139
|
-
end
|
140
|
-
|
141
|
-
exists = !tome.get(id).nil?
|
142
|
-
if exists
|
143
|
-
confirm = prompt_confirm("A password already exists for #{id}. Overwrite (y/n)? ")
|
144
|
-
if !confirm
|
145
|
-
raise CommandError, 'Aborted.'
|
146
|
-
end
|
147
|
-
end
|
148
|
-
|
149
|
-
created = tome.set(id, password)
|
150
|
-
if created
|
151
|
-
@out.print 'Created '
|
152
|
-
else
|
153
|
-
@out.print 'Updated '
|
154
|
-
end
|
155
|
-
|
156
|
-
@out.puts "password for #{id}."
|
157
|
-
end
|
158
|
-
|
159
|
-
def get(args)
|
160
|
-
if args.length != 1
|
161
|
-
raise CommandError, "Invalid arguments.\n\n#{$get_usage}"
|
162
|
-
end
|
163
|
-
|
164
|
-
# tome get bar.com
|
165
|
-
# tome get foo@bar.com
|
166
|
-
pattern = args[0]
|
167
|
-
|
168
|
-
tome = tome_connect()
|
169
|
-
matches = tome.find(pattern)
|
170
|
-
|
171
|
-
if matches.empty?
|
172
|
-
raise CommandError, "No password found for #{pattern}."
|
173
|
-
elsif matches.count == 1
|
174
|
-
match = matches.first
|
175
|
-
@out.puts "Password for #{match.first}:"
|
176
|
-
@out.puts match.last
|
177
|
-
else
|
178
|
-
@out.puts "Multiple matches for #{pattern}:"
|
179
|
-
matches.each { |key, password|
|
180
|
-
@out.puts "#{key}: #{password}"
|
181
|
-
}
|
182
|
-
end
|
183
|
-
end
|
184
|
-
|
185
|
-
def delete(args)
|
186
|
-
if args.length != 1
|
187
|
-
raise CommandError, "Invalid arguments.\n\n#{$delete_usage}"
|
188
|
-
end
|
189
|
-
|
190
|
-
tome = tome_connect()
|
191
|
-
|
192
|
-
# tome del bar.com
|
193
|
-
# tome del foo@bar.com
|
194
|
-
id = args[0]
|
195
|
-
|
196
|
-
exists = !tome.get(id).nil?
|
197
|
-
if exists
|
198
|
-
confirmed = prompt_confirm("Are you sure you want to delete the password for #{id} (y/n)? ")
|
199
|
-
if !confirmed
|
200
|
-
raise CommandError, 'Aborted.'
|
201
|
-
end
|
202
|
-
end
|
203
|
-
|
204
|
-
deleted = tome.delete(id)
|
205
|
-
|
206
|
-
if deleted
|
207
|
-
@out.puts "Deleted password for #{id}."
|
208
|
-
else
|
209
|
-
@out.puts "No password found for #{id}."
|
210
|
-
end
|
211
|
-
end
|
212
|
-
|
213
|
-
def generate(args)
|
214
|
-
if args.length != 1
|
215
|
-
raise CommandError, "Invalid arguments.\n\n#{$generate_usage}"
|
216
|
-
end
|
217
|
-
|
218
|
-
created, tome = tome_create_connect()
|
219
|
-
|
220
|
-
# tome gen bar.com
|
221
|
-
# tome gen foo@bar.com
|
222
|
-
id = args[0]
|
223
|
-
password = generate_password()
|
224
|
-
|
225
|
-
exists = !tome.get(id).nil?
|
226
|
-
if exists
|
227
|
-
confirm = prompt_confirm("A password already exists for #{id}. Overwrite (y/n)? ")
|
228
|
-
if !confirm
|
229
|
-
raise CommandError, 'Aborted.'
|
230
|
-
end
|
231
|
-
end
|
232
|
-
|
233
|
-
created = tome.set(id, password)
|
234
|
-
Clipboard.copy(password)
|
235
|
-
|
236
|
-
if created
|
237
|
-
@out.puts "Generated and copied password for #{id}."
|
238
|
-
else
|
239
|
-
@out.puts "Updated and copied password for #{id}."
|
240
|
-
end
|
241
|
-
end
|
242
|
-
|
243
|
-
def copy(args)
|
244
|
-
if args.length != 1
|
245
|
-
raise CommandError, "Invalid arguments.\n\n#{$copy_usage}"
|
246
|
-
end
|
247
|
-
|
248
|
-
# tome cp bar.com
|
249
|
-
# tome cp foo@bar.com
|
250
|
-
pattern = args[0]
|
251
|
-
|
252
|
-
tome = tome_connect()
|
253
|
-
matches = tome.find(pattern)
|
254
|
-
|
255
|
-
if matches.empty?
|
256
|
-
raise CommandError, "No password found for #{pattern}."
|
257
|
-
elsif matches.count > 1
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
@
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
rescue
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
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: #{error.message}"
|
33
|
+
return 1
|
34
|
+
rescue FileFormatError => error
|
35
|
+
# Fix file separators for Windows.
|
36
|
+
filename = @tome_filename.gsub(File::SEPARATOR, File::ALT_SEPARATOR || File::SEPARATOR)
|
37
|
+
@err.puts "Error: Cannot read #{filename}: #{error.message}"
|
38
|
+
return 1
|
39
|
+
end
|
40
|
+
|
41
|
+
return 0
|
42
|
+
end
|
43
|
+
|
44
|
+
def handle_command(args)
|
45
|
+
# TODO: Handle 'command --help', e.g. 'tome set --help'.
|
46
|
+
|
47
|
+
command = command_from_arg(args[0])
|
48
|
+
|
49
|
+
if command.nil?
|
50
|
+
raise CommandError, "Unrecognized command: #{args[0]}.\n\n#{$usage}"
|
51
|
+
end
|
52
|
+
|
53
|
+
args.shift
|
54
|
+
send(command, args)
|
55
|
+
end
|
56
|
+
|
57
|
+
def command_from_arg(arg)
|
58
|
+
commands = {
|
59
|
+
/\A(help|-h|--help)\z/i => :help,
|
60
|
+
/\A(version|ver|-v|--version)\z/i => :version,
|
61
|
+
/\A(set|s|add)\z/i => :set,
|
62
|
+
/\A(get|g|show)\z/i => :get,
|
63
|
+
/\A(delete|del|rm|remove)\z/i => :delete,
|
64
|
+
/\A(generate|gen)\z/i => :generate,
|
65
|
+
/\A(copy|cp)\z/i => :copy,
|
66
|
+
/\A(rename|ren|rn)\z/i => :rename,
|
67
|
+
/\A(master)\z/i => :master,
|
68
|
+
/\A(list|ls)\z/i => :list
|
69
|
+
}
|
70
|
+
|
71
|
+
commands.each { |pattern, command|
|
72
|
+
return command if arg =~ pattern
|
73
|
+
}
|
74
|
+
|
75
|
+
return nil
|
76
|
+
end
|
77
|
+
|
78
|
+
def help(args)
|
79
|
+
if args.length > 1
|
80
|
+
raise CommandError, "Invalid arguments.\n\n#{$usage}"
|
81
|
+
end
|
82
|
+
|
83
|
+
if args.empty?
|
84
|
+
usage()
|
85
|
+
return
|
86
|
+
end
|
87
|
+
|
88
|
+
command = command_from_arg(args[0])
|
89
|
+
|
90
|
+
if command.nil?
|
91
|
+
raise CommandError, "No help for unrecognized command: #{args[0]}.\n\n#{$usage}"
|
92
|
+
end
|
93
|
+
|
94
|
+
help = {
|
95
|
+
:help => $help_usage,
|
96
|
+
:set => $set_usage,
|
97
|
+
:get => $get_usage,
|
98
|
+
:delete => $delete_usage,
|
99
|
+
:generate => $generate_usage,
|
100
|
+
:copy => $copy_usage,
|
101
|
+
:rename => $rename_usage,
|
102
|
+
:master => $master_usage,
|
103
|
+
:list => $list_usage
|
104
|
+
}
|
105
|
+
|
106
|
+
usage = help[command]
|
107
|
+
if usage.nil?
|
108
|
+
raise CommandError, "No help available for command: #{args[0]}."
|
109
|
+
end
|
110
|
+
|
111
|
+
@out.puts usage
|
112
|
+
end
|
113
|
+
|
114
|
+
def version(args)
|
115
|
+
@out.puts "tome version #{VERSION}"
|
116
|
+
end
|
117
|
+
|
118
|
+
def set(args)
|
119
|
+
if args.length < 1 || args.length > 2
|
120
|
+
raise CommandError, "Invalid arguments.\n\n#{$set_usage}"
|
121
|
+
end
|
122
|
+
|
123
|
+
created, tome = tome_create_connect()
|
124
|
+
|
125
|
+
case args.length
|
126
|
+
# TODO: Validate that first argument is in [username@]domain form.
|
127
|
+
|
128
|
+
# tome set bar.com
|
129
|
+
# tome set foo@bar.com
|
130
|
+
when 1
|
131
|
+
id = args[0]
|
132
|
+
password = prompt_password()
|
133
|
+
|
134
|
+
# tome set bar.com p4ssw0rd
|
135
|
+
# tome set foo@bar.com p4ssw0rd
|
136
|
+
when 2
|
137
|
+
id = args[0]
|
138
|
+
password = args[1]
|
139
|
+
end
|
140
|
+
|
141
|
+
exists = !tome.get(id).nil?
|
142
|
+
if exists
|
143
|
+
confirm = prompt_confirm("A password already exists for #{id}. Overwrite (y/n)? ")
|
144
|
+
if !confirm
|
145
|
+
raise CommandError, 'Aborted.'
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
created = tome.set(id, password)
|
150
|
+
if created
|
151
|
+
@out.print 'Created '
|
152
|
+
else
|
153
|
+
@out.print 'Updated '
|
154
|
+
end
|
155
|
+
|
156
|
+
@out.puts "password for #{id}."
|
157
|
+
end
|
158
|
+
|
159
|
+
def get(args)
|
160
|
+
if args.length != 1
|
161
|
+
raise CommandError, "Invalid arguments.\n\n#{$get_usage}"
|
162
|
+
end
|
163
|
+
|
164
|
+
# tome get bar.com
|
165
|
+
# tome get foo@bar.com
|
166
|
+
pattern = args[0]
|
167
|
+
|
168
|
+
tome = tome_connect()
|
169
|
+
matches = tome.find(pattern)
|
170
|
+
|
171
|
+
if matches.empty?
|
172
|
+
raise CommandError, "No password found for #{pattern}."
|
173
|
+
elsif matches.count == 1
|
174
|
+
match = matches.first
|
175
|
+
@out.puts "Password for #{match.first}:"
|
176
|
+
@out.puts match.last
|
177
|
+
else
|
178
|
+
@out.puts "Multiple matches for #{pattern}:"
|
179
|
+
matches.each { |key, password|
|
180
|
+
@out.puts "#{key}: #{password}"
|
181
|
+
}
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def delete(args)
|
186
|
+
if args.length != 1
|
187
|
+
raise CommandError, "Invalid arguments.\n\n#{$delete_usage}"
|
188
|
+
end
|
189
|
+
|
190
|
+
tome = tome_connect()
|
191
|
+
|
192
|
+
# tome del bar.com
|
193
|
+
# tome del foo@bar.com
|
194
|
+
id = args[0]
|
195
|
+
|
196
|
+
exists = !tome.get(id).nil?
|
197
|
+
if exists
|
198
|
+
confirmed = prompt_confirm("Are you sure you want to delete the password for #{id} (y/n)? ")
|
199
|
+
if !confirmed
|
200
|
+
raise CommandError, 'Aborted.'
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
deleted = tome.delete(id)
|
205
|
+
|
206
|
+
if deleted
|
207
|
+
@out.puts "Deleted password for #{id}."
|
208
|
+
else
|
209
|
+
@out.puts "No password found for #{id}."
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
def generate(args)
|
214
|
+
if args.length != 1
|
215
|
+
raise CommandError, "Invalid arguments.\n\n#{$generate_usage}"
|
216
|
+
end
|
217
|
+
|
218
|
+
created, tome = tome_create_connect()
|
219
|
+
|
220
|
+
# tome gen bar.com
|
221
|
+
# tome gen foo@bar.com
|
222
|
+
id = args[0]
|
223
|
+
password = generate_password()
|
224
|
+
|
225
|
+
exists = !tome.get(id).nil?
|
226
|
+
if exists
|
227
|
+
confirm = prompt_confirm("A password already exists for #{id}. Overwrite (y/n)? ")
|
228
|
+
if !confirm
|
229
|
+
raise CommandError, 'Aborted.'
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
created = tome.set(id, password)
|
234
|
+
Clipboard.copy(password)
|
235
|
+
|
236
|
+
if created
|
237
|
+
@out.puts "Generated and copied password for #{id}."
|
238
|
+
else
|
239
|
+
@out.puts "Updated and copied password for #{id}."
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
def copy(args)
|
244
|
+
if args.length != 1
|
245
|
+
raise CommandError, "Invalid arguments.\n\n#{$copy_usage}"
|
246
|
+
end
|
247
|
+
|
248
|
+
# tome cp bar.com
|
249
|
+
# tome cp foo@bar.com
|
250
|
+
pattern = args[0]
|
251
|
+
|
252
|
+
tome = tome_connect()
|
253
|
+
matches = tome.find(pattern)
|
254
|
+
|
255
|
+
if matches.empty?
|
256
|
+
raise CommandError, "No password found for #{pattern}."
|
257
|
+
elsif matches.count > 1
|
258
|
+
match = pick_match(pattern, matches)
|
259
|
+
else
|
260
|
+
match = matches.first
|
261
|
+
end
|
262
|
+
|
263
|
+
name = match.first
|
264
|
+
password = match.last
|
265
|
+
|
266
|
+
Clipboard.copy(password)
|
267
|
+
if Clipboard.paste == password
|
268
|
+
@out.puts "Password for #{name} copied to clipboard."
|
269
|
+
else
|
270
|
+
@err.puts "Failed to copy password for #{name} to clipboard."
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
def pick_match(pattern, matches)
|
275
|
+
matches = matches.to_a
|
276
|
+
|
277
|
+
@out.puts "Found multiple matches for \"#{pattern}\":\n\n"
|
278
|
+
matches.each_with_index { |match, i|
|
279
|
+
@out.puts "\t#{i + 1}: #{match[0]}"
|
280
|
+
}
|
281
|
+
|
282
|
+
begin
|
283
|
+
@out.print "\n> "
|
284
|
+
|
285
|
+
index = @in.gets.to_i
|
286
|
+
if index <= 0 || index > matches.length
|
287
|
+
@out.puts 'Invalid option.'
|
288
|
+
raise
|
289
|
+
end
|
290
|
+
|
291
|
+
return matches[index - 1]
|
292
|
+
rescue
|
293
|
+
retry
|
294
|
+
end
|
295
|
+
end
|
296
|
+
|
297
|
+
def list(args)
|
298
|
+
if !args.empty?
|
299
|
+
raise CommandError, "Invalid arguments.\n\n#{$list_usage}"
|
300
|
+
end
|
301
|
+
|
302
|
+
tome = tome_connect()
|
303
|
+
|
304
|
+
count = 0
|
305
|
+
tome.each_password { |id, password|
|
306
|
+
@out.puts "#{id}: #{password}"
|
307
|
+
count += 1
|
308
|
+
}
|
309
|
+
|
310
|
+
if count == 0
|
311
|
+
@out.puts 'No passwords stored.'
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
def rename(args)
|
316
|
+
if args.count != 2
|
317
|
+
raise CommandError, "Invalid arguments.\n\n#{$rename_usage}"
|
318
|
+
end
|
319
|
+
|
320
|
+
tome = tome_connect()
|
321
|
+
|
322
|
+
old_id = args[0]
|
323
|
+
new_id = args[1]
|
324
|
+
|
325
|
+
overwriting = !tome.get(new_id).nil?
|
326
|
+
if overwriting
|
327
|
+
confirm = prompt_confirm("A password already exists for #{new_id}. Overwrite (y/n)? ")
|
328
|
+
if !confirm
|
329
|
+
raise CommandError, 'Aborted.'
|
330
|
+
end
|
331
|
+
end
|
332
|
+
|
333
|
+
renamed = tome.rename(old_id, new_id)
|
334
|
+
|
335
|
+
if !renamed
|
336
|
+
raise CommandError, "#{old_id} does not exist."
|
337
|
+
else
|
338
|
+
@out.puts "#{old_id} renamed to #{new_id}."
|
339
|
+
end
|
340
|
+
end
|
341
|
+
|
342
|
+
def master(args)
|
343
|
+
if args.count > 0
|
344
|
+
raise CommandError, "Invalid arguments.\n\n#{$master_usage}"
|
345
|
+
end
|
346
|
+
|
347
|
+
created, tome = tome_create_connect()
|
348
|
+
|
349
|
+
if !created
|
350
|
+
master_password = prompt_password('New master password')
|
351
|
+
tome.master_password = master_password
|
352
|
+
@out.puts 'Master password updated.'
|
353
|
+
end
|
354
|
+
end
|
355
|
+
|
356
|
+
def generate_password
|
357
|
+
Passgen.generate(:length => 30, :symbols => true)
|
358
|
+
end
|
359
|
+
|
360
|
+
def prompt_password(prompt = 'Password')
|
361
|
+
begin
|
362
|
+
@err.print "#{prompt}: "
|
363
|
+
password = input_password()
|
364
|
+
|
365
|
+
if password.empty?
|
366
|
+
@err.puts 'Password cannot be blank.'
|
367
|
+
raise
|
368
|
+
end
|
369
|
+
|
370
|
+
@err.print "#{prompt} (verify): "
|
371
|
+
verify = input_password()
|
372
|
+
|
373
|
+
if verify != password
|
374
|
+
@err.puts 'Passwords do not match.'
|
375
|
+
raise
|
376
|
+
end
|
377
|
+
rescue
|
378
|
+
retry
|
379
|
+
end
|
380
|
+
|
381
|
+
return password
|
382
|
+
end
|
383
|
+
|
384
|
+
def input_password
|
385
|
+
input = proc { |stdin|
|
386
|
+
raw = stdin.gets
|
387
|
+
return nil if raw.nil?
|
388
|
+
|
389
|
+
password = raw.strip
|
390
|
+
@out.puts
|
391
|
+
|
392
|
+
return password
|
393
|
+
}
|
394
|
+
|
395
|
+
begin
|
396
|
+
@in.noecho { |stdin|
|
397
|
+
input.call stdin
|
398
|
+
}
|
399
|
+
rescue Errno::EBADF
|
400
|
+
# This can happen when stdin refers to a file or pipe.
|
401
|
+
# In this case, we ignore 'no echo' and do normal input.
|
402
|
+
input.call @in
|
403
|
+
end
|
404
|
+
end
|
405
|
+
|
406
|
+
def prompt_confirm(prompt)
|
407
|
+
begin
|
408
|
+
@out.print prompt
|
409
|
+
|
410
|
+
confirm = @in.gets.strip
|
411
|
+
|
412
|
+
if confirm =~ /\Ay/i
|
413
|
+
return true
|
414
|
+
elsif confirm =~ /\An/i
|
415
|
+
return false
|
416
|
+
end
|
417
|
+
rescue
|
418
|
+
retry
|
419
|
+
end
|
420
|
+
end
|
421
|
+
|
422
|
+
def usage
|
423
|
+
@err.puts "tome version #{VERSION}"
|
424
|
+
@err.puts
|
425
|
+
@err.puts $usage
|
426
|
+
end
|
427
|
+
|
428
|
+
def tome_connect
|
429
|
+
if !Tome.exists?(@tome_filename)
|
430
|
+
raise CommandError, "Tome database does not exist. Use 'tome set' or 'tome generate' to create a password first."
|
431
|
+
end
|
432
|
+
|
433
|
+
begin
|
434
|
+
@err.print 'Master password: '
|
435
|
+
master_password = input_password()
|
436
|
+
tome = Tome.new(@tome_filename, master_password)
|
437
|
+
rescue MasterPasswordError
|
438
|
+
@err.puts 'Incorrect master password.'
|
439
|
+
|
440
|
+
if master_password.nil?
|
441
|
+
raise CommandError, 'Authentication failed.'
|
442
|
+
else
|
443
|
+
retry
|
444
|
+
end
|
445
|
+
end
|
446
|
+
|
447
|
+
return tome
|
448
|
+
end
|
449
|
+
|
450
|
+
def tome_create_connect
|
451
|
+
if !Tome.exists?(@tome_filename)
|
452
|
+
@out.puts 'Creating tome database.'
|
453
|
+
master_password = prompt_password('Master password')
|
454
|
+
return true, Tome.create!(@tome_filename, master_password)
|
455
|
+
else
|
456
|
+
return false, tome_connect()
|
457
|
+
end
|
458
|
+
end
|
459
|
+
end
|
460
|
+
end
|