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
impl CertLedger
Sourcepub fn open(data_dir: &Path, audit: Option<Arc<AuditLogger>>) -> Result<Self>
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.
Sourcepub fn open_at(
data_dir: &Path,
audit: Option<Arc<AuditLogger>>,
revoked_path: PathBuf,
) -> Result<Self>
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.
Sourcepub fn open_in_memory(audit: Option<Arc<AuditLogger>>) -> Result<Self>
pub fn open_in_memory(audit: Option<Arc<AuditLogger>>) -> Result<Self>
In-memory ledger for unit tests.
Sourcepub fn sweep_undelivered_certificates(&self) -> Result<()>
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.
Sourcepub fn mark_delivered(&self, fingerprint: &str) -> Result<bool>
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.
Sourcepub fn materialize_revocations(&self) -> Result<()>
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.
Sourcepub fn record_issued(&self, entry: &LedgerEntry, renewal: bool) -> Result<()>
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.
CertIssuanceAttempted, BEFORE any row exists. A failure here leaves the ledger untouched.- the row, as
pending. A failure here leaves an attempt with no completion and nothing the ledger vouches for. CertIssued/CertRenewed, AFTER the row commits, so a completion event means the row exists.- 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
Errand callers must not hand the certificate to the client unless this returnsOk. 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 stayspending, which is still invisible to every reader, andreconcile_pending_issuancesdiscards 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.
Sourcepub fn record_issued_requiring(
&self,
entry: &LedgerEntry,
renewal: bool,
still_active: Option<&str>,
) -> Result<()>
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.
Sourcepub fn status_of(&self, fingerprint: &str) -> Result<Option<CertStatus>>
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.
Sourcepub fn is_revoked(&self, fingerprint: &str) -> Result<bool>
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.
Sourcepub fn lookup_by_fingerprint(
&self,
fingerprint: &str,
) -> Result<Option<LedgerEntry>>
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.
Sourcepub fn device_of(&self, fingerprint: &str) -> Result<Option<String>>
pub fn device_of(&self, fingerprint: &str) -> Result<Option<String>>
The device id bound to a presenting cert (its subject CN, via the ledger).
Sourcepub fn mark_revoked(&self, fingerprint: &str, actor: &str) -> Result<bool>
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).
Sourcepub fn revoke_device(&self, device_id: &str, actor: &str) -> Result<usize>
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:
- 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.)
- The renewal commits first. This transaction’s
UPDATEthen runs against committed state that already includes the new row, andWHERE 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.
Sourcepub fn list_active(&self) -> Result<Vec<LedgerEntry>>
pub fn list_active(&self) -> Result<Vec<LedgerEntry>>
All currently-active ledger rows.
Sourcepub fn revoked_fingerprints(&self) -> Result<Vec<String>>
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§
impl !Freeze for CertLedger
impl !RefUnwindSafe for CertLedger
impl !UnwindSafe for CertLedger
impl Send for CertLedger
impl Sync for CertLedger
impl Unpin for CertLedger
impl UnsafeUnpin for CertLedger
Blanket Implementations§
§impl<'a, T, E> AsTaggedExplicit<'a, E> for Twhere
T: 'a,
impl<'a, T, E> AsTaggedExplicit<'a, E> for Twhere
T: 'a,
§impl<'a, T, E> AsTaggedImplicit<'a, E> for Twhere
T: 'a,
impl<'a, T, E> AsTaggedImplicit<'a, E> for Twhere
T: 'a,
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
§impl<T> Instrument for T
impl<T> Instrument for T
§fn instrument(self, span: Span) -> Instrumented<Self>
fn instrument(self, span: Span) -> Instrumented<Self>
§fn in_current_span(self) -> Instrumented<Self>
fn in_current_span(self) -> Instrumented<Self>
Source§impl<T> IntoEither for T
impl<T> IntoEither for T
Source§fn into_either(self, into_left: bool) -> Either<Self, Self>
fn into_either(self, into_left: bool) -> Either<Self, Self>
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 moreSource§fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
fn into_either_with<F>(self, into_left: F) -> Either<Self, Self>
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