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,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
@@ -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
- def self.extract_from_payload(header, payload_io)
26
- groups = []
27
- header.ngroups.times do
28
- group = Group.new(payload_io)
29
- groups << group
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
- @fields = fields
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
- def length
45
- @fields.map(&:length).reduce(&:+)
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
- def group_id
49
- @fields.detect { |field| field.name == 'groupid' }.data
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
- def name
53
- @fields.detect { |field| field.name == 'group_name' }.data.chomp("\000")
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