Skip to content

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.

  1. The report, without its four unsigned members (signature, public_key, signer_fingerprint and signature_timestamp), is serialised as canonical JSON under RFC 8785 (JCS): keys sorted, no insignificant whitespace, numbers in their shortest form.
  2. That byte string is signed with the signer's Ed25519 private key.
  3. 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 signature decodes 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's timestamp, 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, with all_passed false. Use verify-report with --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.