Skip to content

About

Verifiable compliance evidence: hash-chained ledgers, signed tip, hybrid Ed25519+ML-DSA-65, KMS custody, evidence packs, C2SP checkpoints + tlog-witness (split-view evidence, ML-DSA-44 cosignatures, real Rekor checkpoints verified), SCITT RFC 9943; verifiers in Python, JS, Go, Rust, Java + differential oracle. AGPL-3.0 / commercial.

Topics

Resources

Code of conduct

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

116 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CryptoValid Open Core

DOI

verify-evidence

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.

Licensing architecture (dual license, honest and explicit)

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.

Patents / freedom-to-operate (honest — no professional FTO was done)

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.

What this is

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.

Relevance to the Sustainable Development Goals

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.py and 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»): the microfinance module is a real capability, but a proof-of-integrity tool does not by itself advance poverty or inequality targets. What it does: microfinance.py applies 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).

Where this fits (honest scope of the demand)

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.

Status (honest)

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 PASS on an empty file) — each with a regression test. Know what verifier.py proves: 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 a scope field. 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, Ingestor and Go AppendSigned; cryptovalid_tip.py sign reads the file, O(n)) moves that limit: with the tip and the trusted log key, verifier.py --trusted-pubkey <hex> [--require-tip] names tail_truncated, tail_rewritten and unsealed_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-before or 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.

Quick start

python3 verifier.py <ledger.jsonl>     # verifies a hash-chained ledger

Exit 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).

Signed evidence (the enterprise edge)

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 hybrid signature_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 --pq also writes signer.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 every self_hash with 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-pubkey needs --pubkey, and --require-pq alone is refused: a layer checked against the key inside the file is self-declared). The tip: --trusted-pq-pubkey on verifier.py and on the Go cvverify (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. Needs cryptography ≥ 48 (Python; measured 2026-10-04 with 48.0.0, 49.0.0, 50.0.1 — 47.0.0 raises UnsupportedAlgorithm) or Go ≥ 1.27; where a REQUIRED layer cannot be verified the result is pq_unverifiable with ok: false / exit 1, never a green.
  • signer verify re-derives self_hash from 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 cryptography package. 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.

KMS/HSM key custody (no private key in process memory)

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.

Auditor-ready evidence pack

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.

Dispute evidence for agentic-payment mandates (AP2 / SD-JWT)

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 receipt

Without 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.

Auditor-facing report (PDF/HTML)

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).

MCP server — agents that can prove what they did

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.

// client config
{"command": "python3", "args": ["/path/to/cryptovalid_mcp.py"]}

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.

DORA ICT-incident evidence (EU, evidence-driven enforcement)

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.

Transaction evidence for tax/audit (IRS Form 1099-DA era)

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.

Long-term evidence defenders (WORM · re-anchor · heterogeneous anchors)

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).

Threat model (honest — hardened after adversarial review)

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_pack detects 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_authenticated is 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_id in data (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, and pq_protected: true also needs every Ed25519 signature to hold. Without the pinned key a present layer is reported as null (verified against the key inside the file, which anyone can put there), never true; 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 records pq_protected per ledger with the keys it was built against, but the manifest signature is Ed25519 only, so verify_pack confirms the layer only against keys the verifier pins (the ledger keys, or the manifest signer); with nothing pinned it reports null, never true. 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.

An open standard (not just a tool)

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.

Merkle proofs (optional extension, RFC 6962)

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}) — submit root_sha256 to 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 VALID

A 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.

Java verifier (JDK standard library, 2026-09-16)

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.

Go reference: writer + verifier (2026-09-14)

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.

Receipts, monitor, eIDAS 2.0 self-assessment, JWS (2026-09-13)

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 first

Honest 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). admission is admitted or refused; a refusal has refusal_class (format_unsupported, unknown_member, malformed), states: null and its reason in error. An admitted pack has states_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_anchor is never verified: this verifier binds the token to the manifest digest and does not check the TSA's signature (tsa_not_checked). valid is 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: true with a token that is not a granted TimeStampResp for this digest, or with no token, is valid: 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 (or cryptovalid-verify pack) checks a token: the complete receipt on stdout with time_anchor: not_evaluated / instrument_failure, one line check 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 with receipt 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 type build_pack never writes are refusals; until 0.21.0 rfc3161_timestamp: [] or ledgers: true were a traceback (exit 1) and anchored: "yes" counted as a claim. The unknown keys of SPEC §2.1 are now refusal_class: unknown_member (same exit, same valid: 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, with pq_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 that cryptography 50.0.1 accepts for any message under that key — was manifest_authenticated: true, and an ingest archive whose STH and HEAD carried that signature was ok: 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 ap2 needs ap2-evidence-pack >= 1.6.0 (the ap2 extra; the floor CI job installs 1.6.0): it copies the reference's admission, refusal_class, states_vocabulary, states and policy into its receipt untouched and puts the refusal class first in the reason of the reference's layer. An Ap2WriteError that 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 with tsa_cert or trust_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 leaves cryptovalid-verify ap2 at 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.py refuse the same keys (the JSON tree head cryptovalid_sth/1 and the COSE receipts, formats of this project). Measured on 0.21.0 on 19/10/2026: the forged signature above was signature_valid: true for any tree head (ok: false without a trusted key, ok: true with the neutral point as the trusted key), and a COSE inclusion receipt re-signed that way was ok: true under 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. Now signature_valid: false / ok: false with 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 --pubkey too, the failing entries named (until 0.20.3 the verdict was the chain's, PASS, and the failure only reported); the layer is any of signature, signer, signature_pq, signer_pq in any entry (until 0.20.3 only signature). A valid signature by an unknown key stays a PASS without a key: pinning the key is the caller's. verifier.py and the Rust verifier do not check signatures: their receipt now has signatures_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 and signer.py) was rerun on 0.20.3 before the change and gave the same table. 21 vectors in spec/vectors/signatures/ (make_vectors.py regenerates the same bytes): the six verifiers and signer.py verify reproduce 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 through cryptography) — 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.py and cryptovalid_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.py already 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_pq and tsa_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 (vector spec/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 is indeterminate (qualification()), not "not qualified": the ingest warns tsa_qualification_indeterminate, the seal metadata carries eidas_indeterminate, the report says INCONCLUSIVE, the cryptovalid_lotl command adds indeterminate (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 .pyc cut at any offset from byte 16 on made every command that imports it (all but verifier.py itself) 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: an EOFError or ValueError from 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.devnull cost +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's valid does not depend on the timestamp, as before (a claimed token that does not verify leaves valid: 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-256 db5671af..., cited by the two validation reports) are that file at public commit 401e6b2.
  • Found and not changed in 0.21.0 (planned for 0.22.0; done there, see the 0.22.0 section). evidence_pack.py verify reports a declared RFC 3161 token that does not verify (rfc3161.claimed: true, verified: false) and still returns valid: true, exit 0 (measured 17/10/2026 on a pack of examples/sample_ledger.jsonl with 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/ and bench/ and the test files; verifiers/closed_schema_mutants.py on a full stdout still exits 120): when a byte of it was not taken it exits 74 (EX_IOERR in sysexits.h), whatever the verdict, and writes one line receipt not written: <reason> on stderr (if stderr fails too, the exit is still 74). The Python writer lives in verifier.py (which stays one stdlib-only file) and is imported by cryptovalid-verify / verify_evidence.py, signer.py and 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 in poll, 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 under ulimit -f took 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; --help on a full stdout exited 120 (1 under -u). cryptovalid_pruning_probe, mutation_runner and fundcert_crosssource print their usage on stderr now (it went to stdout).
  • Files a command promises are whole or absent. verifier.py --out, the signed ledger of signer.py sign, the chain tip, the monitor and witness state, the checkpoint and SCITT --out files, cryptovalid_receipt.py --cose, the cryptovalid_acta.py receipts, 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 with O_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 keygen over an existing key under ulimit -f 0 left the key at 0 bytes (exit 120); a symlink planted at the predictable <tip>.tmp or <state>.tmp was followed and its target overwritten (the tip, the monitor). A new key (keygen, and keygen --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's open(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 receipt chmod 444) is refused and left as it was (exit 74; it was a traceback, exit 1). Changes of behaviour: signer.py keygen refuses a path where something already exists (a key, writable or not; with --pq also <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.json is 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.jsonl and .cosigned.jsonl logs 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: cvappend under 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 register exited 2 (File too large) and left 24 bytes of a line. The ingest writer keeps a batch that failed queued, and a later flush() 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 register refuses a directory as --out before it records anything; when --out cannot 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 cosign does the same for its --out and 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 pack and archive, cryptovalid_ingest.py verify and eidas_ledger_check.py exit 74 with cannot run openssl here: <reason> or cannot 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 to cryptovalid_heterogeneous and to cryptovalid_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) and cryptovalid-verify archive (a valid ingest archive) said valid: false, exit 1; evidence_pack.py verify reported the token verified: false; cryptovalid_ingest.py verify and eidas_ledger_check.py exited 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 for openssl ts -verify, which had no limit) is the same case, 74 with cannot 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 pack valid: false, exit 1; after 35 s: a valid ingest archive valid: false, exit 1 (with the temporary file's path in the receipt) and cryptovalid_ingest.py verify a traceback, exit 1. cryptovalid-verify ap2 treats ap2-evidence-pack's Ap2WriteError (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 with verify_evidence.py ledger on a valid ledger from a source tree without a cache, one run under ulimit -f 1 left a cut verifier.cpython-311.pyc (and -39), and every later run, without any limit, exited 1 with EOFError: 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, sets sys.dont_write_bytecode before 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 the cryptovalid-verify entry point of an installed wheel (pip compiles the cache at install time).
  • Corrected with it. The RFC 3161 domain of cryptovalid_heterogeneous.py read .get() on the boolean returned by verify_with_openssl: an AttributeError on every token, so that domain never verified (measured on 0.20.2 with a token from a local TSA). It now counts when openssl ts -verify succeeds: a verdict correction of the library function verify_heterogeneous_anchor — an anchor that needs that domain went from ok: false (the domain listed with errore: 'bool' object has no attribute 'get') to ok: true when 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 the token_der of 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 0 started 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.py with 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.py rewrites 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.py and cryptovalid_solana.py against 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 a utf-8-sig pipe, 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 under ulimit -f 1 exited 0 with nothing on stderr (with ulimit -f 0 it exited 74, as on Linux); the cause is not established, and that case of swift test runs on Linux only. Windows and macOS are not measured: the waiting on a full non-blocking stdout uses select/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) and test_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 --help with 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) and swift test (the same three). Each new test fails on 0.20.2: the 17 Python tests (Python 3.11), the 3 Go cmd/cvappend tests, 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 added test_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 in evidence_pack.py build; the SCITT --out before 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's Ap2WriteError; the RFC 3161 domain of cryptovalid_heterogeneous; an ingest batch that failed, written whole by the next flush), a witness test (--out refused 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/full or 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 of test_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.py and 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 (cryptography 48.0.0, ap2-evidence-pack 1.3.0), 50/50 each; JS 150–151 checks (the count moves with a random signature; Node 22 three times, Node 24) and --conformance 11/11; cargo test 17 tests and clippy -D warnings; go vet, go test and go test -race; swift test 25/25 (Linux); conformance.py 11/11; differential oracle 0/110 over the six verifiers (26 declared divergences); closed_schema_mutants.py 1 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 idx on every path (0.20.0), made without a version bump, and tsa_token outside the canonical form (0.20.2).
  • Swift did not read a FIFO. cvverify <(cat ledger.jsonl) answered FILE_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 stay FILE_ERROR, an empty file stays FAIL. The oracle case input-fifo (all six verifiers must answer PASS) is a disagreement with the Swift binary of 0.20.1; a swift 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_token could not be what §2 said. §2 defines it as an RFC 3161 token over self_hash, and §3 kept it inside the bytes self_hash is computed from. Measured with a local test TSA (openssl ts): a real token over an entry's self_hash, added after hashing, was FAIL in all six verifiers; with self_hash recomputed over the token, all six said PASS and the token no longer bound the entry's self_hash. Since 0.20.2 tsa_token is outside the canonical form, like the signatures, in the six verifiers and in signer.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 .jsonl files in four directory trees of the development machine, and on 126 more files carrying self_hash elsewhere 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 carries tsa_token, at the top level or nested (355 474 JSON objects). Two normative vectors fix the rule (tsa_token_attached PASS, tsa_token_in_hash FAIL 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.py and the Rust verifier verify no signature (a changed signature is PASS); signer.py verify refuses 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 by signer.py, Go and Java, not by JS or Swift. SPEC §2 and §5 and spec/CONFORMANCE.md (which said Swift verifies no signature; it has verified Ed25519 since its first version) now say this. §5 also named evidence_pack.py as 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_ingest recomputed self_hash leaving out self_hash, signature and signer only, so a segment whose entries carry signature_pq/signer_pq (or now tsa_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_merkle still 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 carries signature_pq, signer_pq or tsa_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_lotl took a TSPService as a qualified timestamp service when the text Svcstatus/granted appeared anywhere in it, its ServiceHistory included. ETSI TS 119 612 puts the status in ServiceInformation/ServiceStatus, in force from its StatusStartingTime, and the earlier ones in ServiceHistoryInstance entries; ETSI TS 119 615 (PRO-4.3.4-03) reads the status at a date the same way and matches the certificate against the ServiceInformation's digital identity. A service now counts when the entry in force is TSA/QTST and granted, with the certificates of its ServiceInformation. 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 have granted in ServiceInformation; the other 18 are 18 services of one list whose ServiceInformation says withdrawn from a date in 2027-2029 and whose status in force today is granted. 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 and cryptovalid_report --lotl check 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: every OtherTSLPointer whose MimeType is application/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. StatusStartingTime is read only in the form TS 119 612 §5.1.3 writes (YYYY-MM-DDThh:mm:ssZ): datetime.fromisoformat also took a value without Z, 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. Every StatusStartingTime in 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/cryptovalid and read it whatever its age, so a set built on one day answered for every later day. The cache now records built_at and is rebuilt after 24 hours (cache_max_age_s); a cache of 0.20.1 (no rule) is rebuilt. In verify_archive --lotl, a LOTL that cannot be fetched or is not a trusted list is the warning lotl_unavailable and eidas_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_check counted the entries carrying tsa_token (REQ-7.5-04-bis and the ledger report). With tsa_token outside 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 from NON_APPLICABILE into PARZIALE. It now counts the tokens that carry the entry's self_hash as 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 ts TSA) 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.py 11/11; JS --conformance 11/11 and its suite 117–118 checks (the count moves with a random signature); Rust and Swift binaries 11/11 on spec/vectors/; cargo test 9 tests and clippy -D warnings; swift test 18/18; go vet and go test; the 48 Python test files on Python 3.9, 3.11 and 3.13 and with the declared floor (cryptography 48.0.0, ap2-evidence-pack 1.3.0), 48/48 each; closed_schema_mutants.py 1 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, 010 and a raw tab accepted; CRLF lines refused; FILE_ERROR on 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 Character is a grapheme cluster: \r\n was one character, and String equality is canonical equivalence, so é and e + U+0301 were one key), applies the RFC 8259 integer grammar, refuses raw control characters, reads \u as 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_256 and --pubkey, and answers anything else with a usage error (exit 2). The Ed25519 fields are read strictly, as signer.py reads them.
  • Measured. Oracle with Swift 0/106 (12/89 on 2026-10-11; deep-valid-600 and lone-surrogate no 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.py 1 500 mutations, seed 20261011, GREEN over the six verifiers; swift test 17/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 surrogateescape code point was PASS in verifier.py alone; 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); an idx past 2^53-1 made JS throw without a receipt, and a JS receipt over 64 KiB was cut on a pipe; --algo foo was a PASS in JS and Rust; Swift's --pubkey did 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 --algo value into the receipt without escaping it, so a crafted value could add a second verdict key; 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 .jsonl files 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 whose idx is a float (0.0, 1e0), and in Swift on two test files whose idx is written 01, 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 signature is hex over the canonical form; signer.py signs the self_hash string (its UTF-8 bytes) and writes the signature in base64, and signer.py verify and the JS, Go, Java and Swift verifiers check it so (verifier.py and 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_pq and signer_pq were missing). No behaviour changes with this text.

A ledger without idx is refused on every path; the ledger front-end names why (2026-10-11, 0.20.0)

  • The shortcut 0.19.0 declared is closed. Until 0.19.0, with no --algo, a ledger whose first three entries carried no idx was read by a fallback for ledgers of other OMEGA modules (mode: full_recompute_generic or linkage), which applied neither SPEC §2.1 nor §5 step 4: an unknown top-level key passed there, not named. That path is removed from verifier.py. Such a ledger is now FAIL with or without --algo, as in the five other verifiers; the reference names each entry in parse_errors (idx_mismatch: expected 0, got (missing); the receipt keeps the first ten), Go and Java name it idx "" not sequential, Rust and Swift carry no reason text. evidence_pack.py verify reports it as hash_pass: false. A ledger written by an earlier tool without idx no 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) and cryptovalid_monitor.py read only the hash and link results: a ledger without idx, or with a key nobody defined (FAIL since 0.19.0), was SODDISFATTO_PER_COSTRUZIONE and green there. Both now use the verifier's verdict; the monitor's alert names the first reasons.
  • idx_mismatch says 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 object idx with integer-like keys, which JS orders first). Before, Python printed an absent key and null alike (None) and "1" like the number 1; JS printed undefined, null and 1.
  • cryptovalid-verify ledger names the reasons. It printed verdict=FAIL only; the layer now carries reasons (hash, link, schema and parse failures, with idx — as JSON — or line) and the first three in detail.
  • 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 when cargo builds it). Tests and manifests are not sources: a touched test never relinks the binary, and swift build does 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.0 verifier.py is RED with 7 silent PASS, all missing_idx (positive control). The 12 older Swift divergences listed in CONFORMANCE.md are 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 unsealed rfc3161_timestamp block 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 the rfc3161_timestamp block are closed objects. A key outside the list FAILS and is named: unknown_field:<keys> in the reference's schema_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 Java failures[] and the JS schema_failures[].reason, the entry in a schema_failures_idx list in Rust and Swift. data stays free (SPEC §4). Two normative vectors (unknown_top_level_key FAIL, unknown_key_in_data PASS).
  • 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 in CONFORMANCE.md.
  • Declared, not closed in this release. When no algorithm is forced, a ledger whose first three entries carry no idx goes to the reference's fallback for ledgers of other OMEGA modules; when its hashes recompute there (receipt mode: 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 --algo the 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 verify reports such a ledger as hash_pass: true, valid: true. Closed in 0.20.0 (section above). Also declared: the types of ts and data are not checked by any of the six; 12 older Swift divergences are listed in CONFORMANCE.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.jsonl row names must be the ref of a decision the ledger holds, and a row that says its ref has an effect (kind effect, or hasEffect true) needs an effect row bound under that ref; a row's decision kind is not read, since index.jsonl is unsigned. Until now this checker held a row's decision kind to the ledger (a deny row had to name a deny record under that ref) and did not read hasEffect. Source: indexStatement, effectRefsInIndex and noteEffectCompleteness in verax-ai/verax packages/proxy/src/verify-ledger.ts and parseIndexText in ledger-manifest.ts, the same at f59ece9 and at e008f55, and the stage table in test-vectors/tools/stages.ts, which files both of their problem lines ("index names …", "index says ref … has an effect …") under index.
  • Run against Verax's verifier (verifyLedger at e008f55 and at f59ece9, Node 22.23.2, 2026-10-06), on valid-full unchanged and with lines added to index.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 JSON null Verax's verifier fails without naming a stage). Where they differ, this checker fails and Verax's passes for a row that names no string ref, for a row with no string piece whose ref or effect claim fails here (Verax's parser skips such a row; this checker does not read piece, so a row with a held ref, no effect claim and no string piece passes both), for a duplicate member (JSON.parse keeps 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.jsonl or index.jsonl, a duplicate member or NaN included, was reported under checkpoint-signature; the verdict was INVALID either way. It now fails inputs-binding or index. 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.loads raised RecursionError near 1000 levels on 3.9 and 3.11 and near 10000 on 3.13, so an index.jsonl line 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 \n only. Until now str.splitlines also 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/v1 at f59ece9 (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-halted and fail-allow-while-halted-witnessed now stop at control, as the vectors expect; every other verdict, stage and reason is unchanged. With clock_skew_ms=0, 15 of 16: fail-allow-while-halted stops at effect-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 in valid-full) fails at record-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 (2026-10-05, 0.18.0)

  • 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.py and pqcrypto/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 with python3 ap2_evidence.py build from 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 ap2 and the MCP tool import ap2_evidence from ap2-evidence-pack >= 1.3.0 (extra ap2); 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 of SPEC_AP2_EVIDENCE.md are gone from the clone: the normative text and the vectors are ap2-evidence-pack's. The documented python3 ap2_evidence.py build|verify commands become ap2-evidence build|verify (the package's console script).
  • The reference's refusal reason reaches the receipt. verify_ap2 read error from the old copy's receipt; since 1.1.0 the reference's receipts carry refused, 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.
  • sign extra: cryptography >= 48 (was >= 50): the floor where ML-DSA-65 works (47.0.0 raises UnsupportedAlgorithm).
  • cryptovalid-verify auto recognises an AP2 pack whose evidence_format is past the first 2048 bytes. A pack is one JSON object with sorted keys, so artifacts comes first: measured 2026-10-05, a valid pack built by ap2-evidence build 1.3.0 from two mandates has evidence_format at byte 3506, and auto verified it as a ledger and said FAIL. auto now reads the top-level evidence_format of 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 ap2 range (ap2-evidence-pack

    = 1.3.0, < 2) and the sign floor; test_dependency_versions.py fails 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.

The core's boundary allowance in the Cedulon checker (2026-10-05, 0.17.2)

  • cryptovalid_cedulon.verify_ledger applies 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 within clock_skew_ms of the newest attested record, and an effect row that no record names within clock_skew_ms of the oldest, are listed under boundary_deferred and do not fail the verdict; a VALID result with a deferral says so in why ("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 since f59ece9; 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=0 applies no allowance. 0.17.1 applied none.
  • On verax-ai/verax test-vectors/v1 at f59ece9 (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; with clock_skew_ms=0 every verdict, stage and reason equals 0.17.1's (the output gains the key boundary_deferred, empty there). The forged allow of fail-allow-while-halted is now deferred (30 ms from the newest record; it is also 30 ms after the halt extract's windowEndMs, the edge the vectors README measures from) instead of failing effect-binding. Every ledger in the set spans at most 203 ms, so at the default no unmatched allow or unnamed row in it can reach decision-without-effect or effect-without-decision; the two edges are measured in test_cedulon.py. fail-allow-while-halted and fail-allow-while-halted-witnessed expect control and stop at index, 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 at control.
  • test_cedulon.py tests 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 in cldma_confidential.py no longer names a module outside this repository (ff1bd73, on master since 2026-10-05; 0.17.2 is the first release that carries it).

What the wheel ships (2026-10-04, 0.17.1)

  • AP2 verification works from a pip install. ap2_evidence imports sigsuite from pqcrypto/, which the 0.17.0 wheel did not contain: installed from the index, cryptovalid-verify ap2 and the MCP tool verify_ap2_evidence failed on every file with ModuleNotFoundError: sigsuite (a refusal, never a pass). The wheel now ships pqcrypto/. From a clone nothing changes.
  • cryptography is declared, as the sign extra. The wheel declared no dependency, so a plain install had no signature layer. pip install 'cryptovalid-opencore[sign]' installed cryptography >= 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 the mldsa module but raises UnsupportedAlgorithm). 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 a with block. The two long-lived segment handles of cryptovalid_ingest are closed on rollover and in close() (see below for the one path that missed).
  • pqcrypto/sigsuite.py is 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.py carries 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.py and pqcrypto/sigsuite.py; pip overwrites them silently in the order of installation (pip check says 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). Their ap2_evidence.py still differ (1.2.2 is ahead); unifying them is open.
  • test_ap2_evidence.py had two if __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 the sd_hash by setting its first character to X, a no-op whenever the digest already began with X (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 when seal() raises (a signing backend that fails); before, that handle stayed open with no call left to close it.
  • test_packaging.py gains three tests, one per point, read from pyproject.toml and 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: true only for a granted token for this digest. evidence_pack._rfc3161_stamp recorded any HTTP body as anchored: 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.py builds 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_evidence no longer passes a signature that is present but broken (pack and AP2 producer); verifier.py, signer.py, cryptovalid_receipt, cryptovalid_ingest.verify_archive and evidence_pack.verify_pack answer 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/false refused) 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 --pubkey a bad signature is FAIL with exit 1; the tip spec states what all verifiers do with log_pubkey_hex: null (absent).
  • AP2 evidence of the wrong shape is a refusal receipt, not a TypeError/AttributeError (test_ap2_shape.py).
  • cryptovalid_receipt compares the declared vds and sth.kind instead of ignoring them (test_receipt_identifiers.py).
  • cryptovalid_merkle.consistency_verdict gives three outcomes — consistent, inconsistent, could not compare (sizes out of range); verify_consistency is 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-cose vectors-ietf126 @529515ba 6/6 as RFC 9942 receipts (3/6 with the RFC 9943 §6 header MUSTs applied); Verax test-vectors/v1 @f50c59b, all stages, checkpoint key = the witness key, 16/16 verdicts and 15/16 first failing stages; APS aps-conformance-suite @5500a56 canonical bytes 18/18, accountability-record 12/12, actionref-canonical 4/6 against the -01 form; Vaara tests/vectors @ff683c2, only the RFC 9162 proofs and the ML-DSA-65 signatures, 10/10, 7/7, 10/10.
  • test_stamp_tsa_locale.py closes the files it opens; two source comments no longer name modules outside this repository; the JS README says 93 checks; the stale cryptovalid_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.

Vendored key-window vectors restored to the upstream bytes (2026-09-26, 0.16.2)

  • The vendored key-window vectors are now the upstream bytes. examples/acta/key-window/SOURCE.md said the eight files were "copied verbatim" from ScopeBlind/agent-governance-testvectors at 1e24b5687. 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, code and key_status, both before and after. The files now carry the upstream bytes and KW_SHA256 pins them; removing the final newline from one file makes the pin test fail. The re-fetch recipe in SOURCE.md is replaced: the previous command does not run (gh api 2.92.0 rejects --ref as an unknown flag, exit 1). The new recipe, run on 2026-09-26, reproduces the eight files byte for byte.

OpenTimestamps anchoring in a public install (2026-09-26, 0.16.1)

  • committed_attestation.anchor_commitment works 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), answers ok: 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 is ok: false with an error that names the missing library; with no calendar reachable it is ok: false, status: "failed", and errors says per calendar what went wrong; a root that is not 32 bytes is ok: false with an error. The return value keeps its fields; errors is new, and a new calendars argument 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] and cryptography in 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 unless CV_ONLINE_OTS=1. Removing the message guard or the per-calendar error handling turns the offline tests red.
  • test_cryptovalid_acta.py closes the PEM/DER key files it writes (a ResourceWarning in the 0.16.0 CI log).
  • A source comment in cryptovalid_acta.py said 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 untagged 8de0b72 (measured 2026-09-26).

Key status, refusal codes and strict inputs (2026-09-26, 0.16.0)

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).

Verifier hygiene from the cra-evidence review (2026-09-21, 0.15.1)

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 its self_hash untouched verified PASS in JS alone; an entry legitimately carrying that key failed in JS alone. Object.fromEntries keeps the key. Two oracle cases, one in each direction.
  • Rust's JSON parser accepted +10, 010 and 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_ERROR in Rust, and JavaScript decoded it lossily (a raw byte in a value whose self_hash was computed over U+FFFD verified PASS in JS alone — found by the review of this very section) → strict UTF-8 and a FAIL verdict 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's flag and 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.

Signed decision receipts for agents (2026-09-20, 0.15.0)

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).

Checkpoints, witnesses, split-view (2026-09-19, 0.14.0)

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 3600

Log 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.

High-frequency ingestion (Art. 12 logs at scale)

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=1 for 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 bench

Honest 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).

Self-updating regulatory profiles

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 review

Honest 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.

Standards & freedom-to-operate (factual, not legal advice)

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.

The next decade (read on 15/09/2026)

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 cryptography library, 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.py self-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.

Contact, pilots, citation

  • 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.

Install (pip)

pip install --extra-index-url https://robertolocatelli81-dev.github.io/pypi/ cryptovalid-opencore

The 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.

About

Verifiable compliance evidence: hash-chained ledgers, signed tip, hybrid Ed25519+ML-DSA-65, KMS custody, evidence packs, C2SP checkpoints + tlog-witness (split-view evidence, ML-DSA-44 cosignatures, real Rekor checkpoints verified), SCITT RFC 9943; verifiers in Python, JS, Go, Rust, Java + differential oracle. AGPL-3.0 / commercial.

Topics

Resources

Code of conduct

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages