# Stream, export and verify the audit log 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. URL: https://docs.qaldy.com/guides/audit-export/ The [audit log](https://docs.qaldy.com/concepts/audit/) 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. | Want | Do | Status | |---|---|---| | A copy of the whole log, once | **Download the log**: JSON Lines, oldest first, up to 50,000 events a download | [[Beta]] | | A copy that follows as the log grows | Add an **audit destination** of type webhook | [[Beta]] | | The same into your own bucket | Add an **S3** destination: a role you own, an External ID, no key of yours stored | [[Preview]] | | Proof that nothing before a point changed | Daily signed **checkpoints**, checked with the script below | [[Beta]] | 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.](https://docs.qaldy.com/screens/stream.webp "Stream and export on Security. Sample data.") ## A webhook Qaldy `POST`s 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=,v1=` and `x-qaldy-delivery: `. **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::://*`. Nothing else is needed. Objects are named `////
/-.jsonl`, and the newest checkpoint of each batch goes to `//checkpoints/.json`. Your bucket's default encryption applies. :::warning Not working on the pilot yet The S3 signing code is tested against AWS's published vectors and a stubbed endpoint. It has **not been run against a real bucket**, and the pilot's API container has no AWS credentials to assume the role with, so both S3 export and the locked checkpoint copy report that credentials are unavailable and nothing is marked as copied. Use a webhook or the download until this changes. This page changes the day it does. ::: ## 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. ```js title="verify-audit.mjs" // node verify-audit.mjs audit.jsonl [checkpoints.json ] 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.