Expand description
Forward Ed25519-signed hash chain for the audit log.
Each transactions row stores chain_hash and prev_chain_hash columns.
On insert, the daemon computes
chain_hash = ed25519_sign(ROW_DOMAIN || canonical(immutable_fields) || prev_chain_hash, signing_key)The ROW_DOMAIN prefix is part of the signed message, not decoration:
it is what stops a row signature from ever verifying as a checkpoint or
approval-receipt signature. An independent verifier that omits it will
never reproduce a valid chain_hash.
and stores both (the signature is hex-encoded; the first row in a chain has
prev_chain_hash = ""). Ed25519 signatures are deterministic (RFC 8032), so
the same content always produces the same chain_hash.
Verification (sysknife audit verify) walks rows in seq order and checks
each row’s signature with the public key, reporting the first broken
link. Because verification needs only the public key, an auditor or a
central log aggregator can verify the chain without holding the private
key — they cannot forge entries. This is the property a symmetric MAC
(the previous HMAC-SHA256 design) could not provide: with an HMAC, the
verifier and the forger are the same principal.
§Threat model
- Non-repudiation (asymmetric). The daemon signs with the private key;
anyone with the exported public key (
<key>.pub) can verify but not forge. A compromise of the verifier does not enable forgery. - Compromised host / root. An attacker who reads the private key file
can forge future entries. Mitigation is to anchor signed checkpoints to
an append-only external sink (see
checkpoint_sink) so that later tampering with past entries becomes detectable and the tamper window is bounded to “after compromise”. - Tail truncation is undetectable by the chain walk alone: an attacker
who deletes the last K rows leaves a still-consistent chain. It is caught
by anchoring signed checkpoints to an independent append-only sink, since
a truncated chain can no longer reproduce a previously anchored
(seq, chain_tip)(seeverify_checkpointsandcheckpoint_sink). The best-effortaudit_watermarkjournald forward is a lighter complement. - In-flight modification between insert and read is mitigated by computing the signature before INSERT and writing it in the same SQL statement.
- Status mutations are not in the chain. The mutable
statusfield is intentionally excluded — the chain protects the authorisation decision (immutable fields captured at insert time), not the live execution state. A future append-onlyaudit_eventstable will chain status transitions if the threat model demands it.
§Key management
The Ed25519 private key (a 32-byte seed) lives in a file. By default the
path is <db_dir>/audit-key (sibling of the SQLite database, or of
whatever directory sysknife_core::default_database_path resolves to in
production), and the env var SYSKNIFE_AUDIT_KEY_PATH overrides it for
systemd unit drop-ins (typically /etc/sysknife/audit-key). The file is
created with mode 0o600 on first daemon start if it does not exist;
subsequent runs refuse to start if it is world-readable. The public key is
written alongside as <key>.pub (hex) for auditors and aggregators.
Future epochs (key rotation): each row already carries a key_id
column. A planned rotation flow appends a checkpoint row signed with the
outgoing key whose payload references both public-key fingerprints;
verification walks the chain through epoch boundaries by looking up each
row’s key_id in a directory of retired public keys. For now, all rows use
key_id = "v1" and rotation is manual (delete the chain, regenerate).
Structs§
- Audit
Key - Loaded Ed25519 signing key + its identifier. Construct via
AuditKey::load_or_generate. - Audit
Verification - Everything
sysknife audit verifychecks, in one value. - Chain
Content - Chain
Row - One row’s worth of chain data, as fetched from the store.
- Checkpoint
- A signed commitment to the chain tip at a point in time. Periodically
emitted and anchored to an independent, append-only sink (a separate
database, a WORM store, or an RFC 3161 timestamp) so that a later attempt to
rewrite or truncate the local chain is detectable: the anchored
(seq, chain_tip)can no longer be reproduced from the shortened chain. - Event
Content - Immutable content of one approval event, signed into the event chain.
- Event
Row - One approval event as fetched from the store.
Enums§
- Audit
Event Kind - A lifecycle event on an approval receipt.
- Audit
KeyError - Binding
Outcome - Result of checking that the transaction chain’s committed
event_tipvalues are still reproducible from the event chain. - Chain
Identity - Immutable fields hashed into the chain. Status is intentionally absent — see module docs.
- Checkpoint
Outcome - Result of checking anchored checkpoints against the current chain.
- Verify
Outcome - Result of
verify_chain.
Constants§
- CHAIN_
VERSION_ CURRENT - Row encoding written by this binary: adds
caller_roleandevent_tip. - CHAIN_
VERSION_ LEGACY - Row encoding written before the caller-identity migration: no
caller_role, noevent_tip. Still verifiable — seeChainIdentity. - CURRENT_
KEY_ ID - Stable identifier for the current key generation. Stored in every row.
Tied to the schema, not the key bytes — rotation will introduce
"v2"etc. - HASH_
HEX_ LEN - Hex-encoded length of an Ed25519 signature (64 raw bytes → 128 hex chars).
Functions§
- approval_
receipt_ digest - binding_
outcome_ to_ exit_ code - Exit code for the binding check, on the same scale as
outcome_to_exit_code: a missing event is a detected tamper (1). - checkpoint_
outcome_ to_ exit_ code - Process exit code for
sysknife audit checkpointverification, mirroringoutcome_to_exit_code:0consistent,1a detected tamper (bad signature / truncation / rewrite),2cannot-verify (e.g. malformed public key). The 1-vs-2 split matters for CI exactly as it does for chain verify. - outcome_
to_ exit_ code - Process exit code for
sysknife audit verify. - resolve_
audit_ key_ path - Resolve the audit key file path:
$SYSKNIFE_AUDIT_KEY_PATHif set, otherwise<db_dir>/audit-key(sibling of the database). This is the single definition of the key-location precedence documented in the module header; the CLI verify/checkpoint paths resolve through it so they always agree with the daemon on where the key lives. - verify_
all - Run all three checks with the daemon’s key.
- verify_
all_ with_ pubkey - Run all three checks with only the exported public key (the auditor path).
- verify_
chain - Verify a chain using the daemon’s key. Verification uses the public key
(so it proves, but cannot forge) and also asserts every row was written
under this key generation (
key_id). - verify_
chain_ with_ pubkey - Verify a chain with only the hex-encoded Ed25519 public key. This is the
auditor / aggregator path: it proves the chain without the private key and
cannot be used to forge entries.
key_idis not checked — the public key itself identifies the signer. - verify_
checkpoints - Verify anchored
checkpointsagainstrows(the current chain) with the hex public key. Detects truncation (a checkpoint seq no longer in the chain) and rewrite (tip mismatch at a checkpoint seq).rowsmust be in seq order. This is the anti-root guarantee: a host attacker who shortens or edits the local chain cannot reproduce a previously anchored signed tip. - verify_
event_ binding - Check the cross-chain binding: each
chain_version = 2transaction row signs the event-chain tip as of its insert, so a later deletion of trailing approval events leaves a tip that can no longer be found. - verify_
event_ chain - Verify the approval-event chain with the daemon’s key.
- verify_
event_ chain_ with_ pubkey - Verify the approval-event chain with only the exported public key.