From 8d06a1f18fb873acaabb84a6e774ccc232301bb6 Mon Sep 17 00:00:00 2001 From: Ferenc Kiraly Date: Tue, 14 Jan 2025 14:33:55 +0100 Subject: [PATCH] feat: add signBuffer function (#194) --- features/keychain/CHANGELOG.md | 8 + features/keychain/api/index.d.ts | 11 ++ features/keychain/api/index.js | 3 + .../module/__tests__/sign-buffer.test.js | 138 ++++++++++++++++++ features/keychain/module/keychain.js | 82 +++++++++-- 5 files changed, 228 insertions(+), 14 deletions(-) create mode 100644 features/keychain/module/__tests__/sign-buffer.test.js diff --git a/features/keychain/CHANGELOG.md b/features/keychain/CHANGELOG.md index c9f8744..55dac1e 100644 --- a/features/keychain/CHANGELOG.md +++ b/features/keychain/CHANGELOG.md @@ -43,6 +43,14 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - bump slip10 to remove dependance on elliptic fork ([#166](https://github.com/ExodusMovement/exodus-oss/issues/166)) ([29ca457](https://github.com/ExodusMovement/exodus-oss/commit/29ca4571382f3cd0829f5729b9011a2ba0560915)) +## [7.4.3](https://github.com/ExodusMovement/exodus-oss/compare/@exodus/keychain@7.4.2...@exodus/keychain@7.4.3) (2024-12-10) + +## [7.4.2](https://github.com/ExodusMovement/exodus-oss/compare/@exodus/keychain@7.4.1...@exodus/keychain@7.4.2) (2024-11-15) + +### Features + +- expose sodium encrypt/decrypt box in keychain api ([#175](https://github.com/ExodusMovement/exodus-oss/issues/175)) ([2d18ba2](https://github.com/ExodusMovement/exodus-oss/commit/2d18ba2a87261b8d54dc5ebddec77f08c7fd26b7)) + ## [7.4.0](https://github.com/ExodusMovement/exodus-oss/compare/@exodus/keychain@7.3.0...@exodus/keychain@7.4.0) (2024-10-17) ### Features diff --git a/features/keychain/api/index.d.ts b/features/keychain/api/index.d.ts index 057ae86..89d5dae 100644 --- a/features/keychain/api/index.d.ts +++ b/features/keychain/api/index.d.ts @@ -18,6 +18,16 @@ export interface KeychainApi { exportKey(params: { exportPrivate: false } & KeySource): Promise exportKey(params: { exportPrivate: true } & KeySource): Promise exportKey(params: { exportPrivate: true; exportPublic: false } & KeySource): Promise + getPublicKey(params: KeySource): Promise + signBuffer( + params: { + data: Buffer + signatureType: string + extraEntropy?: Buffer + tweak?: Buffer + enc: string + } & KeySource + ): Promise arePrivateKeysLocked(seeds: Buffer[]): boolean removeSeeds(seeds: Buffer[]): string[] sodium: { @@ -40,6 +50,7 @@ export interface KeychainApi { signSchnorr( params: { data: Buffer; extraEntropy?: Buffer; tweak?: Buffer } & KeySource ): Promise + signSchnorrZ(params: { data: Buffer } & KeySource): Promise } } diff --git a/features/keychain/api/index.js b/features/keychain/api/index.js index ce82827..4bc5ac4 100644 --- a/features/keychain/api/index.js +++ b/features/keychain/api/index.js @@ -2,6 +2,8 @@ const createKeychainApi = ({ keychain }) => { return { keychain: { exportKey: (...args) => keychain.exportKey(...args), + getPublicKey: (...args) => keychain.getPublicKey(...args), + signBuffer: (...args) => keychain.signBuffer(...args), arePrivateKeysLocked: (seeds) => keychain.arePrivateKeysLocked(seeds), sodium: { sign: keychain.sodium.sign, @@ -19,6 +21,7 @@ const createKeychainApi = ({ keychain }) => { secp256k1: { signBuffer: keychain.secp256k1.signBuffer, signSchnorr: keychain.secp256k1.signSchnorr, + signSchnorrZ: keychain.secp256k1.signSchnorrZ, }, }, } diff --git a/features/keychain/module/__tests__/sign-buffer.test.js b/features/keychain/module/__tests__/sign-buffer.test.js new file mode 100644 index 0000000..bf92d1e --- /dev/null +++ b/features/keychain/module/__tests__/sign-buffer.test.js @@ -0,0 +1,138 @@ +import { mnemonicToSeed } from 'bip39' + +import createKeychain from './create-keychain.js' +import { getSeedId } from '../crypto/seed-id.js' +import { hashSync } from '@exodus/crypto/hash' +import KeyIdentifier from '@exodus/key-identifier' + +const seed = mnemonicToSeed( + 'menu memory fury language physical wonder dog valid smart edge decrease worth' +) +const entropy = '0000000000000000000000000000000000000000000000000000000000000000' +const seedId = getSeedId(seed) +const data = hashSync('sha256', Buffer.from('I really love keychains')) + +describe('keychain.signBuffer', () => { + const keychain = createKeychain({ seed }) + + it('signatureType "ecdsa" with "der" encoding', async () => { + const keyId = new KeyIdentifier({ + derivationPath: "m/44'/60'/0'/0/0", + derivationAlgorithm: 'BIP32', + keyType: 'secp256k1', + }) + const signatureType = 'ecdsa' + const expected = + '30440220722491f3d490960c4fc16b56b8dacafa9d446e17d9321dbbe3b216da845adc9802203afd466c1450c60f7ef0fcdf55b1e3bb206d9f989530996059890a9d92ab1ef9' + + const signature1 = await keychain.signBuffer({ seedId, keyId, signatureType, data }) + const signature2 = await keychain.signBuffer({ seedId, keyId, signatureType, data, enc: 'der' }) + + expect(signature1.toString('hex')).toBe(expected) + expect(signature2.toString('hex')).toBe(expected) + }) + + it('signatureType "ecdsa" with "sig" encoding', async () => { + const keyId = new KeyIdentifier({ + derivationPath: "m/44'/60'/0'/0/0", + derivationAlgorithm: 'BIP32', + keyType: 'secp256k1', + }) + const signatureType = 'ecdsa' + const expected = + '722491f3d490960c4fc16b56b8dacafa9d446e17d9321dbbe3b216da845adc983afd466c1450c60f7ef0fcdf55b1e3bb206d9f989530996059890a9d92ab1ef9' + + const signature = await keychain.signBuffer({ seedId, keyId, signatureType, data, enc: 'sig' }) + + expect(signature.toString('hex')).toBe(expected) + }) + + it('signatureType "ecdsa" fails with invalid arg', async () => { + const keyId = new KeyIdentifier({ + derivationPath: "m/44'/60'/0'/0/0", + derivationAlgorithm: 'BIP32', + keyType: 'secp256k1', + }) + const signatureType = 'ecdsa' + + await expect(keychain.signBuffer({ keyId, signatureType, data, foo: null })).rejects.toThrow( + 'unsupported options supplied to signBuffer()' + ) + await expect(keychain.signBuffer({ keyId, signatureType, data, tweak: null })).rejects.toThrow( + 'unsupported options supplied for ecdsa signature' + ) + }) + + it('signatureType "schnorr"', async () => { + const keyId = new KeyIdentifier({ + derivationPath: "m/44'/60'/0'/0/0", + derivationAlgorithm: 'BIP32', + keyType: 'secp256k1', + }) + const signatureType = 'schnorr' + const expected = + '10aa0975c224ea48e7d96f40b055d1b51ac257c7f177bb0f1e2c52bd3186fe112777756e2c0de7e2597849a7e3792483da717dcbe70ebf3f3d8d758730de7209' + + const signature = await keychain.signBuffer({ + seedId, + keyId, + signatureType, + data, + extraEntropy: Buffer.from(entropy, 'hex'), + }) + + expect(Buffer.from(signature).toString('hex')).toBe(expected) + }) + + it('signatureType "schnorrZ" fails with "extraEntropy" arg', async () => { + const keyId = new KeyIdentifier({ + derivationPath: "m/44'/60'/0'/0/0", + derivationAlgorithm: 'BIP32', + keyType: 'secp256k1', + }) + const data = hashSync('sha256', Buffer.from('I really love keychains')) + const signatureType = 'schnorrZ' + + await expect( + keychain.signBuffer({ keyId, signatureType, data, extraEntropy: null }) + ).rejects.toThrow('unsupported options supplied for schnorrZ signature') + }) + + it('signatureType "ed25519" fails with invalid params', async () => { + let keyId = new KeyIdentifier({ + derivationPath: "m/44'/60'/0'/0/0", + derivationAlgorithm: 'BIP32', + keyType: 'secp256k1', + }) + const signatureType = 'ed25519' + + await expect(keychain.signBuffer({ keyId, signatureType, data })).rejects.toThrow( + '"keyId.keyType" secp256k1 does not support "signatureType" ed25519' + ) + + keyId = new KeyIdentifier({ + derivationPath: "m/44'/60'/0'/0/0", + derivationAlgorithm: 'BIP32', + keyType: 'nacl', + }) + + await expect( + keychain.signBuffer({ keyId, signatureType, data, extraEntropy: null }) + ).rejects.toThrow('unsupported options supplied for ed25519 signature') + }) + + it('signatureType "ed25519"', async () => { + const keyId = new KeyIdentifier({ + derivationPath: "m/44'/60'/0'/0/0", + derivationAlgorithm: 'BIP32', + keyType: 'nacl', + }) + const signatureType = 'ed25519' + const expected = + 'd0f019e45795a86d79542143483e22a2478498289490072c902408c01744f81d2d7769c7b6c5c28ade5336d20ea8b39c3723264d1d271a24a15dca509e3d5f03' + + const signature = await keychain.signBuffer({ seedId, keyId, signatureType, data }) + + expect(signature.toString('hex')).toBe(expected) + }) +}) diff --git a/features/keychain/module/keychain.js b/features/keychain/module/keychain.js index 54a6827..7087ad0 100644 --- a/features/keychain/module/keychain.js +++ b/features/keychain/module/keychain.js @@ -142,6 +142,25 @@ export class Keychain { return this.#masters[seedId][derivationAlgorithm].derive(derivationPath) } + #getPublicKeyFromHDKey = async ({ hdkey, keyId }) => { + let publicKey = hdkey.publicKey + + if (keyId.keyType === 'legacy') { + if (keyId.assetName in this.#legacyPrivToPub) { + const legacyPrivToPub = this.#legacyPrivToPub[keyId.assetName] + publicKey = await legacyPrivToPub(hdkey.privateKey) + } else { + throw new Error(`asset name ${keyId.assetName} has no legacyPrivToPub mapper`) + } + } else if (keyId.derivationAlgorithm !== 'SLIP10' && keyId.keyType === 'nacl') { + // SLIP10 already produces the correct public key for curve ed25119 + // so we can safely skip using the privToPub mapper. + publicKey = await sodium.privToPub(hdkey.privateKey) + } + + return publicKey + } + async exportKey({ seedId, keyId, exportPrivate, exportPublic = true }) { assert(typeof seedId === 'string', 'seedId must be a string') @@ -160,19 +179,7 @@ export class Keychain { let publicKey = null if (exportPublic) { - publicKey = hdkey.publicKey - if (keyId.keyType === 'legacy') { - if (keyId.assetName in this.#legacyPrivToPub) { - const legacyPrivToPub = this.#legacyPrivToPub[keyId.assetName] - publicKey = await legacyPrivToPub(privateKey) - } else { - throw new Error(`asset name ${keyId.assetName} has no legacyPrivToPub mapper`) - } - } else if (keyId.derivationAlgorithm !== 'SLIP10' && keyId.keyType === 'nacl') { - // SLIP10 already produces the correct public key for curve ed25119 - // so we can safely skip using the privToPub mapper. - publicKey = await sodium.privToPub(privateKey) - } + publicKey = await this.#getPublicKeyFromHDKey({ hdkey, keyId }) } const { xpriv, xpub } = hdkey.toJSON() @@ -184,7 +191,54 @@ export class Keychain { } } - // @deprecated use keychain.(secp256k1|ed25519|sodium).sign* instead + async getPublicKey({ seedId, keyId }) { + const hdkey = this.#getPrivateHDKey({ + seedId, + keyId: new KeyIdentifier(keyId), + getPrivateHDKeySymbol: this.#getPrivateHDKeySymbol, + }) + + return this.#getPublicKeyFromHDKey({ hdkey, keyId }) + } + + async signBuffer({ seedId, keyId, data, signatureType, enc, tweak, extraEntropy, ...rest }) { + const noTweak = tweak === undefined + const noEnc = enc === undefined + const noOpts = noEnc && noTweak && extraEntropy === undefined + const invalidOptions = Object.keys(rest).filter((key) => key !== 'ecOptions') // ignore legacy option `ecOptions` + + assert(invalidOptions.length === 0, `unsupported options supplied to signBuffer()`) + assert(data instanceof Uint8Array, `expected "data" to be a Uint8Array, got: ${typeof data}`) + assert( + (['ecdsa', 'schnorr', 'schnorrZ'].includes(signatureType) && keyId.keyType === 'secp256k1') || + (signatureType === 'ed25519' && keyId.keyType === 'nacl'), + `"keyId.keyType" ${keyId.keyType} does not support "signatureType" ${signatureType}` + ) + + if (signatureType === 'ed25519') { + assert(noOpts, 'unsupported options supplied for ed25519 signature') + return this.ed25519.signBuffer({ seedId, keyId, data }) + } + + if (signatureType === 'schnorrZ') { + assert(noOpts, 'unsupported options supplied for schnorrZ signature') + return this.secp256k1.signSchnorrZ({ seedId, keyId, data }) + } + + // only accept 32 byte buffers for ecdsa + assert(data.length === 32, `expected "data" to have 32 bytes, got: ${data.length}`) + + if (signatureType === 'schnorr') { + assert(noEnc, 'unsupported options supplied for schnorr signature') + return this.secp256k1.signSchnorr({ seedId, keyId, data, tweak, extraEntropy }) + } + + // signatureType === 'ecdsa' + assert(noTweak, 'unsupported options supplied for ecdsa signature') + return this.secp256k1.signBuffer({ seedId, keyId, data, enc, extraEntropy }) + } + + // @deprecated use keychain.signBuffer() instead async signTx({ seedId, keyIds, signTxCallback, unsignedTx }) { this.#assertPrivateKeysUnlocked(seedId ? [seedId] : undefined) assert(typeof signTxCallback === 'function', 'signTxCallback must be a function')