-
-
Notifications
You must be signed in to change notification settings - Fork 478
Optimize UX loading for keystores #5043
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
Changes from all commits
453c208
4379189
9e693a1
15ef9bf
35e3921
b34019b
024ffb1
86706bb
7eb1ec5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import fs from "node:fs"; | ||
| import bls from "@chainsafe/bls"; | ||
| import {Keystore} from "@chainsafe/bls-keystore"; | ||
| import {SignerLocal, SignerType} from "@lodestar/validator"; | ||
| import {fromHex, toHex} from "@lodestar/utils"; | ||
| import {PointFormat} from "@chainsafe/bls/types"; | ||
| import {lockFilepath, unlockFilepath} from "../../../util/lockfile.js"; | ||
| import {LocalKeystoreDefinition} from "./interface.js"; | ||
|
|
||
| export async function loadKeystoreCache( | ||
| cacheFilepath: string, | ||
| keystoreDefinitions: LocalKeystoreDefinition[] | ||
| ): Promise<SignerLocal[]> { | ||
| const keystores: Keystore[] = []; | ||
| const passwords: string[] = []; | ||
| for (const {keystorePath, password} of keystoreDefinitions) { | ||
| keystores.push(Keystore.parse(fs.readFileSync(keystorePath, "utf8"))); | ||
| passwords.push(password); | ||
| } | ||
|
|
||
| if (keystores.length !== passwords.length) { | ||
| throw new Error( | ||
| `Number of keystores and passwords must be equal. keystores=${keystores.length}, passwords=${passwords.length}` | ||
| ); | ||
| } | ||
|
|
||
| if (!fs.existsSync(cacheFilepath)) { | ||
| throw new Error(`Cache file ${cacheFilepath} does not exists.`); | ||
| } | ||
|
|
||
| lockFilepath(cacheFilepath); | ||
|
|
||
| const password = passwords.join(""); | ||
| // We can't use Keystore.parse as it validates the `encrypted message` to be only 32 bytes. | ||
| const keystore = new Keystore(JSON.parse(fs.readFileSync(cacheFilepath, "utf8"))); | ||
| const secretKeyConcatenatedBytes = await keystore.decrypt(password); | ||
|
|
||
| const result: SignerLocal[] = []; | ||
| for (const [index, k] of keystores.entries()) { | ||
| const secretKeyBytes = Uint8Array.prototype.slice.call(secretKeyConcatenatedBytes, index * 32, (index + 1) * 32); | ||
| const secretKey = bls.SecretKey.fromBytes(secretKeyBytes); | ||
| const publicKey = secretKey.toPublicKey().toBytes(PointFormat.compressed); | ||
|
|
||
| if (toHex(publicKey) !== toHex(fromHex(k.pubkey))) { | ||
| throw new Error( | ||
| `Keystore ${k.uuid} does not match the expected pubkey. expected=${toHex(fromHex(k.pubkey))}, found=${toHex( | ||
| publicKey | ||
| )}` | ||
| ); | ||
| } | ||
|
|
||
| result.push({ | ||
| type: SignerType.Local, | ||
| secretKey, | ||
| }); | ||
| } | ||
|
|
||
| unlockFilepath(cacheFilepath); | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
| export async function writeKeystoreCache( | ||
| cacheFilepath: string, | ||
| signers: SignerLocal[], | ||
| passwords: string[] | ||
| ): Promise<void> { | ||
| if (signers.length !== passwords.length) { | ||
| throw new Error( | ||
| `Number of signers and passwords must be equal. signers=${signers.length}, passwords=${passwords.length}` | ||
| ); | ||
| } | ||
| const secretKeys = signers.map((s) => s.secretKey.toBytes()); | ||
| const publicKeys = signers.map((s) => s.secretKey.toPublicKey().toBytes()); | ||
| const password = passwords.join(""); | ||
| const secretKeyConcatenatedBytes = Buffer.concat(secretKeys); | ||
| const publicConcatenatedBytes = Buffer.concat(publicKeys); | ||
| const keystore = await Keystore.create(password, secretKeyConcatenatedBytes, publicConcatenatedBytes, cacheFilepath); | ||
| lockFilepath(cacheFilepath); | ||
| fs.writeFileSync(cacheFilepath, keystore.stringify()); | ||
| unlockFilepath(cacheFilepath); | ||
| } | ||
|
|
||
| export async function clearKeystoreCache(cacheFilepath: string): Promise<void> { | ||
| if (fs.existsSync(cacheFilepath)) { | ||
| unlockFilepath(cacheFilepath); | ||
| fs.unlinkSync(cacheFilepath); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -91,7 +91,11 @@ export async function getSignersFromArgs( | |||||||||||||||||
| ); | ||||||||||||||||||
| }, | ||||||||||||||||||
| }); | ||||||||||||||||||
| return await decryptKeystoreDefinitions(keystoreDefinitions, {...args, onDecrypt: needle}); | ||||||||||||||||||
| return decryptKeystoreDefinitions(keystoreDefinitions, { | ||||||||||||||||||
| ...args, | ||||||||||||||||||
| onDecrypt: needle, | ||||||||||||||||||
| cacheFilePath: `${args.importKeystores[0]}.cache`, | ||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @nazarhussain I noticed that the In our lodestar/docker-compose.validator.yml Lines 4 to 11 in 2042c3b
|
||||||||||||||||||
| }); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| // Remote keys declared manually with --externalSignerPublicKeys | ||||||||||||||||||
|
|
@@ -119,7 +123,11 @@ export async function getSignersFromArgs( | |||||||||||||||||
| ); | ||||||||||||||||||
| }, | ||||||||||||||||||
| }); | ||||||||||||||||||
| const keystoreSigners = await decryptKeystoreDefinitions(keystoreDefinitions, {...args, onDecrypt: needle}); | ||||||||||||||||||
| const keystoreSigners = await decryptKeystoreDefinitions(keystoreDefinitions, { | ||||||||||||||||||
| ...args, | ||||||||||||||||||
| onDecrypt: needle, | ||||||||||||||||||
| cacheFilePath: `${accountPaths.keystoresDir}.cache`, | ||||||||||||||||||
| }); | ||||||||||||||||||
|
|
||||||||||||||||||
| // Read local remote keys, imported via keymanager api | ||||||||||||||||||
| const signerDefinitions = persistedKeysBackend.readAllRemoteKeys(); | ||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import fs from "node:fs"; | ||
| import {randomBytes} from "node:crypto"; | ||
| import tmp from "tmp"; | ||
| import {expect} from "chai"; | ||
| import chainAsPromised from "chai-as-promised"; | ||
| import chai from "chai"; | ||
| import {Keystore} from "@chainsafe/bls-keystore"; | ||
| import {interopSecretKey} from "@lodestar/state-transition"; | ||
| import bls from "@chainsafe/bls"; | ||
| import {SignerLocal, SignerType} from "@lodestar/validator"; | ||
| import {loadKeystoreCache, writeKeystoreCache} from "../../../../../src/cmds/validator/keymanager/keystoreCache.js"; | ||
| import {LocalKeystoreDefinition} from "../../../../../src/cmds/validator/keymanager/interface.js"; | ||
|
|
||
| chai.use(chainAsPromised); | ||
|
|
||
| const numberOfSigners = 10; | ||
|
|
||
| describe("keystoreCache", () => { | ||
| let definitions: LocalKeystoreDefinition[]; | ||
| let signers: SignerLocal[]; | ||
| let secretKeys: Uint8Array[]; | ||
| let passwords: string[]; | ||
| let keystoreCacheFile: string; | ||
|
|
||
| beforeEach(async function setup() { | ||
| this.timeout(50000); | ||
| definitions = []; | ||
| signers = []; | ||
| secretKeys = []; | ||
| passwords = []; | ||
| keystoreCacheFile = tmp.tmpNameSync({postfix: ".cache"}); | ||
|
|
||
| for (let i = 0; i < numberOfSigners; i++) { | ||
| const secretKey = bls.SecretKey.fromBytes(interopSecretKey(i).toBytes()); | ||
| const keystorePath = tmp.tmpNameSync({postfix: ".json"}); | ||
| const password = secretKey.toHex(); | ||
| const keystore = await Keystore.create( | ||
| password, | ||
| secretKey.toBytes(), | ||
| secretKey.toPublicKey().toBytes(), | ||
| keystorePath, | ||
| "test-keystore", | ||
| // To make the test efficient we use a low iteration count | ||
| { | ||
| function: "pbkdf2", | ||
| params: {dklen: 32, c: 10, prf: "hmac-sha256", salt: randomBytes(32).toString("hex")}, | ||
| } | ||
| ); | ||
| fs.writeFileSync(keystorePath, keystore.stringify()); | ||
|
|
||
| signers.push({type: SignerType.Local, secretKey}); | ||
|
|
||
| // Use secretkey hex as password | ||
| definitions.push({password: secretKey.toHex(), keystorePath}); | ||
| passwords.push(password); | ||
| secretKeys.push(secretKey.toBytes()); | ||
| } | ||
| }); | ||
|
|
||
| describe("writeKeystoreCache", () => { | ||
| it("should write a valid keystore cache file", async () => { | ||
| await expect(writeKeystoreCache(keystoreCacheFile, signers, passwords)).to.fulfilled; | ||
| expect(fs.existsSync(keystoreCacheFile)).to.be.true; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is there an easy way to do some basic sanitiy checks on the written file? I guess the proper check happens in the others test were the file is written and then loaded, so maybe should not bother too much here
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The authenticity of files could better be checked in e2e tests. |
||
| }); | ||
|
|
||
| it("should throw error if password length are not same as signers", async () => { | ||
| await expect(writeKeystoreCache(keystoreCacheFile, signers, [passwords[0]])).to.rejectedWith( | ||
| `Number of signers and passwords must be equal. signers=${numberOfSigners}, passwords=1` | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe("loadKeystoreCache", () => { | ||
| it("should load the valid keystore cache", async () => { | ||
| await writeKeystoreCache(keystoreCacheFile, signers, passwords); | ||
| const result = await loadKeystoreCache(keystoreCacheFile, definitions); | ||
|
|
||
| expect(result.map((r) => r.secretKey.toBytes())).to.eql(secretKeys); | ||
| }); | ||
|
|
||
| it("should raise error for mismatch public key", async () => { | ||
| await writeKeystoreCache(keystoreCacheFile, signers, passwords); | ||
| definitions[0].keystorePath = definitions[1].keystorePath; | ||
|
|
||
| await expect(loadKeystoreCache(keystoreCacheFile, definitions)).to.rejected; | ||
| }); | ||
| }); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the point of encrypting the keystore at all if we store the passwords in the same place? I assume the decrypting still takes a long time if the keystore is big but probably still better then decryption every single key separately as it was done before
It does not seem ideal to me that we store passwords in plain text at all which is currently done by Lodestar as we have a
keystoreand asecrets(with passwords) file. Those files are stored on the same device (and even the same folder) so it pointless in terms of security.In my opinion, we should never store the password, only the first time the user imports a encrypted keystore the password should be interactively provided through the cli or keymanager API. After keystores are imported they should be stored unecrypted which makes subsequently loading them much faster and we never store passwords in plain text.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's upto user to use one feature or other. In regard to decryption, for cache decryption it will take time upto one keystore. So if we have 100 validators keys we can save upto 99% of time with the cache.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's what I figured, looks like all CLs implement it like this. We also quickly discussed this topic in the standup, I will create a separate issue for this to get some ideas how we could improve the current security model but it might just be the case that a remoter signer/key manager is the only solution.
that's a great performance improvement, I was assuming it will be much faster but having constant time is really a big deal if you have a lot of validators