1# gcp-secrets-kms
2
3Encryption as a service on Cloud Run. Callers send plaintext and get back
4Cloud KMS ciphertext (and the reverse) without ever holding key material.
5Both 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
12Requests without the right `x-api-key` header get `401`. Until a secret
13version 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
25Each binding grants one role to the service's runtime service account, on
26the 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
34The service reads the `latest` secret version on every request (and
35compares it in constant time), so rotating the key is a new secret
36version — no redeploy.
37
38## Deploy
39
40Credentials 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
43pnpm deploy --profile <name>
44```
45
46Then add the API key as a secret version (the stack prints `secretId`):
47
48```sh
49printf 'my-key' | gcloud secrets versions add "$SECRET_ID" --data-file=-
50```
51
52```sh
53curl -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
60ALCHEMY_PROFILE=<name> bun test
61```
62
63Deploys the stack, seeds the secret, checks the `401`s, round-trips a
64payload through `/encrypt` and `/decrypt`, decrypts the ciphertext directly
65with the KMS API, then destroys the stack and checks that the key was
66released and the secret, service, and image repository are gone. Needs Docker (the
67service image is built locally).
68
69## Destroy
70
71```sh
72pnpm destroy --profile <name>
73```
74
75Cloud KMS never deletes key rings, and keeps keys for at least 24h after
76their versions are destroyed. `destroy` therefore *releases* `DataKey`:
77every version is scheduled for destruction and its ownership labels are
78replaced 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
80version. Either way, ciphertext from before the destroy can no longer be
81decrypted. The key ring is only removed from state; empty rings are free.
82The Cloud Run service, its image repository, its service account, and the
83secret are deleted.