diff --git a/Dockerfile b/Dockerfile index b0ab9be760..060f525737 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,9 +43,10 @@ COPY --from=builder /tmp/bitgo/modules/express /var/bitgo-express/ #COPY_START COPY --from=builder /tmp/bitgo/modules/abstract-lightning /var/modules/abstract-lightning/ COPY --from=builder /tmp/bitgo/modules/sdk-core /var/modules/sdk-core/ +COPY --from=builder /tmp/bitgo/modules/passkey-crypto /var/modules/passkey-crypto/ +COPY --from=builder /tmp/bitgo/modules/sjcl /var/modules/sjcl/ COPY --from=builder /tmp/bitgo/modules/sdk-lib-mpc /var/modules/sdk-lib-mpc/ COPY --from=builder /tmp/bitgo/modules/sdk-opensslbytes /var/modules/sdk-opensslbytes/ -COPY --from=builder /tmp/bitgo/modules/sjcl /var/modules/sjcl/ COPY --from=builder /tmp/bitgo/modules/secp256k1 /var/modules/secp256k1/ COPY --from=builder /tmp/bitgo/modules/statics /var/modules/statics/ COPY --from=builder /tmp/bitgo/modules/utxo-lib /var/modules/utxo-lib/ @@ -145,9 +146,10 @@ COPY --from=builder /tmp/bitgo/modules/sdk-coin-zec /var/modules/sdk-coin-zec/ RUN cd /var/modules/abstract-lightning && yarn link && \ cd /var/modules/sdk-core && yarn link && \ +cd /var/modules/passkey-crypto && yarn link && \ +cd /var/modules/sjcl && yarn link && \ cd /var/modules/sdk-lib-mpc && yarn link && \ cd /var/modules/sdk-opensslbytes && yarn link && \ -cd /var/modules/sjcl && yarn link && \ cd /var/modules/secp256k1 && yarn link && \ cd /var/modules/statics && yarn link && \ cd /var/modules/utxo-lib && yarn link && \ @@ -250,9 +252,10 @@ cd /var/modules/sdk-coin-zec && yarn link RUN cd /var/bitgo-express && \ yarn link @bitgo/abstract-lightning && \ yarn link @bitgo/sdk-core && \ + yarn link @bitgo/passkey-crypto && \ + yarn link @bitgo/sjcl && \ yarn link @bitgo/sdk-lib-mpc && \ yarn link @bitgo/sdk-opensslbytes && \ - yarn link @bitgo/sjcl && \ yarn link @bitgo/secp256k1 && \ yarn link @bitgo/statics && \ yarn link @bitgo/utxo-lib && \ diff --git a/modules/sdk-core/package.json b/modules/sdk-core/package.json index 49f42b17cb..d6881c3097 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -40,6 +40,7 @@ ] }, "dependencies": { + "@bitgo/passkey-crypto": "^0.1.0", "@bitgo/public-types": "5.97.0", "@bitgo/sdk-lib-mpc": "^10.11.1", "@bitgo/secp256k1": "^1.11.0", diff --git a/modules/sdk-core/src/bitgo/passkey/registerPasskey.ts b/modules/sdk-core/src/bitgo/passkey/registerPasskey.ts new file mode 100644 index 0000000000..a5346bf86f --- /dev/null +++ b/modules/sdk-core/src/bitgo/passkey/registerPasskey.ts @@ -0,0 +1,87 @@ +import { BitGoBase } from '../bitgoBase'; +import { WebAuthnOtpDevice, WebAuthnProvider } from './types'; + +interface RegisterChallengeResponse { + challenge: string; + baseSalt: string; + rp: PublicKeyCredentialRpEntity; + user: PublicKeyCredentialUserEntity; + pubKeyCredParams: PublicKeyCredentialParameters[]; + timeout?: number; + excludeCredentials?: PublicKeyCredentialDescriptor[]; + authenticatorSelection?: AuthenticatorSelectionCriteria; + attestation?: AttestationConveyancePreference; + extensions?: AuthenticationExtensionsClientInputs; +} + +interface RegisterOtpResponse { + id: string; + credentialId: string; + prfSalt?: string; + isPasskey?: boolean; + extensions?: Record; +} + +export async function registerPasskey(params: { + bitgo: BitGoBase; + provider: WebAuthnProvider; + label: string; +}): Promise { + const { bitgo, provider, label } = params; + + // Step 1: Fetch server challenge (contains baseSalt) + const challenge = (await bitgo + .get(bitgo.url('/user/otp/webauthn/register', 2)) + .result()) as RegisterChallengeResponse; + + // Step 2: Pass challenge to provider.create() — browser returns attestation + const attestation = await provider.create({ + challenge: Uint8Array.from(atob(challenge.challenge), (c) => c.charCodeAt(0)), + rp: challenge.rp, + user: challenge.user, + pubKeyCredParams: challenge.pubKeyCredParams, + timeout: challenge.timeout, + excludeCredentials: challenge.excludeCredentials, + authenticatorSelection: challenge.authenticatorSelection, + attestation: challenge.attestation, + extensions: challenge.extensions, + }); + + const attestationResponse = attestation.response as AuthenticatorAttestationResponse; + + // Step 3: Check if PRF output is present in the attestation response + const clientExtensionResults = attestation.getClientExtensionResults() as { + prf?: { results?: { first?: ArrayBuffer } }; + }; + const prfOutput = clientExtensionResults.prf?.results?.first; + const prfSupported = prfOutput !== undefined; + + // Step 4: Build payload — include scopes only if PRF output is present + const otpPayload = { + clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(attestationResponse.clientDataJSON))), + attestationObject: btoa(String.fromCharCode(...new Uint8Array(attestationResponse.attestationObject))), + }; + + const putBody: Record = { + otp: JSON.stringify(otpPayload), + type: 'webauthn', + label, + }; + + if (prfSupported) { + putBody.scopes = ['prf']; + } + + // Step 5: PUT /api/v2/user/otp + const response = (await bitgo.put(bitgo.url('/user/otp', 2)).send(putBody).result()) as RegisterOtpResponse; + + // Step 6: Return WebAuthnOtpDevice + prfSupported + return { + id: response.id, + credentialId: response.credentialId, + prfSalt: response.prfSalt, + isPasskey: response.isPasskey, + extensions: response.extensions, + prfSupported, + }; +} diff --git a/modules/sdk-core/test/unit/bitgo/passkey/registerPasskey.ts b/modules/sdk-core/test/unit/bitgo/passkey/registerPasskey.ts new file mode 100644 index 0000000000..5893f37a1c --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/passkey/registerPasskey.ts @@ -0,0 +1,179 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import 'should'; +import { registerPasskey } from '../../../../src/bitgo/passkey/registerPasskey'; + +describe('registerPasskey', function () { + let mockBitGo: sinon.SinonStubbedInstance<{ + get: (url: string) => { result: () => Promise }; + put: (url: string) => { send: (body: unknown) => { result: () => Promise } }; + url: (path: string, version?: number) => string; + }>; + + const mockChallenge = { + challenge: btoa('server-challenge-bytes'), + baseSalt: 'server-base-salt-abc123', + rp: { id: 'bitgo.com', name: 'BitGo' }, + user: { id: new Uint8Array([1, 2, 3]), name: 'test@bitgo.com', displayName: 'Test User' }, + pubKeyCredParams: [{ type: 'public-key' as const, alg: -7 }], + timeout: 60000, + }; + + const mockOtpResponse = { + id: 'mongo-device-id-123', + credentialId: 'cred-id-base64', + prfSalt: 'server-assigned-prf-salt', + isPasskey: true, + extensions: { prf: true }, + }; + + const clientDataJSON = new TextEncoder().encode(JSON.stringify({ type: 'webauthn.create' })); + const attestationObject = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + + function makeAttestation(prfFirst?: ArrayBuffer): PublicKeyCredential { + return { + id: 'cred-id-base64', + rawId: new ArrayBuffer(8), + type: 'public-key', + response: { + clientDataJSON: clientDataJSON.buffer, + attestationObject: attestationObject.buffer, + getTransports: () => [], + } as unknown as AuthenticatorAttestationResponse, + authenticatorAttachment: null, + getClientExtensionResults: () => + prfFirst !== undefined + ? ({ prf: { results: { first: prfFirst } } } as unknown as AuthenticationExtensionsClientOutputs) + : ({} as AuthenticationExtensionsClientOutputs), + } as unknown as PublicKeyCredential; + } + + beforeEach(function () { + mockBitGo = { + get: sinon.stub(), + put: sinon.stub(), + url: sinon.stub().callsFake((path: string, _version?: number) => `/api/v2${path}`), + } as unknown as typeof mockBitGo; + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('with PRF output present', function () { + it('should include scopes in PUT payload and return prfSupported: true', async function () { + const prfOutput = new ArrayBuffer(32); + const attestation = makeAttestation(prfOutput); + + const mockProvider = { + create: sinon.stub().resolves(attestation), + get: sinon.stub(), + }; + + const getResultStub = sinon.stub().resolves(mockChallenge); + (mockBitGo.get as sinon.SinonStub).returns({ result: getResultStub }); + + const sendStub = sinon.stub().returns({ result: sinon.stub().resolves(mockOtpResponse) }); + (mockBitGo.put as sinon.SinonStub).returns({ send: sendStub }); + + const result = await registerPasskey({ + bitgo: mockBitGo as unknown as Parameters[0]['bitgo'], + provider: mockProvider, + label: 'My Passkey', + }); + + // Verify GET challenge called + assert.ok((mockBitGo.get as sinon.SinonStub).calledOnce, 'GET challenge should be called'); + assert.ok( + (mockBitGo.get as sinon.SinonStub).calledWith('/api/v2/user/otp/webauthn/register'), + 'GET should call correct URL' + ); + + // Verify PUT called with scopes + const putBody = sendStub.firstCall.args[0] as Record; + assert.ok(putBody.scopes !== undefined, 'scopes should be present when PRF output exists'); + assert.deepStrictEqual(putBody.scopes, ['prf']); + assert.strictEqual(putBody.type, 'webauthn'); + assert.strictEqual(putBody.label, 'My Passkey'); + + // Verify PUT uses correct endpoint + assert.ok((mockBitGo.put as sinon.SinonStub).calledWith('/api/v2/user/otp'), 'PUT should call correct URL'); + + // Verify return shape + assert.strictEqual(result.id, mockOtpResponse.id); + assert.strictEqual(result.credentialId, mockOtpResponse.credentialId); + assert.strictEqual(result.prfSalt, mockOtpResponse.prfSalt); + assert.strictEqual(result.isPasskey, mockOtpResponse.isPasskey); + assert.strictEqual(result.prfSupported, true); + }); + }); + + describe('without PRF output', function () { + it('should omit scopes from PUT payload and return prfSupported: false', async function () { + const attestation = makeAttestation(undefined); + + const mockProvider = { + create: sinon.stub().resolves(attestation), + get: sinon.stub(), + }; + + const getResultStub = sinon.stub().resolves(mockChallenge); + (mockBitGo.get as sinon.SinonStub).returns({ result: getResultStub }); + + const sendStub = sinon.stub().returns({ result: sinon.stub().resolves(mockOtpResponse) }); + (mockBitGo.put as sinon.SinonStub).returns({ send: sendStub }); + + const result = await registerPasskey({ + bitgo: mockBitGo as unknown as Parameters[0]['bitgo'], + provider: mockProvider, + label: 'My Passkey No PRF', + }); + + // Verify scopes omitted + const putBody = sendStub.firstCall.args[0] as Record; + assert.ok(putBody.scopes === undefined, 'scopes should be omitted when PRF output is absent'); + assert.strictEqual(putBody.type, 'webauthn'); + assert.strictEqual(putBody.label, 'My Passkey No PRF'); + + // Verify return shape + assert.strictEqual(result.id, mockOtpResponse.id); + assert.strictEqual(result.credentialId, mockOtpResponse.credentialId); + assert.strictEqual(result.prfSupported, false); + }); + }); + + describe('baseSalt sourcing', function () { + it('should call GET challenge before provider.create()', async function () { + const callOrder: string[] = []; + + const mockProvider = { + create: sinon.stub().callsFake(() => { + callOrder.push('provider.create'); + return Promise.resolve(makeAttestation()); + }), + get: sinon.stub(), + }; + + const getResultStub = sinon.stub().callsFake(() => { + callOrder.push('GET challenge'); + return Promise.resolve(mockChallenge); + }); + (mockBitGo.get as sinon.SinonStub).returns({ result: getResultStub }); + + const sendStub = sinon.stub().returns({ result: sinon.stub().resolves(mockOtpResponse) }); + (mockBitGo.put as sinon.SinonStub).returns({ send: sendStub }); + + await registerPasskey({ + bitgo: mockBitGo as unknown as Parameters[0]['bitgo'], + provider: mockProvider, + label: 'Test', + }); + + assert.deepStrictEqual( + callOrder, + ['GET challenge', 'provider.create'], + 'GET challenge must precede provider.create' + ); + }); + }); +});