keepassx 0.1.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +30 -0
  3. data/.gitignore +9 -0
  4. data/.rubocop.yml +64 -0
  5. data/.travis.yml +12 -3
  6. data/Gemfile +4 -2
  7. data/Guardfile +16 -0
  8. data/LICENSE +19 -0
  9. data/README.md +33 -0
  10. data/Rakefile +3 -2
  11. data/keepassx.gemspec +20 -10
  12. data/lib/keepassx.rb +42 -3
  13. data/lib/keepassx/aes_crypt.rb +16 -6
  14. data/lib/keepassx/database.rb +218 -27
  15. data/lib/keepassx/database/dumper.rb +87 -0
  16. data/lib/keepassx/database/finder.rb +102 -0
  17. data/lib/keepassx/database/loader.rb +217 -0
  18. data/lib/keepassx/entry.rb +70 -38
  19. data/lib/keepassx/field/base.rb +191 -0
  20. data/lib/keepassx/field/entry.rb +32 -0
  21. data/lib/keepassx/field/group.rb +27 -0
  22. data/lib/keepassx/fieldable.rb +161 -0
  23. data/lib/keepassx/group.rb +93 -20
  24. data/lib/keepassx/hashable_payload.rb +6 -0
  25. data/lib/keepassx/header.rb +102 -27
  26. data/lib/keepassx/version.rb +5 -0
  27. data/spec/factories.rb +23 -0
  28. data/spec/fixtures/database_empty.kdb +0 -0
  29. data/spec/fixtures/database_test.kdb +0 -0
  30. data/spec/fixtures/database_test_dumped.yml +76 -0
  31. data/spec/fixtures/database_with_key.kdb +0 -0
  32. data/spec/fixtures/database_with_key.key +1 -0
  33. data/spec/fixtures/database_with_key2.key +1 -0
  34. data/spec/fixtures/test_data_array.yml +113 -0
  35. data/spec/fixtures/test_data_array_dumped.yml +124 -0
  36. data/spec/keepassx/database_spec.rb +491 -29
  37. data/spec/keepassx/entry_spec.rb +95 -0
  38. data/spec/keepassx/group_spec.rb +92 -0
  39. data/spec/keepassx_spec.rb +17 -0
  40. data/spec/spec_helper.rb +59 -3
  41. metadata +143 -69
  42. data/.rvmrc +0 -1
  43. data/Gemfile.lock +0 -28
  44. data/lib/keepassx/entry_field.rb +0 -49
  45. data/lib/keepassx/group_field.rb +0 -44
  46. data/spec/test_database.kdb +0 -0
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keepassx
4
+ class Database
5
+ module Dumper
6
+
7
+ # Dump Array representation of database.
8
+ #
9
+ # @return [Array]
10
+ def to_a(opts = {})
11
+ result = []
12
+ find_groups(level: 0).each { |group| result << build_branch(group, opts) }
13
+ result
14
+ end
15
+
16
+
17
+ # Dump YAML representation of database.
18
+ #
19
+ # @return [String]
20
+ def to_yaml(opts = {})
21
+ YAML.dump to_a(opts)
22
+ end
23
+
24
+
25
+ # Save database to file storage
26
+ #
27
+ # @param password [String] Password the database will be encoded with.
28
+ # @return [Fixnum]
29
+ def save(opts = {})
30
+ path = opts.delete(:path) { nil }
31
+ password = opts.delete(:password) { nil }
32
+
33
+ new_path = path || @path
34
+ new_password = password || @password
35
+
36
+ raise ArgumentError, 'File path is not set' if new_path.nil?
37
+ raise ArgumentError, 'Password is not set' if new_password.nil?
38
+
39
+ File.open(new_path, 'wb') do |file|
40
+ file.write dump(new_password)
41
+ end
42
+ end
43
+
44
+
45
+ private
46
+
47
+
48
+ # Get raw encoded database.
49
+ #
50
+ # @param password [String] Password the database will be encoded with.
51
+ # @param key_file [String] Path to key file.
52
+ # @return [String]
53
+ def dump(password)
54
+ final_key = header.final_key(password)
55
+ initialize_payload
56
+ header.content_hash = checksum
57
+ @encrypted_payload = encrypt_payload(@payload, final_key)
58
+ data = header.encode << @encrypted_payload.to_s
59
+ data
60
+ end
61
+
62
+
63
+ # Organize descendants in proper structure for given group.
64
+ # @param [Keepassx::Group] group Root group, branch is build for.
65
+ def build_branch(group, opts = {})
66
+ group_hash = group.to_hash(opts)
67
+
68
+ group_entries = find_entries(group: group)
69
+ descendant_groups = find_groups(parent: group)
70
+
71
+ unless group_entries.nil?
72
+ group_hash['entries'] = []
73
+ group_entries.each { |e| group_hash['entries'] << e.to_hash(opts) }
74
+ end
75
+
76
+ unless descendant_groups.nil?
77
+ group_hash['groups'] = []
78
+ # Recursively build branch for descendant groups
79
+ descendant_groups.each { |g| group_hash['groups'] << build_branch(g, opts) }
80
+ end
81
+
82
+ group_hash
83
+ end
84
+
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keepassx
4
+ class Database
5
+ module Finder
6
+
7
+ # Get the first matching entry.
8
+ #
9
+ # @return [Keepassx::Entry]
10
+ def find_entry(opts = {}, &block)
11
+ entries = find_entries(opts, &block)
12
+ filter_list(entries)
13
+ end
14
+
15
+
16
+ # Get the first matching group.
17
+ #
18
+ # @return [Keepassx::Group]
19
+ def find_group(opts = {}, &block)
20
+ groups = find_groups(opts, &block)
21
+ filter_list(groups)
22
+ end
23
+
24
+
25
+ # Get all matching groups.
26
+ #
27
+ # @param opts [Hash]
28
+ # @return [Array<Keepassx::Group>]
29
+ def find_groups(opts = {}, &block)
30
+ find :group, opts, &block
31
+ end
32
+
33
+
34
+ # Get all matching entries.
35
+ #
36
+ # @return [Array<Keepassx::Entry>]
37
+ def find_entries(opts = {}, &block)
38
+ find :entry, opts, &block
39
+ end
40
+
41
+
42
+ def search(pattern)
43
+ backup = groups.find { |g| g.name == 'Backup' }
44
+ backup_group_id = backup&.id
45
+ entries.select { |e| e.group_id != backup_group_id && e.name =~ /#{pattern}/i }
46
+ end
47
+
48
+
49
+ private
50
+
51
+
52
+ # Search for items, using AND statement for the search conditions
53
+ #
54
+ # @param item_type [Symbol] Can be :entry or :group.
55
+ # @param opts [Hash] Search options.
56
+ # @return [Keepassx::Group, Keepassx::Entry]
57
+ def find(item_type, opts = {})
58
+ item_list = item_type == :entry ? @entries : @groups
59
+ items = opts.empty? ? item_list : deep_search(item_list, opts)
60
+
61
+ return items unless block_given?
62
+
63
+ items.each do |i|
64
+ yield i
65
+ end
66
+ end
67
+
68
+
69
+ # rubocop:disable Metrics/MethodLength
70
+ def deep_search(item_list, opts = {})
71
+ opts = { name: opts.to_s } if opts.is_a?(String) || opts.is_a?(Symbol)
72
+
73
+ match_number = opts.length
74
+
75
+ items = []
76
+ opts.each do |k, v|
77
+ items += Array(item_list.select { |e| e.send(k) == v })
78
+ end
79
+
80
+ buffer = Hash.new 0
81
+ items.each do |e|
82
+ buffer[e] += 1
83
+ end
84
+
85
+ # Select only items which matches all conditions
86
+ items = []
87
+ buffer.each do |k, v|
88
+ items << k if v == match_number
89
+ end
90
+
91
+ items
92
+ end
93
+ # rubocop:enable Metrics/MethodLength
94
+
95
+
96
+ def filter_list(list)
97
+ list.empty? ? nil : list.first
98
+ end
99
+
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Keepassx
4
+ class Database
5
+ module Loader
6
+
7
+ attr_reader :header
8
+ attr_reader :groups
9
+ attr_reader :entries
10
+
11
+ # rubocop:disable Metrics/MethodLength
12
+ def initialize(opts)
13
+ @password = nil
14
+ @groups = []
15
+ @entries = []
16
+ raw_db = ''
17
+
18
+ if opts.is_a?(File)
19
+ @path = opts.path
20
+ raw_db = read_file(opts)
21
+ load_database(raw_db)
22
+
23
+ elsif opts.is_a?(String)
24
+ @path = opts
25
+ raw_db = read_file(opts) if File.exist?(opts)
26
+ load_database(raw_db)
27
+
28
+ elsif opts.is_a?(Array)
29
+ @path = nil
30
+ load_database(raw_db)
31
+ opts.each { |item| parse_data(item) }
32
+ end
33
+ end
34
+ # rubocop:enable Metrics/MethodLength
35
+
36
+
37
+ # Unlock database.
38
+ #
39
+ # @param password [String] Database password.
40
+ # @return [Boolean] Whether or not password validation successfull.
41
+ # rubocop:disable Metrics/MethodLength
42
+ def unlock(password, keyfile = nil)
43
+ return true unless locked?
44
+
45
+ # Store password as we'll need it to dump/save database
46
+ @password = password
47
+ keyfile_data = keyfile ? read_file(keyfile) : nil
48
+
49
+ # Uncrypt database
50
+ final_key = header.final_key(password, keyfile_data)
51
+ @payload = decrypt_payload(@encrypted_payload, final_key)
52
+ payload_io = StringIO.new(@payload)
53
+
54
+ # Load it
55
+ groups = Group.extract_from_payload(header, payload_io)
56
+ @groups = initialize_groups(groups)
57
+ @entries = Entry.extract_from_payload(header, payload_io)
58
+
59
+ # Make groups <-> entries association
60
+ @entries.each do |entry|
61
+ group = @groups.detect { |g| g.id == entry.group_id }
62
+ group.entries << entry
63
+ entry.group = group
64
+ end
65
+
66
+ @locked = false
67
+
68
+ true
69
+ rescue OpenSSL::Cipher::CipherError
70
+ false
71
+ end
72
+ # rubocop:enable Metrics/MethodLength
73
+
74
+
75
+ # Get actual payload checksum.
76
+ #
77
+ # @return [String]
78
+ def checksum
79
+ Digest::SHA256.digest(payload)
80
+ end
81
+
82
+
83
+ def payload
84
+ @payload ||= initialize_payload
85
+ end
86
+
87
+
88
+ # Get Entries and Groups total number.
89
+ #
90
+ # @return [Fixnum]
91
+ def length
92
+ length = 0
93
+ [@groups, @entries].each do |items|
94
+ items.each do |item|
95
+ length += item.length
96
+ end
97
+ end
98
+ length
99
+ end
100
+
101
+
102
+ private
103
+
104
+
105
+ def read_file(file)
106
+ read_method = File.respond_to?(:binread) && :binread || :read
107
+ File.send(read_method, file)
108
+ end
109
+
110
+
111
+ def load_database(db)
112
+ if db.empty?
113
+ @header = Header.new
114
+ @encrypted_payload = ''
115
+ @locked = false
116
+ else
117
+ @header = Header.new(db[0..124])
118
+ @encrypted_payload = db[124..-1]
119
+ @locked = true
120
+ end
121
+
122
+ @locked
123
+ end
124
+
125
+
126
+ # See spec/fixtures/test_data_array.yaml for data example
127
+ # rubocop:disable Metrics/MethodLength
128
+ def parse_data(opts)
129
+ groups = opts[:groups] || []
130
+ entries = opts[:entries] || []
131
+ parent = opts[:parent]
132
+
133
+ # Remove groups and entries from options, so new group could be
134
+ # initialized from incoming Hash
135
+ fields = Keepassx::Group.fields
136
+ group_opts = opts.select { |k, _| fields.include?(k.to_s) }
137
+ group = add_group group_opts
138
+ group.parent = parent
139
+
140
+ entries.each do |e|
141
+ add_entry e.merge(group: group)
142
+ end
143
+
144
+ # Recursively proceed each child group
145
+ groups.each do |g|
146
+ parse_data g.merge(parent: group)
147
+ end
148
+ end
149
+ # rubocop:enable Metrics/MethodLength
150
+
151
+
152
+ def decrypt_payload(payload, final_key)
153
+ AESCrypt.decrypt(payload, final_key, header.encryption_iv, 'AES-256-CBC')
154
+ end
155
+
156
+
157
+ def encrypt_payload(payload, final_key)
158
+ AESCrypt.encrypt(payload, final_key, header.encryption_iv, 'AES-256-CBC')
159
+ end
160
+
161
+
162
+ # Set parents for groups
163
+ #
164
+ # @param list [Array] Array of groups.
165
+ # @return [Array] Updated array of groups.
166
+ # rubocop:disable Metrics/MethodLength
167
+ def initialize_groups(list)
168
+ list.each_with_index do |group, index|
169
+ previous_group = index == 0 ? nil : list[index - 1]
170
+
171
+ # If group is first entry or has level equal 0,
172
+ # it gets parent set to nil
173
+ if previous_group.nil? || group.level == 0
174
+ group.parent = nil
175
+
176
+ # If group has same level than the previous group,
177
+ # then is has the same parent
178
+ elsif group.level == previous_group.level
179
+ group.parent = previous_group.parent
180
+
181
+ # If group has level greater than parent's level by one,
182
+ # it gets parent set to the first previous group with level less
183
+ # than group's level by one
184
+ elsif group.level == previous_group.level + 1
185
+ group.parent = previous_group
186
+
187
+ # If group has level less than or equal the level of the previous
188
+ # group and its level is no less than zero, then need to backward
189
+ # search for the first group which level is less than group's
190
+ # level by 1 and set it as a parent of the group
191
+ elsif group.level > 0 && group.level <= previous_group.level
192
+ group.parent = (index - 2).downto 0 do |i|
193
+ parent_candidate = list[i]
194
+ break parent_candidate if parent_candidate.level + 1 == group.level
195
+ end
196
+
197
+ # Invalid level
198
+ else
199
+ raise "Unexpected level '#{group.level}' for group '#{group.name}'"
200
+ end
201
+ end
202
+
203
+ list
204
+ end
205
+ # rubocop:enable Metrics/MethodLength
206
+
207
+
208
+ def initialize_payload
209
+ result = +''
210
+ @groups.each { |group| result << group.encode }
211
+ @entries.each { |entry| result << entry.encode }
212
+ result
213
+ end
214
+
215
+ end
216
+ end
217
+ end
@@ -1,10 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # One entry: [FIELDTYPE(FT)][FIELDSIZE(FS)][FIELDDATA(FD)]
2
4
  # [FT+FS+(FD)][FT+FS+(FD)][FT+FS+(FD)][FT+FS+(FD)][FT+FS+(FD)]...
3
-
5
+ #
4
6
  # [ 2 bytes] FIELDTYPE
5
7
  # [ 4 bytes] FIELDSIZE, size of FIELDDATA in bytes
6
8
  # [ n bytes] FIELDDATA, n = FIELDSIZE
7
-
9
+ #
8
10
  # Notes:
9
11
  # - Strings are stored in UTF-8 encoded form and are null-terminated.
10
12
  # - FIELDTYPE can be one of the following identifiers:
@@ -25,57 +27,87 @@
25
27
  # * 000D: Binary description UTF-8 encoded string
26
28
  # * 000E: Binary data
27
29
  # * FFFF: Entry terminator, FIELDSIZE must be 0
28
- # '''
29
30
 
30
31
  module Keepassx
31
- class Entry
32
- def self.extract_from_payload(header, payload_io)
33
- groups = []
34
- header.nentries.times do
35
- group = Entry.new(payload_io)
36
- groups << group
32
+ class Entry < Fieldable
33
+
34
+ set_field_descriptor Keepassx::Field::Entry
35
+
36
+ attr_reader :group
37
+
38
+ def initialize(payload)
39
+ super do
40
+ # Do some validation
41
+ raise ArgumentError, "'name' is required (type: string)" unless valid_string?(payload[:name])
42
+ raise ArgumentError, "'group_id' is required (type: integer)" unless payload[:group] || valid_integer?(payload[:group_id])
43
+
44
+ # First set @group and @group_id.
45
+ # Remove key from payload to not interfere with KeePassX fields format
46
+ self.group = payload.delete(:group)
47
+
48
+ # Add group_id key to respect KeePassX fields format
49
+ payload[:group_id] = group.id
50
+
51
+ # Build list of fields
52
+ @fields = build_payload(payload)
37
53
  end
38
- groups
39
54
  end
40
55
 
41
- attr_reader :fields
42
56
 
43
- def initialize(payload_io)
44
- fields = []
45
- begin
46
- field = EntryField.new(payload_io)
47
- fields << field
48
- end while not field.terminator?
57
+ class << self
49
58
 
50
- @fields = fields
51
- end
59
+ def extract_from_payload(header, payload)
60
+ entries = []
61
+ header.entries_count.times { entries << Entry.new(payload) }
62
+ entries
63
+ end
52
64
 
53
- def length
54
- @fields.map(&:length).reduce(&:+)
55
65
  end
56
66
 
57
- def notes
58
- @fields.detect { |field| field.name == 'notes' }.data.chomp("\000")
59
- end
60
67
 
61
- def password
62
- @fields.detect { |field| field.name == 'password' }.data.chomp("\000")
63
- end
68
+ def group=(value)
69
+ raise ArgumentError, "Expected Keepassx::Group, got #{value.class}" unless value.is_a?(Keepassx::Group)
64
70
 
65
- def title
66
- @fields.detect { |field| field.name == 'title' }.data.chomp("\000")
71
+ self.group_id = value.id
72
+ @group = value
67
73
  end
68
74
 
69
- def username
70
- @fields.detect { |field| field.name == 'username' }.data.chomp("\000")
71
- end
72
75
 
73
- def group_id
74
- @fields.detect { |field| field.name == 'groupid' }.data
75
- end
76
+ private
77
+
78
+
79
+ # rubocop:disable Metrics/MethodLength
80
+ def default_fields
81
+ @default_fields ||= {
82
+ id: SecureRandom.uuid.gsub('-', ''),
83
+ group_id: nil,
84
+ icon: 1,
85
+ name: nil,
86
+ url: nil,
87
+ username: nil,
88
+ password: nil,
89
+ notes: nil,
90
+ creation_time: Time.now,
91
+ last_mod_time: Time.now,
92
+ last_acc_time: Time.now,
93
+ expiration_time: Time.local(2999, 12, 28, 23, 59, 59),
94
+ binary_desc: nil,
95
+ binary_data: nil,
96
+ terminator: nil,
97
+ }
98
+ end
99
+ # rubocop:enable Metrics/MethodLength
100
+
101
+
102
+ # Keep this method private in order to avoid group/group_id divergence
103
+ def group_id=(value)
104
+ set :group_id, value
105
+ end
106
+
107
+
108
+ def exclusion_list
109
+ super.concat(%w[binary_desc binary_data])
110
+ end
76
111
 
77
- def inspect
78
- "Entry<title=#{title.inspect}, username=[FILTERED], password=[FILTERED], notes=#{notes.inspect}>"
79
- end
80
112
  end
81
113
  end