sesame-cli 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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