An unofficial, mostly Bitwarden-compatible API server written in Ruby (Sinatra and ActiveRecord)
at master 283 lines 8.2 kB view raw
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