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,191 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Keepassx
|
4
|
+
module Field
|
5
|
+
class Base
|
6
|
+
|
7
|
+
FIELD_TERMINATOR = 0xFFFF
|
8
|
+
TYPE_CODE_FIELD_SIZE = 2 # unsigned short integer
|
9
|
+
DATA_LENGTH_FIELD_SIZE = 4 # unsigned integer
|
10
|
+
|
11
|
+
attr_reader :name, :data_type, :type_code
|
12
|
+
|
13
|
+
|
14
|
+
def initialize(payload)
|
15
|
+
if payload.is_a?(StringIO)
|
16
|
+
@type_code, data_length = payload.read(TYPE_CODE_FIELD_SIZE + DATA_LENGTH_FIELD_SIZE).unpack('SI')
|
17
|
+
_, @name, @data_type = self.class.fields_description.find { |type_code, _, _| type_code == @type_code }
|
18
|
+
|
19
|
+
# Not using setter because it should be raw data here
|
20
|
+
@data = payload.read(data_length)
|
21
|
+
|
22
|
+
# Set export_import_methods *after* setting data
|
23
|
+
set_export_import_methods(@data_type)
|
24
|
+
|
25
|
+
elsif payload.is_a?(Hash)
|
26
|
+
@name = payload[:name].to_s
|
27
|
+
@type_code, _, @data_type = self.class.fields_description.find { |_, name, _| name == @name }
|
28
|
+
|
29
|
+
# Set export_import_methods *before* setting data
|
30
|
+
set_export_import_methods(@data_type)
|
31
|
+
|
32
|
+
# Using setter because we need to convert data here
|
33
|
+
self.data = payload[:data]
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
|
38
|
+
def data
|
39
|
+
send(@export_method)
|
40
|
+
end
|
41
|
+
|
42
|
+
|
43
|
+
def data=(value)
|
44
|
+
send(@import_method, value)
|
45
|
+
end
|
46
|
+
|
47
|
+
|
48
|
+
def terminator?
|
49
|
+
name == 'terminator'
|
50
|
+
end
|
51
|
+
|
52
|
+
|
53
|
+
def length
|
54
|
+
TYPE_CODE_FIELD_SIZE + DATA_LENGTH_FIELD_SIZE + size
|
55
|
+
end
|
56
|
+
|
57
|
+
|
58
|
+
def size
|
59
|
+
case data_type
|
60
|
+
when :null
|
61
|
+
0
|
62
|
+
when :int
|
63
|
+
4
|
64
|
+
when :date
|
65
|
+
5
|
66
|
+
when :uuid
|
67
|
+
16
|
68
|
+
else
|
69
|
+
(@data.nil? && 0) || @data.length
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
|
74
|
+
def encode
|
75
|
+
buffer = [type_code, size].pack 'SI'
|
76
|
+
buffer << @data unless @data.nil?
|
77
|
+
buffer
|
78
|
+
end
|
79
|
+
|
80
|
+
|
81
|
+
private
|
82
|
+
|
83
|
+
|
84
|
+
# rubocop:disable Style/UnneededInterpolation
|
85
|
+
def set_export_import_methods(type)
|
86
|
+
@export_method = "#{type}".to_sym
|
87
|
+
@import_method = "#{type}=".to_sym
|
88
|
+
end
|
89
|
+
# rubocop:enable Style/UnneededInterpolation
|
90
|
+
|
91
|
+
|
92
|
+
### EXPORT METHODS
|
93
|
+
|
94
|
+
def null
|
95
|
+
nil
|
96
|
+
end
|
97
|
+
|
98
|
+
|
99
|
+
def shunt
|
100
|
+
@data
|
101
|
+
end
|
102
|
+
|
103
|
+
|
104
|
+
def string
|
105
|
+
@data.chomp("\000")
|
106
|
+
end
|
107
|
+
|
108
|
+
|
109
|
+
def int
|
110
|
+
@data.unpack('I')[0]
|
111
|
+
end
|
112
|
+
|
113
|
+
|
114
|
+
def short
|
115
|
+
@data.unpack('S')[0]
|
116
|
+
end
|
117
|
+
|
118
|
+
|
119
|
+
def ascii
|
120
|
+
# TODO: Add spec
|
121
|
+
@data.unpack('H*')[0]
|
122
|
+
end
|
123
|
+
|
124
|
+
|
125
|
+
def date
|
126
|
+
buffer = @data.unpack('C5')
|
127
|
+
year = (buffer[0] << 6) | (buffer[1] >> 2)
|
128
|
+
month = ((buffer[1] & 0b11) << 2) | (buffer[2] >> 6)
|
129
|
+
day = ((buffer[2] & 0b111111) >> 1)
|
130
|
+
hour = ((buffer[2] & 0b1) << 4) | (buffer[3] >> 4)
|
131
|
+
min = ((buffer[3] & 0b1111) << 2) | (buffer[4] >> 6)
|
132
|
+
sec = ((buffer[4] & 0b111111))
|
133
|
+
|
134
|
+
Time.local(year, month, day, hour, min, sec)
|
135
|
+
end
|
136
|
+
|
137
|
+
|
138
|
+
### IMPORT METHODS
|
139
|
+
|
140
|
+
# rubocop:disable Naming/UncommunicativeMethodParamName
|
141
|
+
def null=(_)
|
142
|
+
@data = nil
|
143
|
+
end
|
144
|
+
# rubocop:enable Naming/UncommunicativeMethodParamName
|
145
|
+
|
146
|
+
|
147
|
+
def shunt=(value)
|
148
|
+
@data = value
|
149
|
+
end
|
150
|
+
|
151
|
+
|
152
|
+
def string=(value)
|
153
|
+
@data = "#{value}\000"
|
154
|
+
end
|
155
|
+
|
156
|
+
|
157
|
+
def int=(value)
|
158
|
+
@data = [value].pack('I')
|
159
|
+
end
|
160
|
+
|
161
|
+
|
162
|
+
def short=(value)
|
163
|
+
@data = [value].pack('S')
|
164
|
+
end
|
165
|
+
|
166
|
+
|
167
|
+
def ascii=(value)
|
168
|
+
@data = [value].pack('H*')
|
169
|
+
end
|
170
|
+
|
171
|
+
|
172
|
+
def date=(value)
|
173
|
+
raise ArgumentError, "Expected: Time, String or Integer, got: '#{value.class}'." unless [Time, String, Integer].include?(value.class)
|
174
|
+
|
175
|
+
value = Time.parse(value) if value.is_a?(String)
|
176
|
+
value = Time.at(value) if value.is_a?(Integer)
|
177
|
+
|
178
|
+
sec, min, hour, day, month, year = value.to_a
|
179
|
+
|
180
|
+
@data = [
|
181
|
+
0x0000FFFF & ((year >> 6) & 0x0000003F),
|
182
|
+
0x0000FFFF & (((year & 0x0000003f) << 2) | ((month >> 2) & 0x00000003)),
|
183
|
+
0x0000FFFF & (((month & 0x00000003) << 6) | ((day & 0x0000001F) << 1) | ((hour >> 4) & 0x00000001)),
|
184
|
+
0x0000FFFF & (((hour & 0x0000000F) << 4) | ((min >> 2) & 0x0000000F)),
|
185
|
+
0x0000FFFF & (((min & 0x00000003) << 6) | (sec & 0x0000003F)),
|
186
|
+
].pack('<C5')
|
187
|
+
end
|
188
|
+
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Keepassx
|
4
|
+
module Field
|
5
|
+
class Entry < Base
|
6
|
+
|
7
|
+
# rubocop:disable Metrics/MethodLength
|
8
|
+
def self.fields_description
|
9
|
+
@fields_description ||= [
|
10
|
+
[0x0, 'ignored', :null],
|
11
|
+
[0x1, 'id', :ascii],
|
12
|
+
[0x2, 'group_id', :int],
|
13
|
+
[0x3, 'icon', :int],
|
14
|
+
[0x4, 'name', :string],
|
15
|
+
[0x5, 'url', :string],
|
16
|
+
[0x6, 'username', :string],
|
17
|
+
[0x7, 'password', :string],
|
18
|
+
[0x8, 'notes', :string],
|
19
|
+
[0x9, 'creation_time', :date],
|
20
|
+
[0xa, 'last_mod_time', :date],
|
21
|
+
[0xb, 'last_acc_time', :date],
|
22
|
+
[0xc, 'expiration_time', :date],
|
23
|
+
[0xd, 'binary_desc', :string],
|
24
|
+
[0xe, 'binary_data', :shunt],
|
25
|
+
[0xFFFF, 'terminator', :null],
|
26
|
+
]
|
27
|
+
end
|
28
|
+
# rubocop:enable Metrics/MethodLength
|
29
|
+
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Keepassx
|
4
|
+
module Field
|
5
|
+
class Group < Base
|
6
|
+
|
7
|
+
# rubocop:disable Metrics/MethodLength
|
8
|
+
def self.fields_description
|
9
|
+
@fields_description ||= [
|
10
|
+
[0x0, 'ignored', :null],
|
11
|
+
[0x1, 'id', :int],
|
12
|
+
[0x2, 'name', :string],
|
13
|
+
[0x3, 'creation_time', :date],
|
14
|
+
[0x4, 'last_mod_time', :date],
|
15
|
+
[0x5, 'last_acc_time', :date],
|
16
|
+
[0x6, 'expiration_time', :date],
|
17
|
+
[0x7, 'icon', :int],
|
18
|
+
[0x8, 'level', :short],
|
19
|
+
[0x9, 'flags', :int],
|
20
|
+
[0xFFFF, 'terminator', :null],
|
21
|
+
]
|
22
|
+
end
|
23
|
+
# rubocop:enable Metrics/MethodLength
|
24
|
+
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,161 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Keepassx
|
4
|
+
class Fieldable
|
5
|
+
|
6
|
+
attr_reader :fields
|
7
|
+
|
8
|
+
def initialize(payload)
|
9
|
+
@fields = []
|
10
|
+
|
11
|
+
if payload.is_a?(StringIO)
|
12
|
+
@fields = decode_payload(payload)
|
13
|
+
elsif payload.is_a?(Hash)
|
14
|
+
yield
|
15
|
+
else
|
16
|
+
raise ArgumentError, "Expected StringIO or Hash, got #{payload.class}"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
|
21
|
+
class << self
|
22
|
+
|
23
|
+
attr_reader :field_descriptor
|
24
|
+
|
25
|
+
def set_field_descriptor(klass)
|
26
|
+
@field_descriptor = klass
|
27
|
+
create_fieldable_methods(klass.fields_description)
|
28
|
+
end
|
29
|
+
|
30
|
+
|
31
|
+
def create_fieldable_methods(methods)
|
32
|
+
methods.each do |_, method, _|
|
33
|
+
define_method method do
|
34
|
+
get method
|
35
|
+
end
|
36
|
+
|
37
|
+
define_method "#{method}=" do |v|
|
38
|
+
set method, v
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
|
44
|
+
# Return the list of fields' names
|
45
|
+
def fields
|
46
|
+
@fields ||= field_descriptor.fields_description.map { |_, name, _| name }
|
47
|
+
end
|
48
|
+
|
49
|
+
end
|
50
|
+
|
51
|
+
|
52
|
+
def length
|
53
|
+
fields.map(&:length).reduce(&:+)
|
54
|
+
end
|
55
|
+
|
56
|
+
|
57
|
+
def to_hash(opts = {})
|
58
|
+
skip_date = opts.fetch(:skip_date, false)
|
59
|
+
|
60
|
+
result = {}
|
61
|
+
fields.each do |field|
|
62
|
+
next if excluded_field?(field.name)
|
63
|
+
next if date_field?(field.name) && skip_date
|
64
|
+
|
65
|
+
result[field.name] = field.data
|
66
|
+
end
|
67
|
+
result
|
68
|
+
end
|
69
|
+
|
70
|
+
|
71
|
+
def encode
|
72
|
+
buffer = +''
|
73
|
+
fields.each do |field|
|
74
|
+
buffer << field.encode
|
75
|
+
end
|
76
|
+
buffer
|
77
|
+
end
|
78
|
+
|
79
|
+
|
80
|
+
def inspect
|
81
|
+
output = []
|
82
|
+
default_fields.each_key do |field_name|
|
83
|
+
if field_name == :password
|
84
|
+
output << 'password=[FILTERED]'
|
85
|
+
else
|
86
|
+
output << "#{field_name}=#{send(field_name)}" unless field_name == :terminator
|
87
|
+
end
|
88
|
+
end
|
89
|
+
"<#{self.class} #{output.join(', ')}>"
|
90
|
+
end
|
91
|
+
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
|
96
|
+
def decode_payload(payload)
|
97
|
+
fields = []
|
98
|
+
|
99
|
+
loop do
|
100
|
+
field = self.class.field_descriptor.new(payload)
|
101
|
+
fields << field
|
102
|
+
break if field.terminator?
|
103
|
+
end
|
104
|
+
|
105
|
+
fields
|
106
|
+
end
|
107
|
+
|
108
|
+
|
109
|
+
def build_payload(payload)
|
110
|
+
fields = []
|
111
|
+
default_fields.merge(payload).each do |k, v|
|
112
|
+
fields << self.class.field_descriptor.new(name: k, data: v)
|
113
|
+
end
|
114
|
+
fields
|
115
|
+
end
|
116
|
+
|
117
|
+
|
118
|
+
def valid_integer?(field)
|
119
|
+
field.is_a?(Integer)
|
120
|
+
end
|
121
|
+
|
122
|
+
|
123
|
+
def valid_string?(field)
|
124
|
+
field.is_a?(String) && !field.empty?
|
125
|
+
end
|
126
|
+
|
127
|
+
|
128
|
+
def get(name)
|
129
|
+
field = @fields.find { |f| f.name == name.to_s }
|
130
|
+
field&.data
|
131
|
+
end
|
132
|
+
|
133
|
+
|
134
|
+
def set(name, value)
|
135
|
+
field = @fields.find { |f| f.name == name.to_s }
|
136
|
+
if field.nil?
|
137
|
+
field = self.class.field_descriptor.new(name: name, data: value)
|
138
|
+
@fields << field
|
139
|
+
else
|
140
|
+
field.data = value
|
141
|
+
end
|
142
|
+
field
|
143
|
+
end
|
144
|
+
|
145
|
+
|
146
|
+
def excluded_field?(field)
|
147
|
+
exclusion_list.include?(field)
|
148
|
+
end
|
149
|
+
|
150
|
+
|
151
|
+
def exclusion_list
|
152
|
+
%w[terminator]
|
153
|
+
end
|
154
|
+
|
155
|
+
|
156
|
+
def date_field?(field)
|
157
|
+
%w[creation_time last_mod_time last_acc_time expiration_time].include?(field)
|
158
|
+
end
|
159
|
+
|
160
|
+
end
|
161
|
+
end
|
data/lib/keepassx/group.rb
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# One group: [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
|
#
|
@@ -20,37 +22,108 @@
|
|
20
22
|
# * 0008: Level, FIELDSIZE = 2
|
21
23
|
# * 0009: Flags, 32-bit value, FIELDSIZE = 4
|
22
24
|
# * FFFF: Group entry terminator, FIELDSIZE must be 0
|
25
|
+
|
23
26
|
module Keepassx
|
24
|
-
class Group
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
27
|
+
class Group < Fieldable
|
28
|
+
|
29
|
+
set_field_descriptor Keepassx::Field::Group
|
30
|
+
|
31
|
+
attr_accessor :entries
|
32
|
+
attr_reader :parent
|
33
|
+
|
34
|
+
def initialize(payload)
|
35
|
+
@parent = nil
|
36
|
+
@entries = []
|
37
|
+
|
38
|
+
super do
|
39
|
+
# Do some validation
|
40
|
+
raise ArgumentError, "'id' is required (type: integer)" unless valid_integer?(payload[:id])
|
41
|
+
raise ArgumentError, "'name' is required (type: string)" unless valid_string?(payload[:name])
|
42
|
+
|
43
|
+
# First set @parent and @level.
|
44
|
+
# Remove key from payload to not interfere with KeePassX fields format
|
45
|
+
self.parent = payload.delete(:parent)
|
46
|
+
|
47
|
+
# Build list of fields
|
48
|
+
@fields = build_payload(payload)
|
30
49
|
end
|
31
|
-
groups
|
32
50
|
end
|
33
51
|
|
34
|
-
def initialize(payload_io)
|
35
|
-
fields = []
|
36
|
-
begin
|
37
|
-
field = GroupField.new(payload_io)
|
38
|
-
fields << field
|
39
|
-
end while not field.terminator?
|
40
52
|
|
41
|
-
|
53
|
+
class << self
|
54
|
+
|
55
|
+
def extract_from_payload(header, payload)
|
56
|
+
groups = []
|
57
|
+
header.groups_count.times { groups << Group.new(payload) }
|
58
|
+
groups
|
59
|
+
end
|
60
|
+
|
42
61
|
end
|
43
62
|
|
44
|
-
|
45
|
-
|
63
|
+
|
64
|
+
def parent=(value)
|
65
|
+
raise ArgumentError, "Expected Keepassx::Group or nil, got #{value.class}" unless valid_parent?(value)
|
66
|
+
|
67
|
+
if value.is_a?(Keepassx::Group)
|
68
|
+
self.level = value.level + 1
|
69
|
+
@parent = value
|
70
|
+
|
71
|
+
elsif value.nil?
|
72
|
+
# Assume, group is located at the top level, in case it has no parent
|
73
|
+
self.level = 0
|
74
|
+
@parent = nil
|
75
|
+
end
|
46
76
|
end
|
47
77
|
|
48
|
-
|
49
|
-
|
78
|
+
|
79
|
+
# Redefine #level method to return 0 instead of nil
|
80
|
+
def level
|
81
|
+
value = get :level
|
82
|
+
value.nil? ? 0 : value
|
50
83
|
end
|
51
84
|
|
52
|
-
|
53
|
-
|
85
|
+
|
86
|
+
def ==(other)
|
87
|
+
return false if other.nil?
|
88
|
+
|
89
|
+
parent == other.parent &&
|
90
|
+
name == other.name &&
|
91
|
+
id == other.id &&
|
92
|
+
level == other.level &&
|
93
|
+
icon == other.icon
|
54
94
|
end
|
95
|
+
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
|
100
|
+
# Redefine #level= to make it private :
|
101
|
+
# Setting group level only is a non-sense as it depends
|
102
|
+
# on parent group.
|
103
|
+
def level=(value)
|
104
|
+
set :level, value
|
105
|
+
end
|
106
|
+
|
107
|
+
|
108
|
+
def default_fields
|
109
|
+
@default_fields ||= {
|
110
|
+
id: :unknown,
|
111
|
+
name: :unknown,
|
112
|
+
creation_time: Time.now,
|
113
|
+
last_mod_time: Time.now,
|
114
|
+
last_acc_time: Time.now,
|
115
|
+
expiration_time: Time.local(2999, 12, 28, 23, 59, 59),
|
116
|
+
icon: 1,
|
117
|
+
level: 0,
|
118
|
+
flags: 0,
|
119
|
+
terminator: nil,
|
120
|
+
}
|
121
|
+
end
|
122
|
+
|
123
|
+
|
124
|
+
def valid_parent?(object)
|
125
|
+
object.is_a?(Keepassx::Group) || object.nil?
|
126
|
+
end
|
127
|
+
|
55
128
|
end
|
56
129
|
end
|