Skip to content

Commit 1b84f65

Browse files
committed
feat(passkey-crypto): add attachPasskeyToWallet function
- use sdk-core wallet and keychain methods for retrieval - validate hot wallet before attaching passkey - derive enterprise-scoped PRF salt via HMAC-SHA256 - re-encrypt private key with PRF-derived password - PUT webauthnInfo to keychain endpoint with base64url prfSalt Ticket: WCN-189
1 parent d8a32c5 commit 1b84f65

5 files changed

Lines changed: 343 additions & 23 deletions

File tree

modules/passkey-crypto/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@
3535
},
3636
"dependencies": {
3737
"@bitgo/public-types": "6.1.0",
38-
"@bitgo/sdk-core": "^36.42.0",
39-
"@bitgo/sjcl": "^1.1.0"
38+
"@bitgo/sdk-core": "36.44.0"
4039
},
4140
"devDependencies": {
4241
"@types/node": "^18.0.0"
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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 walletData = wallet.toJSON();
31+
const enterpriseId = walletData.enterprise;
32+
if (!enterpriseId) {
33+
throw new Error(`Wallet ${walletId} has no enterprise.`);
34+
}
35+
36+
// Fetch the user keychain — iterates keys until it finds one with encryptedPrv
37+
const keychain = await wallet.getEncryptedUserKeychain();
38+
const keychainId = keychain.id;
39+
40+
// Derive enterprise-scoped salt
41+
const enterpriseSalt = deriveEnterpriseSalt(device.prfSalt, enterpriseId);
42+
43+
// Decrypt private key with existing passphrase
44+
const privateKey = bitgo.decrypt({ password: existingPassphrase, input: keychain.encryptedPrv });
45+
46+
// Decode credentialId from base64url to ArrayBuffer for allowCredentials.
47+
// The WebAuthn spec requires allowCredentials to be non-empty when using evalByCredential,
48+
// and each entry must correspond to a key in the evalByCredential map.
49+
const credentialIdBuffer = Buffer.from(device.credentialId.replace(/-/g, '+').replace(/_/g, '/'), 'base64').buffer;
50+
51+
// PRF assertion — evalByCredential maps this device's credentialId to its enterprise salt
52+
const authResult = await provider.get({
53+
publicKey: {
54+
allowCredentials: [{ type: 'public-key', id: credentialIdBuffer }],
55+
} as PublicKeyCredentialRequestOptions,
56+
evalByCredential: { [device.credentialId]: enterpriseSalt },
57+
});
58+
59+
if (!authResult.prfResult) {
60+
throw new Error('PRF assertion did not return a result.');
61+
}
62+
63+
// Derive password from PRF output and re-encrypt
64+
const prfPassword = derivePassword(authResult.prfResult);
65+
const encryptedPrv = bitgo.encrypt({ password: prfPassword, input: privateKey });
66+
67+
// Convert enterpriseSalt from hex to base64url (URL-safe, no padding)
68+
// as required by the server's prfSalt validation.
69+
const prfSaltBase64url = Buffer.from(enterpriseSalt, 'hex')
70+
.toString('base64')
71+
.replace(/\+/g, '-')
72+
.replace(/\//g, '_')
73+
.replace(/=+$/, '');
74+
75+
// PUT webauthnInfo to keychain endpoint
76+
const updatedKeychain = await bitgo
77+
.put(bitgo.url(`/${coin}/key/${keychainId}`, 2))
78+
.send({
79+
webauthnInfo: {
80+
prfSalt: prfSaltBase64url,
81+
otpDeviceId: device.id,
82+
encryptedPrv,
83+
},
84+
})
85+
.result();
86+
87+
return updatedKeychain as Keychain;
88+
}
Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
import * as sjcl from '@bitgo/sjcl';
2-
import type { SjclCodecs, SjclHashes, SjclMisc } from '@bitgo/sjcl';
3-
4-
type SjclType = {
5-
hash: SjclHashes;
6-
codec: SjclCodecs;
7-
misc: SjclMisc;
8-
};
1+
import { createHmac } from 'crypto';
92

103
/**
114
* Derives an enterprise-scoped PRF salt to prevent cross-enterprise key reuse.
@@ -15,16 +8,9 @@ type SjclType = {
158
*
169
* @param baseSalt - Server-provided base64url-encoded PRF salt
1710
* @param enterpriseId - Enterprise identifier
18-
* @returns Base64-encoded HMAC-SHA256 digest
11+
* @returns Hex-encoded HMAC-SHA256 digest
1912
*/
2013
export function deriveEnterpriseSalt(baseSalt: string, enterpriseId: string): string {
21-
const { misc, codec, hash } = sjcl as unknown as SjclType;
22-
23-
const keyBits = codec.base64url.toBits(baseSalt);
24-
const dataBits = codec.utf8String.toBits(enterpriseId);
25-
26-
const hmacInstance = new misc.hmac(keyBits, hash.sha256);
27-
const resultBits = hmacInstance.mac(dataBits);
28-
29-
return codec.base64.fromBits(resultBits);
14+
const keyBytes = Buffer.from(baseSalt.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
15+
return createHmac('sha256', keyBytes).update(enterpriseId).digest('hex');
3016
}
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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+
toJSON: sinon.SinonStub;
41+
getEncryptedUserKeychain: sinon.SinonStub;
42+
};
43+
44+
let mockWallets: {
45+
get: sinon.SinonStub;
46+
};
47+
48+
let mockBaseCoin: {
49+
wallets: sinon.SinonStub;
50+
};
51+
52+
let mockBitGo: {
53+
url: sinon.SinonStub;
54+
coin: sinon.SinonStub;
55+
put: sinon.SinonStub;
56+
decrypt: sinon.SinonStub;
57+
encrypt: sinon.SinonStub;
58+
};
59+
60+
let mockProvider: {
61+
create: sinon.SinonStub;
62+
get: sinon.SinonStub;
63+
};
64+
65+
beforeEach(function () {
66+
mockWallet = {
67+
type: sinon.stub().returns('hot'),
68+
toJSON: sinon.stub().returns({ enterprise: enterpriseId }),
69+
getEncryptedUserKeychain: sinon.stub().resolves({ id: keychainId, encryptedPrv }),
70+
};
71+
72+
mockWallets = {
73+
get: sinon.stub().resolves(mockWallet),
74+
};
75+
76+
mockBaseCoin = {
77+
wallets: sinon.stub().returns(mockWallets),
78+
};
79+
80+
mockBitGo = {
81+
url: sinon
82+
.stub<[path: string, version?: number], string>()
83+
.callsFake((path, version) => `/api/v${version ?? 1}${path}`),
84+
coin: sinon.stub().returns(mockBaseCoin),
85+
put: sinon.stub(),
86+
decrypt: sinon.stub(),
87+
encrypt: sinon.stub(),
88+
};
89+
90+
mockProvider = {
91+
create: sinon.stub(),
92+
get: sinon.stub(),
93+
};
94+
95+
mockBitGo.decrypt.returns(decryptedPrv);
96+
mockBitGo.encrypt.returns(reEncryptedPrv);
97+
98+
const putSendStub = sinon.stub().returns({ result: sinon.stub().resolves(updatedKeychain) });
99+
mockBitGo.put.returns({ send: putSendStub });
100+
101+
mockProvider.get.resolves(mockAuthResult);
102+
});
103+
104+
afterEach(function () {
105+
sinon.restore();
106+
});
107+
108+
async function callAttach(overrides?: Partial<Parameters<typeof attachPasskeyToWallet>[0]>) {
109+
return attachPasskeyToWallet({
110+
bitgo: mockBitGo as unknown as Parameters<typeof attachPasskeyToWallet>[0]['bitgo'],
111+
coin,
112+
walletId,
113+
device,
114+
existingPassphrase,
115+
provider: mockProvider as unknown as WebAuthnProvider,
116+
...overrides,
117+
});
118+
}
119+
120+
it('should attach a passkey and return the updated keychain', async function () {
121+
const result = await callAttach();
122+
123+
sinon.assert.calledWith(mockBitGo.coin, coin);
124+
sinon.assert.calledWith(mockWallets.get, { id: walletId });
125+
sinon.assert.calledOnce(mockWallet.type);
126+
sinon.assert.calledOnce(mockWallet.getEncryptedUserKeychain);
127+
sinon.assert.calledOnce(mockBitGo.decrypt);
128+
sinon.assert.calledWithExactly(mockBitGo.decrypt, { password: existingPassphrase, input: encryptedPrv });
129+
130+
// provider.get called with evalByCredential keyed on device.credentialId
131+
sinon.assert.calledOnce(mockProvider.get);
132+
const getArgs = mockProvider.get.firstCall.args[0];
133+
assert.ok(getArgs.evalByCredential);
134+
assert.strictEqual(typeof getArgs.evalByCredential[device.credentialId], 'string');
135+
136+
// allowCredentials must be populated with the credential ID as an ArrayBuffer
137+
assert.ok(Array.isArray(getArgs.publicKey.allowCredentials));
138+
assert.strictEqual(getArgs.publicKey.allowCredentials.length, 1);
139+
assert.strictEqual(getArgs.publicKey.allowCredentials[0].type, 'public-key');
140+
assert.ok(getArgs.publicKey.allowCredentials[0].id instanceof ArrayBuffer);
141+
142+
// PUT called with correct shape
143+
sinon.assert.calledOnce(mockBitGo.put);
144+
sinon.assert.calledWith(mockBitGo.put, `/api/v2/${coin}/key/${keychainId}`);
145+
const sendStub = mockBitGo.put.firstCall.returnValue.send;
146+
sinon.assert.calledOnce(sendStub);
147+
const putBody = sendStub.firstCall.args[0];
148+
assert.ok(putBody.webauthnInfo);
149+
assert.strictEqual(putBody.webauthnInfo.otpDeviceId, device.id);
150+
// prfSalt must be base64url (URL-safe, no padding) as required by server validation
151+
assert.match(putBody.webauthnInfo.prfSalt, /^[A-Za-z0-9\-_]+$/);
152+
assert.strictEqual(typeof putBody.webauthnInfo.encryptedPrv, 'string');
153+
154+
assert.strictEqual(result.id, keychainId);
155+
});
156+
157+
it('should decode credentialId containing base64url-specific characters (- and _)', async function () {
158+
const deviceWithUrlChars: WebAuthnOtpDevice = {
159+
...device,
160+
credentialId: 'abc-def_ghi+jkl',
161+
};
162+
163+
const result = await callAttach({ device: deviceWithUrlChars });
164+
assert.ok(result);
165+
166+
const getArgs = mockProvider.get.firstCall.args[0];
167+
assert.ok(getArgs.publicKey.allowCredentials[0].id instanceof ArrayBuffer);
168+
});
169+
170+
it('should throw if device.prfSalt is undefined', async function () {
171+
const deviceNoPrf: WebAuthnOtpDevice = { ...device, prfSalt: undefined };
172+
173+
await assert.rejects(
174+
() => callAttach({ device: deviceNoPrf }),
175+
(err: Error) => {
176+
assert.strictEqual(err.message, 'PRF extension not supported by this device. Please use a different passkey.');
177+
return true;
178+
}
179+
);
180+
181+
sinon.assert.notCalled(mockBitGo.coin);
182+
sinon.assert.notCalled(mockBitGo.put);
183+
});
184+
185+
it('should throw if wallet is not a hot wallet', async function () {
186+
mockWallet.type.returns('cold');
187+
188+
await assert.rejects(
189+
() => callAttach(),
190+
(err: Error) => {
191+
assert.ok(err.message.includes('not a hot wallet'));
192+
return true;
193+
}
194+
);
195+
196+
sinon.assert.notCalled(mockBitGo.put);
197+
});
198+
199+
it('should throw if wallet has no enterprise', async function () {
200+
mockWallet.toJSON.returns({ enterprise: undefined });
201+
202+
await assert.rejects(
203+
() => callAttach(),
204+
(err: Error) => {
205+
assert.ok(err.message.includes('has no enterprise'));
206+
return true;
207+
}
208+
);
209+
});
210+
211+
it('should throw if PRF assertion returns no result', async function () {
212+
mockProvider.get.resolves({ ...mockAuthResult, prfResult: undefined });
213+
214+
await assert.rejects(
215+
() => callAttach(),
216+
(err: Error) => {
217+
assert.ok(err.message.includes('PRF assertion did not return a result'));
218+
return true;
219+
}
220+
);
221+
222+
sinon.assert.notCalled(mockBitGo.put);
223+
});
224+
225+
it('should propagate decrypt errors', async function () {
226+
mockBitGo.decrypt.throws(new Error('decryption failed'));
227+
228+
await assert.rejects(
229+
() => callAttach(),
230+
(err: Error) => {
231+
assert.ok(err.message.includes('decryption failed'));
232+
return true;
233+
}
234+
);
235+
236+
sinon.assert.notCalled(mockBitGo.put);
237+
});
238+
239+
it('should use device.credentialId as the key in evalByCredential', async function () {
240+
await callAttach();
241+
242+
const getArgs = mockProvider.get.firstCall.args[0];
243+
const evalKeys = Object.keys(getArgs.evalByCredential);
244+
assert.strictEqual(evalKeys.length, 1);
245+
assert.strictEqual(evalKeys[0], device.credentialId);
246+
});
247+
});

0 commit comments

Comments
 (0)