Audit Chain¶
CTFHive maintains two independent, tamper-evident audit chains built on HMAC linking. Altering any record in either chain invalidates every record that follows it, making retroactive tampering detectable on the next verification pass.
A third HMAC construct — Stripe webhook signature verification — lives in the control plane but is a separate mechanism, not a chained log. See Billing / Stripe integration.
Chain 1 — Application event log¶
Source files: ctfapp/secure_log.py · ctfapp/models/event_log.py
Data structure¶
Every privileged or notable action appends a row to the event_log table:
| Column | Type | Notes |
|---|---|---|
id |
Integer PK |
Append-only; never deleted. |
type |
String(60) |
Short uppercase code, e.g. USER_LOGIN, FLAG_SHARE_DETECTED. |
severity |
String(10) |
INFO | WARNING | CRITICAL. |
actor_user_id |
Integer FK → users.id |
Nullable; the user who triggered the event. |
principal_id |
Integer FK → principals.id |
Nullable; the scoring principal involved. |
challenge_id |
Integer FK → challenges.id |
Nullable; the challenge involved. |
payload_json |
Text |
Arbitrary JSON context, sorted-key serialised. |
prev_sig |
String(64) |
The sig of the immediately preceding row. |
sig |
String(64) |
HMAC-SHA-256 over this entry (see formula below). |
created_at |
DateTime(timezone=True) |
UTC timestamp, stored with tzinfo. |
HMAC formula¶
sig_n = HMAC-SHA256(
key = ADMIN_KEY,
msg = "{prev_sig}|{event_type}|{canonical_ts(created_at)}|{payload_json or ''}"
)
canonical_ts converts the stored timestamp to an unambiguous UTC ISO-8601
string. This is the same string that is signed at write time and
re-derived at verify time, so the chain survives round-trips through both
SQLite (which drops tzinfo on read) and PostgreSQL.
The genesis signature — used as prev_sig for the very first row — is
"0" * 64 (64 zero hex characters).
Payload serialisation¶
Deterministic key ordering ensures the signed byte string is stable across Python versions and across processes.
Boot-time chain continuity¶
def init_audit_log(app=None) -> None:
# Loads the last row's sig into module-level _last_sig
last = EventLog.query.order_by(EventLog.id.desc()).first()
if last:
_last_sig = last.sig
Call init_audit_log(app) once inside the app factory after db.init_app().
The module holds _last_sig in process memory, seeded from the DB on startup,
so the chain is continuous across restarts.
Multi-worker safety
_last_sig is a module-level variable. Under Gunicorn --preload, it is
shared across workers via copy-on-write after the master forks. Under
multiple concurrent workers without --preload, each worker holds its
own copy and the chain can diverge. For production correctness, use
Gunicorn with --preload or promote _last_sig to a Redis GETSET
operation on every write.
Verification¶
def verify_chain(app=None) -> tuple[bool, int | None]:
# Walks event_log in id-ascending order.
# Returns (True, None) if intact.
# Returns (False, first_bad_id) if any sig does not match.
Walk complexity is O(N) in the number of log rows. Run this from an admin
diagnostic route, not on every request. A False result with first_bad_id
identifies the earliest tampered row; every row with id >= first_bad_id
should be treated as untrustworthy.
Tamper-evidence semantics¶
Row 1: sig_1 = HMAC(key, "0"*64 | ...)
Row 2: sig_2 = HMAC(key, sig_1 | ...)
Row 3: sig_3 = HMAC(key, sig_2 | ...)
...
Row N: sig_N = HMAC(key, sig_{N-1} | ...)
Editing the payload of row 3 changes sig_3, which makes sig_4 fail
recomputation (because sig_4 was computed over the original sig_3), and
so on through the tail. An attacker who knows ADMIN_KEY could recompute the
entire chain from the modified row forward — key secrecy is essential.
Chain 2 — Provisioner JSONL log¶
Source file: provisioner/audit.py — AuditLog
This chain records the step-by-step execution of each tenant provisioning run. It is separate from the application event log because provisioning runs outside the Flask app context, on the operator's machine or a CI runner.
Format¶
One JSON object per line (JSONL), appended to a file specified at
AuditLog construction time:
HMAC formula¶
sig_n = HMAC-SHA256(
key = PROVISION_AUDIT_SECRET (bytes),
msg = "{prev_sig}|{canonical_json(record)}"
)
canonical_json serialises the redacted record with sorted keys and no
extra whitespace:
The genesis signature is "0" * 64.
Sensitive-key redaction¶
Before signing or persisting, the following keys are replaced with
"***REDACTED***" (case-insensitive key match, recursive through dicts and
lists):
| Redacted key |
|---|
password |
server_secret |
registry_password |
private_key |
token |
secret |
Executors and command records are expected to strip secrets before calling
AuditLog.append(); this redaction is a backstop, not the primary defence.
API¶
log = AuditLog(path="runs/tenant-abc/audit.jsonl",
secret=os.environ["PROVISION_AUDIT_SECRET"])
sig = log.append({"step": "create_server", "tenant_slug": "abc123"})
ok, bad_idx = log.verify_chain()
# ok=True → chain intact
# ok=False → bad_idx is the 0-based line number of the first tampered record
append() is not thread-safe. Provisioning runs are sequential by design; if
you ever parallelize steps, serialize calls to append().
The log file is created on first write (parents=True mkdir). If the file
already contains entries from a previous run, _load_last_sig() reads forward
to find the last valid sig and continues the chain.
Verification¶
Returns (True, None) or (False, 0-based_line_index_of_first_bad_record).
Comparison¶
| Property | App event log | Provisioner JSONL |
|---|---|---|
| Storage | PostgreSQL event_log table |
JSONL file on disk |
| Hash algorithm | HMAC-SHA-256 | HMAC-SHA-256 |
| Key source | ADMIN_KEY |
PROVISION_AUDIT_SECRET |
| Genesis sig | "0" * 64 |
"0" * 64 |
| Redaction | None (payload is pre-sanitised by callers) | Automatic for sensitive key names |
| Verification call | secure_log.verify_chain(app) |
AuditLog.verify_chain() |
| Output on failure | (False, first_bad_row_id) |
(False, 0-based_line_index) |
Related: Stripe webhook signatures¶
The control plane's CTF_Saas_CTRL_Pane/ctrlapp/billing/stripe_signature.py
also uses HMAC-SHA-256, but it is not a chained log. It verifies a
single inbound webhook request matches the Stripe-issued signing secret, per
the documented Stripe signature scheme
({timestamp}.{raw_body} signed with the endpoint secret). See
Billing / Stripe integration for details.