import { type ACMClient, type CertificateDetail, type CertificateSummary, DeleteCertificateCommand, DescribeCertificateCommand, ExportCertificateCommand, GetCertificateCommand, ImportCertificateCommand, type KeyAlgorithm, ListCertificatesCommand, RequestCertificateCommand, RevokeCertificateCommand, } from '@aws-sdk/client-acm' import { awsCertificatesSchema, CERTIFICATE_DOMAIN_PATTERN, CERTIFICATE_KEY_ALGORITHMS, CERTIFICATE_REVOCATION_REASONS, CERTIFICATE_VALIDATION_METHODS, } from '../cloud-spi/certificatesSchema' import {RuntimeError, ValidationError} from '../cloud-spi/errors' import type { CertificatePem, CertificateRevocationReason, CloudResource, CloudServiceAdapter, CreateResourceInput, ExportedCertificate, ResourceQuery, ServiceSchema, } from '../cloud-spi/types' /** * ListCertificates returns only RSA certificates unless asked for other key * types, so a list without this would silently leave out every EC certificate. */ const ALL_KEY_TYPES: KeyAlgorithm[] = ['RSA_1024', 'RSA_2048', 'RSA_3072', 'RSA_4096', 'EC_prime256v1', 'EC_secp384r1', 'EC_secp521r1'] /** ACM's limit on names per certificate, the domain name included. */ const MAX_NAMES = 100 const CERTIFICATE_TYPE_LABEL: Record = {AMAZON_ISSUED: 'Amazon issued', IMPORTED: 'Imported', PRIVATE: 'Private'} /** * Talks to AWS Certificate Manager, verified against Floci core (community, `latest`). * * Notes from probing the runtime: * - Requested certificates are signed by the runtime's own local CA and issued at * once (its validation wait defaults to 0), but DescribeCertificate still lists * the CNAME records DNS validation would ask for, as AWS does. * - DeleteCertificate refuses with ResourceInUseException while a load balancer, * distribution or API domain uses the certificate, as on AWS. * * Resources are keyed by the certificate's UUID, the last part of its ARN, because * an ARN's slash cannot travel in a URL path segment; the ARN is rebuilt from the * account and region this adapter serves. */ export class AwsCertificatesAdapter implements CloudServiceAdapter { readonly cloud = 'aws' as const readonly service = 'certificates' as const constructor( private readonly acm: ACMClient, private readonly accountId: string, private readonly region = process.env.AWS_REGION || 'us-east-1', ) {} schema(): ServiceSchema { return awsCertificatesSchema() } async list(query: ResourceQuery = {}): Promise { const summaries: CertificateSummary[] = [] let next: string | undefined do { const res = await this.acm.send(new ListCertificatesCommand({Includes: {keyTypes: ALL_KEY_TYPES}, MaxItems: 1000, ...(next ? {NextToken: next} : {})})) summaries.push(...(res.CertificateSummaryList ?? [])) next = res.NextToken } while (next) const search = query.search?.trim().toLowerCase() return summaries.map((summary) => this.fromSummary(summary)).filter((certificate) => !search || certificate.name.toLowerCase().includes(search) || certificate.id.includes(search) || (certificate.metadata.subjectAlternativeNames as string[]).some((name) => name.toLowerCase().includes(search))) } async get(id: string): Promise { try { const res = await this.acm.send(new DescribeCertificateCommand({CertificateArn: this.arn(id)})) return res.Certificate ? this.fromDetail(res.Certificate) : null } catch (error) { if (errorName(error) === 'ResourceNotFoundException') return null throw error } } async create(input: CreateResourceInput): Promise { const source = input.values.source ?? 'request' const arn = source === 'import' ? await this.importCertificate(input.values) : source === 'request' ? await this.requestCertificate(input.values) : null if (arn === null) throw new ValidationError('source must be request or import') const created = await this.get(idFromArn(arn)) if (!created) throw new RuntimeError(`ACM did not return the new certificate ${arn}`) return created } async delete(id: string): Promise { await this.acm.send(new DeleteCertificateCommand({CertificateArn: this.arn(id)})) } async getCertificatePem(id: string): Promise { const res = await this.acm.send(new GetCertificateCommand({CertificateArn: this.arn(id)})) if (!res.Certificate) throw new RuntimeError('ACM did not return the certificate') return {certificate: res.Certificate, certificateChain: res.CertificateChain ?? null} } /** * AWS exports only private certificates and public ones requested as * exportable; the private key comes back encrypted with the passphrase. */ async exportCertificate(id: string, passphrase: string): Promise { if (typeof passphrase !== 'string' || passphrase.length < 4 || passphrase.length > 128) { throw new ValidationError('passphrase must be 4 to 128 characters') } if (/[#$%]/.test(passphrase)) throw new ValidationError('passphrase must not contain #, $ or %') const res = await this.acm.send(new ExportCertificateCommand({CertificateArn: this.arn(id), Passphrase: new TextEncoder().encode(passphrase)})) if (!res.Certificate || !res.PrivateKey) throw new RuntimeError('ACM did not return the exported certificate') return {certificate: res.Certificate, certificateChain: res.CertificateChain ?? null, privateKey: res.PrivateKey} } async revokeCertificate(id: string, reason: CertificateRevocationReason): Promise { if (!(CERTIFICATE_REVOCATION_REASONS as readonly string[]).includes(reason)) { throw new ValidationError(`reason must be one of ${CERTIFICATE_REVOCATION_REASONS.join(', ')}`) } await this.acm.send(new RevokeCertificateCommand({CertificateArn: this.arn(id), RevocationReason: reason})) const revoked = await this.get(id) if (!revoked) throw new RuntimeError(`ACM did not return the revoked certificate ${id}`) return revoked } private async requestCertificate(values: Record): Promise { const domainName = text(values.domainName).toLowerCase() if (!domainName) throw new ValidationError('domainName is required') const pattern = new RegExp(CERTIFICATE_DOMAIN_PATTERN) if (!pattern.test(domainName)) throw new ValidationError('domainName must be a domain name such as example.com or *.example.com') const alternatives = [...new Set(text(values.subjectAlternativeNames).split(/[\s,]+/).map((name) => name.toLowerCase()).filter((name) => name && name !== domainName))] const invalid = alternatives.find((name) => !pattern.test(name)) if (invalid) throw new ValidationError(`${invalid} is not a domain name`) if (alternatives.length + 1 > MAX_NAMES) throw new ValidationError(`A certificate covers at most ${MAX_NAMES} names`) const validationMethod = oneOf(values.validationMethod, CERTIFICATE_VALIDATION_METHODS, 'validationMethod') const keyAlgorithm = oneOf(values.keyAlgorithm, CERTIFICATE_KEY_ALGORITHMS, 'keyAlgorithm') const exportOption = oneOf(values.export, ['DISABLED', 'ENABLED'] as const, 'export') const res = await this.acm.send(new RequestCertificateCommand({ DomainName: domainName, ...(alternatives.length > 0 ? {SubjectAlternativeNames: [domainName, ...alternatives]} : {}), ValidationMethod: validationMethod, KeyAlgorithm: keyAlgorithm, Options: {Export: exportOption}, })) if (!res.CertificateArn) throw new RuntimeError('ACM did not return the requested certificate') return res.CertificateArn } private async importCertificate(values: Record): Promise { const certificate = pem(values.certificate, 'certificate', 'CERTIFICATE') const privateKey = pem(values.privateKey, 'privateKey', 'PRIVATE KEY') const chain = text(values.certificateChain) if (chain) pem(chain, 'certificateChain', 'CERTIFICATE') const encode = (value: string) => new TextEncoder().encode(value) const res = await this.acm.send(new ImportCertificateCommand({ Certificate: encode(certificate), PrivateKey: encode(privateKey), ...(chain ? {CertificateChain: encode(chain)} : {}), })) if (!res.CertificateArn) throw new RuntimeError('ACM did not return the imported certificate') return res.CertificateArn } private arn(id: string): string { if (!/^[0-9a-f-]{36}$/i.test(id)) throw new ValidationError('certificate id must be the UUID at the end of its ARN') return `arn:aws:acm:${this.region}:${this.accountId}:certificate/${id}` } private fromSummary(summary: CertificateSummary): CloudResource { const arn = summary.CertificateArn ?? '' return { id: idFromArn(arn), name: summary.DomainName ?? '', cloud: 'aws', service: 'certificates', type: 'certificate', region: arn.split(':')[3] || this.region, createdAt: iso(summary.CreatedAt ?? summary.ImportedAt), status: summary.Status ?? null, metadata: { arn, certificateType: CERTIFICATE_TYPE_LABEL[summary.Type ?? ''] ?? summary.Type ?? null, inUse: summary.InUse ? 'Yes' : 'No', keyAlgorithm: summary.KeyAlgorithm ?? null, notAfter: iso(summary.NotAfter), subjectAlternativeNames: summary.SubjectAlternativeNameSummaries ?? [], }, } } private fromDetail(detail: CertificateDetail): CloudResource { const arn = detail.CertificateArn ?? '' const inUseBy = detail.InUseBy ?? [] return { id: idFromArn(arn), name: detail.DomainName ?? '', cloud: 'aws', service: 'certificates', type: 'certificate', region: arn.split(':')[3] || this.region, createdAt: iso(detail.CreatedAt ?? detail.ImportedAt), status: detail.Status ?? null, metadata: { arn, certificateType: CERTIFICATE_TYPE_LABEL[detail.Type ?? ''] ?? detail.Type ?? null, inUse: inUseBy.length > 0 ? 'Yes' : 'No', inUseBy, keyAlgorithm: detail.KeyAlgorithm ?? null, signatureAlgorithm: detail.SignatureAlgorithm ?? null, notBefore: iso(detail.NotBefore), notAfter: iso(detail.NotAfter), issuedAt: iso(detail.IssuedAt), importedAt: iso(detail.ImportedAt), revokedAt: iso(detail.RevokedAt), revocationReason: detail.RevocationReason ?? null, failureReason: detail.FailureReason ?? null, subject: detail.Subject ?? null, issuer: detail.Issuer ?? null, serial: detail.Serial ?? null, subjectAlternativeNames: detail.SubjectAlternativeNames ?? [], exportable: detail.Options?.Export === 'ENABLED', transparencyLogging: detail.Options?.CertificateTransparencyLoggingPreference ?? null, renewalEligibility: detail.RenewalEligibility ?? null, domainValidations: (detail.DomainValidationOptions ?? []).map((validation) => ({ domainName: validation.DomainName ?? '', status: validation.ValidationStatus ?? null, method: validation.ValidationMethod ?? null, record: validation.ResourceRecord ? {name: validation.ResourceRecord.Name ?? '', type: validation.ResourceRecord.Type ?? 'CNAME', value: validation.ResourceRecord.Value ?? ''} : null, })), }, } } } /** `arn:aws:acm:us-east-1:000000000000:certificate/` — the UUID is what the console keys on. */ function idFromArn(arn: string): string { return arn.slice(arn.lastIndexOf('/') + 1) } function iso(value: Date | undefined): string | null { return value ? value.toISOString() : null } function text(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } function pem(value: unknown, field: string, label: string): string { const body = text(value) if (!body) throw new ValidationError(`${field} is required`) if (!new RegExp(`-----BEGIN [A-Z ]*${label}-----`).test(body)) { throw new ValidationError(`${field} must be PEM text starting with -----BEGIN ${label}-----`) } return body } function oneOf(value: unknown, allowed: T, field: string): T[number] { const raw = text(value) if (!raw) return allowed[0] as T[number] if (!allowed.includes(raw)) throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`) return raw as T[number] } function errorName(error: unknown): string | undefined { return typeof error === 'object' && error !== null ? (error as {name?: string}).name : undefined }