Skip to content

Commit b2f92f7

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-189
1 parent 94da3fc commit b2f92f7

4 files changed

Lines changed: 351 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": {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { BitGoBase, Keychain } from '@bitgo/sdk-core';
2+
import { deriveEnterpriseSalt } from './deriveEnterpriseSalt';
3+
import { derivePassword } from './derivePassword';
4+
import { WebAuthnOtpDevice, WebAuthnProvider } from './webAuthnTypes';
5+
6+
export async function attachPasskeyToWallet(params: {
7+
bitgo: BitGoBase;
8+
coin: string;
9+
walletId: string;
10+
device: WebAuthnOtpDevice;
11+
existingPassphrase: string;
12+
provider: WebAuthnProvider;
13+
}): Promise<Keychain> {
14+
const { bitgo, coin, walletId, device, existingPassphrase, provider } = params;
15+
16+
// Throw early if PRF extension is not supported
17+
if (!device.prfSalt) {
18+
throw new Error('PRF extension not supported by this device. Please use a different passkey.');
19+
}
20+
21+
const baseCoin = bitgo.coin(coin);
22+
23+
// Fetch wallet and validate it is a hot wallet
24+
const wallet = await baseCoin.wallets().get({ id: walletId });
25+
26+
if (wallet.type() !== 'hot') {
27+
throw new Error(`Wallet ${walletId} is not a hot wallet. Only hot wallets support passkey attachment.`);
28+
}
29+
30+
const keyIds = wallet.keyIds();
31+
if (!keyIds || keyIds.length === 0) {
32+
throw new Error(`Wallet ${walletId} has no keys.`);
33+
}
34+
const keychainId = keyIds[0];
35+
36+
const walletData = wallet.toJSON();
37+
const enterpriseId = walletData.enterprise;
38+
if (!enterpriseId) {
39+
throw new Error(`Wallet ${walletId} has no enterprise.`);
40+
}
41+
42+
// Fetch user keychain
43+
const keychain = await baseCoin.keychains().get({ id: keychainId });
44+
45+
if (!keychain.encryptedPrv) {
46+
throw new Error(
47+
`Keychain ${keychainId} has no encryptedPrv. Cannot attach passkey without an existing encrypted private key.`
48+
);
49+
}
50+
51+
// Derive enterprise-scoped salt
52+
const enterpriseSalt = deriveEnterpriseSalt(device.prfSalt, enterpriseId);
53+
54+
// Decrypt private key with existing passphrase
55+
const privateKey = bitgo.decrypt({ password: existingPassphrase, input: keychain.encryptedPrv });
56+
57+
// PRF assertion — evalByCredential maps this device's credentialId to its enterprise salt
58+
const authResult = await provider.get({
59+
publicKey: {} as PublicKeyCredentialRequestOptions,
60+
evalByCredential: { [device.credentialId]: enterpriseSalt },
61+
});
62+
63+
if (!authResult.prfResult) {
64+
throw new Error('PRF assertion did not return a result.');
65+
}
66+
67+
// Derive password from PRF output and re-encrypt
68+
const prfPassword = derivePassword(authResult.prfResult);
69+
const encryptedPrv = bitgo.encrypt({ password: prfPassword, input: privateKey });
70+
71+
// PUT webauthnInfo to keychain endpoint
72+
const updatedKeychain = await bitgo
73+
.put(bitgo.url(`/${coin}/key/${keychainId}`, 2))
74+
.send({
75+
webauthnInfo: {
76+
prfSalt: enterpriseSalt,
77+
otpDeviceId: device.id,
78+
encryptedPrv,
79+
},
80+
})
81+
.result();
82+
83+
return updatedKeychain as Keychain;
84+
}

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

0 commit comments

Comments
 (0)