diff --git a/features/keychain/module/__tests__/schnorr.test.js b/features/keychain/module/__tests__/schnorr.test.js new file mode 100644 index 00000000..2657be7f --- /dev/null +++ b/features/keychain/module/__tests__/schnorr.test.js @@ -0,0 +1,58 @@ +import { utils } from '@noble/secp256k1' +import ecc from '@exodus/bitcoinerlab-secp256k1' + +import { create } from '../crypto/secp256k1' + +const fixtures = [ + { + // fixtures created by logging inside toAsyncSigner: https://github.com/ExodusMovement/assets/blob/5f93b19e7537f92519ec9bb7fe2514db9b4507e0/bitcoin/bitcoin-api/src/tx-sign/taproot.js#L48 + priv: '90de83eea26049afc40ba7d13fd8d4537331cd226f17051c97ca56c696af66b5', + pub: '0273ae16fb2721654c8735487c024f9a137511eb2d4f2c39e3084bd87cf044ac91', + tweak: '316fb252f50a55c468b86b951093b5a56d8cac0a178d066899b9520c694ce8c6', + priv2: 'c24e3641976a9f742cc41366506c89f8e0be792c86a40b853183a8d2fffc4f7b', + pub2: '02c24e41b8ec4d091f9bfbb481fde7ce0808ed820db8e93409cc404da8b9de7e92', + buffer: '52be7b43a029336afb5bca87f33b5cbe1a84d70e321db62dc12c14eac3c8b3a3', + entropy: '1230000000000000000000000000000000000000000000000000000000000000', + sig: 'c2259b1fc27b846b7204b571a43e4951ef53e40b7486732673ac8d9187eb95d230ccdc5ab20e4e1e975fb211d13531de77f3ea70397fcca8d74629d20ffb4a3f', + }, + { + priv: 'fc7458de3d5616e7803fdc81d688b9642641be32fee74c4558ce680cac3d4111', + pub: '03d734e09fc6ed105225ff316c6fa74f89096f90a437b1c7001af6d0b244d6f151', + priv2: '8c28b8720947067702dc9e0b81c2c89d6f97e14cc65db22a1849d9ce685202e9', + pub2: '0351adaba657ce3a0758dc1a3e37be9d86048288f26674f503d2d2366010680f17', + buffer: '7cb987eb16b030b09f34e3ae52e8fb3a9b6e0caaea3021e0ab4dc15ccd0188bb', + entropy: '0000000000000000000000000000000000000000000000000000000000000000', + sig: 'e397d713d4832069e7a6794f62d23e7f7f8d8670aa1ebb72872ad8cb908a2575b2c71746f795bbfdba3e433e80b08ce1b5454cb2a349b25111c269b9f625cf8d', + }, +] + +const tapTweakHash = (publicKey, h) => { + const xOnlyPoint = ecc.xOnlyPointFromPoint(publicKey) + const hash = utils.taggedHashSync('TapTweak', Buffer.concat(h ? [xOnlyPoint, h] : [xOnlyPoint])) + return Buffer.from(hash) +} + +describe('Schnorr signer', () => { + test.each(fixtures)('signSchnorr should sign buffer with tweaked key', async (fixture) => { + const getPrivateHDKey = () => ({ + privateKey: Buffer.from(fixture.priv, 'hex'), + publicKey: Buffer.from(fixture.pub, 'hex'), + }) + const secp256k1Signer = create({ getPrivateHDKey }) + + let tweak + if (fixture.tweak) { + const publicKey = getPrivateHDKey().publicKey + tweak = tapTweakHash(publicKey) + expect(tweak.toString('hex')).toBe(fixture.tweak) + } + + const result = await secp256k1Signer.signSchnorr({ + data: Buffer.from(fixture.buffer, 'hex'), + tweak, + extraEntropy: Buffer.from(fixture.entropy, 'hex'), + }) + + expect(Buffer.from(result).toString('hex')).toBe(fixture.sig) + }) +}) diff --git a/features/keychain/module/crypto/secp256k1.js b/features/keychain/module/crypto/secp256k1.js index c49b7a0e..0943cd66 100644 --- a/features/keychain/module/crypto/secp256k1.js +++ b/features/keychain/module/crypto/secp256k1.js @@ -1,8 +1,11 @@ import assert from 'minimalistic-assert' import elliptic from '@exodus/elliptic' import { mapValues, pick } from '@exodus/basic-utils' +import ecc from '@exodus/bitcoinerlab-secp256k1' -const validEcOptions = (ecOptions) => +import { tweakPrivateKey } from './tweak' + +const isValidEcOptions = (ecOptions) => !ecOptions || Object.keys(ecOptions).every((key) => ['canonical'].includes(key)) export const create = ({ getPrivateHDKey }) => { @@ -12,11 +15,16 @@ export const create = ({ getPrivateHDKey }) => { const createInstance = () => ({ signBuffer: async ({ seedId, keyId, data, ecOptions, enc = 'der' }) => { assert(['der', 'raw'].includes(enc), 'signBuffer: invalid enc') - assert(validEcOptions(ecOptions), 'signBuffer: invalid EC option') + assert(isValidEcOptions(ecOptions), 'signBuffer: invalid EC option') const { privateKey } = getPrivateHDKey({ seedId, keyId }) const signature = curve.sign(data, privateKey, pick(ecOptions, ['canonical'])) return enc === 'der' ? Buffer.from(signature.toDER()) : { ...signature } }, + signSchnorr: async ({ seedId, keyId, data, tweak, extraEntropy }) => { + const hdkey = getPrivateHDKey({ seedId, keyId }) + const privateKey = tweak ? tweakPrivateKey({ hdkey, tweak }) : hdkey.privateKey + return ecc.signSchnorr(data, privateKey, extraEntropy) + }, }) // For backwards compatibility diff --git a/features/keychain/module/crypto/tweak.js b/features/keychain/module/crypto/tweak.js new file mode 100644 index 00000000..df88e8ef --- /dev/null +++ b/features/keychain/module/crypto/tweak.js @@ -0,0 +1,13 @@ +import assert from 'minimalistic-assert' +import ecc from '@exodus/bitcoinerlab-secp256k1' + +export const tweakPrivateKey = ({ hdkey, tweak }) => { + const { privateKey, publicKey } = hdkey + assert(ecc.isPrivate(privateKey), 'tweakPrivateKey: expected valid private key') + assert(ecc.isPointCompressed(publicKey), 'tweakPrivateKey: expected compressed public key') + + let tweakedPrivateKey = publicKey[0] === 3 ? ecc.privateNegate(privateKey) : privateKey + tweakedPrivateKey = ecc.privateAdd(tweakedPrivateKey, tweak) + + return Buffer.from(tweakedPrivateKey) +} diff --git a/features/keychain/package.json b/features/keychain/package.json index 505e2d44..54d1baec 100644 --- a/features/keychain/package.json +++ b/features/keychain/package.json @@ -31,6 +31,7 @@ "dependencies": { "@exodus/basic-utils": "^2.0.0", "@exodus/bip32": "^2.1.0", + "@exodus/bitcoinerlab-secp256k1": "^1.0.5-exodus.1", "@exodus/elliptic": "^6.5.4-precomputed", "@exodus/key-identifier": "^1.0.1", "@exodus/key-utils": "^3.0.0", @@ -44,6 +45,7 @@ }, "devDependencies": { "@exodus/key-ids": "^1.0.0", + "@noble/secp256k1": "^1.7.1", "bip39": "2.6.0", "bn.js": "^5.2.1", "eslint": "^8.44.0", diff --git a/yarn.lock b/yarn.lock index d5d11215..ec603b30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1834,6 +1834,16 @@ __metadata: languageName: node linkType: hard +"@exodus/bitcoinerlab-secp256k1@npm:^1.0.5-exodus.1": + version: 1.0.5-exodus.1 + resolution: "@exodus/bitcoinerlab-secp256k1@npm:1.0.5-exodus.1" + dependencies: + "@noble/hashes": ^1.1.5 + "@noble/secp256k1": ^1.7.1 + checksum: b9ba53bdf8eee1af328633fe772f592d0dbbd2a6bc8a7c5518028133d7a26a2f377daa8551ddc27dd1932c808ecac6baff153aec655fc98f0153b4aa707f02e3 + languageName: node + linkType: hard + "@exodus/elliptic@npm:6.5.4-precomputed, @exodus/elliptic@npm:^6.5.4-precomputed": version: 6.5.4-precomputed resolution: "@exodus/elliptic@npm:6.5.4-precomputed" @@ -2027,12 +2037,14 @@ __metadata: dependencies: "@exodus/basic-utils": ^2.0.0 "@exodus/bip32": ^2.1.0 + "@exodus/bitcoinerlab-secp256k1": ^1.0.5-exodus.1 "@exodus/elliptic": ^6.5.4-precomputed "@exodus/key-identifier": ^1.0.1 "@exodus/key-ids": ^1.0.0 "@exodus/key-utils": ^3.0.0 "@exodus/slip10": ^1.0.0 "@exodus/sodium-crypto": ^3.1.0 + "@noble/secp256k1": ^1.7.1 bip39: 2.6.0 bn.js: ^5.2.1 buffer-json: ^2.0.0 @@ -2614,6 +2626,20 @@ __metadata: languageName: node linkType: hard +"@noble/hashes@npm:^1.1.5": + version: 1.4.0 + resolution: "@noble/hashes@npm:1.4.0" + checksum: 8ba816ae26c90764b8c42493eea383716396096c5f7ba6bea559993194f49d80a73c081f315f4c367e51bd2d5891700bcdfa816b421d24ab45b41cb03e4f3342 + languageName: node + linkType: hard + +"@noble/secp256k1@npm:^1.7.1": + version: 1.7.1 + resolution: "@noble/secp256k1@npm:1.7.1" + checksum: d2301f1f7690368d8409a3152450458f27e54df47e3f917292de3de82c298770890c2de7c967d237eff9c95b70af485389a9695f73eb05a43e2bd562d18b18cb + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5"