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
1 change: 1 addition & 0 deletions modules/passkey-crypto/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { deriveEnterpriseSalt } from './deriveEnterpriseSalt';
export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
export { removePasskeyFromAccount } from './removePasskeyFromAccount';
export type { WebAuthnOtpDevice, PasskeyAuthResult, PasskeyGetOptions, WebAuthnProvider } from './webAuthnTypes';
export { removePasskeyFromWallet } from './removePasskeyFromWallet';
30 changes: 30 additions & 0 deletions modules/passkey-crypto/src/removePasskeyFromWallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { BitGoBase, decryptKeychainPrivateKey } from '@bitgo/sdk-core';
import { WebAuthnOtpDevice } from './webAuthnTypes';

export async function removePasskeyFromWallet(params: {
bitgo: BitGoBase;
coin: string;
walletId: string;
device: WebAuthnOtpDevice;
walletPassphrase: string;
}): Promise<void> {
const { bitgo, coin: coinName, walletId, device, walletPassphrase } = params;

if (!device.id) {
throw new Error('device.id is required to remove a passkey from the wallet');
}

const baseCoin = bitgo.coin(coinName);
const wallet = await baseCoin.wallets().get({ id: walletId });
const keychainId = wallet.keyIds()[0];
const keychain = await baseCoin.keychains().get({ id: keychainId });

// Verify passphrase before any mutation
const decrypted = decryptKeychainPrivateKey(bitgo, keychain, walletPassphrase);
if (!decrypted) {
throw new Error('Incorrect wallet passphrase. Passkey removal aborted to prevent lockout.');
}

// No sdk-core abstraction for this endpoint; raw DELETE is required
await bitgo.del(bitgo.url(`/key/${keychainId}/webauthndevice/${device.id}`, 2)).result();
}
181 changes: 181 additions & 0 deletions modules/passkey-crypto/test/unit/removePasskeyFromWallet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import * as assert from 'assert';
import * as sinon from 'sinon';
import { removePasskeyFromWallet } from '../../src';

describe('removePasskeyFromWallet', function () {
const coinName = 'tbtc';
const walletId = 'wallet-abc123';
const keychainId = 'key-user-id';
const encryptedPrv = 'encrypted-prv-string';
const walletPassphrase = 'correct-passphrase';

const device = {
id: 'mongo-object-id-123',
credentialId: 'cred-id-456',
prfSalt: 'some-salt',
isPasskey: true,
};

let mockBitGo: any;
let mockWallet: any;
let mockKeychains: any;
let mockWallets: any;

beforeEach(function () {
mockWallet = {
keyIds: sinon.stub().returns([keychainId, 'backup-key-id', 'bitgo-key-id']),
};

mockWallets = {
get: sinon.stub().resolves(mockWallet),
};

mockKeychains = {
get: sinon.stub().resolves({ id: keychainId, encryptedPrv }),
};

mockBitGo = {
coin: sinon.stub().returns({
wallets: sinon.stub().returns(mockWallets),
keychains: sinon.stub().returns(mockKeychains),
}),
decrypt: sinon.stub().returns('xprv-decrypted'),
del: sinon.stub().returns({
result: sinon.stub().resolves({}),
}),
url: sinon.stub().callsFake((path: string, version?: number) => `/api/v${version ?? 1}${path}`),
};
});

afterEach(function () {
sinon.restore();
});

it('should successfully remove a passkey device', async function () {
await removePasskeyFromWallet({
bitgo: mockBitGo,
coin: coinName,
walletId,
device,
walletPassphrase,
});

// Verify coin was initialized
sinon.assert.calledWithExactly(mockBitGo.coin, coinName);

// Verify wallet was fetched
sinon.assert.calledWithExactly(mockWallets.get, { id: walletId });

// Verify keychain was fetched with correct ID
sinon.assert.calledWithExactly(mockKeychains.get, { id: keychainId });

// Verify DELETE was called with device.id (not credentialId)
sinon.assert.calledOnce(mockBitGo.del);
sinon.assert.calledWithExactly(mockBitGo.del, `/api/v2/key/${keychainId}/webauthndevice/${device.id}`);
});

it('should throw and not call DELETE if passphrase is wrong', async function () {
mockBitGo.decrypt = sinon.stub().throws(new Error('decryption failed'));

await assert.rejects(
() =>
removePasskeyFromWallet({
bitgo: mockBitGo,
coin: coinName,
walletId,
device,
walletPassphrase: 'wrong-passphrase',
}),
(err: Error) => {
assert.ok(err.message.includes('Incorrect wallet passphrase'));
return true;
}
);

sinon.assert.notCalled(mockBitGo.del);
});

it('should throw descriptively if keychain has no encryptedPrv', async function () {
mockKeychains.get = sinon.stub().resolves({ id: keychainId });

await assert.rejects(
() =>
removePasskeyFromWallet({
bitgo: mockBitGo,
coin: coinName,
walletId,
device,
walletPassphrase,
}),
(err: Error) => {
assert.ok(err.message.includes('Incorrect wallet passphrase'));
return true;
}
);

sinon.assert.notCalled(mockBitGo.del);
});

it('should throw if device.id is empty', async function () {
const deviceNoId = { ...device, id: '' };

await assert.rejects(
() =>
removePasskeyFromWallet({
bitgo: mockBitGo,
coin: coinName,
walletId,
device: deviceNoId,
walletPassphrase,
}),
(err: Error) => {
assert.ok(err.message.includes('device.id is required'));
return true;
}
);

sinon.assert.notCalled(mockBitGo.coin);
});

it('should propagate wallet fetch errors', async function () {
mockWallets.get = sinon.stub().rejects(new Error('404 Not Found'));

await assert.rejects(
() =>
removePasskeyFromWallet({
bitgo: mockBitGo,
coin: coinName,
walletId,
device,
walletPassphrase,
}),
(err: Error) => {
assert.ok(err.message.includes('404 Not Found'));
return true;
}
);

sinon.assert.notCalled(mockBitGo.del);
});

it('should propagate DELETE errors after passphrase verification', async function () {
mockBitGo.del = sinon.stub().returns({
result: sinon.stub().rejects(new Error('500 Internal Server Error')),
});

await assert.rejects(
() =>
removePasskeyFromWallet({
bitgo: mockBitGo,
coin: coinName,
walletId,
device,
walletPassphrase,
}),
(err: Error) => {
assert.ok(err.message.includes('500 Internal Server Error'));
return true;
}
);
});
});
Loading