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.
- data/CHANGES +14 -0
- data/COPYING +22 -0
- data/README +132 -0
- data/bin/keybox +18 -0
- data/bin/kpg +19 -0
- data/data/chargrams.txt +8432 -0
- data/lib/keybox.rb +29 -0
- data/lib/keybox/application/base.rb +114 -0
- data/lib/keybox/application/password_generator.rb +131 -0
- data/lib/keybox/application/password_safe.rb +410 -0
- data/lib/keybox/cipher.rb +6 -0
- data/lib/keybox/convert.rb +1 -0
- data/lib/keybox/convert/csv.rb +96 -0
- data/lib/keybox/digest.rb +13 -0
- data/lib/keybox/entry.rb +200 -0
- data/lib/keybox/error.rb +5 -0
- data/lib/keybox/password_hash.rb +33 -0
- data/lib/keybox/randomizer.rb +193 -0
- data/lib/keybox/storage.rb +2 -0
- data/lib/keybox/storage/container.rb +307 -0
- data/lib/keybox/storage/record.rb +103 -0
- data/lib/keybox/string_generator.rb +194 -0
- data/lib/keybox/term_io.rb +163 -0
- data/lib/keybox/uuid.rb +86 -0
- data/spec/base_app_spec.rb +56 -0
- data/spec/convert_csv_spec.rb +46 -0
- data/spec/entry_spec.rb +63 -0
- data/spec/keybox_app_spec.rb +268 -0
- data/spec/kpg_app_spec.rb +132 -0
- data/spec/password_hash_spec.rb +11 -0
- data/spec/randomizer_spec.rb +116 -0
- data/spec/storage_container_spec.rb +99 -0
- data/spec/storage_record_spec.rb +63 -0
- data/spec/string_generator_spec.rb +114 -0
- data/spec/uuid_spec.rb +74 -0
- metadata +83 -0
@@ -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
|
data/lib/keybox/entry.rb
ADDED
@@ -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
|
data/lib/keybox/error.rb
ADDED
@@ -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
|