6e0bb98844
CI / checks (pull_request) Successful in 27m28s
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.
291 lines
13 KiB
TypeScript
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
|
|
}
|