From 6a1efe11f87f7649f4b967096797e079b09f669d Mon Sep 17 00:00:00 2001 From: Derran Wijesinghe Date: Mon, 4 May 2026 14:56:16 -0400 Subject: [PATCH] feat(passkey-crypto): add removePasskeyFromWallet function Removes a WebAuthn passkey credential from a wallet's user keychain. Uses idiomatic sdk-core methods (wallets().get(), keychains().get(), decryptKeychainPrivateKey) instead of raw HTTP calls. Verifies the wallet passphrase before issuing the DELETE to prevent accidental lockout. Validates device.id before proceeding. TICKET: WCN-190 --- modules/passkey-crypto/src/index.ts | 1 + .../src/removePasskeyFromWallet.ts | 30 +++ .../test/unit/removePasskeyFromWallet.test.ts | 181 ++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 modules/passkey-crypto/src/removePasskeyFromWallet.ts create mode 100644 modules/passkey-crypto/test/unit/removePasskeyFromWallet.test.ts diff --git a/modules/passkey-crypto/src/index.ts b/modules/passkey-crypto/src/index.ts index bbeb41c893..256add52db 100644 --- a/modules/passkey-crypto/src/index.ts +++ b/modules/passkey-crypto/src/index.ts @@ -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'; diff --git a/modules/passkey-crypto/src/removePasskeyFromWallet.ts b/modules/passkey-crypto/src/removePasskeyFromWallet.ts new file mode 100644 index 0000000000..ec8222b449 --- /dev/null +++ b/modules/passkey-crypto/src/removePasskeyFromWallet.ts @@ -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 { + 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(); +} diff --git a/modules/passkey-crypto/test/unit/removePasskeyFromWallet.test.ts b/modules/passkey-crypto/test/unit/removePasskeyFromWallet.test.ts new file mode 100644 index 0000000000..5ea3a1a818 --- /dev/null +++ b/modules/passkey-crypto/test/unit/removePasskeyFromWallet.test.ts @@ -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; + } + ); + }); +});