sesame-cli 0.1.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.
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'tmpdir'
4
+ require 'securerandom'
5
+ require 'bases'
6
+ require 'digest/crc16'
7
+ require 'rbnacl/libsodium'
8
+ require 'json'
9
+
10
+ module Sesame
11
+ class Cave
12
+ attr_accessor :item
13
+
14
+ def initialize(path)
15
+ @words = Dict.load
16
+ raise Fail, "Unexpected dictionary length" unless @words.length == 2048
17
+ @cave = File.expand_path(File.join(path, 'sesame.cave'))
18
+ @lock = File.join(Dir.tmpdir, 'sesame.lock')
19
+ @store = nil
20
+ @dirty = false
21
+ @item = nil
22
+ end
23
+
24
+ def path
25
+ @cave
26
+ end
27
+
28
+ def exists?
29
+ File.exists?(@cave)
30
+ end
31
+
32
+ def locked?
33
+ File.exists?(@lock)
34
+ end
35
+
36
+ def open?
37
+ !@store.nil?
38
+ end
39
+
40
+ def dirty?
41
+ @dirty
42
+ end
43
+
44
+ def create!(phrase)
45
+ raise Fail, "Cannot create; store already exists" if exists? || locked? || open?
46
+ @store = {}
47
+ insert('sesame', 'cave')
48
+ @dirty = true
49
+ # generate an 88-bit random number as a hex string
50
+ number =
51
+ if phrase.nil?
52
+ RbNaCl::Util.bin2hex(RbNaCl::Random.random_bytes(11))
53
+ else
54
+ words = phrase.downcase.split(' ')
55
+ raise Fail, "There must be exactly eight words" unless words.length == 8
56
+ raise Fail, "Unrecognised word used" unless words.all? { |word| @words.include?(word) }
57
+ Bases.val(words).in_base(@words).to_base(16)
58
+ end
59
+ @secret = _create_secret(number)
60
+ # convert the hex string to a word string (base 2048)
61
+ words = Bases.val(number).in_base(16).to_base(@words, array: true)
62
+ # make sure it's always 8 words long (i.e. zero-pad the phrase)
63
+ words.unshift(@words[0]) while words.length < 8
64
+ # sanity check the conversion
65
+ raise Fail, "Base conversion failure" unless Bases.val(words).in_base(@words).to_base(16) == number.to_s
66
+ # return the phrase to the user
67
+ words.join(' ')
68
+ rescue RbNaCl::CryptoError => e
69
+ raise Fail, e.message
70
+ end
71
+
72
+ def open(phrase)
73
+ raise Fail, "Cannot open store" if !exists? || locked? || open?
74
+ # make sure the phrase is 8 words long and that the words are valid
75
+ words = phrase.downcase.split(' ')
76
+ raise Fail, "There must be exactly eight words" unless words.length == 8
77
+ raise Fail, "Unrecognised word used" unless words.all? { |word| @words.include?(word) }
78
+ # convert the phrase to a hex string
79
+ number = Bases.val(words).in_base(@words).to_base(16)
80
+ @secret = _create_secret(number)
81
+ # load the store and decrypt it
82
+ box = RbNaCl::SimpleBox.from_secret_key(@secret)
83
+ encrypted_data = File.open(@cave, "rb") { |file| file.read }
84
+ data = box.decrypt(encrypted_data)
85
+ @store = JSON.parse(data)
86
+ @dirty = false
87
+ rescue RbNaCl::CryptoError => e
88
+ raise Fail, e.message
89
+ end
90
+
91
+ def close
92
+ raise Fail, "Cannot close store; it's not open" unless open?
93
+ return unless dirty?
94
+ # encrypt and save the store
95
+ box = RbNaCl::SimpleBox.from_secret_key(@secret)
96
+ data = @store.to_json
97
+ encrypted_data = box.encrypt(data)
98
+ File.open(@cave, "wb") { |file| file.write(encrypted_data) }
99
+ rescue RbNaCl::CryptoError => e
100
+ raise Fail, e.messsage
101
+ ensure
102
+ @store = nil
103
+ @secret = nil
104
+ end
105
+
106
+ def lock
107
+ raise Fail, "Cannot lock cave; it's not open" unless open?
108
+ # create a 16-bit checksum of the secret key
109
+ item = _find('sesame', 'cave')
110
+ data = @secret + item[:index].to_s
111
+ checksum = Digest::CRC16.checksum(data).to_s(16)
112
+ # convert it to a short sequence of short words
113
+ words = Bases.val(checksum).in_base(16).to_base((0...16).to_a, array: true)
114
+ words.map! { |num, i| @words[num.to_i] }
115
+ words.unshift(@words[0]) while words.length < 4
116
+ # create a key from it
117
+ key = _create_secret(checksum)
118
+ # encrypt and save the secret
119
+ box = RbNaCl::SimpleBox.from_secret_key(key)
120
+ encrypted_data = box.encrypt(@secret)
121
+ File.open(@lock, "wb") { |file| file.write(encrypted_data) }
122
+ # return the phrase to the user
123
+ words.join(' ')
124
+ rescue RbNaCl::CryptoError => e
125
+ raise Fail, e.messsage
126
+ ensure
127
+ @item = nil
128
+ close
129
+ end
130
+
131
+ def unlock(phrase)
132
+ raise Fail, "Cannot unlock store; it's not locked" unless locked?
133
+ raise Fail, "Cannot unlock store; it's already open" if open?
134
+ # make sure the phrase is 4 words long and that the words are valid
135
+ words = phrase.downcase.split(' ')
136
+ if words.length == 1 && phrase.length == 4
137
+ words = []
138
+ phrase.each_char do |char|
139
+ words << @words[0..15].find { |word| word[0] == char }
140
+ end
141
+ end
142
+ raise Fail, "There must be exactly four words" unless words.length == 4
143
+ raise Fail, "Unrecognised word used" unless words.all? { |word| @words[0..15].include?(word) }
144
+ # convert the phrase to a hex string
145
+ words.map! { |word, i| @words.index(word) }
146
+ checksum = Bases.val(words).in_base((0...16).to_a).to_base(16)
147
+ key = _create_secret(checksum)
148
+ # load the secret and decrypt it
149
+ box = RbNaCl::SimpleBox.from_secret_key(key)
150
+ encrypted_data = File.open(@lock, "rb") { |file| file.read }
151
+ @secret = box.decrypt(encrypted_data)
152
+ # load the store and decrypt it
153
+ box = RbNaCl::SimpleBox.from_secret_key(@secret)
154
+ encrypted_data = File.open(@cave, "rb") { |file| file.read }
155
+ data = box.decrypt(encrypted_data)
156
+ @store = JSON.parse(data)
157
+ item = _find('sesame', 'cave')
158
+ data = @secret + item[:index].to_s
159
+ raise "Checksum failure" unless Digest::CRC16.checksum(data).to_s(16) == checksum
160
+ @dirty = false
161
+ rescue RbNaCl::CryptoError => e
162
+ raise Fail, e.message
163
+ ensure
164
+ @item = nil
165
+ end
166
+
167
+ def forget
168
+ File.delete(@lock)
169
+ end
170
+
171
+ def index
172
+ raise Fail, "Cannot list the store; it's not open" unless open?
173
+ @store
174
+ end
175
+
176
+ def unique?(service)
177
+ raise Fail, "Cannot test service uniqueness; store not open" unless open?
178
+ return if @store[service].nil?
179
+ @store[service].count < 2
180
+ end
181
+
182
+ def get(service, user, index=nil)
183
+ raise Fail, "Cannot get service details; store not open" unless open?
184
+ raise Fail, "Cannot get the sesame service" if service.downcase == 'sesame'
185
+ item = _find(service, user)
186
+ item[:index] = index unless index.nil?
187
+ _generate_phrase(item)
188
+ end
189
+
190
+ def insert(service, user, index=nil)
191
+ raise Fail, "Cannot insert service details; store not open" unless open?
192
+ if @store.length > 0
193
+ raise Fail, "Cannot insert the sesame service" if service.downcase == 'sesame'
194
+ end
195
+ raise Fail, "Service cannot be empty" if service.strip.length == 0
196
+ raise Fail, "User cannot be empty" if user.strip.length == 0
197
+ item = _find(service, user)
198
+ raise Fail, "Service and/or user already exists" unless item.nil?
199
+ @store[service] ||= {}
200
+ @store[service][user] = index || 0
201
+ @dirty = true
202
+ return if service == "sesame"
203
+ item = _find(service, user)
204
+ _generate_phrase(item)
205
+ end
206
+
207
+ def update(service, user, index=nil)
208
+ raise Fail, "Cannot update service details; store not open" unless open?
209
+ item = _find(service, user)
210
+ raise Fail, "Unable to find that service and/or user" unless item.nil?
211
+ if index.nil?
212
+ index = item[:index] + 1
213
+ end
214
+ index = 0 if index < 0
215
+ user = item[:user]
216
+ @store[service][user] = index
217
+ @dirty = true
218
+ item = _find(service, user)
219
+ _generate_phrase(item) unless service == "sesame"
220
+ end
221
+
222
+ def delete(service, user)
223
+ raise Fail, "Cannot delete service details; store not open" unless open?
224
+ raise Fail, "Cannot delete the sesame service" if service.downcase == 'sesame'
225
+ item = _find(service, user)
226
+ raise Fail, "Unable to find that service and/or user" unless item.nil?
227
+ user = item[:user]
228
+ @store[service].delete(user)
229
+ @store.delete(service) if @store[service].count == 0
230
+ @dirty = true
231
+ _generate_phrase(item)
232
+ end
233
+
234
+ protected
235
+
236
+ def _create_secret(number)
237
+ mem = 2 ** 30
238
+ ops = mem / 32
239
+ # salt is blank so we always get the same result
240
+ salt = RbNaCl::Util.zeros(RbNaCl::PasswordHash::SCrypt::SALTBYTES)
241
+ RbNaCl::PasswordHash.scrypt(number, salt, ops, mem, RbNaCl::SecretBox.key_bytes)
242
+ rescue RbNaCl::CryptoError => e
243
+ raise Fail, e.message
244
+ end
245
+
246
+ def _find(service, user)
247
+ return nil if service.nil? || @store[service].nil?
248
+ @item =
249
+ if user.nil?
250
+ item = @store[service].first
251
+ {
252
+ service: service,
253
+ user: item.first,
254
+ index: item.last
255
+ }
256
+ elsif @store[service][user].nil?
257
+ nil
258
+ else
259
+ index = @store[service][user]
260
+ {
261
+ service: service,
262
+ user: user,
263
+ index: index
264
+ }
265
+ end
266
+ end
267
+
268
+ def _generate_phrase(item)
269
+ raise Fail, "Empty item when generating phrase" if item.nil?
270
+ mem = 2 ** 20
271
+ ops = mem / 32
272
+ raise Fail, "Cannot generate a phrase; byte count mismatch" unless RbNaCl::PasswordHash::SCrypt::SALTBYTES == @secret.length
273
+ hash = RbNaCl::PasswordHash.scrypt(item.to_json, @secret, ops, mem, 44)
274
+ bits = hash.bytes.map { |byte| byte % 2 }
275
+ words = Bases.val(bits).in_base(2).to_base(@words, array: true)
276
+ words.unshift(@words[0]) while words.length < 4
277
+ words.join(' ')
278
+ rescue RbNaCl::CryptoError => e
279
+ raise Fail, e.message
280
+ end
281
+ end
282
+ end