Audit Logging

Audit logs are newline-delimited JSON records.

Each line contains:

  • event: audit event payload
  • signature: Signature over the encoded event

Signature algorithm depends on the backend

  • If platform-ecc is included signature algorithm always would be Ed25519
  • If ONLY platform-pqc is included signature algorithm always would be ML-DSA-44

Ed25519 currently has a higher priority because the present threat model does not yet require post-quantum signatures for every audit event, while ML-DSA signatures would significantly increase audit-log storage and network traffic. The priority can be switched to ML-DSA as the quantum threat becomes more immediate.

The signing key is TKeeper's integrity key. event.integrityKeyVersion tells the verifier which integrity public key version to use.

Example line, formatted for readability:

{
  "event": {
    "id": "01J9Y3J7F8H4B8N8H5M6Y2K3Q1",
    "peerId": 1,
    "integrityKeyVersion": 3,
    "timestamp": 1760000000000,
    "event": "keeper.sign",
    "auth": {
      "subject": "service:payments-api"
    },
    "context": {
      "sid": "sign-01J9Y3K2H7G9M5N4"
    },
    "request": {
      "method": "POST",
      "path": "/v2/keeper/sign",
      "remoteAddress": "10.0.12.44"
    },
    "crypto": {
      "algo": "ECDSA",
      "kid": "payments-hot",
      "generation": 2
    },
    "digest": {
      "purpose": "audit",
      "hmacKeyVersion": 4,
      "bodyHash": {
        "alg": "HMAC_SHA256",
        "value64": "3Aq9i3sM1c03eK1d8eAH7Q=="
      }
    },
    "outcome": {
      "statusCode": 200
    },
    "approvers": [
      "Jq7P3Zx7T0Jmj5..."
    ],
    "policy": {
      "decision": "ALLOW",
      "matches": [
        {
          "id": "small-payment",
          "effect": "ALLOW"
        }
      ]
    },
    "imposters": [],
    "dead": []
  },
  "signature": "MEUCIQD3n7uN..."
}

Some fields depend on the operation. approvers appears only for approved operations. policy appears when a Verdict authority was evaluated. imposters and dead appear when a threshold protocol reports bad or unavailable peers.

auth.subject is the identity authenticated on the current HTTP hop. For a direct public request it is the external principal. For a protected peer request it is the keeper peer that signed the internal request, while optional auth.actor preserves the original external principal. Peer-only protocol rounds omit actor.

The policy object is Verdict's PolicyEvaluation: decision plus matched rules.

Integrity boundary

The signature detects modification of an individual encoded event when the verifier has the correct integrity public key. It does not by itself prove that the log is complete, correctly ordered, retained, or delivered to every configured sink.

For compliance or incident evidence:

  • preserve events in an access-controlled external system
  • retain integrity public keys for every referenced version
  • monitor gaps, duplicate ids, and unexpected time ordering at the collector
  • define retention and deletion controls outside TKeeper
  • verify records independently instead of trusting only the producer

File sink

keeper.audit {
  enabled = true
  timeout = 1000

  file {
    directory = "/var/lib/tkeeper/audit"
    extension = "ndjson"
    prefix = "audit"
    max-file-size-bytes = 67108864
    roll-every = 1d
    max-files = 10
    retention-days = 30
    gzip = false
    fsync = false
  }
}

Socket sink

keeper.audit {
  enabled = true

  socket {
    host = "audit.local"
    port = 443
    tls {
      protocols = ["TLSv1.3", "TLSv1.2"]
      verify-hostname = true
      trust {
        mode = "system"
      }
    }
  }
}

The socket sink marks a line accepted after TKeeper writes and flushes it to the local socket stream. This is not a collector durability acknowledgement. If the local write does not complete before ack-timeout, TKeeper treats that sink as failed.

Verification

Verify one signed audit line:

POST /v1/keeper/audit/verify

Body:

{
  "event": {
    "id": "01J9Y3J7F8H4B8N8H5M6Y2K3Q1",
    "peerId": 1,
    "integrityKeyVersion": 3,
    "timestamp": 1760000000000,
    "event": "keeper.sign",
    "auth": null,
    "context": null,
    "request": null,
    "crypto": null,
    "digest": null,
    "outcome": null,
    "approvers": null,
    "policy": null,
    "imposters": null,
    "dead": null
  },
  "signature": "..."
}

Response:

{ "valid": true }

Verify a batch:

POST /v1/keeper/audit/verify/batch

Body:

{
  "logs": [
    {
      "event": {
        "id": "01J9Y3J7F8H4B8N8H5M6Y2K3Q1",
        "peerId": 1,
        "integrityKeyVersion": 3,
        "timestamp": 1760000000000,
        "event": "keeper.sign",
        "auth": null,
        "context": null,
        "request": null,
        "crypto": null,
        "digest": null,
        "outcome": null,
        "approvers": null,
        "policy": null,
        "imposters": null,
        "dead": null
      },
      "signature": "..."
    }
  ]
}

Batch response is keyed by event id:

{
  "01J9Y3J7F8H4B8N8H5M6Y2K3Q1": {
    "valid": true
  }
}

Required permission:

tkeeper.audit.log.verify

Rotate the integrity key:

POST /v1/keeper/integrity/rotate

Required permission:

tkeeper.integrity.rotate

Restart every peer after rotating an integrity key. Peer integrity-key pins are process-local in 2.2.0; until restart, callers that previously contacted the rotated peer continue to reject its new signed responses.

Rotation keeps historical public keys for log verification and removes the corresponding historical private keys. Replaying only the current-version pointer fails closed when it no longer matches the stored history. A coordinated same-location replay of the pointer and its matching records can still pass local verification, as can a complete database rollback. Detecting that class of rollback requires an independently protected monotonic checkpoint or external audit anchor.

Audit-HMAC keys are encrypted in record-id-bound envelopes and must decode to exactly 32 bytes. TKeeper migrates older unbound HMAC records during unseal and does not become ready until the bound form validates.

Peers expose their current integrity public key on the internal API:

GET /v1/integrity/publicKey

Response:

{ "data": "base64-public-key" }

Failure behavior

When audit is enabled, TKeeper checks sink availability before protected operations. At least one configured sink must be available.

When an event is written, the operation continues if at least one configured sink accepts the event before the audit timeout. If all configured sinks fail or miss the timeout, the operation fails with AUDIT_FAILED.

Multiple configured sinks are therefore redundant destinations, not an all-sinks durability guarantee. If policy requires delivery to a particular archive, enforce and monitor that requirement at the deployment or collector layer.

Common problems

Audit sink is down

If at least one sink is alive, operations continue. If no sink is available, protected operations fail with AUDIT_NOT_AVAILABLE before the crypto session starts.

Verification fails

Check the line encoding, the Ed25519 signature, and event.integrityKeyVersion.