A journey to scripting Firefox Sync / Lockwise: hybrid OAuth
Impersonating the Android app to replace deprecated BrowserID with OAuth
August 8, 2021
Impersonating the Android app to replace deprecated BrowserID with OAuth
August 8, 2021
This article is part of a series about scripting Firefox Sync / Lockwise.
Welcome to the last post of this series about scripting Firefox
Sync! So far weāve managed to run the Sync clients we found in the wild
(dating from 8 years ago), and taking inspiration from them, plus all
the available documentation online, we built our own client,
which required us to deconstruct the BrowserID protocol.
And while I was pretty satisfied with this, there was still one little thing bugging me.
See, while I was reading everything possible online about the Firefox
Accounts, Firefox Sync and BrowserID protocols in order to make this
work, including the code of the production clients and servers involved,
I stumbled upon this comment
in the Firefox Accounts server /certificate/sign
endpoint, that we use
to sign a BrowserID public key and get back a certificate:
// This is a legacy endpoint that's typically only used by clients // connected to Sync, so assume `service=sync` for metrics logging // purposes unless we're told otherwise.
// This is a legacy endpoint that's typically only used by clients // connected to Sync.
// This is a legacy endpoint.
I donāt like the idea of using a legacy endpoint when writing new code. There must be something better.
While the TokenServer documents OAuth as an alternative to BrowserID to get credentials, itās unclear how to use it. All that page says is āthe client must obtain an OAuth access token and the corresponding encryption key as a JWKā, but doesnāt mention where to get the OAuth token and the corresponding key.
The BrowserID instructions werenāt necessarily clearer, but at least it had the competitive advantage of having multiple working implementations in the wild that made it easier to understand how it works. OAuth was a different kind of beast.
The Firefox Ecosystem Platform documents how to integrate with Firefox Accounts using OAuth, but the first thing we can read there is:
Before starting integration, please send a request to fxa-staff[at]mozilla.com to request a short meeting so we can all document our expectations and timelines.
Follow a bit later by:
Register for staging OAuth credentials by filing a deployment bug.
The last thing I want to do is send an email to Mozilla to document my expectations, and file a bug to get credentials. I just want to programmatically access my Firefox Sync data!
By looking up fxa browserid oauth
on Google, one of the countless
searches I made to try and understand whatās going on, I found this document,
which states a couple more things.
All new relying services should integrate with Firefox Accounts via the OAuth 2.0 API. There is also a legacy API based on the BrowserID protocol, which is available only in some Firefox user agents and is not recommended for new applications.
This confirms what I had only read in a code comment on the Firefox Accounts server so far. The BrowserID protocol is indeed deprecated, and itās not just the browserid-crypto package thatās unmaintained as I initially thought.
The OAuth 2.0 API is the preferred method of integrating with Firefox Accounts. To delegate authentication to Firefox Accounts in this manner, you will first need to register for OAuth relier credentials, then add support for a HTTP redirection-based login flow to your service.
Firefox Accounts integration is currently recommended only for Mozilla-hosted services. We are exploring the possibility of allowing non-Mozilla services to delegated authentication to Firefox Accounts, and would welcome discussion of potential use cases on the mailing list.
This matches the documentation I found earlier about the fact that we need to contact Mozilla in order to register for OAuth credentials. At that point it seems like a dead end, and Iām considering to build my client on top of the legacy BrowserID API, since it still works after all, and I spent so much time to understand it in depth anyways.
Going back to the OAuth 2.0 API
link from the former quote, this points to a page in an archived repo on
Github, mozilla/fxa-oauth-server/docs/api.md
,
itself saying that the page moved to mozilla/fxa-auth-server/fxa-oauth-server/docs/api.md
,
which is also an archived repo, and with no link this time.
I noticed before that Mozilla archived many of its repos because they
moved to a monorepo in mozilla/fxa
,
which contains the latest version of fxa-auth-server
and its API,
an API that I had already encountered multiple times since the beginning
of this series. Maybe thereās some hope?
We do have a whole OAuth section in there, but I pretty much instantly hit a wall when I see that all those endpoints require an OAuth client ID, which is part of the OAuth credentials Iām supposed to email Mozilla in order to get.
It feels like Iām going in circles. š§
client_id
from the Android appWhile those endpoints require a client ID, itās not necessary to provide
a client secret for public clients (see OAuth grant types).
And because the client ID of public clients isā¦ public, we should be
able to easily borrow one from any of the public clients out there (for
instance, mobile apps), whether itās from the source code, by
decompiling the app, or by inspecting its traffic. And indeed, there is
one directly in the lockwise-android
repo!
With that client ID in hand, we can start playing with the endpoints we found earlier. At first, Iām thinking that I have to do some kind of OAuth login dance, with the usual redirect URL and code challenge, but it turns out itās not a requirement if we already have a session token, which we do by logging in directly with the userās credentials.
That part was not documented anywhere else, probably because itās not meant to be used by third-party developers like me, but more by the code behind accounts.firefox.com.
The fxa-js-client Iām using
even has a method for it,
where I can specify a scope
of https://identity.mozilla.com/apps/oldsync
as documented,
and I do get back a valid OAuth token. Sweet.
const scope = 'https://identity.mozilla.com/apps/oldsync'
const oauthToken = await client.createOAuthToken(creds.sessionToken, clientId, { scope })
X-KeyID
headerNow thatās not enough to authenticate to the TokenServer, I also need to pass
āthe kid
field of the encryption key in the X-KeyID
headerā.
This is not really obvious to me because I donāt have a kid
field in
any of the encryption keys I have been manipulating so far. I found out
later where that kid
should otherwise come from, and let me tell you
it was a whole journey of its own. Iāll develop that in the 5th post of this series.
Wait, didnāt I say earlier that this one was the last post?
When googling firefox sync "x-keyid"
, there was essentially 3 results.
sync-dev@mozilla.org
mailing list on mail-archive.com,
not a specific thread but it turned out that the latest messages on the list before it gets migrated to Google Groups earlier this year were about the X-KeyID
header.The second link doesnāt say anything about X-KeyID
, and while it links
a few other resources, they donāt mention this header either (but they
turned out to be critical when I later tried to implement the full OAuth flow).
Finally, in the mailing list thread, the OP
seems to be trying to do exactly the same thing as me, and they
apparently got a step further because they have a whole algorithm to
compute the X-KeyID
header, involving a number of parameters I donāt
have and some key derivation logic. Iām not sure where that algorithm
comes from, but whatās clear from the mail is that itās not working.
Ryan delivers one more time by replying with an explanation and a link to the production code generating the said key.
For legacy backwards compatibility reasons, the key-derivation for Sync is different than the derivation for general FxA scoped keys. The simplest way to explain the differences is probably to link to the code we have here, which does the derivation.
This is both a good and a bad news for me.
The good news is that with the algorithm from the original message of
the thread, plus the code in Ryanās link, I should be able to generate a
working X-KeyID
which might be all I need to make my OAuth version work!
The bad news is that this key deriving code Iām going to rely on is
called _deriveLegacySyncKey
, and you know form the beginning of this
very post that I donāt like using code thatās called ālegacyā, because
it most necessarily means that thereās a better alternative.
But letās put that aside for now. This will be an adventure for another day. For now weāre so close to getting this code work that I canāt just move on to something else right now.
keyRotationTimestamp
I start with the Python code form the original email:
kid = str(keyRotationTimestamp) + '-' + base64.urlsafe_b64encode(tmp[:16]).decode('utf-8').rstrip('=')
The first thing we see is that we need keyRotationTimestamp
, and I
happen to not have encountered anything named keyRotationTimestamp
yet.
I look it up on the API documentation of Firefox Accounts which Iām already on, hoping that itās returned by some endpoint there but no luck.
What follows is a number of searches:
keyRotationTimestamp
keyRotationTimestamp mozilla
keyRotationTimestamp site:github.com
Followed by me searching that string directly on all of GitHub and browsing the first couple pages of results (out of 49) without luck.
Then I tried to scope my search to the mozilla/fxa
repo
which had been a good source of information in the past, and bingo! The
first result is from the /account/scoped-key-data
endpoint of fxa-auth-server
(you know, the Firefox Accounts server), which do return the
keyRotationTimestamp
! Itās just that the documentation I checked
earlier doesnāt include the details of the payload for this endpoint.
This one too, has a neat matching function in fxa-js-client and I can move to the next step.
X-KeyID
headerLetās get back one more time to the Python code from the email weāre trying to adapt.
kid = str(keyRotationTimestamp) + '-' + base64.urlsafe_b64encode(tmp[:16]).decode('utf-8').rstrip('=')
Now we figured the first part, the rest is the Base64URL representation
of the first 16 bytes of tmp
, which is the result of some key
derivation. According to the email thread, the key derivation part
isnāt working, so weāre not going to try to port it, but Base64URL
encoding the first 16 bytes of a key reminds me of something.
We can also see this pattern in the code Ryan pointed to in order to address the key derivation issue:
scopedKey.kid = options.keyRotationTimestamp + '-' + base64url(kHash.slice(0, 16))
While itās not immediately clear to me what kHash
is, the
base64url(kHash.slice(0, 16))
is again awfully familiar. It is exactly
what we used to do to compute the X-Client-State
header
for the BrowserID version!
const clientState = sha256(syncKey).slice(0, 16).toString('hex')
This is especially promising since the Python code behind the TokenServer
also calls it client_state
:
kid = request.headers.get('X-KeyID')
keys_changed_at, client_state = parse_key_id(kid)
So I try to combine the keyRotationTimestamp
I just retrieved with the
hexadecimal representation of my previous X-Client-State
, and guess
what. It works!
The great news is that this not only makes our code use the latest and greatest way to connect to Firefox Sync, without relying on anything legacy or deprecated, but it also makes our implementation much simpler!
Hereās the updated version of the code we previously built in this series, up to getting the Sync token from the TokenServer. Calling the Sync API from there is not affected, so I wonāt include it again here.
const crypto = require('crypto')
const fetch = require('node-fetch')
const AuthClient = require('fxa-js-client')
const authServerUrl = 'https://api.accounts.firefox.com/v1'
const tokenServerUrl = 'https://token.services.mozilla.com'
const scope = 'https://identity.mozilla.com/apps/oldsync'
const clientId = '...'
const email = '...'
const pass = '...'
async function main () {
const client = new AuthClient(authServerUrl)
const creds = await client.signIn(email, pass, {
keys: true,
reason: 'login'
})
const accountKeys = await client.accountKeys(creds.keyFetchToken, creds.unwrapBKey)
const oauthToken = await client.createOAuthToken(creds.sessionToken, clientId, { scope })
const scopedKeyData = await client.getOAuthScopedKeyData(creds.sessionToken, clientId, scope)
const syncKey = Buffer.from(accountKeys.kB, 'hex')
const clientState = crypto.createHash('sha256').update(syncKey).digest().slice(0, 16).toString('base64url')
const keyId = `${scopedKeyData[scope].keyRotationTimestamp}-${clientState}`
// See <https://github.com/mozilla-services/tokenserver#using-oauth>.
const token = await fetch(`${tokenServerUrl}/1.0/sync/1.5`, {
headers: {
Authorization: `Bearer ${oauthToken.access_token}`,
'X-KeyID': keyId
}
})
.then(res => res.json())
}
main()
Hereās the equivalent code from the previous article for comparison:
const { promisify } = require('util')
const crypto = require('crypto')
const fetch = require('node-fetch')
const AuthClient = require('fxa-js-client')
const njwt = require('njwt')
const authServerUrl = 'https://api.accounts.firefox.com/v1'
const tokenServerUrl = 'https://token.services.mozilla.com'
const email = '...'
const pass = '...'
function base64to10 (data) {
return BigInt('0x' + Buffer.from(data, 'base64').toString('hex')).toString(10)
}
async function main () {
const client = new AuthClient(authServerUrl)
const creds = await client.signIn(email, pass, {
keys: true,
reason: 'login'
})
const accountKeys = await client.accountKeys(creds.keyFetchToken, creds.unwrapBKey)
const kp = await promisify(crypto.generateKeyPair)('rsa', {
modulusLength: 2048
})
const jwk = kp.publicKey.export({ format: 'jwk' })
const publicKey = {
algorithm: jwk.algorithm.slice(0, 2),
n: base64to10(jwk.n),
e: base64to10(jwk.e)
}
// Time interval in milliseconds until the certificate will expire, up to a
// maximum of 24 hours as documented in <https://github.com/mozilla/fxa/blob/f6bc0268a9be12407456fa42494243f336d81a38/packages/fxa-auth-server/docs/api.md#request-body-32>.
const duration = 1000 * 60 * 60 * 24
const { cert } = await client.certificateSign(creds.sessionToken, publicKey, duration)
// Generate an "identity assertion" which is a JWT as documented in
// <https://github.com/mozilla/id-specs/blob/prod/browserid/index.md#identity-assertion>.
const signedObject = njwt.create({ aud: tokenServerUrl, iss: authServerUrl }, kp.privateKey, 'RS256')
.setClaim('exp', Date.now() + duration)
.compact()
// Certs are separated by a `~` as documented in <https://github.com/mozilla/id-specs/blob/prod/browserid/index.md#backed-identity-assertion>.
const backedAssertion = [cert, signedObject].join('~')
// See <https://github.com/mozilla-services/tokenserver#using-browserid>.
const syncKey = Buffer.from(accountKeys.kB, 'hex')
const clientState = crypto.createHash('sha256').update(syncKey).digest().slice(0, 16).toString('hex')
const token = await fetch(`${tokenServerUrl}/1.0/sync/1.5`, {
headers: {
Authorization: `BrowserID ${backedAssertion}`,
'X-Client-State': clientState
}
})
.then(res => res.json())
}
main()
But while this is a solid improvement from what we had previously built since the beginning of this series, thereās still a bit more to unwrap.
You can probably tell by now that I love digging into rabbit holes and Iām eternally unsatisfied.
While I thought that I had found the last piece of the puzzle with the OAuth method described in this post, in order to integrate with Firefox Sync as cleanly as possible, it occurred to me that something was off as I was trying to explain it.
One of the main benefits of OAuth is to be able to grant granular permissions to a third-party service, without giving them knowledge of your password.
Yet with the solution from this post, not only do we still need to have knowledge of the userās password in order to login with the email/password scheme and derive the Sync encryption keys, but this method also grants us a Firefox Accounts session token which allows us to do virtually anything to that user account through the API. This defeats both advantages of OAuth mentioned above; permissions are not granular and we have access to the plaintext password.
This authentication scheme is even referred by Ryan as āgod modeā in a comment on the OAuth flow spec on Google Docs (that I encountered earlier through a Bugzilla issue).
Imagine a third-party browser that does its own Sync implementation, being able to authenticate to our Sync service using standard OAuth-style flow rather than the āgod modeā integration that [they] currently do, where they basically prompt for full access to your account.
This is a concern thatās also addressed in the introduction of the document:
Key material can only be accessed through a bespoke authorization protocol that is [ā¦] far too powerful. The protocol gives the application complete control of the userās Firefox Account, and hands it a copy of their master key material. There is currently no provision for scoping access down to a subset of data or capabilities.
In the final article (for real this time, I promise) weāll see how to use the full OAuth flow to authenticate to Firefox Accounts and access Firefox Sync, so that we never have knowledge of the userās password, and request only the permissions that we need instead of full access.
Check out the other posts in this series!