Skip to content

Commit afb3d1e

Browse files
Marzooqaclaude
andcommitted
feat(sdk-lib-mpc): add deriveUnhardenedMps for EdDSA MPCv2
Implements the Silence Labs BIP32-Ed25519 non-hardened derivation formula (HMAC-SHA512 with big-endian index, no prefix byte) as a TypeScript function in sdk-lib-mpc. This produces the same derived public key that the WASM DSG uses internally, unblocking EdDSA MPCv2 wallet address derivation. Includes cross-check tests that verify DSG signatures at m, m/0, and m/0/1 verify against the derived public keys, confirming formula correctness. TODO(WCI-390): replace with wasm-mps ed25519_derive_public_key once exposed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e1eed7b commit afb3d1e

3 files changed

Lines changed: 150 additions & 0 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { createHmac } from 'crypto';
2+
import { ed25519 } from '@noble/curves/ed25519';
3+
import { pathToIndices } from '../../curves/util';
4+
5+
/**
6+
* Derives a child public key from a common keychain using the Silence Labs
7+
* BIP32-Ed25519 non-hardened derivation formula:
8+
*
9+
* HMAC = HMAC-SHA512(key=chaincode, data=pk_bytes || index_BE_4)
10+
* child_pk = parent_pk + 8 * LE(trunc28(HMAC_left)) * G
11+
* child_chaincode = HMAC_right (right 32 bytes)
12+
*
13+
* This differs from the Cardano BIP32-Ed25519 formula used by
14+
* `Eddsa.deriveUnhardened` in three ways: no 0x02 prefix byte, big-endian
15+
* index, and a single HMAC instead of two. The formulas produce completely
16+
* different child keys at every derived level.
17+
*
18+
* Returns the same on-the-wire format as `Eddsa.deriveUnhardened`:
19+
* 128-char hex = 64-char derived pk + 64-char derived chaincode
20+
*/
21+
export function deriveUnhardenedMps(commonKeychainHex: string, path: string): string {
22+
if (commonKeychainHex.length !== 128) {
23+
throw new Error(
24+
`Invalid commonKeychain: expected 128 hex chars (32-byte pk + 32-byte chaincode), got ${commonKeychainHex.length}`
25+
);
26+
}
27+
28+
const buf = Buffer.from(commonKeychainHex, 'hex');
29+
let pkBytes = Buffer.from(buf.subarray(0, 32));
30+
let ccBytes = Buffer.from(buf.subarray(32, 64));
31+
32+
const indices = path === '' || path === 'm' ? [] : pathToIndices(path);
33+
for (const index of indices) {
34+
const indexBuf = Buffer.alloc(4);
35+
indexBuf.writeUInt32BE(index, 0);
36+
37+
const hmac = createHmac('sha512', ccBytes)
38+
.update(Buffer.concat([pkBytes, indexBuf]))
39+
.digest();
40+
41+
const zl = hmac.subarray(0, 32);
42+
const zr = hmac.subarray(32);
43+
44+
// parse_offset: 8 * LE(zl[0..28] || zeroes)
45+
// Mirrors Rust: U256::from_le_slice(&z_l).shl(3), where z_l[28..32] = 0.
46+
const truncZl = Buffer.alloc(32);
47+
zl.copy(truncZl, 0, 0, 28);
48+
const offset = BigInt('0x' + Buffer.from(truncZl).reverse().toString('hex')) * 8n;
49+
50+
// child_pk = offset * G + parent_pk
51+
const childPoint = ed25519.ExtendedPoint.BASE.multiply(offset).add(ed25519.ExtendedPoint.fromHex(pkBytes));
52+
pkBytes = Buffer.from(childPoint.toRawBytes());
53+
ccBytes = Buffer.from(zr);
54+
}
55+
56+
return pkBytes.toString('hex') + ccBytes.toString('hex');
57+
}

modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * as EddsaMPSDsg from './dsg';
33
export * as MPSUtil from './util';
44
export * as MPSTypes from './types';
55
export * as MPSComms from './commsLayer';
6+
export { deriveUnhardenedMps } from './derive';
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import assert from 'assert';
2+
import { ed25519 } from '@noble/curves/ed25519';
3+
import { deriveUnhardenedMps } from '../../../../src/tss/eddsa-mps/derive';
4+
import { generateEdDsaDKGKeyShares, runEdDsaDSG } from './util';
5+
6+
const MESSAGE = Buffer.from('The Times 03/Jan/2009 Chancellor on brink of second bailout for banks');
7+
8+
describe('deriveUnhardenedMps', function () {
9+
this.timeout(60_000);
10+
11+
// DKG is expensive; run once and reuse across tests.
12+
let commonKeychain: string;
13+
let rootPubKey: Buffer;
14+
let userKeyShare: Buffer;
15+
let bitgoKeyShare: Buffer;
16+
17+
before(async function () {
18+
const [userDkg, , bitgoDkg] = await generateEdDsaDKGKeyShares();
19+
commonKeychain = userDkg.getCommonKeychain();
20+
rootPubKey = userDkg.getSharePublicKey();
21+
userKeyShare = userDkg.getKeyShare();
22+
bitgoKeyShare = bitgoDkg.getKeyShare();
23+
});
24+
25+
describe('input validation', function () {
26+
it('throws when commonKeychainHex is shorter than 128 chars', function () {
27+
assert.throws(() => deriveUnhardenedMps('deadbeef', 'm'), /expected 128 hex chars/);
28+
});
29+
30+
it('throws when commonKeychainHex is longer than 128 chars', function () {
31+
assert.throws(() => deriveUnhardenedMps('a'.repeat(130), 'm'), /expected 128 hex chars/);
32+
});
33+
});
34+
35+
describe('derivation correctness using existing deriveUnhardened path parsing', function () {
36+
it('returns the root key unchanged for path "m"', function () {
37+
const result = deriveUnhardenedMps(commonKeychain, 'm');
38+
assert.strictEqual(result, commonKeychain, 'Path "m" should return the keychain unchanged');
39+
});
40+
41+
it('produces a different key at m/0 than at the root', function () {
42+
const derived = deriveUnhardenedMps(commonKeychain, 'm/0');
43+
assert.notStrictEqual(derived.slice(0, 64), commonKeychain.slice(0, 64));
44+
});
45+
46+
it('is deterministic — same inputs produce the same output', function () {
47+
const a = deriveUnhardenedMps(commonKeychain, 'm/0/1');
48+
const b = deriveUnhardenedMps(commonKeychain, 'm/0/1');
49+
assert.strictEqual(a, b);
50+
});
51+
52+
it('produces different keys for different paths', function () {
53+
const d0 = deriveUnhardenedMps(commonKeychain, 'm/0');
54+
const d1 = deriveUnhardenedMps(commonKeychain, 'm/1');
55+
assert.notStrictEqual(d0.slice(0, 64), d1.slice(0, 64));
56+
});
57+
58+
it('output is always 128 hex chars', function () {
59+
assert.strictEqual(deriveUnhardenedMps(commonKeychain, 'm').length, 128);
60+
assert.strictEqual(deriveUnhardenedMps(commonKeychain, 'm/0').length, 128);
61+
assert.strictEqual(deriveUnhardenedMps(commonKeychain, 'm/0/1').length, 128);
62+
});
63+
});
64+
65+
describe('DSG signature cross-check against the public key derived by deriveUnhardenedMps', function () {
66+
it('signature from DSG at "m" verifies against the root public key', function () {
67+
const { dsgA } = runEdDsaDSG(userKeyShare, bitgoKeyShare, 0, 2, MESSAGE, 'm');
68+
const sig = dsgA.getSignature();
69+
assert(ed25519.verify(sig, MESSAGE, rootPubKey), 'DSG at "m" should verify against the raw DKG public key');
70+
});
71+
72+
it('signature from DSG at "m/0" verifies against deriveUnhardenedMps(commonKeychain, "m/0")', function () {
73+
const derivedPk = Buffer.from(deriveUnhardenedMps(commonKeychain, 'm/0').slice(0, 64), 'hex');
74+
const { dsgA } = runEdDsaDSG(userKeyShare, bitgoKeyShare, 0, 2, MESSAGE, 'm/0');
75+
const sig = dsgA.getSignature();
76+
assert(
77+
ed25519.verify(sig, MESSAGE, derivedPk),
78+
'DSG at "m/0" should verify against deriveUnhardenedMps result at "m/0"'
79+
);
80+
});
81+
82+
it('signature from DSG at "m/0/1" verifies against deriveUnhardenedMps(commonKeychain, "m/0/1")', function () {
83+
const derivedPk = Buffer.from(deriveUnhardenedMps(commonKeychain, 'm/0/1').slice(0, 64), 'hex');
84+
const { dsgA } = runEdDsaDSG(userKeyShare, bitgoKeyShare, 0, 2, MESSAGE, 'm/0/1');
85+
const sig = dsgA.getSignature();
86+
assert(
87+
ed25519.verify(sig, MESSAGE, derivedPk),
88+
'DSG at "m/0/1" should verify against deriveUnhardenedMps result at "m/0/1"'
89+
);
90+
});
91+
});
92+
});

0 commit comments

Comments
 (0)