keepassx 0.1.0 → 1.0.0

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