rubeepass 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/rubeepass.rb ADDED
@@ -0,0 +1,491 @@
1
+ require "cgi"
2
+ require "digest"
3
+ require "openssl"
4
+ require "os"
5
+ require "scoobydoo"
6
+ require "uri"
7
+ require "zlib"
8
+
9
+ class RubeePass
10
+ @@END_OF_HEADER = 0
11
+ @@COMMENT = 1
12
+ @@CIPHER_ID = 2
13
+ @@COMPRESSION = 3
14
+ @@MASTER_SEED = 4
15
+ @@TRANSFORM_SEED = 5
16
+ @@TRANSFORM_ROUNDS = 6
17
+ @@ENCRYPTION_IV = 7
18
+ @@PROTECTED_STREAM_KEY = 8
19
+ @@STREAM_START_BYTES = 9
20
+ @@INNER_RANDOM_STREAM_ID = 10
21
+
22
+ @@MAGIC_SIG1 = 0x9aa2d903
23
+ @@MAGIC_SIG2 = 0xb54bfb67
24
+ @@VERSION = 0x00030000
25
+
26
+ attr_reader :db
27
+ attr_reader :gzip
28
+ attr_reader :protected_decryptor
29
+ attr_reader :xml
30
+
31
+ def absolute_path(to, from = "/")
32
+ return "/" if (to.nil? || to.empty? || (to == "/"))
33
+ from = "/" if (to.start_with?("/"))
34
+
35
+ path = Array.new
36
+
37
+ from.split("/").each do |group|
38
+ next if (group.empty?)
39
+ case group
40
+ when "."
41
+ # Do nothing
42
+ when ".."
43
+ path.pop
44
+ else
45
+ path.push(group)
46
+ end
47
+ end
48
+
49
+ to.split("/").each do |group|
50
+ next if (group.empty?)
51
+ case group
52
+ when "."
53
+ # Do nothing
54
+ when ".."
55
+ path.pop
56
+ else
57
+ path.push(group)
58
+ end
59
+ end
60
+
61
+ return "/#{path.join("/")}"
62
+ end
63
+
64
+ def clear_clipboard(time = 0)
65
+ @thread.kill if (@thread)
66
+ @thread = Thread.new do
67
+ sleep time
68
+ copy_to_clipboard("")
69
+ end
70
+ end
71
+
72
+ def copy_to_clipboard(string)
73
+ string = " \x7F" if (string.nil? || string.empty?)
74
+ if (OS::Underlying.windows?)
75
+ puts "Your OS is not currently supported!"
76
+ return
77
+ elsif (OS.mac?)
78
+ pbcopy = ScoobyDoo.where_are_you("pbcopy")
79
+ rn = ScoobyDoo.where_are_you("reattach-to-user-namespace")
80
+
81
+ cp = pbcopy
82
+ if (ENV["TMUX"])
83
+ cp = nil
84
+ cp = "#{rn} #{pbcopy}" if (rn)
85
+ end
86
+
87
+ if (cp)
88
+ system("echo \"#{string}\" | #{cp}")
89
+ else
90
+ puts "Please install reattach-to-user-namespace!"
91
+ return
92
+ end
93
+ elsif (OS.posix?)
94
+ xclip = ScoobyDoo.where_are_you("xclip")
95
+ xsel = ScoobyDoo.where_are_you("xsel")
96
+
97
+ cp = nil
98
+ if (xclip)
99
+ cp = "xclip -i -selection clipboard"
100
+ elsif (xsel)
101
+ cp = "xsel -i --clipboard"
102
+ end
103
+
104
+ if (cp)
105
+ system("echo \"#{string}\" | #{cp}")
106
+ else
107
+ puts "Please install either xclip or xsel!"
108
+ return
109
+ end
110
+ else
111
+ puts "Your OS is not currently supported!"
112
+ return
113
+ end
114
+ end
115
+
116
+ def derive_aes_key
117
+ cipher = OpenSSL::Cipher::AES.new(256, :ECB)
118
+ cipher.encrypt
119
+ cipher.key = @header[@@TRANSFORM_SEED]
120
+ cipher.padding = 0
121
+
122
+ @header[@@TRANSFORM_ROUNDS].times do
123
+ @key = cipher.update(@key) + cipher.final
124
+ end
125
+
126
+ transform_key = Digest::SHA256::digest(@key)
127
+ combined_key = @header[@@MASTER_SEED] + transform_key
128
+
129
+ @aes_key = Digest::SHA256::digest(combined_key)
130
+ @aes_iv = @header[@@ENCRYPTION_IV]
131
+ end
132
+ private :derive_aes_key
133
+
134
+ def extract_xml
135
+ @xml = Zlib::GzipReader.new(StringIO.new(@gzip)).read
136
+ end
137
+ private :extract_xml
138
+
139
+ def find_group(path)
140
+ return @db.find_group(path)
141
+ end
142
+
143
+ def fuzzy_find(input)
144
+ return @db.fuzzy_find(input)
145
+ end
146
+
147
+ def handle_protected(base64)
148
+ data = nil
149
+ begin
150
+ data = base64.unpack("m*")[0].fix
151
+ rescue ArgumentError => e
152
+ raise Error::InvalidProtectedDataError.new
153
+ end
154
+ raise Error::InvalidProtectedDataError.new if (data.nil?)
155
+
156
+ return @protected_decryptor.add_to_stream(data)
157
+ end
158
+ private :handle_protected
159
+
160
+ def initialize(kdbx, password, keyfile = nil)
161
+ @aes_iv = nil
162
+ @aes_key = nil
163
+ @db = nil
164
+ @gzip = nil
165
+ @header = nil
166
+ @kdbx = kdbx
167
+ @key = nil
168
+ @keyfile = keyfile
169
+ @password = password
170
+ @xml = nil
171
+ end
172
+
173
+ def join_key_and_keyfile
174
+ passhash = Digest::SHA256.digest(@password)
175
+
176
+ filehash = ""
177
+ if (@keyfile)
178
+ contents = File.readlines(@keyfile).join.fix
179
+ if (contents[0..4] == "<?xml")
180
+ # XML Key file
181
+ # My ugly attempt to parse a small XML Key file with a
182
+ # poor attempt at schema validation
183
+ keyfile_line = false
184
+ key_line = false
185
+ contents.each_line do |line|
186
+ line.strip!
187
+ case line
188
+ when "<KeyFile>"
189
+ keyfile_line = true
190
+ when "<Key>"
191
+ key_line = true
192
+ when %r{<Data>.*</Data>}
193
+ data = line.gsub(%r{^<Data>|</Data>$}, "")
194
+ data = data.unpack("m*")[0]
195
+ break if (!keyfile_line || !key_line)
196
+ break if (data.length != 32)
197
+ filehash = data
198
+ end
199
+ end
200
+ elsif (contents.length == 32)
201
+ # Not XML but a 32 byte Key file
202
+ filehash = contents
203
+ elsif (contents.length == 64)
204
+ # Not XML but a 64 byte Key file
205
+ if (contents.match(/^[0-9A-Fa-f]+$/))
206
+ filehash = [contents].pack("H*")
207
+ end
208
+ else
209
+ # Not a Key file
210
+ filehash = Digest::SHA256.digest(contents)
211
+ end
212
+ end
213
+
214
+ @key = Digest::SHA256.digest(passhash + filehash)
215
+ end
216
+ private :join_key_and_keyfile
217
+
218
+ def method_missing(method_name, *args)
219
+ if (method_name.to_s.match(/^clear_clipboard_after_/))
220
+ mn = method_name.to_s.gsub!(/^clear_clipboard_after_/, "")
221
+ case mn
222
+ when /^[0-9]+_sec(ond)?s$/
223
+ time = mn.gsub(/_sec(ond)?s$/, "").to_i
224
+ clear_clipboard(time)
225
+ when /^[0-9]+_min(ute)?s$/
226
+ time = mn.gsub(/_min(ute)?s$/, "").to_i
227
+ clear_clipboard(time * 60)
228
+ else
229
+ super
230
+ end
231
+ else
232
+ super
233
+ end
234
+ end
235
+
236
+ def open
237
+ file = File.open(@kdbx)
238
+
239
+ read_magic_and_version(file)
240
+ read_header(file)
241
+ join_key_and_keyfile
242
+ derive_aes_key
243
+ read_gzip(file)
244
+
245
+ file.close
246
+
247
+ @protected_decryptor = ProtectedDecryptor.new(
248
+ Digest::SHA256.digest(
249
+ @header[@@PROTECTED_STREAM_KEY]
250
+ ),
251
+ ["E830094B97205D2A"].pack("H*")
252
+ )
253
+
254
+ extract_xml
255
+ parse_xml
256
+
257
+ return self
258
+ end
259
+
260
+ def parse_gzip(file)
261
+ gzip = ""
262
+ block_id = 0
263
+
264
+ loop do
265
+ # Read block ID
266
+ data = file.read(4)
267
+ raise Error::InvalidGzipError.new if (data.nil?)
268
+ id = data.unpack("L*")[0]
269
+ raise Error::InvalidGzipError.new if (block_id != id)
270
+
271
+ block_id += 1
272
+
273
+ # Read expected hash
274
+ data = file.read(32)
275
+ raise Error::InvalidGzipError.new if (data.nil?)
276
+ expected_hash = data
277
+
278
+ # Read size
279
+ data = file.read(4)
280
+ raise Error::InvalidGzipError.new if (data.nil?)
281
+ size = data.unpack("L*")[0]
282
+
283
+ # Break is size is 0 and expected hash is all 0's
284
+ if (size == 0)
285
+ expected_hash.each_byte do |byte|
286
+ if (byte != 0)
287
+ raise Error::InvalidGzipError.new
288
+ end
289
+ end
290
+ break
291
+ end
292
+
293
+ # Read data and get actual hash
294
+ data = file.read(size)
295
+ actual_hash = Digest::SHA256.digest(data)
296
+
297
+ # Check that actual hash is same as expected hash
298
+ if (actual_hash != expected_hash)
299
+ raise Error::InvalidGzipError.new
300
+ end
301
+
302
+ # Append data
303
+ gzip += data
304
+ end
305
+
306
+ return gzip
307
+ end
308
+ private :parse_gzip
309
+
310
+ def parse_xml
311
+ curr = Group.new(
312
+ {
313
+ "Keepass" => self,
314
+ "Name" => "/"
315
+ }
316
+ )
317
+ entry_params = Hash.new
318
+ group_params = Hash.new
319
+ ignore = true
320
+ status = nil
321
+
322
+ @xml.each_line do |line|
323
+ line.strip!
324
+
325
+ case line
326
+ when "<History>"
327
+ ignore = true
328
+ next
329
+ when "</History>"
330
+ ignore = false
331
+ next
332
+ when "<Root>"
333
+ ignore = false
334
+ next
335
+ when "</Root>"
336
+ break
337
+ end
338
+
339
+ line = CGI::unescapeHTML(line)
340
+ line = URI::unescape(line)
341
+
342
+ # Always handle protected data
343
+ case line
344
+ when %r{<Value Protected}
345
+ line.gsub!(%r{<?Value( Protected=\"True\")?>}, "")
346
+ if (ignore)
347
+ handle_protected(line)
348
+ else
349
+ entry_params[status] = handle_protected(line)
350
+ status = nil
351
+ end
352
+ next
353
+ else
354
+ next if (ignore)
355
+ end
356
+
357
+ case line
358
+ when "<Entry>"
359
+ entry_params = { "Keepass" => self, "Group" => curr }
360
+ when "</Entry>"
361
+ entry = Entry.new(entry_params)
362
+ curr.entries[entry.title] = entry
363
+ when "<Group>"
364
+ group_params = { "Keepass" => self, "Group" => curr }
365
+ when "</Group>"
366
+ curr = curr.group
367
+ break if (curr.nil?)
368
+ when %r{<Key>.+</Key>}
369
+ status = line.gsub(%r{^<Key>|</Key>$}, "")
370
+ when %r{<Name>}
371
+ line.gsub!(%r{^<Name>|</Name>$}, "")
372
+ group_params["Name"] = line
373
+
374
+ group = Group.new(group_params)
375
+ curr.groups[group.name] = group
376
+ curr = group
377
+ when %r{<UUID>.+</UUID>}
378
+ uuid = line.gsub(%r{^<UUID>|</UUID>$}, "")
379
+ if (group_params["UUID"].nil?)
380
+ group_params["UUID"] = uuid
381
+ else
382
+ entry_params["UUID"] = uuid
383
+ end
384
+ when %r{<Value>}
385
+ line.gsub!(%r{</?Value( /)?>}, "")
386
+ entry_params[status] = line
387
+ status = nil
388
+ end
389
+ end
390
+
391
+ @db = curr
392
+ end
393
+ private :parse_xml
394
+
395
+ def read_gzip(file)
396
+ cipher = OpenSSL::Cipher::AES.new(256, :CBC)
397
+ cipher.decrypt
398
+ cipher.key = @aes_key
399
+ cipher.iv = @aes_iv
400
+
401
+ encrypted = file.read
402
+
403
+ begin
404
+ data = StringIO.new(
405
+ cipher.update(encrypted) + cipher.final
406
+ )
407
+ rescue OpenSSL::Cipher::CipherError => e
408
+ raise Error::InvalidPasswordError.new
409
+ end
410
+
411
+ if (data.read(32) != @header[@@STREAM_START_BYTES])
412
+ raise Error::InvalidPasswordError.new
413
+ end
414
+
415
+ @gzip = parse_gzip(data)
416
+ end
417
+ private :read_gzip
418
+
419
+ def read_header(file)
420
+ header = Hash.new
421
+ loop do
422
+ data = file.read(1)
423
+ raise Error::InvalidHeaderError.new if (data.nil?)
424
+ id = data.unpack("C*")[0]
425
+
426
+ data = file.read(2)
427
+ raise Error::InvalidHeaderError.new if (data.nil?)
428
+ size = data.unpack("S*")[0]
429
+
430
+ data = file.read(size)
431
+
432
+ case id
433
+ when @@END_OF_HEADER
434
+ break
435
+ when @@TRANSFORM_ROUNDS
436
+ header[id] = data.unpack("Q*")[0]
437
+ else
438
+ header[id] = data
439
+ end
440
+ end
441
+
442
+ irsi = "\x02\x00\x00\x00"
443
+ aes = "31c1f2e6bf714350be5805216afc5aff"
444
+ if (
445
+ (header[@@MASTER_SEED].length != 32) ||
446
+ (header[@@TRANSFORM_SEED].length != 32)
447
+ )
448
+ raise Error::InvalidHeaderError.new
449
+ elsif (header[@@INNER_RANDOM_STREAM_ID] != irsi)
450
+ raise Error::NotSalsaError.new
451
+ elsif (header[@@CIPHER_ID].unpack("H*")[0] != aes)
452
+ raise Error::NotAESError.new
453
+ end
454
+
455
+ @header = header
456
+ end
457
+ private :read_header
458
+
459
+ def read_magic_and_version(file)
460
+ data = file.read(4)
461
+ raise Error::InvalidMagicError.new if (data.nil?)
462
+ sig1 = data.unpack("L*")[0]
463
+ if (sig1 != @@MAGIC_SIG1)
464
+ raise Error::InvalidMagicError.new
465
+ end
466
+
467
+ data = file.read(4)
468
+ raise Error::InvalidMagicError.new if (data.nil?)
469
+ sig2 = data.unpack("L*")[0]
470
+ if (sig2 != @@MAGIC_SIG2)
471
+ raise Error::InvalidMagicError.new
472
+ end
473
+
474
+ data = file.read(4)
475
+ raise Error::InvalidVersionError.new if (data.nil?)
476
+ ver = data.unpack("L*")[0]
477
+ if ((ver & 0xffff0000) != @@VERSION)
478
+ raise Error::InvalidVersionError.new if (data.nil?)
479
+ end
480
+ end
481
+ private :read_magic_and_version
482
+
483
+ def to_s
484
+ return @db.to_s
485
+ end
486
+ end
487
+
488
+ require "rubeepass/entry"
489
+ require "rubeepass/error"
490
+ require "rubeepass/group"
491
+ require "rubeepass/protected_decryptor"
data/lib/string.rb ADDED
@@ -0,0 +1,39 @@
1
+ # Redefine String class to allow for colorizing and rsplit
2
+ class String
3
+ def blue
4
+ return colorize(36)
5
+ end
6
+
7
+ def colorize(color)
8
+ return "\e[#{color}m#{self}\e[0m"
9
+ end
10
+
11
+ def fix
12
+ # Fix unicode (I think???)
13
+ # Apparently sometimes length and bytesize don't always agree.
14
+ # When this happens, there are "invisible" bytes, which I need
15
+ # to be "visible". Converting to hex and back fixes this.
16
+ if (length != bytesize)
17
+ return self.unpack("H*").pack("H*")
18
+ end
19
+ return self
20
+ end
21
+
22
+ def green
23
+ return colorize(32)
24
+ end
25
+
26
+ def red
27
+ return colorize(31)
28
+ end
29
+
30
+ def rsplit(pattern)
31
+ ret = rpartition(pattern)
32
+ ret.delete_at(1)
33
+ return ret
34
+ end
35
+
36
+ def white
37
+ return colorize(37)
38
+ end
39
+ end
metadata ADDED
@@ -0,0 +1,167 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rubeepass
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Miles Whittaker
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-10-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.8'
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 5.8.1
23
+ type: :development
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: '5.8'
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 5.8.1
33
+ - !ruby/object:Gem::Dependency
34
+ name: djinni
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.0'
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: 1.0.2
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - "~>"
48
+ - !ruby/object:Gem::Version
49
+ version: '1.0'
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: 1.0.2
53
+ - !ruby/object:Gem::Dependency
54
+ name: os
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '0.9'
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 0.9.6
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "~>"
68
+ - !ruby/object:Gem::Version
69
+ version: '0.9'
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 0.9.6
73
+ - !ruby/object:Gem::Dependency
74
+ name: salsa20
75
+ requirement: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '0.1'
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 0.1.1
83
+ type: :runtime
84
+ prerelease: false
85
+ version_requirements: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.1'
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: 0.1.1
93
+ - !ruby/object:Gem::Dependency
94
+ name: scoobydoo
95
+ requirement: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - "~>"
98
+ - !ruby/object:Gem::Version
99
+ version: '0.1'
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 0.1.1
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '0.1'
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: 0.1.1
113
+ description: Ruby KeePass 2.x implementation. Currently it is read-only.
114
+ email: mjwhitta@gmail.com
115
+ executables:
116
+ - rpass
117
+ extensions: []
118
+ extra_rdoc_files: []
119
+ files:
120
+ - bin/rpass
121
+ - lib/builtins/cd_wish.rb
122
+ - lib/builtins/clear_wish.rb
123
+ - lib/builtins/copy_wish.rb
124
+ - lib/builtins/ls_wish.rb
125
+ - lib/builtins/pwd_wish.rb
126
+ - lib/builtins/show_wish.rb
127
+ - lib/rubeepass.rb
128
+ - lib/rubeepass/entry.rb
129
+ - lib/rubeepass/error.rb
130
+ - lib/rubeepass/error/invalid_gzip_error.rb
131
+ - lib/rubeepass/error/invalid_header_error.rb
132
+ - lib/rubeepass/error/invalid_magic_error.rb
133
+ - lib/rubeepass/error/invalid_password_error.rb
134
+ - lib/rubeepass/error/invalid_protected_data_error.rb
135
+ - lib/rubeepass/error/invalid_protected_stream_key_error.rb
136
+ - lib/rubeepass/error/invalid_version_error.rb
137
+ - lib/rubeepass/error/not_aes_error.rb
138
+ - lib/rubeepass/error/not_salsa20_error.rb
139
+ - lib/rubeepass/group.rb
140
+ - lib/rubeepass/protected_decryptor.rb
141
+ - lib/string.rb
142
+ homepage: http://mjwhitta.github.io/rubeepass
143
+ licenses:
144
+ - GPL-3.0
145
+ metadata: {}
146
+ post_install_message:
147
+ rdoc_options: []
148
+ require_paths:
149
+ - lib
150
+ required_ruby_version: !ruby/object:Gem::Requirement
151
+ requirements:
152
+ - - ">="
153
+ - !ruby/object:Gem::Version
154
+ version: '0'
155
+ required_rubygems_version: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ requirements: []
161
+ rubyforge_project:
162
+ rubygems_version: 2.4.8
163
+ signing_key:
164
+ specification_version: 4
165
+ summary: Ruby KeePass 2.x implementation
166
+ test_files: []
167
+ has_rdoc: