Pkcs11ex.Audit (pkcs11ex_audit v0.1.0)

Copy Markdown View Source

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

append_opts()

@type append_opts() :: [{:inserted_at, DateTime.t()}]

t()

@type t() :: %Pkcs11ex.Audit{storage_handle: term(), storage_module: module()}

Functions

anchor_head(audit, tsa_url, opts \\ [])

@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.

append(audit, payload, opts \\ [])

@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).

at(audit, seq)

@spec at(t(), pos_integer()) :: {:ok, Pkcs11ex.Audit.Entry.t()} | {:error, :not_found}

Convenience wrapper around the storage's at/2.

head(audit)

@spec head(t()) :: {:ok, Pkcs11ex.Audit.Entry.t()} | {:error, :empty}

Convenience wrapper around the storage's head/1.

new(storage_module, storage_handle)

@spec new(module(), term()) :: t()

Construct an audit reference around a running storage process / handle.

verify(audit)

@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_gapseq doesn't follow the previous entry's seq + 1.
  • :prev_hash_mismatchprev_hash doesn't match the previous entry's content_hash.
  • :content_hash_mismatch — recomputed hash differs from the stored one (the entry's payload or inserted_at was tampered with).