Verifiable compliance evidence for internet services — free software.
This directory is the AGPL-3.0 carve-out of the OMEGA Ecosystem, decided by the author (Roberto Locatelli) on 2026-08-08.
| Component | License |
|---|---|
this repository (cryptovalid-opencore) — evidence format spec, standalone verifier, and every deliverable funded by open-source grants |
AGPL-3.0-or-later (see LICENSE in this directory) |
| Everything else in the OMEGA Ecosystem repository | BSL 1.1 (source-available; see repository root) |
Copyright for both sides: Roberto Locatelli, 2026. The author licenses the contents of this directory under the GNU Affero General Public License v3.0 or later. Contributions to this repository are accepted under the same license.
Commercial license — for AGPL-averse enterprises. The AGPL-3.0 (esp. the §13 network clause) deliberately prevents cloud-stripping, but many corporate legal policies forbid AGPL to avoid copyleft contagion. If that is your case, a commercial license of this code is available from the author (roberto.locatelli.81@gmail.com): the same code, without the AGPL network/copyleft obligations. This is the standard open-core arrangement — AGPL for the community, a commercial license for enterprises that need it — so the copyleft protection is a positioning choice, not an adoption dead-end.
Stated plainly so no one relies on a guarantee that does not exist:
- Measured (low patent exposure): the primitives here are open standards or public prior art — SHA-256/SHA3 (FIPS 180-4/202, royalty-free), Ed25519 (published patent-free by its authors), RFC 3161 timestamping and RFC 6962 Merkle trees (open IETF standards), and hash-of-document timestamping + hash-chaining, whose foundational patents (Haber–Stornetta, US 5,136,646/647) expired over a decade ago. There are no third-party dependencies, so no patent or licence travels in from an external library.
- Not established (residual risk — needs a lawyer, not this file): a professional freedom-to-operate search has not been performed. Patent risk in software is rarely in the algorithms; it is in combinations and business methods (e.g. "verifying regulatory compliance using a blockchain anchor"), and it varies by jurisdiction — US software/business-method patents are broader than the EU (Art. 52 EPC). AGPL-3.0 §11 grants a patent licence only from this project's contributors; it does NOT shield you from a third party's patent.
- Post-quantum layer (0.12.0 → 0.13.0), checked on 16/09/2026 on primary sources: ML-DSA is FIPS 204, a public NIST standard; NIST's submission rules required signed IP statements from every submitter, and NIST's two royalty-free patent licence agreements (a US portfolio and the CNRS / Université de Limoges portfolio) concern CRYSTALS-Kyber / ML-KEM, which this project does not use; no patent licence was needed for CRYSTALS-Dilithium / ML-DSA, whose submitters declared none. The hybrid here is two independent signatures (Ed25519 and ML-DSA-65 over the same bytes, separate keys, no combined key or OID): it does NOT implement the IETF LAMPS "composite signatures" draft (draft-ietf-lamps-pq-composite-sigs, RFC Editor queue, one IPR disclosure listed on the datatracker). AWS KMS is used through its public API under AWS's terms. The Java verifier uses only JDK APIs (GPLv2 + Classpath Exception runtime; this file is AGPL-3.0). None of this is an FTO opinion.
- Your responsibility: this project is provided as-is, with no warranty of non-infringement. Anyone deploying it — especially commercially or in the United States — should conduct their own patent due diligence / FTO. The author has not, and this document is not legal advice.
A self-hosted toolkit that turns compliance activities into evidence anyone can re-execute:
- append-only hash-chained ledgers (canonical JSON, SHA-256);
- Ed25519-signed records (optionally hybrid Ed25519 + ML-DSA-65, FIPS 204, since 0.12.0);
- RFC 3161 independent timestamps;
- a standalone verifier (
verifier.py, in this directory): one command, no server, no vendor — a third party replays the chain and reaches the same hashes, or the verification fails. Trust is replaced by verification.
CryptoValid is submitted as a candidate Digital Public Good. Its relevance to the SDGs is institutional transparency and accountability, not (yet) financial inclusion:
-
SDG 16 — Peace, Justice and Strong Institutions. Verifiable, tamper-evident compliance evidence directly supports target 16.6 (effective, accountable and transparent institutions) and target 16.5 (reduce corruption): any third party can re-execute
verifier.pyand reach the same hashes, so an institution's records can be checked without trusting the institution — accountability by verification, not by assertion. -
SDG 9 — Industry, Innovation and Infrastructure. It is open, dependency-free (
dependencies = []) digital infrastructure for trustworthy record-keeping, usable by any organisation at no licence cost. -
SDG 1 / 8 / 10 — NOT claimed. They were withdrawn from the DPG nomination on 2026-08-22 after the 4-mind review (see
DPG_NOMINATION.md, «SOLO 16 + 9»): themicrofinancemodule is a real capability, but a proof-of-integrity tool does not by itself advance poverty or inequality targets. What it does:microfinance.pyapplies the same primitives to microcredit transparency: a microfinance institution's loan portfolio is canonicalized (same portfolio → same digest, re-derivable by a donor or regulator), its internal consistency is attested (disbursed = outstanding + repaid + written-off), and standard Portfolio-at-Risk (PAR30/PAR90) is computed. On the borrower-protection side,over_indebtedness()detects over-indebtedness — a borrower carrying loans across multiple institutions (a debt-trap signal) — using a hashed borrower reference so exposure is aggregated without any PII.
Honest scope (no overclaim): the microfinance module is a real capability but is currently validated on synthetic loan data (unlike the fund side, which is validated on real SEC filings) — MFIs do not publish portfolios the way funds do, so real-data validation awaits an operator's data. The confine is unchanged: proof-of-integrity, not proof-of-veracity (it attests that records are internally consistent and tamper-evident, not that the loans are real or well-underwritten). No PII by design (see PRIVACY.md).
The broad compliance-automation market (evidence-collection dashboards) is owned by SaaS incumbents and is not the target — their model is trust the vendor's database. CryptoValid targets the narrower, regulation-driven case where evidence must survive without trusting any vendor:
- EU AI Act high-risk systems must keep automatic logs (Art. 12) for ≥ 6 months (Art. 19);
the high-risk obligations for Annex III systems apply from 2 December 2027 (deferred by the
Digital Omnibus, Reg. (EU) 2026/1744 — the original 2 August 2026 date no longer holds; see
spec/regulatory_profiles.json, which carries the dated source). - For records to be admissible in judicial or regulatory proceedings, each event should be timestamped with an eIDAS qualified timestamp and made immutable by a third party independent of both provider and deployer — cryptographic measures, not access controls.
That is exactly this format's shape: offline third-party verification (verifier.py), a
qualified-TSP-ready Signed Tree Head (see Merkle proofs below), and tamper-evidence that
fails loudly. Honest limits: this is an emerging segment (not proven revenue); the qualified
timestamp needs a real QTSP integration; and general-purpose "crypto-ledger" demand is genuinely
weak (cf. AWS retiring QLDB). The wedge is court-admissible AI/records evidence, not a database.
Delivered and tested — the CI badge above is green on every push:
- append-only hash-chained ledgers + a standalone stdlib verifier, defined by
conformance test vectors (
spec/vectors/) so any language can prove interoperability; - Ed25519-signed records (
signer.py) and a signed, auditor-ready evidence pack (evidence_pack.py) that a third party verifies offline, vendor-free; - RFC 3161 timestamping — cryptographically verified against a real public TSA;
- a self-updating regulatory profile (MiCA / EU AI Act / DORA / GDPR) that carries provenance and flags stale mappings for human review;
- adversarially hardened: red-team passes found and closed real gaps (truncation and manifest
re-forge at the signed pack layer, timestamp forgery, cross-language JSON canonicalisation; and on
2026-09-11, a bare-ledger verifier that answered
PASSon an empty file) — each with a regression test. Know whatverifier.pyproves: a bare hash chain is internally consistent evidence — it cannot see truncation or a re-chained suffix written by someone with write access to the file. The receipt says so in ascopefield. Since 0.11.0 the signed chain tip (cryptovalid_tip.py, sidecar<ledger>.tip.json, written after every append — O(1) for the writers that already know their tail,Ingestorand GoAppendSigned;cryptovalid_tip.py signreads the file, O(n)) moves that limit: with the tip and the trusted log key,verifier.py --trusted-pubkey <hex> [--require-tip]namestail_truncated,tail_rewrittenandunsealed_tail; the JS and Go verifiers check it too (Rust/Swift: declared no). Without the trusted log key the tip is not checked at all (tip_untrusted): the key inside a tip proves nothing, so there is no "PASS but untrusted" for an automation to misread. What remains, stated in the receipt: a holder of the log key can truncate and re-sign (key custody: HSM/KMS); a rollback to an older genuine tip passes unless you pass--tip-not-beforeor compare with the monitor state / receipts / an anchor; and when one log key signs several ledgers, a whole pair file+tip of another ledger is caught only with--expect-ledger-id(the tip carries the chain's identity,ledger_id= self_hash of entry 0). The production writer keeps the tip itself:Ingestor(tip_keyfile=…)signs it at every flush under its lock. Measured on the arc (#703) bench: 6002/6003 tamperings without the tip, 6003/6003 with it, and an attacker re-signing the tip with a key of their own is refused; - high-frequency ingestion (
cryptovalid_ingest.py): segmented hash-chained ledgers with Merkle-STH sealing (chained across segments, KMS/HSM-signable), batched fsync, fail-closed crash recovery — throughput measured by the bench, never quoted as a fixed claim; - KMS/HSM signing backends (
cryptovalid_kms.py): the Ed25519 private key can live in a PKCS#11 HSM (tested end-to-end against SoftHSM2; YubiHSM 2 exposes the same mechanism), AWS KMS (ECC_NIST_EDWARDS25519, exercised against the API contract — a live signature needs a real AWS account), or HashiCorp Vault Transit (protocol-tested against a stub) — instead of a local key file. Signatures stay standard Ed25519, so the stdlib verifier is unchanged.
Honest caveats, unchanged: no users yet; this is not an HSM itself (it talks to one — key custody is only as strong as the token/KMS policy behind it), not an accredited certification, and not legal advice — it proves what / when / order / who-signed + non-alteration, not the truth of the recorded facts. Broader packaging and an independent security audit are the object of a pending NLnet application.
python3 verifier.py <ledger.jsonl> # verifies a hash-chained ledgerExit code 0 = chain intact; 1 = broken, and the receipt tells you where; 74 = the receipt could not be written whole
(stdout or --out full, closed, or a reader gone), whatever the verdict (since 0.20.3).
Hash-chaining proves a ledger was not altered. Signing proves who sealed it — and lets a third party verify authorship with nothing but a public key. This is what closed, cloud GRC evidence tools do not give you: their evidence lives in a vendor database ("trust us"); CryptoValid evidence is signed, self-hosted, and verifiable offline by anyone, forever.
python3 signer.py keygen signer.key # Ed25519 keypair (seed, chmod 600)
python3 signer.py sign ledger.jsonl ledger.signed.jsonl signer.key
python3 verifier.py ledger.signed.jsonl # hash chain still PASS (stdlib-only)
python3 signer.py verify ledger.signed.jsonl # signatures PASS (content->self_hash->signature)- The signature commits to each entry's
self_hash;signature/signer(and the hybridsignature_pq/signer_pq) are attestation fields excluded from the content hash, so a signed ledger still passes the stdlib hash verifier unchanged — in all five verifiers. - Hybrid post-quantum (0.12.0, 0.13.0):
keygen signer.key --pqalso writessigner.key.pq(ML-DSA-65, FIPS 204, PKCS#8, 0600);sign … --pq-key signer.key.pq— or--pq-kms <AWS KMS key, KeySpec ML_DSA_65>to sign inside KMS — signs everyself_hashwith BOTH keys (re-signing without a PQ key strips stale PQ fields);verify … --pq-pubkey <b64>requires the layer and exits 1 unless every entry carries a valid ML-DSA-65 signature by THAT key (--pq-pubkeyneeds--pubkey, and--require-pqalone is refused: a layer checked against the key inside the file is self-declared). The tip:--trusted-pq-pubkeyonverifier.pyand on the Gocvverify(implies a required tip and needs--trusted-pubkey). Per-entry Ed25519 + ML-DSA-65 are verified by Python (signer.py verify) and Go (cvverify -pubkey <hex> -pq-pubkey <b64>); the tip's by both. The context string is EMPTY (FIPS 204 default) so the JDK, OpenSSL 3.5, .NET and Go verify the same bytes. Needscryptography≥ 48 (Python; measured 2026-10-04 with 48.0.0, 49.0.0, 50.0.1 — 47.0.0 raisesUnsupportedAlgorithm) or Go ≥ 1.27; where a REQUIRED layer cannot be verified the result ispq_unverifiablewithok: false/ exit 1, never a green. signer verifyre-derivesself_hashfrom the content too, so it catches content tampering on its own: the full chain is content → self_hash → signature.- Optional layer, honest scope: the core hash verifier stays stdlib-only; signatures need the
cryptographypackage. Absence of signatures never weakens the hash chain. By default keys are software keys on a file — production should use a KMS/HSM backend (below). Tests:python3 test_signer.py.
cryptovalid_kms.py delegates the signature to a backend where the key is non-exportable;
the evidence format and the verifier do not change. URI-style selection:
# PKCS#11 HSM (YubiHSM 2, SoftHSM2, smartcard) — PIN via env, never on argv
export CRYPTOVALID_PIN=****
python3 cryptovalid_kms.py keygen-hsm --backend \
"pkcs11:module=/usr/lib/softhsm/libsofthsm2.so;token=cryptovalid;key=evidence;pin=env:CRYPTOVALID_PIN"
python3 signer.py sign ledger.jsonl ledger.signed.jsonl --backend "pkcs11:module=...;token=...;key=evidence"
# AWS KMS (KeySpec ECC_NIST_EDWARDS25519, EdDSA supported since 2025-11; needs boto3+credentials)
python3 signer.py sign ledger.jsonl out.jsonl --backend "awskms:key_id=alias/cryptovalid;region=eu-south-1"
# HashiCorp Vault Transit (key type ed25519; token from $VAULT_TOKEN; stdlib-only client)
python3 signer.py sign ledger.jsonl out.jsonl --backend "vault:url=https://vault:8200;key=cryptovalid"Honest bench per backend (test_kms.py): PKCS#11 is tested end-to-end against a
real SoftHSM2 token (non-exportable key, tamper ⇒ FAIL); AWS KMS is exercised against the exact
API contract (MessageType RAW + ED25519_SHA_512) with an injected client — a live signature
requires a real account; Vault Transit is protocol-tested against a local stub. Removing the key
from process memory does not protect against a compromised host asking the HSM to sign
attacker-chosen data — pair it with KMS policies/audit and HSM touch-policies.
Assembling evidence for an auditor is the regtech time-sink. evidence_pack.py turns one or more
ledgers into a self-verifying bundle (MANIFEST with per-file SHA-256 + per-ledger hash/signature
verdicts + signer keys + an optional RFC 3161 timestamp, a human SUMMARY.md, and the ledgers).
A third party re-checks everything with nothing but this repository:
python3 evidence_pack.py build pack_dir/ ledger.signed.jsonl --subject "audit CUST-001"
python3 evidence_pack.py verify pack_dir/verify returns valid: true only if every file digest matches the manifest, the manifest is
self-consistent, every ledger passes its hash chain and (if any entry carries a signature field) its signatures, and
a claimed RFC 3161 token is not refuted — no server, no account, no vendor. Tamper any file and it drops to valid: false.
RFC 3161 anchoring is optional (needs openssl + a TSA): its absence never invalidates the pack; since 0.22.0 a claim
without a token, or a token that openssl reads here as not granted or bound to another digest, does. The TSA's signature
is not checked (time_anchor: tsa_not_checked), and where openssl is absent the token is not read at all
(time_anchor: not_evaluated / instrument_failure, with the exit valid gives). The receipt also says, in one closed field per subject, what was checked and
how it came out (admission, states: SPEC §10) — a refusal of the manifest itself (format_unsupported,
unknown_member, malformed) is told apart from tampering. Tests: python3 test_evidence_pack.py,
python3 test_pack_states_20261019.py.
This is the difference from closed GRC evidence tools: your evidence is a bundle anyone can re-execute to verify — forever, offline, without trusting us.
Build a pack with --sign-key <keyfile> to authenticate the manifest (recommended): it binds the
subject, the file digests, and each ledger's entry count + head hash. verify_pack reports
manifest_authenticated.
The AP2 spec tells implementers what to keep for dispute resolution (the SD-JWTs with their
disclosures) but not how — no key snapshotting, no tamper-evidence, no long-term validation.
ap2-evidence-pack turns a set of SD-JWT mandates into one self-contained evidence file that
verifies offline years later: it validates each ES256 signature at build time, snapshots the
key material with an explicit provenance class (x5c_header / jwk_header / supplied /
jwks_fetched — declared, never dressed up), recomputes cross-mandate hash bindings from the
exact compact serializations, and seals everything under a SHA-256 digest with an optional
RFC 3161 timestamp:
The reference implementation is the ap2-evidence-pack package (Apache-2.0, same author); since 0.18.0 this repository depends on it instead of carrying a copy:
pip install --extra-index-url https://robertolocatelli81-dev.github.io/pypi/ 'cryptovalid-opencore[ap2]'
ap2-evidence build ev.json intent=intent.sdjwt cart=cart.sdjwt --tsa http://tsa.izenpe.com
cryptovalid-verify ap2 ev.json # offline, fail-closed; the reference's own refusal reason is in the receiptWithout the ap2 extra, cryptovalid-verify ap2 is a refusal receipt that names it (exit 1), never a pass.
Honest scope: proves these exact artifacts verified with this key material at build time (and
existed at the TSA's time, if stamped). It does not confer eIDAS art. 45j qualified-archive
legal presumption and does not by itself validate x5c chains to a trust anchor (ap2-evidence-pack does it for a
relying party that gives one, --trust-anchor; cryptovalid-verify ap2 passes none). ES256 only, loudly.
Since 0.22.0 the front-end needs ap2-evidence-pack >= 1.6.0 and copies its admission, refusal_class,
states_vocabulary, states and policy (SPEC_AP2_EVIDENCE.md §6.9) into its receipt untouched; the refusal class
leads the reason of the reference's layer.
Tests of the format live in ap2-evidence-pack; here test_verify_evidence.py and test_mcp.py test the front-end and
the MCP tool verify_ap2_evidence (read-only), and skip without the package.
An ISO inspector or an EU regulator expects a formal document, not JSON on a terminal.
cryptovalid_report.py renders an evidence pack into a typographic audit report — front page,
global status, per-ledger detail, signer keys, RFC 3161 / eIDAS anchoring, honest scope, and the
exact vendor-free re-verify command:
python3 cryptovalid_report.py pack_dir/ # -> report.html + report.pdf
python3 cryptovalid_report.py pack_dir/ --lotl --lotl-ms ES # opt-in eIDAS check (network)Design rules (they ARE the security model of this layer): the report never recomputes a
verdict — every light comes verbatim from the same fail-closed verify_pack() an auditor runs by
hand, so a tampered pack renders RED, always. The report is a rendering, not evidence: the
authoritative artifacts remain MANIFEST.json + the ledgers, and the document says so on its face.
HTML needs nothing but the Python stdlib; PDF uses WeasyPrint only if it
is already installed (honest degrade to HTML otherwise). The eIDAS light has three honest states —
not checked (default) / qualified / not qualified — never a fabricated green.
Tests: python3 test_report.py (tamper→RED proven before the positive path).
cryptovalid_mcp.py exposes CryptoValid to any MCP client (IDE assistants, desktop agents, other
agents) over stdio — zero dependencies: the MCP JSON-RPC transport is implemented with the
Python stdlib, same ethos as the rest of this repo.
Read-only tools, always available, every answer carries provenance (source, SHA-256, UTC):
verify_ledger, verify_pack, verify_archive (with optional trusted-signer set and opt-in
eIDAS/LOTL check). Write tools — append_event, seal_segment — let an agent seal its own
actions into a tamper-evident, signed, RFC 3161-anchorable archive that anyone re-verifies
offline; they are human-gated twice (env CRYPTOVALID_MCP_ALLOW_WRITE=1 and a per-call
confirm_token matching CRYPTOVALID_MCP_CONFIRM) and disabled by default. Signing uses the
same backend URIs as everywhere else (file: / pkcs11: / awskms: / vault: / nethsm:) —
with the HSM/KMS backends the private key never enters the server process.
Tests: python3 test_mcp.py — over the real stdio transport, gate refusals and
tampered-ledger failure proven before the positive path.
Honest scope: sealing proves what/when/order/who-signed — never the truth of the recorded facts.
Under EU DORA, supervisors moved to evidence-driven enforcement in 2026: they want timestamps, classification rationales and log trails — not policy PDFs. Major ICT incidents follow a hard reporting lifecycle: initial notification within 4h of major classification (24h backstop), intermediate within 72h, final within 1 month.
dora_incident.py makes that lifecycle tamper-evident and reproducible: each phase
(detected → classified → initial/intermediate/final) is a canonical, hash-chained record, and
the DORA deadlines are checked against the recorded timestamps — a regulator recomputes the
same verdicts from the records alone, then re-verifies with the unified verifier. Seal the chain
head with a qualified eIDAS timestamp (cryptovalid_tsa) for legal-grade time.
python3 dora_incident.py attest phases.json --incident-id INC-1 / verify att.json phases.json.
Honest scope (the boundary never moves): proof-of-integrity + timeline + deadline-check, NOT a DORA compliance certificate and NOT proof that the major/significant classification is correct (the entity's judgement, reviewed by its auditor). Complements web-evidence anchoring tools by covering the part they leave open: the incident lifecycle and its deadlines.
From 2026 US brokers must report crypto cost basis (Form 1099-DA) — but NOT for assets acquired before 2026, nor for non-custodial/DeFi activity: the taxpayer must track and, on audit, prove their own transactions. Tax software computes the numbers; it does not give evidence that survives an audit without trusting the software.
tx_evidence.py fills exactly that gap: it canonicalizes each transaction deterministically,
hash-chains the set (tamper-evident), and re-derives the cost basis with a declared, deterministic
FIFO method — so a third party (an auditor, the IRS) recomputes the same numbers from the same
records. python3 tx_evidence.py attest transactions.json / verify attestation.json transactions.json.
Honest scope (the boundary never moves): proof-of-integrity + proof-of-reproducibility, NOT tax advice and NOT proof of tax-correctness — it does not pick your accounting method, apply wash-sale/jurisdiction rules, or certify compliance. Those remain the taxpayer's and their advisor's.
Three capabilities that keep evidence sound over years, each a runnable tool now:
cryptovalid_worm.py— WORM escrow of the record (not just its hash): append-only, overwrite refused, GDPR crypto-shredding of the key. Meets immutable-retention rules (SEC Rule 17a-4, MiCA 5–7 years).python3 cryptovalid_worm.py put <store> <key> <file>.cryptovalid_reanchor.py— public-RPC anchors lose witnesses as nodes prune history (an "auto-DoS" of the evidence, measured). This assesses decay and flags when to re-anchor before the evidence becomes unreachable.python3 cryptovalid_reanchor.py <retention.json> --age-days N. Run it on a timer; re-anchoring itself is an on-chain tx and stays human-gated.cryptovalid_heterogeneous.py— real fault-independence: N distinct trust domains (Solana + Bitcoin/OTS + eIDAS QTSP), not replicas of one chain. Same-domain replicas count once.python3 cryptovalid_heterogeneous.py <sha3_hex> <attestations.json> --min-domains 2.
Honest scope: these harden availability, retention and robustness of the evidence — not the truth of its content (that boundary never moves).
Adversarial testing (NEMESIS + an independent LLM red-team) found and CLOSED real gaps:
- Truncation / branching: a bare hash chain does not prevent dropping trailing entries or forking a
new history after any point. A signed manifest commits each ledger's entry count and head hash, so
verify_packdetects truncation. Bare ledgers still need a signed manifest (or an external anchor) for this — documented, not hidden. - Manifest re-forge: an unsigned manifest is integrity-checked, not authenticated — its metadata is
trustworthy only if
manifest_authenticatedis true. A tampered signed manifest →valid: false. - RFC 3161: the timestamp token is now cryptographically verified (status Granted + message imprint
== manifest digest), proved against a real DigiCert TSA. A forged token →
rfc3161.verified: false. - Cross-language integrity: canonicalisation is pinned (ASCII-escaped, no floats, unique keys — see §3 of the spec) with a dedicated conformance vector, so a Go/Rust/JS verifier computes the same hashes.
- Replay across ledgers: carry a
ledger_idindata(see spec §3) to bind entries to one chain.
Declared limits (from a further independent adversarial review, 2026-08-17 — stated, not hidden):
- Split-view / equivocation — declared "future work" here from 2026-08-17 to 2026-09-19; since 0.14.0 it is a measured feature, see Checkpoints, witnesses, split-view below: a signer-key holder can still write two internally valid chains, but a checkpoint of either is cosigned by a witness only if it extends what that witness already cosigned, so a witness that has cosigned one view refuses the other — and, when the two views have the same size, returns a pair of log-signed checkpoints that is proof by itself. What remains: a log that feeds DISJOINT subsets of the trusted witnesses different views gets both cosigned without any refusal whenever a relying party's quorum N fits inside one subset (M trusted witnesses, M ≥ 2N) — which is why witness policies fix the set and demand N > M/2, and why the monitoring side of the protocol (not implemented here) exists; a log colluding with enough witnesses to reach the quorum; a relying party that trusts no witness is where it was.
- Rollback / freshness — the same section: the witness cosignature carries a timestamp inside the signed
bytes, and
verify_witnessed(min_witnesses=N, max_age_s=T)requires N trusted witnesses each with a cosignature younger than T on this exact checkpoint. A witness that has cosigned a newer tree never cosigns the older one again, so a stale view stays acceptable only for as long as the log can keep N witnesses from seeing the newer tree. What remains: the witnesses' clocks are the reference (not the log's), a stale view younger than T passes, and a log that starves N witnesses of its newer checkpoints keeps them re-cosigning the old one (the spec allows re-cosigning the same checkpoint). Without a witness, the anti-truncation HEAD still protects only relative to a known HEAD and TSA anchors give no freshness. - Pre-anchor window: between an event and its sealed anchor, a root-level attacker can rewrite and re-seal silently. The anchor proves "existed by T"; the per-entry timestamp comes from the local clock and is not independently attested.
- Selective omission: an append-only log proves what it contains, never the completeness of what should have been recorded.
- Signing-channel governance: "non-exportable key" ≠ "custody service incompressible": whoever controls the KMS/HSM policy or unseal material can authorize signatures. Policy governance, rotation and revocation are part of the security perimeter.
- Post-quantum: hybrid, opt-in, pinned. Since 0.12.0 ledger entries and the signed chain tip MAY carry an
ML-DSA-65 (FIPS 204, pure mode, empty context since 0.13.0 so that the JDK 24+ and AWS KMS can produce and
verify it) signature next to Ed25519 over the same bytes
(
signer.py keygen --pq,sign --pq-key,cryptovalid_tip.py sign --pq-key). The layer counts only when the relying party PINS the ML-DSA-65 key (--pq-pubkey,--trusted-pq-pubkey, both of which REQUIRE the layer): then a missing, foreign, malformed or invalid PQ signature is a FAIL, andpq_protected: truealso needs every Ed25519 signature to hold. Without the pinned key a present layer is reported asnull(verified against the key inside the file, which anyone can put there), nevertrue; an Ed25519-only ledger is still accepted, so stripping the PQ fields is a downgrade the relying party's requirement refuses, not the file. Both keys must be pinned (the library and the CLI refuse a PQ key without the Ed25519 key): with only the PQ key required, a re-signed Ed25519 layer by a foreign key would still verify. Who checks what: per-entry Ed25519 + ML-DSA-65 — Python and Go (cvverify -pubkey … -pq-pubkey …,crypto/mldsa, Go ≥ 1.27); the tip's ML-DSA-65 — Python and Go; JS verifies the hash chain and per-entry Ed25519, Rust and Swift the hash chain only, and all three say the PQ layer is unchecked. The evidence pack is not quantum-resistant on its own: its manifest recordspq_protectedper ledger with the keys it was built against, but the manifest signature is Ed25519 only, soverify_packconfirms the layer only against keys the verifier pins (the ledger keys, or the manifest signer); with nothing pinned it reportsnull, nevertrue. The PQ private key is a file (PKCS#8, 0600) or, since 0.13.0, an AWS KMS ML-DSA-65 key (--pq-kms: signed in a FIPS 140-3 Level 3 HSM, the key never in process memory — verified live). Cost: about 4.4 KB of base64 signature and 2.6 KB of base64 public key per entry. Already-published TSA anchors (RSA/ECDSA) do not survive a quantum adversary either, which keeps hash-only anchor coverage part of the security perimeter.
Honest scope unchanged: the format proves what/when/order/who-signed + non-alteration, not the truth of the recorded facts, and is not an HSM or a legal-compliance certification.
A verifiable-evidence feature is copyable; an adopted format is not. CryptoValid is defined by
conformance test vectors (spec/vectors/ + spec/CONFORMANCE.md), so a verifier in any language
proves interoperability by reproducing the same verdicts:
python3 conformance.py # exit 0 = the reference verifier conforms (7/7 vectors)Implement it in Go/Rust/JS, match the vectors, and open a PR to the conformance table — the format becomes
a shared standard, not one vendor's tool. Full spec: SPEC_EVIDENCE_FORMAT.md.
cryptovalid_merkle.py builds an RFC 6962 Merkle tree over the same canonical entries,
adding what a linear chain cannot do efficiently:
- inclusion proofs — verify one entry in
O(log n)without recomputing the whole chain; - consistency proofs — cryptographic proof that a newer ledger append-only extends an older one;
- a Signed Tree Head (
{tree_size, root_sha256}) — submitroot_sha256to a qualified TSP (eIDAS EU Trusted List) for a qualified RFC 3161 timestamp (the admissibility requirement above).
python3 cryptovalid_merkle.py sth examples/sample_ledger.jsonl # {tree_size, root_sha256}
python3 cryptovalid_merkle.py prove examples/sample_ledger.jsonl 1 # audit path for entry 1
python3 cryptovalid_merkle.py verify examples/sample_ledger.jsonl 1 # INCLUSION VALIDA working RFC 3161 client (cryptovalid_tsa.py) requests a timestamp over the STH root from any
TSA — point --tsa at an eIDAS qualified TSP for a qualified token, then cryptovalid_lotl.py certifies it is qualified by matching its TSA
certificate against the EU List of Trusted Lists (ETSI TS 119 612, service type TSA/QTST) —
validated end-to-end LIVE (2026-08-16): a real timestamp from a real qualified TSP
(Izenpe, Spanish Trusted List) over an ingestion HEAD hash returned qualified: true, while
two non-qualified TSAs (freetsa, and Certum's public TSA — correctly distinguished from the
qualified services of the same company listed in the Polish TL) were rejected. Honest scope:
qualified: true means the TSA certificate is a QTST-granted service in the EU Trusted Lists
at verification time, or when the fingerprint cache was built — at most 24 hours earlier (the status in force then;
until 0.20.1 a service withdrawn today with granted in its history counted too, and the cache was read whatever its
age — corrected in 0.20.2, see that section), not at the token's time (qtst_fingerprints_at, given the genTime,
answers that; the CLI and the callers do not use it yet); full ETSI TS 119 615 legal validation (chains, revocation, time constraints)
is beyond exact-fingerprint matching, and production use still wants a contracted QTSP with an SLA. The linear hash-chain stays the interoperable core; Merkle is additive. Domain separation:
0x00 leaves, 0x01 nodes. Third-party verification needs only
(entry, index, tree_size, audit_path, root). Tests: python3 test_merkle.py.
verifiers/java/CvVerify.java is a single-file, dependency-free Java implementation of the verifier: the strict
JSON acceptance profile, the canonical hash chain (SHA-256 / SHA3-256), per-entry Ed25519 (JDK 15+) and
ML-DSA-65 (JDK 24+, Signature.getInstance("ML-DSA-65")) signatures with the same tri-state as signer.py, and
the signed chain tip including its post-quantum layer. It is possible only because the profile signs with the EMPTY
FIPS 204 context: the JDK (24 → 27) exposes ML-DSA with no context API. It sits in the differential oracle next to
Python, JS, Go and Rust (verdicts, tip tri-state and entry signatures compared on every hostile input) and in CI on
JDK 27. Run: java verifiers/java/CvVerify.java ledger.jsonl [-pubkey HEX -pq-pubkey B64] [-tip F -trusted-pubkey HEX -trusted-pq-pubkey B64]. Measured on a ledger and tip whose ML-DSA-65 signatures were made inside AWS KMS: PASS,
entries and tip pq_protected: true.
verifiers/go/ is a stdlib-only Go implementation of the profile — the first one that also writes
(cryptovalid.Append: canonical JSON, prev_hash, SHA-256, O(1) per append, flock from tail-read to
fsync) so a Go service can produce a cryptovalid ledger at insert time and anyone can verify it with the
Python, JS or Go verifier. vectors.json (generated by Python) is the single byte-level oracle for the three
languages; on a 200-row audit-log ledger and 6003 enumerated tampers the three verifiers give identical
verdicts. Two profile rules made explicit the same day in all three: nesting > 512 and unpaired UTF-16
surrogates are refused fail-closed (spec/CONFORMANCE.md).
Since 0.12.0 cvverify -trusted-pq-pubkey <b64> also checks the ML-DSA-65 signature of a hybrid tip, and since
0.13.0 -pubkey <hex> -pq-pubkey <b64> verify every entry's Ed25519 + ML-DSA-65 signatures with the standard library
(crypto/mldsa, Go ≥ 1.27, the same tri-state as signer.py); a binary built with an older toolchain answers
pq_unverifiable (a FAIL when the key is required), never a silent pass.
Compared online with the field (Sigstore Rekor v2, immudb, Azure Confidential Ledger, Google Trillian/Tessera, the IETF SCITT architecture) and with the new EU rules for qualified electronic ledgers (eIDAS 2.0, Regulation (EU) 2024/1183 Art. 45l; Commission Implementing Regulation (EU) 2025/2531 of 16 December 2025), four pieces were missing and are now here, stdlib-only:
| Module | What it adds | Standard |
|---|---|---|
cryptovalid_receipt.py |
Portable receipts: inclusion and consistency proofs for one entry / two tree heads, with a tree head really signed by the log key (the old signed_tree_head in cryptovalid_merkle carried no signature — renamed tree_head, alias kept). JSON profile and COSE_Sign1 encoding per RFC 9942 (vds=1 RFC9162_SHA256, vdp with inclusion −1 / consistency −2 as arrays of bstr, alg EdDSA; consistency receipts carry a detached payload and the verifier recomputes root_2); minimal CBOR codec (duplicate keys and deep nesting refused). Verification is fail-closed: it needs your entry (inclusion) or your previous root (consistency) and a trusted log key — nothing named inside the receipt is trusted, and the proof's tree size must equal the signed head's. |
RFC 9942, RFC 9162, RFC 9052, RFC 8949 |
ed25519_order.py |
Ed25519 keys and R of small or mixed order (0.21.0): RFC 8032 5.1.3 decoding, [8]P and [L]P with the 5.1.4 formulas, standard library only; key_problem, r_problem, strict_problem (with a cache per key). Used by signer.py verify and cryptovalid_tip.py before the library's equation; the JS, Go, Java and Swift verifiers carry the same rule. Not constant time (public values only). |
RFC 8032 §5.1; the 12 classes of novifinancial/ed25519-speccheck |
cryptovalid_tip.py |
Signed chain tip (0.11.0): {entries, tip_sha256, ts} signed with the log key after every append (O(1), atomic sidecar <ledger>.tip.json; Go AppendSigned / cvappend -tipkey writes it under the same lock). Any snapshot verifier holding the tip and the trusted key sees tail truncation, suffix rewrite and unsealed appends — the three attacks a bare chain cannot see. Same signed bytes in Python, JS and Go (cross-signed in the tests). Limit: the holder of the log key; deleting the tip is visible only with --require-tip or a copy kept elsewhere (monitor state, receipts, anchor). |
signed tree-head idea of RFC 6962, reduced to the chain tip |
cryptovalid_monitor.py |
Append-only / non-equivocation monitor (what rekor-monitor and immudb's auditor do): keeps the last green tree head and proves at every run that the ledger only grew; catches truncation, rewrite of old entries, forks and log-key changes; a red run never advances the baseline; the saved state is itself verified on load (signed head). Three modes, stated: writer (--keyfile, signs the head), auditor (--trusted-pubkey, never writes an unsigned state, advances only with a signed head published by the writer via --sth-file), none (blind trust, declared). Declared limits: the blind window between two runs, and deletion/replay of the state file (keep it where the ledger writer cannot write, or anchor each signed head externally). |
RFC 6962 consistency proofs |
eidas_ledger_check.py |
Self-assessment of a ledger against IR 2025/2531 (REQ-7.5-03/04/05/06, Art. 45l): what is met by construction (hash list + Merkle, SHA-256/SHA3-256, immediate detectability), what needs the deployment's qualified pieces (qualified certificates, QTSP timestamps, certified signing device), what no code can give (being a QTSP). Produces the automated ledger report (Annex §2) and an Electronic Ledger Practice Statement skeleton (REQ-6.1-12) filled from what was measured. Never a claim of qualification. | eIDAS 2.0, IR 2025/2531, ETSI EN 319 401 |
cryptovalid_jws.py |
JWS container (RFC 7515, EdDSA per RFC 8037) of a record's canonical form with kid and optional x5c — the header the regulation requires for JAdES; the qualified certificate is the user's, the toolkit puts it in the right place. Not a full JAdES profile (stated). |
RFC 7515, RFC 8037, ETSI TS 119 182-1 (target) |
python3 signer.py keygen log.key
python3 cryptovalid_receipt.py inclusion examples/sample_ledger.jsonl 2 log.key --cose r.cose > r.json
python3 cryptovalid_receipt.py verify r.json <log_pubkey_hex> --entry-json my_entry.json # offline: YOUR entry + trusted key
python3 cryptovalid_monitor.py examples/sample_ledger.jsonl state.json --keyfile log.key # run it on a schedule
python3 eidas_ledger_check.py examples/sample_ledger.jsonl --practice-statement --provider "ACME"
python3 cryptovalid_jws.py sign examples/sample_ledger.jsonl 0 log.key --x5c <base64 DER cert>
python3 test_ledger_evidence.py # 18 tests, negatives firstHonest scope, once more: a receipt signed by the log key proves what the log key holder published; a
qualified electronic ledger needs a qualified trust service provider, qualified certificates/timestamps and
certified devices — eidas_ledger_check.py tells you exactly which of those you still owe.
The evidence pack receipt says what was checked; a claimed token that does not verify is a failure; small-order keys refused in the pack and the ingest (2026-10-19, 0.22.0)
Not additive: verdicts change (SPEC v0.5, §6 lists them; §10 is the receipt). The ledger format, its vectors and the six ledger verifiers do not change.
- The pack receipt names admission and states (SPEC §10; the design of ap2-evidence-pack 1.6.0, aligned with
draft-krausz-verification-state-03 §3.1, §3.3, §5.4.2, work in progress).
admissionisadmittedorrefused; a refusal hasrefusal_class(format_unsupported,unknown_member,malformed),states: nulland its reason inerror. An admitted pack hasstates_vocabulary: "cryptovalid-evidence-pack/states-1"and three subjects —sealed_content,producer_signature,time_anchor— each{state, reasons, detail?}with the precedence contradicted > instrument failure > other not evaluated > verified.time_anchoris neververified: this verifier binds the token to the manifest digest and does not check the TSA's signature (tsa_not_checked).validis computed as before plus the changes below; how it relates to the states is written in §10 and checked on a generative bench (verifiers/pack_states_bench.py). - A claimed RFC 3161 token that does not verify makes the pack not valid (the limit declared in 0.21.0):
anchored: truewith a token that is not a granted TimeStampResp for this digest, or with no token, isvalid: false, exit 1 (time_anchor: contradicted,token_invalid/binding_mismatch/token_missing). Until 0.21.0:valid: true, exit 0. A pack without a token, or whose token cannot be checked because openssl is absent, keeps its verdict. - A check that cannot run gives the whole receipt and exit 74. openssl that cannot start, does not answer in time, or
is ended by a signal while
evidence_pack.py verify(orcryptovalid-verify pack) checks a token: the complete receipt on stdout withtime_anchor: not_evaluated / instrument_failure, one linecheck not run here: <reason>on stderr, exit 74. Until 0.21.0 stdout was empty, and openssl ended by a signal was "token not verified" (the pack stayed valid); now that a token that does not verify fails the pack, a crashed tool must not read as a forged token. A receipt that cannot be written is still 74 withreceipt not written. The MCP server returns that receipt (check_not_run) where it returned only an error string. - The manifest is read as a closed, typed object. An unknown
pack_format(1.0 included; until 0.21.0 the field was not read), a key written twice, and a field this verifier reads with a typebuild_packnever writes are refusals; until 0.21.0rfc3161_timestamp: []orledgers: truewere a traceback (exit 1) andanchored: "yes"counted as a claim. The unknown keys of SPEC §2.1 are nowrefusal_class: unknown_member(same exit, samevalid: false). - A ledger's signature layer is opened by any entry, as SPEC §5 step 5 says: until 0.21.0 the pack checked
signatures only when the first entry carried
signature, so a ledger whose first entry was unsigned and whose second carried a signature that does not verify was valid. And a manifest that claims a post-quantum layer (pq_protected: true) for a ledger that does not carry it is not valid (it was valid, withpq_reason: pq_absent). - Keys and R of small or mixed order are refused in the pack manifest signature and in the ingest's tree-head
signatures (the second limit declared in 0.21.0;
ed25519_order, the rule the ledger verifiers apply since 0.21.0). Measured on 0.21.0 on 19/10/2026: a manifest "signed" by the neutral point with R neutral and S = 0 — a signature thatcryptography50.0.1 accepts for any message under that key — wasmanifest_authenticated: true, and an ingest archive whose STH and HEAD carried that signature wasok: true, also with that key pinned. The signer is read as 64 lowercase hex characters and the signature as strict base64 of 64 bytes (both were read leniently). cryptovalid-verify ap2needs ap2-evidence-pack >= 1.6.0 (theap2extra; the floor CI job installs 1.6.0): it copies the reference'sadmission,refusal_class,states_vocabulary,statesandpolicyinto its receipt untouched and puts the refusal class first in the reason of the reference's layer. AnAp2WriteErrorthat carries the reference's complete receipt (SPEC_AP2_EVIDENCE.md §6.10) would become this front-end's receipt and exit 74; 1.6.0 raises it only withtsa_certortrust_anchor, which the front-end does not pass, so today that branch is not reached (measured 19/10/2026: openssl killed, absent or not answering leavescryptovalid-verify ap2at exit 0 on a valid vector with a token, whose binding 1.6.0 reads without openssl). A pack with zero artifacts no longer passes the signature layer of the front-end (all()of nothing was true; the reference's own verdict already failed it). CONFORMANCE.md counted 8 ap2 vectors: there are 42 at v1.6.0.- Not done in 0.22.0, and why. The states are not added to the six ledger verifiers: the design's table has no cell for a ledger, an unknown key there is a FAIL in normative vectors of six implementations rather than a refusal, and the verifiers do not perform the same steps (two do not check signatures, two do not check ML-DSA) — that needs its own pre-registered design. The ingest archive gets the small-order rule but no states, for the same first reason.
- The receipts of
cryptovalid_receipt.pyrefuse the same keys (the JSON tree headcryptovalid_sth/1and the COSE receipts, formats of this project). Measured on 0.21.0 on 19/10/2026: the forged signature above wassignature_valid: truefor any tree head (ok: falsewithout a trusted key,ok: truewith the neutral point as the trusted key), and a COSE inclusion receipt re-signed that way wasok: trueunder that key; on one tree head, 13 of the 14 encodings of points of small order were accepted as a trusted key with an R of small order and S = 0, and a key of mixed order (torsion of order 2) with a signature the library accepts. Nowsignature_valid: false/ok: falsewith the reason (small_order_key,mixed_order_key,small_order_r). evidence_pack.py build --tsa: openssl ended by a signal while it reads the TSA's reply is exit 74 and no pack (the 0.20.3 rule for a check that cannot run while building); until 0.21.0 the pack was written unanchored.
A signature layer that does not verify is a FAIL; Ed25519 keys and R of small or mixed order are refused; Merkle leaves follow §3; a cut bytecode cache is compiled again (2026-10-14, 0.21.0)
SPEC v0.4. Every change below has a test that fails on 0.20.3, and the mutants we ran on its lines are killed by the tests (on
17/10 a review found five that lived — the neutral-point test reduced to x = 0 in JS and Go, the list of signature fields
without the ML-DSA ones in JS (two mutants) and Go — and a test now kills them: a key of mixed order whose torsion part has
order 2 or 4, a ledger whose signature layer is signature_pq alone or signer_pq alone). Receipts in
file mode: the 83 cases of the bench of 0.20.3 (the six verifiers and the Python commands) are identical to 0.20.3 on
Python 3.9, 3.11 and 3.13 (81 byte for byte, 2 by their own rule: generated keys); the bench sees a change made on
purpose (3 cases differ when verifier.py prints with another indent). On 458 ledgers of the development machine (every
one of the 2 880 of 12/10 whose text carries a signature field name, 58, and 400 drawn at random) the six verifiers give
the verdict and exit code of 0.20.3 on all; the receipts that change are 25 of those 58 files (FAIL in 0.20.3 and now), which now name the
signature layer or count it. What changes on purpose:
- The signature profile (SPEC §5 step 5). In the four verifiers that check signatures (JS, Go, Java, Swift) a
signature layer that does not verify is a FAIL without
--pubkeytoo, the failing entries named (until 0.20.3 the verdict was the chain's, PASS, and the failure only reported); the layer is any ofsignature,signer,signature_pq,signer_pqin any entry (until 0.20.3 onlysignature). A valid signature by an unknown key stays a PASS without a key: pinning the key is the caller's.verifier.pyand the Rust verifier do not check signatures: their receipt now hassignatures_not_checked, the number of entries carrying a signature field, only when it is not zero. ML-DSA-65: Go and Java verify it (a broken one is now a FAIL without a key too) when built with Go 1.27 or run on a JDK 24 or later; older ones declare the layer unverifiable (pq_status: unverifiable) and the verdict stays the Ed25519 layer's (PASS, exit 0, with a well-formed ML-DSA signature that does not verify), as before. JS and Swift count it,pq_not_checked. The matrix of 12/10 (9 cases, no key / right key / wrong key, six verifiers andsigner.py) was rerun on 0.20.3 before the change and gave the same table. 21 vectors inspec/vectors/signatures/(make_vectors.pyregenerates the same bytes): the six verifiers andsigner.py verifyreproduce all 21; the code of 0.20.3 differs on 129 of the 147 results. - Ed25519 keys and R of small or mixed order. RFC 8032 lets a verifier use either equation, and libraries differ
outside the subgroup of order L. On the 12 classes of novifinancial/ed25519-speccheck rebuilt on ledger entries (our
keys: the published cases sign other messages), 0.20.3 verified six with or without a pinned key in JS (Node), Go,
Swift (swift-crypto) and
signer.py(OpenSSL throughcryptography) — a key or an R of small order, a key of mixed order, a key with x = 0 and the sign bit set — and four in Java. Now a key must decode as RFC 8032 5.1.3 says, not be of small order and lie in the subgroup of order L; R must decode and not be of small order (small_order_key,mixed_order_key,key_not_decodable,small_order_r,r_not_decodable), before the library's equation, for entry signatures and the signed chain tip, in the four verifiers,signer.pyandcryptovalid_tip.py(ed25519_order.py, standard library); all 12 refused, as by the x402 acceptance checker of the same author. One scalar multiplication per distinct key, three doublings per signature. Not changed: the other modules that verify Ed25519 (receipts, checkpoints, witness, JWS, COSE receipts, SCITT, APS, Cedulon, the evidence pack's manifest signature, the ingest's tree heads) keep the library's rule (cryptovalid_acta.pyalready refused keys of small order). - Merkle leaves (SPEC §9). A leaf is the canonical form of §3. Until 0.20.3 it kept
signature_pq,signer_pqandtsa_token: signing a ledger with ML-DSA, or adding a timestamp token after a tree head, changed the root of entries already covered while the chain stayed PASS (examples/sample_ledger.jsonl: 95db8b0b... unsigned and signed Ed25519; with an ML-DSA layer or a token, a root that depends on their bytes — 264cbff9... and 67d223d0... in the run of 14/10, ML-DSA signing is hedged —; now 95db8b0b... for all four). The root of a ledger without those fields is unchanged; a tree head, checkpoint or receipt made by 0.20.3 for a ledger that carries them is reproduced by 0.20.3, not by 0.21.0 (vectorspec/vectors/merkle_leaves.json, with both roots, computed by an independent RFC 6962 code). - LOTL: a QTST history out of order or with two entries at one instant is an error (ETSI TS 119 615 PRO-4.3.4-03A;
TS 119 612 5.5.10 orders the history by descending StatusStartingTime). Such a service is not counted and its
certificates are named (
qtst_status_at,load_qualified_status; the cache rule moves to /2, so a cache of 0.20.x is rebuilt); a token from it isindeterminate(qualification()), not "not qualified": the ingest warnstsa_qualification_indeterminate, the seal metadata carrieseidas_indeterminate, the report says INCONCLUSIVE, thecryptovalid_lotlcommand addsindeterminate(only then). Until 0.20.3 the service was counted, the first entry of a tie taken (the same entry 03 b names). Measured: 39 services tie in their history on 26 national lists of 12/10 and on the 30 of 14/10 (one could not be fetched), none of them a QTST: the fingerprints are those of 0.20.3 at three instants (12/10, 14/10 and 2019-01-01: 652, 652 and 404 on the lists of 12/10; 1 081, 1 081 and 445 on those of 14/10). A tie between the ServiceInformation and one history entry is not 03A: the ServiceInformation applies, as before. - A bytecode cache already cut is compiled from the source (the rest of the 0.20.3 note on the cache, "it reads one
that is there"). Measured on 0.20.3:
verifier's.pyccut at any offset from byte 16 on made every command that imports it (all butverifier.pyitself) exit 1, the code of a FAIL, on Python 3.9, 3.11 and 3.13 (3.13 writes no cut cache but reads one). Each command's guard now wraps the loader: anEOFErrororValueErrorfrom a cache is followed by a compile of the source. Cost: +0.2 to +1.4 ms per start (median of 20 runs, three commands, three Pythons);sys.pycache_prefix = os.devnullcost +26 to +47 ms (it recompiles the standard library) and is not used. - Declared. An openssl that starts and is then killed by a signal, or a wrapper that exits 127, has answered: the
token is not verified (
rfc3161.verified: false), not "check not run" — an input that makes openssl crash does not become a check that did not run (the same in 0.20.3, now tested). The pack'svaliddoes not depend on the timestamp, as before (a claimed token that does not verify leavesvalid: true: RFC 3161 anchoring is optional). On macOS, read in the XNU source and not measured on a Mac (vn_write, bsd/vfs/vfs_vnops.c, xnu-12377.1.9): the file size limit is compared with the descriptor's offset, not the end of the file, so stdout appended (>>) to a file already past the limit takes a receipt that fits under the limit whole, exit 0 — the case the macOS job met on 13/10 — and a receipt that crosses the limit is cut and exits 74, as on Linux (the Swift suite asserts the first on macOS, the second everywhere). - The Kiva pre-registration (
spec/PREREG_KIVA_20260820.md) names reviewer roles instead of model names and no private note; the pre-registered bytes (SHA3-256db5671af..., cited by the two validation reports) are that file at public commit401e6b2. - Found and not changed in 0.21.0 (planned for 0.22.0; done there, see the 0.22.0 section).
evidence_pack.py verifyreports a declared RFC 3161 token that does not verify (rfc3161.claimed: true,verified: false) and still returnsvalid: true, exit 0 (measured 17/10/2026 on a pack ofexamples/sample_ledger.jsonlwith a token that is not a TimeStampResp): the anchor is optional, but one that is claimed and fails is not yet a failure. The evidence pack's manifest signature and the ingest's tree heads, both formats of this project, still verify Ed25519 with the library's rule (see above); they will refuse keys and R of small or mixed order as the ledger verifiers now do.
An outcome that is not written whole is exit 74; promised files and ledger lines are whole or absent (2026-10-13, 0.20.3)
A defect present in 0.20.2 and before, in every command that prints a receipt. When stdout could not take the receipt
(a full disk, a reader that has gone, stdout closed), the verifier exited with the verdict's code and the receipt was
lost: measured on 0.20.2 (>/dev/full, >&-, | head -c 0, two runs each, a valid and a tampered ledger),
verifier.py, cryptovalid-verify and signer.py exited 0 or 1 with stdout closed and 120 (the interpreter's own
flush, "Exception ignored") on a full disk or a gone reader; the JS and Java verifiers exited 0 or 1 in every case, with
nothing on stderr; Go and Swift exited 0 or 1 on a full disk (Swift also with stdout closed) and died of SIGPIPE (141) on a gone reader; Rust panicked
(exit 101) on a full disk or a gone reader, and exited 0 or 1 with stdout closed (its runtime reopens descriptor 1 on
/dev/null). A script that reads only the exit code took a lost PASS for a PASS.
- Since 0.20.3 every command of this repository that verifies, records or signs evidence writes its outcome through one
writer and only then chooses the exit code (not converted: the development and CI tools under
verifiers/andbench/and the test files;verifiers/closed_schema_mutants.pyon a full stdout still exits 120): when a byte of it was not taken it exits 74 (EX_IOERR insysexits.h), whatever the verdict, and writes one linereceipt not written: <reason>on stderr (if stderr fails too, the exit is still 74). The Python writer lives inverifier.py(which stays one stdlib-only file) and is imported bycryptovalid-verify/verify_evidence.py,signer.pyand the 30 other modules with a__main__:cldma_confidential,conformance,cryptovalid_acta,cryptovalid_checkpoint,cryptovalid_heterogeneous,cryptovalid_ingest,cryptovalid_jws,cryptovalid_kms,cryptovalid_lotl,cryptovalid_mcp,cryptovalid_merkle,cryptovalid_monitor,cryptovalid_pruning_probe,cryptovalid_reanchor,cryptovalid_receipt,cryptovalid_report,cryptovalid_scitt,cryptovalid_solana,cryptovalid_tip,cryptovalid_tsa,cryptovalid_witness,cryptovalid_worm,dora_incident,eidas_ledger_check,evidence_pack,fundcert_crosssource,mutation_runner,pedersen_commit,refresh_regulatory,tx_evidence. The JS, Go (cvverify,cvappend), Rust, Java and Swift verifiers follow the same rule. Exit codes 0, 1 and 2 (and 77 where a command has it) keep their meaning when the outcome is written; the outcome itself is unchanged: on 83 commands run before and after with stdout on a regular file (the same inputs, folder and start time), the exit code and stdout are byte for byte those of 0.20.2 in 81, and in the other 2 (a new key, a measured duration) equal apart from that value. - What the writer does. Python writes the bytes its stdout text layer would write to the byte stream below it, in a
loop that checks every count, and flushes before the exit code is chosen; Rust, Swift and Java write descriptor 1 in
such a loop; Node writes a regular file synchronously to its last byte; Go checks the error of its write. A
non-blocking stdout that is full (a reader that reads late) is waited for as a blocking write waits (Python in
select, Rust and Swift inpoll, Java in sleeps of 1 to 50 ms). SIGPIPE and SIGXFSZ are ignored, so a gone reader and a file size limit are errors the program reads. Usage errors and refusals go to stderr through a write that never raises. Measured on 0.20.2 on 13/10 (a bench of real processes, Python 3.11.2; every case below is exit 74 with one line, or the whole receipt with the verdict's code, on this code): a non-blocking stdout read late cut a receipt larger than the pipe buffer, at 65 536 bytes when the pipe had filled before the reader came (65 536 or 65 537 in Swift, by timing; Java, read 1 s after its start, was still writing and lost the bytes past 1 151 646 of 1 438 366; Python 120, or 1 under-u; Java and Swift 1, the verdict's code; Rust 101); a regular file with 24 bytes left underulimit -ftook 24 bytes and the code was the verdict's (JS, Go, Java) or 120 (Python), or the process was killed by SIGXFSZ (Rust, Swift); stdout opened read-only exited with the verdict's code in JS, Go, Rust, Java and Swift, 120 in Python; a usage error with stderr full, closed or its reader gone exited 120 or 1 in Python (with stderr closed the usage went to stdout), 101 in Rust (2 with stderr closed), and Swift was killed by SIGILL (FileHandle.write); with stderr's reader gone Go and Swift died of SIGPIPE;--helpon a full stdout exited 120 (1 under-u).cryptovalid_pruning_probe,mutation_runnerandfundcert_crosssourceprint their usage on stderr now (it went to stdout). - Files a command promises are whole or absent.
verifier.py --out, the signed ledger ofsigner.py sign, the chain tip, the monitor and witness state, the checkpoint and SCITT--outfiles,cryptovalid_receipt.py --cose, thecryptovalid_acta.pyreceipts, the evidence pack's files, the HTML/PDF report, the ingest STH and head, the LOTL cache, the regulatory profiles and the QEAS receipt are written to a new temporary file in the same folder, of a name nobody can predict (created withO_EXCL), fsynced and closed, then renamed over the path; on a failure the temporary file is removed, the file that was at the path stays as it was, and the command exits 74. Measured on 0.20.2:signer.py keygenover an existing key underulimit -f 0left the key at 0 bytes (exit 120); a symlink planted at the predictable<tip>.tmpor<state>.tmpwas followed and its target overwritten (the tip, the monitor). A new key (keygen, andkeygen --pq) and a WORM object are created through a temporary file and a hard link, never over an existing path, so they too are whole or absent. A path that exists and is not a regular file (/dev/stdout, a FIFO) is written directly, every byte. Over an existing file the command keeps what 0.20.2'sopen(path, "w")kept: a symlink at the path is followed (its target gets the file, the link stays), the existing file's mode is kept, and a file this process may not write (a receiptchmod 444) is refused and left as it was (exit 74; it was a traceback, exit 1). Changes of behaviour:signer.py keygenrefuses a path where something already exists (a key, writable or not; with--pqalso<keyfile>.pq; a symlink): one line on stderr, exit 2 (the code of a usage refusal), nothing written, the key left as it was; to replace a key, move or delete it first (0.20.2 replaced a writable key with a new one, exit 0, and refused a read-only one with a traceback, exit 1); the file at the path is a new file, so a hard link to the old one keeps the old content and the owner is the user who ran the command; a promised file in a folder this process cannot write is 74 even when the file itself is writable (no temporary file can be made there); an evidence pack is a folder: each file is whole,MANIFEST.jsonis written last, and a build that fails midway can leave copied ledgers without a new manifest (exit 74; the pack verifies only with a manifest that matches). - A hash-chained ledger gets the whole line or nothing.
cvappend(Go),cryptovalid_scitt.py register, the witness's.evidence.jsonland.cosigned.jsonllogs and the ingest writer's batches append with unbuffered writes in a loop and fsync; if a write or the fsync fails, the file is truncated back to the bytes it had, under the same lock, and the command exits 74 with a line that says nothing was recorded and the ledger is left as it was. Measured on 0.20.2:cvappendunder a file size limit exited 1 and left part of a line, which the next append refused as a torn last line (the chain could not continue);cryptovalid_scitt.py registerexited 2 (File too large) and left 24 bytes of a line. The ingest writer keeps a batch that failed queued, and a laterflush()writes it whole. With-tipkey, a tip that cannot be written is 74 and the line says the entry is in the ledger and the tip, if there is one, still names the previous entry.cryptovalid_scitt.py registerrefuses a directory as--outbefore it records anything; when--outcannot be written after the line is recorded, the exit is 74 and the line says the statement is registered (with its index) and only the transparent statement was not written.cryptovalid_witness.py cosigndoes the same for its--outand its state. - A check that cannot be run here is 74, never a verdict on the input. When openssl cannot be started (fork refused
under
ulimit -u, no descriptor left) or the file it reads cannot be written (no usable temporary directory),evidence_pack.py verify,cryptovalid-verify packandarchive,cryptovalid_ingest.py verifyandeidas_ledger_check.pyexit 74 withcannot run openssl here: <reason>orcannot write a temporary file for openssl: <reason>(measured under RLIMIT_NPROC 1 and under RLIMIT_FSIZE 0 with TMPDIR set); the same functions raise it tocryptovalid_heterogeneousand tocryptovalid_receipt.py inclusion --tsa(by code, not measured there). Measured on 0.20.2 under RLIMIT_NPROC 1, with a valid RFC 3161 token from a local TSA:cryptovalid-verify pack(a valid pack) andcryptovalid-verify archive(a valid ingest archive) saidvalid: false, exit 1;evidence_pack.py verifyreported the tokenverified: false;cryptovalid_ingest.py verifyandeidas_ledger_check.pyexited 1 with a traceback. openssl absent stays what it was (the token is recorded, not verified). openssl that does not answer within its timeout (15 s for the evidence pack's token, 30 s for the CMS signature and foropenssl ts -verify, which had no limit) is the same case, 74 withcannot run openssl here: no answer within <n> s, as in ap2-evidence-pack 1.5.2: a slow or loaded machine causes it. Measured on 0.20.2 and on the first 0.20.3 commits with an openssl that answers after 20 s: a valid packvalid: false, exit 1; after 35 s: a valid ingest archivevalid: false, exit 1 (with the temporary file's path in the receipt) andcryptovalid_ingest.py verifya traceback, exit 1.cryptovalid-verify ap2treats ap2-evidence-pack'sAp2WriteError(1.5.2) the same way. - A command run under a file size limit no longer breaks the next run. CPython 3.9 and 3.11 write a module's
bytecode cache (
__pycache__/*.pyc) with one raw write and install it even when that write was cut: measured on 13/10 withverify_evidence.py ledgeron a valid ledger from a source tree without a cache, one run underulimit -f 1left a cutverifier.cpython-311.pyc(and-39), and every later run, without any limit, exited 1 withEOFError: marshal data too short(Python 3.13.15 installs no cut file). Since 0.20.3 every module above, when it runs as a command, setssys.dont_write_bytecodebefore importing anything of this repository, so a command writes no bytecode cache (it reads one that is there). Not covered: a cache written by another program that imports these modules as a library, and thecryptovalid-verifyentry point of an installed wheel (pip compiles the cache at install time). - Corrected with it. The RFC 3161 domain of
cryptovalid_heterogeneous.pyread.get()on the boolean returned byverify_with_openssl: anAttributeErroron every token, so that domain never verified (measured on 0.20.2 with a token from a local TSA). It now counts whenopenssl ts -verifysucceeds: a verdict correction of the library functionverify_heterogeneous_anchor— an anchor that needs that domain went fromok: false(the domain listed witherrore: 'bool' object has no attribute 'get') took: truewhen openssl verifies the token against/etc/ssl/certs(by the test, with openssl's answer simulated); a token openssl does not verify, or one whose TSA root is not in/etc/ssl/certs(measured with the local TSA), stays a failed domain. Not changed: from the command line thetoken_derof an attestation is a JSON string, not bytes, so the domain still does not verify there (library use only). - Declared limits. Started with stdout closed (
>&-), Node, Go and Rust reopen descriptor 1 on/dev/null: the write succeeds there and the program cannot tell (exit 0 or 1, as before); Python, Java and Swift see it and exit 74. A reader that closes the pipe only after the receipt fitted in the pipe buffer has received the bytes as far as the writer can know (with| head -c 0started together with a fast binary the writer can win the race; measured: Rust, exit 0 in one of two runs on 12/10).cryptovalid_tsa.pywith a TSA it cannot reach is a traceback and exit 1, as before (now written by the stderr writer, so a full stderr keeps exit 1).mutation_runner.pyrewrites the target source file in place to mutate it and restores it after each mutant (a development tool).cryptovalid_lotl.py(without a fresh cache),cryptovalid_pruning_probe.py,cryptovalid_tsa.pyandcryptovalid_solana.pyagainst a real network are converted but measured here only offline (LOTL with a fresh cache, Solana and the TSA with the connection refused). In process, after text the caller wrote to autf-8-sigpipe, a receipt carries a second byte order mark (the command line never does). The CI's macOS job (13/10, run 38403932117) found one case that does not hold there: the Swift verifier with stdout appended to a file already 1 000 bytes long underulimit -f 1exited 0 with nothing on stderr (withulimit -f 0it exited 74, as on Linux); the cause is not established, and that case ofswift testruns on Linux only. Windows and macOS are not measured: the waiting on a full non-blocking stdout usesselect/poll(POSIX). - Tests with real processes:
test_receipt_not_written.py(12/10: /dev/full, a gone reader, stdout closed, for the Python commands and the Java verifier) andtest_receipt_design_20261013.py(13/10, 17 tests: unbuffered under a file size limit, a non-blocking stdout read only once the pipe is full in Python and Java, usage and--helpwith stderr lost, the promised files, a symlink planted at the old temporary names, the two ledgers, the module commands, the check that cannot run with a local TSA, the bytecode cache, the shape of the Java wait),verifiers/js/test.mjs(a file size limit),go test(cmd/cvappend: a line truncated back, a tip not written, a planted<tip>.tmp),cargo test(tests/receipt_write.rs: usage with stderr full, a file size limit, a non-blocking stdout) andswift test(the same three). Each new test fails on 0.20.2: the 17 Python tests (Python 3.11), the 3 Gocmd/cvappendtests, the 3 Rust and 3 Swift tests and the 5 JS file-size checks (each run against the code and the binaries of 0.20.2). The review of 0.20.3 addedtest_outcome_review_20261013.py(21 tests: a promised file over a read-only file, a symlink, a FIFO and an existing.pq, the existing file's mode, the evidence pack's copies; openssl that times out or cannot be executed, also inevidence_pack.py build; the SCITT--outbefore and after the line; fsync and a write(2) that takes 0 bytes made to fail through an LD_PRELOAD shim, which needs a C compiler; the wait on a full stdout without spinning; a reader that drips; the caller's own text first; one byte order mark per stream; an unexpected error with stderr lost; ap2-evidence-pack'sAp2WriteError; the RFC 3161 domain ofcryptovalid_heterogeneous; an ingest batch that failed, written whole by the next flush), a witness test (--outrefused before the state, a state that cannot be written is 74), and native tests: Go (an fsync that fails, through seccomp; the tip next to a relative ledger), Rust (4), Swift (3; the tests that need/dev/fullor Linux pipe sizes are skipped or fall back where those are absent, as on macOS), JS (3) and the Java wait (TestJavaWaitShape, the 17th test oftest_receipt_design_20261013.py). - Measured on this code (13/10/2026, Linux; Python 3.9.25, 3.11.2, 3.13.15; Node 22.23.2 and 24.19.0; Go 1.27.0;
Rust 1.98.1; JDK 27; Swift 6.3.3). A bench of real processes over every command above (60 cases for
verifier.py,cryptovalid-verify,signer.pyand the JS, Go, Rust, Java and Swift verifiers; 48 for the module commands that run without a network), each with stdout on a file,/dev/null, a slow reader, a late non-blocking reader,| head -c 100,/dev/full, a gone reader,| head -c 0, closed, read-only, a file size limit with and without room, a non-blocking reader that never reads, stderr working, full, closed or with its reader gone, and Python buffered and unbuffered: 1 886 + 2 208 cells on each Python, 0 cells where the outcome is lost or cut without exit 74, delivered with another code than the verdict's, or 74 without its stderr line; the same bench finds 931 + 1 324 such cells on 0.20.2. The Python suites (50 files) on 3.9, 3.11, 3.13 and the declared floor (cryptography48.0.0,ap2-evidence-pack1.3.0), 50/50 each; JS 150–151 checks (the count moves with a random signature; Node 22 three times, Node 24) and--conformance11/11;cargo test17 tests andclippy -D warnings;go vet,go testandgo test -race;swift test25/25 (Linux);conformance.py11/11; differential oracle 0/110 over the six verifiers (26 declared divergences);closed_schema_mutants.py1 500 mutations, seed 20261011, GREEN. 33 mutants of the changed lines (Python 21, Go 3, Rust 3, Java 2, Swift 3, JS 1: the loop on the count, the wait on a full stdout, the stderr writer, the temporary names, the truncation back, the 74 for a promised file, the check that cannot run and the bytecode cache among them) are each killed by a failing assertion of these suites, which pass on the copy without the mutant. The review of 0.20.3 wrote 44 more on the same lines (Python 21, Go 5, Rust 5, Swift 5, Java 4, JS 4): the suites above killed 7; with the review's tests 41 are killed, and 3 live: one equivalent (JS: the write callback ignoring its error while the 'error' listener stays, the same outcome in 88 of 88 cases), one that changes only how fast Java drains after a long wait, and one that differs only under a umask removing the owner's bits (the Go tip's mode); and 9 on the review's own lines, all killed. Windows and macOS: not measured here (the CI has a macOS job for Swift). - Corrected after 0.20.2. The 0.20.2 notes said SPEC §6 lists the changes since v0.2 that turn a PASS into a FAIL and named three; a fourth is older: a ledger with zero entries is FAIL since 0.9.3 (2026-09-11; the JS verifier said PASS on it before). §6 now lists it, says that no verifier states the SPEC version yet (it said verifiers do), and the Merkle section, which also carried the number 6, is §9.
Swift reads a FIFO; tsa_token leaves the canonical form; the SPEC says who verifies signatures; the LOTL check reads the status in force (2026-10-12, 0.20.2)
Defects present in 0.20.1 and before: three found by the review of 0.20.1, one in the ingest found while correcting them,
the others in cryptovalid_lotl and verify_archive --lotl (found by a measurement of the EU Trusted Lists and by the
review of this release). One more, in eidas_ledger_check, the tsa_token change itself would have opened; it is closed
in the same release.
- SPEC v0.3. The SPEC header moves from v0.2 (2026-08-13) to v0.3 (2026-10-12). §6 lists the changes since v0.2
that turn a PASS into a FAIL: the closed entry schema (0.19.0) and
idxon every path (0.20.0), made without a version bump, andtsa_tokenoutside the canonical form (0.20.2). - Swift did not read a FIFO.
cvverify <(cat ledger.jsonl)answeredFILE_ERROR, exit 2, in the Swift verifier of 0.20.1 and 0.20.0, while Python, JS, Go, Java and Rust read the bytes and gave the verdict. Swift now reads the file to its end through a file handle; a directory and a missing path stayFILE_ERROR, an empty file stays FAIL. The oracle caseinput-fifo(all six verifiers must answer PASS) is a disagreement with the Swift binary of 0.20.1; aswift test(FIFO, directory, missing path, empty file) fails when the 0.20.1 read is put back into the new function; the CLI line that calls it is covered by the oracle case and, on macOS in CI, by a FIFO gate. tsa_tokencould not be what §2 said. §2 defines it as an RFC 3161 token overself_hash, and §3 kept it inside the bytesself_hashis computed from. Measured with a local test TSA (openssl ts): a real token over an entry'sself_hash, added after hashing, was FAIL in all six verifiers; withself_hashrecomputed over the token, all six said PASS and the token no longer bound the entry'sself_hash. Since 0.20.2tsa_tokenis outside the canonical form, like the signatures, in the six verifiers and insigner.py: the first ledger is PASS in all six, the second FAIL. A ledger written with the token hashed as content is FAIL from 0.20.2 on. None was found: on 2 880.jsonlfiles in four directory trees of the development machine, and on 126 more files carryingself_hashelsewhere on it (backups and copies), the six verifiers give the same verdict and exit code before and after the change on every file (1 527 PASS and 1 479 FAIL in each of the six; many of these files are not ledgers), no file was rewritten (sha256 before and after), and no line of them carriestsa_token, at the top level or nested (355 474 JSON objects). Two normative vectors fix the rule (tsa_token_attachedPASS,tsa_token_in_hashFAIL at idx 1); four oracle cases check the verdict, not only the agreement; tests in Python, JS, Go, Rust and Swift fail on the code of 0.20.1. No verifier here checks the token itself (§5 step 6), and the SPEC now says so.- "Verified when present" was not true of every verifier. Measured on nine three-entry ledgers (unsigned; signed;
signed by another key; one signature changed, missing, malformed or without
signer; hybrid; hybrid with one ML-DSA-65 signature changed), with no key, the right key and another key:verifier.pyand the Rust verifier verify no signature (a changed signature is PASS);signer.py verifyrefuses every broken, missing or malformed signature, with or without--pubkey; JS, Go, Java and Swift verify Ed25519 and report it, and the verdict depends on it only when a key is given; ML-DSA-65 is verified bysigner.py, Go and Java, not by JS or Swift. SPEC §2 and §5 andspec/CONFORMANCE.md(which said Swift verifies no signature; it has verified Ed25519 since its first version) now say this. §5 also namedevidence_pack.pyas the implementation of step 6: it timestamps the pack's manifest digest, not entries. The verifiers' behaviour on signatures is unchanged: the 189 results of the matrix (verdict and exit code) are the same before and after. - The same list in the ingest. Resuming a segment,
cryptovalid_ingestrecomputedself_hashleaving outself_hash,signatureandsigneronly, so a segment whose entries carrysignature_pq/signer_pq(or nowtsa_token) was refused as tampered while the six verifiers PASS it. It now leaves out the six fields of §3; a test resumes both kinds of segment, refuses each one with its content changed, and fails on the code of 0.20.1.cryptovalid_merklestill builds its leaves leaving out the first three only (on a hybrid or timestamped ledger a change would change every root and receipt built with the current rule; on 2026-10-12 no ledger on the development machine carriessignature_pq,signer_pqortsa_token): not changed here, an open decision. SPEC §6 (Merkle) said the leaves are the canonical form of §3, and the module's docstring "the same rule as self_hash": both now say what the leaves are. - A withdrawn timestamp service counted as qualified.
cryptovalid_lotltook aTSPServiceas a qualified timestamp service when the textSvcstatus/grantedappeared anywhere in it, itsServiceHistoryincluded. ETSI TS 119 612 puts the status inServiceInformation/ServiceStatus, in force from itsStatusStartingTime, and the earlier ones inServiceHistoryInstanceentries; ETSI TS 119 615 (PRO-4.3.4-03) reads the status at a date the same way and matches the certificate against theServiceInformation's digital identity. A service now counts when the entry in force isTSA/QTSTandgranted, with the certificates of itsServiceInformation. On 26 of the 30 national lists the LOTL points to (downloaded on 2026-10-12; PT and SI did not download then; CZ and DK below): 984 certificate fingerprints in 0.20.1, 652 in 0.20.2 (332 fewer, none added; 17 lists change). 634 of the 652 havegrantedinServiceInformation; the other 18 are 18 services of one list whoseServiceInformationsayswithdrawnfrom a date in 2027-2029 and whose status in force today isgranted. A new function,qtst_fingerprints_at(list, instant), gives the services qualified at a given instant (a token's genTime, which the caller supplies);verify_archive, the ingest anchor andcryptovalid_report --lotlcheck the status in force at verification time, or when the cache was built. - Two lists the LOTL names were not fetched. The LOTL pointers were taken by a regex on locations ending in
.xml: the Czech list (TSL_CZ.xtsl) and the Danish one (TSLDK_v6xml) were skipped, and with them 420 and 6 fingerprints of services granted today (measured on the lists of 2026-10-12). The pointers are now read from the LOTL as XML: everyOtherTSLPointerwhose MimeType isapplication/vnd.etsi.tsl+xml, the LOTL's own pointer left out — 31 locations (the 30 national lists and the United Kingdom's last one, which has no granted QTST service). - The list parser was not closed. A document type declaration was refused by a byte search for
<!DOCTYPE, which a UTF-16 list passes: its entities were expanded (measured on Python 3.9, 3.11 and 3.13). TS 119 612 requires UTF-8 for XML trusted lists; a list that is not UTF-8 or that carries a document type declaration is now refused by the XML parser itself, in any encoding it declares.StatusStartingTimeis read only in the form TS 119 612 §5.1.3 writes (YYYY-MM-DDThh:mm:ssZ):datetime.fromisoformatalso took a value withoutZ, an offset or a fraction, and on Python 3.11 and 3.13 three forms that 3.9 refused (.5Z,20170301T000000Z, a week date), so the same list could count a service on one Python and not on another. EveryStatusStartingTimein the lists read on 2026-10-12 (29 national lists, PT missing, and the United Kingdom's) has the prescribed form: 9 888 of 9 888. - The fingerprint cache had no age. The ingest keeps it in
~/.cache/cryptovalidand read it whatever its age, so a set built on one day answered for every later day. The cache now recordsbuilt_atand is rebuilt after 24 hours (cache_max_age_s); a cache of 0.20.1 (no rule) is rebuilt. Inverify_archive --lotl, a LOTL that cannot be fetched or is not a trusted list is the warninglotl_unavailableandeidas_qualified_segments: null, not a traceback (a network error raised out of it until 0.20.1). - A presence counted as a timestamp.
eidas_ledger_checkcounted the entries carryingtsa_token(REQ-7.5-04-bis and the ledger report). Withtsa_tokenoutside the canonical form,"tsa_token": "AAAA"added to every entry of a signed ledger keeps every hash and signature valid, and turned REQ-7.5-04-bis fromNON_APPLICABILEintoPARZIALE. It now counts the tokens that carry the entry'sself_hashas message imprint and whose CMS signature verifies (cryptovalid_tsa); the entries that only carry the field are reported apart (record_con_campo_tsa_token). - Tests for each point (synthetic lists and ledgers, a local
openssl tsTSA) fail on the code of 0.20.1, except the granted-today control, which passes there too. - Measured on this code. Differential oracle 0/110 over the six verifiers (26 declared divergences, on signed tips);
on the 0.20.1 code with the 0.20.1 Swift binary the same oracle counts 5/110.
conformance.py11/11; JS--conformance11/11 and its suite 117–118 checks (the count moves with a random signature); Rust and Swift binaries 11/11 onspec/vectors/;cargo test9 tests andclippy -D warnings;swift test18/18;go vetandgo test; the 48 Python test files on Python 3.9, 3.11 and 3.13 and with the declared floor (cryptography48.0.0,ap2-evidence-pack1.3.0), 48/48 each;closed_schema_mutants.py1 500 mutations, seed 20261011, GREEN over the six verifiers.
The six verifiers follow one acceptance profile; Swift aligned, defects closed in Python, JS, Rust and Swift (2026-10-12, 0.20.1)
- The finding. On 2026-10-11 the differential oracle, run with Swift for the first time, counted 12 Swift-only
divergences: a valid surrogate pair refused;
+10,010and a raw tab accepted; CRLF lines refused;FILE_ERRORon a file that is not UTF-8 (×2); five CLI forms answered with a verdict instead of a usage error. Swift had not been updated for the profile rules of 15–21/09. - The rule, not the case. The Swift parser reads Unicode scalars (a Swift
Characteris a grapheme cluster:\r\nwas one character, and String equality is canonical equivalence, soéande+ U+0301 were one key), applies the RFC 8259 integer grammar, refuses raw control characters, reads\uas four ASCII hex digits, combines surrogate pairs and refuses unpaired ones, bounds nesting at 512, splits lines on LF only and FAILs a file that is not UTF-8. The CLI takes the ledger path first,--algo sha256|sha3_256and--pubkey, and answers anything else with a usage error (exit 2). The Ed25519 fields are read strictly, assigner.pyreads them. - Measured. Oracle with Swift 0/106 (12/89 on 2026-10-11;
deep-valid-600andlone-surrogateno longer declared; seventeen cases added on 2026-10-12 that only the rule can fail, on which the previous Swift code counts 23/106);closed_schema_mutants.py1 500 mutations, seed 20261011, GREEN over the six verifiers;swift test17/17, of which 10 new tests fail on the previous Swift code (the eleventh, whitespace between tokens, passed there too). - Found by the review and closed the same day. A raw non-UTF-8 byte hashed over Python's
surrogateescapecode point was PASS inverifier.pyalone; a whole-number float (1.0,0e0) hashed as an integer was PASS in JS alone, and a leading UTF-8 BOM in JS (and in the Swift code of 0.20.0); anidxpast 2^53-1 made JS throw without a receipt, and a JS receipt over 64 KiB was cut on a pipe;--algo foowas a PASS in JS and Rust; Swift's--pubkeydid not enter the verdict (a wrong key, an unsigned or a partly signed ledger was PASS, exit 0). Each is now refused, with an oracle case or a test that fails on the code before. Rust also printed the--algovalue into the receipt without escaping it, so a crafted value could add a secondverdictkey; JS accepted a signed chain tip that starts with a BOM, which Python, Go and Java refuse. Both closed. - Found by a second, independent review. The JS float check added the same day skipped strings with a backtracking
regex: 72 s on a 400 KB unclosed line of
\"pairs (0.07 s before). It is now a linear scan, equal to the regex on 2.4 million short lines and on 6 257 real and fuzz files, with a test that fails on the regex. - Chains. Run before and after over the
.jsonlfiles of the development machine (on 2026-10-12: the 2 820 under the working trees and the 138 other ledgers found in the home directory) and over new ledgers written by today's writers (PersistentLedger, the ingest,signer.py sign,omega_evidence, payloads with hard Unicode): the six verifiers agree on every file, the new ledgers are PASS in all six, and no file was rewritten (sha256 before and after). A PASS became FAIL in JS on three test files whoseidxis a float (0.0,1e0), and in Swift on two test files whoseidxis written01, all outside the format (SPEC §2, §3, RFC 8259); every Swift change (27 FAIL to PASS, 2 PASS to FAIL, 2 FILE_ERROR to FAIL) moved it to the reference's verdict, among them a real ledger of ours that the previous Swift code refused because of an emoji. - SPEC corrected. §2 said
signatureis hex over the canonical form;signer.pysigns theself_hashstring (its UTF-8 bytes) and writes the signature in base64, andsigner.py verifyand the JS, Go, Java and Swift verifiers check it so (verifier.pyand the Rust verifier do not check signatures). §2 (signature,signature_pq) and §5 step 5 now say so, and §3 names the five attestation fields the canonical form leaves out, as every verifier does (it named three:signature_pqandsigner_pqwere missing). No behaviour changes with this text.
- The shortcut 0.19.0 declared is closed. Until 0.19.0, with no
--algo, a ledger whose first three entries carried noidxwas read by a fallback for ledgers of other OMEGA modules (mode: full_recompute_genericorlinkage), which applied neither SPEC §2.1 nor §5 step 4: an unknown top-level key passed there, not named. That path is removed fromverifier.py. Such a ledger is now FAIL with or without--algo, as in the five other verifiers; the reference names each entry inparse_errors(idx_mismatch: expected 0, got (missing); the receipt keeps the first ten), Go and Java name itidx "" not sequential, Rust and Swift carry no reason text.evidence_pack.py verifyreports it ashash_pass: false. A ledger written by an earlier tool withoutidxno longer verifies here: it is not a ledger of this format. - The callers follow the verdict.
eidas_ledger_check.py(REQ-7.5-04,catena_ok) andcryptovalid_monitor.pyread only the hash and link results: a ledger withoutidx, or with a key nobody defined (FAIL since 0.19.0), wasSODDISFATTO_PER_COSTRUZIONEand green there. Both now use the verifier's verdict; the monitor's alert names the first reasons. idx_mismatchsays what was there. The value is printed as JSON, and(missing)when the key is absent; Python and JS print the same text (except an objectidxwith integer-like keys, which JS orders first). Before, Python printed an absent key andnullalike (None) and"1"like the number1; JS printedundefined,nulland1.cryptovalid-verify ledgernames the reasons. It printedverdict=FAILonly; the layer now carriesreasons(hash, link, schema and parse failures, with idx — as JSON — or line) and the first three indetail.- The benches refuse a stale binary. The differential oracle had measured a Rust binary built on 21/09, older than its
sources, as if it were the tree's code. When the toolchain is on PATH the oracle rebuilds Rust (
cargo build) and, if a Swift binary is present, Swift (swift build); the closed-schema bench does the same. Without the toolchain, the oracle refuses a Rust or Swift binary older than one of its sources (src/,Sources/), naming the file; the bench does so for Swift (it measures Rust only whencargobuilds it). Tests and manifests are not sources: a touched test never relinks the binary, andswift builddoes not relink a source that is only touched. - Measured. The two oracle cases
missing-idx-*are FAIL in Python, JS, Go, Java and Rust: 0/89 disagreements, the two cases no longer declared.closed_schema_mutants.py(1 500 mutations, seed 20261011): GREEN over the six verifiers, Swift included; the same run with the 0.19.0verifier.pyis RED with 7 silent PASS, allmissing_idx(positive control). The 12 older Swift divergences listed inCONFORMANCE.mdare unchanged.
A field nobody defined is never silent: closed entry and manifest schemas, the entry rule in all six verifiers (2026-10-11, 0.19.0)
- The finding. Measured on 0.18.1 on 2026-10-10: a ledger entry written with a top-level key nobody defined (self_hash
recomputed over it), a MANIFEST or
ledgers[i]with an extra key, and an extra key in the unsealedrfc3161_timestampblock all verified PASS / valid with no trace in the receipt of the Python reference; on the entry case the JS, Go, Java, Rust and Swift ledger verifiers answered PASS too. - The rule, written first (SPEC §2.1), then applied everywhere. The entry's top level is exactly the core and optional
fields of SPEC §2; the MANIFEST, each
ledgers[i]and therfc3161_timestampblock are closed objects. A key outside the list FAILS and is named:unknown_field:<keys>in the reference'sschema_failures(manifest_unknown_field: …for a pack). The five other verifiers, which verify ledgers, apply the entry rule: the same text in the Go and Javafailures[]and the JSschema_failures[].reason, the entry in aschema_failures_idxlist in Rust and Swift.datastays free (SPEC §4). Two normative vectors (unknown_top_level_keyFAIL,unknown_key_in_dataPASS). - Measured, not asserted. The differential oracle: 0/89 disagreements over Python, JS, Go, Java and Rust (28 declared),
with cases that require the reason text and the idx list to equal the reference's;
verifiers/closed_schema_mutants.py: 1 500 generated mutations GREEN over the six verifiers, Swift included, while on 0.18.1 the quick run (150) counted 32 silent passes in each of Python, JS, Go, Java and Rust. Details and counts inCONFORMANCE.md. - Declared, not closed in this release. When no algorithm is forced, a ledger whose first three entries carry no
idxgoes to the reference's fallback for ledgers of other OMEGA modules; when its hashes recompute there (receiptmode: full_recompute_generic), that path applies neither SPEC §2.1 nor §5 step 4: an unknown top-level key passes, not named, while the five other verifiers refuse the ledger. With--algothe reference applies the profile and names the key. The ledger receipt says which path it took (mode); the evidence-pack receipt does not:evidence_pack.py verifyreports such a ledger ashash_pass: true,valid: true. Closed in 0.20.0 (section above). Also declared: the types oftsanddataare not checked by any of the six; 12 older Swift divergences are listed inCONFORMANCE.md.
The Cedulon index stage checks refs and effect claims, as Verax's verifier does (2026-10-06, 0.18.1)
- The index stage reads refs, and effect completeness. Every ref an
index.jsonlrow names must be the ref of a decision the ledger holds, and a row that says its ref has an effect (kindeffect, orhasEffecttrue) needs an effect row bound under that ref; a row's decision kind is not read, sinceindex.jsonlis unsigned. Until now this checker held a row's decision kind to the ledger (adenyrow had to name a deny record under that ref) and did not readhasEffect. Source:indexStatement,effectRefsInIndexandnoteEffectCompletenessin verax-ai/veraxpackages/proxy/src/verify-ledger.tsandparseIndexTextinledger-manifest.ts, the same atf59ece9and ate008f55, and the stage table intest-vectors/tools/stages.ts, which files both of their problem lines ("index names …", "index says ref … has an effect …") underindex. - Run against Verax's verifier (
verifyLedgerate008f55and atf59ece9, Node 22.23.2, 2026-10-06), onvalid-fullunchanged and with lines added toindex.jsonl(one line in 18 cases, a line before a held row in 4), 23 cases: the verdicts agree on 13, the unchanged ledger among them (on a line that is JSONnullVerax's verifier fails without naming a stage). Where they differ, this checker fails and Verax's passes for a row that names no stringref, for a row with no stringpiecewhose ref or effect claim fails here (Verax's parser skips such a row; this checker does not readpiece, so a row with a held ref, no effect claim and no stringpiecepasses both), for a duplicate member (JSON.parsekeeps the last) and for a last line that is not JSON (Verax tolerates it as a half-written append when an earlier line parsed); Verax's fails and this checker passes for a line of spaces, or a lone\r, before the last, which this checker skips. Measured the same day beyond those 23: a line of other whitespace only (a tab,\x0c, U+00A0, U+2028) before the last splits the same way; an empty line is skipped by both; an integer of more than 4300 digits (Python's limit on converting an integer string) and arrays or objects nested more than 500 deep (below) fail here and pass there. - A line that is not JSON fails the stage that reads its file. Until now such a line in
inputs.jsonlorindex.jsonl, a duplicate member orNaNincluded, was reported undercheckpoint-signature; the verdict was INVALID either way. It now failsinputs-bindingorindex. A blank line is skipped, as before. - A line nested more than 500 deep fails the stage that reads its file, on every Python. Until now
json.loadsraisedRecursionErrornear 1000 levels on 3.9 and 3.11 and near 10000 on 3.13, so anindex.jsonlline 5000 levels deep made the verdict INVALID (malformed) on 3.9 and 3.11 and VALID on 3.13 (measured 2026-10-06). - A line ends at
\nonly. Until nowstr.splitlinesalso cut a line at U+2028, U+0085 and the other Unicode line boundaries, which JSON allows raw inside a string, so such a line failed as not JSON in any of the five files. - On verax-ai/verax
test-vectors/v1atf59ece9(the same setup as 0.17.2's run: checkpoint under the witness key; inputs, index and operator credentials given), measured 2026-10-06 with Python 3.9, 3.11 and 3.13 and cryptography 50.0.2: 20 of 20 verdicts and 16 of 16 first failing stages (0.17.2: 14 of 16; the checker is the same in 0.18.0).fail-allow-while-haltedandfail-allow-while-halted-witnessednow stop atcontrol, as the vectors expect; every other verdict, stage and reason is unchanged. Withclock_skew_ms=0, 15 of 16:fail-allow-while-haltedstops ateffect-binding(decision-without-effect), since no allowance defers its forged allow, as in 0.17.2. The positive control (one bit of a record signature flipped invalid-full) fails atrecord-signature. test_cedulon.py(19 tests) holds the index stage to seventeen bent ledgers that must fail it (five of them by their reason) and eleven that must pass it, every file to a line that is not JSON, and a line nested past 500 levels to one verdict on every Python; 41 mutations of the index stage, the line loader and the depth check each turn it red on Python 3.9, 3.11 and 3.13.
- One copy of the AP2 verifier, in ap2-evidence-pack. Until 0.17.2 this wheel and ap2-evidence-pack installed the
same two paths,
ap2_evidence.pyandpqcrypto/sigsuite.py; pip overwrote them silently in the order of installation and uninstalling either removed them for the other (measured 2026-10-04). The copy here had fallen behind the reference: measured 2026-10-05, a pack built withpython3 ap2_evidence.py buildfrom a 0.17.2 clone is refused by ap2-evidence-pack 1.2.2 and by its JS verifier (honest_scope does not match the canonical scope of this evidence_format (SPEC §1)), and the copy lacked the strict JSON loader, the regular-file check, the x5c chain and the RFC 3161 token walk. 0.18.0 ships neither file:cryptovalid-verify ap2and the MCP tool importap2_evidencefrom ap2-evidence-pack >= 1.3.0 (extraap2); the two wheels installed together share no path (RECORD intersection 0, measured).ap2_evidence.py,pqcrypto/,test_ap2_evidence.py,test_ap2_shape.py,test_ap2_conformance.py,spec/vectors/ap2/and the copy ofSPEC_AP2_EVIDENCE.mdare gone from the clone: the normative text and the vectors are ap2-evidence-pack's. The documentedpython3 ap2_evidence.py build|verifycommands becomeap2-evidence build|verify(the package's console script). - The reference's refusal reason reaches the receipt.
verify_ap2readerrorfrom the old copy's receipt; since 1.1.0 the reference's receipts carryrefused, so a refused pack was a FAIL with an empty detail (measured on a too-deep pack and on a pack with a non-canonical honest_scope). The last layer now carries it; two tests cover it. signextra:cryptography >= 48(was >= 50): the floor where ML-DSA-65 works (47.0.0 raises UnsupportedAlgorithm).cryptovalid-verify autorecognises an AP2 pack whoseevidence_formatis past the first 2048 bytes. A pack is one JSON object with sorted keys, soartifactscomes first: measured 2026-10-05, a valid pack built byap2-evidence build1.3.0 from two mandates hasevidence_formatat byte 3506, andautoverified it as a ledger and said FAIL.autonow reads the top-levelevidence_formatof a JSON object file; a test builds a pack where it sits past byte 2048.- The versions of linked projects are written once. pyproject.toml holds the
ap2range (ap2-evidence-pack= 1.3.0, < 2) and the
signfloor;test_dependency_versions.pyfails when the CI or the README says otherwise, and the floor job installs both minimums exactly, so every run shows they exist on the index and work. A weekly scheduled run installs the newest ap2-evidence-pack 1.x.
cryptovalid_cedulon.verify_ledgerapplies the boundary rule that decision-profile-03 takes unchanged from the core (cedulon-08 §11.4 step 5, MUST-T10-17; core-03 §12.1 step 5): an allow with no effect row withinclock_skew_msof the newest attested record, and an effect row that no record names withinclock_skew_msof the oldest, are listed underboundary_deferredand do not fail the verdict; a VALID result with a deferral says so inwhy("boundary- deferred, so the guarantee is conditional"). A count shortfall under a ref that has a row stays a finding. The default is 300000 ms, the five minutes the profile names. The profile does not say which edge applies when every effect row carries its own one-millisecond extract; this checker reads a one-ledger audit, whose edges are the oldest and the newest record: for the closing edge, the reading Verax's own verifier takes sincef59ece9; Verax applies no opening-edge allowance (a row no record names is a finding there), the core's rule has one, and this checker applies it. A ledger shorter than the allowance therefore defers every unmatched allow and every unnamed row it holds.clock_skew_ms=0applies no allowance. 0.17.1 applied none.- On verax-ai/verax
test-vectors/v1atf59ece9(20 vectors; checkpoint under the witness key; inputs, index and operator credentials given), measured 2026-10-05: 20 of 20 verdicts and 14 of 16 first failing stages, as with 0.17.1; withclock_skew_ms=0every verdict, stage and reason equals 0.17.1's (the output gains the keyboundary_deferred, empty there). The forged allow offail-allow-while-haltedis now deferred (30 ms from the newest record; it is also 30 ms after the halt extract'swindowEndMs, the edge the vectors README measures from) instead of failingeffect-binding. Every ledger in the set spans at most 203 ms, so at the default no unmatched allow or unnamed row in it can reachdecision-without-effectoreffect-without-decision; the two edges are measured intest_cedulon.py.fail-allow-while-haltedandfail-allow-while-halted-witnessedexpectcontroland stop atindex, because this checker reads an index row by its kind as well as its ref (the index names a deny the ledger no longer holds: the record under that ref is now an allow); Verax's verifier compares refs, and effect completeness (corrected 2026-10-06, see above). Run without the index stage, both stop atcontrol. test_cedulon.pytests both edges at the exact allowance and one millisecond past it, that 0 applies none, also on the edge itself, and that a count shortfall is not deferred; nine mutations of the rule, listed in the test, each turn it red. A comment incldma_confidential.pyno longer names a module outside this repository (ff1bd73, onmastersince 2026-10-05; 0.17.2 is the first release that carries it).
- AP2 verification works from a pip install.
ap2_evidenceimportssigsuitefrompqcrypto/, which the 0.17.0 wheel did not contain: installed from the index,cryptovalid-verify ap2and the MCP toolverify_ap2_evidencefailed on every file withModuleNotFoundError: sigsuite(a refusal, never a pass). The wheel now shipspqcrypto/. From a clone nothing changes. cryptographyis declared, as thesignextra. The wheel declared no dependency, so a plain install had no signature layer.pip install 'cryptovalid-opencore[sign]'installedcryptography>= 50, the version 0.17.1 was measured with (0.18.0 lowered the floor to 48.0.0) (ML-DSA-65 itself works from 48.0.0: generate, sign and verify measured with 48.0.0, 49.0.0 and 50.0.1; 47.0.0 ships themldsamodule but raisesUnsupportedAlgorithm). The hash-chain verifier still needs only the standard library, so the extra stays optional.- Eleven file and URL handles closed. Eleven
open()/urlopen()calls in shipped modules did not close what they opened (one wrote a cache file through an unclosed handle); each is now awithblock. The two long-lived segment handles ofcryptovalid_ingestare closed on rollover and inclose()(see below for the one path that missed). pqcrypto/sigsuite.pyis the code of ap2-evidence-pack 1.2.2 (byte-identical except its first lines: there a three-line Apache-2.0 header and a one-line title, here this repository's one-line title). The copy shipped here was older: it did not refuse small-order Ed25519 keys (with the identity key, R=identity and S=0 verified on every message — the forgery measured 2026-09-25 and closed in ap2-evidence-pack 1.1.0/1.2.x), it decoded base64 leniently and it crashed on a non-object signature entry.test_ap2_evidence.pycarries the small-order test; on 0.17.0 it fails.- Same files as ap2-evidence-pack: install one of the two per environment. Both wheels install
ap2_evidence.pyandpqcrypto/sigsuite.py; pip overwrites them silently in the order of installation (pip checksays nothing) and uninstalling either package removes them for the other (ModuleNotFoundError: ap2_evidence, measured 2026-10-04 with 1.2.2 and 0.17.1). Theirap2_evidence.pystill differ (1.2.2 is ahead); unifying them is open. test_ap2_evidence.pyhad twoif __name__ == "__main__"blocks: run as a script (as the CI does) it stopped after the first one and 13 of its 27 tests never ran; they run now. One of them,test_kb_tampered_sd_hash_refuses_build, tampered thesd_hashby setting its first character toX, a no-op whenever the digest already began withX(1 run in 64; measured 2026-10-04: 1 failure in 200 runs, 0 in 200 after the fix); it now puts a character that differs from the real one.cryptovalid_ingest.Ingestor.close()closes the segment handle also whenseal()raises (a signing backend that fails); before, that handle stayed open with no call left to close it.test_packaging.pygains three tests, one per point, read frompyproject.tomland the shipped sources; on 0.17.0 each of the three fails.
Four clean-room verifiers, an audit of the verifiers, and RFC 3161 marks only for a granted token (2026-10-04, 0.17.0)
- RFC 3161:
anchored: trueonly for a granted token for this digest.evidence_pack._rfc3161_stamprecorded any HTTP body asanchored: true— an error page, a rejection, a token for another digest. It now records a mark only when the reply's status is granted (0 or 1, both carry a token) and the token's message imprint is the requested digest;test_stamp_tsa_locale.pybuilds a local TSA with openssl and fails on 0.16.2 for each wrong reply. - Audit of the verifiers (thirteen defects: twelve fixed in code, each with a test that failed on the code before the fix; the thirteenth settled in the tip spec and pinned by a test).
verify_evidenceno longer passes a signature that is present but broken (pack and AP2 producer);verifier.py,signer.py,cryptovalid_receipt,cryptovalid_ingest.verify_archiveandevidence_pack.verify_packanswer a non-object line, an unreadable HEAD/STH or a malformed MANIFEST with a named failure instead of a traceback, and file names outside the pack are refused before they are opened; tree sizes and leaf indexes are strict unsigned integers (true/falserefused) in the SCITT and COSE readers; JWS and AP2 base64url is strict (RFC 7515: no padding, no standard alphabet, no whitespace, canonical trailing bits); the JS verifier decodes signature and signer as strictly as Python and Java, and with--pubkeya bad signature is FAIL with exit 1; the tip spec states what all verifiers do withlog_pubkey_hex: null(absent). - AP2 evidence of the wrong shape is a refusal receipt, not a
TypeError/AttributeError(test_ap2_shape.py). cryptovalid_receiptcompares the declaredvdsandsth.kindinstead of ignoring them (test_receipt_identifiers.py).cryptovalid_merkle.consistency_verdictgives three outcomes — consistent, inconsistent, could not compare (sizes out of range);verify_consistencyis unchanged.- Four new modules, read from the published texts and the IETF AUDIT BoF vector exchange:
cryptovalid_cose_receipts,cryptovalid_cedulon,cryptovalid_aps,cryptovalid_vaara_receipt(see the module table). Measured on 2026-10-04 against the frozen vector sets: scitt-cosevectors-ietf126@529515ba6/6 as RFC 9942 receipts (3/6 with the RFC 9943 §6 header MUSTs applied); Veraxtest-vectors/v1@f50c59b, all stages, checkpoint key = the witness key, 16/16 verdicts and 15/16 first failing stages; APSaps-conformance-suite@5500a56canonical bytes 18/18,accountability-record12/12,actionref-canonical4/6 against the -01 form; Vaaratests/vectors@ff683c2, only the RFC 9162 proofs and the ML-DSA-65 signatures, 10/10, 7/7, 10/10. test_stamp_tsa_locale.pycloses the files it opens; two source comments no longer name modules outside this repository; the JS README says 93 checks; the stalecryptovalid_opencore.egg-info/is no longer tracked.
Measured before the tag: 48 of 48 test files on Python 3.9.25, 3.11.2 and 3.13.15, pqcrypto 2/2, conformance.py
exit 0, the JS verifier 93/93; the Go, Rust, Java and Swift jobs and the differential oracle are measured by the CI run on
the tagged commit.
- The vendored key-window vectors are now the upstream bytes.
examples/acta/key-window/SOURCE.mdsaid the eight files were "copied verbatim" from ScopeBlind/agent-governance-testvectors at1e24b5687. Compared byte for byte with that commit on 2026-09-26, each copy shipped in 0.16.0 and 0.16.1 lacked the file's final newline (one byte); the JSON content was identical, so no verdict changes: 6/6 on verdict,codeandkey_status, both before and after. The files now carry the upstream bytes andKW_SHA256pins them; removing the final newline from one file makes the pin test fail. The re-fetch recipe inSOURCE.mdis replaced: the previous command does not run (gh api2.92.0 rejects--refas an unknown flag, exit 1). The new recipe, run on 2026-09-26, reproduces the eight files byte for byte.
committed_attestation.anchor_commitmentworks in a public install. From its introduction (0.9.0) through 0.16.0 it imported a module that is not part of this repository. 0.16.0 installed from the index in a clean virtualenv, run with an isolated Python (-I), answersok: false, "No module named 'core'" (measured 2026-09-26). It now uses the public OpenTimestamps library (python-opentimestamps, LGPL-3.0) as an optional dependency:pip install cryptovalid-opencore[ots]. Without it the answer isok: falsewith anerrorthat names the missing library; with no calendar reachable it isok: false,status: "failed", anderrorssays per calendar what went wrong; a root that is not 32 bytes isok: falsewith anerror. The return value keeps its fields;errorsis new, and a newcalendarsargument replaces the library's default calendars (an empty list means no calendar). Measured 2026-09-26, with this code built as a wheel and installed with[ots]andcryptographyin a clean virtualenv, isolated Python: the function committed on 4/4 calendars —https://a.pool.opentimestamps.org,https://b.pool.opentimestamps.org,https://a.pool.eternitywall.com,https://ots.btc.catallaxy.com. In the same install, the test file run against the installed module passed 14/14 with the live test enabled; that test decodes each returned proof as an OpenTimestamps Timestamp rooted at the commitment hash (the message is an input to the decoder, not carried by the proof) and checks that it holds at least one attestation and that every attestation is pending (not yet Bitcoin-confirmed). Upgrading a proof to a confirmed one is not part of this function. Tests: two new offline ones — one without the library, one with a stand-in calendar that has three entries: committed, unreachable, and answering for another message (a defensive guard: with python-opentimestamps 0.4.5 the message is set from the digest we pass, so it cannot trip there) — and one live test, reported as skipped unlessCV_ONLINE_OTS=1. Removing the message guard or the per-calendar error handling turns the offline tests red.test_cryptovalid_acta.pycloses the PEM/DER key files it writes (aResourceWarningin the 0.16.0 CI log).- A source comment in
cryptovalid_acta.pysaid Arabic-Indic and fullwidth digits were accepted "up to 0.16.0"; 0.16.0 refuses them, and they were accepted by 0.15.1 and by the untagged8de0b72(measured 2026-09-26).
Code: cryptovalid_acta.py and its tests; the other modules are unchanged since 0.15.1 (the README, the packaging
metadata and the vendored key-window vectors also change). The public master already carried part of this work as
8de0b72 (25 September, version string 0.16.0, never tagged or released); this is the release. The "before" figures
below are measured on 2026-09-26, unless dated otherwise, on that commit and on the 0.15.1 tag, with the author's probe scripts (not part of this
repository). This release also removes .nocrypto_test/, a test virtualenv skeleton (activation scripts, interpreter
and lib symlinks and a pyvenv.cfg naming a local path; no packages) that the repository had carried by mistake since the
0.14.0 release commit (c12d847, 19 September). Tests: test_cryptovalid_acta.py 51/51 with cedarpy and
cryptography installed (one test is skipped without cedarpy).
Key status is always stated (§5.5). Every verification outcome — the library's result, and the CLI's output on
exit 0, 1 or 77 — carries key_status and a machine-readable code; a refusal at load (exit 2: an unusable key set, key
file or input file) reports only ok: false and error. key_status is one of not_reached (the receipt was refused
before the key set was consulted — the default, because saying anything else would assert what was not measured),
unknown_key, no_window, inside, outside, undecidable. code is null or one of key_outside_validity_window,
key_window_undecidable, key_not_supplied, issuer_not_trusted, key_material_invalid, signature_invalid (the
signature does not verify under the key) and signature_in_signing_input (a payload that carries a signature member,
§6.6); every other refusal — a malformed envelope, a sig that is not lowercase hex — carries its reason in why with
code null. On 8de0b72 a signature with one bit flipped and a payload carrying signature both came back with code
null.
Small-order keys. A relying-party Ed25519 key that is a point of small order is unusable key material: with such a
key anyone can forge a signature on any message. An invalid or non-canonical encoding (y >= p, or the sign bit set with
x = 0) is refused too, because it does not name one point unambiguously; no forgery is claimed for it. With the identity as key, R=identity and S=0 verifies on every message. With a small-order
key of order n (2, 4 or 8), pick S, guess g = k mod n, set R = [S]B − [g]A and retry until k = H(R‖A‖M) matches the
guess: each try succeeds with probability about 1/n. Measured 2026-09-26 with the probe's own curve arithmetic and
OpenSSL (Python cryptography) as the verifier: a signature on a message of the forger's choice verified for all 8
small-order keys, after 1 to 16 tries; the same construction against an ordinary key verified 0 times in 2000 (null
control). Measured 2026-09-25: a receipt forged with R=identity, S=0 against the identity key passed.
--key, a key file and --jwks refuse such a key at load (exit 2); the library returns
not_assessed with code: key_material_invalid. The check is curve arithmetic (canonical decoding, then [8]P is not the
identity). Measured 2026-09-26 on the check itself: the 8 distinct small-order points (computed with the probe's own curve
arithmetic, not the library's, as the torsion part of random points), the 2 encodings with the sign bit on x = 0 and 4
encodings with y >= p are all refused, and an ordinary key is accepted; through the CLI, the identity key is refused at
load given as a --key hex argument, as a key file (hex, PEM or DER) and inside --jwks (exit 2), and the library returns
not_assessed / key_material_invalid.
The relying party's keys. verify --jwks <file> reads an Ed25519 key set with its valid_from/valid_until,
through the same strict JSON parser as the receipts (duplicate member names, NaN and nesting deeper than 512 refused).
A key held for another purpose is ignored and named on stderr: a kty other than OKP, a crv other than
Ed25519, an alg other than EdDSA or Ed25519, a use other than sig, and key_ops without verify (RFC 7517 §5
asks implementations to ignore keys whose kty they do not understand or whose values are outside the supported
ranges). Refused — exit 2, never resolved
by order or skipped — are a kid that appears twice (in the set, or again through --key), a JWK with x but without
kty or crv, an x that is not the canonical unpadded base64url encoding of 32 bytes, and a valid_from or
valid_until that is not an RFC 3339 instant. On a JWK with x but without kty or crv, or with an x that does not
decode to 32 bytes, this is stricter than RFC 7517 §5, which would ignore a key missing a required member or with a value
out of range: such an entry is more likely a defect in the relying party's own list than a foreign key, and ignoring it
would turn a configuration error into "key not supplied". On 8de0b72 a
duplicate kid and a JWK without kty both verified the receipt (exit 0).
--key kid=<hex|file> accepts 64 hex digits in either case, and a key file in SubjectPublicKeyInfo form, PEM or DER; it
refuses at load key material it cannot read as a key (an empty file, a malformed argument). On 8de0b72 a valid Ed25519
key file, PEM or DER, made a valid receipt FAIL (exit 1, "EdDSA needs a 32-byte key and a 64-byte sig…").
Instants. Dates are read in ASCII digits only (RFC 3339 §5.6, ABNF DIGIT): on 0.15.1 and on 8de0b72 an issued_at
written in Arabic-Indic digits was accepted, because Python's \d matches every Unicode decimal digit. A leap second is
accepted only when it is 23:59:60 once the offset is applied (RFC 3339 §5.7; 00:59:60+01:00 is accepted); §5.7 also
limits it to the end of a month in which a leap second occurs, which is not checked here — 23:59:60 UTC is accepted on
any day. It sorts after every 23:59:59.x and before the next second (the comparison is exact by construction;
measured with an 18-digit .999…); 0.15.1 and 8de0b72 accepted
:60 at another minute (12:00:60Z, measured). Window comparisons use every fractional digit: on 8de0b72 an
issued_at 100 ns before valid_from compared equal to it (the comparison had microsecond resolution) and the receipt
passed as inside; it is now outside. Through the CLI, a receipt whose issued_at is one unit in the last digit
before valid_from is outside, and inside when it equals valid_from; measured in two runs, one where issued_at
and valid_from both carry 20 fractional digits and one where both carry 5000.
Every file is read the same way. Receipts, --previous, --jwks, key files and policy files must be regular files
of at most 64 MiB, opened without blocking and judged on the open descriptor: a FIFO, a device or a directory is refused
at once (exit 2). On 8de0b72 a FIFO given as the receipt blocked the verifier until the probe's 10 s timeout, and a
symlink to /dev/zero given as the receipt or as --jwks was read until the process died of a SIGKILL sent from outside
the probe, before its 20 s timeout ran out (sender not identified). A receipt can still come from standard input through
a redirect (verify /dev/stdin < r.json); a pipe is a FIFO and is refused.
Key validity windows (§9.2 of -03). --keys-are-complete declares the key set to be the whole trust list. The
proposed -04 text of the draft (VeritasActa/drafts#3; the IETF datatracker still lists -03 on 2026-09-26) requires the
window to be applied to issued_at and the output to say which case applied, "so that a verifier that does not check
windows cannot be mistaken for one whose check passed". Measured against the six key-window vectors of
ScopeBlind/agent-governance-testvectors (merged to main in 1e24b5687, vendored under examples/acta/key-window/),
scoring all three columns the bench states — verdict, code and key_status — through the CLI with --jwks: 6/6,
6/6, 6/6 on this release, and also on 8de0b72 (the window check predates it; the differences between the two are the
ones measured above, which these six vectors do not exercise). The same vectors on 0.15.1, with the bare key material that
release accepts, give 3/6 verdicts, 3/6 code (a missing field scored as null), 0/6 key_status: it has no window
check at all (valid_from and valid_until do not occur in that file), so it accepts all six and gets the three
rejections wrong. The vectors' own declared invocation is verify <file> --jwks <jwks> --mode receipt --json; --mode
and --json are flags of the reference verifier and this CLI does not implement them, so the run is driven with --jwks
alone. An earlier version of this paragraph said 6/6 verdicts for 0.15.1; that was wrong and is corrected here.
The absence side of the verdict. A check this host could not run is not a finding about the receipt.
verify_receipt and verify_chain report verdict with a total order FAIL > NOT_ASSESSED > PASS, and the CLI maps
it to exit 1 / 77 / 0; ok and assessed are derived from it for existing readers, and ok stays false under
NOT_ASSESSED (fail-closed — a signature that was never checked is never a pass). not_assessed lists which receipts
of a chain were affected. Measured 2026-09-26 in a virtualenv without cryptography: on 0.15.1 a valid receipt and a
forged one both returned ok: false and exit 1 — our own missing library reported with the value of a bad signature;
on this release (and already on 8de0b72) both return not_assessed and exit 77; on this release the output reads
key_status: no_window, code: null.
The boundary is declared, not left implicit. A capability of this host (a missing cryptography, no ML-DSA
backend) is an absence. A usable input the relying party supplies (keys, policy bytes) and the profile itself (an
alg outside §6.9) are judgments; an unusable one is the relying party's configuration defect (below). An unknown kid
is a "could not look" unless the relying party passes keys_are_complete=True, declaring its set to be the whole trust
list: answering "invalid" about a signature this host never checked asserts what it did not measure. Key material of the relying party that is not a key in any form read here
(a number, an empty or non-hex string, 5 bytes, a PEM that does not load) is the relying party's configuration defect,
not a finding about the receipt: not_assessed with code key_material_invalid in the library, exit 2 at load in the
CLI. That test looks at the material alone, never at the receipt, so a usable key of another type than the receipt's
alg stays a fail — otherwise flipping the unsigned alg of a forged receipt would buy it "could not look". An
absence never hides a finding: a forged receipt beside an unverifiable one gives fail, and a broken §6.7 link is still
checked above an unassessed receipt, because the link is a hash and needs no signature backend. A window bound of the
relying party that is not an instant is its configuration defect too: the CLI refuses the key set at load, and the
library — given such an entry directly — returns not_assessed with key_window_undecidable (on 8de0b72: fail). A
bound that CAN be read and excludes the receipt still decides, even when the other bound is unreadable: fail,
key_outside_validity_window (on 8de0b72: key_window_undecidable).
Twelve review rounds on cra-evidence 0.3.0 — whose verifiers are this repository's, re-implemented — found four classes of defect in shared code. Measured here on 21/09/2026 with the probe before the fix (a divergence in every case) and with the differential oracle after it (76 cases, five verifiers, 0 disagreements, five CLI-grammar cases among them):
- JavaScript dropped an own
__proto__key from its canonical form (d[k] = …invokes the prototype setter): an entry with such a key added and itsself_hashuntouched verified PASS in JS alone; an entry legitimately carrying that key failed in JS alone.Object.fromEntrieskeeps the key. Two oracle cases, one in each direction. - Rust's JSON parser accepted
+10,010and raw control characters in strings, whose canonical form is the same as the escaped one, so a byte-tampered line verified PASS in Rust alone (Python, JS, Go and Java refuse them as not JSON). RFC 8259 number grammar and the control-character rule enforced. Three oracle cases. - A file that is not UTF-8 was
FILE_ERRORin Rust, and JavaScript decoded it lossily (a raw byte in a value whoseself_hashwas computed over U+FFFD verified PASS in JS alone — found by the review of this very section) → strict UTF-8 and aFAILverdict everywhere; two oracle cases as bytes. - CLI grammar was five grammars: an unknown flag, a value flag without a value or with
"", or a second positional was silently taken as the ledger path by the JS and Rust CLIs (a verdict with the constraint the operator asked for dropped —--trusted-pubkey "$KEY"with the variable unset verified with the tip unchecked); Go'sflagand Java accepted an empty value as "not given"; the Python CLI validated five of its seven value flags. Now a usage error (exit 2, no verdict) in all five; the oracle runs the five CLIs on five such inputs.
No verdict changed on an in-profile ledger; the 42 test suites (Python, Go, Rust, JS) are unchanged and green.
cryptovalid_acta.py implements draft-farley-acta-signed-receipts-03 (T. Farley, ScopeBlind / Veritas Acta, 29 August
2026; an individual Internet-Draft, Informational; copy downloaded 2026-09-20): a signed receipt of an access-control
decision for an AI agent's tool call — the evidence artifact the FINOS AI Governance Framework thread on MI-21 (agent
decision audit) is converging on, and the profile behind the shared conformance vectors at
ScopeBlind/agent-governance-testvectors. Transcribed and enforced: the envelope shape {payload, signature{alg, kid, sig}}
(§2.1; the flat shape is read, never emitted), issuer_id == kid (§2.2), protectmcp:decision payloads (§3.1), PureEdDSA
over the JCS (RFC 8785) bytes of the payload with no pre-hash (§5, §6.6), previousReceiptHash = "sha256:" + hex(SHA-256(JCS(entire signed receipt))) with the member omitted on a genesis and an unknown prefix refused (§6.7), the
normative policy digest acta-policy-digest-v1 over the exact bytes of every policy file (§6.8), and algorithm agility
(§6.9: EdDSA emitted; ML-DSA-65 and ES256 read against the declared alg, never assumed — the ES256 signature encoding is
not in the draft, IEEE P1363 r||s is read and said). The kid follows §2.1.1 (sb:issuer: + 12 Base58 characters; checked
against the base58 2.1.1 library).
| Measured 2026-09-20 | result |
|---|---|
Fixture policy digest (expected/chain.jsonl of the vectors repository, commit 49ad7c1) |
reproduced from the .cedar bytes: sha256:81ba074a… |
Fixture key: seed 00…01 → public key 4cb5abf6… (as published in fixtures/keys/README.md); kid sb:issuer:6ASf5EcmmEHT computed per §2.1.1 |
agrees with the base58 2.1.1 library (the repository's own receipts use the kid conformance) |
Our receipts through the repository's own checks (conformance/verify.sh) |
schema (ajv, draft-07) 4/4; @veritasacta/verify 0.10.18 signatures 4/4; chain/outcomes PASS — 6/6; each of three tampered sets (decision, link, signature) fails checks 2 and 3 (the schema check passes, as it must: the tampered values are well-formed) |
| Decisions | from the official Cedar bindings (cedarpy 4.12, the cedar-policy Rust crate) — the fixtures' expected_decision is never read |
Reference receipts of the draft author's implementation (protect-mcp 0.29.0, sign --cedar) through cryptovalid_acta verify |
4/4, §6.7 links and §6.8 digest recomputed; the decision altered → refused (examples/acta/reference_protect-mcp/) |
| Algorithm-mixed chain (EdDSA then ML-DSA-65, §6.11) | verifies, each receipt against its own alg |
python3 cryptovalid_acta.py policy-digest examples/acta # sha256:81ba074a…
python3 cryptovalid_acta.py sign --key agent.seed --tool Bash --decision deny --policy-dir examples/acta --out r2.json --previous r1.json
python3 cryptovalid_acta.py verify r1.json r2.json --key sb:issuer:6ASf5EcmmEHT=4cb5abf6…29 --policy-dir examples/acta
python3 cryptovalid_acta.py run-vectors <agent-governance-testvectors clone> receipts/cryptovalid-opencore --seed 00…01
examples/acta/testvectors_driver/run.sh is the driver to drop into that repository's implementations/. Review round 1
(independent AI review; every finding re-measured and ablated in the tests): the §2.2 issuer_id == kid rule was enforced only on the envelope shape — a flat-shape receipt
signed by any registered key could name another issuer (fixed for both shapes); a valid signature with hostile members
outside the signed bytes (NaN inside the signature object, 600-deep nesting) raised instead of returning a verdict (the
whole receipt is canonicalized first; the signature object must carry exactly alg, kid, sig; a payload with a
signature member is refused, §6.6); with the policy bytes given, a receipt without policy_digest passed as "bound"
(now a problem) and policy_digest / previousReceiptHash formats were not checked on read; a chain whose earlier receipt
failed raised at the next link; a --policy-dir without policy files verified nothing and said ok; the CLI printed a
traceback on 100 000-deep JSON and accepted duplicate JSON keys last-wins (both refused). Round 2: verify_chain read the
link and the policy digest from a payload member even on a flat-shape receipt, so a signed decoy payload could make a
broken chain pass and a good one fail (now one shape decision for signature, link and digest, §6.6); ES256 is read low-S only
(the (r, n−s) twin verifies and would change the §6.7 hash of the receipt); issued_at must be a real instant, not just
the RFC 3339 shape; previous must be a signed receipt; a tool name that cannot sit in a Cedar entity literal is refused
before evaluation; the ML-DSA-65 context choice (empty) is declared as ours, not the draft's. Round 3 (the last; its
findings applied without a further round): an earlier-revision policy_digest (16 hex, no prefix — the §6.8
compatibility note) no longer fails the signature check, it is an opaque label the policy binding refuses to bind on; the
ML-DSA-65 and ES256 negative controls now reach the signature check itself (a real key that did not sign), not a length or
format gate; the sentence "tampered sets fail all three checks" was false (the schema check passes on well-formed tampered
values) and is corrected above. Stated limits: a chain truncated
at its end is undetectable without an external commitment to its head (§9.7 — cryptovalid's checkpoints and witnesses are
that commitment); the first receipt of a set may be a genesis or a segment of a longer chain (§2.2 says the format does not
distinguish them; reported as a warning); whether the policy is the one in force is a separate check (§6.8); commitment
mode (§6, Merkle-committed selectively disclosable fields), the evidence predicate (§4) and the other five receipt types are
not implemented; keys come from the relying party — a receipt's embedded public_key (the reference implementation adds
one) is never used (§9.5).
The two limits above were the honest gap between this archive and a transparency log. They are closed the way the
field closes them — with the world's formats, so that the field's own software verifies the result — and the
closure is measured, not asserted (test_cryptovalid_witness.py, 27 tests — 26 without a Go toolchain, 5 without cryptography; the bench against
the reference witness is in the release dossier):
| Module | What it does | Standard (editor's copies downloaded 2026-09-19) |
|---|---|---|
cryptovalid_checkpoint.py |
A checkpoint of a ledger: origin, tree size, RFC 6962 root of the canonical entries (the same leaves the receipts prove against), signed as a note by the log key (Ed25519, key id = SHA-256(name ‖ 0x0A ‖ type ‖ pubkey)[:4]); vkey strings; strict parsing where the spec is strict (tree size without leading zeroes, canonical base64 root, control characters refused, at most 100 signature lines — the ceiling of the Go implementation, the spec floor being 16) and permissive where the spec says so (any non-empty origin line: "go.sum database tree" parses); every line of a known key is verified (the spec's MUST — the Go library verifies the first line per key and drops repeats, so a note with a bogus repeat opens there and is refused here: declared, fail-closed), byte-identical repeats count once, keys of types nobody verifies here (RFC 6962 THS 0x05, unassigned) are ignored, never accepted — ECDSA (0x02) and ML-DSA-44 (0x06) ARE verified, see below — two different trusted keys colliding on one 4-byte id are refused as ambiguous (measured with a real collision); consistency proofs as base64 lists. |
c2sp.org/tlog-checkpoint, c2sp.org/signed-note |
cryptovalid_witness.py |
A witness: keeps the last checkpoint it cosigned per origin (trust on first use, stated; several trusted keys per origin for rotation; an origin that differs from the key name is configured explicitly, as for sum.golang.org), cosigns a new one only when the log signature verifies and the new tree extends the stored one (same size → same root; larger → a consistency proof that verifies; smaller → rollback). Every refusal that involves two log-signed checkpoints returns both (evidenza); the pairs that are proof — split-view, two log-signed roots at one size, anyone can check both signatures — are appended once each to <state>.evidence.jsonl (also when the refusal reaches an HTTP client as a bare status), whether the witness meets them at the size it holds, at a size it cosigned earlier (<state>.cosigned.jsonl, one line per cosigned checkpoint; the last 4 MiB are searched) or at a size it refused earlier without proof and is now passing; rollback and unproven-extension are not proof (a log genuinely signed the older checkpoint, a missing proof may be a client error): they are returned and counted (distinct ones), and remembered as size/root/time (last 100 per origin); the log-signed notes refused without proof beyond the stored size — whatever old size the request stated — are kept in a pending ring (at most 100 per origin and 256 KiB, notes above 16 KiB kept as root only, smallest sizes dropped first) so that a later cosign, a second root at that size or a later rollback to it can turn them into proof (a cosign consumes the pending roots at its own size and leaves the others in the ring). A second root at a size the witness never cosigned pairs with a root it still remembers (pending ring, or the refusals ring as root only → split-view-unkept); one it no longer remembers (never seen, or dropped from the rings) is a rollback/unproven-extension it cannot turn into proof — stated. The cosignature is cosignature/v1 (timestamp inside the signed bytes). serve exposes the add-checkpoint endpoint of the tlog-witness HTTP interface (POST <prefix>/add-checkpoint; 200 with the signature lines, 400, 403, 404, 409 with the size as text/x.tlog.size, 422, in the spec's order; 500 when the witness cannot decide — no Ed25519 implementation (the reason in the body), state file unreadable or unwritable or invalid configuration (the reason on stderr); check-and-persist under a process lock and a file lock, state and evidence fsync'ed before the response); submit is the log side (old size 0, then 409 → the witness's size and our proof; from the answer it keeps only the lines of the witness keys it trusts, each replacing that key's older line). verify_witnessed is the relying party: log signature (one key or a rotation set), the expected origin, ≥ N distinct trusted witnesses on this text — every line of a trusted witness key is verified and its newest counts — and, with max_age_s, ≥ N of them younger than it (a witness whose newest cosignature is in the future beyond clock_skew_s does not count; without max_age_s no clock is consulted). |
c2sp.org/tlog-cosignature (Ed25519 v1), c2sp.org/tlog-witness |
cryptovalid_scitt.py |
SCITT (RFC 9943, Proposed Standard, June 2026): a Signed Statement (COSE_Sign1 with the CWT Claims header — iss, sub — content type and kid, payload attached or detached), its registration on a cryptovalid ledger (the entry binds the statement's SHA-256), a Receipt with the CWT Claims RFC 9943 requires and the RFC 9942 inclusion proof (vds 1, vdp −1; the canonical leaf travels unsigned beside it), and the Transparent Statement (receipts under label 394). The relying party verifies offline, fail-closed: Issuer signature, TS signature with ITS trusted key, that the leaf binds THIS statement, and the RFC 6962 path up to the signed root. Measured with pycose, an independent COSE implementation: it decodes both messages, verifies both signatures, reads labels 15/394/395, refuses a wrong key or a tampered payload (test_cryptovalid_scitt.py, 5 tests, pycose required in CI). Stated: EdDSA only; the RFC9162_SHA256 profile, not CCF's; no public Transparency Service here. |
RFC 9943, RFC 9942, RFC 9052 |
cryptovalid_cose_receipts.py |
COSE Receipts read from the published texts: profile rfc9942 (the receipt layer: Issuer signature, log signature over the root rebuilt from the inclusion proof) and profile rfc9943 (adds the protected-header MUSTs of RFC 9943 §6: CWT Claims with iss and sub, kid when neither x5t nor x5chain is present). EdDSA, ES256 and ES384, the key type bound to the algorithm; vds 1 (RFC 9162) and vds 2 (CCF, draft-ietf-scitt-receipts-ccf-profile-05, value requested, not yet assigned by IANA). Every refusal names its stage (test_cose_receipts.py, 12 tests). |
RFC 9942, RFC 9943, RFC 9052, ccf-profile-05 |
cryptovalid_cedulon.py |
Cedulon Decision Records, a clean-room reader of a Verax-style ledger: record header, signature, claims (the presented claims must equal the signed ones), chain, effect binding (a row presented beside an Effect Extract must be one the extract signs), checkpoints; inputs, index, the WebAuthn approval assertion and halt control when their files are given. The keys are pinned by the caller, the checkpoint key included. Stated: the approval challenge is not rebuilt (its derivation is not written anywhere); the core's boundary allowance is applied (default 300000 ms, clock_skew_ms=0 applies none); the index stage reads refs and, as Verax's verifier does, effect completeness (test_cedulon.py, 19 tests). |
decision-profile-03, CEDULON-08, WebAuthn L3 §7.2 |
cryptovalid_aps.py |
APS action_ref in the three forms, each named after the revision that defines it (-01 native, -03 v2 with domain prefixes, -03 §4.2 external), and the Accountability Record v0.1 (schema in code, Ed25519 over JCS, digest, action_ref recomputed). normalizeTimestamp read as: drop fractional seconds, UTC only (Z or +00:00; another offset is refused) — an interpretation, stated (test_aps.py, 9 tests). |
draft-pidlisnyi-aps-01, -03, RFC 8785 |
cryptovalid_vaara_receipt.py |
Vaara Receipt (draft-sirkkavaara-vaara-receipt-12): decision and execution receipts, signed payload = JCS of exactly the five signed members, ES256 / RS256 / HS256 / ML-DSA-65. The expected algorithm is a required argument: a receipt naming another one is refused before any signature check (without it, an HS256 receipt keyed with the issuer's public key could be forged). Back link, result commitment, evidence digest and timestamp-anchor digests checked when their inputs are given; anchor tokens themselves are not verified, stated (test_vaara_receipt.py, 9 tests). |
vaara-receipt-12, RFC 8785, FIPS 204 |
cryptovalid_monitor.py --match REGEX |
Identity / subject search while monitoring, the way rekor-monitor searches a log for identities (its README, 2026-09-19): each pattern is matched against the canonical bytes of every entry — the bytes the Merkle leaf commits to — and the run reports the matching leaves (index, self_hash, snippet) and the NEW ones since the last green state, so an inclusion receipt can prove any of them. | rekor-monitor (Sigstore) |
verifiers/note_oracle/ |
Independent verifier with no cryptovalid code: golang.org/x/mod/sumdb/note (the Go checksum database's implementation of signed notes) and github.com/transparency-dev/formats/note (cosignature/v1; pinned to the commit the reference witness of 2026-09-18 uses, which also caps cosigner names at 255 bytes — so do we). Built and run in CI; every listed key must have signed, a tampered byte or a missing cosigner is exit 1. |
the ecosystem's own libraries |
Measured on 2026-09-19 against the reference witness (github.com/transparency-dev/witness, commit 55a5a0bf332a of
2026-09-18, the code the Armored Witness hardware runs), three ways: its in-process witness.Update cosigned our
checkpoint of a 5-entry ledger, refused the 8-entry one without a proof, cosigned it with our RFC 6962 consistency
proof, refused a fork at size 8 ("roots do not match", logging both checkpoints as inconsistent) and refused rollbacks;
its HTTP client drove our serve with the same outcome at every step; our submit client drove its HTTP
server (200, 409 then proof, 422 on the fork, 409 on rollbacks) and the cosignature it returned verified in
verify_witnessed and in the Go oracle (VERIFIED sigs=3 unverified=0 on a note cosigned by both witnesses).
python3 signer.py keygen log.key && python3 signer.py keygen witness.key
python3 cryptovalid_checkpoint.py sign ledger.jsonl --origin example.org/ledger --key log.key --out cp.note
LOG=$(python3 cryptovalid_checkpoint.py vkey --key log.key --name example.org/ledger)
WIT=$(python3 cryptovalid_checkpoint.py vkey --key witness.key --name witness.example/w1 --cosigner)
python3 cryptovalid_witness.py serve --state witness.json --name witness.example/w1 --key witness.key --log-vkey "$LOG" --port 8477 &
python3 cryptovalid_witness.py submit ledger.jsonl --origin example.org/ledger --key log.key --url http://127.0.0.1:8477 --witness-vkey "$WIT" --out cp.cosigned.note
python3 cryptovalid_witness.py verify cp.cosigned.note --log-vkey "$LOG" --witness-vkey "$WIT" --min-witnesses 1 --max-age 3600Log keys: Ed25519 (0x01) and ECDSA (0x02, P-256/384/521, the type Rekor v1 uses — key id SHA-256(DER SPKI)[:4], as
transparency-dev/formats computes it) — measured on the three real rekor.sigstore.dev checkpoints of 2026-09-19 in
examples/checkpoints/ (2.77 billion entries on the active shard: verified by us and by the Go oracle's
NewECDSAVerifier, refused when tampered, cosigned by our witness). Cosignatures: Ed25519 cosignature/v1 (0x04) and
ML-DSA-44 cosignature/v1 (0x06, the type the witness spec now says witnesses SHOULD use; FIPS 204 pure mode, the
spec's cosigned_message over origin/size/root — extension lines are outside it, as the spec states): serve --pq-key
adds the ML-DSA-44 line beside the Ed25519 one, and the Go oracle (filippo.io/mldsa through transparency-dev/formats)
verifies it. Stated limits of this layer: RFC 6962 THS keys (0x05) are ignored; our own logs sign Ed25519 (ECDSA is
verified, not emitted); the monitoring (GET) side of tlog-witness is
not implemented, only add-checkpoint; no public witness has cosigned an OMEGA ledger (there is no public OMEGA log — the
interop above is local, with the reference implementation); the witness state file's integrity is the operator's; the
per-entry timestamps remain local-clock claims (pre-anchor window) and an append-only log never proves completeness
(selective omission). Declared divergences from the Go libraries, in the refusing direction: a cosignature
timestamp above 2^63−1 is refused here because the cosignature spec says it MUST NOT exceed that value, a zero timestamp is
refused because the witness spec says a witness MUST NOT emit one (Go's Ed25519 cosignature verifier checks neither; its ML-DSA-44 one refuses above 2^63−1 and accepts zero), a second, invalid line
from a key that already signed validly makes the note invalid here (Go drops repeats), a signature line above 8 KiB of
decoded material makes the note invalid here even from an unknown key (Go has only the 100-line cap and ignores the
line); the checkpoint parser demands canonical base64 for the root (the formats/log reader does not); and one in the
accepting direction: the same vkey listed twice is one key here, ambiguous key in Go's VerifierList (the same key,
no effect on what verifies). The
evidence file holds proofs only (one record per distinct pair of log-signed roots at one size), so an unauthenticated
client replaying the log's public history cannot grow it (measured: 5 replays of one rollback → no record; 291 future
checkpoints without proof → no record at the 289 fresh sizes, and one record at each of the two sizes that already held
refused roots, where the third log-signed root is one more genuine proof — one pair per request, so a log signing many
roots at one size buys one record and one fsync per request, not one per pair). The state it can grow only up to two
bounded rings per origin — the last 100 refusals as size/root/time and at most 100 pending log-signed notes, 256 KiB
(UTF-8 bytes) in all, notes above 16 KiB remembered by their root only — a later conflicting root then yields a
split-view-unkept record with the earlier root, not a full pair (measured: 291 replays → 100 pending, file size flat;
20 notes with 115 extension lines of 8 000 bytes and 60 with 20 KB of four-byte characters → state under 200 KB) — plus
one index of the last 10 000 recorded proof pairs (≈ 0.75 MB at most; beyond it a very old pair replayed is recorded
again), which grows only with the log's misbehaviour. A request that adds something to the rings or to the proofs
(a rollback, an unproven extension, a log-signed root beyond the stored size, a new pair — whatever status it then gets)
rewrites and fsyncs the state file once; a request that adds nothing (a replay of a refusal already in the ring, a
replay of a recorded pair, a bad signature, an unknown origin, a malformed body) writes nothing — measured on every
path with a spy on the writer. The refusals ring counts distinct refusals, not attempts. A rollback is compared with the roots the witness still
holds at that size and with the last 4 MiB of the cosigned history — one file shared by all the origins a witness
serves, so a busy origin can push a quiet one's older cosigned checkpoints out of that window (a bounded read: stated). What grows without a bound: the cosigned
history, one line per checkpoint this witness cosigns (each needed a valid consistency proof), and the proofs, at most one
per request — a log that signs many roots for one size fills the file with proofs of its own misbehaviour at the rate of
its requests, each request costing the witness a bounded number of writes. Lines from keys the witness does not know are stripped from
everything it stores.
cryptovalid_ingest.py turns a high-rate event stream into this same evidence format —
nothing new to verify, just more of it, faster:
- segments (
ledger-000000.jsonl, …): each one an ordinary hash-chained ledger the stdlib verifier accepts unchanged — an auditor needs one file, not the archive; - sealing: each segment gets an RFC 6962 STH sidecar, hash-chained to the previous STH (removing or reordering interior segments is detectable), optionally Ed25519-signed via any KMS/HSM backend above, and ready for a single RFC 3161 timestamp per batch;
- HEAD manifest (
<prefix>.head.json, rewritten at every seal): commits to the segment count and the last STH — a bare STH chain, like any bare hash chain, cannot see its own tail being truncated or extended (same limit the threat model states for entries). Honest scope: the HEAD closes the tail gap only when signed and verified against a pinned public key; an unsigned HEAD can be rewritten by whoever rewrites the archive. Anchor the HEAD hash via RFC 3161 for third-party time; - batched fsync (the durability unit is the flush;
batch_size=1for per-event durability) and fail-closed crash recovery: a torn trailing line is truncated and reported; a tampered history refuses to resume; - automatic RFC 3161 anchoring (
tsa_url=): every seal live-timestamps the STH canonical hash (token in<segment>.sth.tsr+ honest metadata receipt), optionally LOTL-certified as eIDAS-qualified (lotl_check=True, cached) — validated live against a real QTSP (Izenpe). Best-effort by design: a TSA/network failure never blocks ingestion (it lands in the receipt and in a verify warning) and runs outside the writer lock (a slow TSA never stalls appends). Verification of tokens is two-layer and fail-closed: CMS signature (openssl,-noverify: the chain-to-QTST question belongs to the LOTL match) + digest binding to the STH — a forged blob or an unbound token fails; without openssl, acceptance degrades to binding-only with an explicit warning. The LOTL cache lives in~/.cache/cryptovalid/, never inside the archive (a co-located cache could be poisoned to fake the qualified verdict); verify_archive()/python3 cryptovalid_ingest.py verify <dir> [--lotl --lotl-ms ES]re-checks every sealed segment: hash chain, Merkle root vs sidecar, STH chain, signatures, HEAD manifest, and RFC 3161 token binding (LOTL qualification opt-in, needs network).
python3 cryptovalid_ingest.py bench /tmp/cv-bench --n 50000 # throughput MEASURED on your machine
python3 cryptovalid_ingest.py verify /tmp/cv-bench --prefix benchHonest numbers: throughput depends on machine/filesystem/batch — the bench measures it
where you run it (reference dev box: ~25k events/s at batch 256 with per-batch fsync; your
number is the one that counts). Timestamps come from the host clock: pair sealed STHs
with RFC 3161 tokens for independent time. Tests: python3 test_ingest.py (tamper, torn-tail
recovery, concurrency, signed-STH negative controls).
spec/regulatory_profiles.json maps evidence to EU requirements (MiCA, EU AI Act, DORA, GDPR) with
status, effective_utc, source_url and an as_of date. A ledger entry may set
data.regulatory_ref = "<id>" to declare what it supports.
python3 refresh_regulatory.py --check-urls # re-checks sources, flags stale entries; exit 1 = needs reviewHonest scope: it keeps the mapping fresh and provenance-honest and flags stale entries for a human to re-verify against the primary source — it does not auto-interpret law. Outdated regulatory status is never allowed to pass silently.
CryptoValid is built on open standards and techniques whose inventors we name and credit: Merkle trees (Ralph Merkle, US4309569, 1979 — expired), hash-chain timestamping (Stuart Haber & W. Scott Stornetta, US5136647/US5373561, filed 1991–92 — expired 2004), RFC 3161 timestamping and RFC 6962 Merkle proofs (open IETF standards; CT by Laurie, Langley & Kasper), SHA-256 (NIST) and Ed25519 (D. J. Bernstein et al., public domain). The same design has been practised openly since 2013 by Certificate Transparency, Sigstore/Rekor, OpenTimestamps and immudb — broad, load-bearing prior art.
Closest patented art, considered and distinguished (screening 2026-08-17): the Guardtime / KSI family (Buldas et al.) — its foundational US8719576B2 ("distributed calendar infrastructure", priority 2003) expired December 2024, and the living members (e.g. US9614682, US11057187) claim the calendar-aggregation KSI architecture, which this design does not practise (we use per-archive Merkle STHs + independent RFC 3161 TSAs, not a distributed calendar). The IETF datatracker records zero IPR disclosures against RFC 6962 — noting honestly that IETF disclosure duties bind participants only, so this is corroboration, not proof.
No known third-party patent is practised, after the documented screening above. A freedom-to- operate claim is a universal negative and cannot be proven — pending applications and non-US filings remain invisible by construction. Technical FTO note, not a legal opinion; a definitive FTO requires professional counsel.
What will change under an evidence ledger written today, and what this repository already does about it:
- Signatures. NIST IR 8547 (an initial public draft, not a final standard) proposes deprecating the
112-bit-strength quantum-vulnerable schemes after 2030 and disallowing all of them — Ed25519 and ECDSA included —
after 2035. A ledger kept for the multi-year retention windows of DORA, the CRA (technical documentation for at
least 10 years, Art. 13(13)) or eIDAS crosses that line. The signature suite of ap2-evidence-pack
(a dependency since 0.18.0) lets the AP2 evidence format
carry a hybrid classical + ML-DSA-65 (FIPS 204) signature through the
cryptographylibrary, checked against the NIST ACVP ML-DSA-65 signature-verification vectors; since 0.12.0 the core ledger entries, the signed chain tip carry the same hybrid Ed25519 + ML-DSA-65 layer and the evidence-pack manifest records it per ledger (threat model above: opt-in, pinned by the relying party's PQ key; entries checked by Python, the tip by Python and Go). After 2035 an RFC 3161 token (itself RSA/ECDSA-signed) proves nothing on its own: hash-only anchors (OpenTimestamps) and periodic re-timestamping are what keep an Ed25519-only ledger load-bearing, which is why anchor coverage is part of the threat model. - Transparency and receipts. The IETF SCITT architecture is now RFC 9943 (Proposed Standard, June 2026) and
COSE receipts are RFC 9942; this repository's receipts follow RFC 9942 (
vds=1 RFC9162_SHA256, inclusion −1 / consistency −2, alg EdDSA) and its monitor plays the role rekor-monitor and immudb's auditor play (append-only, consistency over time; the blind window between two runs is declared), and since 0.14.0 its checkpoints and witness speak the C2SP formats and protocol the transparency ecosystem (Sigstore, Tessera, the Go checksum database, the witness network) uses — measured against the reference witness, see above. Registering signed statements with a SCITT transparency service and obtaining its receipts is the natural next step and is not implemented yet. - Qualified ledgers in the EU. Commission Implementing Regulation (EU) 2025/2531 gives the requirements for
qualified electronic ledgers under eIDAS 2.0 (Art. 45l);
eidas_ledger_check.pyself-assesses a ledger against them and says what only a QTSP can add. Being qualified is a status a QTSP holds, never something this code confers. - Supply-chain formats. CycloneDX 1.7 (ECMA-424, October 2025) is the current specification; the CRA evidence packs attached to this project's releases are produced with the public cra-evidence tool (CycloneDX 1.6 by choice — the stable schema with mature validators — Art. 14 clock, ENISA SRP fields) and signed with an AWS KMS-held (HSM-backed) key whose public part ships with the release; the Transparency Exchange API (ECMA TC54) is the distribution channel to watch.
- Verification without the producer. The five verifiers in the differential oracle (Python, JavaScript, Go, Java, Rust) plus a reduced-scope Swift one exist so that the evidence outlives this codebase: an auditor in 2035 needs the profile and one independent implementation, not this repository.
- Questions, interoperability reports, divergences found by your own verifier: open a thread in this repository's Discussions or an issue; e-mail: roberto.locatelli.81@gmail.com.
- Pilots: the author runs short evaluation pilots (four to six weeks, scoped and priced up front) with banks, QTSPs, GRC vendors and Java shops that need evidence verifiable offline for years, including through the post-quantum transition. Write with the use case; the answer says what is measured and what is not.
- Licence: AGPL-3.0-or-later for this repository (see the licensing table above); a commercial licence of the same code is available from the author for organisations that cannot adopt AGPL.
- Citation: DOI 10.5281/zenodo.22539578 (Zenodo concept DOI: always the latest version).
- Author: Roberto Locatelli, 2026. Public interventions by his AI agent (Noûs) are signed as such.
pip install --extra-index-url https://robertolocatelli81-dev.github.io/pypi/ cryptovalid-opencoreThe hash-chain verifier needs only the standard library. Signing and signature verification (Ed25519, ECDSA,
ML-DSA-65, COSE, JWS, AP2) need cryptography (≥ 48: the floor where ML-DSA-65 works; measured with 48.0.0 and
50.0.x), installed with the sign extra:
pip install --extra-index-url https://robertolocatelli81-dev.github.io/pypi/ 'cryptovalid-opencore[sign]'AP2 dispute evidence needs the ap2 extra ('cryptovalid-opencore[ap2]'), which installs ap2-evidence-pack >= 1.6.0,
< 2 from the same index (>= 1.3.0 until 0.21.0). Every CI run of this repository (each push, and a weekly schedule) installs the newest 1.x
from the index and runs every suite, so a 1.x release that breaks this front-end turns this repository red. Since
2026-10-05 ap2-evidence-pack's own CI (job downstream-cryptovalid, first run 37287815388) clones this repository's
master on each of its changes and runs test_verify_evidence.py, test_mcp.py, test_audit_v1_types.py,
test_verifier_depth.py and test_packaging.py against the wheel it just built, refuses a skipped AP2 test, and passes
a pack it built through verify_evidence.py ap2 and auto (a tampered one exits 1 through ap2).
Release artifacts are attached to GitHub Releases; the index links carry #sha256= fragments verified by pip. All documented python3 <file>.py commands keep working unchanged from a clone, with one dated exception:
ap2_evidence.py left this repository in 0.18.0 (2026-10-05) for ap2-evidence-pack, whose ap2-evidence command
replaces it.