Skip to content

Commit bd5fe3c

Browse files
author
alvin-dai-bitgo
authored
Merge pull request #5013 from BitGo/WP-2733-gpg-passkey-encryption
feat(wp): gpg encryption for passkey auth
2 parents ded4d13 + 1eda08f commit bd5fe3c

File tree

2 files changed

+213
-4
lines changed

2 files changed

+213
-4
lines changed

modules/bitgo/test/unit/bitgo.ts

Lines changed: 195 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
import * as crypto from 'crypto';
66
import * as nock from 'nock';
77
import * as should from 'should';
8+
import assert = require('assert');
89

9-
import { common } from '@bitgo/sdk-core';
10+
import { common, generateGPGKeyPair, encryptAndSignText } from '@bitgo/sdk-core';
1011
import { bip32, ECPair } from '@bitgo/utxo-lib';
1112
import * as _ from 'lodash';
1213
import * as BitGoJS from '../../src/index';
@@ -687,4 +688,197 @@ describe('BitGo Prototype Methods', function () {
687688
response.user.ecdhKeychain.should.equal('some-xpub');
688689
});
689690
});
691+
692+
describe('passkey authentication', () => {
693+
afterEach(function ensureNoPendingMocks() {
694+
nock.cleanAll();
695+
nock.pendingMocks().should.be.empty();
696+
});
697+
698+
it('should authenticate with a passkey', async () => {
699+
const userId = '123';
700+
const passkey = `{"id": "id", "response": {"authenticatorData": "123", "clientDataJSON": "123", "signature": "123", "userHandle": "${userId}"}}`;
701+
const keyPair = await generateGPGKeyPair('secp256k1');
702+
703+
nock('https://bitgo.fakeurl')
704+
.persist()
705+
.get('/api/v1/client/constants')
706+
.reply(200, { ttl: 3600, constants: { passkeyBitGoGpgKey: keyPair.publicKey } });
707+
708+
nock('https://bitgo.fakeurl')
709+
.post('/api/auth/v1/session')
710+
.reply(200, async (uri, requestBody) => {
711+
assert(typeof requestBody === 'object');
712+
should.exist(requestBody.publicKey);
713+
should.exist(requestBody.userId);
714+
should.exist(requestBody.passkey);
715+
requestBody.userId.should.equal(userId);
716+
requestBody.passkey.should.equal(passkey);
717+
const encryptedToken = (await encryptAndSignText(
718+
'access_token',
719+
requestBody.publicKey,
720+
keyPair.privateKey
721+
)) as string;
722+
723+
return {
724+
encryptedToken: encryptedToken,
725+
user: { username: '[email protected]' },
726+
};
727+
});
728+
729+
const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
730+
const response = await bitgo.authenticateWithPasskey(passkey);
731+
should.exist(response.access_token);
732+
response.access_token.should.equal('access_token');
733+
});
734+
735+
it('should not authenticate with wrong encryption key', async () => {
736+
const keyPair = await generateGPGKeyPair('secp256k1');
737+
738+
nock('https://bitgo.fakeurl')
739+
.persist()
740+
.get('/api/v1/client/constants')
741+
.reply(200, { ttl: 3600, constants: { passkeyBitGoGpgKey: keyPair.publicKey } });
742+
nock('https://bitgo.fakeurl')
743+
.post('/api/auth/v1/session')
744+
.reply(200, async () => {
745+
const keyPair = await generateGPGKeyPair('secp256k1');
746+
const encryptedToken = (await encryptAndSignText(
747+
'access_token',
748+
keyPair.publicKey,
749+
keyPair.privateKey
750+
)) as string;
751+
return {
752+
encryptedToken: encryptedToken,
753+
user: { username: '[email protected]' },
754+
};
755+
});
756+
757+
const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
758+
try {
759+
await bitgo.authenticateWithPasskey(
760+
'{"id": "id", "response": {"authenticatorData": "123", "clientDataJSON": "123", "signature": "123", "userHandle": "123"}}'
761+
);
762+
assert.fail('Expected error not thrown');
763+
} catch (e) {
764+
assert.equal(e.message, 'Error decrypting message: Session key decryption failed.');
765+
}
766+
});
767+
768+
it('should not authenticate with wrong signing key', async () => {
769+
const userId = '123';
770+
const passkey = `{"id": "id", "response": {"authenticatorData": "123", "clientDataJSON": "123", "signature": "123", "userHandle": "${userId}"}}`;
771+
const badKeyPair = await generateGPGKeyPair('secp256k1');
772+
const bitgoKeyPair = await generateGPGKeyPair('secp256k1');
773+
774+
nock('https://bitgo.fakeurl')
775+
.persist()
776+
.get('/api/v1/client/constants')
777+
.reply(200, { ttl: 3600, constants: { passkeyBitGoGpgKey: bitgoKeyPair.publicKey } });
778+
779+
nock('https://bitgo.fakeurl')
780+
.post('/api/auth/v1/session')
781+
.reply(200, async (uri, requestBody) => {
782+
assert(typeof requestBody === 'object');
783+
const encryptedToken = (await encryptAndSignText(
784+
'access_token',
785+
requestBody.publicKey,
786+
badKeyPair.privateKey
787+
)) as string;
788+
789+
return {
790+
encryptedToken: encryptedToken,
791+
user: { username: '[email protected]' },
792+
};
793+
});
794+
795+
const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
796+
try {
797+
await bitgo.authenticateWithPasskey(passkey);
798+
assert.fail('Expected error not thrown');
799+
} catch (e) {
800+
assert(e.message.startsWith('Error decrypting message: Could not find signing key with key ID'));
801+
}
802+
});
803+
it('should throw - missing bitgo public key', async () => {
804+
const userId = '123';
805+
const passkey = `{"id": "id", "response": {"authenticatorData": "123", "clientDataJSON": "123", "signature": "123", "userHandle": "${userId}"}}`;
806+
const keyPair = await generateGPGKeyPair('secp256k1');
807+
808+
nock('https://bitgo.fakeurl').persist().get('/api/v1/client/constants').reply(200, { ttl: 3600, constants: {} });
809+
810+
nock('https://bitgo.fakeurl')
811+
.post('/api/auth/v1/session')
812+
.reply(200, async (uri, requestBody) => {
813+
assert(typeof requestBody === 'object');
814+
const encryptedToken = (await encryptAndSignText(
815+
'access_token',
816+
requestBody.publicKey,
817+
keyPair.privateKey
818+
)) as string;
819+
820+
return {
821+
encryptedToken: encryptedToken,
822+
user: { username: '[email protected]' },
823+
};
824+
});
825+
826+
const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
827+
try {
828+
await bitgo.authenticateWithPasskey(passkey);
829+
assert.fail('Expected error not thrown');
830+
} catch (e) {
831+
assert.equal(e.message, 'Unable to get passkeyBitGoGpgKey');
832+
}
833+
});
834+
it('should throw - invalid userHandle', async () => {
835+
const passkey = `{"id": "id", "response": {"authenticatorData": "123", "clientDataJSON": "123", "signature": "123", "userHandle": 123}}`;
836+
const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
837+
try {
838+
await bitgo.validatePasskeyResponse(passkey);
839+
assert.fail('Expected error not thrown');
840+
} catch (e) {
841+
assert.equal(e.message, 'userHandle is missing');
842+
}
843+
});
844+
it('should throw - invalid authenticatorData', async () => {
845+
const passkey = `{"id": "id", "response": { "clientDataJSON": "123", "signature": "123", "userHandle": "123"}}`;
846+
const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
847+
try {
848+
await bitgo.validatePasskeyResponse(passkey);
849+
assert.fail('Expected error not thrown');
850+
} catch (e) {
851+
assert.equal(e.message, 'authenticatorData is missing');
852+
}
853+
});
854+
it('should throw - invalid passkey json', async () => {
855+
const passkey = `{{"id": "id", "response": { "clientDataJSON": "123", "signature": "123", "userHandle": "123"}}`;
856+
const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
857+
try {
858+
await bitgo.validatePasskeyResponse(passkey);
859+
assert.fail('Expected error not thrown');
860+
} catch (e) {
861+
console.log(e);
862+
assert(e.message.includes('JSON'));
863+
}
864+
});
865+
it('should throw - missing encrypted token', async () => {
866+
const passkey = `{"id": "id", "response": { "authenticatorData": "123", "clientDataJSON": "123", "signature": "123", "userHandle": "123"}}`;
867+
nock('https://bitgo.fakeurl')
868+
.post('/api/auth/v1/session')
869+
.reply(200, async () => {
870+
return {
871+
user: { username: '[email protected]' },
872+
};
873+
});
874+
875+
try {
876+
const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
877+
await bitgo.authenticateWithPasskey(passkey);
878+
assert.fail('Expected error not thrown');
879+
} catch (e) {
880+
assert.equal(e.message, 'Failed to login. Please contact [email protected]');
881+
}
882+
});
883+
});
690884
});

modules/sdk-api/src/bitgoAPI.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
IRequestTracer,
2121
makeRandomKey,
2222
sanitizeLegacyPath,
23+
generateGPGKeyPair,
24+
readSignedMessage,
2325
} from '@bitgo/sdk-core';
2426
import * as sjcl from '@bitgo/sjcl';
2527
import * as utxolib from '@bitgo/utxo-lib';
@@ -956,20 +958,33 @@ export class BitGoAPI implements BitGoBase {
956958
this.validatePasskeyResponse(passkey);
957959
const userId = JSON.parse(passkey).response.userHandle;
958960

961+
const userGpgKey = await generateGPGKeyPair('secp256k1');
959962
const response: superagent.Response = await request.send({
960963
passkey: passkey,
961964
userId: userId,
965+
publicKey: userGpgKey.publicKey,
962966
});
963967
// extract body and user information
964968
const body = response.body;
965969
this._user = body.user;
966970

967-
// Expecting unencrypted access token in response for now
968-
// TODO (WP-2733): Use GPG encryption to decrypt access token
969971
if (body.access_token) {
970972
this._token = body.access_token;
973+
} else if (body.encryptedToken) {
974+
const constants = await this.fetchConstants();
975+
976+
if (!constants.passkeyBitGoGpgKey) {
977+
throw new Error('Unable to get passkeyBitGoGpgKey');
978+
}
979+
980+
const access_token = await readSignedMessage(
981+
body.encryptedToken,
982+
constants.passkeyBitGoGpgKey,
983+
userGpgKey.privateKey
984+
);
985+
response.body.access_token = access_token;
971986
} else {
972-
throw new Error('failed to create access token');
987+
throw new Error('Failed to login. Please contact [email protected]');
973988
}
974989

975990
return handleResponseResult<LoginResponse>()(response);

0 commit comments

Comments
 (0)