Skip to content
Open
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"]
}
Loading
Loading