A journey to scripting Firefox Sync / Lockwise: complete OAuth

August 8, 2021

Picture credit: Val

This article is part of a series about scripting Firefox Sync / Lockwise.

  1. A journey to scripting Firefox Sync / Lockwise: existing clients
  2. A journey to scripting Firefox Sync / Lockwise: figuring the protocol
  3. A journey to scripting Firefox Sync / Lockwise: understanding BrowserID
  4. A journey to scripting Firefox Sync / Lockwise: hybrid OAuth
  5. A journey to scripting Firefox Sync / Lockwise: complete OAuth

OK, this grew a bit out of hand. It all started a month ago when I just wanted to programmatically access my Firefox Lockwise passwords. This brought me on a long journey where I got to play with legacy clients from 8 years ago, the Firefox Accounts and Firefox Sync APIs, the low-level details of the BrowserID protocol and finally its modern counterpart OAuth.

But as I explained at the end of the last article, this approach still had room for improvement as we werenā€™t using the full benefits of OAuth. In particular, we still needed access to the plaintext user password in order to authenticate to Firefox Accounts, which results in a ā€œgod modeā€ session token that gives full, unrestricted access to the user account, including fetching their primary key material which is a requirement in order to decrypt the Firefox Sync collections.

This is unideal and we can do better. The good thing is that even though it wasnā€™t exactly easy to figure out, itā€™s possible.

Logging in with OAuth

The first thing weā€™ll need to change is logging in with OAuth instead of email/password.

The fxa-crypto-relier package is of great help for understanding how it works, but it seems to be designed solely with browser extensions in mind, and is not directly usable for us on the CLI. Otherwise, the integration with Firefox Accounts page seems to be the main documentation about implementing OAuth.

It notably mentions that the OAuth endpoints can be dynamically discovered through the standard OpenID Connect protocol, meaning that our OAuth authorization endpoint will concretely be https://accounts.firefox.com/authorization.

The user authentication in a nutshell part does a great job at explaining the Firefox Accounts OAuth flow:

  1. Create a state token (randomly generated and unguessable) and associate it with a local session.
  2. Send /authorization request to Firefox Accounts. Upon completion, Firefox Accounts redirects back to your app with state and code.
  3. Confirm the returned state token by comparing it with the state token associated with the local session.
  4. Exchange the code for an access token and possibly a refresh token.
  5. If you asked for scope=profile you can fetch user profile information, using the access token, from the FxA profile server.
  6. Associate the profile information with the local session and create an account in the local application database as needed.

Sweet and simple. Since we donā€™t have our own OAuth credentials (mostly because I donā€™t like sending emails), weā€™ll keep using the client ID from the Android app.

Generating the authorization URL

Letā€™s start by building the authorization URL. The parameters we need are listed here.

  1. client_id (required).
  2. scope (required). This is a space separated string. Review the list of scopes.
  3. state (required). This must be a randomly generated unguessable string.
  4. code_challenge (required for PKCE). This is a hash of a randomly generated string.
  5. code_challenge_method (required for PKCE) As of this writing only S256 is supported.
  6. access_type (suggested). This should be either online or offline.

I omitted other parameters that are not relevant to us. Letā€™s look in more details at the ones weā€™ll use.

scope

We use the scope https://identity.mozilla.com/apps/oldsync because it is the one we need to access Firefox Sync data.

While oldsync here makes me feel like there must be something ā€œnewā€ somewhere, it seems to be the latest and greatest way to access the Sync data, so be it.

state

The state parameter is designed to mitigate CSRF attacks. Weā€™ll mimic what fxa-crypto-relier does and create 16 bytes worth of random data and encode it as a Base64URL string. They also trim that final string to 16 characters but it seems that this is mostly for code reuse purpose rather than out of actual necessity so weā€™ll leave that part alone.

const state = crypto.randomBytes(16).toString('base64url')
code_challenge and code_challenge_method

The code challenge is a standard PKCE challenge, and the challenge method is S256 (the only one supported). Those parameters are required for public clients. While itā€™s not mentioned on the previous page, itā€™s made clear on the Firefox Accounts API documentation:

Required for public OAuth clients, who must authenticate their authorization code use via PKCE.

Since the Android app we took the client ID from is a public client, weā€™ll pass those parameters.

Note that even though the code_challenge_method is documented with a lowercase s, itā€™s actually validated as uppercase. I fixed it in the quote earlier to prevent unnecessary issues.

access_type

We set it to offline to get back a refresh token. This step is optional but keep it in mind if you want to be able to refresh the token without user interaction.

This gives us the following piece of code:

const crypto = require('crypto')
const qs = require('querystring')

const authorizationUrl = 'https://accounts.firefox.com/authorization'
const scope = 'https://identity.mozilla.com/apps/oldsync'
const clientId = '...'

// To prevent CSRF attacks.
const state = crypto.randomBytes(16).toString('base64url')

// Dead simple PKCE challenge implementation.
const codeVerifier = crypto.randomBytes(32).toString('base64url')
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')

const params = {
  client_id: clientId,
  scope,
  state,
  code_challenge_method: 'S256',
  code_challenge: codeChallenge,
  access_type: 'offline'
}

const url = `${authorizationUrl}?${qs.stringify(params)}`

console.log(url)

The code verifier is defined by PKCE as a string of 43 Base64URL charaters, which is effectively 32 bytes of entropy, hence crypto.randomBytes(32).

It is recommended that the output of a suitable random number generator be used to create a 32-octet sequence. The octet sequence is then Base64URL encoded to produce a 43-octet URL safe string to use as the code verifier.

The code challenge is also defined as a Base64URL encoded SHA-256 hash of the code verifier, giving us a working two lines client implementation of PKCE.

Note: I didnā€™t use a library like pkce-challenge here because of a weird validation quirk in the Firefox Accounts OAuth token endpoint that weā€™ll use later.

While the PKCE spec defines the alphabet of the code verifier as ALPHA / DIGIT / "-" / "." / "_" / "~", the OAuth endpoint limits it to a Base64URL alphabet (ironically with a link to the RFC), leaving out the . and ~ characters, and resulting in validation issues when using the pkce-challenge library.

That being said this quirk is only for the https://oauth.accounts.firefox.com/v1/token endpoint as exposed by Mozilla through OpenID Connect, but the alternate endpoint https://api.accounts.firefox.com/v1/oauth/token performs proper validation and otherwise seems to behave in a consistent way, so itā€™s possible to use it instead.

For now we just implement our Base64URL compatible PKCE challenge, since itā€™s also a good way to see how PKCE works for learning purpose.

By visiting the generated URL, the user will be prompted to sign in with their Firefox Account, and will be redirected to the configured OAuth redirect URL, with an authorization code in the query string.

Sign in to Firefox Sync page

Since we didnā€™t register our own OAuth app, and we borrowed the Android client ID instead, weā€™re going to be redirected to the URL thatā€™s configured for the Android app. This is fine for educational purpose but weā€™d need to register for proper OAuth credentials for this to be usable in production.

As a good security practice to not leave the code in the URL, this page will itself redirect to another page without the code in the URL, so we canā€™t just extract it from there.

In order to grab the code and go on with the OAuth flow, weā€™ll intercept the redirect in the developer tools network tab. Make sure to tick the persist/preserve logs option before logging in, otherwise the request including the code will be wiped during the redirect. To find it more easily, filter only HTML documents. The one weā€™re looking for starts with https://lockbox.firefox.com/fxa/android-redirect.html?code=.

Network tab intercepting OAuth redirect

In the headers section on the right we can copy the value of the code parameter, which we can feed to our script to continue the authentication. Brilliant.

Note: for a legitimate OAuth client where you control the redirect URL, donā€™t forget to validate that the state parameter matches the one you originally passed in the authorization URL!

Trading the OAuth code for an access token

The next step is to trade the code we get back from the OAuth flow for a proper token. Thanks to the OpenID Connect configuration, we know that the token endpoint is https://oauth.accounts.firefox.com/v1/token. A quick search leads us to its API documentation.

This is where weā€™ll send the code from the redirect URL, as well as the PKCE code verifier we created earlier. But first we need to prompt the user for the code. In a basic CLI, this would look something like this:

const readline = require('readline')

const rl = readline.createInterface({ input: process.stdin, output: process.stdout })

const code = await new Promise(resolve => rl.question('Code: ', resolve))
  .finally(() => rl.close())

Then we can prepare the payload and send it to the token endpoint.

const fetch = require('node-fetch')

const tokenEndpoint = 'https://oauth.accounts.firefox.com/v1/token'

const oauthToken = await fetch(tokenEndpoint, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    client_id: clientId,
    grant_type: 'authorization_code',
    code_verifier: codeVerifier,
    code
  })
})
  .then(res => res.json())

console.log(oauthToken)

We get back an object that includes the OAuth access token, as well as a refresh token if we specified access_type: 'offline' earlier.

This is great, but it doesnā€™t actually allows us to connect to Firefox Sync. Why? Because as we saw in the previous post, and specifically in the TokenServer documentation, we need the kid field of some kind of encryption key that we definitely donā€™t have.

To access the userā€™s Sync data using OAuth, the client must obtain an FxA OAuth access_token with scope https://identity.mozilla.com/apps/oldsync, and the corresponding encryption key as a JWK. They send the OAuth token in the Authorization header, and the kid field of the encryption key in the X-KeyID header.

This is not helping me a lot, but definitely just the access token we managed to get is not enough. Previously, when we had the userā€™s password and a session token for their Firefox Account, we could easily compute the Sync key and hash it to make the X-KeyID header with the keyRotationTimestamp, but with our OAuth token, we can do none of that anymore.

Getting scoped keys: the theory

By browsing the Firefox Ecosystem Platform, where I was already reading about how to integrate with Firefox Accounts, I find a page about becoming a Sync client.

Sadly, this page is not very useful at the moment.

Becoming a Sync client ridiculous documentation

When we first started playing with OAuth, we encountered a Bugzilla issue for the TokenServer to accept OAuth tokens. This issue contains a link to the OAuth flow spec on Google Docs titled ā€œScoped encryption keys for Firefox Accountsā€, and the first thing in there is a note that it now lives on the Firefox Ecosystem Platform.

It is nested in the ā€œtopic deep divesā€ category of the ā€œfor FxA engineersā€ group, which explains why I didnā€™t notice it before, and makes me feel like Iā€™m probably not the target audience of this document. šŸ˜†

Note: this document is approximately 6000 words. Thatā€™s about as long as one blog post in this series. Is it as useful though? Definitely.

Scoped encryption keys for Firefox Accounts is a masterpiece on end-to-end encryption, explaining in details how they derive scoped keys for third-party apps to encrypt the userā€™s data, in a way that algorithmically requires the userā€™s password, but without exposing it to the app in question. All of that with support for changing the primary password, as well as rotating and revoking keys.

While itā€™s very technical and requires some base cryptography knowledge, itā€™s a fantastic piece that I would definitely recommend reading carefully if youā€™re interested in this topic. Even if youā€™re new to cryptography, you might end up opening dozens if not hundreds of tabs to understand whatā€™s going on, but itā€™ll sure be worth the journey.

In this document, we notably read that Mozilla implemented an extension to OAuth in order to securely share encryption keys with third-party apps.

To achieve this, we propose an extension to the standard OAuth authorization flow by which relying applications can obtain encryption keys in a secure and controlled manner.

They describe this in the protocol flow section, which Iā€™ll sum up here (fasten your seatbelts).

  1. Generate a P-256 elliptic curve keypair (P-256 stands for ā€œ256-bit prime field Weierstrass curveā€, and is also known as secp256r1 and prime256v1) to be used for ECDH.
  2. Send the public key as a Base64URL encoded JWK in the OAuth parameters under keys_jwk.

This will make the token endpoint return not only the access and refresh tokens, but also a keys_jwe property. Itā€™s formatted with JWE compact serialization, meaning that we have 5 Base64URL encoded segments separated by .: a JSON header, an encryption key (empty in our case), the encryption IV, the ciphertext, and the AES-GCM authentication tag.

  1. Split the individual segments from keys_jwe and decode them.
  2. Use the ephemeral public key thatā€™s included in the JSON header as epk to perform ECDH against the private key of our initial P-256 keypair.

Note: according to Mozillaā€™s documentation, it seems that the key we just established with ECDH should allow to decrypt the ciphertext segment, which is a JWK of the application scoped key, including the kid field that we need to transmit to the TokenServer. In practice we just get unusable garbage, so something was definitely missing.

The following step is not documented by Mozilla. Itā€™s what the Rust code behind the browser and mobile apps is doing, as well as the code from the (now dead) Firefox Send app.

  1. Perform Concat KDF (Iā€™ll come back to this later) on the previously derived key using a carefully crafted OtherInfo buffer to obtain a symmetric key.
  2. Use that key to decrypt the ciphertext segment with AES-256-GCM, using the IV and authentication tag included in the payload, as well as the raw Base64URL encoded header as AAD.

The result JWK is our scoped key, and includes the kid field that we need to send to the TokenServer in the X-KeyID header. The symmetric key in the k field is the one weā€™ll be able to use to encrypt and decrypt the userā€™s data for that scope.

In the case of Firefox Sync, that k field is a 64 bytes key bundle, that can be decoded and split in two 32 bytes slices to obtain the Sync encryption key and HMAC key as weā€™ve done before.

Now thatā€™s a lot to unpack, so letā€™s go through all of this again in details, and this time with some actual code.

Getting scoped keys: the code

Letā€™s go back to the code to make the authorization URL and include the keys_jwk field, that we found about earlier.

Sending our ECDH public key in keys_jwk

First, we generate a P-256 elliptic curve keypair. It stands for ā€œ256-bit prime field Weierstrass curveā€, and itā€™s also known as secp256r1 and prime256v1.

const { promisify } = require('util')

const kp = await promisify(crypto.generateKeyPair)('ec', {
  namedCurve: 'P-256'
})

Then we serialize the public key as a Base64URL encoded JWK.

const publicJwk = kp.publicKey.export({ format: 'jwk' })
const keysJwk = Buffer.from(JSON.stringify(publicJwk)).toString('base64url')

Finally, we add it to the parameters of our initial example.

const params = {
  client_id: clientId,
  scope,
  state,
  code_challenge_method: 'S256',
  code_challenge: codeChallenge,
  access_type: 'offline',
  keys_jwk: keysJwk
}

Indeed, after going through the OAuth flow and inputting the result code, we now get back an extra keys_jwe parameter from the token endpoint! Iā€™ll continue from this step where we have the result of the token endpoint in a oauthToken variable.

Parsing keys_jwe

Because keys_jwe is formatted according to JWE compact serialization itā€™s made of 5 Base64URL encoded segments separated by a ., so letā€™s parse it.

const rawSegments = oauthToken.keys_jwe.split('.')
const rawHeader = rawSegments[0]
const segments = rawSegments.map(segment => Buffer.from(segment, 'base64'))
const header = JSON.parse(segments[0])
const iv = segments[2]
const ciphertext = segments[3]
const authTag = segments[4]

We left alone segments[1] because as we saw, itā€™s defined as an encryption key by the JWE format but is not used in this protocol.

The parsed header looks something like this:

{
  "enc": "A256GCM",
  "alg": "ECDH-ES",
  "kid": "IGJXkJzwHacMq2Qc52NZ_FBmt-uksqyXs8jC-pViIXM",
  "epk": {
    "kty": "EC",
    "crv": "P-256",
    "x": "UmI2Qm4DLbawF4E6UlmMvYAEomULFEBQiiJ7rxaQnY8",
    "y": "cSC0O-tPAeJXl2s-2ACCxN6wCpDRnhB_ginYIBmfTgU"
  }
}

The epk property contains a JWK representation of the public key matching the private key that Firefox Accounts used for its part of ECDH. Weā€™ll refer to it as peerKey. In combination with the private key from the initial P-256 keypair we created earlier in the kp variable, we can establish a shared secret through ECDH.

const peerKey = crypto.createPublicKey({
  key: header.epk,
  format: 'jwk'
})

const ikm = crypto.diffieHellman({
  privateKey: kp.privateKey,
  publicKey: peerKey
})

Weā€™ll name this shared secret ikm, for input keying material, as weā€™re effectively going to use it as input for a key derivation function.

Deriving the shared secret with Concat KDF

Here, we use Concat KDF, defined in more details in section 5.8.1 of NIST SP 800-56A ā€œThe single step key derivation functionā€ to derive that shared secret into the actual decryption key for our ciphertext.

Note: this process is not part of any public documentation at the time of writing, and is the result of more hours that Iā€™m willing to admit, going through Mozillaā€™s codebase on GitHub, between the mozilla, mozilla-lockwise, mozilla-mobile and mozilla-services organizations.

I was trying to understand specifically how the Lockwise mobile app manages to access the Firefox Sync passwords, and it took me a while to realize that the mobile apps were calling into native Rust code that was taking care of the heavy lifting for OAuth and encryption (especially because the snake case functions from the Rust code are converted to camel case in other languages).

The most interesting part was the handle_oauth_response, function, which calls into decrypt_keys_jwe, decrypt_jwe, derive_shared_secret, and finally get_secret_from_ikm where we get the Concat KDF implementation details.

I later found the implementation in Firefox Send which was also really useful to figure this out.

This answer on Stack Exchange gives a great overview of how Concat KDF works:

Concat KDF hashes the concatenation of a 4-byte counter initialized at 1 (big-endian), the shared secret obtained by ECDH, and some other information passed as input. The counter is incremented and the process is repeated until enough data was produced.

Since in our case the output key length is equal to the hash length (theyā€™re both 256 bits), we only need a partial implementation of Concat KDF that performs a single iteration and doesnā€™t bother trimming the output to the desired length.

// For readability, helper to return a big-endian unsigned 32 bits
// integer as a buffer.
function uint32BE (number) {
  const buffer = Buffer.alloc(4)
  buffer.writeUInt32BE(number)
  return buffer
}

function sha256 (buffer) {
  return crypto.createHash('sha256').update(buffer).digest()
}

// Partial implementation of Concat KDF that only does a single
// iteration and no trimming, because the length of the derived key we
// need matches the hash length.
function concatKdf (key, otherInfo) {
  return sha256(Buffer.concat([uint32BE(1), key, otherInfo]))
}

That being said, for fun, hereā€™s my understanding of a full Concat KDF function (Iā€™m not a cryptography expert though so get that properly reviewed if youā€™re going to use it).

See the implementation
function concatKdf (key, keyLengthBits, otherInfo) {
  const hashLengthBits = 256
  const hashLengthBytes = Math.ceil(hashLengthBits / 8)
  const keyLengthBytes = Math.ceil(keyLengthBits / 8)
  const out = Buffer.alloc(keyLengthBytes)
  const iterations = Math.ceil(keyLengthBytes / hashLengthBytes)

  for (let i = 0; i < iterations; i++) {
    const hash = sha256(Buffer.concat([uint32BE(i + 1), key, otherInfo]))
    const offset = hashLengthBytes * i
    hash.copy(out, offset)
  }

  return out
}

Anyways, we need to compute the OtherInfo parameter first. Concat KDF only defines it as a bit string (see section 5.8.1.2), but Mozilla crafts a very specific one that we need to reproduce. From their Rust code and the Firefox Send JavaScript code, I ended up with:

// Internal Mozilla format for Concat KDF `OtherInfo`, copied from
// Firefox Application Services and Firefox Send code.
const otherInfo = Buffer.concat([
  uint32BE(header.enc.length),
  Buffer.from(header.enc),
  uint32BE(0),
  uint32BE(0),
  uint32BE(256)
])
See the original code for comparison

In Rust:

fn get_secret_from_ikm(
    ikm: InputKeyMaterial,
    apu: &str,
    apv: &str,
    alg: &str,
) -> Result<digest::Digest> {
    let secret = ikm.derive(|z| {
        let mut buf: Vec<u8> = vec![];

        // Concat KDF (1 iteration since `keyLen <= hashLen`).
        // See RFC 7518 section 4.6 for reference.
        buf.extend_from_slice(&1u32.to_be_bytes());
        buf.extend_from_slice(&z);

        // `OtherInfo`
        buf.extend_from_slice(&(alg.len() as u32).to_be_bytes());
        buf.extend_from_slice(alg.as_bytes());
        buf.extend_from_slice(&(apu.len() as u32).to_be_bytes());
        buf.extend_from_slice(apu.as_bytes());
        buf.extend_from_slice(&(apv.len() as u32).to_be_bytes());
        buf.extend_from_slice(apv.as_bytes());
        buf.extend_from_slice(&256u32.to_be_bytes());

        digest::digest(&digest::SHA256, &buf)
    })?;
    Ok(secret)
}

In JavaScript:

const encoder = new TextEncoder()

function getOtherInfo (enc) {
  const name = encoder.encode(enc)
  const length = 256
  const buffer = new ArrayBuffer(name.length + 16)
  const dv = new DataView(buffer)
  const result = new Uint8Array(buffer)
  let i = 0

  dv.setUint32(i, name.length)
  i += 4
  result.set(name, i)
  i += name.length
  dv.setUint32(i, 0)
  i += 4
  dv.setUint32(i, 0)
  i += 4
  dv.setUint32(i, length)

  return result
}

We can now derive that OtherInfo together with the input key material we established earlier to get the decryption key.

const key = concatKdf(ikm, otherInfo)

Decrypting the ciphertext

Finally, we have everything we need to decrypt the ciphertext of the JWE that we got back earlier.

const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)

decipher.setAuthTag(authTag)
decipher.setAAD(rawHeader)

const keys = JSON.parse(Buffer.concat([
  decipher.update(ciphertext),
  decipher.final()
]))

console.log(keys)

This gives us something like this:

{
  "https://identity.mozilla.com/apps/oldsync": {
    "kty": "oct",
    "scope": "https://identity.mozilla.com/apps/oldsync",
    "k": "e_9j35zPyTng1QT1ioegeZxPQOVUS10FdMNV1YIZuJ8zJIvQ-OZMiHiy3tLCMcc_mKTEopDpjzS9kqq-FmS4og",
    "kid": "1628100899317-sLLG5AsHn9Fc1gPhW_rfaQ"
  }
}

If we had requested an application specific key, we would have gotten back a 32 bytes scoped key as defined in ā€œderiving scoped keysā€.

However since we requested the special scope https://identity.mozilla.com/apps/oldsync which is meant to give access to Firefox Sync in a backwards compatible way, itā€™s treated a bit differently and we get 64 bytes of key material, the same that we previously derived from the userā€™s primary key using HKDF in the deriveKeys function of our non-OAuth implementation.

The main difference is that here, the Firefox Accounts login page is the one to perform HKDF, so that we never get access to the userā€™s primary key, which is a cool security feature.

Putting it all together

Because the JWK we get back after decrypting keys_jwe from that custom OAuth dance contains the same 64 bytes of key material that we used to derive from the userā€™s primary key, it means that by splitting it in two 32 bytes slices, we get the exact same Sync encryption key and HMAC key than before.

As importantly, this JWK also contains the kid field which is the missing piece of the puzzle to be able to call the TokenServer in order to get the Firefox Sync API credentials.

To access the userā€™s Sync data using OAuth, the client must obtain an FxA OAuth access_token with scope https://identity.mozilla.com/apps/oldsync, and the corresponding encryption key as a JWK. They send the OAuth token in the Authorization header, and the kid field of the encryption key in the X-KeyID header.

const tokenServerUrl = 'https://token.services.mozilla.com'

const token = await fetch(`${tokenServerUrl}/1.0/sync/1.5`, {
  headers: {
    Authorization: `Bearer ${oauthToken.access_token}`,
    'X-KeyID': keys[scope].kid
  }
})
  .then(res => res.json())

From there, the rest of the code is going to be the same as our original BrowserID implementation!

The only difference is that we already have the Sync key bundle in our JWK, so we donā€™t need the deriveKeys function anymore. Instead, we only to need to split the k field to separate the encryption key from the HMAC key:

const rawBundle = Buffer.from(keys[scope].k, 'base64')

const syncKeyBundle = {
  encryptionKey: rawBundle.slice(0, 32),
  hmacKey: rawBundle.slice(32, 64)
}

This is enough to decrypt the response of storage/crypto/keys, which in turn gives us the keys to decrypt the userā€™s passwords, bookmarks and other collections.

The code, all the code!

If you were to take all the small blocks of code from this post and put them together, you would get something like this:

const { promisify } = require('util')
const crypto = require('crypto')
const qs = require('querystring')
const readline = require('readline')
const fetch = require('node-fetch')

const authorizationUrl = 'https://accounts.firefox.com/authorization'
const tokenEndpoint = 'https://oauth.accounts.firefox.com/v1/token'
const tokenServerUrl = 'https://token.services.mozilla.com'
const scope = 'https://identity.mozilla.com/apps/oldsync'
const clientId = 'e7ce535d93522896'

// For readability, helper to return a big-endian unsigned 32 bits
// integer as a buffer.
function uint32BE (number) {
  const buffer = Buffer.alloc(4)
  buffer.writeUInt32BE(number)
  return buffer
}

function sha256 (buffer) {
  return crypto.createHash('sha256').update(buffer).digest()
}

// Partial implementation of Concat KDF that only does a single
// iteration and no trimming, because the length of the derived key we
// need matches the hash length.
function concatKdf (key, otherInfo) {
  return sha256(Buffer.concat([uint32BE(1), key, otherInfo]))
}

async function main () {
  // To prevent CSRF attacks.
  const state = crypto.randomBytes(16).toString('base64url')

  // Dead simple PKCE challenge implementation.
  const codeVerifier = crypto.randomBytes(32).toString('base64url')
  const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url')

  // Keypair to obtain a shared secret from Firefox Accounts via ECDH.
  const kp = await promisify(crypto.generateKeyPair)('ec', {
    namedCurve: 'P-256'
  })

  const publicJwk = kp.publicKey.export({ format: 'jwk' })
  const keysJwk = Buffer.from(JSON.stringify(publicJwk)).toString('base64url')

  const params = {
    client_id: clientId,
    scope,
    state,
    code_challenge_method: 'S256',
    code_challenge: codeChallenge,
    access_type: 'offline',
    keys_jwk: keysJwk
  }

  const url = `${authorizationUrl}?${qs.stringify(params)}`

  console.log(url)

  const rl = readline.createInterface({ input: process.stdin, output: process.stdout })

  const code = await new Promise(resolve => rl.question('Code: ', resolve))
    .finally(() => rl.close())

  const oauthToken = await fetch(tokenEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      client_id: clientId,
      grant_type: 'authorization_code',
      code_verifier: codeVerifier,
      code
    })
  })
    .then(res => res.json())

  const rawSegments = oauthToken.keys_jwe.split('.')
  const rawHeader = rawSegments[0]
  const segments = rawSegments.map(segment => Buffer.from(segment, 'base64'))
  const header = JSON.parse(segments[0])
  const iv = segments[2]
  const ciphertext = segments[3]
  const authTag = segments[4]

  const peerKey = crypto.createPublicKey({
    key: header.epk,
    format: 'jwk'
  })

  const ikm = crypto.diffieHellman({
    privateKey: kp.privateKey,
    publicKey: peerKey
  })

  // Internal Mozilla format for Concat KDF `OtherInfo`, copied from
  // Firefox Application Services and Firefox Send code.
  const otherInfo = Buffer.concat([
    uint32BE(header.enc.length),
    Buffer.from(header.enc),
    uint32BE(0),
    uint32BE(0),
    uint32BE(256)
  ])

  const key = concatKdf(ikm, otherInfo)
  const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)

  decipher.setAuthTag(authTag)
  decipher.setAAD(rawHeader)

  const keys = JSON.parse(Buffer.concat([
    decipher.update(ciphertext),
    decipher.final()
  ]))

  const token = await fetch(`${tokenServerUrl}/1.0/sync/1.5`, {
    headers: {
      Authorization: `Bearer ${oauthToken.access_token}`,
      'X-KeyID': keys[scope].kid
    }
  })
    .then(res => res.json())

  const rawBundle = Buffer.from(keys[scope].k)

  const syncKeyBundle = {
    encryptionKey: rawBundle.slice(0, 32),
    hmacKey: rawBundle.slice(32, 64)
  }

  console.log(token)
  console.log(syncKeyBundle)
}

main()

From there, you have everything you need to actually call Firefox Sync and decrypt its responses. This piece of code is already long enough so I wonā€™t include that part again here.

Needless to say this code is not production ready, and can be considered to be more of an academic resource for learning purpose. If youā€™re going to use it, please refactor it in a more maintainable way and add proper error handling! Also if you need help integrating Firefox Accounts to your app, feel free to reach out, Iā€™m open to contracting work.

A note about granular scopes

Mozillaā€™s OAuth scopes documentation mentions a set of scopes that would allow granular access to the Sync data, for instance https://identity.mozilla.com/apps/oldsync/bookmarks to get access to the userā€™s bookmarks data only but not other collections, https://identity.mozilla.com/apps/oldsync#read to get read-only access, or even https://identity.mozilla.com/apps/oldsync/history#write for write-only access to just the history collection.

Because the encryption key is shared by every Firefox Sync client, we can decrypt any of the userā€™s Sync collections, even if we had requested a more restricted scope.

The permissions are instead implemented at the API level, so that for instance, a Sync client with a ā€œbookmarksā€ scope in its OAuth token cannot retrieve data from the ā€œhistoryā€ collection, as explained here:

The existing Sync service does not support using different encryption keys to access different subsets of its data, so we must give the same key material for scope sync or for sync:bookmarks. But we can enforce access restrictions at the service level by ensuring that an access token with scope sync:bookmarks cannot be used to retrieve the userā€™s encrypted history data.

Similarly, I assume that read/write restrictions are also ensured at the service level, by only allowing GET requests for clients with a #read scope, or POST, PUT and DELETE for #write clients.

In practice though, I couldnā€™t find any such restriction in syncstorage-rs, the code behind the Firefox Sync API. I also found out that I couldnā€™t request granular scopes during the OAuth process. When I requested a collection-specific scope, a read-only or a write-only scope, the OAuth token endpoint didnā€™t return a keys_jwe even though I specified keys_jwk as part of the authorization parameters.

This makes me think that while the granular scopes are documented, theyā€™re not yet implemented, and the only way to get access to Firefox Sync data right now is to request full access.

Please let me know if I missed something, or if this was to change!

A note about client ID and redirect URL

In this post, for convenience and learning purpose, we used the public OAuth client ID from the Android app, because weā€™d otherwise need to email Mozilla to ā€œdocument our expectations and timelinesā€ to get proper OAuth credentials.

As we saw, Iā€™m not interested in documenting my expectations and timeline. This means that we need to inspect the network traffic during the OAuth redirect to harvest the authorization code and inject it back in our script. But if youā€™re reading this, you might be have a real-life application that justifies getting your own OAuth credentials. If itā€™s your case, everything you need to know is here.

Lastly, even if I could configure my own OAuth redirect URL, the current flow doesnā€™t make it convenient to build a CLI or embedded application. Maybe accessing Firefox Sync collections from a TV, toaster, or a remote headless server isnā€™t the top use cases one would think of, but I wouldnā€™t be surprised if someone came up with applications for those.

For example, Google have an alternative flow for limited input devices which works by displaying a short URL and code to the user to grant permissions to an embedded application from a more capable device.

They also support a copy/paste method where instead of being redirected, the user gets back a code to paste in the application, making it convenient for a CLI running on a remote headless server, where you otherwise would need to setup SSH port forwarding or similar to support a loopback redirect URL.

While Google discourages this method, it solves a real use case for me and I think that itā€™s a good inspiration for future improvements to the Firefox Accounts OAuth flow. The limited input device method is also a nice fallback!

A note about security, and moreā€¦

I strongly believe that proper security is achieved by ensuring that the most secure options are the easiest to implement and to use by design. Obviously this is idealistic and not always possible.

In the case of Lockwise and Firefox Sync, not only the OAuth method was far from obvious to learn about, but I think that the manual process required to create new OAuth clients adds some unnecessary friction to building a more secure web.

While the complete OAuth flow we explored in this posts brings great security improvements by introducing a mechanism to grant third-party access to the userā€™s data without revealing their password and primary key, it is in practice much harder to implement than the Firefox Accounts ā€œgod modeā€ session token that I started with, which also happens to comes with a fully featured JS client and easy to find real-world examples online.

Overall, it feels to me that Mozilla doesnā€™t expect people to be interested in building third-party apps off Firefox Sync. For example in this response on GitHub they seem surprised that someone would try to authenticate to Firefox Accounts from their own code, or in this email thread reply, Ryan is curious about why one would want to programmatically access Firefox Sync data, and the fact the scoped encryption keys documentation is hidden in a ā€œfor FxA engineersā€ section shows that they only expect Mozillians to build off those blocks.

Itā€™s undeniable that the openness of Letā€™s Encrypt (alright, also the fact itā€™s free) greatly contributed to its whopping success, and the literally hundreds of third-party clients and integrations demonstrate that. I think that the ecosystem Mozilla built around Firefox Accounts and Firefox Sync features some awesome pieces of technology and infrastructure that are well worth basing off and extending, and I could see it having a similar success if it was more inviting to integrate with.

Closing thoughts

After nearly 20,000 words, this is the end of this in-depth exploration of Lockwise, Firefox Sync and their underlying APIs and protocols.

This series is the result of the distillation of FORTY TWO pieces of content online as well as learning from the code of 11 different repositories.

While I thought that I would just quickly put together a CLI app to my access my Lockwise passwords, my quest for perfection pushed me to continue and spend a whole month to grasp all the details of the protocol, and not only have functional code to read and write to Firefox Sync, but more importantly write everything down in a comprehensive way, that I believe is the most up-to-date, accurate and practical documentation on the topic as of the time of writing.

In this last post, we saw how to leverage the full OAuth flow to access Firefox Sync collections without ever gaining access to the userā€™s password and primary key, effectively delegating more of the security responsibilities to Firefox Accounts.

Generally, we gained a deep understanding of how Firefox Accounts and Firefox Sync implement end-to-end encryption for its user data, and how they do so in a way thatā€™s reviewable, and that we tested by interacting at a low-level with their APIs and encryption schemes. While Iā€™m not qualified to tell if this is bulletproof for any possible threat model, I definitely feel even more confident using Lockwise as my password manager after reviewing its underlying implementation.

Iā€™m grateful for Mozilla to share their source code and publicly document the protocols they use and designed, even if it was not always perfectly accurate or up-to-date. Their work allowed me to deepen my understanding of cryptography in order to build a compatible client, and I believe that the schemes behind Firefox Accounts and Sync encryption are exemplary in respect to encrypting user data end-to-end, without sacrificing too much (if any) convenience or flexibility.

Bonus: references

Letā€™s use the following script to extract all the references from this series.

grep -Eoh '[(<]http[^)]+[)>]' 2021/08/scripting-firefox-sync-lockwise-* \
  | sed 's/^.//;s/.$//' | sed 's/#.*$//' \
  | sort | uniq -c

With some quick filtering and formatting, hereā€™s the list!

Documentation

References Domain Name
1 auth0.com Confidential and public applications
1 auth0.com Authorization code flow with proof key for code exchange (PKCE)
1 auth0.com Prevent attacks and redirect users with OAuth 2.0 state parameters
1 base64.guru Base64URL
3 bugzilla.mozilla.org Accept FxA OAuth tokens for authorization in TokenServer
1 crypto.stackexchange.com How does the Concat KDF work?
1 datatracker.ietf.org Using AES-CCM and AES-GCM authenticated encryption in the cryptographic message syntax (CMS)
2 datatracker.ietf.org JSON Web Encryption (JWE)
3 datatracker.ietf.org JSON Web Key (JWK)
4 datatracker.ietf.org JSON Web Algorithms (JWA)
1 datatracker.ietf.org Proof key for code exchange by OAuth public clients
2 docs.google.com Scoped encryption keys for Firefox Accounts
1 en.wikipedia.org Elliptic curve Diffieā€“Hellman
1 github.com Firefox Accounts OAuth server API
2 github.com onepw protocol
9 github.com Firefox Accounts authentication server API
1 github.com Firefox Accounts OAuth server API
2 github.com OAuth scopes
1 github.com Firefox Accounts scoped key relier documentation
1 github.com Unable to sign in with fxa-js-client
1 github.com Unified fxa-auth-client
3 github.com Hawk introduction
11 github.com BrowserID specification
1 hacks.mozilla.org Introducing BrowserID ā€“ easier and safer authentication on the web
1 medium.com How Firefox Sync keeps your secrets if TLS fails
8 mozilla.github.io Scoped encryption keys for Firefox Accounts
1 mozilla.github.io Becoming a Sync client
9 mozilla.github.io Integration with FxA
1 mozilla.github.io BrowserID specs - public key format
4 mozilla-services.readthedocs.io SyncStorage API v1.5
1 mozilla-services.readthedocs.io Sync client documentation
5 mozilla-services.readthedocs.io Global storage version 5
2 neuromancer.sk Standard curve database - P-256
3 nodejs.org Node.js documentation - crypto
2 nvlpubs.nist.gov Recommendation for pair-wise key establishment schemes using discrete logarithm cryptography
1 openid.net OpenID Connect
1 stackoverflow.com Firefox Sync API - does it exist?
3 vladikoff.github.io About Firefox Accounts
1 mail-archive.com Getting X-KeyID
1 mail-archive.com Re: Getting X-KeyID
4 npmjs.com browserid-crypto
3 npmjs.com fxa-js-client

Code

References Path
1 mozilla/application-services/components/fxa-client/src/internal/oauth.rs
1 mozilla/application-services/components/fxa-client/src/internal/scoped_keys.rs
5 mozilla/application-services/components/support/jwcrypto/src/ec.rs
3 mozilla/browserid-crypto/lib/algs/ds.js
3 mozilla/browserid-crypto/lib/algs/rs.js
1 mozilla/fxa/packages/fxa-auth-server/lib/routes/sign.js
2 mozilla/fxa/packages/fxa-js-client/client/FxAccountClient.js
1 mozilla/fxa/packages/fxa-auth-server/lib/routes/oauth/key_data.js
1 mozilla/fxa/packages/fxa-auth-server/lib/oauth/validators.js
2 mozilla/fxa/packages/fxa-auth-server/lib/routes/oauth/token.js
2 mozilla/fxa/packages/fxa-auth-server/lib/routes/validators.js
2 mozilla/fxa-crypto-relier/src/deriver/ScopedKeys.js
2 mozilla/fxa-crypto-relier/src/relier/OAuthUtils.js
1 mozilla/fxa-crypto-relier/src/relier/util.js
2 mozilla/fxa/packages/fxa-auth-client
1 mozilla/fxa/packages/fxa-auth-server
1 mozilla/fxa/packages/fxa-content-server
1 mozilla-lockwise/lockwise-android
1 mozilla/PyFxA
1 mozilla/PyFxA/fxa/core.py
3 mozilla/send/app/fxa.js
3 mozilla-services/syncclient
1 mozilla-services/syncclient/syncclient/client.py
3 mozilla-services/syncstorage-rs
19 mozilla-services/tokenserver
1 mozilla-services/tokenserver/tokenserver/views.py
5 zaach/node-fx-sync

Check out the other posts in this series!

  1. A journey to scripting Firefox Sync / Lockwise: existing clients
  2. A journey to scripting Firefox Sync / Lockwise: figuring the protocol
  3. A journey to scripting Firefox Sync / Lockwise: understanding BrowserID
  4. A journey to scripting Firefox Sync / Lockwise: hybrid OAuth
  5. A journey to scripting Firefox Sync / Lockwise: complete OAuth

Want to leave a comment?

Join the discussion on Twitter or send me an email! šŸ’Œ
This post helped you? Buy me a coffee! šŸ»