Skip to content

crl: Refuse to publish CRLs that are missing entries - #8984

Open
beautifulentropy wants to merge 5 commits into
mainfrom
crl-freshness-checks
Open

crl: Refuse to publish CRLs that are missing entries#8984
beautifulentropy wants to merge 5 commits into
mainfrom
crl-freshness-checks

Conversation

@beautifulentropy

@beautifulentropy beautifulentropy commented Aug 26, 2026

Copy link
Copy Markdown
Member
  • sa: Add GetLatestRevokedCertByShard and GetSerialsMetadata
  • database: Add index revokedCertificates for GetLatestRevokedCertByShard
  • crl-storer: Reject CRLs updates which drop entries before they expire
  • crl-updater: Verify db replica against the primary before signing

Fixes #6456


Notes on the new index

GetLatestRevokedCertByShard asks the primary database for the single most recent revocation in a shard, once per shard per update.

SELECT serial, revokedDate, revokedReason
FROM revokedCertificates
WHERE issuerID = ?
  AND shardIdx = ?
  AND notAfterHour >= ?
  AND revokedDate < ?
ORDER BY revokedDate DESC
LIMIT 1

The only existing index that covers this query's filters is (issuerID, shardIdx, notAfterHour), so without a new one that query reads every unexpired row in the shard and sorts them. With (issuerID, shardIdx, revokedDate) the database walks the index backwards and stops at the first row.

Testing: 500k and 5M synthetic revocations were loaded with and without the index applied (2 issuers, 128 shards, revocations spread evenly over 90 and 365 days, certificate lifetimes of 1 to 90 days). The following EXPLAIN query with handler counters was run:

FLUSH STATUS;

SELECT serial, revokedDate, revokedReason
FROM revokedCertificates
WHERE issuerID = 1
  AND shardIdx = 5
  AND notAfterHour >= '2026-08-24 00:00:00'
  AND revokedDate < '2026-08-25 14:55:00'
ORDER BY revokedDate DESC
LIMIT 1;

SHOW SESSION STATUS
WHERE Variable_name IN ('Handler_read_key', 'Handler_read_next',
                        'Handler_read_prev', 'Handler_read_rnd', 'Sort_rows');

Without the index the query scans every unexpired row in the shard and does a filesort: 1,928 rows in 5.6 ms at 500k, 4,884 rows in about 220 ms at 5M with a small buffer pool. With the new index it reads one entry (with no sort) in 0.2 ms and ~1 ms respectively. The index costs about 36 bytes per row, smaller than the existing notAfterHour index, and built online in under 6 seconds at 5M rows.

Deployment process

This change requires a two-phase deployment. As usual, deploying the code as-is changes nothing in production.

Phase 1: Apply the revokedCertificates index, and add crl-storer to the SA's sa.StorageAuthorityReadOnly allowlist. This phase alone does not change production behavior.

Phase 2: Set saReadOnlyService in the crl-storer config and checkFreshness: true in the crl-updater config.

@beautifulentropy
beautifulentropy force-pushed the crl-freshness-checks branch 2 times, most recently from cb9afec to d1ff4e8 Compare August 26, 2026 17:37
@beautifulentropy
beautifulentropy marked this pull request as ready for review August 26, 2026 21:07
@beautifulentropy
beautifulentropy requested a review from a team as a code owner August 26, 2026 21:07
@github-actions

Copy link
Copy Markdown
Contributor

@beautifulentropy, this PR appears to contain configuration and/or SQL schema changes. Please ensure that a corresponding deployment ticket has been filed with the new values.

Comment thread crl/storer/storer.go Outdated

@aarongable aarongable left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have only reviewed the CRL code (i.e. not the SA code), initial comments below.

Comment thread cmd/crl-storer/main.go Outdated
Comment thread crl/updater/continuous.go Outdated
Comment thread crl/updater/batch_test.go Outdated
Comment thread crl/updater/updater.go Outdated
Comment thread sa/sa.go
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, berrors.NotFoundError("no revoked certificates for issuer %d shard %d", req.IssuerNameID, req.ShardIdx)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this will prevent us from generating CRLs from issuers that haven't actually started issuing certs yet, which is part of our normal process for bringing new intermediates online.

@jsha jsha left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks great. The index makes sense to me. It will be high cardinality because of the date, but I think that's okay for this table.

Comment thread crl/updater/updater.go
Comment on lines +162 to +164
// checkFreshness returns an error unless the entries read from a replica
// include the primary's most recent revocation for the shard, or neither has
// any. If the primary in unavailable an error is returned.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API design quirk: "returns an error unless the entries read from a replica ..." - the property "these entries were read from a replica" is really a property of the caller, not this function. How about:

// checkFreshness queries the primary's GetLatestRevokedByShared with the given req,
// and errors if entries is doesn't contain the latest revoked serial for that shard, or if
// the primary says there are no revocations but entries is nonempty.

Comment thread cmd/crl-updater/main.go
//
// TODO(#8983): Default this field to 5 minutes once #8984 has been
// deployed to production and the freshness check is made unconditional.
ThisUpdateBackdate config.Duration `validate:"-"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mentioned at #6456 (comment): I think backdating CRLs is not allowed. Instead I think we can query GetLatestRevokedByShard with a RevokedBefore that is 5 minutes earlier than atTime (the value passed to GetRevokedCertsByShard).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's worse. Suppose a certificate is revoked at time T=0, expires at time T=+2m, we issue a CRL at time T=+4m, and we issue the next CRL at time T=+20m.

Under the backdating system, we don't include the new entry on the first CRL, because we're computing the set of revoked certs at time T=-1m. When we go to issue the second CRL, we ensure we include the cert because it hasn't appeared on a CRL whose thisUpdate (T=-1m) is after its expiry (T=+2m).

Under your proposed system, the first step is the same, except that the CRL's thisUpdate is T=+4m. But then when we go to issue the second CRL, we incorrectly omit the entry, because its expiry (T=+2m) is before the previous CRL's thisUpdate (T=+4m). This would be a violation of the compliance requirement that the cert appear on at least one CRL after it expires.

We could adjust that check by 5 minutes too, but now I'm getting concerned about correctness, especially if that 5-minute period ever gets changed between deploys.

I'm less convinced than you are that the BRs forbid backdating CRLs. I think that setting the thisUpdate to a time which the CRL doesn't actually represent would be bad, but setting to the time as which the set of revoked certificate entries is correct seems... correct. There will always pass some amount of time between when the thisUpdate field is set and when the CA signature is computed. Even if that's just a single second, the CRL is suddenly "backdated" per your interpretation of the BRs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Incorporate CRL diffing into crl-storer

3 participants