Certificate Transparency (RFC 6962)¶
Secsy PKI can optionally submit issued certificates to Certificate Transparency (CT) logs and embed the resulting Signed Certificate Timestamps (SCTs) into the final certificate. Modern browsers require publicly-trusted TLS server certificates to carry SCTs, so this is needed for any CA whose leaves must be accepted by Chrome/Safari without a separate CT delivery mechanism (TLS extension or stapled OCSP).
CT is off by default and enabled per issuance profile, so you can run public, CT-logged TLS profiles alongside private profiles that never touch a log.
How it works¶
For a CT-enabled profile, issuance follows RFC 6962 §3:
- Precertificate. The certificate template is built with a critical
poison extension (
1.3.6.1.4.1.11129.2.4.3, valueNULL) and signed on the HSM. The poison guarantees the object can never be used as a real certificate. - Submission. The precertificate plus its issuer chain is submitted to each
configured log's
add-pre-chainendpoint. Each log returns an SCT. - Policy. The collected SCTs are counted against the profile's
min_scts; thefail_openflag decides what happens when the minimum is not met (see below). - Embedding & final signature. The SCTs are serialised into a
SignedCertificateTimestampListand embedded as the SCT list extension (1.3.6.1.4.1.11129.2.4.2, replacing the poison). The template — identical to the precertificate except for that one trailing extension — is re-signed on the HSM to produce the certificate returned to the caller.
Because the precertificate and the final certificate differ only in the
trailing poison↔SCT-list extension, the TBSCertificate a log signs over
(precertificate TBS with the poison removed) is byte-for-byte identical to what
a relying party reconstructs from the final certificate (final TBS with the SCT
list removed). This is what makes the embedded SCTs verify. Both signatures
happen on the HSM; the CA private key never leaves it.
The CA key signs precertificates directly (no delegated precertificate signing
certificate), so the issuer key hash in each SCT is the SHA-256 of the issuing
CA's SubjectPublicKeyInfo.
Configuring logs¶
Register the CT logs your profiles may use under certificate_transparency:
certificate_transparency:
# Optional: a Google/Chrome-style CT log-list v3 JSON document (a cached copy of
# log_list.json). Any log below whose `operator` is not set explicitly inherits
# it from this list by URL match — so operator-diversity policies work without
# hand-copying every operator name. Explicit `operator` values always win.
known_logs_file: /etc/secsy-pki/ct/log_list.json
logs:
- name: test-log
url: "https://ct.example.com/testlog"
operator: "Example Labs" # organization that runs the log (see below)
# Optional PEM SubjectPublicKeyInfo. When present, every SCT this log
# returns is cryptographically verified (signature + matching log id)
# before it is embedded. Strongly recommended.
public_key: |
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
- name: prod-log
url: "https://ct.googleapis.com/logs/us1/argon2025h1"
operator: "Google"
public_key_file: /etc/secsy-pki/ct/argon2025h1.pem
urlis the log's base URL; the/ct/v1/add-pre-chainpath is appended automatically.operatornames the organization that runs the log (e.g.Google,Cloudflare,DigiCert). CT operator-diversity policies count distinct operators, not distinct logs, so two logs run by the same operator satisfy a diversity requirement only once. Optional here — set it, or populate it in bulk fromknown_logs_file. See Operator diversity.- Supplying a log's public key (inline
public_keyorpublic_key_file) enables SCT signature verification: a returned SCT is only embedded if its signature validates against the log key and its log id matches. Without a key, SCTs are accepted on count alone — acceptable for a trusted internal test log, not for production. - Supported log key/signature algorithms: ECDSA-P256 and RSA, both with SHA-256 (the algorithms real CT logs use).
Enabling CT on a profile¶
Add a ct block to any custom profile:
profiles:
- name: server-ct
description: "TLS server certificate with Certificate Transparency"
key_usages: [digitalSignature, keyEncipherment]
ext_key_usages: [serverAuth]
default_validity_days: 90
ct:
enabled: true
logs: [test-log, prod-log] # empty = submit to every registered log
min_scts: 2 # minimum SCTs required (default: 1)
min_distinct_operators: 2 # minimum DISTINCT log operators (0 = off)
require_operators: [Google] # each listed operator must contribute an SCT
fail_open: false # see failure modes below
timeout_seconds: 5 # per-log attempt timeout
retries: 2 # extra attempts per log after the first
A profile that references an unknown log name, or enables CT when no logs are configured, is rejected at startup — misconfiguration fails loudly rather than silently issuing without CT.
Submissions to the selected logs run concurrently; each log gets up to
retries + 1 attempts, each bounded by timeout_seconds.
Failure modes¶
When the SCT policy is not met — fewer than min_scts usable SCTs, fewer than
min_distinct_operators distinct operators, or a require_operators entry with
no SCT (logs down, timing out, or returning SCTs that fail verification):
fail_open |
Behaviour |
|---|---|
false (default, fail-closed) |
Issuance is rejected. Use when a certificate is worthless to you without CT (public TLS). |
true (fail-open) |
Issuance proceeds, embedding whatever SCTs were obtained (possibly none). The certificate is marked failed_open. Use when availability matters more than guaranteed CT logging. |
Operator misconfiguration (an unknown log name) is always fatal regardless of
fail_open; the flag only covers log availability and diversity shortfalls.
Operator diversity¶
Modern CT policies (Chrome, Apple) require SCTs from a minimum number of distinct log operators, not merely a minimum SCT count. The reason is a threat model: a single log operator that is compromised or colludes can hand out SCTs that all trace back to one organization, so counting SCTs alone can be satisfied by one bad actor. Requiring SCTs from independent operators means no single operator can unilaterally fake the appearance of public logging.
Two per-profile knobs enforce this, layered on top of min_scts:
min_distinct_operators— the minimum number of distinct operators that must each contribute at least one usable SCT.0(default) disables the check.require_operators— an allowlist of operator names that must each be represented by a usable SCT (e.g.[Google, Apple]). Stricter than a bare count.
Each usable SCT is mapped to its log's configured operator; the number of
distinct operators is enforced alongside min_scts, honoring the same
fail_open semantics. The achieved operator count is recorded in the CT audit
detail (operators=N), the issuance response (ct.operators), and the
secsy_ct_distinct_operators metric.
Attributing logs to operators¶
- Set each log's
operatorexplicitly undercertificate_transparency.logs, or - point
certificate_transparency.known_logs_fileat a cached Google/Chrome CT log-list v3 JSON (log_list.json); any log without an explicitoperatorinherits it by URL match. Explicit values always win.
A log with no resolved operator cannot participate in a diversity policy: it
is counted as an independent operator (keyed by its own name) but can never
satisfy a require_operators entry, and — importantly — a profile that enables
min_distinct_operators/require_operators over any candidate log lacking an
operator is rejected at startup. This is deliberate: a diversity policy whose
logs are not all attributable to a known operator cannot be enforced meaningfully,
so the misconfiguration fails loudly rather than silently under-counting. The same
startup check rejects a policy that requires more operators than the candidate
logs can ever cover, or a require_operators name that runs none of them.
Note. Under fail-open, a set of SCTs that meets
min_sctsbut not the operator minimum is still embedded (the SCTs are real) — but the certificate is recorded asfailed_open, notsubmitted, so the shortfall is visible in the inventory, console, and reports.
Observing CT status¶
- API.
POST /api/ca/{id}/issueand/renewresponses include actobject (enabled,embedded,sct_count,status, per-loglogs). Stored certificates (GET /api/ca/{id}/certificates) carryct_status(none/submitted/failed_open),sct_count, andct_logs. See the OpenAPI spec. - Console. The certificate list shows a CT column: an
N SCTbadge for logged certificates (hover for the log names) or afail-openbadge, and the issuance form reports the CT outcome. - Audit log.
cert.issue/cert.renewevents record a CT summary in their detail (e.g.ct=enabled scts=2 operators=2 logs=2/2). - Metric.
secsy_ct_distinct_operatorsis a histogram of the distinct log-operator count observed per CT-enabled issuance (recorded even when the policy fails or ships fail-open). Alert on its lower quantiles falling toward 1 (histogram_quantile(0.1, ...), or a rising..._bucket{le="1"}): the live log set has degraded to a single operator even if the raw SCT count is still met.
You can confirm SCTs in an issued certificate with OpenSSL:
Testing with a mock or test log¶
The implementation is exercised end-to-end without a real log:
server/internal/ctunit tests stand up an in-process RFC 6962 log (httptest) that signs SCTs with an ECDSA key, then verify submission, multi-log fan-out, retries, SCT embedding, relying-party verification (reconstructing the TBS from the final certificate), and rejection of mismatched-key SCTs.server/internal/caissuance tests (build tagsqlite) issue real certificates under a CT-enabled profile against mock logs and assert SCT embedding, database round-trip of CT status, and policy enforcement (fail-closed rejects when the log is down; fail-open proceeds and is markedfailed_open).
Run them:
$ cd server
$ go test ./internal/ct/... # no HSM required
$ go test -tags sqlite ./internal/ca/ -run CT
To point at a real test log instead, register it under
certificate_transparency.logs with its public key and reference it from a
profile.
Inclusion-proof monitoring (post-issuance)¶
An SCT is only a promise: the log signs "I will merge this certificate into my Merkle tree within my Maximum Merge Delay (MMD)". A misbehaving or compromised log can hand out an SCT and then never include the certificate. Embedding SCTs proves the promise was made; it does not prove the promise was kept.
The optional inclusion monitor (a leader-elected background job, so it runs once across a multi-replica deployment) closes that gap. Once a certificate's SCT is older than the log's MMD, the monitor:
- fetches the log's signed tree head (
get-sth) and verifies its signature against the configured log public key; - requests a Merkle audit path (
get-proof-by-hash) for the certificate's leaf hash and verifies the inclusion proof reconstructs the signed tree-head root; - records the per-SCT outcome (
included/pending/failed/unknown_log) in thesct_inclusiontable; - alerts on any SCT a log failed to honor — a missing inclusion proof past
MMD is treated as log misbehavior / possible mis-issuance and raised on the
monitor's notification sinks (log/webhook) with a
secsy_ct_inclusion_failedmetric and act.inclusiondoctor finding.
Enabling it¶
The monitor requires each watched log to have a public key (it must verify
the signed tree head) and reuses the same certificate_transparency.logs
registry. Add an mmd_hours to each log (the deadline it advertises; default 24)
and an inclusion_monitor block:
certificate_transparency:
logs:
- name: prod-log
url: "https://ct.googleapis.com/logs/us1/argon2025h1"
public_key_file: /etc/secsy-pki/ct/argon2025h1.pem # REQUIRED for monitoring
mmd_hours: 24 # log's Maximum Merge Delay; misbehavior is only
# flagged once an SCT is older than this
inclusion_monitor:
enabled: true
interval_minutes: 60 # scan cadence (default 60); runs once on leadership gain
max_certs_per_run: 500 # oldest-unresolved certs processed per scan (default 500)
timeout_seconds: 15 # per get-sth / get-proof-by-hash request (default 15)
Enabling the monitor with no configured logs, or with a log missing a public key, is rejected at startup.
Observing inclusion¶
- CLI.
secsy-ca ct inclusion-statuslists the recorded state (filter with-status included|pending|failed|unknown_log,-ca/-serial,-limit,-json).secsy-ca ct verify-inclusiontriggers an on-demand scan now (-max,-json) instead of waiting for the background loop — useful after issuing a batch or when investigating an alert. - API / console.
GET /api/ct/inclusionreturns the state with per-status counts; the console CT page renders it as a filterable table (status badge, log name, tree size, leaf index), withfailedrows highlighted. - Doctor.
secsy-ca doctorruns act.inclusioncheck:FAILif any SCT is in thefailedstate (a log broke its MMD promise),WARNif the monitor is enabled but has verified nothing yet. - Metrics.
secsy_ct_inclusion_checks_total{result},secsy_ct_inclusion_pending,secsy_ct_inclusion_failed,secsy_ct_inclusion_monitor_runs_total{result}, andsecsy_ct_inclusion_monitor_staleness_seconds. Alert onsecsy_ct_inclusion_failed > 0(log misbehavior) and on rising staleness (the monitor stopped running — check leader election).
The operator response to a firing inclusion alert is in the runbook.
Notes & limitations¶
- CT applies to the X.509 leaf issuance path (
/api/ca/{id}/issue,/renew, and any profile-driven issuance such as ACME when the profile enables CT). It does not apply to CA certificates or SSH certificates. - SCTs are embedded in the certificate (the RFC 6962 §3.3 X.509v3 extension method). The TLS and OCSP-stapling SCT delivery methods are not used.
- Precertificates are signed directly by the CA key; delegated precertificate
signing certificates (
1.3.6.1.4.1.11129.2.4.4) are not required.