A GPKI/eIDAS-aware Advanced Electronic Signature (AdES) verifier
This chapter establishes what pverify is, the precise boundaries of what it verifies and what it deliberately refuses to do, the audiences it serves, the input formats and Advanced-Electronic-Signature (AdES) levels it actually supports as of release v1.3.0, and the architectural stance — a pure-Rust, WASM-buildable verification kernel — that conditions every later design decision. It closes with a reader's guide to the remaining chapters.
The claims in this chapter are grounded in the source tree at the repository root; concrete evidence is cited as crates/<crate>/src/<file>.rs (with a function or line where it sharpens the point). Where the tool's behaviour is subtle — most notably its discipline of degrading to INDETERMINATE rather than fabricating a pass or a failure — the chapter describes what the code actually does, not an idealised summary.
pverify is a Rust toolkit that verifies advanced electronic signatures (AdES) and emits a structured record of observed facts — never a business-level "valid / invalid / qualified" adjudication. This is the project's first and non-negotiable principle, ratified in the Constitution as Principle I, Fact-Reporting (.specify/memory/constitution.md, "Core Principles / I. Fact-Reporting (NON-NEGOTIABLE)"):
pverify outputs structured reports of observed facts, never business-level adjudications such as "qualified electronic signature" or "legally valid".
- The output JSON MUST enumerate every artefact consulted: certificate chain members, the CRLs/OCSP responses/TSA tokens fetched, their sources, and the evaluation performed against each.
- The ETSI EN 319 102-1 indication (
TOTAL_PASSED/INDETERMINATE/TOTAL_FAILED) MAY be emitted as a machine-readable indicator, but pverify MUST NOT translate it into a domain verdict.- pverify MUST NOT emit any text or field claiming legal validity, regulatory qualification, or admissibility.
The rationale is explicit in the Constitution: qualification of a signature depends on the retention period, the relationship between signing time and verification time, archive-timestamp requirements, and counterparty agreements that vary per relying party. A tool that pre-judges those concerns forces misuse. pverify therefore reports what it observed — which certificates it built into which chain, which CRLs and OCSP responses it consulted from which source, which timestamps it recomputed and against which imprint — and leaves the legal/business decision to the consumer.
The output of every verification run is a closed-schema JSON document. The top-level machine-readable verdict per signature is an ETSI EN 319 102-1 indication: one of TOTAL_PASSED, INDETERMINATE, or TOTAL_FAILED, represented by the closed enum Indication in crates/pverify-core/src/report/etsi.rs (declared at line 24). It is qualified by a precise machine-readable sub-indication drawn from the single closed enum SubIndication (same file, line 39), which is mirrored 1:1 into the root report-schema.json. Free-form verdict strings are forbidden by construction: adding a sub-indication value is an additive MINOR schema change, and removing or repurposing one is a MAJOR change.
The relationship between the JNSA デジタル署名検証ガイドライン (第 1.1 版) verdict vocabulary and pverify's ETSI indications is documented in docs/jnsa-conformance-statement.md; the high-level mapping is VALID → TOTAL_PASSED (within pverify's implemented check set), INVALID → TOTAL_FAILED, and INDETERMINATE → INDETERMINATE. The conformance statement is the authoritative cross-walk; this document does not restate it.
A defining behaviour of pverify — and one that professional auditors should internalise before reading any report — is that the tool will never fabricate a verdict it cannot substantiate. Where pverify cannot establish a fact, it degrades to INDETERMINATE with a precise sub-indication rather than inventing a TOTAL_FAILED (which would falsely allege tampering or revocation) or a TOTAL_PASSED (which would falsely affirm trust). Concrete manifestations of this discipline:
INDETERMINATE / signature_algorithm_unsupported, not a failure — so a consumer can grep for "not yet handled" without confusing it with cryptographic compromise.revoked_no_poe sub-indication at INDETERMINATE (introduced in branch 033), reflecting that a revoked TSA cert does not by itself prove the protected signature is a forgery — there may be a proof-of-existence established before the revocation.INDETERMINATE / cms_multi_signer_unsupported (crates/pverify-core/src/verify.rs, verify_cades, line ~497 calling cades_multi_signer_entry at line 1890) rather than silently reporting only the first signer — a silent single-signer report would be a misleading affirmation.IndeterminateRevocationOffline / revocation_not_checked_offline, not a fabricated responder contact (the offline-honesty principle, Constitution §VII / §I).crypto_constraints_failure_no_poe at INDETERMINATE, mirroring ETSI's CRYPTO_CONSTRAINTS_FAILURE_NO_POE; it never escalates to TOTAL_FAILED.This discipline is why a pverify report is auditable: every INDETERMINATE carries a closed-enum reason naming exactly which fact could not be established. See Chapter 14 (Result Model: indications, diagnostic data & schema) for the full sub-indication vocabulary and Chapter 5 §5.7 together with Chapter 14 (ETSI indication aggregation) for the precedence rules.
pverify serves three concentric audiences, as set out in the Vision Statement (docs/vision-statement.md §3):
Primary — integrating developers. Back-end engineers at electronic- application SaaS providers, local-government vendors, and document- management vendors who embed pverify-core to add GPKI signature verification to their own products. The library-first design (Constitution §V) exists for them: pverify-core is an I/O-free no_std + alloc crate that can be driven from a native binary, a WebAssembly module, or a Cloudflare Worker without changing the verification logic.
Secondary — verification operators and auditors. Local governments, audit firms, and public-document custodians who run one-off verifications or audit responses by invoking the CLI directly. The reproducibility (Constitution §II) and auditability (§III) guarantees exist for them: a --from-bundle invocation re-runs a verification weeks later from a content-addressed bundle and yields a byte-identical report.
Tertiary — researchers and standardisation participants. Those who use pverify as a reference implementation of ETSI EN 319 102-1 and RFC 5280 §6 for benchmarking and test-vector validation.
The overarching customer context is the Japanese Government Public Key Infrastructure (GPKI): pverify is built so that a relying party receiving a GPKI-signed CAdES/PAdES/XAdES document — including signatures that traverse the GPKI Bridge CA structure for inter-ministry or government-to-private trust — has a common, hard, reproducible verification substrate. It is simultaneously aware of the European eIDAS / ETSI corpus (EU Trusted Lists, CAdES/PAdES/XAdES/JAdES/ASiC baseline levels), so the same engine verifies both the GPKI/JNSA profile and the eIDAS profile.
pverify verifies five AdES signature families, three of them across multiple baseline levels. The supported surface is intentionally honest: where a format variant or AdES level exceeds the current implementation, the tool refuses with a closed-enum sub-indication at INDETERMINATE rather than guessing.
The verification kernel performs a content sniff over the leading bytes to route an input to the correct per-format verifier (crates/pverify-core/src/verify.rs, detect_format, line 359):
| Leading bytes | Detected format | Standard | Verifier |
|---|---|---|---|
%PDF- |
PAdES | EN 319 142 / RFC 5652 (embedded CMS) | pades::verify_pdf |
BOM/<?xml/bare <name |
XAdES | EN 319 132 | xades::verify_xades |
leading { (after optional BOM) |
JAdES | TS 119 182 / RFC 7515 (JWS) | jades::verify_jades |
PK\x03\x04 (ZIP magic) |
ASiC | EN 319 162 | asic::verify_asic_signature |
| otherwise | CAdES | EN 319 122 / RFC 5652 | verify_cades |
The ZIP-magic case for ASiC is handled before the byte sniff: if the host extracted ASiC container signatures, verify_with routes to the ASiC orchestrator unconditionally, because the container bytes start with the ZIP magic that would otherwise fall through to CAdES (crates/pverify-core/src/verify.rs, verify_with, line ~195: if !request.asic_signatures.is_empty()). A bare ZIP with no recognised ASiC content yields INDETERMINATE / asic_unsupported_container (line ~225). The XML-vs-JSON sniff is non-colliding by construction: an XAdES input begins with < and a JAdES input with { (line 356, "JSON and XML are mutually exclusive on the first non-whitespace byte").
The ETSI baseline-level vocabulary (B-B/BES, B-T, B-LT, B-LTA) is used exactly, and the tool is explicit about which levels it actually verifies per format.
| Format | B-B (BES) | B-T | B-LT | B-LTA | Notes |
|---|---|---|---|---|---|
| CAdES (EN 319 122) | ✓ | ✓ | ✓ | ✓ | Full ladder. B-T = signature-time-stamp; B-LT = certificate-values + revocation-values; B-LTA = archive-time-stamp-v3 with §6.3.4 imprint recomputation. |
| PAdES (EN 319 142) | ✓ | ✓ | ✓ | ✓ | Over embedded CMS plus /DocTimeStamp. As of branch 034 consumes document-level /DSS (/Certs,/CRLs,/OCSPs), /VRI and Adobe revocationInfoArchival for offline-definitive LT/LTA. |
| XAdES (EN 319 132) | ✓ (enveloped) | ✓ | ✓ | ✓ | Enveloped + Exclusive C14N only. B-LTA = xades:ArchiveTimeStamp imprint verification (ETSI TS 101 903 v1.4.2 Annex A.1.5) with exc-C14N over ds:Signature minus later ArchiveTS node-set (branch 036). Detached/enveloping packaging and non-exclusive C14N degrade to INDETERMINATE. |
| JAdES (TS 119 182) | ✓ (JWS-JSON) | ✓ | ✓ | ✓ | JWS JSON Serialization. B-T (sigTst), B-LT (xVals/rVals), B-LTA (arcTst) parsed from the unprotected header and wired into the existing VRT/revocation pipeline (branch 037). sigD and Compact serialization are explicitly refused. |
| ASiC (EN 319 162) | delegated | delegated | delegated | delegated | ASiC-S / ASiC-E; each inner signature is delegated to the CAdES or XAdES pipeline. |
The CAdES format-promotion ladder is implemented at crates/pverify-core/src/verify.rs lines 1034–1042: the presence of an archive-time-stamp promotes the wire format to CAdESLta; otherwise any LT/LTA payload promotes to CAdESLt; otherwise a signature-time-stamp promotes to CAdEST; otherwise the format is CAdESBes.
The B-LTA / archive mechanisms differ by format and are named precisely: CAdES uses archive-time-stamp-v3 (ETSI EN 319 122-1 §6.3.4, with imprint recomputation; a mismatch is archive_timestamp_imprint_mismatch, TOTAL_FAILED), PAdES uses /DocTimeStamp (a PDF /Type /DocTimeStamp dictionary carrying an RFC 3161 token over a revision's ByteRange, surfaced as a timestamp with the pades_doc_timestamp VRT kind), and XAdES uses xades:ArchiveTimeStamp (ETSI TS 101 903 v1.4.2 Annex A.1.5; imprint = exc-C14N of the full ds:Signature element minus the node-set covered by any later ArchiveTimeStamp in the same signature — implemented in branch 036 by XadesUnverifiedTimestamp.imprint_input: Vec<u8> with bergshamra-c14n node-set subtract API, adding SignatureFormat::XadesBLta).
The honest refusals are first-class report outcomes, each carrying a closed- enum reason:
XadesProfile::Unsupported with reason DetachedOrEnveloping or NonExclusiveC14n (crates/pverify-core/src/xades.rs, UnsupportedReason at line 151), surfacing as INDETERMINATE / xades_unsupported_profile (or the canonicalization-specific sub-indication). One exception: an ASiC-resolved detached XAdES is verified, because the ASiC container supplies the resolved data-object bytes (line ~133). The supported shape is enveloped + Exclusive C14N (W3C exc-c14n), provided by the audited bergshamra-c14n crate.JadesUnsupportedReason: DetachedSigD (the JWS sigD detached profile), CompactSerialization (JWS Compact), HigherLevel (only rfsTst/tstVd — branch 037 implemented B-T/B-LT/B-LTA), and NotJadesBaseline.cms_multi_signer_unsupported rather than reporting only the first signer (see §1.1.1).asic_unsupported_container.Certificate, CRL, OCSP, TSA, and signer signatures are all verified through RustCrypto-only primitives, dispatched by OID and SPKI shape in crates/pverify-core/src/crypto.rs (verify_with_alg). The supported signature families are:
verify_rsa_pss, line 151; note the CMS OID table at line ~284 deliberately rejects PSS so PSS only flows through the dedicated PSS path).1.3.101.112).2.16.840.1.101.3.4.3.17/18/19, MlDsaParams at line 216) — a forward-looking post-quantum dispatch.Any signature algorithm outside this set surfaces as signature_algorithm_unsupported at INDETERMINATE. There is no signing or key-generation surface in the production dependency set — the cryptography is verify-only.
Per Constitution §IV (Specification-Compliance, NON-NEGOTIABLE), the implementation treats the following as primary normative references, and any deviation must be enumerated in the relevant feature spec with a written rationale:
pverify-eutl).pverify is deliberately bounded. The following are out of scope for the v1.x line, drawn from the Vision Statement §6 and reinforced by the fact-reporting principle:
Format-level carve-outs within the supported families are equally explicit and are surfaced as closed-enum refusals (not silent passes): higher JAdES baseline levels, detached/enveloping XAdES, non-exclusive C14N, multi-signer CMS, and any signature algorithm outside the supported set (§1.3.3, §1.3.4). The PAdES /DSS work of branch 034 also explicitly excludes generating DSS, promoting DSS /Certs to trust anchors, validating VRI /TU//TS internal consistency, and the otherRevInfo field — these are out of scope by design.
The single most consequential design decision in pverify is the boundary between an I/O-free, no_std + alloc, WASM-buildable verification kernel (pverify-core) and a constellation of host-side crates that perform all container/document parsing, network fetch, filesystem access, and clock reads. This is not an aesthetic preference; it is enforced by the Constitution and by empirical CI gates. The goals it serves are:
pverify-core performs all cryptography, RFC 5280 §6 path validation, revocation evaluation (CRL/OCSP), timestamp and VRT derivation, algorithm- policy evaluation, and ETSI indication aggregation. The crate declares #![no_std] and #![forbid(unsafe_code)] at the top of crates/pverify-core/src/lib.rs (lines 11–12), and pulls in only the alloc crate (line 15). The three host concerns the kernel cannot perform for itself — the verification clock, revocation material, and the trust-anchor set — are trait-mediated: Clock (line 76), RevocationFetcher (line 98), and TrustAnchorStore (line 139) in crates/pverify-core/src/traits.rs. The kernel performs zero network, filesystem, or ambient-clock access; its derive_vrt and derive_validation_objects functions are pure compute.
Because PDF, XML, ZIP, and JSON cannot be parsed in a no_std kernel without dragging heavyweight, often unsafe-laden, non-WASM-clean dependencies into the trusted compute base, all container/document parsing happens in host crates and feeds the kernel model-free byte structures via additive VerificationRequest fields (crates/pverify-core/src/verify.rs, line 69 onwards: pdf_signature_dicts, pdf_validation_data, xades_components, jades_components, asic_signatures). The kernel consults only the field matching the sniffed format; a non-PDF input carries an empty pdf_validation_data, and so on. This boundary is empirically enforced by scripts/cargo-tree-gate.sh, which runs cargo tree -e normal --no-default-features -p pverify-core and fails the build if lopdf, zip, flate2, bergshamra-c14n, pverify-eutl, pverify-aatl, or any host-only crate appears in the core graph.
pverify-core must compile and run on wasm32-unknown-unknown. Cryptography is RustCrypto-only: ring, aws-lc-rs, OpenSSL FFI, and the rustls default provider are prohibited anywhere in the core or WASM graph. This is empirically gated by cargo build --target wasm32-unknown-unknown -p pverify-core plus the cargo-tree gate. The pure-Rust crypto stance is inherited from the prior project civ (Vision Statement §8.5) and is what lets the same kernel run from a CLI to a Cloudflare Worker to an in-browser WASM module.
The native CLI (crates/pverify-cli) and the browser WASM host (web/pverify-wasm) run byte-identical host extractors and the identical verify_with kernel, producing byte-identical reports for identical inputs. For PAdES, the native PDF parse (pverify-cli/src/pdf.rs, lopdf) and the in-browser parse (web/pverify-wasm/src/lib.rs, lopdf) extract the same PdfSignatureDescriptors and the same /DSS+/VRI validation material; this parity is an enforced invariant covered by parity tests over the extractors.
For the same inputs (signed document, trust anchors, verification time, and revocation material), pverify produces byte-identical output regardless of when or where it runs. The verification time is injectable via --at <RFC3339> and otherwise captured once at CLI startup; the VRT engine reads only this request_at value as its time source, never Clock::now. A further invariant governs additive features: an additive feature must leave the report body byte-identical except the schema_version string when its inputs are absent or it is disabled. For example, a PDF with no /DSS produces a report identical to the pre-034 output except for schema_version (FR-008); the algorithm policy, when off (the default), leaves signatures[].algorithm_validity absent; and the embedded-revocation fall-through fires only when the live/CDP channel is indeterminate, so online successes stay byte-identical.
The report shape carries a SemVer string, SCHEMA_VERSION, currently "1.10.0" (crates/pverify-core/src/report/schema.rs). The schema-versioning discipline is strict and additive: the SubIndication, RevocationOutcome, and ValidationObjectOrigin enums and the report struct are closed and mirrored 1:1 in the root report-schema.json. Adding a closed- enum value or an optional field is a MINOR bump; removing or repurposing one is MAJOR. Deprecated variants (e.g. the historical bridge_ca_required_unsupported) are retained for stored-report deserialization but are no longer emitted. Branch 034, for instance, appended two ValidationObjectOrigin variants — PdfDss and PdfVri (crates/pverify-core/src/report/validation_objects.rs) — after the existing variants, declaration-order being sort-order, and bumped the schema 1.5.0 → 1.7.0 accordingly. Subsequent additive slices continued the MINOR-per-slice discipline: branch 035 added OcspAttempt.request_der_hex and CdpEntry structured form (1.7.0 → 1.8.0 → 1.9.0); branch 037 added three new SignatureFormat values (JAdES-B-T, JAdES-B-LT, JAdES-B-LTA) and the previously-unschemed XAdES-B-LTA / LTV-JWS variants (1.9.0 → 1.10.0).
Note on the README disclosure block. The repository
README.mdis a historical artefact pinned at the v0.7 development line; its running text statesschema_versionstays"1.1.0"and the capability phase"v0.6-bridge-acceptance". The authoritative live value is theSCHEMA_VERSIONconstant inreport/schema.rs("1.10.0"), whose doc-comment narrates each bump from1.0.0through1.10.0. Readers should treat the source constant, not the README, as normative for the current release.
The following invariants are referenced throughout the design document; they are listed here once so later chapters can cite them by name. Each is grounded in code and in the Constitution.
| Invariant | One-line statement | Authority |
|---|---|---|
| Fact-reporting / cannot-affirm | Report facts, never a business verdict; degrade to INDETERMINATE with a closed-enum sub-indication where a fact cannot be established. |
Constitution §I (NON-NEGOTIABLE) |
| WASM-clean core | pverify-core is no_std + alloc, forbids unsafe_code, RustCrypto-only, builds for wasm32-unknown-unknown. |
Constitution §VI; cargo-tree-gate.sh |
| No parse in core | All PDF/XML/ZIP/JSON parsing is host-side; the kernel gets model-free byte structures. | Constitution §V; gate-asserted |
| Trait-mediated I/O | Clock / revocation / anchors are the only host couplings, via Clock / RevocationFetcher / TrustAnchorStore. |
crates/pverify-core/src/traits.rs |
| Additive byte-identity | An additive/off feature leaves the report body byte-identical except schema_version. |
Constitution §II (e.g. FR-008) |
| Offline honesty | --offline opens no socket and never fabricates a responder contact. |
Constitution §VII / §I |
| CLI ↔︎ WASM parity | Native and browser hosts run identical extractors and the identical kernel. | parity tests |
| Indication precedence | Severity-ordered base (TOTAL_FAILED > INDETERMINATE > TOTAL_PASSED, first-finding-wins); layers only degrade, never mask a proven TOTAL_FAILED. |
aggregate_etsi, verify.rs |
| Per-object VRT separation | Each object is judged at its own derived Validation Reference Time, by a pure recursive-outer-covering engine. | crates/pverify-core/src/vrt/ |
| Closed additive schema | Closed enums mirrored 1:1 in report-schema.json; add = MINOR, remove/repurpose = MAJOR. |
report/schema.rs, report-schema.json |
This document is organised so that an auditor can read it top-to-bottom, or jump directly to the subsystem under review. The chapter numbering below reflects the integrated design specification; cross-references in later chapters use these numbers.
RevocationOutcome vocabulary./DSS / /VRI / revocationInfoArchival material), XAdES, JAdES, and ASiC, each describing the supported shape, the refusal cases, and the host extractor.aggregate_etsi), the validation_objects inventory and its ValidationObjectOrigin provenance tags, and the schema-versioning rules.cargo audit/cargo vet, perf-gate, cargo-tree gate), and the reproducibility / byte-identity guarantees.Where this chapter has summarised a behaviour (the cannot-affirm discipline, the format ladder, the trait boundary), the corresponding subsystem chapter provides the field-level detail and the failure-mode enumeration. Readers auditing a specific verdict should begin with the report-schema chapter to locate the relevant sub-indication, then follow its cross-reference into the subsystem that produces it.
This chapter is the normative anchor for the rest of the specification. Where later chapters describe what a particular crate or code path does, this chapter establishes why it is permitted to do it — and, just as important, what it is forbidden from doing. The governing instrument is the project constitution at .specify/memory/constitution.md (version 1.2.0, ratified 2026-06-10, last amended 2026-06-22). The constitution supersedes ad-hoc conventions, READMEs, and reviewer preference: "Where they conflict, the constitution wins" (Governance clause). Every principle quoted below is reproduced verbatim from that file, with the article number it carries there.
The constitution defines seven Core Principles (I–VII), a Technology Stack Constraints section, a Phased Path Validation Roadmap, an explicit Out-of-Scope list, and a Governance / versioning policy. Sections 2.1–2.10 of this chapter walk those articles in the order an auditor most needs them, ground each in the code that enforces it, and then 2.11 summarises the cross-cutting invariants (with 2.12 recording the Out-of-Scope bounds) that the architecture survey (Chapter 3) and the implementation chapters (Chapters 5–13) repeatedly rely upon.
A note on terminology: throughout this document "the kernel" or "the core" refers to the pverify-core crate, and "host" refers to any crate that performs I/O on its behalf (pverify-cli, web/pverify-wasm, pverify-eutl, pverify-aatl, the extraction crates). The crate topology that realises this host/kernel split is detailed in Chapter 3; this chapter explains the constitutional rationale for the split.
The constitution is not a vision statement; it is a versioned contract under its own Semantic Versioning regime (Governance → Versioning policy):
- MAJOR: a principle is removed, redefined incompatibly, or a NON-NEGOTIABLE marker is dropped; an Out-of-Scope item is brought in.
- MINOR: a new principle or section is added, or existing guidance is materially expanded.
- PATCH: clarifications, wording, typography, or non-semantic edits.
Each amendment carries a "Sync Impact Report" comment at the head of the file recording the version delta, the principles touched, and the rationale for the SemVer classification. The most recent amendment (1.1.0 → 1.2.0) ratified the algorithm-policy default table; the report is explicit that no NON-NEGOTIABLE marker was dropped and no Out-of-Scope item was brought in, which is precisely why the bump is MINOR rather than MAJOR.
Three of the seven principles are marked NON-NEGOTIABLE: I (Fact-Reporting), IV (Specification-Compliance), and VI (WASM Compatibility). The marker is load-bearing: dropping it is a MAJOR amendment by definition. The constitution also pins a number of values that are themselves constitutional surfaces — the algorithm-policy default table (§2.8) is the salient example, where the code constant pverify_core::algorithm_policy::AlgorithmPolicy::default_policy() "MUST byte-match specs/032-algorithm-validity/contracts/default-policy.md (test C-3)". That is the model the whole project follows: a principle is only real if a test fails when it is violated. The remainder of this chapter identifies, for each principle, the code and the test that hold the line.
Principle I is the philosophical spine of the entire tool. Quoted verbatim:
I. Fact-Reporting (NON-NEGOTIABLE)
pverify outputs structured reports of observed facts, never business-level adjudications such as "qualified electronic signature" or "legally valid".
- The output JSON MUST enumerate every artefact consulted: certificate chain members, the CRLs/OCSP responses/TSA tokens fetched, their sources, and the evaluation performed against each.
- The ETSI EN 319 102-1 indication (
TOTAL_PASSED/INDETERMINATE/TOTAL_FAILED) MAY be emitted as a machine-readable indicator, but pverify MUST NOT translate it into a domain verdict.- pverify MUST NOT emit any text or field claiming legal validity, regulatory qualification, or admissibility.
Rationale: qualification depends on retention period, signing-vs-verification time, archive-TS requirements, and counterparty agreements that vary per user. A tool that pre-judges these forces misuse.
This principle has two operational consequences that pervade the codebase.
The report is an inventory, not a yes/no. The kernel derives a consolidated material inventory — every certificate, CRL, OCSP response and timestamp token consulted, each tagged with its provenance — in derive_validation_objects (crates/pverify-core/src/report/validation_objects.rs). That function is a pure read over the completed &Report tree (no recomputation), and it records, for every object, where it came from. The provenance vocabulary is the closed enum ValidationObjectOrigin (report/validation_objects.rs, lines 98–118): signature_embedded, bundle, fetched_online, trust_anchor, supplied_input, and — appended for the PAdES /DSS+/VRI work — pdf_dss and pdf_vri. The point of these tags is auditability (§2.4): a reader can see not only that a CRL was used but whence it was obtained, which is exactly the "their sources" requirement of Principle I.
The second, subtler consequence is the "cannot affirm" discipline. Where pverify cannot establish a fact, it MUST NOT fabricate either a failure or a pass; it degrades to ETSI EN 319 102-1 INDETERMINATE with a precise, closed-enum sub-indication. This is the difference between not knowing and knowing the answer is negative, and the tool refuses to collapse the two. The top-level indication is the closed enum Indication (crates/pverify-core/src/report/etsi.rs); the qualifying reason is the closed enum SubIndication in the same file. The canonical instances of the discipline:
| Situation | Indication | Sub-indication (serde rename) | Evidence |
|---|---|---|---|
--offline and no cached revocation material |
INDETERMINATE | revocation_not_checked_offline |
revocation/mod.rs (IndeterminateRevocationOffline, ~L667) |
| Multi-signer CMS SignedData | INDETERMINATE | cms_multi_signer_unsupported |
verify.rs:497 → cades_multi_signer_entry (verify.rs:1890) |
| Revoked TSA certificate (033) | INDETERMINATE | revoked_no_poe |
report/etsi.rs:424 |
| Opt-in algorithm constraint failure (032) | INDETERMINATE | crypto_constraints_failure_no_poe |
report/etsi.rs:399 |
| Bridge-CA path required, not supported (historical) | INDETERMINATE | bridge_ca_required_unsupported |
report/etsi.rs:63 (retained for deserialization) |
The multi-signer case is the clearest illustration of the principle's active enforcement. A naive verifier would silently report only the first SignerInfo; pverify deliberately refuses. The code comment at verify.rs:487 records the reasoning, and cades_multi_signer_entry returns the INDETERMINATE / cms_multi_signer_unsupported entry rather than a partial pass. This is "cannot affirm" made executable: pverify will not affirm a signature it has not fully evaluated.
Equally, a revoked TSA certificate does not become a TOTAL_FAILED forgery claim. The 033 work introduced revoked_no_poe: a revoked timestamping authority means the timestamp's proof-of-existence is no longer trustworthy, so the time anchor is unavailable — an indeterminacy, not a proven forgery of the signature. Encoding that as INDETERMINATE rather than TOTAL_FAILED is a direct application of §I. The aggregation that enforces "never mask a proven failure" is described in §2.11.
The third bullet — no claim of legal validity, qualification, or admissibility — is honoured by omission: the report schema (report-schema.json, mirrored from crates/pverify-core/src/report/) carries no qualified, legally_valid or admissible field, and the indication enum stops at the three ETSI values. The rationale clause is unusually explicit for a constitution: qualification "depends on retention period, signing-vs-verification time, archive-TS requirements, and counterparty agreements that vary per user." pverify reports the facts those determinations rest on; it does not make the determination.
II. Reproducibility
For the same inputs (signed document, trust anchors, verification time, and revocation material), pverify MUST produce byte-identical outputs irrespective of when or where it runs.
- Verification time MUST be injectable (
--at <RFC3339>) to support signing-time and historical evaluations.- All network I/O MUST be mediated by traits so a
--from-bundleinvocation reproduces a previous run with no external calls.- Non-deterministic data sources (system clock, ambient TLS roots, randomised ordering) MUST NOT influence the report.
Reproducibility is the property that makes a verification report evidence. The constitution defines the reproducibility tuple precisely: (signed document, trust anchors, verification time, revocation material). Given that tuple, the output is byte-identical regardless of wall-clock time, host, or network reachability.
The CLI captures the verification time exactly once at startup (or takes it from --at, which always wins) and threads it through as request_at. The kernel never reads a wall clock for verification logic — the per-object VRT engine in crates/pverify-core/src/vrt/ derives reference times only from request_at and from the timestamp tokens present in the structure, never from Clock::now(). The Clock trait (crates/pverify-core/src/traits.rs:76) exists for host plumbing, not for verification decisions; this separation is what makes a --at <RFC3339> historical evaluation deterministic.
--from-bundle reproducibilityEvery external dependency the kernel has — clock, revocation material, trust anchors — is a trait: Clock, RevocationFetcher, TrustAnchorStore (crates/pverify-core/src/traits.rs, lines 76, 98, 139). The kernel performs no network, filesystem, or clock access of its own. This is what allows --from-bundle to replay a previous run with zero external calls: the bundle supplies the same revocation material the original run fetched, fed back through the same RevocationFetcher trait surface. An independent auditor re-running against the bundle obtains the identical report — which is precisely the Auditability guarantee of §III (§2.4).
A corollary the project enforces aggressively is byte-identity under additive features. When a feature is disabled or its inputs are absent, the report body MUST be byte-identical to the prior release except for the schema_version string. Concrete instances:
/DSS produces a report that differs from the pre-034 baseline only in schema_version (the 034 FR-008 requirement). The PAdES validation- data fields are additive Vec/Default on VerificationRequest, empty for non-PDF and no-DSS inputs.schema_version (the 032 SC-001 requirement; §2.8).This discipline is why each additive slice bumps schema_version by a MINOR increment and nothing else changes in the common path. The current report-shape version is SCHEMA_VERSION = "1.10.0" (crates/pverify-core/src/report/schema.rs:line).
The constitution explicitly bans the system clock, ambient TLS roots, and randomised ordering from influencing the report. Trust anchors are supplied explicitly (--trust-anchors <DIR>, or ingested by pverify-eutl/pverify-aatl into ordinary anchors), never read from an OS trust store. The kernel is no_std (§2.6) and therefore has no access to ambient OS state at all.
III. Auditability
A verification run MUST be capable of emitting a self-contained "verification bundle" that embeds every certificate, CRL, OCSP response, and TSA token used, such that an independent third party can re-run pverify against the bundle and obtain the same report.
Auditability is the operational pay-off of Reproducibility (§II) and Fact-Reporting (§I) combined. Because the kernel is I/O-free and trait-mediated, the CLI can capture the full set of consulted artefacts during an online run and materialise them as a bundle directory (<bundle>/crls, <bundle>/ocsp, <bundle>/trust-anchors), then a later --from-bundle run replays them through the same RevocationFetcher/TrustAnchorStore traits. The consolidated validation_objects inventory (§2.2.1) is the in-report manifest of exactly what was used and with what provenance, so a third party can cross-check the bundle contents against the report. The bundle value of ValidationObjectOrigin marks material that came from the bundle; in from_bundle mode an object retains its original provenance where known (report/validation_objects.rs comments around L303). The audit chain is therefore closed: report → manifest → bundle → re-run → identical report.
The CLI modes that realise this are the closed Mode enum {Online, Offline, FromBundle} (pverify-cli/src), mapped to the kernel's revocation VerificationMode. See Chapter 3 for the mode plumbing.
IV. Specification-Compliance (NON-NEGOTIABLE)
The implementation treats the following as primary normative references:
- RFC 5280 (X.509, CRL, §6 Path Validation)
- RFC 5652 (CMS)
- RFC 3161 / 5816 (TSP)
- RFC 6960 (OCSP)
- ETSI EN 319 102-1 (signature validation procedures)
- ETSI EN 319 122 / 319 142 (CAdES / PAdES); 319 132 (XAdES, v0.4+)
- デジタル庁 デジタル社会推進標準ガイドライン (GPKI provisions)
- JIS X 5092 / 5093 (CAdES / XAdES Japanese translations)
Behavioural correctness MUST be demonstrated through public test vectors: ETSI conformance suites, デジタル庁 published samples, and project-maintained golden cases. Deviations from any reference document MUST be enumerated in the relevant feature spec with a written rationale.
This principle pins the normative corpus and — crucially — demands that correctness be demonstrated by public test vectors, not asserted. The mapping of standard → implementing module is the subject of Chapters 5–13; this section records the contract and the AdES-level vocabulary, which §IV makes binding.
| Standard | Concern | Primary module(s) |
|---|---|---|
| RFC 5280 §6 | X.509 path validation, CRL | crates/pverify-core/src/path/ (mod.rs, name_constraints.rs, policy.rs, bridge.rs), revocation/crl.rs |
| RFC 5652 | CMS SignedData | crates/pverify-core/src/cms/signed_data.rs |
| RFC 3161 / 5816 | Timestamp tokens (TST) | crates/pverify-core/src/timestamp.rs |
| RFC 6960 | OCSP | crates/pverify-core/src/revocation/ocsp.rs |
| RFC 5035 | ESS signing-certificate binding | CAdES signer-binding path (025) |
| ETSI EN 319 102-1 | Indications / sub-indications | crates/pverify-core/src/report/etsi.rs |
| ETSI EN 319 122 | CAdES | verify_cades (verify.rs:475) |
| ETSI EN 319 142 | PAdES | crates/pverify-core/src/pades/mod.rs |
| ETSI EN 319 132 | XAdES | crates/pverify-core/src/xades.rs + pverify-xades |
| ETSI TS 119 182 | JAdES | crates/pverify-core/src/jades.rs + pverify-jades |
| ETSI EN 319 162 | ASiC | crates/pverify-core/src/asic.rs + pverify-asic |
| CRYPTREC / NIST SP 800-57, 800-131A | Algorithm policy | crates/pverify-core/src/algorithm_policy/ (032) |
| JNSA 署名検証ガイドライン | VRT, gap-review profile | crates/pverify-core/src/vrt/ |
§IV's "Deviations … MUST be enumerated … with a written rationale" clause is the reason pverify is precise about what level of each format it actually verifies, rather than claiming blanket support. The supported matrix, grounded in the format-promotion ladder at verify.rs:1034-1042 and the per-format verifiers:
| Format | Levels verified | Explicitly degraded / refused |
|---|---|---|
| CAdES (EN 319 122) | B-B/BES, B-T (signature-time-stamp), B-LT, B-LTA (archive-time-stamp-v3 with §6.3.4 imprint recomputation) | Multi-signer CMS → INDETERMINATE cms_multi_signer_unsupported |
| PAdES (EN 319 142) | B-B/B-T/B-LT/B-LTA over embedded CMS + /DocTimeStamp; document-level /DSS+/VRI+Adobe revocationInfoArchival consumed for offline-definitive LT/LTA (034) |
— |
| XAdES (EN 319 132) | enveloped B-B with B-T/B-LT and B-LTA ArchiveTimeStamp imprint verification (ETSI TS 101 903 Annex A.1.5; branch 036, SignatureFormat::XadesBLta) (Exclusive C14N via audited bergshamra-c14n) |
detached/enveloping & non-exclusive C14N → INDETERMINATE xades_unsupported_canonicalization |
| JAdES (TS 119 182) | JWS-JSON-serialization B-B | B-T+ and sigD/Compact serialization → refused INDETERMINATE |
| ASiC (EN 319 162) | ASiC-S / ASiC-E, delegating each inner signature to CAdES or XAdES | bare ZIP with no recognised ASiC content → INDETERMINATE asic_unsupported_container |
The refusals are not bugs; they are §I/§IV in concert — the tool declines to affirm a level it has not implemented, and the deviation is enumerated in the corresponding feature spec. See Chapter 5 (CMS/CAdES), Chapter 6 (PAdES), Chapter 7 (XAdES/ASiC), and Chapter 8 (JAdES) for the detail.
These two principles are the architectural keystone and are best read together.
V. Library-First
pverify-coreMUST be implemented as an I/O-freeno_std + alloclibrary so the CLI binary and the Cloudflare Workers reference build sit on a single core. Network access, time, and trust-anchor storage MUST be expressed as traits —RevocationFetcher,Clock,TrustAnchorStore— implemented per target rather than baked into the core.
VI. WASM Compatibility (NON-NEGOTIABLE)
Apart from optional native-only acceleration paths,
pverify-coreMUST compile and run onwasm32-unknown-unknown.
ring,aws-lc-rs, and OpenSSL FFI dependencies are PROHIBITED in the core and in any crate that the core or the Workers build transitively pulls in. Only the pure-Rust RustCrypto ecosystem is permitted.- The Workers build MAY delegate hashing and public-key verification to the Web Crypto
SubtleCryptoAPI to reduce bundle size, provided the resulting output remains byte-identical to the native path under Principle II.- Adding a feature flag that re-enables a banned dependency (e.g.
rustlswith default features, civ'svp-federation) is PROHIBITED.
no_std, unsafe-free, I/O-freeThe first three lines of crates/pverify-core/src/lib.rs are the enforcement of both principles:
#![no_std]
#![forbid(unsafe_code)]
#![deny(missing_debug_implementations)]
extern crate alloc;#![no_std] makes it structurally impossible for the kernel to touch the OS (no std::fs, no std::net, no SystemTime); #![forbid(unsafe_code)] is a hard compile error on any unsafe block — there is no allow override. The kernel uses only alloc. The host concerns it genuinely needs are the three traits in traits.rs (§2.3.2), implemented once for the native target and once for WASM.
A direct consequence of no_std is that the kernel cannot parse PDF, XML, ZIP, or JSON — those require heavyweight std-linked libraries (lopdf, quick-xml, zip/flate2). Therefore all container/document parsing happens host-side and feeds the kernel model-free byte structures via additive VerificationRequest fields. PAdES signature dictionaries and /DSS+/VRI material are extracted by pverify-cli/src/pdf.rs (native, lopdf) and the byte-identical web/pverify-wasm/src/lib.rs (browser, lopdf); XAdES by pverify-xades; ASiC by pverify-asic; JAdES by pverify-jades. The kernel only consults the field matching the format it sniffed.
Principle VI says "in any crate that the core … transitively pulls in" — a statement about the dependency graph, which the project enforces empirically, not by faith:
cargo build --target wasm32-unknown-unknown -p pverify-core must succeed. If any transitive dependency is not wasm32-buildable (because it pulls ring/getrandom/native FFI), this fails.scripts/cargo-tree-gate.sh. This script runs cargo tree -e normal --no-default-features -p pverify-core and fails the build (exit 1, line 117) if any host-only crate appears in the core's production graph: pverify-eutl (L50), pverify-aatl/lopdf (L76), pverify-asic/zip/flate2/miniz_oxide, pverify-jades, pverify-anchor-inventory, pverify-test-helpers, xtask. The PASS message (L120) enumerates exactly what it has confirmed absent.The cryptography constraint is realised by dependency selection: the kernel's crypto deps are RustCrypto-only (der, x509-cert, rsa, p256, p384, ed25519-dalek, ml-dsa, sha1/sha2, spki, pkcs1, x509-ocsp), all verify-only — there is no signing or keygen surface in the production graph. The web/pverify-wasm crate is intentionally detached from the root workspace (an empty [workspace] table in its Cargo.toml, with the rationale recorded at lines 3–9): this keeps wasm-bindgen's generated unsafe out of the root unsafe_code = "forbid" lint surface and keeps the wasm graph out of the released cargo test --workspace / cargo audit surface. See Chapter 3 for the full topology and the WASM host.
The pay-off of "a single core" (§V) is CLI↔︎WASM parity: the native CLI and the browser host run the identical verify_with kernel (crates/pverify-core/src/verify.rs:178) and byte-identical extractors, producing byte-identical reports for identical inputs. The Web Crypto delegation permitted by §VI's second bullet is constrained by the same byte-identity rule: any SubtleCrypto acceleration "MUST remain byte-identical to the native path under Principle II." Parity is pinned by tests over the extractor output shapes; this is treated as an enforced invariant (see Chapter 3).
VII. Offline-First
The CLI MUST support an
--offlinemode in which verification proceeds using only material embedded in the signature (DSS / VRI dictionaries, archive timestamps) and the configured trust anchors. Network access MUST be an explicit opt-in; the default posture forverify --from-bundleis fully offline.
Offline-first has a subtle ethical corner that the project elevates to a named invariant: offline honesty. In --offline mode the tool must not fabricate a responder contact it never made. An earlier implementation, when offline and unable to reach an OCSP responder, emitted ocsp_responder_unreachable — which falsely implies a contact was attempted and failed. That was a §I/§VII violation: the honest fact is "revocation was not checked because we are offline", not "the responder is down". The correction is the IndeterminateRevocationOffline outcome → revocation_not_checked_offline sub-indication (crates/pverify-core/src/revocation/mod.rs, ~L657–667, with the ocsp_attempt: None field making the absence of a contact explicit). Offline mode opens no socket.
This dovetails with §2.3.3's embedded-material fall-through: the embedded DSS/VRI/revocationInfoArchival channels (consumed for PAdES as of branch 034) fire only when the live/CDP channel is indeterminate, which in --offline mode is always — so a fully-evidenced offline PAdES-LT/LTA can reach a definitive result from its own embedded material, while a PDF lacking that material honestly degrades to revocation_not_checked_offline. The default posture for --from-bundle is fully offline by construction (the bundle is the only material source).
The Phased Path Validation Roadmap section of the constitution contains a clause that is unusually prescriptive — the Algorithm-policy enforcement scope — because the policy table is declared a constitutional surface: changing it is an amendment, not a code edit. The general clause states:
Should a release introduce algorithm-policy enforcement (a mode in which weak-algorithm presence degrades the ETSI indication), it MUST be opt-in; the default behaviour MUST remain the v0.1 fact-reporting posture (Principle I). The exact policy table … is a constitutional surface — additions or re-classifications require a constitutional amendment.
The 1.2.0 amendment then ratified the concrete table. Three constraints bind the implementation:
--algorithm-policy [FILE]; a default-off report is byte-identical to the pre-032 body except schema_version (§2.3.3). This preserves §I as the default posture.value, not against wall-clock time. "Unacceptable on/after D" means the object passes iff its VRT date is strictly before D.crypto_constraints_failure_no_poe (mirroring ETSI EN 319 102-1 CRYPTO_CONSTRAINTS_FAILURE_NO_POE) — a textbook "cannot affirm" (§2.2.2). A weak algorithm means we cannot affirm the proof; it does not prove a forgery.The ratified default table (constitution §"Algorithm-policy enforcement scope") takes CRYPTREC 電子政府推奨暗号リスト as primary authority and NIST SP 800-57 Part 1 / SP 800-131A as secondary, version 2026-06-default, and treats an unrecognised OID or undeterminable key length as INDETERMINATE (never a silent pass):
| Algorithm | Rule | Effective | Authority |
|---|---|---|---|
| MD5 (digest) | never acceptable | 1970-01-01 | CRYPTREC excluded |
| SHA-1 (digest) | unacceptable on/after | 2014-01-01 | NIST SP 800-131A; CRYPTREC 危殆化 |
| SHA-256 / SHA-384 / SHA-512 | acceptable | — | CRYPTREC 推奨 |
| RSA < 2048-bit | unacceptable on/after | 2014-01-01 | NIST SP 800-57; CRYPTREC 推奨 |
| ECDSA (P-256 / P-384, ≥256-bit field) | acceptable | — | CRYPTREC 推奨 |
| Ed25519 | acceptable | — | recognised strong |
| ML-DSA-44/65/87 | acceptable | — | PQC, no sunset |
The code constant AlgorithmPolicy::default_policy() (in crates/pverify-core/src/algorithm_policy/) "MUST byte-match specs/032-algorithm-validity/contracts/default-policy.md (test C-3)". This is the strongest form of the project's "a principle is only real if a test enforces it" stance (§2.1): the constitution and the code are bound by a byte-comparison test. See Chapter 13 (Algorithm Validity Policy) for the evaluation engine.
§IV's roadmap section delivers RFC 5280 §6 in stages, with a non-negotiable disclosure rule:
Every release MUST emit, in machine-readable form, the phase it implements so consumers cannot mistake an earlier phase for a complete validation.
The original v0.1 MVP shipped basic chain construction and was required to set a structural flag indicating Bridge-CA chains were not supported:
Phase 1 mandatory disclosure. v0.1 MVP outputs MUST set a structural flag indicating that Bridge-CA chains are NOT supported. Suppressing this flag is a constitutional violation, not a presentation choice. … the flag may flip to
trueonly when v0.3 ships Bridge-CA traversal under Name Constraints and policy processing.
This is §I (Fact-Reporting) applied to the tool's own capability: pverify must not let a consumer mistake a partial validation for a complete one. The current release has reached the v0.3/v0.4+ band — Name Constraints (path/name_constraints.rs, RFC 5280 §4.2.1.10), policy processing (path/policy.rs, §6.1 valid_policy_tree), Bridge-CA traversal (path/bridge.rs), archive-time-stamp-v3 imprint recomputation (EN 319 122-1 §6.3.4), XAdES, OCSP, and the per-object VRT engine are all implemented. The historical bridge_ca_required_unsupported sub-indication is retained in the SubIndication enum (report/etsi.rs:63) purely for deserialization of stored reports — a deprecated-but-not-removed variant, consistent with the additive schema-versioning rule (§2.10).
The report shape is a published contract (report-schema.json at the repository root, mirrored 1:1 from crates/pverify-core/src/report/). The closed enums — SubIndication, RevocationOutcome, ValidationObjectOrigin — and the report struct are all part of it. The versioning rule, derived from the constitution's governance SemVer and applied to the report schema specifically, is:
pdf_dss/pdf_vri to ValidationObjectOrigin (report/validation_objects.rs:113,118) and bumping SCHEMA_VERSION 1.5.0→1.7.0.A critical detail for auditors: enum declaration order is sort order. The new pdf_dss/pdf_vri variants were appended (not interleaved) so that the inventory derivation in derive_validation_objects maps origin → position without disturbing the relative ordering of pre-existing variants — preserving byte-identity of the inventory for reports that don't use the new tags. Deprecated variants (e.g. bridge_ca_required_unsupported, §2.9) are retained for stored-report deserialization but are not emitted by current code paths. SCHEMA_VERSION lives at crates/pverify-core/src/report/schema.rs:line and is currently "1.10.0"; the lineage 1.2.0 → 1.3.0 (031) → 1.4.0 (032) → 1.5.0 (033) → 1.7.0 (034) → 1.8.0 (035 OcspAttempt.request_der_hex) → 1.9.0 (035 CdpEntry structured form + aia_ocsp_uris) is one MINOR bump per additive slice.
The following invariants recur throughout Chapters 5–13. They are derived from, and traceable to, the principles above; this section is the canonical reference list. Each cites the principle it serves and the code that enforces it.
| # | Invariant | Principle | Enforcing evidence |
|---|---|---|---|
| 1 | Cannot affirm — unestablishable facts → INDETERMINATE + closed sub-indication, never fabricated PASS/FAIL | §I | report/etsi.rs (SubIndication); verify.rs:497 (multi-signer); revocation/mod.rs:667 (offline) |
| 2 | Enumerate every artefact with provenance | §I, §III | report/validation_objects.rs (derive_validation_objects, ValidationObjectOrigin) |
| 3 | No business/legal verdict emitted | §I | absence of any qualified/legally_valid field in schema |
| 4 | Verdict byte-identity on additive/off features (only schema_version differs) |
§II | 034 FR-008 (no-DSS PDF); 032 SC-001 (policy off); schema.rs:line |
| 5 | Time captured once, --at wins; VRT uses only request_at |
§II | traits.rs:76 (Clock); vrt/ engine (no Clock::now) |
| 6 | All I/O trait-mediated, kernel does zero network/fs/clock | §II, §V | traits.rs (Clock/RevocationFetcher/TrustAnchorStore) |
| 7 | Reproducible bundle re-run yields identical report | §III | Mode::FromBundle; bundle origin in validation_objects.rs |
| 8 | Standards-pinned behaviour demonstrated by public vectors | §IV | module map §2.5.1; ETSI/DSS interop fixtures |
| 9 | No container parse in core; host feeds model-free bytes | §V, §VI | pverify-cli/src/pdf.rs, pverify-xades/asic/jades; gate-asserted absent from core graph |
| 10 | WASM-clean core: no_std+alloc, forbid(unsafe_code), RustCrypto-only |
§VI | lib.rs:10-14; scripts/cargo-tree-gate.sh (exit 1, L117); WASM detached workspace |
| 11 | CLI↔︎WASM parity — identical kernel + extractors → identical reports | §V, §VI | verify.rs:178 (verify_with); parity tests over extractors |
| 12 | Offline honesty — no fabricated responder contact | §VII, §I | revocation/mod.rs:667 (IndeterminateRevocationOffline, ocsp_attempt: None) |
| 13 | Embedded-material fall-through fires only when live/CDP is indeterminate | §II, §VII | revocation embedded-CRL/OCSP channels (019 precedent); PAdES DSS/VRI (034) |
| 14 | Additive schema versioning; declaration order = sort order; deprecated variants retained | §IV (governance) | report/validation_objects.rs:113,118; schema.rs:line |
| 15 | Per-object VRT separation — each object judged at its own derived VRT; never re-derived by downstream layers | §I, §II (031) | vrt/; verify.rs:775 and per-token loops |
| 16 | Indication precedence / never-mask — severity-ordered base, layers only degrade, never mask a proven TOTAL_FAILED | §I | aggregate_etsi (verify.rs:2343) |
| 17 | Algorithm policy opt-in, VRT-scoped, INDETERMINATE-only; default table byte-matches the constitution | §I, §IV (032) | algorithm_policy/; default-policy test C-3 |
| 18 | Host ingestion produces ordinary anchors; DSS /Certs never promoted to anchors |
§III, §V | pverify-eutl/pverify-aatl; PAdES DSS handling (034) |
Invariant 16 deserves a closing note because it is where "cannot affirm" and "do not under-report a failure" meet. aggregate_etsi (verify.rs:2343) establishes a severity-ordered base indication (TOTAL_FAILED > INDETERMINATE > TOTAL_PASSED, first-finding-wins). Subsequent layers — signer-binding, content-type, the opt-in algorithm-validity layer, and the TSA-revocation layer — may only degrade a non-TOTAL_FAILED base; they never mask a proven TOTAL_FAILED. This is the formal guarantee that a proven message-digest mismatch or ESS cert-substitution defence cannot be silently downgraded to INDETERMINATE by a later layer's indeterminacy. The two halves of §I are thereby simultaneously honoured: pverify never fabricates a failure (the "cannot affirm" side, invariant 1), and it never hides a real one (the never-mask side, invariant 16).
The constitution closes with an explicit Out-of-Scope list; bringing any item in is, by the versioning policy, a MAJOR amendment:
- Signature generation. pverify is verification-only …
- JPKI (My Number Card) signature verification. Tracked for v2.0+ …
- Legal-validity adjudication. Excluded by Principle I.
- End-user GUI. CLI, library, and HTTP API only …
- Qualified Trust Service Provider (QTSP) accreditation. ETSI alignment is claimed; accreditation is not pursued.
Two of these are structural, not merely policy: verification-only means there is no signing/keygen surface in the production dependency graph (§2.6.3), and legal-validity adjudication being excluded "by Principle I" is the same boundary described in §2.2.3.
Governance binds the development process to the constitution. Every /speckit-plan "MUST run a Constitution Check against the principles above"; documented violations must be enumerated with explicit justification in the plan's Complexity Tracking section; and "Pull requests touching pverify-core, the Workers build, or trust-anchor handling MUST cite which principles were verified." The constitution sets the bounds; per-feature operational detail lives in each specs/<feature>/plan.md, and the (non-normative) historical rationale lives in docs/vision-statement.md. The current constitution version is 1.2.0, ratified 2026-06-10, last amended 2026-06-22.
The remaining chapters operate strictly within these bounds: Chapter 3 details the crate topology, data flow, CLI modes, and WASM host that realise §II/§III/§V/§VI/§VII; and Chapters 5–9 detail the RFC 5280 / RFC 5652 / CMS / AdES verification and path validation that §IV pins.
This chapter describes the structural organisation of pverify: the crate topology and its dependency discipline, the load-bearing boundary between the I/O-free verification kernel and its hosts, the two concrete execution models (native CLI and in-browser WebAssembly), the internal module layout of the verification kernel pverify-core, and the end-to-end path a VerificationRequest follows from raw input bytes to a closed-schema JSON fact report. Every claim below is grounded in the source tree; file and function references are given so a reviewer can audit the assertion directly.
Where this chapter touches on the meaning of a verdict or sub-indication it defers to the chapters that own that subject: ETSI indication aggregation (Chapter 5 §5.7 and the Result Model chapter), per-format verification semantics (Chapters on CAdES, PAdES, XAdES, JAdES, ASiC), RFC 5280 path validation (the path-validation chapter), revocation (the revocation chapter), and the report schema and versioning (the report-schema chapter). Cross-references use the term "see the … chapter" because chapter numbering beyond this file is assembled separately.
pverify verifies advanced electronic signatures (AdES) against the Japanese GPKI/JNSA profile and the European eIDAS/ETSI corpus, and emits a fact report rather than a business-level "valid / invalid" verdict. The single non-negotiable architectural boundary that organises the entire system is the separation between:
pverify-core — that is no_std + alloc, forbids unsafe, performs zero I/O, and must compile and run on wasm32-unknown-unknown; andpverify-core/src/lib.rs makes this concrete in its first lines:
#![no_std]
#![forbid(unsafe_code)]
#![deny(missing_debug_implementations)]
extern crate alloc;(crates/pverify-core/src/lib.rs:11-15).
The kernel performs all cryptography, RFC 5280 §6 path validation, revocation evaluation, RFC 3161/5816 timestamp verification, per-object Validation Reference Time (VRT) derivation, opt-in algorithm-policy evaluation, and ETSI EN 319 102-1 indication aggregation. It cannot, however, do three things for itself, because they require access to the outside world: read the verification clock, fetch or look up revocation material, and enumerate the trust-anchor set. These three concerns — and only these — are abstracted behind capability traits in crates/pverify-core/src/traits.rs: Clock, RevocationFetcher, and TrustAnchorStore. The kernel holds &dyn TrustAnchorStore and &dyn RevocationFetcher references and calls through them; it never opens a socket, reads a file, or calls SystemTime::now().
This boundary is not aspirational. It is enforced empirically by scripts/cargo-tree-gate.sh (described in §3.2.2) and by a cargo build --target wasm32-unknown-unknown -p pverify-core step in the gate battery. The constitutional principles behind it are §V (Library-First: no document parsing in the core), §VI (WASM-clean core, NON-NEGOTIABLE), and §I (Fact-Reporting). See the constitution at .specify/memory/constitution.md.
Three properties fall out of the boundary, each of which the government customer can independently verify:
Determinism / reproducibility (§II). Because the kernel reads no clock and no network, a given tuple of (input bytes, trust anchors, verification time, revocation material) produces a byte-identical report regardless of when or where it runs. The verification time is captured once by the host (the CLI reads SystemTime::now() once at startup unless --at is supplied) and threaded in through VerificationRequest.
Auditable cryptography surface (§VI). All cryptography is RustCrypto; there is no ring, aws-lc-rs, OpenSSL FFI, or default-rustls anywhere in the kernel or WASM graph. The cryptographic primitives used for verification are RSA PKCS#1 v1.5, RSA-PSS, ECDSA P-256/P-384, Ed25519, and ML-DSA, dispatched by OID and SubjectPublicKeyInfo in crate::crypto::verify_with_alg (crates/pverify-core/src/crypto.rs). The production crypto surface is verify-only — there is no signing or key-generation path in production dependencies.
Identical native and browser behaviour. Because the kernel is the single source of verification truth and is WASM-buildable, the native CLI and the in-browser demo run the same verify_with function over the same extracted structures, yielding byte-identical reports for identical inputs (the "CLI↔︎WASM parity" invariant, §3.5).
The whole system is governed by a "cannot affirm" rule from Constitution §I: where pverify cannot establish a fact, it degrades to INDETERMINATE with a precise closed-enum sub-indication rather than fabricating a TOTAL_FAILED or a TOTAL_PASSED. Concrete examples that the architecture surfaces honestly rather than guessing at:
IndeterminateRevocationOffline / revocation_not_checked_offline (never a fabricated responder contact — Constitution §VII offline honesty);INDETERMINATE / cms_multi_signer_unsupported (crates/pverify-core/src/verify.rs:496), never a verdict on a silently truncated signer set;revoked_no_poe (INDETERMINATE), not a forgery claim.The sub-indication value space is a single closed Rust enum (crates/pverify-core/src/report/etsi.rs, SubIndication) mirrored 1:1 into the root report-schema.json. The report shape version is SCHEMA_VERSION = "1.10.0" (crates/pverify-core/src/report/schema.rs:line).
The root workspace (Cargo.toml) declares eleven members:
| Crate | Role | Graph class |
|---|---|---|
pverify-core |
The verification kernel (no_std + alloc, I/O-free, RustCrypto-only). |
WASM-clean |
pverify-xades |
Host XAdES (XML-DSIG) extraction: parse + exclusive C14N + byte extraction, no cryptography. | WASM-clean (extract-only) |
pverify-asic |
Host ASiC (EN 319 162) container extraction: ZIP walk, no cryptography. | WASM-clean (extract-only) |
pverify-jades |
Host JAdES (JWS JSON Serialization) extraction, no cryptography. | WASM-clean (extract-only) |
pverify-eutl |
EU Trusted List (ETSI TS 119 612) ingestion → trust anchors. | Host-only (native std) |
pverify-aatl |
Adobe Approved Trust List ingestion (pulls lopdf + flate2). |
Host-only |
pverify-anchor-inventory |
Read-over aggregator of every anchor distribution channel. | Host-only |
pverify-cli |
The native command-line verifier — the only crate allowed native I/O. | Host-only |
fixture-gen |
Signing / fixture-generation tooling (publish = false). |
Tooling |
pverify-test-helpers |
Shared test helpers (publish = false). |
Tooling |
xtask |
Build/CI automation tasks. | Tooling |
A twelfth crate, web/pverify-wasm, is intentionally detached from the root workspace: its Cargo.toml carries an empty [workspace] table so that wasm-bindgen's generated unsafe code and the wasm dependency graph stay out of the root unsafe_code = "forbid" lint surface and out of the released cargo test --workspace / cargo audit surface. It is the browser host.
The crates split into two graph classes with a strict, gate-asserted dependency direction of host → WASM-clean → core.
WASM-clean graph (must compile to wasm32-unknown-unknown, RustCrypto only, no ring/getrandom/openssl):
pverify-core — the kernel. Its dependencies are RustCrypto format and primitive crates (der, x509-cert, rsa, p256, p384, ed25519-dalek, ml-dsa, sha1, sha2, spki, pkcs1, x509-ocsp) plus time, serde, serde_json, and hex. It depends on nothing inside the workspace.pverify-xades, pverify-asic, pverify-jades — host extraction crates that depend on pverify-core (for the boundary types the core owns, e.g. XadesComponents, AsicSignature, JadesComponents) but are themselves required to remain wasm32-buildable. They contain no cryptography — only parse, canonicalize, and byte extraction. They are forbidden from the pverify-core runtime graph by the tree gate.Host-only graph (native std, must NOT appear in the core/WASM runtime graph):
pverify-eutl, pverify-aatl, pverify-anchor-inventory — anchor ingestion. Each emits ordinary TrustAnchor values re-feedable through the existing --trust-anchors path; ingestion adds nothing to the kernel.pverify-cli — the native verifier. It is the only crate permitted native I/O: ureq for online fetch under cfg(not(wasm32)), lopdf for PDF parse, and std::fs.web/pverify-wasm — the browser host: depends on core + xades + asic + jades + lopdf (in-browser PDF parse) + wasm-bindgen.The dashed arrow CLI -. dev-dep .-> TESTH is a dev-dependency only; it is asserted absent from the production cargo tree -e normal graph.
scripts/cargo-tree-gate.sh is the load-bearing empirical guard for the boundary. It runs cargo tree -e normal --no-default-features -p pverify-core and fails the build if any host-only crate or its tell-tale transitive dependencies appear in the core's normal-dependency graph — specifically pverify-eutl, pverify-asic/zip/flate2/miniz_oxide, pverify-aatl/lopdf, pverify-jades, pverify-test-helpers, or xtask. This converts the architectural rule "the kernel never links a PDF/XML/ZIP parser" into a mechanically checked invariant rather than a code-review convention. The complementary cargo build --target wasm32-unknown-unknown -p pverify-core step proves the kernel actually builds for the browser target.
The kernel cannot parse PDF, XML, ZIP, or JSON containers — those parsers pull std and large dependency graphs incompatible with a no_std WASM kernel. Instead, the host pre-parses each container format and threads the extracted, model-free byte structures into additive fields of VerificationRequest (crates/pverify-core/src/verify.rs:69). The kernel only consults the field matching the sniffed format. The relevant request fields and their producers:
VerificationRequest field |
Type | Host producer | Consumed when format is |
|---|---|---|---|
signature_bytes |
Vec<u8> |
raw input file | always (sniffed) |
detached_content_bytes |
Option<Vec<u8>> |
--content file |
CAdES / XAdES (detached) |
extra_certificates |
Vec<Vec<u8>> |
bundle certificates/ |
chain construction |
pdf_signature_dicts |
Vec<PdfSignatureDescriptor> |
pverify-cli/src/pdf.rs / WASM lib.rs |
PAdES |
pdf_validation_data |
PdfValidationData |
pverify-cli/src/pdf.rs / WASM lib.rs |
PAdES |
xades_components |
Vec<XadesComponents> |
pverify-xades |
XAdES |
jades_components |
Vec<JadesComponents> |
pverify-jades |
JAdES |
asic_signatures |
Vec<AsicSignature> |
pverify-asic |
ASiC |
required_policies |
Vec<PolicyOid> |
CLI --required-policy |
all formats |
algorithm_policy |
Option<AlgorithmPolicy> |
CLI --algorithm-policy |
all formats (opt-in) |
The additivity is a deliberate reproducibility contract (Constitution §II): a non-PDF input carries an empty pdf_validation_data (its Default), an out-of-scope JSON carries an empty jades_components, and so on. Every pre-existing caller fills the newer fields with Default::default() / Vec::new(), so adding a channel leaves prior reports byte-identical except the schema_version string (FR-008 byte-identity). For example, a PDF with no /DSS differs from a pre-034 report only in schema_version.
This is the structural expression of Constitution §V "Library-First": the container parse lives entirely in host crates (pverify-cli/src/pdf.rs, pverify-xades, pverify-asic, pverify-jades, pverify-aatl), and the kernel never links lopdf, zip, flate2, or the bergshamra-c14n canonicaliser.
crates/pverify-core/src/traits.rs defines the three host capabilities and a pair of minimal value types:
Clock — fn now(&self) -> OffsetDateTime. The kernel does not in practice call this on the hot path (the verification time is captured into VerificationRequest.verification_time by the host); the trait exists so a host can supply time deterministically. Tests use a fixed-time fake.RevocationFetcher — fn fetch_crl(&self, url: &str) and fn fetch_ocsp(&self, uri: &str, request_bytes: &[u8]). Implementations honour the caller's mode: online (ureq GET/POST), offline (always Err(Error::NotAvailableOffline)), or from-bundle (read from <bundle>/crls/ or <bundle>/ocsp/). fetch_ocsp carries a default impl returning NotAvailableOffline so pre-OCSP implementors degrade honestly. Crucially, the kernel builds the DER OCSPRequest itself (crate::revocation::ocsp::build_ocsp_request_bytes) and the trait returns raw BasicOCSPResponse bytes — parsing stays in the kernel; the trait is stable across x509-ocsp versions.TrustAnchorStore — fn anchors(&self) -> &[TrustAnchor]. Returns the anchors in host-supplied order; the store is "intentionally inert" and does not dedup, sort, or filter. The host computes each TrustAnchor's SHA-256 fingerprint, raw subject_dn bytes, and the validity_window_covers_request_time flag at load time (FR-025).The crate also ships skeleton implementations OfflineRevocationFetcher (the canonical "always denies" fetcher) and InMemoryTrustAnchorStore, which the CLI and WASM hosts wrap or populate.
The native verifier is pverify-cli. Its command surface (crates/pverify-cli/src/main.rs, enum Command) covers four sub-commands:
verify — verify a CAdES / PAdES / XAdES / JAdES / ASiC signature;ingest-trust-list — ingest an EU Trusted List (ETSI TS 119 612) into re-feedable PEM anchors (verification-only, never adjudicates "qualified");ingest-aatl — ingest the Adobe Approved Trust List against a pinned Adobe Root CA G2;trust-anchors — read-only inspection of every anchor distribution channel.The verify path (Command::Verify, main.rs:346) operates as follows:
Clock (src/clock.rs) or from --at. --at always wins (§II reproducibility).--trust-anchors <DIR> with online fetch, --offline, --from-bundle <DIR>, or LDAP-CRL — implemented in src/modes/{online,offline,from_bundle,ldap}.rs. The CLI's Mode enum {Online, Offline, FromBundle} maps to the kernel's revocation VerificationMode; --offline opens no socket (offline honesty).src/trust.rs computes each anchor's SHA-256 fingerprint, raw subject-DN bytes, and validity_window_covers_request_time flag — the host's FR-025 responsibility — and builds an InMemoryTrustAnchorStore.src/pdf.rs::extract_signatures runs lopdf to emit PdfSignatureDescriptors (/ByteRange, /Contents, /SubFilter, revision_end from a %%EOF scan, and /Sig vs /DocTimeStamp tag), and extract_validation_data parses the document-level /DSS pools and /VRI (§3.8). XAdES/ASiC/JAdES inputs are parsed by their extraction crates.VerificationRequest and call verify_with(&request, &store, &fetcher).src/render.rs serialises the returned Report to JSON.The CLI is the canonical verifier. Its bounded-I/O guards (bounded_io.rs, net_guard.rs) and host-side responsibilities (anchor loading, PDF parse) are exactly the host concerns the kernel deliberately excludes.
The browser host is web/pverify-wasm (web/pverify-wasm/src/lib.rs). It exposes two #[wasm_bindgen] entry points:
classify(bytes) — runs the real pverify-core parsers in priority order (PDF → ASiC ZIP → JAdES JSON → X.509 certificate → CRL → CMS SignedData → otherwise "content") so the drag-and-drop UI can auto-route a dropped file by what it is, not by its extension (classify_bytes, lib.rs:158).verify(req) — runs the full pverify_core::verify::verify_with pipeline entirely client-side and returns the pretty-printed JSON report (lib.rs:288).verify deserialises a VerifyInput (signature bytes, optional detached content, trust-anchor DERs, extra certificates, crls/ocsp keyed by source_uri, verification_time_unix, and an optional algorithm_policy selector). It then performs byte-identical host work to the native CLI:
extract_pdf_signatures (a port of pverify-cli/src/pdf.rs::extract_signatures),extract_pdf_validation_data (a port of pverify-cli/src/pdf.rs::extract_validation_data — same /DSS+/VRI union, dedup, MAX_DSS_ENTRIES_PER_POOL/MAX_DSS_STREAM_BYTES bounds, and uppercase-hex SHA-1 VRI keying, lib.rs:519),pverify_xades::extract, pverify_asic::extract, pverify_jades::extract,constructs the same VerificationRequest, and calls the same verify_with. Anchors are built by anchor_from_der, which "computes the fingerprint and validity window the same way pverify-cli/src/trust.rs does" (lib.rs:115). The browser-side DemoFetcher is an in-memory source_uri → bytes lookup mirroring the CLI's bundle fetcher; a miss returns NotAvailableOffline, which the pipeline maps to IndeterminateNoCrl / IndeterminateOcspUnreachable.
CLI↔︎WASM parity is therefore an enforced invariant, not a coincidence: both hosts extract identical structures and run the identical kernel, so they produce byte-identical reports for identical inputs. Parity is pinned by tests over the extractors (e.g. crates/pverify-cli/tests/extractor_parity.rs, and the WASM-side parity tests at web/pverify-wasm/src/lib.rs:729 ff.).
The page layout (web/site/index.html) puts the result section first, directly under the header, with the drop/verify tool below it — so a completed verdict appears at the top of the viewport rather than below the tall format/algorithm reference section, where it could land off-screen and be missed. renderReport (app.js) un-hides #result and calls scrollIntoView so that even a re-verification triggered after the reader has scrolled down (e.g. changing the verification time) brings the fresh verdict back into view. Verification is automatic on drop; the "再検証する" button and the status line beneath it are the manual re-run path.
A third, diagnostic, harness web/verify-cli.mjs drives the same built site/pkg/ WASM module from Node, so a fixture can be verified through the WASM pipeline without a browser. It is explicitly not the product (it has no CRL proxy, so CDP-only chains report INDETERMINATE where the browser, which fetches CRLs through a proxy, reaches TOTAL_PASSED); it exists purely for parity diagnostics, and its header says so (web/verify-cli.mjs:9-13). Its process exit code mirrors the ETSI indication of signatures[0] (0=TOTAL_PASSED, 1=TOTAL_FAILED, 2=INDETERMINATE/no signature).
pverify-corecrates/pverify-core/src/lib.rs declares the kernel's public modules. Their responsibilities:
| Module | Path | Responsibility |
|---|---|---|
verify |
verify.rs |
The orchestrator: verify_with, format sniff/dispatch, per-format verify functions, ETSI aggregation, header derivation. |
cms |
cms/ |
RFC 5652 CMS SignedData (lenient hand-rolled walker signed_data.rs), signed/unsigned attribute projection, ESS signing-certificate binding (ess.rs), canonicalisation (canonical.rs), signature verification (verify.rs). |
pades |
pades/mod.rs |
PAdES orchestration over embedded CMS + /DocTimeStamp; /DSS+/VRI material resolution; Adobe revocationInfoArchival projection. |
xades |
xades.rs |
XAdES verification over host-extracted XadesComponents. |
jades |
jades.rs |
JAdES (JWS JSON Serialization) verification over JadesComponents. |
asic |
asic.rs |
ASiC container orchestration: delegate each inner signature to the CAdES or XAdES pipeline. |
path |
path/ |
RFC 5280 §6 path validation: DFS chain build with backtrack and DER-fingerprint cycle detection (mod.rs), Name-Constraints state machine (name_constraints.rs), policy processing (policy.rs), Bridge-CA cross-certificate traversal (bridge.rs). |
revocation |
revocation/ |
CRL (RFC 5280, crl.rs), indirect-CRL detection (indirect.rs), OCSP (RFC 6960, ocsp.rs), and the revocation context/dispatcher (mod.rs). |
timestamp |
timestamp.rs |
RFC 3161/5816 time-stamp-token verification (TST CMS, imprint, TSA chain, ESS cert binding). |
vrt |
vrt/ |
Per-object Validation Reference Time engine: recursive-outer-covering graph (coverage.rs), promotion rule (promotion.rs), derive_vrt (mod.rs). |
x509 |
x509/ |
Certificate parsing (mod.rs), Name handling (name.rs), extension parsing (extensions.rs). |
crypto |
crypto.rs |
RustCrypto primitive dispatch (verify_with_alg), digest computation (compute_digest, HashAlg). |
algorithms |
algorithms.rs |
Weak-algorithm flagging (flag_if_weak) and header aggregation (aggregate_for_header). |
algorithm_policy |
algorithm_policy/ |
Opt-in CRYPTREC/NIST algorithm-validity policy (policy.rs), policy extraction (extract.rs), and mod.rs. |
ber |
ber/ |
Minimal BER/DER helpers shared across the kernel. |
report |
report/ |
Closed-schema report types (mod.rs), ETSI indications/sub-indications (etsi.rs), schema constants (schema.rs), VRT wire types (vrt.rs), algorithm-validity wire types (algorithm_validity.rs), validation-object inventory (validation_objects.rs), time formatting (time_fmt.rs). |
traits |
traits.rs |
The Clock / RevocationFetcher / TrustAnchorStore capability traits and value types. |
error |
error.rs |
The kernel error type. |
Note the deliberate division within report: schema.rs pins the SCHEMA_VERSION ("1.10.0") and the disclosure constants surfaced on every report header — PATH_VALIDATION_PHASE = "v0.6-bridge-acceptance", BRIDGE_CA_SUPPORTED = true, DN_MATCH_METHOD = "rfc4518-minimal-v0.1" (crates/pverify-core/src/report/schema.rs, crates/pverify-core/src/report/mod.rs:95-107).
VerificationRequest to Report: end-to-end flowThe single orchestrator is verify_with (crates/pverify-core/src/verify.rs:178). It takes (&VerificationRequest, &dyn TrustAnchorStore, &dyn RevocationFetcher) and returns a Report. The flow has six stages.
Covered in §3.4/§3.5: the host parses the container, captures the time, loads anchors, and fills a VerificationRequest. Every format channel is an additive Vec / Default.
verify_with first initialises a header via Report::new_header. It then dispatches in this precedence order (verify.rs:189-327):
asic_signatures is non-empty, the input is an ASiC container regardless of the raw-byte sniff (the container bytes start with the ZIP magic PK\x03\x04, which would otherwise fall through to CAdES). One SignatureEntry is emitted per inner signature via crate::asic::verify_asic_signature.signature_bytes starts with PK\x03\x04 but the host produced no ASiC signatures, the input is a ZIP that is not a recognisable ASiC container; the kernel emits asic_unsupported_container (INDETERMINATE) rather than letting it misclassify as CAdES (verify.rs:227).detect_format (verify.rs:359) inspects the leading bytes: %PDF- → PAdES; a UTF-8-BOM-tolerant <?xml or bare <+NameStartChar → XAdES; a BOM-tolerant leading { → JAdES; otherwise CAdES. JSON and XML are mutually exclusive on the first non-whitespace byte ({ vs <), so the sniffs cannot collide.Each arm emits one SignatureEntry per signature. PAdES routes to crate::pades::verify_pdf; XAdES maps each xades_components entry through crate::xades::verify_xades (or xades_unsupported if the host extracted nothing); JAdES maps each jades_components entry through crate::jades::verify_jades (or the JAdES refusal entry); CAdES routes to verify_cades.
verify_cades (verify.rs:475) is the reference pipeline; the other formats reuse its phases through shared helpers. In order:
SignedData with the lenient walker (SignedDataParser::parse).signer_info_count > 1, return INDETERMINATE / cms_multi_signer_unsupported (verify.rs:496) rather than reporting only the first SignerInfo — a fact-reporting requirement (§I), since the pre-029 parser used to silently truncate the signer set.extra_certificates (consumed for chain construction but never selected as the signer).SignerInfo.sid (issuer+serial or SKI) via select_signer_by_sid (verify.rs:1096); a malformed/not-found sid falls back to a heuristic index only as a report scaffold — the verdict is gated on the SignerIdentifierCheck outcome before any signature/digest gate.path/mod.rs), then verify each chain step's certificate signature with RustCrypto via wire_chain_step_signatures (verify.rs:1944).verify.rs:1034-1042): archive timestamps present → CAdESLta; any LT payload → CAdESLt; signature time-stamps present → CAdEST; otherwise CAdESBes.apply_revocations).crate::vrt::derive_vrt (verify.rs:757) builds the recursive-outer-covering graph from the timestamp tokens and returns a Vrt block assigning each object — the signer chain, the signer signature, each signature_timestamp[i], and each archive_timestamp[i] — its own Validation Reference Time. The signer chain and each TSA chain are then re-validated at their own VRT, and TSA chains are revocation-checked at each object's VRT via apply_tsa_chain_revocation_for_token (verify.rs:1552). The covering graph reads only request_at as its time source (never Clock::now) and is never re-derived or mutated by downstream revocation/algorithm layers (FR-005a). See the VRT chapter.
aggregate_etsi (verify.rs:2343) folds the structural / signature / digest / revocation findings into a base EtsiIndication with severity ordering TOTAL_FAILED > INDETERMINATE > TOTAL_PASSED and a first-finding-wins rule within a severity. Layered passes then run, each able only to degrade a non-TOTAL_FAILED base and never to mask a proven TOTAL_FAILED: signer-binding, content-type, opt-in algorithm-validity, and TSA-revocation. The precedence ladder is explicit in the source — e.g. archive_timestamp_imprint_mismatch ranks immediately after chain_constraints_failure (verify.rs:2357-2391). See the ETSI-aggregation chapter.
After populating report.signatures, verify_with aggregates per-step weak-algorithm flags into the header (aggregate_for_header) and then derives the consolidated material inventory: report.validation_objects = crate::report::derive_validation_objects(&report) (verify.rs:341). derive_validation_objects (crates/pverify-core/src/report/validation_objects.rs:191) is a pure &Report read — no network, no new cryptography (every digest is already a field). It enumerates the certificates, CRLs, OCSP responses, and content objects actually consulted and tags each with a ValidationObjectOrigin provenance value. The host then serialises the Report to JSON (CLI src/render.rs; WASM serde_json::to_string_pretty). Because both hosts run the identical kernel, the outputs are byte-identical for identical inputs.
/DSS+/VRI specialisation (branch 034)PAdES is the one format whose Stage 3 differs materially because of document-level validation material, so it is worth describing here as the clearest illustration of how an additive feature threads through the architecture without touching verdict logic.
Before branch 034, pades::verify_pdf fed the revocation pipeline empty embedded slices, so a fully-evidenced offline PAdES-LT/LTA stalled at INDETERMINATE / revocation_not_checked_offline. Branch 034 added a host-side extraction of the catalog-level /DSS dictionary (/Certs, /CRLs, /OCSPs), unioned across all incremental-update revisions and deduped by DER, plus the /VRI sub-dictionary keyed by the uppercase-hex SHA-1 of each signature's /Contents. This is delivered to the kernel as a new additive VerificationRequest.pdf_validation_data of type PdfValidationData (crates/pverify-core/src/pades/mod.rs:80), populated identically by the native CLI (pverify-cli/src/pdf.rs::extract_validation_data) and the browser host (web/pverify-wasm/src/lib.rs::extract_pdf_validation_data).
Inside pades::verify_pdf the kernel resolves, per signature object, the preferred validation material via resolve_material (crates/pverify-core/src/pades/mod.rs:148): a /VRI entry keyed by the object's /Contents SHA-1 is preferred (provenance PdfVri); otherwise the document-level /DSS pools are the fallback (provenance PdfDss). The resolved CRL/OCSP DER is fed into the existing apply_revocations (signer chain) and apply_tsa_chain_revocation_for_token (each timestamp) calls that previously received &[],&[], at each object's own VRT. Adobe revocationInfoArchival (OID 1.2.840.113583.1.1.8) is projected from the CMS and merged as signature_embedded material.
Several architectural properties are preserved by design:
RevocationOutcome vocabulary — a revoked signer is the existing TOTAL_FAILED, a revoked TSA is 033's existing revoked_no_poe (INDETERMINATE). No new outcome and no new sub-indication were added./Certs are never anchors. DSS /Certs are intermediate/leaf path-building candidates only, never promoted to trust anchors (PdfValidationData.dss_certs doc-comment, pades/mod.rs:81-83)./VRI dictionary key, never a security primitive; because the material is additive evidence (CRLs matched by issuer+serial, OCSP by certID), a collision could only mis-route additive evidence and can never fabricate a "good" outcome (sha1_uppercase_hex doc-comment, pades/mod.rs:183-188).ValidationObjectOrigin variants, pdf_dss and pdf_vri, appended after supplied_input (crates/pverify-core/src/report/validation_objects.rs:113-118), with the 016 derivation mapping the per-CRL / per-OCSP source tags (PdfRevocationSource::{PdfDss,PdfVri}) onto them (validation_objects.rs:308-336). Appending closed-enum variants is an additive MINOR schema bump, which is what took the schema from 1.5.0 to 1.7.0.The dispatch and per-format verifiers are honest about scope. The table below states what the tool verifies versus what it flags as unsupported; the per-format chapters carry the details.
| Family | Standard | Levels verified | Explicitly unsupported (→ INDETERMINATE) |
|---|---|---|---|
| CAdES | ETSI EN 319 122 | B-B/BES, B-T (signature-time-stamp), B-LT, B-LTA (archive-time-stamp-v3 with §6.3.4 imprint recomputation) | multi-signer CMS (cms_multi_signer_unsupported) |
| PAdES | ETSI EN 319 142 | B-B/B-T/B-LT/B-LTA over embedded CMS + /DocTimeStamp; consumes /DSS, /VRI, Adobe revocationInfoArchival (branch 034) |
— |
| XAdES | ETSI EN 319 132 | enveloped B-B with B-T/B-LT timestamps and B-LTA xades:ArchiveTimeStamp imprint verification (ETSI TS 101 903 v1.4.2 Annex A.1.5; SignatureFormat::XadesBLta, branch 036); Exclusive C14N via bergshamra-c14n |
detached/enveloping XAdES, non-exclusive C14N (xades_unsupported_canonicalization) |
| JAdES | ETSI TS 119 182 | JWS-JSON-serialization B-B | B-T+ and sigD/Compact serialization (refused INDETERMINATE) |
| ASiC | ETSI EN 319 162 | ASiC-S / ASiC-E, delegating each inner signature to CAdES or XAdES | non-ASiC ZIP (asic_unsupported_container) |
The format-promotion ladder for CAdES is in verify.rs:1034-1042; the JAdES and XAdES refusal entries are emitted by crate::jades::jades_unsupported_entry and xades_unsupported_entry respectively. Where a level or variant is out of scope, the architecture degrades to INDETERMINATE with a precise sub-indication (§3.1.2), never a fabricated failure.
The following invariants are properties of the architecture itself rather than of any one module, and each is mechanically or test-enforced:
pverify-core is no_std + alloc, forbids unsafe, builds for wasm32-unknown-unknown, and is RustCrypto-only — enforced by the tree gate plus a wasm build step.lopdf/zip/flate2/bergshamra — gate-asserted.traits.rs. derive_vrt and derive_validation_objects are pure compute.SubIndication, RevocationOutcome, and ValidationObjectOrigin are closed enums mirrored 1:1 in report-schema.json; adding a value or optional field is MINOR, removing or repurposing is MAJOR. Deprecated variants (e.g. bridge_ca_required_unsupported) are retained for stored-report deserialization but not emitted.schema_version when its inputs are absent or it is off (e.g. a no-DSS PDF, algorithm-policy off, empty embedded-revocation channels).--offline opens no socket and never fabricates a responder contact; the honest IndeterminateRevocationOffline / revocation_not_checked_offline outcome replaced the prior dishonest ocsp_responder_unreachable emission.verify_with; parity tests pin the extractor output shapes.aggregate_etsi sets a severity-ordered base; later layers only degrade a non-TOTAL_FAILED base.request_at; the covering graph is never re-derived or mutated downstream.pverify-eutl/aatl/ anchor-inventory emit ordinary TrustAnchor values re-feedable through --trust-anchors; ingestion performs no network I/O at parse time.--at is supplied, and --at always wins.These invariants, taken together, are what make the report an auditable fact: the same inputs always produce the same report, the cryptographic and parsing surfaces are bounded and inspectable, and the tool never claims more than it can prove.
This chapter documents how pverify turns an opaque byte slice into a routed verification. It covers the two-layer classification pipeline (a host-side sniff that decides which structural parser to run, and a kernel-side sniff that decides which verifier arm to enter), the precedence rules between the two, the handling of detached versus attached content, ASiC-S/ASiC-E container detection, and the dispatch into each per-format verifier. Every behavioural claim below is grounded in the source; the load-bearing classifier is pverify_core::verify::detect_format (crates/pverify-core/src/verify.rs:359), with its host-side mirror split across crates/pverify-cli/src/main.rs and web/pverify-wasm/src/lib.rs.
The design principle throughout is honest, deterministic dispatch: every input is assigned to exactly one format, the assignment is made from a small prefix of the bytes (so it is cheap and reproducible), and an input that matches a family but cannot be verified at the tool's supported level degrades to INDETERMINATE with a precise closed-enum sub-indication rather than being silently misrouted to another arm or fabricated into a pass/fail (Constitution §I, "cannot affirm"; see Chapter 2 for the constitutional framing and Chapter 3 for the architecture boundary this chapter operates within).
pverify cannot parse PDF, XML, ZIP or JSON inside the verification kernel: the kernel is #![no_std] and is forbidden from linking container/document parsers (Constitution §V/§VI; see Chapter 3 §3.x for the crate topology and the scripts/cargo-tree-gate.sh guard that enforces it). Classification therefore happens twice, by design, and the two layers must agree:
Host-side sniff (structural-parse selection). The native CLI and the browser WASM host inspect the leading bytes of the input to decide which structural extractor to run — lopdf for PDF, pverify-xades for XML, pverify-asic for ZIP, pverify-jades for JSON — and to enforce the CLI content-source rules (§4.4). The host fills the format-specific channels of the VerificationRequest (pdf_signature_dicts, pdf_validation_data, xades_components, jades_components, asic_signatures) with the extracted model-free byte structures.
Kernel-side sniff (verifier-arm dispatch). verify_with (crates/pverify-core/src/verify.rs:178) re-classifies the same request.signature_bytes and routes to exactly one verifier arm. The kernel only ever consults the request channel that matches its own sniff result, so a non-PDF input carrying an (always-empty) pdf_validation_data is indistinguishable from one that never had the field.
Both layers read only the leading bytes — no whole-file scan is required to classify — and they use byte-identical predicates. The CLI's looks_like_xml (crates/pverify-cli/src/main.rs:399) and looks_like_json (main.rs:420) are line-for-line equivalents of the kernel's looks_like_xml/looks_like_json (verify.rs:392/375), and the WASM host's copies (web/pverify-wasm/src/lib.rs:419/439) carry explicit "mirrors pverify_core::verify" comments. This deliberate triplication is the CLI↔︎WASM parity invariant (Chapter 3): the native and browser hosts must extract identical structures and the kernel must reach the identical arm, so a given input produces a byte-identical report on either host.
The asymmetry between the host order (ZIP → PDF → XML → JSON → CAdES) and the kernel order (ASiC channel → ZIP → detect_format) is intentional and is explained in §4.5: the kernel must dispatch ASiC before detect_format because an ASiC container's bytes start with the ZIP magic, which the detect_format sniff does not recognise and would otherwise let fall through to the CAdES arm.
detect_formatThe kernel's classifier is a four-way decision over the leading bytes (crates/pverify-core/src/verify.rs:359):
fn detect_format(bytes: &[u8]) -> DetectedFormat {
if bytes.starts_with(b"%PDF-") {
return DetectedFormat::Pdf;
}
if looks_like_xml(bytes) {
return DetectedFormat::Xades;
}
if looks_like_json(bytes) {
return DetectedFormat::Jades;
}
DetectedFormat::Cades
}DetectedFormat is a closed enum {Pdf, Xades, Jades, Cades} (verify.rs:347). The ordering is significant and is documented in the source: PDF is tested first because %PDF- is an unambiguous binary marker; XML and JSON are mutually exclusive on the first non-whitespace byte (< versus {), so their relative order cannot cause a collision; and CAdES is the residual default — anything that is not recognised as PDF/XML/JSON is treated as a DER-encoded CMS SignedData and handed to verify_cades. Note that ZIP is not a branch of detect_format: a ZIP input is intercepted earlier in verify_with (§4.5) and never reaches this function. The sniff makes no attempt to validate the rest of the structure — a malformed CMS is still routed to CAdES, where it becomes a cades_parse_failed entry rather than a misclassification.
| Format | Predicate | Accepts | Evidence |
|---|---|---|---|
bytes.starts_with(b"%PDF-") |
The literal PDF header marker | verify.rs:360 |
|
| XAdES | looks_like_xml |
optional UTF-8 BOM, then <?xml or bare < followed by an XML NameStartChar (is_ascii_alphabetic() | _ | :) |
verify.rs:392 |
| JAdES | looks_like_json |
optional UTF-8 BOM, then whitespace (SP/TAB/CR/LF), then { |
verify.rs:375 |
| CAdES | residual default (no predicate) | everything else, incl. DER CMS | verify.rs:369 |
Two precision points worth recording for an auditor:
looks_like_json returns true only for a leading {, never [. This is correct for JAdES: JWS JSON Serialization (RFC 7515 §7.2) is always a JSON object, never a bare array, so a leading [ would be a non-JAdES JSON document and is deliberately routed to the CAdES residual rather than to the JAdES arm. The behaviour is asserted in the WASM tests (web/pverify-wasm/src/lib.rs:781, !looks_like_json(b"[]")).looks_like_xml accepts a bare < followed by an ASCII letter, _, or :. The source comment notes this "ASCII subset is sufficient for all GPKI-shaped fixtures" (verify.rs:402): a ds:Signature or xades:…-rooted document, or any XML with an <?xml prolog, classifies correctly; a hypothetical XML whose root element name begins with a non-ASCII Unicode NameStartChar and which omits the prolog and BOM would not be sniffed as XML. No such input arises in the GPKI/eIDAS corpus, and the failure mode is benign (it would fall through to CAdES and produce a cades_parse_failed fact, not a false pass).The UTF-8 BOM (EF BB BF) is stripped before both the XML and JSON tests (verify.rs:376, verify.rs:394), so a BOM-prefixed XAdES or JAdES document is classified on its true first content byte.
verify_withAfter the ASiC interception (§4.5), verify_with matches on the detect_format result and builds a Vec<SignatureEntry> — one entry per signature in the document (crates/pverify-core/src/verify.rs:240). The contract that every arm honours is that the consumer always receives a definite fact: even an empty host extraction yields exactly one explanatory entry, never an empty array.
DetectedFormat::Pdf)PDF inputs route to crate::pades::verify_pdf (verify.rs:241), threaded with the host-extracted pdf_signature_dicts and pdf_validation_data, the trust store, the fetcher, the verification time, the real VerificationMode (so --offline is honoured rather than fabricating an OCSP contact — the 015 fix to a §I gap, verify.rs:255), the canonical required_policies set (verify.rs:259, so --required-policy is enforced for PAdES), and the opt-in algorithm policy (verify.rs:261).
verify_pdf (crates/pverify-core/src/pades/mod.rs:242) iterates the descriptors and produces one SignatureEntry per /Sig dictionary, attaching each covering /DocTimeStamp token to the most-recent preceding signature (mod.rs:309). It supports PAdES B-B, B-T (signature-time-stamp), B-LT and B-LTA over the embedded CMS plus /DocTimeStamp (the PAdES archive-timestamp mechanism), and as of branch 034 consumes the document-level /DSS (/Certs//CRLs//OCSPs), /VRI, and Adobe revocationInfoArchival validation material (see Chapter on PAdES verification for the resolution detail). If the host extracted no signature dictionaries, verify_pdf returns a single explanatory entry rather than an empty vector: no_signature_entry (mod.rs:475) emits INDETERMINATE with sub-indication PdfByteRangeMalformed and the context string "pdf-no-signature-dict" (mod.rs:496).
PAdES is structurally always attached: the /ByteRange references bytes inside the PDF body, so every PAdES SignatureEntry carries content_source = Embedded invariantly (mod.rs:483). This is why the CLI forbids combining a PDF input with --detached-content (§4.4).
DetectedFormat::Xades)XML inputs route to a per-component loop (verify.rs:263). If request.xades_components is empty — the input sniffed as XML but the host extracted no ds:Signature, or the host hit an extraction error it could not categorise — the arm falls back to xades_unsupported_entry (verify.rs:272, defined at verify.rs:413), which is INDETERMINATE with sub-indication XadesUnsupported. Otherwise it calls crate::xades::verify_xades once per extracted component. pverify verifies enveloped XAdES B-B with B-T/B-LT timestamps and B-LTA ArchiveTimeStamp imprint (branch 036, SignatureFormat::XadesBLta; ETSI TS 101 903 v1.4.2 Annex A.1.5) using Exclusive XML Canonicalization via the audited bergshamra-c14n crate; detached/enveloping XAdES and non-exclusive C14N degrade to INDETERMINATE (see the XAdES chapter). The fall-back-to-refusal design means an XML document that is not a verifiable XAdES signature still produces a definite, machine-readable fact (FR-009), never a silent empty result.
DetectedFormat::Jades)JSON inputs route to the analogous per-component loop (verify.rs:296). An empty request.jades_components falls back to jades_unsupported_entry (verify.rs:304); otherwise each component is verified by crate::jades::verify_jades. pverify verifies JWS JSON Serialization B-B only. The JAdES extractor classifies and refuses the out-of-scope shapes explicitly rather than mis-verifying them (crates/pverify-jades/src/parse.rs:212): a sigD detached-payload reference maps to JadesUnsupportedReason::DetachedSigD (parse.rs:232), a higher baseline level (B-T+) maps to HigherLevel (parse.rs:250), and a plain JWS that carries no JAdES baseline properties maps to NotJadesBaseline (parse.rs:261). Compact serialization is likewise out of scope. Each refusal is an honest INDETERMINATE fact, consistent with the §I "cannot affirm" discipline. RFC 7797 b64:false unencoded payloads are handled by the extractor, but only when b64 also appears in crit (parse.rs:116); a b64:false without the required crit declaration is treated honestly rather than silently accepted.
DetectedFormat::Cades, the residual default)Everything not sniffed as PDF/XML/JSON (and not intercepted as ZIP) is handed to verify_cades (verify.rs:326, defined at verify.rs:475). This is the single-entry arm: it produces exactly one SignatureEntry. verify_cades parses the input as a CMS SignedData (RFC 5652) via SignedDataParser; a parse failure becomes a cades_parse_failed_entry (verify.rs:483) — note that a misclassified non-CMS input (e.g. arbitrary binary that is not PDF/XML/JSON) fails here cleanly rather than producing spurious output.
A critical honesty rule lives in this arm: multi-signer CMS is deliberately refused. If parsed.signer_info_count > 1, verify_cades returns cades_multi_signer_entry (verify.rs:496-497), an INDETERMINATE with sub-indication cms_multi_signer_unsupported. The source records the rationale: the pre-029 parser silently kept only the first SignerInfo, reporting a CMS with N>1 signers as though only one existed — a fact-reporting gap that hid signer-set evidence relying parties need for countersignature workflows and audit (verify.rs:487-495). Until signatures[] grows a one-entry-per- SignerInfo model, the honest answer is to refuse rather than verify a truncated signer set. CAdES is supported across BES/B-B, B-T (signature-time- stamp), B-LT and B-LTA (archive-time-stamp-v3 with ETSI EN 319 122-1 §6.3.4 imprint recomputation); see the CAdES chapter.
CAdES is the only family that can be detached — i.e. where the signed payload (eContent) lives outside the CMS and must be supplied alongside the signature. PAdES, XAdES (enveloped), JAdES and ASiC all carry or reference their payload structurally, so the detached-content question only arises for CAdES, and it is resolved entirely at the host layer (it requires file I/O and CLI-flag introspection, which the kernel cannot perform). The logic implements the four-cell FR-027/FR-028 content-source matrix in crates/pverify-cli/src/main.rs:554-606.
The CLI first determines the family with cheap prefix checks: is_pdf (main.rs:535), is_asic (pverify_asic::is_zip, main.rs:540), looks_xml (main.rs:541), looks_json (main.rs:553). For any of these four, the content-source matrix is bypassed entirely (main.rs:554), with content_source_override = None — PAdES is structurally attached, XAdES is refused before content-source resolution, ASiC has no eContent and takes no --detached-content, and JAdES embeds its payload in the JWS structure. Only when the input is none of those four — i.e. a presumed CAdES — does the matrix run.
A hard pre-check guards the obviously-wrong combination: supplying --detached-content together with a PDF signature is an invocation error (exit 2) before any matrix logic (main.rs:507-512), because PAdES is never detached.
For a CAdES input, the CLI peeks at the CMS structure with SignedDataParser, extracting a non-empty eContent if present (main.rs:565-568), and then applies the matrix over (eContent present?, --detached-content supplied?):
eContent |
--detached-content |
Behaviour | Evidence |
|---|---|---|---|
| present | supplied, byte-equal | Accept; content_source = Embedded, content_source_redundant = Some(true) (the redundancy is flagged in the report) |
main.rs:570-577 |
| present | supplied, differs | Invocation error (exit 2): "--detached-content does not match the signature's embedded eContent (FR-027 conflict)" |
main.rs:578-583 |
| present | absent | Attached CAdES; content_source = Embedded (pinned so the redundancy branch cannot misfire) |
main.rs:585-591 |
| absent | supplied | Detached CAdES (the v0.1 path); content_source = Detached |
main.rs:592-596 |
| absent | absent | Invocation error (exit 2): "detached CAdES signature requires --detached-content (FR-028)" |
main.rs:597-604 |
The last cell is the central honesty rule of this section: pverify never silently verifies an empty payload. A detached CAdES signature with no content supplied is rejected at the CLI rather than being verified against zero bytes (main.rs:599). The conflict cell (present-but-differs) likewise refuses rather than guessing which of two contradictory payloads the caller meant.
The resolved tag is threaded into the request as content_source / content_source_redundant (Chapter 3 §request assembly). When the override is None (the PDF/XML/JSON/ASiC cases, or any CAdES the CLI could not peek), the verify pipeline applies its own structural default: CAdES prefers embedded eContent over detached_content_bytes, and refusal/PAdES entries are always Embedded (crates/pverify-core/src/verify.rs:121-131).
ASiC (EN 319 162) packages CAdES or XAdES signatures inside a ZIP container, so an ASiC input begins with the ZIP local-file-header magic PK\x03\x04. Because that magic is not a branch of detect_format, a ZIP would otherwise fall through to the CAdES residual default and be misclassified. pverify prevents this with a two-stage precedence rule split across the host and the kernel.
The CLI routes any ZIP input to the ASiC extractor ahead of the PDF/XML/CAdES sniff (crates/pverify-cli/src/main.rs:723). pverify_asic::is_zip is the prefix test (crates/pverify-asic/src/lib.rs:35, bytes.starts_with(b"PK\x03\x04")). On success, the extracted Vec<AsicSignature> is threaded into request.asic_signatures; on a NotAsic/extraction failure the channel is left empty (.unwrap_or_default()), so the kernel sees ZIP magic with no ASiC signatures and emits an honest asic_unsupported_container fact rather than misclassifying the input as CAdES (main.rs:716-727). The WASM host performs the byte-identical routing (web/pverify-wasm/src/lib.rs:362).
verify_with enforces the same precedence in the kernel (crates/pverify-core/src/verify.rs:189-235), in two ordered checks before detect_format is ever called:
asic_signatures non-empty (verify.rs:195): the host found ASiC signatures, so each is verified independently via crate::asic::verify_asic_signature — one SignatureEntry per inner signature — and verify_with returns immediately. The inner signatures are delegated to the existing detached CAdES / XAdES pipelines (the comment at verify.rs:194 makes this explicit).verify.rs:227): if request.signature_bytes.starts_with(b"PK\x03\x04") but the ASiC channel is empty, the input is a ZIP that is not a recognisable ASiC container (or a hard extraction failure the host could not categorise). verify_with emits a single asic_unsupported_entry (INDETERMINATE, asic_unsupported_container) and returns, never letting it reach detect_format and be misrouted to CAdES (verify.rs:222-235).Only after both checks fail does verify_with call detect_format (verify.rs:239). This is the structural reason the kernel's classification order (ASiC channel → ZIP magic → detect_format) differs from the host's (ZIP → PDF → XML → JSON → CAdES).
When pverify_asic::extract (crates/pverify-asic/src/lib.rs:50) runs, it parses the ZIP and then classifies the container with detect::detect (crates/pverify-asic/src/detect.rs:76). This is a best-effort recognition that records every packaging deviation rather than silently tolerating it (FR-001a). The detection has three outputs: the profile (ASiC-S vs ASiC-E), the primary signature family (CAdES vs XAdES), and a list of AsicDeviations.
Profile (ASiC-S vs ASiC-E). A conformant mimetype entry is authoritative: application/vnd.etsi.asic-s+zip → ASiC-S, application/vnd.etsi.asic-e+zip → ASiC-E (detect.rs:14-15, detect.rs:85-87). The mimetype must additionally be the first entry and STORED (uncompressed) per EN 319 162; violations are recorded as MimetypeNotFirstEntry / MimetypeCompressed deviations but do not by themselves disqualify the container (detect.rs:97-112). A missing or non-conformant mimetype records MimetypeMissing / MimetypeNonConformantValue and falls back to layout inference (detect.rs:88-96, detect.rs:136).
The layout inference infer_profile (detect.rs:147) applies when the mimetype is absent or non-conformant: the presence of any ASiCManifest implies ASiC-E (detect.rs:149); otherwise a single plainly-named signature file (META-INF/signature.p7s or META-INF/signatures.xml, no numeric suffix) implies ASiC-S, while a numeric suffix or more than one signature implies ASiC-E (detect.rs:157-168).
Family (CAdES vs XAdES). Detection scans the META-INF/ entry names with case-sensitive predicates: is_cades_signature matches META-INF/signatureNNN.p7s (detect.rs:45), is_xades_signature matches META-INF/signaturesNNN.xml (detect.rs:54), and is_asic_manifest matches META-INF/ASiCManifest*.xml while deliberately excluding the ASiCArchiveManifest (LTA, out of scope) and the OpenDocument manifest.xml (detect.rs:65-70). The family field records the primary family (CAdES is preferred when both are present, detect.rs:129), but it is not used to dispatch — it is a diagnostic field (detect.rs:35-39). Instead, extract processes every family actually present: it independently extends the output with CAdES signatures (lib.rs:65) and XAdES signatures (lib.rs:68) so a mixed container (both signature*.p7s and signatures*.xml) drops no signature (FR-014/FR-020, lib.rs:56-70).
The not-ASiC boundary. detect::detect returns None — and extract maps that to AsicError::NotAsic (lib.rs:52-54) — when the archive has neither a CAdES nor a XAdES signature file (detect.rs:122-124). The source is explicit that a conformant mimetype alone is not enough: there must be signature material to verify (detect.rs:119-121). This NotAsic outcome is exactly what causes the host to leave asic_signatures empty, which the kernel then surfaces as asic_unsupported_container (§4.5.2). For an ASiC-E CAdES container the CAdES signs the ASiCManifest bytes and each DataObjectReference digest is recomputed in the kernel; for an ASiC-S CAdES container the CAdES signs the single non-mimetype data object directly (lib.rs:110-139).
Regardless of which arm runs, every path produces a Vec<SignatureEntry> and re-converges on a common tail in verify_with (crates/pverify-core/src/verify.rs:329-342, and the equivalent in the ASiC early-return at verify.rs:212-218): per-step weak-algorithm flags are folded into the header via aggregate_for_header, and the consolidated material inventory is derived from the completed report tree by derive_validation_objects — a pure &Report read that adds no network access and no new cryptography (verify.rs:341). The detailed ETSI indication aggregation (aggregate_etsi, the severity ordering and the never-mask layering) is covered in Chapter 5 §5.7 and the Result Model (Chapter 14); from the classification standpoint the key invariant is that the shape of the output — one or more SignatureEntry values plus a header — is identical no matter which format was detected, so downstream consumers parse a single uniform schema.
The classification and dispatch layer never fabricates a verdict for an input it cannot place or verify at its supported level. The table below collects the honest-degradation outcomes that originate in this layer.
| Situation | Outcome | Indication / sub-indication | Evidence |
|---|---|---|---|
| ZIP that is not a recognisable ASiC container | one entry | INDETERMINATE / asic_unsupported_container |
verify.rs:227-235; detect.rs:122 |
| PDF with no signature dictionary | one entry | INDETERMINATE / pdf_byte_range_malformed (ctx pdf-no-signature-dict) |
pades/mod.rs:475-499 |
XML with no extractable ds:Signature |
one entry | INDETERMINATE / xades_unsupported |
verify.rs:271-273, 413 |
JSON with no JAdES baseline (or sigD/compact/B-T+) |
one entry | INDETERMINATE / jades_unsupported_profile (DetachedSigD/HigherLevel/NotJadesBaseline) |
verify.rs:303-305; parse.rs:212-261 |
Multi-signer CMS (N>1 SignerInfo) |
one entry | INDETERMINATE / cms_multi_signer_unsupported |
verify.rs:496-497 |
| Unparseable CMS (incl. misclassified binary) | one entry | parse-failure entry | verify.rs:483 |
PDF input + --detached-content |
exit 2 (invocation error) | (CLI rejection) | main.rs:507-512 |
Detached CAdES, no --detached-content (FR-028) |
exit 2 (invocation error) | (CLI rejection) | main.rs:597-604 |
--detached-content ≠ embedded eContent (FR-027) |
exit 2 (invocation error) | (CLI rejection) | main.rs:578-583 |
The distinction between the two failure categories is principled. CLI invocation errors (exit 2) are reserved for cases where the request itself is contradictory or under-specified — a PDF cannot be detached, a detached CAdES needs its content, two contradictory payloads cannot both be the signed object. INDETERMINATE report entries (exit 0) are reserved for cases where the request is well-formed but pverify cannot establish a fact at its supported level — an unrecognised container, an unsupported JAdES serialization, a multi-signer set the current schema cannot represent. The latter are reported as machine-readable facts so a downstream auditor sees precisely why no verdict was reached, consistent with Constitution §I.
detect_format so the ZIP magic never falls through.SignatureEntry even when the host extracted nothing.INDETERMINATE with a precise closed-enum sub-indication, never a fabricated TOTAL_FAILED/TOTAL_PASSED (Constitution §I).This chapter specifies how pverify parses RFC 5652 Cryptographic Message Syntax (CMS) SignedData and verifies CAdES (ETSI EN 319 122) advanced electronic signatures over it. CMS SignedData is the load-bearing structure for the entire tool: CAdES verification operates on it directly, PAdES (Chapter 6) embeds it inside the PDF /Contents, RFC 3161 timestamp tokens (Chapter 12) are themselves SignedData, and ASiC (Chapter 7) delegates its inner CAdES signatures to the same pipeline described here. The CAdES path is therefore the reference implementation against which the other format chapters are written; where another format defers to "the CAdES pipeline", it means the orchestration documented in §5.7.
All code referenced in this chapter lives in the no_std + alloc, I/O-free verification kernel pverify-core (see Chapter 3 for the kernel/host boundary). The structural CMS parser is crates/pverify-core/src/cms/; the per-signature orchestration is crates/pverify-core/src/verify.rs, function verify_cades. Two JNSA-mandatory binding checks — signer identification + ESS signing-certificate binding (slice 025) and content-type binding (slice 026) — are integrated into that orchestration and are documented in §5.5 and §5.6.
pverify recognises four CAdES baseline levels, projected into the report's SignatureFormat enum by verify_cades (crates/pverify-core/src/verify.rs:1034-1042):
Report SignatureFormat |
ETSI baseline | Recognition condition in verify_cades |
Standard |
|---|---|---|---|
CAdESBes |
B-B / B-ES | No unsigned-attribute extensions present | EN 319 122-1 §6.1 |
CAdEST |
B-T | At least one signature-time-stamp token, no LT/LTA payload |
EN 319 122-1 §6.2 |
CAdESLt |
B-LT | certificate-values/revocation-values present (or archive-TS reserved slot non-empty) but no archive-time-stamp |
EN 319 122-1 §6.3 |
CAdESLta |
B-LTA | At least one archive-time-stamp-v3 token |
EN 319 122-1 §6.3.4 |
The promotion ladder is strictly ordered (archive_timestamps non-empty wins, then has_lt_lta_payload, then timestamps non-empty, else BES); the level is a fact recognised from the structure present, not a profile the input claims. There is no separate "B-ES vs B-B" distinction in the wire enum — CAdESBes covers both the legacy CAdES-BES (RFC 5126) and the EN 319 122-1 B-B baseline, because the structural surface pverify reads (signing certificate + signed attributes, no trusted timestamp) is identical.
Single-signer CMS is the supported case. Multi-signer SignedData is deliberately refused (§5.4.4) rather than reporting only the first signer.
SignedData Structural ParserThe parser is crates/pverify-core/src/cms/signed_data.rs, type SignedDataParser. It is intentionally a permissive structural walker over the BER/DER TLV tree (built by crate::ber::parse_ber_tlv), not a strict field-ordered ASN.1 decoder. The module header records its provenance: it is derived from the civ project's ICAO 9303 SOD parser, which is RFC 5652 SignedData with the same outer shape as CAdES/PAdES, with the LDS-passport-specific extraction removed (Constitution §VII forbids carrying passport-domain code into the PKI surface).
The walker extracts the minimal field set the downstream checks need:
SignedDataParser field |
Source (RFC 5652 §5.1) | Stored form |
|---|---|---|
digest_algorithm_oid |
first digestAlgorithms AlgorithmIdentifier OID |
raw OID value bytes |
e_content_type_oid |
encapContentInfo.eContentType |
raw OID value bytes |
e_content |
encapContentInfo.eContent [0] EXPLICIT inner OCTET STRING |
value bytes (None if detached) |
certificates |
certificates [0] IMPLICIT |
each X.509 SEQUENCE value bytes (header stripped) |
signed_attrs |
first SignerInfo.signedAttrs [0] IMPLICIT |
value bytes (no [0] header) |
signature |
first SignerInfo.signature OCTET STRING |
value bytes |
unsigned_attrs |
first SignerInfo.unsignedAttrs [1] IMPLICIT |
value bytes |
sid |
first SignerInfo.sid CHOICE |
full DER element (tag-and-all), re-encoded |
signer_info_count |
count of SignerInfo SEQUENCEs in signerInfos |
usize |
The walker accepts either a bare RFC 5652 ContentInfo SEQUENCE or an outer application-tagged 0x77 wrapper (the latter is the ICAO SOD framing, retained so any test vector that round-trips through civ also round-trips here — SignedDataParser::parse). It locates the SignedData by finding the first SEQUENCE whose first child is an OID (find_signed_data_content), then descending through the content [0] EXPLICIT wrapper into the inner SignedData SEQUENCE.
Within the SignedData SEQUENCE, extract_signed_data_components walks the children in declared order with a small state machine:
digestAlgorithms is treated as encapContentInfo (saw_encap flag), from which the eContentType OID and optional [0] EXPLICIT eContent are extracted. CAdES uses id-data (1.2.840.113549.1.7.1); embedded RFC 3161 tokens carry id-ct-TSTInfo (1.2.840.113549.1.9.16.1.4).0x31) is digestAlgorithms; any later SET is signerInfos. This relies on there being at most two SETs at this level — true for conformant single-signer CAdES/PAdES. The walker records signer_info_count from the signerInfos SET so the multi-signer gate (§5.4.4) can fire.certificates [0] IMPLICIT (0xA0) yields each Certificate SEQUENCE's value bytes; the full DER header is re-attached downstream (§5.4.1).The sid capture (extract_signer_info) is the one place the parser must be tag-precise. SignerInfo.sid is the element immediately after version (INTEGER, 0x02); the walker captures the first element after version and re-encodes it to full DER (reencode_tlv). The CHOICE is either issuerAndSerialNumber (SEQUENCE, tag 0x30) or subjectKeyIdentifier, the latter being a [0] IMPLICIT OCTET STRING whose wire tag is the primitive context tag 0x80, not the constructed 0xA0. Interpretation of the captured bytes is deferred to the selection site (§5.5.1) where the embedded certificate set is available.
The signedAttrs, signature, and unsignedAttrs fields are each captured once (is_none() guards), so a malformed second SignerInfo never overwrites the first signer's data — but the count is still incremented so the gate can reject the truncation honestly.
An empty input surfaces Error::Parse("empty CMS SignedData input"). A truncated 0x77 wrapper surfaces Error::Ber. An input with no recognisable SignedData SEQUENCE returns a SignedDataParser with all-empty fields (rather than an error); the verify pipeline then reports the structural absences as facts. These behaviours are pinned by the unit tests in signed_data.rs (e.g. parse_empty_input_returns_err, parse_returns_empty_fields_when_no_signed_data_seq_found).
When signedAttrs is present, the signature is computed not over the raw [0] IMPLICIT body but over its DER re-encoding as a SET OF Attribute (universal tag 0x31) with the components sorted in ascending octet order. This is the RFC 5652 §5.4 / X.690 §11.5 rule that exists precisely so the verifier can reconstruct the signed octets independently of the on-wire ordering or the implicit-tag substitution.
crates/pverify-core/src/cms/canonical.rs — canonicalise_signed_attrs — implements this: it splits the stored value bytes into the constituent Attribute TLVs, sorts them with sort_unstable() (lexicographic byte comparison), and re-emits a 0x31-tagged SET. The module header documents why lexicographic byte comparison is equivalent to the X.690 §11.5 zero-padding rule here: for distinct DER TLVs that share an outer tag, the length octets diverge before any padding consideration arises, so the simpler comparison suffices.
verify_content_digest (crates/pverify-core/src/cms/verify.rs):
digest_algorithm_oid (mapped via HashAlg::from_oid_bytes; SHA-1 and SHA-2 only — SHA-3 is out of scope, cms/signed_attrs.rs::oid_to_hash_name).digest(content) over the bytes the signature commits to — the embedded eContent for attached CAdES, the --detached-content bytes for detached CAdES (resolution rule in §5.4.2).signedAttrs for the messageDigest attribute (1.2.840.113549.1.9.4) via find_message_digest_attribute and reads its OCTET STRING value.ContentDigestCheck carrying the declared digest, computed digest, the boolean digest_match, and a separate covered_content_sha256 (the raw covered bytes hashed under SHA-256 regardless of the signature's own digest algorithm — slice 016 diagnostic data, so the signed_content validation object's digest is correct even for SHA-384/512 signatures).The function never emits a verdict — it records the observed digest fact. The mapping to messageDigest_mismatch happens later in aggregate_etsi (§5.7.4), and only when content was actually supplied (a detached signature with no content yields digest_match = false as a fact of non-evaluation, not a failure).
verify_signed_attrs (crates/pverify-core/src/cms/verify.rs) canonicalises signedAttrs (§5.3.1), extracts the signer certificate's SubjectPublicKeyInfo DER (re-encoded through x509-cert so the octet stream matches what the RustCrypto verifier expects), derives the signer's signatureAlgorithm OID by pairing the SPKI algorithm family with the declared digest, and verifies via crate::crypto::verify_with_alg.
The signatureAlgorithm derivation (derive_signer_sig_alg_oid) is necessary because the structural parser does not separately surface the SignerInfo's signatureAlgorithm field; the pairing reproduces the same OID a conformant SignerInfo would carry:
| SPKI algorithm | Digest | Derived signatureAlgorithm |
|---|---|---|
rsaEncryption (1.2.840.113549.1.1.1) |
SHA-1/256/384/512 | sha{N}WithRSAEncryption (1.2.840.113549.1.1.{5,11,12,13}) |
id-ecPublicKey (1.2.840.10045.2.1) |
SHA-1/256/384/512 | ecdsa-with-SHA{N} (1.2.840.10045.4.{1,3.2,3.3,3.4}) |
| Ed25519 (1.3.101.112) | any | 1.3.101.112 (Ed25519's internal SHA-512 drives verification; the CMS digest field is informational) |
| RSA-PSS, brainpool, GOST, SM2, … | — | unsupported → None |
An unrecognised SPKI family produces SignedAttrsSignature::Failed { cause: UnsupportedAlgorithm }. A verify-and-fail produces Failed { cause: Other } (or MalformedPublicKey for a recognised-OID/undecodable-key, slice 010). The typed cause enum (SignedAttrsFailureKind) is stamped at the failure site so aggregate_etsi routes on the enum, never on a reason string prefix (a slice-029 hardening — a refactor of the human-readable reason can no longer silently re-classify the verdict).
The result is a SignedAttrsCheck. Its signed_attrs_digest_match is not an independent digest comparison: it is set to Some(true) exactly when verify_with_alg over the whole signature (canonicalised signedAttrs hashed under the declared digest, the signer's SPKI, and the signature octets) succeeds, and Some(false) otherwise — so the field is a derived alias of the verify outcome rather than a separately-checked predicate. The RSA/ECDSA/Ed25519 verify covers the full signature primitive, not a bare digest equality, so a future PSS/ECDSA path may distinguish a hash-only failure from a full-verify failure here. When signedAttrs is absent, the RFC 5652 §5.4 sign-over-eContent path is not wired — the function surfaces a structural Failed so the pipeline can decide (CAdES B-B requires signedAttrs).
verify_cades)verify_cades (crates/pverify-core/src/verify.rs:475) is the top-level per-signature CAdES verifier, invoked by verify_with after detect_format routes a non-PDF, non-XML, non-JSON, non-ASiC input to the CAdES path (detect_format falls through to DetectedFormat::Cades). It returns exactly one SignatureEntry.
The body-only certificate slices stored by the parser are re-wrapped into full DER SEQUENCEs (reconstitute_certificate_der) and parsed by crate::x509::parse_certificate; a single un-parseable cert is skipped rather than sinking the pipeline. The count of CMS-embedded certs (cms_cert_count) is recorded before the host's extra_certificates (e.g. a bundle's certificates/) are appended, because the host intermediates are eligible as chain material but never as the signer — the signer is always selected from the CMS-embedded prefix (§5.5.1).
Content resolution for the digest and archive-timestamp imprint: embedded eContent (attached CAdES) wins over detached_content_bytes whenever both are present and non-empty; the CLI pre-flight has already enforced byte-equality if both were supplied. The observed source is recorded as ContentSourceTag::Embedded or Detached for the SignatureEntry.
project_unsigned_attrs (crates/pverify-core/src/cms/unsigned_attrs.rs) walks the unsignedAttrs body into an UnsignedAttrsProjection:
| Field | CAdES attribute | OID |
|---|---|---|
signature_time_stamps |
signature-time-stamp (B-T) |
1.2.840.113549.1.9.16.2.14 |
archive_time_stamps_v3 |
archive-time-stamp-v3 (B-LTA) |
04 00 4D 49 02 04 (ETSI) |
certificate_values |
certificate-values (B-LT) |
1.2.840.113549.1.9.16.2.23 |
revocation_values_crls |
revocation-values.crlVals (B-LT) |
1.2.840.113549.1.9.16.2.24 |
revocation_values_ocsp |
revocation-values.ocspVals (B-LT) |
(same attribute, ocspVals branch) |
These feed both the report's EmbeddedValidationData audit projection and the revocation/timestamp pipelines below.
When signer_info_count > 1, verify_cades returns cades_multi_signer_entry — INDETERMINATE with sub-indication cms_multi_signer_unsupported (crates/pverify-core/src/verify.rs:496, :1890). This is a slice-029 fact-reporting correction: before it, the parser silently kept only the first SignerInfo's fields, so a multi-signer CMS was reported as if a single signer existed — hiding signer-set evidence that relying parties care about for countersignature and audit workflows. Until the report grows a one-entry-per-SignerInfo model, the honest answer is refusal, never a verdict over a truncated signer set. This is a direct application of Constitution §I "cannot affirm".
Slice 025-cades-signer-binding closes two JNSA「デジタル署名検証ガイドライン」第1.1版 Mandatory (必須) checks from 表5.5.2-1 (JNSA gaps #90/#91). The governing clarification (specs/025-cades-signer-binding/spec.md): binding-broken → TOTAL_FAILED; cannot-identify → INDETERMINATE. Because PAdES delegates its embedded-CMS evaluation to the CAdES path (pades/mod.rs), both checks automatically extend to PAdES.
Before slice 025, pverify selected the signer by a heuristic ("first non-CA certificate in the CMS-embedded set"). The signer the signature value must verify against is identified by SignerInfo.sid (RFC 5652 §5.3), and a disagreement between the heuristic and the true sid target is a fact-reporting gap.
SignerIdentifier::parse (crates/pverify-core/src/cms/ess.rs) parses the captured sid DER:
0x30 → IssuerAndSerial { issuer_dn, serial }, with the issuer Name SEQUENCE re-encoded to full DER so it can be fed to match_dn exactly like Certificate::issuer_dn.0x80 (the primitive [0] IMPLICIT OCTET STRING) → SubjectKeyId(octets).Err → the malformed path.select_signer_by_sid (crates/pverify-core/src/verify.rs:1096) then resolves the index only within the first cms_cert_count embedded certs:
IssuerAndSerial: match by serial-number equality and issuer-DN linkage (x509::name::match_dn).SubjectKeyId: match by the certificate's subjectKeyIdentifier extension.The outcomes are projected into a SignerIdentifierCheck { sid_form, match_outcome }:
match_outcome |
Meaning | Indication (§5.7.5) |
|---|---|---|
Matched |
sid designates an embedded cert |
(no effect; downstream gates apply) |
NotFound |
well-formed sid matching no embedded cert (substitution / missing signer cert) |
TOTAL_FAILED / signer_certificate_not_found |
Malformed |
absent or unparseable sid |
INDETERMINATE / signer_identifier_malformed |
On NotFound/Malformed the function still returns the legacy heuristic index as a report scaffold so a chain can be assembled for audit — but the verdict is gated on match_outcome before any signature/digest gate, so the heuristic fallback never silently becomes the verified-against certificate.
The ESS signing-certificate (v1, SHA-1 fixed) / signing-certificate-v2 (declared algorithm, SHA-256 default) signed attribute cryptographically pins the signer certificate by hash, defending against certificate-substitution (same key pair, re-issued or forged certificate). parse_ess_signing_certificate (crates/pverify-core/src/cms/ess.rs) locates the attribute (id-aa-signingCertificate 1.2.840.113549.1.9.16.2.12 / id-aa-signingCertificateV2 …2.47) and parses its first ESSCertID/ESSCertIDv2:
ESSCertID ::= SEQUENCE { certHash OCTET STRING, issuerSerial IssuerSerial OPTIONAL }
ESSCertIDv2 ::= SEQUENCE { hashAlgorithm AlgorithmIdentifier DEFAULT {id-sha256},
certHash OCTET STRING, issuerSerial IssuerSerial OPTIONAL }
Only the first ESSCertID is the signer-cert binding; later entries describe chain certs and are out of scope. For v2, a leading SEQUENCE is the explicit hashAlgorithm; its absence means the SHA-256 default. The issuerSerial's issuer is GeneralNames; pverify extracts the inner Name DER of a directoryName [4] GeneralName (constructed context tag 0xA4), and treats any non-directoryName form as not-evaluable (Mandatory-if-Exists: it does not fail).
compute_ess_binding (crates/pverify-core/src/verify.rs:1195) computes the binding against the sid-selected signer certificate:
digest(signer_cert.der_bytes) under the declared algorithm and compare to certHash → CertHashCheck { hash_match }.issuerSerial is present and directoryName-formed: compare its DN linkage and serial to the signer cert → IssuerSerialCheck { matches }.WeakAlgorithmFlag (role EssSigningCertificateDigest) is attached to the signer chain step; it never rejects (FR-012a).Three not-evaluable / malformed dispositions (all Constitution §I "cannot affirm"):
hash_match: None.directoryName issuerSerial GeneralName → matches: None.present: true with hash_match: None (distinguished from absence, which stays present: false with no indication impact). The existing precedence already routes a hash_match: null on an otherwise-passing signature to INDETERMINATE / signing_certificate_digest_not_evaluable, so no new sub-indication was needed.Slice 026-cades-contenttype-binding adds the RFC 5652 §5.3 content-type signed-attribute equality check (a JNSA-mandatory CAdES step). The content-type attribute (1.2.840.113549.1.9.3) MUST carry an OID equal to EncapsulatedContentInfo.eContentType; an attacker who could substitute the encapsulated content type without invalidating the signature would otherwise change the meaning of the signed bytes.
find_content_type_attribute (crates/pverify-core/src/cms/verify.rs) is a structural clone of the messageDigest walk, differing only in the attribute OID and that the value SET must hold exactly one OBJECT IDENTIFIER. It returns Present(oid), Absent, or Malformed. compute_content_type_check (crates/pverify-core/src/verify.rs:1388) compares by raw OID byte-equality and produces a ContentTypeCheck with outcome:
ContentTypeMatch |
Condition | Indication (§5.7.5) |
|---|---|---|
Matched |
content-type OID == eContentType | (no effect) |
NotApplicable |
signedAttrs absent (§5.4 sign-over-eContent) |
(no effect) |
Mismatch |
OIDs differ | TOTAL_FAILED / content_type_mismatch |
Missing |
signedAttrs present but attribute absent |
TOTAL_FAILED / content_type_missing |
NotEvaluable |
attribute malformed, or eContentType unavailable |
INDETERMINATE / content_type_not_evaluable |
RFC 5652 §5.3 makes the attribute MUST-present when any signed attribute is present, hence Missing is a TOTAL_FAILED, while a malformed attribute or absent eContentType (cannot affirm) is INDETERMINATE.
Each signature-time-stamp token (RFC 3161, RFC 5126 §6.3) commits to the signer's signature OCTET STRING; verify_cades hands those bytes as the imprint input to verify_timestamp_token (Chapter 12) with role SignatureTimestamp. The embedded certs serve as the TSA-chain intermediates, validated against the same anchor store as the signer.
For each archive-time-stamp-v3, archive_v3_imprint_input (crates/pverify-core/src/cms/canonical.rs) recomputes the ETSI EN 319 122-1 §6.3.4 canonical imprint input and passes it to verify_timestamp_token (role ArchiveTimestamp); a mismatch is archive_timestamp_imprint_mismatch (TOTAL_FAILED).
The canonical input concatenates, in order: (1) the content bytes (embedded eContent or detached); (2) every embedded certificate's full DER in on-disk SET-OF order (not canonically sorted); (3) the SignedData CRLs slot (currently always empty — the parser does not surface it, and pverify's LTA fixtures place revocation material in unsignedAttrs); (4) the signedAttrs value bytes; (5) the signer signature value bytes; (6) the unsignedAttrs with the archive-TS being recomputed and every later one removed, so a chain of sequential archive-time-stamps forms a hash chain (the checkpoint_index mechanism in unsigned_attrs_without_archive_ts_at_or_after).
The module documents this as a v0.3 simplified encoder: it is a deterministic byte recipe (with pverify-side SA/SI/UA length-prefixed framing in slots 4–6 to keep boundaries unambiguous) sufficient to detect every modification ETSI §6.3.4 cares about — tampered content, embedded cert/CRL, signedAttrs, signature, or any non-self unsigned-attribute change. It is not yet a fully general §6.3.4 encoder (multi-archive layering, certVals/revocVals update semantics, on-disk SET-OF ordering corner cases). This is an acknowledged scope limitation, not a silent approximation: where the imprint cannot be computed (no content available), the imprint is treated as informational only. Auditors validating LTA archives over complex multi-layer structures should treat the imprint result as a positive indicator rather than a complete §6.3.4 conformance assertion.
After the timestamp tokens are populated, crate::vrt::derive_vrt (Chapter 12) builds the recursive-outer-covering graph and assigns each object — signer chain, signer signature, each signature-TS, each archive-TS — its own Validation Reference Time. The signer chain is then re-validated at vrt.signer_chain.value (re-running validate_path → wire_chain_step_signatures → weak-algorithm flagging → ESS flag re-attach → apply_revocations) whenever that VRT differs from request_at; if it equals request_at the re-validation is skipped because it would be byte-identical. Each timestamp's TSA chain is likewise re-validated at its own VRT, and each is revocation-checked via apply_tsa_chain_revocation_for_token (slice 033, crates/pverify-core/src/verify.rs:1552) at that object's VRT, consuming the embedded revocation-values OCSP/CRL material plus the configured fetcher per the verification mode. This runs unconditionally for every timestamp because the first-pass verify_timestamp_token applies zero revocation. A confirmed-revoked TSA cert degrades to INDETERMINATE / revoked_no_poe — "cannot affirm the timestamp", never a forgery claim.
OCSP nonce (RFC 8954, slice 035). When the live OCSP fetcher fires (online mode), it sends a 16-byte nonce generated by a NonceSource trait implementation (backed by a CSPRNG in the platform host). The responder's reply is accepted only if it echoes back the same nonce or omits the nonce extension altogether (RFC 8954 §2.1); a mismatched nonce is treated as an unauthenticated response and the OCSP attempt is degraded to indeterminate. An in-run OCSP cache (OcspCache) deduplicates requests within a single verify_with call so the same certID is fetched at most once per run.
aggregate_etsi (crates/pverify-core/src/verify.rs:2343) folds the chain/signature/digest/archive-imprint findings into a base EtsiIndication with severity ordering TOTAL_FAILED > INDETERMINATE > TOTAL_PASSED and first-finding-wins within a severity tier. The precedence ladder (abbreviated, in evaluation order):
TOTAL_FAILED (e.g. chain_constraints_failure).archive_timestamp_imprint_mismatch (TOTAL_FAILED).ocsp_responder_signature_invalid, TOTAL_FAILED).cause: Other → TOTAL_FAILED / signed_attrs_signature_failed; UnsupportedAlgorithm → INDETERMINATE / signature_algorithm_unsupported; MalformedPublicKey → INDETERMINATE / public_key_malformed.TOTAL_FAILED / messageDigest_mismatch.TOTAL_FAILED / signer_certificate_revoked[_via_ocsp].INDETERMINATE.The terminating-anchor step's placeholder revocation record is excluded from the revocation scan, so a stale-CRL sub-indication is never falsely emitted on the anchor itself.
The base indication is then degraded — never masked upward — by four ordered layers, each of which only acts when the base is not already a proven TOTAL_FAILED:
apply_signer_binding_indication (:1324): the signer-identification gate is absolute first (an NotFound/Malformed sid is evaluated before any signature/digest result, since those presuppose a correctly identified signer). Then ESS certHash/issuerSerial mismatch upgrades a non-TOTAL_FAILED base to TOTAL_FAILED; an ESS not-evaluable digest degrades a would-be TOTAL_PASSED to INDETERMINATE / signing_certificate_digest_not_evaluable.apply_content_type_indication (:1441): mismatch/missing → TOTAL_FAILED; malformed/unavailable → INDETERMINATE.apply_algorithm_validity_indication (:1480): only when --algorithm-policy is supplied; a failing/indeterminate object degrades to INDETERMINATE / crypto_constraints_failure_no_poe, mirroring ETSI CRYPTO_CONSTRAINTS_FAILURE_NO_POE. Off by default; off-path output is byte-identical (Chapter 13).apply_tsa_revocation_indication (:1622): a confirmed-revoked TSA cert on any timestamp degrades to INDETERMINATE / revoked_no_poe.Each layer's "only degrade when not already a proven TOTAL_FAILED" guard is the never-mask discipline: a cryptographically broken signature outranks a deprecated-algorithm or revoked-TSA fact, and a proven failure is never overwritten by a softer indeterminate.
Every disposition in this chapter resolves to the closed SubIndication enum (crates/pverify-core/src/report/etsi.rs, mirrored 1:1 into the root report-schema.json); no free-form verdict text reaches the report. The relevant CAdES-path sub-indications are: signer_certificate_not_found, signer_identifier_malformed, signing_certificate_digest_mismatch, signing_certificate_issuer_serial_mismatch, signing_certificate_digest_not_evaluable, content_type_mismatch, content_type_missing, content_type_not_evaluable, signed_attrs_signature_failed, messageDigest_mismatch, signer_certificate_revoked, signer_certificate_revoked_via_ocsp, archive_timestamp_imprint_mismatch, cms_multi_signer_unsupported, and the various algorithm/revocation indeterminates shared with the other formats.
The SignedDataParser and the verify_cades orchestration are the substrate the rest of the tool builds on: PAdES (Chapter 6) parses the PDF host-side and feeds the embedded CMS through this same SignedDataParser + the §5.5/§5.6 binding checks, layering /DSS//VRI revocation material on top; RFC 3161 timestamp tokens (Chapter 12) are parsed by SignedDataParser as id-ct-TSTInfo-typed SignedData; ASiC (Chapter 7) routes each inner CAdES signature through verify_cades. The signer-binding precedence, the typed-cause failure routing, and the never-mask aggregation discipline are therefore not CAdES-local conventions but the tool-wide contract for how observed cryptographic facts become ETSI indications.
This chapter specifies how pverify verifies PAdES (PDF Advanced Electronic Signatures, ETSI EN 319 142). It covers the host/core split that keeps PDF parsing out of the WASM-clean kernel, the PDF signature-dictionary projection, the /ByteRange integrity and covers-EOF discipline, incremental-update revision handling, document timestamps (/DocTimeStamp), the RFC 5280 §6.1.5 empty-policy-tree relaxation that unblocked Japanese public PDFs, and — at length — the branch-034 work that lets pverify consume a PDF's own long-term validation material: the document-level /DSS dictionary (/Certs, /CRLs, /OCSPs), its per-signature /VRI sub-dictionary, and the Adobe revocationInfoArchival CMS attribute.
The PAdES verifier reuses, by deliberate design, the same CMS/chain/revocation/timestamp machinery that drives CAdES (see Chapter 4 for format dispatch and Chapter 5 for CAdES); the format-specific surface is small and is the focus here. Per-object Validation Reference Time (VRT) derivation is covered in Chapter 12, revocation evaluation in Chapter 11, and the report/schema model in Chapter 14; this chapter cross-references them where the PAdES path threads into them.
A PDF signature is a CMS SignedData (RFC 5652) embedded in a PDF signature dictionary, where the signed content is not the CMS eContent but the PDF bytes selected by a /ByteRange. pverify therefore treats PAdES as detached CAdES over a byte-range-selected message, and routes the embedded CMS through the identical pipeline used for detached CAdES (crates/pverify-core/src/pades/mod.rs, module doc and verify_one_signature).
pverify supports the following PAdES baseline levels:
| Level | Mechanism | pverify support |
|---|---|---|
| PAdES-B / B-B (BES) | Signing certificate + signed attributes, no trusted timestamp | Verified (embedded CMS chain + signed-attrs + content digest) |
| PAdES-B-T | A trusted signature-time-stamp (RFC 3161) over the signature | Verified — embedded signature-time-stamp tokens projected from the CMS unsignedAttrs, plus /DocTimeStamp document timestamps |
| PAdES-B-LT | Embedded long-term validation material (certificates + revocation) so the signature is self-contained | Verified — branch 034 consumes document-level /DSS + /VRI + Adobe revocationInfoArchival |
| PAdES-B-LTA | Archive timestamps re-protecting the structure | Verified — PAdES uses /DocTimeStamp as the archive-timestamp mechanism, surfaced as a TimestampToken with archive_timestamp role |
The format string emitted for every PAdES SignatureEntry is SignatureFormat::PAdESB (crates/pverify-core/src/pades/mod.rs; the entry constructors all set format: SignatureFormat::PAdESB). The level distinction (B/T/LT/LTA) is expressed by the facts the entry carries — the presence and verification of timestamps[] tokens, the vrt block (Chapter 12), and the provenance of consumed revocation material — rather than by a distinct format enum value. This is consistent with the fact-reporting discipline of Constitution §I: pverify reports what it observed (a verified archive timestamp, a DSS-borne CRL) rather than asserting a marketing-grade "this is a valid PAdES-LTA".
A PAdES SignedData carrying more than one SignerInfo is refused honestly. verify_one_signature checks parsed.signer_info_count > 1 immediately after parsing and returns multi_signer_entry, an INDETERMINATE entry with sub-indication CmsMultiSignerUnsupported (crates/pverify-core/src/pades/mod.rs, verify_one_signature and multi_signer_entry). This mirrors the CAdES gate (verify.rs:496, cms_multi_signer_unsupported). The remediation note in the source records why: a pre-029 parser silently retained only the first SignerInfo, so a multi-signer PDF would have reported only the first signer's verdict — a fact-suppression bug. Refusing is the §I "cannot affirm" response.
Note the distinction between one PDF with multiple signature dictionaries (a legitimate incremental-update layout — see §6.4, fully supported, one SignatureEntry per dictionary) and one CMS with multiple SignerInfos (refused). PAdES does not co-sign within a single CMS; multiple PDF signers each add their own signature dictionary in successive incremental updates.
Constitution §V (Library-First) and §VI (WASM-clean core, NON-NEGOTIABLE) forbid PDF parsing inside pverify-core, which must compile to wasm32-unknown-unknown and link no lopdf/zip/flate2. All PDF structure parsing therefore happens in two host extractors that produce model-free byte structures and feed them additively into the kernel:
crates/pverify-cli/src/pdf.rs (uses lopdf).web/pverify-wasm/src/lib.rs (also uses lopdf, in the intentionally-detached web/pverify-wasm workspace).These two extractors are required to be byte-for-byte parallel ports of one another (the CLI↔︎WASM parity invariant). The kernel consumes the parsed structures through additive VerificationRequest fields (crates/pverify-core/src/verify.rs):
pdf_signature_dicts: Vec<PdfSignatureDescriptor> (verify.rs:86) — one descriptor per signature/DocTimeStamp dictionary.pdf_validation_data: PdfValidationData (verify.rs:95) — the document-level /DSS+/VRI material (branch 034, §6.5).Format dispatch in verify_with routes a %PDF--sniffed input to crate::pades::verify_pdf, passing both fields (verify.rs:241–247). For a non-PDF input both fields are Default/empty (e.g. verify.rs:2205), so the kernel only ever consults the channel matching the sniffed format.
The host never performs cryptography or revocation logic; it performs only PDF structure traversal, stream decoding (FlateDecode), %%EOF scanning and DER extraction. The kernel performs all cryptography, path validation, revocation evaluation and indication aggregation, on pure byte slices.
PdfSignatureDescriptor projectionThe descriptor (crates/pverify-core/src/pades/mod.rs, PdfSignatureDescriptor) is the host→core projection of a single signature dictionary:
| Field | Type | Meaning |
|---|---|---|
byte_range |
[u64; 4] |
/ByteRange [start1 len1 start2 len2] — the two windows the signature commits to (everything except the /Contents placeholder) |
contents |
Vec<u8> |
Raw CMS SignedData DER from /Contents, with the PDF hex-string padding already stripped by the host |
subfilter |
String |
/SubFilter (e.g. ETSI.CAdES.detached, ETSI.RFC3161). Informational; not enforced |
revision_end |
u64 |
Byte offset at which the incremental-update revision this dictionary closes ends (its %%EOF). Equals pdf_bytes.len() for a single-revision PDF (§6.4) |
dict_type |
PdfSigDictType |
Signature (/Type /Sig) or DocTimeStamp (/Type /DocTimeStamp) |
dict_type and revision_end are input projection fields, not serialized into the report. The host extractor accepts dictionaries whose /Type is /Sig or /DocTimeStamp and which carry both /ByteRange and /Contents; anything else is skipped (extract_signatures, the match dict.get(b"Type") arm in pdf.rs). Signature dictionaries may live anywhere in the object graph (most commonly under AcroForm.Fields[*].V), so the extractor walks every object rather than only the AcroForm (pdf.rs, for (_id, object) in doc.objects.iter() and the module doc).
The /Contents blob is a fixed-width PDF hex string with trailing zero-byte padding (PDF 32000-1 §12.8.1). Because the CMS SignedData is a single DER SEQUENCE, the host truncates to the outer TLV's declared length so the BER parser never sees the padding (pdf.rs, trim_to_outer_sequence; the WASM port is identical in web/pverify-wasm/src/lib.rs). The truncation reads the 0x30 constructed-SEQUENCE tag and short/long-form length and bounds-checks header_len + value_len <= bytes.len(), falling back to the raw blob on any malformation.
verify_one_signature (crates/pverify-core/src/pades/mod.rs) performs structural ByteRange validation before any cryptography. Two distinct failure modes exist, each with its own closed sub-indication:
Malformed ByteRange → TOTAL_FAILED with SubIndication::PdfByteRangeMalformed. The check (using checked arithmetic to avoid overflow) requires start1 + len1 <= start2 (the two windows are ordered and non-overlapping) and start2 + len2 <= pdf_bytes.len() (both windows lie inside the file). If the arithmetic overflows or the windows are out of order/out of bounds, malformed is true and no crypto is attempted.
ByteRange does not cover EOF → TOTAL_FAILED with SubIndication::PdfByteRangeDoesNotCoverEof. This is the integrity check that catches a signature that does not commit to the whole (relevant) file — i.e. content appended after the signed region that the signature does not protect.
The covers-EOF computation is revision-aware and is the subject of §6.4. For a single-revision PDF it reduces to the classical start2 + len2 == pdf_bytes.len().
The bytes the signature commits to are the concatenation of the two windows:
covered = pdf_bytes[start1 .. start1+len1] || pdf_bytes[start2 .. start2+len2]
This covered slice is fed to the CMS pipeline as the detached content (it replaces the --detached-content slot used by detached CAdES). The content digest is computed by verify_content_digest(&parsed, &covered) and the signed-attributes digest and signature by verify_signed_attrs(&parsed, &signer_cert) — both shared with CAdES (Chapter 5).
If the PDF carries no signature dictionaries at all, verify_pdf returns a single INDETERMINATE entry (no_signature_entry) so a consumer sees a definite fact rather than an empty array (verify_pdf, the sig_dicts.is_empty() guard).
/DocTimeStampPDF supports incremental updates: a later revision appends bytes (a new cross-reference section, new objects, a new %%EOF) without rewriting earlier bytes. A signature created in revision 1 legitimately covers only revision 1's %%EOF; a document timestamp or second signature added in revision 2 covers the file up to revision 2's %%EOF. This is precisely the layout of Japanese official-gazette (官報 / e-官報) PDFs and is the central PAdES-LTA pattern.
Branch 009-v07-pades-incremental (v0.7) fixed two coupled bugs that made such documents falsely report pdf_byte_range_does_not_cover_eof (specs/009-v07-pades-incremental/spec.md):
%%EOF, failed./Type /Sig and silently dropped /Type /DocTimeStamp, so the document timestamp that defines revision 2's extent was invisible.Revision boundaries are discovered host-side by scanning the raw bytes for every %%EOF marker (pdf.rs, scan_revision_ends; parity port in web/pverify-wasm/src/lib.rs). The scan returns the byte offset immediately past each %%EOF and its trailing line-ending. PDF §7.2 requires exactly one end-of-line (EOL) marker after %%EOF; however, some producers emit two consecutive newlines (%%EOF\n\n). The scanner absorbs at most one EOL sequence: it advances past one \r\n, \n, or \r, and stops — the second trailing newline is left unconsumed. This "at-most-one-EOL" rule (PR #126 off-by-1 fix) is what allows PAdES-LT/LTA documents from LGPKI2 and similar producers to pass the covers_own_revision check: their revision boundary ends at the first EOL after %%EOF, so the start2 + len2 covered-end lands exactly there. The scan stays in the host (Constitution §V/§VI); the core only consumes the resulting revision_end per descriptor.
Each descriptor's revision_end is the smallest %%EOF offset at or after that dictionary's covered-end start2 + len2, falling back to the file length if none is at/after (pdf.rs, the revision_ends.iter().copied().find(|&e| e >= covered_end).unwrap_or(pdf_len) assignment).
The VerificationRequest carries a pdf_last_revision_end: u64 field (default 0, meaning pdf_bytes.len()). When set by the host, the kernel clamps pdf_bytes.len() to this value for the FR-702 trailing-content check. This allows a caller that knows the true last revision end (e.g. because it detected a /DSS-appending incremental update) to suppress a false-positive pdf_byte_range_does_not_cover_eof for the /DSS append revision.
The kernel applies the rule in verify_one_signature (crates/pverify-core/src/pades/mod.rs). Let max_revision_end be the largest revision_end across all dictionaries (computed once in verify_pdf). For a signature with covered-end start2 + len2:
covers_own_revision = (start2 + len2 == revision_end) — the signature covers exactly the revision it closes (FR-701).is_final_revision = (revision_end == max_revision_end).trailing_uncovered = (max_revision_end < pdf_bytes.len()).covers_eof = covers_own_revision && !(is_final_revision && trailing_uncovered).In words: a signature covers EOF iff it covers its own revision, unless it closes the last signed revision yet bytes trail that revision uncovered (FR-702). The second clause is essential integrity: trailing content that no signature commits to is a real defect (a possible malicious append) and is surfaced via the existing pdf_byte_range_does_not_cover_eof — no new closed-enum tag was added (clarification Q2→A in specs/009-.../spec.md). For a single-revision PDF, revision_end == max_revision_end == pdf_len, so the rule collapses to the classical start2 + len2 == pdf_len, preserving byte-identity with the pre-v0.7 fixtures.
A /DocTimeStamp dictionary carries an RFC 3161 timestamp token over its own ByteRange-covered bytes — the PAdES archive-timestamp mechanism. verify_pdf processes the descriptor list in document order with a small state machine (crates/pverify-core/src/pades/mod.rs, the for desc in sig_dicts loop):
Signature descriptor is verified by verify_one_signature → one SignatureEntry; it becomes the "most-recent preceding signature".DocTimeStamp descriptor is verified by verify_doc_timestamp (which re-checks the ByteRange structurally, recomputes the covered bytes, and calls crate::timestamp::verify_timestamp_token with role ArchiveTimestamp). The resulting TimestampToken:
timestamps[] and recorded as a covering archive token for that entry's VRT graph (clarification 2026-06-17 Q1→A: a /DocTimeStamp is surfaced as a TimestampToken with archive_timestamp role on the preceding signature, not as its own entry);doc_timestamp_only_entry;The grouping keeps, parallel-indexed with the entries, both the covering TimestampTokens (doc_ts_per_entry) and the originating /DocTimeStamp descriptors (doc_ts_descs_per_entry). The descriptors are needed in the second pass so each archive token's own CMS can be re-parsed to recover its TSA certificates for the per-object-VRT revocation check (033, §6.5.5).
After all /DocTimeStamps are grouped, verify_pdf runs a second pass per signature (apply_pades_vrt). The covering graph is signer ← signature-time-stamp ← /DocTimeStamp (the signature-TS tokens at the front of entry.timestamps, the archive /DocTimeStamp tokens at the tail). crate::vrt::derive_vrt (Chapter 12) computes each object's VRT by the recursive-outer-covering rule: the signer chain promotes to the signature-TS GenTime, the signature-TS chain to the /DocTimeStamp GenTime, and the outermost /DocTimeStamp falls back to request_at.
If — and only if — the signer-chain VRT differs from request.verification_time, the signer chain is re-validated at the promoted time (revalidate_pades_signer_chain): the CMS is re-parsed, validate_path + wire_chain_step_signatures + apply_revocations + the ETSI/binding/content-type layering are re-run at the VRT. This is the PAdES-LTA headline — a signer certificate that expired after signing still validates because the covering archive timestamp proves it was valid at signing time. When no timestamp promotes the chain (the common PAdES-B/-T case), the second pass is a no-op overwrite of the at_request_time placeholder, so those reports stay byte-identical except the additive vrt block.
/DSS, /VRI, revocationInfoArchival (branch 034)This section is the principal subject of the chapter. Branch 034-pades-dss-vri (the JNSA guideline gap review) makes pverify consume the revocation material a PAdES document carries about itself. Before 034, the PAdES path invoked the revocation pipeline with empty embedded slices (&[], &[]), so a fully self-evidenced offline PAdES-LT/LTA stalled at INDETERMINATE / revocation_not_checked_offline even though every CRL and OCSP response it needed sat in its own /DSS (docs/pades-dss-vri.md). 034 reads that material host-side and threads it into the existing revocation pipeline.
| Source | Where it lives | How keyed | Provenance tag |
|---|---|---|---|
Document /DSS pools |
PDF catalog /Root → /DSS → /Certs //CRLs //OCSPs |
Document-global | pdf_dss |
/DSS /VRI sub-dictionary |
/DSS → /VRI → <KEY> → /Cert //CRL //OCSP |
Uppercase-hex SHA-1 of the signature's /Contents |
pdf_vri |
Adobe revocationInfoArchival |
CMS signed/unsigned attribute, OID 1.2.840.113583.1.1.8 |
Embedded in the signature's own CMS | signature_embedded |
All three are additive evidence: the revocation pipeline matches CRLs by issuer+serial and OCSP responses by certID, so a superset of material can only ever resolve an indeterminate to a definite good/revoked — it can never fabricate a good outcome (data-model VR-5). This property is what makes the union-across-revisions and DSS-global-fallback behaviour safe.
/DSS+/VRI extractionextract_validation_data (crates/pverify-cli/src/pdf.rs; parity port extract_pdf_validation_data in web/pverify-wasm/src/lib.rs) parses the document-level material into a pure-data PdfValidationData:
pades::PdfValidationData { dss_certs, dss_crls, dss_ocsps, vri: Vec<VriEntry> }
Extraction rules, grounded in the code:
lopdf resolves the final object graph, but to be forgiving of producers that leave a superseded /DSS in an earlier revision, the extractor collects the catalog's /DSS and every other /DSS-shaped dictionary found by walking every object, then unions all their pools (pdf.rs, the dss_dicts vector built from doc.catalog() plus the object walk). Union is safe precisely because material is additive evidence matched by issuer/serial/certID.collect_pool appends a decoded stream's bytes only if !out.contains(&der) — deduplication is by exact DER, not by parsed identity. Streams are decoded with decompressed_content() (applying FlateDecode etc.), falling back to raw content on a decode error./VRI indexing. Each /DSS /VRI sub-entry is keyed by its dictionary name, uppercased to form contents_sha1_hex, and its /Cert//CRL//OCSP pools collected into a VriEntry. Duplicate keys (a later revision's VRI for the same signature) are skipped; VRI entries are sorted by key for deterministic order (pdf.rs, data.vri.sort_by(...)).MAX_DSS_ENTRIES_PER_POOL = 4096 and MAX_DSS_STREAM_BYTES = 16 MiB cap the work; empty or oversized streams are skipped. A malformed or hostile document degrades to "no usable material" rather than performing unbounded work or panicking — a partially-malformed DSS still yields the entries that parsed cleanly.PdfValidationData is empty (Default) for all non-PDF inputs and for PDFs with no /DSS. This is the byte-identity guarantee (FR-008): a no-DSS PDF report differs from a pre-034 report only in the schema_version string (verified by the e2e test no_dss_document_is_strictly_additive in crates/pverify-cli/tests/pades_dss_vri_e2e.rs, which asserts no pdf_dss/pdf_vri origin appears).
The two extractors are byte-for-byte parallel ports (the CLI↔︎WASM parity invariant); the WASM port carries the same MAX_DSS_* constants and the same collect_pool/resolve_dict/scan_revision_ends/trim_to_outer_sequence logic.
The kernel performs pure-compute resolution per object in resolve_material (crates/pverify-core/src/pades/mod.rs):
key = uppercase_hex(sha1(object.contents))
material = vri.find(|e| e.contents_sha1_hex == key && !e.is_empty()) // → PdfVri
.unwrap_or(dss_{crls, ocsps, certs}) // → PdfDss
A non-empty /VRI entry whose key matches the object's /Contents SHA-1 wins (provenance PdfVri); otherwise the document-level /DSS pools are used (provenance PdfDss). An empty VRI entry (one referencing no material) does not win — resolution falls back to the global pools (VriEntry::is_empty, exercised by the unit test dss_vri_empty_vri_entry_falls_back_to_dss). The resolved certificate DER are parsed for path building; entries that fail to parse are skipped (resolve_material, the filter_map(|der| crate::x509::parse_certificate(der).ok())).
The SHA-1 key is computed by sha1_uppercase_hex (crates/pverify-core/src/pades/mod.rs) reusing the core's existing SHA-1 facility (already present for SHA-1 signature verification — no new dependency, FR-012). The source comment is explicit and audit-relevant: SHA-1 is used here only as a dictionary lookup key, never as a security primitive. Its cryptographic weakness is irrelevant because a collision could only mis-route additive evidence, which cannot fabricate a good outcome (VR-5). The key derivation is pinned against the known SHA-1 vectors for the empty string and "abc" (dss_vri_sha1_uppercase_hex_matches_known_vectors).
ResolvedMaterial is an internal, non-wire struct { crls, ocsps, certs, provenance } with provenance ∈ {PdfDss, PdfVri}.
In verify_one_signature, after selecting the signer certificate and building the intermediate-candidate set, the resolved material is applied (crates/pverify-core/src/pades/mod.rs):
/Certs join the intermediate candidates only — never the anchors (Q7/FR-005/VR-2). Each resolved cert is appended to intermediates if not already present (deduped by fingerprint_hex). DSS certificates are path-building candidates; the configured trust store (anchors.anchors()) remains the sole source of trust anchors. A DSS cert that happens to match a configured anchor fingerprint is still anchored from the store (origin trust_anchor), not from the document.revocationInfoArchival is projected from the CMS (project_revocation_info_archival) and its CRL/OCSP DER merged into the signer-side material. Because it lives in the CMS rather than the PDF structure, it keeps provenance signature_embedded, not pdf_dss.apply_revocations call (crates/pverify-core/src/pades/mod.rs, the apply_revocations(..., &signer_ocsps, &signer_crls, mode) call that previously received &[], &[]).tag_document_revocation_provenance walks the resulting chain steps and, for any CRL/OCSP record whose consumed DER matches (by SHA-256 of the supplied bytes against the record's body_sha256/response_sha256), sets pdf_source = PdfDss or PdfVri. Matching is exact and deterministic — no re-parse. revocationInfoArchival material is not tagged (it keeps the empty-source_uri → signature_embedded default).The revocationInfoArchival projection (extract_rev_info_archival_from_attrs) is a best-effort BER walk over the CMS signed/unsigned attribute blobs (both are scanned; the attribute is "most common" in signedAttrs). It matches the OID body 2a 86 48 86 f7 2f 01 01 08 and parses the value SET → SEQUENCE → [0] crl SEQUENCE OF CertificateList / [1] ocsp SEQUENCE OF OCSPResponse, re-encoding each inner TLV. [2] otherRevInfo is a documented deviation — ignored this slice (crates/pverify-core/src/pades/mod.rs, the _ => {} arm and the doc comment). Malformed input is skipped, never fatal (the iter_tlv walker stops at the first malformed length rather than erroring, FR-011).
The second pass apply_pades_vrt applies revocation to each timestamp's TSA chain at that object's own per-object VRT (033 + 034). For each token in entry.timestamps:
/DocTimeStamp archive token, that token's own descriptor's CMS certificates (doc_ts_descs).resolve_material keyed by the appropriate /Contents — the signer's /Contents for an embedded signature-TS (the VRI key for embedded sig-TS material), or the /DocTimeStamp's own /Contents for an archive cover.crate::verify::apply_tsa_chain_revocation_for_token (the 033 shared helper, verify.rs:1552) is called with the resolved OCSP/CRL DER (previously &[], &[]), at the token's VRT (crates/pverify-core/src/pades/mod.rs, apply_pades_vrt per-token loop).tag_document_revocation_provenance then tags the TSA chain's consumed records.After the per-token loop, apply_tsa_revocation_indication layers the TSA-chain revocation verdict last. This is the consistency the 033 work delivers: a revoked TSA certificate degrades the entry to INDETERMINATE with revoked_no_poe (the "cannot affirm" response — pverify cannot prove the timestamp's time because the TSA's own certificate was revoked), rather than fabricating a forgery claim.
A critical invariant (FR-004a/Q5/VR-4): the DSS/VRI/revocationInfoArchival material rides the same embedded-CRL/OCSP channels introduced in 019 that fire only when a live fetch / CDP lookup is itself indeterminate. The apply_revocations and apply_tsa_chain_revocation_for_token calls receive the material as the embedded-OCSP / embedded-CRL arguments, and the revocation engine (Chapter 11) consults those channels as a fall-through, not as an override. Consequences:
revocation_not_checked_offline now reaches a definitive result from the document's own evidence (the headline of the e2e test dss_borne_crl_reaches_definitive_offline_and_is_tagged_pdf_dss, which proves the signer chain good with no network access and tags the consumed CRL pdf_dss).This is the standing offline-honesty discipline (Constitution §VII): --offline opens no socket, and the honest IndeterminateRevocationOffline is replaced by a definite outcome only when the document carries the evidence to establish it.
A DSS/VRI-supplied CRL or OCSP response yields an existing RevocationOutcome value — no new outcome, no new sub-indication (Q2/FR-007). Specifically (docs/pades-dss-vri.md):
TOTAL_FAILED (via the existing revocation aggregation).INDETERMINATE / revoked_no_poe.The only additive wire surface is provenance, described next.
The 034 wire surface is purely additive provenance, mirrored 1:1 into the root report-schema.json:
ValidationObjectOrigin values, pdf_dss and pdf_vri, appended after the existing five (crates/pverify-core/src/report/validation_objects.rs, enum ValidationObjectOrigin). Declaration order is the sort order for the origin[] array, so existing arrays stay byte-stable. The derivation maps a record's PdfRevocationSource to the origin (validation_objects.rs, the Some(PdfRevocationSource::PdfDss) => ValidationObjectOrigin::PdfDss arms).unsignedAttrs-embedded one: CrlSummary.pdf_source and OcspAttempt.pdf_source, both Option<PdfRevocationSource> (crates/pverify-core/src/report/mod.rs, enum PdfRevocationSource { PdfDss, PdfVri }, and the pdf_source fields). Both omit-when-absent, so a 1.5.0 reader ignores them.SCHEMA_VERSION bumped 1.5.0 → 1.7.0 when branch 034 landed (crates/pverify-core/src/report/schema.rs). Subsequent slices (035 OCSP nonce, CdpEntry struct) advanced it further to 1.9.0 (the current value). This section documents the 034-specific wire additions; see Chapter 14 for the complete lineage. The schema bump is an additive MINOR change: adding a closed-enum value or an optional field is MINOR; removing or repurposing one is MAJOR (Chapter 14). The schema doc comment is explicit that there is no new revocation outcome, no new ETSI sub-indication, no new top-level field, and that 034 is not a constitutional surface (no policy table; it reuses the revocation vocabulary). An input with no /DSS//VRI/revocationInfoArchival is byte-identical to a 1.5.0 body except the schema_version string.
A separate, earlier fix (PR #48, commit 0650b2d) was required before real Japanese public PDFs could verify at all, and it lives in the shared path-validation policy processing (crates/pverify-core/src/path/policy.rs, compute_outcome) rather than in the PAdES module — but its motivating use case is PAdES, so it is documented here.
RFC 5280 §6.1.5 governs the leaf wrap-up of certificate-policy processing. The pre-#48 implementation rejected an empty valid_policy_tree unconditionally, which made real GPKI/官報 chains fail: such a leaf carries a policy OID that differs from its issuer's, with no policyMappings, so the valid_policy_tree legitimately empties at the leaf even though the chain is sound. The corrected logic (compute_outcome, the is_leaf block) follows RFC 5280 precisely:
requireExplicitPolicy fired (modelled as explicit_policy_pending == Some(0)). When requireExplicitPolicy was never asserted, an empty tree under a {anyPolicy} user-initial-policy-set is valid — path processing succeeds when explicit_policy > 0 or the tree is non-NULL.requireExplicitPolicy fired, an empty tree yields Inconclusive { EmptyTree }, and a tree containing only anyPolicy (no specific policy survived) yields Inconclusive { ExplicitPolicyViolated }.user_initial_policy_set (the CLI's --required-policy) narrows further at the leaf: at least one tree node must carry a specific policy that is in the set, otherwise Inconclusive { EmptyTree }.The corresponding ETSI surface is IndeterminatePolicyRejected (crates/pverify-core/src/report/etsi.rs) for the requireExplicitPolicy empty-tree and policy-set-intersection-empty cases; the older PolicyProcessingInconclusive tag is retained for stored-report deserialization. The PAdES path threads the same required_policies into validate_path (branch 029, verify_one_signature and revalidate_pades_signer_chain), so PAdES enforces the identical user-initial-policy-set narrowing as CAdES — an empty slice still means {anyPolicy}, preserving compatibility for runs without --required-policy.
| Condition | Outcome | Sub-indication / origin |
|---|---|---|
| ByteRange windows out of order / out of bounds / overflow | TOTAL_FAILED |
pdf_byte_range_malformed |
| Signature does not cover its revision; or final revision leaves trailing uncovered bytes | TOTAL_FAILED |
pdf_byte_range_does_not_cover_eof |
Producer emits %%EOF\n\n (double newline) |
not a false positive (at-most-one-EOL rule, PR #126) | — |
| PDF carries no signature dictionary | INDETERMINATE |
(single no-signature entry) |
CMS carries > 1 SignerInfo |
INDETERMINATE |
cms_multi_signer_unsupported |
Offline, no live revocation, but /DSS//VRI/revocationInfoArchival supplies the CRL/OCSP |
definite (TOTAL_PASSED if good) |
consumed material tagged pdf_dss / pdf_vri / signature_embedded |
| Signer cert revoked per a DSS-borne CRL/OCSP | TOTAL_FAILED |
existing revoked outcome |
| TSA cert revoked per a DSS-borne CRL/OCSP | INDETERMINATE |
revoked_no_poe (033) |
| No matching revocation record in supplied material | unchanged (indeterminate) | additive evidence never fabricates good |
Leaf valid_policy_tree empty, no requireExplicitPolicy |
not rejected (§6.1.5 relaxation) | — |
| DSS cert matches a configured anchor | anchored from store | origin trust_anchor, never pdf_dss |
| No DSS/VRI/revInfoArchival present | byte-identical to pre-034 | only schema_version differs (1.9.0) |
| LGPKI2 signer cert (LDAP-only CRL) | offline verification depends on /DSS-embedded CRL; lgpki2-org-ca-r2.json trust anchor ledger added |
— |
Throughout, the PAdES verifier honours the cross-cutting discipline of the system: PDF parsing stays host-side (Constitution §V/§VI); the kernel is I/O-free and WASM-clean; additive material flows only as a fall-through that never overrides a live success (reproducibility / byte-identity); and where a fact cannot be established the verifier degrades to INDETERMINATE with a precise closed-enum sub-indication rather than fabricating a verdict (Constitution §I, "cannot affirm").
detect_format), end-to-end data flow.apply_pades_vrt.schema_version semantics, ValidationObjectOrigin, the derive_validation_objects provenance derivation.This chapter documents how pverify verifies XML Advanced Electronic Signatures (XAdES, ETSI EN 319 132 / JIS X 14533‑2) and Associated Signature Containers (ASiC, ETSI EN 319 162). Both are delegated formats: the host crates pverify-xades and pverify-asic perform all XML/ZIP parsing and canonicalization and hand the I/O‑free, no_std kernel (pverify-core) model‑free byte structures, which the kernel then runs through the same RFC 5280 path‑validation, RFC 6960/5280 revocation, RFC 3161/5816 timestamp and ETSI EN 319 102‑1 indication machinery used for CAdES (see Chapter 5) and PAdES (see Chapter 6). The architectural boundary and crate topology are described in Chapter 3; this chapter focuses on what is format‑specific to XML signatures and ZIP containers, and is precise about which profiles are verified versus flagged unsupported.
The supported AdES levels are stated up front so they are not buried:
| Format | B‑B | B‑T | B‑LT | B‑LTA |
|---|---|---|---|---|
| XAdES (enveloped, Exclusive C14N) | ✅ verified | ✅ SignatureTimeStamp + SigAndRefsTimeStamp |
✅ embedded CertificateValues/RevocationValues |
✅ ArchiveTimeStamp imprint verified (exc‑C14N of ds:Signature minus subsequent ATS node‑sets, branch 036) |
| XAdES (detached / enveloping, stand‑alone) | ❌ INDETERMINATE xades_unsupported_profile |
— | — | — |
| XAdES (non‑Exclusive C14N) | ❌ INDETERMINATE xades_unsupported_canonicalization |
— | — | — |
| ASiC‑S/E (delegating to CAdES) | ✅ via CAdES pipeline | ✅ | ✅ | ✅ (CAdES archive‑time‑stamp‑v3) |
| ASiC‑S/E (delegating to XAdES) | ✅ via XAdES pipeline | ✅/⚠️ as XAdES above | ✅ | ⚠️ as XAdES above |
The single non‑negotiable principle throughout is the Constitution §I "cannot affirm" discipline: where pverify cannot establish a fact (an unsupported canonicalization, an unparseable timestamp token, a data object absent from a container) it degrades to INDETERMINATE with a precise closed‑enum sub‑indication, never a fabricated TOTAL_FAILED or TOTAL_PASSED.
The kernel cannot parse XML — that would pull a DOM and canonicalization machinery into the no_std/WASM‑clean core, violating Constitution §V (Library‑First) and §VI (WASM‑clean core). Instead, the host crate pverify-xades (crates/pverify-xades/src/lib.rs) parses the XML, performs Exclusive XML Canonicalization through the audited bergshamra-c14n crate, and emits one model‑free XadesComponents value per ds:Signature element. That boundary type is owned by the kernel (crates/pverify-core/src/xades.rs, XadesComponents) and re‑exported from the host crate, so there is a single source of truth for the shape that crosses into VerificationRequest. Only Vec<u8> and small closed enums cross the boundary — no XML node, no std type. The kernel verifier verify_xades (crates/pverify-core/src/xades.rs) consumes one XadesComponents and produces one SignatureEntry, reusing the existing RFC 5280 / revocation / ETSI pipeline from the point the signer certificate is in hand — deliberately identical to verify_cades (crates/pverify-core/src/verify.rs).
The boundary fields, all already decoded and canonicalized by the host so the kernel never touches XML, are:
Field (XadesComponents) |
Meaning |
|---|---|
signed_info_c14n |
Exclusive‑C14N canonical bytes of ds:SignedInfo — the message the signature value is verified against |
signature_method |
XadesSigAlg resolved from the SignatureMethod URI (RSA PKCS#1 v1.5 / ECDSA / RSA‑PSS / Unsupported) |
signature_value |
base64‑decoded ds:SignatureValue; for ECDSA the host has already converted XML‑DSIG fixed‑width r‖s to a DER Ecdsa-Sig-Value |
signer_cert_der |
DER of ds:KeyInfo/ds:X509Data/ds:X509Certificate; None ⇒ signer‑certificate‑unavailable |
references |
every ds:Reference, already transformed + canonicalized (transformed_c14n) with its digest_alg and expected_digest |
signing_cert_v2 |
the XAdES SigningCertificateV2 binding (CertDigest + alg + optional IssuerSerialV2) |
profile |
EnvelopedBB, DetachedResolved (ASiC), or Unsupported{reason} |
signature_timestamps |
B‑T SignatureTimeStamp tokens + canonicalized imprint inputs |
sig_and_refs_timestamps |
B‑T SigAndRefsTimeStamp tokens + reconstructed type‑1 imprint inputs |
unverified_timestamps |
XadesUnverifiedTimestamp entries for RefsOnlyTimeStamp and for ArchiveTimeStamp tokens whose imprint cannot be computed; each carries a pre‑computed imprint_input: Vec<u8> (or empty for unsupported shapes). See §7.5.1 for how B‑LTA ArchiveTimeStamp imprinture is handled. |
embedded_certs_der / embedded_crls_der / embedded_ocsp_der |
B‑LT CertificateValues / RevocationValues material |
The B‑T/B‑LT fields are all additive and were introduced by branch 019; a plain B‑B signature carries no UnsignedSignatureProperties and so leaves them empty, which means a B‑B report is byte‑identical to the pre‑019 output (the reproducibility invariant, Constitution §II). The same byte‑identity discipline governs the kernel's behaviour: the additive embedded‑revocation channels fire only when the live/CDP channel is indeterminate.
XML canonicalization is the load‑bearing — and historically the most error‑prone — part of XML signature verification, because the digested and signed bytes are not the literal bytes on disk but a canonical serialization of a node‑set. pverify uses two distinct canonicalization algorithms, each applied to a specific place, both provided by the audited bergshamra-c14n crate (crates/pverify-xades/src/c14n.rs):
Exclusive XML Canonicalization (xml-exc-c14n#, W3C). Applied to ds:SignedInfo (the verified message) and to each same‑document ds:Reference subtree. Exclusive C14N prunes inherited namespace declarations that are not visibly used, honouring an InclusiveNamespaces PrefixList when present. The host reads that prefix list from the CanonicalizationMethod/Transform element (inclusive_prefixes, crates/pverify-xades/src/parse.rs) and threads it into exc_c14n_subtree.
Plain Canonical XML 1.0 (REC-xml-c14n-20010315, with and without comments). Applied only to the RFC 3161 timestamp message‑imprint computation (§7.5). Unlike exclusive C14N, plain C14N emits every namespace in scope — inherited declarations are not pruned — so it takes no inclusive‑prefix list. This is implemented over bergshamra-c14n's existing C14nMode::Inclusive (the B‑B build already compiles it, so B‑T added no new dependency — crates/pverify-xades/src/c14n.rs::plain_c14n_subtree).
The "plain C14N for the timestamp imprint, Exclusive C14N for ds:SignedInfo" split is not an arbitrary choice; it was confirmed byte‑exact against the real EU DSS XAdES corpus (the 019 research note records that a SignatureTimeStamp declares plain C14N 1.0 for the data it imprints even when ds:SignedInfo itself uses Exclusive C14N). Both canonicalization surfaces are regression‑locked byte‑for‑byte against a libxml2 golden oracle (crates/pverify-xades/tests/exc_c14n_golden.rs and tests/plain_c14n_golden.rs), so any drift in the canonicalizer is caught deterministically rather than discovered as a verification mismatch in the field.
The enveloped‑signature transform (W3C XML‑DSIG §6.6.4) is the third canonicalization‑adjacent operation: a reference with URI="" plus the #enveloped-signature transform digests the whole document minus the ds:Signature subtree. The host computes this by taking the full‑document node‑set, subtracting the signature subtree, then applying exclusive C14N (exc_c14n_enveloped, crates/pverify-xades/src/c14n.rs). The presence of exactly this URI="" + enveloped‑transform reference is what classifies the signature as the supported enveloped profile (§7.4).
verify_xades (crates/pverify-core/src/xades.rs) executes a fixed, fact‑ordered sequence; each failure maps to a specific outcome. The order is deliberate so a definitive forgery is detected before, and is never masked by, a weaker indeterminate condition.
Step 1 — profile and algorithm gating. If the host classified the profile as Unsupported, the kernel immediately returns INDETERMINATE with either xades_unsupported_profile (detached/enveloping packaging) or xades_unsupported_canonicalization (non‑Exclusive C14N). An Unsupported SignatureMethod URI returns INDETERMINATE signature_algorithm_unsupported. These are cannot‑affirm outcomes: pverify recognises the shape but declines to verify what it does not implement, rather than guessing.
Step 2 — signer certificate presence. signer_cert_der == None → INDETERMINATE xades_signer_certificate_unavailable. The same outcome covers a certificate that fails DER parse or whose SubjectPublicKeyInfo cannot be re‑encoded (extract_spki_der).
Step 3 — reference digests. For every ds:Reference, the kernel recomputes digest_alg(transformed_c14n) and compares it to the stored expected_digest. Any mismatch is TOTAL_FAILED xades_reference_digest_mismatch — positive evidence that the signed content was altered. This is checked before the signature value, because a reference mismatch is a content‑integrity failure independent of the signature‑value cryptography.
Step 4 — signature value over SignedInfo. The kernel verifies signature_value over signed_info_c14n using the SubjectPublicKeyInfo from the signer certificate. RSA‑PSS is dispatched directly with the salt length the host resolved (verify_rsa_pss); RSA PKCS#1 v1.5 and ECDSA reach the shared OID‑driven verify_with_alg (the host has already converted ECDSA r‖s to DER, so the existing verifier is unchanged). A genuine verification failure is TOTAL_FAILED signed_attrs_signature_failed (reusing the CMS class — the XML signature value is the analogue of the CMS signer signature); an unsupported algorithm surfaced at this point degrades to INDETERMINATE signature_algorithm_unsupported, preserving the fact‑reporting distinction between "wrong" and "cannot evaluate".
Step 5 — SigningCertificateV2 binding. When the signature carries an XAdES SigningCertificateV2, the kernel recomputes the digest of the signer certificate DER under the declared DigestMethod and compares it to the stored CertDigest. A mismatch is TOTAL_FAILED xades_signing_certificate_mismatch — the certificate‑substitution defence (an attacker swapping the embedded certificate for one whose key also verifies a re‑signed SignedInfo is caught here). The binding is digest‑based; the IssuerSerialV2 is recorded but not used as the discriminator.
Step 6 — path validation, revocation, ETSI. From here the flow is identical to verify_cades: validate_path (RFC 5280 §6 chain construction at the verification time), wire_chain_step_signatures (RustCrypto certificate‑signature verification), apply_revocations for the signer chain, and ETSI aggregation via aggregate_etsi. The embedded B‑LT certificates are made available as chain material, and the embedded OCSP/CRL DER is fed through the existing embedded‑material channel so an offline‑definitive signer‑revocation verdict is reachable.
On success the report records the bytes the XAdES signature commits to: the signed_content validation object's digest is set to the SHA‑256 of the exclusive‑C14N ds:SignedInfo (covered_content_sha256). The CMS‑shaped signed_attrs and content_digest blocks are recorded as not‑applicable facts (XAdES has no CMS messageDigest), so a consumer reading the report sees an honest "not applicable" rather than a fabricated match.
Weak‑algorithm handling. SHA‑1 in either the SignatureMethod or any reference's DigestMethod is recognised‑and‑flagged, never rejected — matching the CAdES/PAdES posture and Constitution §I. The kernel verifies the signature normally and records a WeakAlgorithmFlag against the signer step (via flag_if_weak), which the host's aggregate_for_header then surfaces in the report header. This is distinct from the opt‑in algorithm‑policy verdict (§7.6), which is a separate, time‑scoped evaluation.
The host resolves XML algorithm URIs to model‑free descriptors in crates/pverify-xades/src/algid.rs. The supported SignatureMethod family is:
| URI family | XadesSigAlg |
|---|---|
xmldsig#rsa-sha1, xmldsig-more#rsa-sha{256,384,512} |
RsaPkcs1v15{digest} |
xmldsig-more#ecdsa-sha{1,256,384,512} (2001/04 and 2007/05 namespaces) |
Ecdsa{digest} |
xmldsig-more#sha{256,384,512}-rsa-MGF1 (legacy RFC 4051) |
RsaPss{digest, salt_len = digest_len} |
xmldsig-more#rsa-pss (RFC 6931) |
RsaPss with digest/SaltLength read from the <pss:RSAPSSParams> child |
| anything else | Unsupported{uri} → INDETERMINATE |
DigestMethod URIs map to SHA‑1/256/384/512; an unrecognised digest URI is unsupported. SHA‑1 is a valid recognised digest (verified then flagged weak), not an unsupported value — the fact‑reporting principle again.
Stand‑alone XAdES support is deliberately scoped to the enveloped profile with Exclusive canonicalization — the shape used by e‑Gov public‑document XML signatures (官職署名), the Ministry of Justice's electronic articles of incorporation (電子定款), and the EU LOTL/Trusted Lists. The host decides the profile in process_reference / extract_one (crates/pverify-xades/src/parse.rs) with this precedence:
CanonicalizationMethod is not Exclusive C14N → Unsupported{NonExclusiveC14n}.resolver was supplied (the ASiC case, §7.7) and a non‑fragment ds:Reference URI was resolved to a ZIP entry → DetachedResolved.URI="" + enveloped‑signature reference is present → Unsupported{DetachedOrEnveloping}.EnvelopedBB.The DetachedResolved lift is narrow: it lifts the detached gate only when the ASiC extractor has already resolved the reference URIs to concrete bytes. Stand‑alone detached/enveloping XAdES still maps to Unsupported, so the enveloped path (011/019) is byte‑identical regardless of whether the ASiC code is present. This is how pverify reuses the XAdES reference‑digest machinery for ASiC‑E XAdES without introducing a separate detached verifier (§7.7).
Branch 019 added B‑T and B‑LT support. The B‑T timestamps in scope are the SignatureTimeStamp and the SigAndRefsTimeStamp; both are RFC 3161 tokens verified through the shared crate::timestamp::verify_timestamp_token. The host canonicalizes the imprinted data (so the kernel receives only raw imprint bytes, §V) and the kernel computes and compares the imprint exactly as CAdES does for its signature‑time‑stamp.
SignatureTimeStamp. The imprint commits to the plain‑C14N canonical ds:SignatureValue (R‑1, byte‑confirmed against the DSS corpus). The host canonicalizes ds:SignatureValue with plain C14N 1.0 and stores it as the imprint_input; the kernel verifies the RFC 3161 token and the imprint.
SigAndRefsTimeStamp. This is the hardest computation in the slice. Per ETSI TS 101 903 §7.5.1 type‑1 coverage, the imprint is over the ordered concatenation, each subtree plain‑C14N‑canonicalized, of ds:SignatureValue followed by every SignatureTimeStamp, CompleteCertificateRefs, CompleteRevocationRefs and (when present) AttributeCertificateRefs / AttributeRevocationRefs, walked in document order under UnsignedSignatureProperties up to (not including) the timestamp itself (build_sig_and_refs_imprint, crates/pverify-xades/src/parse.rs). The reconstruction is the highest‑risk byte‑exact operation in the format; the design is explicitly honest‑indeterminate: if the reconstructed concatenation does not reproduce the imprint, the kernel records message_imprint_match = false and surfaces it rather than fabricating a match. Against the chosen DSS fixture the type‑1 imprint does reconstruct, yielding a genuine TOTAL_PASSED rather than a degrade.
Imprint‑mismatch verdict. When an in‑scope timestamp's imprint does not match (or could not be verified — for example because the timestamp declared an unsupported canonicalization, canonicalization_supported = false) and the rest of the signature is otherwise valid (the base aggregation returned TOTAL_PASSED), the kernel downgrades to INDETERMINATE xades_timestamp_imprint_mismatch: the signature is not forged, but its claimed time cannot be confirmed (ETSI EN 319 102‑1). A genuine TOTAL_FAILED signer/reference failure keeps precedence — the downgrade only touches a passing verdict, never masking proven failure.
B‑LT embedded material. CertificateValues/EncapsulatedX509Certificate become chain material; RevocationValues/CRLValues/EncapsulatedCRLValue and OCSPValues/EncapsulatedOCSPValue feed the existing embedded‑CRL/OCSP channels. One format‑specific subtlety: XAdES EncapsulatedOCSPValue carries a full RFC 6960 OCSPResponse (responseStatus + responseBytes wrapper), whereas the kernel (like CAdES revocation-values.ocspVals) consumes a bare BasicOCSPResponse. The host therefore unwraps the OCSPResponse to its inner BasicOCSPResponse with a minimal hand‑rolled DER walk (unwrap_ocsp_response, crates/pverify-xades/src/parse.rs) before threading it in; a structure that does not match is returned unchanged so the kernel evaluates or rejects it as a fact, never panics. The presence of any B‑LT payload promotes the reported format to XAdES-B-LT; a verified signature timestamp without LT payload is XAdES-B-T; neither is XAdES-B-B.
Per‑object VRT. As with every format, each object (signer chain, signer signature, each signature timestamp) is judged at its own Validation Reference Time derived by the recursive‑outer‑covering engine (crate::vrt::derive_vrt, see Chapter 12). When a signature timestamp's GenTime promotes the signer chain's VRT past the request time, the kernel re‑validates the chain at that promoted time (crates/pverify-core/src/xades.rs), so a signer certificate that expired after signing but was timestamped while valid still verifies — the headline B‑LT payoff. TSA‑chain revocation is applied per timestamp at each timestamp's own VRT through the shared apply_tsa_chain_revocation_for_token helper (branch 033), bringing XAdES to parity with CAdES/PAdES on TSA revocation consistency.
ArchiveTimeStamp imprint verification (branch 036)Branch 036-xades-blta implements ArchiveTimeStamp imprint recomputation for XAdES B‑LTA, closing the gap noted by §I for this tier (ETSI TS 101 903 v1.4.2 Annex A.1.5).
SignatureFormat::XadesBLta ("XAdES-B-LTA") is promoted when at least one ArchiveTimeStamp is present in UnsignedSignatureProperties. Like the B‑T/B‑LT levels, the promotion is a recognised structural fact, not a claimed profile.
Imprint construction. The imprint input is the Exclusive‑C14N (bergshamra-c14n) serialization of the entire ds:Signature element, with the node‑set of subsequent ArchiveTimeStamp elements (those at index k and later, where the current token is at index k-1) subtracted before C14N. This is the "circular exclusion" discipline: each ATS commits to every prior ATS plus the signature body, forming a forward hash chain, while the token being recomputed is excluded so its own imprint can be independently verified. The pre‑computed bytes are stored as XadesUnverifiedTimestamp.imprint_input: Vec<u8> by the host (pverify-xades); the kernel (verify_xades) never touches XML and simply calls verify_timestamp_token with those bytes.
Shared LTA infrastructure. LongTermArchivalIndication and ArchiveTimestampImprintRecord are shared with the CAdES B‑LTA path (Chapter 5) and require no new types. A verified XAdES archive timestamp populates embedded_validation_data.long_term_archival_indication (previously no_archive_timestamp for XAdES regardless of LT payload — that default is superseded by a real LongTermArchivalIndication when the imprint verifies).
Scope and limitations. The current implementation covers:
ArchiveTimeStamp or a short ordered sequence where each token's node‑set is well‑defined.ArchiveTimeStamp form in ETSI TS 101 903, not ArchiveTimeStampV3.Out of scope:
ArchiveTimeStamp chains (loop processing for long ATS chains is a future slice).ArchiveTimeStamp tokens that declare an XPath transform: those cannot be recomputed without an XPath evaluator in the host. They are surfaced in unverified_timestamps with an empty imprint_input and do not downgrade the verdict (cannot‑affirm). This is why the DSS XAdES‑LTA interop fixtures, which use XPath transforms, are excluded from expectations.json.arcTst (a separate format, out of scope for this slice).RefsOnlyTimeStamp remains surfaced present‑but‑unverified (no imprint recomputed) as before.
When the opt‑in algorithm policy is active (--algorithm-policy, off by default), evaluate_xades_algorithm_validity (crates/pverify-core/src/xades.rs) assembles a per‑object inventory and evaluates each object's digest / signature family / key length against the policy as of that object's VRT (read verbatim from the 031 vrt block, never recomputed). The signer‑signature family and digest come from the SignatureMethod descriptor and the key length from the signer SPKI; the signer‑chain object uses the leaf certificate's own signatureAlgorithm; each signature timestamp is judged on its imprint digest. The result is layered onto the indication via apply_algorithm_validity_indication, which mirrors the ETSI CRYPTO_CONSTRAINTS_FAILURE_NO_POE posture and never produces TOTAL_FAILED and never masks one. With the policy off, the field is None and the report is byte‑identical to a non‑policy run (Constitution §II). See Chapter 13 for the algorithm‑policy engine itself.
ASiC (ETSI EN 319 162) is a ZIP archive that binds one or more data objects to one or more detached AdES signatures, plus a mimetype entry identifying the container profile. pverify supports ASiC‑S (single data object) and ASiC‑E (multiple data objects, manifest‑described) and verifies the inner signatures by delegating each to the existing detached CAdES or detached XAdES pipeline — the only genuinely new cryptographic decision being the ASiC‑E CAdES manifest digest chain (§7.9).
As with XAdES, the kernel cannot parse ZIP/DEFLATE — that would pull zip + flate2 + miniz_oxide into the core graph (gate‑forbidden by scripts/cargo-tree-gate.sh). The host crate pverify-asic (crates/pverify-asic/src/lib.rs) reads the container in memory, discovers the META-INF/ signature material and data objects, parses the ASiCManifest, resolves ZIP‑relative ds:Reference URIs for detached XAdES, and emits one model‑free AsicSignature per signature (the boundary type owned by crates/pverify-core/src/asic.rs). The host threads these into VerificationRequest.asic_signatures; the kernel verifier verify_asic_signature (crates/pverify-core/src/asic.rs) maps each through the reuse path its AsicPayload variant selects.
Dispatch precedence. A ZIP container begins with the local‑file‑header magic PK\x03\x04, which the byte sniff would otherwise route to CAdES. So verify_with (crates/pverify-core/src/verify.rs) checks request.asic_signatures before the format sniff: if the host extracted any ASiC signatures they take precedence and each is verified independently (one signatures[] entry per signature, so one failure never suppresses others). A ZIP‑magic input for which the host produced no ASiC signatures — a ZIP that is not a recognisable ASiC container — is reported as INDETERMINATE asic_unsupported_container (asic_unsupported_entry), never allowed to fall through to the CAdES sniff and be misclassified.
The ZIP reader (crates/pverify-asic/src/zip.rs) wraps the pure‑Rust zip crate and adds the security envelope ASiC needs: a cap of 4096 entries, a 256 MiB total‑decompressed budget and a 128 MiB per‑entry cap (zip‑bomb refusal, returning an explicit ResourceBoundExceeded rather than silently truncating), and path‑traversal rejection (enclosed_name() == None, plus a defence‑in‑depth check for .. components and absolute paths). Everything is read into memory — no temp files.
detect (crates/pverify-asic/src/detect.rs) determines the profile best‑effort and records every packaging deviation rather than silently tolerating it — the §I honesty principle applied to container conformance, and matched to what real DSS output emits. A conformant mimetype authoritatively fixes the profile:
mimetype value |
profile |
|---|---|
application/vnd.etsi.asic-s+zip |
ASiC‑S |
application/vnd.etsi.asic-e+zip |
ASiC‑E |
| any other value | deviation mimetype_non_conformant_value, fall back to layout inference |
| absent | deviation mimetype_missing, fall back to layout inference |
ETSI EN 319 162 requires the mimetype to be the first ZIP entry and STORED (uncompressed). pverify checks both and surfaces mimetype_not_first_entry and mimetype_compressed as deviations (AsicDeviation, crates/pverify-core/src/report/mod.rs) — but still verifies the container. The deviation set is attached to every SignatureEntry from the container (asic_conformance_deviations), so an auditor sees exactly which ETSI packaging rules the container broke without the verdict being suppressed.
When the mimetype is absent or non‑conformant, infer_profile derives the profile from the META-INF/ layout: any ASiCManifest*.xml ⇒ ASiC‑E; a single META-INF/signature.p7s (no numeric suffix) ⇒ ASiC‑S, a numeric suffix or more than one signature ⇒ ASiC‑E; analogously for signatures.xml vs signaturesNNN.xml. Recognition requires some signature material — a ZIP with neither a recognisable META-INF/signature*.{p7s,xml} nor a conformant mimetype is not ASiC (detect returns None), and a conformant mimetype alone is insufficient (there must be something to verify). Signature‑file name matching is case‑sensitive per the standard (is_cades_signature, is_xades_signature, is_asic_manifest). The ASiCArchiveManifest (LTA, out of scope) and the OpenDocument informational manifest.xml are deliberately excluded from manifest recognition.
A container may carry both families (mixed signature*.p7s and signatures*.xml). extract processes both families present so no signature is silently dropped (crates/pverify-asic/src/lib.rs); the Detection.family field is diagnostic only and is not used to dispatch.
Each AsicSignature carries an AsicPayload that selects the reuse path (crates/pverify-core/src/asic.rs::verify_asic_signature):
CadesDirect (ASiC‑S CAdES). The detached CAdES messageDigest covers the single data object's raw bytes directly (no manifest). The kernel calls verify_cades_detached with data_object.bytes as the detached content — the entire CAdES path (chain build, content‑digest, revocation, timestamps, ETSI) is reused verbatim. The single data object is the sole entry that is neither mimetype nor under META-INF/ (sole_data_object).
CadesManifest (ASiC‑E CAdES) — the two‑step digest chain. This is the only new verification logic in the ASiC slice (R‑1):
ASiCManifest XML bytes verbatim. The kernel calls verify_cades_detached with manifest_bytes as the detached content.<asic:DataObjectReference> declares a DigestMethod and DigestValue. The host resolves each reference URI to the actual ZIP entry bytes (resolve_entry, tolerating a leading ./ and percent‑escapes DSS emits); the kernel recomputes digest_alg(actual_bytes) and compares it to the manifest's declared digest (apply_manifest_digest_chain, crates/pverify-core/src/asic.rs). This recomputation lives in the kernel so the integrity decision is part of the reproducible report.Each recomputation is recorded in asic_manifest_checks (the §III audit trail: URI, declared digest OID, match boolean). The verdict logic then enforces:
| Condition | Outcome |
|---|---|
step‑1 CAdES already TOTAL_FAILED |
left untouched (most severe; the step‑1 cause is reported) |
| any data‑object digest mismatch | TOTAL_FAILED asic_data_object_digest_mismatch — even if step 1 passed |
any data object absent from the archive (actual_bytes == None) |
INDETERMINATE asic_data_object_missing |
| all present and matching | step‑1 verdict stands |
A digest mismatch (positive evidence of tamper) outranks a missing object (which only means the content cannot be evaluated): a mismatch is TOTAL_FAILED, a mere absence is INDETERMINATE — the cannot‑affirm distinction between proof of alteration and inability to check. Crucially, a data‑object digest mismatch is reported as a failure even when the CAdES signature over the manifest verified perfectly, because the manifest binds the signature to content whose actual bytes no longer match — the integrity chain is broken at step 2.
Xades (ASiC‑S/E XAdES). The host (extract_xades, crates/pverify-asic/src/lib.rs) parses each META-INF/signatures*.xml through pverify_xades::extract_detached, supplying a resolver that maps each ZIP‑relative ds:Reference URI to the named entry's bytes. The resulting XadesComponents carry the DetachedResolved profile (§7.4), and the kernel calls verify_xades unchanged. The reference‑digest check (§7.3 step 3) then verifies each resolved data object's bytes against its stored DigestValue; an unresolvable URI leaves the reference bytes empty so that check fails honestly, never silently passing. This is how ASiC‑E XAdES — the default DSS ASiC‑E output — is verified by reusing the exact same reference‑digest machinery as enveloped XAdES, with only the detached gate narrowly lifted.
Every entry gets its container field set (ASiC-S / ASiC-E) and the conformance deviations attached, regardless of which payload path produced it.
The XAdES/ASiC paths add no new top‑level Indication; they reuse TOTAL_PASSED / INDETERMINATE / TOTAL_FAILED and contribute the following closed SubIndication values (crates/pverify-core/src/report/etsi.rs, mirrored 1:1 into the root report-schema.json):
| Sub‑indication | Indication | Meaning |
|---|---|---|
xades_reference_digest_mismatch |
TOTAL_FAILED | a ds:Reference digest did not match |
xades_signing_certificate_mismatch |
TOTAL_FAILED | SigningCertificateV2 binding mismatch |
xades_unsupported_profile |
INDETERMINATE | detached/enveloping (stand‑alone) packaging |
xades_unsupported_canonicalization |
INDETERMINATE | CanonicalizationMethod not Exclusive C14N |
xades_signer_certificate_unavailable |
INDETERMINATE | no usable ds:X509Certificate |
xades_timestamp_imprint_mismatch |
INDETERMINATE | an in‑scope timestamp imprint did not match / could not be verified, on an otherwise‑passing signature |
xades_unsupported (retained) |
INDETERMINATE | blanket refusal when no ds:Signature extracted |
asic_data_object_digest_mismatch |
TOTAL_FAILED | an ASiC‑E manifest DataObjectReference digest disagreed with the actual entry |
asic_data_object_missing |
INDETERMINATE | a manifest‑declared data object is absent from the archive |
asic_unsupported_container |
INDETERMINATE | a ZIP that is not a recognisable ASiC container |
The format‑specific invariants a reviewer should hold the implementation to:
pverify-xades and pverify-asic are WASM‑clean extraction crates carrying no cryptography (only parse, canonicalize, byte‑extract); they are gate‑asserted out of the pverify-core runtime graph. The kernel sees only Vec<u8> and closed enums.web/pverify-wasm/src/lib.rs) runs the byte‑identical pverify_xades::extract / pverify_asic::extract and the identical verify_with kernel, producing byte‑identical reports for identical inputs.UnsignedSignatureProperties) and an algorithm‑policy‑off run leave the report byte‑identical to pre‑slice output but for the schema_version string; the embedded‑revocation channels fire only when the live/CDP channel is indeterminate.INDETERMINATE with a precise sub‑indication — never a fabricated pass or fail.TOTAL_FAILED and are never overridden by a weaker timestamp or revocation indication; the timestamp‑imprint downgrade and the manifest‑digest enforcement explicitly only touch a non‑failed base.The report shape version is SCHEMA_VERSION = "1.10.0" (crates/pverify-core/src/report/schema.rs); the XAdES/ASiC sub‑indications and the ContainerFormat / AsicDeviation / AsicManifestCheck types are additive closed enums/structs (adding a value is a MINOR bump). Branch 036 added SignatureFormat::XadesBLta ("XAdES-B-LTA") and the imprint_input field on XadesUnverifiedTimestamp — both additive, backward‑compatible, MINOR additions that do not affect the schema_version. For the shared path, revocation, timestamp and VRT engines these paths reuse, see Chapters 9, 11 and 12.
JAdES (JSON Advanced Electronic Signatures, ETSI TS 119 182-1) is the fourth member of the ETSI AdES family that pverify verifies, alongside CAdES (EN 319 122), PAdES (EN 319 142) and XAdES (EN 319 132). Where the other three families build on CMS, PDF and XML respectively, JAdES builds on the JSON Web Signature (JWS) structure of RFC 7515, with the unencoded-payload extension of RFC 7797. This chapter documents what pverify actually verifies in the JAdES path — the JAdES baseline-basic (JAdES-B-B) level over the JWS JSON Serialization — the boundary between the host-side JSON extractor and the no_std verification kernel, and the precise set of shapes pverify deliberately refuses as INDETERMINATE rather than overclaiming conformance.
The implementation lives in two places, matching the architecture-wide host/kernel split described in Chapter 3: the host extraction crate pverify-jades (crates/pverify-jades/), which performs the JSON parse and all byte extraction, and the kernel orchestrator crates/pverify-core/src/jades.rs, which owns the model-free boundary type and runs the cryptographic verification and the shared RFC 5280 path / revocation / ETSI pipeline. The slice that introduced this support is specs/027-jades-bb-verify/.
The discipline of this path is that pverify reports the honest outcome for the material actually supplied and never adjusts its verdict to match another implementation. Two facts are kept strictly separate (specs/027-jades-bb-verify/spec.md, Clarification Q3):
A well-formed JWS that lacks the JAdES baseline properties is still cryptographically meaningful, but pverify will not label it JAdES-B-B; it is reported as detected-but-not-baseline and yields INDETERMINATE for the AdES-level claim. This avoids overclaiming JAdES conformance for a bare JWS.
The supported and refused shapes are summarised below. "Refused" always means a precise INDETERMINATE sub-indication, never a fabricated pass or failure (Constitution §I, "cannot affirm").
| Aspect | Supported (this slice) | Refused → INDETERMINATE |
|---|---|---|
| AdES level | JAdES-B-B (sigT signing time + signing-certificate binding) |
B-T (sigTst), B-LT (xVals/rVals), B-LTA (arcTst) — ETSI TS 119 182-1 §5.2.3–§5.2.7 — now supported (branch 037); rfsTst/tstVd → jades_unsupported_profile |
| Serialization | JWS JSON Serialization — general (signatures[]) and flattened (signature) |
JWS Compact Serialization → jades_unsupported_serialization (reserved; never reaches the JSON extractor) |
| Payload reference | Enveloping (payload inside the JWS structure) | JAdES sigD detached payload → jades_unsupported_serialization |
| Payload encoding | b64:true (RFC 7515) and b64:false (RFC 7797 unencoded) |
— |
alg |
RS256/384/512, PS256/384/512, ES256, ES384, EdDSA (Ed25519) | ES512/P-521, Ed448, none, anything else → signature_algorithm_unsupported |
| Signer cert | x5c[0] (RFC 7515 §4.1.6 standard base64 DER) |
x5t#S256-only with no host-side lookup → jades_signer_certificate_unavailable |
| Signing-cert binding | JOSE x5t#S256 (RFC 7515 §4.1.8, SHA-256) |
JAdES x5t#o signed property is out of MVP scope |
The closed SubIndication enum (crates/pverify-core/src/report/etsi.rs) carries five JAdES-specific values, mirrored 1:1 into the root report schema: jades_payload_digest_mismatch, jades_signing_certificate_mismatch, jades_unsupported_profile, jades_unsupported_serialization, jades_signer_certificate_unavailable. The SignatureFormat enum (crates/pverify-core/src/report/mod.rs) adds JAdES-B-B (a positively-verified baseline signature) and JAdES-unsupported (the retained label for refused shapes). These were additive, backward-compatible growth: slice 027 only appended values to existing closed enums, with no change to the report's top-level shape. The schema_version carried by the report is independent of this chapter's content and has since advanced to 1.10.0 through later slices (033 TSA revocation, 034 PAdES DSS/VRI, 035 OCSP nonce + CdpEntry, 037 JAdES B-T/B-LT/B-LTA — which bumped 1.9.0 → 1.10.0 by appending three new SignatureFormat variants: JAdES-B-T, JAdES-B-LT, JAdES-B-LTA); this chapter does not assert any specific bump for 027.
The kernel orchestrator verify_with (crates/pverify-core/src/verify.rs) routes by content sniff in detect_format: %PDF- → PAdES, an XML-leading input → XAdES, a JSON-leading input → JAdES, otherwise CAdES (ASiC is handled earlier when asic_signatures is non-empty). The JSON sniff is looks_like_json (verify.rs:375): it skips an optional UTF-8 BOM, then skips ASCII whitespace, and accepts only if the first non-whitespace byte is {. A top-level array ([) is explicitly not a JWS JSON Serialization document and is rejected by the sniff. Because JSON ({) and XML (<) are mutually exclusive on the first non-whitespace byte, the XAdES and JAdES sniffs never collide (verify.rs:354-369, research R-8).
The host (pverify-cli/src/main.rs::looks_like_json and web/pverify-wasm/src/lib.rs) mirrors this sniff byte-for-byte so that the CLI and the in-browser WASM build only invoke the JAdES extractor on JSON-leading payloads — one instance of the CLI↔︎WASM parity invariant. On the native CLI:
let jades_components = if looks_like_json(&signature_bytes) {
pverify_jades::extract(&signature_bytes).unwrap_or_default()
} else { Vec::new() };A hard extraction error is swallowed to an empty Vec here, but this is not a silent pass: in verify_with an empty jades_components for a JSON-sniffed input falls back to crate::jades::jades_unsupported_entry, which emits JAdES-unsupported / jades_unsupported_profile / INDETERMINATE (verify.rs:303-306). The consumer therefore always receives a definite fact.
JadesComponentsPer the architecture-wide rule that no JSON/XML/PDF parser ever links into pverify-core (Constitution §V), all JSON parsing happens host-side in pverify-jades and only model-free bytes cross into the kernel. The boundary type JadesComponents is declared in crates/pverify-core/src/jades.rs (so the no_std side owns its shape) and re-exported by pverify-jades (crates/pverify-jades/src/lib.rs). One value is produced per JWS signature in the document — N for general serialization, 1 for flattened.
| Field | Type | Meaning / source |
|---|---|---|
signing_input |
Vec<u8> |
The JWS Signing Input, assembled by the host (§8.4). Core never recomputes it (no JSON in core). |
signature_method |
JadesSigAlg |
The alg protected-header value mapped to a verifiable family (§8.6). |
signature_value |
Vec<u8> |
base64url-decoded JWS signature. For ECDSA the host has already converted raw r‖s → DER Ecdsa-Sig-Value. |
signer_cert_der |
Option<Vec<u8>> |
DER of the signer certificate from x5c[0]. None ⇒ signer-certificate-unavailable. |
extra_certs_der |
Vec<Vec<u8>> |
The remaining x5c chain certs offered for path building (mirrors XAdES). |
signing_cert_binding |
Option<JadesSigningCertBinding> |
The JOSE x5t#S256 digest binding, when present. |
profile |
JadesProfile |
EnvelopedBB for the supported shape, else Unsupported { reason }. |
The host threads the extracted Vec<JadesComponents> into the additive VerificationRequest.jades_components field (verify.rs:110); a non-JAdES input leaves it empty, preserving the byte-identity invariant for every other format. The extractor itself performs no cryptography — only JSON parse plus byte extraction (crates/pverify-jades/src/lib.rs).
The signing input is the byte string the JWS signature is computed over. The host builds it in crates/pverify-jades/src/signing_input.rs::build:
signing_input = ASCII(BASE64URL(UTF8(protected_header))) || '.' || payload_segment
A load-bearing subtlety: the host uses the protected member's base64url string verbatim from the document — it does not re-encode the decoded header. Re-encoding could drift from what the producer signed when JSON is re-emitted with different whitespace, which would corrupt the signing input. The decoded header is used only to read its members (alg, b64, x5c, x5t#S256, profile keys); the byte string fed to the signature check is the raw transmitted form.
The payload_segment depends on the RFC 7797 b64 protected-header flag (signing_input.rs::build, parse.rs:123):
b64 absent or true (the RFC 7515 default): the document's payload member is itself BASE64URL(payload_bytes), and that ASCII string is appended as-is.b64:false (RFC 7797 unencoded payload): the raw payload bytes are appended directly, with no second base64url step.In the current implementation both branches append payload_str.as_bytes() (the b64:false branch interprets the document's payload member as the raw payload bytes carried as a UTF-8 string). The slice deliberately supports both because real JAdES output — including the ETSI DSS reference vectors — routinely sets b64:false (Clarification Q1). The extractor records the producer-declared b64 value honestly even when it is not also listed in crit (a producer protocol error per RFC 7797 §6); it still builds the signing input the way the producer signed it, and the kernel's signature check then reports the truth. The construction is exercised against the RFC 7515 §A.1 and RFC 7797 §4.2 canonical vectors in signing_input.rs unit tests.
The signer certificate is resolved host-side in crates/pverify-jades/src/parse.rs::resolve_x5c. The x5c header is preferred from the protected header, falling back to the unprotected header. Per RFC 7515 §4.1.6, each x5c element is standard base64 (with padding) DER — note the deliberate contrast with the base64url used everywhere else in JWS; the parser uses the STANDARD engine for x5c and URL_SAFE_NO_PAD for protected/signature/x5t#S256. x5c[0] becomes signer_cert_der; the remainder become extra_certs_der path candidates, mirroring how embedded XAdES certificates flow. If no x5c is present the result is (None, []), and the kernel surfaces jades_signer_certificate_unavailable (INDETERMINATE). The MVP does not perform a host-side x5t#S256 certificate-store lookup, so an x5t-only document with no x5c is unavailable, not resolved (parse.rs comment, R-3).
The signing-certificate binding is the certificate-substitution defence. The host extracts the JOSE x5t#S256 thumbprint (RFC 7515 §4.1.8 — a fixed SHA-256 digest of the signer cert, base64url-decoded) into JadesSigningCertBinding { cert_digest, digest_alg: Sha256 } (parse.rs:147-158). The JAdES x5t#o signed property is out of MVP scope. The kernel verifies the binding in verify_jades step 5 (jades.rs:262-271): it recomputes compute_digest(SHA-256, signer_der) and compares against the claimed digest; a mismatch is TOTAL_FAILED / jades_signing_certificate_mismatch. This mirrors the XAdES xades_signing_certificate_mismatch behaviour and is the JSON analogue of the ESS signing-certificate binding for CAdES (see Chapter 5).
alg → verifiable family)The host maps the JWS alg protected-header string to a JadesSigAlg in crates/pverify-jades/src/algid.rs::map_alg. The mapping introduces no new cryptographic primitive — every supported alg resolves to a family pverify-core::crypto already verifies (research R-7 / FR-004):
JWS alg |
JadesSigAlg |
Notes |
|---|---|---|
| RS256/384/512 | RsaPkcs1v15 { digest } |
RSA PKCS#1 v1.5, RFC 7518 §3.3 |
| PS256/384/512 | RsaPss { digest, salt_len } |
RSASSA-PSS, MGF1 over the same digest; salt length = digest length (32/48/64) |
| ES256 / ES384 | Ecdsa { digest } |
curve resolved from the signer SPKI inside core; alg pins only the digest |
| EdDSA | Ed25519 |
RFC 8037; Ed448 is out of scope |
none, ES512/P-521, Ed448, anything else |
Unsupported { alg } |
round-trips the raw string so the report names it verbatim |
none is refused — never a pass (RFC 7518 §3.6; algid.rs unit test refuses_alg_none). An Unsupported alg reaches the kernel and surfaces as INDETERMINATE / signature_algorithm_unsupported, with the locus naming the alg (alg:ES512), so the failing input is auditable.
For ECDSA, the JWS signature is the fixed-width concatenation r‖s (RFC 7518 §3.4), whereas RustCrypto's verifier expects a DER Ecdsa-Sig-Value SEQUENCE { r INTEGER, s INTEGER }. The host converts it in parse.rs::ecdsa_rs_to_der (mirroring pverify-xades), correctly handling minimal-integer encoding and the leading-zero pad when the high bit is set. An odd-length (malformed) r‖s is passed through unchanged so the kernel rejects it as a fact rather than panicking.
The host classifies the profile in parse.rs::classify_profile, deciding between the supported EnvelopedBB and the four JadesUnsupportedReason discriminants. The order of tests is significant:
sigD wins first: a JAdES detached payload reference is out of scope (FR-008). It is later mapped to jades_unsupported_serialization.sigTst (B-T), xVals/rVals (B-LT), or arcTst/atsHashIndex (B-LTA) no longer marks the input HigherLevel. Branch 037 routes these to the ETSI verification path — see §8.13 for details.rfsTst/tstVd remain unsupported: these mark the input HigherLevel → jades_unsupported_profile.EnvelopedBB requires both a sigT signing-time header and a signing-certificate binding (Clarification Q3). The JOSE x5t#S256 thumbprint counts as the binding. A well-formed JWS that satisfies neither is NotJadesBaseline → jades_unsupported_profile — verified cryptographically if asked, but never labelled JAdES.The kernel maps these reasons to sub-indications in verify_jades step 1 (jades.rs:164-180): NotJadesBaseline → JadesUnsupportedProfile; DetachedSigD and CompactSerialization → JadesUnsupportedSerialization. HigherLevel now fires only for rfsTst/tstVd → JadesUnsupportedProfile. CompactSerialization is a reserved variant — a JWS Compact string is never JSON-object-rooted, so it never reaches the JSON-only extractor; the host's JSON sniff would not route it here.
verify_jadescrate::jades::verify_jades (crates/pverify-core/src/jades.rs:154) consumes one JadesComponents and produces one SignatureEntry. It is structurally identical to verify_xades: it performs the JAdES-specific gates, then hands the resolved signer certificate to the same RFC 5280 path / revocation / ETSI aggregation pipeline every other format uses. The ordered steps are:
Step 4 — signature verification. RSA-PSS dispatches directly to crate::crypto::verify_rsa_pss with the explicit digest and salt length; everything else resolves a signatureAlgorithm OID via jades_sig_alg_oid and calls verify_with_alg (jades.rs:228-242). The SPKI is re-extracted from the signer cert DER via extract_spki_der. If the verifier returns an UnsupportedSignatureAlgorithm error, the result is INDETERMINATE / signature_algorithm_unsupported; any other error (the genuine signature mismatch) is TOTAL_FAILED / jades_payload_digest_mismatch. This sub-indication is the JSON analogue of XAdES's xades_reference_digest_mismatch; note that for JAdES it covers both a tampered payload (which changes the signing input) and a tampered signature value, since both manifest as a signature-verification failure over the signing input (e2e tests tampered_payload_… and tampered_signature_…, both → jades_payload_digest_mismatch).
Step 6 — shared pipeline. The signer certificate and the extra_certs_der path candidates are handed to validate_path (RFC 5280 §6 basic-plus chain construction — see Chapter 9), then wire_chain_step_signatures verifies each chain step's certificate signature with RustCrypto primitives, then apply_revocations evaluates revocation for the signer chain. Crucially, apply_revocations is called with empty embedded-CRL and embedded-OCSP slices (&[], &[], jades.rs:295-305): JAdES-B-B carries no embedded validation material, so revocation is driven purely by the live/CDP channel under the active Mode. In --offline mode with no cached material this honestly degrades to IndeterminateRevocationOffline / revocation_not_checked_offline (INDETERMINATE), never a fabricated responder contact (Constitution §VII).
The SignedAttrsCheck is reported as a synthetic "verified" record (jades_signed_attrs_ok, jades.rs:575) — JWS has no CMS signedAttrs as such; the signature over the signing input is the integrity binding, and it has already been checked in step 4. The ContentDigestCheck records the SHA-256 of the signing input as covered_content_sha256 so the 016 validation_objects[] material inventory can surface the signed material (jades.rs:312-313), mirroring XAdES.
The base ETSI indication is folded by the shared aggregate_etsi (jades.rs:315) over the chain outcome, the (verified) signed-attrs record and the content-digest record, with severity ordering TOTAL_FAILED > INDETERMINATE > TOTAL_PASSED, first-finding-wins. A healthy chain to a supplied anchor with no revocation problem in the supplied material yields TOTAL_PASSED (e2e rs256_enveloping_total_passed, es256_enveloping_total_passed). A revoked or path-broken chain yields the corresponding INDETERMINATE/TOTAL_FAILED exactly as for CAdES — the JAdES path introduces no new chain-level verdict logic.
JAdES-B-B carries no covering timestamps (etsiU is empty in this slice), so the per-object Validation Reference Time block is the honest degenerate case. verify_jades still calls the shared crate::vrt::derive_vrt with empty timestamp slices and CoveringSource::Jades (jades.rs:332-339); the engine returns a Vrt block whose signer_chain and signer_signature both have derivation == request_at and value == verification_time (FR-005 / FR-011a). In branch 037, the sigTst (B-T) and arcTst (B-LTA) tokens thread through the shared recursive-outer-covering engine with no wire-shape change. The arcTst hash input is non-circular: BASE64URL(protected).BASE64URL(payload).BASE64URL(signature).BASE64URL(sigTst_verbatim_b64u) — the current arcTst token itself is excluded from the hash input. Per-object VRT is documented in detail in Chapter 12.
Two later cross-cutting layers are wired but degenerate for B-B today:
--algorithm-policy is active, evaluate_jades_algorithm_validity (jades.rs:394) assembles two ObjectUnderEvaluations — the signer chain (judged by the leaf certificate's own signatureAlgorithm OID) and the signer signature (family/digest taken from the JWS alg descriptor; e.g. RS256 → RSA + SHA-256, mapped back to OID 1.2.840.113549.1.1.11) — both at the (request-time) VRT, and runs the shared CRYPTREC/NIST policy evaluator. The result populates algorithm_validity and can only degrade a non-TOTAL_FAILED base, never produce TOTAL_FAILED (e2e jades_algorithm_validity_populates_when_enforced). Off by default, this leaves the report byte-identical.apply_tsa_revocation_indication is called with an empty token slice (jades.rs:363) — a no-op for B-B since there are no covering timestamps. The seam exists so that future JAdES timestamp levels degrade their indication through the same shared apply_tsa_chain_revocation_for_token path as PAdES/CAdES.A positively-verified signature is reported with format: JAdES-B-B and content_source: Embedded (the payload is enveloped). All refused shapes are reported with format: JAdES-unsupported and the matching INDETERMINATE sub-indication. The signer_cert_der/extra_certs_der feed the consolidated validation_objects[] inventory through the standard 016 derivation; JAdES introduces no new ValidationObjectOrigin variant (the DSS/VRI-specific pdf_dss/pdf_vri of branch 034 are PAdES-only).
The native CLI and the in-browser WASM host run the byte-identical extractor (pverify_jades::extract) and the identical verify_with kernel, so identical inputs produce byte-identical reports — the CLI↔︎WASM parity invariant, pinned by web/pverify-wasm/src/lib.rs parity tests (wasm_jades_sniff_and_extract_at_parity) and the CLI e2e suite crates/pverify-cli/tests/jades_bb_e2e.rs. The e2e tests cover the five canonical scenarios: RS256 and ES256 enveloping happy paths (TOTAL_PASSED), one-byte payload tamper and signature tamper (TOTAL_FAILED / jades_payload_digest_mismatch), signing-cert binding mismatch (TOTAL_FAILED / jades_signing_certificate_mismatch), signer-cert unavailable (INDETERMINATE), and sigD/plain-JWS refusals (INDETERMINATE / jades_unsupported_serialization and jades_unsupported_profile). Reproducibility follows from the kernel reading time only from the captured verification_time (never Clock::now), so a given (input, anchors, time, revocation-material) tuple yields a deterministic report.
| Standard | Where honoured |
|---|---|
| ETSI TS 119 182-1 (JAdES) | profile classification: sigT + signing-cert binding = B-B; sigTst (B-T), xVals/rVals (B-LT), arcTst (B-LTA) — ETSI TS 119 182-1 §5.2.3–§5.2.7 — now verified (branch 037); rfsTst/tstVd remain refused (parse.rs::classify_profile) |
| RFC 7515 (JWS) | JSON Serialization parse (general + flattened), §5.1 signing input, §4.1.6 x5c standard-base64 DER, §4.1.8 x5t#S256 (parse.rs, signing_input.rs) |
| RFC 7797 (unencoded payload) | b64:false handling in signing_input.rs::build; honest recording of the declared flag |
| RFC 7518 / RFC 8037 (JWA / EdDSA) | alg → family mapping; salt-length = digest-length for PSS; none refused (algid.rs) |
| RFC 5280 (path validation) | shared validate_path over the resolved signer + x5c chain (jades.rs step 6; Chapter 9) |
| RFC 6960 / 5280 (OCSP / CRL) | shared apply_revocations (no embedded material for B-B); offline honesty preserved |
| ETSI EN 319 102-1 (indications) | shared aggregate_etsi; closed SubIndication/Indication enums |
The JAdES path is deliberately conservative: it climbs one rung of the AdES baseline ladder (B-B), reuses the entire established path/revocation/ETSI machinery from the point the signer certificate is in hand, and refuses everything it cannot fully verify with a precise, auditable INDETERMINATE rather than a guess. The higher levels (B-T/B-LT/B-LTA) and the result-report JWS-envelope tech demo are explicitly out of scope for this slice (specs/027-jades-bb-verify/spec.md). Note: branch 037 has since implemented B-T/B-LT/B-LTA support — see §8.13 for the full implementation details.
Branch 037 (specs/037-jades-bt-blta/) extended the JAdES verification path to cover the full ETSI TS 119 182-1 §5.2.3–§5.2.7 unsigned-properties ladder. The implementation adds three new SignatureFormat variants — JAdES-B-T, JAdES-B-LT, JAdES-B-LTA — and bumps schema_version from 1.9.0 to 1.10.0. All three e2e tests in crates/pverify-cli/tests/jades_bt_blta_e2e.rs pass.
The sigTst unsigned property in the JWS unprotected header carries one or more RFC 3161 timestamp tokens over the JWS Signing Input. The hash input is:
sigTst_hash_input = BASE64URL(protected) || "." || BASE64URL(payload)
This is exactly the JWS Signing Input (signing_input, §8.4) — the same byte string the signer's private key operated over. The host extracts the sigTst token DER from the unprotected header and passes it to the kernel via JadesComponents.sig_tst_tokens. The kernel verifies the token imprint against a recomputation of the signing input and threads the token through apply_tsa_chain_revocation_for_token (the 033 shared helper), setting the per-object VRT for the signer objects to the token's genTime when it verifies. A sigTst-bearing input is promoted to JAdES-B-T.
The xVals and rVals unsigned properties carry certificate chains and revocation material (CRLs / OCSP responses) embedded in the JWS unprotected header. The host decodes and passes these as JadesComponents.x_vals and JadesComponents.r_vals. The kernel feeds them into apply_revocations via the same embedded_crls / embedded_ocsp_responses channels that PAdES-LT and the 034 DSS path use — no new revocation logic. A xVals/rVals-bearing input (that also carries sigTst) is promoted to JAdES-B-LT.
The arcTst unsigned property carries an archive timestamp token over the entire JWS structure accumulated so far, but excluding the current arcTst token itself (non-circular). The hash input is:
arcTst_hash_input = BASE64URL(protected) || "." || BASE64URL(payload) || "." || BASE64URL(signature) || "." || BASE64URL(sigTst_verbatim_b64u)
where sigTst_verbatim_b64u is the literal base64url string of the sigTst property as it appears in the unprotected header. The current arcTst token is deliberately excluded from its own hash input. The kernel verifies the arcTst imprint, threads the token through apply_tsa_chain_revocation_for_token, and sets the per-object VRT anchor to the arcTst genTime. A fully evidenced input is promoted to JAdES-B-LTA.
The following are explicitly out of scope for branch 037 and remain refused with jades_unsupported_profile / INDETERMINATE:
rfsTst and tstVd unsigned properties (validation report / timestamp validation data).sigD detached payload reference.draft-miyachi-ltv-jws-00 extends JWS with a top-level ltv object that carries validation evidence at four progressive levels, described as SIG-B through SIG-LTA. pverify implements experimental support for all four levels via the same pverify-jades extractor and verify_with kernel path — no new crate, no new pipeline.
The ltv header map is parsed in crates/pverify-jades/src/parse.rs. The four levels map to SignatureFormat values LTV-JWS-SIG-B, LTV-JWS-SIG-T, LTV-JWS-SIG-LTV, and LTV-JWS-SIG-LTA. Shapes that carry an ltv key but cannot be classified (malformed or unrecognised structure) are reported as LTV-JWS-unsupported / INDETERMINATE / jades_unsupported_profile.
Level determination rules (implemented in classify_ltv_level, parse.rs):
| Level | Distinguishing feature |
|---|---|
| SIG-LTA | ltv.archive present and decodable |
| SIG-LTV | ltv.timestamp present, decoded JSON contains validations key |
| SIG-T | ltv.timestamp present, no validations key |
| SIG-B | ltv.signing present, no higher-level keys |
Each ltv.* value is Base64URL-encoded JSON (not inline JSON), so the extractor decodes and re-parses each field. The ltv.signing object carries the signer certificate chain and is the bridge to the existing path-validation pipeline: the signer leaf and intermediate DER bytes are extracted and fed as x5c-equivalent chain candidates to validate_path. Revocation material embedded in ltv.signing or ltv.timestamp is threaded into apply_revocations / apply_tsa_chain_revocation_for_token via the same embedded_crls / embedded_ocsp_responses channels that PAdES-LT uses. This is additive reuse of existing infrastructure — no new revocation logic was introduced.
Interoperability findings recorded in docs/research/ltv-jws-compatibility-notes.md include: the BASE64URL wrapping (versus bare inline JSON) in ltv.timestamp/ltv.signing; the {"timestamp": BASE64URL} dict shape of ltv.archive; and the validations key as the SIG-LTV/T discriminator. These findings are candidates for feedback to the draft author.
Scope and status. This is an experimental implementation tracking draft-miyachi-ltv-jws-00. The format is subject to change as the draft evolves. The four LTV-JWS-* SignatureFormat values are additive additions to the existing closed enum and are backward-compatible. No constitutional amendment was required. The 18 integration tests in crates/pverify-cli/tests/ltv_jws_fixtures.rs cover all four levels and the unsupported-profile fallback.
Release: v1.3.0 · Report schema: 1.9.0
This chapter specifies the X.509 certification path engine of pverify-core — the subsystem that, given a leaf (end-entity or CA) certificate, a pool of candidate intermediate certificates, a set of trust anchors and a verification time, constructs a certification path and evaluates the structural RFC 5280 §6 invariants over it. The engine is the foundation on which every AdES format verdict rests: CAdES, PAdES, XAdES, JAdES and the ASiC-delegated inner signatures all converge on the same validate_path entry point (see Chapter 4 for the format-dispatch overview).
The engine lives entirely in crates/pverify-core/src/path/:
| File | Responsibility |
|---|---|
path/mod.rs |
Chain construction (DFS + backtrack), validity-at-time, BasicConstraints, KeyUsage, structural-finding aggregation, the orchestration that threads name-constraints and policy state through each step |
path/name_constraints.rs |
RFC 5280 §4.2.1.10 / §6.1.4(g) name-constraints state machine |
path/policy.rs |
RFC 5280 §6.1 valid_policy_tree processing, policy mapping, policyConstraints/inhibitAnyPolicy countdowns |
path/bridge.rs |
Bridge-CA cross-certificate crossing detection |
A second concern — verifying each non-anchor step's signature against its parent's SubjectPublicKeyInfo — is deliberately not in path/mod.rs. It is wired in the orchestrator (crates/pverify-core/src/verify.rs, wire_chain_step_signatures, verify.rs:1944) so that the RustCrypto dispatch is pulled into the dependency graph exactly once and shared with the CMS signer-signature verification. This split is explained in §9.8.
The engine performs pure computation: it takes already-parsed Certificate projections and TrustAnchor byte structures (the host has done all DER ingestion and trust-anchor loading, per the Constitution §V/§VI boundary described in Chapter 3) and returns a ChainOutcome. It opens no socket, reads no file and never consults Clock::now; the only time input is the verification_time argument supplied by the caller, which is the per-object Validation Reference Time (VRT) derived as described in Chapter 12 (timestamps / VRT). This is what lets the same engine run byte-identically on native and wasm32-unknown-unknown targets.
This chapter is grounded in the specifications specs/003-v03-rfc5280-path/ (v0.3 — name constraints, policy processing, anchor-driven initialisation) and specs/008-v06-bridge-acceptance/ (v0.6 — DFS+backtrack chain construction, multi-bridge traversal, cycle detection).
RFC 5280 §6 path validation has two distinct halves:
basicConstraints, keyUsage, name constraints, certificate policies. This half is implemented in path/mod.rs and its sibling modules and is the primary subject of this chapter.signatureValue was produced by the private key matching the issuer's subjectPublicKeyInfo. This half is implemented in verify.rs::wire_chain_step_signatures (§9.8).The engine is honest about what it does and does not enforce. In particular, the extendedKeyUsage extension is parsed and surfaced into every ChainStep (ChainStep.extended_key_usages, path/mod.rs:334) but is not enforced by the path engine: there is no rejection of an end-entity certificate lacking a required EKU, and — notably for auditors — no id-kp-timeStamping (OID 1.3.6.1.5.5.7.3.8) requirement is imposed on TSA certificates within the path engine (grep of path/ and timestamp.rs finds no EKU comparison). EKU is reported as observed fact, consistent with the Constitution §I fact-reporting discipline; relying parties who require EKU enforcement read the surfaced extended_key_usages field. This scope statement is repeated where relevant below rather than buried.
The single entry point is:
pub fn validate_path(
leaf: &Certificate,
intermediates: &[Certificate],
anchors: &[TrustAnchor],
verification_time: OffsetDateTime,
required_policies: &[String],
) -> ChainOutcome(path/mod.rs:103). Its inputs and the contract on each:
| Parameter | Meaning |
|---|---|
leaf |
The certificate at chain depth N (the signer cert, or a TSA cert when validating a timestamp's signer chain) |
intermediates |
A flat, unordered pool of candidate CA certificates (from the CMS certificates set, /DSS /Certs, embedded certificate-values, etc.) |
anchors |
The trust-anchor set; each carries DER bytes, SHA-256 fingerprint, raw subject DN and a host-computed validity_window_covers_request_time flag (crate::traits::TrustAnchor) |
verification_time |
The per-object VRT (031-vrt-per-object, FR-002a). The signer chain is validated at vrt.signer_chain.value; each TSA chain at the next-outer covering timestamp's GenTime. The function itself simply judges validity/freshness at whatever time it is given. |
required_policies |
The user-initial-policy-set per RFC 5280 §6.1.1(c). An empty slice means {anyPolicy} (v0.1/v0.2 behavioural compatibility); populated by the CLI --required-policy <OID> flag (FR-053) |
The return value:
pub struct ChainOutcome {
pub chain_result: ChainResult, // the per-step report tree
pub structural_indication: Option<StructuralFinding>, // the dominant path-level finding
}chain_result is the ChainResult consumed by the report serialiser — one ChainStep per certificate including the terminating anchor, plus the terminating_anchor_fingerprint and the bridge_attempts vector. structural_indication is None when path validation raised no structural objection (the CMS / revocation / digest layers downstream still have to run before TOTAL_PASSED), or Some(StructuralFinding) carrying an Indication + SubIndication + locus (step[N] or anchor/chain) that aggregate_etsi (§9.9) folds into the per-signature ETSI verdict.
The StructuralFinding always carries a locus (path/mod.rs:81) so that every verdict points at the specific offending certificate, in keeping with the Constitution §I requirement that the report is a fact inventory with a failing_locus, not a bare boolean.
Chain construction is a depth-first search with backtracking, implemented by construct_chain (path/mod.rs:477). It replaced the v0.5 single-pass "first matching intermediate" walk in branch 008 (FR-601/FR-602) so that multi-bridge chains and dead-end intermediates are handled correctly, while preserving byte-identical output for the single-anchor straight chains that the single-pass walk already produced (V1–V23): at each depth the walker still prefers the first matching candidate, so it never needs to backtrack on a straight chain.
Key invariants of construct_chain:
path/mod.rs:536-572):
OfficialStatusCA 2019 vs. 2024 key-update, which carry the same Subject DN but distinct SKIDs. TrustAnchor.subject_key_identifier carries the SKID bytes and Certificate.authority_key_identifier carries the child's AKID keyid; the comparison selects the anchor whose public key the child certificate was actually signed by.signature_verification: Failed on the chain step even though the chain is cryptographically valid — the signer's certificate was issued by generation A but the store contained both A and B, and a DN-only walk happened to select B first. This is why an expired-but-matching anchor still terminates the chain (and is then flagged, §9.4).match_dn (the rfc4518-minimal comparator, §9.10), via .linkage. The walker never compares raw DER bytes for chaining decisions.tried_indices[i] records the next pool index to attempt at chain depth i+1. When the walker selects intermediate i it advances the slot to i+1 (path/mod.rs:614), so on backtrack the next sibling is tried rather than re-selecting the same intermediate — the explicit-indexing recovery of the old single-pass invariant.MAX_CHAIN_DEPTH = 10 (path/mod.rs:62). This was re-evaluated in v0.6 against the worst-case multi-bridge chain (leaf → ApCA-foreign → Foreign-Bridge → Domestic-Bridge → DomesticRoot, 5 steps) and kept at 10. Hitting the cap does not panic; the walker falls through to backtrack and ultimately surfaces a chain-incomplete INDETERMINATE (§9.6).Cross-certification graphs (notably GPKI BridgeCA ↔︎ peer bridges) can contain cycles. A naive walk could loop forever. construct_chain maintains a path-local BTreeSet<[u8; 32]> (cycle_set) keyed by the SHA-256 fingerprint of the full DER-encoded certificate (cert.fingerprint, path/mod.rs:496-497). A candidate intermediate already on the current path is skipped (path/mod.rs:580); the set is populated on push and cleared on backtrack (backtrack, path/mod.rs:667).
The choice of the full-cert DER fingerprint as the identity key — rather than the SubjectPublicKeyInfo fingerprint or a (Subject DN, SPKI) tuple — is deliberate and documented in specs/008-v06-bridge-acceptance/spec.md (Clarification Q7, FR-602). During GPKI / FBCA cross-certificate rotation periods the trust store legitimately holds two Bridge-CA certificates with identical (Subject DN, SPKI) but different signatures and validity windows; SPKI- or tuple-keyed cycle detection would treat them as one logical node and falsely refuse the second, valid crossing. Full-DER fingerprint correctly distinguishes them. RFC 5280 §6 specifies no cycle-detection algorithm (it assumes a pre-constructed path), so this is pverify's own termination guarantee, not an RFC-mandated identity.
construct_chain returns (chain, bridge_pairs, terminating_anchor):
chain — the leaf-first certificate path (chain[0] = leaf, chain[len-1] = the intermediate closest to the anchor).bridge_pairs — every observed cross-certificate crossing as a [child_fp, attached_fp] pair (§9.7). Empty for straight chains.terminating_anchor — Some(anchor) when a path to an anchor was found, None otherwise.After construction, validate_path walks chain leaf-first (path/mod.rs:144) and records the structural facts and findings per step.
For each certificate the engine evaluates cert.validity.covers(verification_time) (path/mod.rs:159). When the window does not cover the time, the engine distinguishes "not yet valid" from "expired" so the sub-indication is honest (path/mod.rs:163):
| Condition | Indication | SubIndication |
|---|---|---|
verification_time < not_before |
TOTAL_FAILED |
SignerCertificateNotYetValidAtTime |
verification_time > not_after |
TOTAL_FAILED |
SignerCertificateExpiredAtTime |
The trust anchor is handled separately by anchor_chain_step (path/mod.rs:700): it uses the host-computed validity_window_covers_request_time flag, and an expired anchor yields INDETERMINATE / AnchorExpiredAtTime (path/mod.rs:715), not TOTAL_FAILED. The asymmetry is intentional — an out-of-window leaf/intermediate is a hard validity failure of the path, whereas an out-of-window anchor is a configuration question the relying party may resolve differently, so pverify reports the fact at INDETERMINATE rather than asserting forgery (Constitution §I "cannot affirm").
The anchor terminal step re-parses the anchor DER once (path/mod.rs:709) so that it can carry the same field shape — subject text, validity window, key usage, basic constraints, signature algorithm — as the leaf/intermediate steps. If the DER fails to decode (which should not happen, since the host validated it at load time) the step is still emitted with placeholder fields so the report can serialise (<unparseable anchor>, path/mod.rs:742) — the no-panic posture of Constitution §I.
On every non-leaf step (idx > 0), basicConstraints.cA MUST be true (path/mod.rs:182). When the extension is absent or cA == false, the engine emits INDETERMINATE / KeyUsageMissingRequiredBit — reusing the broader KeyUsageMissingRequiredBit sub-indication because, as the code comments, the absence of the cA bit is "the structural twin of keyCertSign absence" and v0.3 chose not to mint a bespoke missing-cA sub-indication (path/mod.rs:187-194).
The numeric pathLenConstraint is surfaced into the report (ReportBasicConstraints.path_len_constraint) but not numerically enforced (path/mod.rs:179-181 comment: "v0.1 ignores path_len_constraint numerically"). Auditors should note this: a path that exceeds an intermediate's declared pathLenConstraint is reported with the constraint visible but is not rejected on that basis alone. The hard depth cap of §9.3.1 (MAX_CHAIN_DEPTH = 10) is the only depth limit actually enforced.
| Step | Requirement (when extension present) | Failure |
|---|---|---|
Leaf (idx == 0) |
MUST assert digitalSignature or nonRepudiation; and the extension MUST be present (empty = absent ⇒ fail) |
TOTAL_FAILED / KeyUsageMissingRequiredBit |
| Non-leaf | When the keyUsage extension is present, MUST assert keyCertSign (absent extension is tolerated) |
TOTAL_FAILED / KeyUsageMissingRequiredBit |
(path/mod.rs:202-225.) The leaf rule is stricter than RFC 5280 strictly requires — RFC 5280 permits an implementation to require or skip when the extension is absent; pverify v0.1+ requires the leaf to carry keyUsage with a signing bit so that signing intent is explicit (path/mod.rs:208-209). The acceptance of nonRepudiation (a.k.a. contentCommitment) alongside digitalSignature matches the GPKI/JNSA profile where non-repudiation certificates are common.
Name-constraints processing is anchor-first while the chain walk is leaf-first, so the engine runs a pre-pass (compute_name_constraints_states, path/mod.rs:822) that builds the cumulated state at every chain depth before the leaf-first step loop consults it. The pre-pass:
nameConstraints (initialise_from_anchor, name_constraints.rs:83) — the RFC 5937 anchor-driven discipline mandated by FR-046a/Q3. This is the operationally important case for GPKI Bridge-CA where the domestic root declares PKI-wide constraints.apply_intermediate (name_constraints.rs:97) to absorb each CA's constraints, recording the state after each absorption, then reverses the vector so the leaf-first step loop can index it directly (path/mod.rs:859-873).The state (NameConstraintsState, name_constraints.rs:54) keeps independent per-form lists, mirroring RFC 5280 §6.1.4(g)'s "constraints maintained for each name form":
permitted: BTreeMap<GeneralNameForm, Vec<SubtreeConstraint>>excluded: BTreeMap<GeneralNameForm, Vec<SubtreeConstraint>>unsupported_forms_pending: Vec<String>Per RFC 5280, permittedSubtrees compose by intersection and excludedSubtrees by union. The implementation maintains the union of every depth's permitted entries per form and performs the intersection logic at check time — equivalent to per-depth intersection for the supported-form set, and documented as such (name_constraints.rs:109-117).
The supported GeneralName forms are directoryName, dNSName, rfc822Name and uniformResourceIdentifier (name_constraints.rs:34, FR-043). Any other form (x400Address, ediPartyName, registeredID, otherName) that appears in a constraint is recorded in unsupported_forms_pending and triggers the conservative reject of §9.5.4 (FR-614).
check_subject)check_subject (name_constraints.rs:165) evaluates the subject and SAN entries against the cumulated state, with strict per-form discipline (Q1) — cross-form matching is forbidden:
directoryName matches the certificate subject (and any directoryName SAN entries). The match rule is RFC 5280's "constrained DN is a prefix (in DIT terms) of the name." Because RFC 4514 renders RDNs most-specific-first, the implementation does a case-insensitive suffix comparison with comma-boundary checking (dn_under, name_constraints.rs:468), under the rfc4518-minimal canonicalisation (lowercase, whitespace-stripped around RDN separators, canonical_dn, name_constraints.rs:446). The limitation is disclosed in the code (name_constraints.rs:289-295): constraints depending on ASN.1-level RDN ordering or per-attribute matching fall outside the v0.3 rule.dNSName — case-insensitive; a leading . denotes "strict subdomain of" (.example.jp matches host.example.jp but not example.jp); without the dot the constraint matches the exact host or any subdomain (matches_dns, name_constraints.rs:388).rfc822Name — a constraint containing @ is a specific mailbox; a leading-dot constraint matches strict subdomains; a bare-domain constraint matches addresses whose domain is exactly that domain (matches_rfc822, name_constraints.rs:400). Per the RFC 5280 §6.1.4(g) note, an rfc822Name constraint additionally matches the emailAddress AVA inside the subject if and only if no rfc822Name SAN is present (name_constraints.rs:224-249).uniformResourceIdentifier — the constraint is applied to the host component of the URI authority only (uri_host extracts it, stripping scheme, userinfo and port), then matched as a dNSName (matches_uri_host, name_constraints.rs:418).Per the RFC 5280 §6.1.4(g) note, name constraints are not applied to the subject of a self-issued, non-leaf certificate. check_subject returns NameConstraintsOutcome::SkippedSelfIssued in that case (name_constraints.rs:171-176); the leaf is always checked even when self-issued. The per-step report sets name_constraints_check.skipped_self_issued = true so the carve-out is auditable. The self-issued test is match_dn(issuer, subject).linkage (path/mod.rs:230).
The outcome of check_subject is folded into the structural-finding stack (path/mod.rs:258-284). The v0.6 re-routing (T626, FR-632, Clarification Q3) is important for auditors because it changed the closed-enum surface from the v0.3 behaviour:
NameConstraintsOutcome |
Indication | SubIndication |
|---|---|---|
Failed { side, form, value } |
INDETERMINATE |
IndeterminateNamedConstraint |
UnsupportedForm { tag } |
INDETERMINATE |
IndeterminateNamedConstraint |
Malformed { reason } |
INDETERMINATE |
NameConstraintsMalformed |
Verified / SkippedSelfIssued |
(no finding) | — |
The rationale (path/mod.rs:241-257) is squarely Constitution §I: a constraint failure is re-routed from the v0.3 TOTAL_FAILED/ChainConstraintsFailure tag to INDETERMINATE/IndeterminateNamedConstraint because "the relying party may have a different Name-Constraints policy than what the chain's state machine concluded" — pverify surfaces "rejected by the constraint state machine" without claiming the chain is forged. An unsupported form is conservatively rejected (FR-614) to the same INDETERMINATE tag. Only a structurally malformed extension keeps its own NameConstraintsMalformed tag, because malformed-DER is a structural-soundness signal distinct from "the constraint actively rejected the chain."
Policy processing uses the same anchor-first pre-pass discipline (compute_policy_states, path/mod.rs:884). The state (PolicyState, policy.rs:56) models the valid_policy_tree (nodes: Vec<PolicyTreeNode>) plus the three §6.1.4 countdowns: explicit_policy_pending, policy_mapping_pending, inhibit_any_policy_pending, and a malformed slot.
certificatePolicies (§4.2.1.4, OID 2.5.29.32)policyMappings (§4.2.1.5, OID 2.5.29.33)policyConstraints (§4.2.1.11, OID 2.5.29.36) — requireExplicitPolicy / inhibitPolicyMappinginhibitAnyPolicy (§4.2.1.14, OID 2.5.29.54)initialise (policy.rs:111) seeds the tree from the anchor's certificatePolicies when present (RFC 5937 anchor-driven, FR-046a), otherwise from {anyPolicy}, then filters through user_initial_policy_set (empty = {anyPolicy}, RFC 5280 §6.1.1(c)). Anchor policyConstraints / inhibitAnyPolicy seed the countdowns.
apply_intermediate (policy.rs:153) implements §6.1.3(d) (intersection of the cert's declared policies with the tree, intersect_with_cert_policies, policy.rs:206) and §6.1.4(a)/(b)/(c) (the countdowns, decremented saturating-at-zero after each cert so the next cert sees the decremented value, policy.rs:186-191). Policy mappings replace the issuerDomainPolicy node with the subjectDomainPolicy (apply_mappings, policy.rs:266), honouring policy_mapping_pending == 0 (when the mapping budget is exhausted, mappings are silently dropped per §6.1.4(b)).
The v0.3 simplifications are disclosed in policy.rs:33-39: expected_policy_set is populated as [valid_policy] for every node (v0.3 does not exercise multi-mapping cases that diverge them); policy-mapping cycle detection is not separately implemented because the chain walk terminates after the chain ends (self-limiting).
compute_outcome (policy.rs:304) performs the §6.1.5 wrap-up only at the leaf. The load-bearing subtlety, with a regression test, is the empty-tree case:
Per RFC 5280 §6.1.5, path processing succeeds when
explicit_policy > 0or thevalid_policy_treeis non-NULL. An empty tree is therefore a rejection only whenrequireExplicitPolicyfired (modelled asexplicit_policy_pending == Some(0)).
(policy.rs:316-326.) This matters because real GPKI / 官報 (Kanpō) chains commonly have a leaf whose policy OID differs from the issuer's, with no policyMappings — the tree legitimately empties at the leaf, yet the chain is accepted because no certificate asserts requireExplicitPolicy. The earlier bug that rejected empty trees unconditionally made those real chains falsely INDETERMINATE; the guard tests compute_outcome_empty_tree_at_leaf_without_explicit_policy_is_verified and its with_explicit_policy counterpart (policy.rs:413-443) pin the corrected behaviour.
Other §6.1.5 rejections: inhibitAnyPolicy == 0 with only anyPolicy in the tree (InhibitAnyPolicyViolatedAtLeaf), requireExplicitPolicy fired with no specific policy (ExplicitPolicyViolated), and a non-empty user-initial-policy-set with no matching specific policy in the tree (EmptyTree) (policy.rs:342-366).
PolicyOutcome |
Indication | SubIndication |
|---|---|---|
Inconclusive { reason } |
INDETERMINATE |
IndeterminatePolicyRejected (v0.6, FR-632) |
Malformed { extension: PolicyMappings } |
INDETERMINATE |
PolicyMappingsMalformed |
Malformed { extension: other } |
INDETERMINATE |
PolicyProcessingInconclusive |
Verified |
(no finding) | — |
(path/mod.rs:300-323.) As with name constraints, the v0.6 re-routing flips actively-rejected policy paths to the new IndeterminatePolicyRejected tag, keeping PolicyMappingsMalformed for the structural-soundness (malformed-DER) signal. Every policy verdict is INDETERMINATE, never TOTAL_FAILED — a policy mismatch is "the chain does not satisfy this policy set," not proof of forgery.
pverify supports inter-domain trust through cross-certificates — the GPKI BridgeCA model where a bridge CA is cross-certified with multiple domain roots. Two mechanisms cooperate.
bridge::detect_crossing (path/bridge.rs:56) recognises the two classic cross-certificate flavours by comparing the candidate intermediate's issuer/subject DNs against the anchor set:
!issuer_in_anchors && subject_in_anchors).issuer_in_anchors && !subject_in_anchors).Both return the crossing pair [intermediate_fp, anchor_side_fp] for the BridgeAttempt.crossing_pair field.
In v0.6 the walker actively traverses bridges rather than refusing them. Every hop above the leaf is recorded as a crossing pair (path/mod.rs:618-643):
[current_fp, intermediate_fp] — this is how intermediate-to-intermediate bridge hops (the V24 multi-bridge shape) are captured.bridge::detect_crossing heuristic is consulted; the V9 single-bridge shape hits this branch.On backtrack, the matching bridge_pairs entry (the one whose second fingerprint equals the popped cert) is popped too (backtrack, path/mod.rs:647-653), so the recorded crossings always reflect the final chain.
After the chain is built and the per-step findings collected, each crossing is turned into a BridgeAttempt (path/mod.rs:397-445):
| Condition | BridgeAttemptResult |
|---|---|
Any name-constraints Failed recorded against the chain |
RejectedConstraintFailure (with a side/form/value detail string) |
Any policy Inconclusive recorded |
RejectedPolicyInconclusive (with a reason detail) |
| Neither | Accepted |
For multi-bridge chains the v0.6 implementation applies the first failure (if any) to all crossings; per-bridge per-finding correlation is noted as a future refinement (path/mod.rs:386-393). Each BridgeAttempt also carries name_constraints_evaluated — true when the bridge-side cert carried a nameConstraints extension and no bridge-side constraint failure was recorded (path/mod.rs:424-437, FR-633/US3-AS1), giving the auditor positive evidence that the crossing's constraints were checked.
bridge_ca_required_unsupportedIn v0.1/v0.2 a chain that needed a cross-certificate produced INDETERMINATE / bridge_ca_required_unsupported. FR-048 removed that emission; the sub-indication remains in the closed enum (retained for stored-report deserialisation per the additive-schema discipline) but is no longer emitted (verify.rs:2670-2673). v0.3+ reports Bridge-CA outcomes through chain_result.bridge_attempts[*].result/detail instead, and v0.5 (FR-510) renamed the former singleton field to the bridge_attempts Vec.
If a chain genuinely reaches no anchor and no crossing was recorded (bridge_pairs empty), the engine surfaces a generic structural INDETERMINATE with locus = "chain" and no sub-indication (path/mod.rs:372-378) — the honest "this path is incomplete" signal.
path/)validate_path populates each non-anchor ChainStep.signature_verification with the placeholder ChainStepSignature::Failed { reason: "cert signature verification deferred to T034" } (path/mod.rs:340). This placeholder is never mapped to a chain_signature_failed finding by the structural-finding stack (path/mod.rs:22-24), so it cannot poison reports; validate_path surfaces only structural sub-indications.
The orchestrator then calls wire_chain_step_signatures (verify.rs:1944, invoked at verify.rs:568 for the CAdES signer chain, verify.rs:783 for the per-object VRT re-validation, and verify.rs:1585 for TSA chains) to replace the placeholder with a real result. For each non-root step it:
parent_spki_for_step, verify.rs:2037).(tbsCertificate DER, signatureValue) (split_certificate_for_verification, verify.rs:2063).crate::crypto::verify_with_alg(sig_alg_oid, parent_spki, tbs, sig), which routes RSA PKCS#1 v1.5, RSA-PSS, ECDSA P-256/P-384, Ed25519 and ML-DSA by OID + SPKI family (RustCrypto-only — see Chapter 3 on the WASM-clean crypto constraint).The result mapping (verify.rs:1994-2011):
verify_with_alg result |
ChainStepSignature |
Downstream indication |
|---|---|---|
Ok(()) |
Verified |
— |
Err(UnsupportedSignatureAlgorithm(oid)) |
Failed { "unsupported certificate signatureAlgorithm: …" } |
INDETERMINATE / SignatureAlgorithmUnsupported |
Err(MalformedPublicKey(msg)) |
Failed { "malformed public key: …" } |
INDETERMINATE / PublicKeyMalformed |
Err(other) |
Failed { "<error>" } |
(see §9.9 caveat) |
The split between path-structure and crypto-step verification exists so that the RSA / ECDSA / Ed25519 / ML-DSA dependencies are introduced into the core graph exactly once and shared with the CMS signer-signature path, rather than duplicated inside path/.
The per-signature ETSI indication is computed by aggregate_etsi (verify.rs:2343; see Chapter 5 §5.7 for the overall layering). Path-validation findings enter at the top of its precedence ladder:
TOTAL_FAILED — if chain.structural_indication is a TOTAL_FAILED finding (expired/not-yet-valid cert, leaf KeyUsage failure, non-leaf keyCertSign failure), it returns immediately (verify.rs:2375-2378), ahead of every other cause. pick_dominant_finding (path/mod.rs:819) selects the most severe finding (TotalFailed > Indeterminate > TotalPassed, first-pushed wins on ties) before this point.RevokedOnCrl/RevokedOnOcsp) — these are the CMS/revocation causes layered after the structural TF (see Chapters on CMS, revocation, timestamps).verify.rs:2548-2576. A step whose cert_signature_alg OID is not in the supported table yields INDETERMINATE / SignatureAlgorithmUnsupported; a recognised-OID step whose signature_verification reason begins "malformed public key:" yields INDETERMINATE / PublicKeyMalformed.INDETERMINATE — emitted at their own precedence slot (verify.rs:2604-2668) for IndeterminateNamedConstraint, NameConstraintsMalformed and IndeterminatePolicyRejected (these usually arrive via structural_indication already, but the per-step scan is defensive).An auditor-relevant caveat. A chain step that produces a generic cryptographic verify-and-fail — verify_with_alg returns an error that is neither UnsupportedSignatureAlgorithm nor MalformedPublicKey, i.e. a true bad-signature on an intermediate's certificate — does not currently route to a dedicated TOTAL_FAILED / ChainSignatureFailed indication in aggregate_etsi. The SubIndication::ChainSignatureFailed value exists in the closed enum (report/etsi.rs:41) but the only construction of it in the codebase is inside a unit test (verify.rs:2971). The aggregation scans for unsupported-OID and malformed-key shapes explicitly, but a bona-fide forged intermediate signature is reflected only in the step's signature_verification = Failed { reason } fact in the report tree, not promoted to a top-level TOTAL_FAILED. In practice the signer's own signature (verified by the CMS layer, §2 of the ladder) and revocation cover the dominant attack surface, and a forged intermediate would typically also fail DN/issuer chaining; but this gap between the recorded step fact and the aggregated verdict is documented here for review rather than glossed over.
rfc4518-minimalAll issuer↔︎subject linkage decisions — chain construction, self-issued detection, bridge detection and the per-step dn_match_with_parent evidence — go through match_dn (crates/pverify-core/src/x509/name.rs), the rfc4518-minimal comparator. It applies a deliberately reduced stringprep profile (name.rs:1-24):
A–Z → a–z).[\t\n\f\r ] to one space, trim leading/trailing.UTF8String, PrintableString, BMPString, TeletexString and IA5String to a Unicode string before applying 1–2.It deliberately does not collapse NFC vs NFD Unicode composition differences; the disclosure (name.rs and v0.1 research note R-005) records that the real GPKI mismatches observed so far are encoding-tag and trailing-whitespace differences, both of which the minimal profile catches. The method string is pinned as dn_match_method == "rfc4518-minimal-v0.1" in every report, and FR-061 forbids changing it within the v0.3 line (full stringprep is a later branch). Each ChainStep carries the DnMatchEvidence (method, bytewise_equal, and the relaxations applied) so the auditor can see exactly how a non-bytewise-equal linkage was established (path/mod.rs:155-157, report/mod.rs:678).
ChainStep)Every certificate in the path — leaf, intermediates and the terminating anchor — produces one ChainStep (crates/pverify-core/src/report/mod.rs:611). The fields most relevant to path-validation audit:
| Field | Source / meaning |
|---|---|
subject_dn_text, subject_fingerprint |
Subject RFC 4514 text and SHA-256 hex |
issuer_subject_fingerprint |
Fingerprint of the parent step / anchor (None at the anchor) |
validity_not_before / not_after / validity_window_covers_verification_time |
The validity window and whether the VRT falls inside it |
key_usage_bits |
keyUsage bit names (surfaced + enforced per §9.4.3) |
extended_key_usages |
EKU OIDs — surfaced, not enforced (§9.1.1) |
basic_constraints |
{ ca, path_len_constraint } — ca enforced, path_len_constraint surfaced only (§9.4.2) |
cert_signature_alg |
The certificate signatureAlgorithm OID; drives the §9.9 unsupported-OID scan |
signature_verification |
Verified / Failed { reason } / NotApplicableForRoot (anchor) — wired by §9.8 |
dn_match_with_parent |
DnMatchEvidence from rfc4518-minimal (§9.10) |
name_constraints_check |
Per-step NC projection; None when no constraints in scope (preserves byte-identity for pre-v0.3 fixtures, SC-017) |
policy_check |
Per-step policy projection; None for the trivial {anyPolicy} state (SC-017) |
aia_ocsp_uris |
AIA id-ad-ocsp URIs (consumed by the revocation layer, Chapter on revocation) |
revocation |
Placeholder IndeterminateNoCrl from the path engine; replaced by the real revocation result downstream (apply_revocations) |
The revocation record emitted by the path engine itself is always a placeholder (placeholder_revocation, path/mod.rs:787; anchor_revocation_placeholder, path/mod.rs:804) carrying IndeterminateNoCrl and the cert's CDP URIs — an honest "not yet evaluated by this layer" value. The real CRL/OCSP outcome is written by the revocation subsystem (see the revocation chapter), which is why the anchor's placeholder record is explicitly skipped when aggregate_etsi scans for revocation findings (verify.rs:2476-2489).
The name_constraints_check and policy_check fields being Option and absent for trivial states is a reproducibility-preserving design (#[serde(skip_serializing_if = "Option::is_none")], report/mod.rs:634, 643): a chain that carries no constraints/policies serialises byte-identically to the pre-v0.3 report shape, honouring the additive-feature byte-identity invariant of Constitution §II.
ChainOutcome on native and wasm32.MAX_CHAIN_DEPTH = 10 cap make construct_chain terminate on adversarial cross-certification graphs.TOTAL_FAILED; an out-of-window anchor is INDETERMINATE (config question, not forgery).INDETERMINATE, never TOTAL_FAILED — pverify reports "rejected by the state machine under this anchor/policy set" without asserting the chain is forged (Constitution §I).pathLenConstraint are surfaced, not enforced. This includes the absence of an id-kp-timeStamping requirement on TSA certs within the path engine.BridgeAttempt carrying an Accepted / RejectedConstraintFailure / RejectedPolicyInconclusive verdict; the legacy bridge_ca_required_unsupported emission is retired.ChainSignatureFailed verdict (§9.9 caveat).Downstream layers — CMS signer-signature verification, revocation (CRL/OCSP, including PAdES /DSS+/VRI material), timestamp and VRT derivation, algorithm policy and ETSI aggregation — build on the ChainOutcome this engine returns; see the respective chapters.
Release: v1.3.0 · Report schema: 1.9.0
This chapter describes where pverify's trust anchors come from, how candidate roots are authenticated and admitted into the distribution, how the verification kernel consumes them, and — equally important — what is deliberately not an anchor. Trust-anchor selection is the single most consequential decision a signature verifier makes: it determines, by axiom, which signatures can ever reach TOTAL_PASSED. pverify therefore treats anchor management as a provenance problem governed by RFC 6024 (Trust Anchor Management Requirements), kept strictly outside the cryptographic kernel, and surfaced as auditable fact in line with Constitution §I.
The material in this chapter sits on top of the kernel boundary defined in Chapter 3 (the crate::traits::TrustAnchorStore capability trait) and feeds the path-validation engine described in Chapter 9. The ingestion crates discussed here — pverify-eutl, pverify-aatl, pverify-anchor-inventory — are host-only std libraries that the cargo-tree gate (scripts/cargo-tree-gate.sh) forbids from the WASM-clean core graph; see Chapter 3 §3.2 for the topology.
The verification kernel pverify-core ships with zero built-in trust anchors. The anchor set is always supplied by the caller through one capability trait:
// crates/pverify-core/src/traits.rs
pub trait TrustAnchorStore {
fn anchors(&self) -> &[TrustAnchor];
}
pub struct TrustAnchor {
pub der_bytes: Vec<u8>, // full Certificate SEQUENCE DER
pub fingerprint: [u8; 32], // SHA-256 of der_bytes
pub subject_dn: Vec<u8>, // raw encoded Name SEQUENCE
pub validity_window_covers_request_time: bool, // FR-025, computed host-side
pub subject_key_identifier: Option<Vec<u8>>, // SKID keyid bytes, for AKID-tie-break
}This is a deliberate architectural commitment, documented at docs/trust-anchor-policy.md §10: "pverify-core does not embed trust anchors; the anchor set is always supplied by the caller (the CLI via --trust-anchors, the browser via the bundled roots.json)." It has three consequences relevant to PKI auditors:
pverify-core is no_std + alloc, I/O-free and forbids unsafe (Chapter 3 §3.1), there is no path by which a root could be compiled in, fetched, or otherwise materialised inside the kernel. Every anchor that reaches path validation passed through a host loader the operator can inspect.TrustAnchor's doc-comment states, the fields are "the routing-and-identity surface only"; structural DER parsing of der_bytes happens inside the path engine (x509::Certificate), and issuer→subject matching uses the raw subject_dn bytes via the RFC 4518 minimal-DN matcher (crate::x509::name::match_dn). subject_key_identifier holds the SKID keyid bytes and is used for AKID-based tie-breaking when two anchors share the same DN (see Chapter 9 §9.3 anchor-first preference).validity_window_covers_request_time is computed at load time by the host against the verification time t (crates/pverify-cli/src/trust.rs). The store is "intentionally inert" — it surfaces every anchor including expired ones, in supplied order, never deduplicating or filtering (InMemoryTrustAnchorStore, traits.rs). Chain construction then skips any anchor whose flag is false. Excluding an expired anchor entirely would mask the fact that the operator supplied one; surfacing it lets the report record an expired-anchor finding.A recurring auditor question — answered at length in docs/trust-anchor-policy.md §9 — is whether pverify should re-confirm, at verification time, that an anchor's authorising publication (e.g. the官報 SECOM root advertised at kanpo.go.jp) is still live, or that the distribution host's TLS is still valid. It must not, and deliberately does not. The policy follows RFC 6024's separation of establishment (a one-time secure out-of-band authentication, recorded as authority_url + the SHA-256 pin + retrieved_at) from verification (path validation against the already-established local anchor set). Once an anchor's fingerprint is pinned in the ledger it is self-authenticating; re-fetching the source at verification time would break §II reproducibility, re-introduce the SSRF surface closed by the 021 net-guard, and add nothing to the proof — exactly as an OS/browser root store does not re-contact each CA's website on every TLS handshake. The only certificate check the kernel performs against an anchor at verification time is the validity window, which path validation already evaluates at t. Re-attestation of a publication is a governance/lifecycle activity (ledger-update or release cadence), not a runtime behaviour.
pverify recognises anchors from several distribution channels, each with a data_source provenance tag. The channels divide into three trust tiers, enumerated in docs/trust-anchor-policy.md §2.
web/roots/*.json)The primary channel is a machine-readable JSON ledger — one file per root — that is itself a pointer-plus-integrity-pin Trusted List. Per docs/trust-anchor-policy.md §4, the certificate bytes are not committed; each ledger entry records the source URL (retrieved_from), the SHA-256 pin, the data_source, purpose, subject DN, validity window and adoption rationale, and the web build (build-roots.mjs) downloads the bytes at build time and fails if they do not match the pin (catching either a tampered download or an issuer key rotation). The four admission conditions (§3 of the policy) are: authorised provenance with an official authority_url; SHA-256 pin match; structural validity (self-signed, BasicConstraints cA=TRUE, keyCertSign, future-dated); and a complete provenance record.
The confirmed data_source tokens carried in the ledger are:
data_source |
Meaning | Example roots |
|---|---|---|
web-publication |
Root publicly advertised by the issuing body | SECOM RSA Root CA 2023 (current官報 / e-官報 PDF anchor — note: SECOM commercial PKI, not GPKI) |
gpki |
Japanese Government PKI self-signed roots | JGCA (Japanese Government Root CA, 2023–2048, WebTrust-audited, Microsoft Trusted Root updated May 2025), GPKI BridgeCA, OfficialStatusCA, JPKI sign/auth |
lgpki2 |
J-LIS Local Government PKI 2nd generation | LGPKI2 Organization CA R2 (lgpki2-org-ca-r2.json, serial 5b:87:8c:23, see §10.2.2) |
fpki |
US Federal PKI trust root | Federal Common Policy CA G2 (FCPCA G2), a single self-signed root |
accredited-ca |
Accredited certification businesses (電子署名法) cross-certified by GPKI BridgeCA | AOSign, TOiNX, TDB TypeA, Secom Passport for G-ID, DIACERT/DIACERT-PLUS, e-Probatio |
commercial-registration |
Commercial-registration e-authentication (法務省) | Registrar of Tokyo Legal Affairs Bureau roots (2022/2025/2026) |
eutl-ojeu / eidas-lotl-signer |
The EU LOTL XAdES signer (an ingestion bootstrap, not a document-signing anchor) | EU LOTL signing certificate |
adobe-aatl |
Adobe Root CA G2 (the AATL distribution's list-signer bootstrap) | Adobe Root CA G2 |
lgpki2-org-ca-r2.json)web/roots/lgpki2-org-ca-r2.json records the J-LIS LGPKI2 Organization CA R2 (serial 5b:87:8c:23), the root for Japanese local government document signing.
Why trusted. The certificate was retrieved from J-LIS's official CA information page (authority_url: https://www.lgpki.go.jp/CAInfo/install.htm, retrieved_from: https://www.lgpki.go.jp/CAInfo/ocar2ver2.cer) and its SHA-256 fingerprint (ca137029…) is pinned in the ledger. The build breaks if the live byte does not match the pin. The CP/CPS (C-6-3-11) and technical specification (C-6-4-5_LG_tech_LGPKI_spec) are published at the same domain.
Trust scope. LGPKI2 is cross-certified with the GPKI Bridge CA, but BCA is absent from every public root store (OS / browser / Microsoft Trusted Root), so this root is closed-network in practice. Only documents signed under the LGPKI2 hierarchy — typically prefectural or municipal official records — are expected to chain to it.
Revocation. Subscriber certificates carry only DirectoryName-form CDP entries (CN=CRL{N},OU=Organization CA R2,O=LGPKI2,C=JP); www.lgpki.go.jp:389 is internet-reachable (confirmed 2026-06-26) and is reached via the dirname_to_ldap_url hint table (§11.4). Offline verification relies on /DSS-embedded CRL material.
A separate experimental tier carries the FPKI BRAWL Dev FCPCA D1 root — an ML-DSA-87 (FIPS 204, OID 2.16.840.1.101.3.4.3.19) self-signed root that exercises pverify's post-quantum verification path (010-ml-dsa-verify). It is tagged purpose: pqc-interop-lab and the web UI displays an explicit "experimental lab — not a production trust anchor" badge. It is segregated by data_source/purpose in every surface so it never silently terminates a production verification path.
Two data_source tokens — eidas-lotl-signer and the AATL aatl-list-signer — are not document-signing anchors. They are the pinned signers used to authenticate an ingestion source (the EU LOTL XML, the Adobe AATL PDF). The policy is explicit (§2): these "are not document-signing anchors (purpose: …-signer, not injected into the verification trust store)". A national CA admitted through the LOTL is isolated under purpose: eidas-qualified; an AATL-admitted government root is isolated under data_source: adobe-aatl. The bootstrap signer never enters --trust-anchors.
pverify-eutl, slice 014)pverify-eutl ingests an ETSI TS 119 612 TrustServiceStatusList (TSL) and emits ordinary pverify_core::traits::TrustAnchor values wrapped with ingestion provenance. It is a native std crate, gate-excluded from the core/WASM runtime graph, and performs no network I/O of its own — fetching national lists during a LOTL traversal is a caller-supplied fetch closure (crates/pverify-eutl/src/ingest.rs, ingest_lotl), keeping the crate deterministic and testable from captured bytes.
Crucially, eutl does not authenticate a Trusted List by building an RFC 5280 chain to a self-signed anchor and running revocation. As crates/pverify-eutl/src/verify.rs documents (research R-003), "the trust is established by the pin (the OJEU-pinned LOTL signer, or the LOTL-vouched national signer identity)". authenticate() therefore:
ds:Signature via pverify_xades::extract (reusing the same Exclusive C14N engine, bergshamra-c14n, that all XAdES verification uses — Chapter 7);signer_certs DER-exact (signer_certs.iter().any(|pin| pin.as_slice() == signer_der));ds:Reference digest (content integrity);SignedInfo using pverify_core's RustCrypto primitives (RSA-PSS, RSA PKCS#1 v1.5, ECDSA per the OID resolution in xades_sig_alg_oid);SigningCertificateV2/CertDigest binding when present (certificate-substitution defence).An unsupported XAdES profile, canonicalization or signature algorithm is a verification failure for the list, never a silent skip of the check (FR-004), and "no anchors are read from a list that fails here" (ingest_one returns signature_verified: false with empty anchors).
t filter (the security core)The single most security-critical decision in eutl is the granted-at-t filter, implemented in crates/pverify-eutl/src/status.rs and verify.rs::authenticate. A TSL service yields a trust anchor if and only if two conditions hold (ingest.rs::anchor_services):
…/Svctype/CA/QC (SVCTYPE_CA_QC, R-002, ServiceType-only). Qualifications are deliberately not consulted; TSA / OCSP / CRL service types are excluded with non_ca_qc_type.t is granted (STATUS_GRANTED, R-001).The status resolver ServiceStatus::status_at(t) (status.rs) is precise about time. It collects every timed (StatusStartingTime, ServiceStatus) instance from the current status plus the ServiceHistory, sorts ascending, and selects the instance whose half-open interval [starting_time, next_starting_time) contains t (the latest interval is unbounded on the right). Resolving at "now" instead of t would either trust a CA withdrawn after the document was signed, or distrust one that was granted at signing time — breaking both the security goal and §II reproducibility. The boundary semantics are inclusive-left (exactly at a StatusStartingTime the new status takes effect), as the boundary_at_exact_starting_time test asserts.
The accepted set is granted only. The eleven pre-eIDAS legacy status URIs (undersupervision, accredited, supervisionincessation, etc., enumerated in LEGACY_STATUS_URIS) are recognised — so a historical-t resolution lands on the correct interval — but they all exclude; broadening to legacy "active" states is a recorded follow-up, never silently assumed. An unrecognised status URI resolves as "not granted" (excluded), never silently accepted.
Every examined-but-not-anchored service is recorded with its reason in excluded[] (ExcludedService / ExclusionReason::{NonCaQcType, Status}), so the §I fact record is honest about both what was admitted and what was dropped. A granted CA/QC service whose ServiceDigitalIdentity carried no parseable CA cert is also recorded (granted_but_no_parseable_ca_certificate) rather than silently dropped.
ingest_lotl drives a full LOTL (List of Trusted Lists) traversal:
Each national list is authenticated against the signer identity the LOTL vouches for it (TslPointer.expected_signer_identity, from the pointer's ServiceDigitalIdentity), not against a global anchor set (FR-012). A single failing country becomes a SkippedList with a recorded reason and never aborts the run (FR-011, R-006 partial success) — the honest "reached but not ingested" signal. Anchors are deduplicated across countries by SHA-256 fingerprint, with seen_in accumulating every (territory, service_name) endorsement (dedup_and_sort), and emitted in fingerprint order for determinism.
The TSL is parsed (crates/pverify-eutl/src/tsl.rs) over the same bergshamra-xml DOM the C14N layer already trusts (research R-004) — one XML parser, eliminating the signature-wrapping / parser-differential risk of a second parser. Malformed or rootless XML is a fatal EutlError, never a silent empty.
observed_statusEach admitted CA cert is turned into a TrustAnchor by build_anchor exactly as pverify-cli/src/trust.rs builds them — only CA certs (BasicConstraints.cA == true) are anchored; a leaf-shaped ServiceDigitalIdentity cert is recorded-not-anchored. Every produced anchor carries purpose: "eidas-qualified" and observed_status: "granted". Constitution §I is reinforced in the doc-comments: observed_status "records the raw TS 119 612 status string only", and purpose = "eidas-qualified" is "a provenance/isolation tag, never a verdict" — pverify never adjudicates "qualified" or "legally valid".
pverify-aatl, slice 026)pverify-aatl is the AATL analogue of pverify-eutl. Adobe's AATL distribution (https://trustlist.adobe.com/tl12.acrobatsecuritysettings) is a CMS-signed PDF 1.6 (PAdES) whose signing chain terminates at the self-signed Adobe Root CA G2, wrapping a single FlateDecode text/xml /EmbeddedFile — a namespace-less <SecuritySettings><TrustedIdentities><Identity>… list (≈294 identities). The crate is ingest(distribution_bytes, adobe_root_ca_g2_der, at), a pure function with no network I/O.
Like eutl, the AATL distribution is authenticated by a pin, but the mechanism is notable: crates/pverify-aatl/src/pdf.rs::authenticate runs the actual pverify_core::pades::verify_pdf pipeline (Chapter 6) against a single-anchor store containing only the pinned Adobe Root CA G2, in VerificationMode::Offline. The accept criterion is a structural pin-based predicate, not the ETSI Indication. This is deliberate: offline, the distribution's ETSI indication is expected to be INDETERMINATE (revocation cannot be checked — the §I "cannot affirm" discipline), so consuming the indication would reject every offline ingest. Instead entry_satisfies_pin requires, over some produced SignatureEntry:
chain_result.terminating_anchor_fingerprint == hex(SHA-256(pin)) — the chain reached the pinned Adobe Root CA G2;Verified (the anchor step being NotApplicableForRoot);Verified and signed_attrs_digest_match == Some(true);content_digest_check.digest_match == true — the embedded XML payload is intact.Trust is the pin, exactly as eutl trusts the OJEU-pinned LOTL signer. (The verify_pdf call passes &Default::default() for the 034 DSS/VRI material and None for the 032 algorithm policy, so authentication is a plain pinned-root chain check.)
After authentication, extract_embedded_xml pulls the inflated text/xml /EmbeddedFile and parse_identities (crates/pverify-aatl/src/parse.rs) parses each <Identity> over the same bergshamra-xml DOM. classify (ingest.rs) then admits an identity as an anchor only if, in order:
<Certificate> body base64-decodes and parses as X.509 (certificate_parse_failed otherwise);<Trust><Root> is 1 (not_root otherwise);<Trust><CertifiedDocuments> is 1 (not_certified_documents otherwise);not_government_affiliated otherwise).The heuristic (crates/pverify-aatl/src/filter.rs::is_government_affiliated) is a case-insensitive substring match of the subject DN against the closed, documented GOV_KEYWORDS set (government, gobierno, gouvernement, icp-brasil, cca india, bundesamt, ministry, federal, uae, dod , …). Constitution §I governs this surface explicitly: "the heuristic is NOT a verdict… a genuinely-government identity the heuristic misses is excluded and recorded, never silently anchored, and a borderline match is visible for review." The matched keyword is recorded verbatim in the anchor's affiliation_match provenance field so a human can audit every admission, and the keyword list "is the audit surface — extend it deliberately, never silently." The unit tests pin both directions: government subjects (ICP-Brasil, Swiss Confederation, CCA India) match; contested commercial CAs (WoSign) and ordinary commercial roots (DigiCert) do not.
Every excluded identity is recorded as an ExcludedIdentity with its ExclusionReason so the union (admitted anchors ∪ excluded) equals the distribution's full identity count — the invariant later surfaced in the inventory (§10.5).
The AATL distribution is fetched, not committed (docs/trust-anchor-policy.md §2): Adobe's curated compilation has unclear redistribution licensing (potential compilation/database rights), so pverify follows the prevailing OSS practice (tl-create, PDF4QT, pyHanko) of having the user/CI fetch it directly from Adobe; only the pinned Adobe Root CA G2 (Adobe's own root) is recorded in the ledger. The policy also flags a concrete trap auditors should note: the官報 trust roots (SECOM RSA Root CA 2023 / OfficialStatusCA 2024) are not in AATL (DER-SHA256 confirmed distinct), and AATL contains a deceptively-named look-alike, SECOM Document Signing RSA Root CA 2023, which must not be conflated with the官報 anchor — the官報 anchor stays individually pinned.
The AATL ledger entry's retrieved_from points at the AATL distribution (tl12.acrobatsecuritysettings, a PAdES-signed PDF), not at a bare certificate — the Adobe Root CA G2 that the entry pins lives inside that PDF's CMS signature. Earlier the web build (build-roots.mjs) tried to read the pinned cert straight from the fetched bytes and, when the ledger pointed at the distribution rather than a cached DER, handed the CLI the wrong bytes — ingest-aatl then authenticated nothing and the production roots page ingested zero AATL anchors. The build now extracts the pinned cert from the distribution before the CLI call: it decodes every /Contents <hex…> CMS blob in the PDF (and falls back to a raw-byte scan for an operator-supplied bare cert), scans each for an X.509 DER SEQUENCE, and returns the first slice whose DER SHA-256 equals the ledger pin (extractPinnedCertByHash). Accepting only a slice that hashes to the pin makes the scan safe regardless of where in the bytes it points: the pin remains the sole integrity anchor (fetch-don't-bundle is preserved — nothing new is committed). If no slice matches, the build skips the AATL report rather than ingesting an unauthenticated cert.
pverify-anchor-inventory, slice 028)pverify-anchor-inventory is a host-only std aggregation library that reads over every channel pverify's release distributes and emits a single canonical Inventory artefact, consumed byte-identically by two surfaces: the CLI pverify trust-anchors dump command and the web web/site/trust-anchors.json powering the trust-anchor visualisation page. Its construction guarantee (aggregate is a pure function with no clock read and no randomness; write_json emits 2-space-indent / LF / single-trailing-newline JSON) makes two runs against the same release inputs byte-identical — Constitution §II reproducibility, the basis for cross-release anchor-drift diffing.
The closed ChannelLabel enum fixes the channel set and its display order: pinned-ledger, fpki-bundle, eutl, aatl-trusted, aatl-excluded, pqc-interop (crates/pverify-anchor-inventory/src/inventory.rs). The aggregator ingests each channel (pinned ledger entries, the captured eutl IngestReport, the captured AATL IngestReport), then merge_and_sort deduplicates by fingerprint while merging attributions — when the same fingerprint appears in two channels (e.g. an FPKI cert that is also the FCPCA G2 ledger root), one AnchorEntry is emitted with multiple EntryAttributions, and first-non-empty-wins for affiliation/country/key_algorithm. FPKI roots (data_source == "fpki") route to fpki-bundle; pqc-interop-lab purpose routes to pqc-interop; the rest to pinned-ledger.
The AATL identities that the 026 ingest dropped are surfaced read-only as a separate type (ExcludedEntry) in a separate JSON array (excluded[]), so "no rendering / filter / sort / export path can collide them with trust anchors" (FR-007/FR-010). The web UI realises this as a distinct tab ("AATL 除外(参考)") with a persistent non-dismissible "informational, not a trust anchor" banner. For each not_government_affiliated exclusion, the inventory attaches a GovKwAudit panel embedding the full GOV_KEYWORDS snapshot beside the subject DN and a fixed plain-language audit note (GOV_KW_AUDIT_NOTE) — letting a reviewer audit the §10.4.2 heuristic by inspection without trusting it on faith (FR-009).
check_invariants (crates/pverify-anchor-inventory/src/aggregate.rs) is called before the inventory is returned and fails the build loud on any violation. Of audit interest:
entries[] and excluded[] are in their deterministic sort order.IngestReport.total_identities — the §10.4 conservation law, surfaced as a build-time check.is_inventory_anchor is only set when the fingerprint truly exists in entries[].crate::chain_reach::derive_next_hop_issuers answers "what is on the other side of this root" (US3) by a single render-time hop: for each root, within each of its own contributing channels, it lists any distributed entry whose Issuer DN equals the root's Subject DN (RFC 4514 text equality post-trim, chosen over OID-encoded matching to mirror "how a human reads the data", R-4). Two properties are load-bearing and auditor-relevant:
pinned-ledger does not pick up next-hops from fpki-bundle even when DNs coincide; the same entry must contribute attributions on both channels for a cross-channel edge to surface. The cross_channel_edges_forbidden test pins this.next_hop_issuers: [], and the renderer keys off the empty array to print an explicit "no next-hop issuers recorded in the distributed material" message — never a silent empty panel, never a fabricated edge (§I).This is not path validation and makes no trust claim; it is an inspection aid over already-distributed bytes.
The web trust-anchor page (web/site/roots.js) groups the inventory by provenance and labels each card with why the root is bundled — which PKI / programme it came from — so a reader can judge an anchor at a glance without expanding it. Each card shows a localized data_source tag (SOURCE_KEYS: GPKI, GPKI-cross-certified accredited CA, Commercial Registration, Official Gazette, FPKI, EU Trusted List, Adobe AATL, PQC interoperability lab) plus the entry's country tag; unknown sources fall back to the raw key (never hidden, §I). Channels large enough to bury the rest of the page — in practice the EU LOTL/national trusted lists with their hundreds of national-list anchors — are collapsed by default behind a <details> whose summary is the channel banner (COLLAPSED_BY_DEFAULT), so the domestic pinned-ledger roots stay visible while the EU channel opens on deliberate click. The pinned-ledger channel is labelled "個別ピン登録のルート(手動台帳)" to convey that it is the manually-curated, individually-pinned set rather than an ingested list.
pverify's anchor channels embody two structurally different PKI trust topologies, and the distinction matters for what can and cannot be collapsed in the anchor set.
A bridge / hierarchy model (the US FBCA, CertiPath, Japan's GPKI BridgeCA federation) is reducible: a single anchor plus a pool of cross-certificates can express trust in many domains, because the bridge cross-certifies the domain roots. pverify's path engine handles bridge crossings explicitly in crates/pverify-core/src/path/bridge.rs (Chapter 9) — detect_crossing recognises the "foreign signs domestic" and "domestic signs foreign" cross-certificate shapes.
A Trusted List model (eIDAS LOTL/TSL, Adobe AATL) is not reducible: it is a flat set of N mutually-independent self-signed roots, each individually trusted by enumeration. There is no single bridge to which they all cross-certify. As recorded in the project memory note "trust-model asymmetry" (and confirmed by the eutl/aatl design): bridge/hierarchy roots can be folded into "one anchor + a cross-certificate pool", but a Trusted List cannot — the web/roots set is, by construction, a list of independent self-signed roots, so "drop everything but the bridge CA" is not a valid reduction. This is precisely why eutl and aatl emit one ordinary TrustAnchor per admitted root rather than a single ingested "super-anchor": the flat-N model demands flat-N anchors, each carrying its own provenance.
Three classes of certificate are deliberately excluded from the anchor set. Auditors should verify these boundaries hold, because each represents a real attack or confusion that the design forecloses.
/DSS /Certs are never anchorsThe 034 PAdES DSS/VRI feature (Chapter 6) consumes a PDF's document-level /DSS /Certs pool to build the signer's certificate chain. These certificates are admitted only as intermediate/leaf path-building candidates, never as trust anchors (crates/pverify-core/src/pades/mod.rs, Q7/FR-005). The code is explicit at the point of use: the resolved /Certs are appended to the intermediates candidate set —
// crates/pverify-core/src/pades/mod.rs ~L662–677
// Append its `/Certs` to the intermediate-candidate set (never to anchors —
// Q7/FR-005/VR-2) …
for cert in &resolved.certs {
if !intermediates.iter().any(|c| c.fingerprint_hex == cert.fingerprint_hex) {
intermediates.push(cert.clone());
}
}
This is essential: a /DSS is part of the document under verification and is attacker-controllable. If a /Certs entry could become a trust anchor, an adversary could embed a self-signed root and have their own signature chain "terminate" at it. The chain must still reach an anchor the operator supplied. The same rule applies to Adobe revocationInfoArchival material (it supplies revocation evidence, tagged signature_embedded, never anchors).
The 28 synthetic roots under fixtures/bundles/*/trust-anchors/ exist only for the test suite. They fail admission conditions 1–3 of the policy (docs/trust-anchor-policy.md §5) and are never distributed as real document-signing anchors.
As noted in §10.2.4, the EU LOTL signer (eidas-lotl-signer) and the Adobe Root CA G2 (aatl-list-signer) authenticate the ingestion source; they are never injected into the verification trust store. OS/browser trust-store membership alone is likewise not an admission reason — the four policy conditions decide (docs/trust-anchor-policy.md §5).
A unifying design choice ties the whole chapter together: ingestion produces nothing the kernel doesn't already understand. Every ingestion crate builds its anchors with the identical (der_bytes, SHA-256 fingerprint, subject_dn, validity-covers-t) shape that pverify-cli/src/trust.rs produces for --trust-anchors. The CLI ingest commands therefore materialise admitted anchors as PEM files (one <fingerprint>.pem per anchor, deterministic filenames; crates/pverify-cli/src/ingest.rs::materialize and der_to_pem), which the operator can then feed straight back through the ordinary verify --trust-anchors <DIR> walker. The CLI exposes ingest-trust-list (eutl, with --lotl for a full traversal and exit code 4 for a list-signature failure), ingest-aatl (aatl), and trust-anchors dump (the inventory).
This closes the loop on Constitution §I and the §10.1 anchor model: pverify-core gains nothing from any ingestion crate — the report enums and schema_version stay frozen — because ingestion emits ordinary TrustAnchors plus its own provenance report types, and the only trust the kernel ever sees is the flat, operator-supplied, fingerprint-pinned anchor set re-fed through the one capability trait.
Release: v1.3.0 · Report schema: 1.9.0
This chapter describes how pverify establishes the revocation status of each certificate on a signature's certification path. It covers the two revocation channels pverify understands — CRL (RFC 5280) and OCSP (RFC 6960) — the precedence rules that arbitrate between distribution-point fetches, embedded in-signature material and stapled responses, the LDAP-CRL transport added for the Japanese accredited-CA ecosystem, the freshness/staleness windows, the offline-honesty discipline, and the closed RevocationOutcome vocabulary the verifier ultimately reports.
Revocation evaluation lives entirely in the no_std, I/O-free verification kernel. The whole computation is in crates/pverify-core/src/revocation/ (mod.rs orchestration, crl.rs CRL parse/verify, ocsp.rs OCSP processing, indirect.rs indirect-CRL detection). The kernel performs no network I/O of its own; the bytes it consumes arrive over the RevocationFetcher trait (crates/pverify-core/src/traits.rs), whose concrete implementations are the CLI's online (ureq), offline and from-bundle fetchers (crates/pverify-cli/src/modes/). This separation is the §V/§VI boundary restated for revocation: the kernel decides which URIs are worth a fetch and how to interpret the returned bytes, while the host decides how to retrieve them. See Chapter 3 for the overall architecture and Chapter 9 for the path validation that produces the chain whose steps are revocation-checked here.
The governing principle throughout is Constitution §I "cannot affirm": every revocation result is a fact the verifier observed — good on CRL, revoked on OCSP, no CRL pointer, responder unreachable, not checked because offline. Where a status cannot be established, the verifier degrades to INDETERMINATE with a precise sub-indication rather than fabricating a pass or a failure.
Revocation is applied after a certification path has been constructed and its intra-chain signatures verified, and it is applied per object at that object's own Validation Reference Time (VRT — Chapter 12). The signer chain is revocation-checked via apply_revocations (crates/pverify-core/src/verify.rs:2075); each timestamp's TSA chain is revocation-checked via apply_tsa_chain_revocation_for_token (verify.rs:1552), introduced in branch 033 so that TSA-certificate revocation is consistent across all formats. Both entry points converge on the single revocation orchestrator, check_revocation_ctx (crates/pverify-core/src/revocation/mod.rs:184).
apply_revocations iterates the non-anchor steps of the chain (verify.rs:2111) — the trust anchor itself is never revocation-checked, since it is trusted a priori — and for each child certificate builds a RevocationContext (revocation/mod.rs:76) carrying the parent CA's SPKI and subject DN, the fetcher, the verification time (the per-object VRT), the trust anchors, the cert pool used for OCSP responder/issuer lookup, the embedded OCSP/CRL material, and the verification mode.
Two details of apply_revocations matter to an auditor:
ocspVals; intermediates pass an empty embedded-OCSP list (verify.rs:2126). This is because stapled OCSP responses are CertID-bound to a specific leaf+issuer pair.CRLValues entry can apply to any certificate on the path, so the full embedded-CRL slice is threaded into every step's context (verify.rs:2142).pverify consults up to three sources of revocation material in a strict, auditable order. The contract is that the first channel to yield a definitive fact wins, and a later channel is consulted only when the earlier channel was indeterminate. This ordering is what preserves the byte-identity invariant (Constitution §II): a signature whose CDP CRL channel succeeds produces exactly the same report whether or not embedded material is also present.
check_revocation_ctx (revocation/mod.rs:184) implements the ladder:
CDP / direct-CRL pipeline (run_cdp_pipeline, revocation/mod.rs:284). The CRL distribution points named in the certificate are classified and, if fetchable, retrieved and consumed. If the outcome is definitive — GoodOnCrl or RevokedOnCrl — it is returned verbatim and no further channel is consulted (revocation/mod.rs:208).
Embedded CRLs (try_embedded_crls, revocation/mod.rs:252). Reached only when the CDP outcome is one of the Indeterminate* CRL variants. Each in-signature CRL (CAdES/PAdES revocation-values.crlVals, XAdES RevocationValues/CRLValues) runs through the same consume_crl validation as a fetched one — issuer-DN linkage, indirect-CRL refusal, signature verification, freshness window and serial lookup. The first embedded CRL that yields GoodOnCrl/RevokedOnCrl wins (revocation/mod.rs:274); a CRL that does not apply (wrong issuer, stale, indirect) is skipped so a later embedded CRL can still match. This is the 019-xades-bt-blt mechanism that lets an LT-level signature validate its path offline.
OCSP pipeline (run_ocsp_pipeline, revocation/mod.rs:620). Reached only when the CDP channel was indeterminate, no embedded CRL produced a verdict, and the leaf carries OCSP material (an AIA id-ad-ocsp URI or embedded ocspVals — revocation/mod.rs:228). Within the OCSP pipeline the source order is again precedence-ranked: embedded ocspVals are tried before any AIA-fetch (revocation/mod.rs:637).
The fall-through trigger is explicit: the CDP outcome must be one of IndeterminateNoCrl, IndeterminateStaleCrl, IndeterminateIndirectCrl, IndeterminateUnsupportedProtocol or the legacy IndeterminateOcspOnly (revocation/mod.rs:199). Any definitive CRL outcome short-circuits the rest of the ladder.
When the OCSP outcome replaces the CDP outcome, the CDP-side evidence is preserved in the returned record: cdp_entries and crl_used are copied forward by finalise_ocsp_record (revocation/mod.rs:988) so an auditor sees both the CRL attempt and the OCSP attempt, not just the one that produced the verdict.
classify_distribution_points (revocation/mod.rs:540) inspects the certificate's CRL distribution points and AIA OCSP pointers and buckets them:
| Field | Meaning |
|---|---|
cdp_entries |
Structured CDP entries (Vec<CdpEntry>), each carrying kind (uri_http / uri_ldap / uri_other / dirname), value (URI string or DN text), and fetchable (bool). Replaces the flat cdp_uris field present in schema ≤ 1.8.0. |
has_only_unsupported_protocol |
the cert has ≥1 CDP URI but none is fetchable (e.g. only ftp://) |
no_crl_pointer |
the cert has no CDP URI of any kind |
aia_ocsp_uris |
AIA id-ad-ocsp URIs (Vec<String>), surfaced for auditor visibility alongside cdp_entries (schema 1.9.0 addition). |
has_ocsp_aia |
true when at least one AIA id-ad-ocsp access description (1.3.6.1.5.5.7.48.1) is present; drives the OCSP pipeline entry decision |
The CdpEntry.kind discriminator was introduced at schema 1.9.0 (035-ocsp-client). The kind values are:
kind |
Condition |
|---|---|
uri_http |
scheme is http:// or https:// |
uri_ldap |
scheme is ldap:// |
uri_other |
URI present but scheme is neither HTTP nor LDAP |
dirname |
CDP uses directoryName (no URI) |
The fetchable-scheme decision is in is_fetchable_scheme (revocation/mod.rs:569): the core never opens a socket, it only decides which schemes are worth handing to the host fetcher. A genuinely-unsupported scheme (e.g. ftp://) never reaches the fetcher and produces IndeterminateUnsupportedProtocol. A certificate with no CRL pointer at all produces IndeterminateNoCrl — unless it carries an AIA OCSP pointer, in which case the legacy IndeterminateOcspOnly is emitted by the inner CDP function and the outer orchestrator re-routes the leaf into the OCSP pipeline (so a v0.4+ binary never surfaces IndeterminateOcspOnly — revocation/mod.rs:302).
Fetchable CDPs are tried sequentially; the first successful fetch wins (revocation/mod.rs:344). If every CDP fetch fails — including an ldap:// CDP that the host could not reach — the loop exhausts and the outcome is IndeterminateNoCrl (revocation/mod.rs:351). Note that both a transient fetch error and an offline NotAvailableOffline collapse to IndeterminateNoCrl on the CRL channel; the offline-honesty distinction (§11.6) applies to the OCSP channel, where pverify would otherwise fabricate a responder contact.
parse_crl (crates/pverify-core/src/revocation/crl.rs:105) decodes the DER CertificateList into a ParsedCrl projection carrying the issuer DN, thisUpdate, nextUpdate, the revoked-entry list (serial + revocation date + reason), the IDP indirectCRL flag and a SHA-256 fingerprint of the issuer DN.
An implementation subtlety worth recording for auditors: pverify declares a local CertificateListCompat type (crl.rs:50) in which TbsCertList.version is OPTIONAL. The upstream x509-cert 0.2/0.3-rc type models version as a required field, which makes a v1 CRL — one that omits the version INTEGER entirely, as RFC 5280 §5.1 permits — fail to decode. Real-world GPKI CRLs are v2 so this stays latent there, but the GlobalSign post-quantum test hierarchy ships v1 CRLs which surfaced the defect (see docs/research/fpki-brawl-pqc.md). The compat type discriminates v1 (absent version) from v2 (present) by tag — the next field, signature, is a SEQUENCE (tag 0x30) that never collides with the INTEGER version (tag 0x02) — and re-encoding a v1 TBS omits version, reproducing the original signed bytes for the signature check.
The CRL reason enumeration is mapped to human-readable strings in extract_reason_string (crl.rs:198): keyCompromise, cACompromise, superseded, cessationOfOperation, certificateHold, etc.
pverify refuses to consume an indirect CRL. consume_crl (revocation/mod.rs:384) calls indirect::is_indirect (crates/pverify-core/src/revocation/indirect.rs:26) before verifying the signature. A CRL is treated as indirect (and routed to IndeterminateIndirectCrl) when either:
IssuingDistributionPoint extension (2.5.29.28) asserts indirectCRL = TRUE; orIssuer Name does not match the parent CA's Subject Name, compared via the RFC 4518-minimal DN matcher crate::x509::name::match_dn.A DN-match error is also treated as suspect and refused — "better to refuse than to silently consume" (indirect.rs:34). This is a deliberate conservatism: pverify never consumes a CRL whose issuer linkage to the certificate's issuer it cannot positively establish, which forecloses a class of CRL-substitution attacks. The refusal could in principle be lifted by a CRL-signer reconciliation pass; that is out of the current scope.
After the indirect check, consume_crl (revocation/mod.rs:384) performs, in order:
Signature verification — verify_crl_signature (crl.rs:159) re-encodes the TBSCertList and dispatches through crate::crypto::verify_with_alg by the signatureAlgorithm OID (RSA PKCS#1 v1.5, ECDSA P-256/P-384, etc.). A failed verification yields IndeterminateNoCrl — pverify will not treat a CRL it cannot authenticate as evidence of good, but it also does not fail the signature on a bad CRL: the inability to use the CRL is an INDETERMINATE fact, not a tampering claim.
Freshness (revocation/mod.rs:438):
thisUpdate > verification_time → IndeterminateStaleCrl (the CRL is dated after the reference time — a future CRL cannot attest the past).nextUpdate present and nextUpdate < verification_time → IndeterminateStaleCrl (the CRL has expired relative to the VRT).The freshness window is evaluated against the per-object VRT, not a single request-wide clock — for the signer chain this is vrt.signer_chain.value, for a TSA chain its own per-token VRT. This is what lets an LT/LTA signature validate against a CRL that was fresh at signing time but stale today.
Serial lookup (revocation/mod.rs:464). The certificate's serial number is extracted from its DER and searched against the revoked entries. A match yields RevokedOnCrl with the revocation date and reason; no match yields GoodOnCrl. If the serial extraction fails it defaults to empty — which can never match, so the failure mode is never a false revoked.
The CrlSummary recorded for an auditor (make_crl_summary, revocation/mod.rs:497) carries the source URI, the CRL's thisUpdate/ nextUpdate, a SHA-256 of the CRL body, and the issuer fingerprint. The pdf_source field is left None here — the revocation engine is provenance-agnostic, and PDF /DSS//VRI provenance is post-tagged by the PAdES layer (branch 034, R-7; see Chapter 6).
Many of the Japanese 電子署名法-accredited 認証局 (AOSign, TDB, TOiNX, e-Probatio, …) publish their CRLs only over ldap://. To verify certificates issued under those CAs, pverify treats ldap:// as a fetchable scheme in the core (is_fetchable_scheme, revocation/mod.rs:569) and hands the verbatim CDP URI to the host. The core stays I/O-free; the LDAP transport lives in the CLI host, crates/pverify-cli/src/modes/ldap.rs.
The LDAP client is a minimal, dependency-free LDAP v3 client built on std::net::TcpStream — "zero new third-party dependency" was a clarified constraint. It hand-rolls just enough BER to perform:
bind_request, ldap.rs:218 — version 3, empty name, empty simple [0] credential), followed bysearch_request, ldap.rs:231 — wholeSubtree scope, neverDerefAliases, a present filter [7] on the requested attribute, and the attribute selector).The URL parser (parse_ldap_url, ldap.rs:83) follows RFC 4516 (ldap://host[:port]/baseDN[?attr[?scope...]]), percent-decodes the base DN, defaults the port to 389 and the attribute to certificateRevocationList;binary. The response reader (fetch_crl, ldap.rs:405) parses SearchResultEntry ([APPLICATION 4], tag 0x64) messages, matches the attribute type by base name (stripping any ;binary transfer option), and selects the first attribute value that begins with a DER SEQUENCE byte 0x30 as the CertificateList. It stops on SearchResultDone (0x65).
The transport threat model is documented explicitly (ldap.rs:24): the client speaks cleartext LDAP on port 389 — no LDAPS (636) or StartTLS. An active network attacker could substitute a forged CertificateList, but that risk is bounded downstream: every fetched CRL is signature-verified by the kernel against the issuing CA (§11.3.4) before it can influence a verdict, so a tampered CRL is rejected as an honest parse/verification failure rather than trusted. The residual exposure is a denial of revocation data — an attacker who drops or corrupts the response yields IndeterminateNoCrl, never a false good. Operational guidance in the source recommends supplying CRLs out-of-band via --from-bundle (content-addressed, fingerprint-checked) where channel integrity matters; an ldaps:///StartTLS upgrade is deferred pending per-CA interop testing.
The SSRF address net-guard (021) applies to the LDAP path as well as HTTP: in connect_with_timeout (ldap.rs:509) the resolved addresses are filtered through net_guard::guard_ldap_addrs so a connection is opened only to a classified-public address (unless --allow-private-networks is set). A net-guard block prints a reason to stderr and returns NotAvailableOffline, which maps to IndeterminateNoCrl exactly like an unreachable HTTP CDP. On the online fetcher, ldap:// URIs are routed to this client by fetch_crl (crates/pverify-cli/src/modes/online.rs:299), and a FetchLogEntry is recorded for parity with the HTTP path.
The response is size-capped at 32 MiB (MAX_RESPONSE_BYTES, ldap.rs:57), mirroring the HTTP fetcher, so a directory that streams an unbounded reply cannot exhaust memory.
When the CRL channel is indeterminate and OCSP material exists, the OCSP pipeline (run_ocsp_pipeline, revocation/mod.rs:620) is entered. All OCSP computation lives in crates/pverify-core/src/revocation/ocsp.rs; the HTTP transport that POSTs the OCSPRequest and returns the BasicOCSPResponse bytes is the host's RevocationFetcher::fetch_ocsp.
The source order (revocation/mod.rs:637) is: embedded ocspVals first, then AIA-fetch (online or from-bundle). The contract is "the first evaluable response wins": an embedded blob that fails to parse falls through to the next source, but an embedded response that parses and yields a definitive Indeterminate* (unauthorised/stale/nonce-mismatch/bad-signature) is terminal — the verifier consulted that response and got a fact, even if the fact is "not usable".
For an AIA-fetch, the request is built by build_ocsp_request_bytes (ocsp.rs:118): a single-cert RFC 6960 §4.1 OCSPRequest with the CertID under SHA-1 (RFC 6960 §4.1.1's REQUIRED default — issuer name hash, issuer key hash, leaf serial) plus a requestExtensions[Nonce]. The request is unsigned — pverify does not impersonate a requester identity; nonce-based correlation is the only client-side authentication.
The nonce is CSPRNG-generated (NonceSource::generate_nonce, 035-ocsp-client P1 / RFC 8954 §2.1). The NonceSource trait is injected by the caller, enabling platform-specific entropy without coupling the kernel to a specific RNG:
// crates/pverify-core/src/revocation/ocsp.rs
pub trait NonceSource: Send + Sync {
fn generate_nonce(&self) -> [u8; 16];
}The CLI uses OsNonceSource (rand::rngs::OsRng) and the browser/Workers WASM uses WasmNonceSource (getrandom + js feature), each drawing 16 bytes from the OS/platform entropy source per request.
nonce = CSPRNG[16] // CLI: OsRng, WASM/Workers: getrandom/js
16 octets is within RFC 8954 §3.1's permitted 1–32 range. A CSPRNG nonce provides authenticity — an attacker cannot predict the correct nonce before the request is sent. A nonce-mismatched response (Mismatched) maps to IndeterminateOcspUnknown → revocation_not_checked_online (§11.5.5).
Note: Prior to 035 P1, a deterministic
derive_nonce(SHA-256-based pseudo-nonce) was used. The function is still present in the codebase but is not used in the online-mode request builder (replaced by CSPRNG injection).
Workers/WASM online mode (035-ocsp-client P2). OCSP fetch now works in Cloudflare Workers (wasm32-unknown-unknown + js target). The RevocationFetcher implementation for WASM uses the fetch Web API, routing OCSP POST requests through the same /ocsp Cloudflare Pages Function used by the browser UI. This closes the gap where a Workers-hosted invocation of pverify-core previously could not perform live OCSP checks.
In-run OCSP cache (035-ocsp-client P3). Within a single verification run, repeated OCSP requests for the same (responder_url, cert_serial) pair are deduplicated. The cache is scoped to the lifetime of VerificationRequest execution — it is never persisted across runs, so it cannot cause stale-response reuse in a later invocation. The cache is keyed by (responder_url: &str, issuer_name_hash: &[u8], serial: &[u8]) and is a write-once read-many map; the first successful AIA-fetch response for a key is stored and re-used for subsequent lookups within the same run, reducing network latency when a signer and its TSA share an OCSP responder.
Each candidate response runs through evaluate_ocsp_candidate (revocation/mod.rs:752):
Parse — parse_basic_ocsp_response (ocsp.rs:168). A DER decode failure on an embedded response returns None (try next source); on a fetched response it is terminal (IndeterminateOcspMalformed).
Locate the matching SingleResponse by CertID — find_matching_single_response (ocsp.rs:185). The match is hash-algorithm-agnostic: cert_id_matches (ocsp.rs:200) recomputes the issuer name/key hashes under whatever digest the responder declared — SHA-1 (1.3.14.3.2.26), SHA-256, SHA-384 or SHA-512 — and compares. An unrecognised hash OID is a no-match, surfaced as IndeterminateOcspUnknown. (This hash-agnostic dispatch closed a real defect — v0.4.1.x Case 2 — where the freshness path re-located the entry by byte-equality against a SHA-1-only CertID and silently rejected SHA-256 responder CertIDs even when the responder said good.)
Responder authorisation (RFC 6960 §4.2.2.2 / §4.2.2.2.1) — validate_responder_authorisation (revocation/mod.rs:1129), detailed in §11.5.3.
Freshness — evaluate_freshness (ocsp.rs:335), detailed in §11.5.4.
Nonce — match_nonce (ocsp.rs:407), detailed in §11.5.5.
Signature — verify_response_signature (ocsp.rs:455) re-encodes tbsResponseData and verifies it against the responder cert's SPKI via the shared verify_with_alg dispatcher.
The outcome is aggregated by a gate order with first-failing-gate-wins severity (revocation/mod.rs:862): signature → authorisation → nonce → freshness → CertStatus. An unsupported responder signature algorithm yields IndeterminateOcspMalformed (algorithm not run — INDETERMINATE, the OID is surfaced verbatim in responder_signature_alg); a failed verification of a supported algorithm yields IndeterminateOcspResponderUnauthorised at the revocation-outcome layer, with the eventual TOTAL_FAILED ocsp_responder_signature_invalid decided by the indication-aggregation ladder (precedence in Chapter 5 §5.7; Result Model in Chapter 14). Only after all gates pass is CertStatus mapped: good → GoodOnOcsp, revoked → RevokedOnOcsp (with revocation time + reason), unknown → IndeterminateOcspUnknown.
validate_responder_authorisation (revocation/mod.rs:1129) implements the RFC 6960 case-split:
Case (a) — issuer signs (is_issuer_signs, ocsp.rs:531): the responder cert's subject DN equals the leaf's issuer DN — the CA signs its own OCSP responses. Outcome IssuerSigns.
Case (b) — designated signer: requires three conditions:
build_responder_chain_outcome (revocation/mod.rs:1261) reuses the same crate::path::validate_path RFC 5280 §6 engine as the signer chain, with the cert pool minus the responder itself as intermediates. A chain that does not anchor → ChainNotAnchored.id-kp-OCSPSigning EKU (1.3.6.1.5.5.7.3.9, responder_has_ocsp_signing_eku, ocsp.rs:499). Missing → MissingOcspSigningEku.id-pkix-ocsp-nocheck (1.3.6.1.5.5.7.48.1.5, responder_has_nocheck, ocsp.rs:512), pverify MUST NOT recursively check the responder cert's own revocation. If it does not carry nocheck and the responder cert has any revocation pointer (CRL DP or AIA OCSP), pverify refuses with ResponderRevocationCheckRequiredButNotSupported rather than entering an unbounded OCSP-of-OCSP recursion (revocation/mod.rs:1196).The responder cert is located by ResponderId (locate_responder_cert, revocation/mod.rs:1211): ByName matches a candidate's subject DN; ByKey matches the SHA-1 of the candidate's SPKI subjectPublicKey raw bits. Candidates are drawn first from response.certs (the responder's own embedded assertion, the most authoritative source) and then from the host-supplied cert pool. A designated-signer responder's chain validation result is surfaced in the OcspAttempt.responder_chain_outcome field so the auditor sees the responder's own chain.
evaluate_freshness (ocsp.rs:335) maps (producedAt, nextUpdate) × mode × source onto an OcspFreshness outcome:
| Condition | Outcome | Notes |
|---|---|---|
source == embedded_ocsp_vals |
ArchivalTimeFresh |
the archive-TS-v3 imprint over the SignedData is the integrity anchor, not the responder's window (FR-076 carve-out) |
producedAt > verification_time |
ProducedAfterVerificationTime |
responder clock skew; refused regardless of mode |
nextUpdate < verification_time (online/offline) |
Stale |
the response has expired relative to the VRT |
nextUpdate < verification_time (from_bundle) |
FromBundleStorage |
bundle storage is the integrity anchor (§II reproducibility) |
nextUpdate absent, online + AIA-fetch, producedAt within 24 h |
Fresh |
RFC 6960 §4.2.2.1 — absent nextUpdate means "status is always current"; 24 h window accepted for live fetches (035-ocsp-client fix) |
nextUpdate absent (other online/offline) |
NoNextUpdate |
refused per FR-067's pessimistic policy |
nextUpdate absent (from_bundle) |
FromBundleStorage |
carve-out |
producedAt ≤ verification_time ≤ nextUpdate |
Fresh |
accepted |
Three carve-outs deserve emphasis. Embedded responses are always ArchivalTimeFresh because, by the ETSI LT/LTA model, the archive timestamp over the SignedData — not the responder's own nextUpdate — is what protects the embedded revocation evidence. From-bundle responses with an absent or past nextUpdate are FromBundleStorage rather than Stale, because the bundle's content-addressed storage is the integrity anchor and the auditor's verdict must be reproducible across every later run regardless of how stale the responder's own window has since become. Live AIA-fetch responses with no nextUpdate (e.g. MOJ CRPKI 商業登記 OCSP responder) are accepted as Fresh per RFC 6960 §4.2.2.1 when producedAt is within 24 h of the verification time — the standard explicitly states that absent nextUpdate means the status is always current. In online and offline modes outside these carve-outs a stale or no-nextUpdate response is refused. As in the CRL case, verification_time here is the per-object VRT of the certificate whose revocation the response attests (031-vrt-per-object).
Stale, ProducedAfterVerificationTime and NoNextUpdate all map to the revocation outcome IndeterminateOcspStale (revocation/mod.rs:937).
match_nonce (ocsp.rs:407) applies the following discipline:
source == embedded_ocsp_vals → SkippedEmbedded (a signer/archivist could not predict a future verifier's nonce).mode == from_bundle or mode == offline → SkippedFromBundle (bundle responses captured at archival time carry a different nonce; bundle storage is the integrity anchor — the same discipline applies to offline, where pverify generated no nonce).derive_nonce(...) (revocation/mod.rs:804); a response with no nonce extension → NotPresentOnline; a present-but-different nonce → Mismatched; an equal nonce → Matched.Mismatched and NotPresentOnline both route to IndeterminateOcspUnknown (revocation/mod.rs:925) — pverify refuses an online response it cannot correlate to its own request.
When the OCSP channel is consulted, the per-leaf OcspAttempt (crates/pverify-core/src/report/mod.rs:1490) is surfaced on the RevocationRecord. It records, verbatim, the source, the responder_uri (or the literal "embedded"), the ordered attempted_uris, the responder subject DN, the producedAt (camelCase preserved to match the RFC 6960 wire-name), the responder signature algorithm OID, and the freshness/nonce_match/ responder_authorisation/response_status enum verdicts. The OcspResponseStatus enum (report/mod.rs:1445) mirrors RFC 6960 §4.2.1's successful/malformedRequest/internalError/tryLater/sigRequired/ unauthorized plus two pverify-internal pseudo-statuses, malformed (DER decode failure) and unreachable (transport failure / bundle miss). The response_sha256 (016-diagnostic-data) pins the consulted response bytes by digest.
035-ocsp-retry addition (schema 1.8.0): When response_status == "unreachable" for an AIA-fetch, the attempt record additionally carries request_der_hex — the hex-encoded DER of the OCSPRequest the core built for that URI. The browser JS host reads this field from the first-pass report, POSTs it to the /ocsp Cloudflare Pages Function, and supplies the proxy-fetched response back to the core in the second pass. This closes the browser OCSP gap: the core's revocation pipeline runs identically in both passes; the host merely provides the pre-fetched material as though it were a bundle lookup. request_der_hex is absent for embedded responses, bundle lookups, and successful fetches — byte-identity is preserved for every report that does not hit the retry path.
The attempt is omitted entirely when pverify did not consult the OCSP channel — the byte-identity invariant means a CRL-good chain carries no ocsp_attempt.
A signature whose leaf carries an AIA OCSP URI, verified --offline with no bundle, used to report INDETERMINATE / ocsp_responder_unreachable with a fabricated OcspAttempt (response_status = unreachable, a bundle_lookup_miss against a bundle that was never supplied, and a responder_authorisation verdict that was never made). This was factually dishonest: offline mode opens no sockets, so no responder was ever contacted — a direct violation of Constitution §I.
The fix is in run_ocsp_pipeline (revocation/mod.rs:662). After the embedded ocspVals loop has run (embedded responses are not a network contact and are still honoured offline), and before any AIA-fetch attempt, the pipeline short-circuits in Offline mode:
if matches!(mode, VerificationMode::Offline) {
return RevocationRecord { outcome: IndeterminateRevocationOffline,
ocsp_attempt: None, /* cdp_entries, crl_used preserved */ };
}
The result carries the new closed-enum RevocationOutcome::IndeterminateRevocationOffline (report/mod.rs:822) with no ocsp_attempt at all — consumers read an absent attempt as "not consulted". The CDP-side evidence (cdp_entries, crl_used) is preserved so the auditor still sees what was attempted on the CRL channel. The indication-aggregation ladder maps this to the dedicated sub-indication revocation_not_checked_offline (verify.rs:2752), distinct from the ocsp_responder_unreachable claim.
The fix is precisely scoped. FromBundle (a genuine bundle-miss legitimately yields bundle_lookup_miss = true + unreachable) and Online (a genuine transient timeout yields IndeterminateOcspUnreachable) are unchanged (revocation/mod.rs:674). The zero-egress guarantee is regression-locked: the offline path opens no sockets. This is the same honesty discipline that, on the embedded-CRL fall-through, lets an offline LT signature still validate from its own in-signature material — what offline forbids is fabricating a contact, not consuming material already in hand.
RevocationOutcome VocabularyThe per-step revocation result is the closed enum RevocationOutcome (crates/pverify-core/src/report/mod.rs:768). It is the single vocabulary reused verbatim by the signer chain, the TSA chains (033) and the PAdES /DSS//VRI-sourced checks (034) — branch 034 added zero new outcome values, only provenance tags. Adding a value is an additive MINOR schema bump (FR-021a); the legacy IndeterminateOcspOnly is retained in the wire enum for stored-report deserialization but is never emitted by a current binary.
| Outcome | Severity / Indication | Sub-indication (via aggregation, Ch. 5 §5.7 / Ch. 14) |
|---|---|---|
GoodOnCrl |
TOTAL_PASSED contribution | — |
GoodOnOcsp |
TOTAL_PASSED contribution | — |
RevokedOnCrl |
TOTAL_FAILED | signer_certificate_revoked |
RevokedOnOcsp |
TOTAL_FAILED | signer_certificate_revoked_via_ocsp |
IndeterminateNoCrl |
INDETERMINATE | crl_stale_no_fresher |
IndeterminateStaleCrl |
INDETERMINATE | crl_stale_no_fresher |
IndeterminateIndirectCrl |
INDETERMINATE | unsupported_indirect_crl |
IndeterminateUnsupportedProtocol |
INDETERMINATE | unsupported_cdp_protocol |
IndeterminateOcspUnknown |
INDETERMINATE | ocsp_unknown_status |
IndeterminateOcspMalformed |
INDETERMINATE | ocsp_unknown_status |
IndeterminateOcspStale |
INDETERMINATE | ocsp_response_stale |
IndeterminateOcspResponderUnauthorised |
INDETERMINATE (or TF if signature invalid) | ocsp_responder_chain_unauthorised |
IndeterminateOcspUnreachable |
INDETERMINATE | ocsp_responder_unreachable |
IndeterminateRevocationOffline |
INDETERMINATE | revocation_not_checked_offline |
IndeterminateOcspOnly (legacy) |
retained, not emitted | ocsp_only_revocation_pointers |
The mapping from outcome to ETSI sub-indication is performed in aggregate_etsi/the indication ladder (verify.rs from line 2491 for the TOTAL_FAILED revoked cases, and from line 2680 for the INDETERMINATE cluster), with the aggregation precedence in Chapter 5 §5.7 and the resulting Result Model in Chapter 14. The crucial property an auditor relies on: the only two outcomes that produce TOTAL_FAILED are RevokedOnCrl and RevokedOnOcsp — a positive, signature-verified revocation statement. Every inability to establish status — no CRL, stale CRL, unreachable responder, unauthorised responder, offline — is INDETERMINATE with a precise reason, never a fabricated failure. This is the "cannot affirm" discipline applied to the revocation channel.
For completeness and to bound auditor expectations:
apply_revocations iterates only the non-anchor steps (verify.rs:2087).id-pkix-ocsp-nocheck but bearing its own revocation pointers yields ResponderRevocationCheckRequiredButNotSupported (§11.5.3).good, but neither does a bad CRL signature produce a TOTAL_FAILED — it is IndeterminateNoCrl (§11.3.4)./DSS//VRI material is tagged by the PAdES layer, not by the revocation engine, which is deliberately provenance-agnostic (pdf_source: None at revocation/mod.rs:517). See Chapter 6.Release: v1.3.0 · Report schema: 1.9.0
This chapter specifies how pverify verifies RFC 3161/5816 time-stamp tokens, how it recomputes the message imprint of archive time-stamps, how it judges the timestamp authority (TSA) certificate chain — including the consistent TSA-certificate revocation behaviour landed in branch 033-tsa-revocation-consistency — and, at the centre of the chapter, how the pverify-core::vrt engine derives a per-object Validation Reference Time (VRT) for the signer chain, the signer signature, each signature time-stamp and each archive time-stamp via the JNSA §4 recursive outer-covering rule introduced in branch 031-vrt-per-object.
The material here is the temporal backbone of every long-term (B-LT / B-LTA) verdict pverify produces. The path-validation machinery (see Chapter 9) and the revocation machinery (see Chapter 11) are time-parameterised: they take a single OffsetDateTime and judge certificate validity windows and revocation freshness at that instant. The VRT engine is the component that decides which instant each object is judged at, and the timestamp verifier is the component that produces the trustworthy times the engine consumes. The two are tightly coupled and are therefore documented together.
All claims below are grounded in crates/pverify-core/src/timestamp.rs, crates/pverify-core/src/vrt/ (mod.rs, coverage.rs, promotion.rs), crates/pverify-core/src/report/vrt.rs, the integration sites in crates/pverify-core/src/verify.rs, and the specifications under specs/031-vrt-per-object/ and specs/033-tsa-revocation-consistency/.
pverify's timestamp surface implements:
| Standard | Clause | What pverify does |
|---|---|---|
| RFC 3161 | §2.4.1, §2.4.2 | Parse a TimeStampToken as a CMS SignedData; require eContentType == id-ct-TSTInfo (1.2.840.113549.1.9.16.1.4); decode TSTInfo |
| RFC 3161 | §2.4.2 | Verify the TSA's CMS signedAttrs signature and the messageDigest attribute over the TSTInfo content |
| RFC 3161 | §2.4.1 | Recompute hash(message_imprint_input) under the messageImprint.hashAlgorithm and compare to hashedMessage |
| RFC 5816 | (ESS cert binding) | Honour the ESS signing-certificate/v2 binding via the shared CMS signed-attrs path (Chapter 5) |
| RFC 5652 / EN 319 122-1 §6.3 | CAdES archive-time-stamp-v3 |
Recompute the §6.3.4 canonical imprint input and compare (Chapter 5) |
| EN 319 142-1 §5.4 / ISO 32000-2 §12.8.5 | PAdES /DocTimeStamp |
Treat as an archive time-stamp over the revision's ByteRange |
| ETSI EN 319 102-1 | timestamp sub-process, REVOKED_NO_POE |
A confirmed-revoked TSA cert degrades to INDETERMINATE / revoked_no_poe (033) |
| ETSI TS 101 903 v1.4.2 Annex A.1.5 | XAdES ArchiveTimeStamp imprint |
exc-C14N of ds:Signature minus current+subsequent ATS node-sets (036) |
| JNSA デジタル署名検証ガイドライン 第 1.1 版 §3.2 / §4 | recursive outer-covering | The per-object VRT engine (031) |
The per-object VRT discipline addresses the JNSA guideline gap review — long-term-signature validation reference time is not fully applied (specs/031-vrt-per-object/spec.md "Background"). The consistent TSA-certificate revocation behaviour addresses the JNSA guideline gap review (specs/033-tsa-revocation-consistency/).
What this chapter does not cover: the §6.3.4 archive-time-stamp-v3 canonical input construction itself (Chapter 5), the OCSP/CRL evaluation internals (Chapter 11), and the RFC 5280 path-validation algorithm (Chapter 9). This chapter consumes those as primitives and concentrates on the temporal logic.
verify_timestamp_tokencrates/pverify-core/src/timestamp.rs::verify_timestamp_token is the per-token entry point. Every format handler — CAdES, PAdES, XAdES, JAdES, and ASiC-inner — funnels each RFC 3161 token through this one function, which projects the result into a crate::report::TimestampToken (report/mod.rs:998).
pub fn verify_timestamp_token(
tst_bytes: &[u8],
message_imprint_input: Option<&[u8]>,
role: TimestampRole,
intermediates: &[Certificate],
anchors: &[TrustAnchor],
verification_time: OffsetDateTime,
) -> Result<TimestampToken, Error>The parameters carry the following contract (timestamp.rs:124):
tst_bytes — the outer ContentInfo (CMS-shaped). The function rejects any input whose encapContentInfo.eContentType is not id-ct-TSTInfo (TST_INFO_OID, timestamp.rs:67), returning Error::Parse per RFC 3161 §2.4.2.message_imprint_input — the bytes the token claims to commit to. Some(input) triggers an imprint recomputation; None is the structural-surface mode used by archive callers that have not (or cannot) reconstruct the canonical imprint input, and by the first PAdES /DocTimeStamp pass. See §12.3.role — TimestampRole::{SignatureTimestamp, ContentTimestamp, ArchiveTimestamp} (report/mod.rs:988). This drives whether a per-stamp imprint_record is populated (only for ArchiveTimestamp).verification_time — the time at which the TSA chain is judged. This is the per-object VRT. The parameter retains the generic name verification_time rather than vrt precisely because the function is format-agnostic: the caller decides whether the value passed is a covering-TS GenTime or the raw request time (timestamp.rs:113-123). This is the seam at which 031 plugs in.verify_timestamp_token performs, in order (timestamp.rs:132-319):
SignedDataParser::parse decodes the outer CMS; eContentType is checked against id-ct-TSTInfo. A mismatch is a hard parse error.TSTInfo decode. parse_tst_info (timestamp.rs:334) walks the TSTInfo SEQUENCE positionally via the BER walker, extracting messageImprint (hashAlgorithm OID + hashedMessage OCTET STRING) and genTime. It requires at least the first five fields (version, policy, messageImprint, serialNumber, genTime); policy, serialNumber, accuracy, ordering, nonce, tsa, extensions are not surfaced in the report.parse_generalized_time (timestamp.rs:409) accepts the YYYYMMDDhhmmssZ form (15+ ASCII bytes), trims a fractional-second suffix if present, and rejects any value lacking the trailing Z (i.e. local-time or offset GeneralizedTime is refused — RFC 3161 TSAs emit UTC Z). This is a deliberately strict decoder.reconstitute_certificate_der, timestamp.rs:324) and parsed. The TSA leaf is the first non-CA cert (basic_constraints.is_none_or(|bc| !bc.ca)), falling back to the first cert; the remaining embedded certs become TSA intermediates (timestamp.rs:170-181). A token carrying no parseable certificate is a parse error.verify_signed_attrs confirms the TSA's signature over its signedAttrs, and verify_content_digest confirms the messageDigest attribute equals the digest of the TSTInfo eContent. The two booleans signature_ok and message_digest_ok gate the final message_imprint_match flag. Verifying both rules out a TSA whose signedAttrs claim the wrong eContent (timestamp.rs:183-195).message_imprint_input == Some(_)). See §12.3.validate_path (Chapter 9) runs the TSA leaf + intermediates against the anchors at verification_time, with an empty user-initial-policy-set (&[] = {anyPolicy}). RFC 3161 §2.4 does not tie TSA acceptance to the signer's policy posture, so the TSA chain is validated independently of --required-policy (timestamp.rs:267-277).crate::verify::wire_chain_step_signatures then verifies each chain step's certificate signature with RustCrypto, marking each step Verified / NotApplicableForRoot (timestamp.rs:284). Without this step the TSA chain steps would carry only the structural placeholder from validate_path.message_imprint_match semanticsThe final wire field message_imprint_match is set to imprint_check && signature_ok && message_digest_ok (timestamp.rs:313). This is a conjunction of three facts, not just the imprint comparison:
Some(input) callers, imprint_check is the recomputed-versus-declared digest comparison.None callers (structural surface), imprint_check defaults to true (timestamp.rs:258), so the field reflects only TSA self-consistency (signature + messageDigest). The field name is honest about this: a None-mode token whose TSA signature verified is "internally consistent and signed by the embedded TSA", which is exactly what the downstream VRT engine treats as token_internally_valid (§12.5).A token whose TSA signature fails — even if the imprint matched — reports message_imprint_match == false, because a TST whose signature failed established nothing (timestamp.rs:305-312). The downstream sub-indication layer (Chapter 5 §5.7) treats false as "this TST is INDETERMINATE".
Cannot-affirm note.
verify_timestamp_tokenreturnsResult; a malformed token (Err) is dropped by the caller rather than fabricating a failed token. The verdict that flows from a dropped orfalse-flagged token is INDETERMINATE, never a forged-token TOTAL_FAILED. This is Constitution §I applied to timestamps.
When message_imprint_input == Some(input), the verifier recomputes hash(input) under the algorithm declared in messageImprint.hashAlgorithm and compares it byte-for-byte to hashedMessage (timestamp.rs:211-259):
imprint_alg = HashAlg::from_oid_bytes(tstInfo.hashAlgorithm)
match imprint_alg {
Some(alg) => matched = (compute_digest(alg, input) == tstInfo.hashedMessage)
None => matched = false // unsupported algorithm — cannot recompute
}
The supported digest catalogue is SHA-1 / SHA-256 / SHA-384 / SHA-512 (HashAlg::from_oid_bytes). An unknown OID yields matched = false and, for ArchiveTimestamp callers, an ImprintOutcome::UnsupportedAlgorithm { oid } record — pverify reports that it could not recompute, not a false mismatch.
For role == ArchiveTimestamp, the comparison is captured in a structured ArchiveTimestampImprintRecord (report/mod.rs:1323) embedded on the token:
| Field | Meaning |
|---|---|
imprint_algorithm_oid |
Dotted OID of the imprint hash algorithm |
declared_digest |
Hex of tstInfo.hashedMessage |
recomputed_digest |
Hex of compute_digest(alg, input) (empty when the algorithm is unsupported) |
outcome |
ImprintOutcome::{Match, Mismatch, UnsupportedAlgorithm{oid}} |
signature_timestamp and content_timestamp roles carry no imprint record by design (timestamp.rs:232, 242-254) — their cover is the signature value, not a recomputed canonical input; the per-stamp record is reserved for the archive-TS imprint recomputation (the §6.3.4 input for CAdES-LTA, Chapter 5). The aggregate LongTermArchivalIndication (report/mod.rs:1051) is folded from the per-stamp ImprintOutcomes by the CAdES handler (verify.rs:913-934): any mismatch → ArchiveTimestampImprintMismatch, else any unsupported → ArchiveTimestampImprintUnsupportedAlgorithm, else ArchiveTimestampVerified; an archive-free signature is NoArchiveTimestamp.
A
Mismatchis direct evidence that the archive time-stamp does not cover the artefact it claims to cover. The CAdES handler escalates that to a TOTAL_FAILED via thearchive_timestamp_imprint_mismatchprecedence inaggregate_etsi(Chapter 5 §5.7); anUnsupportedAlgorithmis INDETERMINATE (cannot affirm).
Branch 036-xades-blta (PR #125) added support for the xades:ArchiveTimeStamp imprint verification specified in ETSI TS 101 903 v1.4.2 Annex A.1.5. The XAdES ATS imprint is structurally different from the CAdES archive-time-stamp-v3 input:
Imprint input. The imprint is the Exclusive C14N (exc-C14N, RFC 3741) of the entire ds:Signature element, with a specific node-set subtraction: any xades:ArchiveTimeStamp elements that appear at or after the current ATS in document order are excluded from the node-set before canonicalisation. This "non-circular" construction means each ATS commits to the signature state as it existed immediately before that ATS was added, without including subsequent ATS entries that did not yet exist at sealing time.
imprint_input = exc_c14n(ds:Signature node-set \ {ATS[k], ATS[k+1], …, ATS[n]})
^^^^^^ current ATS index k and all subsequent ATSes
This is implemented in crates/pverify-xades/src/blta.rs using the bergshamra-c14n crate's NodeSet subtract API — no new dependency beyond what XAdES already uses.
Shared data structures. ArchiveTimestampImprintRecord (§12.3) and LongTermArchivalIndication (§12.5 / report/mod.rs:1051) are shared between CAdES and XAdES. The XAdES handler (verify_xades) populates the same imprint_record on each xades:ArchiveTimeStamp token and folds the per-stamp ImprintOutcomes into the same LongTermArchivalIndication value used by CAdES: any mismatch → ArchiveTimestampImprintMismatch, else any unsupported → ArchiveTimestampImprintUnsupportedAlgorithm, else ArchiveTimestampVerified.
In-scope for 036. Single xades:ArchiveTimeStamp or a non-circular chain where each ATS subtracts the current and subsequent ATSes. Fixtures: synthetic build_blta / build_blta_tampered (in fixtures/), two xades_blta_e2e tests pass.
Out of scope. ArchiveTimestampV3 (CAdES concatenation format), multiple ATS chained imprint loops, JAdES arcTst, and DSS XAdES-LTA fixtures that use ds:Transform-based node-set selection (excluded from expectations.json because DSS applies an XPath Transform inside the ATS signed data, which pverify's exc-C14N imprint recomputation does not replicate).
Before branch 031, every check downstream of the VerificationRequest received the same request.verification_time — signer path validation, archive-TS verification, the TSA chain inside verify_timestamp_token, and the PAdES/XAdES/JAdES analogues (enumerated in specs/031-vrt-per-object/spec.md "Current pverify behaviour"). This is incorrect for long-term signatures.
Consider the headline JNSA failure (US1, spec.md lines 41-53): a CAdES-LTA whose signer certificate was valid 2023-06-01 … 2025-06-01, signed under a signature-time-stamp issued 2024-03-01, verified today at --at = 2026-06-20. The certificate has expired at the request time, but it was valid at the time the trusted timestamp proves the signature existed. Judging the signer chain at --at reports INDETERMINATE / signing_certificate_expired; judging it at the signature-TS GenTime correctly reports TOTAL_PASSED. JNSA デジタル署名検証ガイドライン 第 1.1 版 §3.2 / §4 mandate that each object be judged at the timestamp-derived time that covers that object.
The VRT engine computes, for one signature, four (families of) reference times:
Each object's VRT is the GenTime of the next outer covering timestamp, recursively; the outermost object falls back to request.verification_time. The signer chain and signer signature are the innermost covered objects; a signature-time-stamp covers them; an archive-time-stamp covers the signature-time-stamp; chained archive time-stamps cover each other. The engine surfaces every chosen time and the rule that chose it in signatures[i].vrt, so a relying party can audit "which time judged what" without reading source (Constitution §I).
vrt/coverage.rs)The engine projects the parsed timestamp tokens into an internal CoveringGraph (vrt/coverage.rs:140) — a tree-shaped DAG of CoveringNodes with outer_cover edges. None of these types crosses the wire; only crate::report::vrt is serialized.
CoveringKind (coverage.rs:38) tags each node:
| Kind | Source |
|---|---|
SignerSignature |
the signer-signature value (the innermost covered object); one per signature, a pseudo-node at index 0 |
SignatureTimestamp |
CAdES signature-time-stamp / xades:SignatureTimeStamp / JAdES sigTst |
CadesAtsv3 |
CAdES archive-time-stamp-v3 |
PadesDocTimestamp |
PAdES /DocTimeStamp |
XadesArchiveTimestamp |
xades:ArchiveTimeStamp |
JadesArchiveTimestamp |
JAdES arcTst |
Each CoveringNode (coverage.rs:93) carries the data the promotion gate needs, all of which is VRT-independent — the node flags are read off the already-verified TimestampToken and never re-derived:
gen_time — the TST's GenTime (sentinel UNIX_EPOCH for the signer pseudo-node).token_internally_valid — projected from ts.message_imprint_match (§12.2.3): the TSA signature + messageDigest + (where applicable) imprint were all consistent.imprint_status: Option<bool> — Some(true/false) from the archive imprint record (ImprintOutcome::Match), None for signature-TS / signer nodes that carry no imprint record.tsa_chain_anchored — chain_is_anchored(&ts.tsa_chain_outcome) (coverage.rs:440): the chain is non-empty and every step's signature is Verified or NotApplicableForRoot. This matches the predicate aggregate_etsi uses for TSA promotion.from_timestamps)All five formats share one builder, from_timestamps (coverage.rs:338), parameterised by the source tag and the archive node kind. The per-format constructors from_cades / from_pades / from_xades / from_jades (and ASiC, which routes to the CAdES shape when its inner signature is CAdES — an inner XAdES is verified through verify_xades, which tags its own source as Xades, mod.rs:160-170) differ only in which CoveringKind the archive nodes carry.
The builder constructs the covering edges (coverage.rs:346-429):
SignerSignature pseudo-node.archive_timestamps[0] if any archive TS exists, else None.archive[i]'s outer cover is archive[i+1] (each later token covers the earlier ones). The outermost archive TS has outer_cover == None.signature_timestamp[0] if present, else archive_timestamp[0] if present, else None.The "outermost-last" ordering is a contract on the extractor: CAdES emits archive-time-stamp-v3 unsigned attributes in nesting order; PAdES /DocTimeStamps are ByteRange-ordered so each later one covers all earlier PDF revisions (including earlier /Sig and /DocTimeStamp entries) — coverage.rs:245-271.
The PAdES covering shape is structurally identical to CAdES; the only difference is the archive node kind, which drives the wire-form kind: pades_doc_timestamp discriminator and the doc_timestamp derivation tag. JAdES-B-B is the degenerate case: an empty etsiU array yields a graph containing only the signer pseudo-node, so the signer VRT falls back to request_at (coverage.rs:302-325).
vrt/promotion.rs)derive_promotion_time (promotion.rs:147) computes, for one node, (time, derivation, demotion_reason). It is memoised over a BTreeMap cache so a chained-ATSv3 walk stays O(N), and it is depth-bounded at 8 (COVERING_DEPTH_BUDGET, promotion.rs:40) with cycle detection over a visited-set. The depth bound and cycle detection are an explicit anti-DoS / anti-forgery measure: "the only way to exhaust the depth budget on a well-formed input is a forged cycle" (promotion.rs:37-54), so both exhaustion paths map to VrtDemotionReason::CoveringCycleDetected.
is_valid_for_promotion (promotion.rs:71) gates whether an outer node may promote the time of an inner one. A node is a valid promotion source iff (promotion.rs:85-102):
Ok(false));gen_time <= request_at (the TS does not postdate the verification request; this prevents a future-dated TS from back-dating a verdict — risk R-2);token_internally_valid is true (TSA signature + messageDigest OK) and imprint_status != Some(false) (the archive imprint, if recomputed, matched);tsa_chain_anchored is true.Clauses 4 and 5 of FR-002a — the TSA cert is valid at the next outer covering time, and TSA-chain revocation is fresh at that time — are deliberately not evaluated inside the engine (promotion.rs:22-27). The engine is pure-compute and free of any certificate store; clauses 4-5 are realised at the call site, where validate_path and apply_revocations re-judge the TSA chain at the VRT this engine hands them (§12.8). This separation is what keeps the VRT engine deterministic, I/O-free and wasm-clean.
derive_inner (promotion.rs:167) implements the walk:
derive(node):
if cached(node): return cached
if depth == 0 or node already seen: return (request_at, fallback_invalid, CoveringCycleDetected)
outer = outer_cover_of(node)
if outer is None: # outermost
return (request_at, RequestAt, None)
if is_valid_for_promotion(outer):
result = (outer.gen_time, derivation_for_kind(outer.kind), None)
else:
result = (request_at, fallback_invalid, diagnose_ineligible(outer))
# recurse into outer's OWN vrt — clause-4 input for the call-site gate
outer_vrt = derive(outer)
if outer_vrt demoted as cycle and result was clean:
result = (request_at, fallback_invalid, CoveringCycleDetected) # propagate
return result
Two subtleties a PKI auditor should note:
promotion.rs:263-276): even when node's direct outer is eligible, the engine recurses into the outer's own VRT. If that recursion surfaces a cycle, the promotion is retracted — an inner object cannot trust a promotion to a covering time whose own time is itself cycle-broken.promotion.rs:235-240): the only way an Ok(false) can come from a SignerSignature-kind outer is a malformed input claiming the signer covers a TS; that is stamped as CoveringCycleDetected.When a candidate covering TS is ineligible, diagnose_ineligible (promotion.rs:109) synthesises the reason from the outer's flags, in priority order: FutureGentime → ImprintMismatch → TokenParseFailed → TsaChainUnanchored → (last-resort) TsaCertInvalidAtOuterCover. The full closed enum VrtDemotionReason (report/vrt.rs:247):
| Variant | Wire string | FR-002a clause |
|---|---|---|
FutureGentime |
future_gentime |
clause 1 |
ImprintMismatch |
imprint_mismatch |
clause 2 |
TokenParseFailed |
token_parse_failed |
clause 2 |
TsaChainUnanchored |
tsa_chain_unanchored |
clause 3 |
TsaCertInvalidAtOuterCover |
tsa_cert_invalid_at_outer_cover |
clause 4 |
TsaRevocationStaleAtOuterCover |
tsa_revocation_stale_at_outer_cover |
clause 5 |
CoveringCycleDetected |
covering_cycle_detected |
risk R-2 mitigation |
The derivation_for_kind map (promotion.rs:283) chooses the wire derivation tag: SignatureTimestamp → signature_timestamp; CadesAtsv3 / XadesArchiveTimestamp / JadesArchiveTimestamp → covering_archive_timestamp; PadesDocTimestamp → doc_timestamp. The split between covering_archive_timestamp and doc_timestamp lets a consumer distinguish a CMS-unsigned-attribute time anchor from a PDF-revision time anchor without inspecting the source TS entry's kind.
derive_vrt entry point and the wire shapederive_vrt (vrt/mod.rs:84) is the public, pure-compute entry. It takes a SignatureInputs borrow (mod.rs:45) — source tag, request_at, and the signature/archive token slices with their parallel timestamps[] indices — and returns a crate::report::vrt::Vrt block. It performs no I/O, reads no clock, and consults no trust store; FR-011a guarantees request_at byte-equals inputs[].at (mod.rs:18-21, 82-83), so the same inputs always yield the same VRT records (Constitution §II reproducibility). The unit test derive_vrt_is_byte_deterministic (mod.rs:231) pins this.
The engine derives the signer's promotion time once, copies it into both signer_chain and signer_signature (they share the innermost cover, mod.rs:99-106), then walks every non-signer node to fill the signature_timestamp[] and archive_timestamp[] arrays (mod.rs:111-145). Note one cross-format asymmetry: XAdES and JAdES archive-TS nodes are skipped from the wire-form archive_timestamp[] array (mod.rs:135-142) — their VRT surfaces in their format-native covering record via the derivation field, keeping the wire kind discriminator closed to the two values cades_atsv3 / pades_doc_timestamp (Q1, FR-010).
Vrt block (report/vrt.rs)Vrt (report/vrt.rs:31) is present on every SignatureEntry; inner arrays are empty when no covering timestamps / no revocation evidence apply (FR-005):
signatures[i].vrt = {
signer_chain: { value, derivation },
signer_signature: { value, derivation },
signature_timestamp: [ { value, derivation, timestamp_index, timestamp_role,
demotion_reason?, predates_request_at } ],
archive_timestamp: [ { ...as above..., kind: cades_atsv3 | pades_doc_timestamp } ],
revocation_evidence: [ { value, derivation, source_kind, source_index, attests } ]
}
| Field | Type | Notes |
|---|---|---|
value |
RFC 3339 time | the chosen reference time |
derivation |
VrtDerivation |
request_at / signature_timestamp / covering_archive_timestamp / doc_timestamp / request_at_fallback_invalid_timestamp |
timestamp_index |
usize | back-reference into signatures[i].timestamps[] so a relying party can correlate a VRT entry with the token it judges |
timestamp_role |
TimestampRole |
replicated for grep-friendly filtering |
demotion_reason |
Option<VrtDemotionReason> |
present only when the candidate covering TS was demoted; omitted on success |
predates_request_at |
bool | FR-008: gen_time > request_at (set on the TS entry, mod.rs:117) |
kind |
ArchiveTimestampKind |
archive entries only |
VrtDerivation (report/vrt.rs:207) is a closed enum; adding a value is an additive MINOR schema bump. The serde wire strings are pinned by round-trip tests (report/vrt.rs:291). The block was introduced at schema_version 1.2.0 → 1.3.0 (031), and the schema has since progressed to 1.9.0 (036). A Vrt::request_at_fallback() deserialization default (report/vrt.rs:54) substitutes report.verification_time for the UNIX_EPOCH sentinel when a stored pre-1.3.0 report is replayed through a current binary, so older reports round-trip honestly without a second clock read.
The VRT engine only computes times; the call site applies them. Taking CAdES as the reference (verify.rs:748-901), the flow is a two-pass design:
Every TST is verified once at request.verification_time (verify.rs:733-746 for archive TS, the analogous earlier loop for signature TS). The crucial property is that the engine's covering-graph inputs — gen_time and imprint_record — are VRT-independent, so the first pass produces them correctly regardless of the verification time used for the TSA chain (verify.rs:748-754).
derive_vrt is called with source = CoveringSource::Cades, request_at = request.verification_time, and the populated token slices with their indices (verify.rs:757-764). PAdES does the same with CoveringSource::Pades (pades/mod.rs:899-910).
verify.rs:775-807): if vrt.signer_chain.value != request.verification_time, the signer path is re-validated at the VRT — validate_path → wire_chain_step_signatures → the weak-algorithm flagging → apply_revocations, all at the VRT. The skip-when-equal guard is a byte-identity optimisation: re-validating at the same time would produce identical output, and the existing chain outcome already carries the layered flags.verify.rs:816-833) and each archive TS (verify.rs:834-857): if its VRT differs from request_at, the token is re-verified at the VRT (its next-outer covering TS's GenTime per FR-002a clause 4), replacing the first-pass token. This is where clause 4 (TSA cert validity at the outer cover) is actually checked — by validate_path inside the re-verification.The pades/mod.rs path is identical in structure (pades/mod.rs:911-919 for the signer re-validation guarded on vrt_block.signer_chain.value != verification_time).
This two-pass design — derive once, re-judge each object at its own time — keeps the VRT engine pure while moving the cert-store-dependent clauses 4-5 to the call site that owns the store and the fetcher. Critically, the revocation/algorithm layers never re-derive or mutate the covering graph or any VRT (FR-005a); the VRT is read once and threaded down.
Before branch 033, the signer chain was revocation-checked but the TSA chain inside a timestamp was only structurally surfaced — verify_timestamp_token itself touches no revocation (timestamp.rs:41-50). XAdES already applied TSA-chain revocation; CAdES, PAdES, JAdES and ASiC did not, which was the inconsistency the JNSA guideline gap review flagged. Branch 033 introduced one shared helper, apply_tsa_chain_revocation_for_token (verify.rs:1552), called by every format handler at each timestamp object's own VRT.
apply_tsa_chain_revocation_for_token (verify.rs:1552-1603):
token.tsa_subject_dn_text; the remaining embedded certs are the intermediates. If the leaf cannot be found, the function returns without mutation — the placeholder chain stands and surfaces as undeterminable (FR-006 "missing TSA leaf").vrt_time) so revocation has a step structure to annotate, wires the step signatures, then calls apply_revocations with the same (embedded_ocsp_der, embedded_crls_der, mode) channels the signer chain uses (FR-006a — the TSA chain is treated identically to the signer chain under each verification mode).token.tsa_chain_outcome with the resulting ChainResult. It touches only the timestamp's own chain — never the signer verdict, never the covering graph, never any VRT (verify.rs:1545-1547).The CAdES integration applies the helper unconditionally for every timestamp (verify.rs:867-901), not gated by the VRT-promotion continue of the second pass, because the first-pass verify_timestamp_token applied zero revocation — the TSA chain steps still carry the validate_path placeholder and must be filled in for every token. Each token's vrt_time is read from vrt_block.signature_timestamp[i] / archive_timestamp[i], falling back to request.verification_time.
In offline mode, the helper consumes embedded CMS revocation material only and never fabricates a responder contact; an "expected but unobtainable" status degrades to an undeterminable RevocationOutcome, mirroring the signer-chain offline policy (Constitution §VII offline honesty). For PAdES (branch 034) the embedded channels additionally carry the /DSS//VRI-resolved CRL/OCSP DER — see Chapter 6 §6.5. The CAdES call deliberately passes only the embedded CMS values, never PDF /DSS//VRI (FR-004).
revoked_no_poeapply_tsa_revocation_indication (verify.rs:1622-1653) is the last layer of the indication-composition ladder (after aggregate_etsi → signer-binding → content-type → algorithm-validity, verify.rs:992-998). It:
TOTAL_FAILED — returns the base unchanged if it is already TOTAL_FAILED (verify.rs:1627);RevokedOnCrl / RevokedOnOcsp at its VRT, degrades the indication to INDETERMINATE / SubIndication::TsaCertificateRevokedNoPoe (wire string revoked_no_poe, etsi.rs:424), with failing_locus set to the first revoked timestamp's locus, e.g. tsa_revocation.signature_timestamp[1] or tsa_revocation.archive_timestamp[0] (verify.rs:1640-1648).This mirrors ETSI EN 319 102-1 REVOKED_NO_POE for the timestamp sub-process: the TSA certificate is revoked and there is no proof of existence before revocation, so the timestamp can no longer be relied on as proof-of-existence. It is never TOTAL_FAILED — a revoked TSA certificate is "cannot affirm the timestamp", not a proven signature forgery (Constitution §I). An undeterminable TSA outcome (as opposed to confirmed-revoked) does not degrade on its own (FR-006); offline expected-but-unobtainable is already handled symmetrically by the signer-chain policy applied to the TSA chain (FR-006a), so no extra degrade is introduced.
TsaCertificateRevokedNoPoe was an additive closed-enum value introduced at schema_version 1.4.0 → 1.5.0; it reuses the existing RevocationOutcome vocabulary and adds no new revocation outcome.
The VRT engine and the TSA-revocation helper are shared by all five formats so a relying party never has to special-case CAdES vs PAdES vs XAdES vs JAdES vs ASiC (spec.md US3). The cross-cutting invariants a reviewer should hold the implementation to:
derive_vrt reads only request_at as a time source; it never calls Clock::now(), never touches a cert store, and never performs I/O (vrt/mod.rs:18-21). FR-002a clauses 4-5 are deferred to the call site by design.request_at == inputs[].at. No second clock read at VRT derivation (FR-011a). The CLI captures the verification time once at startup unless --at is supplied (Constitution §II).tsa_chain_outcome.request_at_fallback_invalid_timestamp with a populated demotion_reason — pverify reports why it could not promote, never silently recovers a verdict (spec.md US2 scenario 2).promotion.rs:37-54).request_at is skipped because it would produce identical output; this preserves byte-identical reports for the common no-covering-TS case (verify.rs:775, pades/mod.rs:911).pverify-core kernel; identical inputs produce byte-identical VRT blocks.Tying the chapter together with the US1 fixture (spec.md lines 41-53): signer cert valid 2023-06-01 … 2025-06-01; signature-TS GenTime 2024-03-01; archive-TS GenTime later; --at = 2026-06-20.
gen_time and imprint_record are captured. The signer chain validated at --at would report INDETERMINATE / signing_certificate_expired.2024-03-01) ≤ --at, is internally valid, and its TSA chain anchors, so it is an eligible promotion source.derive_vrt. vrt.signer_chain.derivation == signature_timestamp, vrt.signer_chain.value == 2024-03-01. The signature-TS's own VRT is the archive-TS GenTime (covering_archive_timestamp); the archive-TS (outermost) falls back to request_at.2024-03-01 — inside the cert validity window — yielding TOTAL_PASSED. Each TSA chain is revocation-checked at its own VRT via apply_tsa_chain_revocation_for_token.signatures[0].etsi_indication.indication == "TOTAL_PASSED", and signatures[0].vrt surfaces every chosen time and its derivation tag for audit.Contrast US2 scenario 2 (spec.md line 68): remove the archive-TS so the signature-TS itself becomes outermost. If both TSA certs have since expired, the signature-TS chain VRT falls back to request_at, the demotion surfaces honestly, and the indication degrades to INDETERMINATE — no silent recovery. That honest-degradation behaviour is the whole point of surfacing the derivation tag and demotion reason alongside the time.
This chapter specifies pverify's algorithm-validity verdict — the opt-in, per-object, VRT-scoped check that compares each cryptographic object's digest algorithm, signature-algorithm family and key length against a versioned policy effective as of that object's validation reference time, and degrades the ETSI indication to INDETERMINATE / crypto_constraints_failure_no_poe when an algorithm has sunset with no proof of existence before the sunset. It was introduced on branch 032-algorithm-validity (the JNSA guideline gap review) and moved algorithm validity from flag-only (weak_algorithms[], retained unchanged) to verdict-impacting.
The check is governed by ETSI EN 319 102-1's CRYPTO_CONSTRAINTS_FAILURE_NO_POE sub-indication, sources its default thresholds from CRYPTREC (電子政府推奨暗号 リスト) with NIST SP 800-57 Part 1 / SP 800-131A as the secondary reference, and is constrained by a dedicated constitutional clause (the "Algorithm-policy enforcement scope" clause ratified at constitution v1.2.0). It builds directly on the per-object Validation Reference Time engine of branch 031 (Chapter 12): the algorithm-validity engine never reads a clock and never re-derives a VRT — it consumes signatures[i].vrt verbatim.
The authoritative source files for this chapter are crates/pverify-core/src/algorithm_policy/{mod.rs,policy.rs,extract.rs} (the pure-compute engine, policy input types and OID/key extractors), crates/pverify-core/src/report/algorithm_validity.rs (the wire types), crates/pverify-core/src/verify.rs (the per-object assembly and indication layering), and docs/algorithm-validity-policy.md (the published human-readable policy reference, asserted byte-equal to the code constant).
The algorithm-validity check is shaped by four non-negotiable design principles that distinguish it from a naive "reject weak crypto" gate. Each is implemented in code and asserted by tests; this section names them up front because they explain every subtlety in the rest of the chapter.
1. Opt-in, default-off byte-identity. The check fires only when the host supplies a policy. With the policy absent, the report body is byte-identical to a pre-032 report except for the schema_version string (which advanced 1.3.0 → 1.4.0 on branch 032; the workspace is now at 1.9.0, see crates/pverify-core/src/report/schema.rs:line). The mechanism is structural: SignatureEntry.algorithm_validity is an Option<AlgorithmValidityCheck> with skip-when-None, so None is absent from the JSON rather than serialised as null, and the indication-layering call is skipped entirely when the policy is None (crates/pverify-core/src/verify.rs:970). This satisfies the Reproducibility invariant (Constitution §II): an additive feature must not perturb the report body when disabled.
2. Never TOTAL_FAILED — the "cannot affirm" posture (Constitution §I). A failing algorithm check never yields TOTAL_FAILED. pverify cannot affirm that a SHA-1 signature is forged; it can only state that it cannot affirm the signature's validity because the algorithm sunset before any proven point-of-existence. The honest verdict is therefore INDETERMINATE with sub-indication crypto_constraints_failure_no_poe (the EN 319 102-1 CRYPTO_CONSTRAINTS_FAILURE_NO_POE value). A cryptographically broken signature — a messageDigest mismatch, a failed signature verification — is the more severe and more fundamental fact and is reported as TOTAL_FAILED by the core pipeline; the algorithm layer never masks it (see principle 4).
3. VRT-scoped, not wall-clock-scoped (the long-term-signature payoff). Each object is judged against the policy effective as of that object's own VRT, read verbatim from the 031 VRT block. A SHA-1 signer signature whose VRT is 2012-06-01 (because a valid covering timestamp predates the 2014 sunset) is acceptable — this is precisely the long-term-signature semantics ETSI intends. The same SHA-1 signature with a request-time fallback VRT of 2026-06-22 (no covering timestamp, no proof-of-existence before the sunset) fails. The engine reads no clock; it consumes vrt_value from the request and never recomputes the covering graph (crates/pverify-core/src/algorithm_policy/mod.rs module doc, R-4).
4. Layered after the core verdict, never masking TOTAL_FAILED. The algorithm-validity indication is applied as one layer in the indication ladder after the base aggregate_etsi verdict and after the signer-binding and content-type layers. If the base is already TOTAL_FAILED, the layer returns it unchanged (apply_algorithm_validity_indication, crates/pverify-core/src/verify.rs:1488).
5. Unrecognised never silently passes (the exact gap 032 closes). An algorithm OID pverify does not recognise, or a key length it cannot recover, does not slip through as a pass. Under the default unrecognised_treatment = indeterminate it degrades to INDETERMINATE / crypto_constraints_failure_no_poe. This is the central correctness property of the slice: a verifier that silently accepts an unknown algorithm is unsafe.
The governing policy is a versioned, JSON-serialisable struct, AlgorithmPolicy (crates/pverify-core/src/algorithm_policy/policy.rs:170). Its identity is the (source, version) pair; both strings are surfaced verbatim in every report (policy_source / policy_version) so a report records which policy decided each object.
pub struct AlgorithmPolicy {
pub source: String, // authority of record
pub version: String, // policy version string
pub digest_rules: Vec<DigestRule>,
pub signature_rules: Vec<SignatureFamilyRule>,
pub unrecognised_treatment: UnrecognisedTreatment,
}The policy can only constrain algorithms pverify can name. The digest and signature-family value-spaces are closed Rust enums, mirrored 1:1 into the wire form:
| Enum | Variants (wire form) | File |
|---|---|---|
DigestId |
sha1, sha256, sha384, sha512, md5 |
policy.rs:99 |
SignatureFamily |
rsa, ecdsa, ed25519, mldsa |
policy.rs:110 |
UnrecognisedTreatment |
indeterminate (default), fact_only |
policy.rs:125 |
SignatureFamily::MlDsa deliberately serialises as the compact mldsa (not the snake-case ml_dsa) to match the ratified default-policy table and the mldsa-ok rule-id style (policy.rs:117). DigestId::Md5 is an always-unacceptable marker — it exists only so the default policy can pin MD5 to "never acceptable" (CRYPTREC-excluded) rather than leaving it unrecognised.
pub struct DigestRule {
pub digest: DigestId,
pub rule_id: String, // e.g. "sha1-sunset-2014"
pub acceptable_before: Option<PolicyDate>, // None = always; Some(D) = ok iff VRT.date < D
pub authority: String, // citation, surfaced in the report
}
pub struct SignatureFamilyRule {
pub family: SignatureFamily,
pub rule_id: String,
pub min_key_bits: Option<u32>, // None = no length floor (Ed25519/ML-DSA)
pub min_effective: Option<PolicyDate>, // None = always enforced; Some(D) = enforced iff VRT.date >= D
pub authority: String,
}PolicyDate is a date-only value (YYYY-MM-DD, RFC 3339 full-date) with a custom Serialize/Deserialize that enforces strict 10-character form with dashes at positions 4 and 7 and validates month/day ranges (policy.rs:56–policy.rs:89). Day granularity is sufficient for sunset semantics and keeps the policy hand-auditable. The constant PolicyDate::UNIX_EPOCH (1970-01-01) is the "never acceptable" marker: any realistic VRT is on/after it, so a digest pinned to acceptable_before = 1970-01-01 always fails.
The comparison semantics are deliberately asymmetric between the two rule kinds, both keyed on the helper vrt_on_or_after(vrt, date) (mod.rs:118), which returns vrt.date() >= date:
VRT.date < acceptable_before. The algorithm was reliable up to but not including the sunset date.min_effective is None OR VRT.date >= min_effective. Before the effective date the threshold is not yet in force (a short key from before the floor is acceptable); on/after it, a key shorter than min_key_bits fails.A malformed policy date (unreachable in practice — dates are validated at deserialize) is treated as effective (fail-closed), per the conservative comment at mod.rs:122.
Every policy input struct carries #[serde(deny_unknown_fields)] (policy.rs:138, :151, :169). A policy file with an extra or misspelled key is rejected at parse time, never silently ignored, and the run aborts with a non-zero exit and a clear error rather than falling back to the default (crates/pverify-cli/src/main.rs:319 load_algorithm_policy). This is FR-008. The test deny_unknown_fields_fails_closed (policy.rs:306) pins the behaviour.
When the host enables enforcement without supplying a file, the engine uses AlgorithmPolicy::default_policy() (policy.rs:186). Its full content is defined as a code constant and is a constitutional surface: the values are ratified by the constitution's "Algorithm-policy enforcement scope" clause (.specify/memory/constitution.md, v1.2.0), and the constant is asserted byte-equal to the machine-readable normative form in specs/032-algorithm-validity/contracts/default-policy.md (test C-3). A re-classification of an algorithm is therefore a governed change (a constitutional amendment), not a silent code edit.
Identity.
| Field | Value |
|---|---|
source |
CRYPTREC 電子政府推奨暗号リスト (primary) + NIST SP 800-57 Part 1 / SP 800-131A (secondary) |
version |
2026-06-default |
unrecognised_treatment |
indeterminate |
Digest rules (policy.rs:199–:230):
| Digest | rule_id |
acceptable_before |
Authority |
|---|---|---|---|
| MD5 | md5-never |
1970-01-01 (never) |
CRYPTREC excluded |
| SHA-1 | sha1-sunset-2014 |
2014-01-01 |
NIST SP 800-131A; CRYPTREC 危殆化 |
| SHA-256 | sha256-ok |
null (always) |
CRYPTREC 推奨 |
| SHA-384 | sha384-ok |
null (always) |
CRYPTREC 推奨 |
| SHA-512 | sha512-ok |
null (always) |
CRYPTREC 推奨 |
Signature-family rules (policy.rs:231–:260):
| Family | rule_id |
min_key_bits |
min_effective |
Authority |
|---|---|---|---|---|
| RSA | rsa-min-2048-2014 |
2048 | 2014-01-01 |
NIST SP 800-57; CRYPTREC 推奨 |
| ECDSA | ecdsa-p256-min |
256 | null (always) |
CRYPTREC 推奨 |
| Ed25519 | ed25519-ok |
null (no floor) |
null |
recognised strong |
| ML-DSA | mldsa-ok |
null (no floor) |
null |
PQC, no sunset |
The default rationale is explicit about authority precedence: CRYPTREC is the domestic authority of record (pverify is a Japanese-government GPKI verification tool tracking the JNSA デジタル署名検証ガイドライン), with NIST SP 800-131A / SP 800-57 supplying the transition dates CRYPTREC leaves implicit — notably the SHA-1 and RSA-2048 2014 cutovers. For ECDSA the observed "key bits" are the curve field size (P-256 → 256, P-384 → 384), so the 256 floor admits both recognised curves while excluding anything weaker. Ed25519 and ML-DSA carry no length floor — they are recognised-strong with no sunset.
The engine lives in crates/pverify-core/src/algorithm_policy/mod.rs and is no_std + alloc, zero-I/O, zero-clock and WASM-clean (Constitution §VI). Its public surface is two functions:
pub fn evaluate(objects: &[ObjectUnderEvaluation], policy: &AlgorithmPolicy)
-> Vec<AlgorithmValidityResult>; // one result per object, input order
pub fn overall_outcome(results: &[AlgorithmValidityResult]) -> AvOutcome;It consumes a pre-assembled &[ObjectUnderEvaluation] (built by the verify pipeline from the already-parsed VRT block and algorithm identifiers) and returns one AlgorithmValidityResult per object in input order — deterministic, hence byte-stable (Constitution §II). The determinism is pinned by determinism_byte_identical (mod.rs:456).
Each object carries its locus, its VRT (value + derivation, mirrored verbatim from the 031 block), and the observed algorithm data:
pub struct ObjectUnderEvaluation {
pub locus: ObjectLocus, // SignerChain | SignerSignature | Signature/ArchiveTimestamp{index}
pub vrt_value: OffsetDateTime, // R-4: consumed verbatim, never recomputed
pub vrt_derivation: VrtDerivation, // mirrored for the report (FR-012)
pub digest: Option<DigestId>, // None if undeterminable/unrecognised
pub signature_family: Option<SignatureFamily>,
pub signature_alg_oid: String, // always recorded, even if unrecognised
pub key_bits: Option<u32>, // None if undeterminable
}evaluate_one (mod.rs:264) runs two independent sub-checks and composes them worst-of:
Digest sub-check (check_digest, mod.rs:130):
digest = None) → Pass with no rule. This is not a failure on its own; a timestamp object, for instance, may carry only an imprint digest, and a family whose digest is internal carries no separate digest. The signature/key sub-check carries the verdict.unrecognised_treatment (never a silent pass).acceptable_before = Some(D) and VRT.date >= D; otherwise passes. The matched rule is recorded either way.Signature/key sub-check (check_signature, mod.rs:161):
signature_family = None and signature_alg_oid empty → Pass (this object carries no signature component to check, e.g. a timestamp judged on its imprint digest alone).signature_family = None but signature_alg_oid non-empty → an unrecognised OID was observed → routed through unrecognised_treatment (R-2). The engine carefully distinguishes "nothing to check" (empty OID) from "we saw an OID we don't recognise" — only the latter degrades.min_key_bits = None (Ed25519 / ML-DSA) → Pass, rule recorded.min_key_bits floor: the floor is enforced per the min_effective date; if enforced, key_bits >= min passes, key_bits < min fails, and key_bits = None (undeterminable against an enforced floor) degrades to Indeterminate ("cannot affirm").Composition (mod.rs:272): the object's outcome is the worst of the two sub-checks by the severity ordering Fail (2) > Indeterminate (1) > Pass (0) (severity, mod.rs:101). On a tie the signature/key rule wins, so a passing object records the more specific signature decision rather than a digest one (auditability, FR-012). matched_rule and reference_vrt are recorded for pass and fail alike, so a reader can recompute the decision from the report alone.
The single knob that converts "we cannot affirm" into an indication is unrecognised_outcome (mod.rs:218): Indeterminate (default) degrades the indication; FactOnly records the fact without degrading (for lenient deployments — explicitly NOT the default). This is the policy author's choice; the default is the safe one.
The engine never parses bytes — the host-side extract module (crates/pverify-core/src/algorithm_policy/extract.rs) maps already-parsed algorithm identifiers and certificate SPKI bytes to the closed policy enums. Every datum it recovers is already parsed elsewhere in the verify path for signature verification / weak-flagging (research R-5); this module gathers them rather than re-parsing, and adds no new dependency (FR-015): RSA key bits via the already-present rsa crate, ECDSA curve via spki / x509-cert.
| Function | Maps | Recognises |
|---|---|---|
digest_id_from_oid |
digest OID → DigestId |
SHA-1 (1.3.14.3.2.26), SHA-256/384/512, MD5 (1.2.840.113549.2.5) |
signature_family_from_oid |
sig-alg OID → SignatureFamily |
bare + combined RSA OIDs (incl. RSASSA-PSS …1.1.10), bare + combined ECDSA OIDs, Ed25519 (1.3.101.112), ML-DSA-44/65/87 |
digest_from_signature_oid |
combined sig-alg OID → embedded DigestId |
the <digest>With<family> PKCS#1 v1.5 / ECDSA-with-SHA OIDs |
key_bits_from_cert_der |
cert DER + family → key bits | RSA modulus bits, ECDSA curve field size (P-256→256, P-384→384) |
key_bits_from_cert_der (extract.rs:94) re-encodes the SubjectPublicKeyInfo out of the certificate DER and, for RSA, computes RsaPublicKey::size() * 8; for ECDSA it reads the namedCurve OID (1.2.840.10045.3.1.7 → 256, 1.3.132.0.34 → 384). For Ed25519 / ML-DSA it returns None by design — those families carry no length floor, so a None against a min_key_bits = None rule passes. Any parse failure also returns None; against an enforced floor (RSA/ECDSA with a min_effective date in force) a None is treated as a failure-to-affirm → Indeterminate. The recognised OID/curve sets are the same ones crypto.rs uses for signature verification, so the engine cannot "know" how to verify a signature it then fails to recognise for policy.
Each per-format verifier assembles the object inventory at the seam where the VRT block, the parsed signer certificate and the per-token records are all in hand, then calls evaluate and layers the indication. The CMS-shaped formats (CAdES, PAdES-embedded CMS, ASiC-wrapped CAdES) share a single assembler; XAdES and JAdES have analogues that build the same ObjectUnderEvaluation shape.
assemble_and_evaluate_cms_objects (crates/pverify-core/src/verify.rs:1671) builds the object set in a deterministic order:
signer_chain — judged on the signer (leaf) certificate's own signatureAlgorithm (the digest+family the issuer used to sign the leaf); the key length is the issuer's public key — the key that actually produced the certificate signature. The issuer is located by subject/issuer DN match among the intermediates (find_issuer_cert, verify.rs:1779); when the issuer cert is unavailable (e.g. a self-issued leaf, or an anchor not surfaced) the key length falls back to the signer key, which is the common GPKI shape.signer_signature — the signer's signature over the signed attributes: digest = the signed-attrs digest algorithm; family = the signer key's family (recovered from the leaf SPKI via signer_spki_family, verify.rs:1818); key length = the signer key. The reported signature_alg_oid is the combined digest+family OID where determinable (signer_signature_oid, verify.rs:1832) so the report shows the real algorithm and the policy's family rule matches.signature_timestamp[i] — one per signature-time-stamp, judged on its message-imprint digest. signature_family = None and signature_alg_oid empty, so only the imprint digest is checked (timestamp_object, verify.rs:1797).archive_timestamp[i] — one per archive-time-stamp, judged the same way.Each object's VRT is read from the matching 031 VRT entry by index; when no entry is found (defensive) it falls back to the signer-signature VRT (verify.rs:1735–:1764).
Scope note (032): the timestamp objects judge the imprint digest only. The TSA signing algorithm and CRL/OCSP responder signing algorithms are out of scope for branch 032 (deferred follow-ups,
docs/algorithm-validity-policy.md§ Scope). Timestamp validity itself remains the concern of the 031 VRT engine (Chapter 12) and the 033 TSA-revocation layer (Chapter 11).
The same check runs across all five families. The assembler is shared for the CMS-shaped formats; XAdES and JAdES build the inventory from their own signature methods:
| Format | Call site | Notes |
|---|---|---|
| CAdES | verify.rs:972 (assemble_and_evaluate_cms_objects) |
reference implementation |
| PAdES | crates/pverify-core/src/pades/mod.rs:1077 |
embedded CMS, reuses the CMS assembler |
| XAdES | crates/pverify-core/src/xades.rs:653 (evaluate_xades_algorithm_validity) |
signature method → family/digest |
| JAdES | crates/pverify-core/src/jades.rs:350 |
B-B only in the current slice |
| ASiC | via the delegated inner CAdES/XAdES pipeline | inherits the inner format's check |
All call sites guard on algorithm_policy.is_some() / request.algorithm_policy.as_ref().map(...), so the field stays None and the indication is untouched when the policy is absent — preserving the off-path byte-identity (§13.1, principle 1).
apply_algorithm_validity_indication (crates/pverify-core/src/verify.rs:1480) is the single point at which the algorithm check influences the verdict. It runs as one layer in the never-mask indication ladder (Chapter 5 §5.7; Result Model Chapter 14):
aggregate_etsi (base)
→ apply_signer_binding_indication
→ apply_content_type_indication
→ apply_algorithm_validity_indication ← this chapter
→ apply_tsa_revocation_indication
Its logic is deliberately small:
pub(crate) fn apply_algorithm_validity_indication(
base: EtsiIndication,
results: &[AlgorithmValidityResult],
) -> EtsiIndication {
if base.indication == Indication::TotalFailed { return base; } // never mask (R-6)
// most-severe failing object: Fail > Indeterminate; earliest on a tie
let worst = results.iter()
.filter(|r| matches!(r.outcome, Fail | Indeterminate))
.fold(None, /* keep earliest on severity tie */);
match worst {
Some(r) => EtsiIndication::indeterminate(
SubIndication::CryptoConstraintsFailureNoPoe,
Some(r.locus.locus_string())), // e.g. "algorithm_validity.signer_signature"
None => base, // all pass → base unchanged (FR-010)
}
}Key properties:
TOTAL_FAILED (the early return at verify.rs:1488, R-6). A cryptographically broken signature stays TOTAL_FAILED; the algorithm fact is the less fundamental one.algorithm_validity block.Fail outranks Indeterminate; among equals the earliest in input order wins (the fold keeps the current on a >= severity comparison, verify.rs:1507), so the failing_locus is deterministic.failing_locus is a precise object string — AvObjectLocus::locus_string (crates/pverify-core/src/report/algorithm_validity.rs:101) renders algorithm_validity.signer_chain, algorithm_validity.signer_signature, algorithm_validity.signature_timestamp[1], etc. An auditor can therefore see which object degraded the verdict and at which VRT.The result is always INDETERMINATE with crypto_constraints_failure_no_poe, never TOTAL_FAILED — for both the Fail (sunset, no POE) and Indeterminate (unrecognised / undeterminable) cases. The two cases are distinguished in the per-object outcome field, not in the indication.
When enforcement is on, each SignatureEntry carries an algorithm_validity block (crates/pverify-core/src/report/algorithm_validity.rs:38):
AlgorithmValidityCheck
├─ policy_source : String — authority of record of the *active* policy
├─ policy_version: String — version of the active policy
├─ results : [AlgorithmValidityResult]
└─ overall : AvOutcome — worst across results (pass iff all pass)
AlgorithmValidityResult
├─ locus : { kind, index? } — signer_chain | signer_signature
│ | signature_timestamp | archive_timestamp
├─ reference_vrt : { value, derivation } — the VRT this object was judged at,
│ reusing 031's time_fmt + VrtDerivation
├─ observed_digest : String|null — digest OID, null if undeterminable
├─ observed_signature_alg : String — sig-alg OID, always recorded
├─ observed_key_bits : u32|null — key bits, null if undeterminable
├─ matched_rule : MatchedRule|null — recorded for pass AND fail (FR-012)
└─ outcome : "pass"|"fail"|"indeterminate"
MatchedRule
├─ rule_id : String — stable id, e.g. "sha1-sunset-2014"
├─ kind : "digest_sunset" | "key_length"
├─ effective_date : String|null — YYYY-MM-DD, null for always-applicable
└─ authority : String — citation copied from the rule
Every result is self-describing and recomputable from the report alone (FR-012): a relying party can re-derive the verdict by hand — for a sunset rule, fail iff reference_vrt.value's date ≥ effective_date and the algorithm/key is the weak one. The reference_vrt reuses the canonical crate::report::time_fmt serializer and the 031 VrtDerivation enum verbatim, so the time format and the derivation vocabulary are not duplicated (algorithm_validity.rs:170). policy_source / policy_version always name the active policy (default or supplied), so a stored report is unambiguous about which authority decided each object.
weak_algorithms[]The pre-existing weak_algorithms[] facts (header-level WeakAlgorithmFlag, crates/pverify-core/src/report/mod.rs:250) are retained unchanged by branch 032. They are a flag-only inventory — every per-step SHA-1 observation, by role (signed_attrs_digest, signer_signature, certificate_signature, crl_signature, tsa_message_imprint, tsa_signature, ess_signing_certificate_digest) — and flagging them never degrades the indication. The 032 algorithm_validity block is the orthogonal, verdict-impacting, opt-in, VRT-scoped layer; it does not replace the facts. A report with the policy on therefore carries both: the raw weak-algorithm observations and the policy verdict that contextualises them against each object's VRT.
The policy is loaded host-side; the core never reads a file (Constitution §V).
CLI (crates/pverify-cli/src/main.rs): the flag is --algorithm-policy [FILE], modelled as Option<Option<PathBuf>> (main.rs:271). Absent → None (off). Present with no path → the built-in default_policy(). Present with a path → load_algorithm_policy (main.rs:319) reads, UTF-8-validates and parses the JSON, failing closed on any error (main.rs:763). The parsed policy is threaded into VerificationRequest.algorithm_policy (verify.rs:157).
WASM (web/pverify-wasm/src/lib.rs:66): the byte-identical equivalent. The input carries an AlgorithmPolicyInput enum — Default (built-in) or Custom (a fully-parsed AlgorithmPolicy) — resolved to the same Option<AlgorithmPolicy> (lib.rs:380). Because both hosts feed the identical struct into the identical verify_with kernel, the CLI↔︎WASM parity invariant holds: identical inputs (including identical policy) yield byte-identical reports.
These are drawn from the engine tests (mod.rs:352–:488) and the published reference (docs/algorithm-validity-policy.md), all against the default policy.
| Object | VRT | Observed | Rule matched | Outcome | Indication contribution |
|---|---|---|---|---|---|
| SHA-1 / RSA-2048 signer sig | 2013-12-31 |
sha1, rsa, 2048 | sha1-sunset-2014 |
pass (2013 < 2014) |
none |
| SHA-1 / RSA-2048 signer sig | 2014-01-01 |
sha1, rsa, 2048 | sha1-sunset-2014 |
fail (2014 ≥ 2014) |
INDETERMINATE / crypto_constraints_failure_no_poe |
| RSA-1024 / SHA-256 signer | 2026-06-22 |
sha256, rsa, 1024 | rsa-min-2048-2014 |
fail (2026 ≥ 2014, 1024 < 2048) |
INDETERMINATE / …no_poe |
| RSA-2048 / SHA-256 signer | any | sha256, rsa, 2048 | rsa-min-2048-2014 |
pass | none |
| ECDSA P-256 / SHA-256 | any | sha256, ecdsa, 256 | ecdsa-p256-min |
pass | none |
| Ed25519 | any | —, ed25519, — | ed25519-ok |
pass | none |
| ML-DSA-65 | any | —, mldsa, — | mldsa-ok |
pass | none |
Unknown sig OID 1.2.3.4.5 |
any | —, none, — | (none) | indeterminate | INDETERMINATE / …no_poe |
| RSA family, key bits unrecoverable | 2026-06-22 |
sha256, rsa, null | rsa-min-2048-2014 |
indeterminate | INDETERMINATE / …no_poe |
The first two rows are the long-term-signature payoff in action: the same SHA-1 signature is affirmable when a covering timestamp proves its existence before the 2014 sunset, and unaffirmable when it does not. The penultimate row is the gap 032 closes — an unknown algorithm does not silently pass.
--algorithm-policy FILE replaces the built-in default with a JSON policy of the same shape. A deployment can be stricter than the default (e.g. sunset RSA-2048 effective 2020, require RSA-3072) by supplying a file like:
{
"source": "ACME strict deployment policy",
"version": "strict-2020",
"digest_rules": [
{ "digest": "sha256", "rule_id": "sha256-ok", "acceptable_before": null, "authority": "CRYPTREC" }
],
"signature_rules": [
{ "family": "rsa", "rule_id": "rsa-min-3072-2020", "min_key_bits": 3072,
"min_effective": "2020-01-01", "authority": "ACME" }
],
"unrecognised_treatment": "indeterminate"
}The emitted policy_source / policy_version then name this policy, so the report records which policy decided each object. A malformed or extra-field file aborts the run (#[serde(deny_unknown_fields)], §13.2.3) — never a silent fallback to the default.
The wire types in crates/pverify-core/src/report/algorithm_validity.rs are a 1:1 mirror of the schema delta pinned by specs/032-algorithm-validity/contracts/report-schema-algorithm-validity-delta.md, which is in turn mirrored into the root report-schema.json. The introduction of the algorithm_validity block, the AvOutcome / RuleKind / AvObjectLocus closed enums and the crypto_constraints_failure_no_poe sub-indication were all additive (a new optional field, new closed-enum values), hence an additive MINOR bump (1.3.0 → 1.4.0 on branch 032; the report shape is now at SCHEMA_VERSION = "1.10.0", crates/pverify-core/src/report/schema.rs:line). crypto_constraints_failure_no_poe is a value of the single closed SubIndication enum (crates/pverify-core/src/report/etsi.rs:399), round-trip tested at etsi.rs:698.
Unusually for an additive feature, the values of the default policy table are a constitutional surface. The constitution's "Algorithm-policy enforcement scope" clause was materially expanded and the default table ratified at version 1.2.0 (.specify/memory/constitution.md lines 4, 183, 193). The intent is governance: because pverify is a government verification tool whose verdicts may carry legal weight, a change to what counts as a weak algorithm as of a date must be a deliberate, documented amendment — not a silent code edit. The C-3 contract test enforces the lockstep between the code constant, the contracts/default-policy.md normative form, and the published docs/algorithm-validity-policy.md.
In scope (032): digest / signature-family / key-length checks at each object's VRT, across CAdES, PAdES, XAdES, JAdES, and ASiC-wrapped CAdES/XAdES.
Out of scope (deferred): re-judging timestamp validity (the 031 VRT engine's concern, Chapter 12); checking the TSA signing algorithm or the CRL/OCSP responder signing algorithms (the timestamp objects judge only the imprint digest, §13.6.1); network-fetched or auto-updating policies (the policy is supplied as a static, deterministic input); and any legal-validity claim (pverify reports facts, not legal conclusions — Constitution §I). The algorithm-validity layer composes with, but does not subsume, the TSA-revocation layer of branch 033 (Chapter 11), which independently degrades to INDETERMINATE / revoked_no_poe when a TSA certificate is revoked at its VRT.
This chapter specifies the shape, semantics and governance of the JSON fact report that pverify emits — the only externally-consumed artefact of a verification run. It defines the ETSI EN 319 102-1 indication and sub-indication value-spaces (§14.2–14.4), the validation_objects[] material inventory and its provenance model including the PAdES /DSS and /VRI additions of branch 034 (§14.5), the diagnostic data carried on each signature entry (§14.6), the report-schema.json contract and its additive-MINOR versioning discipline (§14.7–14.8), and the CLI exit-code semantics together with the relying-party verdict map (§14.9).
The governing principle throughout is Constitution §I, fact-reporting / "cannot affirm": the report states observed facts, never a business verdict. Where a fact cannot be established the report degrades to INDETERMINATE with a precise closed-enum sub-indication rather than fabricating TOTAL_FAILED or TOTAL_PASSED. The pipeline that produces the indications is described in Chapter 3 (system architecture); the per-object Validation Reference Time machinery whose results the report surfaces is described in Chapter 12; this chapter is concerned with what the report says, not how it is computed.
The report type is the Rust struct Report in crates/pverify-core/src/report/mod.rs. Its module documentation states the binding contract explicitly: the Rust types are a 1:1 mirror of specs/001-verify-cades-pades/contracts/report-schema.json, and any divergence is a bug, not a stylistic choice. Three determinism constraints (Constitution §II, reproducibility) are enforced at the type level and are normative for every field:
BTreeMap fields that do exist (NameConstraintsCheck.cumulated_permitted / cumulated_excluded, report/mod.rs) are keyed on the Ord-derived GeneralNameForm enum, which gives a deterministic key order.f64. Every numeric field is an integer or a boolean, removing floating-point formatting non-determinism.Vec<u8> is forbidden as a serialised field; all binary material is pre-encoded to hex strings by the host before it reaches the report layer (e.g. subject_fingerprint, body_sha256). Times use time::serde::rfc3339, which round-trips RFC 3339 exactly and preserves sub-second precision.Because the kernel is no_std + alloc and I/O-free (Chapter 3), the report is constructed entirely from in-memory model structures; the host (pverify-cli/src/render.rs natively, web/pverify-wasm/src/lib.rs in the browser) merely serialises the returned Report to JSON. The native and WASM paths run the identical verify_with kernel, so their outputs are byte-identical given identical inputs (the CLI↔︎WASM parity invariant, Chapter 3).
Report (crates/pverify-core/src/report/mod.rs) is emitted once per verify invocation regardless of how many signatures the document carries (one document → one report → N signatures[] entries). The required field order is fixed by the schema's required array and is asserted by the header_serialises_in_schema_required_order test.
┌─ Report ─────────────────────────────────────────────────────────────┐
│ pverify_version binary version (env CARGO_PKG_VERSION) │
│ schema_version "1.10.0" — report-shape SemVer (§14.8) │
│ path_validation_phase "v0.6-bridge-acceptance" (capability surface) │
│ bridge_ca_supported true │
│ dn_match_method "rfc4518-minimal-v0.1" │
│ verification_time RFC 3339 — the single captured clock instant │
│ mode online | offline | from_bundle │
│ inputs[] InputHash — one per consumed file / policy OID │
│ fetch_log[] FetchLogEntry — outbound HTTP, online only │
│ weak_algorithms[] WeakAlgorithmFlag — union of per-step flags │
│ signatures[] SignatureEntry — one per signature (§14.6) │
│ validation_objects[] ValidationObject — material inventory (§14.5) │
└────────────────────────────────────────────────────────────────────────┘
The first five fields are the constitutional invariants (crates/pverify-core/src/report/schema.rs): compile-time facts about the binary that produced the report, never read from configuration. schema_version, path_validation_phase, bridge_ca_supported and dn_match_method are pinned as const strings/booleans both in schema.rs and as "const" JSON-Schema constraints (report-schema.json lines 24–27); the header_emits_constitutional_invariants test enforces the round-trip. Adding a new invariant constant is a MINOR schema bump; changing the value of an existing one is MAJOR (§14.8).
mode is the closed enum Mode { Online, Offline, FromBundle } (wire "online"/"offline"/"from_bundle"). It controls revocation acquisition and, by extension, the origin-derivation heuristic in §14.5. verification_time is the single instant captured once at CLI startup (or supplied via --at); the same (inputs, anchors, time, revocation-material) tuple yields a byte-identical report independent of when or where it runs (Constitution §II).
The header also carries validation_objects[] appended last in the required order. This positioning is deliberate: it was added in branch 016 as a new required top-level field, and appending it last keeps every prior field byte-identical with pre-016 reports (report/mod.rs, Report.validation_objects doc-comment, SC-004).
Report::from_json (report/mod.rs) is the contract surface for reading older stored reports. A pre-031 report (schema 1.2.0) omits the signatures[].vrt block entirely. Serde fills the missing field via #[serde(default = "vrt::Vrt::request_at_fallback")], which synthesises a block whose times are the OffsetDateTime::UNIX_EPOCH sentinel; normalise_stored_vrt_defaults then substitutes report.verification_time for every sentinel value (FR-014). Callers that use serde_json::from_str directly get the raw sentinel; only the from_json boundary performs the substitution. This is the canonical example of how the closed schema absorbs older report bodies without a MAJOR break (§14.8).
The top-level verdict is the closed enum Indication (crates/pverify-core/src/report/etsi.rs), mirroring ETSI EN 319 102-1 §5:
| Rust variant | Wire string | Meaning (EN 319 102-1) |
|---|---|---|
TotalPassed |
"TOTAL_PASSED" |
Every check pverify ran returned positively. |
Indeterminate |
"INDETERMINATE" |
Available information was insufficient to decide. |
TotalFailed |
"TOTAL_FAILED" |
At least one check pverify ran returned negatively. |
The indication is carried per signature in the EtsiIndication body (report/etsi.rs):
pub struct EtsiIndication {
pub indication: Indication,
pub sub_indication: Option<SubIndication>, // None only on TOTAL_PASSED
pub failing_locus: Option<String>, // free-text artefact/step id
}failing_locus is a deliberately free-text identifier of the artefact/step where the failure was observed (e.g. "step[1]", "1.3.101.112" for an unsupported OID, "archive_time_stamp_v3", "tsa_revocation.signature_timestamp[1]"). It is the only free-text field in the indication; the sub_indication value-space is strictly closed (§14.4). A TOTAL_PASSED indication carries sub_indication: null and failing_locus: null (the total_passed_emits_null_optionals test pins this).
The indication is assembled by aggregate_etsi (crates/pverify-core/src/verify.rs:2343) plus a small stack of layered refinements. aggregate_etsi establishes a severity-ordered base with TOTAL_FAILED > INDETERMINATE > TOTAL_PASSED and the rule "the first finding in the dominant severity class wins; ties prefer the earliest source so the report is positionally stable." Concretely, the precedence ladder inside aggregate_etsi checks, in order: the path-validation structural finding (which already encodes chain_constraints_failure at TOTAL_FAILED severity), then archive-time-stamp-v3 imprint mismatch (verify.rs:2386, TOTAL_FAILED), then OCSP-responder verify-and-fail (verify.rs:2403, TOTAL_FAILED), then the signedAttrs / content-digest gates, then the revocation outcomes.
After the base, four layered passes run; each is forbidden from masking a proven TOTAL_FAILED and only ever degrades a non-failed base (Chapter 5 §5.7):
apply_signer_binding_indication (verify.rs:1324) — ESS signing-certificate binding.apply_content_type_indication (verify.rs:1441) — RFC 5652 §5.3 content-type equality.apply_algorithm_validity_indication (verify.rs:1480) — opt-in, VRT-scoped algorithm policy. The guard at verify.rs:1488 returns the base unchanged when it is already TOTAL_FAILED; among failing objects it picks the most-severe locus (Fail > Indeterminate, earliest on ties).apply_tsa_revocation_indication (verify.rs:1622) — TSA-chain revocation, the 033 cross-format consistency layer.This layering realises the cannot affirm discipline at the verdict level: a deprecated algorithm, an unconfirmable timestamp, or a revoked TSA certificate degrades the verdict to INDETERMINATE but never overrides the harder fact of a cryptographically broken signature.
SubIndication (crates/pverify-core/src/report/etsi.rs) is a single closed Rust enum, mirrored value-for-value into the sub_indication enum of report-schema.json (lines 964–1031). Free-form sub-indications are forbidden — the module comment notes that surprising strings would let consumers misread severity (Constitution §I). Adding a value is an additive MINOR schema bump; removing or repurposing one is MAJOR (§14.8). Several deprecated variants (bridge_ca_required_unsupported, chain_constraints_failure, policy_processing_inconclusive, ocsp_only_revocation_pointers, not_yet_supported_in_v0.1) are retained in the wire enum for stored-report deserialisation but are no longer emitted by the current binary; emission is suppressed in aggregate_etsi / validate_path.
The wire strings are taken verbatim and must round-trip exactly. Two are intentionally irregular and are pinned by dedicated tests: the camelCased messageDigest_mismatch (sub_indication_camel_case_preserved) and the literal version dot in not_yet_supported_in_v0.1 (sub_indication_version_dot_preserved).
The table below groups every currently-emitted sub-indication by severity class and the check that raises it. The class is a property of how aggregate_etsi and its layers route the value, not of the enum itself.
| Wire string | Raised by |
|---|---|
chain_signature_failed |
A chain-step certificate signature verified and failed. |
signed_attrs_signature_failed |
The signer's signedAttrs / content signature verified and failed. |
messageDigest_mismatch |
RFC 5652 §11.2 messageDigest ≠ recomputed content digest. |
signer_certificate_revoked |
CRL reported the signer leaf revoked (RevokedOnCrl). |
signer_certificate_revoked_via_ocsp |
OCSP CertStatus = revoked for the signer leaf (RevokedOnOcsp). |
ocsp_responder_signature_invalid |
OCSP response signature verified and failed (tampering-equivalent). |
signer_certificate_not_found |
SignerInfo.sid matched no embedded certificate (signer substitution). |
signing_certificate_digest_mismatch |
ESS signing-certificate(v2) certHash ≠ signer cert (RFC 5035). |
signing_certificate_issuer_serial_mismatch |
ESS issuerSerial present but ≠ signer cert issuer+serial. |
content_type_mismatch |
content-type signed-attr OID ≠ eContentType (RFC 5652 §5.3). |
content_type_missing |
signedAttrs present but content-type attribute absent. |
archive_timestamp_imprint_mismatch |
archive-time-stamp-v3 imprint recompute ≠ declared (EN 319 122-1 §6.3.4). |
xades_reference_digest_mismatch |
A ds:Reference digest ≠ recomputed canonicalized node-set. |
xades_signing_certificate_mismatch |
XAdES SigningCertificateV2 CertDigest ≠ verifying cert. |
jades_payload_digest_mismatch |
JAdES JWS signature ≠ computed signing input. |
jades_signing_certificate_mismatch |
JAdES x5t#S256 / x5t#o digest ≠ signer cert. |
asic_data_object_digest_mismatch |
ASiC-E CAdES manifest DigestValue ≠ recomputed ZIP-entry digest. |
pdf_byte_range_does_not_cover_eof |
PAdES /ByteRange leaves bytes after the last %%EOF unsigned. |
pdf_byte_range_malformed |
PAdES /ByteRange is structurally invalid. |
key_usage_missing_required_bit |
A required KeyUsage bit is absent. |
signer_certificate_expired_at_time / signer_certificate_not_yet_valid_at_time / anchor_expired_at_time |
Validity-window failure at the relevant reference time. |
| Wire string | Raised by |
|---|---|
signature_algorithm_unsupported |
Unrecognised signature OID or unsupported ECDSA curve params (did not run). |
public_key_malformed |
Recognised algorithm, SPKI key octets undecodable into a verifying key. |
crypto_constraints_failure_no_poe |
Opt-in algorithm policy: algorithm/key no longer acceptable at object's VRT. |
revoked_no_poe |
A timestamp's TSA chain confirmed revoked at that object's VRT (033). |
signer_identifier_malformed |
SignerInfo.sid malformed/absent — signer not identifiable. |
signing_certificate_digest_not_evaluable |
ESS certHash hash algorithm not computable. |
content_type_not_evaluable |
content-type present-but-unparseable, or eContentType unavailable. |
revocation_not_checked_offline |
--offline and no usable embedded/CDP revocation; no socket opened. |
ocsp_response_stale |
OCSP nextUpdate past, absent (online/offline), or producedAt future. |
ocsp_responder_chain_unauthorised |
RFC 6960 §4.2.2.2 designated-signer authorisation failed. |
ocsp_unknown_status |
OCSP unknown, nonce mismatch, malformed DER, or non-successful status. |
ocsp_responder_unreachable |
Every OCSP attempt failed transiently (online) or bundle-miss (from_bundle). |
crl_stale_no_fresher |
CRL nextUpdate past and no fresher CRL available. |
unsupported_indirect_crl / unsupported_cdp_protocol |
Indirect-CRL / non-HTTP CDP scheme not supported. |
indeterminate_named_constraint |
Name-Constraints state machine rejected the chain (RFC 5280 §4.2.1.10). |
name_constraints_unsupported_form / name_constraints_malformed |
Unsupported GeneralName form / malformed extension. |
indeterminate_policy_rejected |
Policy processing rejected the chain (RFC 5280 §6.1.5). |
policy_mappings_malformed |
policyMappings malformed (e.g. maps anyPolicy). |
archive_timestamp_imprint_unsupported_algorithm |
archive-TS-v3 imprint hash OID outside supported catalogue. |
xades_unsupported / xades_unsupported_profile / xades_unsupported_canonicalization / xades_signer_certificate_unavailable / xades_timestamp_imprint_mismatch |
XAdES out-of-scope packaging / C14N / cert / timestamp imprint. |
jades_unsupported_profile / jades_unsupported_serialization / jades_signer_certificate_unavailable |
JAdES out-of-scope baseline / serialization / cert. |
asic_data_object_missing / asic_unsupported_container |
ASiC covered object absent / unrecognised ZIP container. |
cms_multi_signer_unsupported |
CMS SignedData carried more than one SignerInfo — verdict refused. |
A point of audit subtlety: signature_algorithm_unsupported and the *_signature_failed values are deliberately distinct. The former means "we did not run the verification" (INDETERMINATE); the latter means "we ran it and it failed" (TOTAL_FAILED). Branch 029 hardened this boundary: SignedAttrsSignature::Failed now carries a typed cause (SignedAttrsFailureKind, report/mod.rs) stamped at the failure site, and aggregate_etsi routes on the typed value rather than re-parsing the human-readable reason string — closing a brittle coupling that could have silently flipped an unsupported algorithm to a forgery claim. The cause is #[serde(skip)], so the wire form is byte-identical to pre-029 reports.
validation_objects[] — the material inventory and its provenancevalidation_objects[] (branch 016) is a deduplicated, deterministically-sorted inventory of every raw material the run actually consulted — certificates, CRLs, OCSP responses, timestamp tokens and the signed content — each pinned by its SHA-256, tagged with a ValidationObjectKind, and carrying an origin[] array recording where the bytes came from. It is built by derive_validation_objects (crates/pverify-core/src/report/validation_objects.rs) as a pure derivation pass over the already-assembled Report tree: every digest it emits is a field already present elsewhere on the report, so FR-007 ("no divergent hashing") holds by construction, and the derivation reads only &Report — no raw-byte side inputs, no network, no new crypto.
pub struct ValidationObject {
pub sha256: String, // 64 lowercase hex, primary dedup key
pub kind: ValidationObjectKind,
pub origin: Vec<ValidationObjectOrigin>, // ≥1, sorted by decl order, deduped
pub usage: Vec<ValidationObjectUsage>, // omitted when empty
pub parsed: bool, // omitted when true (the common case)
}ValidationObjectKind (closed enum): certificate, crl, ocsp_response, timestamp_token, signed_content.
Determinism is structural (Constitution §II, SC-003): the derivation uses no HashMap. It collects tentative objects during the walk, deduplicates by the 64-hex digest via a BTreeMap (dedup_and_sort), unions and sorts each object's origin[] and usage[], ANDs the parsed flag across contributors, and finally sorts the whole vector by (kind, sha256). Two runs over identical inputs therefore produce a byte-identical array. A digest uniquely identifies its bytes, so kind is invariant across a collision and the first wins (dedup_and_sort comment). The schema constrains sha256 to ^[0-9a-f]{64}$ and origin to minItems: 1 (report-schema.json lines 1044–1055), so an object can never have an empty provenance set (the origin_is_never_empty test).
ValidationObjectOrigin and the 034 PAdES additionsValidationObjectOrigin (validation_objects.rs) is a closed enum whose declaration order is the sort order of the origin[] array:
| Variant | Wire string | Meaning |
|---|---|---|
SignatureEmbedded |
signature_embedded |
Bytes carried inside the signature (CMS certs/CRLs/OCSP, eContent, ByteRange, Adobe revocationInfoArchival). |
Bundle |
bundle |
Read from a --from-bundle directory. |
FetchedOnline |
fetched_online |
Fetched live over the network (online mode). |
TrustAnchor |
trust_anchor |
The terminating anchor (host-loaded; never a /DSS cert). |
SuppliedInput |
supplied_input |
Supplied detached content. |
PdfDss |
pdf_dss |
034: a PDF document-level /DSS pool, DSS-global fallback. |
PdfVri |
pdf_vri |
034: reached via a /VRI entry keyed by the signature's /Contents SHA-1. |
The two PAdES origins were appended after SuppliedInput precisely so that declaration order — and therefore origin[] sort order — leaves every pre-034 array byte-stable (FR-008/Q4). They are projected onto the inventory through the parallel additive provenance fields CrlSummary.pdf_source and OcspAttempt.pdf_source (report/mod.rs), each an Option<PdfRevocationSource> (pdf_dss / pdf_vri) that is omit-when-absent. Because these fields are absent for every fetched, bundled or CMS-embedded record, a PDF with no /DSS//VRI/revocationInfoArchival is byte-identical to a pre-034 report except schema_version.
The origin derivation for revocation material is the subtle part (collect_revocation in validation_objects.rs). A PDF-document source tag wins over the mode-based / OcspSource heuristic (R-7): if a CRL or OCSP response carries pdf_source: PdfDss/PdfVri, its origin is PdfDss /PdfVri regardless of run mode. This matters because a DSS/VRI-borne CRL/OCSP rides the embedded revocation channel internally (it would otherwise be heuristically tagged signature_embedded). Only when pdf_source is absent does the derivation fall back to the run-mode heuristic: from_bundle ⇒ bundle; a CRL with no source_uri ⇒ signature_embedded; otherwise ⇒ fetched_online; and analogously for OCSP via the OcspSource enum. This is the only place in the report where origin is inferred rather than directly known, and the inference is documented field-by-field.
Note one deliberate scope boundary, consistent with the DSS glossary definition: DSS /Certs are intermediates/leaf only and are never promoted to a trust anchor (Q7/FR-005). The TrustAnchor origin is reserved for the host-loaded anchor named by chain_result.terminating_anchor_fingerprint.
ValidationObjectUsage cross-referencesWhen present, usage[] records what role a material played (branch 016 US2). It is an internally-tagged enum on kind (ValidationObjectUsage, validation_objects.rs):
{"kind":"certificate_role","role": signer|ca|trust_anchor} — the leaf (chain step 0) is the signer, the terminating anchor is the trust anchor, every other step is a CA.{"kind":"revocation_target","evaluated_subject_fingerprint": "<64hex>"} — the leaf the CRL/OCSP was evaluated against.{"kind":"timestamp","timestamp_role": signature_timestamp|content_timestamp|archive_timestamp}.usage[] is omitted when empty so that US1-only consumers see byte-identical objects. There is intentionally no OcspResponder certificate role this slice (C1): OCSP responder certificates carry no SHA-256 in today's report, so there is no certificate object to attach the role to — a deferred additive extension.
Each SignatureEntry (crates/pverify-core/src/report/mod.rs) is the auditable, fact-level record of one signature. The non-optional spine is:
| Field | Purpose |
|---|---|
format |
SignatureFormat — the detected AdES level (§14.6.1). |
pdf_byte_range / pdf_covers_eof |
PAdES-only; null for non-PDF. |
content_source |
ContentSourceTag — embedded or detached; which byte source the digest ran against. |
chain_result |
The constructed RFC 5280 path: steps[], terminating_anchor_fingerprint, bridge_attempts[]. |
signed_attrs_check |
signedAttrs presence, digest-algorithm OID, digest match, signature outcome. |
content_digest_check |
declared vs computed content digest, plus covered_content_sha256 (016). |
timestamps[] |
TimestampToken — each signature/content/archive TST with its TSA chain. |
embedded_validation_data |
LT/LTA: embedded certs, CRLs, archive timestamps, archival indication. |
vrt |
Per-object Validation Reference Times (031; Chapter 12). |
etsi_indication |
The per-signature verdict (§14.3). |
A number of fields are additive optionals that are skipped when absent, preserving byte-identity for the formats that do not produce them: container / asic_manifest_checks[] / asic_conformance_deviations[] (ASiC, branch 020), signer_identifier_check / signing_certificate_binding (CMS signer binding, branch 025), content_type_check (RFC 5652 §5.3, branch 026), and algorithm_validity (opt-in policy, branch 032; Some(..) only with --algorithm-policy, so default-off reports are byte-identical to a 1.3.0 body except schema_version).
Branch 016 also added the three "previously-missing" SHA-256 fields that let derive_validation_objects read only &Report: ContentDigestCheck.covered_content_sha256 (the signed_content object's digest — the raw covered bytes, distinct from the signature-algorithm computed_digest), TimestampToken.token_sha256, and OcspAttempt.response_sha256. Each is skip_serializing_if empty/none, preserving byte-identity for stored reports that lacked them.
formatSignatureFormat (report/mod.rs) is the honest scope declaration. The emitted values, and what each implies about what pverify actually verified, are:
CAdES-BES / CAdES-T (signature-time-stamp) / CAdES-LT / CAdES-LTA (archive-time-stamp-v3 with §6.3.4 imprint recomputation).PAdES-B / PAdES-B-T / PAdES-B-LT / PAdES-B-LTA over embedded CMS plus /DocTimeStamp; from branch 034 the LT/LTA levels are offline-definitive using document-level /DSS//VRI and Adobe revocationInfoArchival.XAdES-B-B (enveloped, Exclusive C14N), XAdES-B-T, XAdES-B-LT, XAdES-B-LTA (ArchiveTimeStamp imprint verification per ETSI TS 101 903 v1.4.2 Annex A.1.5, branch 036; imprint = exc-C14N of ds:Signature minus subsequent ArchiveTS node-sets). Detached/enveloping packaging or non-exclusive C14N is refused via the retained XAdES-unsupported value and the corresponding sub-indication.JAdES-B-B / JAdES-B-T / JAdES-B-LT / JAdES-B-LTA (JWS JSON Serialization; branch 037). sigD detached, JWS Compact, and rfsTst/tstVd are refused via JAdES-unsupported.ASiC (EN 319 162) is orthogonal to format: an inner signature keeps its nested CAdES/XAdES value and the container field records ASiC-S / ASiC-E — there are no combined ASiC×format variants.
The RevocationRecord on each chain step (report/mod.rs) carries the closed RevocationOutcome enum, the CDP URIs, an optional CrlSummary, optional revocation time/reason, and an optional OcspAttempt. The RevocationOutcome vocabulary (GoodOnCrl, RevokedOnCrl, GoodOnOcsp, RevokedOnOcsp, the Indeterminate* family including IndeterminateRevocationOffline) is reused verbatim by the signer-chain, TSA-chain (033) and DSS-sourced (034) checks: a DSS-borne CRL/OCSP yields the existing outcome vocabulary with no new value (Q2/ FR-007). The honest offline outcome IndeterminateRevocationOffline replaced the earlier dishonest IndeterminateOcspUnreachable emission in offline mode (Constitution §VII / §I, offline honesty).
OcspAttempt (report/mod.rs) is the deepest diagnostic record. It surfaces RFC 6960 facts verbatim: source (OcspSource: stapled embedded vals vs AIA-fetch online/from-bundle), the responder URI and the ordered attempted_uris[], responder_subject_dn, producedAt (the camelCase wire-name is intentional, surfaced as-is per §I), responder_signature_alg OID, and four closed-enum outcomes — OcspFreshness, OcspNonceMatch, OcspResponderAuthorisation (RFC 6960 §4.2.2.2 designated-signer authorisation), and OcspResponseStatus (whose values 0..6 mirror the RFC 6960 §4.2.1 ASN.1 enumeration, plus the pverify-internal malformed/unreachable pseudo-statuses for parser/transport failures). The from_bundle/embedded carve-outs on freshness and nonce-match name the documented reproducibility exceptions explicitly.
TimestampToken carries the TSA subject DN, the nested tsa_chain_outcome (a full ChainResult for the TSA chain), gen_time, the message-imprint match, token_sha256, and — for archive timestamps only — an optional ArchiveTimestampImprintRecord with the recomputed/declared digests and per-stamp ImprintOutcome (match/mismatch/unsupported_algorithm).
report-schema.json contractThe normative wire contract is specs/001-verify-cades-pades/contracts/report-schema.json — a JSON Schema that pins, value-for-value, every closed enum and the required field set of the Rust types. The constitutional invariants are encoded as "const" constraints (lines 24–27), so a report whose schema_version, path_validation_phase, bridge_ca_supported or dn_match_method deviates fails schema validation. The EtsiIndication definition (lines 955–1037) requires all three of indication, sub_indication, failing_locus, with sub_indication and failing_locus typed as oneOf [null, …]; the sub_indication enum (lines 969–1030) is the authoritative list and includes the retained-but-unemitted deprecated values. ValidationObject (lines 1039–1062) requires sha256/kind/ origin, constrains sha256 to 64 hex and origin to a sorted subset of the seven-value origin enum including pdf_dss/pdf_vri.
The relationship between the schema and the Rust types is a maintained 1:1 mirror. The discipline (documented in etsi.rs, schema.rs and report/mod.rs) is that any rename, additive value or new field must update both the Rust enum/struct and the schema together; a divergence is a release-blocking bug. Several tests in etsi.rs and report/mod.rs pin the exact wire strings (round_trip_sub, the per-variant *_round_trips tests, validation_objects_roundtrip_and_enum_wire_strings) so an accidental rename fails CI.
SCHEMA_VERSION is the SemVer of the report shape (crates/pverify-core/src/report/schema.rs, currently "1.10.0"). The versioning rule is strict and is the project's central backward-compatibility lever:
SubIndication, RevocationOutcome, ValidationObjectOrigin, …) or adding an optional / skip-when-absent field, or adding a new required top-level field that is appended last.const.A deprecated value is therefore never removed — it is retained for stored-report deserialisation and merely no longer emitted, which keeps the change MINOR (the deprecation pattern visible across bridge_ca_required_unsupported, IndeterminateOcspOnly, NotYetSupportedInV01, etc.).
The 1.x lineage, read from the SCHEMA_VERSION doc-comment in schema.rs, is:
| Version | Branch | Additive change |
|---|---|---|
| 1.0.0 | v0.1 | Initial report shape. |
| 1.1.0 | v0.5 | bridge_attempt → bridge_attempts[] (structural field rename, treated as a coupled bump). |
| 1.2.0 | 016 | New required top-level validation_objects[] + *_sha256 diagnostic fields. |
| 1.3.0 | 031 | New required signature-level vrt block. |
| 1.4.0 | 032 | Additive optional algorithm_validity field + crypto_constraints_failure_no_poe value. |
| 1.5.0 | 033 | New value revoked_no_poe; per-step TSA revocation field populated (content, not shape). |
| 1.6.0 | 034 | Two new ValidationObjectOrigin values (pdf_dss/pdf_vri) + CrlSummary/OcspAttempt pdf_source (omit-when-absent). |
| 1.7.0 | 035 | New optional EtsiIndication.findings[] (FindingEntry objects) — every independently observed problem surfaced in parallel; omit-when-empty for byte-identity on single-finding cases. |
| 1.8.0 | 035-ocsp-retry | Additive optional OcspAttempt.request_der_hex (hex-encoded OCSPRequest DER, present only when the responder was unreachable so the JS host can POST it via the /ocsp proxy and retry). Absent for embedded responses, bundle lookups, and successful fetches — byte-identity preserved for every pre-1.8.0 report. |
| 1.9.0 | v1.2.0 (GPKI DirName CDP + CdpEntry) | Breaking field rename: RevocationRecord.cdp_uris: Vec<String> replaced by cdp_entries: Vec<CdpEntry> (each entry carries kind (uri_http/uri_ldap/uri_other/dirname), value, and fetchable: bool). DirectoryName-form CDP entries are now surfaced with fetchable:false instead of being silently dropped. aia_ocsp_uris: Vec<String> added to RevocationRecord. Because cdp_uris is renamed (not merely extended), the bump is a MINOR with an explicit compatibility note: consumers that ignore unknown fields (the SHOULD in §14.1) are unaffected; strict consumers must migrate from cdp_uris to cdp_entries. Note: v1.3.0 (036-xades-blta) added SignatureFormat::XadesBLta ("XAdES-B-LTA") and VerificationRequest.pdf_last_revision_end (LGPKI2 PAdES fix) without a schema bump — both are additive and backward-compatible. |
| 1.10.0 | 037 (ETSI JAdES B-T / B-LT / B-LTA) | Three new SignatureFormat values (JAdES-B-T, JAdES-B-LT, JAdES-B-LTA) plus the previously-unschemed XAdES-B-LTA and all LTV-JWS variants added to the schema enum. A pre-037 report with no ETSI JAdES B-T+ signatures is byte-identical to a 1.9.0 body except the schema_version string (additive MINOR). |
A defining property of these bumps is verdict byte-identity on off / absent features (Constitution §II): an additive feature must leave the report body byte-identical except the schema_version string when its inputs are absent or it is disabled. The header_emits_constitutional_invariants and validation_objects_roundtrip_and_enum_wire_strings tests assert schema_version == "1.10.0"; the report-schema.json mirror pins the same value at line 24. Note that the four constitutional invariants other than schema_version (path_validation_phase, bridge_ca_supported, dn_match_method) were not rotated by branches 016, 031–037 — they track the RFC 5280 §6 capability surface, which has not changed since v0.6.
The CLI exit codes (crates/pverify-cli/src/main.rs, module exit) are deliberately decoupled from the verification verdict:
| Code | Constant | Meaning |
|---|---|---|
| 0 | SUCCESS |
Verification ran to completion. The verdict is in the report. |
| 2 | INVOCATION_ERROR |
Missing/unreadable file, conflicting flags, malformed --at, bundle violation. |
| 3 | RUNTIME_ERROR |
Mid-run I/O failure that prevented producing a complete report. |
| 4 | INTERNAL_ERROR |
Report serialiser produced an unrepresentable shape (release-blocker bug). |
The critical contract (FR-023, documented on exit::SUCCESS) is that the CLI exits 0 for every reported verdict, including TOTAL_FAILED and INDETERMINATE. A non-passing indication is never degraded into a non-zero exit; non-zero codes are reserved for invocation and runtime errors. The verify dispatch confirms this: even when a XAdES/ASiC/JAdES input lands on an honest *_unsupported fact, or a signature is TOTAL_FAILED, the path renders the report and returns exit::SUCCESS (main.rs, the dispatch comments at lines ~707–734 and the final render::render(...) → SUCCESS at line 799). Therefore:
Automation MUST NOT treat exit
0as signature validity. The verdict issignatures[*].etsi_indication.indicationin the JSON output.
This is restated verbatim in the JNSA conformance statement (docs/jnsa-conformance-statement.md, the Exit-code caveat).
JNSA デジタル署名検証ガイドライン speaks in VALID / INVALID / INDETERMINATE; pverify reports ETSI EN 319 102-1 indications. The mapping, taken from docs/jnsa-conformance-statement.md, is:
| JNSA term | pverify indication |
Relying-party reading |
|---|---|---|
VALID |
TOTAL_PASSED |
Every check pverify ran returned positively — within the implementation constraints. Not equivalent to JNSA VALID if any Mandatory check is unimplemented for the input class. |
INVALID |
TOTAL_FAILED |
At least one check pverify ran returned negatively (verify-and-fail, revoked cert, …). |
INDETERMINATE |
INDETERMINATE |
Available information was insufficient to decide (offline + no embedded revocation; unsupported algorithm; revoked TSA; …). |
The conformance statement's caveat under TOTAL_PASSED is the load-bearing one for procurement: a TOTAL_PASSED is a positive result only over the checks pverify ran, enumerated in the M/E/O conformance matrix of that document. A relying party must read both the indication and the matrix — for example, signer-cert revocation is recorded as P (partial: offline/from-bundle degrade honestly to INDETERMINATE), and algorithm validity is P (opt-in via --algorithm-policy). The report's own path_validation_phase, weak_algorithms[], vrt[], algorithm_validity and validation_objects[] fields exist precisely so that this "what was actually checked" question is answerable from the JSON without reading source code.
Coupling the exit code to the verdict would force the binary to collapse the three-valued ETSI vocabulary into a binary pass/fail at the process boundary, which is exactly the business-verdict adjudication Constitution §I forbids the tool from making. An INDETERMINATE "revoked TSA cert" (revoked_no_poe) or "offline, revocation not checked" (revocation_not_checked_offline) is a fact about the verifier's knowledge state, not a claim that the signature is bad; a relying party with additional out-of-band evidence may legitimately treat it as acceptable. Surfacing the verdict only in the structured report — and reserving the exit code for "did the tool run" — keeps that policy decision in the relying party's hands, which is the entire point of a fact-reporting verifier.
This chapter describes the security posture pverify maintains while processing untrusted, attacker-influenced input. The tool's core use case — verifying public AdES documents online against the GPKI/JNSA and eIDAS/ETSI corpora — is adversarial by design: an operator runs pverify against a file that a third party handed them, and the verifier is expected to follow URIs and parse byte structures that the file's author fully controls. The threat model therefore treats the signature under test, every embedded certificate, every distribution URI, and every container envelope as hostile until proven otherwise.
The chapter covers five interlocking controls:
The architectural foundations these controls rest on — the I/O-free no_std kernel, the host/kernel capability boundary, and the "cannot affirm" discipline — are described in Chapter 3 (architecture overview) and Chapter 11 (revocation); this chapter is concerned with the adversarial perspective on those same boundaries.
pverify's design draws a single sharp trust boundary between the host crates (pverify-cli, web/pverify-wasm, and the ingestion crates pverify-eutl / pverify-aatl / pverify-asic / pverify-xades / pverify-jades) and the I/O-free verification kernel pverify-core. The kernel performs all cryptography and policy evaluation but never opens a socket, reads a file, or reads a clock; the only host capabilities it consults are mediated by the Clock, RevocationFetcher and TrustAnchorStore traits (crates/pverify-core/src/traits.rs). This is the structural reason the attack surface for "fetch a hostile URL" or "parse a hostile container" lives entirely in the host crates and is gated there.
The inputs that an adversary controls, and the asset each control protects:
| Adversary-controlled input | Threat | Control | Section |
|---|---|---|---|
| CDP / OCSP / LDAP URIs inside the signature's certificates | SSRF into internal services, loopback, cloud-metadata | Address net-guard (resolve-then-pin) | §15.2 |
HTTP redirect (Location) from a public CDP host |
SSRF bypass via open-redirect to internal address | Per-hop guard, manual redirect following | §15.2.4 |
| Size of the signature / detached content / Trusted-List XML file on disk | Local memory-exhaustion DoS | read_bounded caps |
§15.3.1 |
| Size of a fetched CRL / OCSP response body | Remote memory-exhaustion DoS | Fetch-body ceilings (take(cap+1)) |
§15.3.2 |
| ASiC ZIP container (entry count, decompressed size, entry names) | Zip-bomb DoS, path-traversal write | Entry/size budgets, enclosed_name rejection |
§15.3.3 |
ds:Signature count in an XAdES document |
CPU/memory DoS via C14N over many signatures | Per-document signature cap | §15.3.4 |
| Nesting depth / node count / length octets of a BER/DER structure | Stack exhaustion, heap blow-up, integer overflow | BER parse budget | §15.4 |
| The signature bytes themselves | Forged "Good" verdict | RFC 5280/5652/6960 verification in core; see Chapters 5–11 | — |
The threat model is enforced by a small set of cross-cutting invariants that each control instantiates. They are stated here in adversarial form:
The kernel cannot be made to perform I/O. pverify-core is #![no_std] + #![forbid(unsafe_code)] and links no networking, filesystem, PDF, XML, ZIP or JSON library. scripts/cargo-tree-gate.sh empirically asserts that lopdf, zip, flate2, miniz_oxide, ureq, pverify-eutl, pverify-aatl, etc. never enter the cargo tree -e normal -p pverify-core graph. An SSRF or zip-bomb that reaches the kernel is therefore impossible by construction — the kernel only ever sees model-free byte structures the host already vetted and bounded.
A blocked or refused operation is never a verdict. Every control degrades to an honest INDETERMINATE (Constitution §I, "cannot affirm") rather than fabricating a TOTAL_PASSED or a TOTAL_FAILED. A blocked SSRF fetch is recorded with the same fetch-log shape as any unreachable endpoint (http_status: 0); an over-cap file read or over-budget BER parse is an invocation error (the tool stops with a diagnostic), not a silent truncation followed by verification of the truncated bytes.
The secure behaviour is the default. There is exactly one behavioural switch (--allow-private-networks) and it is off by default, with no cfg(test) divergence from the production path; pverify's own loopback test harnesses opt in via the same flag an operator would (crates/pverify-cli/src/net_guard.rs, module docs SC-004).
Report byte-stability is preserved. A security control that does not fire leaves the JSON report — and its schema_version — byte-identical to the pre-control output. The 021 net-guard touched only the CLI host crate (schema_version unchanged); block reasons go to stderr only, never to the machine-readable report (docs/network-egress-and-ssrf.md; spec FR-009).
The controls in this chapter were landed in response to a 2026-06-19 penetration-test pass (umbrella issue #81) and re-validated in a 2026-06-20 security review (docs/security-review-2026-06-20.md, docs/review-remediation-2026-06-20.md). The pen-test findings map to slices as follows:
bounded_io.rs + fetch ceilings (§15.3).pverify-core/src/ber/mod.rs (§15.4).The 2026-06-20 review additionally drove four verification-correctness fixes (PAdES --required-policy enforcement, multi-SignerInfo honest refusal, malformed-ESS distinct from absent-ESS, and typed ETSI failure classification); those concern verdict integrity rather than the resource/egress surface and are described where they belong — see Chapter 5 (CAdES), Chapter 6 (PAdES), Chapter 9 (path validation / policy) and the indication aggregation in §5.7 and the Result Model (Chapter 14).
A 2026-06-23 security review (docs/security-review-2026-06-23.md, issue #120) identified four additional findings scoped to the browser deployment:
TOTAL_PASSED verdict. Fixed: user-dropped self-signed certs are only added as trust anchors when the user explicitly enables the "custom trust" toggle; the verdict shows a warning banner when custom trust is active (§15.7).algorithm_policy: "default") was not sent by the production browser UI, leaving the CRYPTREC-primary gate disabled. Fixed: buildUploadRequest() in web/site/app.js now always includes the field./crl edge proxy used redirect: "follow", allowing an open-redirect on a trusted CA host to escape the allow-list. Fixed: manual redirect following with per-hop scheme/host re-validation; LDAP port restricted to {389, 636} (§15.5).web/site/_headers now sets CSP, Referrer-Policy, X-Content-Type-Options, and Permissions-Policy; Google Fonts links removed from all site HTML (§15.7).In online mode (the default — neither --offline nor --from-bundle) pverify verify performs revocation checking by dereferencing URIs taken out of the untrusted artefact under test: CRL Distribution Point (CDP) URIs and OCSP / LDAP Authority-Information-Access (AIA) URIs embedded in the certificates of the signature. These URIs are entirely attacker-controlled. A crafted certificate can point its CDP at http://169.254.169.254/... (the AWS/GCP IMDS cloud-metadata endpoint), http://127.0.0.1:8080/ (a loopback admin service), or http://10.1.2.3/internal (an RFC 1918 host). Following them blindly turns the operator's machine into an SSRF pivot — the highest-rated pen-test finding (#71, P1).
A scheme allowlist alone (the pre-021 control) is insufficient: it stops file:// and gopher:// but happily follows http://10.0.0.1/. A host allowlist is also rejected for the CLI: pverify is a general-purpose verifier of signatures from arbitrary CAs, so a fixed list of trusted CDP hosts would break legitimate verification (spec 021, Context). The CLI's control is therefore address-based: reject any fetch whose target resolves to a non-public IP address. (The web edge proxy, which serves a small fixed trust-anchor set, can use a host allowlist — see §15.5.)
crates/pverify-cli/src/net_guard.rs::classify(addr: IpAddr) -> Option<BlockReason> is the pure decision function: None permits a globally-routable public unicast address, Some(reason) blocks everything else. It is built on std::net predicates only — no new dependency, and deliberately not the still-unstable IpAddr::is_global (spec FR-010). The blocked ranges, with the standard each derives from:
| Range / address | Classification | Standard |
|---|---|---|
127.0.0.0/8, ::1 |
Loopback |
— |
10/8, 172.16/12, 192.168/16 |
Private |
RFC 1918 |
fc00::/7 (incl. fd00:ec2::254 IPv6 IMDS) |
Private |
RFC 4193 |
100.64.0.0/10 (CGNAT) |
Private |
RFC 6598 |
198.18.0.0/15 (benchmarking) |
Private |
RFC 2544 |
169.254.0.0/16 (incl. 169.254.169.254 IMDS), fe80::/10 |
LinkLocal |
RFC 3927 / RFC 4291 |
0.0.0.0, 0.0.0.0/8, :: |
Unspecified |
RFC 1122 / RFC 4291 |
224.0.0.0/4, 255.255.255.255, ff00::/8 |
Multicast |
RFC 5771 / RFC 4291 |
192.0.2/24, 198.51.100/24, 203.0.113/24, 2001:db8::/32 |
Reserved (documentation/TEST-NET) |
RFC 5737 / RFC 3849 |
240.0.0.0/4 (reserved-for-future) |
Reserved |
RFC 1112 §4 |
Two subtle points the code handles explicitly and which an auditor should confirm:
IPv4-mapped IPv6 is unwrapped before classification. classify calls Ipv6Addr::to_ipv4_mapped() and, if it matches, classifies the embedded IPv4 form. This prevents ::ffff:169.254.169.254 or ::ffff:10.0.0.1 from smuggling a blocked v4 address past the v6 rules (net_guard.rs, classify + the ipv4_mapped_unwraps_to_ipv4_class test).
Ranges the standard library has no predicate for are checked by hand. CGNAT (100.64/10), benchmarking (198.18/15), 0.0.0.0/8, 240.0.0.0/4, IPv6 unique-local (fc00::/7), IPv6 link-local (fe80::/10) and IPv6 documentation (2001:db8::/32) are decided with explicit octet/segment masks (classify_v4 / classify_v6) because is_reserved, is_global etc. are unstable. The exhaustive must_block_all_non_public and must_permit_all_public unit tests pin the classifier's behaviour over every representative literal, including the two cloud-metadata addresses.
BlockReason is not serialised: it drives only the stderr diagnostic (reason_str) and the unit tests. A blocked fetch reuses the existing unreachable fetch-log shape, so the report and its schema_version stay byte-stable (FR-005 / FR-009).
The TOCTOU concern with any address check is DNS rebinding: a hostname that resolves to a public address when checked but a private one when connected to. pverify's chosen resistance level is resolve-then-pin (spec 021 clarification): resolve the host, classify every resolved address, and connect to exactly the classified address rather than re-resolving.
For HTTP and OCSP this is enforced by installing a custom ureq::Resolver, GuardResolver, on the agent (net_guard.rs; wired in crates/pverify-cli/src/modes/online.rs::HttpFetcher::new via AgentBuilder::resolver(...)). GuardResolver::resolve:
to_socket_addrs(). An empty result is fail-closed: the fetch is blocked (BlockReason::Unresolvable) and an io::Error is returned.SocketAddr is non-public, the entire netloc is refused. A host that resolves to a mix of public and internal addresses must not be allowed to leak the internal one (spec Edge Case).SocketAddrs — the checked address is the connected address.For LDAP (the hand-rolled std::net client in crates/pverify-cli/src/modes/ldap.rs) the same discipline is applied by guard_ldap_addrs: it filters the resolved set to the public subset and connect_with_timeout dials only those. Note the deliberate asymmetry — the HTTP resolver blocks the whole netloc if any address is non-public, while the LDAP filter keeps the public subset and dials it, because the LDAP client tries addresses in turn and pinning to the public ones is sufficient to stop the SSRF (a non-public address is never dialled). The behaviour is documented in guard_ldap_addrs's doc-comment and pinned by guard_ldap_addrs_drops_non_public.
A guard that checks only the initial URL is trivially defeated: an attacker hosts the CDP on a public server that returns 302 Location: http://127.0.0.1/secret. pverify defeats this by disabling ureq's automatic redirect following (AgentBuilder::redirects(0) in HttpFetcher::new) and following redirects manually under its own control (online.rs::follow_redirects, cap MAX_REDIRECTS = 5):
follow_redirects re-issues the request for each Location, so GuardResolver (and the scheme check) runs afresh on every hop before any connection. The redirect-decision logic is extracted as the pure next_redirect(current, status, location) so it is unit-testable without a socket. Two properties an auditor should note:
next_redirect_refuses_non_http_location_scheme confirms that a redirect to file://, ldap:// or gopher:// is never followed — only http/https redirect targets continue the chain. A redirect cannot re-open a scheme the initial allowlist rejected.redirected_from field, so a reviewer can see the whole path a fetch took.The initial scheme allowlist remains exactly {http, https, ldap} (online.rs rejects others with non_http_cdp_uri / non_http_ocsp_uri before any DNS resolution; spec FR-011).
--allow-private-networks (off by default) short-circuits all three guard points to "permit all" for one run. It is documented in --help with its security implication and is intended only for trusted lab/CI or internal-PKI deployments. Critically, it is the sole behavioural switch: there is no cfg(test) bypass, and pverify's own loopback-based revocation harnesses opt in via this flag exactly as an operator would, which is the most honest demonstration that the secure default genuinely blocks loopback (net_guard.rs module docs; docs/network-egress-and-ssrf.md).
Residual risks, documented and accepted (docs/network-egress-and-ssrf.md):
HTTP_PROXY / HTTPS_PROXY is set, ureq resolves the proxy host through the guard (so a loopback/private proxy is itself blocked unless the flag is set), but the guard cannot see the addresses the proxy contacts on pverify's behalf. Restricting the proxy's own egress is the operator's job.std-only with no kernel-level connector. Because HTTP/OCSP pins ureq to the already-resolved address, there is no second resolution to race for that path; a pathological adversary controlling the OS resolver could in principle still race a rebinding in a layer that re-resolves, but the realistic crafted-URI and open-redirect cases are closed.INDETERMINATE), never a false "Good".Under --offline and --from-bundle the guard is a no-op: those modes open no sockets, and their output is byte-identical to before 021.
pverify ingests several classes of untrusted-or-large input, any of which could exhaust memory and cause a local (CLI) or remote (fetched-response) denial of service. The caps are deliberately generous — every legitimate signature, PDF, and Trusted List sits far below them, so honest verify outcomes are unchanged (pen-test #72; docs/security-review-2026-06-20.md, "Positive findings").
bounded_io.rs)crates/pverify-cli/src/bounded_io.rs::read_bounded(path, cap) replaces bare fs::read for every untrusted local input. It uses a two-stage guard:
metadata().len() pre-check rejects an oversized regular file before any allocation.File::take(cap.saturating_add(1)).read_to_end(...) followed by a buf.len() > cap check. The +1 sentinel byte turns "exactly one byte over" into a TooLarge error, and the take ceiling bounds the buffer even when the stat under-reports — a file that grows between stat and read (TOCTOU), or a pipe/special file whose size is unreliable, still cannot allocate past the cap.The default caps:
| Input class | Cap | Constant / override |
|---|---|---|
| Signature, detached content, Trusted-List XML | 256 MiB | DEFAULT_MAX_INPUT_BYTES; overridable via --max-input-mb |
| Trust-anchor / signer certificate file | 1 MiB | MAX_ANCHOR_BYTES |
Bundle manifest (manifest.json) |
16 MiB | MAX_MANIFEST_BYTES |
Exceeding a cap is a BoundedReadError — an invocation error, not a verification verdict. pverify never silently truncates input it then verifies (Constitution §I, fact-reporting). The error message tells the operator how to raise the signature/content limit (--max-input-mb) for the rare oversized-but-legitimate artefact.
Fetched revocation material is bounded with the same take(cap+1) pattern in crates/pverify-cli/src/modes/online.rs. As of v1.3.0 (the 2026-06-25 security review), all fetch_url call sites explicitly add a body_too_large check immediately after the take(cap+1) read: if buf.len() > cap the response is treated identically to an unreachable endpoint rather than continuing with a silently-truncated body. The pattern is:
let buf = resp.take(cap + 1).read_to_end()?;
if buf.len() > cap { return Err(FetchError::BodyTooLarge); }This closes a boundary where a partial read could have been forwarded to the parser as though the body were complete.
| Fetch | Cap | Constant |
|---|---|---|
| CRL over HTTP/HTTPS | 64 MiB | MAX_CRL_BYTES |
| OCSP response over HTTP/HTTPS | 1 MiB | MAX_OCSP_BYTES |
A response exceeding its ceiling is recorded honestly in the fetch log and the revocation outcome degrades to INDETERMINATE, exactly as for any other failed fetch — a hostile responder cannot OOM the verifier by streaming an unbounded body.
OCSP relay nonce forwarding (v1.3.0). The Cloudflare Workers OCSP relay (web/functions/ocsp.js) now threads the full OCSP request_der_hex from the frontend through to the upstream TSA OCSP responder and validates the RFC 8954 16-byte nonce in the relay response before forwarding it to the browser WASM client. This closes a nonce-stripping window where a relay that ignored the nonce field could have forwarded a stale or replayed OCSP response.
ASiC containers (EN 319 162) are ZIP archives; the parser in crates/pverify-asic/src/zip.rs::ZipArchive::parse enforces a defence-in-depth budget against zip-bombs and path-traversal (pen-test #72; FR-017 / FR-018):
MAX_ENTRIES = 4096. A container declaring more entries is refused with ResourceBoundExceeded before any decompression.MAX_ENTRY_DECOMPRESSED = 128 MiB. The declared file.size() is checked first, then the actual read is bounded.MAX_TOTAL_DECOMPRESSED = 256 MiB. Each entry is read with take(remaining_budget + 1), so a lying central-directory size cannot exhaust memory: even if the declared size is small, the actual read is capped at the running budget and refused if it overflows.ZipFile::enclosed_name().is_none() (the zip crate's escape detector) and a belt-and-braces explicit rejection of leading //\ and any .. path component. The whole archive is decoded into memory; no entry is ever written to the filesystem (FR-002, "no temp files"), so a traversal name is defence-in-depth rather than a live write primitive.The zip dependency uses the pure-Rust deflate-flate2 / miniz_oxide backend, keeping pverify-asic WASM-buildable and free of native FFI (zip.rs module docs, R-4).
Each ds:Signature in an XAdES document drives Exclusive C14N and reference processing — work an attacker could multiply with a document declaring thousands of signatures. crates/pverify-xades/src/parse.rs refuses a document whose ds:Signature count exceeds MAX_SIGNATURES_PER_DOCUMENT = 32 before any per-signature canonicalization, returning XadesError::TooManySignatures (pen-test #79). 32 is far above any legitimate count while bounding the C14N work.
CAdES and the embedded CMS payload of PAdES are parsed by a lenient, hand-rolled BER/TLV walker in crates/pverify-core/src/ber/mod.rs (the pverify-core SignedData parser descends through it). Because this code runs in the no_std kernel over fully attacker-controlled bytes, an unbudgeted recursive descent is a classic resource-exhaustion target: a chain of nested constructed tags can blow the call stack, and a flat or wide payload can force unbounded Vec growth. The budget (pen-test #73) closes both.
parse_ber_tlv seeds a BerBudget { depth, nodes } and threads it through the recursion:
MAX_BER_DEPTH = 32 caps constructed-tag nesting. Real CMS — a SignedData wrapping EncapContentInfo / SignerInfos / certificate sets — bottoms out well under ten levels; the cap exists only to stop a crafted nesting chain from exhausting the call stack. depth is decremented on descent and restored on return, so it bounds stack depth specifically.MAX_BER_NODES = 100_000 caps the total number of TLV nodes parsed from a single input. Each node owns a Vec<u8> value plus a Vec<BerTlv> children vector, so this bounds heap allocation against a flat/wide payload. nodes is monotonically consumed across the whole tree — including subtrees whose inner parse error is swallowed — so the total work is bounded regardless of structure. The budget is checked before each allocation.A subtle but important behaviour: when a constructed-bit-set TLV's children fail to parse (not every constructed wrapper in real CMS — e.g. an OCTET STRING wrapping opaque bytes — has well-formed children at that layer), the inner error is deliberately swallowed and children left empty, but the node budget the swallowed subtree consumed is not refunded. This keeps the parser lenient about real-world CMS while still bounding total work even against an adversary who buries expensive structure inside swallowed subtrees (parse_ber_tlv_budgeted, the comment at the constructed-tag branch).
Exceeding either bound returns Error::Ber("BER nesting too deep") / Error::Ber("BER node budget exceeded"). The error type is intentionally &'static str-only: the dynamic "exceeds remaining data N" detail from the upstream civ source was dropped on purpose to avoid allocating in the parse hot path and to prevent leaking byte offsets into log surfaces (ber/mod.rs module docs, R-001).
The header-length helpers reinforce the budget at the encoding layer. parse_tlv_total_length and skip_tlv_header support definite-form length octets only up to 0x83 (3 length octets ⇒ 16 MiB); a longer length encoding (0x84+) returns None / Error::Ber("invalid TLV length") rather than attempting to allocate a multi-gigabyte value. encode_length is the symmetric counterpart, bounded to the same 16 MiB range. The recursive parse_ber_tlv itself supports arbitrary-width long-form lengths but checks i + len > data.len() on every value, so a length that overruns the buffer is a clean Error::Ber("TLV value exceeds remaining data") — never an out-of-bounds read.
The unit tests pin the adversarial cases directly: parse_ber_tlv_rejects_nesting_beyond_depth_cap builds MAX_BER_DEPTH + 2 nested SEQUENCEs and asserts the realised tree depth never exceeds the cap (no stack overflow), and parse_ber_tlv_rejects_too_many_nodes feeds MAX_BER_NODES + 1 flat primitives and asserts the node budget trips before they are all allocated.
The CLI deliberately rejects a host allowlist (it must verify arbitrary public CAs — §15.2.1). The web edge proxy can and does use one, because the browser deployment serves a small, fixed trust-anchor set.
web/functions/crl.js is a Cloudflare Pages Function that proxies CRL/OCSP fetches for the browser front-end (it exists to terminate CORS for distribution points that do not send CORS headers). It refuses any host not listed in web/revocation-hosts.json — a curated list of the CRL/OCSP distribution hosts for the roots shipped in web/site/roots/:
repo1.secomtrust.net
repository.secomtrust.net
ldap.aosign.com
dir.tdb.ne.jp
repository.toinx.net
ldap.e-probatio.com
build-roots.mjs copies this file into web/site/revocation-hosts.json so the edge Function and the browser front-end read the same allowlist, and the allowlist is kept in lock-step with the shipped trust anchors: when a new root is added, its distribution hosts are added here. This is a tighter control than the CLI's address guard — appropriate because the web deployment is a fixed-trust-set product, not a general-purpose tool — but the kernel verdict is unchanged in either deployment: the WASM host runs the identical verify_with kernel as the CLI, so a CRL the proxy declines to fetch simply yields the same honest INDETERMINATE as any unreachable CRL.
The two egress models are complementary, not redundant:
| Deployment | Egress control | Rationale |
|---|---|---|
| CLI (general-purpose) | Address net-guard (resolve-then-pin), reject non-public IPs | Must verify arbitrary public CAs; no fixed host set exists |
| Web edge proxy (fixed trust set) | Host allowlist (revocation-hosts.json) |
Small curated root set; tightest possible egress for the browser |
Prior to v1.1.2, crl.js used redirect: "follow", delegating redirect resolution to the Cloudflare fetch runtime. If an allow-listed CA host exposed an open redirect, the proxy could be induced to fetch a URL outside the allow-list on the second hop.
As of v1.1.2, the proxy manually follows at most MAX_REDIRECTS = 5 hops: on each 3xx response it extracts Location, resolves it relative to the current URL, and re-validates scheme (http: / https: only) and hostname (must be on the allowlist) before issuing the next request. A hop that fails either check returns a 502 immediately.
LDAP URLs are additionally validated to port 389 or 636 only (ALLOWED_LDAP_PORTS = {389, 636}). The earlier code accepted any port for allow-listed hostnames; a crafted URI could have directed the Worker to a non-standard port on a trusted hostname.
The proxy now checks Sec-Fetch-Site early: any request where this browser- injected header is present and not same-origin, same-site, or none is rejected with 403 before any allowlist evaluation. This prevents third-party web pages from driving fetches to the allow-listed CA host set through our proxy (bandwidth amplification / open-relay abuse). Server-to-server calls (where Sec-Fetch-Site is absent) are unaffected.
The following residual risks are documented and accepted for the current release, recorded here so a government reviewer has the complete register (docs/network-egress-and-ssrf.md, docs/security-review-2026-06-20.md, docs/security-review-2026-06-23.md "Residual risks already documented"):
HTTP_PROXY/HTTPS_PROXY set, the guard validates the proxy endpoint, not every address the proxy may contact. Restricting the proxy's egress is the operator's authoritative control in that deployment (§15.2.5).std-only design and is documented as accepted (§15.2.5; spec 021 Assumptions).INDETERMINATE), not a forged "Good".docs/trust-anchor-policy.md), outside the scope of the resource/egress controls in this chapter.--offline / --from-bundle and run online verification in an egress-restricted sandbox (umbrella #81). The net-guard is defence-in-depth, not a license to verify untrusted input online in an unrestricted network.The standing security discipline for new work: any new dependency must pass cargo vet check --locked before a release branch (a trap learned when an unvetted dev-dependency surfaced only at release time — see docs/review-remediation-2026-06-20.md and the project memory note on cargo-vet dev-deps), and any new host-side parser or fetcher must carry its own resource budget and route blocked/over-budget operations to INDETERMINATE rather than a fabricated verdict, consistent with the invariants in §15.1.2.
The browser deployment (web/site/) introduces a set of controls that are distinct from the CLI's I/O guards because the attack surface is the UI itself, not the verification kernel.
Prior to v1.1.2, any self-signed certificate dropped onto the verification form was silently merged into the trust_anchors array sent to the WASM kernel. This created a social-engineering vector: an attacker could supply a document signed under an attacker-controlled CA together with that CA's self-signed root certificate; a user who dropped both would see TOTAL_PASSED under attacker-supplied trust, with no visual warning.
As of v1.1.2:
buildUploadRequest() in app.js only adds self-signed certificates to trust_anchors when the #custom-trust-toggle checkbox is explicitly checked by the user.maybeAutoVerify() so the result immediately reflects the changed trust boundary.The default case — checkbox unchecked — uses only the bundled provenance- tracked roots, preserving the existing trust model documented in Chapter 10.
The WASM API accepts an algorithm_policy field; "default" selects the built-in CRYPTREC-primary policy. Prior to v1.1.2 the production browser UI did not send this field, so the WASM wrapper resolved None and the policy layer stayed disabled. Every verification through the public site now sends algorithm_policy: "default".
web/site/_headers (Cloudflare Pages static headers file) sets:
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
'wasm-unsafe-eval' is required by WebAssembly.instantiate (the wasm-pack generated glue calls it). connect-src 'self' restricts fetch() to the same origin; the CRL proxy endpoint is same-origin, so revocation fetches work without relaxation. Google Fonts <link> tags were removed from all site HTML files; system fonts (system-ui, -apple-system, "Hiragino Sans") now lead the font stack, eliminating the third-party page-visit leak and the font-src relaxation it would require.
This chapter specifies how pverify produces, attests, and publishes its release artefacts, and how a relying party (RP) independently confirms that a downloaded artefact was built from the tagged pverify source by the official build workflow. The subject matter is deliberately outside the verification kernel: none of the machinery described here links pverify-core or enters its dependency graph, and none of it grants pverify a signing capability. The tool remains verification-only and key-less (Constitution §I), and the supply-chain layer expresses that same honesty discipline through bills of materials that declare exactly what the binary can do — and a build pipeline that proves where the bytes came from.
The design is the product of two feature slices:
slsa-sbom-cbom (specs/013-slsa-sbom-cbom/): the tag-triggered SLSA Build-L3 release pipeline, the dependency SBOM, the cryptographic CBOM with its drift gate, and the cargo vet / cargo audit supply-chain gates.bom-attestation (specs/024-bom-attestation/): a typed CycloneDX in-toto attestation layered additively on top of the existing SLSA provenance, so off-the-shelf supply-chain verifiers can discover and validate each BOM directly via gh attestation verify.Both slices are explicitly constrained to leave the kernel, the report schema (schema_version 1.9.0, see Chapter 14), and every verification verdict byte-identical (013 SC-009; 024 FR-009/SC-004). They are build-, CI-, and documentation-only changes.
The two governing artefacts are the workflows .github/workflows/release.yml (tag-triggered) and .github/workflows/ci.yml (every push/PR), the committed cbom.json at the repository root, the supply-chain/ cargo vet store, and the relying-party procedure in docs/release-verification.md.
pverify is delivered to a government customer who must answer three distinct questions about a downloaded build, and the supply-chain layer is structured so each question maps to exactly one mechanism:
| Question the RP asks | Mechanism | Artefact |
|---|---|---|
| "Were these exact bytes built from the tagged pverify source by the official workflow, untampered?" | SLSA v1.0 Build-L3 provenance (key-less, Sigstore/Fulcio cert, Rekor log) | pverify-<tag>.intoto.jsonl |
| "What third-party crates is this binary built from?" | CycloneDX dependency SBOM, regenerable from the pinned lockfile | sbom.json (+ typed attestation) |
| "What cryptography can this binary perform, and only verify (never sign)?" | CycloneDX Cryptography BOM, code-derived and drift-gated | cbom.json (+ typed attestation) |
These axes are intentionally orthogonal. The CBOM is the crypto pverify verifies with; the SBOM is the crates pverify is built from; the provenance is the origin binding over every artefact including both BOMs. The relying-party documentation states this distinction in plain language (docs/release-verification.md, "CBOM vs SBOM" callout) precisely so an auditor does not conflate them.
A fourth, transverse concern — what crypto does pverify dispatch, exactly? — is answered not by a static document alone but by a CI gate that makes the document provably current (§16.4). This is the supply-chain expression of the §I "fact-reporting" principle: a published cryptographic claim that can silently drift from the code is not a fact, so the build fails if it ever does.
release.yml)The release pipeline is triggered only by version tags (on: push: tags: ["v*"], release.yml:21-23). Untagged work — including every commit on a feature branch — never invokes it; the pipeline's structural correctness is instead asserted continuously by a lint job in ci.yml (§16.7). The first real invocation was the first tag after v0.7.0; v0.9.0 is the released baseline that ships the full bundle.
The workflow declares a top-level permissions: contents: read (release.yml:29-30). This is not merely defensive: the repository is currently private, and actions/checkout needs contents: read to fetch a private repository at all (a zero-permission token yields a 404). Jobs that publish — provenance, release, and the two BOM-attestation jobs — widen this with their own job-level permissions: block, granting only the additional scopes they require (id-token: write for OIDC key-less signing, attestations: write for the GitHub attestation store, contents: write for release-asset upload). No job holds a scope it does not use.
Every downstream job has needs: supply-chain-vet (release.yml:55,125,182,217), so a failed cargo vet check --locked short-circuits the entire release before any artefact is built — the supply-chain audit is a release-blocking precondition, not an after-the-fact check.
The build job (release.yml:53-118) compiles pverify-cli for five target triples:
| Target triple | Runner | Notes |
|---|---|---|
x86_64-unknown-linux-gnu |
ubuntu-latest |
native |
aarch64-unknown-linux-gnu |
ubuntu-latest |
cross-linked via gcc-aarch64-linux-gnu |
x86_64-apple-darwin |
macos-latest |
native |
aarch64-apple-darwin |
macos-latest |
native |
x86_64-pc-windows-msvc |
windows-latest |
native |
Because pverify is RustCrypto-only and OpenSSL-free (Constitution §VI), the pipeline is substantially simpler than a typical cross-platform Rust release: all OpenSSL/vcpkg setup is deleted, and only the aarch64-linux target needs a GNU cross-linker (CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER, release.yml:79-88). This is a direct payoff of the §VI WASM-clean constraint documented in Chapter 3: a pure-Rust crypto graph cross-compiles trivially.
The job sets fail-fast: false (release.yml:58) and, after the build, performs an explicit missing-binary check that emits a GitHub error annotation and exit 1 if the expected target/<triple>/release/pverify[.exe] is absent (release.yml:99-103). This satisfies FR-021: a target that cannot be produced surfaces as a visible failure rather than a silently dropped artefact. Each binary is packaged as pverify-<triple>.tar.gz with a .sha256 sidecar (sha256sum on Linux, shasum -a 256 fallback for macOS, release.yml:106-110) and uploaded with if-no-files-found: error.
source job)The source job (release.yml:123-174) produces the tag-pinned source archive via git archive --format=tar.gz --prefix="pverify-<version>/" HEAD (release.yml:142). It also performs the CBOM version injection (entity E5 of the data model): the committed cbom.json carries the development placeholder "0.0.0", and the published copy is rewritten to the tag version with jq '.metadata.component.version=$v'. An empty-output guard ([[ ! -s cbom.tmp ]], release.yml:146-149) ensures a jq failure can never ship a truncated CBOM — the job fails loudly instead. The version inject happens before the CBOM attestation so the attested digest matches the published bytes (§16.6).
wasm job, FR-018)The browser build is a first-class release subject. The wasm job (release.yml:180-210) installs the wasm32-unknown-unknown target and wasm-pack, builds web/pverify-wasm standalone (it is a workspace-detached crate — see Chapter 3 §3.2 — so it is built from inside web/ rather than the root workspace), and tars web/site/pkg/ into pverify-wasm-<tag>.tar.gz with a SHA-256 sidecar. The browser host runs the byte-identical verify_with kernel as the native CLI (the CLI↔︎WASM parity invariant of Chapter 3), so shipping it as a provenanced artefact lets an RP confirm the in-browser verifier they load is the official build.
sbom job, FR-022/FR-023)The sbom job (release.yml:215-266) installs cargo-cyclonedx and generates the SBOM from the pinned Cargo.lock. A subtlety worth recording for auditors: cargo-cyclonedx 0.5.9 emits one file per workspace member, not an aggregate. The SBOM that matters is the released binary's full dependency tree, so the job scopes generation to crates/pverify-cli/Cargo.toml (--manifest-path … --override-filename sbom, release.yml:240-241), asserts the output exists (release.yml:242-245, fail-loud if cargo-cyclonedx produced nothing), and normalises crates/pverify-cli/sbom.json to the repo-root sbom.json. The SBOM is therefore a mechanical function of the lockfile at the tagged commit and is reproducible by anyone from the source archive — the relying-party doc gives the exact reproduction recipe (docs/release-verification.md, "How the SBOM is generated").
compute-digests (release.yml:272-297) downloads every artefact, computes sha256sum over all *.tar.gz plus cbom.json and sbom.json (explicitly excluding the .sha256 sidecars), and base64-encodes the result for the SLSA generator. The data-model invariant INV-8 is that this subject set ⊇ {5 binaries, source, CBOM, SBOM, WASM}.
The terminal release job (release.yml:326-349) attaches every tarball, every .sha256 sidecar, cbom.json, and sbom.json to the GitHub Release via softprops/action-gh-release with fail_on_unmatched_files: true. The SLSA provenance .intoto.jsonl is uploaded by the provenance generator itself (upload-assets: true).
The provenance job (release.yml:302-320) is a uses: of the upstream slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0 reusable workflow. It consumes the base64-encoded subject digests from compute-digests and emits a single signed pverify-<tag>.intoto.jsonl covering every release subject (FR-017/FR-018).
SLSA Build Level 3 means the provenance is generated by a hardened, isolated builder (the GitHub-hosted reusable workflow runs in a context the workflow author cannot tamper with), the build is identified by source repository and tag, and the signing is key-less: a short-lived Fulcio-issued X.509 certificate is minted from the workflow's OIDC identity, used to sign the in-toto statement, and its issuance is recorded in the Sigstore/Rekor public transparency log. No long-lived signing key exists anywhere — consistent with §I (pverify holds no keys; even the release infrastructure holds none).
The generic SLSA generator refuses to run for a private repository unless the caller opts in with private-repository: true (release.yml:320). The workflow comment is explicit and an auditor must understand it precisely:
opting in records the repository name and workflow ref in the PUBLIC Rekor transparency log (the signed artifacts are not published, but
digital-go-jp/pverifybecomes publicly discoverable).
So while the artefacts remain private, the existence of the digital-go-jp/pverify repository and its release workflow ref is disclosed in the public Rekor log. This is a deliberate, documented trade-off — the SLSA provenance path is more public than the typed BOM-attestation path (§16.6.3), and the relying-party documentation flags the difference explicitly so the two are never conflated.
cbom.json is a CycloneDX 1.6 document at the repository root (bomFormat: "CycloneDX", specVersion: "1.6"). Its metadata.component names pverify as an application with the placeholder version "0.0.0" and a scope property recording that it "enumerates every cryptographic algorithm pverify can DISPATCH at verification time (verify-only)." It carries 17 components, each a cryptographic-asset: 13 with primitive: "signature" (including the RSA-PSS carve-out, §16.4.3) and 4 with primitive: "hash".
Each algorithm entry records its OID, its parameterSetIdentifier (RSA / P-256 / P-384 / Ed25519 / ML-DSA-44|65|87), its cryptoFunctions (always ["verify"], never "sign"), its nistQuantumSecurityLevel (0 classical; 2/3/5 for ML-DSA-44/65/87), and properties including pverify:role and — where applicable — pverify:weak="true". For example, the SHA-1 entries and the RSA/ECDSA-over-SHA-1 combinations are marked weak: pverify recognises and flags SHA-1, it does not pretend to reject it (FR-005; this honesty matches the recognise-and-flag policy of the verification engine described in Chapters 5–7). The covered set is the verification dispatch surface of the kernel: RSA PKCS#1 v1.5 (SHA-1/256/384/512), RSA-PSS (XAdES path), ECDSA P-256/P-384 (SHA-1/256/384/512), Ed25519, ML-DSA-44/65/87, and the hashes SHA-1/256/384/512.
The crucial design property is that the dispatch behaviour and the published CBOM cannot drift apart through dual maintenance, because both are anchored to one authoritative enumeration in the kernel:
crates/pverify-core/src/crypto.rs:288 — SUPPORTED_SIGNATURE_OIDS, a &[(&str, &str)] of (OID, human-name) pairs.crates/pverify-core/src/crypto.rs:308 — SUPPORTED_HASH_OIDS, likewise.Unit tests in crypto.rs (around line 773) enforce that this table is itself internally consistent with the dispatch function: every OID in SUPPORTED_SIGNATURE_OIDS round-trips through signature_alg_from_oid to Ok(_) (INV-1), the set of OIDs the dispatcher accepts equals exactly the table's keys (INV-2), and SUPPORTED_HASH_OIDS equals the set of HashAlg::oid_string() values (INV-3). Exposing these consts is the only core change 013 makes, and it adds no dependency — satisfying FR-025's "do not touch the core dependency graph" constraint while making the dispatch table machine-readable from tooling.
RSA-PSS (1.2.840.113549.1.1.10) is reachable in pverify only via the XAdES XML-DSIG algorithm-URI path (pverify-xades), never via the CMS OID table — which deliberately rejects PSS (test signature_alg_from_oid_rejects_rsa_pss). PSS is therefore intentionally absent from SUPPORTED_SIGNATURE_OIDS but present in the CBOM as a verify capability. The drift gate special-cases this: the carve-out OID is allow-listed as "expected-in-CBOM, absent-from-const," and the gate also asserts the carve-out must be present (a missing PSS entry is a capability regression). This is documented in both the crypto.rs doc-comment (crypto.rs:282-287) and the gate source.
cargo xtask cbom-checkThe drift-check is a host-only developer tool (xtask/src/cbom_check.rs, invoked via cargo xtask cbom-check). It is a pure function of (compiled-in consts, on-disk cbom.json): no network, no clock, no filesystem writes, and its diff output is sorted for deterministic runs. Its algorithm:
cbom.json, partitioning cryptographic-asset entries by primitive into cbom_sig and cbom_hash OID sets, and collecting into non_verify any signature entry whose cryptoFunctions != ["verify"] (cbom_check.rs:94-101).SUPPORTED_SIGNATURE_OIDS / SUPPORTED_HASH_OIDS."sign" — is a fact-reporting violation (cbom_check.rs:116-120). This is the supply-chain enforcement of "pverify never signs."+ <oid>).- <oid>), except the RSA-PSS carve-out.- RSA-PSS capability missing).bail! → non-zero exit, naming each mismatch (cbom_check.rs:163-171). A clean run prints cbom-check: OK — <n> algorithms match.The gate catches drift in both directions (FR-007, SC-003): adding a new algorithm OID to the code without updating cbom.json, or claiming an algorithm in cbom.json the code does not dispatch, both fail. The check is version-agnostic — it ignores metadata.component.version entirely (FR-009) — so it operates identically on the in-repo "0.0.0" placeholder and on a version-injected release copy. It runs in CI as the cbom-check job (ci.yml:49-61), a release-blocking required check.
The SBOM (sbom.json) is generated, not committed — it is a mechanical function of Cargo.lock and is gitignored to avoid a stale hand-maintained copy. It is produced in three contexts that all use the same scoping:
sbom job (§16.2.6),web/build.sh, copying a generated sbom.json if present),The SBOM enumerates the resolved third-party crates of the released binary's dependency tree (name, version, license, purl) at the pinned lockfile state, so it is reproducible per commit/tag. The relying-party doc gives the verbatim reproduction recipe and a jq one-liner to count components (docs/release-verification.md, Steps 4 and "How the SBOM is generated").
The SLSA provenance already guarantees BOM integrity — both cbom.json and sbom.json are provenance subjects, so an RP can confirm each was produced by the tagged release workflow and is untampered. What v0.9.0 lacked was a typed, independently consumable binding: to retrieve a BOM with standard supply-chain tooling an RP previously had to hand-parse the SLSA provenance subject list and match digests manually. Slice 024 adds an ecosystem-standard CycloneDX-typed in-toto attestation that off-the-shelf verifiers discover directly. It is purely additive — it does not remove, replace, or weaken the SLSA provenance (FR-007).
Each BOM is attested with actions/attest-sbom@v2:
sbom job for sbom.json (release.yml:254-258), andsource job for cbom.json (release.yml:160-164), after the version inject so the attested digest matches the published bytes.For each BOM the file is simultaneously the attestation subject (bound by SHA-256 — tampering breaks verification, SC-003) and the predicate (the CycloneDX document itself). The CBOM is a CycloneDX 1.6 document, so attest-sbom accepts it uniformly with the SBOM; the workflow comment records the fallback (actions/attest@v2 with predicate-type: https://cyclonedx.org/bom) should attest-sbom ever reject the crypto-only CycloneDX doc (FR-002, R-2).
Signing is key-less via the workflow OIDC identity; the attestation is stored in GitHub's attestation store keyed to the repository (no external OCI registry). Critically, neither attest step carries continue-on-error: a failed attestation fails its job, which means compute-digests / provenance / release never run, so no partial release can ship a BOM without its attestation (FR-010 fail-loud, by construction). The two jobs widen their token scope to id-token: write + attestations: write for exactly this purpose (release.yml:129-132,222-225).
The relying-party trust model is documented in docs/release-verification.md (Step 5, "Trust model"):
digital-go-jp/pverify, workflow .github/workflows/release.yml, ref refs/tags/v<version>. An RP constrains verification with --signer-workflow (or --cert-identity) so an attestation from any other workflow cannot satisfy it.private-repository: true opt-in and therefore does record the repository+workflow identity in the public Rekor log. The documentation warns explicitly: "Do not conflate the two: the typed BOM attestation path does not publish your repo identity to a public log." An auditor reviewing the two paths must keep this asymmetry in mind.ci.yml)ci.yml runs on every push to main and every pull request, and is the mechanism that keeps the release pipeline and supply-chain documents always shippable rather than only at tag time:
| Job | Command | What it guards |
|---|---|---|
lints |
cargo fmt --all --check; cargo clippy --workspace --all-targets -- -D warnings |
style / lint drift |
tests |
fetch unlicensed third-party fixtures (SHA-256-pinned, never committed) then cargo test --workspace |
the whole verification suite |
cbom-check |
cargo xtask cbom-check |
CBOM↔︎code drift (§16.4), release-blocking |
release-workflow-lint |
actionlint -color .github/workflows/release.yml (invokes shellcheck on every run: block) |
release.yml known-good before the first tag (FR-020, 024 FR-011) |
wasm-gate |
cargo build --target wasm32-unknown-unknown -p pverify-core |
§VI WASM-clean core (Chapter 3) |
cargo-audit |
cargo audit --deny warnings --ignore RUSTSEC-2023-0071 |
known-vulnerable dependencies |
perf-gate |
scripts/perf-gate.sh (four verify_with cases vs calibrated medians) |
verification-latency regressions |
Two of these deserve elaboration for this chapter.
release-workflow-lint — validating the pipeline before it firesBecause release.yml is only ever exercised by a real tag, a structural error would otherwise surface for the first time during a release. The release-workflow-lint job (ci.yml:63-78) runs actionlint — which validates the workflow schema and expressions and automatically invokes shellcheck on every embedded run: script — on every PR/push, so the workflow is known-good before the first tagged invocation (FR-020/SC-008). Slice 024 relies on this same gate to catch any structural error introduced by the attestation steps before a tag is pushed (024 FR-011/SC-005).
cargo audit and cargo vet — the two-axis dependency gatepverify runs both dependency-audit tools, on different axes and at different points in the lifecycle:
cargo audit (ci.yml:92-105) runs on every PR/push and checks the locked tree against the RustSec advisory database with --deny warnings. One advisory is ignored with documented rationale: RUSTSEC-2023-0071 (the Marvin timing attack on the rsa crate's decryption path). The rationale is recorded canonically in audit.toml: pverify-core only verifies RSA PKCS#1 v1.5 signatures (RsaPublicKey::verify) and never decrypts, so the attack vector — timing the decryption operation — is never present; upstream has no fixed release ("No fixed upgrade is available!"). An auditor evaluating this exception should note it is narrowly scoped to a capability pverify does not exercise, and is to be re-evaluated when rsa 0.10 ships a constant-time path. A subtlety worth flagging: cargo-audit 0.22.x no longer reads [advisories].ignore from audit.toml, so the CI workflow mirrors the same list via an explicit --ignore RUSTSEC-2023-0071 flag — audit.toml is the canonical rationale, the workflow is the executor, and the two must be edited together.
cargo vet (supply-chain-vet job, release.yml:37-47) runs as a release-blocking gate at tag time: cargo vet check --locked. Unlike cargo audit (which asks "is any dependency known-vulnerable?"), cargo vet asks "has every dependency been audited or explicitly exempted?" Its store lives in supply-chain/: config.toml (imports the bytecode-alliance, Google, and Mozilla shared audit sets, then a long list of per-crate [[exemptions.*]] with safe-to-deploy or safe-to-run criteria), audits.toml (first-party audits), and imports.lock.
A standing operational hazard, recorded in the project's institutional memory, is worth stating for maintainers: cargo vet only fires at release time, so unvetted crates (including dev/test-only dependencies) accumulate silently on feature branches and surface as a release block on the first tag. The discipline is to run cargo vet check --locked locally whenever a new crate enters the tree. Slice 034 discovered and resolved exactly this: the insta snapshot-test dependency (and its console/encode_unicode/similar transitives) that slice 028 had left unvetted were cleared with safe-to-run exemptions. The safe-to-run vs safe-to-deploy distinction matters here: dev- and test-only crates that never enter the shipped binary need only safe-to-run, which is why the insta chain carries that weaker criterion while production dependencies carry safe-to-deploy.
The end-to-end relying-party procedure is docs/release-verification.md. Its trust path is RP ↔︎ GitHub ↔︎ slsa-verifier — it needs no running pverify service. The five steps:
gh release download).slsa-verifier verify-artifact … --source-uri github.com/digital-go-jp/pverify --source-tag v$VERSION, which confirms the artefact's digest is a provenance subject and that the provenance was produced by the pverify release workflow at the expected source repository and tag. Repeated independently for each subject (binary, CBOM, SBOM, WASM, source) — each is its own subject (INV-8) and verifiable on its own.sha256sum -c) as a convenience cross-check; the authoritative binding remains the provenance in Step 2.jq checks: that the CBOM carries the release version (not the 0.0.0 placeholder), and that every signature entry's cryptoFunctions is exactly ["verify"] — proving pverify is verify-only directly from the document.gh attestation verify <bom> --repo digital-go-jp/pverify --signer-workflow digital-go-jp/pverify/.github/workflows/release.yml, which re-digests the local file, fetches the matching attestation from GitHub, and checks the Sigstore bundle against the signer identity. The doc also gives the negative checks that MUST fail: a tampered BOM (no matching attestation) and a wrong signer identity (identity mismatch). It records the connectivity prerequisites (gh attestation verify is online by default; air-gapped use requires a pre-downloaded bundle).The document additionally surfaces the trust-anchor inventory as a supply-chain transparency artefact: pverify trust-anchors {list,dump} renders every anchor pverify distributes (prod-pinned ledger, EU Trusted List ingest, AATL ingest, PQC-interop) plus the AATL identities deliberately excluded, with their exclusion reasons. The dump output is byte-identical run-to-run (no clock or random source is read; see Chapter 10), which makes cross-release inventory diffs small and readable — every change in the distributed trust set surfaces as an auditable patch.
The supply-chain layer is constructed so it cannot affect the verification kernel:
verify and never sign, enforced by INV-4 in the drift gate (§16.4.4); all signing in the pipeline is key-less OIDC; pverify the tool holds no key. The drift gate makes the published cryptographic claim a provably-current fact rather than stale documentation.cbom-check and trust-anchor dump are pure functions with no clock/random input.xtask, CI/release workflows, and host tooling — never in pverify-core or the WASM graph. xtask depends on pverify-core (to read the OID consts) but core never depends back; scripts/cargo-tree-gate.sh asserts xtask (and pverify-test-helpers) is absent from both the pverify-core and pverify-cli production cargo tree -e normal graphs (Chapter 3 §3.2). Adding the two OID consts is the only core source change, and it adds no dependency (FR-025).schema_version are unchanged by either slice.Neither slice amends the constitution: both are build/CI/documentation additions that reinforce §I/§II/§VI without touching any policy table or the report schema.
This chapter maps pverify to the governing standards corpus from the point of view of a relying party performing procurement due diligence. It has three purposes. First, it states precisely which European (eIDAS / ETSI) AdES levels pverify actually verifies, as opposed to what the report's level-naming might suggest. Second, it reproduces and explains the supplier-side conformance matrix against the JNSA デジタル署名検証ガイドライン 第 1.1 版(2023-12-20), with each claim grounded in code. Third, it gives an honest account of the gaps that remain — including the ones that are deliberate design choices ("intentional deviations") rather than defects.
The two primary source documents this chapter is built on are docs/jnsa-conformance-statement.md (the supplier conformity declaration) and docs/jnsa-guideline-gap-review-2026-06-20.md (the prioritised gap review). The gap review opened six findings (two High, four Medium, two Low). Five of them have landed; this chapter records both the resolved state and the residual caveats. Throughout, the governing principle is Constitution §I, Fact-Reporting ("cannot affirm"): where pverify cannot establish a fact, it degrades to INDETERMINATE with a precise closed-enum sub-indication rather than fabricating a TOTAL_FAILED or a TOTAL_PASSED. See Chapter 3 for the architecture this discipline is enforced within, and Chapter 5 §5.7 together with the Result Model (Chapter 14) for the indication-precedence machinery referenced below.
pverify is a verifier of advanced electronic signatures. It is not a signature creator, not a trust-list operator, and not a business-decision engine. The standards it claims conformance against, and the scope of each claim, are:
| Standard | Subject | pverify's claim |
|---|---|---|
| RFC 5280 | X.509 path validation | §6.1 basic-plus: chain build, validity windows, BasicConstraints, KeyUsage, Name Constraints, certificate policies, CRL semantics. crates/pverify-core/src/path/mod.rs, path/name_constraints.rs, path/policy.rs, path/bridge.rs. |
| RFC 5652 | CMS SignedData | Single-signer SignedData parse + signedAttrs/messageDigest/content-type binding. crates/pverify-core/src/cms/signed_data.rs. Multi-signer deliberately refused. |
| RFC 5035 | ESS signing-certificate v1/v2 | Signing-certificate hash binding (certificate-substitution defence). |
| RFC 3161 / 5816 | Time-stamp tokens | TST CMS verify, messageImprint recompute, TSA chain build + RFC 5816 ESS binding. crates/pverify-core/src/timestamp.rs. |
| RFC 6960 | OCSP | Request build, BasicOCSPResponse parse, §4.2.2.2 designated-signer authorisation, freshness, nonce, signature. crates/pverify-core/src/revocation/ocsp.rs. |
| RFC 7515 / 7797 | JWS / unencoded payload | JWS JSON serialization signing-input recompute, b64:false variant. crates/pverify-jades. |
| ETSI EN 319 102-1 | Validation indications | TOTAL_PASSED / INDETERMINATE / TOTAL_FAILED + closed sub-indication enum. crates/pverify-core/src/report/etsi.rs. |
| ETSI EN 319 122 | CAdES | B-B / B-T / B-LT / B-LTA (archive-time-stamp-v3, §6.3.4 imprint recompute). |
| ETSI EN 319 142 | PAdES | B-B / B-T / B-LT / B-LTA over embedded CMS + /DocTimeStamp; /DSS+/VRI+revocationInfoArchival (branch 034). |
| ETSI EN 319 132 / TS 101 903 | XAdES | Enveloped B-B with B-T / B-LT / B-LTA (ArchiveTimeStamp imprint per ETSI TS 101 903 v1.4.2 Annex A.1.5, branch 036); Exclusive C14N only. |
| ETSI TS 119 182 | JAdES | JWS-JSON-serialization B-B / B-T / B-LT / B-LTA (branch 037; sigTst/xVals/rVals/arcTst from unprotected header). rfsTst/tstVd, sigD, Compact refused. |
| ETSI EN 319 162 | ASiC | ASiC-S / ASiC-E containers, delegating inner signatures to CAdES/XAdES. |
| eIDAS | Regulatory frame | The AdES family and trust-list model (EU Trusted List, pverify-eutl). |
| CRYPTREC / NIST | Algorithm policy | Opt-in, VRT-scoped algorithm-validity evaluation (--algorithm-policy). |
| JNSA 署名検証ガイドライン 第 1.1 版 | Verification requirement profile | The M/E/O conformance matrix reproduced in §17.4. |
The single most important honesty statement is this: a TOTAL_PASSED from pverify means "every check pverify ran returned positively, within the implementation constraints disclosed in this chapter." It is not equivalent to a JNSA VALID verdict if any Mandatory check for that signature class is unimplemented or only partially implemented. The conformance matrix in §17.4 exists precisely so a relying party can determine which Mandatory checks were in force for a given signature class.
The report's signatures[].format field is a closed enum (SignatureFormat, crates/pverify-core/src/report/mod.rs:264). Its wire values and meanings are:
| Wire value | Family | Meaning |
|---|---|---|
CAdES-BES |
CAdES | Baseline B / BES — basic signature, no trusted timestamp. |
CAdES-T |
CAdES | B + ≥1 signature-time-stamp (RFC 3161). |
CAdES-LT |
CAdES | T + embedded validation material (certificate-values / revocation-values). |
CAdES-LTA |
CAdES | LT + archive-time-stamp-v3 (EN 319 122-1 §6.3.4 imprint recomputation). |
PAdES-B |
PAdES | Baseline B over embedded CMS. |
PAdES-B-T |
PAdES | B + signature timestamp / /DocTimeStamp. |
PAdES-B-LT |
PAdES | T + validation material via /DSS (branch 034). |
PAdES-B-LTA |
PAdES | LT + /DocTimeStamp archive timestamp. |
XAdES-B-B |
XAdES | Positively-verified enveloped XAdES baseline B. |
XAdES-B-T |
XAdES | + verified SignatureTimeStamp / SigAndRefsTimeStamp. |
XAdES-B-LT |
XAdES | + embedded CertificateValues / RevocationValues. |
XAdES-B-LTA |
XAdES | + ArchiveTimeStamp with imprint recomputation (ETSI TS 101 903 v1.4.2 Annex A.1.5; branch 036). Imprint input = exc-C14N of ds:Signature minus subsequent ArchiveTS node-sets; verified via shared ArchiveTimestampImprintRecord. |
XAdES-unsupported |
XAdES | Detection-only refusal route (detached / enveloping / non-exclusive C14N). |
JAdES-B-B |
JAdES | Positively-verified JWS-JSON-serialization baseline B. |
JAdES-B-T |
JAdES | + sigTst signature timestamp verified (branch 037). |
JAdES-B-LT |
JAdES | + xVals/rVals embedded cert/revocation material consumed (branch 037). |
JAdES-B-LTA |
JAdES | + arcTst archive timestamp verified; hash input = protected.payload.signature.sigTst_b64u (branch 037). |
JAdES-unsupported |
JAdES | Refusal route (sigD, Compact serialization, rfsTst/tstVd, non-JAdES JWS). |
The level-promotion ladder for CAdES is implemented at verify.rs:1034: an entry is promoted from BES → T → LT → LTA only when the structural evidence for the higher level is present and verified. The same ladder shape is mirrored for PAdES and XAdES.
The gap review's Low finding "AdES level naming can overstate support" is the caveat a reader must hold throughout: the format value reports observed structure, not a guarantee that every JNSA Mandatory row for that level was in force. For example, a PAdES-B-LT label asserts that LT validation material was present and consumed; it does not assert that the algorithm-validity Mandatory check was a verdict-impacting gate unless --algorithm-policy was supplied (§17.5). The conformance matrix is the authoritative mapping from "structure observed" to "checks run".
The "cannot affirm" discipline surfaces as a family of refusal sub-indications. These are deliberate INDETERMINATE outcomes, never fabricated failures:
| Condition | Sub-indication | Code site |
|---|---|---|
| Multi-signer CMS | cms_multi_signer_unsupported |
verify.rs:494, emitted at 1912 |
| Unsupported XAdES C14N | xades_unsupported_canonicalization |
XAdES path |
| Detached / enveloping XAdES | xades_unsupported_profile |
XAdES path |
| XAdES signer cert not resolvable | xades_signer_certificate_unavailable |
XAdES path |
JAdES Compact / sigD |
jades_unsupported_serialization |
JAdES path |
JAdES rfsTst/tstVd / non-baseline |
jades_unsupported_profile |
JAdES path |
| JAdES signer cert absent | jades_signer_certificate_unavailable |
JAdES path |
| Unrecognised ZIP / container | asic_unsupported_container |
ASiC path |
| Offline with no embedded revocation | revocation_not_checked_offline |
revocation pipeline |
| Confirmed-revoked TSA cert | revoked_no_poe |
TSA-revocation layer |
| Opt-in algorithm-policy fail/unaffirmable | crypto_constraints_failure_no_poe |
algorithm-validity layer |
The multi-signer refusal is the canonical example of the principle. The pre-029 parser silently kept only the first SignerInfo; the present code refuses to truncate, routing instead to INDETERMINATE / cms_multi_signer_unsupported rather than presenting a misleadingly-scoped result.
JNSA speaks in VALID / INVALID / INDETERMINATE. pverify reports ETSI EN 319 102-1 indications. The mapping (from docs/jnsa-conformance-statement.md) is:
| JNSA term | pverify etsi_indication.indication |
Meaning in pverify |
|---|---|---|
VALID |
TOTAL_PASSED |
Every check pverify ran returned positively, within the implementation constraints in §17.4. Not equivalent to JNSA VALID if any Mandatory check is unimplemented. |
INVALID |
TOTAL_FAILED |
At least one check pverify ran returned negatively (cryptographic verify-and-fail, revoked cert, imprint mismatch). |
INDETERMINATE |
INDETERMINATE |
Available information was insufficient to decide (offline + no embedded revocation, unsupported algorithm OID, unreachable responder, …). |
Exit-code caveat (load-bearing for automation). The CLI exits 0 for every reported verdict — including TOTAL_FAILED and INDETERMINATE. Non-zero exit codes are reserved for invocation/runtime errors (bad arguments, I/O failures). Automation MUST NOT treat exit 0 as signature validity; it must read signatures[*].etsi_indication.indication from the JSON output. This resolves the gap review's Medium finding "Result vocabulary and exit-code semantics need a relying-party map."
The severity ordering used by the aggregator (aggregate_etsi, verify.rs:2343) is TOTAL_FAILED > INDETERMINATE > TOTAL_PASSED, first-finding-wins. The layered post-processors (signer-binding, content-type, opt-in algorithm-validity, TSA-revocation) only ever degrade a non-TOTAL_FAILED base and never mask a proven TOTAL_FAILED. This never-mask discipline is itself a conformance property: a real cryptographic failure cannot be hidden by a later inconclusive check.
Notation: M = Mandatory, E = Mandatory-if-Exists, O = Optional. Status: Y = implemented and verdict-impacting; F = recorded as fact, not verdict-impacting (intentional deviation); P = partial; N = not implemented (explicit limitation). The matrix below reproduces docs/jnsa-conformance-statement.md and is grounded against code at each row.
| JNSA reference | M/E/O | Status | Report field | Code evidence / limitation |
|---|---|---|---|---|
| Format / packaging recognised | M | Y | signatures[].format |
Content sniff, not extension (verify.rs:359 detect_format). |
messageDigest of signed content |
M | Y | content_digest_check |
RFC 5652 §11.2 recompute. |
| Signer-cert chain to anchor | M | Y | chain_result |
RFC 5280 §6.1 (path/mod.rs). |
| Signer-cert validity at validation time | M | Y | chain_result.steps[].validity_at_time |
Reference time = per-object VRT (§17.4.7). |
| Signer-cert revocation | M | P | chain_result.steps[].revocation |
CRL + OCSP. Offline/from-bundle degrade honestly to revocation_not_checked_offline. |
| TSA-chain revocation | M | Y | timestamps[].chain_result.steps[].revocation |
Consistent across all formats (033); revoked → revoked_no_poe. |
| Algorithm validity at validation time | M | P | algorithm_validity (opt-in) + weak_algorithms[] |
Opt-in verdict (032); default-off ⇒ fact-only. See §17.5. |
| Validation reference time per object | M | Y | signatures[].vrt.{…} |
Per-object VRT engine (031); see §17.4.7. |
The signer-cert revocation row is marked P rather than Y because the honest-offline and from-bundle modes can legitimately produce INDETERMINATE / revocation_not_checked_offline when no live channel and no embedded//DSS material exist — pverify will not fabricate a responder contact (Constitution §VII). This is the correct conformance posture, but it is a partial satisfaction of the Mandatory check in the JNSA sense (the check may not always conclude).
| JNSA reference | M/E/O | Status | Report field |
|---|---|---|---|
| CMS SignedData parses | M | Y | refusal entry on parse error |
Signer identification by SignerInfo.sid |
M | Y | signer_identifier_check (issuerAndSerial / SKI) |
| ESS signing-certificate v2 binding | E | Y | signing_certificate_binding (RFC 5035) |
messageDigest signed attr |
M | Y | content_digest_check |
content-type equality |
E | Y | content_type_check (RFC 5652 §5.3) |
| Multi-signer SignedData | E | N | cms_multi_signer_unsupported (INDETERMINATE — honest refusal) |
signature-time-stamp (CAdES-T) |
E | Y | timestamps[] role signature_timestamp |
archive-time-stamp-v3 (CAdES-A) |
E | Y | timestamps[] role archive_timestamp + imprint check |
Embedded revocation-values (LT/LTA) |
E | Y | revocation pipeline consumes them |
| JNSA reference | M/E/O | Status | Report field / limitation |
|---|---|---|---|
| XML parse + canonicalisation | M | P | Exclusive C14N only → else xades_unsupported_canonicalization. |
| Reference digests vs c14n nodes | M | Y | xades_reference_checks[]; tamper → xades_reference_digest_mismatch (TOTAL_FAILED). |
| Enveloped / enveloping / detached | M | P | Enveloped only; else xades_unsupported_profile. |
SigningCertificateV2 binding |
E | Y | signing_certificate_binding. |
Signer cert via KeyInfo/X509Data |
M | Y | external resolution out of scope → xades_signer_certificate_unavailable. |
SignatureTimeStamp / SigAndRefsTimeStamp |
E | Y | timestamps[] (019). |
ArchiveTimeStamp (XAdES-B-LTA) |
E | Y | timestamps[] role archive_timestamp; imprint = exc-C14N of ds:Signature minus subsequent ArchiveTS node-sets; verified via ArchiveTimestampImprintRecord (036). |
| JNSA reference | M/E/O | Status | Report field / limitation |
|---|---|---|---|
| ByteRange covers revision end | M | Y | pdf_byte_range, pdf_covers_eof; tamper → pdf_byte_range_does_not_cover_eof. |
| Embedded CMS under byte-range bytes | M | Y | identical to CAdES. |
--required-policy enforcement |
E | Y | policy_check; PAdES narrows user-initial-policy-set identically to CAdES (029). |
/DSS + /VRI consumption |
E | Y | validation_objects[].origin = pdf_dss / pdf_vri (034). See §17.6. |
revocationInfoArchival in CMS |
E | Y | validation_objects[].origin = signature_embedded (034). See §17.6. |
/DocTimeStamp (PAdES-LTA) |
E | Y | timestamps[] role archive_timestamp (009). |
| JNSA reference | M/E/O | Status | Report field / limitation |
|---|---|---|---|
| JWS JSON serialization parses | M | Y | Compact → jades_unsupported_serialization. |
| Signing-input recompute (RFC 7515/7797) | M | Y | b64:true and b64:false. |
x5c / x5t#S256 signer resolution |
M | Y | missing → jades_signer_certificate_unavailable. |
JAdES B-B sigT + signing-cert binding |
E | Y | signing_certificate_binding. |
JAdES B-T (sigTst) |
E | Y | signature timestamp verified; feeds VRT pipeline (037). |
JAdES B-LT (xVals/rVals) |
E | Y | embedded certs/revocation fed to existing revocation pipeline (037). |
JAdES B-LTA (arcTst) |
E | Y | archive timestamp + non-circular imprint protected.payload.sig.sigTst (037). |
rfsTst / tstVd |
E | N | out of scope → jades_unsupported_profile. |
sigD detached payload |
E | N | → jades_unsupported_serialization. |
ES512 / Ed448 / alg:none |
M | F | refusal signature_algorithm_unsupported — honest "did not run the crypto" (jades.rs:184). |
ASiC (EN 319 162): ZIP integrity + path-traversal rejection (entries never written to disk), manifest digest equality (tamper → asic_data_object_digest_mismatch), data-object presence (missing → asic_data_object_missing), and recognisable ASiC gate (unrecognised → asic_unsupported_container, never misclassified as CAdES) are all M / Y.
RFC 3161 timestamp tokens (all formats): TST parse, messageImprint recompute (tamper → archive_timestamp_imprint_mismatch, TOTAL_FAILED in archive role), TSA chain to anchor, TSA validity at genTime, TSA revocation (revoked → revoked_no_poe), and imprint-algorithm-in-supported-set (archive_timestamp_imprint_unsupported_algorithm for SHA-1/256/384/512) are all M / Y.
Trust anchors: provenance recorded by hash in inputs[] (EUTL, AATL, GPKI bundles, browser-store mirrors), self-sign self-consistency check, and expiry at validation time (expired → anchor_expired_at_time) are all M / Y.
The gap review's first High finding — "long-term-signature validation reference time is not fully applied" — is resolved by the per-object VRT engine (crates/pverify-core/src/vrt/, branch 031). The engine derives, by the JNSA §4 recursive-outer-covering rule, a distinct validation reference time for every object: the signer chain, the signer signature/material, each signature timestamp, and each archive timestamp. An object covered by a valid outer timestamp is judged at that timestamp's GenTime (recursively, for chained archive timestamps); an uncovered object falls back to request.verification_time (--at / execution time).
The kernel then re-validates each object at its own VRT — the signer chain at verify.rs:775, each timestamp token in the per-token loops, and each TSA chain's revocation via apply_tsa_chain_revocation_for_token (033) at the object's own VRT. Critically, the VRT engine is pure compute: it reads only request_at as its time source and never calls Clock::now, and the covering graph is never re-derived or mutated by downstream revocation/algorithm layers. The chosen VRT and its derivation tag (including per-candidate demotion_reason when a covering TS is rejected for future-GenTime / unanchored TSA / imprint mismatch) are surfaced verbatim under signatures[].vrt, with a block shape identical across CAdES / PAdES / XAdES / JAdES / ASiC. See Chapter 12 for the engine internals.
The gap review's second High finding — "algorithm validity is only flagged, not enforced" — is resolved by branch 032 (algorithm_policy/, report/algorithm_validity.rs), but the resolution is deliberately opt-in, and the default behaviour is an intentional deviation that a relying party must understand.
Default behaviour (no --algorithm-policy): accept-and-flag. Weak algorithms (SHA-1, weak RSA key sizes) are recognised and surfaced in signatures[].weak_algorithms[] but do not change the verdict. A signature using SHA-1 can therefore still be TOTAL_PASSED if every other check passes. This is transparent and is the Fact-Reporting posture (Constitution §I): pverify reports the observed algorithm fact and leaves the accept/reject policy to the operator. In JNSA terms, the Mandatory algorithm-validity check is implemented as fact, not verdict, by default — hence the P status in §17.4.1.
Opt-in verdict (--algorithm-policy [FILE]). When a policy is supplied, each object is judged against a versioned policy with effective dates and minimum key sizes, evaluated as-of that object's own per-object VRT (§17.4.7). A failed or unaffirmable algorithm check degrades the indication to INDETERMINATE / crypto_constraints_failure_no_poe — never TOTAL_FAILED, honouring "cannot affirm" (an obsolete-but-not-broken algorithm is an absence of proof, not proof of forgery). The default policy is CRYPTREC-primary / NIST-secondary (SHA-1 and RSA < 2048 sunset 2014-01-01); deployments may supply a stricter file. With the policy off, the report is byte-identical to pre-032 except the schema_version string. See docs/algorithm-validity-policy.md.
This mirrors ETSI EN 319 102-1's CRYPTO_CONSTRAINTS_FAILURE_NO_POE, but maps it to INDETERMINATE rather than failure — a conscious conformance choice consistent with the tool's overall discipline.
/DSS / /VRI / revocationInfoArchival (resolved)The gap review's finding — "PAdES DSS/VRI validation material is not consumed" — is resolved by branch 034. Before 034, the PAdES revocation calls fed the pipeline empty embedded slices (&[],&[]), so a fully-evidenced offline PAdES-LT/LTA stalled at INDETERMINATE / revocation_not_checked_offline even though the document carried its own revocation proof. The resolution threads that material into the existing pipeline with zero new verdict logic.
Host extraction (Constitution §V — no PDF parse in core). The host extractors (pverify-cli/src/pdf.rs natively, web/pverify-wasm/src/lib.rs in-browser, byte-identical) parse the catalog-level /DSS dictionary's /Certs, /CRLs, /OCSPs pools — unioned across all incremental PDF revisions, deduped by DER — and the /VRI sub-dictionary, keyed by the uppercase-hex SHA-1 of each signature's /Contents (PAdES / EN 319 142-1 §5.4). The result is fed into the additive pdf_validation_data field (PdfValidationData, crates/pverify-core/src/pades/mod.rs:80); a non-PDF or no-DSS input carries an empty value, preserving byte-identity (FR-008).
Per-object resolution (pure compute). resolve_material (pades/mod.rs:148) resolves the preferred material per object as VRI-preferred, DSS-global fallback (clarification Q1): when a /VRI entry keyed by the object's /Contents SHA-1 exists and is non-empty, that subset is used (provenance PdfVri); otherwise the document-level /DSS pools are used (provenance PdfDss). The resolved CRL/OCSP DER then flows into the existing calls that previously received &[],&[]: apply_revocations for the signer chain (pades/mod.rs:702) and apply_tsa_chain_revocation_for_token for each timestamp's TSA chain (pades/mod.rs:1010), each at that object's own VRT.
Three deliberate constraints, each a conformance fact:
DSS /Certs are never trust anchors (Q7 / FR-005). They are intermediate / leaf path-building candidates only — documented at pades/mod.rs:82 ("NEVER trust anchors") and at the call site (pades/mod.rs:665). A document cannot bootstrap its own trust by carrying a self-signed root in /DSS.
Additive fallback in all modes (Q5). The material rides the embedded-CRL/ OCSP channels that fire only when the live/CDP channel is indeterminate (the 019 precedent). Online live-fetch successes therefore stay byte-identical; DSS material can only supply evidence where there was none, never override a fetched answer. Because the revocation pipeline matches CRLs by issuer+serial and OCSP by certID, additive material can never fabricate a good outcome (a superset is safe).
Zero new verdict logic (Q2). A DSS-borne CRL/OCSP yields the existing RevocationOutcome vocabulary verbatim: a revoked signer is the existing TOTAL_FAILED, a revoked TSA is 033's revoked_no_poe. No new outcome and no new sub-indication were introduced. The only wire-level change is provenance: two additive ValidationObjectOrigin values, pdf_dss and pdf_vri (report/validation_objects.rs:113, :118).
Adobe revocationInfoArchival. The proprietary CMS attribute (OID 1.2.840.113583.1.1.8) is projected from the embedded CMS (pades/mod.rs:1340 onward, a best-effort DER walk) and merged into the signer-chain revocation material — tagged signature_embedded, not pdf_dss, because it lives in the CMS rather than the PDF structure (R-5). A producer that carries revocation only in this attribute reaches the same definitive result it would from an equivalent /DSS.
The net conformance effect: a fully-evidenced offline PAdES-LT now reaches TOTAL_PASSED instead of degrading to INDETERMINATE. This satisfies JNSA's explicit treatment of PAdES as structurally distinct from CAdES/XAdES (validation material may live in PDF dictionaries). Out of scope (and disclosed as such): DSS generation, DSS-cert-as-anchor, VRI /TU//TS internal-consistency checking, and otherRevInfo in revocationInfoArchival; and the equivalent DSS mechanisms for CAdES/XAdES/JAdES/ASiC (XAdES already consumes its own embedded material via 019).
The gap review's finding — "TSA certificate revocation coverage is uneven" — is resolved by branch 033. Before 033, some XAdES paths had additive TSA-revocation handling but CAdES/PAdES paths did not uniformly apply the same pipeline to TSA certificate chains. The shared helper apply_tsa_chain_revocation_for_token (verify.rs:1552) is now invoked for every timestamp object, in every format (CAdES / PAdES / XAdES / JAdES / ASiC), at that object's own per-object VRT (§17.4.7), reusing the signer-chain revocation pipeline and the offline policy verbatim.
The outcome is reported on each timestamp's own timestamps[].chain_result.steps[].revocation, distinct from signer revocation. A confirmed-revoked TSA degrades the indication to INDETERMINATE / revoked_no_poe — never TOTAL_FAILED, because a timestamp from a later-revoked TSA is an absence of proof-of-existence, not proof of forgery (the "cannot affirm" principle applied to time evidence). An undeterminable TSA-revocation state never falls below INDETERMINATE.
The gap review's Medium "Supplier conformity declaration is missing" is resolved by the existence of docs/jnsa-conformance-statement.md itself — the M/E/O matrix reproduced and code-grounded in §17.4. It is explicitly not a third-party assessment: it is what pverify's maintainers state about pverify's behaviour. The artefacts offered for independent assessment are the closed JSON output schema (root report-schema.json / specs/001-verify-cades-pades/contracts/report-schema.json) and the fixtures under fixtures/. The result-vocabulary and exit-code map (§17.3) addresses the related Medium "Result vocabulary and exit-code semantics need a relying-party map."
The following are disclosed honestly. Items 1–3 are deliberate design choices, not defects; items 4–5 are genuine residual scope.
Weak algorithms default to accept-and-flag, not reject (§17.5). The Mandatory algorithm-validity check is fact-by-default, verdict only under --algorithm-policy. Rationale: Fact-Reporting (Constitution §I); operators apply their own accept/reject policy.
Multi-signer CMS is refused, not modelled. Each SignerInfo is not yet emitted as a separate signatures[] entry; the document routes to INDETERMINATE / cms_multi_signer_unsupported rather than silently scoping to the first signer. One-entry-per-signer modelling is future scope.
Exit code is not a verdict (§17.3). Documented in the README and the conformance statement.
Signature-policy modelling is limited to --required-policy <OID> (gap review Low). pverify processes certificate policies (RFC 5280 §6.1 valid_policy_tree, mapping, anyPolicy, inhibit flags; path/policy.rs) and accepts a user-initial-policy-set via --required-policy. It does not yet expose a broader versioned validation-policy file covering algorithm rules, acceptable TSA anchors/timestamp policies, path restrictions, optional-to-mandatory escalation, or a required-AdES-level-for-use-case constraint. Government deployments frequently want policy choices beyond a certificate-policy OID; this is the recommended next expansion.
AdES level naming requires the conformance-statement caveat (gap review Low). The format value reports observed structure; a relying party should read it together with §17.4 to know which Mandatory rows were in force. The recommended future UI refinement is to display "observed structure" separately from "fully validated level," gating the latter on all JNSA M/E rows for that level.
Additionally, the format-scoped limitations remain in force and are themselves conformance facts, not bugs: XAdES is enveloped + Exclusive-C14N only (other profiles/canonicalizations refuse to INDETERMINATE); JAdES is B-B JWS-JSON only (B-T+, sigD, Compact, alg:none, ES512/Ed448 refuse to INDETERMINATE); and DSS-equivalent material is consumed for PAdES (034) and XAdES (019) but not yet projected for CAdES/JAdES/ASiC beyond their existing embedded channels.
Branch 036 closes the XAdES B-LTA gap: the ArchiveTimeStamp element (ETSI TS 101 903 v1.4.2 Annex A.1.5) imprint is now recomputed and verified. The imprint input is the Exclusive C14N serialisation of the ds:Signature element, with the node-sets of subsequent ArchiveTimeStamp elements subtracted from the canonicalized output (the non-circular imprint construction). This is realised via the bergshamra-c14n NodeSet subtract API, requiring no new dependency.
The SignatureFormat::XadesBLta ("XAdES-B-LTA") variant was added to the report enum. The ArchiveTimestampImprintRecord wire type (already present for CAdES) is shared — no new schema type and no schema bump. Synthetic fixtures (build_blta / build_blta_tampered) and an end-to-end test (xades_blta_e2e) cover both the match and mismatch paths.
Scope out: DSS XAdES-LTA fixtures that use XPath transform (not supported), ArchiveTimestampV3 (CAdES chained form), multiple chained ArchiveTS loop processing, and JAdES arcTst.
| # | Severity | Finding | Status | Resolution |
|---|---|---|---|---|
| 1 | High | LTV validation reference time not fully applied | RESOLVED | Per-object VRT engine (031, schema 1.3.0). §17.4.7. |
| 2 | High | Algorithm validity only flagged, not enforced | RESOLVED | Opt-in VRT-scoped algorithm policy (032, schema 1.4.0). §17.5. |
| 3 | Medium | TSA revocation coverage uneven | RESOLVED | Shared TSA-revocation helper across all formats (033, schema 1.5.0). §17.7. |
| 4 | Medium | PAdES /DSS//VRI not consumed |
RESOLVED | DSS/VRI/revocationInfoArchival consumption (034, schema 1.7.0). §17.6. |
| 5 | Medium | Supplier conformity declaration missing | RESOLVED | docs/jnsa-conformance-statement.md + this chapter. §17.8. |
| 6 | Medium | Result vocabulary / exit-code map | RESOLVED | §17.3 (+ README, web UI). |
| 7 | Low | Signature policy under-modelled | OPEN | --required-policy only; versioned validation-policy file is future scope. §17.9(4). |
| 8 | Low | AdES level naming can overstate support | OPEN (mitigated) | Conformance statement provides the caveat; UI separation of observed-vs-validated is future scope. §17.9(5). |
The report-shape version that carries all of the above is SCHEMA_VERSION = "1.10.0" (crates/pverify-core/src/report/schema.rs), mirrored 1:1 into the root report-schema.json. Each resolved finding above bumped the schema additively (MINOR), consistent with the additive-versioning invariant: adding a closed-enum value (e.g. pdf_dss / pdf_vri / revoked_no_poe / crypto_constraints_failure_no_poe) or an optional field is a MINOR bump; removing or repurposing one would be MAJOR.
This appendix consolidates the reference material an auditor needs when reading a pverify report alongside its source. It collects (18.1) a consolidated glossary extending the survey vocabulary; (18.2) a field-by-field reference of the top-level report schema, grounded in the Rust types that serialise it; (18.3) the closed-enum catalogues (ETSI indication / sub-indication, RevocationOutcome, ValidationObjectOrigin) that the schema admits; (18.4) the spec/version lineage from v0.1 through the v1.1.0 design target, mapping each Spec Kit slice directory to its feature and its schema_version effect; and (18.5) a source-file map of which crate and module owns which concern.
Every claim here is grounded in the workspace at branch 036-xades-blta. The single source of truth for the report shape is the Rust type tree rooted at crates/pverify-core/src/report/mod.rs, with the closed enums in crates/pverify-core/src/report/etsi.rs; the JSON Schema mirror lives at specs/001-verify-cades-pades/contracts/report-schema.json and is asserted 1:1 against the Rust constants by the schema-conformance tests (see Chapter 3 for the WASM-clean boundary and Chapter 14 for the report-derivation pipeline).
The survey-phase glossary (reproduced and extended below) defines the AdES, PKI, and pverify-specific vocabulary. This section retains those terms verbatim where they remain accurate against the code, corrects any drift, and adds the terms that recur in this and adjacent chapters but were not in the survey set. Terms are grouped by domain; within a group they are ordered for reading, not alphabetised.
| Term | Definition |
|---|---|
| AdES | Advanced Electronic Signature. The umbrella for the ETSI signature families pverify verifies: CAdES (CMS / EN 319 122), PAdES (PDF / EN 319 142), XAdES (XML / EN 319 132), JAdES (JWS / TS 119 182), packaged by ASiC (EN 319 162). |
| B-B (BES) | Baseline level B: a basic signature with signing certificate and signed attributes, no trusted timestamp. Supported for CAdES, PAdES, XAdES and JAdES. Wire values CAdES-BES, PAdES-B, XAdES-B-B, JAdES-B-B in SignatureFormat (report/mod.rs:264). |
| B-T | Baseline level T: B plus at least one trusted signature-time-stamp (RFC 3161). Supported for CAdES (CAdES-T), PAdES (PAdES-B-T) and XAdES (XAdES-B-T); refused (INDETERMINATE / jades_unsupported_profile) for JAdES in the current slice (report/mod.rs:354). |
| B-LT | Baseline level LT (Long-Term): T plus embedded validation material (certificate-values, revocation-values) so the signature is self-contained for later verification. Supported for CAdES (CAdES-LT), PAdES (PAdES-B-LT, via /DSS as of branch 034) and XAdES (XAdES-B-LT). |
| B-LTA | Baseline level LTA (Long-Term with Archive): LT plus archive-time-stamps that re-protect the whole structure. CAdES (CAdES-LTA) uses archive-time-stamp-v3 (ETSI EN 319 122-1 §6.3.4 imprint recomputation); PAdES (PAdES-B-LTA) uses /DocTimeStamp; XAdES (XAdES-B-LTA) uses ArchiveTimeStamp per ETSI TS 101 903 v1.4.2 Annex A.1.5 (branch 036). |
| XAdES-B-LTA | XAdES Long-Term Archive level. The ArchiveTimeStamp element's imprint is recomputed: the input is the Exclusive C14N of the ds:Signature element with the node-sets of subsequent ArchiveTimeStamp elements subtracted (non-circular construction, bergshamra-c14n NodeSet subtract API). Wire value "XAdES-B-LTA" in SignatureFormat; ArchiveTimestampImprintRecord is shared with CAdES. An imprint mismatch yields archive_timestamp_imprint_mismatch (TOTAL_FAILED). |
| ArchiveTimeStamp | The XAdES xades:ArchiveTimeStamp unsigned property (ETSI TS 101 903 §7.5.1) carrying an RFC 3161 TST whose imprint covers the whole ds:Signature (minus later ArchiveTS node-sets). Distinct from the CAdES archive-time-stamp-v3 attribute and the PAdES /DocTimeStamp — but all three share the same TimestampToken wire representation with role = archive_timestamp. |
| LGPKI2 | Local Government PKI 2 — Japanese municipal public-key infrastructure (J-LIS). OrgCA (R2) PAdES-LT/LTA off-by-1 fixed in v1.3.0; v1.3.1 adds dirname_to_ldap_url suffix-pattern synthesis for CN=CRL{N},OU=Organization CA R2,O=LGPKI2,C=JP DirectoryName CDPs → ldap://www.lgpki.go.jp:389. |
| CdpEntry | Structured CDP (CRL Distribution Point) record (report/mod.rs): kind (uri_http / uri_ldap / uri_other / dirname), value (string), fetchable (bool). Replaced the flat cdp_uris: Vec<String> in the schema 1.8.0 → 1.9.0 bump, surfacing DirName CDPs (e.g. GPKI OSCA). |
| DocTimeStamp | A PDF /Type /DocTimeStamp dictionary carrying an RFC 3161 TST over a revision's /ByteRange — the PAdES archive-timestamp mechanism. Surfaced as a TimestampToken with role = archive_timestamp. Both PDF extractors (pverify-cli/src/pdf.rs, web/pverify-wasm/src/lib.rs) tag it via PdfSigDictType so the core stays PDF-model-free (slice 009). |
| Term | Definition |
|---|---|
| RFC 5280 path validation | The X.509 §6 basic-plus algorithm: build leaf→anchor by issuer/subject DN matching, check validity windows, BasicConstraints, KeyUsage, name constraints and certificate policies. Implemented in crates/pverify-core/src/path/mod.rs with DFS chain construction, backtrack and DER-fingerprint cycle detection. |
| Bridge CA | A CA (e.g. GPKI BridgeCA) cross-certified with multiple domain roots, enabling inter-domain trust. pverify performs DFS+backtrack cross-certificate traversal across 0..N bridges (path/bridge.rs); the deprecated bridge_ca_required_unsupported sub-indication is retained for stored-report deserialisation but is no longer emitted (v0.3+). |
| Name Constraints | RFC 5280 §4.2.1.10 permittedSubtrees / excludedSubtrees state machine (path/name_constraints.rs). A violation routes to INDETERMINATE indeterminate_named_constraint; an unsupported GeneralName form to name_constraints_unsupported_form; a malformed extension to name_constraints_malformed. |
| Certificate Policies / policy processing | RFC 5280 §6.1 valid_policy_tree processing, policy mapping, anyPolicy and inhibit_* handling (path/policy.rs). user-initial-policy-set is supplied via VerificationRequest.required_policies (CLI --required-policy); empty means {anyPolicy}. An empty tree fails only when requireExplicitPolicy fired (RFC 5280 §6.1.5 — see PR #48 fix), yielding indeterminate_policy_rejected. |
| Trust anchor | A self-signed root the verifier trusts a priori (crate::traits::TrustAnchor, traits.rs: der_bytes, SHA-256 fingerprint, raw subject DN, validity_window_covers_request_time flag, subject_key_identifier for AKID-based tie-breaking). Loaded host-side. DSS /Certs are never promoted to anchors (FR-005, Q7). |
| OCSP | RFC 6960 Online Certificate Status Protocol. pverify builds the request, parses BasicOCSPResponse, enforces §4.2.2.2 designated-signer authorisation, freshness, nonce and signature (revocation/ocsp.rs). |
| CRL | RFC 5280 Certificate Revocation List. pverify parses CertificateList, handles indirect-CRL detection and staleness (revocation/crl.rs, revocation/indirect.rs); the embedded-CRL channel fires only when the CDP / live channel is indeterminate. |
| RFC 3161 / 5816 timestamp | A time-stamp token (TST) binding a message imprint to a TSA-asserted time. pverify verifies the TST CMS, the imprint, and the TSA certificate chain (timestamp.rs); RFC 5816 ESS cert binding is honoured. TSA certificate chains are revocation-checked at each object's VRT (slice 033). |
| messageImprint | The hash value a timestamp or archive-time-stamp-v3 commits to. For CAdES-LTA pverify recomputes the §6.3.4 canonical input and compares; a mismatch is archive_timestamp_imprint_mismatch (TOTAL_FAILED). |
| EU Trusted List (TSL / LOTL) | ETSI TS 119 612 TrustServiceStatusList, an enveloped-XAdES list of qualified trust services. Ingested by pverify-eutl (signer-pin model, service-status-at-time-t filter) into ordinary trust anchors. Never re-published, never adjudicates "qualified" (§I). |
| AATL | Adobe Approved Trust List — a CMS-signed PDF (itself a PAdES signature) wrapping an XML TrustedIdentities list. Ingested by pverify-aatl against a pinned Adobe Root CA G2, filtered to government-affiliated Root && CertifiedDocuments identities. |
| foreign-government | data_source value used for root certificates from foreign government PKIs that are not covered by EU LOTL / FPKI / GPKI — currently Taiwan GRCA (GRCA1/2/3), Korea GPKIRootCA1, Singapore NCA B1. Entries are manually pinned in web/roots/*.json. SHA-256 is derived from the retrieved DER bytes and cross-checked against the canonical distribution point and (where available) Microsoft CCADB. The key_algorithm field is required for all entries from v1.4.1 onward. |
| Term | Definition |
|---|---|
| CMS SignedData | RFC 5652 cryptographic message syntax — the container for CAdES and the embedded payload of PAdES. Parsed by a lenient hand-rolled walker (cms/signed_data.rs) producing SignerInfo, signedAttrs, certificates and unsignedAttrs. Multi-signer CMS is refused (INDETERMINATE / cms_multi_signer_unsupported), never silently reduced to the first signer (slice 029). |
| ESS signing-certificate binding | RFC 5035 signing-certificate(v1) / v2 signed attribute pinning the signer cert by hash (+ optional issuerSerial). A certHash mismatch is TOTAL_FAILED (signing_certificate_digest_mismatch, certificate-substitution defence); v1 SHA-1 is accept-and-flag (cms/ess.rs, slice 025). |
content-type signed-attribute binding |
RFC 5652 §5.3 equality of the content-type signed attribute OID against the eContentType (slice 026). Mismatch is TOTAL_FAILED (content_type_mismatch); absent-when-signedAttrs-present is content_type_missing; unparseable is content_type_not_evaluable (INDETERMINATE). |
| Exclusive C14N | Exclusive XML Canonicalization (W3C) used to canonicalize XAdES node-sets before digesting. Provided by the audited bergshamra-c14n crate; non-exclusive C14N degrades to xades_unsupported_canonicalization. Plain Canonical XML 1.0 (C14nMode::Inclusive) is used only for the XAdES timestamp imprint (slice 019). |
| JWS Signing Input | For JAdES, the BASE64URL(protected) '.' payload-segment string (RFC 7515 §5.1), with the RFC 7797 b64:false unencoded-payload variant handled by pverify-jades; the JWS signature verifies over this input. |
| DSS (Document Security Store) | A PDF catalog-level /DSS dictionary holding document-wide validation material in /Certs, /CRLs and /OCSPs pools (PAdES / EN 319 142-1). pverify unions it across all incremental revisions, deduping by DER, treating /Certs as intermediates/leaf only — never trust anchors (slice 034, Q6/Q7). |
| VRI (Validation Related Information) | A /DSS sub-dictionary mapping the uppercase-hex SHA-1 of a signature's /Contents to the /Cert, /CRL, /OCSP subset relevant to that signature. pverify resolves VRI-preferred, DSS-global-fallback per object (pades::resolve_material, slice 034, Q1). |
| revocationInfoArchival | Adobe proprietary CMS attribute (OID 1.2.840.113583.1.1.8) carrying CRL / OCSP / otherRevInfo inside the signature. pverify projects its CRL / OCSP DER and merges them as signature_embedded material (slice 034). otherRevInfo is out of scope. |
| Term | Definition |
|---|---|
| ETSI Indication | The top-level verdict from EN 319 102-1: TOTAL_PASSED, INDETERMINATE or TOTAL_FAILED. Closed enum Indication in report/etsi.rs:24. |
| Sub-indication | The precise machine-readable reason qualifying an Indication (e.g. messageDigest_mismatch, signer_certificate_revoked, crypto_constraints_failure_no_poe). A single closed Rust enum SubIndication (report/etsi.rs:39) mirrored 1:1 into report-schema.json; free-form values are forbidden (§I). See §18.3.1 for the catalogue. |
| cannot affirm | Constitution §I principle: where pverify cannot establish a fact it returns INDETERMINATE with a sub-indication, never a fabricated TOTAL_FAILED or TOTAL_PASSED. E.g. a revoked TSA cert yields revoked_no_poe (INDETERMINATE), not a forgery claim. |
| RevocationOutcome | Closed enum of per-step revocation results (GoodOnCrl, RevokedOnCrl, GoodOnOcsp, RevokedOnOcsp, IndeterminateRevocationOffline, …) in report/mod.rs:768, reused verbatim by signer-chain, TSA-chain and DSS-sourced checks. See §18.3.2. |
| VRT (Validation Reference Time) | Per-object time at which an object's certificate validity and revocation freshness are judged, derived by the recursive-outer-covering rule (JNSA §4) in crate::vrt; the next-outer covering timestamp's genTime promotes an inner object's reference time. Surfaced per signature in signatures[].vrt (slice 031). |
| ValidationObjectOrigin | Provenance tag on each consolidated validation object: signature_embedded, bundle, fetched_online, trust_anchor, supplied_input, pdf_dss, pdf_vri (report/validation_objects.rs:97). pdf_dss / pdf_vri were appended in branch 034; declaration order is the sort order for the origin[] array. |
| Mode | Closed enum {Online, Offline, FromBundle} (report/mod.rs:54) controlling revocation acquisition. Offline opens no socket (offline honesty); FromBundle reads <bundle>/crls, /ocsp, /trust-anchors. |
| Offline honesty | Constitution §VII principle: in --offline mode pverify must not fabricate a responder contact. The IndeterminateRevocationOffline outcome / revocation_not_checked_offline sub-indication replaced the prior dishonest ocsp_responder_unreachable emission (slice 012). |
| Algorithm policy (CRYPTREC / NIST) | Opt-in (--algorithm-policy) per-object evaluation of digest / signature family / key length against a policy as-of each object's VRT, mirroring ETSI CRYPTO_CONSTRAINTS_FAILURE_NO_POE. Off by default; never produces TOTAL_FAILED (algorithm_policy/, report/algorithm_validity.rs, slice 032). |
| Term | Definition |
|---|---|
no_std + alloc kernel |
pverify-core compiles without the standard library, using only the alloc crate, forbidding unsafe code and all direct I/O — the precondition that lets it compile to wasm32-unknown-unknown and stay deterministic (pverify-core/src/lib.rs declares #![no_std] and #![forbid(unsafe_code)]). |
| Byte-identity (additive features) | Reproducibility invariant: an additive feature must leave the report body byte-identical except the schema_version string when the feature is off / inputs absent. E.g. a no-DSS PDF report differs from pre-034 only in schema_version (FR-008). |
| CLI↔︎WASM parity | The native CLI and the browser WASM host must extract identical structures and run the identical verify_with kernel, producing byte-identical reports for identical inputs. Enforced by parity tests over the extractors. |
| schema_version | SemVer of the report shape (report/schema.rs SCHEMA_VERSION, currently 1.12.0), mirrored in report-schema.json. Adding a closed-enum value or optional field is MINOR; adding a new required top-level field is MINOR; removing / repurposing a value is MAJOR. See §18.4 for the lineage. |
The report is one JSON object per verify invocation, regardless of how many signatures the document carries. The Rust source of truth is pub struct Report (crates/pverify-core/src/report/mod.rs:69); serde preserves field declaration order on serialise, which is the schema's required order and is what keeps the JSON byte-identical across runs.
The header (pverify_version … mode) is filled by Report::new_header (report/mod.rs:95) from the compile-time constitutional constants in report/schema.rs; the verify pipeline appends signatures[] and finally derives validation_objects[] via derive_validation_objects (report/validation_objects.rs:191).
+---------------------------+----------------------+-----------+-------------------------------------------------------------+
| Field | JSON type | Required | Meaning / source |
+---------------------------+----------------------+-----------+-------------------------------------------------------------+
| pverify_version | string | yes | Binary version (CARGO_PKG_VERSION) — schema.rs PVERIFY_VER. |
| schema_version | string (SemVer) | yes | Report-shape version, "1.12.0" — schema.rs SCHEMA_VERSION. |
| trust_profiles | array<string> | yes | Active trust profile names (040). Default ["jpki"]. |
| path_validation_phase | string | yes | RFC 5280 §6 slice actually run; "v0.6-bridge-acceptance". |
| bridge_ca_supported | bool | yes | Binary capability flag; true since v0.3. |
| dn_match_method | string | yes | DN-compare rule; "rfc4518-minimal-v0.1". |
| verification_time | string (RFC 3339) | yes | The clock the run judged against (--at or startup capture). |
| mode | enum {online, | yes | Revocation acquisition mode (report/mod.rs:54). |
| | offline, from_bundle}| | |
| inputs | array<InputHash> | yes | Every file/OID consumed, by SHA-256 + role. |
| fetch_log | array<FetchLogEntry> | yes | Outbound HTTP fetches (online mode); empty otherwise. |
| weak_algorithms | array<WeakAlgFlag> | yes | Union of per-step weak-algorithm observations (SHA-1). |
| signatures | array<SignatureEntry>| yes | One entry per signature in the document. |
| validation_objects | array<ValidationObj> | yes | Dedup-by-SHA-256 inventory of every consulted material. |
+---------------------------+----------------------+-----------+-------------------------------------------------------------+
The five constitutional disclosure constants (schema_version, path_validation_phase, bridge_ca_supported, dn_match_method, and the implicit pverify_version) are never read from configuration — they are compile-time facts about the binary that produced the report (report/schema.rs header comment, §I fact-reporting). verification_time is captured once at CLI startup unless --at is supplied, and --at always wins (§II reproducibility).
inputs[] — InputHash (report/mod.rs:203)+-------------+----------------------------------------------+--------------------------------------------------+
| Field | Type | Meaning |
+-------------+----------------------------------------------+--------------------------------------------------+
| path | string | Filesystem path, OR (role=required_policy) the |
| | | OID dotted-decimal string itself. |
| role | enum: signature | detached_content | | What the file is to the verifier. |
| | trust_anchors_dir | bundle_root | | |
| | bundle_manifest | required_policy | |
| body_sha256 | string (64 hex) | SHA-256 of the file bytes (or OID UTF-8 bytes). |
+-------------+----------------------------------------------+--------------------------------------------------+
required_policy entries are sorted lexicographically before emission so flag-supply order does not influence the report (preserves byte-identity).
signatures[] — SignatureEntry (report/mod.rs:348)This is the workhorse record; one per signature. Optional fields use skip_serializing_if so that formats that cannot carry them stay byte-identical to earlier schema versions.
+-------------------------------+----------------------------+-----------+-------------------------------------------------+
| Field | Type | Required | Meaning |
+-------------------------------+----------------------------+-----------+-------------------------------------------------+
| format | SignatureFormat enum | yes | Detected level (see §18.2.4). |
| pdf_byte_range | [u64;4] | null | yes/null | PAdES /ByteRange; null for non-PDF. |
| pdf_covers_eof | bool | null | yes/null | Whether this revision covers its %%EOF. |
| content_source | enum {embedded, detached} | yes | Which byte source the digest check ran against. |
| content_source_redundant | bool | optional | Some(true) iff eContent AND byte-equal detached.|
| chain_result | ChainResult | yes | RFC 5280 path-validation result (§18.2.5). |
| signed_attrs_check | SignedAttrsCheck | yes | signedAttrs digest + signature outcome. |
| content_digest_check | ContentDigestCheck | yes | messageDigest vs covered content + its SHA-256. |
| timestamps | array<TimestampToken> | yes | Signature/content/archive timestamps (§18.2.6). |
| embedded_validation_data | EmbeddedValidationData|null| yes/null | LT/LTA embedded certs/CRLs/archive-TS. |
| container | ContainerFormat enum | optional | ASiC-S / ASiC-E if extracted from a container. |
| asic_manifest_checks | array<AsicManifestCheck> | optional | ASiC-E CAdES manifest digest-chain step 2. |
| asic_conformance_deviations | array<AsicDeviation> | optional | EN 319 162 packaging deviations (best-effort). |
| signer_identifier_check | SignerIdentifierCheck | optional | CMS SignerInfo.sid form + match (slice 025). |
| signing_certificate_binding | SigningCertificateBinding | optional | ESS signing-certificate(v2) binding (025). |
| content_type_check | ContentTypeCheck | optional | RFC 5652 §5.3 content-type equality (slice 026).|
| etsi_indication | EtsiIndication | yes | The verdict for this signature (§18.2.7). |
| vrt | Vrt | yes | Per-object validation reference times (031). |
| algorithm_validity | AlgorithmValidityCheck | optional | Opt-in algorithm policy verdict (slice 032). |
+-------------------------------+----------------------------+-----------+-------------------------------------------------+
vrt is required since slice 031, but stored 1.2.0 reports that omit it deserialise via the Vrt::request_at_fallback() default, and Report::from_json (report/mod.rs:127) substitutes verification_time for the synthesised UNIX_EPOCH sentinel — a deliberate forward-compatibility path for previously-stored reports.
SignatureFormat enum (report/mod.rs:264)The format is the detected AdES level, and is explicit about scope. The wire values and what each means in terms of supported vs. detection-only-refusal:
+--------------------+-----------------------------------------------------------------------------+
| Wire value | Status |
+--------------------+-----------------------------------------------------------------------------+
| CAdES-BES | Supported (B-B). |
| CAdES-T | Supported (B-T, signature-time-stamp). |
| CAdES-LT | Supported (B-LT, embedded validation material). |
| CAdES-LTA | Supported (B-LTA, archive-time-stamp-v3 §6.3.4 imprint recomputation). |
| PAdES-B | Supported (B-B over embedded CMS). |
| PAdES-B-T | Supported (B-T, signature-time-stamp / DocTimeStamp). |
| PAdES-B-LT | Supported (B-LT, /DSS + /VRI + revocationInfoArchival as of slice 034). |
| PAdES-B-LTA | Supported (B-LTA, /DocTimeStamp archive). |
| XAdES-B-B | Supported (enveloped B-B, Exclusive C14N). |
| XAdES-B-T | Supported (SignatureTimeStamp / SigAndRefsTimeStamp). |
| XAdES-B-LT | Supported (embedded CertificateValues / RevocationValues). |
| XAdES-B-LTA | Supported (ArchiveTimeStamp imprint per ETSI TS 101 903 v1.4.2 Annex A.1.5, branch 036). |
| XAdES-unsupported | Detection-only refusal: detached/enveloping, non-exclusive C14N, etc. |
| JAdES-B-B | Supported (JWS JSON Serialization B-B). |
| JAdES-unsupported | Detection-only refusal: sigD detached, Compact serialization, B-T+. |
+--------------------+-----------------------------------------------------------------------------+
There is no combined "ASiC×format" variant: ASiC is recorded orthogonally in the container field (ContainerFormat::{AsicS, AsicE}), while format carries the nested CAdES / XAdES value of the inner signature.
chain_result — ChainResult (report/mod.rs:586)ChainResult records the RFC 5280 §6 path-validation outcome: the ordered steps[] (each a ChainStep with subject_fingerprint, cert_signature_alg, BasicConstraints, KeyUsage, validity-window facts, per-step revocation record, and a ChainStepSignature cert-signature-verify result), the bridge_attempts[] cross-certificate traversals, the terminating_anchor_fingerprint, and the Name-Constraints / policy-processing checks. The per-step revocation field (RevocationRecord, report/mod.rs:826) is the integration point for the DSS/VRI material in slice 034 and for TSA-chain revocation in slice 033.
timestamps[] — TimestampToken (report/mod.rs:998)+----------------------+--------------------------------------+--------------------------------------------------+
| Field | Type | Meaning |
+----------------------+--------------------------------------+--------------------------------------------------+
| role | enum: signature_timestamp | | Which timestamp role (RFC 3161 TST). |
| | content_timestamp | archive_timestamp| |
| tsa_subject_dn_text | string | TSA cert subject DN. |
| tsa_chain_outcome | ChainResult | TSA cert chain validation (+ revocation, 033). |
| gen_time | string (RFC 3339) | TSA-asserted genTime. |
| hash_algorithm_oid | string | messageImprint hash algorithm OID. |
| message_imprint_match| bool | Whether the imprint recomputation matched. |
| token_sha256 | string (64 hex) | SHA-256 of the TST bytes (omit if absent). |
| imprint_record | ArchiveTimestampImprintRecord | null | §6.3.4 recompute record (archive role only). |
+----------------------+--------------------------------------+--------------------------------------------------+
etsi_indication — EtsiIndication (report/etsi.rs:435)+----------------+-------------------------------------------+--------------------------------------------------+
| Field | Type | Meaning |
+----------------+-------------------------------------------+--------------------------------------------------+
| indication | enum: TOTAL_PASSED | INDETERMINATE | | The verdict (Indication, §18.3 / EN 319 102-1). |
| | TOTAL_FAILED | |
| sub_indication | SubIndication | null | The precise reason (closed enum, §18.3.1). |
| failing_locus | string | null | Free-text identifier of the failing object/step. |
+----------------+-------------------------------------------+--------------------------------------------------+
The aggregator aggregate_etsi (verify.rs) establishes a severity-ordered base (TOTAL_FAILED > INDETERMINATE > TOTAL_PASSED, first-finding-wins) and subsequent layers (signer-binding, content-type, algorithm-validity, TSA-revocation) only degrade a non-TOTAL_FAILED base — they never mask a proven TOTAL_FAILED.
validation_objects[] — ValidationObject (report/validation_objects.rs:53)The deduplicated, deterministically-sorted inventory of every raw material the run actually consulted. It is a pure derivation pass over the assembled report tree (no divergent hashing, no side inputs).
+---------+-----------------------------------------------+--------------------------------------------------+
| Field | Type | Meaning |
+---------+-----------------------------------------------+--------------------------------------------------+
| sha256 | string (64 hex) | Material digest; primary dedup key. |
| kind | enum: certificate | crl | ocsp_response | | Artefact class. |
| | timestamp_token | signed_content | |
| origin | array<ValidationObjectOrigin> | Where the bytes came from; sorted, deduped. |
| usage | array<ValidationObjectUsage> | Cross-references (cert role / rev target / TS). |
| parsed | bool (default true, omit when true) | false iff consulted but failed to parse. |
+---------+-----------------------------------------------+--------------------------------------------------+
Dedup is by sha256 (via a BTreeMap, never a HashMap); origins and usages are merged, parsed is the logical AND across contributors, and the array is sorted by (kind, sha256). Two runs over identical inputs produce byte-identical arrays (§II / SC-003).
The report's machine-readable value spaces are closed Rust enums mirrored 1:1 into report-schema.json. Adding a value is an additive MINOR change; removing or repurposing one is MAJOR (§18.4). Deprecated variants are retained for stored-report deserialisation but not emitted.
SubIndication catalogue (report/etsi.rs:39)Grouped by the verdict they pair with. TF = TOTAL_FAILED, IND = INDETERMINATE. The wire string is the serde(rename) value.
Chain / structural (TF unless noted):
chain_signature_failed TF cert-chain signature verify failed
signer_certificate_revoked TF signer revoked on CRL
signer_certificate_revoked_via_ocsp TF signer revoked on OCSP
signer_certificate_expired_at_time TF signer cert expired at VRT
signer_certificate_not_yet_valid_at_time TF signer cert not yet valid at VRT
anchor_expired_at_time TF trust anchor expired at VRT
key_usage_missing_required_bit TF KeyUsage lacks a required bit
chain_constraints_failure TF (deprecated; re-routed to indeterminate_named_constraint)
Signed-attrs / content / signer binding:
messageDigest_mismatch TF messageDigest != content digest
signed_attrs_signature_failed TF signedAttrs signature verify failed
signer_certificate_not_found TF SignerInfo.sid matched no embedded cert (025)
signer_identifier_malformed IND sid malformed/absent — cannot identify signer (025)
signing_certificate_digest_mismatch TF ESS certHash mismatch (025)
signing_certificate_issuer_serial_mismatch TF ESS issuerSerial mismatch (025)
signing_certificate_digest_not_evaluable IND ESS certHash alg uncomputable (025)
content_type_mismatch TF content-type OID != eContentType (026)
content_type_missing TF content-type attr absent w/ signedAttrs (026)
content_type_not_evaluable IND content-type unparseable (026)
Path constraints / policy (IND):
indeterminate_named_constraint IND Name-Constraints state machine rejected the chain
name_constraints_unsupported_form IND unsupported GeneralName form
name_constraints_malformed IND malformed nameConstraints extension
indeterminate_policy_rejected IND policy processing rejected (requireExplicitPolicy/intersection)
policy_processing_inconclusive IND (deprecated; re-routed)
policy_mappings_malformed IND policyMappings maps anyPolicy etc.
bridge_ca_required_unsupported IND (deprecated v0.3; retained for stored reports)
Algorithm / key:
signature_algorithm_unsupported IND unrecognised sig OID or unsupported curve
public_key_malformed IND recognised alg but undecodable SPKI (010)
crypto_constraints_failure_no_poe IND opt-in algorithm-policy failure at VRT (032)
not_yet_supported_in_v0.1 IND legacy placeholder
Timestamp / archive:
archive_timestamp_imprint_mismatch TF archive-TS-v3 imprint recompute mismatch (§6.3.4)
archive_timestamp_imprint_unsupported_algorithm IND archive-TS imprint alg outside catalogue
xades_timestamp_imprint_mismatch IND XAdES SignatureTimeStamp imprint mismatch (019)
revoked_no_poe IND TSA cert chain revoked at its VRT (033)
Revocation channel (OCSP/CRL):
ocsp_responder_signature_invalid TF OCSP response signature invalid
ocsp_response_stale IND nextUpdate past / absent / producedAt future
ocsp_responder_chain_unauthorised IND RFC 6960 §4.2.2.2 designated-signer failed
ocsp_unknown_status IND CertStatus unknown / nonce mismatch / malformed
ocsp_responder_unreachable IND online transient or from_bundle bundle-miss
revocation_not_checked_offline IND --offline, no socket opened (012)
crl_stale_no_fresher IND CRL stale, no fresher available
unsupported_indirect_crl IND indirect CRL not supported
unsupported_cdp_protocol IND CDP scheme not supported
ocsp_only_revocation_pointers IND (deprecated v0.4; retained)
PAdES / XAdES / JAdES / ASiC structural:
pdf_byte_range_does_not_cover_eof IND trailing bytes uncovered by ByteRange
pdf_byte_range_malformed IND malformed /ByteRange
xades_unsupported IND un-categorised XAdES catch-all
xades_reference_digest_mismatch TF ds:Reference digest mismatch (tamper)
xades_signing_certificate_mismatch TF SigningCertificateV2 CertDigest mismatch
xades_unsupported_profile IND detached/enveloping rather than enveloped
xades_unsupported_canonicalization IND non-exclusive C14N
xades_signer_certificate_unavailable IND no embedded signer cert
jades_payload_digest_mismatch TF JWS signature != signing input
jades_signing_certificate_mismatch TF JAdES x5t#S256 / x5t#o binding mismatch
jades_unsupported_profile IND JAdES B-T+ / plain JWS without JAdES props
jades_unsupported_serialization IND sigD detached / Compact serialization
jades_signer_certificate_unavailable IND no x5c / x5t#S256 lookup result
asic_data_object_digest_mismatch TF ASiC-E manifest DigestValue mismatch
asic_data_object_missing IND referenced data object absent from archive
asic_unsupported_container IND ZIP not a recognisable ASiC container
cms_multi_signer_unsupported IND >1 SignerInfo — refused, not first-only (029)
no_signatures_found IND OFD: document carried no Signatures.xml (044)
signer_certificate_unverified IND OFD: cert found but issuing PKI not in trust profile (044)
RevocationOutcome catalogue (report/mod.rs:768)+----------------------------------+----------+-----------------------------------------------------+
| Variant (Rust) | Verdict | Meaning |
+----------------------------------+----------+-----------------------------------------------------+
| GoodOnCrl | passed | CRL: certificate good. |
| RevokedOnCrl | failed | CRL: certificate revoked. |
| GoodOnOcsp | passed | OCSP: good, all gates passed. |
| RevokedOnOcsp | failed | OCSP: CertStatus revoked. |
| IndeterminateNoCrl | indet. | No usable CRL: none fetchable, OR a fetched CRL |
| | | failed to parse / failed signature verification. |
| IndeterminateStaleCrl | indet. | A CRL was fetched but is outside its freshness |
| | | window (thisUpdate in the future, or nextUpdate |
| | | already past). |
| IndeterminateIndirectCrl | indet. | Indirect CRL unsupported. |
| IndeterminateUnsupportedProtocol | indet. | CDP protocol unsupported. |
| IndeterminateOcspUnknown | indet. | OCSP unknown / nonce mismatch. |
| IndeterminateOcspResponderUnauthorised | indet. | RFC 6960 §4.2.2.2 designated-signer failed. |
| IndeterminateOcspStale | indet. | nextUpdate past / absent / producedAt future. |
| IndeterminateOcspMalformed | indet. | Non-decodable / non-successful response status. |
| IndeterminateOcspUnreachable | indet. | All AIA fetches failed (online) / bundle-miss. |
| IndeterminateRevocationOffline | indet. | --offline, no socket opened (012, offline honesty). |
| IndeterminateOcspOnly | indet. | Legacy v0.1; superseded by OCSP-channel outcomes. |
+----------------------------------+----------+-----------------------------------------------------+
A DSS- or VRI-sourced CRL/OCSP yields exactly these outcomes — slice 034 added no new revocation outcome and no new sub-indication; only the provenance fields CrlSummary.pdf_source / OcspAttempt.pdf_source (PdfRevocationSource::{PdfDss, PdfVri}, report/mod.rs:870) were added so the inventory can tag the material's origin.
ValidationObjectOrigin (report/validation_objects.rs:97)Declaration order is the sort order for the origin[] array; the two PDF values were appended last in branch 034 precisely so that existing arrays stay byte-stable.
signature_embedded in-signature material (CMS unsignedAttrs, embedded certs/CRLs)
bundle --from-bundle directory
fetched_online live HTTP / CDP / AIA fetch
trust_anchor the terminating anchor
supplied_input detached content / supplied OID
pdf_dss PDF /DSS pool (DSS-global fallback) — slice 034
pdf_vri PDF /VRI-keyed subset (VRI-preferred) — slice 034
The PDF-source tag wins over the mode-/OcspSource-based heuristic in derive_validation_objects (validation_objects.rs:307 for CRLs, :334 for OCSP): a DSS- or VRI-borne response rides the embedded channel, so its OcspSource would otherwise read embedded_ocsp_vals — the explicit pdf_source tag corrects the provenance.
pverify is built slice-by-slice under Spec Kit; each slice has a directory under specs/<NNN>-<name>/ carrying its plan.md, research.md, data-model.md, contracts/, quickstart.md and tasks.md. The table below maps each landed slice to its feature and its effect on schema_version. The source is docs/speckit-history.md, the directory list under specs/, and the chronologically-annotated SCHEMA_VERSION doc-comment in crates/pverify-core/src/report/schema.rs (which records every bump).
Note that the schema_version SemVer and the released-binary tag (v0.x) are independent axes: many slices add closed-enum values or optional fields without bumping schema_version (the discipline is that only a new required top-level field, or a deliberate coupled rotation, bumps it). The released tag lineage (v0.1 … v0.9, then v1.1.0) is summarised at the foot.
+-----------+------------------------------+-------------------------------------------+-------------------+
| Spec dir | Feature | Schema effect | schema_version |
+-----------+------------------------------+-------------------------------------------+-------------------+
| 001 | verify-cades-pades | Baseline report shape. | 1.0.0 |
| 002 | ecdsa-attached-cades | ECDSA + attached CAdES; +2 sub-ind. | 1.0.0 (held) |
| 003 | v03-rfc5280-path | RFC 5280 §6 complete + Bridge-CA + arch- | 1.0.0 (held); |
| | | TS-v3 imprint; +7 sub-ind, bridge flag. | phase rotated |
| 004 | v04-ocsp-support | RFC 6960 OCSP; +6 sub-ind, +7 rev-outcome.| 1.0.0 (held); |
| | | | phase v0.4-ocsp |
| 005 | v04-1-reinforcement | Reinforcement slice. | held |
| 006 | v041x-ocsp-harness | Synthetic-responder test harness. | held |
| 007 | v05-carry-over | bridge_attempt -> bridge_attempts rename. | 1.0.0 -> 1.1.0 |
| 008 | v06-bridge-acceptance | Multi-bridge + Name-Constraints + policy; | 1.1.0 (held); |
| | | +2 sub-ind, phase v0.6-bridge-acceptance. | phase rotated |
| 009 | v07-pades-incremental | PAdES incremental-update + DocTimeStamp. | 1.1.0 (held) |
| 010 | ml-dsa-verify | ML-DSA (FIPS 204) verify; +1 sub-ind. | 1.1.0 (held) |
| 011 | xades-bb-core | XAdES B-B (enveloped, exc-c14n) + RSA-PSS;| 1.1.0 (held) |
| | | +1 format, +5 sub-ind. | |
| 012 | ldap-crl | LDAP-CRL fetch (CLI + core BER client). | held |
| 012 | offline-revocation-honesty | Offline honesty fix; +1 sub-ind, +1 rev. | 1.1.0 (held) |
| 013 | slsa-sbom-cbom | SLSA L3 + SBOM + CBOM (CI/build). | held (no report) |
| 014 | eutl-ingestion | pverify-eutl (EU TSL -> anchors). | held (own types) |
| 015 | dss-interop-fixtures | DSS interop regression vectors (+~25 LOC | 1.1.0 (held) |
| | | offline-honesty fix for PAdES/XAdES). | |
| 016 | diagnostic-data | validation_objects[] (new REQUIRED field);| 1.1.0 -> 1.2.0 |
| | | +3 digest fields, +4 closed enums. | |
| 019 | xades-bt-blt | XAdES B-T / B-LT; +2 format, +1 sub-ind. | 1.2.0 (held) |
| 020 | asic-container-verify | ASiC-S/-E (pverify-asic); +2 format-side | 1.2.0 (held) |
| | | enums, +3 sub-ind, +container field. | |
| 021 | cli-net-guard | SSRF net-guard (CLI-only). | 1.2.0 (held) |
| 024 | bom-attestation | Typed CycloneDX attestation (CI/docs). | held (no report) |
| 025 | cades-signer-binding | SignerInfo.sid + ESS binding; +5 sub-ind, | 1.2.0 (held) |
| | | +1 weak-alg role, +2 optional fields. | |
| 026 | aatl-ingestion | pverify-aatl (AATL -> anchors). | held (own types) |
| 026 | cades-contenttype-binding | content-type signed-attr; +3 sub-ind, | 1.2.0 (held) |
| | | +1 optional field. | |
| 027 | jades-bb-verify | JAdES B-B (pverify-jades); +2 format, | 1.2.0 (held) |
| | | +5 sub-ind. | |
| 028 | trust-anchor-viz | Trust-anchor visualisation (web/tooling). | held |
| 031 | vrt-per-object | signatures[].vrt (new REQUIRED field); | 1.2.0 -> 1.3.0 |
| | | +3 closed enums under $defs.Vrt. | |
| 032 | algorithm-validity | Opt-in algorithm policy; +1 optional | 1.3.0 -> 1.4.0 |
| | | field, +3 enums, +1 sub-ind (constitution | |
| | | 1.1.0 -> 1.2.0). | |
| 033 | tsa-revocation-consistency | TSA-cert revocation across all formats; | 1.4.0 -> 1.5.0 |
| | | +1 sub-ind (revoked_no_poe). Content-only.| |
| 034 | pades-dss-vri | PAdES /DSS + /VRI + revocationInfoArchival;| 1.5.0 -> 1.7.0 |
| | | +2 origin values, +2 pdf_source fields. | |
| 035 | multi-findings | EtsiIndication.findings[] parallel | 1.7.0 |
| | | reporting; omit-when-empty byte-identity. | |
| 035-retry | ocsp-retry | OcspAttempt.request_der_hex; /ocsp proxy | 1.7.0 -> 1.8.0 |
| | | retry in browser; RFC 6960 §4.2.2.1 | |
| | | nextUpdate-absent fix for MOJ CRPKI; | |
| | | outer OCSPResponse unwrap fix. | |
| 035 P1+P2 | ocsp-client | RFC 8954 CSPRNG nonce (NonceSource trait);| held (no schema) |
| | | Workers online mode (WorkersOnlineFetcher | |
| | | + WasmNonceSource); nonce mismatch -> | |
| | | revocation_not_checked_online. | |
| patch | akid-anchor-selection | TrustAnchor.subject_key_identifier; | held (no schema) |
| | | AKID-based tie-break when same-DN anchors | |
| | | coexist (GPKI OSCA 2019/2024 fix). | |
| cdp-entry | structured CDPs | cdp_uris:Vec<String> → cdp_entries: | 1.8.0 -> 1.9.0 |
| | | Vec<CdpEntry>(kind/value/fetchable); | |
| | | DirName CDPs surface (GPKI OSCA); | |
| | | aia_ocsp_uris added to RevocationRecord. | |
| ltv-jws | experimental LTV-JWS | SIG-B to SIG-LTA (draft-miyachi-00); | held (no schema) |
| | | 4 new SignatureFormat variants. | |
| gpki-hint | GPKI DirName→LDAP URL | dirname_to_ldap_url hint table for | held (no schema) |
| | | OfficialStatusCA / BridgeCA CDPs. | |
| 036 | xades-blta | XAdES B-LTA: ArchiveTimeStamp imprint | held (no schema) |
| | | (ETSI TS 101 903 v1.4.2 Annex A.1.5); | |
| | | +1 format (XAdES-B-LTA); shares | |
| | | ArchiveTimestampImprintRecord with CAdES; | |
| | | bergshamra-c14n NodeSet subtract API. | |
| lgpki2 | LGPKI2 DirName→LDAP URL | dirname_to_ldap_url suffix pattern for | held (no schema) |
| | | CN=CRL{N},OU=Organization CA R2, | |
| | | O=LGPKI2,C=JP → www.lgpki.go.jp:389; | |
| | | LGPKI2 PAdES-LT now TOTAL_PASSED online. | |
| 040 | trust-profile-selection | --trust-profile flag; 6 named profiles | 1.10.0 -> 1.11.0 |
| | | (jpki/aatl/eutl/fpki/fgov/local); new | |
| | | required trust_profiles[] report field; | |
| | | UI TZ dropdown removed; 6 checkboxes. | |
| 044 | ofd-verify | OFD (GB/T 33190-2016) signature verify; | 1.11.0 -> 1.12.0 |
| | | SignatureFormat::Ofd; OfdSignatureEntry; | |
| | | SM2/SM3 crypto; GB/T 38540 seal parsing; | |
| | | SubIndication::NoSignaturesFound added. | |
+-----------+------------------------------+-------------------------------------------+-------------------+
Released-binary tag lineage (the git tags, from docs/speckit-history.md §"Released-baseline lineage"): v0.1 (001) · v0.2 (002) · v0.3 (003) · v0.4 (004) · v0.4.1 (005) · v0.4.1.1 (006) · v0.5 (007) · v0.6 (008) · v0.7 (009) · v0.8 (019, XAdES B-T/B-LT, full SLSA L3) · v0.9 (021, CLI net-guard) · v1.1.0 (034, PAdES /DSS+/VRI) · v1.1.9 (patch: AKID-based anchor selection for same-DN coexisting roots, e.g. GPKI OSCA 2019/2024) · v1.2.0 (CdpEntry structured CDPs + GPKI DirName hint + LTV-JWS experimental) · v1.3.0 (036-xades-blta: XAdES B-LTA ArchiveTimeStamp imprint + LGPKI2 PAdES off-by-1 fix
v1.3.1 (lgpki2-ldap: LGPKI2 DirectoryName CDP → LDAP URL synthesis, www.lgpki.go.jp:389 reachable) · v1.3.2 (037-jades-bt-blta: ETSI JAdES B-T/B-LT/B-LTA, sigTst/xVals/ rVals/arcTst unprotected-header parsing wired into VRT/revocation pipeline; schema 1.10.0) · v1.4.0 (038-crl-dashboard: GPKI/LGPKI CRL dashboard, pverify crl-inspect subcommand, weekly CI refresh, all crate versions unified to 1.4.0) · v1.4.1 (foreign-government trust anchors: Taiwan GRCA1/2/3, Korea GPKIRootCA1, Singapore NCA B1; data_source: foreign-government; issue #95 closed; key_algorithm field populated for all 39 ledger JSONs and shown in the roots UI; scripts/hooks/pre-push fmt+clippy hook; GitHub Actions bumped to Node 24) · v1.5.0 (040-trust-profile-selection: 6-way trust profile selection via --trust-profile; new required trust_profiles[] report field; Web UI profile checkboxes + TZ dropdown removal; schema 1.10.0→1.11.0) · v1.6.0 (044-ofd-verify: OFD GB/T 33190-2016 signature verification; SignatureFormat::Ofd; SM2/SM3 crypto via pverify-ofd; GB/T 38540 electronic seal parsing; SubIndication::NoSignaturesFound + SignerCertificateUnverified added; OfdSignerCert browser extraction via pverify-core::ofd; AATL government-affiliated CAs merged into roots.json for fgov profile parity; schema 1.11.0→1.12.0) · v1.7.0 (new trust anchors: HK Post Root CA 3 (Hong Kong Post, RSA 4096) and CCA India 2022 (India CCA, RSA 4096) added as foreign-government; new crypto-statistics section: algorithm/key-length, expiry-year, issuance-year charts from trust-anchors.json with trust-profile filter and fingerprint deduplication; statistics page renamed to /stats.html (301 redirect from old URL); full key_algorithm coverage (947 entries): EUTL/AATL derive field from DER at ingest time; EC curve OID decode bug fixed (p.value()→p.to_der()); RSA-PSS OID added; roots-list UI shows key_algorithm in card summary; EUTL tab no longer collapsed; AATL profile checkbox added (commercial roots lazy-loaded); CI: web-only pushes skip Rust jobs; schema 1.12.0 unchanged) · v1.7.1 (stats page new charts + roots UI improvements + trust anchor + CI fix: new trust anchor my FinTech my Digital Certificate-Sign G1 (aatl); security-strength scatter plot and validity heatmap added to stats page; roots-list その他 tab renamed 外国政府; dedicated AATL tab added; web-lint CI job; en.json double-quote bug fix; schema 1.12.0 unchanged) · v1.7.2 (JAdES sigTst/arcTst hash-input fixes + ES512/P-521 + DSS interop fixtures: sigTst imprint corrected to verbatim BASE64URL-encoded signature member per §5.4.1; arcTst hash input reordered to payload.protected.signature.etsiU[0]etsiU[1]… with no dot separators between etsiU items per §5.3.6.3.1, matching DSS reference; ECDSA P-521 / ES512 added; two DSS interop fixtures jades-bt-clear-etsiu and jades-blta; schema 1.12.0 unchanged) · v1.7.3 (RustCrypto next-gen stack upgrade: der 0.7→0.8, spki 0.7→0.8, pkcs8 0.10→0.11, sha1/sha2 0.10→0.11, signature 2→3, elliptic-curve 0.13→0.14; x509-cert 0.2→0.3-rc.4, p256/p384/p521 0.13→0.14-rc.15, ecdsa 0.16→0.17-rc.23, rsa 0.9→0.10-rc.18 (all RC exact-pinned); schema 1.12.0 unchanged) · v1.7.4 (verification trace mode: CLI --trace flag writes ANSI green step-by-step trace to stderr, stdout JSON unaffected; browser: permanent collapsible trace panel below validation-objects, black/green terminal style, collapsed by default; no new Cargo deps; schema 1.12.0 unchanged) · v1.7.5 (SLH-DSA FIPS 205 + FN-DSA FIPS 206 signature verification: all 12 SLH-DSA parameter sets (SHA2 + SHAKE, OIDs .3.4.3.20–.31) via slh-dsa 0.2.0-rc.5; FN-DSA-512 and FN-DSA-1024 (OIDs .3.4.3.46–.47) via fn-dsa-vrfy/comm 0.3.0; key_algorithm_from_cert_der gains 17 PQC OID arms; CBOM grows to 33 algorithms; schema 1.12.0 unchanged). The tag sequence jumps directly from v0.9.0 to v1.1.0: the intervening v1.0.0 (JAdES B-B / 027) and v0.10.0 milestones were web-only releases and were never cut as git tags, so they do not appear in git tag. schema_version is 1.12.0 at v1.6.0 through v1.7.5.A few lineage subtleties worth an auditor's attention:
schema_version bumps are rare and always coupled to a structural reason. Only four slices ever bumped it: 007 (field rename), 016 (new required validation_objects[]), 031 (new required signatures[].vrt), and the monotonic 032/033/034 additive sequence. Every other slice added closed-enum values or optional fields and held the version — the discipline recorded inline in report/schema.rs.path_validation_phase is a separate disclosure axis that rotates only on an RFC 5280 §6 capability change (v0.1-basic → … → v0.6-bridge-acceptance, its current frozen value). Slices 031–034 are §6 input refinements (which time to validate at, what revocation material to feed), not phase changes, so the phase string has not rotated since v0.6.012 and 026) — 012-ldap-crl / 012-offline-revocation-honesty and 026-aatl-ingestion / 026-cades-contenttype-binding — reflecting parallel work under one umbrella; both directories exist under specs/.bridge_ca_required_unsupported, chain_constraints_failure, policy_processing_inconclusive, ocsp_only_revocation_pointers, not_yet_supported_in_v0.1, IndeterminateOcspOnly) remain in the wire enums so stored reports from older binaries still deserialise; current binaries do not emit them. This is the practical face of the "removing a value is MAJOR" rule.This map names which crate and module owns which concern, so a reviewer can go from a report field or behaviour to the code that produces it. Dependency direction is strictly host → clean → core (see Chapter 3.2); pverify-core depends on nothing in the workspace.
pverify-core — the WASM-clean verification kernel+-------------------------------------+------------------------------------------------------------+
| File | Concern |
+-------------------------------------+------------------------------------------------------------+
| lib.rs | Crate root; #![no_std], #![forbid(unsafe_code)]. |
| verify.rs | Orchestrator: VerificationRequest, verify_with, format |
| | sniff (detect_format), per-format dispatch, verify_cades, |
| | aggregate_etsi, per-object VRT re-validation, TSA-chain |
| | revocation wiring (apply_tsa_chain_revocation_for_token). |
| traits.rs | Capability boundary: Clock, RevocationFetcher, |
| | TrustAnchorStore, TrustAnchor. |
| crypto.rs | RustCrypto verify dispatch (verify_with_alg); SUPPORTED_* |
| | OID consts (CBOM source of truth); RSA-PSS. |
| algorithms.rs | Algorithm OID <-> family/digest classification. |
| error.rs | Core error type. |
| ber/mod.rs | Lenient BER/DER walker primitives. |
| cms/signed_data.rs | CMS SignedData parse (SignerInfo, signedAttrs, certs). |
| cms/signed_attrs.rs | signedAttrs reconstitution + digest. |
| cms/unsigned_attrs.rs | unsignedAttrs (T/LT/LTA material). |
| cms/ess.rs | RFC 5035 ESS signing-certificate(v1/v2) binding (025). |
| cms/canonical.rs / cms/verify.rs | CMS canonicalisation + signature verification. |
| crypto.rs / path/* | (see below) chain + crypto. |
| path/mod.rs | RFC 5280 §6 DFS chain build + backtrack + cycle detect. |
| path/bridge.rs | Bridge-CA cross-certificate traversal. |
| path/name_constraints.rs | §4.2.1.10 permitted/excluded subtree state machine. |
| path/policy.rs | §6.1 valid_policy_tree / mappings / inhibit_*. |
| x509/mod.rs, name.rs, extensions.rs | Certificate parse, DN match (rfc4518-minimal), extensions. |
| revocation/mod.rs | Revocation orchestration, embedded fall-through, PDF source|
| | tagging (PdfRevocationSource), apply_revocations. |
| revocation/crl.rs | RFC 5280 CRL parse + staleness. |
| revocation/indirect.rs | Indirect-CRL detection. |
| revocation/ocsp.rs | RFC 6960 OCSP request/response + §4.2.2.2 authorisation. |
| timestamp.rs | RFC 3161/5816 TST verify + imprint + TSA chain. |
| vrt/mod.rs, coverage.rs, promotion.rs| Per-object VRT recursive-outer-covering engine (031). |
| pades/mod.rs | PAdES verify_pdf; PdfValidationData / VriEntry boundary |
| | types; DSS/VRI resolve_material; revocationInfoArchival. |
| xades.rs | XAdES orchestration (verify_xades). |
| jades.rs | JAdES orchestration (verify_jades) + boundary types. |
| asic.rs | ASiC orchestration (verify_asic_signature) + boundary. |
| algorithm_policy/{mod,policy,extract}.rs | Opt-in algorithm policy engine (032). |
| report/mod.rs | Report + SignatureEntry + all support structs/enums. |
| report/etsi.rs | Indication / SubIndication closed enums + EtsiIndication. |
| report/schema.rs | SCHEMA_VERSION + constitutional disclosure consts. |
| report/validation_objects.rs | validation_objects[] derivation + origin enum (016/034). |
| report/vrt.rs | Vrt wire types (VrtEntry, VrtDerivation, etc.). |
| report/algorithm_validity.rs | AlgorithmValidityCheck wire types (032). |
| report/time_fmt.rs | RFC 3339 time serde helpers. |
+-------------------------------------+------------------------------------------------------------+
wasm32, no crypto)+----------------+---------------------------------------------------------------+
| Crate | Concern |
+----------------+---------------------------------------------------------------+
| pverify-xades | XML parse + Exclusive C14N + XadesComponents extraction. |
| pverify-asic | ZIP read + ASiC manifest parse + AsicSignature extraction. |
| pverify-jades | JWS JSON Serialization parse + JadesComponents extraction. |
+----------------+---------------------------------------------------------------+
These depend ON pverify-core (for the boundary types it owns) but contain no cryptography and are forbidden from the core runtime graph by scripts/cargo-tree-gate.sh.
std, NOT in core/WASM runtime graph)+--------------------------+------------------------------------------------------------------+
| Crate / file | Concern |
+--------------------------+------------------------------------------------------------------+
| pverify-cli/src/main.rs | CLI entry; Command::Verify; clock capture; mode selection. |
| pverify-cli/src/pdf.rs | PDF parse (lopdf): extract_signatures + extract_validation_data |
| | (/DSS union-across-revisions + /VRI by /Contents SHA-1). |
| pverify-cli/src/trust.rs | Trust-anchor load + SHA-256 fingerprint + validity-covers flag. |
| pverify-cli/src/modes/ | online.rs (+ net_guard), offline.rs, from_bundle.rs, ldap.rs. |
| pverify-cli/src/net_guard.rs | SSRF resolve-then-pin classifier (021). |
| pverify-cli/src/render.rs| Report -> JSON serialisation. |
| pverify-eutl | EU Trusted List ingestion -> TrustAnchor (014). |
| pverify-aatl | AATL ingestion -> TrustAnchor (026). |
| pverify-anchor-inventory | Read-over aggregator of all anchor distribution channels. |
| web/pverify-wasm/src/lib.rs | Browser host: in-WASM PDF/XAdES/ASiC/JAdES extraction + |
| | verify_with; CLI-parity extractors. |
+--------------------------+------------------------------------------------------------------+
publish = false)fixture-gen Test-fixture / signing tooling.
pverify-test-helpers Shared test helpers (CLI dev-dep only).
xtask cargo xtask cbom-check drift gate (013); depends ON core.
+--------------------------------------------------------+----------------------------------------------+
| Artefact | Concern |
+--------------------------------------------------------+----------------------------------------------+
| crates/pverify-core/src/report/schema.rs | SCHEMA_VERSION source of truth. |
| specs/001-verify-cades-pades/contracts/report-schema.json | JSON Schema mirror (1:1 with the enums). |
| scripts/cargo-tree-gate.sh | Empirical core-graph isolation guard. |
| scripts/fetch-dss-interop-fixtures.sh | Fetch+pin DSS interop vectors (never commit).|
| cbom.json | CycloneDX crypto BOM (verify-only). |
+--------------------------------------------------------+----------------------------------------------+
signatures[].vrt, see the timestamp / VRT chapter (Chapter 12) and Chapter 14 (report derivation / Result Model).The closed-enum discipline in §18.3 and the lineage in §18.4 are the auditor's two anchors: any value seen in a report must appear in §18.3, and any schema_version seen must appear in §18.4. A value or version outside those tables is, by construction, not something a pverify binary at this lineage can emit.