Remote signer backend (GCP Cloud KMS): keep operator private keys out of plaintext keystores

Author: ndr-dsCreated Jul 14, 2026Updated Jul 23, 2026
Labelssecurityneeds discussion

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.rstrait Signer with 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.rsKeystore(persistent::File<InMemorySigner>), the file-backed implementation (impl Signer for Keystore).
  • linera-service/src/cli/common_options.rsCommonCliOptions exposes --keystore <path>; keystore() reads the file.
  • linera-service/src/cli/main.rs — the loaded keystore is passed to create_client_context(storage, wallet, keystore) at every command site. ClientContext is generic over the signer via the linera-core Environment trait.

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 default AccountSecretKey::Ed25519 (linera-base/src/crypto/mod.rs, generate_from — note release binaries build without the revm feature, 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 via GetPublicKey, derives AccountOwner::from(AccountPublicKey::Ed25519(...)), and builds the owner→key-version map that backs contains_key/sign.
  • Auth: Application Default Credentials (works with workload identity in CI/cluster and gcloud auth locally). 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-dalek against the GetPublicKey-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_KEY is set) that signs via a real KMS key.
  • E2E: on a linera net up local network, change-ownership a 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 clippy clean; CLI.md regenerated (cargo run --bin linera -- help-markdown > CLI.md) since --keystore help 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