Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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 && \
Expand Down Expand Up @@ -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 && \
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
87 changes: 87 additions & 0 deletions modules/sdk-core/src/bitgo/passkey/registerPasskey.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean>;
}

export async function registerPasskey(params: {
bitgo: BitGoBase;
provider: WebAuthnProvider;
label: string;
}): Promise<WebAuthnOtpDevice & { prfSupported: boolean }> {
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<string, unknown> = {
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,
};
}
179 changes: 179 additions & 0 deletions modules/sdk-core/test/unit/bitgo/passkey/registerPasskey.ts
Original file line number Diff line number Diff line change
@@ -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: <T>() => Promise<T> };
put: (url: string) => { send: (body: unknown) => { result: <T>() => Promise<T> } };
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<typeof registerPasskey>[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<string, unknown>;
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<typeof registerPasskey>[0]['bitgo'],
provider: mockProvider,
label: 'My Passkey No PRF',
});

// Verify scopes omitted
const putBody = sendStub.firstCall.args[0] as Record<string, unknown>;
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<typeof registerPasskey>[0]['bitgo'],
provider: mockProvider,
label: 'Test',
});

assert.deepStrictEqual(
callOrder,
['GET challenge', 'provider.create'],
'GET challenge must precede provider.create'
);
});
});
});
Loading