Skip to content

Commit 6a1efe1

Browse files
committed
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
1 parent f273031 commit 6a1efe1

3 files changed

Lines changed: 212 additions & 0 deletions

File tree

modules/passkey-crypto/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export { deriveEnterpriseSalt } from './deriveEnterpriseSalt';
44
export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
55
export { removePasskeyFromAccount } from './removePasskeyFromAccount';
66
export type { WebAuthnOtpDevice, PasskeyAuthResult, PasskeyGetOptions, WebAuthnProvider } from './webAuthnTypes';
7+
export { removePasskeyFromWallet } from './removePasskeyFromWallet';
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { BitGoBase, decryptKeychainPrivateKey } from '@bitgo/sdk-core';
2+
import { WebAuthnOtpDevice } from './webAuthnTypes';
3+
4+
export async function removePasskeyFromWallet(params: {
5+
bitgo: BitGoBase;
6+
coin: string;
7+
walletId: string;
8+
device: WebAuthnOtpDevice;
9+
walletPassphrase: string;
10+
}): Promise<void> {
11+
const { bitgo, coin: coinName, walletId, device, walletPassphrase } = params;
12+
13+
if (!device.id) {
14+
throw new Error('device.id is required to remove a passkey from the wallet');
15+
}
16+
17+
const baseCoin = bitgo.coin(coinName);
18+
const wallet = await baseCoin.wallets().get({ id: walletId });
19+
const keychainId = wallet.keyIds()[0];
20+
const keychain = await baseCoin.keychains().get({ id: keychainId });
21+
22+
// Verify passphrase before any mutation
23+
const decrypted = decryptKeychainPrivateKey(bitgo, keychain, walletPassphrase);
24+
if (!decrypted) {
25+
throw new Error('Incorrect wallet passphrase. Passkey removal aborted to prevent lockout.');
26+
}
27+
28+
// No sdk-core abstraction for this endpoint; raw DELETE is required
29+
await bitgo.del(bitgo.url(`/key/${keychainId}/webauthndevice/${device.id}`, 2)).result();
30+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import * as assert from 'assert';
2+
import * as sinon from 'sinon';
3+
import { removePasskeyFromWallet } from '../../src';
4+
5+
describe('removePasskeyFromWallet', function () {
6+
const coinName = 'tbtc';
7+
const walletId = 'wallet-abc123';
8+
const keychainId = 'key-user-id';
9+
const encryptedPrv = 'encrypted-prv-string';
10+
const walletPassphrase = 'correct-passphrase';
11+
12+
const device = {
13+
id: 'mongo-object-id-123',
14+
credentialId: 'cred-id-456',
15+
prfSalt: 'some-salt',
16+
isPasskey: true,
17+
};
18+
19+
let mockBitGo: any;
20+
let mockWallet: any;
21+
let mockKeychains: any;
22+
let mockWallets: any;
23+
24+
beforeEach(function () {
25+
mockWallet = {
26+
keyIds: sinon.stub().returns([keychainId, 'backup-key-id', 'bitgo-key-id']),
27+
};
28+
29+
mockWallets = {
30+
get: sinon.stub().resolves(mockWallet),
31+
};
32+
33+
mockKeychains = {
34+
get: sinon.stub().resolves({ id: keychainId, encryptedPrv }),
35+
};
36+
37+
mockBitGo = {
38+
coin: sinon.stub().returns({
39+
wallets: sinon.stub().returns(mockWallets),
40+
keychains: sinon.stub().returns(mockKeychains),
41+
}),
42+
decrypt: sinon.stub().returns('xprv-decrypted'),
43+
del: sinon.stub().returns({
44+
result: sinon.stub().resolves({}),
45+
}),
46+
url: sinon.stub().callsFake((path: string, version?: number) => `/api/v${version ?? 1}${path}`),
47+
};
48+
});
49+
50+
afterEach(function () {
51+
sinon.restore();
52+
});
53+
54+
it('should successfully remove a passkey device', async function () {
55+
await removePasskeyFromWallet({
56+
bitgo: mockBitGo,
57+
coin: coinName,
58+
walletId,
59+
device,
60+
walletPassphrase,
61+
});
62+
63+
// Verify coin was initialized
64+
sinon.assert.calledWithExactly(mockBitGo.coin, coinName);
65+
66+
// Verify wallet was fetched
67+
sinon.assert.calledWithExactly(mockWallets.get, { id: walletId });
68+
69+
// Verify keychain was fetched with correct ID
70+
sinon.assert.calledWithExactly(mockKeychains.get, { id: keychainId });
71+
72+
// Verify DELETE was called with device.id (not credentialId)
73+
sinon.assert.calledOnce(mockBitGo.del);
74+
sinon.assert.calledWithExactly(mockBitGo.del, `/api/v2/key/${keychainId}/webauthndevice/${device.id}`);
75+
});
76+
77+
it('should throw and not call DELETE if passphrase is wrong', async function () {
78+
mockBitGo.decrypt = sinon.stub().throws(new Error('decryption failed'));
79+
80+
await assert.rejects(
81+
() =>
82+
removePasskeyFromWallet({
83+
bitgo: mockBitGo,
84+
coin: coinName,
85+
walletId,
86+
device,
87+
walletPassphrase: 'wrong-passphrase',
88+
}),
89+
(err: Error) => {
90+
assert.ok(err.message.includes('Incorrect wallet passphrase'));
91+
return true;
92+
}
93+
);
94+
95+
sinon.assert.notCalled(mockBitGo.del);
96+
});
97+
98+
it('should throw descriptively if keychain has no encryptedPrv', async function () {
99+
mockKeychains.get = sinon.stub().resolves({ id: keychainId });
100+
101+
await assert.rejects(
102+
() =>
103+
removePasskeyFromWallet({
104+
bitgo: mockBitGo,
105+
coin: coinName,
106+
walletId,
107+
device,
108+
walletPassphrase,
109+
}),
110+
(err: Error) => {
111+
assert.ok(err.message.includes('Incorrect wallet passphrase'));
112+
return true;
113+
}
114+
);
115+
116+
sinon.assert.notCalled(mockBitGo.del);
117+
});
118+
119+
it('should throw if device.id is empty', async function () {
120+
const deviceNoId = { ...device, id: '' };
121+
122+
await assert.rejects(
123+
() =>
124+
removePasskeyFromWallet({
125+
bitgo: mockBitGo,
126+
coin: coinName,
127+
walletId,
128+
device: deviceNoId,
129+
walletPassphrase,
130+
}),
131+
(err: Error) => {
132+
assert.ok(err.message.includes('device.id is required'));
133+
return true;
134+
}
135+
);
136+
137+
sinon.assert.notCalled(mockBitGo.coin);
138+
});
139+
140+
it('should propagate wallet fetch errors', async function () {
141+
mockWallets.get = sinon.stub().rejects(new Error('404 Not Found'));
142+
143+
await assert.rejects(
144+
() =>
145+
removePasskeyFromWallet({
146+
bitgo: mockBitGo,
147+
coin: coinName,
148+
walletId,
149+
device,
150+
walletPassphrase,
151+
}),
152+
(err: Error) => {
153+
assert.ok(err.message.includes('404 Not Found'));
154+
return true;
155+
}
156+
);
157+
158+
sinon.assert.notCalled(mockBitGo.del);
159+
});
160+
161+
it('should propagate DELETE errors after passphrase verification', async function () {
162+
mockBitGo.del = sinon.stub().returns({
163+
result: sinon.stub().rejects(new Error('500 Internal Server Error')),
164+
});
165+
166+
await assert.rejects(
167+
() =>
168+
removePasskeyFromWallet({
169+
bitgo: mockBitGo,
170+
coin: coinName,
171+
walletId,
172+
device,
173+
walletPassphrase,
174+
}),
175+
(err: Error) => {
176+
assert.ok(err.message.includes('500 Internal Server Error'));
177+
return true;
178+
}
179+
);
180+
});
181+
});

0 commit comments

Comments
 (0)