Append-only hash-chained audit log.
Future extraction
Per docs/specs/specs.md §9 Phase 5, audit lives in a sister
library pkcs11ex_audit. This namespace ships inside pkcs11ex for
now to keep the working session moving; the public API
(Pkcs11ex.Audit, Pkcs11ex.Audit.Entry, Pkcs11ex.Audit.Storage)
is what gets extracted, with the same module names.
What this is
A tamper-evident log: each entry's :content_hash includes the
previous entry's hash as a prefix. Walking the chain end-to-end and
recomputing each hash detects any modification — verify/1 does that
walk and reports the first divergence.
Storage is pluggable (Pkcs11ex.Audit.Storage behaviour). The library
ships Pkcs11ex.Audit.Storage.InMemory for dev/tests; production
deployments plug a durable adapter (Postgres, SQLite, append-only
files, S3 with Object Lock, etc.).
What this is NOT
- Authenticated. The chain proves "no entry was modified after insertion" given an honest verifier holding the head hash. It does NOT prove "the operator didn't replay/truncate the whole chain from a saved state." External anchoring (RFC 3161 trusted timestamping over the chain head) is the answer to that — Phase 5 step 2.
- Encrypted. Payload is stored in cleartext per the storage
adapter's contract. Apps that need confidentiality encrypt the
payload before calling
append/3. - A signing primitive. The "chain root signed by the platform key" pattern lives a layer above; this module is the substrate.
Usage
{:ok, _} = Pkcs11ex.Audit.Storage.InMemory.start_link(name: :sigs)
audit = Pkcs11ex.Audit.new(Pkcs11ex.Audit.Storage.InMemory, :sigs)
{:ok, entry} =
Pkcs11ex.Audit.append(audit, %{
jws: jws,
subject_id: :acme_corp,
key_ref: {:platform, :signing}
})
:ok = Pkcs11ex.Audit.verify(audit)
Summary
Functions
Anchor the current chain head against an RFC 3161 Time-Stamping
Authority. Reads the head, sends its content_hash to the TSA, stores
the returned TimeStampToken (TST) as a new audit entry whose payload
carries the anchored seq + hash + opaque TST bytes.
Append payload as a new entry. Returns the constructed Entry.
Convenience wrapper around the storage's at/2.
Convenience wrapper around the storage's head/1.
Construct an audit reference around a running storage process / handle.
Walk the chain head-to-tail. Recomputes each content_hash and checks
the prev_hash linkage. Returns :ok on a clean chain,
{:error, :empty_chain} for a chain with no entries, or
{:error, {reason, seq}} at the first divergence.
Types
@type append_opts() :: [{:inserted_at, DateTime.t()}]
Functions
@spec anchor_head(t(), String.t(), keyword()) :: {:ok, Pkcs11ex.Audit.Entry.t()} | {:error, term()}
Anchor the current chain head against an RFC 3161 Time-Stamping
Authority. Reads the head, sends its content_hash to the TSA, stores
the returned TimeStampToken (TST) as a new audit entry whose payload
carries the anchored seq + hash + opaque TST bytes.
This addresses the "operator-replay/truncate" gap of a bare hash chain by binding the chain state to a TSA-attested time. The TST itself is a CMS SignedData; auditors verify its signature against the TSA's cert chain (out of scope for this library — store the bytes, hand to whoever audits).
Required
tsa_url— the TSA's HTTP endpoint (e.g.,"http://timestamp.digicert.com").
Optional opts
:timeout— milliseconds, default 10_000.
Returns
{:ok, anchor_entry} on success, where anchor_entry.payload is a
map %{kind: :rfc3161_anchor, anchored_seq, anchored_hash, nonce, tst}.
Returns {:error, :empty_chain} if there's nothing to anchor.
@spec append(t(), term(), append_opts()) :: {:ok, Pkcs11ex.Audit.Entry.t()} | {:error, term()}
Append payload as a new entry. Returns the constructed Entry.
Reads the current head, computes content_hash, and asks the storage
to persist. Storage adapters are expected to serialize concurrent
appends (see Pkcs11ex.Audit.Storage moduledoc).
@spec at(t(), pos_integer()) :: {:ok, Pkcs11ex.Audit.Entry.t()} | {:error, :not_found}
Convenience wrapper around the storage's at/2.
@spec head(t()) :: {:ok, Pkcs11ex.Audit.Entry.t()} | {:error, :empty}
Convenience wrapper around the storage's head/1.
Construct an audit reference around a running storage process / handle.
@spec verify(t()) :: :ok | {:error, :empty_chain | {atom(), pos_integer()}}
Walk the chain head-to-tail. Recomputes each content_hash and checks
the prev_hash linkage. Returns :ok on a clean chain,
{:error, :empty_chain} for a chain with no entries, or
{:error, {reason, seq}} at the first divergence.
An empty chain returns :empty_chain (not :ok) so callers can
distinguish "nothing to verify" from "everything verified clean."
Database-wipe attacks reduce a populated chain to empty; treating
empty as success would silently obscure that. RFC 3161 anchoring
(see anchor_head/3) is the cross-cutting answer to truncation,
but verify/1 should at least surface the truncation-shaped state.
Reasons for a divergent chain:
:seq_gap—seqdoesn't follow the previous entry'sseq + 1.:prev_hash_mismatch—prev_hashdoesn't match the previous entry'scontent_hash.:content_hash_mismatch— recomputed hash differs from the stored one (the entry'spayloadorinserted_atwas tampered with).