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.
- checksums.yaml +7 -0
- data/.document +5 -0
- data/Gemfile +20 -0
- data/Gemfile.lock +109 -0
- data/README.md +234 -0
- data/Rakefile +44 -0
- data/UNLICENSE +24 -0
- data/VERSION +1 -0
- data/bin/sesame +42 -0
- data/lib/sesame/cave.rb +282 -0
- data/lib/sesame/dict.rb +2062 -0
- data/lib/sesame/fail.rb +4 -0
- data/lib/sesame/jinn.rb +308 -0
- data/lib/sesame/lang/en.yml +50 -0
- data/lib/sesame.rb +4 -0
- data/sesame-cli.gemspec +100 -0
- data/sesame.gemspec +99 -0
- data/spec/spec_helper.rb +30 -0
- metadata +273 -0
data/lib/sesame/cave.rb
ADDED
@@ -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
|