Signed reports for auditors: the report kinds and how to verify them
This page is for an auditor who needs a generated, signed artefact of the backup system’s behaviour, and who wants to verify that artefact themselves rather than take the console’s word for it. It describes the report kinds, the JSON and PDF each produces, the signature scheme, and the exact verification step you run out of band.
A report is generated in your own Cloudflare account, over your own observable state, by the engine. The vendor reads nothing. When a signer is configured the report is signed with the engine’s own signer, so the artefact you take away is tamper-evident and you can confirm it came from your engine. Everything on this page is taken from the report generator, the signing path, the PDF writer and the reports screen in the code.
The report kinds
The report kinds are a closed set, defined as the ReportKind union in reports.ts. There are six, each generated from a different slice of the account’s own observable state, and each is viewable as JSON and downloadable as a PDF. The first four are described below; the fifth is the framework-organised compliance evidence pack, covered on compliance and evidence, which re-projects the posture report through a compliance framework’s control mapping and is signed the same way; the sixth is change records, an owner-opt-in kind covered after the table. A typo in the kind cannot reach the engine: an unknown kind is a 404.
| Kind | What it attests | Period |
|---|---|---|
restore-tests |
The drill evidence recorded over the period (each scheduled or manual restore test) plus the last-test recency per downpipe | A time window, defaulting to the last 90 days |
sla-compliance |
Per downpipe, the expected versus successful run counts, the freshness, and a strikes count, so a missed schedule is evidenced rather than assumed | A time window, defaulting to the last 90 days |
immutability |
An attestation of the recoverability properties in force: the configured destination’s real WORM property and the break-glass versus operational posture | Point in time |
posture |
The posture report as a signed artefact: the score and the severity-ranked control checks at the moment of generation | Point in time |
evidence-pack |
The live posture re-projected through one compliance framework’s control mapping (or every framework, for the all pack): per control, the obligation, how downpipes supports it, and the live posture-check status that evidences it. It supports the obligation; it does not certify you. |
Point in time |
change-requests |
Every change-controlled action over the period (a destination repoint or removal, an identity-provider change, a restore apply, clearing either push destination, and the other owner actions), with its change number, who made it, and whether it was an Emergency Change to validate retrospectively. Config mutations are not in it, because the Require Change Number policy does not cover them. Shown in the console as “Change records” | A time window, defaulting to the last 90 days |
The change-requests kind is owner-opt-in: the console only offers its card, labelled “Change records”, when the “Require Change Number” policy is switched on, so a tenant that never enabled change management is not shown an always-empty report. The engine still serves it by direct URL regardless. The three time-bounded kinds (restore-tests, sla-compliance, change-requests) take their window from ?from= and ?to= as epoch seconds and default to the last 90 days when you do not supply one. A non-numeric or negative from or to falls back to that default rather than failing the read; a numeric but inverted range, where from is after to, is swapped rather than reset to the default. The three point-in-time kinds (immutability, posture, evidence-pack) carry a null period and ignore the window. The immutability report is itself an attestation rather than a blanket claim; the precise meaning lives on immutability and attestation.
Reading and downloading a report
In the console, each report is a card on the Reports screen. The card loads its metadata on first paint, so you see when it was generated, the period it covers and its signature state without a click, and it offers View JSON and Download PDF. Under the hood the JSON comes from GET /admin/reports/:kind and the PDF from the same path with ?format=pdf. Both reads are available to any authenticated role. The evidence-pack kind additionally takes ?framework= (a framework id, or all for every framework, which is the default); an unknown framework id is a 404, just like an unknown kind.

This is a time-bounded kind, so the card resolves the default window to real dates rather than showing an unbounded report: the period reads as the ninety days ending at generation. The point-in-time kinds carry a null period and say so instead, as the posture card further down this page does.
The recovery-time block below it is part of the SLA report body rather than console decoration: each per-downpipe row carries a derived estimate and the number of drills it was derived from. It is a projection from observed restore-test throughput, scaled to the current archive size, and the card says so in place rather than leaving the number to be read as a commitment. A downpipe with no restore-test history carries no estimate at all instead of a fabricated one, so an absent figure means the drills to support it have not run.
What a report can and cannot carry
The no-custody rule holds inside a report exactly as it holds everywhere else. Every report body is a redaction-safe projection built from data the input types already carry, so there is no field a secret could live in. The table below is the full set.
| A report carries | A report never carries |
|---|---|
| Downpipe names and ids (your own configuration) | A key or any key material |
| Counts, expected and successful run totals, strikes | A value or a record’s contents |
| Recency and event timestamps | A private fingerprint |
| Coarse states (fresh, configured, pass or fail) | A destination endpoint, bucket or credential |
| The destination kind, as an enumeration | A selector or a binding’s secret |
| A single public signature string | The signer’s private material |
| A derived recovery-time estimate and the drill count behind it, on the SLA kind, and only where one can be derived | A guaranteed recovery time, since that estimate is a projection |
The signature is the only cryptographic field, and it is a public detached value. It carries no private key material, which is why a signed report is safe to hand to an auditor or attach to a board pack.
The signature scheme
When a signer is configured, the report is signed with the engine’s post-quantum hybrid signer. The scheme is Ed25519 together with ML-DSA-87, the same hybrid the rest of the platform uses. The signature is computed over the canonical JSON of the signed-over view, which is exactly the object {kind, generatedAt, period, data} and nothing else, so the signer and any verifier serialise the same field set in the same canonical, key-sorted form.
The value is a detached string with the scheme prefix edmldsa1:, followed by the base64url of the raw detached signature bytes. The prefix tells a verifier the scheme before it decodes anything. Because canonical JSON sorts keys, the field order in the report does not matter to the signature, but the field set must match exactly, which is why the signed view is computed in one place in the code.
Signing is fail-open, and an unsigned report is informational only
If no signer is configured, or if signing throws for any reason, the engine returns the report unsigned rather than failing the read. The data is still valuable, but an unsigned report is not tamper-evident: treat its figures as informational, never as assured. A missing signer must never turn a read into an error, so the unsigned report is the honest fallback, clearly marked as such.
What the console does, and what it does not
This is the boundary that matters most for an auditor, so it is stated plainly. The console reads the signature off a report and checks that it is present and well-formed, with the expected edmldsa1: prefix. When that holds, it shows the report as signed and tamper-evident, and it surfaces the signature value for you to copy.
The console does not verify the signature. The verifying public key is not shipped to the browser, so there is no key in the page to check against. A report’s figures are therefore not verified in the browser; full cryptographic verification is an out-of-band step you run yourself, against the published engine signer fingerprint, using the recipe below. The console’s signature state has three honest readings.
| State | What the console asserts | What it does not assert |
|---|---|---|
| Signed, tamper-evident | The signature is present and carries the expected post-quantum hybrid prefix | That the signature has been cryptographically verified |
| Signed, unrecognised scheme | A signature is present but not with the expected prefix; verify before relying on it | Anything about the scheme’s validity |
| Not signed | No signature is present, so the figures are informational only | Any tamper-evidence at all |
Verifying a report out of band
Verification is a step you run yourself, away from the console, against your own engine signer. The signer fingerprint to check against is the one your key ceremony produced and printed on your recovery sheet: the edmldsa1: fingerprint is the SHA-384 of the signer’s public key, generated in your browser during the ceremony and never held by the vendor. The ceremony also lets you save the signer public key file (downpipe-signer-public-v1); the offline downpipe tool’s verify command reads a signer public key file of this kind, but only to verify an archive run, not a signed Report JSON. There is no built-in downpipe subcommand for report verification, so the steps below are the manual recipe: implement them yourself with a general-purpose Ed25519 and ML-DSA-87 library, or ask your own engineering team to script it once from this page.
Take the report as JSON, not the PDF
Open the report card and use View JSON, or fetch
GET /admin/reports/:kinddirectly. You need the exact JSON object the engine emitted, including thesignaturefield. The PDF is for reading; the JSON is what is signed.Reconstruct the signed-over view
The signature is over the canonical JSON of
{kind, generatedAt, period, data}only, with keys sorted and no extra fields. Build that object from the report’s own values. Anything outside those four keys is not part of what was signed.Split the signature into its scheme and its bytes
The
signaturefield isedmldsa1:followed by a base64url payload. Strip the prefix, base64url-decode the remainder, and you have the raw detached hybrid signature bytes: the Ed25519 signature followed by the ML-DSA-87 signature.Verify against your published signer fingerprint
Confirm the signer public key matches the
edmldsa1:fingerprint on your recovery sheet by hashing the public key with SHA-384, then verify the detached signature over the canonical body with the hybrid verifier: the Ed25519 half and the ML-DSA-87 half must both check out against their respective segments of the signature bytes. This step has no existingdownpipesubcommand to run for you; it is a manual verification against the public primitives above. A pass means the report came from your engine and has not been altered; a fail means it has, or it is not your signer.
Because the fingerprint you check against is the one you captured at ceremony time and keep yourself, verification does not depend on the vendor at any point. That is deliberate: a no-custody design cannot ask you to trust a vendor-held key, so the assurance is anchored to a value you published from your own ceremony.
The PDF: deterministic and dependency-free
The PDF is a small, branded document the engine writes itself, with no PDF library, no headless browser and no embedded font. It uses only the standard base fonts that every reader has, plus vector graphics and the report’s own already-redaction-safe fields. The Report is its sole input, and that input carries no secret, so the PDF cannot leak one.
It is deterministic by construction. There is no Date.now and there are no random ids in the output; the only time it shows is the report’s own generatedAt. Given the same report, the bytes are identical, which means two people generating the same report get byte-identical files and a stored PDF can be compared exactly. The signature line on the document states the precise claim: when signed it reads as present, tamper-evident and signed, with the scheme prefix; when unsigned it says plainly that the signer is not configured. It is never written as the stronger word.
Where this fits
To understand what the immutability report’s attestation actually proves, read immutability and attestation. The posture report is a signed snapshot of the live posture computation, which is documented in full on the posture score. The same live posture, re-projected control by control through a compliance framework, is the evidence-pack kind, covered on compliance and evidence. The restore-tests and SLA reports draw on drill evidence and run history, so prove recoverability explains how that evidence is produced. For the standing honest phrasings behind every claim here, read precise claims and honesty, and for the report read and download calls in full, see the admin endpoints reference.
Last updated .