Authy: reversed š
September 28, 2021
September 28, 2021
I recently signed up for SendGrid for a new business. It seems like a nice tool to send my transactional emails.
One thing with SendGrid though is that they enforce 2FA. You canāt access your account without enabling 2FA first. Thatās great, except one thing:
They only offer two options for 2FA: Authy and SMS.
Thatās somewhat understandable, because SendGrid belongs to Twilio, who also owns Authy. By forcing SendGrid users on Authy, and making sure that they canāt easily use any of the competitor 2FA apps, they boost their Authy adoption metrics, and that will certainly make investors happy. Users? Not so much.
But Iām not a Authy user, and I donāt plan to be. I definitely will not install the Authy app just for this. āUse the alternative SMS 2FA methodā youāll say? But thatās less secure on top of having a poor user experience. Iād rather use my existing password manager for this which is much faster and more convenient.
Note: if you donāt care about the technical details and just want to transfer your Authy secrets to another authenticator, check out Authy user client.
Otherwise, keep reading, Iāll give you all the details about it! āļø
Every time I face a situation like this, I reverse. I love reversing things, especially when it helps me make my life better. Iāll spend days, even weeks doing whatās necessary to achieve what I want.
Maybe that means decompiling apps and running them through a debugger, or patching APKs to wipe certificate pinning mechanism in order to intercept the TLS traffic through a logging proxy.
Often though, things are even easier. In the case of Authy, they have a Chrome app that we can easily debug to understand how the protocol work. The best thing about it? Someone already did a lot of the work and documented it. Sweet.
Note: I noticed later that someone wrote a similar program in Go.
The main difference is that itās intended to be used alongside an existing Authy app and account, instead of replacing it completely, but a lot of the code is the same. If Go is more your jam, check it out!
That article I linked earlier gives us a good starting point:
Thatās awesome, but weāre not there yet. I donāt want to have to use Authy (even once) in order to not use Authy later.
A mystery still remains. Since during 2FA setup they donāt ever show a QR code or give access to a TOTP URI or plaintext secret, how do this secret ever reach the Authy app? Is it cryptographically derived from the phone number, the service name, and other parameters? How does the Authy app automagically knows what secret to generate the codes with?
Well, as it often turns out, the easiest answer is often the right one. By inspecting the network traffic of the Chrome app, we clearly see thatā¦ the secrets are directly retrieved from the Authy servers.
Now, how do we write our own code that fetches the secrets from the Authy servers, without installing the Authy app? Let me tell you.
If we install the Chrome app, use our phone number to sign up or log in, and generate a one-time code, the following happens:
Additionally, all the requests contain an API key thatās public and hardcoded in the Chrome app.
Note: the secrets for each 2FA entry are unique to each Authy installation. This means that the secrets in the app on one device will be different from the secrets on another device (including our own client).
I expect they use that to make the secrets revocable. If you remove one of your Authy devices, the secret seeds associated with it are going to be invalidated and the codes generated by this device are no longer valid.
With that in mind, we can write an API client.
At that point youāll be interested to look at the code of Authy user client.
I wonāt copy everything here, but I essentially made a quick API wrapper that allows me to define every method very concisely:
const checkUserStatus = api({
url: p => `/users/${p.country_code}-${p.cellphone}/status`,
search: ['api_key']
})
const createUser = api({
url: '/users/new',
body: ['api_key', 'locale', 'email', 'cellphone', 'country_code']
})
const startRegistration = api({
url: p => `/users/${p.authy_id}/devices/registration/start`,
body: ['api_key', 'locale', 'via', 'signature', 'device_app']
})
const completeRegistration = api({
url: p => `/users/${p.authy_id}/devices/registration/complete`,
body: ['api_key', 'locale', 'pin']
})
const listDevices = api({
url: p => `/users/${p.authy_id}/devices`,
search: ['api_key', 'locale', 'otp1', 'otp2', 'otp3', 'device_id']
})
const deleteDevice = api({
url: p => `/users/${p.authy_id}/devices/${p.delete_device_id}/delete`,
body: ['api_key', 'locale', 'otp1', 'otp2', 'otp3', 'device_id']
})
const enableMultiDevice = api({
url: p => `/users/${p.authy_id}/devices/enable`,
body: ['api_key', 'locale', 'otp1', 'otp2', 'otp3', 'device_id']
})
const disableMultiDevice = api({
url: p => `/users/${p.authy_id}/devices/disable`,
body: ['api_key', 'locale', 'otp1', 'otp2', 'otp3', 'device_id']
})
const sync = api({
url: p => `/users/${p.authy_id}/devices/${p.device_id}/apps/sync`,
body: ['api_key', 'locale', 'otp1', 'otp2', 'otp3', 'device_id']
})
Weāll also need a method to generate a TOTP codes using Authy settings:
const base32 = require('rfc-3548-b32')
const totpGenerator = require('totp-generator')
function getOtp (secret) {
// `totpGenerator` wants Base32, Authy uses hex.
secret = base32.encode(Buffer.from(secret, 'hex'))
return totpGenerator(secret, { digits: 7, period: 10 })
}
The main difference here is that Authy stores the TOTP secrets in hexadecimal while most TOTP libraries and services expect Base32 as defined in RFC 3548, so we need to do a quick conversion.
Itās a bit stupid because the first thing totp-generator does is converting the secret back to hex but thereās no way to avoid that by specifying a custom encoding, so be it.
Then we also need an helper method to generate the 3 next codes in the time period sequence:
function getOtps (secret) {
// `totpGenerator` wants Base32, Authy uses hex.
secret = base32.encode(Buffer.from(secret, 'hex'))
const now = Date.now()
return {
otp1: totpGenerator(secret, { digits: 7, period: 10, now }),
otp2: totpGenerator(secret, { digits: 7, period: 10, now: now + 10_000 }),
otp3: totpGenerator(secret, { digits: 7, period: 10, now: now + 20_000 })
}
}
This depends on this PR on totp-generator which might or might not be merged when you read this.
From there I export those methods to be used in the CLI, or by other Node.js programs.
From there, we can use the awesome prompts package to build an interactive CLI that dumps the TOTP secrets from your Authy account.
const crypto = require('crypto')
const prompts = require('prompts')
const uri = require('uri-tag').default
const authy = require('authy-user-client')
async function prompt (params) {
const res = await prompts({ ...params, name: 'value' })
if (!res.value) {
process.exit()
}
return res.value
}
const countryCode = await prompt({ type: 'number', message: 'Country code:', initial: 1, min: 1 })
const phoneNumber = await prompt({ type: 'number', name: 'phoneNumber', message: 'Phone number:', validate: value => value !== '' })
const status = await authy.checkUserStatus({ country_code: countryCode, cellphone: phoneNumber })
let authyId = status.authy_id
if (!authyId) {
const email = await prompt({ type: 'text', message: 'Email:' })
const registration = await authy.createUser({ email, country_code: countryCode, cellphone: phoneNumber })
authyId = registration.authy_id
}
const via = await prompt({
type: 'select',
message: 'Authentication method:',
choices: [
{ title: 'Push', value: 'push' },
{ title: 'Call', value: 'call' },
{ title: 'SMS', value: 'sms' }
]
})
await authy.startRegistration({
authy_id: authyId,
via,
// Not sure why, but works better with this. š¤·
signature: crypto.randomBytes(32).toString('hex')
})
const pin = await prompt({ type: 'number', message: 'PIN:', min: 1, validate: value => value !== '' })
const registrationResponse = await authy.completeRegistration({ authy_id: authyId, pin })
const deviceId = registrationResponse.device.id
const secretSeed = registrationResponse.device.secret_seed
const syncResponse = await authy.sync({
authy_id: authyId,
device_id: deviceId,
...authy.getOtps(secretSeed)
})
for (const app of syncResponse.apps) {
const url = new URL(uri`otpauth://totp/${app.name}`)
url.search = new URLSearchParams(Object.entries({
// Authy uses hex, everything else uses Base32.
secret: authy.hexToBase32(app.secret_seed),
digits: app.digits,
period: 10
}))
console.log(`${app.name}: ${url}`)
}
This will list all the associated apps with a standard TOTP URI to be used in any compliant authenticator or password manager.
You can look at the full source for the implementation of individual calls for more fine-grained control!
Thanks to this, we can work around services that force the Authy app for 2FA and extract the secret and settings to use them with our favorite authenticator app.
Was this overkill? Hell yeah. But was it fun? Of course!
Still, I hope this can be useful to you if you run into the same issue. You can just use the CLI and happily move on with your life and your favorite TOTP provider! š
Note: if you need help understanding and documenting other undocumented APIs and protocols, let me know, Iām currently available for contracting projects and Iāll be happy to help you with that. āļø