fix(gcp-kms): Cloud KMS data plane (encrypt/decrypt, asymmetric, MAC, public key, random bytes) - #1434
Conversation
…key, MAC and random bytes data plane
NitinKumar004
left a comment
There was a problem hiding this comment.
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, andgoogle_kms_secret_ciphertextwith AAD, read back throughdata.google_kms_secret, return the original plaintext. A second plan exits 0 and destroy removes all 3 resources.- The
google_kms_secretdata source sendsadditional_authenticated_dataas given, whilegoogle_kms_secret_ciphertextbase64-encodes it. The round trip only matches when the data source getsbase64encode("aad1"). Real Cloud KMS does the same thing because the difference is in the provider, so this is not an emulator problem.
- The
- REST: encrypt on v1, create v2,
updatePrimaryVersionto v2, then decrypt the v1 ciphertext. It returns 200 with the plaintext, andusedPrimaryis 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.generateRandomBytesreturns 16 bytes with a CRC. - GET on a version after data-plane use returns only the control-plane fields.
secretandprivare unexported and never reachmodel_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. Everysealdraws a fresh 96-bit random nonce, so a nonce is not reused under the same key. - The
magic | uint32 version | nonce | sealedheader is not authenticated. That is fine, because changing the version id or decrypting under another key picks a different AES key, and GCMOpenthen 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)
decryptdoes not apply the 64 KiBtooLargecap tociphertextoradditionalAuthenticatedData, thoughencryptdoes. (dataplane.godecrypt)- Key material is generated lazily while holding the store-wide write lock. The first
asymmetricSignorgetPublicKeyon 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.gousableVersion->ensureMaterial) signInputaccepts bothdigestanddatafor digest algorithms and silently usesdigest. Real Cloud KMS rejects a request that supplies both.
Plan (fast mode)
Row: GKMS-02 (TRACKER_GCP.md). Cloud KMS had no data plane:
:encryptreturned 400 "unsupported Cloud KMS operation",/publicKeyfell through to the Firestore 404, andlocations/{l}:generateRandomBytesreturned 501. That broke TFgoogle_kms_secret_ciphertext/google_kms_secretand every CMEK-style app call.Root cause:
server/gcp/kms/handler.goonly routed control-plane verbs (serveCryptoKey/serveVersiondefault towriteUnsupported), andfillRoutehad 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 returnsname(the version used),ciphertextandciphertextCrc32c.cryptoKeys/{k}:decryptreturnsplaintextandusedPrimary.:asymmetricSign,:asymmetricDecrypt,GET .../publicKey(PKIX PEM),:macSign/:macVerify, andlocations/{l}:generateRandomBytes(8..1024 bytes).*Crc32cchecksums are verified (a mismatch is 400) and echoed asverified*flags.Fix:
crypto.go: per-version key material from the Go stdlib, generated on first data-plane use and kept on the version: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.goanddataplane_types.go: the wire handlers and JSON shapes.handler.go: routes, including the/publicKeydepth and the location-level:generateRandomBytes.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.gouses the realcloud.google.com/go/kms/apiv1REST client (a new test-only module; the lean-binary guard passes):usedPrimary=false).All of these are red on origin/development, where every verb returns 400 "unsupported" or 404.
Gates:
go build ./...passes.go vetandgo test -racepass on./server/gcp/kms/and./server/gcp/, and thecmd/cloudemulean-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 andkms_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).:encryptgives 400 FAILED_PRECONDITION (no primary).locations/us-central1:generateRandomBytes: 200 with data and dataCrc32c.Note: the provider's
google_kms_secretdata source sendsadditional_authenticated_dataverbatim, so it has to be base64 (base64encode("ctx")). Real KMS rejects non-base64 bytes the same way.Docker: pending Gate 2.