speakeasyjs/speakeasy

Not working with Authy

MrXyfir opened this issue · 29 comments

Having a few issues getting this working with Authy. It works with Google Authenticator no problem. If I have Authenticator and Authy scan the same QR code they'll give me different OTPs, with Authy's being incorrect.

Using the latest npm version of speakeasy and qrcode on Node v8.6.0.

generating the secret/url

const speakeasy = require('speakeasy');
const qr = require('qrcode');

const email = 'user@email.com';

const {ascii: secret} = speakeasy.generateSecret({
  issuer: 'MyApp',
  length: 128,
  name: email
});

let url = speakeasy.otpauthURL({
  algorithm: 'sha512',
  issuer: 'MyApp',
  digits: 8,
  secret,
  label: email
});

// Convert otpauth url to qr code url
url = await new Promise((resolve, reject) =>
  qr.toDataURL(url, (e, u) => e ? reject(e) : resolve(u))
);

req.session.otpTempSecret = secret;

verifying the token

const verified = speakeasy.totp.verify({
  algorithm: 'sha512',
  secret: req.session.otpTempSecret,
  digits: 8,
  token: req.body.token.replace(/\D/g, '')
});

if (!verified) throw 'Invalid token';

Only on Authy am I having two issues:

  1. There is no issuer. 'MyApp' is not shown, only the user's email that was passed as a label.
  2. It generates incorrect codes.

I also tried loading the latest version of speakeasy from Github but there was no change to either issues.

Any ideas what I'm doing wrong? I'm assuming somewhere there's a communication issue between my implementation of speakeasy and Authy as I have no problem with speakeasy on Google Authenticator and no problem with Authy on other sites. I don't really care about the issuer not showing on Authy but generating incorrect codes is a bit of problem...

I'm having the same problem

I fixed the problem be changing my current code:

var verified = speakeasy.totp.verify({
	secret: req.user.secret,
	encoding: 'base32',
	token: req.body.code
});

and adding window

var verified = speakeasy.totp.verify({
	secret: req.user.secret,
	encoding: 'base32',
	token: req.body.code,
	window: 2
});

This helped with my server been out of time. Hopefully it will help you too!

@LukeXF Still doesn't work for me.

You could try and raising it even more?

I've compared Authy's and Google Authenticator's codes before and it didn't appear like Authy was ahead or behind but simply generating completely different codes. So I don't think that's the problem for me but I'll try it increasing the window maybe to 10 or so and update with my results when I get a chance. Either way though, I really shouldn't have to sacrifice security just to support Authy.

At this point though I'm honestly considering just writing my own package. Especially since this one hasn't been updated in over two years (at least on npm).

I doubt speakeasy supports Authy. @jakelee8 @mikepb

You need to get @markbao to update the NPM package but I doubt that will happen anytime soon. Why shouldn't it support Authy? Like @MrXyfir said:

I really shouldn't have to sacrifice security just to support Authy.

After building a 2FA system with speakeasy I can see that Google Auth App works but the Authy App doesn't - how odd! It's a shame if speakeasy won't support Authy because it puts it behind other 2FA systems and so far I'm really enjoying speakeasy, don't want to switch just because of this.

An example of using Authy: (Google Auth works)
screen shot 2017-10-05 at 00 35 52

@railsstudent Authy should (and was built to) work anywhere Google Authenticator works. Although I guess clearly there are some exceptions. I use Authy on tons of sites other sites that support TOTP 2FA (most of which only mention Google Auth). I originally used Google Authenticator when I first implemented speakeasy and then switched over to Authy. I was quite surprised when every site worked fine except for my own.

@LukeXF Do you know of any other actively developed systems similar to this one for Node? I've tried searching around but came up empty. It seems like there are a lot of people here that want to work on this package but since no one has access to the npm account it's unlikely to happen without splitting it off onto a new one.

I originally used Google Authenticator when I first implemented speakeasy and then switched over to Authy. I was quite surprised when every site worked fine except for my own.

@MrXyfir I had that exact same issue!

Also I've emailed the NPM owner for speakeasy, let's hope he responds!

So

QRCode.toDataURL(secret.otpauth_url, function (err, data_url) {
	config.qrCode = data_url;
});

works (even WITH AUTHY haha, I'm surprised), but

var url = speakeasy.otpauthURL({
	secret: secret.base32,
	label: req.user.local.email,
	issuer: 'BetterNodeLogin'
});

QRCode.toDataURL(url, function (err, data_url) {
	config.qrCode = data_url;
});

doesn't. I wonder how it breaks when I built the URL myself

Did some more research, I found out when I

var secret = speakeasy.generateSecret();
console.log(secret);

I get:

{ ascii: '.Ccq?cq5IXC1*3NL5UbLvR?oL$7tc7zG',
  hex: '2e4363713f637135495843312a334e4c3555624c76523f6f4c24377463377a47',
  base32: 'FZBWG4J7MNYTKSKYIMYSUM2OJQ2VKYSMOZJD632MEQ3XIYZXPJDQ',
  otpauth_url: 'otpauth://totp/SecretKey?secret=FZBWG4J7MNYTKSKYIMYSUM2OJQ2VKYSMOZJD632MEQ3XIYZXPJDQ' 
}

Which as you can see the secret.otpauth_url is:
otpauth://totp/SecretKey?secret=FZBWG4J7MNYTKSKYIMYSUM2OJQ2VKYSMOZJD632MEQ3XIYZXPJDQ
Which is different to

var url = speakeasy.otpauthURL({
			secret: secret.base32,
			label: req.user.local.email,
			issuer: 'BetterNodeLogin'
		});

Because that console.log(url); outputs:
otpauth://totp/me@luke.sx?secret=IZNEEV2HGRFDOTKOLFKEWU2LLFEU2WKTKVGTET2KKEZFMS2ZKNGU6WSKIQ3DGMSNIVITGWCJLFNFQUCKIRIQ&issuer=BetterNodeLogin

and the secret is clearly different from the first working example. I wonder why the secret is generated differently when I used the built in function as opposed to my own URL...

Thanks for the findings. I am not sure about the root cause but i can take a look.

All, sorry for the late response here. I've been busy with work and haven't been able to make plans for the next release because of the breaking changes it would introduce, but at the least I think I can help with your issue, @LukeXF.

@LukeXF: Based on your latest comment, you are generating a secret that has a base32 encoding that starts with FZBWG4J7.... The key issue here is that when you create your own otpauth:// URL with otpauthURL(), you are passing in the base32 secret without specifying the encoding for the secret. By default, otpauthURL() assumes a secret that is passed in without an encoding argument is ASCII-encoded, so it will use base32 to convert it again. (docs)

You can see this if you try to base32-encode FZBWG4J7... – you'll get the secret that you see in the otpauth:// URL, IZNEEV2H.... This is also why the secret.otpauth_url that you get back from generateSecret() is working correctly, even in Authy, since it uses the correct base32-encoded URL. The solution to this is to pass in the encoding parameter as base32 which will bypass the conversion to base32. Hope this helps.

I still need to look into why this is not working with Authy. It absolutely should support Authy and any other system that implements the spec.

(Sorry for closing the issue – that was accidental.)

Thanks for the input @markbao, I'll give it a look at later today! Also any news on updating SpeakEasy on NPM?

@LukeXF The issue is that there are some breaking changes in the next release. I've been waiting to do an assessment of what the potential impact of the breaking changes would be, but that takes some time and I haven't been able to get around to it.

@MrXyfir Thanks for your patience on this. I read your code but don't seem to see any issues in it. Can I ask you to try to use the generateSecret() function's otpauth_url return value to generate a QR code and see if that works in Authy?

See this comment for more info: #95 (comment)

@markbao

So, using otpauth_url from generateSecret() does work with Authy, as @LukeXF said. Problem is, I can't set digits, algorithm, or issuer like I have in my original comment. Also it should probably be noted that I tested on both the current npm and Github versions.

@MrXyfir Thanks for trying that. When you generate the 8-digit otpauth:// URL, do you see 8 digits or 6 digits in Authy?

Could you also try:

  • editing your code above and printing what the output is of otpauth_url from generateSecret() and comparing it to the one from otpauthURL with none of the additional digits, algorithm, or issuer parameters
  • checking if this URL works with Authy
  • adding algorithm and seeing if it works with Authy
  • adding digits and seeing if it works with Authy

It may be that Authy doesn’t support one of these fields. There’s a tweet from them from 2014 that said they didn’t support 8-digit codes; this may have changed by now.

It may be that Authy doesn’t support one of these fields. There’s a tweet from them from 2014 that said they didn’t support 8-digit codes; this may have changed by now.

@markbao I use Authy primarily and I have a few sites that use 7 digit codes as well as 6 (fyi, I don't have any 8 digit site yet), so I know that they're not just limited to 6 digit codes.

The solution to this is to pass in the encoding parameter as base32 which will bypass the conversion to base32. Hope this helps.

@markbao great news, got custom URL building working! And it works with Authy as well as Google Auth. AND it fixed my space encoding problem. Thank you, I should of really read over the docs again, but yes, adding the encoding so it's not defaulted to ASCII fixed the problem.

var url = speakeasy.otpauthURL({
	secret: secret.base32,
	label: req.user.local.email,
	issuer: 'The Spaces Work',
	encoding: "base32"
});

@markbao Sorry for the late response, been very busy.

In response to your digits question: as @LukeXF said, yes, Authy shows the full eight digits.

Anyways, I think I may have figured out the issue.

For the following tests I sent the url given from generateSecret() to the client. mr@xyfir.com is the label/name, which would under normal circumstances be the end-user's email address.

  • With no settings other than name/label. This works with Authy.
    • generateSecret(): otpauth://totp/mr%40xyfir.com?secret=EU3DUV3OEMYT6SJFOY4SQQZZPI3TEW3RPU2TG6SQPBBCKILIOFYQ
    • otpauthURL(): otpauth://totp/mr@xyfir.com?secret=EU3DUV3OEMYT6SJFOY4SQQZZPI3TEW3RPU2TG6SQPBBCKILIOFYQ
  • With length: 128. This works with Authy.
    • generateSecret(): otpauth://totp/mr%40xyfir.com?secret=HEUWYW3IJ5MHGXJDGNEWISCUNFNEQRJOFZYF44C5EFJVANSYHBVU2NBWKJ2H23KAHI4SYJDQPFTXWNSMNBWE2UTULVWT GLTRFFRXKW2SJE7SSPSCEQYEO4TDF4SWQ42GLNXHO4TCKBSG6QZ2LBDGIWB6KRKUIYZ4KNKG4QSPKZAE423BPNDWMWDDGI2SYQJTGARXOOKOPNAG4
    • otpauthURL(): otpauth://totp/mr@xyfir.com?secret=HEUWYW3IJ5MHGXJDGNEWISCUNFNEQRJOFZYF44C5EFJVANSYHBVU2NBWKJ2H23KAHI4SYJDQPFTXWNSMNBWE2UTULVWTGLTRFFRXKW2SJE7SSPSCEQYEO4TDF4SWQ42GLNXHO4TCKBSG6Q Z2LBDGIWB6KRKUIYZ4KNKG4QSPKZAE423BPNDWMWDDGI2SYQJTGARXOOKOPNAG4

Then I decided to flip it around and use the url given from otpauthURL(). They did not work with Authy. The only obvious difference I can see is that the name/label value is not encoded in the url otpauthURL() returns, but it is in the one generateSecret() returns.

I'll do more testing tomorrow.

So I used encodeURIComponent() on the label for otpauthURL() and it worked with Authy.

I'll now try passing different values to otpauthURL(). Remember, I'm url-encoding label.

  • algorithm: 'sha512'
    • This does not work with Authy.
    • otpauth://totp/mr%40xyfir.com?secret=IN6U2KKJOZZXASTQK5ND6MDDHFBFWQKYLVMEMUBJFBXD4KT5KZUA&algorithm=SHA512
  • digits: 8
    • This works with Authy.
    • otpauth://totp/mr%40xyfir.com?secret=HFWFMVLFJR4X23ZQMJGVEVJMKN5UATBBKJ5VI53MKFDHQRDBFJFA&digits=8
  • issuer: 'MyApp', digits: 8, length: 128
    • This works with Authy, but the issuer is still ignored!
    • otpauth://totp/mr%40xyfir.com?secret=N45VAZZ6IVKFGZCKJASVATZFGZJE42KEJJKGKMBEHFNWO22UOVNFENBGJJEVQV3UGFTVCZTHLBYXE5BMOJNUMLRBJVYTKTLEKBCHSI3OJ43WC7LNHQ2WYJSDHNBUSUZIOB3TM23BIQ4CUSCMIFUEWMRGGRITUJJTOVDWQWSTI5GSMYKJLZRWYZCILVBD643RPB6XK53GNQQV4&issuer=MyApp&digits=8

So there are at least two major issues preventing otpauthURL() from working with Authy.

  1. The label value is not being url-encoded.
  2. Apparently algorithm cannot be set to anything other than sha1. I tried sha1, sha256, and sha512. Only sha1 worked. I'm having a hard time finding which ones Authy supports but I'd be very surprised if it was only SHA-1.

Then of course there is the issuer being ignored, but at least it doesn't break everything.

Based on this documentation about the otpauth URL syntax, the recommendation seems to be that you include the issuer both as an issue key in the otpauthURL options and as the prefix to the label key.

We also recently discovered that Authy and Google Authenticator on iOS will reject the otpauth URL if the issuer-portion of the label contains a space. (I imagine it would reject it if there is any non-encoded space in the label, but I've only tested it in the issuer portion of the label.)

We had been doing this...

// Sample values:
var issuer = "Has Space";
var label = "First Last";

const otpSecrets = speakeasy.generateSecret({length: 10});
let otpAuthUrlOptions = {
  'label': label,
  'secret': otpSecrets.base32,
  'encoding': 'base32'
};
if (issuer) {
  otpAuthUrlOptions.issuer = issuer;
  otpAuthUrlOptions.label = issuer + ':' + label;
}

... but I'm about to change to the following in an attempt to fix this iOS problem (which, as a side note, I probably should have been doing all along). Note the uses of encodeURIComponent():

// Sample values:
var issuer = "Has Space";
var label = "First Last";

const otpSecrets = speakeasy.generateSecret({length: 10});
let otpAuthUrlOptions = {
  'label': encodeURIComponent(label),
  'secret': otpSecrets.base32,
  'encoding': 'base32'
};
if (issuer) {
  otpAuthUrlOptions.issuer = issuer;

  // Note: The issuer and account-name used in the `label` need to be
  // URL-encoded. Presumably speakeasy doesn't automatically do so because
  // the colon (:) separating them needs to NOT be encoded.
  otpAuthUrlOptions.label = encodeURIComponent(issuer) + ':' + encodeURIComponent(label);
}

Here is an example output from speakeasy.otpauthURL(otpAuthUrlOptions);:
otpauth://totp/Has%20Space:First%20Last?secret=HJ2VWR3RF4SEMNZJ&issuer=Has%20Space

@forevermatt Thank you! That solves my problem with the issuer not showing in Authy!

adrai commented

If you have to support Android devices, make sure you're using algorithm: sha1

Using this repo fixes the issue for me:

#134

All, sorry for the late response here. I've been busy with work and haven't been able to make plans for the next release because of the breaking changes it would introduce, but at the least I think I can help with your issue, @LukeXF.

@LukeXF: Based on your latest comment, you are generating a secret that has a base32 encoding that starts with FZBWG4J7.... The key issue here is that when you create your own otpauth:// URL with otpauthURL(), you are passing in the base32 secret without specifying the encoding for the secret. By default, otpauthURL() assumes a secret that is passed in without an encoding argument is ASCII-encoded, so it will use base32 to convert it again. (docs)

You can see this if you try to base32-encode FZBWG4J7... – you'll get the secret that you see in the otpauth:// URL, IZNEEV2H.... This is also why the secret.otpauth_url that you get back from generateSecret() is working correctly, even in Authy, since it uses the correct base32-encoded URL. The solution to this is to pass in the encoding parameter as base32 which will bypass the conversion to base32. Hope this helps.

I still need to look into why this is not working with Authy. It absolutely should support Authy and any other system that implements the spec.

(Sorry for closing the issue – that was accidental.)

Best response that work like a charm when specify encoding:base32 for all methods (verify, otpauthURL)