1import * as GCP from "alchemy/GCP";
2import * as Effect from "effect/Effect";
3import { HttpServerRequest } from "effect/http/HttpServerRequest";
4import * as HttpServerResponse from "effect/http/HttpServerResponse";
5import { ApiKey, DataKey } from "./resources.ts";
6
7/** Constant-time comparison so response timing does not leak the key. */
8const 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
17const toBase64 = (text: string) =>
18 Effect.sync(() => Buffer.from(text, "utf8").toString("base64"));
19
20const fromBase64 = (data: string) =>
21 Effect.sync(() => Buffer.from(data, "base64").toString("utf8"));
22
23const 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 */
40export 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) {}