Skip to content

Commit 772edd2

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 772edd2

6 files changed

Lines changed: 394 additions & 21 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: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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+
// Decode credentialId from base64url to ArrayBuffer for allowCredentials.
58+
// The WebAuthn spec requires allowCredentials to be non-empty when using evalByCredential,
59+
// and each entry must correspond to a key in the evalByCredential map.
60+
const credentialIdBuffer = Buffer.from(device.credentialId.replace(/-/g, '+').replace(/_/g, '/'), 'base64').buffer;
61+
62+
// PRF assertion — evalByCredential maps this device's credentialId to its enterprise salt
63+
const authResult = await provider.get({
64+
publicKey: {
65+
allowCredentials: [{ type: 'public-key', id: credentialIdBuffer }],
66+
} as PublicKeyCredentialRequestOptions,
67+
evalByCredential: { [device.credentialId]: enterpriseSalt },
68+
});
69+
70+
if (!authResult.prfResult) {
71+
throw new Error('PRF assertion did not return a result.');
72+
}
73+
74+
// Derive password from PRF output and re-encrypt
75+
const prfPassword = derivePassword(authResult.prfResult);
76+
const encryptedPrv = bitgo.encrypt({ password: prfPassword, input: privateKey });
77+
78+
// Convert enterpriseSalt from hex to base64url (URL-safe, no padding)
79+
// as required by the server's prfSalt validation.
80+
const prfSaltBase64url = Buffer.from(enterpriseSalt, 'hex')
81+
.toString('base64')
82+
.replace(/\+/g, '-')
83+
.replace(/\//g, '_')
84+
.replace(/=+$/, '');
85+
86+
// PUT webauthnInfo to keychain endpoint
87+
const updatedKeychain = await bitgo
88+
.put(bitgo.url(`/${coin}/key/${keychainId}`, 2))
89+
.send({
90+
webauthnInfo: {
91+
prfSalt: prfSaltBase64url,
92+
otpDeviceId: device.id,
93+
encryptedPrv,
94+
},
95+
})
96+
.result();
97+
98+
return updatedKeychain as Keychain;
99+
}
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
}

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';

0 commit comments

Comments
 (0)