Skip to content

Commit 4c6a78f

Browse files
committed
feat(sdk-core): add removePasskeyFromWallet with passphrase verification
Removes a WebAuthn passkey credential from a wallet's user keychain. Verifies the wallet passphrase via decrypt() before issuing the DELETE to prevent accidental lockout. Validates device.id, wallet coin, and wallet keys before proceeding. Uses WebAuthnOtpDevice from @bitgo/public-types instead of a local stub. TICKET: WCN-190
1 parent d05150a commit 4c6a78f

5 files changed

Lines changed: 286 additions & 0 deletions

File tree

modules/sdk-core/src/bitgo/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export * from './errors';
1515
export * from './inscriptionBuilder';
1616
export * from './internal';
1717
export * from './keychain';
18+
export * from './passkey';
1819
export * as bitcoin from './legacyBitcoin';
1920
export * from './market';
2021
export * from './pendingApproval';
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './types';
2+
export * from './removePasskeyFromWallet';
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { BitGoBase } from '../bitgoBase';
2+
import { WebAuthnOtpDevice } from './types';
3+
4+
export async function removePasskeyFromWallet(params: {
5+
bitgo: BitGoBase;
6+
walletId: string;
7+
device: WebAuthnOtpDevice;
8+
walletPassphrase: string;
9+
}): Promise<void> {
10+
const { bitgo, walletId, device, walletPassphrase } = params;
11+
12+
if (!device.id) {
13+
throw new Error('device.id is required to remove a passkey from the wallet');
14+
}
15+
16+
// Fetch wallet to infer coin and keychainId
17+
const walletData = await bitgo.get(bitgo.url(`/wallet/${walletId}`, 2)).result();
18+
19+
const coin = walletData.coin;
20+
if (!coin || typeof coin !== 'string') {
21+
throw new Error(`Wallet ${walletId} has no coin type. Cannot remove passkey.`);
22+
}
23+
24+
const keys = walletData.keys as string[] | undefined;
25+
if (!keys || keys.length === 0) {
26+
throw new Error(`Wallet ${walletId} has no keys. Cannot remove passkey.`);
27+
}
28+
const keychainId = keys[0];
29+
30+
// Fetch user keychain
31+
const keychain = await bitgo.get(bitgo.url(`/${coin}/key/${keychainId}`, 2)).result();
32+
33+
if (!keychain.encryptedPrv) {
34+
throw new Error(`Keychain ${keychainId} has no encryptedPrv. Cannot verify passphrase before passkey removal.`);
35+
}
36+
37+
// Verify passphrase before any mutation
38+
try {
39+
bitgo.decrypt({ password: walletPassphrase, input: keychain.encryptedPrv });
40+
} catch {
41+
throw new Error('Incorrect wallet passphrase. Passkey removal aborted to prevent lockout.');
42+
}
43+
44+
// DELETE the webauthn device using device.id (MongoDB ObjectId), not credentialId
45+
await bitgo.del(bitgo.url(`/key/${keychainId}/webauthndevice/${device.id}`, 2)).result();
46+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type { WebAuthnOtpDevice } from '@bitgo/public-types';
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import * as assert from 'assert';
2+
import * as sinon from 'sinon';
3+
import 'should';
4+
import { removePasskeyFromWallet } from '../../../../src/bitgo/passkey/removePasskeyFromWallet';
5+
import { WebAuthnOtpDevice } from '../../../../src/bitgo/passkey/types';
6+
7+
describe('removePasskeyFromWallet', function () {
8+
const walletId = 'wallet-abc123';
9+
const keychainId = 'key-user-id';
10+
const encryptedPrv = 'encrypted-prv-string';
11+
const walletPassphrase = 'correct-passphrase';
12+
const decryptedPrv = 'xprv-decrypted';
13+
14+
const device: WebAuthnOtpDevice = {
15+
id: 'mongo-object-id-123',
16+
credentialId: 'cred-id-456',
17+
prfSalt: 'some-salt',
18+
isPasskey: true,
19+
};
20+
21+
let mockBitGo: sinon.SinonStubbedInstance<{
22+
url: (path: string, version?: number) => string;
23+
get: (url: string) => { result: () => Promise<unknown> };
24+
del: (url: string) => { result: () => Promise<unknown> };
25+
decrypt: (params: { password: string; input: string }) => string;
26+
}>;
27+
28+
beforeEach(function () {
29+
mockBitGo = {
30+
url: sinon
31+
.stub<[path: string, version?: number], string>()
32+
.callsFake((path, version) => `/api/v${version ?? 1}${path}`),
33+
get: sinon.stub(),
34+
del: sinon.stub(),
35+
decrypt: sinon.stub(),
36+
};
37+
38+
// Default: wallet fetch returns coin + keys
39+
(mockBitGo.get as sinon.SinonStub).withArgs(`/api/v2/wallet/${walletId}`).returns({
40+
result: sinon.stub().resolves({ coin: 'tbtc', keys: [keychainId, 'backup-key-id', 'bitgo-key-id'] }),
41+
});
42+
43+
// Default: keychain fetch returns encryptedPrv
44+
(mockBitGo.get as sinon.SinonStub).withArgs(`/api/v2/tbtc/key/${keychainId}`).returns({
45+
result: sinon.stub().resolves({ id: keychainId, encryptedPrv }),
46+
});
47+
48+
// Default: decrypt succeeds
49+
(mockBitGo.decrypt as sinon.SinonStub).returns(decryptedPrv);
50+
51+
// Default: DELETE succeeds
52+
(mockBitGo.del as sinon.SinonStub).returns({
53+
result: sinon.stub().resolves({}),
54+
});
55+
});
56+
57+
afterEach(function () {
58+
sinon.restore();
59+
});
60+
61+
it('should successfully remove a passkey device', async function () {
62+
await removePasskeyFromWallet({
63+
bitgo: mockBitGo as any,
64+
walletId,
65+
device,
66+
walletPassphrase,
67+
});
68+
69+
// Verify decrypt was called with the right args
70+
sinon.assert.calledOnce(mockBitGo.decrypt);
71+
sinon.assert.calledWithExactly(mockBitGo.decrypt, { password: walletPassphrase, input: encryptedPrv });
72+
73+
// Verify DELETE was called with device.id (not credentialId)
74+
sinon.assert.calledOnce(mockBitGo.del);
75+
sinon.assert.calledWithExactly(mockBitGo.del, `/api/v2/key/${keychainId}/webauthndevice/${device.id}`);
76+
});
77+
78+
it('should throw and not call DELETE if passphrase is wrong', async function () {
79+
(mockBitGo.decrypt as sinon.SinonStub).throws(new Error('decryption failed'));
80+
81+
await assert.rejects(
82+
() =>
83+
removePasskeyFromWallet({
84+
bitgo: mockBitGo as any,
85+
walletId,
86+
device,
87+
walletPassphrase: 'wrong-passphrase',
88+
}),
89+
(err: Error) => {
90+
assert.ok(err.message.includes('Incorrect wallet passphrase'));
91+
assert.ok(err.message.includes('Passkey removal aborted to prevent lockout'));
92+
return true;
93+
}
94+
);
95+
96+
// DELETE must NOT have been called
97+
sinon.assert.notCalled(mockBitGo.del);
98+
});
99+
100+
it('should throw descriptively if keychain has no encryptedPrv', async function () {
101+
(mockBitGo.get as sinon.SinonStub).withArgs(`/api/v2/tbtc/key/${keychainId}`).returns({
102+
result: sinon.stub().resolves({ id: keychainId }),
103+
});
104+
105+
await assert.rejects(
106+
() =>
107+
removePasskeyFromWallet({
108+
bitgo: mockBitGo as any,
109+
walletId,
110+
device,
111+
walletPassphrase,
112+
}),
113+
(err: Error) => {
114+
assert.ok(err.message.includes('no encryptedPrv'));
115+
return true;
116+
}
117+
);
118+
119+
// No decrypt or DELETE should be called
120+
sinon.assert.notCalled(mockBitGo.decrypt);
121+
sinon.assert.notCalled(mockBitGo.del);
122+
});
123+
124+
it('should throw if device.id is empty', async function () {
125+
const deviceNoId = { ...device, id: '' };
126+
127+
await assert.rejects(
128+
() =>
129+
removePasskeyFromWallet({
130+
bitgo: mockBitGo as any,
131+
walletId,
132+
device: deviceNoId,
133+
walletPassphrase,
134+
}),
135+
(err: Error) => {
136+
assert.ok(err.message.includes('device.id is required'));
137+
return true;
138+
}
139+
);
140+
141+
// No API calls should be made
142+
sinon.assert.notCalled(mockBitGo.get as sinon.SinonStub);
143+
sinon.assert.notCalled(mockBitGo.del);
144+
});
145+
146+
it('should throw if wallet has no coin', async function () {
147+
(mockBitGo.get as sinon.SinonStub).withArgs(`/api/v2/wallet/${walletId}`).returns({
148+
result: sinon.stub().resolves({ keys: [keychainId] }),
149+
});
150+
151+
await assert.rejects(
152+
() =>
153+
removePasskeyFromWallet({
154+
bitgo: mockBitGo as any,
155+
walletId,
156+
device,
157+
walletPassphrase,
158+
}),
159+
(err: Error) => {
160+
assert.ok(err.message.includes('has no coin type'));
161+
return true;
162+
}
163+
);
164+
165+
sinon.assert.notCalled(mockBitGo.decrypt);
166+
sinon.assert.notCalled(mockBitGo.del);
167+
});
168+
169+
it('should throw if wallet has no keys', async function () {
170+
(mockBitGo.get as sinon.SinonStub).withArgs(`/api/v2/wallet/${walletId}`).returns({
171+
result: sinon.stub().resolves({ coin: 'tbtc', keys: [] }),
172+
});
173+
174+
await assert.rejects(
175+
() =>
176+
removePasskeyFromWallet({
177+
bitgo: mockBitGo as any,
178+
walletId,
179+
device,
180+
walletPassphrase,
181+
}),
182+
(err: Error) => {
183+
assert.ok(err.message.includes('has no keys'));
184+
return true;
185+
}
186+
);
187+
188+
sinon.assert.notCalled(mockBitGo.decrypt);
189+
sinon.assert.notCalled(mockBitGo.del);
190+
});
191+
192+
it('should propagate wallet fetch errors', async function () {
193+
(mockBitGo.get as sinon.SinonStub).withArgs(`/api/v2/wallet/${walletId}`).returns({
194+
result: sinon.stub().rejects(new Error('404 Not Found')),
195+
});
196+
197+
await assert.rejects(
198+
() =>
199+
removePasskeyFromWallet({
200+
bitgo: mockBitGo as any,
201+
walletId,
202+
device,
203+
walletPassphrase,
204+
}),
205+
(err: Error) => {
206+
assert.ok(err.message.includes('404 Not Found'));
207+
return true;
208+
}
209+
);
210+
211+
sinon.assert.notCalled(mockBitGo.del);
212+
});
213+
214+
it('should propagate DELETE errors after passphrase verification', async function () {
215+
(mockBitGo.del as sinon.SinonStub).returns({
216+
result: sinon.stub().rejects(new Error('500 Internal Server Error')),
217+
});
218+
219+
await assert.rejects(
220+
() =>
221+
removePasskeyFromWallet({
222+
bitgo: mockBitGo as any,
223+
walletId,
224+
device,
225+
walletPassphrase,
226+
}),
227+
(err: Error) => {
228+
assert.ok(err.message.includes('500 Internal Server Error'));
229+
return true;
230+
}
231+
);
232+
233+
// Passphrase verification should have succeeded before DELETE failed
234+
sinon.assert.calledOnce(mockBitGo.decrypt);
235+
});
236+
});

0 commit comments

Comments
 (0)