diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/derive.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/derive.ts new file mode 100644 index 0000000000..e88df3d8c4 --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/derive.ts @@ -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'); +} diff --git a/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts b/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts index cc355458a4..dee6ca0393 100644 --- a/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts +++ b/modules/sdk-lib-mpc/src/tss/eddsa-mps/index.ts @@ -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'; diff --git a/modules/sdk-lib-mpc/test/unit/tss/eddsa/derive.ts b/modules/sdk-lib-mpc/test/unit/tss/eddsa/derive.ts new file mode 100644 index 0000000000..5f3951d3b3 --- /dev/null +++ b/modules/sdk-lib-mpc/test/unit/tss/eddsa/derive.ts @@ -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"' + ); + }); + }); +});