| 1 | import * as GCP from "alchemy/GCP"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 4 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 5 | import { ApiKey, DataKey } from "./resources.ts"; |
| 6 | |
| 7 | /** Constant-time comparison so response timing does not leak the key. */ |
| 8 | const sameKey = (expected: string, given: string | undefined) => { |
| 9 | if (given === undefined || given.length !== expected.length) return false; |
| 10 | let diff = 0; |
| 11 | for (let index = 0; index < expected.length; index++) { |
| 12 | diff |= expected.charCodeAt(index) ^ given.charCodeAt(index); |
| 13 | } |
| 14 | return diff === 0; |
| 15 | }; |
| 16 | |
| 17 | const toBase64 = (text: string) => |
| 18 | Effect.sync(() => Buffer.from(text, "utf8").toString("base64")); |
| 19 | |
| 20 | const fromBase64 = (data: string) => |
| 21 | Effect.sync(() => Buffer.from(data, "base64").toString("utf8")); |
| 22 | |
| 23 | const error = (status: number, message: string) => |
| 24 | HttpServerResponse.json({ error: message }, { status }); |
| 25 | |
| 26 | /** |
| 27 | * Encryption as a service on Cloud Run. |
| 28 | * |
| 29 | * Callers never hold key material: `/encrypt` hands plaintext to Cloud KMS |
| 30 | * and returns the ciphertext, `/decrypt` does the reverse. Both routes are |
| 31 | * gated by an API key the service reads from Secret Manager per request, |
| 32 | * so rotating the key is `gcloud secrets versions add` — no redeploy. |
| 33 | * |
| 34 | * - `POST /encrypt` — `{ plaintext }` → `{ ciphertext }` (base64). |
| 35 | * - `POST /decrypt` — `{ ciphertext }` → `{ plaintext }`. |
| 36 | * |
| 37 | * Each binding grants one role on the service's runtime service account, |
| 38 | * scoped to the one resource it was bound to. |
| 39 | */ |
| 40 | export default class Api extends GCP.Function<Api>()( |
| 41 | "Api", |
| 42 | { |
| 43 | main: import.meta.url, |
| 44 | invokerIamDisabled: true, |
| 45 | }, |
| 46 | Effect.gen(function* () { |
| 47 | const dataKey = yield* DataKey; |
| 48 | const apiKey = yield* ApiKey; |
| 49 | |
| 50 | // roles/cloudkms.cryptoKeyEncrypter / cryptoKeyDecrypter on DataKey. |
| 51 | const encrypt = yield* GCP.KMS.Encrypt(dataKey); |
| 52 | const decrypt = yield* GCP.KMS.Decrypt(dataKey); |
| 53 | // roles/secretmanager.secretAccessor on ApiKey. |
| 54 | const apiKeySecret = yield* GCP.SecretManager.ReadSecret(apiKey); |
| 55 | |
| 56 | /** |
| 57 | * Read the `latest` version on every call so a rotated key takes |
| 58 | * effect immediately. With no version yet the API refuses everyone |
| 59 | * instead of failing open. |
| 60 | */ |
| 61 | const authorize = (request: HttpServerRequest) => |
| 62 | apiKeySecret.access().pipe( |
| 63 | Effect.map((expected) => |
| 64 | expected === undefined |
| 65 | ? ("unconfigured" as const) |
| 66 | : sameKey(expected, request.headers["x-api-key"]) |
| 67 | ? ("ok" as const) |
| 68 | : ("denied" as const), |
| 69 | ), |
| 70 | Effect.orDie, |
| 71 | ); |
| 72 | |
| 73 | const readField = (request: HttpServerRequest, field: string) => |
| 74 | request.json.pipe( |
| 75 | Effect.map((body) => { |
| 76 | const value = (body as Record<string, unknown> | null)?.[field]; |
| 77 | return typeof value === "string" && value.length > 0 |
| 78 | ? value |
| 79 | : undefined; |
| 80 | }), |
| 81 | Effect.catch(() => Effect.succeed(undefined)), |
| 82 | ); |
| 83 | |
| 84 | return { |
| 85 | fetch: Effect.gen(function* () { |
| 86 | const request = yield* HttpServerRequest; |
| 87 | const { pathname } = new URL(request.originalUrl); |
| 88 | |
| 89 | if (request.method === "GET" && pathname === "/") { |
| 90 | return HttpServerResponse.text("ok"); |
| 91 | } |
| 92 | |
| 93 | const route = |
| 94 | request.method === "POST" && |
| 95 | (pathname === "/encrypt" || pathname === "/decrypt") |
| 96 | ? pathname |
| 97 | : undefined; |
| 98 | if (route === undefined) return yield* error(404, "not found"); |
| 99 | |
| 100 | const auth = yield* authorize(request); |
| 101 | if (auth === "unconfigured") { |
| 102 | return yield* error( |
| 103 | 503, |
| 104 | "no api key version has been added to the secret", |
| 105 | ); |
| 106 | } |
| 107 | if (auth === "denied") return yield* error(401, "invalid api key"); |
| 108 | |
| 109 | if (route === "/encrypt") { |
| 110 | const plaintext = yield* readField(request, "plaintext"); |
| 111 | if (plaintext === undefined) { |
| 112 | return yield* error(400, "plaintext is required"); |
| 113 | } |
| 114 | const { ciphertext } = yield* encrypt({ |
| 115 | body: { plaintext: yield* toBase64(plaintext) }, |
| 116 | }).pipe(Effect.orDie); |
| 117 | return yield* HttpServerResponse.json({ ciphertext }); |
| 118 | } |
| 119 | |
| 120 | const ciphertext = yield* readField(request, "ciphertext"); |
| 121 | if (ciphertext === undefined) { |
| 122 | return yield* error(400, "ciphertext is required"); |
| 123 | } |
| 124 | // KMS rejects ciphertext it did not produce (or that was tampered |
| 125 | // with) as a bad request; that is the caller's fault, not ours. |
| 126 | const decrypted = yield* decrypt({ body: { ciphertext } }).pipe( |
| 127 | Effect.map((response) => response.plaintext), |
| 128 | Effect.catchTag("BadRequest", () => Effect.succeed(null)), |
| 129 | Effect.orDie, |
| 130 | ); |
| 131 | if (decrypted === null) { |
| 132 | return yield* error(400, "ciphertext could not be decrypted"); |
| 133 | } |
| 134 | return yield* HttpServerResponse.json({ |
| 135 | plaintext: yield* fromBase64(decrypted ?? ""), |
| 136 | }); |
| 137 | }), |
| 138 | }; |
| 139 | }).pipe( |
| 140 | Effect.provide([ |
| 141 | GCP.KMS.EncryptHttp, |
| 142 | GCP.KMS.DecryptHttp, |
| 143 | GCP.SecretManager.ReadSecretHttp, |
| 144 | ]), |
| 145 | ), |
| 146 | ) {} |