diff --git a/.nvmrc b/.nvmrc index 3f430af..53d1c14 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v18 +v22 diff --git a/features/keychain/module/__tests__/schnorr-z.test.js b/features/keychain/module/__tests__/schnorr-z.test.js new file mode 100644 index 0000000..1a652b9 --- /dev/null +++ b/features/keychain/module/__tests__/schnorr-z.test.js @@ -0,0 +1,40 @@ +jest.doMock('node:crypto', () => ({ + __esModule: false, + ...jest.requireActual('crypto'), + randomBytes: jest.fn(), +})) + +const { create } = await import('../crypto/secp256k1.js') +const { randomBytes } = await import('node:crypto') + +const extraEntropy = Buffer.from( + '1230000000000000000000000000000000000000000000000000000000000000', + 'hex' +) + +randomBytes.mockImplementation(() => { + // fix entropy to assert signatures + return extraEntropy +}) + +const fixture = { + priv: 'e7ef6cc440b01b2473540541aee345f6a6585e532a2590e2ab8a2bef379b1b0a', + pub: '03d219e7afe97792ce85e5ac18c79d86a3658f7f5ec7b53bc3f3ed334cc98b0366', + buffer: + '0881800410011a14a54e49719267e8312510d7b78598cef16ff127ce22230a210246e7178dc8253201101e18fd6f6eb9972451d121fc57aa2a06dd5c111e58dc6a2a120a100000000000000000000000000000001432120a100000000000000000000000000000000a38e901', + sig: 'a019058cd148c2821a3a98c9ffaf2d9c5a4a68b1ca3a844c8c51ca95d7a60ad12863cf7f6c6bee55e5447ce621dc8808cc429576636556a4f22de0d702e69c9c', +} + +describe('SchnorrZ signer', async () => { + const getPrivateHDKey = () => ({ + privateKey: Buffer.from(fixture.priv, 'hex'), + publicKey: Buffer.from(fixture.pub, 'hex'), + }) + const secp256k1Signer = create({ getPrivateHDKey }) + const result = await secp256k1Signer.signSchnorrZ({ + keyId: { keyType: 'secp256k1' }, + data: Buffer.from(fixture.buffer, 'hex'), + }) + + expect(result.toString('hex')).toBe(fixture.sig) +}) diff --git a/features/keychain/module/crypto/schnorr-z.js b/features/keychain/module/crypto/schnorr-z.js new file mode 100644 index 0000000..26c0609 --- /dev/null +++ b/features/keychain/module/crypto/schnorr-z.js @@ -0,0 +1,63 @@ +import { hash } from '@exodus/crypto/hash' +import { hmac } from '@exodus/crypto/hmac' +import { randomBytes } from '@exodus/crypto/randomBytes' +import * as secp256k1 from '@noble/secp256k1' + +async function singleRoundHmacDRBG(nonce) { + const seed = randomBytes(32) + let K = Buffer.alloc(32, 0) + let V = Buffer.alloc(32, 1) + K = await hmac('sha256', K, [V, new Uint8Array([0]), seed, nonce]) + V = await hmac('sha256', K, V) + K = await hmac('sha256', K, [V, new Uint8Array([1]), seed, nonce]) + V = await hmac('sha256', K, V) + return hmac('sha256', K, V) +} + +/** + * + * // Based on The ZILLIQA Technical Whitepaper, Appendix A + * // https://docs.zilliqa.com/whitepaper.pdf + * // --- + * // Algorithm is as follows + * // 1. k = rand(1, n) + * // 2. Q = [k]G + * // 3. r = H(Q || pk || m) mod n + * // 4. If r = 0 Goto 1 + * // 5. s = k - r * sk mod n + * // 6. If s = 0 Goto 1 + * // 7. mu = (r, s) + * // 8. return mu. + * + * @param {Buffer} data + * @param {Buffer} privateKey + * @returns {string} + */ +export async function schnorrZ({ data, privateKey }) { + const { utils, Signature, CURVE, getPublicKey } = secp256k1 + const big = (buf) => BigInt('0x' + buf.toString('hex')) + + const pk = getPublicKey(privateKey, true) + + // eslint-disable-next-line no-constant-condition + while (true) { + // 1. k comes from drbg until satisfies 0 < k < n + const k = await singleRoundHmacDRBG(data) + const kn = big(k) + if (!(kn > BigInt(0) && kn < CURVE.n)) continue // this is rechecked below + + const Q = getPublicKey(k, true) // 2. This is Q = G * k multiplication. Also checks 0 < k < n and throws + const H = await hash('sha256', [Q, pk, data]) + const r = utils.mod(big(H), CURVE.n) // 3 + if (r === BigInt(0)) continue // 4 + + const s = utils.mod(kn - r * big(privateKey), CURVE.n) // 5 + if (s === BigInt(0)) continue // 6 + + const sig = new Signature(r, s) // 7 + return Buffer.from(sig.toCompactRawBytes()) // 8 + } + + // eslint-disable-next-line no-unreachable + throw new Error('Makes Flow happy') +} diff --git a/features/keychain/module/crypto/secp256k1.js b/features/keychain/module/crypto/secp256k1.js index 6f7cf1d..3b404fa 100644 --- a/features/keychain/module/crypto/secp256k1.js +++ b/features/keychain/module/crypto/secp256k1.js @@ -4,6 +4,7 @@ import { mapValues } from '@exodus/basic-utils' import BN from 'bn.js' import { tweakPrivateKey } from './tweak.js' +import { schnorrZ } from './schnorr-z.js' export const create = ({ getPrivateHDKey }) => { const createInstance = () => ({ @@ -51,6 +52,14 @@ export const create = ({ getPrivateHDKey }) => { const privateKey = tweak ? tweakPrivateKey({ hdkey, tweak }) : hdkey.privateKey return secp256k1.schnorrSign({ data, privateKey, extraEntropy }) }, + signSchnorrZ: async ({ seedId, keyId, data }) => { + assert( + keyId.keyType === 'secp256k1', + `SchnorrZ signatures are not supported for ${keyId.keyType}` + ) + const { privateKey } = getPrivateHDKey({ seedId, keyId }) + return schnorrZ({ data, privateKey }) + }, }) // For backwards compatibility diff --git a/features/keychain/module/keychain.js b/features/keychain/module/keychain.js index 5c955c3..7ec6e42 100644 --- a/features/keychain/module/keychain.js +++ b/features/keychain/module/keychain.js @@ -181,6 +181,7 @@ export class Keychain { } } + // @deprecated use keychain.(secp256k1|ed25519|sodium).sign* instead async signTx({ seedId, keyIds, signTxCallback, unsignedTx }) { this.#assertPrivateKeysUnlocked(seedId ? [seedId] : undefined) assert(typeof signTxCallback === 'function', 'signTxCallback must be a function') diff --git a/features/keychain/package.json b/features/keychain/package.json index 3b79d78..70e3155 100644 --- a/features/keychain/package.json +++ b/features/keychain/package.json @@ -37,6 +37,7 @@ "@exodus/key-utils": "^3.7.0", "@exodus/slip10": "^2.1.0", "@exodus/sodium-crypto": "^3.1.0", + "@noble/secp256k1": "^1.7.1", "bn.js": "^5.2.1", "buffer-json": "^2.0.0", "json-stable-stringify": "^1.0.1", @@ -44,7 +45,6 @@ }, "devDependencies": { "@exodus/key-ids": "^1.0.0", - "@noble/secp256k1": "^1.7.1", "bip39": "2.6.0", "eslint": "^8.44.0", "events": "^3.3.0" diff --git a/package.json b/package.json index ab824de..b0d2062 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ ] }, "engines": { - "yarn": ">=3" + "yarn": ">=3", + "node": "22.4.1" }, "scripts": { "prepare": "command -v husky && (echo 'running husky' && husky install) || (echo 'skipping husky' && true)",