Skip to main content

acme_proxy_store/
revocation.rs

1//! The `revocations` table: what a local CA has revoked.
2//!
3//! One row per `(issuer, serial)`. The CRL is signed over these rows and stored
4//! in [`super::crl`]; `signer::local_ca` is the only writer of either. No
5//! foreign key to `orders`, for the reason the migration gives: a revocation
6//! must outlive an order an operator deletes.
7
8use tracing::{debug, info};
9
10use crate::db::Database;
11
12/// One revoked certificate.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Revocation {
15    /// The CA that issued it: hex SHA-256 of that CA's SubjectPublicKeyInfo.
16    pub issuer: String,
17    /// The certificate's serial, hex, as `orders.cert_serial` stores it.
18    pub serial: String,
19    /// Epoch seconds.
20    pub revoked_at: i64,
21    /// RFC 5280 §5.3.1 `CRLReason` code.
22    pub reason: Option<u32>,
23    /// The certificate's own `notAfter`, epoch seconds. `None` is never pruned.
24    pub not_after: Option<i64>,
25}
26
27impl Revocation {
28    /// Records the revocation unless this serial is already revoked under this
29    /// issuer, answering whether a row was written.
30    ///
31    /// The first revocation wins: a repeat changes neither its time nor its
32    /// reason, the rule the file-backed ledger's merge kept too.
33    pub async fn insert_if_absent<'e>(
34        &self,
35        executor: impl Into<crate::sql::Exec<'e>>,
36    ) -> Result<bool, sqlx::Error> {
37        let result = crate::sql::query(
38            "INSERT INTO revocations (issuer, serial, revoked_at, reason, not_after) \
39             VALUES (?, ?, ?, ?, ?) ON CONFLICT (issuer, serial) DO NOTHING;",
40        )
41        .bind(&self.issuer)
42        .bind(&self.serial)
43        .bind(self.revoked_at)
44        .bind(self.reason.map(i64::from))
45        .bind(self.not_after)
46        .execute(executor)
47        .await?;
48        let inserted = result.rows_affected() == 1;
49        debug!(
50            event = "db_revocation_recorded",
51            outcome = "success",
52            issuer = %self.issuer,
53            cert_serial = %self.serial,
54            inserted,
55        );
56        Ok(inserted)
57    }
58
59    /// Every revocation recorded under `issuer`, oldest first.
60    pub async fn list_for_issuer<'e>(
61        issuer: &str,
62        executor: impl Into<crate::sql::Exec<'e>>,
63    ) -> Result<Vec<Self>, sqlx::Error> {
64        let rows = crate::sql::query(
65            "SELECT issuer, serial, revoked_at, reason, not_after FROM revocations \
66             WHERE issuer = ? ORDER BY revoked_at, serial;",
67        )
68        .bind(issuer)
69        .fetch_all(executor)
70        .await?;
71        rows.iter()
72            .map(|row| {
73                let reason: Option<i64> = row.try_get("reason")?;
74                Ok(Self {
75                    issuer: row.try_get("issuer")?,
76                    serial: row.try_get("serial")?,
77                    revoked_at: row.try_get("revoked_at")?,
78                    // A code outside `u32` can only arrive by hand-edit; reading
79                    // it as "no reason" is `reason_from_u32`'s own treatment of
80                    // an unrecognised code, and refusing to list a revocation
81                    // over it would be the unsafe direction.
82                    reason: reason.and_then(|code| u32::try_from(code).ok()),
83                    not_after: row.try_get("not_after")?,
84                })
85            })
86            .collect()
87    }
88
89    /// How many revocations under `issuer` [`Self::prune_expired`] would delete
90    /// for the same pair of bounds.
91    pub async fn count_expired(
92        issuer: &str,
93        cutoff: i64,
94        listed_before: i64,
95        database: &Database,
96    ) -> Result<u64, sqlx::Error> {
97        let count: i64 = crate::sql::query(
98            "SELECT COUNT(*) FROM revocations \
99             WHERE issuer = ? AND not_after IS NOT NULL AND not_after < ? \
100               AND revoked_at <= ?;",
101        )
102        .bind(issuer)
103        .bind(cutoff)
104        .bind(listed_before)
105        .fetch_one(database)
106        .await?
107        .try_get(0)?;
108        Ok(u64::try_from(count).unwrap_or_default())
109    }
110
111    /// Deletes the revocations under `issuer` whose certificates expired before
112    /// `cutoff`, and which a CRL issued at or after `listed_before` already
113    /// carried (RFC 5280 §3.3), returning how many went.
114    ///
115    /// **A row with no `not_after` is never deleted**: an unknown expiry is not
116    /// an expired one. The caller backdates `cutoff` by the clock-skew
117    /// allowance.
118    ///
119    /// `listed_before` is the stored CRL's `thisUpdate`, and the second bound
120    /// is what §3.3 actually asks for: an entry "MUST NOT be removed from the
121    /// CRL until it appears on one regularly scheduled CRL issued beyond the
122    /// revoked certificate's validity period". A certificate that expired an
123    /// hour ago has not yet appeared on any such CRL, so dropping it here would
124    /// leave a relying party holding the last CRL that listed it — signed
125    /// before the expiry — with nothing to replace it.
126    ///
127    /// The `revoked_at` bound is inclusive: the CRL is signed from the rows as
128    /// they stand when it is built, so an entry stamped in the same second is
129    /// one it carries. An entry recorded in that second but after the snapshot
130    /// could be dropped without having been listed, and only if it is *also*
131    /// already expired — a revocation of a certificate nothing would honour
132    /// anyway.
133    pub async fn prune_expired<'e>(
134        issuer: &str,
135        cutoff: i64,
136        listed_before: i64,
137        executor: impl Into<crate::sql::Exec<'e>>,
138    ) -> Result<u64, sqlx::Error> {
139        let result = crate::sql::query(
140            "DELETE FROM revocations \
141             WHERE issuer = ? AND not_after IS NOT NULL AND not_after < ? \
142               AND revoked_at <= ?;",
143        )
144        .bind(issuer)
145        .bind(cutoff)
146        .bind(listed_before)
147        .execute(executor)
148        .await?;
149        info!(
150            event = "db_revocation_pruned",
151            outcome = "success",
152            issuer = %issuer,
153            rows_removed = result.rows_affected(),
154            cutoff,
155        );
156        Ok(result.rows_affected())
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    fn revocation(serial: &str, revoked_at: i64, not_after: Option<i64>) -> Revocation {
165        Revocation {
166            issuer: "ca".to_string(),
167            serial: serial.to_string(),
168            revoked_at,
169            reason: Some(1),
170            not_after,
171        }
172    }
173
174    #[tokio::test]
175    async fn the_first_revocation_of_a_serial_is_the_one_kept() {
176        let database = Database::connect_for_test().await.unwrap();
177
178        assert!(
179            revocation("01", 100, None)
180                .insert_if_absent(&database)
181                .await
182                .unwrap()
183        );
184        let mut repeat = revocation("01", 200, Some(9));
185        repeat.reason = Some(4);
186        assert!(!repeat.insert_if_absent(&database).await.unwrap());
187
188        let listed = Revocation::list_for_issuer("ca", &database).await.unwrap();
189        assert_eq!(listed, vec![revocation("01", 100, None)]);
190    }
191
192    /// One serial under two issuers is two certificates.
193    #[tokio::test]
194    async fn issuers_do_not_see_each_others_rows() {
195        let database = Database::connect_for_test().await.unwrap();
196        revocation("01", 100, None)
197            .insert_if_absent(&database)
198            .await
199            .unwrap();
200        let mut other = revocation("01", 100, None);
201        other.issuer = "other".to_string();
202        assert!(other.insert_if_absent(&database).await.unwrap());
203
204        assert_eq!(
205            Revocation::list_for_issuer("ca", &database)
206                .await
207                .unwrap()
208                .len(),
209            1
210        );
211        assert!(
212            Revocation::list_for_issuer("nobody", &database)
213                .await
214                .unwrap()
215                .is_empty()
216        );
217    }
218
219    /// §3.3: an entry stays until it has appeared on a CRL issued after the
220    /// certificate expired. A CRL signed before the expiry does not count, so
221    /// the entry it lists is kept even though the certificate is gone.
222    #[tokio::test]
223    async fn an_entry_no_crl_has_outlived_is_kept() {
224        let database = Database::connect_for_test().await.unwrap();
225        // Revoked at 10, expired at 50; the stored CRL was signed at 40.
226        revocation("expired", 10, Some(50))
227            .insert_if_absent(&database)
228            .await
229            .unwrap();
230
231        assert_eq!(
232            Revocation::count_expired("ca", 50, 40, &database)
233                .await
234                .unwrap(),
235            0
236        );
237        assert_eq!(
238            Revocation::prune_expired("ca", 50, 40, &database)
239                .await
240                .unwrap(),
241            0
242        );
243
244        // A CRL signed at 60 carries it past its expiry, and now it may go.
245        assert_eq!(
246            Revocation::prune_expired("ca", 60, 60, &database)
247                .await
248                .unwrap(),
249            1
250        );
251    }
252
253    #[tokio::test]
254    async fn the_prune_takes_only_known_expiries_before_the_cutoff() {
255        let database = Database::connect_for_test().await.unwrap();
256        for row in [
257            revocation("expired", 1, Some(50)),
258            revocation("boundary", 2, Some(100)),
259            revocation("current", 3, Some(500)),
260            revocation("unknown", 4, None),
261        ] {
262            row.insert_if_absent(&database).await.unwrap();
263        }
264        let mut elsewhere = revocation("elsewhere", 5, Some(50));
265        elsewhere.issuer = "other".to_string();
266        elsewhere.insert_if_absent(&database).await.unwrap();
267
268        assert_eq!(
269            Revocation::count_expired("ca", 100, 1_000, &database)
270                .await
271                .unwrap(),
272            1
273        );
274        assert_eq!(
275            Revocation::prune_expired("ca", 100, 1_000, &database)
276                .await
277                .unwrap(),
278            1
279        );
280
281        let left: Vec<String> = Revocation::list_for_issuer("ca", &database)
282            .await
283            .unwrap()
284            .into_iter()
285            .map(|row| row.serial)
286            .collect();
287        assert_eq!(left, ["boundary", "current", "unknown"]);
288        assert_eq!(
289            Revocation::list_for_issuer("other", &database)
290                .await
291                .unwrap()
292                .len(),
293            1,
294            "a prune is scoped to its issuer"
295        );
296    }
297
298    /// A reason code only a hand-edit could store reads as "no reason" rather
299    /// than hiding the revocation.
300    #[tokio::test]
301    async fn an_out_of_range_reason_reads_as_none() {
302        let database = Database::connect_for_test().await.unwrap();
303        crate::sql::query(
304            "INSERT INTO revocations (issuer, serial, revoked_at, reason) VALUES ('ca', '01', 1, -3);",
305        )
306        .execute(&database)
307        .await
308        .unwrap();
309        let listed = Revocation::list_for_issuer("ca", &database).await.unwrap();
310        assert_eq!(listed[0].reason, None);
311    }
312}