rubeepass 0.1.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.
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: