keybox 1.0.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,2 @@
1
+ require 'keybox/storage/record'
2
+ require 'keybox/storage/container'
@@ -0,0 +1,307 @@
1
+ require 'keybox/cipher'
2
+ require 'keybox/digest'
3
+ require 'keybox/storage/record'
4
+ require 'keybox/uuid'
5
+ require 'keybox/randomizer'
6
+ require 'keybox/error'
7
+ require 'openssl'
8
+ require 'yaml'
9
+
10
+ module Keybox
11
+ module Storage
12
+ ##
13
+ # The container of the Keybox records. The Container itself is
14
+ # a Keybox::Storage::Record with a few extra methods.
15
+ #
16
+ # A instance of a Container is created with a assprhase and a
17
+ # path to a file. The passphrase can be anything that has a
18
+ # to_s method.
19
+ #
20
+ # container = Keybox::Storage::Container.new("i love ruby", "/tmp/database.yml")
21
+ #
22
+ # This will load from the given file with the given passphrase
23
+ # if the file exists, or it will initalize the container to
24
+ # accept records.
25
+ #
26
+ # The records are held decrypted in memory, so keep that in mind
27
+ # if that is a concern.
28
+ #
29
+ # Add Records
30
+ #
31
+ # record = Keybox::Storage::Record.new
32
+ # record.field1 = "data"
33
+ # record.field1 = "some more data"
34
+ #
35
+ # container << record
36
+ #
37
+ # Delete Records
38
+ #
39
+ # container.delete(record)
40
+ #
41
+ # There is no 'update' record, just delete it and add it.
42
+ #
43
+ # Find a record accepts a string and will look in all the
44
+ # records it contains for anything that matches it. It coerces
45
+ # strings into +Regexp+ so any regex can be used here too.
46
+ #
47
+ # container.find("data")
48
+ #
49
+ # Report if the container or any of its records have been
50
+ # modified:
51
+ #
52
+ # container.modified?
53
+ #
54
+ # Save the container to its default location:
55
+ #
56
+ # container.save
57
+ #
58
+ # Or to some other location
59
+ #
60
+ # container.save("/some/other/path.yml")
61
+ #
62
+ # Direct access to the decrypted records is also available
63
+ # through the +records+ accessor.
64
+ #
65
+ # container.records #=> Array of Keybox::Storage::Record
66
+ #
67
+ class Container < Keybox::Storage::Record
68
+
69
+ attr_reader :records
70
+
71
+ ITERATIONS = 2048
72
+ def initialize(passphrase,path)
73
+ super()
74
+
75
+ @path = path
76
+ @passphrase = passphrase
77
+ @records = []
78
+
79
+ if not load_from_file then
80
+ self.version = Keybox::VERSION
81
+
82
+ self.key_calc_iterations = ITERATIONS
83
+ self.key_digest_salt = Keybox::RandomDevice.random_bytes(32)
84
+ self.key_digest_algorithm = Keybox::Digest::DEFAULT_ALGORITHM
85
+ self.key_digest = calculated_key_digest(passphrase)
86
+
87
+ self.records_init_vector = Keybox::RandomDevice.random_bytes(16)
88
+ self.records_cipher_algorithm = Keybox::Cipher::DEFAULT_ALGORITHM
89
+
90
+ self.records_encrypted_data = ""
91
+ self.records_digest_salt = Keybox::RandomDevice.random_bytes(32)
92
+ self.records_digest_algorithm = Keybox::Digest::DEFAULT_ALGORITHM
93
+ self.records_digest = ""
94
+ end
95
+ end
96
+
97
+ #
98
+ # Change the master password of the container
99
+ #
100
+ def passphrase=(new_passphrase)
101
+ @passphrase = new_passphrase
102
+ self.key_digest_salt = Keybox::RandomDevice.random_bytes(32)
103
+ self.key_digest = calculated_key_digest(new_passphrase)
104
+ self.records_init_vector = Keybox::RandomDevice.random_bytes(16)
105
+ self.records_digest_salt = Keybox::RandomDevice.random_bytes(32)
106
+ end
107
+
108
+ #
109
+ # load from file, if this is successful then replace the
110
+ # existing member fields on this instance with the data from
111
+ # the file
112
+ #
113
+ def load_from_file
114
+ return false unless File.exists?(@path)
115
+ return false unless tmp = YAML.load_file(@path)
116
+ @creation_time = tmp.creation_time
117
+ @modification_time = tmp.modification_time
118
+ @last_access_time = tmp.last_access_time
119
+ @data_members = tmp.data_members
120
+ @uuid = tmp.uuid
121
+ validate_passphrase
122
+ decrypt_records
123
+ validate_decryption
124
+ load_records
125
+ @modified = false
126
+ true
127
+ end
128
+
129
+ #
130
+ # save the current container to a file
131
+ #
132
+ def save(path = @path)
133
+ calculate_records_digest
134
+ encrypt_records
135
+ File.open(path,"w") do |f|
136
+ f.write(self.to_yaml)
137
+ end
138
+
139
+ # mark everything as not modified
140
+ @records.each do |record|
141
+ record.modified = false
142
+ end
143
+ @modified = false
144
+ end
145
+
146
+ #
147
+ # calculate the encryption key from the initial passphrase
148
+ #
149
+ def calculated_key(passphrase = @passphrase)
150
+ key = self.key_digest_salt + passphrase
151
+ self.key_calc_iterations.times do
152
+ key = Keybox::Digest::CLASSES[self.key_digest_algorithm].digest(key)
153
+ end
154
+ return key
155
+ end
156
+
157
+ #
158
+ # calculate the key digest of the encryption key
159
+ #
160
+ def calculated_key_digest(passphrase)
161
+ Keybox::Digest::CLASSES[self.key_digest_algorithm].hexdigest(calculated_key(passphrase))
162
+ end
163
+
164
+ #
165
+ # Add a record to the system
166
+ #
167
+ def <<(obj)
168
+ if obj.respond_to?("needs_container_passphrase?") and obj.needs_container_passphrase? then
169
+ obj.container_passphrase = @passphrase
170
+ end
171
+ @records << obj
172
+ end
173
+
174
+ #
175
+ # The number of Records in the Container
176
+ #
177
+ def length
178
+ @records.size
179
+ end
180
+
181
+ #
182
+ # The number of Records in the Container
183
+ #
184
+ def size
185
+ @records.size
186
+ end
187
+
188
+ #
189
+ # Delete a record from the system, we force a modified flag
190
+ # here since the underlying Record wasn't 'assigned to' we
191
+ # have to force modification notification.
192
+ #
193
+ def delete(obj)
194
+ @records.delete(obj)
195
+ @modified = true
196
+ end
197
+
198
+ #
199
+ # Search only records that have a +url+ field
200
+ #
201
+ def find_by_url(url)
202
+ find(url,%w(url))
203
+ end
204
+
205
+ #
206
+ # Search all the records in the database finding any that
207
+ # match the search string passed in. The Search string is
208
+ # converted to a Regexp before beginning.
209
+ #
210
+ # A list of restricted fields can also be passed in and the
211
+ # regexp will only be matched against those fields
212
+ #
213
+ def find(search_string,restricted_to = nil)
214
+ regex = Regexp.new(search_string)
215
+ matches = []
216
+ @records.each do |record|
217
+ restricted_to = restricted_to || ( record.fields - %w(password) )
218
+ record.data_members.each_pair do |k,v|
219
+ if regex.match(v) and restricted_to.include?(k.to_s) then
220
+ matches << record
221
+ break
222
+ end
223
+ end
224
+ end
225
+ return matches
226
+ end
227
+
228
+ #
229
+ # See if we are modified, or if any of the records are
230
+ # modified
231
+ #
232
+ def modified?
233
+ return true if super
234
+ @records.each do |record|
235
+ return true if record.modified?
236
+ end
237
+ return false
238
+ end
239
+
240
+ private
241
+
242
+ #
243
+ # calculate the key digest of the input pass phrase and
244
+ # compare that to the digest in the data file. If they are
245
+ # the same, then the pass phrase should be the correct one
246
+ # for the data.
247
+ #
248
+ def validate_passphrase
249
+ raise Keybox::ValidationError, "Passphrase digests do not match. The passphrase given does not decrypt the data in this file." unless key_digest == calculated_key_digest(@passphrase)
250
+ end
251
+
252
+ #
253
+ # encrypt the data in the records and store it in
254
+ # records_encrypted_data
255
+ #
256
+ def encrypt_records
257
+ cipher = OpenSSL::Cipher::Cipher.new(self.records_cipher_algorithm)
258
+ cipher.encrypt
259
+ cipher.key = calculated_key
260
+ cipher.iv = self.records_init_vector
261
+ self.records_encrypted_data = cipher.update(@records.to_yaml)
262
+ self.records_encrypted_data << cipher.final
263
+ end
264
+
265
+ #
266
+ # decrypt the data in the record_data and store it in
267
+ # records
268
+ #
269
+ def decrypt_records
270
+ cipher = OpenSSL::Cipher::Cipher.new(self.records_cipher_algorithm)
271
+ cipher.decrypt
272
+ cipher.key = calculated_key
273
+ cipher.iv = self.records_init_vector
274
+ @decrypted_yaml = cipher.update(self.records_encrypted_data)
275
+ @decrypted_yaml << cipher.final
276
+ end
277
+
278
+ #
279
+ # make sure that the decrypted data is validated against its
280
+ # hash
281
+ #
282
+ def validate_decryption
283
+ digest = Keybox::Digest::CLASSES[self.records_digest_algorithm]
284
+ hexdigest = digest.hexdigest(self.records_digest_salt + @decrypted_yaml)
285
+ raise Keybox::ValidationError, "Record digests do not match. The given passphrase does not decrypt the data." unless hexdigest == self.records_digest
286
+ end
287
+
288
+
289
+ def calculate_records_digest
290
+ digest = Keybox::Digest::CLASSES[self.records_digest_algorithm]
291
+ self.records_digest = digest.hexdigest(self.records_digest_salt + @records.to_yaml)
292
+ end
293
+
294
+ def load_records
295
+ @records = YAML.load(@decrypted_yaml)
296
+
297
+ # if a record wants, it can have a reference to the
298
+ # container.
299
+ @records.each do |record|
300
+ if record.respond_to?("needs_container_passphrase?") and record.needs_container_passphrase? then
301
+ record.container_passphrase = @passphrase
302
+ end
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,103 @@
1
+ module Keybox
2
+ module Storage
3
+
4
+ ##
5
+ # Record is very similar to an OStruct but it keeps track of the
6
+ # last access, creation and modification times of the object.
7
+ # Each Record also has a UUID for unique identification.
8
+ #
9
+ # rec = Keybox::Storage::Record.new
10
+ #
11
+ # rec.modified? # => false
12
+ # rec.some_field = "some value" # => "some value"
13
+ # rec.modified? # => true
14
+ # rec.modification_time # => Time
15
+ # rec.data_members # => { :some_field => "some value" }
16
+ # rec.uuid.to_s # => "4b25074d-d3c5-23cd-bf9f-e28d8c8d453d"
17
+ #
18
+ # The +creation_time+, +modification_time+ and +last_access_time+ of
19
+ # each field in the Record is recorded.
20
+ #
21
+ # The UUID is create when the class is instantiated.
22
+ #
23
+ class Record
24
+
25
+ attr_reader :creation_time
26
+ attr_reader :modification_time
27
+ attr_reader :last_access_time
28
+ attr_reader :uuid
29
+ attr_reader :data_members
30
+
31
+ PROTECTED_METHODS = [ :creation_time=, :modification_time=, :last_access_time=,
32
+ :uuid=, :data_members=, :modified, ]
33
+ def initialize
34
+ @creation_time = Time.now
35
+ @modification_time = @creation_time.dup
36
+ @last_access_time = @creation_time.dup
37
+ @uuid = Keybox::UUID.new
38
+ @data_members = Hash.new
39
+ @modified = false
40
+ end
41
+
42
+ def modified?
43
+ # since this class can be loaded from a YAML file and
44
+ # modified is not stored in the serialized format, if
45
+ # @modified is not initialized, initialize it.
46
+ if not instance_variables.include?("@modified") then
47
+ @modified = false
48
+ end
49
+ @modified
50
+ end
51
+
52
+ # this is here so that after this class has been serialized
53
+ # to a file the container can mark it as clean. The class
54
+ # should take care of marking itself dirty.
55
+ def modified=(value)
56
+ @modified = value
57
+ end
58
+
59
+ def method_missing(method_id, *args)
60
+ method_name = method_id.id2name
61
+ member_sym = method_name.gsub(/=/,'').to_sym
62
+
63
+ # guard against assigning to the time data members and
64
+ # the data_members element
65
+ if PROTECTED_METHODS.include?(method_id) then
66
+ raise NoMethodError, "invalid method #{method_name} for #{self.class.name}", caller(1)
67
+ end
68
+
69
+ # if the method ends with '=' and has a single argument,
70
+ # then convert the name to the appropriate data member
71
+ # and store the argument in the hash.
72
+ if method_name[-1].chr == "=" then
73
+ raise ArgumentError, "'#{method_name}' requires one and only one argument", caller(1) unless args.size == 1
74
+ @data_members[member_sym] = args[0]
75
+ @modified = true
76
+ @modification_time = Time.now
77
+ @last_access_time = @modification_time.dup
78
+ elsif args.size == 0 then
79
+ @last_access_time = Time.now
80
+ @data_members[member_sym]
81
+ else
82
+ raise NoMethodError, "undefined method #{method_name} for #{self.class.name}", caller(1)
83
+ end
84
+ end
85
+
86
+ def to_yaml_properties
87
+ %w{ @creation_time @modification_time @last_access_time @data_members @uuid }
88
+ end
89
+
90
+ def ==(other)
91
+ self.eql?(other)
92
+ end
93
+
94
+ def eql?(other)
95
+ if other.kind_of?(Keybox::Storage::Record) then
96
+ self.uuid == other.uuid
97
+ else
98
+ self.uuid == other
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,194 @@
1
+ require 'keybox/randomizer'
2
+
3
+ module Keybox
4
+
5
+ # base class for string generation. A string generator can be
6
+ # called serially to generate a new string in chunks, or all at once
7
+ # to generate one of a certain size.
8
+ class StringGenerator
9
+ attr_accessor :chunks
10
+ attr_accessor :min_length
11
+ attr_accessor :max_length
12
+ attr_accessor :autoclear
13
+
14
+ def initialize
15
+ @chunks = Array.new
16
+ @min_length = 8
17
+ @max_length = 10
18
+ @randomizer = Keybox::Randomizer.new
19
+ @autoclear = true
20
+ end
21
+
22
+ def generate
23
+ clear if autoclear
24
+ generate_chunk until valid?
25
+ self.to_s
26
+ end
27
+
28
+ def clear
29
+ @chunks.clear
30
+ end
31
+
32
+ def to_s
33
+ @chunks.join('')[0...@max_length]
34
+ end
35
+
36
+ def valid?
37
+ raise Keybox::ValidationError, "max_length (#{max_length}) must be greater than or equal to min_length (#{min_length})", caller if max_length < min_length
38
+ @chunks.join('').size >= @min_length
39
+ end
40
+
41
+ def generate_chunk
42
+ raise Keybox::KeyboxError, "generate_chunk Not Implemented"
43
+ end
44
+
45
+ end
46
+
47
+ # CharGramGenerator emits a sequence that is a CharGram of length N.
48
+ # The ngrams can be the default ones that ship with keybox or an
49
+ # array passed in.
50
+ class CharGramGenerator < StringGenerator
51
+ attr_reader :pool
52
+
53
+
54
+ def initialize(chargram_list = nil)
55
+ super()
56
+ wordlist = chargram_list || load_default_chargram_list
57
+ @pool = Hash.new
58
+ wordlist.each do |word|
59
+ letters = word.split('')
60
+ letter_set = @pool[letters.first] ||= Array.new
61
+ letter_set << word
62
+ end
63
+ end
64
+
65
+ def size
66
+ @pool.inject(0) { |sum,h| sum + h[1].size }
67
+ end
68
+
69
+ # return a random chargram from the pool indidcated by the last
70
+ # character of the last emitted item
71
+ def generate_chunk
72
+ pool_key = ""
73
+ if @chunks.size > 0 then
74
+ pool_key = @chunks.pop
75
+ else
76
+ pool_key = @randomizer.pick_one_from(@pool.keys)
77
+ end
78
+ new_chunk = @randomizer.pick_one_from(@pool[pool_key])
79
+ @chunks.concat(new_chunk.split(//))
80
+ new_chunk
81
+ end
82
+
83
+ private
84
+
85
+ def load_default_chargram_list
86
+ list = []
87
+ File.open(File.join(Keybox::APP_DATA_DIR,"chargrams.txt")) do |f|
88
+ f.each_line do |line|
89
+ next if line =~ /^#/
90
+ list << line.rstrip
91
+ end
92
+ end
93
+ list
94
+ end
95
+ end
96
+
97
+ module SymbolSet
98
+ LOWER_ASCII = ("a".."z").to_a
99
+ UPPER_ASCII = ("A".."Z").to_a
100
+ NUMERAL_ASCII = ("0".."9").to_a
101
+ SPECIAL_ASCII = ("!".."/").to_a + (":".."@").to_a + %w( [ ] ^ _ { } | ~ )
102
+ ALL = LOWER_ASCII + UPPER_ASCII + NUMERAL_ASCII + SPECIAL_ASCII
103
+
104
+ MAPPING = {
105
+ "lower" => LOWER_ASCII,
106
+ "upper" => UPPER_ASCII,
107
+ "numerical" => NUMERAL_ASCII,
108
+ "special" => SPECIAL_ASCII,
109
+ "all" => ALL
110
+ }
111
+ end
112
+
113
+ #
114
+ # SymbolSetGenerator uses symbol sets and emits a single character
115
+ # from the aggregated symobl sets that the instance is wrapping.
116
+ #
117
+ # That is When a SymbolSetGenerator is instantiated, it can take a
118
+ # list of symbol sets (a-z, A-Z, 0-9) etc as parameters. Those sets
119
+ # are merged and when 'emit' is called a string of length 1 is
120
+ # returned from the aggregated symbols
121
+ #
122
+ class SymbolSetGenerator < StringGenerator
123
+ include SymbolSet
124
+
125
+ attr_accessor :required_sets
126
+
127
+ def initialize(set = ALL)
128
+ super()
129
+ @symbols = set.flatten.uniq
130
+ @required_sets = []
131
+ end
132
+
133
+ # every time we access the symbols set we need to make sure that
134
+ # the required symbols are a part of it, and if they aren't then
135
+ # make sure they are.
136
+ def symbols
137
+ if @required_sets.size > 0 then
138
+ if not @symbols.include?(@required_sets.first[0]) then
139
+ @symbols << @required_sets
140
+ @symbols.flatten!
141
+ @symbols.uniq!
142
+ end
143
+ end
144
+ @symbols
145
+ end
146
+
147
+ def required_generated?
148
+ result = true
149
+ @required_sets.each do |set|
150
+ set_found = false
151
+ @chunks.each do |chunk|
152
+ if set.include?(chunk) then
153
+ set_found = true
154
+ break
155
+ end
156
+ end
157
+ result = (result and set_found)
158
+ end
159
+ return result
160
+ end
161
+
162
+ # we force generation of the required sets at the beginning the
163
+ # first time we are called.
164
+ def generate_chunk
165
+ chunk = ""
166
+ if required_generated? then
167
+ @chunks << @randomizer.pick_one_from(symbols)
168
+ chunk = @chunks.last
169
+ else
170
+ req = generate_required
171
+ @chunks.concat(req)
172
+ chunk = req.join('')
173
+ end
174
+ return chunk
175
+ end
176
+
177
+ # valid for the symbol set generator means the parent classes
178
+ # validity and that we have in the
179
+ def valid?
180
+ super and required_generated?
181
+ end
182
+
183
+ private
184
+
185
+ # generate symbols from the required sets
186
+ def generate_required
187
+ req = []
188
+ required_sets.each do |set|
189
+ req << @randomizer.pick_one_from(set)
190
+ end
191
+ return req
192
+ end
193
+ end
194
+ end