An unofficial, mostly Bitwarden-compatible API server written in Ruby (Sinatra and ActiveRecord)
1#
2# Copyright (c) 2017 joshua stein <jcs@jcs.org>
3#
4# Permission to use, copy, modify, and distribute this software for any
5# purpose with or without fee is hereby granted, provided that the above
6# copyright notice and this permission notice appear in all copies.
7#
8# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
9# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
10# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
11# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
12# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
13# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
14# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
15#
16
17require "jwt"
18require "pbkdf2"
19require "openssl"
20
21class Bitwarden
22 class InvalidCipherString < StandardError; end
23 class Bitwarden::KDF
24 PBKDF2 = 0
25
26 TYPES = {
27 0 => PBKDF2,
28 }
29 TYPE_IDS = TYPES.invert
30
31 DEFAULT_ITERATIONS = {
32 PBKDF2 => 100_000,
33 }
34
35 ITERATION_RANGES = {
36 PBKDF2 => 5000 .. 1_000_000,
37 }
38 end
39
40 # convenience methods for hashing/encryption/decryption that the apps do,
41 # just so we can test against
42 class << self
43 # stretch a password+salt
44 def makeKey(password, salt, kdf_type, kdf_iterations)
45 case kdf_type
46 when Bitwarden::KDF::PBKDF2
47 if !(r = Bitwarden::KDF::ITERATION_RANGES[kdf_type]).include?(kdf_iterations)
48 raise "PBKDF2 iterations must be between #{r}"
49 end
50
51 PBKDF2.new(:password => password, :salt => salt,
52 :iterations => kdf_iterations,
53 :hash_function => OpenSSL::Digest::SHA256,
54 :key_length => 32).bin_string
55 else
56 raise "unknown kdf type #{kdf_type.inspect}"
57 end
58 end
59
60 # encrypt random bytes with a key from makeKey to make a new encryption
61 # CipherString
62 def makeEncKey(key, algo = CipherString::TYPE_AESCBC256_HMACSHA256_B64)
63 pt = OpenSSL::Random.random_bytes(64)
64 encrypt(pt, key, algo).to_s
65 end
66
67 # base64-encode a wrapped, stretched password+salt for signup/login
68 def hashPassword(password, salt, kdf_type, kdf_iterations)
69 key = makeKey(password, salt, kdf_type, kdf_iterations)
70
71 case kdf_type
72 when Bitwarden::KDF::PBKDF2
73 # stretching has already been done in makeKey, only do 1 iteration here
74 Base64.strict_encode64(PBKDF2.new(:password => key, :salt => password,
75 :iterations => 1, :key_length => 256/8,
76 :hash_function => OpenSSL::Digest::SHA256).bin_string)
77 else
78 raise "unknown kdf type #{kdf_type.inspect}"
79 end
80 end
81
82 # encrypt+mac a value with a key and mac key and random iv, return a
83 # CipherString of it
84 def encrypt(pt, key, algo = CipherString::TYPE_AESCBC256_HMACSHA256_B64)
85 mac = nil
86 macKey = nil
87
88 case algo
89 when CipherString::TYPE_AESCBC256_B64
90 if key.bytesize != 32
91 raise "unhandled key size #{key.bytesize}"
92 end
93
94 when CipherString::TYPE_AESCBC256_HMACSHA256_B64
95 macKey = nil
96 if key.bytesize == 32
97 tkey = hkdfStretch(key, "enc", 32)
98 macKey = hkdfStretch(key, "mac", 32)
99 key = tkey
100 elsif key.bytesize == 64
101 macKey = key[32, 32]
102 key = key[0, 32]
103 else
104 raise "invalid key size #{key.bytesize}"
105 end
106 else
107 raise "TODO: #{algo}"
108 end
109
110 iv = OpenSSL::Random.random_bytes(16)
111
112 cipher = OpenSSL::Cipher.new "AES-256-CBC"
113 cipher.encrypt
114 cipher.key = key
115 cipher.iv = iv
116 ct = cipher.update(pt)
117 ct << cipher.final
118
119 mac = nil
120 if macKey
121 mac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey,
122 iv + ct)
123 end
124
125 CipherString.new(
126 mac ? CipherString::TYPE_AESCBC256_HMACSHA256_B64 :
127 CipherString::TYPE_AESCBC256_B64,
128 Base64.strict_encode64(iv),
129 Base64.strict_encode64(ct),
130 mac ? Base64.strict_encode64(mac) : nil,
131 )
132 end
133
134 # compare two hmacs, with double hmac verification
135 # https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2011/february/double-hmac-verification/
136 def macsEqual(macKey, mac1, mac2)
137 hmac1 = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, mac1)
138 hmac2 = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, mac2)
139 return hmac1 == hmac2
140 end
141
142 # decrypt a CipherString and return plaintext
143 def decrypt(cs, key)
144 if !cs.is_a?(CipherString)
145 cs = CipherString.parse(cs)
146 end
147
148 iv = Base64.decode64(cs.iv)
149 ct = Base64.decode64(cs.ct)
150 mac = cs.mac ? Base64.decode64(cs.mac) : nil
151
152 case cs.type
153 when CipherString::TYPE_AESCBC256_B64
154 cipher = OpenSSL::Cipher.new "AES-256-CBC"
155 cipher.decrypt
156 cipher.iv = iv
157 cipher.key = key
158 pt = cipher.update(ct)
159 pt << cipher.final
160 return pt
161
162 when CipherString::TYPE_AESCBC256_HMACSHA256_B64
163 macKey = nil
164 if key.bytesize == 32
165 tkey = hkdfStretch(key, "enc", 32)
166 macKey = hkdfStretch(key, "mac", 32)
167 key = tkey
168 elsif key.bytesize == 64
169 macKey = key[32, 32]
170 key = key[0, 32]
171 else
172 raise "invalid key size #{key.bytesize}"
173 end
174
175 cmac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"),
176 macKey, iv + ct)
177 if !self.macsEqual(macKey, mac, cmac)
178 raise "invalid mac #{mac.inspect} != #{cmac.inspect}"
179 end
180
181 cipher = OpenSSL::Cipher.new "AES-256-CBC"
182 cipher.decrypt
183 cipher.iv = iv
184 cipher.key = key
185 pt = cipher.update(ct)
186 pt << cipher.final
187 return pt
188
189 else
190 raise "TODO implement #{c.type}"
191 end
192 end
193
194 def hkdfStretch(prk, info, size)
195 hashlen = 32
196 prev = []
197 okm = []
198 n = (size / hashlen.to_f).ceil
199 n.times do |x|
200 t = []
201 t += prev
202 t += info.split("").map{|c| c.ord }
203 t += [ (x + 1) ]
204 hmac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), prk,
205 t.map{|c| c.chr }.join(""))
206 prev = hmac.bytes
207 okm += hmac.bytes
208 end
209
210 if okm.length != size
211 raise "invalid hkdf result: #{okm.length} != #{size}"
212 end
213
214 okm.map{|c| c.chr }.join("")
215 end
216 end
217
218 class CipherString
219 TYPE_AESCBC256_B64 = 0
220 TYPE_AESCBC128_HMACSHA256_B64 = 1
221 TYPE_AESCBC256_HMACSHA256_B64 = 2
222 TYPE_RSA2048_OAEPSHA256_B64 = 3
223 TYPE_RSA2048_OAEPSHA1_B64 = 4
224 TYPE_RSA2048_OAEPSHA256_HMACSHA256_B64 = 5
225 TYPE_RSA2048_OAEPSHA1_HMACSHA256_B64 = 6
226
227 attr_reader :type, :iv, :ct, :mac
228
229 def self.parse(str)
230 if !(m = str.to_s.match(/\A(\d)\.([^|]+)\|(.+)\z/))
231 raise Bitwarden::InvalidCipherString, "invalid CipherString: " <<
232 str.inspect
233 end
234
235 type = m[1].to_i
236 iv = m[2]
237 ct, mac = m[3].split("|", 2)
238 CipherString.new(type, iv, ct, mac)
239 end
240
241 def initialize(type, iv, ct, mac = nil)
242 @type = type
243 @iv = iv
244 @ct = ct
245 @mac = mac
246 end
247
248 def to_s
249 [ self.type.to_s + "." + self.iv, self.ct, self.mac ].
250 reject{|p| !p }.
251 join("|")
252 end
253 end
254
255 class Token
256 class << self
257 KEY = "#{APP_ROOT}/db/#{RUBYWARDEN_ENV}/jwt-rsa.key"
258
259 attr_reader :rsa
260
261 # load or create RSA pair used for JWT signing
262 def load_keys
263 if File.exist?(KEY)
264 @rsa = OpenSSL::PKey::RSA.new File.read(KEY)
265 else
266 @rsa = OpenSSL::PKey::RSA.generate 2048
267
268 if !Dir.exists?(File.dirname(KEY))
269 Dir.mkdir(File.dirname(KEY))
270 end
271 f = File.new(KEY, File::CREAT|File::TRUNC|File::RDWR, 0600)
272 f.write @rsa.to_pem
273 f.write @rsa.public_key.to_pem
274 f.close
275 end
276 end
277
278 def sign(payload)
279 JWT.encode(payload, @rsa, "RS256")
280 end
281 end
282 end
283end