An unofficial, mostly Bitwarden-compatible API server written in Ruby (Sinatra and ActiveRecord)

add api calls to create, delete, update, and sync cipher items

+1538 -297
+1 -1
.gitignore
··· 1 1 .bundle 2 - db.sqlite3 2 + db/*.sqlite3 3 3 jwt-rsa.key 4 4 vendor/
+1 -1
API.md
··· 8 8 9 9 The following notes were made by analyzing traffic between the Firefox 10 10 extension and the Bitwarden servers by running 11 - [mitm.rb](mitm.rb) 11 + [tools/mitm.rb](mitm.rb) 12 12 and having the Firefox extension use `http://127.0.0.1:4567/` as its 13 13 base URL. 14 14
+3
Gemfile
··· 9 9 gem "jwt" 10 10 11 11 gem "sqlite3" 12 + 13 + gem "minitest" 14 + gem "rack-test"
+5
Gemfile.lock
··· 4 4 backports (3.10.3) 5 5 json (2.1.0) 6 6 jwt (2.1.0) 7 + minitest (5.10.3) 7 8 multi_json (1.12.2) 8 9 mustermann (1.0.1) 9 10 pbkdf2-ruby (0.2.1) 10 11 rack (2.0.3) 11 12 rack-protection (2.0.0) 12 13 rack 14 + rack-test (0.7.0) 15 + rack (>= 1.0, < 3) 13 16 rotp (3.3.0) 14 17 sinatra (2.0.0) 15 18 mustermann (~> 1.0) ··· 32 35 DEPENDENCIES 33 36 json 34 37 jwt 38 + minitest 35 39 pbkdf2-ruby 40 + rack-test 36 41 rotp 37 42 sinatra 38 43 sinatra-contrib
+13
LICENSE
··· 1 + Copyright (c) 2017 joshua stein <jcs@jcs.org> 2 + 3 + Permission to use, copy, modify, and distribute this software for any 4 + purpose with or without fee is hereby granted, provided that the above 5 + copyright notice and this permission notice appear in all copies. 6 + 7 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+22
README.md
··· 10 10 [Bitwarden apps](https://github.com/bitwarden). 11 11 12 12 Data is stored in a local SQLite database. 13 + This means you can easily run it locally and have your data never leave 14 + your device, or run it on your own web server via Rack and some front-end 15 + HTTP server with TLS to support logging in from multiple devices. 16 + Backing up your data is as easy as copying the `db/production.sqlite3` file 17 + somewhere. 13 18 14 19 ### API Documentation 15 20 ··· 19 24 documentation available other than the 20 25 [.NET Bitwarden code](https://github.com/bitwarden/core) 21 26 itself. 27 + 28 + ## Usage 29 + 30 + Run `bundle install` at least once. 31 + 32 + To run via Rack on port 4567: 33 + 34 + env RAILS_ENV=production bundle exec rackup config.ru 35 + 36 + You'll probably want to run it once with signups enabled, to allow yourself 37 + to create an account: 38 + 39 + env RAILS_ENV=production ALLOW_SIGNUPS=1 bundle exec rackup config.ru 40 + 41 + Run test suite: 42 + 43 + bundle exec rake test 22 44 23 45 ### License 24 46
+5
Rakefile
··· 1 + require "rake/testtask" 2 + 3 + Rake::TestTask.new do |t| 4 + t.pattern = "spec/*_spec.rb" 5 + end
-159
bitwarden.rb
··· 1 - #!/usr/bin/env ruby 2 - 3 - APP_ROOT = File.dirname(__FILE__) 4 - 5 - require "sinatra" 6 - require "sinatra/namespace" 7 - require "cgi" 8 - 9 - require "#{APP_ROOT}/lib/bitwarden.rb" 10 - require "#{APP_ROOT}/lib/helper.rb" 11 - 12 - require "#{APP_ROOT}/lib/db.rb" 13 - require "#{APP_ROOT}/lib/dbmodel.rb" 14 - require "#{APP_ROOT}/lib/user.rb" 15 - require "#{APP_ROOT}/lib/device.rb" 16 - 17 - ALLOW_SIGNUPS = true 18 - 19 - BASE_URL = "/api" 20 - IDENTITY_BASE_URL = "/identity" 21 - ICONS_URL = "/icons" 22 - 23 - # create/load JWT signing keys 24 - Bitwarden.load_jwt_keys 25 - 26 - # create/update tables 27 - Db.connection 28 - 29 - before do 30 - # import JSON params 31 - if request.request_method.upcase == "POST" && 32 - request.content_type.to_s.match(/^application\/json[$;]/) 33 - params.merge!(JSON.parse(request.body.read)) 34 - end 35 - end 36 - 37 - namespace IDENTITY_BASE_URL do 38 - # login with a username and password, register/update the device, and get an 39 - # oauth token in response 40 - post "/connect/token" do 41 - content_type :json 42 - 43 - need_params( 44 - :client_id, 45 - :grant_type, 46 - :deviceIdentifier, 47 - :deviceName, 48 - :deviceType, 49 - :password, 50 - :scope, 51 - :username, 52 - ) do |p| 53 - return validation_error("#{p} cannot be blank") 54 - end 55 - 56 - if params[:grant_type] != "password" 57 - return validation_error("grant type not supported") 58 - end 59 - 60 - if params[:scope] != "api offline_access" 61 - return validation_error("scope not supported") 62 - end 63 - 64 - u = User.find_by_email(params[:username]) 65 - if !u 66 - return validation_error("Invalid username") 67 - end 68 - 69 - if !u.has_password_hash?(params[:password]) 70 - return validation_error("Invalid password") 71 - end 72 - 73 - if u.totp_secret.present? 74 - if params[:twoFactorToken].blank? || 75 - !u.verifies_totp_code?(params[:twoFactorToken]) 76 - return [ 400, { 77 - "error" => "invalid_grant", 78 - "error_description" => "Two factor required.", 79 - "TwoFactorProviders" => [ 0 ], # authenticator 80 - "TwoFactorProviders2" => { "0" => nil } 81 - }.to_json ] 82 - end 83 - end 84 - 85 - d = Device.find_by_device_uuid(params[:deviceIdentifier]) 86 - if d && d.user_id != u.id 87 - # wat 88 - d.destroy 89 - d = nil 90 - end 91 - 92 - if !d 93 - d = Device.new 94 - d.user_id = u.id 95 - d.device_uuid = params[:deviceIdentifier] 96 - end 97 - 98 - d.device_type = params[:deviceType] 99 - d.name = params[:deviceName] 100 - d.device_push_token = params[:devicePushToken] 101 - 102 - d.generate_tokens! 103 - 104 - User.transaction do 105 - if d.save 106 - return tee({ 107 - :access_token => d.access_token, 108 - :expires_in => (d.token_expiry - Time.now.to_i), 109 - :token_type => "Bearer", 110 - :refresh_token => d.refresh_token, 111 - :Key => d.user.key, 112 - # TODO: :privateKey and :TwoFactorToken 113 - }.to_json) 114 - else 115 - return validation_error("Unknown error") 116 - end 117 - end 118 - end 119 - end 120 - 121 - namespace BASE_URL do 122 - # create a new user 123 - post "/accounts/register" do 124 - content_type :json 125 - 126 - if !ALLOW_SIGNUPS 127 - return validation_error("Signups are not permitted") 128 - end 129 - 130 - need_params(:masterPasswordHash) do |p| 131 - return validation_error("#{p} cannot be blank") 132 - end 133 - 134 - if !params[:email].to_s.match(/^.+@.+\..+$/) 135 - return validation_error("Invalid e-mail address") 136 - end 137 - 138 - if !params[:key].to_s.match(/^0\..+\|.+/) 139 - return validation_error("Invalid key") 140 - end 141 - 142 - User.transaction do 143 - if User.find_by_email(params[:email]) 144 - return validation_error("E-mail is already in use") 145 - end 146 - 147 - u = User.new 148 - u.email = params[:email] 149 - u.password_hash = params[:masterPasswordHash] 150 - u.key = params[:key] 151 - 152 - if u.save 153 - return "" 154 - else 155 - return validation_error("User save failed") 156 - end 157 - end 158 - end 159 - end
+20
config.ru
··· 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 + 17 + require File.dirname(__FILE__) + "/lib/bitwarden_ruby.rb" 18 + require "#{APP_ROOT}/lib/api.rb" 19 + 20 + run Sinatra::Application
db/.gitkeep

This is a binary file and will not be displayed.

+331
lib/api.rb
··· 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 + 17 + # 18 + # helper methods 19 + # 20 + 21 + def device_from_bearer 22 + if m = request.env["HTTP_AUTHORIZATION"].to_s.match(/^Bearer (.+)/) 23 + token = m[1] 24 + if (d = Device.find_by_access_token(token)) 25 + if d.token_expires_at >= Time.now 26 + return d 27 + end 28 + end 29 + end 30 + 31 + nil 32 + end 33 + 34 + def need_params(*ps) 35 + ps.each do |p| 36 + if params[p].to_s.blank? 37 + yield(p) 38 + end 39 + end 40 + end 41 + 42 + def validation_error(msg) 43 + [ 400, { 44 + "ValidationErrors" => { "" => [ 45 + msg, 46 + ]}, 47 + "Object" => "error", 48 + }.to_json ] 49 + end 50 + 51 + # 52 + # begin sinatra routing 53 + # 54 + 55 + # import JSON params for every request 56 + before do 57 + if request.content_type.to_s.match(/\Aapplication\/json(;|\z)/) 58 + js = request.body.read.to_s 59 + if !js.strip.blank? 60 + params.merge!(JSON.parse(js)) 61 + end 62 + end 63 + 64 + # we're always going to reply with json 65 + content_type :json 66 + end 67 + 68 + namespace IDENTITY_BASE_URL do 69 + # depending on grant_type: 70 + # password: login with a username/password, register/update the device 71 + # refresh_token: just generate a new access_token 72 + # respond with an access_token and refresh_token 73 + post "/connect/token" do 74 + d = nil 75 + 76 + case params[:grant_type] 77 + when "refresh_token" 78 + need_params(:refresh_token) do |p| 79 + return validation_error("#{p} cannot be blank") 80 + end 81 + 82 + d = Device.find_by_refresh_token(params[:refresh_token]) 83 + if !d 84 + return validation_error("Invalid refresh token") 85 + end 86 + 87 + when "password" 88 + need_params( 89 + :client_id, 90 + :grant_type, 91 + :deviceIdentifier, 92 + :deviceName, 93 + :deviceType, 94 + :password, 95 + :scope, 96 + :username, 97 + ) do |p| 98 + return validation_error("#{p} cannot be blank") 99 + end 100 + 101 + if params[:scope] != "api offline_access" 102 + return validation_error("scope not supported") 103 + end 104 + 105 + u = User.find_by_email(params[:username]) 106 + if !u 107 + return validation_error("Invalid username") 108 + end 109 + 110 + if !u.has_password_hash?(params[:password]) 111 + return validation_error("Invalid password") 112 + end 113 + 114 + if u.two_factor_enabled? && 115 + (params[:twoFactorToken].blank? || 116 + !u.verifies_totp_code?(params[:twoFactorToken])) 117 + return [ 400, { 118 + "error" => "invalid_grant", 119 + "error_description" => "Two factor required.", 120 + "TwoFactorProviders" => [ 0 ], # authenticator 121 + "TwoFactorProviders2" => { "0" => nil } 122 + }.to_json ] 123 + end 124 + 125 + d = Device.find_by_uuid(params[:deviceIdentifier]) 126 + if d && d.user_uuid != u.uuid 127 + # wat 128 + d.destroy 129 + d = nil 130 + end 131 + 132 + if !d 133 + d = Device.new 134 + d.user_uuid = u.uuid 135 + d.uuid = params[:deviceIdentifier] 136 + end 137 + 138 + d.type = params[:deviceType] 139 + d.name = params[:deviceName] 140 + if params[:devicePushToken].present? 141 + d.push_token = params[:devicePushToken] 142 + end 143 + else 144 + return validation_error("grant type not supported") 145 + end 146 + 147 + d.regenerate_tokens! 148 + 149 + User.transaction do 150 + if !d.save 151 + return validation_error("Unknown error") 152 + end 153 + 154 + { 155 + :access_token => d.access_token, 156 + :expires_in => (d.token_expires_at - Time.now).floor, 157 + :token_type => "Bearer", 158 + :refresh_token => d.refresh_token, 159 + :Key => d.user.key, 160 + # TODO: when to include :privateKey and :TwoFactorToken? 161 + }.to_json 162 + end 163 + end 164 + end 165 + 166 + namespace BASE_URL do 167 + # create a new user 168 + post "/accounts/register" do 169 + content_type :json 170 + 171 + if !ALLOW_SIGNUPS 172 + return validation_error("Signups are not permitted") 173 + end 174 + 175 + need_params(:masterPasswordHash) do |p| 176 + return validation_error("#{p} cannot be blank") 177 + end 178 + 179 + if !params[:email].to_s.match(/^.+@.+\..+$/) 180 + return validation_error("Invalid e-mail address") 181 + end 182 + 183 + if !params[:key].to_s.match(/^0\..+\|.+/) 184 + return validation_error("Invalid key") 185 + end 186 + 187 + begin 188 + if !Bitwarden::CipherString.parse(params[:key]) 189 + raise 190 + end 191 + rescue 192 + return validation_error("Invalid key") 193 + end 194 + 195 + User.transaction do 196 + params[:email].downcase! 197 + 198 + if User.find_by_email(params[:email]) 199 + return validation_error("E-mail is already in use") 200 + end 201 + 202 + u = User.new 203 + u.email = params[:email] 204 + u.password_hash = params[:masterPasswordHash] 205 + u.password_hint = params[:masterPasswordHint] 206 + u.key = params[:key] 207 + 208 + # is this supposed to come from somewhere? 209 + u.culture = "en-US" 210 + 211 + # i am a fair and just god 212 + u.premium = true 213 + 214 + if !u.save 215 + return validation_error("User save failed") 216 + end 217 + 218 + "" 219 + end 220 + end 221 + 222 + # fetch profile and ciphers 223 + get "/sync" do 224 + d = device_from_bearer 225 + if !d 226 + return validation_error("invalid bearer") 227 + end 228 + 229 + { 230 + "Profile" => d.user.to_hash, 231 + "Folders" => [], 232 + "Ciphers" => d.user.ciphers.map{|c| 233 + c.to_hash 234 + }, 235 + "Domains" => { 236 + "Object" => "domains" 237 + }, 238 + "Object" => "sync" 239 + }.to_json 240 + end 241 + 242 + # create a new cipher 243 + post "/ciphers" do 244 + d = device_from_bearer 245 + if !d 246 + return validation_error("invalid bearer") 247 + end 248 + 249 + need_params(:type, :name) do |p| 250 + return validation_error("#{p} cannot be blank") 251 + end 252 + 253 + c = Cipher.new 254 + c.user_uuid = d.user_uuid 255 + c.update_from_params(params) 256 + 257 + Cipher.transaction do 258 + if !c.save 259 + return validation_error("error saving") 260 + end 261 + 262 + c.to_hash.merge({ 263 + "Edit" => true, 264 + }).to_json 265 + end 266 + end 267 + 268 + # update a cipher 269 + put "/ciphers/:uuid" do 270 + d = device_from_bearer 271 + if !d 272 + return validation_error("invalid bearer") 273 + end 274 + 275 + c = nil 276 + if params[:uuid].blank? || !(c = Cipher.find_by_uuid(params[:uuid])) 277 + return validation_error("invalid cipher") 278 + end 279 + 280 + if c.user_uuid != d.user_uuid 281 + return validation_error("invalid cipher") 282 + end 283 + 284 + need_params(:type, :name) do |p| 285 + return validation_error("#{p} cannot be blank") 286 + end 287 + 288 + c.update_from_params(params) 289 + 290 + Cipher.transaction do 291 + if !c.save 292 + return validation_error("error saving") 293 + end 294 + 295 + c.to_hash.merge({ 296 + "Edit" => true, 297 + }).to_json 298 + end 299 + end 300 + 301 + # delete a cipher 302 + delete "/ciphers/:uuid" do 303 + d = device_from_bearer 304 + if !d 305 + return validation_error("invalid bearer") 306 + end 307 + 308 + c = nil 309 + if params[:uuid].blank? || !(c = Cipher.find_by_uuid(params[:uuid])) 310 + return validation_error("invalid cipher") 311 + end 312 + 313 + if c.user_uuid != d.user_uuid 314 + return validation_error("invalid cipher") 315 + end 316 + 317 + c.destroy 318 + 319 + "" 320 + end 321 + end 322 + 323 + namespace ICONS_URL do 324 + get "/icons/:domain/icon.png" do 325 + content_type "image/x-icon" 326 + 327 + # TODO 328 + 329 + "" 330 + end 331 + end
+167 -13
lib/bitwarden.rb
··· 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 + 1 17 require "jwt" 18 + require "pbkdf2" 19 + require "openssl" 2 20 3 21 class Bitwarden 22 + # convenience methods for hashing/encryption/decryption that the apps do, 23 + # just so we can test against 4 24 class << self 5 - attr_reader :jwt_rsa 25 + # pbkdf2 stretch a password+salt 26 + def makeKey(password, salt) 27 + PBKDF2.new(:password => password, :salt => salt, 28 + :iterations => 5000, :hash_function => OpenSSL::Digest::SHA256, 29 + :key_length => (256 / 8)).bin_string 30 + end 6 31 7 - JWT_KEY = "#{APP_ROOT}/jwt-rsa.key" 32 + # encrypt random bytes with a key to make new encryption key 33 + def makeEncKey(key) 34 + pt = OpenSSL::Random.random_bytes(64) 35 + iv = OpenSSL::Random.random_bytes(16) 8 36 9 - # load or create RSA pair used for JWT signing 10 - def load_jwt_keys 11 - if File.exists?(JWT_KEY) 12 - @jwt_rsa = OpenSSL::PKey::RSA.new File.read(JWT_KEY) 37 + cipher = OpenSSL::Cipher.new "AES-256-CBC" 38 + cipher.encrypt 39 + cipher.key = key 40 + cipher.iv = iv 41 + ct = cipher.update(pt) 42 + ct << cipher.final 43 + 44 + CipherString.new( 45 + CipherString::TYPE_AESCBC256_B64, 46 + Base64.strict_encode64(iv), 47 + Base64.strict_encode64(ct), 48 + ).to_s 49 + end 50 + 51 + # base64-encode a wrapped, stretched password+salt for signup/login 52 + def hashPassword(password, salt) 53 + key = makeKey(password, salt) 54 + Base64.strict_encode64(PBKDF2.new(:password => key, :salt => password, 55 + :iterations => 1, :key_length => 256/8, 56 + :hash_function => OpenSSL::Digest::SHA256).bin_string) 57 + end 58 + 59 + # encrypt+mac a value with a key and mac key and random iv, return a 60 + # CipherString of it 61 + def encrypt(pt, key, macKey) 62 + iv = OpenSSL::Random.random_bytes(16) 63 + 64 + cipher = OpenSSL::Cipher.new "AES-256-CBC" 65 + cipher.encrypt 66 + cipher.key = key 67 + cipher.iv = iv 68 + ct = cipher.update(pt) 69 + ct << cipher.final 70 + 71 + mac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, 72 + iv + ct) 73 + 74 + CipherString.new( 75 + CipherString::TYPE_AESCBC256_HMACSHA256_B64, 76 + Base64.strict_encode64(iv), 77 + Base64.strict_encode64(ct), 78 + Base64.strict_encode64(mac), 79 + ) 80 + end 81 + 82 + # compare two hmacs, with double hmac verification 83 + # https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2011/february/double-hmac-verification/ 84 + def macsEqual(macKey, mac1, mac2) 85 + hmac1 = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, mac1) 86 + hmac2 = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, mac2) 87 + return hmac1 == hmac2 88 + end 89 + 90 + # decrypt a CipherString and return plaintext 91 + def decrypt(str, key, macKey) 92 + c = CipherString.parse(str) 93 + 94 + case c.type 95 + when CipherString::TYPE_AESCBC256_HMACSHA256_B64 96 + iv = Base64.decode64(c.iv) 97 + ct = Base64.decode64(c.ct) 98 + mac = Base64.decode64(c.mac) 99 + 100 + cmac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), 101 + macKey, iv + ct) 102 + if !self.macsEqual(macKey, mac, cmac) 103 + raise "invalid mac" 104 + end 105 + 106 + cipher = OpenSSL::Cipher.new "AES-256-CBC" 107 + cipher.decrypt 108 + cipher.iv = iv 109 + cipher.key = key 110 + pt = cipher.update(ct) 111 + pt << cipher.final 112 + return pt 113 + 13 114 else 14 - @jwt_rsa = OpenSSL::PKey::RSA.generate 2048 115 + raise "TODO implement #{c.type}" 116 + end 117 + end 118 + end 15 119 16 - f = File.new(JWT_KEY, File::CREAT|File::TRUNC|File::RDWR, 0600) 17 - f.write @jwt_rsa.to_pem 18 - f.write @jwt_rsa.public_key.to_pem 19 - f.close 120 + class CipherString 121 + TYPE_AESCBC256_B64 = 0 122 + TYPE_AESCBC128_HMACSHA256_B64 = 1 123 + TYPE_AESCBC256_HMACSHA256_B64 = 2 124 + TYPE_RSA2048_OAEPSHA256_B64 = 3 125 + TYPE_RSA2048_OAEPSHA1_B64 = 4 126 + TYPE_RSA2048_OAEPSHA256_HMACSHA256_B64 = 5 127 + TYPE_RSA2048_OAEPSHA1_HMACSHA256_B64 = 6 128 + 129 + attr_reader :type, :iv, :ct, :mac 130 + 131 + def self.parse(str) 132 + if !(m = str.to_s.match(/\A(\d)\.([^|]+)\|(.+)\z/)) 133 + raise "invalid CipherString: #{str.inspect}" 20 134 end 135 + 136 + type = m[1].to_i 137 + iv = m[2] 138 + ct, mac = m[3].split("|", 2) 139 + CipherString.new(type, iv, ct, mac) 21 140 end 22 141 23 - def jwt_sign(payload) 24 - JWT.encode(payload, @jwt_rsa, "RS256") 142 + def initialize(type, iv, ct, mac = nil) 143 + @type = type 144 + @iv = iv 145 + @ct = ct 146 + @mac = mac 147 + end 148 + 149 + def to_s 150 + [ self.type.to_s + "." + self.iv, self.ct, self.mac ]. 151 + reject{|p| !p }. 152 + join("|") 153 + end 154 + end 155 + 156 + class Token 157 + class << self 158 + KEY = "#{APP_ROOT}/jwt-rsa.key" 159 + 160 + attr_reader :rsa 161 + 162 + # load or create RSA pair used for JWT signing 163 + def load_keys 164 + if File.exists?(KEY) 165 + @rsa = OpenSSL::PKey::RSA.new File.read(KEY) 166 + else 167 + @rsa = OpenSSL::PKey::RSA.generate 2048 168 + 169 + f = File.new(KEY, File::CREAT|File::TRUNC|File::RDWR, 0600) 170 + f.write @rsa.to_pem 171 + f.write @rsa.public_key.to_pem 172 + f.close 173 + end 174 + end 175 + 176 + def sign(payload) 177 + JWT.encode(payload, @rsa, "RS256") 178 + end 25 179 end 26 180 end 27 181 end
+49
lib/bitwarden_ruby.rb
··· 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 + 17 + Encoding.default_internal = Encoding.default_external = Encoding::UTF_8 18 + 19 + APP_ROOT = File.realpath(File.dirname(__FILE__) + "/../") 20 + 21 + RACK_ENV ||= (ENV["RACK_ENV"] || "development") 22 + 23 + require "sinatra" 24 + require "sinatra/namespace" 25 + require "cgi" 26 + 27 + require "#{APP_ROOT}/lib/bitwarden.rb" 28 + require "#{APP_ROOT}/lib/helper.rb" 29 + 30 + require "#{APP_ROOT}/lib/db.rb" 31 + require "#{APP_ROOT}/lib/dbmodel.rb" 32 + require "#{APP_ROOT}/lib/user.rb" 33 + require "#{APP_ROOT}/lib/device.rb" 34 + require "#{APP_ROOT}/lib/cipher.rb" 35 + 36 + BASE_URL ||= "/api" 37 + IDENTITY_BASE_URL ||= "/identity" 38 + ICONS_URL ||= "/icons" 39 + 40 + # whether to allow new users 41 + if !defined?(ALLOW_SIGNUPS) 42 + ALLOW_SIGNUPS = (ENV["ALLOW_SIGNUPS"] || false) 43 + end 44 + 45 + # create/load JWT signing keys 46 + Bitwarden::Token.load_keys 47 + 48 + # create/update tables 49 + Db.connect("#{APP_ROOT}/db/#{RACK_ENV}.sqlite3")
+84
lib/cipher.rb
··· 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 + 17 + class Cipher < DBModel 18 + set_table_name "ciphers" 19 + set_primary_key "uuid" 20 + 21 + attr_writer :user 22 + 23 + TYPE_LOGIN = 1 24 + TYPE_NOTE = 2 25 + TYPE_CARD = 3 26 + 27 + def to_hash 28 + { 29 + "Id" => self.uuid, 30 + "Type" => self.type, 31 + "RevisionDate" => self.updated_at.strftime("%Y-%m-%dT%H:%M:%S.000000Z"), 32 + "FolderId" => self.folder_uuid, 33 + "Favorite" => self.favorite, 34 + "OrganizationId" => nil, 35 + "Attachments" => self.attachments, 36 + "OrganizationUseTotp" => false, 37 + "Data" => JSON.parse(self.data.to_s), 38 + "Object" => "cipher", 39 + } 40 + end 41 + 42 + def update_from_params(params) 43 + self.folder_uuid = params[:folderId] 44 + self.organization_uuid = params[:organizationId] 45 + self.favorite = params[:favorite] 46 + self.type = params[:type].to_i 47 + 48 + cdata = { 49 + "Name" => params[:name] 50 + } 51 + 52 + case self.type 53 + when TYPE_LOGIN 54 + params[:login].each do |k,v| 55 + cdata[k.to_s.ucfirst] = v 56 + end 57 + 58 + when TYPE_CARD 59 + params[:card].each do |k,v| 60 + cdata[k.to_s.ucfirst] = v 61 + end 62 + end 63 + 64 + cdata["Notes"] = params[:notes] 65 + 66 + if params[:fields] && params[:fields].is_a?(Array) 67 + cdata["Fields"] = params[:fields].map{|f| 68 + fh = {} 69 + f.each do |k,v| 70 + fh[k.ucfirst] = v 71 + end 72 + fh 73 + } 74 + else 75 + cdata["Fields"] = nil 76 + end 77 + 78 + self.data = cdata.to_json 79 + end 80 + 81 + def user 82 + @user ||= User.find_by_uuid(self.user_uuid) 83 + end 84 + end
+112 -47
lib/db.rb
··· 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 + 1 17 require "sqlite3" 2 18 3 19 class Db 4 - @@db = nil 20 + class << self 21 + attr_reader :db, :db_file 5 22 6 - def self.db_file 7 - "#{APP_ROOT}/db.sqlite3" 8 - end 23 + def connect(db_file) 24 + @db_file = db_file 25 + 26 + @db = SQLite3::Database.new(@db_file) 27 + 28 + @db.execute(" 29 + CREATE TABLE IF NOT EXISTS 30 + users 31 + (uuid STRING PRIMARY KEY, 32 + created_at DATETIME, 33 + updated_at DATETIME, 34 + email TEXT UNIQUE, 35 + email_verified BOOLEAN, 36 + premium BOOLEAN, 37 + name TEXT, 38 + password_hash TEXT, 39 + password_hint TEXT, 40 + key TEXT, 41 + private_key BLOB, 42 + public_key BLOB, 43 + totp_secret STRING, 44 + security_stamp STRING, 45 + culture STRING) 46 + ") 47 + 48 + @db.execute(" 49 + CREATE TABLE IF NOT EXISTS 50 + devices 51 + (uuid STRING PRIMARY KEY, 52 + created_at DATETIME, 53 + updated_at DATETIME, 54 + user_uuid STRING, 55 + name STRING, 56 + type INTEGER, 57 + push_token STRING UNIQUE, 58 + access_token STRING UNIQUE, 59 + refresh_token STRING UNIQUE, 60 + token_expires_at DATETIME) 61 + ") 62 + 63 + @db.execute(" 64 + CREATE TABLE IF NOT EXISTS 65 + ciphers 66 + (uuid STRING PRIMARY KEY, 67 + created_at DATETIME, 68 + updated_at DATETIME, 69 + user_uuid STRING, 70 + folder_uuid STRING, 71 + organization_uuid STRING, 72 + type INTEGER, 73 + data BLOB, 74 + favorite BOOLEAN, 75 + attachments BLOB) 76 + ") 77 + 78 + @db.results_as_hash = true 79 + 80 + @db.execute(" 81 + CREATE TABLE IF NOT EXISTS 82 + schema_version 83 + (version INTEGER) 84 + ") 9 85 10 - def self.connection 11 - if @@db 12 - return @@db 13 - end 86 + v = @db.execute("SELECT version FROM schema_version").first 87 + if !v 88 + v = { "version" => 0 } 89 + end 14 90 15 - @@db = SQLite3::Database.new(self.db_file) 91 + case v["version"] 92 + when 0 93 + @db.execute("INSERT INTO schema_version (version) VALUES (1)") 94 + end 16 95 17 - @@db.execute(" 18 - CREATE TABLE IF NOT EXISTS 19 - users 20 - (id INTEGER PRIMARY KEY ASC, 21 - email TEXT UNIQUE, 22 - name TEXT, 23 - password_hash TEXT, 24 - key TEXT, 25 - totp_secret STRING, 26 - security_stamp STRING, 27 - culture STRING) 28 - ") 96 + # eagerly cache column definitions 97 + ObjectSpace.each_object(Class).each do |klass| 98 + if klass < DBModel 99 + klass.fetch_columns 100 + end 101 + end 29 102 30 - @@db.execute(" 31 - CREATE TABLE IF NOT EXISTS 32 - devices 33 - (id INTEGER PRIMARY KEY ASC, 34 - device_uuid STRING UNIQUE, 35 - user_id INTEGER, 36 - name STRING, 37 - device_type INTEGER, 38 - device_push_token STRING, 39 - access_token STRING UNIQUE, 40 - refresh_token STRING UNIQUE, 41 - token_expiry INTEGER) 42 - ") 103 + @db 104 + end 43 105 44 - @@db.execute(" 45 - CREATE TABLE IF NOT EXISTS 46 - ciphers 47 - (id INTEGER PRIMARY KEY ASC, 48 - cipher_uuid STRING UNIQUE, 49 - updated_at INTEGER, 50 - user_id INTEGER, 51 - data STRING, 52 - cipher_type INTEGER, 53 - cipher_attachments STRING) 54 - ") 106 + def connection 107 + @db 108 + end 55 109 56 - @@db.results_as_hash = true 110 + def execute(query, params = []) 111 + caster = proc{|a| 112 + if a.is_a?(String) && a.encoding != Encoding::BINARY 113 + a = a.encode(Encoding::UTF_8) 114 + elsif a.is_a?(TrueClass) || a.is_a?(FalseClass) 115 + a = (a ? 1 : 0) 116 + end 117 + a 118 + } 57 119 58 - @@db 120 + self.connection.execute(*[ 121 + caster.call(query), params.map{|a| caster.call(a) } 122 + ]) 123 + end 59 124 end 60 125 end
+194 -42
lib/dbmodel.rb
··· 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 + 1 17 class DBModel 2 18 class << self 3 - attr_accessor :table_name, :table_attrs 19 + attr_reader :table_name, :columns, :primary_key 20 + 21 + def method_missing(method, *args, &block) 22 + if m = method.to_s.match(/^find_by_(.+)/) 23 + return find_by_column(m[1], args[0]) 24 + elsif m = method.to_s.match(/^find_all_by_(.+)/) 25 + return find_all_by_column(m[1], args[0]) 26 + else 27 + super 28 + end 29 + end 30 + 31 + # transform ruby data into sql 32 + def cast_data_for_column(data, col) 33 + if !@columns 34 + raise "need to fetch columns but in a query" 35 + end 4 36 5 - def set_table_name(table) 6 - @table_name = table 37 + case @columns[col][:type] 38 + when /boolean/i 39 + return data == true ? 1 : 0 40 + when /datetime/i 41 + return (data == nil ? nil : data.to_i) 42 + when /integer/i 43 + return (data == nil ? nil : data.to_i) 44 + else 45 + return data 46 + end 47 + end 48 + 49 + def fetch_columns 50 + return if @columns 51 + 52 + @columns = {} 53 + 54 + Db.execute("SELECT sql FROM sqlite_master WHERE tbl_name = ?", 55 + [ self.table_name ]).first["sql"]. 56 + gsub("\n", " "). 57 + gsub(/^\s*CREATE\s+TABLE\s+#{self.table_name}\s*\(/i, ""). 58 + split(","). 59 + each do |f| 60 + if !(m = f.match(/^\s*([A-Za-z0-9_-]+)\s+([A-Za-z ]+)/)) 61 + raise "can't parse column definition #{f.inspect}" 62 + end 63 + 64 + @columns[m[1]] = { 65 + :type => m[2].strip.upcase, 66 + } 67 + end 68 + 69 + attr_accessor *(columns.keys) 70 + end 71 + 72 + def find_all_by_column(column, value, limit = nil) 73 + fetch_columns 74 + 75 + Db.execute("SELECT * FROM `#{table_name}` WHERE " << 76 + "`#{column}` = ? #{limit}", [ value.encode("utf-8") ]).map do |rec| 77 + obj = self.new 78 + obj.new_record = false 79 + 80 + rec.each do |k,v| 81 + next if !k.is_a?(String) 82 + obj.send("#{k}=", uncast_data_from_column(v, k)) 83 + end 84 + 85 + obj 86 + end 87 + end 88 + 89 + def find_by_column(column, value) 90 + find_all_by_column(column, value, "LIMIT 1").first 91 + end 92 + 93 + def primary_key 94 + @primary_key || "id" 95 + end 96 + 97 + def primary_key_uuid? 98 + !!primary_key.match(/uuid$/) 99 + end 100 + 101 + def set_primary_key(col) 102 + @primary_key = col 7 103 end 8 104 9 105 def set_table_attrs(attrs) 10 106 @table_attrs = attrs 11 107 attr_accessor *attrs 12 108 end 13 - end 14 109 15 - def self.method_missing(method, *args, &block) 16 - if m = method.to_s.match(/^find_by_(.+)/) 17 - return self.find_by_column(m[1], args[0]) 18 - elsif m = method.to_s.match(/^find_all_by_(.+)/) 19 - return self.find_all_by_column(m[1], args[0]) 20 - else 21 - super 110 + def set_table_name(table) 111 + @table_name = table 22 112 end 23 - end 24 113 25 - def self.find_by_column(column, value) 26 - self.find_all_by_column(column, value, "LIMIT 1").first 27 - end 114 + # transform database data into ruby 115 + def uncast_data_from_column(data, col) 116 + if !@columns 117 + raise "need to fetch columns but in a query" 118 + end 28 119 29 - def self.find_all_by_column(column, value, limit = nil) 30 - Db.connection.execute("SELECT * FROM `#{self.table_name}` WHERE " << 31 - "`#{column}` = ? #{limit}", [ value ]).map do |rec| 32 - obj = self.new 120 + case @columns[col][:type] 121 + when /boolean/i 122 + return (data >= 1) 123 + when /datetime/i 124 + return (data == nil ? nil : Time.at(data.to_i)) 125 + when /integer/i 126 + return (data == nil ? nil : data.to_i) 127 + else 128 + return data 129 + end 130 + end 33 131 34 - rec.each do |k,v| 35 - next if !k.is_a?(String) 36 - obj.send("#{k}=", v) 132 + def writable_columns_for(what) 133 + k = columns.keys 134 + 135 + # normally we don't want to include `id` in the insert/update because 136 + # the db handles that for us, but when we have uuid primary keys, we 137 + # need to generate them ourselves, so include the column 138 + unless what == :insert && primary_key_uuid? 139 + k.reject!{|a| a == primary_key } 37 140 end 38 141 39 - obj 142 + k 40 143 end 41 144 end 42 145 146 + attr_accessor :new_record 147 + 43 148 def self.transaction(&block) 149 + ret = true 150 + 44 151 Db.connection.transaction do 45 - yield block 152 + ret = yield block 46 153 end 154 + 155 + ret 156 + end 157 + 158 + def initialize 159 + @new_record = true 160 + end 161 + 162 + def method_missing(method, *args, &block) 163 + self.class.fetch_columns 164 + super 165 + end 166 + 167 + def actual_before_create 168 + if self.class.primary_key_uuid? && self.send(self.class.primary_key).blank? 169 + self.send("#{self.class.primary_key}=", SecureRandom.uuid) 170 + end 171 + 172 + if self.class.columns["created_at"] 173 + self.created_at = Time.now 174 + end 175 + 176 + before_create 177 + end 178 + 179 + def actual_before_save 180 + if self.class.columns["updated_at"] 181 + self.updated_at = Time.now 182 + end 183 + 184 + before_save 185 + end 186 + 187 + def before_create 188 + true 47 189 end 48 190 49 191 def before_save ··· 51 193 end 52 194 53 195 def destroy 54 - if self.id 55 - Db.connection.execute("DELETE FROM `#{self.class.table_name}` WHERE " << 56 - "id = ?", [ self.id ]) 196 + if !self.new_record && self.send(self.class.primary_key) 197 + Db.execute("DELETE FROM `#{self.class.table_name}` WHERE " << 198 + "`#{self.class.primary_key}` = ?", 199 + [ self.send(self.class.primary_key) ]) 57 200 end 58 201 end 59 202 60 203 def save 61 - return false if !self.before_save 204 + self.class.fetch_columns 205 + 206 + return false if !self.actual_before_save 207 + 208 + if self.new_record 209 + return false if !self.actual_before_create 62 210 63 - if self.id 64 - Db.connection.execute("UPDATE `#{self.class.table_name}` SET " + 65 - self.class.table_attrs.reject{|a| a == :id }. 66 - map{|a| "#{a.to_s} = ?" }.join(", ") << 67 - " WHERE `id` = ?", 68 - self.class.table_attrs.reject{|a| a == :id }. 69 - map{|a| self.send(a) } + [ self.id ]) 70 - else 71 - Db.connection.execute("INSERT INTO `#{self.class.table_name}` (" << 72 - self.class.table_attrs.reject{|a| a == :id }. 73 - map{|a| a.to_s }.join(", ") << 211 + Db.execute("INSERT INTO `#{self.class.table_name}` (" << 212 + self.class.writable_columns_for(:insert).map{|a| a.to_s }.join(", ") << 74 213 ") VALUES (" << 75 - self.class.table_attrs.reject{|a| a == :id }. 76 - map{|a| "?" }.join(", ") << 214 + self.class.writable_columns_for(:insert).map{|a| "?" }.join(", ") << 77 215 ")", 78 - self.class.table_attrs.reject{|a| a == :id }.map{|a| self.send(a) }) 216 + self.class.writable_columns_for(:insert).map{|a| 217 + self.class.cast_data_for_column(self.send(a), a) 218 + }) 219 + 220 + self.new_record = false 221 + else 222 + Db.execute("UPDATE `#{self.class.table_name}` SET " + 223 + self.class.writable_columns_for(:update).map{|a| "#{a.to_s} = ?" }. 224 + join(", ") << 225 + " WHERE `#{self.class.primary_key}` = ?", 226 + self.class.writable_columns_for(:update).map{|a| 227 + self.class.cast_data_for_column(self.send(a), a) 228 + } + [ self.send(self.class.primary_key) ]) 79 229 end 230 + 231 + true 80 232 end 81 233 end
+33 -12
lib/device.rb
··· 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 + 1 17 class Device < DBModel 2 18 set_table_name "devices" 3 - set_table_attrs [ :id, :device_uuid, :user_id, :name, :device_type, 4 - :device_push_token, :access_token, :refresh_token, :token_expiry ] 19 + set_primary_key "uuid" 5 20 6 21 attr_writer :user 7 22 8 - def generate_tokens! 9 - self.token_expiry = (Time.now + (60 * 60)).to_i 10 - self.refresh_token = SecureRandom.urlsafe_base64(64)[0, 64] 23 + DEFAULT_TOKEN_VALIDITY = (60 * 60) 24 + 25 + def regenerate_tokens!(validity = DEFAULT_TOKEN_VALIDITY) 26 + if self.refresh_token.blank? 27 + self.refresh_token = SecureRandom.urlsafe_base64(64)[0, 64] 28 + end 29 + 30 + self.token_expires_at = Time.now + validity 11 31 12 32 # the official clients parse this JWT and checks for the existence of some 13 33 # of these fields 14 - self.access_token = Bitwarden.jwt_sign({ 15 - :nbf => (Time.now - (60 * 5)).to_i, 16 - :exp => self.token_expiry.to_i, 34 + self.access_token = Bitwarden::Token.sign({ 35 + :nbf => (Time.now - (60 * 2)).to_i, 36 + :exp => self.token_expires_at.to_i, 17 37 :iss => IDENTITY_BASE_URL, 18 - :sub => self.device_uuid, 19 - :premium => true, 38 + :sub => self.user.uuid, 39 + :premium => self.user.premium, 20 40 :name => self.user.name, 21 41 :email => self.user.email, 42 + :email_verified => self.user.email_verified, 22 43 :sstamp => self.user.security_stamp, 23 - :device => self.device_uuid, 44 + :device => self.uuid, 24 45 :scope => [ "api", "offline_access" ], 25 46 :amr => [ "Application" ], 26 47 }) 27 48 end 28 49 29 50 def user 30 - @user ||= User.find_by_id(self.user_id) 51 + @user ||= User.find_by_uuid(self.user_uuid) 31 52 end 32 53 end
+21 -19
lib/helper.rb
··· 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 + 1 17 class NilClass 2 18 def blank? 3 19 true ··· 31 47 32 48 res == 0 33 49 end 34 - end 35 50 36 - def need_params(*ps) 37 - ps.each do |p| 38 - if params[p].blank? 39 - yield(p) 51 + def ucfirst 52 + if self.length == 0 53 + "" 54 + else 55 + self[0].upcase << self[1 .. -1].to_s 40 56 end 41 57 end 42 58 end 43 - 44 - def tee(d) 45 - STDERR.puts d 46 - d 47 - end 48 - 49 - def validation_error(msg) 50 - [ 400, { 51 - "ValidationErrors" => { "" => [ 52 - msg, 53 - ]}, 54 - "Object" => "error", 55 - }.to_json ] 56 - end
+55 -3
lib/user.rb
··· 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 + 1 17 class User < DBModel 2 18 set_table_name "users" 3 - set_table_attrs [ :id, :email, :name, :password_hash, :key, :totp_secret, 4 - :security_stamp, :culture ] 19 + set_primary_key "uuid" 20 + 21 + def before_save 22 + if self.security_stamp.blank? 23 + self.security_stamp = SecureRandom.uuid 24 + end 25 + end 26 + 27 + def ciphers 28 + @ciphers ||= Cipher.find_all_by_user_uuid(self.uuid). 29 + each{|d| d.user = self } 30 + end 5 31 6 32 def devices 7 - @devices ||= Device.find_all_by_user_id(self.id).each{|d| d.user = self } 33 + @devices ||= Device.find_all_by_user_uuid(self.uuid). 34 + each{|d| d.user = self } 8 35 end 9 36 10 37 def has_password_hash?(hash) 11 38 self.password_hash.timingsafe_equal_to(hash) 39 + end 40 + 41 + # TODO: password_hash=() should update security_stamp when it changes, I 42 + # think 43 + 44 + def to_hash 45 + { 46 + "Id" => self.uuid, 47 + "Name" => self.name, 48 + "Email" => self.email, 49 + "EmailVerified" => self.email_verified, 50 + "Premium" => self.premium, 51 + "MasterPasswordHint" => self.password_hint, 52 + "Culture" => self.culture, 53 + "TwoFactorEnabled" => self.two_factor_enabled?, 54 + "Key" => self.key, 55 + "PrivateKey" => nil, 56 + "SecurityStamp" => self.security_stamp, 57 + "Organizations" => [], 58 + "Object" => "profile" 59 + } 60 + end 61 + 62 + def two_factor_enabled? 63 + self.totp_secret.present? 12 64 end 13 65 14 66 def verifies_totp_code?(code)
mitm.rb tools/mitm.rb
+167
spec/cipher_spec.rb
··· 1 + require_relative "spec_helper.rb" 2 + 3 + @access_token = nil 4 + 5 + describe "cipher module" do 6 + before do 7 + post "/api/accounts/register", { 8 + :name => nil, 9 + :email => "api@example.com", 10 + :masterPasswordHash => Bitwarden.hashPassword("asdf", "api@example.com"), 11 + :masterPasswordHint => nil, 12 + :key => Bitwarden.makeEncKey( 13 + Bitwarden.makeKey("adsf", "api@example.com") 14 + ), 15 + } 16 + 17 + post "/identity/connect/token", { 18 + :grant_type => "password", 19 + :username => "api@example.com", 20 + :password => Bitwarden.hashPassword("asdf", "api@example.com"), 21 + :scope => "api offline_access", 22 + :client_id => "browser", 23 + :deviceType => 3, 24 + :deviceIdentifier => SecureRandom.uuid, 25 + :deviceName => "firefox", 26 + :devicePushToken => "" 27 + } 28 + 29 + @access_token = last_json_response["access_token"] 30 + end 31 + 32 + it "should not allow access with bogus bearer token" do 33 + post_json "/api/ciphers", { 34 + :type => 1, 35 + :folderId => nil, 36 + :organizationId => nil, 37 + :name => "2.d7MttWzJTSSKx1qXjHUxlQ==|01Ath5UqFZHk7csk5DVtkQ==|EMLoLREgCUP5Cu4HqIhcLqhiZHn+NsUDp8dAg1Xu0Io=", 38 + :notes => nil, 39 + :favorite => false, 40 + :login => { 41 + :uri => "2.T57BwAuV8ubIn/sZPbQC+A==|EhUSSpJWSzSYOdJ/AQzfXuUXxwzcs/6C4tOXqhWAqcM=|OWV2VIqLfoWPs9DiouXGUOtTEkVeklbtJQHkQFIXkC8=", 42 + :username => "2.JbFkAEZPnuMm70cdP44wtA==|fsN6nbT+udGmOWv8K4otgw==|JbtwmNQa7/48KszT2hAdxpmJ6DRPZst0EDEZx5GzesI=", 43 + :password => "2.e83hIsk6IRevSr/H1lvZhg==|48KNkSCoTacopXRmIZsbWg==|CIcWgNbaIN2ix2Fx1Gar6rWQeVeboehp4bioAwngr0o=", 44 + :totp => nil 45 + } 46 + }, { 47 + "HTTP_AUTHORIZATION" => "Bearer #{@access_token.upcase}", 48 + } 49 + 50 + last_response.status.wont_equal 200 51 + end 52 + 53 + it "should allow creating, updating, and deleting ciphers" do 54 + post_json "/api/ciphers", { 55 + :type => 1, 56 + :folderId => nil, 57 + :organizationId => nil, 58 + :name => "2.d7MttWzJTSSKx1qXjHUxlQ==|01Ath5UqFZHk7csk5DVtkQ==|EMLoLREgCUP5Cu4HqIhcLqhiZHn+NsUDp8dAg1Xu0Io=", 59 + :notes => nil, 60 + :favorite => false, 61 + :login => { 62 + :uri => "2.T57BwAuV8ubIn/sZPbQC+A==|EhUSSpJWSzSYOdJ/AQzfXuUXxwzcs/6C4tOXqhWAqcM=|OWV2VIqLfoWPs9DiouXGUOtTEkVeklbtJQHkQFIXkC8=", 63 + :username => "2.JbFkAEZPnuMm70cdP44wtA==|fsN6nbT+udGmOWv8K4otgw==|JbtwmNQa7/48KszT2hAdxpmJ6DRPZst0EDEZx5GzesI=", 64 + :password => "2.e83hIsk6IRevSr/H1lvZhg==|48KNkSCoTacopXRmIZsbWg==|CIcWgNbaIN2ix2Fx1Gar6rWQeVeboehp4bioAwngr0o=", 65 + :totp => nil 66 + } 67 + }, { 68 + "HTTP_AUTHORIZATION" => "Bearer #{@access_token}", 69 + } 70 + 71 + last_response.status.must_equal 200 72 + uuid = last_json_response["Id"] 73 + uuid.to_s.wont_equal "" 74 + 75 + c = Cipher.find_by_uuid(uuid) 76 + c.wont_be_nil 77 + c.uuid.must_equal uuid 78 + JSON.parse(c.data)["Name"].must_equal "2.d7MttWzJTSSKx1qXjHUxlQ==|01Ath5UqFZHk7csk5DVtkQ==|EMLoLREgCUP5Cu4HqIhcLqhiZHn+NsUDp8dAg1Xu0Io=" 79 + 80 + # update 81 + 82 + ik = Bitwarden.makeKey("asdf", "api@example.com") 83 + k = Bitwarden.makeEncKey(ik) 84 + new_name = Bitwarden.encrypt("some new name", ik[0, 32], ik[32, 32]).to_s 85 + 86 + put_json "/api/ciphers/#{uuid}", { 87 + :type => 1, 88 + :folderId => nil, 89 + :organizationId => nil, 90 + :name => new_name, 91 + :notes => nil, 92 + :favorite => false, 93 + :login => { 94 + :uri => "2.T57BwAuV8ubIn/sZPbQC+A==|EhUSSpJWSzSYOdJ/AQzfXuUXxwzcs/6C4tOXqhWAqcM=|OWV2VIqLfoWPs9DiouXGUOtTEkVeklbtJQHkQFIXkC8=", 95 + :username => "2.JbFkAEZPnuMm70cdP44wtA==|fsN6nbT+udGmOWv8K4otgw==|JbtwmNQa7/48KszT2hAdxpmJ6DRPZst0EDEZx5GzesI=", 96 + :password => "2.e83hIsk6IRevSr/H1lvZhg==|48KNkSCoTacopXRmIZsbWg==|CIcWgNbaIN2ix2Fx1Gar6rWQeVeboehp4bioAwngr0o=", 97 + :totp => nil 98 + } 99 + }, { 100 + "HTTP_AUTHORIZATION" => "Bearer #{@access_token}", 101 + } 102 + 103 + last_response.status.must_equal 200 104 + last_json_response["Id"].to_s.wont_equal "" 105 + 106 + c = Cipher.find_by_uuid(last_json_response["Id"]) 107 + JSON.parse(c.data)["Name"].must_equal new_name 108 + 109 + uuid = c.uuid 110 + 111 + # delete 112 + 113 + delete_json "/api/ciphers/#{uuid}", {}, { 114 + "HTTP_AUTHORIZATION" => "Bearer #{@access_token}", 115 + } 116 + last_response.status.must_equal 200 117 + 118 + Cipher.find_by_uuid(uuid).must_be_nil 119 + end 120 + 121 + it "should not allow creating, updating, or deleting bogus ciphers" do 122 + post_json "/api/ciphers", { 123 + :type => -5, 124 + }, { 125 + "HTTP_AUTHORIZATION" => "Bearer #{@access_token}", 126 + } 127 + 128 + last_response.status.wont_equal 200 129 + 130 + # create, then bogus update 131 + 132 + post_json "/api/ciphers", { 133 + :type => 1, 134 + :folderId => nil, 135 + :organizationId => nil, 136 + :name => "2.d7MttWzJTSSKx1qXjHUxlQ==|01Ath5UqFZHk7csk5DVtkQ==|EMLoLREgCUP5Cu4HqIhcLqhiZHn+NsUDp8dAg1Xu0Io=", 137 + :notes => nil, 138 + :favorite => false, 139 + :login => { 140 + :uri => "2.T57BwAuV8ubIn/sZPbQC+A==|EhUSSpJWSzSYOdJ/AQzfXuUXxwzcs/6C4tOXqhWAqcM=|OWV2VIqLfoWPs9DiouXGUOtTEkVeklbtJQHkQFIXkC8=", 141 + :username => "2.JbFkAEZPnuMm70cdP44wtA==|fsN6nbT+udGmOWv8K4otgw==|JbtwmNQa7/48KszT2hAdxpmJ6DRPZst0EDEZx5GzesI=", 142 + :password => "2.e83hIsk6IRevSr/H1lvZhg==|48KNkSCoTacopXRmIZsbWg==|CIcWgNbaIN2ix2Fx1Gar6rWQeVeboehp4bioAwngr0o=", 143 + :totp => nil 144 + } 145 + }, { 146 + "HTTP_AUTHORIZATION" => "Bearer #{@access_token}", 147 + } 148 + 149 + last_response.status.must_equal 200 150 + uuid = last_json_response["Id"] 151 + 152 + put_json "/api/ciphers/#{uuid}", { 153 + :type => -5, 154 + }, { 155 + "HTTP_AUTHORIZATION" => "Bearer #{@access_token}", 156 + } 157 + 158 + last_response.status.wont_equal 200 159 + 160 + # bogus delete 161 + 162 + delete_json "/api/ciphers/something-bogus", {}, { 163 + "HTTP_AUTHORIZATION" => "Bearer #{@access_token}", 164 + } 165 + last_response.status.wont_equal 200 166 + end 167 + end
+62
spec/cipherstring_spec.rb
··· 1 + require_relative "spec_helper.rb" 2 + 3 + describe "bitwarden encryption stuff" do 4 + it "should make a key from a password and salt" do 5 + b64 = "2K4YP5Om9r5NpA7FCS4vQX5t+IC4hKYdTJN/C20cz9c=" 6 + 7 + k = Bitwarden.makeKey("this is a password", "nobody@example.com") 8 + Base64.strict_encode64(k).encode("utf-8").must_equal b64 9 + 10 + # make sure key and salt affect it 11 + k = Bitwarden.makeKey("this is a password", "nobody2@example.com") 12 + Base64.strict_encode64(k).encode("utf-8").wont_equal b64 13 + 14 + k = Bitwarden.makeKey("this is A password", "nobody@example.com") 15 + Base64.strict_encode64(k).encode("utf-8").wont_equal b64 16 + end 17 + 18 + it "should make a cipher string from a key" do 19 + cs = Bitwarden.makeEncKey( 20 + Bitwarden.makeKey("this is a password", "nobody@example.com") 21 + ) 22 + 23 + cs.must_match /^0\.[^|]+|[^|]+$/ 24 + end 25 + 26 + it "should hash a password" do 27 + #def hashedPassword(password, salt) 28 + Bitwarden.hashPassword("secret password", "user@example.com"). 29 + must_equal "VRlYxg0x41v40mvDNHljqpHcqlIFwQSzegeq+POW1ww=" 30 + end 31 + 32 + it "should parse a cipher string" do 33 + cs = Bitwarden::CipherString.parse( 34 + "0.u7ZhBVHP33j7cud6ImWFcw==|WGcrq5rTEMeyYkWywLmxxxSgHTLBOWThuWRD/6gVKj77+Vd09DiZ83oshVS9+gxyJbQmzXWilZnZRD/52tah1X0MWDRTdI5bTnTf8KfvRCQ=" 35 + ) 36 + 37 + cs.type.must_equal Bitwarden::CipherString::TYPE_AESCBC256_B64 38 + cs.iv.must_equal "u7ZhBVHP33j7cud6ImWFcw==" 39 + cs.ct.must_equal "WGcrq5rTEMeyYkWywLmxxxSgHTLBOWThuWRD/6gVKj77+Vd09DiZ83oshVS9+gxyJbQmzXWilZnZRD/52tah1X0MWDRTdI5bTnTf8KfvRCQ=" 40 + cs.mac.must_be_nil 41 + end 42 + 43 + it "should parse a type-3 cipher string" do 44 + cs = Bitwarden::CipherString.parse("2.ftF0nH3fGtuqVckLZuHGjg==|u0VRhH24uUlVlTZd/uD1lA==|XhBhBGe7or/bXzJRFWLUkFYqauUgxksCrRzNmJyigfw=") 45 + cs.type.must_equal 2 46 + end 47 + 48 + it "should encrypt and decrypt properly" do 49 + ik = Bitwarden.makeKey("password", "user@example.com") 50 + k = Bitwarden.makeEncKey(ik) 51 + j = Bitwarden.encrypt("hi there", ik[0, 32], ik[32, 32]) 52 + 53 + cs = Bitwarden::CipherString.parse(j) 54 + 55 + ik = Bitwarden.makeKey("password", "user@example.com") 56 + Bitwarden.decrypt(cs.to_s, ik[0, 32], ik[32, 32]).must_equal "hi there" 57 + end 58 + 59 + it "should test mac equality" do 60 + Bitwarden.macsEqual("asdfasdfasdf", "hi", "hi").must_equal true 61 + end 62 + end
+147
spec/identity_spec.rb
··· 1 + require_relative "spec_helper.rb" 2 + 3 + describe "identity module" do 4 + it "should return successful response to account creation" do 5 + post "/api/accounts/register", { 6 + :name => nil, 7 + :email => "nobody@example.com", 8 + :masterPasswordHash => Bitwarden.hashPassword("asdf", 9 + "nobody@example.com"), 10 + :masterPasswordHint => nil, 11 + :key => Bitwarden.makeEncKey( 12 + Bitwarden.makeKey("adsf", "nobody@example.com") 13 + ), 14 + } 15 + last_response.status.must_equal 200 16 + end 17 + 18 + it "should not allow duplicate signups" do 19 + 2.times do |x| 20 + post "/api/accounts/register", { 21 + :name => nil, 22 + :email => "nobody2@example.com", 23 + :masterPasswordHash => Bitwarden.hashPassword("asdf", 24 + "nobody2@example.com"), 25 + :masterPasswordHint => nil, 26 + :key => Bitwarden.makeEncKey( 27 + Bitwarden.makeKey("adsf", "nobody2@example.com") 28 + ), 29 + } 30 + if x == 0 31 + last_response.status.must_equal 200 32 + else 33 + last_response.status.wont_equal 200 34 + end 35 + end 36 + end 37 + 38 + it "validates required parameters" do 39 + post "/api/accounts/register", { 40 + :name => nil, 41 + :email => "nobody3@example.com", 42 + :masterPasswordHash => "", 43 + :masterPasswordHint => nil, 44 + :key => Bitwarden.makeEncKey( 45 + Bitwarden.makeKey("adsf", "nobody3@example.com") 46 + ), 47 + } 48 + last_response.status.wont_equal 200 49 + 50 + post "/api/accounts/register", { 51 + :name => nil, 52 + :email => "nobody3@example.com", 53 + :masterPasswordHash => Bitwarden.hashPassword("asdf", 54 + "nobody3@example.com"), 55 + :masterPasswordHint => nil, 56 + :key => "junk", 57 + } 58 + last_response.status.wont_equal 200 59 + end 60 + 61 + it "actually creates the user account and allows logging in" do 62 + post "/api/accounts/register", { 63 + :name => nil, 64 + :email => "nobody4@example.com", 65 + :masterPasswordHash => Bitwarden.hashPassword("asdf", 66 + "nobody4@example.com"), 67 + :masterPasswordHint => nil, 68 + :key => Bitwarden.makeEncKey( 69 + Bitwarden.makeKey("adsf", "nobody4@example.com") 70 + ), 71 + } 72 + last_response.status.must_equal 200 73 + 74 + (u = User.find_by_email("nobody4@example.com")).wont_be_nil 75 + u.uuid.wont_be_nil 76 + u.password_hash.must_equal "PGC1vNJZUL3z5wTKAgpXsODf6KzIPcr0XCzTplceXQU=" 77 + 78 + post "/identity/connect/token", { 79 + :grant_type => "password", 80 + :username => "nobody4@example.com", 81 + :password => Bitwarden.hashPassword("asdf", "nobody4@example.com"), 82 + :scope => "api offline_access", 83 + :client_id => "browser", 84 + :deviceType => 3, 85 + :deviceIdentifier => SecureRandom.uuid, 86 + :deviceName => "firefox", 87 + :devicePushToken => "" 88 + } 89 + 90 + last_response.status.must_equal 200 91 + (access_token = last_json_response["access_token"]).wont_be_nil 92 + 93 + get "/api/sync", {}, { 94 + "HTTP_AUTHORIZATION" => "Bearer #{access_token}", 95 + } 96 + last_response.status.must_equal 200 97 + end 98 + 99 + it "enforces token validity period" do 100 + post "/api/accounts/register", { 101 + :name => nil, 102 + :email => "nobody5@example.com", 103 + :masterPasswordHash => Bitwarden.hashPassword("asdf", 104 + "nobody5@example.com"), 105 + :masterPasswordHint => nil, 106 + :key => Bitwarden.makeEncKey( 107 + Bitwarden.makeKey("adsf", "nobody5@example.com") 108 + ), 109 + } 110 + last_response.status.must_equal 200 111 + 112 + post "/identity/connect/token", { 113 + :grant_type => "password", 114 + :username => "nobody5@example.com", 115 + :password => Bitwarden.hashPassword("asdf", "nobody5@example.com"), 116 + :scope => "api offline_access", 117 + :client_id => "browser", 118 + :deviceType => 3, 119 + :deviceIdentifier => SecureRandom.uuid, 120 + :deviceName => "firefox", 121 + :devicePushToken => "" 122 + } 123 + 124 + access_token = last_json_response["access_token"] 125 + 126 + get "/api/sync", {}, { 127 + "HTTP_AUTHORIZATION" => "Bearer #{access_token}", 128 + } 129 + last_response.status.must_equal 200 130 + 131 + d = Device.find_by_access_token(access_token) 132 + d.regenerate_tokens!(1) 133 + d.save 134 + 135 + get "/api/sync", {}, { 136 + "HTTP_AUTHORIZATION" => "Bearer #{d.access_token}", 137 + } 138 + last_response.status.must_equal 200 139 + 140 + sleep 2 141 + 142 + get "/api/sync", {}, { 143 + "HTTP_AUTHORIZATION" => "Bearer #{d.access_token}", 144 + } 145 + last_response.status.wont_equal 200 146 + end 147 + end
+46
spec/spec_helper.rb
··· 1 + ENV["RACK_ENV"] = "test" 2 + 3 + require "minitest/autorun" 4 + require "rack/test" 5 + 6 + # clear out test db 7 + if File.exists?(f = File.dirname(__FILE__) + "/../db/test.sqlite3") 8 + File.unlink(f) 9 + end 10 + 11 + # most tests require this to be on 12 + ALLOW_SIGNUPS = true 13 + 14 + require File.realpath(File.dirname(__FILE__) + "/../lib/bitwarden_ruby.rb") 15 + require "#{APP_ROOT}/lib/api.rb" 16 + 17 + include Rack::Test::Methods 18 + 19 + def last_json_response 20 + JSON.parse(last_response.body) 21 + end 22 + 23 + def get_json(path, params = {}, headers = {}) 24 + json_request :get, path, params, headers 25 + end 26 + 27 + def post_json(path, params = {}, headers = {}) 28 + json_request :post, path, params, headers 29 + end 30 + 31 + def put_json(path, params = {}, headers = {}) 32 + json_request :put, path, params, headers 33 + end 34 + 35 + def delete_json(path, params = {}, headers = {}) 36 + json_request :delete, path, params, headers 37 + end 38 + 39 + def json_request(verb, path, params = {}, headers = {}) 40 + send verb, path, params.to_json, 41 + headers.merge({ "CONTENT_TYPE" => "application/json" }) 42 + end 43 + 44 + def app 45 + Sinatra::Application 46 + end