A journey to scripting Firefox Sync / Lockwise: understanding BrowserID
Migrating from unmaintained browserid-crypto (jwcrypto
) to a generic implementation
August 8, 2021
Migrating from unmaintained browserid-crypto (jwcrypto
) to a generic implementation
August 8, 2021
This article is part of a series about scripting Firefox Sync / Lockwise.
In the previous post we made a script that is able to fetch and decrypt collections from Firefox Sync, including Lockwise passwords. But one thing was still bugging me. When running the code, the console was showing a deprecation warning.
[DEP0005] DeprecationWarning: Buffer() is deprecated due to security and usability issues. Please use the Buffer.alloc(), Buffer.allocUnsafe(), or Buffer.from() methods instead.
By running node --trace-warnings
, I could see that it was coming from
the browserid-crypto
package. It was trivial to fix by migrating
from the deprecated new Buffer()
constructor to Buffer.from()
, so I
made another PR
for this.
Like the first PR, it’s reviewed and approved by Ryan (who’s name I saw countless times when researching about Firefox Accounts and Sync protocols). He also notes that this library is unmaintained:
FWIW, it would be best to consider this library unmaintained at this point, but I’m happy to take small fixes like this all the same.
While there’s nothing wrong with using an unmaintained library if it gets the job done, I took this as a challenge to implement the protocol using only native (or more generic, well-maintained) modules, and this is going to be the topic of this blog post!
Let’s start from the script we previously built and extract the part that use the browserid-crypto package.
const { promisify } = require('util')
const jwcrypto = require('browserid-crypto')
const kp = await promisify(jwcrypto.generateKeypair)({ algorithm: 'DS', keysize: 256 })
// Also works with RSA.
// const kp = await promisify(jwcrypto.generateKeypair)({ algorithm: 'RS', keysize: 256 })
// 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, kp.publicKey.toSimpleObject(), 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 = await promisify(jwcrypto.assertion.sign)(
{},
{
audience: tokenServerUrl,
issuer: authServerUrl,
expiresAt: Date.now() + duration
},
kp.secretKey
)
// 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('~')
There are 3 steps here:
crypto
module./certificate/sign
endpoint.This is the easy part. The main difference is that while
jwcrypto.generateKeyPair
takes an internal keysize
parameter, which
they map to RSA,
and DSA key sizes,
we need to explicitly give the RSA key size (usually synonymous of the
modulus length).
In our case, a BrowserID RSA key of “256” corresponds to a 2048-bit RSA key and similarly, a BrowserID DSA key of “256” corresponds to a 2048-bit DSA key. They also both specify SHA-256 as the JWT hash algorithm, which will be useful for later.
const { promisify } = require('util')
const crypto = require('crypto')
// With RSA
const kp = await promisify(crypto.generateKeyPair)('rsa', {
modulusLength: 2048
})
// With DSA
const kp = await promisify(crypto.generateKeyPair)('dsa', {
modulusLength: 2048,
divisorLength: 256
})
Note: for DSA, the BrowserID key has a divisor length of 256 bits
(the q
parameter), and this is especially important as
browserid-crypto and the TokenServer don’t accept any other divisor
length for a key size of 2048.
Where it gets a bit more tricky, especially for DSA, is when we want to encode the key as JSON for the Firefox Accounts API to sign:
const { cert } = await client.certificateSign(creds.sessionToken, kp.publicKey.toSimpleObject(), duration)
The keys from browserid-crypto conveniently include a toSimpleObject
function that formats the key in the BrowserID JSON format. I couldn’t
find documentation for it, but from looking at the actual JSON objects,
it is very similar to (but not compatible with) the JWK
format.
BrowserID was introduced in 2011, well before the JWK specification was proposed in 2015. They both encode the low-level key parameters in a JSON object, and there is just a couple of differences, especially:
kty
property (set to RSA
for
RSA keys),while BrowserID uses an algorithm
property that can be
RS
or DS
.The RSA parameters
are n
(modulus) and e
(exponent), as well as a fuckton of other parameters
for private keys (which we don’t need here). You can see them on an
existing key with openssl rsa -in rsa-private-key.pem -text -noout
.
We can take the earlier code to generate a RSA keypair, and export the public key as a JWK like this:
const { promisify } = require('util')
const crypto = require('crypto')
const kp = await promisify(crypto.generateKeyPair)('rsa', {
modulusLength: 2048
})
console.log(kp.publicKey.export({ format: 'jwk' }))
{
"kty": "RSA",
"n": "3M852Cy7DIH1wYJVgRxQfDYPa26fC4KR4uYmHeGV7rTtiQ2-IdypkOQd6Clp01-J4L9e28w-3hR06ZWKRMIbfyajcer1bd_9luBKkRiFlYxa-CBNTlOJBmtej7MbouQJdqcxRIHufk7R4HBWYzR8H1WUDzJfIZJLxz2eymTNXu7CPFyDoNZXQ9SRu7tzPzhUsDrkdpNSs2x8tRrllJRiO-BOC2Ce3W5vCE9eB91VFuIOHOuL5y-Fr6K-vCfvpLBzoF2uk399ZGxZ8rLXHk01QDoin3BVXQzGBKNXoVNrNe-tKflp5QJ5wMifvL4tPfCCrps8rrfbE1NDPE2x1QmCfQ",
"e": "AQAB"
}
On the other hand, a BrowserID RSA public key looks like this:
const jwcrypto = require('browserid-crypto')
require('browserid-crypto/lib/algs/rs')
jwcrypto.generateKeypair({ algorithm: 'RS', keysize: 256 }, (err, { publicKey }) => {
console.log(publicKey.toSimpleObject())
})
{
"algorithm": "RS",
"n": "24561144013955114361783231655761853176741812326893374232205401875943449227620158204608340216900927757193227109312970662811636219675773452185909191206484694392560433664701055247500397746104758184735693308844235833317883872067955852418577691056051019648528118784214798195301896767050575864274186910237901534713406182369363255235410257674380032656581487055343920363852506722639241918085307849979198768941882638020102729524988683333585179817471524571511030397962907590237048329319430881173155778553010801560573247170682531231684185163187096747308113243183139470492492221024173487301503496674419087411376160055924262029047",
"e": "65537"
}
It is easy to convert a native JWK to the BrowserID format, as we can
leverage BigInt
to output a large base 10 string. But the constructor doesn’t accept
Base64 directly, so we need to do an intermediate conversion through
hexadecimal.
const { promisify } = require('util')
const crypto = require('crypto')
function base64to10 (data) {
return BigInt('0x' + Buffer.from(data, 'base64').toString('hex')).toString(10)
}
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)
}
The DSA parameters
are p
(larger prime modulus), q
(smaller prime modulus), g
(generator), y
(public group element), as well as x
(private
exponent) for private keys.
Since JWK doesn’t support DSA, we cannot do kp.publicKey.export({ format: 'jwk' })
with a DSA key generated by the crypto
module, as that would fail with
[ERR_CRYPTO_JWK_UNSUPPORTED_KEY_TYPE]: Unsupported JWK Key Type
.
With browserid-crypto, we can replace RS
by DS
in the previous
example to generate the following key, and get a better idea of what
we’re trying to reproduce:
const jwcrypto = require('browserid-crypto')
require('browserid-crypto/lib/algs/rs')
jwcrypto.generateKeypair({ algorithm: 'DS', keysize: 256 }, (err, { publicKey }) => {
console.log(publicKey.toSimpleObject())
})
{
"algorithm": "DS",
"y": "735b5ddcb95622cb39370efbd0ab4020e7ed5b73f06aecf7ba89ea57f7627ecec5973e1fcb8628125d58d94fed65d65affbfb2722f302085de127fb6fba97e18502da5e1d23d05979ff5a64b587a75b1f0953b4afce05cab74af5b886b059f67889756360d2d41c2312493695d891fad1b2b9cf6169e335f65d573da27b524aa968b9de93d0f0ddf157345917598b630b8937b2c76bedf8fb5ae686d0eddddee2c6cb9829b6d5a19bb07332e7ab3e6116c523198ef699af154b0ea038e92e15ca43ef757f7e854463596346634f759c30730d04cae296d6e663322cb030749c818c922cf2ed51a117bcc17aa603b560159ace99b4aea549c402d1390a1cf1648",
"p": "d6c4e5045697756c7a312d02c2289c25d40f9954261f7b5876214b6df109c738b76226b199bb7e33f8fc7ac1dcc316e1e7c78973951bfc6ff2e00cc987cd76fcfb0b8c0096b0b460fffac960ca4136c28f4bfb580de47cf7e7934c3985e3b3d943b77f06ef2af3ac3494fc3c6fc49810a63853862a02bb1c824a01b7fc688e4028527a58ad58c9d512922660db5d505bc263af293bc93bcd6d885a157579d7f52952236dd9d06a4fc3bc2247d21f1a70f5848eb0176513537c983f5a36737f01f82b44546e8e7f0fabc457e3de1d9c5dba96965b10a2a0580b0ad0f88179e10066107fb74314a07e6745863bc797b7002ebec0b000a98eb697414709ac17b401",
"q": "b1e370f6472c8754ccd75e99666ec8ef1fd748b748bbbc08503d82ce8055ab3b",
"g": "9a8269ab2e3b733a5242179d8f8ddb17ff93297d9eab00376db211a22b19c854dfa80166df2132cbc51fb224b0904abb22da2c7b7850f782124cb575b116f41ea7c4fc75b1d77525204cd7c23a15999004c23cdeb72359ee74e886a1dde7855ae05fe847447d0a68059002c3819a75dc7dcbb30e39efac36e07e2c404b7ca98b263b25fa314ba93c0625718bd489cea6d04ba4b0b7f156eeb4c56c44b50e4fb5bce9d7ae0d55b379225feb0214a04bed72f33e0664d290e7c840df3e2abb5e48189fa4e90646f1867db289c6560476799f7be8420a6dc01d078de437f280fff2d7ddf1248d56e1a54b933a41629d6c252983c58795105802d30d7bcd819cf6ef"
}
Exporting the key as a JWK was really handy earlier to access the low-level key parameters, but we cannot do that with DSA. We can only export the key as DER (binary) or PEM (Base64 encoded DER).
This means that we’ll need to use the OpenSSL CLI to dump the key parameters as we saw earlier. Let’s start by generating the PEM private key.
const { promisify } = require('util')
const crypto = require('crypto')
const kp = await promisify(crypto.generateKeyPair)('dsa', {
modulusLength: 2048,
divisorLength: 256
})
const privateKey = kp.privateKey.export({ format: 'pem', type: 'pkcs8' })
Then, we can invoke the openssl
command, piping it the private key.
const cp = require('child_process')
const sub = cp.spawn('openssl', ['dsa', '-in', '-', '-text', '-noout'])
sub.stdin.write(privateKey)
The child process will now emit data
events on the stdout
stream and
we can use that to parse the OpenSSL output. For context, here’s what a
typical output looks like:
openssl dsa -in dsa-private-key.pem -text -noout
Private-Key: (2048 bit)
priv:
4b:66:fe:d5:68:c2:7e:3d:4a:fc:c0:45:10:01:91:
fe:d7:83:be:39:0b:79:f3:0f:a1:c3:63:0e:8a:8f:
63:db
pub:
7e:50:55:ea:62:b8:70:0f:89:ca:f9:ad:41:21:05:
8d:2c:71:e3:14:a5:1c:70:7d:a6:68:97:10:2f:93:
f3:82:ee:98:25:7c:6a:42:71:9a:e0:b0:bf:c2:76:
18:df:fe:68:63:ba:a8:a0:4d:10:9f:5a:da:c6:e3:
c9:94:23:4a:d5:8e:00:ac:6b:f8:40:06:10:d1:6a:
09:17:7e:73:8e:10:5b:5a:a0:dc:7a:c7:7d:cb:96:
3b:8d:d8:d5:27:05:e0:0f:d8:e3:04:24:c3:ef:49:
0d:56:54:54:3a:cd:c8:bf:36:03:2e:e7:8f:21:a2:
8e:14:f9:17:57:85:7f:83:73:01:bc:90:aa:01:d1:
4b:cb:84:c0:99:ee:2a:d2:3d:d7:30:97:51:89:fd:
ef:b8:7a:ea:5e:5f:17:37:53:ce:43:b5:05:64:b9:
09:c8:3f:07:eb:c4:9b:77:a5:6b:d2:d3:d0:ed:3e:
47:1d:54:7d:f1:a1:ef:66:25:a6:fc:61:1b:cb:ae:
60:f9:3b:7d:58:f3:e4:19:3b:09:4d:3f:87:c6:97:
95:9b:78:02:55:fc:d8:74:86:06:50:8b:78:23:63:
c6:b2:46:96:48:88:93:c6:32:d4:88:33:c7:44:f1:
b9:73:b7:1a:72:0c:1e:55:40:7c:f3:cf:7a:fe:06:
b7
P:
00:bc:a3:68:a5:2b:1d:b5:c6:8a:4e:70:0d:78:4b:
17:83:37:8f:d4:3a:9c:27:e7:08:b5:6d:9a:91:b4:
8e:22:81:7e:ee:10:8c:08:45:c3:a1:f5:95:b3:9c:
71:83:49:c2:dc:58:67:d3:c4:5c:1a:db:2e:c6:a4:
18:4a:8a:15:b8:3b:b8:94:29:b4:43:79:e3:32:11:
98:26:6e:65:01:11:f0:b9:cf:a2:e5:dc:4b:f8:4c:
31:27:ff:75:cf:b8:b4:13:b0:f5:e8:da:ab:76:7b:
ba:7d:ca:9b:fd:c1:29:89:77:6e:ee:95:33:3c:64:
94:5e:4d:5b:0b:f4:b8:4f:91:54:8c:40:35:75:11:
06:1e:7f:ed:ae:17:9e:ce:9b:8d:e1:79:75:7c:fd:
d2:60:3f:89:10:6e:95:04:67:5b:08:31:71:ea:13:
76:78:28:cd:cb:03:2b:66:19:3e:39:12:98:86:d3:
90:d6:43:72:6e:32:bf:27:c6:76:f4:ab:04:e6:54:
f3:41:ca:52:60:7e:74:1c:26:b3:e9:4c:0e:94:88:
bd:7d:3e:af:a0:0d:50:58:89:a5:7a:d2:9d:4c:27:
0f:2c:c2:6e:98:2e:a8:6d:22:97:19:2a:7c:ae:0c:
b8:d3:1e:46:f9:e5:62:b4:91:2c:43:a2:02:1d:30:
6f:4f
Q:
00:b0:60:bd:58:73:4e:5a:37:e5:4e:a3:15:2a:a7:
d9:dd:e2:b6:c2:f9:3d:37:4b:9d:43:33:9b:25:9c:
bc:97:67
G:
02:80:e7:af:91:ef:92:ef:51:67:2e:84:a8:e4:f1:
c5:e0:c1:98:c2:c9:59:e0:89:3f:71:3f:99:fd:ee:
cf:fa:db:6e:6f:bc:8b:5b:d0:06:35:0d:c2:19:96:
c1:be:18:43:ed:76:52:70:4d:d3:8f:71:e2:b4:d0:
a6:1e:ed:0d:67:71:24:dd:f1:86:06:99:f4:39:a8:
45:d1:ac:5b:55:af:f3:89:0d:44:87:e9:36:ac:02:
a6:fc:5d:27:56:96:92:d5:5e:35:a8:62:5f:63:9c:
bf:da:ff:8e:c0:a0:28:7a:9c:41:2a:2c:bb:c6:80:
7c:7b:86:58:4e:af:95:2c:06:51:5f:15:81:cc:8f:
c1:9b:72:fa:82:71:65:81:ee:9e:99:f7:04:f9:1e:
90:e4:ea:88:0e:44:b1:78:0e:67:8b:b6:61:7b:94:
27:f1:7f:a6:7f:7b:59:21:73:71:92:a6:5f:98:67:
a3:b7:e4:b2:dd:e7:55:f3:22:ac:de:44:1a:54:71:
e3:33:ce:22:ac:38:93:e1:6b:9b:96:43:ce:4c:8c:
87:a3:86:97:a1:1c:b6:7c:cc:d8:ab:7d:82:a2:0f:
f5:7a:75:a5:f1:bc:e7:04:94:ae:83:98:98:70:5d:
89:b0:54:8b:84:bf:ec:b1:eb:bb:fc:55:98:d0:ca:
b4
With the following code, we can parse it into an object:
const readline = require('readline')
const rl = readline.createInterface({ input: sub.stdout })
const params = {}
let currentParam
rl.on('line', line => {
// Continuation of an existing parameter, append to it.
if (line.startsWith(' ') && currentParam) {
params[currentParam] += line.trim()
return
}
// Definition of a new parameter.
const split = line.split(':')
if (split.length < 2) {
return
}
currentParam = split[0]
params[currentParam] = split[1].trim()
})
await new Promise((resolve, reject) => {
sub.on('exit', code => {
if (code > 0) {
return reject(new Error(`OpenSSL failed with code ${code}`))
}
resolve()
})
})
console.log(params)
Conveniently, OpenSSL returns the parameters in hexadecimal form
already, so we just need to remove the :
that it separates each byte
with, and rename the properties to make a BrowserID JSON key.
const publicKey = {
algorithm: 'DS',
y: params.pub.replaceAll(':', ''),
p: params.P.replaceAll(':', ''),
q: params.Q.replaceAll(':', ''),
g: params.G.replaceAll(':', '')
}
The last part to refactor is the JWT:
const signedObject = await promisify(jwcrypto.assertion.sign)(
{},
{
audience: tokenServerUrl,
issuer: authServerUrl,
expiresAt: Date.now() + duration
},
kp.secretKey
)
It seems that this is a standard JWT, so we can use the njwt
package for this (a simpler and more flexible alternative to
jsonwebtoken
).
Note: the main quirk is that Mozilla uses a milliseconds timestamp
for the exp
field, while JWT defines it as a standard timestamp (in
seconds).
This means that with both libraries, we need to work around that in
order to force the exp
field to be in milliseconds. For njwt
, this
means doing jwt.setClaim('exp', expiresAt)
instead of using
jwt.setExpiration(expiresAt)
, and for jsonwebtoken
it means
including the exp
claim as part of the payload instead of using the
expiresIn
parameter that is otherwise expressed as a duration in
seconds.
As for the private key, while njwt
documents it should be a (PEM)
string or a (DER) buffer, since it just forwards it to Node.js crypto
module, we can directly give it the KeyObject
that’s in kp.privateKey
.
const njwt = require('njwt')
const signedObject = njwt.create({ aud: tokenServerUrl, iss: authServerUrl }, kp.privateKey, 'RS256')
.setClaim('exp', Date.now() + duration)
.compact()
Here we specified the RS256
algorithm for a RSA key. This works
perfectly, and with this code, we can effectively generate a BrowserID
assertion that will be accepted by the TokenServer!
But neither njwt
nor jsonwebtoken
support DSA signatures. In fact, it seems that most JWT libraries don’t
support DSA whatsoever.
That being said, we can leverage the fact that njwt
forwards the
private key to the crypto.sign
method to make it work with our DSA key. All we need to do is to trick
it into thinking that it’s signing with the RS256
algorithm so that it
follows the SHA-256 signature code path (which actually works perfectly
with a DSA key), and force the alg
header to be DS256
just at the
time it is encoded in the JWT (otherwise the library will complain that
the algorithm is unsupported).
const njwt = require('njwt')
const jwt = njwt.create({ aud: tokenServerUrl, iss: authServerUrl }, kp.privateKey, 'RS256')
.setClaim('exp', Date.now() + duration)
jwt.header.compact = function compact () {
const alg = this.alg
this.alg = 'DS256'
const header = njwt.JwtHeader.prototype.compact.call(this)
this.alg = alg
return header
}
const signedObject = jwt.compact()
This should work but it does not. I keep getting HTTP 401s with
invalid-credentials
error from the TokenServer. Why? It took some
trial and error to figure as this was far from obvious.
It turns out that Node.js defaults the DSA signature encoding to DER, and BrowserID only supports the IEEE P1363 format.
Thankfully, Node.js allows us to wrap the private key in an object to
specify extra options like a dsaEncoding: 'ieee-p1363'
. In the
previous code this would look like:
const jwt = njwt.create(
{ aud: tokenServerUrl, iss: authServerUrl },
{ key: kp.privateKey, dsaEncoding: 'ieee-p1363' },
'RS256'
)
Congratulations! We now also have a working DSA JWT!
Now, let’s even remove the njwt
dependency, and bake our own JWT
in-house. Because why not. It’s actually pretty trivial, and to be
honest, a simpler and cleaner solution for DSA than the hack we just
made.
const crypto = require('crypto')
const header = { alg: 'DS256' }
const payload = { exp: Date.now() + duration, aud: tokenServerUrl, iss: authServerUrl }
const body = [
Buffer.from(JSON.stringify(header)).toString('base64url'),
Buffer.from(JSON.stringify(payload)).toString('base64url')
].join('.')
const signature = crypto.sign('SHA256', body, {
key: kp.privateKey,
dsaEncoding: 'ieee-p1363'
}).toString('base64url')
const signedObject = [body, signature].join('.')
Believe it or not, this is all it takes to make a valid JWT. Not so bad, isn’t it?
Since we have a valid cert
and signedObject
at that point, that part
stays the same. All we need to do is to bundle them together
with the ~
character (tilde).
const backedAssertion = [cert, signedObject].join('~')
This is the value we can pass in the Authorization
header such as:
Authorization: BrowserID <backedAssertion>
And that’s it! We now have a fully working BrowserID implementation,
with both RSA and DSA support, that only depend on the native crypto
module!
There were some incompatibilities with DSA that we needed to work around, especially the fact that it is not supported by JWK, forcing us to fall back to the OpenSSL CLI to extract the key parameters, and also that common JWT libraries don’t support it either, leading us to write our own (dead simple) JWT implementation.
This makes the code a bit simpler if we only use RSA, where it’s barely longer than the initial browserid-crypto version, while the DSA version is a bit hairier with the code to invoke OpenSSL and parse its output, as well as the custom JWT signature.
Because of that, unlike in the previous post, I won’t include the full code here. You should easily be able to put together the pieces that you need from this article.
But this is probably not an exercise you should be interested in anyways, because as I later found out, it’s not just browserid-crypto that’s unmaintained, but the BrowserID protocol altogether that’s deprecated! In the next stop of this journey, we’ll look at the OAuth version, which turned out to be much easier to support than it first looked like.
Check out the other posts in this series!