diff --git a/.travis.yml b/.travis.yml index c0b0820..f01a171 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,4 +2,4 @@ sudo: false language: ruby rvm: - 2.5.0 -before_install: gem install bundler -v 1.16.0 +before_install: gem install bundler -v 1.16.4 diff --git a/Gemfile.lock b/Gemfile.lock index b29fea3..8aa6e7d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,45 +4,45 @@ PATH bitwapi (0.1.0) jwt (~> 1.5, >= 1.5.4) pbkdf2-ruby - rest-client (~> 2.1.0.rc1) + rest-client (~> 2.1.0) GEM remote: https://rubygems.org/ specs: diff-lcs (1.3) - domain_name (0.5.20170404) + domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) http-accept (1.7.0) http-cookie (1.0.3) domain_name (~> 0.5) jwt (1.5.6) - mime-types (3.1) + mime-types (3.2.2) mime-types-data (~> 3.2015) - mime-types-data (3.2016.0521) + mime-types-data (3.2019.0331) netrc (0.11.0) pbkdf2-ruby (0.2.1) rake (10.5.0) - rest-client (2.1.0.rc1) + rest-client (2.1.0) http-accept (>= 1.7.0, < 2.0) http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) - rspec (3.7.0) - rspec-core (~> 3.7.0) - rspec-expectations (~> 3.7.0) - rspec-mocks (~> 3.7.0) - rspec-core (3.7.1) - rspec-support (~> 3.7.0) - rspec-expectations (3.7.0) + rspec (3.8.0) + rspec-core (~> 3.8.0) + rspec-expectations (~> 3.8.0) + rspec-mocks (~> 3.8.0) + rspec-core (3.8.2) + rspec-support (~> 3.8.0) + rspec-expectations (3.8.4) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.7.0) - rspec-mocks (3.7.0) + rspec-support (~> 3.8.0) + rspec-mocks (3.8.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.7.0) - rspec-support (3.7.0) + rspec-support (~> 3.8.0) + rspec-support (3.8.2) unf (0.1.4) unf_ext - unf_ext (0.0.7.4) + unf_ext (0.0.7.6) PLATFORMS ruby @@ -54,4 +54,4 @@ DEPENDENCIES rspec (~> 3.0) BUNDLED WITH - 1.16.0 + 1.16.4 diff --git a/README.md b/README.md index 0107eec..f9b4b22 100644 --- a/README.md +++ b/README.md @@ -33,26 +33,26 @@ api = Bitwapi::API.official Or with your own unofficial Bitwarden-ruby instance: ```ruby require 'bitwapi' -api = Bitwapi.API.unofficial("https://mybitwarden.example.com") +api = Bitwapi::API.unofficial("https://mybitwarden.example.com") ``` ### Register a new account -``` +```ruby # hint and name are optional api.register(email, password, hint:'hint for password', name:'user name') - ``` ### Login a new device -``` +```ruby # device_name is optional (default: bitwapi/version) api.login(email, password, device_name: "my device") ``` You probably shouldn't login a new device each time you want to access your vault (please don't, at least if you are using the official Bitwarden servers. I don't want them to ban this unofficial client because of abuse from your part). Once you have credentials, save them and use them for future access: -``` + +```ruby require 'json' require 'bitwapi' @@ -66,7 +66,6 @@ File.write("mycredentials.json", credentials.to_json) json_credentials = File.read("mycredentials.json") credentials = JSON.parse(json_credentials, symbolize_names: true) api = Bitwapi::API.new(credentials) - ``` Bitwapi automaticaly refresh the access token when needed. You do not have to care about all that. @@ -74,14 +73,15 @@ Bitwapi automaticaly refresh the access token when needed. You do not have to ca ### Get the vault from server -``` +```ruby api = Bitwapi::API.new(credentials) vault = api.get_vault ``` ### Get ciphers from the vault -``` + +```ruby # all ciphers ciphers = vault.ciphers.to_a id = ciphers[0].id diff --git a/bitwapi.gemspec b/bitwapi.gemspec index 8ab58f9..22bbb53 100644 --- a/bitwapi.gemspec +++ b/bitwapi.gemspec @@ -29,7 +29,7 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_dependency "rest-client", "~> 2.1.0.rc1" + spec.add_dependency "rest-client", "~> 2.1.0" spec.add_dependency "pbkdf2-ruby" spec.add_dependency "jwt", '~> 1.5', '>= 1.5.4' diff --git a/lib/bitwapi/api.rb b/lib/bitwapi/api.rb index b3ba197..0331294 100644 --- a/lib/bitwapi/api.rb +++ b/lib/bitwapi/api.rb @@ -31,7 +31,7 @@ def self.official(options={}) def self.unofficial(base, options={}) urls = { base_url: "#{base}/api", - identity_url: "#{base}/identity_api", + identity_url: "#{base}/identity", icons_url: "#{base}/icons", } self.new(urls.merge(default_options).merge(options)) @@ -65,12 +65,14 @@ def credentials } end - def register(email, password, name:nil, hint:nil, access_token:nil) + def register(email, password, name:nil, hint:nil, access_token:nil, kdf:nil, iterations:nil) + kdf ||= Bitwapi::Crypto::PBKDF2_SHA256 + iterations ||= Bitwapi::Crypto::DEFAULT_ITERATIONS[kdf] destination = "#{@base_url}/accounts/register" - internal_key = @crypto.make_master_key(password, email) + internal_key = @crypto.make_master_key(password, email, kdf, iterations) key = @crypto.make_enc_key(internal_key) - master_password_hash = @crypto.hash_password(password, email) - transport.json_post(destination, { + master_password_hash = @crypto.hash_password(password, email, kdf, iterations) + @transport.json_post(destination, { name: name, email: email, masterPasswordHash: master_password_hash, @@ -84,9 +86,21 @@ def generate_identifier SecureRandom.uuid end - def login(email, password, device_type: @device_type, device_identifier: generate_identifier, device_name: @agent_string, device_push_token: "", client_id:"browser", two_factor_provider: nil, two_factor_token: nil, two_factor_remember: 1) + def prelogin(email) + destination = "#{@base_url}/accounts/prelogin" + @transport.json_post(destination, { + email: email + }, { 'Authorization' => "none"}) + end + + def login(email, password, device_type: @device_type, device_identifier: generate_identifier, device_name: @agent_string, device_push_token: "", client_id:"browser", two_factor_provider: nil, two_factor_token: nil, two_factor_remember: 1, kdf_type:nil, kdf_iterations:nil) + if kdf_type.nil? || kdf_iterations.nil? + data = prelogin(email) + kdf_type = data[:Kdf] || Bitwapi::Crypto::PBKDF2_SHA256 + kdf_iterations = data[:KdfIterations] || Bitwapi::Crypto::DEFAULT_ITERATIONS[kdf_type] + end destination = "#{@identity_url}/connect/token" - master_password_hash = @crypto.hash_password(password, email) + master_password_hash = @crypto.hash_password(password, email, kdf_type, kdf_iterations) grant = { grant_type: 'password', username: email, @@ -105,12 +119,13 @@ def login(email, password, device_type: @device_type, device_identifier: generat twoFactorRemember: two_factor_remember, }) end - transport.post(destination, grant, { 'Authorization' => "none"}).tap do |resp| + @transport.post(destination, grant, { 'Authorization' => "none"}).tap do |resp| resp[:expire_at] = get_token_expiration(resp[:access_token]) @access_token = resp[:access_token] @refresh_token = resp[:refresh_token] @expire_at = resp[:expire_at] end + credentials end def get_valid_token @@ -143,7 +158,7 @@ def get_vault def refresh_token destination = "#{@identity_url}/connect/token" - transport.post(destination, { + @transport.post(destination, { "grant_type": "refresh_token", "client_id": "browser", "refresh_token": @refresh_token, @@ -153,8 +168,9 @@ def refresh_token @refresh_token = resp[:refresh_token] @expire_at = resp[:expire_at] end + credentials end end -end \ No newline at end of file +end diff --git a/lib/bitwapi/cipher.rb b/lib/bitwapi/cipher.rb index fd47ddc..c0296a0 100644 --- a/lib/bitwapi/cipher.rb +++ b/lib/bitwapi/cipher.rb @@ -6,13 +6,13 @@ module Bitwapi class Cipher TYPE = nil - ATTRIBUTES = [ ] + ATTRIBUTES = [] - def empty_block - { + def empty_block + { CollectionIds: [], FolderId: nil, - Favorite: true, + Favorite: false, Edit: true, Type: self.class::TYPE, Id: nil, @@ -21,7 +21,7 @@ def empty_block Attachments: nil, OrganizationUseTotp: false, RevisionDate: nil, - Object: "cipherDetails", + Object: "cipher" } end @@ -29,14 +29,14 @@ def empty_data_block { Name: nil, Notes: nil, - Fields: [ ], + Fields: [] }.merge( self.class::ATTRIBUTES.map {|attribute| [attribute, nil] }.to_h ) end def self.attributes(*names) self.const_set(:ATTRIBUTES, names) - names.each do |title| - underscore = title.to_s.gsub(/([A-Z])([A-Z]*[a-z]*)/){"_#{$1.downcase}#{$2}"}[1..-1] + names.each do |title| + underscore = title.to_s.gsub(/([A-Z])([A-Z]*[a-z]*)/) { "_#{$1.downcase}#{$2}" }[1..-1] define_method(underscore.to_sym) { @data[:Data][title.to_sym] } end end @@ -54,7 +54,7 @@ def self.from_encrypted(data, &block) klass.new(data) end - def initialize(data, &block) + def initialize(data) @data = data end @@ -96,7 +96,7 @@ def type end def revision_date - @data[:RevisionDate] ? Time.parse(@data[:RevisionDate]+"Z") : nil + @data[:RevisionDate] ? Time.parse(@data[:RevisionDate]) : nil end def attachments @@ -117,4 +117,4 @@ def fields end -end \ No newline at end of file +end diff --git a/lib/bitwapi/crypto.rb b/lib/bitwapi/crypto.rb index ad101bd..43a5a1d 100644 --- a/lib/bitwapi/crypto.rb +++ b/lib/bitwapi/crypto.rb @@ -22,18 +22,36 @@ class Crypto RSA2048_OAEPSHA256_HMACSHA256_B64 = 5 RSA2048_OAEPSHA1_HMACSHA256_B64 = 6 - def make_master_key(password, email) - make_key(password, email.downcase) + PBKDF2_SHA256 = 0 + DEFAULT_ITERATIONS = { + PBKDF2_SHA256 => 100_000 + }.freeze + ITERATION_RANGES = { + PBKDF2_SHA256 => 5_000..1_000_000 + }.freeze + + def make_master_key(password, email, kdf_type, kdf_iterations) + make_key(password, email.downcase, kdf_type, kdf_iterations) end - def make_key(password, salt) - PBKDF2.new(:password => password, :salt => salt, - :iterations => 5000, :hash_function => OpenSSL::Digest::SHA256, - :key_length => (256/8)).bin_string + def make_key(password, salt, kdf_type, kdf_iterations) + case kdf_type + when PBKDF2_SHA256 + range = ITERATION_RANGES[kdf_type] + unless range.include?(kdf_iterations) + raise "PBKDF2 iterations must be between #{range}" + end + + PBKDF2.new(:password => password, :salt => salt, + :iterations => kdf_iterations, :hash_function => OpenSSL::Digest::SHA256, + :key_length => (256/8)).bin_string + else + raise "unknown kdf type #{kdf_type.inspect}" + end end - def hash_password(password, salt) - key = make_key(password, salt) + def hash_password(password, salt, kdf_type, kdf_iterations) + key = make_master_key(password, salt, kdf_type, kdf_iterations) Base64.strict_encode64(PBKDF2.new(:password => key, :salt => password, :iterations => 1, :key_length => 256/8, :hash_function => OpenSSL::Digest::SHA256).bin_string) @@ -80,7 +98,7 @@ def decrypt(str, key, mac_key=nil) key, mac_key = split_key(key) if mac_key.nil? type, iv, ct, mac = decompose_cipher_string(str) - + case type when AESCBC256_B64, AESCBC256_HMACSHA256_B64 @@ -104,8 +122,8 @@ def decrypt(str, key, mac_key=nil) end end - def decrypted_key(enc_key, email, password) - master_key = make_master_key(password, email) + def decrypted_key(enc_key, email, password, kdf_type, kdf_iterations) + master_key = make_master_key(password, email, kdf_type, kdf_iterations) decrypt(enc_key, master_key, nil) end @@ -133,4 +151,4 @@ def decompose_cipher_string(str) end -end \ No newline at end of file +end diff --git a/lib/bitwapi/vault.rb b/lib/bitwapi/vault.rb index 29f6772..9a0da86 100644 --- a/lib/bitwapi/vault.rb +++ b/lib/bitwapi/vault.rb @@ -14,9 +14,11 @@ def initialize(data, password:nil) unlock!(password) if password end - def unlock!(password) + def unlock!(password, kdf:nil, iterations:nil) + kdf ||= Bitwapi::Crypto::PBKDF2_SHA256 + iterations ||= Bitwapi::Crypto::DEFAULT_ITERATIONS[kdf] email = @data[:Profile][:Email] - @key = @crypto.decrypted_key(@data[:Profile][:Key], email, password) + @key = @crypto.decrypted_key(@data[:Profile][:Key], email, password, kdf, iterations) true end @@ -49,4 +51,4 @@ def decrypt_data(data) end -end \ No newline at end of file +end diff --git a/spec/bitwapi/crypto_spec.rb b/spec/bitwapi/crypto_spec.rb index b6ab64c..5568304 100644 --- a/spec/bitwapi/crypto_spec.rb +++ b/spec/bitwapi/crypto_spec.rb @@ -18,21 +18,23 @@ context "when email: 'nobody@example.com' and password: 'this is a password'" do let(:email) { 'nobody@example.com' } let(:password) { 'this is a password' } - it { expect(Base64.strict_encode64(subject.make_master_key(password, email))).to eq(b64_key) } + let(:kdf) { 0 } + let(:iterations) { 5000 } + it { expect(Base64.strict_encode64(subject.make_master_key(password, email, kdf, iterations))).to eq(b64_key) } context "when a case change in email" do let(:changed_email) { 'NOBODY@example.com' } it "should not change the result" do - previous_result = subject.make_master_key(password, email) - expect(subject.make_master_key(password, changed_email)).to eq(previous_result) + previous_result = subject.make_master_key(password, email, kdf, iterations) + expect(subject.make_master_key(password, changed_email, kdf, iterations)).to eq(previous_result) end end context "when a change in password" do let(:changed_password) { 'this IS a password' } it "should change the result" do - previous_result = subject.make_master_key(password, email) - expect(subject.make_master_key(changed_password, email)).not_to eq(previous_result) + previous_result = subject.make_master_key(password, email, kdf, iterations) + expect(subject.make_master_key(changed_password, email, kdf, iterations)).not_to eq(previous_result) end end end @@ -46,21 +48,23 @@ context "when salt: 'nobody@example.com' and password: 'this is a password'" do let(:salt) { 'nobody@example.com' } let(:password) { 'this is a password' } - it { expect(Base64.strict_encode64(subject.make_key(password, salt))).to eq(b64_key) } + let(:kdf) { 0 } + let(:iterations) { 5000 } + it { expect(Base64.strict_encode64(subject.make_key(password, salt, kdf, iterations))).to eq(b64_key) } context "when a change in salt" do let(:changed_salt) { 'NOBODY@example.com' } it "should change the result" do - previous_result = subject.make_key(password, salt) - expect(subject.make_key(password, changed_salt)).not_to eq(previous_result) + previous_result = subject.make_key(password, salt, kdf, iterations) + expect(subject.make_key(password, changed_salt, kdf, iterations)).not_to eq(previous_result) end end context "when a change in password" do let(:changed_password) { 'this IS a password' } it "should change the result" do - previous_result = subject.make_key(password, salt) - expect(subject.make_key(changed_password, salt)).not_to eq(previous_result) + previous_result = subject.make_key(password, salt, kdf, iterations) + expect(subject.make_key(changed_password, salt, kdf, iterations)).not_to eq(previous_result) end end end @@ -73,21 +77,23 @@ context "when salt: 'user@example.com' and password: 'secret password'" do let(:salt) { 'user@example.com' } let(:password) { 'secret password' } - it { expect(subject.hash_password(password, salt)).to eq(b64_key) } + let(:kdf) { 0 } + let(:iterations) { 5000 } + it { expect(subject.hash_password(password, salt, kdf, iterations)).to eq(b64_key) } context "when a change in salt" do let(:changed_salt) { 'NOBODY@example.com' } it "should change the result" do - previous_result = subject.hash_password(password, salt) - expect(subject.hash_password(password, changed_salt)).not_to eq(previous_result) + previous_result = subject.hash_password(password, salt, kdf, iterations) + expect(subject.hash_password(password, changed_salt, kdf, iterations)).not_to eq(previous_result) end end context "when a change in password" do let(:changed_password) { 'this IS a password' } it "should change the result" do - previous_result = subject.hash_password(password, salt) - expect(subject.hash_password(changed_password, salt)).not_to eq(previous_result) + previous_result = subject.hash_password(password, salt, kdf, iterations) + expect(subject.hash_password(changed_password, salt, kdf, iterations)).not_to eq(previous_result) end end end @@ -95,7 +101,7 @@ end describe "#make_enc_key" do - let(:internal_key) { subject.make_key('this is a password', 'nobody@example.com') } + let(:internal_key) { subject.make_key('this is a password', 'nobody@example.com', 0, 5000) } context "when with internal key derived from #make_key" do it "should be a decodable_cipher_string" do enc_key = subject.make_enc_key(internal_key) @@ -123,18 +129,20 @@ describe "#decrypted_key" do let(:email) { "this.is.me@example.com" } let(:password) { "this is not a good password" } + let(:kdf) { 0 } + let(:iterations) { 5000 } let(:master_key) { Base64.decode64("Lqqg1CvuUp6Lq7LuU3ktpus8FXMSvloTXHnFNLlI8OI=") } let(:encrypted_key) { "0.Ah1dfJ//WjegyKBNl4Ix+A==|CHOvDWcsrHSIHuUj8hcCZpvB5+54BKf4eZbjpyo89p/Ziqcgzmrg2Js4mH9uYlzIZZk0Byc8DhAqJqRFPBfFADFGqZmAcKoFoj3++wav3B0=" } let(:decrypted_key) { Base64.decode64("jahq8PuXjiqJ3v6v//kSVaAwt/hCkhcjifiKVQWaraHz95N2I7Q0mNKbt1mStRvxhPmJGF24ENI020i2FBFuoA==")} - + context "when a new key from 'this.is.me@example.com' and 'this is not a good password'" do it "should be able to recover a decrypted key" do - expect{ subject.decrypted_key(encrypted_key, email, password) }.not_to raise_error + expect{ subject.decrypted_key(encrypted_key, email, password, kdf, iterations) }.not_to raise_error end it "should be of 64 bytes length" do - expect( subject.decrypted_key(encrypted_key, email, password) ).to satisfy {|s| s.bytes.length == 64 } + expect( subject.decrypted_key(encrypted_key, email, password, kdf, iterations) ).to satisfy {|s| s.bytes.length == 64 } end - it { expect( subject.decrypted_key(encrypted_key, email, password) ).to eq(decrypted_key) } + it { expect( subject.decrypted_key(encrypted_key, email, password, kdf, iterations) ).to eq(decrypted_key) } end end @@ -172,7 +180,7 @@ it { expect(subject.compose_cipher_string(type, iv, ct)).to eq(cipherstring) } end - context "with a cipherstring with a mac" do + context "with a cipherstring with a mac" do let(:cipherstring) { "2.ftF0nH3fGtuqVckLZuHGjg==|u0VRhH24uUlVlTZd/uD1lA==|XhBhBGe7or/bXzJRFWLUkFYqauUgxksCrRzNmJyigfw=" } let(:type) { 2 } let(:iv) { Base64.decode64("ftF0nH3fGtuqVckLZuHGjg==") } @@ -212,12 +220,14 @@ context "when email is 'nobody@example.com' and password is 'e3d5.fr'" do let(:email) { 'nobody@example.com'} let(:password) { 'bitwapi API' } + let(:kdf) { 0 } + let(:iterations) { 5000 } let(:text) { 'https://e3d5.fr/'} it "create a key, encrypt, decrypt" do - master_hash = subject.hash_password(password, email) - master_key = subject.make_master_key(password, email) + master_hash = subject.hash_password(password, email, kdf, iterations) + master_key = subject.make_master_key(password, email, kdf, iterations) encrypted_key = subject.make_enc_key(master_key) - decrypted_key = subject.decrypted_key(encrypted_key, email, password) + decrypted_key = subject.decrypted_key(encrypted_key, email, password, kdf, iterations) encrypted_text = subject.encrypt(text, decrypted_key) decrypted_text = subject.decrypt(encrypted_text, decrypted_key) expect(decrypted_text).to eq(text) @@ -241,7 +251,7 @@ end end - context "with a cipherstring with a mac" do + context "with a cipherstring with a mac" do let(:cipherstring) { "2.ftF0nH3fGtuqVckLZuHGjg==|u0VRhH24uUlVlTZd/uD1lA==|XhBhBGe7or/bXzJRFWLUkFYqauUgxksCrRzNmJyigfw=" } let(:type) { 2 } let(:iv) { "ftF0nH3fGtuqVckLZuHGjg==" } @@ -258,4 +268,4 @@ end -end \ No newline at end of file +end