diff --git a/modules/passkey-crypto/package.json b/modules/passkey-crypto/package.json index 1c3beb216b..d87804a456 100644 --- a/modules/passkey-crypto/package.json +++ b/modules/passkey-crypto/package.json @@ -35,8 +35,7 @@ }, "dependencies": { "@bitgo/public-types": "6.1.0", - "@bitgo/sdk-core": "^36.42.0", - "@bitgo/sjcl": "^1.1.0" + "@bitgo/sdk-core": "^36.44.0" }, "devDependencies": { "@types/node": "^18.0.0" diff --git a/modules/passkey-crypto/src/attachPasskeyToWallet.ts b/modules/passkey-crypto/src/attachPasskeyToWallet.ts new file mode 100644 index 0000000000..575828f7b9 --- /dev/null +++ b/modules/passkey-crypto/src/attachPasskeyToWallet.ts @@ -0,0 +1,88 @@ +import { BitGoBase, Keychain } from '@bitgo/sdk-core'; +import { deriveEnterpriseSalt } from './deriveEnterpriseSalt'; +import { derivePassword } from './derivePassword'; +import { WebAuthnOtpDevice, WebAuthnProvider } from './webAuthnTypes'; + +export async function attachPasskeyToWallet(params: { + bitgo: BitGoBase; + coin: string; + walletId: string; + device: WebAuthnOtpDevice; + existingPassphrase: string; + provider: WebAuthnProvider; +}): Promise { + const { bitgo, coin, walletId, device, existingPassphrase, provider } = params; + + // Throw early if PRF extension is not supported + if (!device.prfSalt) { + throw new Error('PRF extension not supported by this device. Please use a different passkey.'); + } + + const baseCoin = bitgo.coin(coin); + + // Fetch wallet and validate it is a hot wallet + const wallet = await baseCoin.wallets().get({ id: walletId }); + + if (wallet.type() !== 'hot') { + throw new Error(`Wallet ${walletId} is not a hot wallet. Only hot wallets support passkey attachment.`); + } + + const walletData = wallet.toJSON(); + const enterpriseId = walletData.enterprise; + if (!enterpriseId) { + throw new Error(`Wallet ${walletId} has no enterprise.`); + } + + // Fetch the user keychain — iterates keys until it finds one with encryptedPrv + const keychain = await wallet.getEncryptedUserKeychain(); + const keychainId = keychain.id; + + // Derive enterprise-scoped salt + const enterpriseSalt = deriveEnterpriseSalt(device.prfSalt, enterpriseId); + + // Decrypt private key with existing passphrase + const privateKey = bitgo.decrypt({ password: existingPassphrase, input: keychain.encryptedPrv }); + + // Decode credentialId from base64url to ArrayBuffer for allowCredentials. + // The WebAuthn spec requires allowCredentials to be non-empty when using evalByCredential, + // and each entry must correspond to a key in the evalByCredential map. + const credentialIdBuffer = Buffer.from(device.credentialId.replace(/-/g, '+').replace(/_/g, '/'), 'base64').buffer; + + // PRF assertion — evalByCredential maps this device's credentialId to its enterprise salt + const authResult = await provider.get({ + publicKey: { + allowCredentials: [{ type: 'public-key', id: credentialIdBuffer }], + } as PublicKeyCredentialRequestOptions, + evalByCredential: { [device.credentialId]: enterpriseSalt }, + }); + + if (!authResult.prfResult) { + throw new Error('PRF assertion did not return a result.'); + } + + // Derive password from PRF output and re-encrypt + const prfPassword = derivePassword(authResult.prfResult); + const encryptedPrv = bitgo.encrypt({ password: prfPassword, input: privateKey }); + + // Convert enterpriseSalt from hex to base64url (URL-safe, no padding) + // as required by the server's prfSalt validation. + const prfSaltBase64url = Buffer.from(enterpriseSalt, 'hex') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + + // PUT webauthnInfo to keychain endpoint + const updatedKeychain = await bitgo + .put(bitgo.url(`/${coin}/key/${keychainId}`, 2)) + .send({ + webauthnInfo: { + prfSalt: prfSaltBase64url, + otpDeviceId: device.id, + encryptedPrv, + }, + }) + .result(); + + return updatedKeychain as Keychain; +} diff --git a/modules/passkey-crypto/src/deriveEnterpriseSalt.ts b/modules/passkey-crypto/src/deriveEnterpriseSalt.ts index 688c3e9822..caef1d40fe 100644 --- a/modules/passkey-crypto/src/deriveEnterpriseSalt.ts +++ b/modules/passkey-crypto/src/deriveEnterpriseSalt.ts @@ -1,11 +1,4 @@ -import * as sjcl from '@bitgo/sjcl'; -import type { SjclCodecs, SjclHashes, SjclMisc } from '@bitgo/sjcl'; - -type SjclType = { - hash: SjclHashes; - codec: SjclCodecs; - misc: SjclMisc; -}; +import { createHmac } from 'crypto'; /** * Derives an enterprise-scoped PRF salt to prevent cross-enterprise key reuse. @@ -15,16 +8,9 @@ type SjclType = { * * @param baseSalt - Server-provided base64url-encoded PRF salt * @param enterpriseId - Enterprise identifier - * @returns Base64-encoded HMAC-SHA256 digest + * @returns Hex-encoded HMAC-SHA256 digest */ export function deriveEnterpriseSalt(baseSalt: string, enterpriseId: string): string { - const { misc, codec, hash } = sjcl as unknown as SjclType; - - const keyBits = codec.base64url.toBits(baseSalt); - const dataBits = codec.utf8String.toBits(enterpriseId); - - const hmacInstance = new misc.hmac(keyBits, hash.sha256); - const resultBits = hmacInstance.mac(dataBits); - - return codec.base64.fromBits(resultBits); + const keyBytes = Buffer.from(baseSalt.replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + return createHmac('sha256', keyBytes).update(enterpriseId).digest('hex'); } diff --git a/modules/passkey-crypto/test/unit/attachPasskeyToWallet.test.ts b/modules/passkey-crypto/test/unit/attachPasskeyToWallet.test.ts new file mode 100644 index 0000000000..222515cfaa --- /dev/null +++ b/modules/passkey-crypto/test/unit/attachPasskeyToWallet.test.ts @@ -0,0 +1,247 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { attachPasskeyToWallet } from '../../src/attachPasskeyToWallet'; +import { WebAuthnOtpDevice, PasskeyAuthResult, WebAuthnProvider } from '../../src/webAuthnTypes'; + +describe('attachPasskeyToWallet', function () { + const coin = 'tbtc'; + const walletId = 'wallet-abc123'; + const keychainId = 'key-user-id'; + const enterpriseId = 'enterprise-xyz'; + const encryptedPrv = 'encrypted-prv-string'; + const decryptedPrv = 'xprv-decrypted'; + const existingPassphrase = 'correct-passphrase'; + const reEncryptedPrv = 're-encrypted-prv'; + + const prfResultBuffer = new Uint8Array([0x1e, 0x5c, 0xb4, 0x78]).buffer; + + const device: WebAuthnOtpDevice = { + id: 'mongo-object-id-123', + credentialId: 'cred-id-456', + prfSalt: 'ZqJ64M2dL65zn2-Jxd58SMN2ILc9QjbCFxUTGHd_LC8', + isPasskey: true, + }; + + const mockAuthResult: PasskeyAuthResult = { + prfResult: prfResultBuffer, + credentialId: 'cred-id-456', + otpCode: '123456', + }; + + const updatedKeychain = { + id: keychainId, + pub: 'xpub-123', + type: 'independent' as const, + encryptedPrv, + }; + + let mockWallet: { + type: sinon.SinonStub; + toJSON: sinon.SinonStub; + getEncryptedUserKeychain: sinon.SinonStub; + }; + + let mockWallets: { + get: sinon.SinonStub; + }; + + let mockBaseCoin: { + wallets: sinon.SinonStub; + }; + + let mockBitGo: { + url: sinon.SinonStub; + coin: sinon.SinonStub; + put: sinon.SinonStub; + decrypt: sinon.SinonStub; + encrypt: sinon.SinonStub; + }; + + let mockProvider: { + create: sinon.SinonStub; + get: sinon.SinonStub; + }; + + beforeEach(function () { + mockWallet = { + type: sinon.stub().returns('hot'), + toJSON: sinon.stub().returns({ enterprise: enterpriseId }), + getEncryptedUserKeychain: sinon.stub().resolves({ id: keychainId, encryptedPrv }), + }; + + mockWallets = { + get: sinon.stub().resolves(mockWallet), + }; + + mockBaseCoin = { + wallets: sinon.stub().returns(mockWallets), + }; + + mockBitGo = { + url: sinon + .stub<[path: string, version?: number], string>() + .callsFake((path, version) => `/api/v${version ?? 1}${path}`), + coin: sinon.stub().returns(mockBaseCoin), + put: sinon.stub(), + decrypt: sinon.stub(), + encrypt: sinon.stub(), + }; + + mockProvider = { + create: sinon.stub(), + get: sinon.stub(), + }; + + mockBitGo.decrypt.returns(decryptedPrv); + mockBitGo.encrypt.returns(reEncryptedPrv); + + const putSendStub = sinon.stub().returns({ result: sinon.stub().resolves(updatedKeychain) }); + mockBitGo.put.returns({ send: putSendStub }); + + mockProvider.get.resolves(mockAuthResult); + }); + + afterEach(function () { + sinon.restore(); + }); + + async function callAttach(overrides?: Partial[0]>) { + return attachPasskeyToWallet({ + bitgo: mockBitGo as unknown as Parameters[0]['bitgo'], + coin, + walletId, + device, + existingPassphrase, + provider: mockProvider as unknown as WebAuthnProvider, + ...overrides, + }); + } + + it('should attach a passkey and return the updated keychain', async function () { + const result = await callAttach(); + + sinon.assert.calledWith(mockBitGo.coin, coin); + sinon.assert.calledWith(mockWallets.get, { id: walletId }); + sinon.assert.calledOnce(mockWallet.type); + sinon.assert.calledOnce(mockWallet.getEncryptedUserKeychain); + sinon.assert.calledOnce(mockBitGo.decrypt); + sinon.assert.calledWithExactly(mockBitGo.decrypt, { password: existingPassphrase, input: encryptedPrv }); + + // provider.get called with evalByCredential keyed on device.credentialId + sinon.assert.calledOnce(mockProvider.get); + const getArgs = mockProvider.get.firstCall.args[0]; + assert.ok(getArgs.evalByCredential); + assert.strictEqual(typeof getArgs.evalByCredential[device.credentialId], 'string'); + + // allowCredentials must be populated with the credential ID as an ArrayBuffer + assert.ok(Array.isArray(getArgs.publicKey.allowCredentials)); + assert.strictEqual(getArgs.publicKey.allowCredentials.length, 1); + assert.strictEqual(getArgs.publicKey.allowCredentials[0].type, 'public-key'); + assert.ok(getArgs.publicKey.allowCredentials[0].id instanceof ArrayBuffer); + + // PUT called with correct shape + sinon.assert.calledOnce(mockBitGo.put); + sinon.assert.calledWith(mockBitGo.put, `/api/v2/${coin}/key/${keychainId}`); + const sendStub = mockBitGo.put.firstCall.returnValue.send; + sinon.assert.calledOnce(sendStub); + const putBody = sendStub.firstCall.args[0]; + assert.ok(putBody.webauthnInfo); + assert.strictEqual(putBody.webauthnInfo.otpDeviceId, device.id); + // prfSalt must be base64url (URL-safe, no padding) as required by server validation + assert.match(putBody.webauthnInfo.prfSalt, /^[A-Za-z0-9\-_]+$/); + assert.strictEqual(typeof putBody.webauthnInfo.encryptedPrv, 'string'); + + assert.strictEqual(result.id, keychainId); + }); + + it('should decode credentialId containing base64url-specific characters (- and _)', async function () { + const deviceWithUrlChars: WebAuthnOtpDevice = { + ...device, + credentialId: 'abc-def_ghi+jkl', + }; + + const result = await callAttach({ device: deviceWithUrlChars }); + assert.ok(result); + + const getArgs = mockProvider.get.firstCall.args[0]; + assert.ok(getArgs.publicKey.allowCredentials[0].id instanceof ArrayBuffer); + }); + + it('should throw if device.prfSalt is undefined', async function () { + const deviceNoPrf: WebAuthnOtpDevice = { ...device, prfSalt: undefined }; + + await assert.rejects( + () => callAttach({ device: deviceNoPrf }), + (err: Error) => { + assert.strictEqual(err.message, 'PRF extension not supported by this device. Please use a different passkey.'); + return true; + } + ); + + sinon.assert.notCalled(mockBitGo.coin); + sinon.assert.notCalled(mockBitGo.put); + }); + + it('should throw if wallet is not a hot wallet', async function () { + mockWallet.type.returns('cold'); + + await assert.rejects( + () => callAttach(), + (err: Error) => { + assert.ok(err.message.includes('not a hot wallet')); + return true; + } + ); + + sinon.assert.notCalled(mockBitGo.put); + }); + + it('should throw if wallet has no enterprise', async function () { + mockWallet.toJSON.returns({ enterprise: undefined }); + + await assert.rejects( + () => callAttach(), + (err: Error) => { + assert.ok(err.message.includes('has no enterprise')); + return true; + } + ); + }); + + it('should throw if PRF assertion returns no result', async function () { + mockProvider.get.resolves({ ...mockAuthResult, prfResult: undefined }); + + await assert.rejects( + () => callAttach(), + (err: Error) => { + assert.ok(err.message.includes('PRF assertion did not return a result')); + return true; + } + ); + + sinon.assert.notCalled(mockBitGo.put); + }); + + it('should propagate decrypt errors', async function () { + mockBitGo.decrypt.throws(new Error('decryption failed')); + + await assert.rejects( + () => callAttach(), + (err: Error) => { + assert.ok(err.message.includes('decryption failed')); + return true; + } + ); + + sinon.assert.notCalled(mockBitGo.put); + }); + + it('should use device.credentialId as the key in evalByCredential', async function () { + await callAttach(); + + const getArgs = mockProvider.get.firstCall.args[0]; + const evalKeys = Object.keys(getArgs.evalByCredential); + assert.strictEqual(evalKeys.length, 1); + assert.strictEqual(evalKeys[0], device.credentialId); + }); +}); diff --git a/modules/passkey-crypto/test/unit/deriveEnterpriseSalt.test.ts b/modules/passkey-crypto/test/unit/deriveEnterpriseSalt.test.ts index 7d1c7d49d2..d9eaab111f 100644 --- a/modules/passkey-crypto/test/unit/deriveEnterpriseSalt.test.ts +++ b/modules/passkey-crypto/test/unit/deriveEnterpriseSalt.test.ts @@ -5,7 +5,7 @@ import { deriveEnterpriseSalt } from '../../src'; const REAL_FIXTURE = { basePrfSalt: 'ZqJ64M2dL65zn2-Jxd58SMN2ILc9QjbCFxUTGHd_LC8', enterpriseId: '69c2aea1a3d7bc07f7f775c0ca86b0ec', - expectedDerivedSalt: 'oiasOqzkuyuEz/8043+3IXYghSu3LV4N/a1MLIRzmU8=', + expectedDerivedSalt: 'a226ac3aace4bb2b84cfff34e37fb7217620852bb72d5e0dfdad4c2c8473994f', }; describe('deriveEnterpriseSalt', function () { @@ -37,10 +37,10 @@ describe('deriveEnterpriseSalt', function () { assert.notStrictEqual(saltA, saltB); }); - it('returns a non-empty base64 string', function () { + it('returns a non-empty hex string', function () { const result = deriveEnterpriseSalt(REAL_FIXTURE.basePrfSalt, REAL_FIXTURE.enterpriseId); assert.strictEqual(typeof result, 'string'); assert.ok(result.length > 0); - assert.match(result, /^[A-Za-z0-9+/]+=*$/); + assert.match(result, /^[0-9a-f]{64}$/); }); });