feat(bootstrap): adopt Chainlink node keys via [key_import] - #1317
feat(bootstrap): adopt Chainlink node keys via [key_import]#1317tt-cll wants to merge 5 commits into
Conversation
|
👋 tt-cll, thanks for creating this pull request! To help reviewers, please consider creating future PRs as drafts first. This allows you to self-review and make any final changes before notifying the team. Once you're ready, you can mark it as "Ready for review" to request feedback. Thanks! |
There was a problem hiding this comment.
Pull request overview
Adds a bootstrap-time key adoption flow to support migrating CCV operators from running on a Chainlink node to standalone binaries without changing on-chain signer/transmitter identities.
Changes:
- Introduces
[key_import]bootstrap config and wiring to import a Chainlink-exported OCR2 bundle (EVM onchain key) or ETH v3 keystore on first boot. - Adds import format detection/decoders plus
EnsureImportedKeylogic (no-op when the key already exists). - Updates config docs/README and adds a changelog entry describing the migration behavior.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tools/configdoc/registry/registry.go | Populates KeyImport in the configdoc instance for generated docs/examples. |
| docs/config/bootstrap/config.documented.toml | Documents the new [key_import] section in the generated bootstrap config reference. |
| changelog/2026-07-29_cl_to_standalone_key_migration.md | Operator-facing changelog describing the migration and key adoption. |
| bootstrap/README.md | Adds bootstrap documentation for adopting a key from a Chainlink node export. |
| bootstrap/keys/import.go | Implements format detection, OCR2/ETH export decoding, and keystore import envelopeing. |
| bootstrap/keys/import_test.go | Unit tests for decoders, format detection, and EnsureImportedKey behavior (including expected_id). |
| bootstrap/config.go | Adds KeyImport config schema + validation and conversion to keys.Import. |
| bootstrap/bootstrap.go | Wires key import into keystore initialization and resolves which declared key is importable. |
Suppressed comments (1)
bootstrap/config.go:194
- validateKeyImport currently rejects a configured [key_import] unless expected_id is set, but the PR description (and bootstrap/README.md) describe expected_id as optional. The keys.Import implementation already treats ExpectedID as opt-in, so this validation makes the feature stricter than intended and breaks migrations that omit expected_id.
if strings.TrimSpace(imp.ExpectedID) == "" {
return []error{fmt.Errorf(
"invalid 'key_import' section: expected_id is required: it is the check that fails " +
"the boot when the wrong node's export is mounted")}
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
| } | ||
|
|
||
| blob, err := encodeForImport(keyName, keyType, privateKey) |
There was a problem hiding this comment.
Is the reason why we do decodeImport, which decrypts the private key data, and then an encodeForImport, which re-encrypts the data w/ constant password (declared in this file), to import the key into the right name? I'm wondering if we can simplify this a bit if we export the keys from chainlink under a specific keyName instead, would clean up a decent bit of code.
There was a problem hiding this comment.
I don't know if that export trick would work. I don't see a way to specify a format or key name to export to in the request body https://github.com/smartcontractkit/chainlink-common/blob/3207712d718767c65e222c376baeed33dc8ca419/keystore/admin.go#L72. We have to do some massaging to get the keys into our format and that's not supported by the API it looks like.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (3)
bootstrap/keys/import.go:106
- Import.Validate only checks that expected_id is non-empty; it does not validate that it is a 20-byte hex address. If an operator mistypes expected_id (wrong length / non-hex), the failure later can look like “wrong node's export is mounted”, which is misleading. Validate expected_id's shape up front so config errors are reported clearly.
if strings.TrimSpace(i.ExpectedID) == "" {
return fmt.Errorf("field 'expected_id' is required: it is the check that fails the boot " +
"when the wrong node's export is mounted")
}
bootstrap/keys/import.go:155
- EnsureImportedKey’s non-secp256k1 error message interpolates spec.Format, but spec.Format is allowed to be empty (normal path: format auto-detected). In that case the message becomes confusing (“but imports carry…”). Populate a fallback label when spec.Format is empty.
if keyType != keystore.ECDSA_S256 {
return fmt.Errorf(
"key %q is declared as %s but %s imports carry a secp256k1 key and can only populate %s",
keyName, keyType, spec.Format, keystore.ECDSA_S256,
)
bootstrap/bootstrap.go:863
- New resolveKeyImport + initializeKeystore branching adds critical startup behavior (KMS backend rejection and inference of which declared key gets imported). There are bootstrap package tests, but none cover the key-import selection/error cases, so regressions here would be easy to miss.
func resolveKeyImport(backend KeystoreBackend, requiredKeys []keyToInit, keyImport *KeyImport) (string, keys.Import, error) {
if keyImport == nil {
return "", keys.Import{}, nil
}
if backend == KeystoreBackendKMS {
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (2)
bootstrap/keys/import.go:155
- The key-type mismatch error message includes spec.Format, but spec.Format is often left empty (format auto-detected). Using %s yields a blank in the message ("but imports"), which is confusing when diagnosing misdeclared key types.
if keyType != keystore.ECDSA_S256 {
return fmt.Errorf(
"key %q is declared as %s but %s imports carry a secp256k1 key and can only populate %s",
keyName, keyType, spec.Format, keystore.ECDSA_S256,
)
bootstrap/config.go:387
- key_import is explicitly unsupported with the KMS keystore backend (enforced later in resolveKeyImport), but config validation currently doesn't surface that incompatibility. Adding a validation-time check improves operator feedback by failing fast with a clear config error.
// The key import populates the keystore, which both modes initialize when [db] and [keystore]
// are present, so it is validated in every mode rather than only alongside the JD infra bundle.
errs = append(errs, validateKeyImport(c.KeyImport)...)
return errors.Join(errs...)
|
Code coverage report:
Files added (in
|
Description
https://smartcontract-it.atlassian.net/browse/CCIP-12567
[key_import]in the bootstrap config adopts a key exported from a Chainlink node instead of generating a fresh one: the OCR2 bundle's onchain signing key for a verifier, the funded EVM account for an executor. Both have to survive the CL-to-standalone move or the committee signer set needs reconfiguring and the transmitter needs refunding.expected_idpin (enforced against the keystore as well as the export) against mounting the wrong node's file.Part of the CL-to-standalone migration series; the operator procedure lands with the cutover PR.
Testing
Unit tests for both decoders (real export shapes), format detection, validation, and
EnsureImportedKeyincluding theexpected_idenforcement against an existing keystore key.Checklist
changelogdirectory)Stack created with GitHub Stacks CLI • Give Feedback 💬