keepassx 0.1.0 → 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.
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