keepassx 0.1.0 → 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|