1import * as NodeCrypto from "node:crypto";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Provider from "./Provider.ts";
5import { Resource } from "./Resource.ts";
6
7export type KeyPairAlgorithm = "ed25519" | "rsa" | "ec";
8
9export interface KeyPairProps {
10 /**
11 * Key algorithm used to generate the pair.
12 * @default "ed25519"
13 */
14 algorithm?: KeyPairAlgorithm;
15 /**
16 * Modulus length in bits. Only used when {@link algorithm} is `"rsa"`.
17 * @default 2048
18 */
19 modulusLength?: number;
20 /**
21 * Named curve. Only used when {@link algorithm} is `"ec"`.
22 * @default "P-256"
23 */
24 namedCurve?: string;
25}
26
27export type KeyPair = Resource<
28 "Alchemy.KeyPair",
29 KeyPairProps,
30 {
31 algorithm: KeyPairAlgorithm;
32 privateKey: Redacted.Redacted<string>;
33 publicKey: string;
34 }
35>;
36
37/**
38 * A deterministic-in-state public/private keypair generator.
39 *
40 * The keypair is generated once on first reconcile and then persisted in
41 * state so subsequent deploys keep the same keys unless the resource is
42 * replaced. `privateKey` is PEM-encoded `pkcs8` and `publicKey` is
43 * PEM-encoded `spki`.
44 *
45 *
46 * ### Generating a Keypair
47 * **Example:** Default ed25519 keypair
48 * ```typescript
49 * const keys = yield* KeyPair("signing-key");
50 * // keys.privateKey: Redacted<string> (PEM pkcs8)
51 * // keys.publicKey: string (PEM spki)
52 * // keys.algorithm: "ed25519"
53 * ```
54 *
55 * **Example:** RSA keypair
56 * ```typescript
57 * const keys = yield* KeyPair("rsa-key", {
58 * algorithm: "rsa",
59 * modulusLength: 2048,
60 * });
61 * ```
62 *
63 * **Example:** EC keypair on a named curve
64 * ```typescript
65 * const keys = yield* KeyPair("ec-key", {
66 * algorithm: "ec",
67 * namedCurve: "P-256",
68 * });
69 * ```
70 *
71 * ### Consuming the Keys
72 * **Example:** Pass the private key to a Worker as a secret
73 * ```typescript
74 * const keys = yield* KeyPair("signing-key");
75 * export const Worker = Cloudflare.Worker("Worker", {
76 * main: "./src/worker.ts",
77 * bindings: {
78 * SIGNING_KEY: keys.privateKey,
79 * PUBLIC_KEY: keys.publicKey,
80 * },
81 * });
82 * ```
83 *
84 * @resource
85 */
86export const KeyPair = Resource<KeyPair>("Alchemy.KeyPair");
87
88const pemEncoding = {
89 privateKeyEncoding: { type: "pkcs8", format: "pem" },
90 publicKeyEncoding: { type: "spki", format: "pem" },
91} as const;
92
93const generate = (
94 props: KeyPairProps,
95): {
96 privateKey: string;
97 publicKey: string;
98 algorithm: KeyPairAlgorithm;
99} => {
100 const algorithm = props.algorithm ?? "ed25519";
101 if (algorithm === "rsa") {
102 const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("rsa", {
103 modulusLength: props.modulusLength ?? 2048,
104 ...pemEncoding,
105 });
106 return { algorithm, privateKey, publicKey };
107 }
108 if (algorithm === "ec") {
109 const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", {
110 namedCurve: props.namedCurve ?? "P-256",
111 ...pemEncoding,
112 });
113 return { algorithm, privateKey, publicKey };
114 }
115 const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync(
116 "ed25519",
117 pemEncoding,
118 );
119 return { algorithm, privateKey, publicKey };
120};
121
122export const KeyPairProvider = () =>
123 Provider.succeed(KeyPair, {
124 reconcile: Effect.fn(function* ({ news = {}, output }) {
125 // Observe — there is no remote state. The cached `output` is the
126 // authoritative current value; once minted the keypair is preserved
127 // across reconciles to keep it stable.
128 if (output?.privateKey && output?.publicKey) {
129 return output;
130 }
131
132 // Ensure — no observed value: mint a fresh keypair. The next
133 // reconcile will see this in `output` and short-circuit above.
134 const generated = yield* Effect.sync(() => generate(news));
135 return {
136 algorithm: generated.algorithm,
137 privateKey: Redacted.make(generated.privateKey),
138 publicKey: generated.publicKey,
139 };
140 }),
141 delete: Effect.fn(function* () {
142 return undefined;
143 }),
144 read: Effect.fn(function* ({ output }) {
145 return output;
146 }),
147 // Non-listable: the keypair is generated client-side with `node:crypto` and
148 // lives only in alchemy state (the private key is write-only). There is no
149 // remote service to enumerate, so listing yields nothing.
150 list: () => Effect.succeed([]),
151 });