An unofficial, mostly Bitwarden-compatible API server written in Ruby (Sinatra and ActiveRecord)
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.