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.

API.md: add ruby code

+93 -5
+93 -5
API.md
··· 1 1 ## Bitwarden API Overview 2 2 3 3 Despite being open source, the 4 - [Bitwarden API code](https://github.com/bitwarden/core) 4 + [.NET Bitwarden API code](https://github.com/bitwarden/core) 5 5 is somewhat difficult to navigate and comprehend from a high level, 6 6 and there is no formal documentation on API endpoints or how the 7 7 encryption and decryption is implemented. ··· 25 25 PBKDF2 is used with a password of `$masterPassword`, salt of lowercased 26 26 `$email`, and 5000 iterations to stretch password into `$internalKey`. 27 27 28 + def makeKey(password, salt) 29 + PBKDF2.new(:password => password, :salt => salt, 30 + :iterations => 5000, :hash_function => OpenSSL::Digest::SHA256, 31 + :key_length => (256 / 8)).bin_string 32 + end 33 + 28 34 irb> $internalKey = makeKey("p4ssw0rd", "nobody@example.com".downcase) 29 35 => "\x13\x88j`\x99m\xE3FA\x94\xEE'\xF0\xB2\x1A!\xB6>\\)\xF4\xD5\xCA#\xE5\e\xA6f5o{\xAA" 30 36 31 - An IV `$iv` is created of 16 random bytes and `$internalKey` is used as the 37 + An IV `$iv` is created with 16 random bytes and `$internalKey` is used as the 32 38 key to encrypt 64 random bytes. 33 39 The first 32 bytes of the result become `$encKey` and the last 32 bytes become 34 40 `$macKey`. ··· 38 44 (`0` for `AesCbc256_B64`), a dot, the Base64-encoded IV, and the Base64-encoded 39 45 `$encKey` and `$macKey`, with the pipe (`|`) character to become `$key`. 40 46 47 + def cipherString(enctype, iv, ct, mac) 48 + [ enctype.to_s + "." + iv, ct, mac ].reject{|p| !p }.join("|") 49 + end 50 + 51 + # encrypt random bytes with a key to make new encryption key 52 + def makeEncKey(key) 53 + pt = OpenSSL::Random.random_bytes(64) 54 + iv = OpenSSL::Random.random_bytes(16) 55 + 56 + cipher = OpenSSL::Cipher.new "AES-256-CBC" 57 + cipher.encrypt 58 + cipher.key = key 59 + cipher.iv = iv 60 + ct = cipher.update(pt) 61 + ct << cipher.final 62 + 63 + return cipherString(0, Base64.strict_encode64(iv), Base64.strict_encode64(ct), nil) 64 + end 65 + 41 66 irb> $key = makeEncKey($internalKey) 42 67 => "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=" 43 68 ··· 50 75 This hash is created with 1 round of PBKDF2 over a password of 51 76 `$internalKey` (which itself was created by 5000 rounds of (`$masterPassword`, 52 77 `$email`)) and salt of `$masterPassword`. 78 + 79 + # base64-encode a wrapped, stretched password+salt for signup/login 80 + def hashedPassword(password, salt) 81 + key = makeKey(password, salt) 82 + Base64.strict_encode64(PBKDF2.new(:password => key, :salt => password, 83 + :iterations => 1, :key_length => 256/8, 84 + :hash_function => OpenSSL::Digest::SHA256).bin_string) 85 + end 53 86 54 87 irb> $masterPasswordHash = hashedPassword("p4ssw0rd", "nobody@example.com") 55 88 => "r5CFRR+n9NQI8a525FY+0BPR0HGOjVJX0cR1KEMnIOo=" ··· 93 126 `$macKey` and securely compared to the presented MAC, and if equal, the cipher 94 127 text is then decrypted using `$encKey`: 95 128 129 + # compare two hmacs, with double hmac verification 130 + # https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2011/february/double-hmac-verification/ 131 + def macsEqual(macKey, mac1, mac2) 132 + hmac1 = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, mac1) 133 + hmac2 = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, mac2) 134 + return hmac1 == hmac2 135 + end 136 + 137 + # decrypt a CipherString and return plaintext 138 + def decrypt(str, key, macKey) 139 + if str[0].to_i != 2 140 + raise "implement #{str[0].to_i} decryption" 141 + end 142 + 143 + # AesCbc256_HmacSha256_B64 144 + iv, ct, mac = str[2 .. -1].split("|", 3) 145 + 146 + iv = Base64.decode64(iv) 147 + ct = Base64.decode64(ct) 148 + mac = Base64.decode64(mac) 149 + 150 + cmac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, iv + ct) 151 + if !macsEqual(macKey, mac, cmac) 152 + raise "invalid mac" 153 + end 154 + 155 + cipher = OpenSSL::Cipher.new "AES-256-CBC" 156 + cipher.decrypt 157 + cipher.iv = iv 158 + cipher.key = key 159 + pt = cipher.update(ct) 160 + pt << cipher.final 161 + pt 162 + end 163 + 96 164 irb> decrypt("2.6DmdNKlm3a+9k/5DFg+pTg==|7q1Arwz/ZfKEx+fksV3yo0HMQdypHJvyiix6hzgF3gY=|7lSXqjfq5rD3/3ofNZVpgv1ags696B2XXJryiGjDZvk=", $encKey, $macKey) 97 165 => "https://example.com/login" 98 166 ··· 103 171 (`|`) character, and then appended to the type (`2`) and a dot to form a 104 172 CipherString. 105 173 174 + # encrypt+mac a value with a key and mac key and random iv, return cipherString 175 + def encrypt(pt, key, macKey) 176 + iv = OpenSSL::Random.random_bytes(16) 177 + 178 + cipher = OpenSSL::Cipher.new "AES-256-CBC" 179 + cipher.encrypt 180 + cipher.key = key 181 + cipher.iv = iv 182 + ct = cipher.update(pt) 183 + ct << cipher.final 184 + 185 + mac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, iv + ct) 186 + 187 + cipherString(2, Base64.strict_encode64(iv), Base64.strict_encode64(ct), Base64.strict_encode64(mac)) 188 + end 189 + 106 190 irb> encrypt("A secret note here...", $encKey, $macKey) 107 191 => "2.NLkXMHtgR8u9azASR4XPOQ==|6/9QPcnoeQJDKBZTjcBAjVYJ7U/ArTch0hUSHZns6v8=|p55cl9FQK/Hef+7yzM7Cfe0w07q5hZI9tTbxupZepyM=" 108 192 ··· 192 276 A successful login will have a `200` status and a JSON response: 193 277 194 278 { 195 - "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkJDMz[...](long random string)", 279 + "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkJDMz[...](JWT string)", 196 280 "expires_in": 3600, 197 281 "token_type": "Bearer", 198 282 "refresh_token": "28fb1911ef6db24025ce1bae5aa940e117eb09dfe609b425b69bff73d73c03bf", ··· 239 323 `TwoFactorToken` values are sent, but I'm not sure what these are for. 240 324 241 325 { 242 - "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkJDMz[...](long string)", 326 + "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkJDMz[...](JWT string)", 243 327 "expires_in": 3600, 244 328 "token_type": "Bearer", 245 329 "refresh_token": "28fb1911ef6db24025ce1bae5aa940e117eb09dfe609b425b69bff73d73c03bf", ··· 250 334 251 335 The `access_token`, `refresh_token`, and `expires_in` values must be stored 252 336 and used for further API access. 337 + `$access_token` must be a 338 + [JWT](https://jwt.io/) 339 + string, which the browser extension decodes and parses, and must have at least 340 + `nbf`, `exp`, `iss`, `sub`, `email`, `name`, `premium`, and `iss` keys. 253 341 `$access_token` is sent as the `Authentication` header for up to `$expires_in` 254 342 seconds, after which the `$refresh_token` will need to be sent back to the 255 343 identity server to get a new `$access_token`. ··· 262 350 Issue a `GET` to `$baseURL/sync` with an `Authorization` header of the 263 351 `$access_token`. 264 352 265 - GET $baseURL(base url)/sync 353 + GET $baseURL/sync 266 354 Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IkJDMz(rest of $access_token) 267 355 268 356 A successful response will contain a JSON body with `Profile`, `Folders`,