Skip to content

Commit

Permalink
feat: add signBuffer function (#194)
Browse files Browse the repository at this point in the history
  • Loading branch information
feri42 authored Jan 14, 2025
1 parent a44c99c commit 8d06a1f
Show file tree
Hide file tree
Showing 5 changed files with 228 additions and 14 deletions.
8 changes: 8 additions & 0 deletions features/keychain/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions features/keychain/api/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ export interface KeychainApi {
exportKey(params: { exportPrivate: false } & KeySource): Promise<PublicKeys>
exportKey(params: { exportPrivate: true } & KeySource): Promise<PublicKeys & PrivateKeys>
exportKey(params: { exportPrivate: true; exportPublic: false } & KeySource): Promise<PrivateKeys>
getPublicKey(params: KeySource): Promise<Buffer>
signBuffer(
params: {
data: Buffer
signatureType: string
extraEntropy?: Buffer
tweak?: Buffer
enc: string
} & KeySource
): Promise<Buffer>
arePrivateKeysLocked(seeds: Buffer[]): boolean
removeSeeds(seeds: Buffer[]): string[]
sodium: {
Expand All @@ -40,6 +50,7 @@ export interface KeychainApi {
signSchnorr(
params: { data: Buffer; extraEntropy?: Buffer; tweak?: Buffer } & KeySource
): Promise<Buffer>
signSchnorrZ(params: { data: Buffer } & KeySource): Promise<Buffer>
}
}

Expand Down
3 changes: 3 additions & 0 deletions features/keychain/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,6 +21,7 @@ const createKeychainApi = ({ keychain }) => {
secp256k1: {
signBuffer: keychain.secp256k1.signBuffer,
signSchnorr: keychain.secp256k1.signSchnorr,
signSchnorrZ: keychain.secp256k1.signSchnorrZ,
},
},
}
Expand Down
138 changes: 138 additions & 0 deletions features/keychain/module/__tests__/sign-buffer.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
82 changes: 68 additions & 14 deletions features/keychain/module/keychain.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand All @@ -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()
Expand All @@ -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')
Expand Down

0 comments on commit 8d06a1f

Please sign in to comment.