Skip to content

Commit 895e229

Browse files
committed
feat(sdk-core): add removePasskeyFromWallet function
TICKET: WCN-190
1 parent 63ee850 commit 895e229

5 files changed

Lines changed: 167 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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
// Fetch wallet to infer coin and keychainId
13+
// We use a temporary coin to access the wallets API; the wallet's actual coin is read after fetch
14+
const walletData = await bitgo.get(bitgo.url(`/wallet/${walletId}`, 2)).result();
15+
16+
const coin = walletData.coin as string;
17+
const keychainId = (walletData.keys as string[])[0];
18+
19+
// Fetch user keychain
20+
const keychain = await bitgo.get(bitgo.url(`/${coin}/key/${keychainId}`, 2)).result();
21+
22+
if (!keychain.encryptedPrv) {
23+
throw new Error(`Keychain ${keychainId} has no encryptedPrv. Cannot verify passphrase before passkey removal.`);
24+
}
25+
26+
// Verify passphrase before any mutation
27+
try {
28+
bitgo.decrypt({ password: walletPassphrase, input: keychain.encryptedPrv });
29+
} catch {
30+
throw new Error('Incorrect wallet passphrase. Passkey removal aborted to prevent lockout.');
31+
}
32+
33+
// DELETE the webauthn device using device.id (MongoDB ObjectId), not credentialId
34+
await bitgo.del(bitgo.url(`/key/${keychainId}/webauthndevice/${device.id}`, 2)).result();
35+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// TODO: replace with: export type { WebAuthnOtpDevice } from '@bitgo/public-types'
2+
export interface WebAuthnOtpDevice {
3+
id: string; // serialized MongoDB _id — used for DELETE
4+
credentialId: string; // from authenticatorInfo.credID
5+
prfSalt?: string;
6+
isPasskey?: boolean;
7+
extensions?: Record<string, boolean>;
8+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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.stub().callsFake((path: string, version?: number) => `/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+
});

0 commit comments

Comments
 (0)