1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync.
2import * as NodeCrypto from "node:crypto";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6
7import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
8
9const CLOUD_LINK_KEY_PAIR = "cloud-link-ed25519-key-pair";
10const CLOUD_LINK_PRIVATE_KEY = "cloud-link-ed25519-private-key";
11const CLOUD_LINK_PUBLIC_KEY = "cloud-link-ed25519-public-key";
12
13const EnvironmentKeyPair = Schema.Struct({
14 privateKey: Schema.String,
15 publicKey: Schema.String,
16});
17type EnvironmentKeyPair = typeof EnvironmentKeyPair.Type;
18
19const EnvironmentKeyPairJson = Schema.fromJsonString(EnvironmentKeyPair);
20const decodeEnvironmentKeyPair = Schema.decodeUnknownEffect(EnvironmentKeyPairJson);
21const encodeEnvironmentKeyPair = Schema.encodeEffect(EnvironmentKeyPairJson);
22
23function bytesToString(bytes: Uint8Array): string {
24 return new TextDecoder().decode(bytes);
25}
26
27function stringToBytes(value: string): Uint8Array {
28 return new TextEncoder().encode(value);
29}
30
31const KEY_PAIR_RESOURCE = "environment signing key pair";
32
33const keyPairDecodeError = (cause: unknown): ServerSecretStore.SecretStoreDecodeError =>
34 new ServerSecretStore.SecretStoreDecodeError({ resource: KEY_PAIR_RESOURCE, cause });
35
36const keyPairEncodeError = (cause: unknown): ServerSecretStore.SecretStoreEncodeError =>
37 new ServerSecretStore.SecretStoreEncodeError({ resource: KEY_PAIR_RESOURCE, cause });
38
39const keyPairConcurrentReadError = (): ServerSecretStore.SecretStoreConcurrentReadError =>
40 new ServerSecretStore.SecretStoreConcurrentReadError({ resource: KEY_PAIR_RESOURCE });
41
42const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* (
43 secrets: ServerSecretStore.ServerSecretStore["Service"],
44) {
45 const encoded = yield* secrets.get(CLOUD_LINK_KEY_PAIR);
46 if (Option.isNone(encoded)) {
47 return Option.none<EnvironmentKeyPair>();
48 }
49 const decoded = yield* decodeEnvironmentKeyPair(bytesToString(encoded.value)).pipe(
50 Effect.mapError(keyPairDecodeError),
51 );
52 return Option.some(decoded);
53});
54
55const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(function* (
56 secrets: ServerSecretStore.ServerSecretStore["Service"],
57 keyPair: EnvironmentKeyPair,
58) {
59 const encoded = yield* encodeEnvironmentKeyPair(keyPair).pipe(
60 Effect.mapError(keyPairEncodeError),
61 );
62 return yield* secrets.create(CLOUD_LINK_KEY_PAIR, stringToBytes(encoded)).pipe(
63 Effect.as(keyPair),
64 Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) =>
65 ServerSecretStore.isSecretAlreadyExistsError(error)
66 ? readEnvironmentKeyPair(secrets).pipe(
67 Effect.flatMap(
68 Option.match({
69 onSome: Effect.succeed,
70 onNone: () => Effect.fail(keyPairConcurrentReadError()),
71 }),
72 ),
73 )
74 : Effect.fail(error),
75 ),
76 );
77});
78
79export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* (
80 secrets: ServerSecretStore.ServerSecretStore["Service"],
81) {
82 const existing = yield* readEnvironmentKeyPair(secrets);
83 if (Option.isSome(existing)) {
84 return existing.value;
85 }
86
87 const existingPrivate = yield* secrets.get(CLOUD_LINK_PRIVATE_KEY);
88 const existingPublic = yield* secrets.get(CLOUD_LINK_PUBLIC_KEY);
89 if (Option.isSome(existingPrivate) && Option.isSome(existingPublic)) {
90 return yield* persistEnvironmentKeyPair(secrets, {
91 privateKey: bytesToString(existingPrivate.value),
92 publicKey: bytesToString(existingPublic.value),
93 });
94 }
95
96 const keyPair = NodeCrypto.generateKeyPairSync("ed25519", {
97 privateKeyEncoding: { format: "pem", type: "pkcs8" },
98 publicKeyEncoding: { format: "pem", type: "spki" },
99 });
100 return yield* persistEnvironmentKeyPair(secrets, {
101 privateKey: keyPair.privateKey,
102 publicKey: keyPair.publicKey,
103 });
104});