ruby-keepassx 0.2.0beta11
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/.coveralls.yml +1 -0
- data/.gitignore +9 -0
- data/.rvmrc +1 -0
- data/.travis.yml +5 -0
- data/Gemfile +5 -0
- data/README.md +28 -0
- data/Rakefile +5 -0
- data/lib/keepassx.rb +56 -0
- data/lib/keepassx/aes_crypt.rb +19 -0
- data/lib/keepassx/database.rb +431 -0
- data/lib/keepassx/entry.rb +155 -0
- data/lib/keepassx/entry_field.rb +27 -0
- data/lib/keepassx/exceptions.rb +4 -0
- data/lib/keepassx/field.rb +205 -0
- data/lib/keepassx/group.rb +138 -0
- data/lib/keepassx/group_field.rb +22 -0
- data/lib/keepassx/header.rb +178 -0
- data/lib/keepassx/item.rb +128 -0
- data/lib/keepassx/utilities.rb +226 -0
- data/ruby-keepassx.gemspec +23 -0
- data/spec/fixtures/test_data_array.yaml +54 -0
- data/spec/fixtures/test_data_hash.yaml +36 -0
- data/spec/fixtures/test_database.kdb +0 -0
- data/spec/keepassx/database_spec.rb +338 -0
- data/spec/keepassx/entry_spec.rb +72 -0
- data/spec/keepassx/group_spec.rb +47 -0
- data/spec/spec_helper.rb +64 -0
- metadata +126 -0
@@ -0,0 +1,155 @@
|
|
1
|
+
# One entry: [FIELDTYPE(FT)][FIELDSIZE(FS)][FIELDDATA(FD)]
|
2
|
+
# [FT+FS+(FD)][FT+FS+(FD)][FT+FS+(FD)][FT+FS+(FD)][FT+FS+(FD)]...
|
3
|
+
|
4
|
+
# [ 2 bytes] FIELDTYPE
|
5
|
+
# [ 4 bytes] FIELDSIZE, size of FIELDDATA in bytes
|
6
|
+
# [ n bytes] FIELDDATA, n = FIELDSIZE
|
7
|
+
|
8
|
+
# Notes:
|
9
|
+
# - Strings are stored in UTF-8 encoded form and are null-terminated.
|
10
|
+
# - FIELDTYPE can be one of the following identifiers:
|
11
|
+
# * 0000: Invalid or comment block, block is ignored
|
12
|
+
# * 0001: UUID, uniquely identifying an entry, FIELDSIZE must be 16
|
13
|
+
# * 0002: Group ID, identifying the group of the entry, FIELDSIZE = 4
|
14
|
+
# It can be any 32-bit value except 0 and 0xFFFFFFFF
|
15
|
+
# * 0003: Image ID, identifying the image/icon of the entry, FIELDSIZE = 4
|
16
|
+
# * 0004: Title of the entry, FIELDDATA is an UTF-8 encoded string
|
17
|
+
# * 0005: URL string, FIELDDATA is an UTF-8 encoded string
|
18
|
+
# * 0006: UserName string, FIELDDATA is an UTF-8 encoded string
|
19
|
+
# * 0007: Password string, FIELDDATA is an UTF-8 encoded string
|
20
|
+
# * 0008: Notes string, FIELDDATA is an UTF-8 encoded string
|
21
|
+
# * 0009: Creation time, FIELDSIZE = 5, FIELDDATA = packed date/time
|
22
|
+
# * 000A: Last modification time, FIELDSIZE = 5, FIELDDATA = packed date/time
|
23
|
+
# * 000B: Last access time, FIELDSIZE = 5, FIELDDATA = packed date/time
|
24
|
+
# * 000C: Expiration time, FIELDSIZE = 5, FIELDDATA = packed date/time
|
25
|
+
# * 000D: Binary description UTF-8 encoded string
|
26
|
+
# * 000E: Binary data
|
27
|
+
# * FFFF: Entry terminator, FIELDSIZE must be 0
|
28
|
+
# '''
|
29
|
+
|
30
|
+
module Keepassx
|
31
|
+
|
32
|
+
class Entry
|
33
|
+
|
34
|
+
include Item
|
35
|
+
|
36
|
+
FIELD_CLASS = Keepassx::EntryField
|
37
|
+
FIELD_MAPPING = {
|
38
|
+
:title => :title,
|
39
|
+
:icon => :imageid,
|
40
|
+
:lastmod => :last_mod_time,
|
41
|
+
:lastaccess => :last_acc_time,
|
42
|
+
:creation => :creation_time,
|
43
|
+
:expire => :expiration_time,
|
44
|
+
:password => :password,
|
45
|
+
:username => :username,
|
46
|
+
:uuid => :uuid,
|
47
|
+
:url => :url,
|
48
|
+
:binary_desc => :binary_desc,
|
49
|
+
:binary_data => :binary_data,
|
50
|
+
:comment => :notes,
|
51
|
+
}
|
52
|
+
|
53
|
+
|
54
|
+
def self.extract_from_payload(header, groups, payload)
|
55
|
+
items = []
|
56
|
+
header.entry_number.times do
|
57
|
+
entry = Entry.new(payload)
|
58
|
+
entry.group = groups.detect { |g| g.id.eql? entry.group_id }
|
59
|
+
items << entry
|
60
|
+
end
|
61
|
+
items
|
62
|
+
end
|
63
|
+
|
64
|
+
|
65
|
+
def self.fields
|
66
|
+
FIELD_MAPPING.keys
|
67
|
+
end
|
68
|
+
|
69
|
+
|
70
|
+
def initialize(payload)
|
71
|
+
super
|
72
|
+
|
73
|
+
if payload.is_a? StringIO
|
74
|
+
decode payload
|
75
|
+
|
76
|
+
elsif payload.is_a? Hash
|
77
|
+
fail "'title' is required" if payload[:title].nil?
|
78
|
+
fail "'group' is required" if payload[:group].nil?
|
79
|
+
self.group = payload[:group]
|
80
|
+
|
81
|
+
fields = self.class.fields
|
82
|
+
data = payload.reject { |k, _| !fields.include? k }
|
83
|
+
data[:group_id] = group.id
|
84
|
+
|
85
|
+
@fields = []
|
86
|
+
default_fields.merge(data).each do |k, v|
|
87
|
+
fail "Unknown field: '#{k}'" unless self.respond_to? "#{k}=", true
|
88
|
+
@fields << self.send("#{k}=", v)
|
89
|
+
end
|
90
|
+
|
91
|
+
else
|
92
|
+
fail "Expecting StringIO or Hash, got #{payload.class}"
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
|
97
|
+
attr_reader :group
|
98
|
+
|
99
|
+
|
100
|
+
def group= v
|
101
|
+
if v.is_a? Keepassx::Group
|
102
|
+
self.group_id = v.id
|
103
|
+
@group = v
|
104
|
+
else
|
105
|
+
fail "Expected Keepassx::Group, got #{v.class}"
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
|
110
|
+
def group_id
|
111
|
+
get :groupid
|
112
|
+
end
|
113
|
+
|
114
|
+
|
115
|
+
FIELD_MAPPING.each do |method, field|
|
116
|
+
define_method method do
|
117
|
+
get field
|
118
|
+
end
|
119
|
+
|
120
|
+
define_method "#{method}=" do |v|
|
121
|
+
set field, v
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
|
126
|
+
private
|
127
|
+
|
128
|
+
def default_fields
|
129
|
+
@default_fields ||= {
|
130
|
+
:uuid => SecureRandom.uuid,
|
131
|
+
:group_id => nil,
|
132
|
+
:icon => 1,
|
133
|
+
:title => nil,
|
134
|
+
:url => nil,
|
135
|
+
:username => nil,
|
136
|
+
:password => nil,
|
137
|
+
:comment => nil,
|
138
|
+
:creation => Time.now,
|
139
|
+
:lastmod => Time.now,
|
140
|
+
:lastaccess => Time.now,
|
141
|
+
:expire => Time.local(2999, 12, 28, 23, 59, 59),
|
142
|
+
:binary_desc => nil,
|
143
|
+
:binary_data => nil,
|
144
|
+
:terminator => nil
|
145
|
+
}
|
146
|
+
end
|
147
|
+
|
148
|
+
|
149
|
+
# Keep this method private in order to avoid group/group_id divergence
|
150
|
+
def group_id= v
|
151
|
+
set :groupid, v
|
152
|
+
end
|
153
|
+
|
154
|
+
end
|
155
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Keepassx
|
2
|
+
class EntryField
|
3
|
+
include Field
|
4
|
+
|
5
|
+
FIELD_TYPES = [
|
6
|
+
[0x0, 'ignored', :null],
|
7
|
+
[0x1, 'uuid', :ascii],
|
8
|
+
[0x2, 'groupid', :int],
|
9
|
+
[0x3, 'imageid', :int],
|
10
|
+
[0x4, 'title', :string],
|
11
|
+
[0x5, 'url', :string],
|
12
|
+
[0x6, 'username', :string],
|
13
|
+
[0x7, 'password', :string],
|
14
|
+
[0x8, 'notes', :string],
|
15
|
+
[0x9, 'creation_time', :date],
|
16
|
+
[0xa, 'last_mod_time', :date],
|
17
|
+
[0xb, 'last_acc_time', :date],
|
18
|
+
[0xc, 'expiration_time', :date],
|
19
|
+
[0xd, 'binary_desc', :string],
|
20
|
+
[0xe, 'binary_data', :shunt],
|
21
|
+
[0xFFFF, 'terminator', :null]
|
22
|
+
]
|
23
|
+
|
24
|
+
attr_reader :name, :data_type, :type_code
|
25
|
+
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,205 @@
|
|
1
|
+
module Keepassx
|
2
|
+
|
3
|
+
module Field
|
4
|
+
|
5
|
+
# FIELD_TERMINATOR = 0xFFFF # FIXME: Use it or remove it
|
6
|
+
TYPE_CODE_FIELD_SIZE = 2 # unsigned short integer
|
7
|
+
DATA_LENGTH_FIELD_SIZE = 4 # unsigned integer
|
8
|
+
|
9
|
+
|
10
|
+
def initialize(payload)
|
11
|
+
|
12
|
+
if payload.is_a? StringIO
|
13
|
+
|
14
|
+
@type_code, data_length = payload.read(
|
15
|
+
TYPE_CODE_FIELD_SIZE + DATA_LENGTH_FIELD_SIZE).unpack('SI')
|
16
|
+
@name, @data_type = parse_type_code
|
17
|
+
fail "Failed to determine field name from '#{payload.inspect}'" if
|
18
|
+
@name.nil?
|
19
|
+
fail "Failed to determine field typefrom '#{payload.inspect}'" if
|
20
|
+
@data_type.nil?
|
21
|
+
# Not using setter because it should be raw data here
|
22
|
+
@data = payload.read(data_length) unless terminator?
|
23
|
+
set_export_import_methods
|
24
|
+
|
25
|
+
elsif payload.is_a? Hash
|
26
|
+
@name = payload[:name].to_s
|
27
|
+
type_set = self.class::FIELD_TYPES.detect do |_, name, _|
|
28
|
+
name.eql? @name.to_s
|
29
|
+
end
|
30
|
+
@type_code, _, @data_type = type_set
|
31
|
+
|
32
|
+
set_export_import_methods
|
33
|
+
self.data = payload[:data]
|
34
|
+
end
|
35
|
+
|
36
|
+
rescue NoMethodError => e
|
37
|
+
raise Keepassx::MalformedDataError.
|
38
|
+
new "Read error at #{payload.lineno}" if e.name.eql? :unpack
|
39
|
+
# fail
|
40
|
+
end
|
41
|
+
|
42
|
+
|
43
|
+
def encode
|
44
|
+
buffer = [@type_code, size].pack 'SI'
|
45
|
+
buffer << @data unless @data.nil? # Writing raw data
|
46
|
+
# [buffer].pack "a#{size}"
|
47
|
+
buffer
|
48
|
+
end
|
49
|
+
|
50
|
+
|
51
|
+
def terminator?
|
52
|
+
@name.eql? 'terminator'
|
53
|
+
end
|
54
|
+
|
55
|
+
|
56
|
+
# Return raw data length
|
57
|
+
def length
|
58
|
+
TYPE_CODE_FIELD_SIZE + DATA_LENGTH_FIELD_SIZE + size
|
59
|
+
end
|
60
|
+
|
61
|
+
|
62
|
+
def size
|
63
|
+
case @data_type
|
64
|
+
when :null
|
65
|
+
0
|
66
|
+
when :int
|
67
|
+
4
|
68
|
+
when :date
|
69
|
+
5
|
70
|
+
when :uuid
|
71
|
+
16
|
72
|
+
else
|
73
|
+
(@data.nil?) && 0 || @data.length
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
|
78
|
+
def data
|
79
|
+
self.send @export_method
|
80
|
+
end
|
81
|
+
|
82
|
+
|
83
|
+
def data= value
|
84
|
+
self.send @import_method, value
|
85
|
+
end
|
86
|
+
|
87
|
+
|
88
|
+
def raw
|
89
|
+
@data
|
90
|
+
end
|
91
|
+
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
def set_export_import_methods
|
96
|
+
@export_method = "#{@data_type}".to_sym
|
97
|
+
@import_method = "#{@data_type}=".to_sym
|
98
|
+
end
|
99
|
+
|
100
|
+
|
101
|
+
def parse_type_code
|
102
|
+
(_, name, data_type) = self.class::FIELD_TYPES.detect do |(code, *rest)|
|
103
|
+
code == @type_code
|
104
|
+
end
|
105
|
+
[name, data_type]
|
106
|
+
end
|
107
|
+
|
108
|
+
|
109
|
+
def string
|
110
|
+
@data.chomp("\000")
|
111
|
+
end
|
112
|
+
|
113
|
+
|
114
|
+
def int
|
115
|
+
@data.unpack('I')[0]
|
116
|
+
end
|
117
|
+
|
118
|
+
|
119
|
+
def short
|
120
|
+
@data.unpack('S')[0]
|
121
|
+
end
|
122
|
+
|
123
|
+
|
124
|
+
def ascii
|
125
|
+
# TODO: Add spec
|
126
|
+
@data.unpack('H*')[0]
|
127
|
+
end
|
128
|
+
|
129
|
+
|
130
|
+
def date
|
131
|
+
buffer = @data.unpack('C5')
|
132
|
+
year = (buffer[0] << 6) | (buffer[1] >> 2)
|
133
|
+
month = ((buffer[1] & 0b11) << 2) | (buffer[2] >> 6)
|
134
|
+
day = ((buffer[2] & 0b111111) >> 1)
|
135
|
+
hour = ((buffer[2] & 0b1) << 4) | (buffer[3] >> 4)
|
136
|
+
min = ((buffer[3] & 0b1111) << 2) | (buffer[4] >> 6)
|
137
|
+
sec = ((buffer[4] & 0b111111))
|
138
|
+
|
139
|
+
Time.local(year, month, day, hour, min, sec)
|
140
|
+
end
|
141
|
+
|
142
|
+
|
143
|
+
def null
|
144
|
+
nil
|
145
|
+
end
|
146
|
+
|
147
|
+
|
148
|
+
def shunt
|
149
|
+
@data
|
150
|
+
end
|
151
|
+
|
152
|
+
|
153
|
+
def string= value
|
154
|
+
@data = "#{value}\000"
|
155
|
+
# @data.force_encoding 'ASCII-8BIT' if @data.respond_to? :force_encoding
|
156
|
+
end
|
157
|
+
|
158
|
+
|
159
|
+
def int= value
|
160
|
+
@data = [value].pack('I')
|
161
|
+
end
|
162
|
+
|
163
|
+
|
164
|
+
def short= value
|
165
|
+
@data = [value].pack('S')
|
166
|
+
end
|
167
|
+
|
168
|
+
|
169
|
+
def ascii= value
|
170
|
+
@data = [value].pack('H*')
|
171
|
+
end
|
172
|
+
|
173
|
+
|
174
|
+
def date= value
|
175
|
+
fail "Expected: Time, String or Fixnum, got: '#{value.class}'." unless
|
176
|
+
[Time, String, Fixnum].include? value.class
|
177
|
+
|
178
|
+
value = Time.parse value if value.is_a? String
|
179
|
+
value = Time.at value if value.is_a? Fixnum
|
180
|
+
|
181
|
+
sec, min, hour, day, month, year = value.to_a
|
182
|
+
@data = [
|
183
|
+
0x0000FFFF & ((year >> 6) & 0x0000003F),
|
184
|
+
0x0000FFFF & (((year & 0x0000003f) << 2) |
|
185
|
+
((month >> 2) & 0x00000003)),
|
186
|
+
0x0000FFFF & (((month & 0x00000003) << 6) |
|
187
|
+
((day & 0x0000001F) << 1) | ((hour >> 4) & 0x00000001)),
|
188
|
+
0x0000FFFF & (((hour & 0x0000000F) << 4) |
|
189
|
+
((min >> 2) & 0x0000000F)),
|
190
|
+
0x0000FFFF & (((min & 0x00000003) << 6) | (sec & 0x0000003F))
|
191
|
+
].pack('<C5')
|
192
|
+
end
|
193
|
+
|
194
|
+
|
195
|
+
def null= _
|
196
|
+
@data = nil
|
197
|
+
end
|
198
|
+
|
199
|
+
|
200
|
+
def shunt= value
|
201
|
+
@data = value
|
202
|
+
end
|
203
|
+
|
204
|
+
end
|
205
|
+
end
|
@@ -0,0 +1,138 @@
|
|
1
|
+
# One group: [FIELDTYPE(FT)][FIELDSIZE(FS)][FIELDDATA(FD)]
|
2
|
+
# [FT+FS+(FD)][FT+FS+(FD)][FT+FS+(FD)][FT+FS+(FD)][FT+FS+(FD)]...
|
3
|
+
#
|
4
|
+
# [ 2 bytes] FIELDTYPE
|
5
|
+
# [ 4 bytes] FIELDSIZE, size of FIELDDATA in bytes
|
6
|
+
# [ n bytes] FIELDDATA, n = FIELDSIZE
|
7
|
+
#
|
8
|
+
# Notes:
|
9
|
+
# - Strings are stored in UTF-8 encoded form and are null-terminated.
|
10
|
+
# - FIELDTYPE can be one of the following identifiers:
|
11
|
+
# * 0000: Invalid or comment block, block is ignored
|
12
|
+
# * 0001: Group ID, FIELDSIZE must be 4 bytes
|
13
|
+
# It can be any 32-bit value except 0 and 0xFFFFFFFF
|
14
|
+
# * 0002: Group name, FIELDDATA is an UTF-8 encoded string
|
15
|
+
# * 0003: Creation time, FIELDSIZE = 5, FIELDDATA = packed date/time
|
16
|
+
# * 0004: Last modification time, FIELDSIZE = 5, FIELDDATA = packed date/time
|
17
|
+
# * 0005: Last access time, FIELDSIZE = 5, FIELDDATA = packed date/time
|
18
|
+
# * 0006: Expiration time, FIELDSIZE = 5, FIELDDATA = packed date/time
|
19
|
+
# * 0007: Image ID, FIELDSIZE must be 4 bytes
|
20
|
+
# * 0008: Level, FIELDSIZE = 2
|
21
|
+
# * 0009: Flags, 32-bit value, FIELDSIZE = 4
|
22
|
+
# * FFFF: Group entry terminator, FIELDSIZE must be 0
|
23
|
+
module Keepassx
|
24
|
+
|
25
|
+
class Group
|
26
|
+
|
27
|
+
include Item
|
28
|
+
|
29
|
+
FIELD_CLASS = Keepassx::GroupField
|
30
|
+
FIELD_MAPPING = {
|
31
|
+
:id => :groupid,
|
32
|
+
:title => :group_name,
|
33
|
+
:icon => :imageid,
|
34
|
+
:lastmod => :lastmod_time,
|
35
|
+
:lastaccess => :lastacc_time,
|
36
|
+
:creation => :creation_time,
|
37
|
+
:expire => :expire_time,
|
38
|
+
:level => :level,
|
39
|
+
:flags => :flags
|
40
|
+
}
|
41
|
+
|
42
|
+
|
43
|
+
def self.extract_from_payload(header, payload_io)
|
44
|
+
groups = []
|
45
|
+
header.group_number.times { groups << Group.new(payload_io) }
|
46
|
+
groups
|
47
|
+
end
|
48
|
+
|
49
|
+
# return
|
50
|
+
def self.fields
|
51
|
+
FIELD_MAPPING.keys
|
52
|
+
end
|
53
|
+
|
54
|
+
|
55
|
+
attr_reader :parent
|
56
|
+
|
57
|
+
|
58
|
+
def initialize payload
|
59
|
+
super
|
60
|
+
|
61
|
+
if payload.is_a? StringIO
|
62
|
+
decode payload
|
63
|
+
|
64
|
+
elsif payload.is_a? Hash
|
65
|
+
fail "'title' is required" if payload[:title].nil?
|
66
|
+
fail "'id' is required" if payload[:id].nil?
|
67
|
+
|
68
|
+
group_parent = payload.delete :parent
|
69
|
+
@fields = []
|
70
|
+
default_fields.merge(payload).each do |k, v|
|
71
|
+
@fields << self.send("#{k.to_s}=", v)
|
72
|
+
end
|
73
|
+
|
74
|
+
self.parent = group_parent
|
75
|
+
|
76
|
+
else
|
77
|
+
fail "Expected StringIO or Hash, got #{payload.class}"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
|
82
|
+
FIELD_MAPPING.each do |method, field|
|
83
|
+
define_method method do
|
84
|
+
get field
|
85
|
+
end
|
86
|
+
|
87
|
+
define_method "#{method}=" do |v|
|
88
|
+
set field, v
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
|
93
|
+
def parent= v
|
94
|
+
if v.is_a? Keepassx::Group
|
95
|
+
self.level = v.level + 1 # FIXME: If parent is nil, then set level to 0
|
96
|
+
@parent = v
|
97
|
+
|
98
|
+
elsif v.nil?
|
99
|
+
self.level = 0 # Assume group located on top level if has no parent
|
100
|
+
|
101
|
+
else
|
102
|
+
fail "Expected Keepassx::Group, got #{v.class}"
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
|
107
|
+
def level
|
108
|
+
value = get :level
|
109
|
+
value.nil? ? 0 : value
|
110
|
+
end
|
111
|
+
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
def level= v
|
116
|
+
set :level, v
|
117
|
+
end
|
118
|
+
|
119
|
+
|
120
|
+
def default_fields
|
121
|
+
@default_field ||= {
|
122
|
+
:id => :unknown,
|
123
|
+
:title => :unknown,
|
124
|
+
:icon => 1,
|
125
|
+
# Group's timestamps does not make sense to me,
|
126
|
+
# hence removing that from defaults
|
127
|
+
# :creation => Time.now,
|
128
|
+
# :lastmod => Time.now,
|
129
|
+
# :lastaccess => Time.now,
|
130
|
+
# :expire => Time.local(2999, 12, 28, 23, 59, 59),
|
131
|
+
:level => 0,
|
132
|
+
:flags => 0,
|
133
|
+
:terminator => nil
|
134
|
+
}
|
135
|
+
end
|
136
|
+
|
137
|
+
end
|
138
|
+
end
|