Skip to content
Merged
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ OpenAPI is generated at `/openapi.json`; Swagger UI is available at `/docs`.
| `POST` | `/v1/certificates/{id}/authorize` | operator | Authorize or retry DNS Persist issuance after publishing TXT |
| `POST` | `/v1/certificates/{id}/release` | operator | Release a held DNS Persist revision; requires `Idempotency-Key` and `{ "revision": n }` |
| `POST` | `/v1/certificates/{id}/renew` | operator | Queue manual renewal |
| `POST` | `/v1/certificates/{id}/revoke` | operator | Revoke the issued primary domain through acme.sh; requires `Idempotency-Key` |
| `DELETE` | `/v1/certificates/{id}` | operator | Soft-delete as revoked |
| `GET` | `/v1/accounts` | readonly | List configured ACME accounts |
| `GET` | `/v1/providers` | readonly | List configured DNS providers |
Expand All @@ -195,6 +196,35 @@ curl \
http://localhost:8080/v1/certificates
```

### Certificate revocation

`DELETE /v1/certificates/{id}` only changes the local request record; it does
not contact a certificate authority. To revoke an issued certificate at its
configured CA, call `POST /v1/certificates/{id}/revoke` with an
`Idempotency-Key` header and, optionally, an RFC 5280 reason:

```sh
curl -X POST \
-H "Authorization: Bearer $ACME_API_KEY" \
-H "Idempotency-Key: revoke-example-20260724" \
-H "Content-Type: application/json" \
--data '{"reason": 1}' \
http://localhost:8080/v1/certificates/$CERTIFICATE_ID/revoke
```

The operation invokes acme.sh as `--revoke --domain <primary-domain>` and adds
`--revoke-reason` when requested. It does not delete deployed artifacts,
disable renewal, or otherwise modify the local certificate record. Reusing the
same key returns the durable original result without another acme.sh command.
Reasons `0` through `10` are accepted except `7`, which RFC 5280 leaves unused.

acme.sh selects the certificate it revokes from its managed domain and key-type
slot; it does not accept a certificate file, serial number, fingerprint, or
deployment generation. Generation selection only repoints acme.api's deployed
artifact view and does not modify acme.sh's managed certificate. Consequently,
CA revocation through this endpoint cannot target a retained historical
generation independently of the certificate currently managed by acme.sh.

## Certificate Deployment

Successful issuance and renewal deploy artifacts under the `deployment_directory`
Expand Down
29 changes: 29 additions & 0 deletions acme_api/backend/acmesh_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ async def issue_certificate(
*chain_args_for(domains),
]

key_algorithm = str(challenge_params.get("key_algorithm", "ecdsa"))
if key_algorithm != "ecdsa":
args += ["--keylength", key_algorithm.removeprefix("rsa-")]
if server_url is not None:
# Without an explicit server acme.sh falls back to its default CA,
# silently ignoring the account's configured directory URL.
Expand Down Expand Up @@ -238,6 +241,32 @@ async def renew_certificate(
domains=domains,
)

async def revoke_certificate(
self,
domain: str,
*,
reason: int | None = None,
key_algorithm: str = "ecdsa",
account_key_path: str | None = None,
server_url: str | None = None,
) -> None:
"""Revoke acme.sh's certificate for ``domain`` using the configured account."""
args = ["--revoke", "--domain", domain]
Comment thread
streaky marked this conversation as resolved.
Comment thread
streaky marked this conversation as resolved.
if key_algorithm == "ecdsa":
args.append("--ecc")
if reason is not None:
args += ["--revoke-reason", str(reason)]
if server_url is not None:
args += ["--server", server_url]
if account_key_path is not None:
args += ["--accountkey-file", account_key_path]
try:
await self._run(args)
except TerminalAcmeShError as exc:
error_text = f"{exc.stderr}\n{exc}".lower()
if "already revoked" not in error_text and "alreadyrevoked" not in error_text:
raise

async def get_certificate_expiry(self, cert_path: str) -> CertExpiry:
args = [
"--in",
Expand Down
12 changes: 12 additions & 0 deletions acme_api/backend/mock_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ async def renew_certificate(
domains=domains,
)

async def revoke_certificate(
self,
domain: str,
*,
reason: int | None = None,
key_algorithm: str = "ecdsa",
account_key_path: str | None = None,
server_url: str | None = None,
) -> None:
"""Accept a deterministic revocation without contacting an ACME server."""
del domain, reason, key_algorithm, account_key_path, server_url

async def get_certificate_expiry(self, cert_path: str) -> CertExpiry:
"""Return a predictable expiry for the supplied certificate path."""
cert = self._cert_expiry("example.com")
Expand Down
10 changes: 10 additions & 0 deletions acme_api/backend/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,14 @@ async def renew_certificate(
force_renewal: bool = False,
) -> IssuanceResult: ...

async def revoke_certificate(
self,
domain: str,
*,
reason: int | None = None,
key_algorithm: str = "ecdsa",
account_key_path: str | None = None,
server_url: str | None = None,
) -> None: ...

async def get_certificate_expiry(self, cert_path: str) -> CertExpiry: ...
3 changes: 3 additions & 0 deletions acme_api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from acme_api.models.api_key import APIKey, APIKeyRole
from acme_api.models.base import Base, TimestampMixin
from acme_api.models.certificate import Certificate, CertificateStatus
from acme_api.models.certificate_revocation import CertificateRevocation, CertificateRevocationStatus
from acme_api.models.event import Event
from acme_api.models.renewal_attempt import RenewalAttempt
from acme_api.models.webhook import WebhookConfig
Expand All @@ -28,6 +29,8 @@
"Base",
"Certificate",
"CertificateStatus",
"CertificateRevocation",
"CertificateRevocationStatus",
"Event",
"RenewalAttempt",
"TimestampMixin",
Expand Down
6 changes: 6 additions & 0 deletions acme_api/models/certificate.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,9 @@ def deployment_directory(self) -> str:
lazy="selectin",
doc="Records of each renewal attempt made against this certificate.",
)
revocations = relationship(
"acme_api.models.certificate_revocation.CertificateRevocation",
back_populates="certificate",
lazy="selectin",
doc="Domain-based ACME revocation requests for this certificate.",
)
58 changes: 58 additions & 0 deletions acme_api/models/certificate_revocation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Durable records for domain-based ACME certificate revocations."""

from __future__ import annotations

import datetime as _dt
import enum
import uuid as _uuid

from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column, relationship

from acme_api.models.base import Base


class CertificateRevocationStatus(enum.StrEnum):
"""Terminal and in-flight states for an acme.sh revocation command."""

PENDING = "pending"
SUCCEEDED = "succeeded"
FAILED = "failed"


class CertificateRevocation(Base):
"""One idempotent, domain-based revocation request for a certificate."""

__tablename__ = "certificate_revocations"
__table_args__ = (
UniqueConstraint(
"certificate_id",
"idempotency_key",
name="uq_certificate_revocations_certificate_idempotency_key",
),
)

id: Mapped[_uuid.UUID] = mapped_column(primary_key=True, default=_uuid.uuid4)
certificate_id: Mapped[_uuid.UUID] = mapped_column(ForeignKey("certificates.id"), index=True, nullable=False)
domain: Mapped[str] = mapped_column(String(253), nullable=False)
reason: Mapped[int | None] = mapped_column(Integer, nullable=True)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
actor: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[CertificateRevocationStatus] = mapped_column(
Enum(CertificateRevocationStatus),
default=CertificateRevocationStatus.PENDING,
nullable=False,
)
error_category: Mapped[str | None] = mapped_column(String(32), nullable=True)
error_details: Mapped[str | None] = mapped_column(String(2048), nullable=True)
created_at: Mapped[_dt.datetime] = mapped_column(
DateTime(timezone=True), server_default=text("CURRENT_TIMESTAMP"), nullable=False
)
attempt_started_at: Mapped[_dt.datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[_dt.datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)

certificate = relationship(
"acme_api.models.certificate.Certificate",
back_populates="revocations",
lazy="selectin",
)
40 changes: 40 additions & 0 deletions acme_api/routers/certificates.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,19 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from acme_api.auth.hash import AuthenticatedUser
from acme_api.auth.rbac import require_operator, require_readonly
from acme_api.db import get_db_session
from acme_api.deployer import DeploymentError
from acme_api.models.certificate import Certificate, CertificateStatus
from acme_api.models.certificate_revocation import CertificateRevocation
from acme_api.schemas.certificate import (
CertificateCreate,
CertificateGenerationSelect,
CertificateRead,
CertificateRelease,
CertificateRevocationCreate,
CertificateRevocationRead,
)
from acme_api.services.certificate_contracts import (
CertificateBackendUnavailableError,
Expand All @@ -36,6 +40,7 @@
CertificateNotFoundError,
CertificateNotRenewableError,
)
from acme_api.services.certificate_revocations import request_certificate_revocation
from acme_api.services.certificates import CertificateLifecycleService

router = APIRouter(prefix="/v1/certificates", tags=["Certificates"])
Expand Down Expand Up @@ -228,6 +233,41 @@ async def select_certificate_generation(
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc


@router.post(
"/{certificate_id}/revoke",
response_model=CertificateRevocationRead,
summary="Revoke a certificate at its ACME certificate authority",
description=(
"Revokes the certificate selected by acme.sh's managed primary-domain and key-type slot. "
"It cannot select an acme.api deployment generation or historical artifact."
),
responses={
**_NOT_FOUND_RESPONSE,
409: {"description": "Certificate is unissued or request conflicts."},
},
)
async def revoke_certificate_at_ca(
certificate_id: uuid.UUID,
request: Request,
payload: CertificateRevocationCreate | None = None,
idempotency_key: str = Header(alias="Idempotency-Key", min_length=1, max_length=255),
actor: AuthenticatedUser = Depends(require_operator),
) -> CertificateRevocation:
"""Revoke the managed certificate's primary domain through acme.sh."""
try:
return await request_certificate_revocation(
_certificate_service(request),
certificate_id,
reason=payload.reason if payload is not None else None,
idempotency_key=idempotency_key,
actor=actor.name,
)
except CertificateNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except CertificateLifecycleError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc


@router.delete(
"/{certificate_id}",
status_code=status.HTTP_204_NO_CONTENT,
Expand Down
31 changes: 31 additions & 0 deletions acme_api/schemas/certificate.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,37 @@ class CertificateRelease(BaseModel):
revision: int = Field(ge=1)


class CertificateRevocationCreate(BaseModel):
"""Optional standardized reason for an acme.sh domain revocation."""

reason: int | None = Field(default=None, ge=0, le=10)

@field_validator("reason")
@classmethod
def _reject_unused_reason(cls, reason: int | None) -> int | None:
"""Reject the unused RFC 5280 reason-code value."""
if reason == 7:
raise ValueError("Revocation reason code 7 is unused.")
return reason


class CertificateRevocationRead(BaseModel):
"""Safe persisted state for a certificate revocation request."""

model_config = ConfigDict(from_attributes=True)

id: uuid.UUID
certificate_id: uuid.UUID
domain: str
reason: int | None = None
actor: str | None = None
status: Literal["pending", "succeeded", "failed"]
error_category: Literal["transient", "terminal"] | None = None
error_details: str | None = None
created_at: datetime
completed_at: datetime | None = None


class DnsPersistChallenge(BaseModel):
"""One-time account-bound TXT record required by DNS Persist mode."""

Expand Down
Loading
Loading