Skip to content

Commit ebd4c33

Browse files
committed
feat(passkey-crypto): add removePasskeyFromWallet function
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. TICKET: WCN-190
1 parent 3dd77d6 commit ebd4c33

4 files changed

Lines changed: 282 additions & 0 deletions

File tree

modules/passkey-crypto/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
},
3636
"dependencies": {
3737
"@bitgo/public-types": "6.1.0",
38+
"@bitgo/sdk-core": "^36.42.0",
3839
"@bitgo/sjcl": "^1.1.0"
3940
},
4041
"devDependencies": {

modules/passkey-crypto/src/index.ts

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

0 commit comments

Comments
 (0)