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.
data/lib/sesame/cave.rb CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env ruby
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
- def initialize(path)
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, "Unexpected dictionary length" unless @words.length == 2048
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.exists?(@cave)
36
+ File.exist?(@cave)
30
37
  end
31
38
 
39
+ # True if the lock file exists; false otherwise.
32
40
  def locked?
33
- File.exists?(@lock)
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
- def create!(phrase)
45
- raise Fail, "Cannot create; store already exists" if exists? || locked? || open?
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, "There must be exactly eight words" unless words.length == 8
56
- raise Fail, "Unrecognised word used" unless words.all? { |word| @words.include?(word) }
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
- raise Fail, "Base conversion failure" unless Bases.val(words).in_base(@words).to_base(16) == number.to_s
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, "Cannot open store" if !exists? || locked? || open?
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, "There must be exactly eight words" unless words.length == 8
77
- raise Fail, "Unrecognised word used" unless words.all? { |word| @words.include?(word) }
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, "rb") { |file| file.read }
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, "Cannot close store; it's not open" unless open?
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, "wb") { |file| file.write(encrypted_data) }
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, "Cannot lock cave; it's not open" unless open?
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 + item[:index].to_s
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, i| @words[num.to_i] }
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, "wb") { |file| file.write(encrypted_data) }
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, "Cannot unlock store; it's not locked" unless locked?
133
- raise Fail, "Cannot unlock store; it's already open" if open?
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, "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) }
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, i| @words.index(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, "rb") { |file| file.read }
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, "rb") { |file| file.read }
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 + item[:index].to_s
159
- raise "Checksum failure" unless Digest::CRC16.checksum(data).to_s(16) == checksum
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, "Cannot list the store; it's not open" unless open?
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, "Cannot test service uniqueness; store not open" unless open?
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
- 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'
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
- 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'
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, "Service cannot be empty" if service.strip.length == 0
196
- raise Fail, "User cannot be empty" if user.strip.length == 0
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, "Service and/or user already exists" unless item.nil?
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 == "sesame"
241
+ return if service == 'sesame'
203
242
  item = _find(service, user)
204
243
  _generate_phrase(item)
205
244
  end
206
245
 
207
- def update(service, user, index=nil)
208
- raise Fail, "Cannot update service details; store not open" unless open?
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, "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
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 == "sesame"
258
+ _generate_phrase(item) unless service == 'sesame'
220
259
  end
221
260
 
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'
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, "Unable to find that service and/or user" unless item.nil?
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 == 0
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 ** 30
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, "Empty item when generating phrase" if item.nil?
270
- mem = 2 ** 20
309
+ raise Fail, 'Empty item when generating phrase' if item.nil?
310
+ mem = 2**20
271
311
  ops = mem / 32
272
- raise Fail, "Cannot generate a phrase; byte count mismatch" unless RbNaCl::PasswordHash::SCrypt::SALTBYTES == @secret.length
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
- self._words.split("\n").map(&:strip)
8
+ _words.split("\n").map(&:strip)
5
9
  end
6
-
7
- private
8
10
 
9
11
  def self._words
10
- <<-EOP
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
- EOP
2061
+ DICTIONARY
2060
2062
  end
2061
2063
  end
2062
2064
  end
data/lib/sesame/fail.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Sesame
2
4
  class Fail < StandardError
3
5
  end