···55555656 bundle exec rake test
57575858+### Changing Master Password
5959+6060+Changing a user's master password must be done from the command line (as it
6161+requires interacting with the plaintext password, which the web API will never
6262+do).
6363+6464+ env RACK_ENV=production bundle exec ruby tools/change_master_password.rb -u you@example.com
6565+5866### 1Password Conversion
59676068Export everything from 1Password in its "1Password Interchange Format".
+10-6
lib/bitwarden.rb
···60606161 # encrypt+mac a value with a key and mac key and random iv, return a
6262 # CipherString of it
6363- def encrypt(pt, key, macKey)
6363+ def encrypt(pt, key, macKey = nil)
6464 iv = OpenSSL::Random.random_bytes(16)
65656666 cipher = OpenSSL::Cipher.new "AES-256-CBC"
···7070 ct = cipher.update(pt)
7171 ct << cipher.final
72727373- mac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey,
7474- iv + ct)
7373+ mac = nil
7474+ if macKey
7575+ mac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey,
7676+ iv + ct)
7777+ end
75787679 CipherString.new(
7777- CipherString::TYPE_AESCBC256_HMACSHA256_B64,
8080+ mac ? CipherString::TYPE_AESCBC256_HMACSHA256_B64 :
8181+ CipherString::TYPE_AESCBC256_B64,
7882 Base64.strict_encode64(iv),
7983 Base64.strict_encode64(ct),
8080- Base64.strict_encode64(mac),
8484+ mac ? Base64.strict_encode64(mac) : nil,
8185 )
8286 end
8387···110114 cmac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"),
111115 macKey, iv + ct)
112116 if !self.macsEqual(macKey, mac, cmac)
113113- raise "invalid mac"
117117+ raise "invalid mac #{mac.inspect} != #{cmac.inspect}"
114118 end
115119116120 cipher = OpenSSL::Cipher.new "AES-256-CBC"
+14-3
lib/user.rb
···6262 self.password_hash.timingsafe_equal_to(hash)
6363 end
64646565- # TODO: password_hash=() should update security_stamp when it changes, I
6666- # think
6767-6865 def to_hash
6966 {
7067 "Id" => self.uuid,
···85828683 def two_factor_enabled?
8784 self.totp_secret.present?
8585+ end
8686+8787+ def update_master_password(old_pwd, new_pwd)
8888+ # original random encryption key must be preserved, just re-encrypted with
8989+ # a new key derived from the new password
9090+9191+ orig_key = Bitwarden.decrypt(self.key,
9292+ Bitwarden.makeKey(old_pwd, self.email), nil)
9393+9494+ self.key = Bitwarden.encrypt(orig_key,
9595+ Bitwarden.makeKey(new_pwd, self.email)).to_s
9696+9797+ self.password_hash = Bitwarden.hashPassword(new_pwd, self.email)
9898+ self.security_stamp = SecureRandom.uuid
8899 end
8910090101 def verifies_totp_code?(code)
+86
spec/user_spec.rb
···11+require_relative "spec_helper.rb"
22+33+describe "User" do
44+ USER_EMAIL = "user@example.com"
55+ USER_PASSWORD = "p4ssw0rd"
66+77+ before do
88+ User.all.each{|u| u.destroy }
99+1010+ u = User.new
1111+ u.email = USER_EMAIL
1212+ u.password_hash = Bitwarden.hashPassword(USER_PASSWORD, USER_EMAIL)
1313+ u.password_hint = "it's like password but not"
1414+ u.key = Bitwarden.makeEncKey(Bitwarden.makeKey(USER_PASSWORD, USER_EMAIL))
1515+ u.save
1616+ end
1717+1818+ it "should compare a user's hash" do
1919+ u = User.find_by_email(USER_EMAIL)
2020+ u.email.must_equal USER_EMAIL
2121+ u.has_password_hash?(
2222+ Bitwarden.hashPassword(USER_PASSWORD, USER_EMAIL)).must_equal true
2323+2424+ u.has_password_hash?(
2525+ Bitwarden.hashPassword(USER_PASSWORD, USER_EMAIL + "2")).wont_equal true
2626+ end
2727+2828+ it "encrypts and decrypts user's ciphers" do
2929+ u = User.find_by_email(USER_EMAIL)
3030+3131+ mk = Bitwarden.makeKey(USER_PASSWORD, USER_EMAIL)
3232+3333+ c = Cipher.new
3434+ c.user_uuid = u.uuid
3535+ c.type = Cipher::TYPE_LOGIN
3636+3737+ cdata = {
3838+ "Name" => u.encrypt_data_with_master_password_key("some name", mk)
3939+ }
4040+4141+ c.data = cdata.to_json
4242+ c.migrate_data!
4343+4444+ c = Cipher.last
4545+ u.decrypt_data_with_master_password_key(c.to_hash["Name"], mk).
4646+ must_equal "some name"
4747+ end
4848+4949+ it "supports changing a master password" do
5050+ u = User.find_by_email(USER_EMAIL)
5151+5252+ mk = Bitwarden.makeKey(USER_PASSWORD, USER_EMAIL)
5353+5454+ c = Cipher.new
5555+ c.user_uuid = u.uuid
5656+ c.type = Cipher::TYPE_LOGIN
5757+5858+ cdata = {
5959+ "Name" => u.encrypt_data_with_master_password_key("some name", mk)
6060+ }
6161+ c.data = cdata.to_json
6262+ c.migrate_data!
6363+6464+ u.update_master_password(USER_PASSWORD, USER_PASSWORD + "2")
6565+ u.save.must_equal true
6666+6767+ post "/identity/connect/token", {
6868+ :grant_type => "password",
6969+ :username => USER_EMAIL,
7070+ :password => Bitwarden.hashPassword(USER_PASSWORD + "2", USER_EMAIL),
7171+ :scope => "api offline_access",
7272+ :client_id => "browser",
7373+ :deviceType => 3,
7474+ :deviceIdentifier => SecureRandom.uuid,
7575+ :deviceName => "firefox",
7676+ :devicePushToken => ""
7777+ }
7878+ last_response.status.must_equal 200
7979+8080+ mk = Bitwarden.makeKey(USER_PASSWORD + "2", USER_EMAIL)
8181+8282+ c = Cipher.find_by_uuid(c.uuid)
8383+ u.decrypt_data_with_master_password_key(c.to_hash["Name"], mk).
8484+ must_equal "some name"
8585+ end
8686+end
+95
tools/change_master_password.rb
···11+#!/usr/bin/env ruby
22+#
33+# Copyright (c) 2018 joshua stein <jcs@jcs.org>
44+#
55+# Permission to use, copy, modify, and distribute this software for any
66+# purpose with or without fee is hereby granted, provided that the above
77+# copyright notice and this permission notice appear in all copies.
88+#
99+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
1010+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1111+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1212+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1313+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1414+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1515+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1616+#
1717+1818+require File.realpath(File.dirname(__FILE__) + "/../lib/bitwarden_ruby.rb")
1919+require "getoptlong"
2020+2121+def usage
2222+ puts "usage: #{$0} -u user@example.com"
2323+ exit 1
2424+end
2525+2626+username = nil
2727+2828+begin
2929+ GetoptLong.new(
3030+ [ "--user", "-u", GetoptLong::REQUIRED_ARGUMENT ],
3131+ ).each do |opt,arg|
3232+ case opt
3333+ when "--user"
3434+ username = arg
3535+ end
3636+ end
3737+3838+rescue GetoptLong::InvalidOption
3939+ usage
4040+end
4141+4242+if !username
4343+ usage
4444+end
4545+4646+@u = User.find_by_email(username)
4747+if !@u
4848+ raise "can't find existing User record for #{username.inspect}"
4949+end
5050+5151+print "master password for #{@u.email}: "
5252+system("stty -echo")
5353+password = STDIN.gets.chomp
5454+system("stty echo")
5555+print "\n"
5656+5757+if !@u.has_password_hash?(Bitwarden.hashPassword(password, username))
5858+ raise "master password does not match stored hash"
5959+end
6060+6161+new_master = nil
6262+new_master_conf = nil
6363+new_master_hint = nil
6464+6565+while new_master.to_s == "" || (new_master != new_master_conf)
6666+ print "new master password: "
6767+ system("stty -echo")
6868+ new_master = STDIN.gets.chomp
6969+ system("stty echo")
7070+ print "\n"
7171+7272+ print "new master password (again): "
7373+ system("stty -echo")
7474+ new_master_conf = STDIN.gets.chomp
7575+ system("stty echo")
7676+ print "\n"
7777+7878+ if new_master == new_master_conf
7979+ print "new master password hint (optional): "
8080+ system("stty -echo")
8181+ new_master_hint = STDIN.gets.chomp
8282+ system("stty echo")
8383+ print "\n"
8484+ else
8585+ puts "error: passwords do not match"
8686+ end
8787+end
8888+8989+@u.update_master_password(password, new_master)
9090+@u.password_hint = new_master_hint
9191+if !@u.save
9292+ puts "error saving new password"
9393+end
9494+9595+puts "master password changed"