An unofficial, mostly Bitwarden-compatible API server written in Ruby (Sinatra and ActiveRecord)
at master 689 lines 24 kB view raw view rendered
1## Bitwarden API Overview 2 3Despite being open source, the 4[.NET Bitwarden API code](https://github.com/bitwarden/core) 5is somewhat difficult to navigate and comprehend from a high level, 6and there is no formal documentation on API endpoints or how the 7encryption and decryption is implemented. 8 9The following notes were made by analyzing traffic between the Firefox 10extension and the Bitwarden servers by running 11[tools/mitm.rb](mitm.rb) 12and having the Firefox extension use `http://127.0.0.1:4567/` as its 13base URL. 14 15The details for key derivation and encryption/decryption were done by 16reading the 17[web extension](https://github.com/bitwarden/browser) 18code. 19 20### Password hashing and encryption key derivation 21 22Overview: 23 24- PBKDF2 with `$KdfIterations` rounds stretches the user's master password 25 with a salt of the user's e-mail address to become the master key (unknown 26 to the server). 27- 64 random bytes are generated to become the symmetric key, the first half 28 of which becomes the encryption key and the second half becomes the MAC key. 29- The master key and a random 16-byte IV are used to encrypt the symmetric 30 key with AES-256-CBC. 31 The output+IV become the "protected symmetric key" attached to the user's 32 account, stored on the server and sent to the Bitwarden apps upon syncing. 33- Private values for each string stored with an item (called "Cipher" objects) 34 are encrypted with the user's symmetric key, which can only be known by 35 decrypting the user's protected symmetric key with their master key. 36 This encryption and decryption must be done entirely on the client side 37 since the user's master password/key is never known to the server. 38 39Changing the user's password or e-mail address will create a new master key, 40which is then used to re-encrypt the existing symmetric key, creating a new 41protected symmetric key while still being able to decrypt all existing Cipher 42object strings. 43 44#### Example 45 46User enters a `$masterPassword` of `p4ssw0rd` and an `$email` of 47`nobody@example.com`. 48 49PBKDF2 is used with a password of `$masterPassword`, salt of lowercased 50`$email`, and `$iterations` KDF iterations to stretch password into 51`$masterKey`. 52 53 def makeKey(password, salt, iterations) 54 PBKDF2.new(:password => password, :salt => salt, 55 :iterations => iterations, :hash_function => OpenSSL::Digest::SHA256, 56 :key_length => (256 / 8)).bin_string 57 end 58 59 irb> $masterKey = makeKey("p4ssw0rd", "nobody@example.com".downcase, 5000) 60 => "\x13\x88j`\x99m\xE3FA\x94\xEE'\xF0\xB2\x1A!\xB6>\\)\xF4\xD5\xCA#\xE5\e\xA6f5o{\xAA" 61 62A random, 64-byte key `$symmetricKey` is created to become the symmetric key. 63The first 32 bytes become `$encKey` and the last 32 bytes become `$macKey`. 64A random, 16-byte IV `$iv` is created and `$masterKey` is used as the key to 65encrypt `$symmetricKey`. 66 67A "CipherString" (a Bitwarden internal format) is created by joining the 68[encryption type](https://github.com/bitwarden/browser/blob/f1262147a33f302b5e569f13f56739f05bbec362/src/services/constantsService.js#L13-L21) 69(`0` for `AesCbc256_B64`), a dot, the Base64-encoded IV, and the Base64-encoded 70`$encKey` and `$macKey`, with the pipe (`|`) character to become 71`$protectedKey`. 72 73 def cipherString(enctype, iv, ct, mac) 74 [ enctype.to_s + "." + iv, ct, mac ].reject{|p| !p }.join("|") 75 end 76 77 # encrypt random bytes with a key to make new encryption key 78 def makeEncKey(key) 79 # pt[0, 32] becomes the cipher encryption key 80 # pt[32, 32] becomes the mac key 81 pt = OpenSSL::Random.random_bytes(64) 82 iv = OpenSSL::Random.random_bytes(16) 83 84 cipher = OpenSSL::Cipher.new "AES-256-CBC" 85 cipher.encrypt 86 cipher.key = key 87 cipher.iv = iv 88 ct = cipher.update(pt) 89 ct << cipher.final 90 91 return cipherString(0, Base64.strict_encode64(iv), Base64.strict_encode64(ct), nil) 92 end 93 94 irb> $protectedKey = makeEncKey($masterKey) 95 => "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=" 96 97This is now the main key associated with the user and sent to the server upon 98account creation, and sent back to the device upon sync. 99 100An additional hash of the stretched password becomes `$masterPasswordHash` 101and is also sent to the server upon account creation and login, to actually 102verify the user account. 103This hash is created with 1 round of PBKDF2 over a password of 104`$masterKey` (which itself was created by 5000 rounds of (`$masterPassword`, 105`$email`)) and salt of `$masterPassword`. 106 107 # base64-encode a wrapped, stretched password+salt for signup/login 108 def hashedPassword(password, salt, kdf_iterations) 109 key = makeKey(password, salt, kdf_iterations) 110 Base64.strict_encode64(PBKDF2.new(:password => key, :salt => password, 111 :iterations => 1, :key_length => 256/8, 112 :hash_function => OpenSSL::Digest::SHA256).bin_string) 113 end 114 115 irb> $masterPasswordHash = hashedPassword("p4ssw0rd", "nobody@example.com", 5000) 116 => "r5CFRR+n9NQI8a525FY+0BPR0HGOjVJX0cR1KEMnIOo=" 117 118Upon future logins with the user's plain-text `$masterPassword` and `$email`, 119`$masterKey` can be calculated from them and then `$masterPassword` should 120be cleared from memory. 121`$protectedKey` returned from the server is then decrypted using `$masterKey`, 122revealing the `$encKey` and `$macKey` used for per-item encryption. 123`$masterPassword` and `$masterKey` should never leave the device. 124 125### "Cipher" encryption and decryption 126 127Bitwarden refers to individual items (site logins, secure notes, credit cards, 128etc.) as "cipher" objects, with its 129[type](https://github.com/bitwarden/browser/blob/f1262147a33f302b5e569f13f56739f05bbec362/src/services/constantsService.js#L22-L27) 130value indicating what it is. 131Each cipher has a number of key/value pairs, with some values being encrypted: 132 133 { 134 "type": 1, 135 "folderId": null, 136 "organizationId": null, 137 "name":"2.zAgCKbTvGowtaRn1er5WGA==|oVaVLIjfBQoRr5EvHTwfhQ==|lHSTUO5Rgfkjl3J/zGJVRfL8Ab5XrepmyMv9iZL5JBE=", 138 "notes":"2.NLkXMHtgR8u9azASR4XPOQ==|6/9QPcnoeQJDKBZTjcBAjVYJ7U/ArTch0hUSHZns6v8=|p55cl9FQK/Hef+7yzM7Cfe0w07q5hZI9tTbxupZepyM=", 139 "favorite": false, 140 "login": { 141 "uris": [ 142 { 143 "uri": "2.6DmdNKlm3a+9k/5DFg+pTg==|7q1Arwz/ZfKEx+fksV3yo0HMQdypHJvyiix6hzgF3gY=|7lSXqjfq5rD3/3ofNZVpgv1ags696B2XXJryiGjDZvk=", 144 "match": null 145 } 146 ], 147 "username": "2.4Dwitdv4Br85MABzhMJ4hg==|0BJtHtXbfZWwQXbFcBn0aA==|LM4VC+qNpezmub1f4l1TMLDb9g/Q+sIis2vDbU32ZGA=", 148 "password": "2.OOlWRBGib6G8WRvBOziKzQ==|Had/obAdd2/6y4qzM1Kc/A==|LtHXwZc5PkiReFhkzvEHIL01NrsWGvintQbmqwxoXSI=", 149 "totp": null 150 } 151 } 152 153The values for `name`, `notes`, `login.uris[0].uri`, `login.username`, and 154`login.password` are each encrypted as "CipherString" values, with the 155leading `2` indicating its type (`AesCbc256_HmacSha256_B64`). 156 157To decrypt a value, the CipherString must be broken up into its IV, cipher 158text, and MAC, then each part Base64-decoded. The MAC is calculated using 159`$macKey` and securely compared to the presented MAC, and if equal, the cipher 160text is then decrypted using `$encKey`: 161 162 # compare two hmacs, with double hmac verification 163 # https://www.nccgroup.trust/us/about-us/newsroom-and-events/blog/2011/february/double-hmac-verification/ 164 def macsEqual(macKey, mac1, mac2) 165 hmac1 = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, mac1) 166 hmac2 = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, mac2) 167 return hmac1 == hmac2 168 end 169 170 # decrypt a CipherString and return plaintext 171 def decrypt(str, key, macKey) 172 if str[0].to_i != 2 173 raise "implement #{str[0].to_i} decryption" 174 end 175 176 # AesCbc256_HmacSha256_B64 177 iv, ct, mac = str[2 .. -1].split("|", 3) 178 179 iv = Base64.decode64(iv) 180 ct = Base64.decode64(ct) 181 mac = Base64.decode64(mac) 182 183 cmac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, iv + ct) 184 if !macsEqual(macKey, mac, cmac) 185 raise "invalid mac" 186 end 187 188 cipher = OpenSSL::Cipher.new "AES-256-CBC" 189 cipher.decrypt 190 cipher.iv = iv 191 cipher.key = key 192 pt = cipher.update(ct) 193 pt << cipher.final 194 pt 195 end 196 197 irb> decrypt("2.6DmdNKlm3a+9k/5DFg+pTg==|7q1Arwz/ZfKEx+fksV3yo0HMQdypHJvyiix6hzgF3gY=|7lSXqjfq5rD3/3ofNZVpgv1ags696B2XXJryiGjDZvk=", $encKey, $macKey) 198 => "https://example.com/login" 199 200Encryption of a value is done by generating a random 16-byte IV `$iv` and 201using the key `$encKey` to encrypt the text to `$cipherText`. 202The MAC `$mac` is computed over `($iv + $cipherText)`. 203`$iv`, `$cipherText`, and `$mac` are each Base64-encoded, joined by a pipe 204(`|`) character, and then appended to the type (`2`) and a dot to form a 205CipherString. 206 207 # encrypt+mac a value with a key and mac key and random iv, return cipherString 208 def encrypt(pt, key, macKey) 209 iv = OpenSSL::Random.random_bytes(16) 210 211 cipher = OpenSSL::Cipher.new "AES-256-CBC" 212 cipher.encrypt 213 cipher.key = key 214 cipher.iv = iv 215 ct = cipher.update(pt) 216 ct << cipher.final 217 218 mac = OpenSSL::HMAC.digest(OpenSSL::Digest.new("SHA256"), macKey, iv + ct) 219 220 cipherString(2, Base64.strict_encode64(iv), Base64.strict_encode64(ct), Base64.strict_encode64(mac)) 221 end 222 223 irb> encrypt("A secret note here...", $encKey, $macKey) 224 => "2.NLkXMHtgR8u9azASR4XPOQ==|6/9QPcnoeQJDKBZTjcBAjVYJ7U/ArTch0hUSHZns6v8=|p55cl9FQK/Hef+7yzM7Cfe0w07q5hZI9tTbxupZepyM=" 225 226## API 227 228### URLs 229 230By default, BitWarden uses three different subdomains of `bitwarden.com`, one 231as the `$baseURL` which does most API operations, one as the `$identityURL` 232which handles logins (but not signups for some reason) and issues OAuth tokens, 233and an `$iconURL` which just fetches, caches, and serves requests for site 234icons. 235 236When configuring a self-hosted environment in the device apps before logging 237in, all three of these are assumed to be the same URL. 238 239### Signup 240 241Collect an e-mail address and master password, calculate `$internalKey`, 242`$masterPasswordHash`, and the `$key` CipherString from the two values: 243 244 irb> $internalKey = makeKey("p4ssw0rd", "nobody@example.com".downcase, 5000) 245 => "\x13\x88j`\x99m\xE3FA\x94\xEE'\xF0\xB2\x1A!\xB6>\\)\xF4\xD5\xCA#\xE5\e\xA6f5o{\xAA" 246 247 irb> $masterPasswordHash = hashedPassword("p4ssw0rd", "nobody@example.com", 5000) 248 => "r5CFRR+n9NQI8a525FY+0BPR0HGOjVJX0cR1KEMnIOo=" 249 250 irb> $key = makeEncKey($internalKey) 251 => "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=" 252 253Securely erase `$masterPassword` from memory, as it is no longer needed until 254the next login. 255 256Issue a `POST` to `$baseURL/accounts/register` with a JSON body containing the 257e-mail address, `$masterPasswordHash`, KDF iteration count `$kdfIterations`, 258and `$key` (_not $internalKey_!): 259 260 POST $baseURL/accounts/register 261 Content-type: application/json 262 263 { 264 "name": null, 265 "email": "nobody@example.com", 266 "masterPasswordHash": "r5CFRR+n9NQI8a525FY+0BPR0HGOjVJX0cR1KEMnIOo=", 267 "masterPasswordHint": null, 268 "key": "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=", 269 "kdf": 0, 270 "kdfIterations": 5000, 271 } 272 273The response should be a `200` with a zero-byte body. 274 275### Login 276 277Collect an e-mail address and master password, and issue a `POST` to 278`$baseURL/accounts/prelogin` to determine the KDF iterations for the given 279e-mail address: 280 281 POST $baseURL/accounts/prelogin 282 Content-type: application/json 283 284 { 285 "email": "nobody@example.com", 286 } 287 288The `prelogin` response will contain the KDF iteration count: 289 290 { 291 "Kdf": 0, 292 "KdfIterations": 5000, 293 } 294 295With the KDF iteration count known, calculate `$internalKey` and 296`$masterPasswordHash` from the three values: 297 298 irb> $internalKey = makeKey("p4ssw0rd", "nobody@example.com".downcase, 5000) 299 => "\x13\x88j`\x99m\xE3FA\x94\xEE'\xF0\xB2\x1A!\xB6>\\)\xF4\xD5\xCA#\xE5\e\xA6f5o{\xAA" 300 301 irb> $masterPasswordHash = hashedPassword("p4ssw0rd", "nobody@example.com", 5000) 302 => "r5CFRR+n9NQI8a525FY+0BPR0HGOjVJX0cR1KEMnIOo=" 303 304Securely erase the master password from memory, as it is no longer needed 305until the next login. 306 307Issue a `POST` to `$identityURL/connect/token` (not `$baseURL` which may be 308different). 309 310The `deviceIdentifier` is a random UUID generated by the device and remains 311constant across logins. 312`deviceType` is `3` 313[for Firefox](https://github.com/bitwarden/core/blob/c9a2e67d0965fd046a0b3099e9511c26f0201acd/src/Core/Enums/DeviceType.cs). 314 315 POST $identityURL/connect/token 316 Content-type: application/x-www-form-urlencoded 317 318 { 319 "grant_type": "password", 320 "username": "nobody@example.com", 321 "password": "r5CFRR+n9NQI8a525FY+0BPR0HGOjVJX0cR1KEMnIOo=", 322 "scope": "api offline_access", 323 "client_id": "browser", 324 "deviceType": 3 325 "deviceIdentifier": "aac2e34a-44db-42ab-a733-5322dd582c3d", 326 "deviceName": "firefox", 327 "devicePushToken": "" 328 } 329 330A successful login will have a `200` status and a JSON response: 331 332 { 333 "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkJDMz[...](JWT string)", 334 "expires_in": 3600, 335 "token_type": "Bearer", 336 "refresh_token": "28fb1911ef6db24025ce1bae5aa940e117eb09dfe609b425b69bff73d73c03bf", 337 "Key": "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=", 338 } 339 340If 2FA is enabled on the account (which has to be done through the Bitwarden 341website for bitwarden.com accounts, or some other mechanism for private 342accounts), the return status will be `400` and the JSON response will contain 343a non-empty `TwoFactorProviders` array containing the 344[provider IDs](https://github.com/bitwarden/browser/blob/f1262147a33f302b5e569f13f56739f05bbec362/src/services/constantsService.js#L33-L40) 345of available services: 346 347 { 348 "error": "invalid_grant", 349 "error_description": "Two factor required.", 350 "TwoFactorProviders": [ 0 ], 351 "TwoFactorProviders2": { "0" : null } 352 } 353 354The Bitwarden apps will prompt for the 2FA token, and then attempt to login 355again to `$identityURL/connect/token` with the `twoFactorProvider` and 356`twoFactorToken` values filled out: 357 358 POST $identityURL/connect/token 359 Content-type: application/x-www-form-urlencoded 360 361 { 362 "grant_type": "password", 363 "username": "nobody@example.com", 364 "password": "r5CFRR+n9NQI8a525FY+0BPR0HGOjVJX0cR1KEMnIOo=", 365 "scope": "api offline_access", 366 "client_id": "browser", 367 "deviceType": 3, 368 "deviceIdentifier": "aac2e34a-44db-42ab-a733-5322dd582c3d", 369 "deviceName": "firefox", 370 "devicePushToken": "" 371 "twoFactorToken": "123456", 372 "twoFactorProvider": 0, 373 "twoFactorRemember": 1, 374 } 375 376Upon successful login to an account with 2FA, additional `PrivateKey` and 377`TwoFactorToken` values are sent, but I'm not sure what these are for. 378 379 { 380 "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkJDMz[...](JWT string)", 381 "expires_in": 3600, 382 "token_type": "Bearer", 383 "refresh_token": "28fb1911ef6db24025ce1bae5aa940e117eb09dfe609b425b69bff73d73c03bf", 384 "PrivateKey": "2.WAfJirrIw2vPRIYZn/IadA==|v/PLyfn3P1YKDdbRCd+40k3Z[...](very long CipherString)", 385 "Key": "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=", 386 "TwoFactorToken": "CfDJ8MXkSBvqpelMmq7HvH8L8fsvRsCETUwZQeOOXh21leQs2PmyuvuxdlhT95S+Otmn63gl6FNqLDL2gCqSNB+fHWTqdlX38GSWvGJimuAUeLu3Xgrd2Y0bEzjoBW+3YV4mHJPGwIu/2CaWZl6JW4F229x8fwYbPhRADczligiG1EFxbFswRwmZqmSny5o0VgKUHLIiSDfl2elHYzVpkkKYBoysX9pQ1NoYa7IJJReaWYoP" 387 } 388 389The `access_token`, `refresh_token`, and `expires_in` values must be stored 390and used for further API access. 391`$access_token` must be a 392[JWT](https://jwt.io/) 393string, which the browser extension decodes and parses, and must have at least 394`nbf`, `exp`, `iss`, `sub`, `email`, `name`, `premium`, and `iss` keys. 395`$access_token` is sent as the `Authentication` header for up to `$expires_in` 396seconds, after which the `$refresh_token` will need to be sent back to the 397identity server to get a new `$access_token`. 398 399### Sync 400 401The main action of the client is a one-way sync, which just fetches all 402objects from the server and updates its local database. 403 404Issue a `GET` to `$baseURL/sync` with an `Authorization` header of the 405`$access_token`. 406 407 GET $baseURL/sync 408 Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IkJDMz(rest of $access_token) 409 410A successful response will contain a JSON body with `Profile`, `Folders`, 411`Ciphers`, and `Domains` objects. 412 413 { 414 "Profile": { 415 "Id": "0fbfc68d-ba11-416a-ac8a-a82600f0e601", 416 "Name": null, 417 "Email": "nobody@example.com", 418 "EmailVerified": false, 419 "Premium": false, 420 "MasterPasswordHint": null, 421 "Culture": "en-US", 422 "TwoFactorEnabled": false, 423 "Key": "0.uRcMe+Mc2nmOet4yWx9BwA==|PGQhpYUlTUq/vBEDj1KOHVMlTIH1eecMl0j80+Zu0VRVfFa7X/MWKdVM6OM/NfSZicFEwaLWqpyBlOrBXhR+trkX/dPRnfwJD2B93hnLNGQ=", 424 "PrivateKey": null, 425 "SecurityStamp": "5d203c3f-bc89-499e-85c4-4431248e1196", 426 "Organizations": [ 427 ], 428 "Object": "profile" 429 }, 430 "Folders": [ 431 { 432 "Id": "14220912-d002-471d-a364-a82a010cb8f2", 433 "Name": "2.tqb+y2z4ChCYHj4romVwGQ==|E8+D7aR5CNnd+jF7fdb9ow==|wELCxyy341G2F+w8bTb87PAUi6sdXeIFTFb4N8tk3E0=", 434 "RevisionDate": "2017-11-13T16:20:56.5633333", 435 "Object": "folder" 436 } 437 ], 438 "Ciphers": [ 439 { 440 "FolderId": null, 441 "Favorite": false, 442 "Edit": true, 443 "Id": "0f01a66f-7802-42bc-9647-a82600f11e10", 444 "OrganizationId": null, 445 "Type":1, 446 "Login":{ 447 "Uris": [ 448 { 449 "Uri": "2.6DmdNKlm3a+9k/5DFg+pTg==|7q1Arwz/ZfKEx+fksV3yo0HMQdypHJvyiix6hzgF3gY=|7lSXqjfq5rD3/3ofNZVpgv1ags696B2XXJryiGjDZvk=", 450 "Match": null, 451 }, 452 ], 453 "Username": "2.4Dwitdv4Br85MABzhMJ4hg==|0BJtHtXbfZWwQXbFcBn0aA==|LM4VC+qNpezmub1f4l1TMLDb9g/Q+sIis2vDbU32ZGA=", 454 "Password":"2.OOlWRBGib6G8WRvBOziKzQ==|Had/obAdd2/6y4qzM1Kc/A==|LtHXwZc5PkiReFhkzvEHIL01NrsWGvintQbmqwxoXSI=", 455 "Totp":null, 456 }, 457 "Name": "2.zAgCKbTvGowtaRn1er5WGA==|oVaVLIjfBQoRr5EvHTwfhQ==|lHSTUO5Rgfkjl3J/zGJVRfL8Ab5XrepmyMv9iZL5JBE=", 458 "Notes": "2.NLkXMHtgR8u9azASR4XPOQ==|6/9QPcnoeQJDKBZTjcBAjVYJ7U/ArTch0hUSHZns6v8=|p55cl9FQK/Hef+7yzM7Cfe0w07q5hZI9tTbxupZepyM=", 459 "Fields": null, 460 "Attachments": null, 461 "OrganizationUseTotp": false, 462 "RevisionDate": "2017-11-09T14:37:52.9033333", 463 "Object":"cipher" 464 } 465 ], 466 "Domains": { 467 "EquivalentDomains": null, 468 "GlobalEquivalentDomains": [ 469 { 470 "Type": 2, 471 "Domains": [ 472 "ameritrade.com", 473 "tdameritrade.com" 474 ], 475 "Excluded": false 476 }, 477 [...] 478 ], 479 "Object": "domains" 480 }, 481 "Object": "sync" 482 } 483 484### Token Refresh 485 486After `$expires_in` seconds of login (or last refresh), the `$access_token` 487expires and has to be refreshed. 488Send a `POST` request to the identity server with the `$refresh_token` and 489get a new `$access_token` in return. 490 491 POST $identityURL/connect/token 492 Content-type: application/x-www-form-urlencoded 493 494 { 495 "grant_type": "refresh_token", 496 "client_id": "browser", 497 "refresh_token": "28fb1911ef6db24025ce1bae5aa940e117eb09dfe609b425b69bff73d73c03bf", 498 } 499 500A successful response will contain a JSON body with a new `$access_token` 501and the same `$refresh_token`. 502 503 { 504 "access_token": "(new access token)", 505 "expires_in": 3600, 506 "token_type": "Bearer", 507 "refresh_token": "28fb1911ef6db24025ce1bae5aa940e117eb09dfe609b425b69bff73d73c03bf", 508 } 509 510### Saving a new item 511 512When a new item (login, secure note, etc.) is created on a device, it is 513sent to the server via a `POST` to `$baseURL/ciphers`: 514 515 POST $baseURL/ciphers 516 Content-type: application/json 517 Authorization: Bearer $access_token 518 519 { 520 "type": 1, 521 "folderId": null, 522 "organizationId": null, 523 "name": "2.d7MttWzJTSSKx1qXjHUxlQ==|01Ath5UqFZHk7csk5DVtkQ==|EMLoLREgCUP5Cu4HqIhcLqhiZHn+NsUDp8dAg1Xu0Io=", 524 "notes": null, 525 "favorite": false, 526 "login": { 527 "uri": "2.T57BwAuV8ubIn/sZPbQC+A==|EhUSSpJWSzSYOdJ/AQzfXuUXxwzcs/6C4tOXqhWAqcM=|OWV2VIqLfoWPs9DiouXGUOtTEkVeklbtJQHkQFIXkC8=", 528 "username": "2.JbFkAEZPnuMm70cdP44wtA==|fsN6nbT+udGmOWv8K4otgw==|JbtwmNQa7/48KszT2hAdxpmJ6DRPZst0EDEZx5GzesI=", 529 "password": "2.e83hIsk6IRevSr/H1lvZhg==|48KNkSCoTacopXRmIZsbWg==|CIcWgNbaIN2ix2Fx1Gar6rWQeVeboehp4bioAwngr0o=", 530 "totp": null 531 } 532 } 533 534With no errors, the server will send back a JSON response with the 535cipher data: 536 537 { 538 "FolderId": null, 539 "Favorite": false, 540 "Edit": true, 541 "Id": "4c2869dd-0e1c-499f-b116-a824016df251", 542 "OrganizationId": null, 543 "Type": 1, 544 "Login": { 545 "Uris": [ 546 { 547 "Uri": "2.T57BwAuV8ubIn/sZPbQC+A==|EhUSSpJWSzSYOdJ/AQzfXuUXxwzcs/6C4tOXqhWAqcM=|OWV2VIqLfoWPs9DiouXGUOtTEkVeklbtJQHkQFIXkC8=", 548 "Match": null, 549 }, 550 ], 551 }, 552 "Username": "2.JbFkAEZPnuMm70cdP44wtA==|fsN6nbT+udGmOWv8K4otgw==|JbtwmNQa7/48KszT2hAdxpmJ6DRPZst0EDEZx5GzesI=", 553 "Password": "2.e83hIsk6IRevSr/H1lvZhg==|48KNkSCoTacopXRmIZsbWg==|CIcWgNbaIN2ix2Fx1Gar6rWQeVeboehp4bioAwngr0o=", 554 "Totp": null, 555 "Name": "2.d7MttWzJTSSKx1qXjHUxlQ==|01Ath5UqFZHk7csk5DVtkQ==|EMLoLREgCUP5Cu4HqIhcLqhiZHn+NsUDp8dAg1Xu0Io=", 556 "Notes": null, 557 "Fields": null, 558 "Attachments": null, 559 "OrganizationUseTotp": false, 560 "RevisionDate": "2017-11-07T22:12:22.235914Z", 561 "Object": "cipher" 562 } 563 564### Updating an item 565 566Send a `PUT` request to `$baseURL/ciphers/(cipher UUID)`: 567 568 PUT $baseURL/ciphers/(cipher UUID) 569 Content-type: application/json 570 Authorization: Bearer $access_token 571 572 { 573 "type": 2, 574 "folderId": null, 575 "organizationId": null, 576 "name": "2.G38TIU3t1pGOfkzjCQE7OQ==|Xa1RupttU7zrWdzIT6oK+w==|J3C6qU1xDrfTgyJD+OrDri1GjgGhU2nmRK75FbZHXoI=", 577 "notes": "2.rSw0uVQEFgUCEmOQx0JnDg==|MKqHLD25aqaXYHeYJPH/mor7l3EeSQKsI7A/R+0bFTI=|ODcUScISzKaZWHlUe4MRGuTT2S7jpyDmbOHl7d+6HiM=", 578 "favorite": true, 579 "secureNote":{ 580 "type": 0 581 } 582 } 583 584The JSON response will be the same as when creating a new item. 585 586### Deleting an item 587 588Send an empty `DELETE` request to `$baseURL/ciphers/(cipher UUID)`: 589 590 DELETE $baseURL/ciphers/(cipher UUID) 591 Authorization: Bearer (access_token) 592 593A successful but zero-length response will be returned. 594 595### Adding an attachment 596 597Send a `POST` request to `$baseURL/ciphers/(cipher UUID)/attachment` 598 599It is a multipart/form-data post, with the file under the `data`-attribute the single posted entity. 600 601 602 POST $baseURL/ciphers/(cipher UUID)/attachment 603 Content-type: application/json 604 Authorization: Bearer $access_token 605 { 606 "data": { 607 "filename": "encrypted_filename" 608 "tempfile": blob 609 } 610 } 611 612The JSON response will then be the complete cipher item, but now containing an entry for the new attachment: 613 614 { 615 "FolderId"=>nil, 616 ... 617 "Data"=> ..., 618 "Attachments"=> 619 [ 620 { "Id"=>"7xytytjp1hc2ijy3n5y5vbbnzcukmo8b", 621 "Url"=> "https://cdn.bitwarden.com/attachments/(cipher UUID)/7xytytjp1hc2ijy3n5y5vbbnzcukmo8b", 622 "FileName"=> "2.GOkRA8iZio1KxB+UkJpfcA==|/Mc8ACbPr9CRRQmNKPYHVg==|4BBQf8YTbPupap6qR97qMdn0NJ88GdTgDPIyBsQ46aA=", 623 "Size"=>"65", 624 "SizeName"=>"65 Bytes", 625 "Object"=>"attachment" 626 } 627 ], 628 ..., 629 "Object"=>"cipher" 630 } 631 632### Deleting an attachment 633 634Send an empty `DELETE` request to `$baseURL/ciphers/(cipher UUID)/attachment/(attachment id)`: 635 636 DELETE $baseURL/ciphers/(cipher UUID)/attachment/(attachment id) 637 Authorization: Bearer (access_token) 638 639A successful but zero-length response will be returned. 640 641### Downloading an attachment 642 643$cdn_url using the official server is https://cdn.bitwarden.com. 644 645Send an unauthenticated `GET` request to `$cdn_url/attachments/(cipher UUID)/(attachment id)`: 646 647 GET $cdn_url/attachments/(cipher UUID)/(attachment id) 648 649The file will be sent as a response. 650 651### Folders 652 653To create a folder, `POST` to `$baseURL/folders`: 654 655 POST $baseURL/folders 656 Content-type: application/json 657 Authorization: Bearer $access_token 658 659 { 660 "name": "2.FQAwIBaDbczEGnEJw4g4hw==|7KreXaC0duAj0ulzZJ8ncA==|nu2sEvotjd4zusvGF8YZJPnS9SiJPDqc1VIfCrfve/o=" 661 } 662 663JSON response: 664 665 { 666 "Id": "14220912-d002-471d-a364-a82a010cb8f2", 667 "Name": "2.FQAwIBaDbczEGnEJw4g4hw==|7KreXaC0duAj0ulzZJ8ncA==|nu2sEvotjd4zusvGF8YZJPnS9SiJPDqc1VIfCrfve/o=", 668 "RevisionDate": "2017-11-13T16:18:23.3078169Z", 669 "Object": "folder" 670 } 671 672To rename a folder, `PUT` to `$baseURL/folders/(folder UUID)` with the 673same structure as the `POST`, and get the same result. 674 675To delete a folder, `DELETE` to `$baseURL/folders/(folder UUID)` and get a 676successful, zero-length response. 677 678### Icons 679 680Each login cipher can show an icon (favicon) for its URL, which is fetched via 681Bitwarden's servers (presumably for caching). 682 683To fetch an icon for a URL, issue an unauthenticated `GET` to 684`$iconURL/(domain)/icon.png`: 685 686 GET $iconURL/google.com/icon.png 687 (no authentication header) 688 689The binary response will contain the icon.