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.
- checksums.yaml +7 -0
- data/.codeclimate.yml +30 -0
- data/.gitignore +9 -0
- data/.rubocop.yml +64 -0
- data/.travis.yml +12 -3
- data/Gemfile +4 -2
- data/Guardfile +16 -0
- data/LICENSE +19 -0
- data/README.md +33 -0
- data/Rakefile +3 -2
- data/keepassx.gemspec +20 -10
- data/lib/keepassx.rb +42 -3
- data/lib/keepassx/aes_crypt.rb +16 -6
- data/lib/keepassx/database.rb +218 -27
- data/lib/keepassx/database/dumper.rb +87 -0
- data/lib/keepassx/database/finder.rb +102 -0
- data/lib/keepassx/database/loader.rb +217 -0
- data/lib/keepassx/entry.rb +70 -38
- data/lib/keepassx/field/base.rb +191 -0
- data/lib/keepassx/field/entry.rb +32 -0
- data/lib/keepassx/field/group.rb +27 -0
- data/lib/keepassx/fieldable.rb +161 -0
- data/lib/keepassx/group.rb +93 -20
- data/lib/keepassx/hashable_payload.rb +6 -0
- data/lib/keepassx/header.rb +102 -27
- data/lib/keepassx/version.rb +5 -0
- data/spec/factories.rb +23 -0
- data/spec/fixtures/database_empty.kdb +0 -0
- data/spec/fixtures/database_test.kdb +0 -0
- data/spec/fixtures/database_test_dumped.yml +76 -0
- data/spec/fixtures/database_with_key.kdb +0 -0
- data/spec/fixtures/database_with_key.key +1 -0
- data/spec/fixtures/database_with_key2.key +1 -0
- data/spec/fixtures/test_data_array.yml +113 -0
- data/spec/fixtures/test_data_array_dumped.yml +124 -0
- data/spec/keepassx/database_spec.rb +491 -29
- data/spec/keepassx/entry_spec.rb +95 -0
- data/spec/keepassx/group_spec.rb +92 -0
- data/spec/keepassx_spec.rb +17 -0
- data/spec/spec_helper.rb +59 -3
- metadata +143 -69
- data/.rvmrc +0 -1
- data/Gemfile.lock +0 -28
- data/lib/keepassx/entry_field.rb +0 -49
- data/lib/keepassx/group_field.rb +0 -44
- 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
|
data/lib/keepassx/entry.rb
CHANGED
@@ -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
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
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
|
-
|
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
|
-
|
51
|
-
|
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
|
62
|
-
|
63
|
-
end
|
68
|
+
def group=(value)
|
69
|
+
raise ArgumentError, "Expected Keepassx::Group, got #{value.class}" unless value.is_a?(Keepassx::Group)
|
64
70
|
|
65
|
-
|
66
|
-
@
|
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
|
-
|
74
|
-
|
75
|
-
|
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
|