Skip to content

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

payload_str = json.dumps(payload, sort_keys=True, default=str)

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

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:

{"prev_sig":"0000...","sig":"a3f7...","record":{"step":"create_server","tenant_slug":"abc123",...}}

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:

json.dumps(_redact(record), sort_keys=True, separators=(",", ":"), default=str)

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

ok, bad_idx = log.verify_chain()

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)

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.