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,6 @@
1
+ module Keybox
2
+ module Cipher
3
+ AES_256 = "aes-256-cbc"
4
+ DEFAULT_ALGORITHM = AES_256
5
+ end
6
+ end
@@ -0,0 +1 @@
1
+ require 'keybox/convert/csv'
@@ -0,0 +1,96 @@
1
+
2
+ require 'csv'
3
+ require 'keybox/entry'
4
+ module Keybox
5
+ module Convert
6
+ #
7
+ # Convert to/from a CSV file. When loading a CSV file, it is assumed
8
+ # that there is a header on the csv file that shows which of the fields
9
+ # belongs to the various fields of the entry. At a minimum the header
10
+ # line should have the default fields from an HostAccountEntry which are:
11
+ #
12
+ # - title
13
+ # - username
14
+ # - hostname
15
+ # - password
16
+ # - additional_info
17
+ #
18
+ # These headers can be in any order and CSV will do the right
19
+ # thing. But these headers must exist. A
20
+ # Keybox::ValidationError is thrown if they do not.
21
+ #
22
+ class CSV
23
+ class << self
24
+
25
+ # parse the header line from the CSV file and make sure
26
+ # that all the required columns are listed.
27
+ #
28
+ def parse_header(header)
29
+ field_indexes = {}
30
+ Keybox::HostAccountEntry.default_fields.each do |field|
31
+ field_indexes[field] = header.index(field)
32
+ if field_indexes[field].nil? then
33
+ raise Keybox::ValidationError, "There must be a header on the CSV to import and it must contain the '#{field}' field."
34
+ end
35
+ end
36
+ field_indexes
37
+ end
38
+
39
+ # returns an Array of AccountEntry classes or its
40
+ # descendants
41
+ #
42
+ def from_file(csv_filename)
43
+ reader = ::CSV.open(csv_filename,"r")
44
+ entries = Keybox::Convert::CSV.from_reader(reader)
45
+ reader.close
46
+ return entries
47
+ end
48
+
49
+ # pull all the items from the CSV file. There MUST be a
50
+ # header line that says what the different fields are.
51
+ # A HostAccountEntry object is created for each line and
52
+ # the array of those objects is returned
53
+ #
54
+ def from_reader(csv_reader)
55
+ field_indexes = parse_header(csv_reader.shift)
56
+ entries = []
57
+ csv_reader.each do |row|
58
+ entry = Keybox::HostAccountEntry.new
59
+ field_indexes.each_pair do |field,index|
60
+ value = row[index] || ""
61
+ entry.send("#{field}=",value.strip)
62
+ end
63
+ entries << entry
64
+ end
65
+ return entries
66
+ end
67
+
68
+ #
69
+ # records should be an array of AccountEntry objects
70
+ #
71
+ def to_file(records,csv_filename)
72
+ writer = ::CSV.open(csv_filename,"w")
73
+ Keybox::Convert::CSV.to_writer(records,writer)
74
+ writer.close
75
+ end
76
+
77
+ #
78
+ # write all the fields for each record. We go through
79
+ # all the records (an array of AccountEntry objects),
80
+ # recording all the fields, then using that as the header.
81
+ #
82
+ def to_writer(records,csv_writer)
83
+ field_names = records.collect { |r| r.fields }.flatten.uniq
84
+ csv_writer << field_names
85
+ records.each do |record|
86
+ values = []
87
+ field_names.each do |field|
88
+ values << record.send(field) || ""
89
+ end
90
+ csv_writer << values
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,13 @@
1
+ require 'openssl'
2
+ module Keybox
3
+ #
4
+ # By default Keybox wants to use sha-256 as the hashing function.
5
+ # This is available in OpenSSL 0.9.8 or greater.
6
+ #
7
+ module Digest
8
+ SHA_256 = "sha256"
9
+ CLASSES = { SHA_256 => ::OpenSSL::Digest::SHA256 }
10
+ DEFAULT_ALGORITHM = SHA_256
11
+ ALGORITHMS = CLASSES.keys
12
+ end
13
+ end
@@ -0,0 +1,200 @@
1
+ require 'keybox/storage/record'
2
+
3
+ module Keybox
4
+
5
+ # Entries in the Keybox storage container. The base class is
6
+ # AccountEntry with current child classes HostAccountEntry and
7
+ # URLAccountEntry.
8
+ #
9
+ # In most cases HostAccountEntry will suffice. Use URLAccountEntry
10
+ # when you would like to link an entry's +password+ field to be
11
+ # generated based upon the master password of the container.
12
+ class AccountEntry < Keybox::Storage::Record
13
+ class << self
14
+ def default_fields
15
+ %w(title username additional_info)
16
+ end
17
+
18
+ # fields that can be displayed, some could be calculated
19
+ def display_fields
20
+ (visible_fields + private_fields).uniq
21
+ end
22
+
23
+ def private_fields
24
+ []
25
+ end
26
+
27
+ def visible_fields
28
+ default_fields - private_fields
29
+ end
30
+
31
+ def visible_field?(field_name)
32
+ visible_fields.include?(field_name)
33
+ end
34
+
35
+ def private_field?(field_name)
36
+ private_fields.include?(field_name)
37
+ end
38
+ end
39
+
40
+ def each
41
+ fields.each do |f|
42
+ yield [f,self.send(f)]
43
+ end
44
+ end
45
+
46
+ # fields that are actually stored in the entry
47
+ def fields
48
+ (default_fields + @data_members.keys ).collect { |k| k.to_s }.uniq
49
+ end
50
+
51
+ def default_fields
52
+ self.class.default_fields
53
+ end
54
+
55
+ def display_fields
56
+ self.class.display_fields
57
+ end
58
+
59
+ def private_fields
60
+ self.class.private_fields
61
+ end
62
+
63
+ def private_field?(field_name)
64
+ self.class.private_field?(field_name)
65
+ end
66
+
67
+ def visible_fields
68
+ self.class.visible_fields
69
+ end
70
+
71
+ def visible_field?(field_name)
72
+ self.class.visible_field?(field_name)
73
+ end
74
+
75
+
76
+ def values
77
+ fields.collect { |f| self.send(f) }
78
+ end
79
+
80
+ def initialize(title = "",username = "")
81
+ super()
82
+ self.title = title
83
+ self.username = username
84
+ self.additional_info = ""
85
+ end
86
+
87
+ def needs_container_passphrase?
88
+ false
89
+ end
90
+
91
+ def to_s
92
+ s = StringIO.new
93
+ max_length = self.max_field_length
94
+ fields.each do |f|
95
+ line = "#{f.rjust(max_length + 1)} :"
96
+ value = self.send(f)
97
+ if private_field?(f) then
98
+ # if its private field, then blank out value just to
99
+ value = " ***** private ***** "
100
+ end
101
+ s.puts "#{f.rjust(max_length + 1)} : #{value}"
102
+ end
103
+ return s.string
104
+ end
105
+
106
+ def max_field_length
107
+ fields.collect { |f| f.length }.max
108
+ end
109
+
110
+ end
111
+
112
+ #
113
+ # Host Accounts are those typical login accounts on machines. These
114
+ # contain at a minimum the fields:
115
+ #
116
+ # - title
117
+ # - hostname
118
+ # - username
119
+ # - pasword
120
+ # - additional_info
121
+ #
122
+ # Since HostAccountEntry is a descendant of Keybox::Storage::Record
123
+ # other fields may be added dynamically.
124
+ #
125
+ class HostAccountEntry < Keybox::AccountEntry
126
+
127
+ class << self
128
+ def default_fields
129
+ %w(title hostname username password additional_info)
130
+ end
131
+
132
+ def private_fields
133
+ %w(password)
134
+ end
135
+ end
136
+
137
+ def initialize(title = "",hostname = "",username = "",password = "")
138
+ super(title,username)
139
+ self.hostname = hostname
140
+ self.password = password
141
+ end
142
+
143
+ end
144
+
145
+ #
146
+ # URLAccounts do not have a +password+ field, although it appears
147
+ # to. It is calculated based upon the URL and the master password
148
+ # for the Container. The minimum fields for URLAccountEntry are:
149
+ #
150
+ # - title
151
+ # - url
152
+ # - username
153
+ # - additional_info
154
+ #
155
+ # This is inspired by http://crypto.stanford.edu/PwdHash/ and
156
+ # http://www.xs4all.nl/~jlpoutre/BoT/Javascript/PasswordComposer/
157
+ #
158
+ # This class also needs to be told the container's passphrase to
159
+ # calculate its own password.
160
+ #
161
+ # TODO: Have this class use any other Keybox::Storage::Record
162
+ # for the master password instead of the container.
163
+ #
164
+ class URLAccountEntry < Keybox::AccountEntry
165
+ class << self
166
+ def initial_fields
167
+ %w(title url username additional_info)
168
+ end
169
+
170
+ def private_fields
171
+ %w(password)
172
+ end
173
+ end
174
+
175
+ def initialize(title = "",url = "",username = "")
176
+ super(title,username)
177
+ self.url = url
178
+ end
179
+
180
+ def password_hash_alg
181
+ if not instance_variables.include?("@password_hash_alg") then
182
+ @password_hash_alg = Keybox::PasswordHash.new
183
+ end
184
+ @password_hash_alg
185
+ end
186
+
187
+ def needs_container_passphrase?
188
+ true
189
+ end
190
+
191
+ def container_passphrase=(p)
192
+ password_hash_alg.master_password = p.dup
193
+ end
194
+
195
+ def password
196
+ password_hash_alg.password_for_url(self.url)
197
+ end
198
+
199
+ end
200
+ end
@@ -0,0 +1,5 @@
1
+ module Keybox
2
+ class KeyboxError < ::StandardError ; end
3
+ class ValidationError < KeyboxError; end
4
+ class ApplicationError < KeyboxError; end
5
+ end
@@ -0,0 +1,33 @@
1
+ require 'uri'
2
+ require 'digest/sha1'
3
+ module Keybox
4
+
5
+ # this is an implementation of the password hash algorithm used at
6
+ # http://www.xs4all.nl/~jlpoutre/BoT/Javascript/PasswordComposer/
7
+ #
8
+ # This implementation uses the SHA1 hash instead of the MD5
9
+ #
10
+ # This class uses a master password and with that information
11
+ # generates a unique password for all subsequent strings passed to
12
+ # it.
13
+ #
14
+ class PasswordHash
15
+
16
+ attr_writer :master_password
17
+
18
+ def initialize(master_password = "")
19
+ @master_password = master_password
20
+ @digest_class = ::Digest::SHA1
21
+ end
22
+
23
+ def password_for_url(url)
24
+ uri = URI.parse(url)
25
+ password_for(uri.host)
26
+ end
27
+
28
+ def password_for(str)
29
+ @digest_class.hexdigest("#{@master_password}:#{str}")[0..8]
30
+ end
31
+ end
32
+
33
+ end
@@ -0,0 +1,193 @@
1
+ require 'openssl'
2
+ module Keybox
3
+
4
+ ##
5
+ # Use an IO device to retrieve random data. Usually this is
6
+ # somethine like '/dev/random' but may be anything that can read
7
+ # from by IO.read.
8
+ #
9
+ # bytes = RandomDevice.random_bytes(42)
10
+ #
11
+ # or
12
+ #
13
+ # rd = RandomDevice.new
14
+ # bytes = rd.random_bytes(42)
15
+ #
16
+ # If there is a some other hardware device or some service that
17
+ # provides random byte stream, set it as the default device in this
18
+ # class and it will use it instead of the default.
19
+ #
20
+ # my_random_device = RandomDevice("/dev/super-random-device")
21
+ #
22
+ # or
23
+ #
24
+ # RandomDevice.default = "/dev/super-random-device"
25
+ # my_random_device = RandomDevice.new
26
+ # my_random_device.source # => "/dev/super-random-device"
27
+ #
28
+ # All further instances of RandomDevice will use it as the default.
29
+ #
30
+ # RandomDevice can either produce random bytes as a class or as an
31
+ # instance.
32
+ #
33
+ # random_bytes = RandomDevice.random_bytes(42)
34
+ # random_bytes.size # => 42
35
+ #
36
+ class RandomDevice
37
+ @@DEVICES = [ "/dev/urandom", "/dev/random" ]
38
+ @@DEFAULT = nil
39
+
40
+ attr_accessor :source
41
+
42
+ def initialize(device = nil)
43
+ if not device.nil? and File.readable?(device) then
44
+ @source = device
45
+ else
46
+ @source = RandomDevice.default
47
+ end
48
+ end
49
+
50
+ def random_bytes(count = 1)
51
+ File.read(source,count)
52
+ end
53
+
54
+ class << self
55
+ def default
56
+ return @@DEFAULT unless @@DEFAULT.nil?
57
+
58
+ @@DEVICES.each do |device|
59
+ if File.readable?(device) then
60
+ @@DEFAULT = device
61
+ break
62
+ end
63
+ end
64
+ return @@DEFAULT
65
+ end
66
+
67
+ def default=(device)
68
+ if File.readable?(device) then
69
+ @@DEVICES << device
70
+ @@DEFAULT = device
71
+ else
72
+ raise ArgumentError, "device #{device} is not readable and therefore makes a bad random device"
73
+ end
74
+ end
75
+
76
+ def random_bytes(count = 1)
77
+ File.read(RandomDevice.default,count)
78
+ end
79
+ end
80
+ end
81
+
82
+
83
+ #
84
+ # A RandomSource uses one from a set of possible source
85
+ # class/modules. So long as the @@DEFAULT item responds to
86
+ # +random_bytes+ it is fine.
87
+ #
88
+ # RandomSource supplies a +rand+ method in the same vein as
89
+ # Kernel#rand.
90
+ #
91
+ class RandomSource
92
+ @@SOURCE_CLASSES = [ ::Keybox::RandomDevice, ::OpenSSL::Random ]
93
+ @@SOURCE = nil
94
+
95
+ class << self
96
+ def register(klass)
97
+ if klass.respond_to?("random_bytes") then
98
+ @@SOURCE_CLASSES << klass unless @@SOURCE_CLASSES.include?(klass)
99
+ else
100
+ raise ArgumentError, "class #{klass.name} does not have a 'random_bytes' method"
101
+ end
102
+ end
103
+
104
+ def source_classes
105
+ @@SOURCE_CLASSES
106
+ end
107
+
108
+ def source=(klass)
109
+ register(klass)
110
+ @@SOURCE = klass
111
+ end
112
+
113
+ def source
114
+ return @@SOURCE unless @@SOURCE.nil? or not @@SOURCE_CLASSES.include?(@@SOURCE)
115
+ @@SOURCE_CLASSES.each do |klass|
116
+ if klass.random_bytes(2).length == 2 then
117
+ RandomSource.source = klass
118
+ break
119
+ end
120
+ end
121
+ @@SOURCE
122
+ end
123
+
124
+ #
125
+ # Behave like Kernel#rand where if no max is specified return
126
+ # a value >= 0.0 but < 1.0.
127
+ #
128
+ # If a max is specified, return an Integer between 0 and
129
+ # upto but not including max.
130
+ #
131
+ def rand(max = nil)
132
+ bytes = source.random_bytes(8)
133
+ num = bytes.unpack("F").first.abs / Float::MAX
134
+ if max then
135
+ num = bytes.unpack("Q").first % max.floor
136
+ end
137
+ return num
138
+ end
139
+ end
140
+ end
141
+
142
+ #
143
+ # Randomizer will randomly pick a value from anything that
144
+ # behaves like an array or has a +to_a+ method. Behaving like an
145
+ # array means having a +size+ or +length+ method along with an +at+
146
+ # method.
147
+ #
148
+ # The source of randomness is determined at runtime. Any class that
149
+ # can provide a +rand+ method and operates in the same manner
150
+ # as Kernel#rand can be a used as the random source.
151
+ #
152
+ # The item that is being 'picked' from can be any class that has a
153
+ # +size+ method along with an +at+ method.
154
+ #
155
+ # array = ("aaa"..."zzz").to_a
156
+ # r = Randomizer.new
157
+ # r.pick_one_from(array) # => "lho"
158
+ # r.pick_count_from(array,5) # => ["mvt", "tde", "wdu", "ker", "bgc"]
159
+ #
160
+ #
161
+ class Randomizer
162
+
163
+ REQUIRED_METHODS = %w( at size )
164
+
165
+ attr_reader :random_source
166
+
167
+ def initialize(random_source_klass = ::Keybox::RandomSource)
168
+ raise ArgumentError, "Invalid random source class" unless random_source_klass.respond_to?("rand")
169
+ @random_source = random_source_klass
170
+ end
171
+
172
+ def pick_one_from(array)
173
+ pick_count_from(array).first
174
+ end
175
+
176
+ def pick_count_from(array, count = 1)
177
+ raise ArgumentError, "Unable to pick from object of class #{array.class.name}" unless has_correct_duck_type?(array)
178
+ results = []
179
+ range = array.size
180
+ count.times do
181
+ rand_index = random_source.rand(range)
182
+ results << array.at(rand_index)
183
+ end
184
+ results
185
+ end
186
+
187
+ private
188
+
189
+ def has_correct_duck_type?(obj)
190
+ (REQUIRED_METHODS & obj.public_methods).size == REQUIRED_METHODS.size
191
+ end
192
+ end
193
+ end