tome 1.0.1 → 1.0.3
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
-
[![
|
13
|
-
[![
|
14
|
-
[![
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
*
|
20
|
-
* `
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
Master password
|
33
|
-
|
34
|
-
Password
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
*
|
88
|
-
*
|
89
|
-
*
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
*
|
97
|
-
*
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
* [
|
119
|
-
|
120
|
-
|
121
|
-
*
|
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
|
+
[![Gem Version](https://badge.fury.io/rb/tome.svg)](http://rubygems.org/gems/tome)
|
13
|
+
[![Build Status](https://secure.travis-ci.org/schmich/tome.svg)](http://travis-ci.org/schmich/tome)
|
14
|
+
[![Dependency Status](https://gemnasium.com/schmich/tome.svg)](http://gemnasium.com/schmich/tome)
|
15
|
+
[![Code Quality](http://img.shields.io/codeclimate/github/schmich/tome.svg)](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
|