| 1 | # gcp-secrets-kms |
| 2 | |
| 3 | Encryption as a service on Cloud Run. Callers send plaintext and get back |
| 4 | Cloud KMS ciphertext (and the reverse) without ever holding key material. |
| 5 | Both routes are gated by an API key stored in Secret Manager. |
| 6 | |
| 7 | | Route | Body | Returns | |
| 8 | | -------------- | ---------------- | ---------------- | |
| 9 | | `POST /encrypt` | `{ plaintext }` | `{ ciphertext }` | |
| 10 | | `POST /decrypt` | `{ ciphertext }` | `{ plaintext }` | |
| 11 | |
| 12 | Requests without the right `x-api-key` header get `401`. Until a secret |
| 13 | version exists the API answers `503` instead of failing open. |
| 14 | |
| 15 | ## Architecture |
| 16 | |
| 17 | - `GCP.KMS.KeyRing` `Keys` — container for the key, in the stack's region. |
| 18 | - `GCP.KMS.CryptoKey` `DataKey` — symmetric `ENCRYPT_DECRYPT` key. |
| 19 | - `GCP.SecretManager.Secret` `ApiKey` — the API key callers must present. |
| 20 | - `GCP.Function` `Api` — public Cloud Run service (`invokerIamDisabled: true`) |
| 21 | serving both routes. |
| 22 | |
| 23 | ## Bindings |
| 24 | |
| 25 | Each binding grants one role to the service's runtime service account, on |
| 26 | the single resource it was bound to: |
| 27 | |
| 28 | | Binding | Role | On | |
| 29 | | -------------------------------- | ------------------------------------- | --------- | |
| 30 | | `GCP.KMS.Encrypt(dataKey)` | `roles/cloudkms.cryptoKeyEncrypter` | `DataKey` | |
| 31 | | `GCP.KMS.Decrypt(dataKey)` | `roles/cloudkms.cryptoKeyDecrypter` | `DataKey` | |
| 32 | | `GCP.SecretManager.ReadSecret(apiKey)` | `roles/secretmanager.secretAccessor` | `ApiKey` | |
| 33 | |
| 34 | The service reads the `latest` secret version on every request (and |
| 35 | compares it in constant time), so rotating the key is a new secret |
| 36 | version — no redeploy. |
| 37 | |
| 38 | ## Deploy |
| 39 | |
| 40 | Credentials come from your alchemy profile: run `alchemy profile` once and pick GCP (*Service account JSON* for a key file, or *Stored* for an access token or key kept in `~/.alchemy/credentials`, plus a default region), then deploy with `--profile <name>`. |
| 41 | |
| 42 | ```sh |
| 43 | pnpm deploy --profile <name> |
| 44 | ``` |
| 45 | |
| 46 | Then add the API key as a secret version (the stack prints `secretId`): |
| 47 | |
| 48 | ```sh |
| 49 | printf 'my-key' | gcloud secrets versions add "$SECRET_ID" --data-file=- |
| 50 | ``` |
| 51 | |
| 52 | ```sh |
| 53 | curl -X POST "$URL/encrypt" -H 'x-api-key: my-key' \ |
| 54 | -H 'content-type: application/json' -d '{"plaintext":"hello"}' |
| 55 | ``` |
| 56 | |
| 57 | ## Test |
| 58 | |
| 59 | ```sh |
| 60 | ALCHEMY_PROFILE=<name> bun test |
| 61 | ``` |
| 62 | |
| 63 | Deploys the stack, seeds the secret, checks the `401`s, round-trips a |
| 64 | payload through `/encrypt` and `/decrypt`, decrypts the ciphertext directly |
| 65 | with the KMS API, then destroys the stack and checks that the key was |
| 66 | released and the secret, service, and image repository are gone. Needs Docker (the |
| 67 | service image is built locally). |
| 68 | |
| 69 | ## Destroy |
| 70 | |
| 71 | ```sh |
| 72 | pnpm destroy --profile <name> |
| 73 | ``` |
| 74 | |
| 75 | Cloud KMS never deletes key rings, and keeps keys for at least 24h after |
| 76 | their versions are destroyed. `destroy` therefore *releases* `DataKey`: |
| 77 | every version is scheduled for destruction and its ownership labels are |
| 78 | replaced with `alchemy-released`. A deploy that asks for the same key id |
| 79 | (set `cryptoKeyId` to pin one) reclaims it and mints a fresh primary |
| 80 | version. Either way, ciphertext from before the destroy can no longer be |
| 81 | decrypted. The key ring is only removed from state; empty rings are free. |
| 82 | The Cloud Run service, its image repository, its service account, and the |
| 83 | secret are deleted. |