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: lockPrivateKeys and unlockPrivateKeys #78

Merged
merged 21 commits into from
Apr 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions features/keychain/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const createKeychainApi = ({ keychain }) => {
return {
keychain: {
exportKey: (...args) => keychain.exportKey(...args),
arePrivateKeysLocked: () => keychain.arePrivateKeysLocked(),
sodium: {
signDetached: keychain.sodium.signDetached,
getKeysFromSeed: (...args) =>
Expand Down
162 changes: 162 additions & 0 deletions features/keychain/module/__tests__/lock-private-keys.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { mnemonicToSeed } from 'bip39'
import { createKeyIdentifierForExodus } from '@exodus/key-ids'
import createKeychain from './create-keychain'
import { getSeedId } from '../crypto/seed-id'

const seed = mnemonicToSeed(
'menu memory fury language physical wonder dog valid smart edge decrease worth'
)

const seed1 = mnemonicToSeed(
'menu memory fury language physical wonder dog valid smart edge decrease test'
)

const seedId = getSeedId(seed)

describe('lockPrivateKeys', () => {
it('should allow private key usage when unlocked', async () => {
const keychain = createKeychain({ seed })
const keyId = createKeyIdentifierForExodus({ exoType: 'FUSION' })
const exportedKeys = await keychain.exportKey({
seedId,
keyId,
exportPrivate: true,
})

// Public keys should be the same
const sodiumEncryptor = keychain.createSodiumEncryptor(keyId)
const {
sign: { publicKey },
} = await sodiumEncryptor.getSodiumKeysFromSeed({ seedId })
expect(Buffer.compare(publicKey, exportedKeys.publicKey)).toBe(0)
})

it('should allow addSeed when locked', async () => {
const keychain = createKeychain({ seed })
keychain.lockPrivateKeys()
keychain.addSeed(seed)
})

it('should allow removeAllSeeds when locked', async () => {
const keychain = createKeychain({ seed })
keychain.lockPrivateKeys()
keychain.removeAllSeeds()
})

it('should allow exportKey for public keys when locked', async () => {
const keychain = createKeychain({ seed })
keychain.lockPrivateKeys()
const keyId = createKeyIdentifierForExodus({ exoType: 'FUSION' })
const exportedKeys = await keychain.exportKey({
seedId,
keyId,
})

expect(exportedKeys.publicKey).toBeDefined()
})

it('should allow clone when locked', async () => {
const keychain = createKeychain({ seed })
keychain.lockPrivateKeys()
keychain.clone()
})

it('should allow exportKeys after lock/unlock', async () => {
const keychain = createKeychain({ seed })
keychain.lockPrivateKeys()
keychain.unlockPrivateKeys([seed])

const keyId = createKeyIdentifierForExodus({ exoType: 'FUSION' })
const exportedKeys = await keychain.exportKey({
seedId,
keyId,
exportPrivate: true,
})

// Public keys should be the same
const sodiumEncryptor = keychain.createSodiumEncryptor(keyId)
const {
sign: { publicKey },
} = await sodiumEncryptor.getSodiumKeysFromSeed({ seedId })
expect(Buffer.compare(publicKey, exportedKeys.publicKey)).toBe(0)
})

it('should block unlock for wrong seeds length', async () => {
const keychain = createKeychain({ seed })
keychain.lockPrivateKeys()
await expect(async () => keychain.unlockPrivateKeys([])).rejects.toThrow(
/must pass in same number of seeds/
)
await expect(async () => keychain.unlockPrivateKeys([seed, seed])).rejects.toThrow(
/must pass in same number of seeds/
)
})

it('should block unlock when already unlocked', async () => {
const keychain = createKeychain({ seed })
await expect(async () => keychain.unlockPrivateKeys([seed])).rejects.toThrow(/already unlocked/)
})

it('should block unlock for wrong seed ids', async () => {
const keychain = createKeychain({ seed })
keychain.lockPrivateKeys()
await expect(async () => keychain.unlockPrivateKeys([seed1])).rejects.toThrow(
/must pass in existing seed/
)

const keychain1 = createKeychain({ seed })
keychain1.addSeed(seed1)
keychain1.lockPrivateKeys()
await expect(async () => keychain1.unlockPrivateKeys([seed, seed])).rejects.toThrow(
/must pass in existing seed/
)
})

it('should block exportKey for private keys when locked', async () => {
const keychain = createKeychain({ seed })
keychain.lockPrivateKeys()
const keyId = createKeyIdentifierForExodus({ exoType: 'FUSION' })
await expect(
keychain.exportKey({
seedId,
keyId,
exportPrivate: true,
})
).rejects.toThrow(/private keys are locked/)
})

it('should block signTx when locked', async () => {
const keychain = createKeychain({ seed })
keychain.lockPrivateKeys()
await expect(keychain.signTx({})).rejects.toThrow(/private keys are locked/)
})

it('should block sodium when locked', async () => {
const keychain = createKeychain({ seed })
keychain.lockPrivateKeys()
await expect(keychain.sodium.getSodiumKeysFromSeed({})).rejects.toThrow(
/private keys are locked/
)
await expect(keychain.createSodiumEncryptor({}).getSodiumKeysFromSeed({})).rejects.toThrow(
/private keys are locked/
)
})

it('should block ed25519 when locked', async () => {
const keychain = createKeychain({ seed })
keychain.lockPrivateKeys()
await expect(keychain.ed25519.signBuffer({})).rejects.toThrow(/private keys are locked/)
await expect(keychain.createEd25519Signer({}).signBuffer({})).rejects.toThrow(
/private keys are locked/
)
})

it('should block secp256k1 when locked', async () => {
const keychain = createKeychain({ seed })
keychain.lockPrivateKeys()
await expect(keychain.secp256k1.signBuffer({})).rejects.toThrow(/private keys are locked/)
await expect(keychain.createSecp256k1Signer({}).signBuffer({})).rejects.toThrow(
/private keys are locked/
)
})
})
39 changes: 37 additions & 2 deletions features/keychain/module/keychain.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export const MODULE_ID = 'keychain'
export class Keychain {
#masters = Object.create(null)
#legacyPrivToPub = null
#privateKeysAreLocked = false
#getPrivateHDKeySymbol = Symbol('getPrivateHDKey')
mvayngrib marked this conversation as resolved.
Show resolved Hide resolved

// TODO: remove default param. Use it temporarily for backward compatibility.
constructor({ legacyPrivToPub = Object.create(null) }) {
Expand All @@ -42,6 +44,32 @@ export class Keychain {
this.secp256k1 = secp256k1.create({ getPrivateHDKey: this.#getPrivateHDKey })
}

#assertPrivateKeysUnlocked() {
assert(!this.#privateKeysAreLocked, 'private keys are locked')
}

arePrivateKeysLocked() {
return this.#privateKeysAreLocked
}

lockPrivateKeys() {
this.#privateKeysAreLocked = true
}

unlockPrivateKeys(seeds) {
assert(this.#privateKeysAreLocked, 'already unlocked')
assert(
seeds?.length === Object.values(this.#masters).length,
andrejborstnik marked this conversation as resolved.
Show resolved Hide resolved
'must pass in same number of seeds'
)
const seedIds = new Set(seeds.map((seed) => getSeedId(seed)))
for (const seedId of Object.keys(this.#masters)) {
assert(seedIds.has(seedId), 'must pass in existing seed')
}

this.#privateKeysAreLocked = false
}

addSeed(seed) {
assert(Buffer.isBuffer(seed) && seed.length === 64, 'seed must be buffer of 64 bytes')
const masters = Object.assign(
Expand All @@ -59,7 +87,8 @@ export class Keychain {
this.#masters = Object.create(null)
}

#getPrivateHDKey = ({ seedId, keyId }) => {
#getPrivateHDKey = ({ seedId, keyId, getPrivateHDKeySymbol }) => {
if (getPrivateHDKeySymbol !== this.#getPrivateHDKeySymbol) this.#assertPrivateKeysUnlocked()
throwIfInvalidKeyIdentifier(keyId)

assert(typeof seedId === 'string', 'seedId must be a BIP32 key identifier in hex encoding')
Expand All @@ -72,9 +101,14 @@ export class Keychain {
}

async exportKey({ seedId, keyId, exportPrivate }) {
if (exportPrivate) this.#assertPrivateKeysUnlocked()
keyId = new KeyIdentifier(keyId)

const hdkey = this.#getPrivateHDKey({ seedId, keyId })
const hdkey = this.#getPrivateHDKey({
seedId,
keyId,
getPrivateHDKeySymbol: this.#getPrivateHDKeySymbol,
})
const privateKey = hdkey.privateKey
let publicKey = hdkey.publicKey

Expand All @@ -101,6 +135,7 @@ export class Keychain {
}

async signTx({ seedId, keyIds, signTxCallback, unsignedTx }) {
this.#assertPrivateKeysUnlocked()
sparten11740 marked this conversation as resolved.
Show resolved Hide resolved
assert(typeof signTxCallback === 'function', 'signTxCallback must be a function')
const hdkeys = Object.fromEntries(
keyIds.map((keyId) => {
Expand Down
Loading