| 1 | import * as Acme from "@distilled.cloud/acme"; |
| 2 | import * as acme from "@distilled.cloud/acme/acme"; |
| 3 | import * as Data from "effect/Data"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Redacted from "effect/Redacted"; |
| 6 | import { isResolved } from "../Diff.ts"; |
| 7 | import * as Provider from "../Provider.ts"; |
| 8 | import { Resource } from "../Resource.ts"; |
| 9 | import type { CertificateAuthority } from "./CertificateAuthority.ts"; |
| 10 | import { accountLayer, type AccountCredentials } from "./Client.ts"; |
| 11 | import type { KeyAlgorithm } from "./Pki.ts"; |
| 12 | import type { Providers } from "./Providers.ts"; |
| 13 | |
| 14 | export interface ExternalAccountBinding { |
| 15 | /** The key id the CA issued (ZeroSSL: `eab_kid`). */ |
| 16 | keyId: string; |
| 17 | /** The base64url HMAC key the CA issued (ZeroSSL: `eab_hmac_key`). */ |
| 18 | hmacKey: Redacted.Redacted<string> | string; |
| 19 | } |
| 20 | |
| 21 | export interface AccountProps { |
| 22 | /** |
| 23 | * The certificate authority: a preset ({@link LetsEncrypt}, |
| 24 | * {@link ZeroSSL}, …) or `{ directoryUrl }`. Changing it replaces the |
| 25 | * account. |
| 26 | */ |
| 27 | ca: CertificateAuthority; |
| 28 | /** |
| 29 | * Contact URLs (`mailto:…`) the CA notifies about expiry and incidents. |
| 30 | * Updated in place. |
| 31 | */ |
| 32 | contact?: string[]; |
| 33 | /** |
| 34 | * You must agree to the CA's terms of service. Explicit on purpose — |
| 35 | * never defaulted. |
| 36 | */ |
| 37 | termsOfServiceAgreed: true; |
| 38 | /** |
| 39 | * External Account Binding for CAs that require one (ZeroSSL, Google |
| 40 | * Trust Services). Changing the key id replaces the account. |
| 41 | */ |
| 42 | eab?: ExternalAccountBinding; |
| 43 | /** |
| 44 | * Account key algorithm. Changing it replaces the account. |
| 45 | * @default "ES256" |
| 46 | */ |
| 47 | keyAlgorithm?: KeyAlgorithm; |
| 48 | } |
| 49 | |
| 50 | export interface Account extends Resource< |
| 51 | "ACME.Account", |
| 52 | AccountProps, |
| 53 | { |
| 54 | /** The CA's directory URL. */ |
| 55 | directoryUrl: string; |
| 56 | /** PEM root trusted for the CA's HTTPS endpoint (private CAs only). */ |
| 57 | trustedRoot: string | undefined; |
| 58 | /** The account URL the CA assigned — the `kid` on every later request. */ |
| 59 | accountUrl: string; |
| 60 | /** Observed account status (`valid`, `deactivated`, `revoked`). */ |
| 61 | status: string; |
| 62 | /** Observed contact URLs. */ |
| 63 | contact: string[]; |
| 64 | /** Account key algorithm. */ |
| 65 | keyAlgorithm: KeyAlgorithm; |
| 66 | /** The account's private JWK (JSON). Sensitive; persisted in stack state. */ |
| 67 | privateKey: Redacted.Redacted<string>; |
| 68 | }, |
| 69 | never, |
| 70 | Providers |
| 71 | > {} |
| 72 | |
| 73 | /** |
| 74 | * An ACME account with a certificate authority — the identity that every |
| 75 | * {@link Certificate} and every runtime {@link IssueCertificate} call |
| 76 | * signs with. One per CA per stack. |
| 77 | * |
| 78 | * The account key is generated on first reconcile and persisted as a |
| 79 | * redacted value alongside the CA's account URL. Protect the state store |
| 80 | * as secret material; redaction alone does not encrypt the storage backend. |
| 81 | * |
| 82 | * ### Let's Encrypt |
| 83 | * **Example:** An account with Let's Encrypt |
| 84 | * ```typescript |
| 85 | * export const LetsEncrypt = ACME.Account("LetsEncrypt", { |
| 86 | * ca: ACME.LetsEncrypt, |
| 87 | * contact: ["mailto:ops@example.com"], |
| 88 | * termsOfServiceAgreed: true, |
| 89 | * }); |
| 90 | * ``` |
| 91 | * |
| 92 | * ### External Account Binding |
| 93 | * ZeroSSL and Google Trust Services bind ACME accounts to an existing |
| 94 | * customer account with a key id + HMAC pair from their dashboard (ZeroSSL |
| 95 | * also mints them over its REST API — see `@distilled.cloud/zerossl`). |
| 96 | * |
| 97 | * **Example:** ZeroSSL |
| 98 | * ```typescript |
| 99 | * export const ZeroSsl = ACME.Account("ZeroSSL", { |
| 100 | * ca: ACME.ZeroSSL, |
| 101 | * eab: { |
| 102 | * keyId: Config.String("ZEROSSL_EAB_KID"), |
| 103 | * hmacKey: Config.Redacted("ZEROSSL_EAB_HMAC_KEY"), |
| 104 | * }, |
| 105 | * termsOfServiceAgreed: true, |
| 106 | * }); |
| 107 | * ``` |
| 108 | * |
| 109 | * ### Private CAs |
| 110 | * A private CA (Pebble, step-ca) whose HTTPS certificate is not publicly |
| 111 | * trusted passes its root as `trustedRoot`. |
| 112 | * |
| 113 | * **Example:** Pebble |
| 114 | * ```typescript |
| 115 | * export const Pebble = ACME.Account("Pebble", { |
| 116 | * ca: { directoryUrl: "https://localhost:14000/dir", trustedRoot: pebbleRootPem }, |
| 117 | * termsOfServiceAgreed: true, |
| 118 | * }); |
| 119 | * ``` |
| 120 | * |
| 121 | * @resource |
| 122 | * @product Account |
| 123 | * @category Certificates |
| 124 | */ |
| 125 | export const Account = Resource<Account>("ACME.Account"); |
| 126 | |
| 127 | /** The CA answered `newAccount` without a `Location`, so the account has no URL. */ |
| 128 | export class AccountUrlMissing extends Data.TaggedError( |
| 129 | "ACME.AccountUrlMissing", |
| 130 | )<{ |
| 131 | readonly directoryUrl: string; |
| 132 | }> {} |
| 133 | |
| 134 | /** |
| 135 | * Normalize to exactly one `Redacted` layer: a value that already came |
| 136 | * redacted (an SDK's sensitive field) wrapped again by the caller would |
| 137 | * otherwise sign with an object instead of the key. |
| 138 | */ |
| 139 | const unwrap = (value: Redacted.Redacted<string> | string) => { |
| 140 | let inner: unknown = value; |
| 141 | while (Redacted.isRedacted(inner)) inner = Redacted.value(inner); |
| 142 | return Redacted.make(String(inner)); |
| 143 | }; |
| 144 | |
| 145 | const credentialsOf = ( |
| 146 | props: Pick<AccountProps, "ca" | "eab">, |
| 147 | accountKey: Redacted.Redacted<string>, |
| 148 | accountUrl: string | undefined, |
| 149 | ): AccountCredentials => ({ |
| 150 | ca: props.ca, |
| 151 | accountKey, |
| 152 | accountUrl, |
| 153 | externalAccountBinding: |
| 154 | props.eab === undefined |
| 155 | ? undefined |
| 156 | : { keyId: props.eab.keyId, hmacKey: unwrap(props.eab.hmacKey) }, |
| 157 | }); |
| 158 | |
| 159 | /** `newAccount` with `onlyReturnExisting` — the account, or `undefined`. */ |
| 160 | const observeAccount = (credentials: AccountCredentials) => |
| 161 | acme.newAccount({ onlyReturnExisting: true }).pipe( |
| 162 | Effect.catchTag("AcmeAccountDoesNotExist", () => Effect.succeed(undefined)), |
| 163 | Effect.provide(accountLayer(credentials)), |
| 164 | ); |
| 165 | |
| 166 | const sameContacts = ( |
| 167 | a: ReadonlyArray<string> | undefined, |
| 168 | b: ReadonlyArray<string> | undefined, |
| 169 | ) => { |
| 170 | const left = [...(a ?? [])].sort(); |
| 171 | const right = [...(b ?? [])].sort(); |
| 172 | return left.length === right.length && left.every((v, i) => v === right[i]); |
| 173 | }; |
| 174 | |
| 175 | export const AccountProvider = () => |
| 176 | Provider.succeed(Account, { |
| 177 | stables: ["directoryUrl", "accountUrl", "keyAlgorithm", "privateKey"], |
| 178 | |
| 179 | diff: Effect.fn(function* ({ news, olds, output }) { |
| 180 | if (news === undefined || !isResolved(news) || output === undefined) { |
| 181 | return undefined; |
| 182 | } |
| 183 | if ( |
| 184 | news.ca.directoryUrl !== output.directoryUrl || |
| 185 | (news.keyAlgorithm ?? "ES256") !== output.keyAlgorithm || |
| 186 | news.eab?.keyId !== olds?.eab?.keyId |
| 187 | ) { |
| 188 | return { action: "replace" as const }; |
| 189 | } |
| 190 | return undefined; |
| 191 | }), |
| 192 | |
| 193 | read: Effect.fn(function* ({ olds, output }) { |
| 194 | if (output === undefined || olds === undefined) return undefined; |
| 195 | const credentials = credentialsOf( |
| 196 | olds, |
| 197 | output.privateKey, |
| 198 | output.accountUrl, |
| 199 | ); |
| 200 | const existing = yield* observeAccount(credentials); |
| 201 | if (existing === undefined) return undefined; |
| 202 | return { |
| 203 | ...output, |
| 204 | status: existing.status, |
| 205 | contact: existing.contact ?? [], |
| 206 | }; |
| 207 | }), |
| 208 | |
| 209 | reconcile: Effect.fn(function* ({ news, output }) { |
| 210 | const keyAlgorithm = news.keyAlgorithm ?? "ES256"; |
| 211 | // Observe: the key is the identity; keep it across reconciles. |
| 212 | const accountKey = |
| 213 | output?.privateKey ?? |
| 214 | (yield* Acme.Jose.generateAccountKey(keyAlgorithm)); |
| 215 | const credentials = credentialsOf(news, accountKey, output?.accountUrl); |
| 216 | let account = yield* observeAccount(credentials); |
| 217 | |
| 218 | // Repeating newAccount with the same key returns the existing account. |
| 219 | if (account === undefined) { |
| 220 | account = yield* acme |
| 221 | .newAccount({ |
| 222 | contact: news.contact, |
| 223 | termsOfServiceAgreed: true, |
| 224 | }) |
| 225 | .pipe(Effect.provide(accountLayer(credentials))); |
| 226 | } |
| 227 | const accountUrl = account.location ?? output?.accountUrl; |
| 228 | if (accountUrl === undefined) { |
| 229 | return yield* new AccountUrlMissing({ |
| 230 | directoryUrl: news.ca.directoryUrl, |
| 231 | }); |
| 232 | } |
| 233 | |
| 234 | // Sync: contacts are the only mutable aspect. |
| 235 | const withKid = accountLayer(credentialsOf(news, accountKey, accountUrl)); |
| 236 | if (!sameContacts(news.contact, account.contact)) { |
| 237 | account = yield* acme |
| 238 | .updateAccount({ url: accountUrl, contact: news.contact ?? [] }) |
| 239 | .pipe(Effect.provide(withKid)); |
| 240 | } |
| 241 | |
| 242 | return { |
| 243 | directoryUrl: news.ca.directoryUrl, |
| 244 | trustedRoot: news.ca.trustedRoot, |
| 245 | accountUrl, |
| 246 | status: account.status, |
| 247 | contact: account.contact ?? [], |
| 248 | keyAlgorithm, |
| 249 | privateKey: accountKey, |
| 250 | }; |
| 251 | }), |
| 252 | |
| 253 | delete: Effect.fn(function* ({ olds, output }) { |
| 254 | const credentials = credentialsOf( |
| 255 | olds, |
| 256 | output.privateKey, |
| 257 | output.accountUrl, |
| 258 | ); |
| 259 | // Deactivation is permanent and idempotent: a deactivated account |
| 260 | // answers `unauthorized`, a purged one `accountDoesNotExist`. |
| 261 | yield* acme |
| 262 | .updateAccount({ url: output.accountUrl, status: "deactivated" }) |
| 263 | .pipe( |
| 264 | Effect.catchTag(["AcmeAccountDoesNotExist", "AcmeUnauthorized"], () => |
| 265 | Effect.succeed(undefined), |
| 266 | ), |
| 267 | Effect.provide(accountLayer(credentials)), |
| 268 | ); |
| 269 | }), |
| 270 | }); |