Skip to content

Commit 6a6ee30

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 6a6ee30

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: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import * as assert from 'assert';
2+
import { deriveEnterpriseSalt } from '../../src';
3+
4+
describe('deriveEnterpriseSalt', function () {
5+
const PRF_SALT = 'deadbeefcafebabe';
6+
const ENTERPRISE_ID = 'enterprise-abc123';
7+
8+
it('throws when baseSalt is undefined', function () {
9+
assert.throws(() => deriveEnterpriseSalt(undefined, ENTERPRISE_ID), /Failed to derive enterprise salt/);
10+
});
11+
12+
it('throws when baseSalt is empty string', function () {
13+
assert.throws(() => deriveEnterpriseSalt('', ENTERPRISE_ID), /Failed to derive enterprise salt/);
14+
});
15+
16+
it('is deterministic — same inputs always produce the same salt', function () {
17+
const first = deriveEnterpriseSalt(PRF_SALT, ENTERPRISE_ID);
18+
const second = deriveEnterpriseSalt(PRF_SALT, ENTERPRISE_ID);
19+
assert.ok(first);
20+
assert.strictEqual(first, second);
21+
});
22+
23+
it('produces a known output for a given prfSalt and enterpriseId', function () {
24+
// HMAC-SHA256(key=deadbeefcafebabe decoded as base64url, data=enterprise-abc123 utf8) encoded as base64
25+
assert.strictEqual(deriveEnterpriseSalt(PRF_SALT, ENTERPRISE_ID), 'jdXFg2z9rGUspk4ofQy5fwoMKzAzQLC7rdWIQqOQTfM=');
26+
});
27+
28+
it('produces different salts for different enterpriseIds with the same prfSalt', function () {
29+
const saltA = deriveEnterpriseSalt(PRF_SALT, 'enterprise-abc123');
30+
const saltB = deriveEnterpriseSalt(PRF_SALT, 'enterprise-xyz789');
31+
assert.notStrictEqual(saltA, saltB);
32+
});
33+
34+
it('produces different salts for different prfSalts with the same enterpriseId', function () {
35+
const saltA = deriveEnterpriseSalt('deadbeefcafebabe', ENTERPRISE_ID);
36+
const saltB = deriveEnterpriseSalt('0102030405060708', ENTERPRISE_ID);
37+
assert.notStrictEqual(saltA, saltB);
38+
});
39+
40+
it('returns a non-empty base64 string', function () {
41+
const result = deriveEnterpriseSalt(PRF_SALT, ENTERPRISE_ID);
42+
assert.strictEqual(typeof result, 'string');
43+
assert.ok(result.length > 0);
44+
assert.match(result, /^[A-Za-z0-9+/]+=*$/);
45+
});
46+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import * as assert from 'assert';
2+
import { derivePassword } from '../../src';
3+
4+
describe('derivePassword', function () {
5+
it('throws if prfResult is undefined', function () {
6+
assert.throws(() => derivePassword(undefined), /Failed to derive password/);
7+
});
8+
9+
it('produces a known hex password for a given PRF output', function () {
10+
// Known 32-byte PRF result → expected hex-encoded wallet passphrase
11+
const prfBytes = new Uint8Array([
12+
0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc,
13+
0xba, 0x98, 0x76, 0x54, 0x32, 0x10, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
14+
]);
15+
assert.strictEqual(
16+
derivePassword(prfBytes.buffer),
17+
'deadbeefcafebabe0123456789abcdeffedcba98765432100011223344556677'
18+
);
19+
});
20+
21+
it('converts an ArrayBuffer of zeros to a hex string of zeros', function () {
22+
assert.strictEqual(derivePassword(new ArrayBuffer(4)), '00000000');
23+
});
24+
25+
it('returns a lowercase hex string', function () {
26+
const input = new Uint8Array([0xab, 0xcd]).buffer;
27+
const result = derivePassword(input);
28+
assert.strictEqual(result, result.toLowerCase());
29+
});
30+
31+
it('returns a string of length 2x the input byte length', function () {
32+
assert.strictEqual(derivePassword(new ArrayBuffer(32)).length, 64);
33+
});
34+
35+
it('is deterministic — same inputs produce same output', function () {
36+
const input = new Uint8Array([1, 2, 3, 4, 5]).buffer;
37+
assert.strictEqual(derivePassword(input), derivePassword(input));
38+
});
39+
});
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)