Remote signer backend (GCP Cloud KMS): keep operator private keys out of plaintext keystores
Motivation
Operator private keys — most importantly the admin chain owner key used for committee operations (linera validator add/remove/update), resource-control-policy changes, and revoke-epochs — currently exist as plaintext keystore.json files. Today they are minted on an operator machine and distributed via secret storage (GCP Secret Manager). Anyone with read access to the secret holds the key, and the plaintext exists on disk at mint time and inside every process that signs.
The goal: the private key is generated inside a non-exportable key service (Cloud KMS) and never exists in plaintext anywhere — not on operator machines, not in secret storage, not in CI. Signing happens via an API call to the key service; only the public key ever leaves it.
Current architecture (where the seam is)
The abstraction already exists and is the right shape:
linera-base/src/crypto/signer.rs—trait Signerwith exactly two methods:async fn sign(&self, owner: &AccountOwner, value: &[u8]) -> Result<AccountSignature, Self::Error>async fn contains_key(&self, owner: &AccountOwner) -> Result<bool, Self::Error>
linera-wallet-json/src/keystore.rs—Keystore(persistent::File<InMemorySigner>), the file-backed implementation (impl Signer for Keystore).linera-service/src/cli/common_options.rs—CommonCliOptionsexposes--keystore <path>;keystore()reads the file.linera-service/src/cli/main.rs— the loaded keystore is passed tocreate_client_context(storage, wallet, keystore)at every command site.ClientContextis generic over the signer via thelinera-coreEnvironmenttrait.
wallet.json is unaffected: it holds chain-tracking state, not key material. Only the keystore is being replaced.
Proposed change
1. A Signer implementation backed by GCP Cloud KMS
New module/crate (suggestion: linera-signer-kms or a feature-gated module in linera-wallet-json), implementing Signer by calling the Cloud KMS asymmetricSign API.
- Key algorithm:
EC_SIGN_ED25519(PureEdDSA over raw input — see supported algorithms). This matches the defaultAccountSecretKey::Ed25519(linera-base/src/crypto/mod.rs,generate_from— note release binaries build without therevmfeature, so Ed25519 is what wallets generate today). - Configuration: a set of KMS key-version resource names (
projects/P/locations/L/keyRings/R/cryptoKeys/K/cryptoKeyVersions/V). At startup the signer fetches each key's public key viaGetPublicKey, derivesAccountOwner::from(AccountPublicKey::Ed25519(...)), and builds the owner→key-version map that backscontains_key/sign. - Auth: Application Default Credentials (works with workload identity in CI/cluster and
gcloud authlocally). No new credential formats. - Design the module so additional backends (Vault Transit, AWS KMS — both also support Ed25519 signing) can be added later behind the same config surface; do not implement them in this issue.
2. CLI plumbing
Extend keystore selection so commands can run with the remote signer instead of the file keystore. Suggested UX: --keystore accepts a URI scheme — plain path (or file://...) keeps current behavior, gcp-kms://<key-version-resource-name>[,<more>] selects the KMS backend. Environment-variable equivalent (LINERA_KEYSTORE) must work the same way, since automation passes config via env.
All ~18 create_client_context call sites in linera-service/src/cli/main.rs go through the same option, so the dispatch can live in CommonCliOptions::keystore() returning an enum/boxed signer (whatever fits ClientContext's generic bound with minimal churn — an enum implementing Signer avoids dyn issues).
Commands that generate keys into the keystore (keygen, wallet init paths that call generate_key) must fail with a clear error on a read-only remote backend — key generation happens in KMS (via terraform/gcloud), not through the CLI.
3. No genesis-tooling changes — migration is ownership rotation
Existing chains migrate with the already-existing change-ownership command: rotate the admin chain's owner to the KMS-held public key (ownership changes only need the public key), verify signing works via the new backend, then destroy the file keystore. The same procedure doubles as key-compromise recovery. Document this in the module docs; the operational runbook itself is out of scope here.
Acceptance criteria
- Signature-compatibility unit test: sign a known payload via (mocked-transport) KMS flow, verify with
ed25519-dalekagainst theGetPublicKey-format public key. The KMS HTTP layer should be mockable for unit tests. - Env-gated live integration test (skipped unless e.g.
LINERA_TEST_GCP_KMS_KEYis set) that signs via a real KMS key. - E2E: on a
linera net uplocal network,change-ownershipa chain to a KMS-held key (live test, env-gated) and successfully execute an owner operation signed through the backend. -
keygen/key-generation paths return a clear error under the remote backend. -
cargo fmt+cargo clippyclean;CLI.mdregenerated (cargo run --bin linera -- help-markdown > CLI.md) since--keystorehelp text changes.
Related: #1924 (key management — this issue implements the "key management service" direction for operator keys).
Out of scope
- Vault Transit / AWS KMS backends (follow-ups; the config surface should allow them).
- Migrating existing networks (operational runbook, tracked in infra).
- Validator server keys (
server_N.json) — hot-path consensus signing has different latency constraints; evaluate separately.
Source: linera-io/linera-protocol