···8899The following notes were made by analyzing traffic between the Firefox
1010extension and the Bitwarden servers by running
1111-[mitm.rb](mitm.rb)
1111+[tools/mitm.rb](mitm.rb)
1212and having the Firefox extension use `http://127.0.0.1:4567/` as its
1313base URL.
1414
···11+Copyright (c) 2017 joshua stein <jcs@jcs.org>
22+33+Permission to use, copy, modify, and distribute this software for any
44+purpose with or without fee is hereby granted, provided that the above
55+copyright notice and this permission notice appear in all copies.
66+77+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
88+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
99+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1010+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1111+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1212+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1313+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+22
README.md
···1010[Bitwarden apps](https://github.com/bitwarden).
11111212Data is stored in a local SQLite database.
1313+This means you can easily run it locally and have your data never leave
1414+your device, or run it on your own web server via Rack and some front-end
1515+HTTP server with TLS to support logging in from multiple devices.
1616+Backing up your data is as easy as copying the `db/production.sqlite3` file
1717+somewhere.
13181419### API Documentation
1520···1924documentation available other than the
2025[.NET Bitwarden code](https://github.com/bitwarden/core)
2126itself.
2727+2828+## Usage
2929+3030+Run `bundle install` at least once.
3131+3232+To run via Rack on port 4567:
3333+3434+ env RAILS_ENV=production bundle exec rackup config.ru
3535+3636+You'll probably want to run it once with signups enabled, to allow yourself
3737+to create an account:
3838+3939+ env RAILS_ENV=production ALLOW_SIGNUPS=1 bundle exec rackup config.ru
4040+4141+Run test suite:
4242+4343+ bundle exec rake test
22442345### License
2446
+5
Rakefile
···11+require "rake/testtask"
22+33+Rake::TestTask.new do |t|
44+ t.pattern = "spec/*_spec.rb"
55+end
-159
bitwarden.rb
···11-#!/usr/bin/env ruby
22-33-APP_ROOT = File.dirname(__FILE__)
44-55-require "sinatra"
66-require "sinatra/namespace"
77-require "cgi"
88-99-require "#{APP_ROOT}/lib/bitwarden.rb"
1010-require "#{APP_ROOT}/lib/helper.rb"
1111-1212-require "#{APP_ROOT}/lib/db.rb"
1313-require "#{APP_ROOT}/lib/dbmodel.rb"
1414-require "#{APP_ROOT}/lib/user.rb"
1515-require "#{APP_ROOT}/lib/device.rb"
1616-1717-ALLOW_SIGNUPS = true
1818-1919-BASE_URL = "/api"
2020-IDENTITY_BASE_URL = "/identity"
2121-ICONS_URL = "/icons"
2222-2323-# create/load JWT signing keys
2424-Bitwarden.load_jwt_keys
2525-2626-# create/update tables
2727-Db.connection
2828-2929-before do
3030- # import JSON params
3131- if request.request_method.upcase == "POST" &&
3232- request.content_type.to_s.match(/^application\/json[$;]/)
3333- params.merge!(JSON.parse(request.body.read))
3434- end
3535-end
3636-3737-namespace IDENTITY_BASE_URL do
3838- # login with a username and password, register/update the device, and get an
3939- # oauth token in response
4040- post "/connect/token" do
4141- content_type :json
4242-4343- need_params(
4444- :client_id,
4545- :grant_type,
4646- :deviceIdentifier,
4747- :deviceName,
4848- :deviceType,
4949- :password,
5050- :scope,
5151- :username,
5252- ) do |p|
5353- return validation_error("#{p} cannot be blank")
5454- end
5555-5656- if params[:grant_type] != "password"
5757- return validation_error("grant type not supported")
5858- end
5959-6060- if params[:scope] != "api offline_access"
6161- return validation_error("scope not supported")
6262- end
6363-6464- u = User.find_by_email(params[:username])
6565- if !u
6666- return validation_error("Invalid username")
6767- end
6868-6969- if !u.has_password_hash?(params[:password])
7070- return validation_error("Invalid password")
7171- end
7272-7373- if u.totp_secret.present?
7474- if params[:twoFactorToken].blank? ||
7575- !u.verifies_totp_code?(params[:twoFactorToken])
7676- return [ 400, {
7777- "error" => "invalid_grant",
7878- "error_description" => "Two factor required.",
7979- "TwoFactorProviders" => [ 0 ], # authenticator
8080- "TwoFactorProviders2" => { "0" => nil }
8181- }.to_json ]
8282- end
8383- end
8484-8585- d = Device.find_by_device_uuid(params[:deviceIdentifier])
8686- if d && d.user_id != u.id
8787- # wat
8888- d.destroy
8989- d = nil
9090- end
9191-9292- if !d
9393- d = Device.new
9494- d.user_id = u.id
9595- d.device_uuid = params[:deviceIdentifier]
9696- end
9797-9898- d.device_type = params[:deviceType]
9999- d.name = params[:deviceName]
100100- d.device_push_token = params[:devicePushToken]
101101-102102- d.generate_tokens!
103103-104104- User.transaction do
105105- if d.save
106106- return tee({
107107- :access_token => d.access_token,
108108- :expires_in => (d.token_expiry - Time.now.to_i),
109109- :token_type => "Bearer",
110110- :refresh_token => d.refresh_token,
111111- :Key => d.user.key,
112112- # TODO: :privateKey and :TwoFactorToken
113113- }.to_json)
114114- else
115115- return validation_error("Unknown error")
116116- end
117117- end
118118- end
119119-end
120120-121121-namespace BASE_URL do
122122- # create a new user
123123- post "/accounts/register" do
124124- content_type :json
125125-126126- if !ALLOW_SIGNUPS
127127- return validation_error("Signups are not permitted")
128128- end
129129-130130- need_params(:masterPasswordHash) do |p|
131131- return validation_error("#{p} cannot be blank")
132132- end
133133-134134- if !params[:email].to_s.match(/^.+@.+\..+$/)
135135- return validation_error("Invalid e-mail address")
136136- end
137137-138138- if !params[:key].to_s.match(/^0\..+\|.+/)
139139- return validation_error("Invalid key")
140140- end
141141-142142- User.transaction do
143143- if User.find_by_email(params[:email])
144144- return validation_error("E-mail is already in use")
145145- end
146146-147147- u = User.new
148148- u.email = params[:email]
149149- u.password_hash = params[:masterPasswordHash]
150150- u.key = params[:key]
151151-152152- if u.save
153153- return ""
154154- else
155155- return validation_error("User save failed")
156156- end
157157- end
158158- end
159159-end
+20
config.ru
···11+#
22+# Copyright (c) 2017 joshua stein <jcs@jcs.org>
33+#
44+# Permission to use, copy, modify, and distribute this software for any
55+# purpose with or without fee is hereby granted, provided that the above
66+# copyright notice and this permission notice appear in all copies.
77+#
88+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
99+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1010+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1111+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1212+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1313+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1414+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1515+#
1616+1717+require File.dirname(__FILE__) + "/lib/bitwarden_ruby.rb"
1818+require "#{APP_ROOT}/lib/api.rb"
1919+2020+run Sinatra::Application
db/.gitkeep
This is a binary file and will not be displayed.
+331
lib/api.rb
···11+#
22+# Copyright (c) 2017 joshua stein <jcs@jcs.org>
33+#
44+# Permission to use, copy, modify, and distribute this software for any
55+# purpose with or without fee is hereby granted, provided that the above
66+# copyright notice and this permission notice appear in all copies.
77+#
88+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
99+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1010+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1111+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1212+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1313+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1414+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1515+#
1616+1717+#
1818+# helper methods
1919+#
2020+2121+def device_from_bearer
2222+ if m = request.env["HTTP_AUTHORIZATION"].to_s.match(/^Bearer (.+)/)
2323+ token = m[1]
2424+ if (d = Device.find_by_access_token(token))
2525+ if d.token_expires_at >= Time.now
2626+ return d
2727+ end
2828+ end
2929+ end
3030+3131+ nil
3232+end
3333+3434+def need_params(*ps)
3535+ ps.each do |p|
3636+ if params[p].to_s.blank?
3737+ yield(p)
3838+ end
3939+ end
4040+end
4141+4242+def validation_error(msg)
4343+ [ 400, {
4444+ "ValidationErrors" => { "" => [
4545+ msg,
4646+ ]},
4747+ "Object" => "error",
4848+ }.to_json ]
4949+end
5050+5151+#
5252+# begin sinatra routing
5353+#
5454+5555+# import JSON params for every request
5656+before do
5757+ if request.content_type.to_s.match(/\Aapplication\/json(;|\z)/)
5858+ js = request.body.read.to_s
5959+ if !js.strip.blank?
6060+ params.merge!(JSON.parse(js))
6161+ end
6262+ end
6363+6464+ # we're always going to reply with json
6565+ content_type :json
6666+end
6767+6868+namespace IDENTITY_BASE_URL do
6969+ # depending on grant_type:
7070+ # password: login with a username/password, register/update the device
7171+ # refresh_token: just generate a new access_token
7272+ # respond with an access_token and refresh_token
7373+ post "/connect/token" do
7474+ d = nil
7575+7676+ case params[:grant_type]
7777+ when "refresh_token"
7878+ need_params(:refresh_token) do |p|
7979+ return validation_error("#{p} cannot be blank")
8080+ end
8181+8282+ d = Device.find_by_refresh_token(params[:refresh_token])
8383+ if !d
8484+ return validation_error("Invalid refresh token")
8585+ end
8686+8787+ when "password"
8888+ need_params(
8989+ :client_id,
9090+ :grant_type,
9191+ :deviceIdentifier,
9292+ :deviceName,
9393+ :deviceType,
9494+ :password,
9595+ :scope,
9696+ :username,
9797+ ) do |p|
9898+ return validation_error("#{p} cannot be blank")
9999+ end
100100+101101+ if params[:scope] != "api offline_access"
102102+ return validation_error("scope not supported")
103103+ end
104104+105105+ u = User.find_by_email(params[:username])
106106+ if !u
107107+ return validation_error("Invalid username")
108108+ end
109109+110110+ if !u.has_password_hash?(params[:password])
111111+ return validation_error("Invalid password")
112112+ end
113113+114114+ if u.two_factor_enabled? &&
115115+ (params[:twoFactorToken].blank? ||
116116+ !u.verifies_totp_code?(params[:twoFactorToken]))
117117+ return [ 400, {
118118+ "error" => "invalid_grant",
119119+ "error_description" => "Two factor required.",
120120+ "TwoFactorProviders" => [ 0 ], # authenticator
121121+ "TwoFactorProviders2" => { "0" => nil }
122122+ }.to_json ]
123123+ end
124124+125125+ d = Device.find_by_uuid(params[:deviceIdentifier])
126126+ if d && d.user_uuid != u.uuid
127127+ # wat
128128+ d.destroy
129129+ d = nil
130130+ end
131131+132132+ if !d
133133+ d = Device.new
134134+ d.user_uuid = u.uuid
135135+ d.uuid = params[:deviceIdentifier]
136136+ end
137137+138138+ d.type = params[:deviceType]
139139+ d.name = params[:deviceName]
140140+ if params[:devicePushToken].present?
141141+ d.push_token = params[:devicePushToken]
142142+ end
143143+ else
144144+ return validation_error("grant type not supported")
145145+ end
146146+147147+ d.regenerate_tokens!
148148+149149+ User.transaction do
150150+ if !d.save
151151+ return validation_error("Unknown error")
152152+ end
153153+154154+ {
155155+ :access_token => d.access_token,
156156+ :expires_in => (d.token_expires_at - Time.now).floor,
157157+ :token_type => "Bearer",
158158+ :refresh_token => d.refresh_token,
159159+ :Key => d.user.key,
160160+ # TODO: when to include :privateKey and :TwoFactorToken?
161161+ }.to_json
162162+ end
163163+ end
164164+end
165165+166166+namespace BASE_URL do
167167+ # create a new user
168168+ post "/accounts/register" do
169169+ content_type :json
170170+171171+ if !ALLOW_SIGNUPS
172172+ return validation_error("Signups are not permitted")
173173+ end
174174+175175+ need_params(:masterPasswordHash) do |p|
176176+ return validation_error("#{p} cannot be blank")
177177+ end
178178+179179+ if !params[:email].to_s.match(/^.+@.+\..+$/)
180180+ return validation_error("Invalid e-mail address")
181181+ end
182182+183183+ if !params[:key].to_s.match(/^0\..+\|.+/)
184184+ return validation_error("Invalid key")
185185+ end
186186+187187+ begin
188188+ if !Bitwarden::CipherString.parse(params[:key])
189189+ raise
190190+ end
191191+ rescue
192192+ return validation_error("Invalid key")
193193+ end
194194+195195+ User.transaction do
196196+ params[:email].downcase!
197197+198198+ if User.find_by_email(params[:email])
199199+ return validation_error("E-mail is already in use")
200200+ end
201201+202202+ u = User.new
203203+ u.email = params[:email]
204204+ u.password_hash = params[:masterPasswordHash]
205205+ u.password_hint = params[:masterPasswordHint]
206206+ u.key = params[:key]
207207+208208+ # is this supposed to come from somewhere?
209209+ u.culture = "en-US"
210210+211211+ # i am a fair and just god
212212+ u.premium = true
213213+214214+ if !u.save
215215+ return validation_error("User save failed")
216216+ end
217217+218218+ ""
219219+ end
220220+ end
221221+222222+ # fetch profile and ciphers
223223+ get "/sync" do
224224+ d = device_from_bearer
225225+ if !d
226226+ return validation_error("invalid bearer")
227227+ end
228228+229229+ {
230230+ "Profile" => d.user.to_hash,
231231+ "Folders" => [],
232232+ "Ciphers" => d.user.ciphers.map{|c|
233233+ c.to_hash
234234+ },
235235+ "Domains" => {
236236+ "Object" => "domains"
237237+ },
238238+ "Object" => "sync"
239239+ }.to_json
240240+ end
241241+242242+ # create a new cipher
243243+ post "/ciphers" do
244244+ d = device_from_bearer
245245+ if !d
246246+ return validation_error("invalid bearer")
247247+ end
248248+249249+ need_params(:type, :name) do |p|
250250+ return validation_error("#{p} cannot be blank")
251251+ end
252252+253253+ c = Cipher.new
254254+ c.user_uuid = d.user_uuid
255255+ c.update_from_params(params)
256256+257257+ Cipher.transaction do
258258+ if !c.save
259259+ return validation_error("error saving")
260260+ end
261261+262262+ c.to_hash.merge({
263263+ "Edit" => true,
264264+ }).to_json
265265+ end
266266+ end
267267+268268+ # update a cipher
269269+ put "/ciphers/:uuid" do
270270+ d = device_from_bearer
271271+ if !d
272272+ return validation_error("invalid bearer")
273273+ end
274274+275275+ c = nil
276276+ if params[:uuid].blank? || !(c = Cipher.find_by_uuid(params[:uuid]))
277277+ return validation_error("invalid cipher")
278278+ end
279279+280280+ if c.user_uuid != d.user_uuid
281281+ return validation_error("invalid cipher")
282282+ end
283283+284284+ need_params(:type, :name) do |p|
285285+ return validation_error("#{p} cannot be blank")
286286+ end
287287+288288+ c.update_from_params(params)
289289+290290+ Cipher.transaction do
291291+ if !c.save
292292+ return validation_error("error saving")
293293+ end
294294+295295+ c.to_hash.merge({
296296+ "Edit" => true,
297297+ }).to_json
298298+ end
299299+ end
300300+301301+ # delete a cipher
302302+ delete "/ciphers/:uuid" do
303303+ d = device_from_bearer
304304+ if !d
305305+ return validation_error("invalid bearer")
306306+ end
307307+308308+ c = nil
309309+ if params[:uuid].blank? || !(c = Cipher.find_by_uuid(params[:uuid]))
310310+ return validation_error("invalid cipher")
311311+ end
312312+313313+ if c.user_uuid != d.user_uuid
314314+ return validation_error("invalid cipher")
315315+ end
316316+317317+ c.destroy
318318+319319+ ""
320320+ end
321321+end
322322+323323+namespace ICONS_URL do
324324+ get "/icons/:domain/icon.png" do
325325+ content_type "image/x-icon"
326326+327327+ # TODO
328328+329329+ ""
330330+ end
331331+end
+167-13
lib/bitwarden.rb
···11+#
22+# Copyright (c) 2017 joshua stein <jcs@jcs.org>
33+#
44+# Permission to use, copy, modify, and distribute this software for any
55+# purpose with or without fee is hereby granted, provided that the above
66+# copyright notice and this permission notice appear in all copies.
77+#
88+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
99+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1010+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1111+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1212+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1313+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1414+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1515+#
1616+117require "jwt"
1818+require "pbkdf2"
1919+require "openssl"
220321class Bitwarden
2222+ # convenience methods for hashing/encryption/decryption that the apps do,
2323+ # just so we can test against
424 class << self
55- attr_reader :jwt_rsa
2525+ # pbkdf2 stretch a password+salt
2626+ def makeKey(password, salt)
2727+ PBKDF2.new(:password => password, :salt => salt,
2828+ :iterations => 5000, :hash_function => OpenSSL::Digest::SHA256,
2929+ :key_length => (256 / 8)).bin_string
3030+ end
63177- JWT_KEY = "#{APP_ROOT}/jwt-rsa.key"
3232+ # encrypt random bytes with a key to make new encryption key
3333+ def makeEncKey(key)
3434+ pt = OpenSSL::Random.random_bytes(64)
3535+ iv = OpenSSL::Random.random_bytes(16)
83699- # load or create RSA pair used for JWT signing
1010- def load_jwt_keys
1111- if File.exists?(JWT_KEY)
1212- @jwt_rsa = OpenSSL::PKey::RSA.new File.read(JWT_KEY)
3737+ cipher = OpenSSL::Cipher.new "AES-256-CBC"
3838+ cipher.encrypt
3939+ cipher.key = key
4040+ cipher.iv = iv
4141+ ct = cipher.update(pt)
4242+ ct << cipher.final
4343+4444+ CipherString.new(
4545+ CipherString::TYPE_AESCBC256_B64,
4646+ Base64.strict_encode64(iv),
4747+ Base64.strict_encode64(ct),
4848+ ).to_s
4949+ end
5050+5151+ # base64-encode a wrapped, stretched password+salt for signup/login
5252+ def hashPassword(password, salt)
5353+ key = makeKey(password, salt)
5454+ Base64.strict_encode64(PBKDF2.new(:password => key, :salt => password,
5555+ :iterations => 1, :key_length => 256/8,
5656+ :hash_function => OpenSSL::Digest::SHA256).bin_string)
5757+ end
5858+5959+ # encrypt+mac a value with a key and mac key and random iv, return a
6060+ # CipherString of it
6161+ def encrypt(pt, key, macKey)
6262+ iv = OpenSSL::Random.random_bytes(16)
6363+6464+ cipher = OpenSSL::Cipher.new "AES-256-CBC"
6565+ cipher.encrypt
6666+ cipher.key = key
6767+ cipher.iv = iv
6868+ ct = cipher.update(pt)
6969+ ct << cipher.final
7070+7171+ mac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey,
7272+ iv + ct)
7373+7474+ CipherString.new(
7575+ CipherString::TYPE_AESCBC256_HMACSHA256_B64,
7676+ Base64.strict_encode64(iv),
7777+ Base64.strict_encode64(ct),
7878+ Base64.strict_encode64(mac),
7979+ )
8080+ end
8181+8282+ # compare two hmacs, with double hmac verification
8383+ # https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2011/february/double-hmac-verification/
8484+ def macsEqual(macKey, mac1, mac2)
8585+ hmac1 = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, mac1)
8686+ hmac2 = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, mac2)
8787+ return hmac1 == hmac2
8888+ end
8989+9090+ # decrypt a CipherString and return plaintext
9191+ def decrypt(str, key, macKey)
9292+ c = CipherString.parse(str)
9393+9494+ case c.type
9595+ when CipherString::TYPE_AESCBC256_HMACSHA256_B64
9696+ iv = Base64.decode64(c.iv)
9797+ ct = Base64.decode64(c.ct)
9898+ mac = Base64.decode64(c.mac)
9999+100100+ cmac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"),
101101+ macKey, iv + ct)
102102+ if !self.macsEqual(macKey, mac, cmac)
103103+ raise "invalid mac"
104104+ end
105105+106106+ cipher = OpenSSL::Cipher.new "AES-256-CBC"
107107+ cipher.decrypt
108108+ cipher.iv = iv
109109+ cipher.key = key
110110+ pt = cipher.update(ct)
111111+ pt << cipher.final
112112+ return pt
113113+13114 else
1414- @jwt_rsa = OpenSSL::PKey::RSA.generate 2048
115115+ raise "TODO implement #{c.type}"
116116+ end
117117+ end
118118+ end
151191616- f = File.new(JWT_KEY, File::CREAT|File::TRUNC|File::RDWR, 0600)
1717- f.write @jwt_rsa.to_pem
1818- f.write @jwt_rsa.public_key.to_pem
1919- f.close
120120+ class CipherString
121121+ TYPE_AESCBC256_B64 = 0
122122+ TYPE_AESCBC128_HMACSHA256_B64 = 1
123123+ TYPE_AESCBC256_HMACSHA256_B64 = 2
124124+ TYPE_RSA2048_OAEPSHA256_B64 = 3
125125+ TYPE_RSA2048_OAEPSHA1_B64 = 4
126126+ TYPE_RSA2048_OAEPSHA256_HMACSHA256_B64 = 5
127127+ TYPE_RSA2048_OAEPSHA1_HMACSHA256_B64 = 6
128128+129129+ attr_reader :type, :iv, :ct, :mac
130130+131131+ def self.parse(str)
132132+ if !(m = str.to_s.match(/\A(\d)\.([^|]+)\|(.+)\z/))
133133+ raise "invalid CipherString: #{str.inspect}"
20134 end
135135+136136+ type = m[1].to_i
137137+ iv = m[2]
138138+ ct, mac = m[3].split("|", 2)
139139+ CipherString.new(type, iv, ct, mac)
21140 end
221412323- def jwt_sign(payload)
2424- JWT.encode(payload, @jwt_rsa, "RS256")
142142+ def initialize(type, iv, ct, mac = nil)
143143+ @type = type
144144+ @iv = iv
145145+ @ct = ct
146146+ @mac = mac
147147+ end
148148+149149+ def to_s
150150+ [ self.type.to_s + "." + self.iv, self.ct, self.mac ].
151151+ reject{|p| !p }.
152152+ join("|")
153153+ end
154154+ end
155155+156156+ class Token
157157+ class << self
158158+ KEY = "#{APP_ROOT}/jwt-rsa.key"
159159+160160+ attr_reader :rsa
161161+162162+ # load or create RSA pair used for JWT signing
163163+ def load_keys
164164+ if File.exists?(KEY)
165165+ @rsa = OpenSSL::PKey::RSA.new File.read(KEY)
166166+ else
167167+ @rsa = OpenSSL::PKey::RSA.generate 2048
168168+169169+ f = File.new(KEY, File::CREAT|File::TRUNC|File::RDWR, 0600)
170170+ f.write @rsa.to_pem
171171+ f.write @rsa.public_key.to_pem
172172+ f.close
173173+ end
174174+ end
175175+176176+ def sign(payload)
177177+ JWT.encode(payload, @rsa, "RS256")
178178+ end
25179 end
26180 end
27181end
+49
lib/bitwarden_ruby.rb
···11+#
22+# Copyright (c) 2017 joshua stein <jcs@jcs.org>
33+#
44+# Permission to use, copy, modify, and distribute this software for any
55+# purpose with or without fee is hereby granted, provided that the above
66+# copyright notice and this permission notice appear in all copies.
77+#
88+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
99+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1010+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1111+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1212+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1313+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1414+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1515+#
1616+1717+Encoding.default_internal = Encoding.default_external = Encoding::UTF_8
1818+1919+APP_ROOT = File.realpath(File.dirname(__FILE__) + "/../")
2020+2121+RACK_ENV ||= (ENV["RACK_ENV"] || "development")
2222+2323+require "sinatra"
2424+require "sinatra/namespace"
2525+require "cgi"
2626+2727+require "#{APP_ROOT}/lib/bitwarden.rb"
2828+require "#{APP_ROOT}/lib/helper.rb"
2929+3030+require "#{APP_ROOT}/lib/db.rb"
3131+require "#{APP_ROOT}/lib/dbmodel.rb"
3232+require "#{APP_ROOT}/lib/user.rb"
3333+require "#{APP_ROOT}/lib/device.rb"
3434+require "#{APP_ROOT}/lib/cipher.rb"
3535+3636+BASE_URL ||= "/api"
3737+IDENTITY_BASE_URL ||= "/identity"
3838+ICONS_URL ||= "/icons"
3939+4040+# whether to allow new users
4141+if !defined?(ALLOW_SIGNUPS)
4242+ ALLOW_SIGNUPS = (ENV["ALLOW_SIGNUPS"] || false)
4343+end
4444+4545+# create/load JWT signing keys
4646+Bitwarden::Token.load_keys
4747+4848+# create/update tables
4949+Db.connect("#{APP_ROOT}/db/#{RACK_ENV}.sqlite3")
+84
lib/cipher.rb
···11+#
22+# Copyright (c) 2017 joshua stein <jcs@jcs.org>
33+#
44+# Permission to use, copy, modify, and distribute this software for any
55+# purpose with or without fee is hereby granted, provided that the above
66+# copyright notice and this permission notice appear in all copies.
77+#
88+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
99+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1010+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1111+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1212+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1313+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1414+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1515+#
1616+1717+class Cipher < DBModel
1818+ set_table_name "ciphers"
1919+ set_primary_key "uuid"
2020+2121+ attr_writer :user
2222+2323+ TYPE_LOGIN = 1
2424+ TYPE_NOTE = 2
2525+ TYPE_CARD = 3
2626+2727+ def to_hash
2828+ {
2929+ "Id" => self.uuid,
3030+ "Type" => self.type,
3131+ "RevisionDate" => self.updated_at.strftime("%Y-%m-%dT%H:%M:%S.000000Z"),
3232+ "FolderId" => self.folder_uuid,
3333+ "Favorite" => self.favorite,
3434+ "OrganizationId" => nil,
3535+ "Attachments" => self.attachments,
3636+ "OrganizationUseTotp" => false,
3737+ "Data" => JSON.parse(self.data.to_s),
3838+ "Object" => "cipher",
3939+ }
4040+ end
4141+4242+ def update_from_params(params)
4343+ self.folder_uuid = params[:folderId]
4444+ self.organization_uuid = params[:organizationId]
4545+ self.favorite = params[:favorite]
4646+ self.type = params[:type].to_i
4747+4848+ cdata = {
4949+ "Name" => params[:name]
5050+ }
5151+5252+ case self.type
5353+ when TYPE_LOGIN
5454+ params[:login].each do |k,v|
5555+ cdata[k.to_s.ucfirst] = v
5656+ end
5757+5858+ when TYPE_CARD
5959+ params[:card].each do |k,v|
6060+ cdata[k.to_s.ucfirst] = v
6161+ end
6262+ end
6363+6464+ cdata["Notes"] = params[:notes]
6565+6666+ if params[:fields] && params[:fields].is_a?(Array)
6767+ cdata["Fields"] = params[:fields].map{|f|
6868+ fh = {}
6969+ f.each do |k,v|
7070+ fh[k.ucfirst] = v
7171+ end
7272+ fh
7373+ }
7474+ else
7575+ cdata["Fields"] = nil
7676+ end
7777+7878+ self.data = cdata.to_json
7979+ end
8080+8181+ def user
8282+ @user ||= User.find_by_uuid(self.user_uuid)
8383+ end
8484+end
+112-47
lib/db.rb
···11+#
22+# Copyright (c) 2017 joshua stein <jcs@jcs.org>
33+#
44+# Permission to use, copy, modify, and distribute this software for any
55+# purpose with or without fee is hereby granted, provided that the above
66+# copyright notice and this permission notice appear in all copies.
77+#
88+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
99+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1010+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1111+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1212+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1313+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1414+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1515+#
1616+117require "sqlite3"
218319class Db
44- @@db = nil
2020+ class << self
2121+ attr_reader :db, :db_file
52266- def self.db_file
77- "#{APP_ROOT}/db.sqlite3"
88- end
2323+ def connect(db_file)
2424+ @db_file = db_file
2525+2626+ @db = SQLite3::Database.new(@db_file)
2727+2828+ @db.execute("
2929+ CREATE TABLE IF NOT EXISTS
3030+ users
3131+ (uuid STRING PRIMARY KEY,
3232+ created_at DATETIME,
3333+ updated_at DATETIME,
3434+ email TEXT UNIQUE,
3535+ email_verified BOOLEAN,
3636+ premium BOOLEAN,
3737+ name TEXT,
3838+ password_hash TEXT,
3939+ password_hint TEXT,
4040+ key TEXT,
4141+ private_key BLOB,
4242+ public_key BLOB,
4343+ totp_secret STRING,
4444+ security_stamp STRING,
4545+ culture STRING)
4646+ ")
4747+4848+ @db.execute("
4949+ CREATE TABLE IF NOT EXISTS
5050+ devices
5151+ (uuid STRING PRIMARY KEY,
5252+ created_at DATETIME,
5353+ updated_at DATETIME,
5454+ user_uuid STRING,
5555+ name STRING,
5656+ type INTEGER,
5757+ push_token STRING UNIQUE,
5858+ access_token STRING UNIQUE,
5959+ refresh_token STRING UNIQUE,
6060+ token_expires_at DATETIME)
6161+ ")
6262+6363+ @db.execute("
6464+ CREATE TABLE IF NOT EXISTS
6565+ ciphers
6666+ (uuid STRING PRIMARY KEY,
6767+ created_at DATETIME,
6868+ updated_at DATETIME,
6969+ user_uuid STRING,
7070+ folder_uuid STRING,
7171+ organization_uuid STRING,
7272+ type INTEGER,
7373+ data BLOB,
7474+ favorite BOOLEAN,
7575+ attachments BLOB)
7676+ ")
7777+7878+ @db.results_as_hash = true
7979+8080+ @db.execute("
8181+ CREATE TABLE IF NOT EXISTS
8282+ schema_version
8383+ (version INTEGER)
8484+ ")
9851010- def self.connection
1111- if @@db
1212- return @@db
1313- end
8686+ v = @db.execute("SELECT version FROM schema_version").first
8787+ if !v
8888+ v = { "version" => 0 }
8989+ end
14901515- @@db = SQLite3::Database.new(self.db_file)
9191+ case v["version"]
9292+ when 0
9393+ @db.execute("INSERT INTO schema_version (version) VALUES (1)")
9494+ end
16951717- @@db.execute("
1818- CREATE TABLE IF NOT EXISTS
1919- users
2020- (id INTEGER PRIMARY KEY ASC,
2121- email TEXT UNIQUE,
2222- name TEXT,
2323- password_hash TEXT,
2424- key TEXT,
2525- totp_secret STRING,
2626- security_stamp STRING,
2727- culture STRING)
2828- ")
9696+ # eagerly cache column definitions
9797+ ObjectSpace.each_object(Class).each do |klass|
9898+ if klass < DBModel
9999+ klass.fetch_columns
100100+ end
101101+ end
291023030- @@db.execute("
3131- CREATE TABLE IF NOT EXISTS
3232- devices
3333- (id INTEGER PRIMARY KEY ASC,
3434- device_uuid STRING UNIQUE,
3535- user_id INTEGER,
3636- name STRING,
3737- device_type INTEGER,
3838- device_push_token STRING,
3939- access_token STRING UNIQUE,
4040- refresh_token STRING UNIQUE,
4141- token_expiry INTEGER)
4242- ")
103103+ @db
104104+ end
431054444- @@db.execute("
4545- CREATE TABLE IF NOT EXISTS
4646- ciphers
4747- (id INTEGER PRIMARY KEY ASC,
4848- cipher_uuid STRING UNIQUE,
4949- updated_at INTEGER,
5050- user_id INTEGER,
5151- data STRING,
5252- cipher_type INTEGER,
5353- cipher_attachments STRING)
5454- ")
106106+ def connection
107107+ @db
108108+ end
551095656- @@db.results_as_hash = true
110110+ def execute(query, params = [])
111111+ caster = proc{|a|
112112+ if a.is_a?(String) && a.encoding != Encoding::BINARY
113113+ a = a.encode(Encoding::UTF_8)
114114+ elsif a.is_a?(TrueClass) || a.is_a?(FalseClass)
115115+ a = (a ? 1 : 0)
116116+ end
117117+ a
118118+ }
571195858- @@db
120120+ self.connection.execute(*[
121121+ caster.call(query), params.map{|a| caster.call(a) }
122122+ ])
123123+ end
59124 end
60125end
+194-42
lib/dbmodel.rb
···11+#
22+# Copyright (c) 2017 joshua stein <jcs@jcs.org>
33+#
44+# Permission to use, copy, modify, and distribute this software for any
55+# purpose with or without fee is hereby granted, provided that the above
66+# copyright notice and this permission notice appear in all copies.
77+#
88+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
99+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1010+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1111+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1212+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1313+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1414+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1515+#
1616+117class DBModel
218 class << self
33- attr_accessor :table_name, :table_attrs
1919+ attr_reader :table_name, :columns, :primary_key
2020+2121+ def method_missing(method, *args, &block)
2222+ if m = method.to_s.match(/^find_by_(.+)/)
2323+ return find_by_column(m[1], args[0])
2424+ elsif m = method.to_s.match(/^find_all_by_(.+)/)
2525+ return find_all_by_column(m[1], args[0])
2626+ else
2727+ super
2828+ end
2929+ end
3030+3131+ # transform ruby data into sql
3232+ def cast_data_for_column(data, col)
3333+ if !@columns
3434+ raise "need to fetch columns but in a query"
3535+ end
43655- def set_table_name(table)
66- @table_name = table
3737+ case @columns[col][:type]
3838+ when /boolean/i
3939+ return data == true ? 1 : 0
4040+ when /datetime/i
4141+ return (data == nil ? nil : data.to_i)
4242+ when /integer/i
4343+ return (data == nil ? nil : data.to_i)
4444+ else
4545+ return data
4646+ end
4747+ end
4848+4949+ def fetch_columns
5050+ return if @columns
5151+5252+ @columns = {}
5353+5454+ Db.execute("SELECT sql FROM sqlite_master WHERE tbl_name = ?",
5555+ [ self.table_name ]).first["sql"].
5656+ gsub("\n", " ").
5757+ gsub(/^\s*CREATE\s+TABLE\s+#{self.table_name}\s*\(/i, "").
5858+ split(",").
5959+ each do |f|
6060+ if !(m = f.match(/^\s*([A-Za-z0-9_-]+)\s+([A-Za-z ]+)/))
6161+ raise "can't parse column definition #{f.inspect}"
6262+ end
6363+6464+ @columns[m[1]] = {
6565+ :type => m[2].strip.upcase,
6666+ }
6767+ end
6868+6969+ attr_accessor *(columns.keys)
7070+ end
7171+7272+ def find_all_by_column(column, value, limit = nil)
7373+ fetch_columns
7474+7575+ Db.execute("SELECT * FROM `#{table_name}` WHERE " <<
7676+ "`#{column}` = ? #{limit}", [ value.encode("utf-8") ]).map do |rec|
7777+ obj = self.new
7878+ obj.new_record = false
7979+8080+ rec.each do |k,v|
8181+ next if !k.is_a?(String)
8282+ obj.send("#{k}=", uncast_data_from_column(v, k))
8383+ end
8484+8585+ obj
8686+ end
8787+ end
8888+8989+ def find_by_column(column, value)
9090+ find_all_by_column(column, value, "LIMIT 1").first
9191+ end
9292+9393+ def primary_key
9494+ @primary_key || "id"
9595+ end
9696+9797+ def primary_key_uuid?
9898+ !!primary_key.match(/uuid$/)
9999+ end
100100+101101+ def set_primary_key(col)
102102+ @primary_key = col
7103 end
81049105 def set_table_attrs(attrs)
10106 @table_attrs = attrs
11107 attr_accessor *attrs
12108 end
1313- end
141091515- def self.method_missing(method, *args, &block)
1616- if m = method.to_s.match(/^find_by_(.+)/)
1717- return self.find_by_column(m[1], args[0])
1818- elsif m = method.to_s.match(/^find_all_by_(.+)/)
1919- return self.find_all_by_column(m[1], args[0])
2020- else
2121- super
110110+ def set_table_name(table)
111111+ @table_name = table
22112 end
2323- end
241132525- def self.find_by_column(column, value)
2626- self.find_all_by_column(column, value, "LIMIT 1").first
2727- end
114114+ # transform database data into ruby
115115+ def uncast_data_from_column(data, col)
116116+ if !@columns
117117+ raise "need to fetch columns but in a query"
118118+ end
281192929- def self.find_all_by_column(column, value, limit = nil)
3030- Db.connection.execute("SELECT * FROM `#{self.table_name}` WHERE " <<
3131- "`#{column}` = ? #{limit}", [ value ]).map do |rec|
3232- obj = self.new
120120+ case @columns[col][:type]
121121+ when /boolean/i
122122+ return (data >= 1)
123123+ when /datetime/i
124124+ return (data == nil ? nil : Time.at(data.to_i))
125125+ when /integer/i
126126+ return (data == nil ? nil : data.to_i)
127127+ else
128128+ return data
129129+ end
130130+ end
331313434- rec.each do |k,v|
3535- next if !k.is_a?(String)
3636- obj.send("#{k}=", v)
132132+ def writable_columns_for(what)
133133+ k = columns.keys
134134+135135+ # normally we don't want to include `id` in the insert/update because
136136+ # the db handles that for us, but when we have uuid primary keys, we
137137+ # need to generate them ourselves, so include the column
138138+ unless what == :insert && primary_key_uuid?
139139+ k.reject!{|a| a == primary_key }
37140 end
381413939- obj
142142+ k
40143 end
41144 end
42145146146+ attr_accessor :new_record
147147+43148 def self.transaction(&block)
149149+ ret = true
150150+44151 Db.connection.transaction do
4545- yield block
152152+ ret = yield block
46153 end
154154+155155+ ret
156156+ end
157157+158158+ def initialize
159159+ @new_record = true
160160+ end
161161+162162+ def method_missing(method, *args, &block)
163163+ self.class.fetch_columns
164164+ super
165165+ end
166166+167167+ def actual_before_create
168168+ if self.class.primary_key_uuid? && self.send(self.class.primary_key).blank?
169169+ self.send("#{self.class.primary_key}=", SecureRandom.uuid)
170170+ end
171171+172172+ if self.class.columns["created_at"]
173173+ self.created_at = Time.now
174174+ end
175175+176176+ before_create
177177+ end
178178+179179+ def actual_before_save
180180+ if self.class.columns["updated_at"]
181181+ self.updated_at = Time.now
182182+ end
183183+184184+ before_save
185185+ end
186186+187187+ def before_create
188188+ true
47189 end
4819049191 def before_save
···51193 end
5219453195 def destroy
5454- if self.id
5555- Db.connection.execute("DELETE FROM `#{self.class.table_name}` WHERE " <<
5656- "id = ?", [ self.id ])
196196+ if !self.new_record && self.send(self.class.primary_key)
197197+ Db.execute("DELETE FROM `#{self.class.table_name}` WHERE " <<
198198+ "`#{self.class.primary_key}` = ?",
199199+ [ self.send(self.class.primary_key) ])
57200 end
58201 end
5920260203 def save
6161- return false if !self.before_save
204204+ self.class.fetch_columns
205205+206206+ return false if !self.actual_before_save
207207+208208+ if self.new_record
209209+ return false if !self.actual_before_create
622106363- if self.id
6464- Db.connection.execute("UPDATE `#{self.class.table_name}` SET " +
6565- self.class.table_attrs.reject{|a| a == :id }.
6666- map{|a| "#{a.to_s} = ?" }.join(", ") <<
6767- " WHERE `id` = ?",
6868- self.class.table_attrs.reject{|a| a == :id }.
6969- map{|a| self.send(a) } + [ self.id ])
7070- else
7171- Db.connection.execute("INSERT INTO `#{self.class.table_name}` (" <<
7272- self.class.table_attrs.reject{|a| a == :id }.
7373- map{|a| a.to_s }.join(", ") <<
211211+ Db.execute("INSERT INTO `#{self.class.table_name}` (" <<
212212+ self.class.writable_columns_for(:insert).map{|a| a.to_s }.join(", ") <<
74213 ") VALUES (" <<
7575- self.class.table_attrs.reject{|a| a == :id }.
7676- map{|a| "?" }.join(", ") <<
214214+ self.class.writable_columns_for(:insert).map{|a| "?" }.join(", ") <<
77215 ")",
7878- self.class.table_attrs.reject{|a| a == :id }.map{|a| self.send(a) })
216216+ self.class.writable_columns_for(:insert).map{|a|
217217+ self.class.cast_data_for_column(self.send(a), a)
218218+ })
219219+220220+ self.new_record = false
221221+ else
222222+ Db.execute("UPDATE `#{self.class.table_name}` SET " +
223223+ self.class.writable_columns_for(:update).map{|a| "#{a.to_s} = ?" }.
224224+ join(", ") <<
225225+ " WHERE `#{self.class.primary_key}` = ?",
226226+ self.class.writable_columns_for(:update).map{|a|
227227+ self.class.cast_data_for_column(self.send(a), a)
228228+ } + [ self.send(self.class.primary_key) ])
79229 end
230230+231231+ true
80232 end
81233end
+33-12
lib/device.rb
···11+#
22+# Copyright (c) 2017 joshua stein <jcs@jcs.org>
33+#
44+# Permission to use, copy, modify, and distribute this software for any
55+# purpose with or without fee is hereby granted, provided that the above
66+# copyright notice and this permission notice appear in all copies.
77+#
88+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
99+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1010+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1111+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1212+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1313+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1414+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1515+#
1616+117class Device < DBModel
218 set_table_name "devices"
33- set_table_attrs [ :id, :device_uuid, :user_id, :name, :device_type,
44- :device_push_token, :access_token, :refresh_token, :token_expiry ]
1919+ set_primary_key "uuid"
520621 attr_writer :user
72288- def generate_tokens!
99- self.token_expiry = (Time.now + (60 * 60)).to_i
1010- self.refresh_token = SecureRandom.urlsafe_base64(64)[0, 64]
2323+ DEFAULT_TOKEN_VALIDITY = (60 * 60)
2424+2525+ def regenerate_tokens!(validity = DEFAULT_TOKEN_VALIDITY)
2626+ if self.refresh_token.blank?
2727+ self.refresh_token = SecureRandom.urlsafe_base64(64)[0, 64]
2828+ end
2929+3030+ self.token_expires_at = Time.now + validity
11311232 # the official clients parse this JWT and checks for the existence of some
1333 # of these fields
1414- self.access_token = Bitwarden.jwt_sign({
1515- :nbf => (Time.now - (60 * 5)).to_i,
1616- :exp => self.token_expiry.to_i,
3434+ self.access_token = Bitwarden::Token.sign({
3535+ :nbf => (Time.now - (60 * 2)).to_i,
3636+ :exp => self.token_expires_at.to_i,
1737 :iss => IDENTITY_BASE_URL,
1818- :sub => self.device_uuid,
1919- :premium => true,
3838+ :sub => self.user.uuid,
3939+ :premium => self.user.premium,
2040 :name => self.user.name,
2141 :email => self.user.email,
4242+ :email_verified => self.user.email_verified,
2243 :sstamp => self.user.security_stamp,
2323- :device => self.device_uuid,
4444+ :device => self.uuid,
2445 :scope => [ "api", "offline_access" ],
2546 :amr => [ "Application" ],
2647 })
2748 end
28492950 def user
3030- @user ||= User.find_by_id(self.user_id)
5151+ @user ||= User.find_by_uuid(self.user_uuid)
3152 end
3253end
+21-19
lib/helper.rb
···11+#
22+# Copyright (c) 2017 joshua stein <jcs@jcs.org>
33+#
44+# Permission to use, copy, modify, and distribute this software for any
55+# purpose with or without fee is hereby granted, provided that the above
66+# copyright notice and this permission notice appear in all copies.
77+#
88+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
99+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1010+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1111+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1212+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1313+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1414+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1515+#
1616+117class NilClass
218 def blank?
319 true
···31473248 res == 0
3349 end
3434-end
35503636-def need_params(*ps)
3737- ps.each do |p|
3838- if params[p].blank?
3939- yield(p)
5151+ def ucfirst
5252+ if self.length == 0
5353+ ""
5454+ else
5555+ self[0].upcase << self[1 .. -1].to_s
4056 end
4157 end
4258end
4343-4444-def tee(d)
4545- STDERR.puts d
4646- d
4747-end
4848-4949-def validation_error(msg)
5050- [ 400, {
5151- "ValidationErrors" => { "" => [
5252- msg,
5353- ]},
5454- "Object" => "error",
5555- }.to_json ]
5656-end
+55-3
lib/user.rb
···11+#
22+# Copyright (c) 2017 joshua stein <jcs@jcs.org>
33+#
44+# Permission to use, copy, modify, and distribute this software for any
55+# purpose with or without fee is hereby granted, provided that the above
66+# copyright notice and this permission notice appear in all copies.
77+#
88+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
99+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1010+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1111+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1212+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1313+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1414+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
1515+#
1616+117class User < DBModel
218 set_table_name "users"
33- set_table_attrs [ :id, :email, :name, :password_hash, :key, :totp_secret,
44- :security_stamp, :culture ]
1919+ set_primary_key "uuid"
2020+2121+ def before_save
2222+ if self.security_stamp.blank?
2323+ self.security_stamp = SecureRandom.uuid
2424+ end
2525+ end
2626+2727+ def ciphers
2828+ @ciphers ||= Cipher.find_all_by_user_uuid(self.uuid).
2929+ each{|d| d.user = self }
3030+ end
531632 def devices
77- @devices ||= Device.find_all_by_user_id(self.id).each{|d| d.user = self }
3333+ @devices ||= Device.find_all_by_user_uuid(self.uuid).
3434+ each{|d| d.user = self }
835 end
9361037 def has_password_hash?(hash)
1138 self.password_hash.timingsafe_equal_to(hash)
3939+ end
4040+4141+ # TODO: password_hash=() should update security_stamp when it changes, I
4242+ # think
4343+4444+ def to_hash
4545+ {
4646+ "Id" => self.uuid,
4747+ "Name" => self.name,
4848+ "Email" => self.email,
4949+ "EmailVerified" => self.email_verified,
5050+ "Premium" => self.premium,
5151+ "MasterPasswordHint" => self.password_hint,
5252+ "Culture" => self.culture,
5353+ "TwoFactorEnabled" => self.two_factor_enabled?,
5454+ "Key" => self.key,
5555+ "PrivateKey" => nil,
5656+ "SecurityStamp" => self.security_stamp,
5757+ "Organizations" => [],
5858+ "Object" => "profile"
5959+ }
6060+ end
6161+6262+ def two_factor_enabled?
6363+ self.totp_secret.present?
1264 end
13651466 def verifies_totp_code?(code)
···11+require_relative "spec_helper.rb"
22+33+describe "bitwarden encryption stuff" do
44+ it "should make a key from a password and salt" do
55+ b64 = "2K4YP5Om9r5NpA7FCS4vQX5t+IC4hKYdTJN/C20cz9c="
66+77+ k = Bitwarden.makeKey("this is a password", "nobody@example.com")
88+ Base64.strict_encode64(k).encode("utf-8").must_equal b64
99+1010+ # make sure key and salt affect it
1111+ k = Bitwarden.makeKey("this is a password", "nobody2@example.com")
1212+ Base64.strict_encode64(k).encode("utf-8").wont_equal b64
1313+1414+ k = Bitwarden.makeKey("this is A password", "nobody@example.com")
1515+ Base64.strict_encode64(k).encode("utf-8").wont_equal b64
1616+ end
1717+1818+ it "should make a cipher string from a key" do
1919+ cs = Bitwarden.makeEncKey(
2020+ Bitwarden.makeKey("this is a password", "nobody@example.com")
2121+ )
2222+2323+ cs.must_match /^0\.[^|]+|[^|]+$/
2424+ end
2525+2626+ it "should hash a password" do
2727+ #def hashedPassword(password, salt)
2828+ Bitwarden.hashPassword("secret password", "user@example.com").
2929+ must_equal "VRlYxg0x41v40mvDNHljqpHcqlIFwQSzegeq+POW1ww="
3030+ end
3131+3232+ it "should parse a cipher string" do
3333+ cs = Bitwarden::CipherString.parse(
3434+ "0.u7ZhBVHP33j7cud6ImWFcw==|WGcrq5rTEMeyYkWywLmxxxSgHTLBOWThuWRD/6gVKj77+Vd09DiZ83oshVS9+gxyJbQmzXWilZnZRD/52tah1X0MWDRTdI5bTnTf8KfvRCQ="
3535+ )
3636+3737+ cs.type.must_equal Bitwarden::CipherString::TYPE_AESCBC256_B64
3838+ cs.iv.must_equal "u7ZhBVHP33j7cud6ImWFcw=="
3939+ cs.ct.must_equal "WGcrq5rTEMeyYkWywLmxxxSgHTLBOWThuWRD/6gVKj77+Vd09DiZ83oshVS9+gxyJbQmzXWilZnZRD/52tah1X0MWDRTdI5bTnTf8KfvRCQ="
4040+ cs.mac.must_be_nil
4141+ end
4242+4343+ it "should parse a type-3 cipher string" do
4444+ cs = Bitwarden::CipherString.parse("2.ftF0nH3fGtuqVckLZuHGjg==|u0VRhH24uUlVlTZd/uD1lA==|XhBhBGe7or/bXzJRFWLUkFYqauUgxksCrRzNmJyigfw=")
4545+ cs.type.must_equal 2
4646+ end
4747+4848+ it "should encrypt and decrypt properly" do
4949+ ik = Bitwarden.makeKey("password", "user@example.com")
5050+ k = Bitwarden.makeEncKey(ik)
5151+ j = Bitwarden.encrypt("hi there", ik[0, 32], ik[32, 32])
5252+5353+ cs = Bitwarden::CipherString.parse(j)
5454+5555+ ik = Bitwarden.makeKey("password", "user@example.com")
5656+ Bitwarden.decrypt(cs.to_s, ik[0, 32], ik[32, 32]).must_equal "hi there"
5757+ end
5858+5959+ it "should test mac equality" do
6060+ Bitwarden.macsEqual("asdfasdfasdf", "hi", "hi").must_equal true
6161+ end
6262+end