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