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

Configure Feed

Select the types of activity you want to include in your feed.

add a tool to change a user's master password

+213 -9
+8
README.md
··· 55 55 56 56 bundle exec rake test 57 57 58 + ### Changing Master Password 59 + 60 + Changing a user's master password must be done from the command line (as it 61 + requires interacting with the plaintext password, which the web API will never 62 + do). 63 + 64 + env RACK_ENV=production bundle exec ruby tools/change_master_password.rb -u you@example.com 65 + 58 66 ### 1Password Conversion 59 67 60 68 Export everything from 1Password in its "1Password Interchange Format".
+10 -6
lib/bitwarden.rb
··· 60 60 61 61 # encrypt+mac a value with a key and mac key and random iv, return a 62 62 # CipherString of it 63 - def encrypt(pt, key, macKey) 63 + def encrypt(pt, key, macKey = nil) 64 64 iv = OpenSSL::Random.random_bytes(16) 65 65 66 66 cipher = OpenSSL::Cipher.new "AES-256-CBC" ··· 70 70 ct = cipher.update(pt) 71 71 ct << cipher.final 72 72 73 - mac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, 74 - iv + ct) 73 + mac = nil 74 + if macKey 75 + mac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, 76 + iv + ct) 77 + end 75 78 76 79 CipherString.new( 77 - CipherString::TYPE_AESCBC256_HMACSHA256_B64, 80 + mac ? CipherString::TYPE_AESCBC256_HMACSHA256_B64 : 81 + CipherString::TYPE_AESCBC256_B64, 78 82 Base64.strict_encode64(iv), 79 83 Base64.strict_encode64(ct), 80 - Base64.strict_encode64(mac), 84 + mac ? Base64.strict_encode64(mac) : nil, 81 85 ) 82 86 end 83 87 ··· 110 114 cmac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), 111 115 macKey, iv + ct) 112 116 if !self.macsEqual(macKey, mac, cmac) 113 - raise "invalid mac" 117 + raise "invalid mac #{mac.inspect} != #{cmac.inspect}" 114 118 end 115 119 116 120 cipher = OpenSSL::Cipher.new "AES-256-CBC"
+14 -3
lib/user.rb
··· 62 62 self.password_hash.timingsafe_equal_to(hash) 63 63 end 64 64 65 - # TODO: password_hash=() should update security_stamp when it changes, I 66 - # think 67 - 68 65 def to_hash 69 66 { 70 67 "Id" => self.uuid, ··· 85 82 86 83 def two_factor_enabled? 87 84 self.totp_secret.present? 85 + end 86 + 87 + def update_master_password(old_pwd, new_pwd) 88 + # original random encryption key must be preserved, just re-encrypted with 89 + # a new key derived from the new password 90 + 91 + orig_key = Bitwarden.decrypt(self.key, 92 + Bitwarden.makeKey(old_pwd, self.email), nil) 93 + 94 + self.key = Bitwarden.encrypt(orig_key, 95 + Bitwarden.makeKey(new_pwd, self.email)).to_s 96 + 97 + self.password_hash = Bitwarden.hashPassword(new_pwd, self.email) 98 + self.security_stamp = SecureRandom.uuid 88 99 end 89 100 90 101 def verifies_totp_code?(code)
+86
spec/user_spec.rb
··· 1 + require_relative "spec_helper.rb" 2 + 3 + describe "User" do 4 + USER_EMAIL = "user@example.com" 5 + USER_PASSWORD = "p4ssw0rd" 6 + 7 + before do 8 + User.all.each{|u| u.destroy } 9 + 10 + u = User.new 11 + u.email = USER_EMAIL 12 + u.password_hash = Bitwarden.hashPassword(USER_PASSWORD, USER_EMAIL) 13 + u.password_hint = "it's like password but not" 14 + u.key = Bitwarden.makeEncKey(Bitwarden.makeKey(USER_PASSWORD, USER_EMAIL)) 15 + u.save 16 + end 17 + 18 + it "should compare a user's hash" do 19 + u = User.find_by_email(USER_EMAIL) 20 + u.email.must_equal USER_EMAIL 21 + u.has_password_hash?( 22 + Bitwarden.hashPassword(USER_PASSWORD, USER_EMAIL)).must_equal true 23 + 24 + u.has_password_hash?( 25 + Bitwarden.hashPassword(USER_PASSWORD, USER_EMAIL + "2")).wont_equal true 26 + end 27 + 28 + it "encrypts and decrypts user's ciphers" do 29 + u = User.find_by_email(USER_EMAIL) 30 + 31 + mk = Bitwarden.makeKey(USER_PASSWORD, USER_EMAIL) 32 + 33 + c = Cipher.new 34 + c.user_uuid = u.uuid 35 + c.type = Cipher::TYPE_LOGIN 36 + 37 + cdata = { 38 + "Name" => u.encrypt_data_with_master_password_key("some name", mk) 39 + } 40 + 41 + c.data = cdata.to_json 42 + c.migrate_data! 43 + 44 + c = Cipher.last 45 + u.decrypt_data_with_master_password_key(c.to_hash["Name"], mk). 46 + must_equal "some name" 47 + end 48 + 49 + it "supports changing a master password" do 50 + u = User.find_by_email(USER_EMAIL) 51 + 52 + mk = Bitwarden.makeKey(USER_PASSWORD, USER_EMAIL) 53 + 54 + c = Cipher.new 55 + c.user_uuid = u.uuid 56 + c.type = Cipher::TYPE_LOGIN 57 + 58 + cdata = { 59 + "Name" => u.encrypt_data_with_master_password_key("some name", mk) 60 + } 61 + c.data = cdata.to_json 62 + c.migrate_data! 63 + 64 + u.update_master_password(USER_PASSWORD, USER_PASSWORD + "2") 65 + u.save.must_equal true 66 + 67 + post "/identity/connect/token", { 68 + :grant_type => "password", 69 + :username => USER_EMAIL, 70 + :password => Bitwarden.hashPassword(USER_PASSWORD + "2", USER_EMAIL), 71 + :scope => "api offline_access", 72 + :client_id => "browser", 73 + :deviceType => 3, 74 + :deviceIdentifier => SecureRandom.uuid, 75 + :deviceName => "firefox", 76 + :devicePushToken => "" 77 + } 78 + last_response.status.must_equal 200 79 + 80 + mk = Bitwarden.makeKey(USER_PASSWORD + "2", USER_EMAIL) 81 + 82 + c = Cipher.find_by_uuid(c.uuid) 83 + u.decrypt_data_with_master_password_key(c.to_hash["Name"], mk). 84 + must_equal "some name" 85 + end 86 + end
+95
tools/change_master_password.rb
··· 1 + #!/usr/bin/env ruby 2 + # 3 + # Copyright (c) 2018 joshua stein <jcs@jcs.org> 4 + # 5 + # Permission to use, copy, modify, and distribute this software for any 6 + # purpose with or without fee is hereby granted, provided that the above 7 + # copyright notice and this permission notice appear in all copies. 8 + # 9 + # THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + # WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + # MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + # ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + # WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + # ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + # OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 + # 17 + 18 + require File.realpath(File.dirname(__FILE__) + "/../lib/bitwarden_ruby.rb") 19 + require "getoptlong" 20 + 21 + def usage 22 + puts "usage: #{$0} -u user@example.com" 23 + exit 1 24 + end 25 + 26 + username = nil 27 + 28 + begin 29 + GetoptLong.new( 30 + [ "--user", "-u", GetoptLong::REQUIRED_ARGUMENT ], 31 + ).each do |opt,arg| 32 + case opt 33 + when "--user" 34 + username = arg 35 + end 36 + end 37 + 38 + rescue GetoptLong::InvalidOption 39 + usage 40 + end 41 + 42 + if !username 43 + usage 44 + end 45 + 46 + @u = User.find_by_email(username) 47 + if !@u 48 + raise "can't find existing User record for #{username.inspect}" 49 + end 50 + 51 + print "master password for #{@u.email}: " 52 + system("stty -echo") 53 + password = STDIN.gets.chomp 54 + system("stty echo") 55 + print "\n" 56 + 57 + if !@u.has_password_hash?(Bitwarden.hashPassword(password, username)) 58 + raise "master password does not match stored hash" 59 + end 60 + 61 + new_master = nil 62 + new_master_conf = nil 63 + new_master_hint = nil 64 + 65 + while new_master.to_s == "" || (new_master != new_master_conf) 66 + print "new master password: " 67 + system("stty -echo") 68 + new_master = STDIN.gets.chomp 69 + system("stty echo") 70 + print "\n" 71 + 72 + print "new master password (again): " 73 + system("stty -echo") 74 + new_master_conf = STDIN.gets.chomp 75 + system("stty echo") 76 + print "\n" 77 + 78 + if new_master == new_master_conf 79 + print "new master password hint (optional): " 80 + system("stty -echo") 81 + new_master_hint = STDIN.gets.chomp 82 + system("stty echo") 83 + print "\n" 84 + else 85 + puts "error: passwords do not match" 86 + end 87 + end 88 + 89 + @u.update_master_password(password, new_master) 90 + @u.password_hint = new_master_hint 91 + if !@u.save 92 + puts "error saving new password" 93 + end 94 + 95 + puts "master password changed"