CertMate Client Certificates - API Reference
September 9, 2026 · View on GitHub
Overview
The CertMate Client Certificates API provides REST endpoints for complete certificate management with authentication, rate limiting, and audit logging.
Base URL: http://localhost:8000
Every path below is absolute and starts with
/api. It used to be a mix: thirteen paths written relative to a base ending in/api, and eight written with the prefix — so against the stated base one set resolved to/api/api/.... The four translations had lost the base-URL line entirely, which left their relative paths with nothing to resolve against at all. Authentication: Bearer Token (required on all endpoints) Content-Type:application/json
Authentication
All API endpoints require Bearer token authentication.
Header Format
Authorization: Bearer YOUR_TOKEN
Example Request
curl -X GET http://localhost:8000/api/client-certs \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json"
Rate Limiting
API endpoints have rate limits to prevent abuse:
| Endpoint | Limit | Per |
|---|---|---|
| General | 100 | minute |
| Create Certificate | 30 | minute |
| Batch Operations | 10 | minute |
| OCSP Status | 200 | minute |
| CRL Download | 60 | minute |
| Per-IP ceiling | 600 | minute |
The working bucket is per API key (requests authenticated with the same bearer key share one limit) and per IP for session/anonymous requests, so several clients behind one NAT or proxy do not share — and abuse — a single bucket.
Every /api/ request is also counted against a coarse per-IP ceiling, checked first. It sits far above the working limits, so a normal client never meets it; it exists because a bucket keyed only on the caller-supplied bearer token can be reset at will by changing the token, which previously made the per-key limits — and the protection on the unauthenticated OCSP and CRL endpoints — bypassable.
Configuring rate limits
The limits are configurable per instance (admin only), so a trusted automation fleet behind a single address can raise them instead of tripping the default. Settings → API Keys → API Rate Limits exposes a value-per-endpoint form and an on/off toggle; changes apply immediately, with no restart.
The same configuration is available over the API:
GET /api/settings/rate-limits
-> { "enabled": true,
"limits": { "default": 100, "certificate_create": 30, ... },
"defaults": { ... } }
PUT /api/settings/rate-limits
{ "enabled": true, "limits": { "certificate_create": 500 } }
Each limit is requests per minute (1–100000). Only the endpoint keys returned by GET are accepted; omitted keys keep their default. Setting "enabled": false turns API rate limiting off entirely (the login endpoint keeps its own separate limiter regardless).
Rate Limit Response
When rate limited, you'll receive:
HTTP 429 Too Many Requests
{
"error": "Rate limit exceeded",
"message": "Too many requests. Please try again later.",
"retry_after": 60
}
Endpoints
Certificate Management
1. Create Certificate
Endpoint: POST /api/client-certs/create
Create a new client certificate.
Request:
{
"common_name": "user@example.com",
"email": "user@example.com",
"organization": "ACME Corp",
"organizational_unit": "Engineering",
"cert_usage": "api-mtls",
"days_valid": 365,
"generate_key": true,
"notes": "Production certificate"
}
Parameters:
common_name(required) - Certificate subjectemail(optional) - Email addressorganization(optional) - Organization nameorganizational_unit(optional) - Department namecert_usage(optional) - Usage type:api-mtls,vpn, or customdays_valid(optional) - Validity in days (default: 365)generate_key(optional) - Generate private key (default: true)notes(optional) - Additional notes
Response (201 Created):
{
"identifier": "cert-abc123",
"common_name": "user@example.com",
"serial_number": "12345678901234567890",
"created_at": "2024-10-30T18:00:00Z",
"expires_at": "2025-10-30T18:00:00Z",
"cert_usage": "api-mtls",
"status": "active"
}
Example:
curl -X POST http://localhost:8000/api/client-certs/create \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{
"common_name": "user@example.com",
"email": "user@example.com",
"organization": "ACME Corp",
"cert_usage": "api-mtls",
"days_valid": 365
}'
2. List Certificates
Endpoint: GET /api/client-certs
List all client certificates with optional filtering.
Query Parameters:
usage(optional) - Filter by usage type (e.g.,api-mtls)revoked(optional) - Filter by status (trueorfalse)search(optional) - Search in common name
Response (200 OK):
{
"certificates": [{
"identifier": "cert-001",
"common_name": "user1@example.com",
"organization": "ACME Corp",
"cert_usage": "api-mtls",
"created_at": "2024-10-30T18:00:00Z",
"expires_at": "2025-10-30T18:00:00Z",
"revoked": false,
"status": "active"
},
{
"identifier": "cert-002",
"common_name": "user2@example.com",
"organization": "ACME Corp",
"cert_usage": "vpn",
"created_at": "2024-10-29T18:00:00Z",
"expires_at": "2025-10-29T18:00:00Z",
"revoked": true,
"status": "revoked"
}
],
"total": 2
}
Examples:
# List all certificates
curl http://localhost:8000/api/client-certs \
-H "Authorization: Bearer TOKEN"
# Filter by usage type
curl "http://localhost:8000/api/client-certs?usage=api-mtls" \
-H "Authorization: Bearer TOKEN"
# List only revoked
curl "http://localhost:8000/api/client-certs?revoked=true" \
-H "Authorization: Bearer TOKEN"
# Search by common name
curl "http://localhost:8000/api/client-certs?search=user1" \
-H "Authorization: Bearer TOKEN"
3. Get Certificate Details
Endpoint: GET /api/client-certs/<identifier>
Get complete metadata for a certificate.
Response (200 OK):
{
"type": "client_certificate",
"identifier": "cert-001",
"common_name": "user@example.com",
"email": "user@example.com",
"organization": "ACME Corp",
"organizational_unit": "Engineering",
"serial_number": "12345678901234567890",
"created_at": "2024-10-30T18:00:00Z",
"expires_at": "2025-10-30T18:00:00Z",
"cert_usage": "api-mtls",
"notes": "Production certificate",
"revocation": {
"revoked": false,
"revoked_at": null,
"reason_revoked": null
},
"renewal": {
"renewal_enabled": true,
"renewal_threshold_days": 30
}
}
Example:
curl http://localhost:8000/api/client-certs/cert-001 \
-H "Authorization: Bearer TOKEN"
4. Download Certificate Files
Endpoint: GET /api/client-certs/<identifier>/download/<type>
Download certificate, private key, or CSR file.
Parameters:
identifier- Certificate IDtype- File type:crt,key,csr, orpfx(encrypted PKCS#12; requires a PFX password set in Settings, operator role)
Response (200 OK):
- Content-Type:
application/octet-stream - File attachment with proper naming
Examples:
# Download certificate
curl http://localhost:8000/api/client-certs/cert-001/download/crt \
-H "Authorization: Bearer TOKEN" \
-o certificate.crt
# Download private key
curl http://localhost:8000/api/client-certs/cert-001/download/key \
-H "Authorization: Bearer TOKEN" \
-o private.key
# Download CSR
curl http://localhost:8000/api/client-certs/cert-001/download/csr \
-H "Authorization: Bearer TOKEN" \
-o request.csr
5. Revoke Certificate
Endpoint: POST /api/client-certs/<identifier>/revoke
Revoke a certificate with optional reason.
Request (optional):
{
"reason": "compromised"
}
Response (200 OK):
{
"message": "Certificate revoked: cert-001",
"revoked_at": "2024-10-30T18:15:00Z",
"reason": "compromised"
}
Example:
curl -X POST http://localhost:8000/api/client-certs/cert-001/revoke \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{
"reason": "compromised"
}'
6. Renew Certificate
Endpoint: POST /api/client-certs/<identifier>/renew
Renew a certificate (same CN, new serial).
Response (201 Created):
{
"identifier": "cert-001-renewed",
"common_name": "user@example.com",
"serial_number": "98765432109876543210",
"created_at": "2024-10-30T18:20:00Z",
"expires_at": "2025-10-30T18:20:00Z",
"status": "active"
}
Example:
curl -X POST http://localhost:8000/api/client-certs/cert-001/renew \
-H "Authorization: Bearer TOKEN"
7. Get Statistics
Endpoint: GET /api/client-certs/stats
Get certificate usage statistics.
Response (200 OK):
{
"total": 100,
"active": 85,
"revoked": 15,
"expiring_soon": 8,
"by_usage": {
"api-mtls": 60,
"vpn": 35,
"other": 5
},
"created_count": 100,
"renewal_enabled": 92
}
Example:
curl http://localhost:8000/api/client-certs/stats \
-H "Authorization: Bearer TOKEN"
8. Batch Import Certificates
Endpoint: POST /api/client-certs/batch
Create multiple certificates from CSV data in single request.
Request:
{
"headers": ["common_name", "email", "organization", "cert_usage", "days_valid"],
"rows": [["user1@example.com", "user1@example.com", "ACME Corp", "api-mtls", "365"],
["user2@example.com", "user2@example.com", "ACME Corp", "vpn", "365"],
["user3@example.com", "user3@example.com", "ACME Corp", "api-mtls", "365"]
]
}
Response (201 Created):
{
"total": 3,
"successful": 3,
"failed": 0,
"errors": [],
"certificates": [{
"identifier": "cert-batch-001",
"common_name": "user1@example.com"
},
{
"identifier": "cert-batch-002",
"common_name": "user2@example.com"
},
{
"identifier": "cert-batch-003",
"common_name": "user3@example.com"
}
]
}
Example:
curl -X POST http://localhost:8000/api/client-certs/batch \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{
"headers": ["common_name", "email", "organization"],
"rows": [["user1@example.com", "user1@example.com", "ACME Corp"],
["user2@example.com", "user2@example.com", "ACME Corp"]
]
}'
OCSP & CRL
9. OCSP Status Query
Endpoint: GET /api/ocsp/status/<serial_number>
Query certificate status via OCSP.
Response (200 OK):
{
"response_status": "successful",
"certificate_status": "good|revoked|unknown",
"certificate_serial": 12345678,
"this_update": "2024-10-30T18:00:00Z",
"next_update": null,
"responder_name": "CertMate OCSP Responder"
}
Example:
curl http://localhost:8000/api/ocsp/status/12345678 \
-H "Authorization: Bearer TOKEN"
10. CRL Distribution
Endpoint: GET /api/crl/download/<format_type>
Download Certificate Revocation List.
Parameters:
format_type-pem,der, orinfo
Response:
- For
pemandder: File attachment - For
info: JSON with CRL metadata
Examples:
# Download CRL in PEM format
curl http://localhost:8000/api/crl/download/pem \
-H "Authorization: Bearer TOKEN" \
-o ca.crl
# Download CRL in DER format
curl http://localhost:8000/api/crl/download/der \
-H "Authorization: Bearer TOKEN" \
-o ca.crl
# Get CRL info
curl http://localhost:8000/api/crl/download/info \
-H "Authorization: Bearer TOKEN"
CRL Info Response:
{
"status": "available",
"issuer": "CN=CertMate CA, O=CertMate",
"last_update": "2024-10-30T18:00:00Z",
"next_update": "2024-10-31T18:00:00Z",
"revoked_count": 5,
"revoked_serials": [12345678,
87654321
]
}
11. Download Domain Certificate Files
Endpoint: GET /api/certificates/<domain>/download
Download certificate files for a specific domain. By default, this endpoint returns a ZIP archive containing all certificate components. A specific file can be requested using the file query parameter. JSON mode is also available for automation that wants all PEMs in one response.
Parameters:
domain(Path) - The domain name associated with the certificate.file(Query, Optional) - Specify a single file to download.- Supported values:
fullchain.pem,privkey.pem,combined.pem
- Supported values:
format(Query, Optional) - Set tojsonto return all certificate files in a JSON object.key_format(Query, Optional) -pkcs1orpkcs8. Certbot writes PKCS#8 (BEGIN PRIVATE KEY); some older stacks require the legacy traditional form. Valid withfile=privkey.pem(serves the converted key) or withformat=json(adds a converted copy to the response).
Response (200 OK):
- Default:
application/zip(A ZIP file containing all PEM files) - With
fileparam:application/x-pem-file(The raw content of the requested file) - With
format=json:application/jsonwithdomain,cert_pem,chain_pem,fullchain_pem, andprivate_key_pem - With
format=json&key_format=pkcs1: the above plusprivate_key_pkcs1_pem
The JSON form is the preferred automation shape for Ansible, Salt, or any other client that wants to write PEM files directly.
key_format=pkcs1 on the JSON form adds private_key_pkcs1_pem and leaves private_key_pem untouched, so an existing consumer is unaffected and a client needing the legacy key no longer has to make a second call and stage it through a file. The field is named for the encoding rather than for RSA: the traditional form of an ECDSA key is SEC1 (BEGIN EC PRIVATE KEY), and CertMate issues ECDSA by default. Key types with no traditional encoding (Ed25519) return 422.
Examples:
# Download all files as a ZIP archive
curl http://localhost:8000/api/certificates/example.com/download \
-H "Authorization: Bearer TOKEN" \
-o example_com_bundle.zip
# Download only the fullchain.pem file
curl "http://localhost:8000/api/certificates/example.com/download?file=fullchain.pem" \
-H "Authorization: Bearer TOKEN" \
-o fullchain.pem
# Download only the private key
curl "http://localhost:8000/api/certificates/example.com/download?file=privkey.pem" \
-H "Authorization: Bearer TOKEN" \
-o privkey.pem
# Download the full certificate bundle as JSON
curl "http://localhost:8000/api/certificates/example.com/download?format=json" \
-H "Authorization: Bearer TOKEN" \
-o example_com_bundle.json
12. Reissue Domain Certificate (edit configuration)
Endpoint: POST /api/certificates/<domain>/reissue
Edit a certificate's configuration and reissue it in place — extend or drop SAN entries without delete + recreate. Omitted fields keep the values the certificate was issued with (read from its metadata), so DNS/alias/CA configuration never needs re-entering. The current certificate keeps being served until the reissue succeeds. The key shape is preserved unless explicitly changed (no key flags are sent and certbot keeps the lineage key).
Request Body (all fields optional):
{
"san_domains": ["www.example.com", "api.example.com"],
"domain_alias": "",
"async": true
}
san_domains: replacement SAN set — omit to keep,[]to drop every SANdomain_alias: omit to keep,""to cleardns_provider,account_id,ca_provider,challenge_type: omit to keepkey_type/key_size/elliptic_curve: omit to keep the existing key shapeasync: defer issuance to a background job (202 + job id, pollGET /api/certificates/jobs/<job_id>)
Response (200 OK, or 202 Accepted with async): message, domain, dns_provider, ca_provider, duration.
Errors: 404 when no certificate exists for the domain (use create), 403 scope, 400 validation, 409 operation in progress, 422 certbot failure (the previous certificate is still in place).
Example:
curl -X POST http://localhost:8000/api/certificates/example.com/reissue \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"san_domains": ["www.example.com", "api.example.com"]}'
Log stream (admin, debugging)
GET /api/web/logs/stream
Server-Sent Events tail of the application log file, for watching an issuance or a deployment live from a terminal.
Requires file logging to be on. By default CertMate logs to stdout only —
what docker logs and every log shipper expect — so this endpoint reports
"Log file not found" until you set CERTMATE_LOG_FILE
(e.g. CERTMATE_LOG_FILE=/app/logs/certmate.log). The file is rotated
automatically; see the environment table in the README.
curl -N -H "Authorization: Bearer TOKEN" \
https://certmate.example.com/api/web/logs/stream
Admin-only, because application logs can contain credentials. Only lines
written after the connection opens are sent — this is a tail, not a history
download. The stream emits a : keepalive comment while idle and closes after
30 idle minutes; an EventSource client reconnects on its own, a curl
session has to be restarted.
Error Handling
Error Response Format
Every failure carries a human-readable error and a machine-readable code:
{
"error": "Certificate not found for domain: example.com",
"code": "CERTIFICATE_NOT_FOUND"
}
code is always a string. Failures raised by the HTTP layer rather than by
the application — an unmatched path, a wrong method, a body over the size limit
— carry two more fields, message (the framework's description) and status
(the numeric status, which is also the status line):
{
"error": "Not Found",
"message": "The requested URL was not found on the server.",
"code": "NOT_FOUND",
"status": 404
}
Until the contract version moved to 2.0, code on that second shape was
the status integer while every application error used a string, so a client
could not branch on the field without checking its type first. It is one type
now, and the number a caller may have been reading is in status on those same
responses. The version is on every response as X-CertMate-API-Version; it is
2.1 since the async issuance endpoints gained ISSUANCE_QUEUE_FULL.
Codes
Branch on these rather than on the message text, which is written for people and may be reworded.
| Code | Typical status | Means |
|---|---|---|
CERTIFICATE_NOT_FOUND | 404 | No certificate for that domain on this instance |
CERT_FILE_NOT_FOUND | 404 | The certificate exists but the requested file does not |
JOB_NOT_FOUND | 404 | Unknown async issuance job id |
DOMAIN_REQUIRED | 400 | The request named no domain |
INVALID_REQUEST / INVALID_FORMAT | 400 | The body failed validation |
INVALID_FILE / INVALID_FILE_TYPE / INVALID_PATH | 400 | Bad file argument |
INVALID_KEY_FORMAT / KEY_FORMAT_NOT_APPLICABLE / KEY_CONVERSION_FAILED | 400/422 | Key export could not be produced in the requested form |
AUTO_RENEW_FLAG_REQUIRED | 400 | enabled missing from an auto-renew update |
INCOMPATIBLE_PARAMETERS | 400 | Two request fields contradict each other |
AUTH_HEADER_MISSING / INVALID_AUTH_FORMAT / INVALID_AUTH_SCHEME / INVALID_TOKEN / AUTH_ERROR | 401 | Authentication failed, and which part |
SESSION_REQUIRED | 401 | The endpoint needs a browser session, not a bearer token |
INSUFFICIENT_ROLE | 403 | Authenticated, but the role is too low |
DOMAIN_OUT_OF_SCOPE | 403 | The API key is scoped to other domains |
PRIVKEY_REQUIRES_OPERATOR | 403 | Private-key download needs operator or above |
CERTIFICATE_ALREADY_EXISTS | 409 | A certificate for that domain is already managed |
DOMAIN_OPERATION_IN_PROGRESS | 409 | Another create/renew holds this domain's lock |
METADATA_SCHEMA_DOWNGRADE | 409 | metadata.json was written by a newer build; the write was refused |
DOMAIN_NOT_IN_SETTINGS | 409 | The certificate exists on disk but no settings entry names it |
ACME_RATE_LIMITED | 422 | The CA refused because a rate limit was reached — waiting is the fix, retrying is the cause |
CERTIFICATE_CREATION_FAILED / CERTIFICATE_REISSUE_FAILED / CERTIFICATE_REISSUE_REJECTED | 422 | Issuance was attempted and refused |
RENEWAL_CONFIG_BROKEN | 422 | certbot's renewal config for this lineage no longer resolves; reissue |
DNS_ACCOUNT_NOT_CONFIGURED | 422 | The DNS account this certificate uses is gone from settings |
ISSUANCE_QUEUE_FULL | 429 | Too much async issuance is already queued or running; the body carries the depth and the limit |
ADOPTION_UNAVAILABLE | 503 | Discovery/adoption is not available on this build |
ASYNC_ISSUANCE_DISABLED | 503 | Async issuance is switched off |
CERTIFICATE_CREATION_ERROR / CERTIFICATE_RENEWAL_ERROR / CERTIFICATE_REISSUE_ERROR / CERTIFICATE_DOWNLOAD_ERROR / AUTO_RENEW_UPDATE_FAILED | 500 | The operation failed unexpectedly; the server log has the cause |
INTERNAL_SERVER_ERROR | 500 | An exception escaped a handler |
NOT_FOUND, METHOD_NOT_ALLOWED, REQUEST_ENTITY_TOO_LARGE, … | 4xx | Refused by the HTTP layer; the symbol is the status name |
Common HTTP Status Codes
| Code | Meaning | Example |
|---|---|---|
| 200 | Success | Certificate listed |
| 201 | Created | Certificate created |
| 400 | Bad Request | Missing required field |
| 401 | Unauthorized | Invalid/missing token |
| 403 | Forbidden | Role or domain scope |
| 404 | Not Found | Certificate doesn't exist |
| 409 | Conflict | Operation already running |
| 422 | Unprocessable | Issuance refused by the CA |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Server Error | Internal error |
| 503 | Service Unavailable | OCSP/CRL not available |
Example Error
curl http://localhost:8000/api/client-certs/invalid-id \
-H "Authorization: Bearer TOKEN"
# Response
{
"error": "Certificate not found: invalid-id",
"message": "Certificate not found: invalid-id",
"code": "NOT_FOUND",
"status": 404
}
Audit Logging
Certificate-lifecycle operations and configuration/access-control changes are recorded to an audit log. This includes the security-relevant lifecycle paths — successful and failed create, renew, reissue, deploy, and auto-renew toggles, plus unattended (scheduler-driven) renewals — each attributed to the actor that performed it and the trigger that caused it.
Log format
The audit log is written to logs/audit/certificate_audit.log. Each line is a
standard Python log line whose message is the JSON audit entry:
2026-06-15 18:00:00 - certmate.audit - INFO - {"timestamp": "...", ...}
To recover the JSON, split each line on the literal - INFO - and parse the
remainder. Note two time bases: the line prefix timestamp is local server
time, while the JSON timestamp field is UTC (ISO-8601). Read it live with:
tail -f logs/audit/certificate_audit.log
Entry shape
{
"timestamp": "2026-06-15T18:00:00.000000+00:00",
"operation": "renew",
"resource_type": "certificate",
"resource_id": "api.example.com",
"status": "success",
"user": "api_key:renew-bot",
"ip_address": "10.0.0.9",
"details": {"force": false},
"error": null,
"actor": {
"kind": "agent",
"id": "9f2c…",
"label": "api_key:renew-bot",
"token_prefix": "cm_1a2b",
"agent_session": "sess-9f2"
},
"trigger": {"cause": "agent"}
}
actor.kind—user(a human session / OIDC login),api_token(an API key or the legacy global bearer token),agent(an API key explicitly flagged as an AI/MCP agent — see below),scheduler(an unattended renewal job), orsystem. It is derived only from the authenticated identity.actor.id/token_prefix— the stable API key id and token prefix behind the action (absent for the legacy global bearer token, which cannot be told apart per-caller — prefer scoped keys).actor.agent_session/agent_id— the values of the client-suppliedX-CertMate-Agent-Session/X-CertMate-Agent-Idheaders (the MCP server sends them). These are an informational claim only: they are recorded for correlation but never changeactor.kind, so a non-agent caller cannot forge anagentattribution.trigger.cause—manual,api,agent,scheduled_renewal, orevent; for scheduled renewalstrigger.job_idnames the job.
To have an agent's actions recorded as actor.kind="agent", create a scoped API
key with is_agent: true (a checkbox on Settings → API Keys, or is_agent in
POST /api/keys) and point the MCP server at it. See the MCP guide.
Reading the audit log over the API
GET /api/activity?limit=N returns the most recent entries (admin/viewer,
bounded to 500).
Tamper-evidence (hash chain)
Alongside the human-readable log, every entry is appended to a tamper-evident
SHA-256 hash chain at data/audit/certificate_audit.chain.jsonl. Each record
is {seq, entry, prev_hash, hash} where hash commits to the entry and the
previous record's hash, and seq is a gap-free counter — so any modification,
deletion, or reorder by anyone who cannot recompute the whole chain is
detectable and localizable. It is on by default; disable with
CERTMATE_AUDIT_CHAIN=0.
Verify from the API: GET /api/audit/verify (admin) returns the verifier
result and HTTP 200 when intact or 409 when broken:
{"ok": true, "count": 128, "first_seq": 0, "last_seq": 127, "head_hash": "5ee1…", "reason": "intact"}
Verify off-box: the standalone verifier depends only on the Python standard library, so an auditor can run it without installing or trusting CertMate:
python -m modules.core.audit_verify data/audit/certificate_audit.chain.jsonl
# OK: audit chain intact (128 entries, seq 0..127)
# or: FAIL: audit chain broken at seq 42: hash mismatch at seq 42: entry was modified
Exit code 0 intact, 1 broken (with the offending seq and reason), 2
missing/unreadable.
Signed export bundle (third-party verifiable)
The instance holds an Ed25519 signing key, persisted at data/.audit_signing_key
(generated on first run, 0600; override with AUDIT_SIGNING_KEY_FILE to hold
it off-box). Its public identity is exposed at GET /api/audit/public-key
(admin): {algorithm, public_key_pem, fingerprint}. The chain head is signed
into periodic checkpoints (certificate_audit.checkpoints.jsonl).
GET /api/audit/export (admin, optional ?from_seq/?to_seq) returns a signed,
self-verifying bundle — {manifest, entries, bundle_signature}. The manifest
pins the instance fingerprint, public key, seq range and head_hash; the
signature is over the canonical manifest, which (via head_hash) transitively
commits to every entry. An auditor verifies it off the box without running
or trusting CertMate, optionally pinning the key out of band:
python -m modules.core.audit_verify --bundle bundle.json --pubkey instance.pem
# OK: audit bundle intact and signed (128 entries, seq 0..127; signed by 0m2V5lDmnkPWOUHX)
The verifier checks the chain structure, that the manifest matches the entries, the Ed25519 signature, and that the fingerprint matches the (optionally pinned) public key.
Partial slices. A full export starts at the genesis and is
format_version: 1. A slice that starts mid-chain (?from_seq=N past the first
entry) is format_version: 2 and additionally carries anchor_prev_hash /
anchor_seq in the manifest — the predecessor hash its first entry continues
from — so the fragment can be verified even though it has no genesis. The anchor
is inside the signed manifest, so the signature attests it. The verifier reports
such a bundle as a partial slice and names the anchor seq: it proves the
entries from the anchor forward are authentic and ordered, and proves nothing
about what came before. Verifiers older than v2.23.0 report unsupported bundle format_version 2 for an anchored slice; full exports remain byte-compatible
with them.
Threat-model honesty. The chain + signature detect any interior modification, deletion, or reorder, and tie an export to this instance's public key — for anyone who does not hold the signing key. They do not bind the operator, who holds the key and could re-sign a rewritten chain, and tail truncation is only caught by comparing exports over time (a later export with fewer entries) or against an externally held checkpoint. Fully constraining the operator requires shipping the signed checkpoints to an external append-only sink — opt-in external anchoring, a planned follow-up not yet shipped. See compliance.md.
Certificate Types
API mTLS
For API client authentication via mutual TLS.
cert_usage: "api-mtls"
VPN
For VPN client authentication.
cert_usage: "vpn"
Custom Usage Types
You can use any custom usage type string:
cert_usage: "custom-application"
Best Practices
Security
- Protect Your Token
- Keep tokens secret
- Rotate tokens regularly
- Use HTTPS in production
- Certificate Management
- Enable auto-renewal
- Monitor expiration dates
- Review audit logs regularly
- Revoke compromised certs immediately
- Rate Limiting
- Respect rate limits
- Implement exponential backoff
- Batch operations when possible
Performance
- Use Batch Operations
- Import multiple certs at once
- Reduces API calls
- Better error reporting
- Filter Results
- Use query parameters
- Filter by usage or status
- Reduces data transfer
- Cache When Appropriate
- Cache certificate metadata
- Refresh periodically
- Check expiration locally