Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add signBuffer function #194

Merged
merged 2 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading