| 1 | // @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync. |
| 2 | import * as NodeCrypto from "node:crypto"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import * as Schema from "effect/Schema"; |
| 6 | |
| 7 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 8 | |
| 9 | const CLOUD_LINK_KEY_PAIR = "cloud-link-ed25519-key-pair"; |
| 10 | const CLOUD_LINK_PRIVATE_KEY = "cloud-link-ed25519-private-key"; |
| 11 | const CLOUD_LINK_PUBLIC_KEY = "cloud-link-ed25519-public-key"; |
| 12 | |
| 13 | const EnvironmentKeyPair = Schema.Struct({ |
| 14 | privateKey: Schema.String, |
| 15 | publicKey: Schema.String, |
| 16 | }); |
| 17 | type EnvironmentKeyPair = typeof EnvironmentKeyPair.Type; |
| 18 | |
| 19 | const EnvironmentKeyPairJson = Schema.fromJsonString(EnvironmentKeyPair); |
| 20 | const decodeEnvironmentKeyPair = Schema.decodeUnknownEffect(EnvironmentKeyPairJson); |
| 21 | const encodeEnvironmentKeyPair = Schema.encodeEffect(EnvironmentKeyPairJson); |
| 22 | |
| 23 | function bytesToString(bytes: Uint8Array): string { |
| 24 | return new TextDecoder().decode(bytes); |
| 25 | } |
| 26 | |
| 27 | function stringToBytes(value: string): Uint8Array { |
| 28 | return new TextEncoder().encode(value); |
| 29 | } |
| 30 | |
| 31 | const KEY_PAIR_RESOURCE = "environment signing key pair"; |
| 32 | |
| 33 | const keyPairDecodeError = (cause: unknown): ServerSecretStore.SecretStoreDecodeError => |
| 34 | new ServerSecretStore.SecretStoreDecodeError({ resource: KEY_PAIR_RESOURCE, cause }); |
| 35 | |
| 36 | const keyPairEncodeError = (cause: unknown): ServerSecretStore.SecretStoreEncodeError => |
| 37 | new ServerSecretStore.SecretStoreEncodeError({ resource: KEY_PAIR_RESOURCE, cause }); |
| 38 | |
| 39 | const keyPairConcurrentReadError = (): ServerSecretStore.SecretStoreConcurrentReadError => |
| 40 | new ServerSecretStore.SecretStoreConcurrentReadError({ resource: KEY_PAIR_RESOURCE }); |
| 41 | |
| 42 | const 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 | |
| 55 | const 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 | |
| 79 | export 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 | }); |