Skip to content

fix(gcp-kms): Cloud KMS data plane (encrypt/decrypt, asymmetric, MAC, public key, random bytes) - #1434

Merged
NitinKumar004 merged 3 commits into
developmentfrom
fix/gcp-kms-data-plane
Oct 4, 2026
Merged

NitinKumar004 merged 3 commits into
developmentfrom
fix/gcp-kms-data-plane

Conversation

@NitinKumar004

Copy link
Copy Markdown
Collaborator

Plan (fast mode)

Row: GKMS-02 (TRACKER_GCP.md). Cloud KMS had no data plane: :encrypt returned 400 "unsupported Cloud KMS operation", /publicKey fell through to the Firestore 404, and locations/{l}:generateRandomBytes returned 501. That broke TF google_kms_secret_ciphertext / google_kms_secret and every CMEK-style app call.

Root cause: server/gcp/kms/handler.go only routed control-plane verbs (serveCryptoKey/serveVersion default to writeUnsupported), and fillRoute had no depth for .../cryptoKeyVersions/{v}/publicKey. Its package header said the data plane was "out of scope".

Non-goal check: there's no docs/coverage/nongoals/kms.md, and AWS KMS (providers/aws/kms/crypto.go) already does real AES-GCM and RSA. Nothing in the project says KMS shouldn't encrypt, so this PR builds real round-trip semantics. The control plane is unchanged.

Real behaviour (cloudkms v1 REST reference):

  • cryptoKeys/{k}:encrypt (or a version name) uses the primary version and returns name (the version used), ciphertext and ciphertextCrc32c. cryptoKeys/{k}:decrypt returns plaintext and usedPrimary.
  • An AAD mismatch or bad ciphertext gives 400 INVALID_ARGUMENT. A version that isn't ENABLED gives 400 FAILED_PRECONDITION. So does a purpose mismatch or a key with no primary.
  • :asymmetricSign, :asymmetricDecrypt, GET .../publicKey (PKIX PEM), :macSign/:macVerify, and locations/{l}:generateRandomBytes (8..1024 bytes).
  • Optional request *Crc32c checksums are verified (a mismatch is 400) and echoed as verified* flags.

Fix:

  • crypto.go: per-version key material from the Go stdlib, generated on first data-plane use and kept on the version:
    • AES-256/128-GCM for symmetric keys and HMAC keys of hash size.
    • RSA 2048/3072/4096 (never below 2048), ECDSA P-256/P-384 and Ed25519.
    • The symmetric ciphertext is magic | uint32 version id | nonce | sealed, so decrypt picks the sealing version after rotation.
  • dataplane_store.go: state, purpose and primary checks under the store lock.
  • dataplane.go and dataplane_types.go: the wire handlers and JSON shapes.
  • handler.go: routes, including the /publicKey depth and the location-level :generateRandomBytes.
  • Not supported: secp256k1 and external algorithms return FAILED_PRECONDITION with an "unsupported by the emulator" message.
  • Persistence: the GCP KMS handler is a wire-only store with no Snapshottable today (control plane included), so key material is in-memory like the rest of its state. Snapshot coverage for this handler would be a separate row.

Size: about 950 raw non-test lines including comments and wire struct tags, roughly at the ~600 logic-LOC cap. It's one coherent slice; the request/response types make up most of the overage.

Tests

server/gcp/kms/dataplane_sdk_test.go uses the real cloud.google.com/go/kms/apiv1 REST client (a new test-only module; the lean-binary guard passes):

  • Encrypt→decrypt round trip with AAD.
  • Rotate (new version plus updatePrimaryVersion), then decrypt the old ciphertext (usedPrimary=false).
  • AAD mismatch gives 400 INVALID_ARGUMENT.
  • A disabled version gives 400 FAILED_PRECONDITION.
  • asymmetricSign plus verification with the getPublicKey PEM for EC P-256, RSA-PSS, RSA-PKCS1 and Ed25519.
  • RSA-OAEP asymmetricDecrypt; macSign/macVerify (match and tamper).
  • A purpose mismatch gives FAILED_PRECONDITION; generateRandomBytes returns 32 bytes.

All of these are red on origin/development, where every verb returns 400 "unsupported" or 404.

Gates: go build ./... passes. go vet and go test -race pass on ./server/gcp/kms/ and ./server/gcp/, and the cmd/cloudemu lean-deps guard passes. golangci-lint --new-from-rev=origin/development: 0 issues. gofmt is clean.

E2E

Binary (serve, GCP :16069), OpenTofu 1.10 with the google provider and kms_custom_endpoint:

  • google_kms_key_ring + google_kms_crypto_key (rotation_period) + google_kms_secret_ciphertext + data google_kms_secret: apply OK, and the decrypted output equals the plaintext (s3cret-value).
  • plan: no changes.
  • Update rotation_period to 8640000s: apply 1 changed, plan no changes, GET shows the new value.
  • destroy: 3 destroyed. The versions are DESTROY_SCHEDULED; the ring and key persist, as in real KMS. A later :encrypt gives 400 FAILED_PRECONDITION (no primary).
  • curl locations/us-central1:generateRandomBytes: 200 with data and dataCrc32c.

Note: the provider's google_kms_secret data source sends additional_authenticated_data verbatim, so it has to be base64 (base64encode("ctx")). Real KMS rejects non-base64 bytes the same way.

Docker: pending Gate 2.

@NitinKumar004 NitinKumar004 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: MERGE (reviewed head 3dfa817)

Clean build of ./cmd/cloudemu at the PR head. go list -deps ./cmd/cloudemu does not include cloud.google.com/go/kms, so the new module is test-only and the binary stays lean.

End to end against serve

  • Terraform (google provider 6.x, kms_custom_endpoint): key ring, crypto key, and google_kms_secret_ciphertext with AAD, read back through data.google_kms_secret, return the original plaintext. A second plan exits 0 and destroy removes all 3 resources.
    • The google_kms_secret data source sends additional_authenticated_data as given, while google_kms_secret_ciphertext base64-encodes it. The round trip only matches when the data source gets base64encode("aad1"). Real Cloud KMS does the same thing because the difference is in the provider, so this is not an emulator problem.
  • REST: encrypt on v1, create v2, updatePrimaryVersion to v2, then decrypt the v1 ciphertext. It returns 200 with the plaintext, and usedPrimary is omitted (false). A wrong AAD gives 400 INVALID_ARGUMENT. New encrypts report v2. With v1 DISABLED, decrypting the old ciphertext gives 400 FAILED_PRECONDITION. Re-enabling v1 makes it decrypt again. generateRandomBytes returns 16 bytes with a CRC.
  • GET on a version after data-plane use returns only the control-plane fields. secret and priv are unexported and never reach model_json.go, so no key material leaks in get or list.

Crypto

  • All randomness comes from crypto/rand: keys, GCM nonces, and RSA/ECDSA signing. Every seal draws a fresh 96-bit random nonce, so a nonce is not reused under the same key.
  • The magic | uint32 version | nonce | sealed header is not authenticated. That is fine, because changing the version id or decrypting under another key picks a different AES key, and GCM Open then fails with INVALID_ARGUMENT. Ciphertexts can't be confused across keys.
  • The RSA size comes from the algorithm name, limited to 2048/3072/4096, and anything else is rejected. There is no path below 2048.
  • State and purpose are checked under the store lock before material is created (usableVersion). DESTROY_SCHEDULED and DISABLED versions fail with FAILED_PRECONDITION.

Persistence (follow-up, not a blocker)
The Cloud KMS handler state lives in server/gcp/kms/store.go. That is a wire-only store built in server/gcp/gcp.go (kmssrv.New(d.Clock)). It is not a provider field, so snapshot.Discover(p) in providers/gcp/gcp.go never sees it. Key rings, keys and versions were already lost on a --persist restart before this PR. What is new is the effect on users: ciphertext stored before a restart can never be decrypted afterwards, even if the same key is re-created by name. Fixing this means moving the store into a providers/gcp/kms mock that implements snapshot.Snapshottable, the same way providers/gcp/privateca/snapshot.go does. Key material would need to be serialized too: secret as bytes and priv as PKCS#8 DER. The handler would then be wired to that mock and a case added to persist/handler_state_test.go. That is a refactor of the whole control-plane store, not a small addition, so it should be a separate row.

Nits (optional)

  1. decrypt does not apply the 64 KiB tooLarge cap to ciphertext or additionalAuthenticatedData, though encrypt does. (dataplane.go decrypt)
  2. Key material is generated lazily while holding the store-wide write lock. The first asymmetricSign or getPublicKey on an RSA_4096 version can block every other KMS call for up to a second. Generating outside the lock and installing with a re-check would avoid that. (dataplane_store.go usableVersion -> ensureMaterial)
  3. signInput accepts both digest and data for digest algorithms and silently uses digest. Real Cloud KMS rejects a request that supplies both.

Comment thread server/gcp/kms/crypto.go Fixed
@NitinKumar004
NitinKumar004 marked this pull request as ready for review October 4, 2026 08:20
@NitinKumar004
NitinKumar004 merged commit 07ee8a5 into development Oct 4, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants