Menu QaldyDocs

Stream, export and verify the audit log

Beta

Download your workspace's audit log, stream it to a signed webhook or your own bucket, and check a copy offline with a short script, with no Qaldy account and no trust in our word.

The audit log is a hash chain Qaldy checks for you. This page is for the admin or the reviewer who wants a copy of their own and wants to check it without us. Everything below is under Security, in Stream and export, and needs an admin.

WantDoStatus
A copy of the whole log, onceDownload the log: JSON Lines, oldest first, up to 50,000 events a downloadBeta
A copy that follows as the log growsAdd an audit destination of type webhookBeta
The same into your own bucketAdd an S3 destination: a role you own, an External ID, no key of yours storedPreview
Proof that nothing before a point changedDaily signed checkpoints, checked with the script belowBeta

Every event has the fields the hash covers: seq, at, workspaceId, actor, action, target and data, plus prevHash and hash. Rows hold ids and metadata, never a secret, a prompt or an app's data.

Stream and export on the Security screen: download the log, add an audit destination, and the signed checkpoints with their public key.
Stream and export on Security. Sample data.

A webhook

Qaldy POSTs JSON to the HTTPS address you give:

json
{
  "v": 1,
  "type": "audit.events",
  "workspaceId": "ws_…",
  "deliveryId": "dlv_sink_…_1_500",
  "events": [{ "seq": 1, "at": "…", "workspaceId": "ws_…", "actor": {}, "action": "…", "target": {}, "data": {}, "prevHash": "…", "hash": "…" }],
  "checkpoint": { "workspaceId": "…", "seq": 184, "hash": "…", "at": "…", "keyId": "…", "signature": "…" }
}
  • Up to 500 events a request, oldest first, in order, with no gap. A retry repeats the same deliveryId and the same events, so deduplicate on seq. After an outage you get the missed events in order.
  • A type of ping with no events is the Send a test button.
  • checkpoint is the newest signed checkpoint at the time, or null.
  • Headers: x-qaldy-signature: t=<unix seconds>,v1=<hex> and x-qaldy-delivery: <deliveryId>.

Check the signature with the secret shown once when you added the destination (it starts with qas_). Use the raw body, not one you parsed and wrote again:

js
import { createHmac, timingSafeEqual } from 'node:crypto'

export function holds(secret, header, rawBody, nowSeconds = Date.now() / 1000) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  const t = Number(parts.t)
  if (!Number.isInteger(t) || Math.abs(nowSeconds - t) > 300) return false
  const mac = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
  const a = Buffer.from(mac)
  const b = Buffer.from(parts.v1 ?? '')
  return a.length === b.length && timingSafeEqual(a, b)
}

The signature proves the request came from your workspace's Qaldy and was not altered. It does not hide the content, so use an HTTPS endpoint you control.

Where Qaldy will and will not send. HTTPS on port 443 only, to a host name rather than an IP address, with no user name, password or ? part. Qaldy resolves the name, refuses any address in a private, loopback, link-local or metadata range, connects to the address it checked, follows no redirects and gives up after 10 seconds. Anything but a 2xx counts as a failure: Qaldy backs off from one minute to an hour, and after three failures in a row the destination shows Failing and one row goes to the audit log. Pause stops sending; resuming continues from the same event.

Your own S3 bucket

Preview Qaldy never holds an AWS key of yours. You create a role; Qaldy's API assumes it for 15 minutes at a time.

  1. In the dialog give the bucket, the region, an optional folder and the ARN of a role you will create.
  2. The next screen shows the trust policy (Qaldy's principal and an External ID that belongs to this destination alone). Paste it on the role. The External ID makes the role unusable by anyone else.
  3. Allow the role s3:PutObject on arn:aws:s3:::<bucket>/<folder>/*. Nothing else is needed.

Objects are named <folder>/<workspaceId>/<yyyy>/<mm>/<dd>/<firstSeq>-<lastSeq>.jsonl, and the newest checkpoint of each batch goes to <folder>/<workspaceId>/checkpoints/<seq>.json. Your bucket's default encryption applies.

Checkpoints

Once a day, for each workspace whose log moved, the API signs {"at","hash","keyId","seq","v":1,"workspaceId"} (canonical JSON, RFC 8785) after the line qaldy.audit.checkpoint.v1 and a newline, with ed25519. keyId is the first 8 bytes of the SHA-256 of the public key, in hex. The checkpoints and the public key are what GET /v1/workspaces/{ws}/audit/checkpoints returns, and the Security screen shows the public key. Pin that key from a source you trust, for instance by copying it the day you set this up: a key fetched from the same place as the data proves nothing about it.

Check a copy yourself

This script reads the JSON Lines file (or the batches your webhook received, joined in order). It recomputes every hash and every link, and, if you give it the checkpoints and the public key as 64 hex characters, checks each checkpoint's signature and that the event at that number still has the signed hash. It uses nothing but Node 20 or newer.

verify-audit.mjs
// node verify-audit.mjs audit.jsonl [checkpoints.json <public key, 64 hex characters>]
import { createHash, createPublicKey, verify } from 'node:crypto'
import { readFileSync } from 'node:fs'

// RFC 8785: object keys sorted by UTF-16 code unit, no spaces.
const canonical = (v) =>
  v === null || typeof v !== 'object'
    ? JSON.stringify(v)
    : Array.isArray(v)
      ? `[${v.map(canonical).join(',')}]`
      : `{${Object.keys(v)
          .filter((k) => v[k] !== undefined)
          .sort()
          .map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`)
          .join(',')}}`

const [eventsFile, checkpointsFile, publicKeyHex] = process.argv.slice(2)
let bad = 0
const fail = (message) => {
  bad += 1
  console.log(`BROKEN  ${message}`)
}

const events = readFileSync(eventsFile, 'utf8')
  .split('\n')
  .filter(Boolean)
  .map((line) => JSON.parse(line))
const hashAt = new Map()
events.forEach((event, i) => {
  const { prevHash, hash, ...body } = event
  const before = events[i - 1]
  if (!before && event.seq === 1 && prevHash !== '0'.repeat(64)) fail('event 1 does not start the chain')
  if (before && event.seq !== before.seq + 1) fail(`event ${event.seq} does not follow ${before.seq}: events are missing`)
  else if (before && prevHash !== before.hash) fail(`event ${event.seq} does not link to the event before it`)
  const want = createHash('sha256')
    .update(Buffer.from(prevHash, 'hex'))
    .update(canonical(body), 'utf8')
    .digest('hex')
  if (want !== hash) fail(`event ${event.seq}: its content does not match its hash`)
  hashAt.set(event.seq, hash)
})
if (bad === 0) console.log(`OK      ${events.length} events: every hash and link holds`)

if (checkpointsFile) {
  const raw = JSON.parse(readFileSync(checkpointsFile, 'utf8'))
  const list = Array.isArray(raw) ? raw : raw.items
  const spki = Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(publicKeyHex, 'hex')])
  const key = createPublicKey({ key: spki, format: 'der', type: 'spki' })
  for (const cp of list) {
    const { workspaceId, seq, hash, at, keyId } = cp
    const message = `qaldy.audit.checkpoint.v1\n${canonical({ v: 1, workspaceId, seq, hash, at, keyId })}`
    if (!verify(null, Buffer.from(message), key, Buffer.from(cp.signature, 'base64url')))
      fail(`checkpoint at event ${seq}: the signature does not verify`)
    else if (hashAt.has(seq) && hashAt.get(seq) !== hash)
      fail(`checkpoint at event ${seq}: genuine, but the events no longer match it`)
    else console.log(`OK      checkpoint at event ${seq}`)
  }
}
process.exit(bad === 0 ? 0 : 1)

Exit code 0 means everything held; 1 means something did not, and the line says which event. If your file starts after event 1 (a partial export) the first event's link to the one before it cannot be checked.

What is recorded about all this

audit.viewed (the log was opened), audit.exported, audit.sink_created, audit.sink_updated, audit.sink_deleted, audit.sink_failing and audit.sink_recovered, connector.inventory_viewed (the connections or keys list), and workspace.panic. An admin who points the log somewhere else leaves a row in the log.

What it still cannot do

  • It cannot vouch for events newer than the newest checkpoint, except by the delivery lag of a stream you keep.
  • It does not prove the first events were honest, only that nothing changed after the checkpoint that covers them.
  • Nobody outside the team has audited the implementation.

Type to search. Up and down to choose, Enter to open, Esc to close.