-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathopvault.rb
More file actions
executable file
·295 lines (228 loc) · 7.33 KB
/
opvault.rb
File metadata and controls
executable file
·295 lines (228 loc) · 7.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
#!/usr/bin/env ruby
# Copyright (C) 2018 Dmitry Yakimenko (detunized@gmail.com).
# Licensed under the terms of the MIT license. See LICENCE for details.
# TODO: Better incorrect password detection
# TODO: Better error reporting
require "base64"
require "json"
require "openssl"
class Account < Struct.new :id, :name, :username, :password, :url, :note, :folder
def initialize id:, name:, username:, password:, url:, note:, folder:
super id, name, username, password, url, note, folder
end
end
class Folder < Struct.new :id, :name
def initialize id:, name:
super id, name
end
end
class KeyMac < Struct.new :key, :mac_key
def self.from_str s
new s[0, 32], s[32, 32]
end
end
# Used to mark items with no folder (not to use nil)
NO_FOLDER = Folder.new id: "-", name: "-"
def open_vault path, password
# Load all the files
profile = load_profile path
encrypted_folders = load_folders path
encrypted_items = load_items path
# Derive key encryption key
kek = derive_kek profile, password
# Decrypt main keys
master_key = decrypt_master_key profile, kek
overview_key = decrypt_overview_key profile, kek
# Decrypt, parse and convert folders
folders = decrypt_folders encrypted_folders.values, overview_key
# We're only interested in logins/accounts that are not deleted
account_items = select_active_account_items encrypted_items.values
# Check digital signatures on the accounts to see if the vault is not corrupted
verify_item_tags account_items, overview_key
# Decrypt, parse, convert and assign folders
accounts = decrypt_items account_items, master_key, overview_key, folders
# Done
accounts
end
def verify_item_tags items, key
items.each do |item|
keys = (item.keys - ["hmac"]).sort
values = keys.map { |k| item[k] }
message = keys.zip(values).join
stored = decode64 item["hmac"]
computed = hmac_sha256 key.mac_key, message
if computed != stored
raise "Item tag doesn't match"
end
end
end
def select_active_account_items items
items
.select { |i| i["category"] == "001" } # 001 is a login item
.reject { |i| i["trashed"] }
end
def decrypt_items items, master_key, overview_key, folders
items.map { |i| decrypt_item i, master_key, overview_key, folders }
end
def decrypt_item item, master_key, overview_key, folders
overview = decrypt_item_overview item, overview_key
item_key = decrypt_item_key item, master_key
details = decrypt_item_details item, item_key
Account.new id: item["uuid"],
name: overview["title"],
username: find_detail_field(details, "username"),
password: find_detail_field(details, "password"),
url: overview["url"],
note: details["notesPlain"],
folder: folders[item["folder"]] || NO_FOLDER
end
def decrypt_item_overview item, overview_key
JSON.load decrypt_base64_opdata item["o"], overview_key
end
def decrypt_item_key item, master_key
raw = decode64 item["k"]
if raw.size != 112
raise "Item key is corrupted: invalid size"
end
iv = raw[0, 16]
ciphertext = raw[16, 64]
stored_tag = raw[80, 32]
computed_tag = hmac_sha256 master_key.mac_key, iv + ciphertext
if computed_tag != stored_tag
raise "Item key is corrupted: tag doesn't match"
end
KeyMac.from_str decrypt_aes256 ciphertext, iv, master_key.key
end
def decrypt_item_details item, item_key
JSON.load decrypt_base64_opdata item["d"], item_key
end
def find_detail_field details, name
details.fetch("fields", [])
.find_all { |i| i["designation"] == name }
.map { |i| i["value"] }
.first
end
def decrypt_folders folders, overview_key
folders
.reject { |i| i["trashed"] }
.map { |i| decrypt_folder i, overview_key }
.map { |i| [i.id, i] }
.to_h
end
def decrypt_folder folder, overview_key
overview = decrypt_folder_overview folder, overview_key
Folder.new id: folder["uuid"],
name: overview["title"]
end
def decrypt_folder_overview folder, overview_key
JSON.load decrypt_base64_opdata folder["overview"], overview_key
end
def make_filename path, filename
File.join path, "default", filename
end
def load_profile path
filename = make_filename path, "profile.js"
load_js_as_json filename, "var profile=", ";"
end
def load_folders path
filename = make_filename path, "folders.js"
load_js_as_json filename, "loadFolders(", ");"
end
def load_items path
items = {}
"0123456789ABCDEF".each_char do |i|
filename = make_filename path, "band_#{i}.js"
if File.exist? filename
items.merge! load_band filename
end
end
items
end
def load_band filename
load_js_as_json filename, "ld(", ");"
end
def load_js_as_json filename, prefix, suffix
content = File.read filename
if !content.start_with? prefix
raise "Unsupported format: must start with #{prefix}"
end
if !content.end_with? suffix
raise "Unsupported format: must end with #{suffix}"
end
JSON.load content[prefix.size...-suffix.size]
end
def derive_kek profile, password
salt = decode64 profile["salt"]
iterations = profile["iterations"]
KeyMac.from_str pbkdf2_sha512 password, salt, iterations, 64
end
def decrypt_master_key profile, kek
decrypt_base64_key profile["masterKey"], kek
end
def decrypt_overview_key profile, kek
decrypt_base64_key profile["overviewKey"], kek
end
def decrypt_base64_key key_base64, kek
raw = decrypt_base64_opdata key_base64, kek
KeyMac.from_str sha512 raw
end
def decrypt_base64_opdata blob_base64, key
blob = decode64 blob_base64
decrypt_opdata blob, key
end
def decrypt_opdata blob, key
if blob.size < 64
raise "Opdata01 container is corrupted: too short"
end
header = blob[0, 32]
if !header.start_with? "opdata01"
raise "Opdata01 container is corrupted: missing header"
end
length = header[8, 8].unpack("V")[0]
iv = header[16, 16]
padding = 16 - length % 16
if blob.size != 32 + padding + length + 32
raise "Opdata01 container is corrupted: invalid length"
end
ciphertext = blob[header.size, padding + length]
stored_tag = blob[header.size + ciphertext.size, 32]
computed_tag = hmac_sha256 key.mac_key, header + ciphertext
if computed_tag != stored_tag
raise "Opdata01 container is corrupted: tag doesn't match"
end
plaintext = decrypt_aes256 ciphertext, iv, key.key
plaintext[padding, length]
end
#
# Utils
#
def decode64 base64
Base64.decode64 base64
end
#
# Crypto
#
def pbkdf2_sha512 password, salt, iterations, size
OpenSSL::PKCS5.pbkdf2_hmac password, salt, iterations, size, "sha512"
end
def sha512 message
Digest::SHA512.digest message
end
def hmac_sha256 key, message
OpenSSL::HMAC.digest "sha256", key, message
end
def decrypt_aes256 ciphertext, iv, key
aes = OpenSSL::Cipher.new "aes-256-cbc"
aes.decrypt
aes.key = key
aes.iv = iv
aes.padding = 0
aes.update(ciphertext) + aes.final
end
#
# main
#
accounts = open_vault "test.opvault", "password"
accounts.each_with_index do |i, index|
puts "#{index + 1}: #{i.id}, #{i.name} #{i.username}, #{i.password}, #{i.url}, #{i.note}, #{i.folder.name}"
end