feat(api): add ACM certificates to the cloud explorer
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.
This commit is contained in:
Zakaria
2026-10-25 12:41:42 -04:00
parent d4726950da
commit 6e0bb98844
23 changed files with 1116 additions and 1 deletions
@@ -0,0 +1,164 @@
import {describe, expect, test} from 'bun:test'
import {
type ACMClient,
DeleteCertificateCommand,
DescribeCertificateCommand,
ExportCertificateCommand,
GetCertificateCommand,
ImportCertificateCommand,
ListCertificatesCommand,
RequestCertificateCommand,
RevokeCertificateCommand,
} from '@aws-sdk/client-acm'
import {AwsCertificatesAdapter} from './AwsCertificatesAdapter'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
function stubAcm(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as ACMClient
return {client, sent}
}
function awsError(name: string, status = 400): Error {
return Object.assign(new Error(name), {name, $metadata: {httpStatusCode: status}})
}
const ID = '5ed9dc37-d9fb-456e-911e-e87f091480ba'
const ARN = `arn:aws:acm:us-east-1:000000000000:certificate/${ID}`
const detail = {
CertificateArn: ARN,
DomainName: 'notes.thecloud.localhost',
SubjectAlternativeNames: ['notes.thecloud.localhost', 'api.thecloud.localhost'],
DomainValidationOptions: [{
DomainName: 'notes.thecloud.localhost',
ValidationStatus: 'SUCCESS',
ValidationMethod: 'DNS',
ResourceRecord: {Name: '_a1.notes.thecloud.localhost.', Type: 'CNAME', Value: '_e8.acm-validations.aws.'},
}],
Status: 'ISSUED',
Type: 'AMAZON_ISSUED',
KeyAlgorithm: 'RSA-2048',
Issuer: 'CN=Floci Local CA',
CreatedAt: new Date('2026-10-25T16:28:12Z'),
NotAfter: new Date('2027-10-25T16:28:12Z'),
InUseBy: ['arn:aws:elasticloadbalancing:us-east-1:000000000000:loadbalancer/app/web/1'],
Options: {Export: 'ENABLED', CertificateTransparencyLoggingPreference: 'ENABLED'},
}
function adapterWith(handler: (command: object) => SendResult | Promise<SendResult>) {
const stub = stubAcm(handler)
return {...stub, adapter: new AwsCertificatesAdapter(stub.client, '000000000000', 'us-east-1')}
}
describe('AwsCertificatesAdapter', () => {
test('lists every key type across pages and searches the alternative names too', async () => {
const {adapter, sent} = adapterWith((command) => {
if (!(command instanceof ListCertificatesCommand)) return {}
return command.input.NextToken
? {CertificateSummaryList: [{CertificateArn: ARN.replace(ID, '11111111-2222-3333-4444-555555555555'), DomainName: 'shop.test', Type: 'IMPORTED', Status: 'ISSUED', InUse: false}]}
: {CertificateSummaryList: [{CertificateArn: ARN, DomainName: 'notes.thecloud.localhost', SubjectAlternativeNameSummaries: ['notes.thecloud.localhost', 'api.thecloud.localhost'], Type: 'AMAZON_ISSUED', Status: 'ISSUED', KeyAlgorithm: 'EC-prime256v1', InUse: true}], NextToken: 'page-2'}
})
const all = await adapter.list()
expect(all.map((certificate) => [certificate.id, certificate.name, certificate.metadata.certificateType, certificate.metadata.inUse])).toEqual([
[ID, 'notes.thecloud.localhost', 'Amazon issued', 'Yes'],
['11111111-2222-3333-4444-555555555555', 'shop.test', 'Imported', 'No'],
])
// Without Includes, AWS leaves EC certificates out of the list.
expect((sent[0] as ListCertificatesCommand).input.Includes?.keyTypes).toContain('EC_prime256v1')
expect((await adapter.list({search: 'api.thecloud'})).map((certificate) => certificate.id)).toEqual([ID])
})
test('describes a certificate with its users and the DNS records validation asks for', async () => {
const {adapter, sent} = adapterWith(() => ({Certificate: detail}))
const certificate = await adapter.get(ID)
expect((sent[0] as DescribeCertificateCommand).input.CertificateArn).toBe(ARN)
expect(certificate?.metadata).toMatchObject({
inUse: 'Yes',
inUseBy: detail.InUseBy,
exportable: true,
issuer: 'CN=Floci Local CA',
notAfter: '2027-10-25T16:28:12.000Z',
domainValidations: [{domainName: 'notes.thecloud.localhost', status: 'SUCCESS', method: 'DNS', record: {name: '_a1.notes.thecloud.localhost.', type: 'CNAME', value: '_e8.acm-validations.aws.'}}],
})
})
test('a missing certificate is null, and an id that is not a UUID never reaches ACM', async () => {
const {adapter, sent} = adapterWith(() => { throw awsError('ResourceNotFoundException') })
expect(await adapter.get(ID)).toBeNull()
await expect(adapter.get('../other')).rejects.toBeInstanceOf(ValidationError)
expect(sent).toHaveLength(1)
})
test('requests a certificate for the domain and its extra names', async () => {
const {adapter, sent} = adapterWith((command) => command instanceof RequestCertificateCommand ? {CertificateArn: ARN} : {Certificate: detail})
const created = await adapter.create({values: {source: 'request', domainName: 'Notes.TheCloud.localhost', subjectAlternativeNames: 'api.thecloud.localhost\nnotes.thecloud.localhost\n*.thecloud.localhost', keyAlgorithm: 'EC_prime256v1', export: 'ENABLED'}})
expect(created.id).toBe(ID)
expect((sent[0] as RequestCertificateCommand).input).toEqual({
DomainName: 'notes.thecloud.localhost',
SubjectAlternativeNames: ['notes.thecloud.localhost', 'api.thecloud.localhost', '*.thecloud.localhost'],
ValidationMethod: 'DNS',
KeyAlgorithm: 'EC_prime256v1',
Options: {Export: 'ENABLED'},
})
})
test('refuses a request ACM would reject before calling it', async () => {
const {adapter, sent} = adapterWith(() => ({}))
await expect(adapter.create({values: {source: 'request', domainName: 'not a domain'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {source: 'request', domainName: 'a.test', subjectAlternativeNames: 'b..test'}})).rejects.toThrow('b..test is not a domain name')
await expect(adapter.create({values: {source: 'request', domainName: 'a.test', keyAlgorithm: 'RSA_8192'}})).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.create({values: {source: 'copy', domainName: 'a.test'}})).rejects.toBeInstanceOf(ValidationError)
expect(sent).toHaveLength(0)
})
test('imports PEM text as bytes, with the chain when given', async () => {
const {adapter, sent} = adapterWith((command) => command instanceof ImportCertificateCommand ? {CertificateArn: ARN} : {Certificate: {...detail, Type: 'IMPORTED'}})
const certificate = '-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----'
const privateKey = '-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----'
const created = await adapter.create({values: {source: 'import', certificate, privateKey, certificateChain: certificate}})
expect(created.metadata.certificateType).toBe('Imported')
const input = (sent[0] as ImportCertificateCommand).input
expect(new TextDecoder().decode(input.Certificate)).toBe(certificate)
expect(new TextDecoder().decode(input.PrivateKey)).toBe(privateKey)
expect(input.CertificateChain).toBeDefined()
await expect(adapter.create({values: {source: 'import', certificate, privateKey: 'secret'}})).rejects.toThrow('privateKey must be PEM text')
})
test('downloads, exports and revokes by the rebuilt ARN', async () => {
const {adapter, sent} = adapterWith((command) => {
if (command instanceof GetCertificateCommand) return {Certificate: 'CERT', CertificateChain: 'CHAIN'}
if (command instanceof ExportCertificateCommand) return {Certificate: 'CERT', CertificateChain: 'CHAIN', PrivateKey: 'ENCRYPTED'}
if (command instanceof RevokeCertificateCommand) return {CertificateArn: ARN}
return {Certificate: {...detail, Status: 'REVOKED', RevocationReason: 'SUPERSEDED'}}
})
expect(await adapter.getCertificatePem(ID)).toEqual({certificate: 'CERT', certificateChain: 'CHAIN'})
expect(await adapter.exportCertificate(ID, 'open-sesame')).toEqual({certificate: 'CERT', certificateChain: 'CHAIN', privateKey: 'ENCRYPTED'})
expect(new TextDecoder().decode((sent[1] as ExportCertificateCommand).input.Passphrase)).toBe('open-sesame')
const revoked = await adapter.revokeCertificate(ID, 'SUPERSEDED')
expect(revoked.status).toBe('REVOKED')
expect((sent[2] as RevokeCertificateCommand).input).toEqual({CertificateArn: ARN, RevocationReason: 'SUPERSEDED'})
await expect(adapter.exportCertificate(ID, 'abc')).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.exportCertificate(ID, 'has$dollar')).rejects.toBeInstanceOf(ValidationError)
await expect(adapter.revokeCertificate(ID, 'BORED' as never)).rejects.toBeInstanceOf(ValidationError)
})
test('deletes by ARN and lets ACM refuse a certificate that is in use', async () => {
const {adapter, sent} = adapterWith(() => { throw awsError('ResourceInUseException') })
await expect(adapter.delete(ID)).rejects.toThrow('ResourceInUseException')
expect((sent[0] as DeleteCertificateCommand).input.CertificateArn).toBe(ARN)
})
})
@@ -0,0 +1,290 @@
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
}