Signed reports (Enterprise Edition)
The Community Edition writes its reports unsigned. This page is for reports that the Enterprise Edition signs: how a report is signed, how to keep the keys you verify with, and how to verify a signed report with CertiStack or without it. Reading a report, judging one, and verifying an unsigned one are on Reports and verification.
How a report is signed
The Community Edition does not sign: its reports are written without the four
signature members and are otherwise the same document (the schema version does not
change). This section is how the Enterprise Edition signs a report, which is what
verify-report checks. Signing happens in the certistackd service, never in the temporary worker.
The worker returns unsigned evidence; the controller checks the teardown itself
and then, in the Enterprise Edition (the certistackd service), signs.
- The report, without its four unsigned members (
signature,public_key,signer_fingerprintandsignature_timestamp), is serialised as canonical JSON under RFC 8785 (JCS): keys sorted, no insignificant whitespace, numbers in their shortest form. - That byte string is signed with the signer's Ed25519 private key.
- The hex-encoded signature, the hex-encoded public key, and the key's
fingerprint (
SHA256:followed by the hex SHA-256 of the raw public key bytes) are written into the report.
Every other member, including ones a future schema adds, is covered by the
signature. A report whose JSON repeats a member name at any depth is rejected,
because a duplicate could carry a value the signature never covered. For the
same reason a report that holds an integer of 2^53 or more is refused, when it
is signed and when it is verified, and one that is oversized (over 128 MiB) or
deeply nested is refused before it is parsed. Two more corners are closed the
same way. Text that is not Unicode is refused: a byte that is not UTF-8, or half
of a surrogate pair written as a \u escape (\ud800 alone), which a decoder
reads as U+FFFD, so several different files would otherwise carry one signature.
And two members of one object whose names differ only by letter case (all_passed
and ALL_PASSED, or a name written with the long s \u017f) are refused, since
a JSON decoder matches names without regard to case and one would hide the other.
CertiStack writes neither.
The embedded public_key only says which key claims to have signed. It is
never trusted by itself: verification succeeds only against a key you supply.
What the signature means, and what it does not, is in
What a valid signature tells you.
The optional signature timestamp
A signature shows that the holder of a key signed a report, not when. A report
can carry an optional signature_timestamp member: a token from a timestamp
authority over the signature (authority, time in RFC 3339 UTC, token in
base64, and hash_algorithm, which must be sha256).
- It is not signed. The token covers the SHA-256 of the 64 raw bytes the hex
signaturedecodes to, so it can only be added after signing, and a report has the same canonical bytes with it and without it. It is not the report'stimestamp, which says when the report was built and is signed. - It adds one thing, and only when it is verified: proof that the signature existed at a time. It does not say who signed, and it does not say the recovery was good.
No report that CertiStack Community writes carries one, and Community includes
no timestamp verifier. verify-report prints a Sig timestamp line, and a
signature_timestamp object in its JSON summary, with one of four statuses:
| Status | verify-report says |
Evidence? |
|---|---|---|
none |
none |
No timestamp to rely on. |
stub |
stub timestamp, NOT evidence |
No. A stub token has no secret and no signature: anyone can write one, for any time. It exists for tests and for the fixtures of the verify page. |
unverified |
present, not verified |
No. A well-formed token of an authority this build has no verifier for. |
verified |
verified at <time> by <authority> |
Yes, and only when a verifier this build was given accepted the token for this signature. Community ships none. |
A malformed member, or a token that does not cover the report's own signature,
makes verify-report fail. A timestamp never makes an invalid signature valid:
the signature is judged first.
Keys
The Community Edition signs nothing and keeps no signing key: it has no keygen
command and does not read compliance.sign_key_path. What it keeps is the
verifier. The files below are the public keys you hold to check a report that the
Enterprise Edition signed; the private key is made and kept by that edition's
certistackd service, and never reaches a Community install.
Key file formats
| File | Format |
|---|---|
| Public key file | One line of 64 hexadecimal characters (or the 32 raw bytes). verify-report --public-key reads it, and it is also a valid one-key keyring. |
| Keyring | Either a JSON file or a plain list, of at most 1 MiB. |
A JSON keyring names the people or machines whose reports you trust. The name and node appear in the verification output:
{
"trusted_signers": [
{
"name": "controller-01",
"public_key": "c7c886ebaa4d7722003b19d141d622d635607097e838e9d1c88fe87424ba76a6",
"node": "pve-node-01",
"created_at": "2026-09-30T00:00:00Z"
}
]
}
name and public_key are the fields that matter; fingerprint is computed
when absent (a fingerprint that does not match its key is refused), and node
and created_at are informational. A plain keyring is a text file with one hex
public key per line; blank lines and lines starting with # are ignored.
A keyring is refused, with the entry named, rather than loaded with an entry
dropped: when a member name differs from the documented spelling only in letter
case, when a key appears twice, when a key is not 32 bytes of hex, when it is a
small-order or non-curve point (such as 64 zeros), or when an entry carries a
member that marks it revoked, because this release does not evaluate
revocation and the key would stay trusted. Remove an entry to revoke its key. A
keyring with no entries accepts no report, and verify-report says so.
Keep and rotate the keyring
A keyring lists the keys accepted today and carries no dates. Keeping a key in it trusts everything that key signed, whenever the report says it was signed; deleting a key stops the verification of everything it ever signed, genuine history included. Before you rotate or retire a key, or after one may have been exposed, read the signer lifecycle (item 9).
- Get a signer's public key from the operator of the Enterprise Edition that signs, by a route you trust, and compare its fingerprint with theirs.
- Keep the public keys apart from the reports they verify, in a place the auditor trusts (a signed repository, an internal wiki page that only you can edit, a ticket).
- When a signer rotates its key, add the new public key to the keyring. Keep the old public key in the keyring for as long as reports signed with it must verify, then move it to a separate historical keyring once those reports are archived with a hash record.
- If a signing key may have leaked, stop trusting new reports under it, add its replacement, and tell your auditors which reports the old key signed and until when.
Verify a signed report
verify-report checks the signature against keys you trust, then judges the report as it does an
unsigned one (Reports and verification explains acceptance, the
--expect-* flags and the exit status).
certistack verify-report --keyring /secure/certistack/trusted-signers.pub \
/var/lib/certistack/reports/minimal-single-vm/85a66c2e-439d-4d16-8b78-854395951f5d.json
The report is the deliverable. Verify it against a trusted-signer keyring: a report cannot establish trust in its own embedded public key. Give exactly one trust source:
| Flag | Trusts |
|---|---|
--keyring <file> |
Any key in a JSON or plain keyring. |
--public-key <file> |
The single key in that file. |
--trusted-key <hex> |
The 64-character hex key you type. |
verify-report refuses to run without one, and never looks for keys next to
the report or in the working directory. The one exception is --unsigned, for a
report that is expected to carry no signature (An unsigned report).
✓ Audit Certificate Signature VALID
Trust Source: keyring:/secure/certistack/trusted-signers.pub
✓ Signer identity verified against trusted keyring
✓ Signer Identity: TRUSTED ("controller-01")
Signer Node: pve-node-01
Signer Key: c7c886ebaa4d7722003b19d141d622d635607097e838e9d1c88fe87424ba76a6
Fingerprint: SHA256:6d364dc4f05ae622f480d2bf20a4b6535e7129731e4b7358bec66ebb91ff0794
Sig timestamp: none
Engine: v0.1.0 (commit: abcdef123456)
Schema: 1.7
Proxmox: PVE 9.1.1, PBS 4.2.7, proxmox-backup-client 4.2.5 (tested)
Report ID: 85a66c2e-439d-4d16-8b78-854395951f5d
Plan ID: minimal-single-vm
Plan Name: Minimal single-VM recovery check
Node: pve-node-01
Timestamp: 2026-09-30T22:09:14Z
Frameworks: [DORA]
RTO: 42.50 seconds
RTO Target: none set (the plan sets no recovery time objective)
All Passed: true
Validation: full_integrity
Admission Policy:
- Limits: RAM 85.0%, I/O wait 12.0%
- Last Check: granted (check 1)
...
Phase Timings Breakdown:
...
Teardown & Safety Evidence:
- VM Destroyed: true
- Overlays Removed: 1 disk(s)
- Loops Unmapped: 1 device(s)
- SDN Sandbox Purged: Zone csZone1 / VNet csVnet1
- Owned Resources Clean: true
- All Cleaned: true (at 2026-09-27T03:10:00Z)
Acceptance: ACCEPTED as pass evidence (internal consistency only; add --expect-plan, --expect-node, and --expect-version/--expect-commit to bind it to your request)
Verify without CertiStack
An auditor does not need the CertiStack binary. The signature is a standard
Ed25519 signature over an RFC 8785 canonical form, so any toolchain that has
both can check it. This script uses Python with the cryptography and
rfc8785 packages. The trusted public key is an argument, taken from your own
records, never from the report.
python3 -m pip install cryptography rfc8785
python3 verify_report.py report.json c7c886ebaa4d7722003b19d141d622d635607097e838e9d1c88fe87424ba76a6
#!/usr/bin/env python3
"""verify_report.py report.json TRUSTED_PUBLIC_KEY_HEX"""
import json
import sys
import rfc8785
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
def reject_duplicates(pairs):
names = [name for name, _ in pairs]
if len(names) != len(set(names)):
sys.exit("REJECT: a JSON object repeats a member name")
return dict(pairs)
report_path, trusted_key_hex = sys.argv[1], sys.argv[2].strip().lower()
try:
with open(report_path, "rb") as handle:
report = json.load(handle, object_pairs_hook=reject_duplicates)
except ValueError as error:
sys.exit(f"REJECT: not valid JSON: {error}")
# Trust comes from your own key, never from the report's embedded public_key.
if str(report.get("public_key", "")).lower() != trusted_key_hex:
sys.exit("REJECT: the report was not signed by the trusted key")
if not report.get("signature"):
sys.exit("REJECT: the report is unsigned")
signature = bytes.fromhex(report.pop("signature"))
report.pop("public_key")
report.pop("signer_fingerprint", None)
report.pop("signature_timestamp", None) # a timestamp of the signature; not signed
try:
Ed25519PublicKey.from_public_bytes(bytes.fromhex(trusted_key_hex)).verify(
signature, rfc8785.dumps(report)
)
except InvalidSignature:
sys.exit("REJECT: the signature does not match the report contents")
print("OK: signature valid")
print("all_passed:", report["all_passed"])
print("all_cleaned:", report.get("teardown_evidence", {}).get("all_cleaned"))
Use a real RFC 8785 implementation. Serialising with json.dumps(sort_keys=True)
looks equivalent but is not: it writes the float 1.5e-7 as 1.5e-07, which
changes the bytes and fails verification of a perfectly good report. CertiStack
also refuses a report that holds an integer of 2^53 or more, which the signature
could not cover exactly.
The script checks the signature and nothing else. It does not apply the
acceptance rules of verify-report (a signed all_passed: true over a failed
probe prints OK here), so read the report yourself, or run verify-report
--expect-outcome pass where the binary is available.
Verify in a browser
The CertiStack website has a static page, https://certistack.dev/verify/, that checks a report's signature in your browser, for someone who has been handed a report and has no CertiStack to run (an auditor, a customer, a colleague). Everything happens on your machine: the page makes no request, and the report is read by the browser and goes nowhere.
It does what the script above does. It re-creates the canonical bytes in
JavaScript (RFC 8785, refusing what verify-report refuses: a repeated member
name, an integer of 2^53 or more, and the same size and nesting limits), checks
the Ed25519 signature with the browser's WebCrypto, refuses an embedded public
key that cannot anchor trust, and compares the signer to a public key or
fingerprint you paste. It shows the schema, plan, whether the run passed, the
recovery time and the signer's fingerprint.
What it does not do:
- It does not say who signed. Without a key you paste, a valid result means the report was not altered since it was signed by whoever holds the embedded key. Anyone can sign a report under a key of their own.
- It does not apply the acceptance rules of
verify-report: a valid signature over a failed recovery is shown as valid, withall_passedfalse. Useverify-reportwith--expect-*flags for the verdict. - It does not verify timestamps. A timestamp token is reported as present.
- It needs a browser with Ed25519 in WebCrypto (a current Chrome or Edge, Firefox 129 or later, Safari 17 or later). Where there is none it says so; it never shows a pass it did not compute.
What a valid signature tells you
A valid signature from a key you trust tells you that the report is exactly what that key's holder signed, and so that nothing in it was altered since. It does not tell you that the recovery is good enough for your business, that a framework label in the report has been met, or that the signing service was not misconfigured. Framework labels declare scope; they are not a certification. A report signed by the Enterprise Edition is a tamper-evident record attributable to the key holder; what it proves, and what it does not, is bounded by the supported recovery contract.
In a signed report, three things are worth reading every time: validation_mode must be
full_integrity, teardown_evidence.all_cleaned must be true, and the
acceptance verdict verify-report gives must be ACCEPTED for the request you
made.