Skip to content

Commit b4f41b4

Browse files
committed
feat(sdk-core): add attachPasskeyToWallet function
- fetch wallet to infer coin, keychainId, enterpriseId - verify encryptedPrv exists before PRF assertion - derive enterprise salt and re-encrypt prv with PRF-derived password - PUT webauthnInfo to keychain endpoint Ticket: WCN-411
1 parent 94da3fc commit b4f41b4

4 files changed

Lines changed: 369 additions & 0 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { BitGoBase } from '../bitgoBase';
2+
import { Keychain } from '../keychain';
3+
import { deriveEnterpriseSalt, derivePassword } from '@bitgo/passkey-crypto';
4+
import { WebAuthnOtpDevice, WebAuthnProvider } from './types';
5+
6+
export async function attachPasskeyToWallet(params: {
7+
bitgo: BitGoBase;
8+
walletId: string;
9+
device: WebAuthnOtpDevice;
10+
existingPassphrase: string;
11+
provider: WebAuthnProvider;
12+
}): Promise<Keychain> {
13+
const { bitgo, walletId, device, existingPassphrase, provider } = params;
14+
15+
// Throw early if PRF extension is not supported
16+
if (!device.prfSalt) {
17+
throw new Error('PRF extension not supported by this device. Please use a different passkey.');
18+
}
19+
20+
// Fetch wallet to infer coin, keychainId, enterpriseId
21+
const walletData = await bitgo.get(bitgo.url(`/wallet/${walletId}`, 2)).result();
22+
23+
const coin = walletData.coin;
24+
if (!coin || typeof coin !== 'string') {
25+
throw new Error(`Wallet ${walletId} has no coin type.`);
26+
}
27+
28+
const keys = walletData.keys as string[] | undefined;
29+
if (!keys || keys.length === 0) {
30+
throw new Error(`Wallet ${walletId} has no keys.`);
31+
}
32+
const keychainId = keys[0];
33+
34+
const enterpriseId = walletData.enterprise as string | undefined;
35+
if (!enterpriseId) {
36+
throw new Error(`Wallet ${walletId} has no enterprise.`);
37+
}
38+
39+
// Fetch user keychain
40+
const keychain = await bitgo.get(bitgo.url(`/${coin}/key/${keychainId}`, 2)).result();
41+
42+
if (!keychain.encryptedPrv) {
43+
throw new Error(
44+
`Keychain ${keychainId} has no encryptedPrv. Cannot attach passkey without an existing encrypted private key.`
45+
);
46+
}
47+
48+
// Derive enterprise-scoped salt
49+
const enterpriseSalt = deriveEnterpriseSalt(device.prfSalt, enterpriseId);
50+
51+
// Decrypt private key with existing passphrase
52+
const privateKey = bitgo.decrypt({ password: existingPassphrase, input: keychain.encryptedPrv });
53+
54+
// PRF assertion — evalByCredential maps this device's credentialId to its enterprise salt
55+
const authResult = await provider.get({
56+
publicKey: {} as PublicKeyCredentialRequestOptions,
57+
evalByCredential: { [device.credentialId]: enterpriseSalt },
58+
});
59+
60+
if (!authResult.prfResult) {
61+
throw new Error('PRF assertion did not return a result.');
62+
}
63+
64+
// Derive password from PRF output and re-encrypt
65+
const prfPassword = derivePassword(authResult.prfResult);
66+
const encryptedPrv = bitgo.encrypt({ password: prfPassword, input: privateKey });
67+
68+
// PUT webauthnInfo to keychain endpoint
69+
const updatedKeychain = await bitgo
70+
.put(bitgo.url(`/${coin}/key/${keychainId}`, 2))
71+
.send({
72+
webauthnInfo: {
73+
prfSalt: enterpriseSalt,
74+
otpDeviceId: device.id,
75+
encryptedPrv,
76+
},
77+
})
78+
.result();
79+
80+
return updatedKeychain as Keychain;
81+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './attachPasskeyToWallet';
2+
export * from './types';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type { WebAuthnOtpDevice, WebAuthnProvider, PasskeyAuthResult, PasskeyGetOptions } from '@bitgo/passkey-crypto';
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
import * as assert from 'assert';
2+
import * as sinon from 'sinon';
3+
import 'should';
4+
import { attachPasskeyToWallet } from '../../../../src/bitgo/passkey/attachPasskeyToWallet';
5+
import { WebAuthnOtpDevice } from '../../../../src/bitgo/passkey/types';
6+
import { PasskeyAuthResult } from '@bitgo/passkey-crypto';
7+
8+
describe('attachPasskeyToWallet', function () {
9+
const walletId = 'wallet-abc123';
10+
const keychainId = 'key-user-id';
11+
const enterpriseId = 'enterprise-xyz';
12+
const encryptedPrv = 'encrypted-prv-string';
13+
const decryptedPrv = 'xprv-decrypted';
14+
const existingPassphrase = 'correct-passphrase';
15+
const prfPassword = 'prf-derived-password';
16+
const reEncryptedPrv = 're-encrypted-prv';
17+
const enterpriseSalt = 'derived-enterprise-salt';
18+
19+
const prfResultBuffer = new Uint8Array([0x1e, 0x5c, 0xb4, 0x78]).buffer;
20+
21+
const device: WebAuthnOtpDevice = {
22+
id: 'mongo-object-id-123',
23+
credentialId: 'cred-id-456',
24+
prfSalt: 'base64url-salt',
25+
isPasskey: true,
26+
};
27+
28+
const mockAuthResult: PasskeyAuthResult = {
29+
prfResult: prfResultBuffer,
30+
credentialId: 'cred-id-456',
31+
otpCode: '123456',
32+
};
33+
34+
const updatedKeychain = {
35+
id: keychainId,
36+
pub: 'xpub-123',
37+
type: 'independent' as const,
38+
encryptedPrv,
39+
webauthnDevices: [
40+
{
41+
otpDeviceId: device.id,
42+
prfSalt: enterpriseSalt,
43+
encryptedPrv: reEncryptedPrv,
44+
authenticatorInfo: {
45+
credID: device.credentialId,
46+
fmt: 'none' as const,
47+
publicKey: 'pub-key',
48+
},
49+
},
50+
],
51+
};
52+
53+
let mockBitGo: {
54+
url: sinon.SinonStub;
55+
get: sinon.SinonStub;
56+
put: sinon.SinonStub;
57+
decrypt: sinon.SinonStub;
58+
encrypt: sinon.SinonStub;
59+
};
60+
61+
let mockProvider: {
62+
create: sinon.SinonStub;
63+
get: sinon.SinonStub;
64+
};
65+
66+
let deriveEnterpriseSaltStub: sinon.SinonStub;
67+
let derivePasswordStub: sinon.SinonStub;
68+
69+
beforeEach(function () {
70+
mockBitGo = {
71+
url: sinon
72+
.stub<[path: string, version?: number], string>()
73+
.callsFake((path, version) => `/api/v${version ?? 1}${path}`),
74+
get: sinon.stub(),
75+
put: sinon.stub(),
76+
decrypt: sinon.stub(),
77+
encrypt: sinon.stub(),
78+
};
79+
80+
mockProvider = {
81+
create: sinon.stub(),
82+
get: sinon.stub(),
83+
};
84+
85+
// Default: wallet fetch returns coin + keys + enterprise
86+
mockBitGo.get.withArgs(`/api/v2/wallet/${walletId}`).returns({
87+
result: sinon.stub().resolves({
88+
coin: 'tbtc',
89+
keys: [keychainId, 'backup-key-id', 'bitgo-key-id'],
90+
enterprise: enterpriseId,
91+
}),
92+
});
93+
94+
// Default: keychain fetch returns encryptedPrv
95+
mockBitGo.get.withArgs(`/api/v2/tbtc/key/${keychainId}`).returns({
96+
result: sinon.stub().resolves({ id: keychainId, encryptedPrv }),
97+
});
98+
99+
// Default: decrypt succeeds
100+
mockBitGo.decrypt.returns(decryptedPrv);
101+
102+
// Default: encrypt succeeds
103+
mockBitGo.encrypt.returns(reEncryptedPrv);
104+
105+
// Default: PUT succeeds
106+
const putSendStub = sinon.stub().returns({ result: sinon.stub().resolves(updatedKeychain) });
107+
mockBitGo.put.returns({ send: putSendStub });
108+
109+
// Default: provider.get returns PRF result
110+
mockProvider.get.resolves(mockAuthResult);
111+
112+
// Stub passkey-crypto functions
113+
deriveEnterpriseSaltStub = sinon.stub();
114+
deriveEnterpriseSaltStub.returns(enterpriseSalt);
115+
116+
derivePasswordStub = sinon.stub();
117+
derivePasswordStub.returns(prfPassword);
118+
});
119+
120+
afterEach(function () {
121+
sinon.restore();
122+
});
123+
124+
async function callAttach(overrides?: Partial<Parameters<typeof attachPasskeyToWallet>[0]>) {
125+
return attachPasskeyToWallet({
126+
bitgo: mockBitGo as unknown as Parameters<typeof attachPasskeyToWallet>[0]['bitgo'],
127+
walletId,
128+
device,
129+
existingPassphrase,
130+
provider: mockProvider,
131+
...overrides,
132+
});
133+
}
134+
135+
it('should attach a passkey and return the updated keychain', async function () {
136+
const result = await callAttach();
137+
138+
// Verify wallet was fetched
139+
sinon.assert.calledWith(mockBitGo.get, `/api/v2/wallet/${walletId}`);
140+
141+
// Verify keychain was fetched
142+
sinon.assert.calledWith(mockBitGo.get, `/api/v2/tbtc/key/${keychainId}`);
143+
144+
// Verify decrypt was called with existing passphrase
145+
sinon.assert.calledOnce(mockBitGo.decrypt);
146+
sinon.assert.calledWithExactly(mockBitGo.decrypt, { password: existingPassphrase, input: encryptedPrv });
147+
148+
// Verify provider.get was called with evalByCredential using device.credentialId
149+
sinon.assert.calledOnce(mockProvider.get);
150+
const getArgs = mockProvider.get.firstCall.args[0];
151+
assert.deepStrictEqual(getArgs.evalByCredential, { [device.credentialId]: sinon.match.string });
152+
153+
// Verify encrypt was called with PRF-derived password
154+
sinon.assert.calledOnce(mockBitGo.encrypt);
155+
156+
// Verify PUT was called with correct webauthnInfo shape
157+
sinon.assert.calledOnce(mockBitGo.put);
158+
sinon.assert.calledWith(mockBitGo.put, `/api/v2/tbtc/key/${keychainId}`);
159+
const sendStub = mockBitGo.put.firstCall.returnValue.send;
160+
sinon.assert.calledOnce(sendStub);
161+
const putBody = sendStub.firstCall.args[0];
162+
assert.ok(putBody.webauthnInfo);
163+
assert.strictEqual(putBody.webauthnInfo.otpDeviceId, device.id);
164+
assert.strictEqual(typeof putBody.webauthnInfo.prfSalt, 'string');
165+
assert.strictEqual(typeof putBody.webauthnInfo.encryptedPrv, 'string');
166+
167+
// Verify result is the keychain from server
168+
assert.strictEqual(result.id, keychainId);
169+
});
170+
171+
it('should throw if device.prfSalt is undefined', async function () {
172+
const deviceNoPrf: WebAuthnOtpDevice = { ...device, prfSalt: undefined };
173+
174+
await assert.rejects(
175+
() => callAttach({ device: deviceNoPrf }),
176+
(err: Error) => {
177+
assert.strictEqual(err.message, 'PRF extension not supported by this device. Please use a different passkey.');
178+
return true;
179+
}
180+
);
181+
182+
// No API calls should be made
183+
sinon.assert.notCalled(mockBitGo.get);
184+
sinon.assert.notCalled(mockBitGo.put);
185+
});
186+
187+
it('should throw descriptively if keychain has no encryptedPrv', async function () {
188+
mockBitGo.get.withArgs(`/api/v2/tbtc/key/${keychainId}`).returns({
189+
result: sinon.stub().resolves({ id: keychainId }),
190+
});
191+
192+
await assert.rejects(
193+
() => callAttach(),
194+
(err: Error) => {
195+
assert.ok(err.message.includes('no encryptedPrv'));
196+
return true;
197+
}
198+
);
199+
200+
// No decrypt/encrypt/PUT should happen
201+
sinon.assert.notCalled(mockBitGo.decrypt);
202+
sinon.assert.notCalled(mockBitGo.encrypt);
203+
sinon.assert.notCalled(mockBitGo.put);
204+
});
205+
206+
it('should use device.credentialId as the key in evalByCredential', async function () {
207+
await callAttach();
208+
209+
const getArgs = mockProvider.get.firstCall.args[0];
210+
const evalKeys = Object.keys(getArgs.evalByCredential);
211+
assert.strictEqual(evalKeys.length, 1);
212+
assert.strictEqual(evalKeys[0], device.credentialId);
213+
});
214+
215+
it('should throw if wallet has no coin', async function () {
216+
mockBitGo.get.withArgs(`/api/v2/wallet/${walletId}`).returns({
217+
result: sinon.stub().resolves({ keys: [keychainId], enterprise: enterpriseId }),
218+
});
219+
220+
await assert.rejects(
221+
() => callAttach(),
222+
(err: Error) => {
223+
assert.ok(err.message.includes('has no coin type'));
224+
return true;
225+
}
226+
);
227+
});
228+
229+
it('should throw if wallet has no keys', async function () {
230+
mockBitGo.get.withArgs(`/api/v2/wallet/${walletId}`).returns({
231+
result: sinon.stub().resolves({ coin: 'tbtc', keys: [], enterprise: enterpriseId }),
232+
});
233+
234+
await assert.rejects(
235+
() => callAttach(),
236+
(err: Error) => {
237+
assert.ok(err.message.includes('has no keys'));
238+
return true;
239+
}
240+
);
241+
});
242+
243+
it('should throw if wallet has no enterprise', async function () {
244+
mockBitGo.get.withArgs(`/api/v2/wallet/${walletId}`).returns({
245+
result: sinon.stub().resolves({ coin: 'tbtc', keys: [keychainId] }),
246+
});
247+
248+
await assert.rejects(
249+
() => callAttach(),
250+
(err: Error) => {
251+
assert.ok(err.message.includes('has no enterprise'));
252+
return true;
253+
}
254+
);
255+
});
256+
257+
it('should throw if PRF assertion returns no result', async function () {
258+
mockProvider.get.resolves({ ...mockAuthResult, prfResult: undefined });
259+
260+
await assert.rejects(
261+
() => callAttach(),
262+
(err: Error) => {
263+
assert.ok(err.message.includes('PRF assertion did not return a result'));
264+
return true;
265+
}
266+
);
267+
268+
// PUT should not have been called
269+
sinon.assert.notCalled(mockBitGo.put);
270+
});
271+
272+
it('should propagate decrypt errors', async function () {
273+
mockBitGo.decrypt.throws(new Error('decryption failed'));
274+
275+
await assert.rejects(
276+
() => callAttach(),
277+
(err: Error) => {
278+
assert.ok(err.message.includes('decryption failed'));
279+
return true;
280+
}
281+
);
282+
283+
sinon.assert.notCalled(mockBitGo.put);
284+
});
285+
});

0 commit comments

Comments
 (0)