Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 195 additions & 1 deletion modules/bitgo/test/unit/bitgo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
import * as crypto from 'crypto';
import * as nock from 'nock';
import * as should from 'should';
import assert = require('assert');

import { common } from '@bitgo/sdk-core';
import { common, generateGPGKeyPair, encryptAndSignText } from '@bitgo/sdk-core';
import { bip32, ECPair } from '@bitgo/utxo-lib';
import * as _ from 'lodash';
import * as BitGoJS from '../../src/index';
Expand Down Expand Up @@ -687,4 +688,197 @@ describe('BitGo Prototype Methods', function () {
response.user.ecdhKeychain.should.equal('some-xpub');
});
});

describe('passkey authentication', () => {
afterEach(function ensureNoPendingMocks() {
nock.cleanAll();
nock.pendingMocks().should.be.empty();
});

it('should authenticate with a passkey', async () => {
const userId = '123';
const passkey = `{"id": "id", "response": {"authenticatorData": "123", "clientDataJSON": "123", "signature": "123", "userHandle": "${userId}"}}`;
const keyPair = await generateGPGKeyPair('secp256k1');

nock('https://bitgo.fakeurl')
.persist()
.get('/api/v1/client/constants')
.reply(200, { ttl: 3600, constants: { passkeyBitGoGpgKey: keyPair.publicKey } });

nock('https://bitgo.fakeurl')
.post('/api/auth/v1/session')
.reply(200, async (uri, requestBody) => {
assert(typeof requestBody === 'object');
should.exist(requestBody.publicKey);
should.exist(requestBody.userId);
should.exist(requestBody.passkey);
requestBody.userId.should.equal(userId);
requestBody.passkey.should.equal(passkey);
const encryptedToken = (await encryptAndSignText(
'access_token',
requestBody.publicKey,
keyPair.privateKey
)) as string;

return {
encryptedToken: encryptedToken,
user: { username: 'auth-test@bitgo.com' },
};
});

const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
const response = await bitgo.authenticateWithPasskey(passkey);
should.exist(response.access_token);
response.access_token.should.equal('access_token');
});

it('should not authenticate with wrong encryption key', async () => {
const keyPair = await generateGPGKeyPair('secp256k1');

nock('https://bitgo.fakeurl')
.persist()
.get('/api/v1/client/constants')
.reply(200, { ttl: 3600, constants: { passkeyBitGoGpgKey: keyPair.publicKey } });
nock('https://bitgo.fakeurl')
.post('/api/auth/v1/session')
.reply(200, async () => {
const keyPair = await generateGPGKeyPair('secp256k1');
const encryptedToken = (await encryptAndSignText(
'access_token',
keyPair.publicKey,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename the one of the keyPair object, it looks very confusing

keyPair.privateKey
)) as string;
return {
encryptedToken: encryptedToken,
user: { username: 'auth-test@bitgo.com' },
};
});

const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
try {
await bitgo.authenticateWithPasskey(
'{"id": "id", "response": {"authenticatorData": "123", "clientDataJSON": "123", "signature": "123", "userHandle": "123"}}'
);
assert.fail('Expected error not thrown');
} catch (e) {
assert.equal(e.message, 'Error decrypting message: Session key decryption failed.');
}
});

it('should not authenticate with wrong signing key', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: add empty lines between tests, that looks more readable

const userId = '123';
const passkey = `{"id": "id", "response": {"authenticatorData": "123", "clientDataJSON": "123", "signature": "123", "userHandle": "${userId}"}}`;
const badKeyPair = await generateGPGKeyPair('secp256k1');
const bitgoKeyPair = await generateGPGKeyPair('secp256k1');

nock('https://bitgo.fakeurl')
.persist()
.get('/api/v1/client/constants')
.reply(200, { ttl: 3600, constants: { passkeyBitGoGpgKey: bitgoKeyPair.publicKey } });

nock('https://bitgo.fakeurl')
.post('/api/auth/v1/session')
.reply(200, async (uri, requestBody) => {
assert(typeof requestBody === 'object');
const encryptedToken = (await encryptAndSignText(
'access_token',
requestBody.publicKey,
badKeyPair.privateKey
)) as string;

return {
encryptedToken: encryptedToken,
user: { username: 'auth-test@bitgo.com' },
};
});

const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
try {
await bitgo.authenticateWithPasskey(passkey);
assert.fail('Expected error not thrown');
} catch (e) {
assert(e.message.startsWith('Error decrypting message: Could not find signing key with key ID'));
}
});
it('should throw - missing bitgo public key', async () => {
const userId = '123';
const passkey = `{"id": "id", "response": {"authenticatorData": "123", "clientDataJSON": "123", "signature": "123", "userHandle": "${userId}"}}`;
const keyPair = await generateGPGKeyPair('secp256k1');

nock('https://bitgo.fakeurl').persist().get('/api/v1/client/constants').reply(200, { ttl: 3600, constants: {} });

nock('https://bitgo.fakeurl')
.post('/api/auth/v1/session')
.reply(200, async (uri, requestBody) => {
assert(typeof requestBody === 'object');
const encryptedToken = (await encryptAndSignText(
'access_token',
requestBody.publicKey,
keyPair.privateKey
)) as string;

return {
encryptedToken: encryptedToken,
user: { username: 'auth-test@bitgo.com' },
};
});

const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
try {
await bitgo.authenticateWithPasskey(passkey);
assert.fail('Expected error not thrown');
} catch (e) {
assert.equal(e.message, 'Unable to get passkeyBitGoGpgKey');
}
});
it('should throw - invalid userHandle', async () => {
const passkey = `{"id": "id", "response": {"authenticatorData": "123", "clientDataJSON": "123", "signature": "123", "userHandle": 123}}`;
const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
try {
await bitgo.validatePasskeyResponse(passkey);
assert.fail('Expected error not thrown');
} catch (e) {
assert.equal(e.message, 'userHandle is missing');
}
});
it('should throw - invalid authenticatorData', async () => {
const passkey = `{"id": "id", "response": { "clientDataJSON": "123", "signature": "123", "userHandle": "123"}}`;
const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
try {
await bitgo.validatePasskeyResponse(passkey);
assert.fail('Expected error not thrown');
} catch (e) {
assert.equal(e.message, 'authenticatorData is missing');
}
});
it('should throw - invalid passkey json', async () => {
const passkey = `{{"id": "id", "response": { "clientDataJSON": "123", "signature": "123", "userHandle": "123"}}`;
const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
try {
await bitgo.validatePasskeyResponse(passkey);
assert.fail('Expected error not thrown');
} catch (e) {
console.log(e);
assert(e.message.includes('JSON'));
}
});
it('should throw - missing encrypted token', async () => {
const passkey = `{"id": "id", "response": { "authenticatorData": "123", "clientDataJSON": "123", "signature": "123", "userHandle": "123"}}`;
nock('https://bitgo.fakeurl')
.post('/api/auth/v1/session')
.reply(200, async () => {
return {
user: { username: 'auth-test@bitgo.com' },
};
});

try {
const bitgo = TestBitGo.decorate(BitGo, { env: 'mock' });
await bitgo.authenticateWithPasskey(passkey);
assert.fail('Expected error not thrown');
} catch (e) {
assert.equal(e.message, 'Failed to login. Please contact support@bitgo.com');
}
});
});
});
21 changes: 18 additions & 3 deletions modules/sdk-api/src/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
IRequestTracer,
makeRandomKey,
sanitizeLegacyPath,
generateGPGKeyPair,
readSignedMessage,
} from '@bitgo/sdk-core';
import * as sjcl from '@bitgo/sjcl';
import * as utxolib from '@bitgo/utxo-lib';
Expand Down Expand Up @@ -956,20 +958,33 @@ export class BitGoAPI implements BitGoBase {
this.validatePasskeyResponse(passkey);
const userId = JSON.parse(passkey).response.userHandle;

const userGpgKey = await generateGPGKeyPair('secp256k1');
const response: superagent.Response = await request.send({
passkey: passkey,
userId: userId,
publicKey: userGpgKey.publicKey,
});
// extract body and user information
const body = response.body;
this._user = body.user;

// Expecting unencrypted access token in response for now
// TODO (WP-2733): Use GPG encryption to decrypt access token
if (body.access_token) {
this._token = body.access_token;
} else if (body.encryptedToken) {
const constants = await this.fetchConstants();

if (!constants.passkeyBitGoGpgKey) {
throw new Error('Unable to get passkeyBitGoGpgKey');
}

const access_token = await readSignedMessage(
body.encryptedToken,
constants.passkeyBitGoGpgKey,
userGpgKey.privateKey
);
response.body.access_token = access_token;
} else {
throw new Error('failed to create access token');
throw new Error('Failed to login. Please contact support@bitgo.com');
}

return handleResponseResult<LoginResponse>()(response);
Expand Down