Class: Sesame::Cave

Inherits:
Object
  • Object
show all
Defined in:
lib/sesame/cave.rb

Overview

The Cave class implements a simple password manager.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(path, pow = 30) ⇒ Cave

Initialize with the path where the cave file will be stored. Optionally specify the complexity of passphrase generation (only change this to lower values to make tests run more quickly).

Raises:



18
19
20
21
22
23
24
25
26
27
# File 'lib/sesame/cave.rb', line 18

def initialize(path, pow = 30)
  @words = Dict.load
  raise Fail, 'Unexpected dictionary length' unless @words.length == 2048
  @cave = File.expand_path(File.join(path, 'sesame.cave'))
  @lock = File.join(Dir.tmpdir, 'sesame.lock')
  @store = nil
  @dirty = false
  @item = nil
  @pow = pow
end

Instance Attribute Details

#itemObject

Returns the value of attribute item.



13
14
15
# File 'lib/sesame/cave.rb', line 13

def item
  @item
end

Instance Method Details

#closeObject

Close the cave, encrypting and saving its contents if dirty.



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/sesame/cave.rb', line 110

def close
  raise Fail, 'Cannot close store; it\'s not open' unless open?
  return unless dirty?
  # encrypt and save the store
  box = RbNaCl::SimpleBox.from_secret_key(@secret)
  data = @store.to_json
  encrypted_data = box.encrypt(data)
  File.open(@cave, 'wb') { |file| file.write(encrypted_data) }
rescue RbNaCl::CryptoError => e
  raise Fail, e.messsage
ensure
  @item = nil
  @store = nil
  @secret = nil
  @dirty = false
end

#create!(phrase = nil) ⇒ Object

Create a new cave. If the optional phrase is not supplied, then a random phrase will be returned (this is preferable; users should not select their own passphrase, because humans can’t random).



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/sesame/cave.rb', line 57

def create!(phrase = nil)
  raise Fail, 'Cannot create; store already exists' if exists? || locked? || open?
  @store = {}
  insert('sesame', 'cave')
  @dirty = true
  # generate an 88-bit random number as a hex string
  number =
    if phrase.nil?
      RbNaCl::Util.bin2hex(RbNaCl::Random.random_bytes(11))
    else
      words = phrase.downcase.split(' ')
      raise Fail, 'There must be exactly eight words' unless words.length == 8
      raise Fail, 'Unrecognised word used' unless words.all? { |word| @words.include?(word) }
      Bases.val(words).in_base(@words).to_base(16)
    end
  number.prepend('0') while number.length < 22
  @secret = _create_secret(number)
  # convert the hex string to a word string (base 2048)
  words = Bases.val(number).in_base(16).to_base(@words, array: true)
  # make sure it's always 8 words long (i.e. zero-pad the phrase)
  words.unshift(@words[0]) while words.length < 8
  # sanity check the conversion
  sanity = Bases.val(words).in_base(@words).to_base(16)
  sanity.prepend('0') while sanity.length < 22
  raise Fail, 'Base conversion failure' unless sanity == number
  # return the phrase to the user
  words.join(' ')
rescue RbNaCl::CryptoError => e
  raise Fail, e.message
end

#delete(service, user = nil) ⇒ Object

Remove a service and username, then generate and return the passphrase.

Raises:



261
262
263
264
265
266
267
268
269
270
# File 'lib/sesame/cave.rb', line 261

def delete(service, user = nil)
  raise Fail, 'Cannot delete service details; store not open' unless open?
  raise Fail, 'Cannot delete the sesame service' if service.casecmp('sesame').zero?
  item = _find(service, user)
  user = item[:user]
  @store[service].delete(user)
  @store.delete(service) if @store[service].count.zero?
  @dirty = true
  _generate_phrase(item)
end

#dirty?Boolean

True if the cave has been modified and needs to be persisted.

Returns:

  • (Boolean)


50
51
52
# File 'lib/sesame/cave.rb', line 50

def dirty?
  @dirty
end

#exists?Boolean

True if the cave file exists; false otherwise.

Returns:

  • (Boolean)


35
36
37
# File 'lib/sesame/cave.rb', line 35

def exists?
  File.exist?(@cave)
end

#forgetObject

Remove the lock file.



199
200
201
# File 'lib/sesame/cave.rb', line 199

def forget
  File.delete(@lock)
end

#get(service, user = nil, index = nil) ⇒ Object

Generate and return the passphrase for a service and username.

Raises:



221
222
223
224
225
226
227
# File 'lib/sesame/cave.rb', line 221

def get(service, user = nil, index = nil)
  raise Fail, 'Cannot get service details; store not open' unless open?
  raise Fail, 'Cannot get the sesame service' if service.casecmp('sesame').zero?
  item = _find(service, user)
  item[:index] = index unless index.nil?
  _generate_phrase(item)
end

#indexObject

Return the store. Note that this doesn’t expose any super-sensitive data; the store is just a hash of service name, usernames for each service, and a nonce for each username. These are combined with the secret (which is itself derived from the users passphrase) to create the password for each service and username.

Raises:



208
209
210
211
# File 'lib/sesame/cave.rb', line 208

def index
  raise Fail, 'Cannot list the store; it\'s not open' unless open?
  @store.sort.to_h
end

#insert(service, user, index = nil) ⇒ Object

Insert a new service and username, then generate and return the passphrase.

Raises:



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/sesame/cave.rb', line 230

def insert(service, user, index = nil)
  raise Fail, 'Cannot insert service details; store not open' unless open?
  if @store.length.positive?
    raise Fail, 'Cannot insert the sesame service' if service.casecmp('sesame').zero?
  end
  raise Fail, 'Service cannot be empty' if service.strip.length.zero?
  raise Fail, 'User cannot be empty' if user.nil? || user.strip.length.zero?
  raise Fail, 'User already exists for that service' unless @store[service].nil? || @store[service][user].nil?
  @store[service] ||= {}
  @store[service][user] = index || 0
  @dirty = true
  return if service == 'sesame'
  item = _find(service, user)
  _generate_phrase(item)
end

#lockObject

Lock the cave by encrypting and saving the secret to a lock file, and then closing the cave (which may mean saving it, if it was dirty).



129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/sesame/cave.rb', line 129

def lock
  raise Fail, 'Cannot lock cave; it\'s not open' unless open?
  # create a 16-bit checksum of the secret key
  item = _find('sesame', 'cave')
  data = @secret.dup
  data << item[:index]
  checksum = Digest::CRC16.checksum(data).to_s(16)
  checksum.prepend('0') while checksum.length < 4
  # convert it to a short sequence of short words
  words = Bases.val(checksum).in_base(16).to_base((0...16).to_a, array: true)
  words.map! { |num, _| @words[num.to_i] }
  words.unshift(@words[0]) while words.length < 4
  # create a key from it
  key = _create_secret(checksum)
  # encrypt and save the secret
  box = RbNaCl::SimpleBox.from_secret_key(key)
  encrypted_data = box.encrypt(@secret)
  File.open(@lock, 'wb') { |file| file.write(encrypted_data) }
  # return the phrase to the user
  words.join(' ')
rescue RbNaCl::CryptoError => e
  raise Fail, e.messsage
ensure
  close
end

#locked?Boolean

True if the lock file exists; false otherwise.

Returns:

  • (Boolean)


40
41
42
# File 'lib/sesame/cave.rb', line 40

def locked?
  File.exist?(@lock)
end

#open(phrase) ⇒ Object

Open an existing cave, using the supplied phrase to decrypt its contents.



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/sesame/cave.rb', line 89

def open(phrase)
  raise Fail, 'Cannot open store' if !exists? || locked? || open?
  # make sure the phrase is 8 words long and that the words are valid
  words = phrase.downcase.split(' ')
  raise Fail, 'There must be exactly eight words' unless words.length == 8
  raise Fail, 'Unrecognised word used' unless words.all? { |word| @words.include?(word) }
  # convert the phrase to a hex string
  number = Bases.val(words).in_base(@words).to_base(16)
  number.prepend('0') while number.length < 22
  @secret = _create_secret(number)
  # load the store and decrypt it
  box = RbNaCl::SimpleBox.from_secret_key(@secret)
  encrypted_data = File.open(@cave, 'rb', &:read)
  data = box.decrypt(encrypted_data)
  @store = JSON.parse(data)
  @dirty = false
rescue RbNaCl::CryptoError => e
  raise Fail, e.message
end

#open?Boolean

True if the cave file has been loaded into memory and decrypted.

Returns:

  • (Boolean)


45
46
47
# File 'lib/sesame/cave.rb', line 45

def open?
  !@store.nil?
end

#pathObject

Return the full path of the cave file.



30
31
32
# File 'lib/sesame/cave.rb', line 30

def path
  @cave
end

#unique?(service) ⇒ Boolean

True if a particular service has exactly one username.

Returns:

  • (Boolean)

Raises:



214
215
216
217
218
# File 'lib/sesame/cave.rb', line 214

def unique?(service)
  raise Fail, 'Cannot test service uniqueness; store not open' unless open?
  raise Fail, 'No such service' if @store[service].nil?
  @store[service].count < 2
end

#unlock(phrase) ⇒ Object

Unlock the cave, by loading and decrypting the secret using the supplied phrase, and then using that to load and decrypt the cave itself.



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
# File 'lib/sesame/cave.rb', line 157

def unlock(phrase)
  raise Fail, 'Cannot unlock store; it\'s not locked' unless locked?
  raise Fail, 'Cannot unlock store; it\'s already open' if open?
  # make sure the phrase is 4 words long and that the words are valid
  words = phrase.downcase.split(' ')
  if words.length == 1 && phrase.length == 4
    words = []
    phrase.each_char do |char|
      words << @words[0..15].find { |word| word[0] == char }
    end
  end
  raise Fail, 'There must be exactly four words' unless words.length == 4
  raise Fail, 'Unrecognised word used' unless words.all? { |word| @words[0..15].include?(word) }
  # convert the phrase to a hex string
  words.map! { |word, _| @words.index(word) }
  checksum = Bases.val(words).in_base((0...16).to_a).to_base(16)
  checksum.prepend('0') while checksum.length < 4
  key = _create_secret(checksum)
  # load the secret and decrypt it
  box = RbNaCl::SimpleBox.from_secret_key(key)
  encrypted_data = File.open(@lock, 'rb', &:read)
  @secret = box.decrypt(encrypted_data)
  # load the store and decrypt it
  box = RbNaCl::SimpleBox.from_secret_key(@secret)
  encrypted_data = File.open(@cave, 'rb', &:read)
  data = box.decrypt(encrypted_data)
  @store = JSON.parse(data)
  item = _find('sesame', 'cave')
  data = @secret.dup
  data << item[:index]
  sanity = Digest::CRC16.checksum(data).to_s(16)
  sanity.prepend('0') while sanity.length < 4
  raise 'Checksum failure' unless sanity == checksum
  @dirty = false
rescue RbNaCl::CryptoError => e
  raise Fail, e.message
ensure
  @item = nil
  forget if locked?
end

#update(service, user = nil, index = nil) ⇒ Object

Update the nonce for a service and username, then generate and return the passphrase.

Raises:



248
249
250
251
252
253
254
255
256
257
258
# File 'lib/sesame/cave.rb', line 248

def update(service, user = nil, index = nil)
  raise Fail, 'Cannot update service details; store not open' unless open?
  item = _find(service, user)
  index = item[:index] + 1 if index.nil?
  index = 0 if index.negative?
  user = item[:user]
  @store[service][user] = index
  @dirty = true
  item = _find(service, user)
  _generate_phrase(item) unless service == 'sesame'
end