Skip to content
Draft
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
8 changes: 8 additions & 0 deletions modules/passkey-crypto/.mocharc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
require: 'tsx'
timeout: '20000'
reporter: 'min'
reporter-option:
- 'cdn=true'
- 'json=false'
exit: true
spec: ['test/unit/**/*.ts']
42 changes: 42 additions & 0 deletions modules/passkey-crypto/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "@bitgo/passkey-crypto",
"version": "0.1.0",
"description": "Pure cryptographic primitives for BitGo passkey (WebAuthn PRF) key derivation",
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "yarn tsc --build --incremental --verbose .",
"fmt": "prettier --write .",
"check-fmt": "prettier --check '**/*.{ts,js,json}'",
"clean": "rm -r ./dist",
"lint": "eslint --quiet .",
"prepare": "npm run build",
"test": "npm run unit-test",
"unit-test": "mocha 'test/unit/**/*.ts'"
},
"author": "BitGo SDK Team <sdkteam@bitgo.com>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/BitGo/BitGoJS.git",
"directory": "modules/passkey-crypto"
},
"lint-staged": {
"*.{js,ts}": [
"yarn prettier --write",
"yarn eslint --fix"
]
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@bitgo/sjcl": "^1.1.0"
},
"devDependencies": {
"@types/node": "^18.0.0"
}
}
30 changes: 30 additions & 0 deletions modules/passkey-crypto/src/deriveEnterpriseSalt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as sjcl from '@bitgo/sjcl';
import type { SjclCodecs, SjclHashes, SjclMisc } from '@bitgo/sjcl';

type SjclType = {
hash: SjclHashes;
codec: SjclCodecs;
misc: SjclMisc;
};

/**
* Derives an enterprise-scoped PRF salt to prevent cross-enterprise key reuse.
*
* Computes HMAC-SHA256(key=prfSalt_base64url_decoded, data=enterpriseId_utf8).
* The baseSalt must always come from the server — never generate it client-side.
*
* @param baseSalt - Server-provided base64url-encoded PRF salt
* @param enterpriseId - Enterprise identifier
* @returns Base64-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);
}
12 changes: 12 additions & 0 deletions modules/passkey-crypto/src/derivePassword.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Derives a wallet passphrase from a WebAuthn PRF result.
*
* The PRF output (ArrayBuffer) is hex-encoded and used directly as the
* walletPassphrase for SJCL-based encryption (bitgo.encrypt).
*
* @param prfResult - Raw PRF output from WebAuthn credential assertion
* @returns Lowercase hex string to use as walletPassphrase
*/
export function derivePassword(prfResult: ArrayBuffer): string {
return Buffer.from(prfResult).toString('hex');
}
2 changes: 2 additions & 0 deletions modules/passkey-crypto/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { derivePassword } from './derivePassword';
export { deriveEnterpriseSalt } from './deriveEnterpriseSalt';
46 changes: 46 additions & 0 deletions modules/passkey-crypto/test/unit/deriveEnterpriseSalt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as assert from 'assert';
import { deriveEnterpriseSalt } from '../../src';

// Real fixture values captured from a live environment (DB + browser devtools)
const REAL_FIXTURE = {
basePrfSalt: 'ZqJ64M2dL65zn2-Jxd58SMN2ILc9QjbCFxUTGHd_LC8',
enterpriseId: '69c2aea1a3d7bc07f7f775c0ca86b0ec',
expectedDerivedSalt: 'oiasOqzkuyuEz/8043+3IXYghSu3LV4N/a1MLIRzmU8=',
};

describe('deriveEnterpriseSalt', function () {
it('produces the correct derived salt for real fixture values', function () {
// Verifies SDK output matches what the retail UI produces for the same inputs,
// ensuring clients can move between SDK and retail app seamlessly.
assert.strictEqual(
deriveEnterpriseSalt(REAL_FIXTURE.basePrfSalt, REAL_FIXTURE.enterpriseId),
REAL_FIXTURE.expectedDerivedSalt
);
});

it('is deterministic — same inputs always produce the same salt', function () {
const first = deriveEnterpriseSalt(REAL_FIXTURE.basePrfSalt, REAL_FIXTURE.enterpriseId);
const second = deriveEnterpriseSalt(REAL_FIXTURE.basePrfSalt, REAL_FIXTURE.enterpriseId);
assert.ok(first);
assert.strictEqual(first, second);
});

it('produces different salts for different enterpriseIds with the same prfSalt', function () {
const saltA = deriveEnterpriseSalt(REAL_FIXTURE.basePrfSalt, REAL_FIXTURE.enterpriseId);
const saltB = deriveEnterpriseSalt(REAL_FIXTURE.basePrfSalt, 'different-enterprise-id');
assert.notStrictEqual(saltA, saltB);
});

it('produces different salts for different prfSalts with the same enterpriseId', function () {
const saltA = deriveEnterpriseSalt(REAL_FIXTURE.basePrfSalt, REAL_FIXTURE.enterpriseId);
const saltB = deriveEnterpriseSalt('deadbeefcafebabe0102030405060708', REAL_FIXTURE.enterpriseId);
assert.notStrictEqual(saltA, saltB);
});

it('returns a non-empty base64 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+/]+=*$/);
});
});
36 changes: 36 additions & 0 deletions modules/passkey-crypto/test/unit/derivePassword.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as assert from 'assert';
import { derivePassword } from '../../src';

// Real fixture values captured from a live environment (browser devtools)
const REAL_FIXTURE = {
prfOutputBase64: 'Hly0eFbg+8ZX9B2GWuDlNTRkvSLF0nHRTTOvw+ljAzs=',
expectedPasswordHex: '1e5cb47856e0fbc657f41d865ae0e5353464bd22c5d271d14d33afc3e963033b',
};

describe('derivePassword', function () {
it('produces the correct hex password for real PRF output fixture', function () {
// Verifies SDK output matches what the retail UI produces for the same PRF result,
// ensuring clients can move between SDK and retail app seamlessly.
const prfBuffer = Buffer.from(REAL_FIXTURE.prfOutputBase64, 'base64');
assert.strictEqual(derivePassword(new Uint8Array(prfBuffer).buffer), REAL_FIXTURE.expectedPasswordHex);
});

it('converts an ArrayBuffer of zeros to a hex string of zeros', function () {
assert.strictEqual(derivePassword(new ArrayBuffer(4)), '00000000');
});

it('returns a lowercase hex string', function () {
const input = new Uint8Array([0xab, 0xcd]).buffer;
const result = derivePassword(input);
assert.strictEqual(result, result.toLowerCase());
});

it('returns a string of length 2x the input byte length', function () {
assert.strictEqual(derivePassword(new ArrayBuffer(32)).length, 64);
});

it('is deterministic — same inputs produce same output', function () {
const input = new Uint8Array([1, 2, 3, 4, 5]).buffer;
assert.strictEqual(derivePassword(input), derivePassword(input));
});
});
12 changes: 12 additions & 0 deletions modules/passkey-crypto/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./",
"strictPropertyInitialization": false,
"esModuleInterop": true,
"typeRoots": ["../../types", "./node_modules/@types", "../../node_modules/@types"]
},
"include": ["src/**/*", "test/**/*"],
"exclude": ["node_modules"]
}
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export * from './errors';
export * from './inscriptionBuilder';
export * from './internal';
export * from './keychain';
export * from './passkey';
export * as bitcoin from './legacyBitcoin';
export * from './market';
export * from './pendingApproval';
Expand Down
2 changes: 2 additions & 0 deletions modules/sdk-core/src/bitgo/passkey/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './types';
export * from './removePasskeyFromWallet';
35 changes: 35 additions & 0 deletions modules/sdk-core/src/bitgo/passkey/removePasskeyFromWallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { BitGoBase } from '../bitgoBase';
import { WebAuthnOtpDevice } from './types';

export async function removePasskeyFromWallet(params: {
bitgo: BitGoBase;
walletId: string;
device: WebAuthnOtpDevice;
walletPassphrase: string;
}): Promise<void> {
const { bitgo, walletId, device, walletPassphrase } = params;

// Fetch wallet to infer coin and keychainId
// We use a temporary coin to access the wallets API; the wallet's actual coin is read after fetch
const walletData = await bitgo.get(bitgo.url(`/wallet/${walletId}`, 2)).result();

const coin = walletData.coin as string;
const keychainId = (walletData.keys as string[])[0];

// Fetch user keychain
const keychain = await bitgo.get(bitgo.url(`/${coin}/key/${keychainId}`, 2)).result();

if (!keychain.encryptedPrv) {
throw new Error(`Keychain ${keychainId} has no encryptedPrv. Cannot verify passphrase before passkey removal.`);
}

// Verify passphrase before any mutation
try {
bitgo.decrypt({ password: walletPassphrase, input: keychain.encryptedPrv });
} catch {
throw new Error('Incorrect wallet passphrase. Passkey removal aborted to prevent lockout.');
}

// DELETE the webauthn device using device.id (MongoDB ObjectId), not credentialId
await bitgo.del(bitgo.url(`/key/${keychainId}/webauthndevice/${device.id}`, 2)).result();
}
8 changes: 8 additions & 0 deletions modules/sdk-core/src/bitgo/passkey/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// TODO: replace with: export type { WebAuthnOtpDevice } from '@bitgo/public-types'
export interface WebAuthnOtpDevice {
id: string; // serialized MongoDB _id — used for DELETE
credentialId: string; // from authenticatorInfo.credID
prfSalt?: string;
isPasskey?: boolean;
extensions?: Record<string, boolean>;
}
121 changes: 121 additions & 0 deletions modules/sdk-core/test/unit/bitgo/passkey/removePasskeyFromWallet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import * as assert from 'assert';
import * as sinon from 'sinon';
import 'should';
import { removePasskeyFromWallet } from '../../../../src/bitgo/passkey/removePasskeyFromWallet';
import { WebAuthnOtpDevice } from '../../../../src/bitgo/passkey/types';

describe('removePasskeyFromWallet', function () {
const walletId = 'wallet-abc123';
const keychainId = 'key-user-id';
const encryptedPrv = 'encrypted-prv-string';
const walletPassphrase = 'correct-passphrase';
const decryptedPrv = 'xprv-decrypted';

const device: WebAuthnOtpDevice = {
id: 'mongo-object-id-123',
credentialId: 'cred-id-456',
prfSalt: 'some-salt',
isPasskey: true,
};

let mockBitGo: sinon.SinonStubbedInstance<{
url: (path: string, version?: number) => string;
get: (url: string) => { result: () => Promise<unknown> };
del: (url: string) => { result: () => Promise<unknown> };
decrypt: (params: { password: string; input: string }) => string;
}>;

beforeEach(function () {
mockBitGo = {
url: sinon.stub().callsFake((path: string, version?: number) => `/api/v${version ?? 1}${path}`),
get: sinon.stub(),
del: sinon.stub(),
decrypt: sinon.stub(),
};

// Default: wallet fetch returns coin + keys
(mockBitGo.get as sinon.SinonStub).withArgs(`/api/v2/wallet/${walletId}`).returns({
result: sinon.stub().resolves({ coin: 'tbtc', keys: [keychainId, 'backup-key-id', 'bitgo-key-id'] }),
});

// Default: keychain fetch returns encryptedPrv
(mockBitGo.get as sinon.SinonStub).withArgs(`/api/v2/tbtc/key/${keychainId}`).returns({
result: sinon.stub().resolves({ id: keychainId, encryptedPrv }),
});

// Default: decrypt succeeds
(mockBitGo.decrypt as sinon.SinonStub).returns(decryptedPrv);

// Default: DELETE succeeds
(mockBitGo.del as sinon.SinonStub).returns({
result: sinon.stub().resolves({}),
});
});

afterEach(function () {
sinon.restore();
});

it('should successfully remove a passkey device', async function () {
await removePasskeyFromWallet({
bitgo: mockBitGo as any,
walletId,
device,
walletPassphrase,
});

// Verify decrypt was called with the right args
sinon.assert.calledOnce(mockBitGo.decrypt);
sinon.assert.calledWithExactly(mockBitGo.decrypt, { password: walletPassphrase, input: encryptedPrv });

// Verify DELETE was called with device.id (not credentialId)
sinon.assert.calledOnce(mockBitGo.del);
sinon.assert.calledWithExactly(mockBitGo.del, `/api/v2/key/${keychainId}/webauthndevice/${device.id}`);
});

it('should throw and not call DELETE if passphrase is wrong', async function () {
(mockBitGo.decrypt as sinon.SinonStub).throws(new Error('decryption failed'));

await assert.rejects(
() =>
removePasskeyFromWallet({
bitgo: mockBitGo as any,
walletId,
device,
walletPassphrase: 'wrong-passphrase',
}),
(err: Error) => {
assert.ok(err.message.includes('Incorrect wallet passphrase'));
assert.ok(err.message.includes('Passkey removal aborted to prevent lockout'));
return true;
}
);

// DELETE must NOT have been called
sinon.assert.notCalled(mockBitGo.del);
});

it('should throw descriptively if keychain has no encryptedPrv', async function () {
(mockBitGo.get as sinon.SinonStub).withArgs(`/api/v2/tbtc/key/${keychainId}`).returns({
result: sinon.stub().resolves({ id: keychainId }),
});

await assert.rejects(
() =>
removePasskeyFromWallet({
bitgo: mockBitGo as any,
walletId,
device,
walletPassphrase,
}),
(err: Error) => {
assert.ok(err.message.includes('no encryptedPrv'));
return true;
}
);

// No decrypt or DELETE should be called
sinon.assert.notCalled(mockBitGo.decrypt);
sinon.assert.notCalled(mockBitGo.del);
});
});
Loading
Loading