1import type { SecretKey as FlySecretKey } from "@distilled.cloud/fly-io/machines";
2import * as machines from "@distilled.cloud/fly-io/machines";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import { Unowned } from "../AdoptPolicy.ts";
7import { deepEqual, isResolved } from "../Diff.ts";
8import * as Provider from "../Provider.ts";
9import { Resource } from "../Resource.ts";
10import { App, listOwnedApps } from "./App.ts";
11import {
12 createFlyVolumeName,
13 matchesAlchemyPhysicalName,
14 sanitizeFlyVolumeName,
15} from "./Metadata.ts";
16import type { Providers } from "./Providers.ts";
17
18/**
19 * A resource-valued prop: the resource itself, or an Effect that produces
20 * it (so `yield* App(...)` and `App(...)` both type-check).
21 */
22type Ref<T> = T | Effect.Effect<T, never, Providers>;
23
24export interface SecretKeyProps {
25 /**
26 * Parent Fly App. Changing it replaces the secret key.
27 */
28 app: Ref<App>;
29 /**
30 * Secret key name, unique per App. If omitted, a unique name is generated
31 * from the stack, stage and logical ID (the ownership stamp). Changing it
32 * replaces the key.
33 */
34 name?: string;
35 /**
36 * Fly key type (`nacl_sign`, `nacl_box`, `hs256`, `hs384`, `hs512`,
37 * `xaes256gcm`, `nacl_auth`, `nacl_secretbox`, `es256`). Changing it
38 * replaces the key.
39 */
40 type?: string;
41 /**
42 * Raw key material. If omitted, Fly generates a key via
43 * `generateSecretKey`. If set, the key is created or updated with
44 * `setSecretKey`. Never persisted in state.
45 */
46 value?: ReadonlyArray<number>;
47}
48
49export type SecretKey = Resource<
50 "Fly.SecretKey",
51 SecretKeyProps,
52 {
53 /** Parent Fly App name. */
54 appName: string;
55 /** Secret key name (unique per App). */
56 name: string;
57 /** Observed Fly key type. */
58 type: string | undefined;
59 /** Public key (base64), when the type has one. Never private material. */
60 publicKey: string | undefined;
61 /** RFC3339 creation timestamp. */
62 createdAt: string | undefined;
63 /** RFC3339 last-update timestamp. */
64 updatedAt: string | undefined;
65 },
66 never,
67 Providers
68>;
69
70const resolveSecretKeyProps = (
71 props: SecretKeyProps | Effect.Effect<SecretKeyProps, never, Providers>,
72): Effect.Effect<SecretKeyProps, never, Providers> =>
73 Effect.gen(function* () {
74 const resolved = Effect.isEffect(props) ? yield* props : props;
75 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
76 const app = Effect.isEffect(resolved.app)
77 ? yield* resolved.app as Effect.Effect<App, never, Providers>
78 : resolved.app;
79 return { ...resolved, app };
80 });
81
82const SecretKeyResource = Resource<SecretKey>("Fly.SecretKey");
83
84/**
85 * A Fly.SecretKey is an App KMS key, not an env secret. Generate a
86 * random key or set raw material. Private bytes never appear in
87 * attributes.
88 *
89 * Use it at runtime with {@link Encrypt}, {@link Decrypt}, {@link Sign},
90 * and {@link Verify}. Generate, set, and delete stay on this resource.
91 *
92 * @see https://docs.machines.dev/secrets/Secretkeys_list
93 *
94 * ### Generate a key
95 * Omit `value` and Fly generates a key via `generateSecretKey`. `type`
96 * is Fly's key type (`nacl_sign`, `nacl_box`, `nacl_secretbox`,
97 * `hs256`, `hs384`, `hs512`, `xaes256gcm`, `nacl_auth`, `es256`, …).
98 *
99 * **Example:** Signing key
100 * ```typescript
101 * export const Signing = Fly.SecretKey("Signing", {
102 * app: Site,
103 * type: "nacl_sign",
104 * });
105 * ```
106 *
107 * :::caution[Changing `app`, `name`, or `type` replaces the key]
108 * The new key is created. The old one is deleted. Ciphertext from the
109 * old key will not decrypt.
110 * :::
111 *
112 * ### Set raw material
113 * Pass `value` as bytes. The key is created or updated with
114 * `setSecretKey`. Never persisted in state.
115 *
116 * **Example:** HS256
117 * ```typescript
118 * const hmac = yield* Fly.SecretKey("Hmac", {
119 * app: Site,
120 * type: "hs256",
121 * value: hmacBytes,
122 * });
123 * ```
124 *
125 * ### Encrypt
126 * Bind {@link Encrypt} to a box/secretbox/AEAD key. Provide
127 * {@link EncryptHttp}. Optional `associatedData` is AEAD associated
128 * data.
129 *
130 * Fly crypto ops need a KMS token. Org API tokens are typed
131 * `Forbidden`. Encrypt and sign from a {@link Service}, not a laptop
132 * Action.
133 *
134 * **Example:** Encrypt a payload
135 * ```typescript
136 * const encrypt = yield* Fly.Encrypt(Box);
137 * const { ciphertext } = yield* encrypt({
138 * plaintext: new TextEncoder().encode("attack at dawn"),
139 * });
140 * ```
141 *
142 * ### Decrypt
143 * Bind {@link Decrypt} to the same key. Plaintext comes back
144 * `Redacted`. Unwrap with `Redacted.value`. `associatedData` must
145 * match encryption. Provide {@link DecryptHttp}.
146 *
147 * **Example:** Decrypt a payload
148 * ```typescript
149 * const decrypt = yield* Fly.Decrypt(Box);
150 * const { plaintext } = yield* decrypt({ ciphertext });
151 * const bytes = Redacted.value(plaintext);
152 * ```
153 *
154 * ### Sign
155 * Bind {@link Sign} to a signing key (`nacl_sign`, `hs256`, `es256`,
156 * …). The private key never leaves Fly KMS. Provide {@link SignHttp}.
157 *
158 * **Example:** Sign a payload
159 * ```typescript
160 * const sign = yield* Fly.Sign(Signing);
161 * const { signature } = yield* sign({
162 * plaintext: new TextEncoder().encode("release-manifest-v1"),
163 * });
164 * ```
165 *
166 * ### Verify
167 * Bind {@link Verify} to the same key. A bad signature is a typed
168 * error from the Machines API. Provide {@link VerifyHttp}.
169 *
170 * **Example:** Verify a signature
171 * ```typescript
172 * const verify = yield* Fly.Verify(Signing);
173 * const { valid } = yield* verify({ plaintext, signature });
174 * ```
175 *
176 * @resource
177 * @product Secret Key
178 */
179export const SecretKey: typeof SecretKeyResource = Object.assign(
180 (
181 id: string,
182 props: SecretKeyProps | Effect.Effect<SecretKeyProps, never, Providers>,
183 ) => SecretKeyResource(id, resolveSecretKeyProps(props)),
184 SecretKeyResource,
185);
186
187export class SecretKeyNotCreated extends Data.TaggedError(
188 "Fly.SecretKeyNotCreated",
189)<{
190 appName: string;
191 name: string;
192}> {}
193
194export class SecretKeyAppRequired extends Data.TaggedError(
195 "Fly.SecretKeyAppRequired",
196)<{
197 message: string;
198}> {}
199
200const appNameOf = (value: unknown): string | undefined => {
201 if (value === null || typeof value !== "object") return undefined;
202 const rec = value as { appName?: unknown };
203 return typeof rec.appName === "string" && rec.appName.length > 0
204 ? rec.appName
205 : undefined;
206};
207
208const resolveName = (id: string, name: string | undefined, existing?: string) =>
209 Effect.gen(function* () {
210 if (name !== undefined) return sanitizeFlyVolumeName(name);
211 if (existing !== undefined) return existing;
212 return yield* createFlyVolumeName(id);
213 });
214
215const toAttrs = (
216 appName: string,
217 key: FlySecretKey,
218 fallbackName: string,
219): SecretKey["Attributes"] => ({
220 appName,
221 name: key.name ?? fallbackName,
222 type: key.type,
223 publicKey: key.public_key,
224 createdAt: key.created_at,
225 updatedAt: key.updated_at,
226});
227
228const getByName = (appName: string, secretName: string) =>
229 machines
230 .getSecretKey({
231 app_name: appName,
232 secret_name: secretName,
233 })
234 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
235
236const listKeys = (appName: string) =>
237 machines.listSecretKeys({ app_name: appName }).pipe(
238 Effect.map((res) => res.secret_keys ?? []),
239 Effect.catchTag(["NotFound", "Forbidden"], () => Effect.succeed([])),
240 );
241
242const putKey = (input: {
243 appName: string;
244 secretName: string;
245 type: string | undefined;
246 value: ReadonlyArray<number> | undefined;
247}) => {
248 if (input.value !== undefined) {
249 return machines.setSecretKey({
250 app_name: input.appName,
251 secret_name: input.secretName,
252 type: input.type,
253 value: [...input.value],
254 });
255 }
256 return machines.generateSecretKey({
257 app_name: input.appName,
258 secret_name: input.secretName,
259 type: input.type,
260 });
261};
262
263const valuesEqual = (
264 left: ReadonlyArray<number> | undefined,
265 right: ReadonlyArray<number> | undefined,
266): boolean => {
267 if (left === undefined && right === undefined) return true;
268 if (left === undefined || right === undefined) return false;
269 return deepEqual(left, right);
270};
271
272export const SecretKeyProvider = () =>
273 Provider.succeed(SecretKey, {
274 stables: ["appName", "name", "type", "publicKey", "createdAt"],
275 nuke: { dependsOn: ["Fly.App"] },
276
277 diff: Effect.fn(function* ({ news, output }) {
278 if (news === undefined || !isResolved(news)) return undefined;
279 if (output === undefined) return undefined;
280 const desiredName =
281 news.name !== undefined
282 ? sanitizeFlyVolumeName(news.name)
283 : output.name;
284 const nameChanged = desiredName !== output.name;
285 const nextApp = appNameOf(news.app);
286 const appChanged = nextApp !== undefined && nextApp !== output.appName;
287 const typeChanged =
288 news.type !== undefined &&
289 output.type !== undefined &&
290 news.type !== output.type;
291 if (nameChanged || appChanged || typeChanged) {
292 return {
293 action: "replace" as const,
294 // Same (app, name) cannot hold two types — delete first.
295 deleteFirst: !nameChanged && !appChanged,
296 };
297 }
298 return undefined;
299 }),
300
301 read: Effect.fn(function* ({ id, olds, output }) {
302 const appName =
303 output?.appName ??
304 (olds !== undefined ? appNameOf(olds.app) : undefined);
305 if (appName === undefined) return undefined;
306 const name = yield* resolveName(id, olds?.name, output?.name);
307 const found = yield* getByName(appName, name);
308 if (found === undefined) return undefined;
309 const attrs = toAttrs(appName, found, name);
310 if (output !== undefined) return attrs;
311 return matchesAlchemyPhysicalName(name) ? attrs : Unowned(attrs);
312 }),
313
314 list: Effect.fn(function* () {
315 const apps = yield* listOwnedApps();
316 const rows = yield* Effect.forEach(
317 apps,
318 (app) =>
319 listKeys(app.appName).pipe(
320 Effect.map((keys) =>
321 keys.flatMap((key) => {
322 const name = key.name;
323 if (!matchesAlchemyPhysicalName(name)) return [];
324 return [toAttrs(app.appName, key, name ?? "")];
325 }),
326 ),
327 ),
328 { concurrency: 8 },
329 );
330 return rows.flat();
331 }),
332
333 reconcile: Effect.fn(function* ({ id, news, olds, output }) {
334 const props = news ?? ({} as SecretKeyProps);
335 const appName = appNameOf(props.app) ?? output?.appName;
336 if (appName === undefined) {
337 return yield* new SecretKeyAppRequired({
338 message: "SecretKey requires a resolved Fly.App",
339 });
340 }
341 const name = yield* resolveName(id, props.name, output?.name);
342
343 // Observe by cached identity, then the desired (app, name).
344 let current =
345 output !== undefined
346 ? yield* getByName(output.appName, output.name)
347 : undefined;
348 if (
349 current === undefined &&
350 (output === undefined ||
351 output.appName !== appName ||
352 output.name !== name)
353 ) {
354 current = yield* getByName(appName, name);
355 }
356
357 if (current === undefined) {
358 yield* putKey({
359 appName,
360 secretName: name,
361 type: props.type,
362 value: props.value,
363 }).pipe(Effect.catchTag("Conflict", () => Effect.void));
364 current = yield* getByName(appName, name);
365 }
366
367 if (current === undefined) {
368 return yield* new SecretKeyNotCreated({ appName, name });
369 }
370
371 // Sync — re-set when the caller supplied new material. Observed
372 // cloud never returns private bytes; `olds.value` is only a hint.
373 const desiredValue = props.value;
374 if (desiredValue !== undefined) {
375 const previousValue = olds?.value;
376 const shouldSet =
377 previousValue === undefined ||
378 !valuesEqual(previousValue, desiredValue);
379 if (shouldSet) {
380 yield* putKey({
381 appName,
382 secretName: name,
383 type: props.type ?? current.type,
384 value: desiredValue,
385 }).pipe(Effect.catchTag("Conflict", () => Effect.void));
386 current = (yield* getByName(appName, name)) ?? current;
387 }
388 }
389
390 return toAttrs(appName, current, name);
391 }),
392
393 delete: Effect.fn(function* ({ output }) {
394 if (output.appName.length === 0 || output.name.length === 0) return;
395 yield* machines
396 .deleteSecretKey({
397 app_name: output.appName,
398 secret_name: output.name,
399 })
400 .pipe(Effect.catchTag("NotFound", () => Effect.void));
401 yield* getByName(output.appName, output.name).pipe(
402 Effect.map((key) => key === undefined),
403 Effect.repeat({
404 schedule: Schedule.spaced("1 second"),
405 until: (gone) => gone,
406 times: 8,
407 }),
408 );
409 }),
410 });