sesame-cli 0.1.0 → 0.2.0
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 +4 -4
- data/.rubocop.yml +6 -0
- data/Gemfile +4 -1
- data/Gemfile.lock +16 -0
- data/README.md +36 -22
- data/Rakefile +14 -16
- data/VERSION +1 -1
- data/bin/sesame +12 -10
- data/lib/sesame/cave.rb +101 -61
- data/lib/sesame/dict.rb +7 -5
- data/lib/sesame/fail.rb +2 -0
- data/lib/sesame/jinn.rb +134 -84
- data/lib/sesame/lang/en.yml +2 -0
- data/lib/sesame.rb +2 -0
- data/sesame-cli.gemspec +12 -6
- data/spec/cave_spec.rb +188 -0
- data/spec/dict_spec.rb +13 -0
- data/spec/jinn_spec.rb +7 -0
- data/spec/spec_helper.rb +9 -4
- metadata +22 -5
- data/sesame.gemspec +0 -99
data/lib/sesame/cave.rb
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'tmpdir'
|
4
4
|
require 'securerandom'
|
@@ -8,41 +8,54 @@ require 'rbnacl/libsodium'
|
|
8
8
|
require 'json'
|
9
9
|
|
10
10
|
module Sesame
|
11
|
+
# The Cave class implements a simple password manager.
|
11
12
|
class Cave
|
12
13
|
attr_accessor :item
|
13
14
|
|
14
|
-
|
15
|
+
# Initialize with the path where the cave file will be stored. Optionally
|
16
|
+
# specify the complexity of passphrase generation (only change this to lower
|
17
|
+
# values to make tests run more quickly).
|
18
|
+
def initialize(path, pow = 30)
|
15
19
|
@words = Dict.load
|
16
|
-
raise Fail,
|
20
|
+
raise Fail, 'Unexpected dictionary length' unless @words.length == 2048
|
17
21
|
@cave = File.expand_path(File.join(path, 'sesame.cave'))
|
18
22
|
@lock = File.join(Dir.tmpdir, 'sesame.lock')
|
19
23
|
@store = nil
|
20
24
|
@dirty = false
|
21
25
|
@item = nil
|
26
|
+
@pow = pow
|
22
27
|
end
|
23
28
|
|
29
|
+
# Return the full path of the cave file.
|
24
30
|
def path
|
25
31
|
@cave
|
26
32
|
end
|
27
33
|
|
34
|
+
# True if the cave file exists; false otherwise.
|
28
35
|
def exists?
|
29
|
-
File.
|
36
|
+
File.exist?(@cave)
|
30
37
|
end
|
31
38
|
|
39
|
+
# True if the lock file exists; false otherwise.
|
32
40
|
def locked?
|
33
|
-
File.
|
41
|
+
File.exist?(@lock)
|
34
42
|
end
|
35
43
|
|
44
|
+
# True if the cave file has been loaded into memory and decrypted.
|
36
45
|
def open?
|
37
46
|
!@store.nil?
|
38
47
|
end
|
39
48
|
|
49
|
+
# True if the cave has been modified and needs to be persisted.
|
40
50
|
def dirty?
|
41
51
|
@dirty
|
42
52
|
end
|
43
53
|
|
44
|
-
|
45
|
-
|
54
|
+
# Create a new cave. If the optional phrase is not supplied, then a random
|
55
|
+
# phrase will be returned (this is preferable; users should not select their
|
56
|
+
# own passphrase, because humans can't random).
|
57
|
+
def create!(phrase = nil)
|
58
|
+
raise Fail, 'Cannot create; store already exists' if exists? || locked? || open?
|
46
59
|
@store = {}
|
47
60
|
insert('sesame', 'cave')
|
48
61
|
@dirty = true
|
@@ -52,35 +65,40 @@ module Sesame
|
|
52
65
|
RbNaCl::Util.bin2hex(RbNaCl::Random.random_bytes(11))
|
53
66
|
else
|
54
67
|
words = phrase.downcase.split(' ')
|
55
|
-
raise Fail,
|
56
|
-
raise Fail,
|
68
|
+
raise Fail, 'There must be exactly eight words' unless words.length == 8
|
69
|
+
raise Fail, 'Unrecognised word used' unless words.all? { |word| @words.include?(word) }
|
57
70
|
Bases.val(words).in_base(@words).to_base(16)
|
58
71
|
end
|
72
|
+
number.prepend('0') while number.length < 22
|
59
73
|
@secret = _create_secret(number)
|
60
74
|
# convert the hex string to a word string (base 2048)
|
61
75
|
words = Bases.val(number).in_base(16).to_base(@words, array: true)
|
62
76
|
# make sure it's always 8 words long (i.e. zero-pad the phrase)
|
63
77
|
words.unshift(@words[0]) while words.length < 8
|
64
78
|
# sanity check the conversion
|
65
|
-
|
79
|
+
sanity = Bases.val(words).in_base(@words).to_base(16)
|
80
|
+
sanity.prepend('0') while sanity.length < 22
|
81
|
+
raise Fail, 'Base conversion failure' unless sanity == number
|
66
82
|
# return the phrase to the user
|
67
83
|
words.join(' ')
|
68
84
|
rescue RbNaCl::CryptoError => e
|
69
85
|
raise Fail, e.message
|
70
86
|
end
|
71
87
|
|
88
|
+
# Open an existing cave, using the supplied phrase to decrypt its contents.
|
72
89
|
def open(phrase)
|
73
|
-
raise Fail,
|
90
|
+
raise Fail, 'Cannot open store' if !exists? || locked? || open?
|
74
91
|
# make sure the phrase is 8 words long and that the words are valid
|
75
92
|
words = phrase.downcase.split(' ')
|
76
|
-
raise Fail,
|
77
|
-
raise Fail,
|
93
|
+
raise Fail, 'There must be exactly eight words' unless words.length == 8
|
94
|
+
raise Fail, 'Unrecognised word used' unless words.all? { |word| @words.include?(word) }
|
78
95
|
# convert the phrase to a hex string
|
79
96
|
number = Bases.val(words).in_base(@words).to_base(16)
|
97
|
+
number.prepend('0') while number.length < 22
|
80
98
|
@secret = _create_secret(number)
|
81
99
|
# load the store and decrypt it
|
82
100
|
box = RbNaCl::SimpleBox.from_secret_key(@secret)
|
83
|
-
encrypted_data = File.open(@cave,
|
101
|
+
encrypted_data = File.open(@cave, 'rb', &:read)
|
84
102
|
data = box.decrypt(encrypted_data)
|
85
103
|
@store = JSON.parse(data)
|
86
104
|
@dirty = false
|
@@ -88,49 +106,56 @@ module Sesame
|
|
88
106
|
raise Fail, e.message
|
89
107
|
end
|
90
108
|
|
109
|
+
# Close the cave, encrypting and saving its contents if dirty.
|
91
110
|
def close
|
92
|
-
raise Fail,
|
111
|
+
raise Fail, 'Cannot close store; it\'s not open' unless open?
|
93
112
|
return unless dirty?
|
94
113
|
# encrypt and save the store
|
95
114
|
box = RbNaCl::SimpleBox.from_secret_key(@secret)
|
96
115
|
data = @store.to_json
|
97
116
|
encrypted_data = box.encrypt(data)
|
98
|
-
File.open(@cave,
|
117
|
+
File.open(@cave, 'wb') { |file| file.write(encrypted_data) }
|
99
118
|
rescue RbNaCl::CryptoError => e
|
100
119
|
raise Fail, e.messsage
|
101
120
|
ensure
|
121
|
+
@item = nil
|
102
122
|
@store = nil
|
103
123
|
@secret = nil
|
104
124
|
end
|
105
125
|
|
126
|
+
# Lock the cave by encrypting and saving the secret to a lock file, and then
|
127
|
+
# closing the cave (which may mean saving it, if it was dirty).
|
106
128
|
def lock
|
107
|
-
raise Fail,
|
129
|
+
raise Fail, 'Cannot lock cave; it\'s not open' unless open?
|
108
130
|
# create a 16-bit checksum of the secret key
|
109
131
|
item = _find('sesame', 'cave')
|
110
|
-
data = @secret
|
132
|
+
data = @secret.dup
|
133
|
+
data << item[:index]
|
111
134
|
checksum = Digest::CRC16.checksum(data).to_s(16)
|
135
|
+
checksum.prepend('0') while checksum.length < 4
|
112
136
|
# convert it to a short sequence of short words
|
113
137
|
words = Bases.val(checksum).in_base(16).to_base((0...16).to_a, array: true)
|
114
|
-
words.map! { |num,
|
138
|
+
words.map! { |num, _| @words[num.to_i] }
|
115
139
|
words.unshift(@words[0]) while words.length < 4
|
116
140
|
# create a key from it
|
117
141
|
key = _create_secret(checksum)
|
118
142
|
# encrypt and save the secret
|
119
143
|
box = RbNaCl::SimpleBox.from_secret_key(key)
|
120
144
|
encrypted_data = box.encrypt(@secret)
|
121
|
-
File.open(@lock,
|
145
|
+
File.open(@lock, 'wb') { |file| file.write(encrypted_data) }
|
122
146
|
# return the phrase to the user
|
123
147
|
words.join(' ')
|
124
148
|
rescue RbNaCl::CryptoError => e
|
125
149
|
raise Fail, e.messsage
|
126
150
|
ensure
|
127
|
-
@item = nil
|
128
151
|
close
|
129
152
|
end
|
130
153
|
|
154
|
+
# Unlock the cave, by loading and decrypting the secret using the supplied
|
155
|
+
# phrase, and then using that to load and decrypt the cave itself.
|
131
156
|
def unlock(phrase)
|
132
|
-
raise Fail,
|
133
|
-
raise Fail,
|
157
|
+
raise Fail, 'Cannot unlock store; it\'s not locked' unless locked?
|
158
|
+
raise Fail, 'Cannot unlock store; it\'s already open' if open?
|
134
159
|
# make sure the phrase is 4 words long and that the words are valid
|
135
160
|
words = phrase.downcase.split(' ')
|
136
161
|
if words.length == 1 && phrase.length == 4
|
@@ -139,94 +164,109 @@ module Sesame
|
|
139
164
|
words << @words[0..15].find { |word| word[0] == char }
|
140
165
|
end
|
141
166
|
end
|
142
|
-
raise Fail,
|
143
|
-
raise Fail,
|
167
|
+
raise Fail, 'There must be exactly four words' unless words.length == 4
|
168
|
+
raise Fail, 'Unrecognised word used' unless words.all? { |word| @words[0..15].include?(word) }
|
144
169
|
# convert the phrase to a hex string
|
145
|
-
words.map! { |word,
|
170
|
+
words.map! { |word, _| @words.index(word) }
|
146
171
|
checksum = Bases.val(words).in_base((0...16).to_a).to_base(16)
|
172
|
+
checksum.prepend('0') while checksum.length < 4
|
147
173
|
key = _create_secret(checksum)
|
148
174
|
# load the secret and decrypt it
|
149
175
|
box = RbNaCl::SimpleBox.from_secret_key(key)
|
150
|
-
encrypted_data = File.open(@lock,
|
176
|
+
encrypted_data = File.open(@lock, 'rb', &:read)
|
151
177
|
@secret = box.decrypt(encrypted_data)
|
152
178
|
# load the store and decrypt it
|
153
179
|
box = RbNaCl::SimpleBox.from_secret_key(@secret)
|
154
|
-
encrypted_data = File.open(@cave,
|
180
|
+
encrypted_data = File.open(@cave, 'rb', &:read)
|
155
181
|
data = box.decrypt(encrypted_data)
|
156
182
|
@store = JSON.parse(data)
|
157
183
|
item = _find('sesame', 'cave')
|
158
|
-
data = @secret
|
159
|
-
|
184
|
+
data = @secret.dup
|
185
|
+
data << item[:index]
|
186
|
+
sanity = Digest::CRC16.checksum(data).to_s(16)
|
187
|
+
sanity.prepend('0') while sanity.length < 4
|
188
|
+
raise 'Checksum failure' unless sanity == checksum
|
160
189
|
@dirty = false
|
161
190
|
rescue RbNaCl::CryptoError => e
|
162
191
|
raise Fail, e.message
|
163
192
|
ensure
|
164
193
|
@item = nil
|
194
|
+
forget if locked?
|
165
195
|
end
|
166
196
|
|
197
|
+
# Remove the lock file.
|
167
198
|
def forget
|
168
199
|
File.delete(@lock)
|
169
200
|
end
|
170
201
|
|
202
|
+
# Return the store. Note that this doesn't expose any super-sensitive data;
|
203
|
+
# the store is just a hash of service name, usernames for each service, and
|
204
|
+
# a nonce for each username. These are combined with the secret (which is
|
205
|
+
# itself derived from the users passphrase) to create the password for each
|
206
|
+
# service and username.
|
171
207
|
def index
|
172
|
-
raise Fail,
|
173
|
-
@store
|
208
|
+
raise Fail, 'Cannot list the store; it\'s not open' unless open?
|
209
|
+
@store.sort.to_h
|
174
210
|
end
|
175
211
|
|
212
|
+
# True if a particular service has exactly one username.
|
176
213
|
def unique?(service)
|
177
|
-
raise Fail,
|
214
|
+
raise Fail, 'Cannot test service uniqueness; store not open' unless open?
|
178
215
|
return if @store[service].nil?
|
179
216
|
@store[service].count < 2
|
180
217
|
end
|
181
218
|
|
182
|
-
|
183
|
-
|
184
|
-
raise Fail,
|
219
|
+
# Generate and return the passpgrase for a service and username.
|
220
|
+
def get(service, user = nil, index = nil)
|
221
|
+
raise Fail, 'Cannot get service details; store not open' unless open?
|
222
|
+
raise Fail, 'Cannot get the sesame service' if service.casecmp('sesame').zero?
|
185
223
|
item = _find(service, user)
|
186
224
|
item[:index] = index unless index.nil?
|
187
225
|
_generate_phrase(item)
|
188
226
|
end
|
189
227
|
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
228
|
+
# Insert a new service and username, then generate and return the passphrase.
|
229
|
+
def insert(service, user, index = nil)
|
230
|
+
raise Fail, 'Cannot insert service details; store not open' unless open?
|
231
|
+
if @store.length.positive?
|
232
|
+
raise Fail, 'Cannot insert the sesame service' if service.casecmp('sesame').zero?
|
194
233
|
end
|
195
|
-
raise Fail,
|
196
|
-
raise Fail,
|
234
|
+
raise Fail, 'Service cannot be empty' if service.strip.length.zero?
|
235
|
+
raise Fail, 'User cannot be empty' if user.nil? || user.strip.length.zero?
|
197
236
|
item = _find(service, user)
|
198
|
-
raise Fail,
|
237
|
+
raise Fail, 'Service and/or user already exists' unless item.nil?
|
199
238
|
@store[service] ||= {}
|
200
239
|
@store[service][user] = index || 0
|
201
240
|
@dirty = true
|
202
|
-
return if service ==
|
241
|
+
return if service == 'sesame'
|
203
242
|
item = _find(service, user)
|
204
243
|
_generate_phrase(item)
|
205
244
|
end
|
206
245
|
|
207
|
-
|
208
|
-
|
246
|
+
# Update the nonce for a service and username, then generate and return the
|
247
|
+
# passphrase.
|
248
|
+
def update(service, user = nil, index = nil)
|
249
|
+
raise Fail, 'Cannot update service details; store not open' unless open?
|
209
250
|
item = _find(service, user)
|
210
|
-
raise Fail,
|
211
|
-
if index.nil?
|
212
|
-
|
213
|
-
end
|
214
|
-
index = 0 if index < 0
|
251
|
+
raise Fail, 'Unable to find that service and/or user' if item.nil?
|
252
|
+
index = item[:index] + 1 if index.nil?
|
253
|
+
index = 0 if index.negative?
|
215
254
|
user = item[:user]
|
216
255
|
@store[service][user] = index
|
217
256
|
@dirty = true
|
218
257
|
item = _find(service, user)
|
219
|
-
_generate_phrase(item) unless service ==
|
258
|
+
_generate_phrase(item) unless service == 'sesame'
|
220
259
|
end
|
221
260
|
|
222
|
-
|
223
|
-
|
224
|
-
raise Fail,
|
261
|
+
# Remove a service and username, then generate and return the passphrase.
|
262
|
+
def delete(service, user = nil)
|
263
|
+
raise Fail, 'Cannot delete service details; store not open' unless open?
|
264
|
+
raise Fail, 'Cannot delete the sesame service' if service.casecmp('sesame').zero?
|
225
265
|
item = _find(service, user)
|
226
|
-
raise Fail,
|
266
|
+
raise Fail, 'Unable to find that service and/or user' if item.nil?
|
227
267
|
user = item[:user]
|
228
268
|
@store[service].delete(user)
|
229
|
-
@store.delete(service) if @store[service].count
|
269
|
+
@store.delete(service) if @store[service].count.zero?
|
230
270
|
@dirty = true
|
231
271
|
_generate_phrase(item)
|
232
272
|
end
|
@@ -234,7 +274,7 @@ module Sesame
|
|
234
274
|
protected
|
235
275
|
|
236
276
|
def _create_secret(number)
|
237
|
-
mem = 2
|
277
|
+
mem = 2**@pow
|
238
278
|
ops = mem / 32
|
239
279
|
# salt is blank so we always get the same result
|
240
280
|
salt = RbNaCl::Util.zeros(RbNaCl::PasswordHash::SCrypt::SALTBYTES)
|
@@ -266,10 +306,10 @@ module Sesame
|
|
266
306
|
end
|
267
307
|
|
268
308
|
def _generate_phrase(item)
|
269
|
-
raise Fail,
|
270
|
-
mem = 2
|
309
|
+
raise Fail, 'Empty item when generating phrase' if item.nil?
|
310
|
+
mem = 2**20
|
271
311
|
ops = mem / 32
|
272
|
-
raise Fail,
|
312
|
+
raise Fail, 'Cannot generate a phrase; byte count mismatch' unless RbNaCl::PasswordHash::SCrypt::SALTBYTES == @secret.length
|
273
313
|
hash = RbNaCl::PasswordHash.scrypt(item.to_json, @secret, ops, mem, 44)
|
274
314
|
bits = hash.bytes.map { |byte| byte % 2 }
|
275
315
|
words = Bases.val(bits).in_base(2).to_base(@words, array: true)
|
data/lib/sesame/dict.rb
CHANGED
@@ -1,13 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Sesame
|
4
|
+
# The Dict class provides the list of words used for generating passphrases.
|
2
5
|
class Dict
|
6
|
+
# Return an array of words.
|
3
7
|
def self.load
|
4
|
-
|
8
|
+
_words.split("\n").map(&:strip)
|
5
9
|
end
|
6
|
-
|
7
|
-
private
|
8
10
|
|
9
11
|
def self._words
|
10
|
-
<<-
|
12
|
+
<<-DICTIONARY
|
11
13
|
ant
|
12
14
|
box
|
13
15
|
cat
|
@@ -2056,7 +2058,7 @@ module Sesame
|
|
2056
2058
|
zebra
|
2057
2059
|
zinc
|
2058
2060
|
zone
|
2059
|
-
|
2061
|
+
DICTIONARY
|
2060
2062
|
end
|
2061
2063
|
end
|
2062
2064
|
end
|