Docs

Tamper evidence

An audit log is only evidence if you can show it wasn't edited. Softechlog seals every event into signed, hash-chained checkpoints you can verify yourself — and pin, so that not even we can rewrite history without detection.

How it works

  1. A leaf at ingest. When an event is stored, the API hashes it into a 32-byte leaf and returns it as leaf_hash from track() / POST /v1/events. Personal fields (actor, IP, device, targets, metadata) are hashed behind random salts, so a leaf reveals nothing and a redaction can later destroy the salt without breaking the hash.
  2. A queue. The leaf waits in a small queue until the next seal.
  3. A seal every 5 minutes. The sealer takes the queued leaves (up to 5,000 per checkpoint), hashes them, and writes a checkpoint that includes the previous checkpoint's hash — a chain — signed with our Ed25519 key.
  4. A signed retention cutoff. Each checkpoint also signs the project's retention period and a purge watermark, so retention deletions are explainable, and can't be stretched to explain an arbitrary deletion. Erasures are explained by their own sealed evidence event.

The exact encoding

Everything below is normative: the server, @futuregensystems/softechlog/verify and softechlog.integrity agree byte for byte, pinned by the shared test vectors (docs/integrity-test-vectors.v1.json). You can write your own verifier from it.

Encodingstl.*.v1
enc_field(None) = FF FF FF FF
enc_field(s)    = u32_be(len(utf8(s))) || utf8(s)       # numbers are rendered as decimal strings first
enc(list)       = concatenation of enc_field over the list
ts(dt)          = UTC "YYYY-MM-DDTHH:MM:SS.ffffffZ"     # always 6 fractional digits
micros(dt)      = signed int64 microseconds since 1970-01-01T00:00:00Z
H(x)            = SHA-256(x)
metadata_json   = the exact JSON string the server hashed (shipped in bundles; never re-serialise it)

# Event leaf — S_a, S_t: 16 random bytes each, generated at ingest
core          = H(enc(["stl.core.v1", id, project_id, tenant_id, action, target_type, capture_mode,
                       ts(occurred_at), ts(created_at)]))
actor_commit  = H("stl.actor.v1\0"  || S_a || enc([id, actor_external_id, session_id, ip_address, user_agent]))
target_commit = H("stl.target.v1\0" || S_t || enc([id, target_id, target_name, metadata_json]))
leaf          = H("stl.leaf.v1\0" || core || actor_commit || target_commit)

# Redaction stores a part's commit and destroys its salt and fields; verifiers use the stored commit.

# Leaves blob — 56-byte entries sorted by (created_at, event id bytes)
entry       = event_id (16 bytes, RFC 4122) || int64_be(micros(occurred_at)) || leaf (32)
leaves_hash = H(leaves).hex()

# Checkpoint
header    = enc(["stl.checkpoint.v1", project_id, str(seq), prev_hash, leaves_hash, str(leaf_count),
                 first_created_at, last_created_at, max_occurred_at, retention_cutoff,
                 str(retention_days), sealed_at, key_id])       # absent timestamps are None
hash      = H("stl.checkpoint.v1\0" || header).hex()
signature = Ed25519(bytes.fromhex(hash)).hex()
prev_hash = "0" * 64 for seq 1, else the previous checkpoint's hash
key_id    = H(raw 32-byte public key).hex()[:16]

Verifiers never canonicalise JSON: bundles ship the exact strings that were hashed. Actor profile fields (name, email, avatar) and sessions are not covered.

Verifying

In the dashboard

Compliance → Verify now checks the latest checkpoints and every event they seal, and lists any problem by kind, checkpoint and event.

With the API

Node.jsverifyIntegrity()
import { Softechlog } from '@futuregensystems/softechlog';

const log = new Softechlog({ secretKey: process.env.SOFTECHLOG_SECRET_KEY! });

const report = await log.verifyIntegrity({
  pins: await loadMyPins(),             // [{ seq, hash }] you stored yourself
  expectedRetentionDays: 90,
});
if (!report.ok) alert(report.failures);   // kind · seq · event_id · detail
Pythonverify_integrity()
report = log.verify_integrity(pins=[(41, "9f2c…")], expected_retention_days=90)
if not report.ok:
    alert(report.failures)

The server additionally checks that no stored event is missing from the chain (event_unsealed) and cross-checks erasures against their sealed evidence events. Each call covers up to 50 checkpoints; follow next_from_seq for more. The kinds are listed in the API reference.

Offline

Don't want to trust our answer? Download the data and verify it locally — no network calls, just SHA-256 and Ed25519:

Offline@futuregensystems/softechlog/verify · softechlog.integrity
import { Softechlog } from '@futuregensystems/softechlog';
import { verifyCheckpointBundle, verifyCheckpointChain } from '@futuregensystems/softechlog/verify';

const keys = await log.integrityKeys();          // cross-check the key_id against your records
const { checkpoints } = await log.listCheckpoints({ afterSeq: 0, limit: 500 });
console.log(verifyCheckpointChain(checkpoints, keys, { pins: myPins }));

const bundle = await log.checkpointBundle(42);  // contains IPs and user agents — your data
console.log(verifyCheckpointBundle(bundle, keys)); // { ok, failures, warnings, counts }

# Python — pip install "softechlog[verify]"
from softechlog.integrity import verify_bundle, verify_chain
result = verify_bundle(log.checkpoint_bundle(42), log.integrity_keys())

Bundles contain your events' IP addresses and user agents. They are your own data behind a secret key or the dashboard — handle them like an export.

Pinning

A signature proves a checkpoint came from our key — but we hold that key. Pinning is what makes the log evidence against us too: store the latest checkpoint once a day somewhere you control and we can't write, then pass your pins when verifying. If a pinned checkpoint ever differs, verification fails with pinned_mismatch.

Daily cronNode.js
// Daily cron: store the latest checkpoint somewhere we cannot write (e.g. S3 Object Lock).
const cp = await log.latestCheckpoint();
if (cp) await putObjectLocked(`softechlog/pins/${cp.seq}.json`, JSON.stringify(cp));

// Later — or during an audit — verify against every pin you hold.
const pins = (await listStoredPins()).map(cp => ({ seq: cp.seq, hash: cp.hash }));
const report = await log.verifyIntegrity({ pins });

For high-value events, also keep the leaf_hash that track() returns — it covers the few minutes before the event is sealed.

Keys

GET /v1/integrity/keys (public, no credentials) lists the signing keys: key_id, the raw public key in base64, and current or retired. Retired keys stay listed so old checkpoints keep verifying. Cross-check the fingerprint against the one published here:

Production key fingerprint: published at launch.

Rotation:

Rotationoperator runbook
# 1. Generate a new key (on a trusted machine)
python -m jobs.integrity keygen
# 2. Append the OLD public key to INTEGRITY_RETIRED_PUBLIC_KEYS (comma list)
# 3. Set INTEGRITY_SIGNING_KEY to the new seed and restart the API

If a signing key is ever compromised, we rotate it and publish the time of compromise. Only checkpoints you pinned before that time remain trustworthy — another reason to pin.

Threat model

What it proves

  • Any edit, insertion, deletion or reordering inside sealed history is detected when it was made without the signing key. That covers database access, a bad restore, a buggy migration or raw SQL.
  • Deletions are accepted only when explained by the signed retention cutoff (never newer than the retention days signed in the same checkpoint) or by a sealed erasure record.
  • With your own pinned checkpoint, Softechlog cannot rewrite, add to or remove anything sealed at or before it without detection, even though we hold the key. Two different signed checkpoints for one seq prove equivocation.

What it doesn't

  • It does not prove events were true or complete when sent; a stolen secret key ingests validly sealed events.
  • Events not yet sealed (≤ 5 minutes by default) are covered only by your leaf_hash receipts.
  • Without a pin, we hold the database and the key and could build a new consistent chain.
  • Actor name, email and avatar, and sessions, are not covered.
  • Retention is ours to apply; check the signed retention_days with expectedRetentionDays.
  • An erasure is visible, not hidden. An unexpected softechlog.actor.erased event is a signal to investigate.
  • occurred_at is caller-supplied. created_at and sealed_at are our clock; there is no third-party timestamping, so your pin time is your evidence.
  • Offline verification takes erasure explanations from the bundle; the server additionally cross-checks them against the sealed evidence event.