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
57 changes: 57 additions & 0 deletions modules/sdk-lib-mpc/src/tss/eddsa-mps/derive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { createHmac } from 'crypto';
import { ed25519 } from '@noble/curves/ed25519';
import { pathToIndices } from '../../curves/util';

/**
* Derives a child public key from a common keychain using the Silence Labs
* BIP32-Ed25519 non-hardened derivation formula:
*
* HMAC = HMAC-SHA512(key=chaincode, data=pk_bytes || index_BE_4)
* child_pk = parent_pk + 8 * LE(trunc28(HMAC_left)) * G
* child_chaincode = HMAC_right (right 32 bytes)
*
* This differs from the Cardano BIP32-Ed25519 formula used by
* `Eddsa.deriveUnhardened` in three ways: no 0x02 prefix byte, big-endian
* index, and a single HMAC instead of two. The formulas produce completely
* different child keys at every derived level.
*
* Returns the same on-the-wire format as `Eddsa.deriveUnhardened`:
* 128-char hex = 64-char derived pk + 64-char derived chaincode
*/
export function deriveUnhardenedMps(commonKeychainHex: string, path: string): string {
if (commonKeychainHex.length !== 128) {
throw new Error(
`Invalid commonKeychain: expected 128 hex chars (32-byte pk + 32-byte chaincode), got ${commonKeychainHex.length}`
);
}

const buf = Buffer.from(commonKeychainHex, 'hex');
let pkBytes = Buffer.from(buf.subarray(0, 32));
let ccBytes = Buffer.from(buf.subarray(32, 64));

const indices = path === '' || path === 'm' ? [] : pathToIndices(path);
for (const index of indices) {
const indexBuf = Buffer.alloc(4);
indexBuf.writeUInt32BE(index, 0);

const hmac = createHmac('sha512', ccBytes)
.update(Buffer.concat([pkBytes, indexBuf]))
.digest();

const zl = hmac.subarray(0, 32);
const zr = hmac.subarray(32);

// parse_offset: 8 * LE(zl[0..28] || zeroes)
// Mirrors Rust: U256::from_le_slice(&z_l).shl(3), where z_l[28..32] = 0.
const truncZl = Buffer.alloc(32);
zl.copy(truncZl, 0, 0, 28);
const offset = BigInt('0x' + Buffer.from(truncZl).reverse().toString('hex')) * 8n;

// child_pk = offset * G + parent_pk
const childPoint = ed25519.ExtendedPoint.BASE.multiply(offset).add(ed25519.ExtendedPoint.fromHex(pkBytes));
pkBytes = Buffer.from(childPoint.toRawBytes());
ccBytes = Buffer.from(zr);
}

return pkBytes.toString('hex') + ccBytes.toString('hex');
}
1 change: 1 addition & 0 deletions modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * as EddsaMPSDsg from './dsg';
export * as MPSUtil from './util';
export * as MPSTypes from './types';
export * as MPSComms from './commsLayer';
export { deriveUnhardenedMps } from './derive';
92 changes: 92 additions & 0 deletions modules/sdk-lib-mpc/test/unit/tss/eddsa/derive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import assert from 'assert';
import { ed25519 } from '@noble/curves/ed25519';
import { deriveUnhardenedMps } from '../../../../src/tss/eddsa-mps/derive';
import { generateEdDsaDKGKeyShares, runEdDsaDSG } from './util';

const MESSAGE = Buffer.from('The Times 03/Jan/2009 Chancellor on brink of second bailout for banks');

describe('deriveUnhardenedMps', function () {
this.timeout(60_000);

// DKG is expensive; run once and reuse across tests.
let commonKeychain: string;
let rootPubKey: Buffer;
let userKeyShare: Buffer;
let bitgoKeyShare: Buffer;

before(async function () {
const [userDkg, , bitgoDkg] = await generateEdDsaDKGKeyShares();
commonKeychain = userDkg.getCommonKeychain();
rootPubKey = userDkg.getSharePublicKey();
userKeyShare = userDkg.getKeyShare();
bitgoKeyShare = bitgoDkg.getKeyShare();
});

describe('input validation', function () {
it('throws when commonKeychainHex is shorter than 128 chars', function () {
assert.throws(() => deriveUnhardenedMps('deadbeef', 'm'), /expected 128 hex chars/);
});

it('throws when commonKeychainHex is longer than 128 chars', function () {
assert.throws(() => deriveUnhardenedMps('a'.repeat(130), 'm'), /expected 128 hex chars/);
});
});

describe('derivation correctness using existing deriveUnhardened path parsing', function () {
it('returns the root key unchanged for path "m"', function () {
const result = deriveUnhardenedMps(commonKeychain, 'm');
assert.strictEqual(result, commonKeychain, 'Path "m" should return the keychain unchanged');
});

it('produces a different key at m/0 than at the root', function () {
const derived = deriveUnhardenedMps(commonKeychain, 'm/0');
assert.notStrictEqual(derived.slice(0, 64), commonKeychain.slice(0, 64));
});

it('is deterministic — same inputs produce the same output', function () {
const a = deriveUnhardenedMps(commonKeychain, 'm/0/1');
const b = deriveUnhardenedMps(commonKeychain, 'm/0/1');
assert.strictEqual(a, b);
});

it('produces different keys for different paths', function () {
const d0 = deriveUnhardenedMps(commonKeychain, 'm/0');
const d1 = deriveUnhardenedMps(commonKeychain, 'm/1');
assert.notStrictEqual(d0.slice(0, 64), d1.slice(0, 64));
});

it('output is always 128 hex chars', function () {
assert.strictEqual(deriveUnhardenedMps(commonKeychain, 'm').length, 128);
assert.strictEqual(deriveUnhardenedMps(commonKeychain, 'm/0').length, 128);
assert.strictEqual(deriveUnhardenedMps(commonKeychain, 'm/0/1').length, 128);
});
});

describe('DSG signature cross-check against the public key derived by deriveUnhardenedMps', function () {
it('signature from DSG at "m" verifies against the root public key', function () {
const { dsgA } = runEdDsaDSG(userKeyShare, bitgoKeyShare, 0, 2, MESSAGE, 'm');
const sig = dsgA.getSignature();
assert(ed25519.verify(sig, MESSAGE, rootPubKey), 'DSG at "m" should verify against the raw DKG public key');
});

it('signature from DSG at "m/0" verifies against deriveUnhardenedMps(commonKeychain, "m/0")', function () {
const derivedPk = Buffer.from(deriveUnhardenedMps(commonKeychain, 'm/0').slice(0, 64), 'hex');
const { dsgA } = runEdDsaDSG(userKeyShare, bitgoKeyShare, 0, 2, MESSAGE, 'm/0');
const sig = dsgA.getSignature();
assert(
ed25519.verify(sig, MESSAGE, derivedPk),
'DSG at "m/0" should verify against deriveUnhardenedMps result at "m/0"'
);
});

it('signature from DSG at "m/0/1" verifies against deriveUnhardenedMps(commonKeychain, "m/0/1")', function () {
const derivedPk = Buffer.from(deriveUnhardenedMps(commonKeychain, 'm/0/1').slice(0, 64), 'hex');
const { dsgA } = runEdDsaDSG(userKeyShare, bitgoKeyShare, 0, 2, MESSAGE, 'm/0/1');
const sig = dsgA.getSignature();
assert(
ed25519.verify(sig, MESSAGE, derivedPk),
'DSG at "m/0/1" should verify against deriveUnhardenedMps result at "m/0/1"'
);
});
});
});
Loading