|
| 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 | +} |
0 commit comments