Skip to main content

CertLedger

Struct CertLedger 

pub struct CertLedger { /* private fields */ }
Expand description

The daemon’s issued-certificate ledger over SQLite (<data_dir>/tls/ledger.db).

Implementations§

Source§

impl CertLedger

Source

pub fn open(data_dir: &Path, audit: Option<Arc<AuditLogger>>) -> Result<Self>

Open (creating if absent) the ledger at <data_dir>/tls/ledger.db. The CA already lives under <data_dir>/tls/, so the ledger sits beside it.

Source

pub fn open_at( data_dir: &Path, audit: Option<Arc<AuditLogger>>, revoked_path: PathBuf, ) -> Result<Self>

Open the ledger with an explicit materialization target, for callers that know the verifier reads a configured crl_path rather than the default. See effective_revoked_list_path.

Source

pub fn open_in_memory(audit: Option<Arc<AuditLogger>>) -> Result<Self>

In-memory ledger for unit tests.

Source

pub fn sweep_undelivered_certificates(&self) -> Result<()>

Run the undelivered sweep on this handle, for a caller holding a long-lived ledger that wants to reconcile without reopening.

The enrollment endpoint is the case this exists for: it builds ONE ledger for the daemon’s lifetime, so without a trigger like this its only sweep would be the one at startup. Best-effort by design - it reports whether the sweep ran, and a caller on an unrelated path should log rather than fail, since a sweep failure does not make the caller’s own work unsafe and the rows stay eligible for the next attempt.

Source

pub fn mark_delivered(&self, fingerprint: &str) -> Result<bool>

Record that the certificate behind fingerprint actually reached its keyholder. Returns true if this call is the one that marked it.

Called by the site that owns the real delivery/publication boundary - the enrollment response write, the renewal response construction, the operator CLI’s staged-file rename - and only on that boundary’s success. Until then the row is active but undelivered, and reconcile_undelivered_issuances will revoke it.

Idempotent: the delivered_at IS NULL guard means a repeat call reports false and leaves the FIRST delivery time standing, so the recorded instant is when the credential actually went out rather than whenever something last touched the row.

Only an ACTIVE row is markable. Marking a pending row would claim delivery of a certificate the ledger has not vouched for; marking a revoked one would contradict the revocation.

Source

pub fn materialize_revocations(&self) -> Result<()>

Rewrite the revoked-fingerprint file from the SQLite truth (atomic temp + rename). This is what makes a revoke take effect at the next handshake - the WSS verifier re-reads the file when its mtime changes. No-op for an in-memory ledger. Rewrite the revocation file from the ledger’s committed truth, under the SAME exclusive write lock a revocation takes.

The lock is the point. Reading the revoked set, writing the scratch file and renaming it are three steps, and a revocation committing between the read and the rename would be published to the file and then immediately overwritten by this call’s older snapshot - a committed revocation silently vanishing from the file the WSS verifier reads, which is the one direction revocation may never fail.

BEGIN IMMEDIATE takes SQLite’s write lock up front, and CertLedger::mark_revoked holds that same lock across its own flip, materialization and commit. So the two cannot interleave: one runs to completion before the other reads anything. That lock is held by SQLite itself, so it serializes across independent connections AND across processes sharing the data dir - which a parking_lot mutex on this handle would not, and this ledger is opened per request and per CLI invocation.

A read-only DEFERRED transaction would NOT do: it takes only a read lock, which a concurrent writer is free to pass straight through.

Source

pub fn record_issued(&self, entry: &LedgerEntry, renewal: bool) -> Result<()>

Record an issuance across both durable surfaces as a two-phase commit. renewal selects CertRenewed vs CertIssued for the completion event.

SQLite and the append-only audit file cannot share one transaction, so the row carries the protocol instead: it is committed in the pending state - which no reader resolves to a credential - and promoted to entry.status only once the completion event is durable.

  1. CertIssuanceAttempted, BEFORE any row exists. A failure here leaves the ledger untouched.
  2. the row, as pending. A failure here leaves an attempt with no completion and nothing the ledger vouches for.
  3. CertIssued/CertRenewed, AFTER the row commits, so a completion event means the row exists.
  4. the promotion to entry.status, which is what publishes the certificate to every reader.

The two invariants this buys, in the order they matter:

  • A row this ledger vouches for was always audited as complete. Step 4 cannot run before step 3 succeeds, so no failure - of the completion write’s rotation, open, serialization, write or sync - can publish a certificate the caller never delivered. That is the failure that used to strand an ACTIVE row for an undelivered credential, and because the client’s retry carries a fresh CSR (a different fingerprint) it left a SECOND active row rather than replacing the first.
  • No failure publishes anything. Every step returns Err and callers must not hand the certificate to the client unless this returns Ok. When a failure follows step 2, the pending row this call staged is removed on the same connection; if that compensation cannot run (or the process dies first) the row stays pending, which is still invisible to every reader, and reconcile_pending_issuances discards it at the next open.

The converse is deliberately NOT claimed: a completion event without a row is possible, when step 4 fails. The caller still gets Err and still delivers nothing, so the residue is a fingerprint that is audited but absent - which fails closed - rather than a live credential with no audit.

Re-recording a fingerprint the ledger already holds is idempotent and never downgrades it: step 2 does not disturb an existing row, and step 4 rewrites it. A failed completion for such a call therefore leaves the established row exactly as it was, with its old validity - correct, since the caller is returning an error rather than delivering the renewed certificate.

§Why promotion happens before delivery

Step 4 publishes the row while the caller still holds the certificate: the enrollment response has not been written, the renewal reply has not been serialized, the operator CLI has not renamed its staged files. That ordering is chosen, not incidental.

The inverse - deliver first, promote after - fails in the strictly worse direction. A crash in that window hands a client a live, CA-signed certificate whose row is later reconciled away, so the ledger has no record of a credential that exists in the wild: nothing to list, nothing to revoke, and a certificate that keeps authenticating until it expires because the WSS verifier authorizes by CA chain, not by ledger membership. A ghost row is the opposite failure and a recoverable one - the ledger over-records a credential that may not exist, and an over-recorded credential can be revoked. This ledger must never under-record a credential that might exist in the wild.

Delivery is therefore tracked as its own dimension rather than folded into status: delivered_at stays NULL until CertLedger::mark_delivered is called at the real delivery/publication boundary, and reconcile_undelivered_issuances revokes - never deletes - whatever is still unmarked once UNDELIVERED_CERT_TTL_SECS has passed. The ghost row ends up in the materialized revocation list, while a delivered certificate is never at risk of vanishing from the record.

Every call sweeps stale undelivered rows before staging anything, which is what bounds a ghost row on a handle that is never reopened - the enrollment endpoint holds exactly one for the daemon’s lifetime. The sweep is age-gated at the TTL, so it can never touch the row this call is about to create, nor any other issuance still in flight.

Source

pub fn record_issued_requiring( &self, entry: &LedgerEntry, renewal: bool, still_active: Option<&str>, ) -> Result<()>

CertLedger::record_issued for a renewal, which may only publish while the certificate it renews is STILL active.

Renewal reads the presenting certificate’s status, resolves its device, signs a new certificate, and records it - four steps, on a connection the operator’s revoke-client-cert does not share. An operator revoking that device in the middle of them would see their revocation succeed and then watch the renewal hand the same device a brand-new active certificate: revocation reported, device still connected. That is the worst outcome this ledger has, because the operator believes the device is off the network.

still_active closes it by making the presenting fingerprint a precondition of the issuance commit itself, re-tested inside the transaction that publishes the new row. Revocation therefore always wins: either it lands before the check and the renewal is refused, or it lands after the new row is published and revoke_device - which revokes every active certificate the device holds - takes the new one too. There is no ordering in which the device keeps a usable certificate.

A refused renewal returns ISSUANCE_PRECONDITION_FAILED in its error chain. It is retryable only in the sense that re-enrolling is the client’s correct next move; retrying the renewal will keep failing, because the certificate it presents is revoked.

Source

pub fn status_of(&self, fingerprint: &str) -> Result<Option<CertStatus>>

The status of a cert by fingerprint, or None if unknown to the ledger.

A pending row reads as None: its issuance has not been recorded as complete, so the ledger does not vouch for that certificate and callers must treat it exactly as they treat one they have never seen. For the renew RPC that means “re-enroll”, which is the correct answer for a certificate that was never delivered.

Source

pub fn is_revoked(&self, fingerprint: &str) -> Result<bool>

True iff the cert is known to this ledger AND marked revoked.

A cert this ledger has never seen is NOT revoked here, and that is not a gap the verifier closes by ledger membership: the WSS verifier’s authority model is CA-based. It authorizes any certificate that chains to the configured client CA, subject to the optional leaf pins and this revocation list. Ledger membership is not required for normal RPC initialization - that is what makes the documented bring-your-own-CA path work, since certificates minted outside this daemon are legitimate and never appear in its issued-cert table.

Source

pub fn lookup_by_fingerprint( &self, fingerprint: &str, ) -> Result<Option<LedgerEntry>>

Look up the full ledger row for a fingerprint. A pending row reads as None, for the reason given on CertLedger::status_of.

Source

pub fn device_of(&self, fingerprint: &str) -> Result<Option<String>>

The device id bound to a presenting cert (its subject CN, via the ledger).

Source

pub fn mark_revoked(&self, fingerprint: &str, actor: &str) -> Result<bool>

Mark a cert revoked by fingerprint. Returns true if a row changed. Writes a CertRevoked audit event when a row was actually flipped.

Ordering guarantee: the status flip and the materialized enforcement file commit together or not at all. The file is rewritten from the in-transaction view BEFORE the SQLite commit, so

  • a materialization failure rolls the flip back: the ledger never reports a revocation the WSS verifier is not enforcing;
  • a commit failure after the file write leaves the file over-enforcing until the next materialization (fail-closed, never fail-open).
Source

pub fn revoke_device(&self, device_id: &str, actor: &str) -> Result<usize>

Revoke every active cert held by a device (e.g. a compromised device), as ONE atomic transaction. Returns the number of certs revoked.

§Why this is one transaction

This is the operator’s “get that device off the network” command, so its promise is total: when it returns, the device holds no usable certificate. A snapshot followed by per-fingerprint revocations - which is what this replaced - cannot keep that promise, because a renewal can publish a NEW active row for the same device in the gap between the snapshot and the updates. The command then revokes only the stale set, reports success, and leaves the device holding a certificate it was handed moments earlier.

Reading and flipping inside one IMMEDIATE transaction closes it, and CertLedger::record_issued_requiring’s promote step is likewise an IMMEDIATE transaction, so SQLite’s write lock serializes the two. Only two orderings exist, and neither leaves the device usable:

  1. Revocation commits first. The renewal’s promote step then re-tests its presenting fingerprint, finds it revoked, and refuses - no new row is ever published. (The presenting certificate always belongs to this device: renewal resolves the device id FROM it.)
  2. The renewal commits first. This transaction’s UPDATE then runs against committed state that already includes the new row, and WHERE device_id = ? AND status = 'active' sweeps it along with the rest.

There is no third ordering in which the two overlap, which is precisely what the write lock buys and what a stale snapshot gave away. No revocation epoch or device generation is needed for the same reason.

Source

pub fn list_active(&self) -> Result<Vec<LedgerEntry>>

All currently-active ledger rows.

Source

pub fn revoked_fingerprints(&self) -> Result<Vec<String>>

The set of currently-revoked fingerprints - the source the WSS verifier revocation check (and CRL materialization) consume.

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
§

impl<'a, T, E> AsTaggedExplicit<'a, E> for T
where T: 'a,

§

fn explicit(self, class: Class, tag: u32) -> TaggedParser<'a, Explicit, Self, E>

§

impl<'a, T, E> AsTaggedImplicit<'a, E> for T
where T: 'a,

§

fn implicit( self, class: Class, constructed: bool, tag: u32, ) -> TaggedParser<'a, Implicit, Self, E>

Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
§

impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
where ST: ?Sized, DT: ?Sized,

§

impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
where ST: ?Sized, DT: ?Sized,

Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

§

impl<T> Instrument for T

§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided [Span], returning an Instrumented wrapper. Read more
§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T> IntoEither for T

Source§

fn into_either(self, into_left: bool) -> Either<Self, Self>

Converts self into a Left variant of Either<Self, Self> if into_left is true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
Source§

fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
where F: FnOnce(&Self) -> bool,

Converts self into a Left variant of Either<Self, Self> if into_left(&self) returns true. Converts self into a Right variant of Either<Self, Self> otherwise. Read more
§

impl<T> PolicyExt for T
where T: ?Sized,

§

fn and<P, B, E>(self, other: P) -> And<T, P>
where T: Sized + Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] only if self and other return Action::Follow. Read more
§

fn or<P, B, E>(self, other: P) -> Or<T, P>
where T: Sized + Policy<B, E>, P: Policy<B, E>,

Create a new Policy that returns [Action::Follow] if either self or other returns Action::Follow. Read more
§

impl<T> Read<Exclusive, BecauseExclusive> for T
where T: ?Sized,

Source§

impl<T> Same for T

Source§

type Output = T

Should always be Self
Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
§

impl<V, T> VZip<V> for T
where V: MultiLane<T>,

§

fn vzip(self) -> V

§

impl<T> WithSubscriber for T

§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a [WithDispatch] wrapper. Read more
§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a [WithDispatch] wrapper. Read more