Files
floci-ui/packages/api/src/adapter-aws/AwsCertificatesAdapter.ts
T
Zakaria 6e0bb98844
CI / checks (pull_request) Successful in 27m28s
feat(api): add ACM certificates to the cloud explorer
Request public certificates (optionally exportable) or import your own, see
each domain's DNS validation records and what uses a certificate, download the
certificate and chain, export an exportable one with its encrypted key, and
revoke it. ListCertificates asks for every key type, since AWS otherwise
leaves EC certificates out.
2026-10-25 12:41:42 -04:00

291 lines
13 KiB
TypeScript

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<string, string> = {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<CloudResource[]> {
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<CloudResource | null> {
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<CloudResource> {
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<void> {
await this.acm.send(new DeleteCertificateCommand({CertificateArn: this.arn(id)}))
}
async getCertificatePem(id: string): Promise<CertificatePem> {
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<ExportedCertificate> {
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<CloudResource> {
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<string, unknown>): Promise<string> {
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<string, unknown>): Promise<string> {
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/<uuid>` — 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<T extends readonly string[]>(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
}