Skip to content

Commit e993fc3

Browse files
committed
feat(passkey-crypto): add @bitgo/passkey-crypto package
Pure cryptographic primitives for WebAuthn PRF-based key derivation: - derivePassword: converts ArrayBuffer PRF result to hex walletPassphrase - deriveEnterpriseSalt: HMAC-SHA256 via SJCL matching retail implementation exactly TICKET: WCN-186
1 parent affc67f commit e993fc3

9 files changed

Lines changed: 306 additions & 3 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
require: 'tsx'
2+
timeout: '20000'
3+
reporter: 'min'
4+
reporter-option:
5+
- 'cdn=true'
6+
- 'json=false'
7+
exit: true
8+
spec: ['test/unit/**/*.ts']
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"name": "@bitgo/passkey-crypto",
3+
"version": "0.1.0",
4+
"description": "Pure cryptographic primitives for BitGo passkey (WebAuthn PRF) key derivation",
5+
"main": "./dist/src/index.js",
6+
"types": "./dist/src/index.d.ts",
7+
"files": [
8+
"dist"
9+
],
10+
"scripts": {
11+
"build": "yarn tsc --build --incremental --verbose .",
12+
"fmt": "prettier --write .",
13+
"check-fmt": "prettier --check '**/*.{ts,js,json}'",
14+
"clean": "rm -r ./dist",
15+
"lint": "eslint --quiet .",
16+
"prepare": "npm run build",
17+
"test": "npm run unit-test",
18+
"unit-test": "mocha 'test/unit/**/*.ts'"
19+
},
20+
"author": "BitGo SDK Team <sdkteam@bitgo.com>",
21+
"license": "MIT",
22+
"repository": {
23+
"type": "git",
24+
"url": "https://github.com/BitGo/BitGoJS.git",
25+
"directory": "modules/passkey-crypto"
26+
},
27+
"lint-staged": {
28+
"*.{js,ts}": [
29+
"yarn prettier --write",
30+
"yarn eslint --fix"
31+
]
32+
},
33+
"publishConfig": {
34+
"access": "public"
35+
},
36+
"dependencies": {
37+
"@bitgo/sjcl": "^1.1.0"
38+
},
39+
"devDependencies": {
40+
"@types/node": "^18.0.0"
41+
}
42+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as sjcl from '@bitgo/sjcl';
2+
import type { SjclCodecs, SjclHashes, SjclMisc } from '@bitgo/sjcl';
3+
4+
type SjclType = {
5+
hash: SjclHashes;
6+
codec: SjclCodecs;
7+
misc: SjclMisc;
8+
};
9+
10+
/**
11+
* Derives an enterprise-scoped PRF salt to prevent cross-enterprise key reuse.
12+
*
13+
* Computes HMAC-SHA256(key=prfSalt_base64url_decoded, data=enterpriseId_utf8).
14+
* The baseSalt must always come from the server — never generate it client-side.
15+
*
16+
* @param baseSalt - Server-provided base64url-encoded PRF salt
17+
* @param enterpriseId - Enterprise identifier
18+
* @returns Base64-encoded HMAC-SHA256 digest
19+
* @throws If baseSalt is missing
20+
*/
21+
export function deriveEnterpriseSalt(baseSalt: string | undefined, enterpriseId: string): string {
22+
if (!baseSalt) {
23+
throw new Error('Failed to derive enterprise salt');
24+
}
25+
26+
const { misc, codec, hash } = sjcl as unknown as SjclType;
27+
28+
const keyBits = codec.base64url.toBits(baseSalt);
29+
const dataBits = codec.utf8String.toBits(enterpriseId);
30+
31+
const hmacInstance = new misc.hmac(keyBits, hash.sha256);
32+
const resultBits = hmacInstance.mac(dataBits);
33+
34+
return codec.base64.fromBits(resultBits);
35+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Derives a wallet passphrase from a WebAuthn PRF result.
3+
*
4+
* The PRF output (ArrayBuffer) is hex-encoded and used directly as the
5+
* walletPassphrase for SJCL-based encryption (bitgo.encrypt).
6+
*
7+
* @param prfResult - Raw PRF output from WebAuthn credential assertion
8+
* @returns Lowercase hex string to use as walletPassphrase
9+
* @throws If prfResult is missing
10+
*/
11+
export function derivePassword(prfResult: ArrayBuffer | undefined): string {
12+
if (!prfResult) {
13+
throw new Error('Failed to derive password');
14+
}
15+
return Buffer.from(prfResult).toString('hex');
16+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { derivePassword } from './derivePassword';
2+
export { deriveEnterpriseSalt } from './deriveEnterpriseSalt';
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import * as assert from 'assert';
2+
import * as sjcl from '@bitgo/sjcl';
3+
import type { SjclCodecs, SjclHashes, SjclMisc } from '@bitgo/sjcl';
4+
import { deriveEnterpriseSalt } from '../../src';
5+
6+
type SjclType = { hash: SjclHashes; codec: SjclCodecs; misc: SjclMisc };
7+
8+
describe('deriveEnterpriseSalt', function () {
9+
// base64url-encoded salt as the server would provide
10+
const BASE_SALT = Buffer.from('server-provided-base-salt').toString('base64url');
11+
const ENTERPRISE_ID = 'ent-abc123';
12+
13+
function computeExpected(baseSalt: string, enterpriseId: string): string {
14+
const { misc, codec, hash } = sjcl as unknown as SjclType;
15+
const keyBits = codec.base64url.toBits(baseSalt);
16+
const dataBits = codec.utf8String.toBits(enterpriseId);
17+
const hmacInstance = new misc.hmac(keyBits, hash.sha256);
18+
return codec.base64.fromBits(hmacInstance.mac(dataBits));
19+
}
20+
21+
it('matches the SJCL HMAC-SHA256 test vector', function () {
22+
assert.strictEqual(deriveEnterpriseSalt(BASE_SALT, ENTERPRISE_ID), computeExpected(BASE_SALT, ENTERPRISE_ID));
23+
});
24+
25+
it('returns a base64 string', function () {
26+
const result = deriveEnterpriseSalt(BASE_SALT, ENTERPRISE_ID);
27+
assert.match(result, /^[A-Za-z0-9+/]+=*$/);
28+
});
29+
30+
it('is deterministic — same inputs produce same output', function () {
31+
assert.strictEqual(deriveEnterpriseSalt(BASE_SALT, ENTERPRISE_ID), deriveEnterpriseSalt(BASE_SALT, ENTERPRISE_ID));
32+
});
33+
34+
it('produces different output for different enterprise IDs', function () {
35+
assert.notStrictEqual(deriveEnterpriseSalt(BASE_SALT, 'ent-aaa'), deriveEnterpriseSalt(BASE_SALT, 'ent-bbb'));
36+
});
37+
38+
it('produces different output for different base salts', function () {
39+
const saltA = Buffer.from('salt-one').toString('base64url');
40+
const saltB = Buffer.from('salt-two').toString('base64url');
41+
assert.notStrictEqual(deriveEnterpriseSalt(saltA, ENTERPRISE_ID), deriveEnterpriseSalt(saltB, ENTERPRISE_ID));
42+
});
43+
44+
it('throws if baseSalt is undefined', function () {
45+
assert.throws(() => deriveEnterpriseSalt(undefined, ENTERPRISE_ID), /Failed to derive enterprise salt/);
46+
});
47+
48+
it('throws if baseSalt is an empty string', function () {
49+
assert.throws(() => deriveEnterpriseSalt('', ENTERPRISE_ID), /Failed to derive enterprise salt/);
50+
});
51+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import * as assert from 'assert';
2+
import { derivePassword } from '../../src';
3+
4+
describe('derivePassword', function () {
5+
it('converts an ArrayBuffer of zeros to a hex string of zeros', function () {
6+
const input = new ArrayBuffer(4);
7+
assert.strictEqual(derivePassword(input), '00000000');
8+
});
9+
10+
it('converts known bytes to expected hex', function () {
11+
const input = new Uint8Array([0xde, 0xad, 0xbe, 0xef]).buffer;
12+
assert.strictEqual(derivePassword(input), 'deadbeef');
13+
});
14+
15+
it('returns a lowercase hex string', function () {
16+
const input = new Uint8Array([0xab, 0xcd]).buffer;
17+
const result = derivePassword(input);
18+
assert.strictEqual(result, result.toLowerCase());
19+
});
20+
21+
it('returns a string of length 2x the input byte length', function () {
22+
const input = new ArrayBuffer(32);
23+
assert.strictEqual(derivePassword(input).length, 64);
24+
});
25+
26+
it('produces the same output for the same input (deterministic)', function () {
27+
const input = new Uint8Array([1, 2, 3, 4, 5]).buffer;
28+
assert.strictEqual(derivePassword(input), derivePassword(input));
29+
});
30+
31+
it('throws if prfResult is undefined', function () {
32+
assert.throws(() => derivePassword(undefined), /Failed to derive password/);
33+
});
34+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"outDir": "./dist",
5+
"rootDir": "./",
6+
"strictPropertyInitialization": false,
7+
"esModuleInterop": true,
8+
"typeRoots": ["../../types", "./node_modules/@types", "../../node_modules/@types"]
9+
},
10+
"include": ["src/**/*", "test/**/*"],
11+
"exclude": ["node_modules"]
12+
}

0 commit comments

Comments
 (0)