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.

import sinatra API, enough to create accounts and login

+457
+4
.gitignore
··· 1 + .bundle 2 + db.sqlite3 3 + jwt-rsa.key 4 + vendor/
+5
Gemfile
··· 1 1 source "https://rubygems.org" 2 2 3 3 gem "sinatra" 4 + gem "sinatra-contrib" 4 5 gem "json" 5 6 6 7 gem "pbkdf2-ruby" 8 + gem "rotp" 9 + gem "jwt" 10 + 11 + gem "sqlite3"
+16
Gemfile.lock
··· 1 1 GEM 2 2 remote: https://rubygems.org/ 3 3 specs: 4 + backports (3.10.3) 4 5 json (2.1.0) 6 + jwt (2.1.0) 7 + multi_json (1.12.2) 5 8 mustermann (1.0.1) 6 9 pbkdf2-ruby (0.2.1) 7 10 rack (2.0.3) 8 11 rack-protection (2.0.0) 9 12 rack 13 + rotp (3.3.0) 10 14 sinatra (2.0.0) 11 15 mustermann (~> 1.0) 12 16 rack (~> 2.0) 13 17 rack-protection (= 2.0.0) 14 18 tilt (~> 2.0) 19 + sinatra-contrib (2.0.0) 20 + backports (>= 2.0) 21 + multi_json 22 + mustermann (~> 1.0) 23 + rack-protection (= 2.0.0) 24 + sinatra (= 2.0.0) 25 + tilt (>= 1.3, < 3) 26 + sqlite3 (1.3.13) 15 27 tilt (2.0.8) 16 28 17 29 PLATFORMS ··· 19 31 20 32 DEPENDENCIES 21 33 json 34 + jwt 22 35 pbkdf2-ruby 36 + rotp 23 37 sinatra 38 + sinatra-contrib 39 + sqlite3 24 40 25 41 BUNDLED WITH 26 42 1.13.7
+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
+27
lib/bitwarden.rb
··· 1 + require "jwt" 2 + 3 + class Bitwarden 4 + class << self 5 + attr_reader :jwt_rsa 6 + 7 + JWT_KEY = "#{APP_ROOT}/jwt-rsa.key" 8 + 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) 13 + else 14 + @jwt_rsa = OpenSSL::PKey::RSA.generate 2048 15 + 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 20 + end 21 + end 22 + 23 + def jwt_sign(payload) 24 + JWT.encode(payload, @jwt_rsa, "RS256") 25 + end 26 + end 27 + end
+60
lib/db.rb
··· 1 + require "sqlite3" 2 + 3 + class Db 4 + @@db = nil 5 + 6 + def self.db_file 7 + "#{APP_ROOT}/db.sqlite3" 8 + end 9 + 10 + def self.connection 11 + if @@db 12 + return @@db 13 + end 14 + 15 + @@db = SQLite3::Database.new(self.db_file) 16 + 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 + ") 29 + 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 + ") 43 + 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 + ") 55 + 56 + @@db.results_as_hash = true 57 + 58 + @@db 59 + end 60 + end
+81
lib/dbmodel.rb
··· 1 + class DBModel 2 + class << self 3 + attr_accessor :table_name, :table_attrs 4 + 5 + def set_table_name(table) 6 + @table_name = table 7 + end 8 + 9 + def set_table_attrs(attrs) 10 + @table_attrs = attrs 11 + attr_accessor *attrs 12 + end 13 + end 14 + 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 22 + end 23 + end 24 + 25 + def self.find_by_column(column, value) 26 + self.find_all_by_column(column, value, "LIMIT 1").first 27 + end 28 + 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 33 + 34 + rec.each do |k,v| 35 + next if !k.is_a?(String) 36 + obj.send("#{k}=", v) 37 + end 38 + 39 + obj 40 + end 41 + end 42 + 43 + def self.transaction(&block) 44 + Db.connection.transaction do 45 + yield block 46 + end 47 + end 48 + 49 + def before_save 50 + true 51 + end 52 + 53 + def destroy 54 + if self.id 55 + Db.connection.execute("DELETE FROM `#{self.class.table_name}` WHERE " << 56 + "id = ?", [ self.id ]) 57 + end 58 + end 59 + 60 + def save 61 + return false if !self.before_save 62 + 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(", ") << 74 + ") VALUES (" << 75 + self.class.table_attrs.reject{|a| a == :id }. 76 + map{|a| "?" }.join(", ") << 77 + ")", 78 + self.class.table_attrs.reject{|a| a == :id }.map{|a| self.send(a) }) 79 + end 80 + end 81 + end
+32
lib/device.rb
··· 1 + class Device < DBModel 2 + 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 ] 5 + 6 + attr_writer :user 7 + 8 + def generate_tokens! 9 + self.token_expiry = (Time.now + (60 * 60)).to_i 10 + self.refresh_token = SecureRandom.urlsafe_base64(64)[0, 64] 11 + 12 + # the official clients parse this JWT and checks for the existence of some 13 + # 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, 17 + :iss => IDENTITY_BASE_URL, 18 + :sub => self.device_uuid, 19 + :premium => true, 20 + :name => self.user.name, 21 + :email => self.user.email, 22 + :sstamp => self.user.security_stamp, 23 + :device => self.device_uuid, 24 + :scope => [ "api", "offline_access" ], 25 + :amr => [ "Application" ], 26 + }) 27 + end 28 + 29 + def user 30 + @user ||= User.find_by_id(self.user_id) 31 + end 32 + end
+56
lib/helper.rb
··· 1 + class NilClass 2 + def blank? 3 + true 4 + end 5 + 6 + def present? 7 + false 8 + end 9 + end 10 + 11 + class String 12 + def blank? 13 + self.strip == "" 14 + end 15 + 16 + def present? 17 + !blank? 18 + end 19 + 20 + def timingsafe_equal_to(other) 21 + if self.bytesize != other.bytesize 22 + return false 23 + end 24 + 25 + bytes = self.unpack("C#{self.bytesize}") 26 + 27 + res = 0 28 + other.each_byte do |byte| 29 + res |= byte ^ bytes.shift 30 + end 31 + 32 + res == 0 33 + end 34 + end 35 + 36 + def need_params(*ps) 37 + ps.each do |p| 38 + if params[p].blank? 39 + yield(p) 40 + end 41 + end 42 + 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
+17
lib/user.rb
··· 1 + class User < DBModel 2 + set_table_name "users" 3 + set_table_attrs [ :id, :email, :name, :password_hash, :key, :totp_secret, 4 + :security_stamp, :culture ] 5 + 6 + def devices 7 + @devices ||= Device.find_all_by_user_id(self.id).each{|d| d.user = self } 8 + end 9 + 10 + def has_password_hash?(hash) 11 + self.password_hash.timingsafe_equal_to(hash) 12 + end 13 + 14 + def verifies_totp_code?(code) 15 + ROTP::TOTP.new(self.totp_secret).now == code.to_i 16 + end 17 + end