pverify — Detailed Design Specification

A GPKI/eIDAS-aware Advanced Electronic Signature (AdES) verifier

Release
v1.7.5
Report schema
1.12.0
Commit
28bf7fa
Status
Authoritative
Build date
2026-07-17

1. Introduction, Scope & Design Goals

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.


1.1 What pverify Is

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

1.1.1 The "Cannot Affirm" Discipline

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:

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.


1.2 Target Users

pverify serves three concentric audiences, as set out in the Vision Statement (docs/vision-statement.md §3):

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

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

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


1.3 Scope: What pverify Verifies

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.

1.3.1 Supported Input Formats

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

1.3.2 Supported AdES Baseline Levels

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

1.3.3 Refusal Behaviour for Unsupported Variants

The honest refusals are first-class report outcomes, each carrying a closed- enum reason:

1.3.4 Cryptographic Primitives Supported

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:

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.

1.3.5 The Standards pverify Treats as Normative

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:


1.4 Out of Scope (Explicit Non-Goals)

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.


1.5 Design Goals — the Pure-Rust + WASM Dual-Target Stance

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:

1.5.1 Library-First, I/O-Free Core (Constitution §V)

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.

1.5.2 No Document Parsing in the Core (Constitution §V)

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.

1.5.3 WASM Compatibility (Constitution §VI, NON-NEGOTIABLE)

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.

1.5.4 CLI ↔︎ WASM Parity

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.

1.5.5 Reproducibility and Byte-Identity (Constitution §II)

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.

1.5.6 The Schema as a Closed Promise

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.md is a historical artefact pinned at the v0.7 development line; its running text states schema_version stays "1.1.0" and the capability phase "v0.6-bridge-acceptance". The authoritative live value is the SCHEMA_VERSION constant in report/schema.rs ("1.10.0"), whose doc-comment narrates each bump from 1.0.0 through 1.10.0. Readers should treat the source constant, not the README, as normative for the current release.


1.6 Cross-Cutting Invariants at a Glance

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.

diagram

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

1.7 Reader's Guide to the Rest of the Document

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.

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.

2. Design Principles & Project Constitution

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.


2.1 The Constitution as a Versioned, Testable Contract

The constitution is not a vision statement; it is a versioned contract under its own Semantic Versioning regime (Governance → Versioning policy):

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.

diagram


2.2 §I Fact-Reporting / "Cannot Affirm" (NON-NEGOTIABLE)

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

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.

2.2.1 Enumeration over verdict

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.

2.2.2 "Cannot affirm": INDETERMINATE is a first-class result

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:497cades_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.


2.3 §II Reproducibility & Verdict Byte-Identity

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.

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.

2.3.1 Injectable time, captured once

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.

2.3.2 Trait-mediated I/O ⇒ --from-bundle reproducibility

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

2.3.3 Verdict byte-identity on additive / off features

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:

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

2.3.4 No ambient non-determinism

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.


2.4 §III Auditability — The Verification Bundle

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.


2.5 §IV Specification-Compliance (NON-NEGOTIABLE)

IV. Specification-Compliance (NON-NEGOTIABLE)

The implementation treats the following as primary normative references:

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.

2.5.1 The standards-to-module map (orientation)

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/

2.5.2 Honest AdES-level vocabulary

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


2.6 §V Library-First & §VI WASM Compatibility (NON-NEGOTIABLE)

These two principles are the architectural keystone and are best read together.

V. Library-First

pverify-core MUST be implemented as an I/O-free no_std + alloc library 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-core MUST compile and run on wasm32-unknown-unknown.

2.6.1 The kernel is no_std, unsafe-free, I/O-free

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

2.6.2 No container parsing in the core

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.

2.6.3 The empirical guards

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:

  1. Compile-to-WASM gate. 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.
  2. 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.

2.6.4 CLI↔︎WASM parity

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


2.7 §VII Offline-First & Offline Honesty

VII. Offline-First

The CLI MUST support an --offline mode 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 for verify --from-bundle is 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).


2.8 Algorithm-Policy Enforcement Scope (Constitutional Surface)

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:

  1. Opt-in, default-off. The mode is enabled only by --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.
  2. VRT-scoped. Each object the per-object VRT engine (031) derives a reference time for — signer chain, signer signature, and each signature / archive / document timestamp — is judged against the policy effective as of that object's VRT value, not against wall-clock time. "Unacceptable on/after D" means the object passes iff its VRT date is strictly before D.
  3. Degrades to INDETERMINATE, never TOTAL_FAILED. A failing or unaffirmable algorithm result yields INDETERMINATE with 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.


2.9 The Phased Path Validation Roadmap & Mandatory Disclosure

§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 true only 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).


2.10 Additive Schema Versioning

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:

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.


2.11 Cross-Cutting Invariants (Reference)

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.

diagram

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

2.11.1 The never-mask discipline (invariant 16, expanded)

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


2.12 Out of Scope (until v1.0) and Governance Bounds

The constitution closes with an explicit Out-of-Scope list; bringing any item in is, by the versioning policy, a MAJOR amendment:

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.

3. System Architecture

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.

3.1 Architectural intent and the central boundary

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

3.1.1 Why the boundary exists

Three properties fall out of the boundary, each of which the government customer can independently verify:

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

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

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

3.1.2 "Cannot affirm" discipline

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:

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

3.2 Crate topology

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.

3.2.1 The two graph classes

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

Host-only graph (native std, must NOT appear in the core/WASM runtime graph):

diagram

The dashed arrow CLI -. dev-dep .-> TESTH is a dev-dependency only; it is asserted absent from the production cargo tree -e normal graph.

3.2.2 The tree gate

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.

3.3 The host vs. WASM-clean-core boundary in detail

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.

3.3.1 The trait-mediated capability boundary

crates/pverify-core/src/traits.rs defines the three host capabilities and a pair of minimal value types:

The crate also ships skeleton implementations OfflineRevocationFetcher (the canonical "always denies" fetcher) and InMemoryTrustAnchorStore, which the CLI and WASM hosts wrap or populate.

3.4 Native CLI execution model

The native verifier is pverify-cli. Its command surface (crates/pverify-cli/src/main.rs, enum Command) covers four sub-commands:

The verify path (Command::Verify, main.rs:346) operates as follows:

  1. Read inputs and capture time. Read the signature file and optional detached content from disk; capture the verification time once at startup from the host Clock (src/clock.rs) or from --at. --at always wins (§II reproducibility).
  2. Select a mode. One of --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).
  3. Load trust anchors. 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.
  4. Host-parse the container. If the input is a PDF, 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.
  5. Assemble and call. Fill a VerificationRequest and call verify_with(&request, &store, &fetcher).
  6. Render. 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.

3.5 Browser / WebAssembly execution model

The browser host is web/pverify-wasm (web/pverify-wasm/src/lib.rs). It exposes two #[wasm_bindgen] entry points:

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:

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

diagram

3.6 Module layout of pverify-core

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

3.7 From VerificationRequest to Report: end-to-end flow

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

3.7.1 Stage 0/1 — host parse and request assembly

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.

3.7.2 Stage 2 — format dispatch

verify_with first initialises a header via Report::new_header. It then dispatches in this precedence order (verify.rs:189-327):

  1. ASiC takes precedence. If 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.
  2. Bare ZIP, no ASiC content. If 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).
  3. Content sniff. Otherwise 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.

3.7.3 Stage 3 — per-signature verification (CAdES reference)

verify_cades (verify.rs:475) is the reference pipeline; the other formats reuse its phases through shared helpers. In order:

  1. Parse the CMS SignedData with the lenient walker (SignedDataParser::parse).
  2. Refuse multi-signer CMS honestly: if 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.
  3. Reconstitute the embedded certificate DER blobs and append any host-supplied extra_certificates (consumed for chain construction but never selected as the signer).
  4. Select the signer by 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.
  5. Validate the path (RFC 5280 §6 structural checks + chain construction, path/mod.rs), then verify each chain step's certificate signature with RustCrypto via wire_chain_step_signatures (verify.rs:1944).
  6. Project the unsigned attributes (T/LT/LTA: signature-time-stamps, archive-time-stamp-v3, certificate-values, revocation-values CRL/OCSP) and compute the format-promotion ladder (verify.rs:1034-1042): archive timestamps present → CAdESLta; any LT payload → CAdESLt; signature time-stamps present → CAdEST; otherwise CAdESBes.
  7. Apply revocation to the signer chain (apply_revocations).
  8. Verify the signedAttrs digest and signature and the content digest; check the ESS signing-certificate binding (RFC 5035) and the content-type binding.

3.7.4 Stage 4 — per-object VRT

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.

3.7.5 Stage 5 — indication aggregation

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.

3.7.6 Stage 6 — report serialisation and the validation-object inventory

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.

3.8 The PAdES /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:

3.9 AdES level vocabulary supported by the architecture

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.

3.10 Cross-cutting invariants the architecture guarantees

The following invariants are properties of the architecture itself rather than of any one module, and each is mechanically or test-enforced:

  1. WASM-clean core (§VI, NON-NEGOTIABLE). 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.
  2. No container parse in core (§V). All PDF/XML/ZIP/JSON parsing lives in host crates; the kernel never links lopdf/zip/flate2/bergshamra — gate-asserted.
  3. I/O-free, trait-mediated boundary. Clock, revocation, and anchors are the only host concerns the kernel reaches for, and only through traits.rs. derive_vrt and derive_validation_objects are pure compute.
  4. Additive schema versioning. 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.
  5. Verdict byte-identity on additive/off features (§II). An additive feature leaves the report body byte-identical except schema_version when its inputs are absent or it is off (e.g. a no-DSS PDF, algorithm-policy off, empty embedded-revocation channels).
  6. Offline honesty (§VII / §I). --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.
  7. CLI↔︎WASM parity. Both hosts run byte-identical extractors and the identical verify_with; parity tests pin the extractor output shapes.
  8. Cannot affirm (§I, NON-NEGOTIABLE). Unestablished facts degrade to INDETERMINATE with a closed-enum sub-indication.
  9. Indication precedence / never-mask. aggregate_etsi sets a severity-ordered base; later layers only degrade a non-TOTAL_FAILED base.
  10. Per-object VRT separation. Each object is judged at its own derived VRT via a pure recursive-outer-covering engine reading only request_at; the covering graph is never re-derived or mutated downstream.
  11. Host ingestion produces ordinary anchors. pverify-eutl/aatl/ anchor-inventory emit ordinary TrustAnchor values re-feedable through --trust-anchors; ingestion performs no network I/O at parse time.
  12. Reproducible time capture (§II). The CLI captures the verification time once at startup unless --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.

4. Input Classification & Format Dispatch

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

4.1 Two-layer classification

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:

  1. 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 runlopdf 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.

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

diagram

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.

4.2 The kernel sniff: detect_format

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

4.2.1 The marker predicates

Format Predicate Accepts Evidence
PDF 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:

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.

4.3 Per-format dispatch in verify_with

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

4.3.1 PAdES arm (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).

4.3.2 XAdES arm (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.

4.3.3 JAdES arm (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.

4.3.4 CAdES arm (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.

4.4 Detached versus attached content

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

4.5 ASiC-S / ASiC-E detection and the ZIP precedence rule

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.

4.5.1 Host: ZIP-first extraction

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

4.5.2 Kernel: ASiC channel precedence, then ZIP-magic interception

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:

  1. 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).
  2. ZIP magic with no ASiC signatures (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).

4.5.3 Profile and family detection inside the container

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

diagram

4.6 After dispatch: aggregation is uniform across arms

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.

4.7 Failure modes and the "cannot affirm" discipline

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.

4.8 Summary of invariants

5. CMS / CAdES Verification

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.

5.1 Scope and AdES Level Vocabulary

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.

5.2 The CMS SignedData Structural Parser

5.2.1 Design: a permissive TLV walker, not a typed decoder

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

5.2.2 Field discrimination rules and their subtleties

Within the SignedData SEQUENCE, extract_signed_data_components walks the children in declared order with a small state machine:

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.

5.2.3 Failure behaviour

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

5.3 SignedAttrs Canonicalisation and the messageDigest Check

5.3.1 SignedAttrs canonicalisation (RFC 5652 §5.4)

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

5.3.2 The messageDigest comparison (RFC 5652 §11.2)

verify_content_digest (crates/pverify-core/src/cms/verify.rs):

  1. Reads the digest algorithm from 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).
  2. Computes 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).
  3. Walks signedAttrs for the messageDigest attribute (1.2.840.113549.1.9.4) via find_message_digest_attribute and reads its OCTET STRING value.
  4. Compares, producing a 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).

5.3.3 The SignedAttrs signature check

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

5.4 CAdES Orchestration (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.

5.4.1 Stage sequence

diagram

5.4.2 Certificate reconstitution and content resolution

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.

5.4.3 Unsigned-attribute projection (T/LT/LTA material)

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.

5.4.4 Multi-signer refusal

When signer_info_count > 1, verify_cades returns cades_multi_signer_entryINDETERMINATE 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".

5.5 Signer Identification and ESS Binding (slice 025)

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.

5.5.1 SignerIdentifier resolution

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:

select_signer_by_sid (crates/pverify-core/src/verify.rs:1096) then resolves the index only within the first cms_cert_count embedded certs:

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.

5.5.2 ESS signing-certificate binding (RFC 5035)

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:

Three not-evaluable / malformed dispositions (all Constitution §I "cannot affirm"):

5.6 Content-Type Binding (slice 026)

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.

5.7 Timestamps, Per-Object VRT, and Indication Aggregation

5.7.1 Signature-time-stamps (B-T)

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.

5.7.2 Archive-time-stamps-v3 (B-LTA)

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.

5.7.3 Per-object VRT and TSA-chain revocation

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

5.7.4 Base ETSI indication aggregation

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

  1. Path-validation structural TOTAL_FAILED (e.g. chain_constraints_failure).
  2. archive_timestamp_imprint_mismatch (TOTAL_FAILED).
  3. OCSP responder signature verifies-and-fails (ocsp_responder_signature_invalid, TOTAL_FAILED).
  4. SignedAttrs signature failure — routed by the typed cause: OtherTOTAL_FAILED / signed_attrs_signature_failed; UnsupportedAlgorithmINDETERMINATE / signature_algorithm_unsupported; MalformedPublicKeyINDETERMINATE / public_key_malformed.
  5. Content digest mismatch (only when content was supplied) → TOTAL_FAILED / messageDigest_mismatch.
  6. Signer-cert revoked on CRL/OCSP → TOTAL_FAILED / signer_certificate_revoked[_via_ocsp].
  7. Archive imprint under unsupported algorithm, malformed-public-key chain step, unsupported chain signatureAlgorithm, Name-Constraints findings, policy-rejected → various 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.

5.7.5 Layered indications (never masking a proven TOTAL_FAILED)

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:

diagram

  1. Signer-binding (025)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.
  2. Content-type (026)apply_content_type_indication (:1441): mismatch/missing → TOTAL_FAILED; malformed/unavailable → INDETERMINATE.
  3. Algorithm-validity (032, opt-in)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).
  4. TSA-revocation (033)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.

5.8 Closed Vocabulary and Cross-Format Reuse

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.

6. PAdES Verification

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.

6.1 Scope and AdES-level support

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

6.1.1 Multi-signer refusal

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.

6.2 Host/core split: where PDF parsing happens

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:

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

Format dispatch in verify_with routes a %PDF--sniffed input to crate::pades::verify_pdf, passing both fields (verify.rs:241247). 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.

diagram

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.

6.2.1 The PdfSignatureDescriptor projection

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

6.3 ByteRange integrity and the covers-EOF check

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:

  1. Malformed ByteRangeTOTAL_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.

  2. ByteRange does not cover EOFTOTAL_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).

6.4 Incremental-update revisions and /DocTimeStamp

PDF 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):

  1. The covers-EOF test required every signature to cover the final file length. Revision 1's signature, covering only revision-1's %%EOF, failed.
  2. The extractor honoured only /Type /Sig and silently dropped /Type /DocTimeStamp, so the document timestamp that defines revision 2's extent was invisible.

6.4.1 Revision-boundary discovery

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.

6.4.2 The revision-aware covers-EOF rule

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:

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.

6.4.3 DocTimeStamp processing

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

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

6.4.4 Per-object VRT second pass

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.

6.5 Document-level validation material: /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.

6.5.1 The three material sources

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.

6.5.2 Host-side /DSS+/VRI extraction

extract_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:

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.

6.5.3 Per-object resolution: VRI-preferred, DSS-global-fallback

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

diagram

6.5.4 Signer-chain consumption

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

  1. DSS /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.
  2. Adobe 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.
  3. The merged CRL/OCSP DER feed the existing apply_revocations call (crates/pverify-core/src/pades/mod.rs, the apply_revocations(..., &signer_ocsps, &signer_crls, mode) call that previously received &[], &[]).
  4. Provenance tagging. After revocation runs, 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_urisignature_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).

6.5.5 TSA-chain consumption (each timestamp, at its own VRT)

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:

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.

6.5.6 Additive-fallback channel discipline

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:

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.

6.5.7 Zero new verdict logic

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

The only additive wire surface is provenance, described next.

6.5.8 Wire-shape changes and schema version

The 034 wire surface is purely additive provenance, mirrored 1:1 into the root report-schema.json:

SCHEMA_VERSION bumped 1.5.01.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.

6.5.9 End-to-end flow

diagram

6.6 The RFC 5280 §6.1.5 empty-policy-tree relaxation

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:

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.

6.7 Invariants and failure modes (summary)

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

6.8 Cross-references

7. XAdES & ASiC Verification

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.

7.1 The XAdES integration boundary

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.

diagram

7.2 Canonicalization

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

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

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

7.3 XAdES B‑B verification

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 == NoneINDETERMINATE 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.

7.3.1 Algorithm mapping

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.

7.4 Profile classification (enveloped vs detached)

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:

  1. If the CanonicalizationMethod is not Exclusive C14N → Unsupported{NonExclusiveC14n}.
  2. Else if a resolver was supplied (the ASiC case, §7.7) and a non‑fragment ds:Reference URI was resolved to a ZIP entry → DetachedResolved.
  3. Else if no URI="" + enveloped‑signature reference is present → Unsupported{DetachedOrEnveloping}.
  4. Else → 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).

7.5 XAdES B‑T and B‑LT timestamps

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.

7.5.1 XAdES B‑LTA: 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:

Out of scope:

RefsOnlyTimeStamp remains surfaced present‑but‑unverified (no imprint recomputed) as before.

7.6 Algorithm‑policy evaluation for XAdES

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.

7.7 ASiC container structure

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.

diagram

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.

7.8 ASiC profile detection and conformance deviations

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.

7.9 ASiC payload verification

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

  1. Step 1 — the CAdES signs the ASiCManifest XML bytes verbatim. The kernel calls verify_cades_detached with manifest_bytes as the detached content.
  2. Step 2 — each <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_mismatcheven 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.

7.10 Outcome vocabulary and invariants

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:

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.

8. JAdES Verification

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

8.1 Scope: what is verified and what is refused

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

  1. The cryptographic fact: does the JWS signature verify over the reconstructed JWS Signing Input, using the signer certificate's public key?
  2. The AdES-level claim: is the input a JAdES-B-B signature — that is, does it carry the JAdES baseline signed properties on top of a plain JWS?

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/tstVdjades_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.

8.2 Format dispatch and content sniff

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.

diagram

8.3 The extraction boundary: JadesComponents

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

8.4 JWS Signing Input reconstruction (RFC 7515 / RFC 7797)

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

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.

8.5 Signer certificate and signing-certificate binding

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

8.6 Algorithm mapping (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.

8.7 Profile classification

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:

diagram

The kernel maps these reasons to sub-indications in verify_jades step 1 (jades.rs:164-180): NotJadesBaselineJadesUnsupportedProfile; DetachedSigD and CompactSerializationJadesUnsupportedSerialization. HigherLevel now fires only for rfsTst/tstVdJadesUnsupportedProfile. 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.

8.8 Kernel verification: verify_jades

crate::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:

diagram

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.

8.9 ETSI indication aggregation and per-object VRT

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:

8.10 Provenance, parity and reproducibility

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.

8.11 Standards traceability

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.

8.13 Branch 037: JAdES B-T / B-LT / B-LTA implementation

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.

8.13.1 sigTst (B-T): signature timestamp

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.

8.13.2 xVals / rVals (B-LT): embedded validation material

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.

8.13.3 arcTst (B-LTA): archive timestamp with non-circular hash input

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.

8.13.4 Scope: what remains out of scope

The following are explicitly out of scope for branch 037 and remain refused with jades_unsupported_profile / INDETERMINATE:

8.12 Experimental: LTV-JWS (draft-miyachi-ltv-jws-00)

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.

9. X.509 Certification Path Validation

Release: v1.3.0 · Report schema: 1.9.0

9.1 Scope and Position in the Pipeline

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

9.1.1 The Two Halves of RFC 5280 §6, and an Honest Scope Statement

RFC 5280 §6 path validation has two distinct halves:

  1. Structural validation — name chaining (issuer↔︎subject), validity windows, 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.
  2. Cryptographic validation — verifying that each certificate's 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.

9.2 Entry Point and Outcome Shape

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.

9.3 Chain Construction (DFS + Backtrack)

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.

9.3.1 The Walk

diagram

Key invariants of construct_chain:

9.3.2 Cycle Detection

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.

9.3.3 Output of Construction

construct_chain returns (chain, bridge_pairs, terminating_anchor):

9.4 Validity, BasicConstraints and KeyUsage Checks

After construction, validate_path walks chain leaf-first (path/mod.rs:144) and records the structural facts and findings per step.

9.4.1 Validity-at-Time (FR-008 / FR-025)

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.

9.4.2 BasicConstraints (RFC 5280 §4.2.1.9)

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.

9.4.3 KeyUsage (RFC 5280 §4.2.1.3)

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.

9.5 Name Constraints (RFC 5280 §4.2.1.10 / §6.1.4(g))

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:

  1. Initialises the state at depth 0 from the anchor's own 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.
  2. Walks closest-to-anchor → leaf, calling 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).

9.5.1 State Composition

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":

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

9.5.2 Per-Form Matching (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:

9.5.3 Self-Issued Carve-Out (Q2)

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

9.5.4 Outcome → Indication Mapping

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

9.6 Certificate Policies (RFC 5280 §6.1)

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.

9.6.1 Extensions Processed (FR-051)

9.6.2 Initialisation and the State Walk

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

9.6.3 Leaf Wrap-Up (§6.1.5) and the GPKI Empty-Tree Fix

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 > 0 or the valid_policy_tree is non-NULL. An empty tree is therefore a rejection only when requireExplicitPolicy fired (modelled as explicit_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).

9.6.4 Outcome → Indication Mapping

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.

9.7 Bridge-CA / Multi-Anchor Cross-Certificate Traversal

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.

9.7.1 Crossing Detection

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:

  1. Foreign signs Domestic — the intermediate's subject is one of our anchors but its issuer is a CA we do not hold (!issuer_in_anchors && subject_in_anchors).
  2. Domestic signs Foreign — the intermediate's issuer is one of our anchors but its subject is outside our trust set (issuer_in_anchors && !subject_in_anchors).

Both return the crossing pair [intermediate_fp, anchor_side_fp] for the BridgeAttempt.crossing_pair field.

9.7.2 Traversal in the DFS Walker (FR-601)

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

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.

9.7.3 Bridge-Attempt Verdicts (Post-Hoc)

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

9.7.4 The Retired bridge_ca_required_unsupported

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

9.8 Cryptographic Step Verification (Wired Outside 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:

  1. Locates the parent SPKI — the next step's certificate, or the anchor's SPKI for the terminating step (parent_spki_for_step, verify.rs:2037).
  2. Splits the child certificate into (tbsCertificate DER, signatureValue) (split_certificate_for_verification, verify.rs:2063).
  3. Dispatches to 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/.

9.9 How Path Findings Become an ETSI Verdict

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:

  1. Structural 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.
  2. Archive-timestamp imprint mismatch, OCSP responder signature failure, signedAttrs failure, content-digest mismatch, revocation (RevokedOnCrl/RevokedOnOcsp) — these are the CMS/revocation causes layered after the structural TF (see Chapters on CMS, revocation, timestamps).
  3. Unsupported algorithm / malformed key on a chain step — scanned at 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.
  4. Name-constraints and policy 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.

9.10 DN Matching: rfc4518-minimal

All 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):

  1. ASCII case-folding (AZaz).
  2. Whitespace compression — collapse runs of [\t\n\f\r ] to one space, trim leading/trailing.
  3. Encoding unification — decode 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).

9.11 Report Surface (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.

9.12 Summary of Invariants and Failure Modes

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.

10. Trust Anchor Management

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.

10.1 The Anchor Model: Kernel Holds None, Host Supplies All

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:

  1. The kernel cannot smuggle trust. Because 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.
  2. The five identity/routing fields are the entire anchor surface. The kernel does not re-parse the anchor against its source. As 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).
  3. Anchor-validity is the host's responsibility (FR-025). 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.

10.1.1 Establishment vs. verification (RFC 6024)

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.

10.2 Anchor Provenance Channels

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.

10.2.1 The pinned ledger (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

10.2.2 LGPKI2 trust anchor (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.

10.2.3 The PQC-interop lab channel (isolated, not production trust)

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.

10.2.4 The bootstrap/list-signer distinction

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.

10.3 EU Trusted List ingestion (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.

10.3.1 The trust model: signer pin, not RFC 5280 chain

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:

  1. extracts the enveloped ds:Signature via pverify_xades::extract (reusing the same Exclusive C14N engine, bergshamra-c14n, that all XAdES verification uses — Chapter 7);
  2. requires the embedded signer certificate to match one of the pinned signer_certs DER-exact (signer_certs.iter().any(|pin| pin.as_slice() == signer_der));
  3. verifies every ds:Reference digest (content integrity);
  4. verifies the signature value over canonical SignedInfo using pverify_core's RustCrypto primitives (RSA-PSS, RSA PKCS#1 v1.5, ECDSA per the OID resolution in xades_sig_alg_oid);
  5. enforces the 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).

10.3.2 The status-at-time-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):

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.

10.3.3 LOTL traversal with partial success

ingest_lotl drives a full LOTL (List of Trusted Lists) traversal:

diagram

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.

10.3.4 Anchor construction and observed_status

Each 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".

10.4 Adobe Approved Trust List ingestion (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.

10.4.1 Pin-based authentication via the real PAdES pipeline

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:

  1. chain_result.terminating_anchor_fingerprint == hex(SHA-256(pin)) — the chain reached the pinned Adobe Root CA G2;
  2. every chain-step signature Verified (the anchor step being NotApplicableForRoot);
  3. the CMS signed-attributes signature Verified and signed_attrs_digest_match == Some(true);
  4. 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.)

10.4.2 The government-affiliation filter (a documented heuristic, never a verdict)

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:

  1. its <Certificate> body base64-decodes and parses as X.509 (certificate_parse_failed otherwise);
  2. <Trust><Root> is 1 (not_root otherwise);
  3. <Trust><CertifiedDocuments> is 1 (not_certified_documents otherwise);
  4. its subject DN matches the government-affiliation keyword heuristic (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).

10.4.3 Fetch-don't-bundle and the SECOM look-alike caveat

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.

10.4.4 Bootstrapping the list-signer cert from the distribution itself

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.

10.5 The anchor inventory (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.

10.5.1 Channels and dedup

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.

10.5.2 §I segregation of excluded identities

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

10.5.3 Self-checking invariants

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:

10.5.4 Chain-reach: a deliberately conservative one-hop view

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:

This is not path validation and makes no trust claim; it is an inspection aid over already-distributed bytes.

10.5.5 Rendering: provenance grouping and large-channel collapse

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.

10.6 The flat-N (Trusted List) vs. bridge/hierarchy trust models

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.

diagram

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.

10.7 What is NOT an anchor

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.

10.7.1 PAdES /DSS /Certs are never anchors

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

10.7.2 Synthetic fixture roots

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.

10.7.3 Ingestion bootstrap signers

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

10.8 Re-feeding ingested anchors and the CLI surface

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.

11. Revocation Checking

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.

11.1 Where Revocation Sits in the Pipeline

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:

diagram

11.2 The Precedence Channels: CDP-first, OCSP-fallback, Embedded-only-on-indeterminate

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:

  1. 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 definitiveGoodOnCrl or RevokedOnCrl — it is returned verbatim and no further channel is consulted (revocation/mod.rs:208).

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

  3. 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 ocspValsrevocation/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.

diagram

11.3 CRL Processing (RFC 5280)

11.3.1 Distribution-point classification

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 IndeterminateOcspOnlyrevocation/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.

11.3.2 Parsing the CertificateList

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.

11.3.3 Indirect-CRL refusal

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:

  1. its IssuingDistributionPoint extension (2.5.29.28) asserts indirectCRL = TRUE; or
  2. its Issuer 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.

11.3.4 Signature, freshness and serial lookup

After the indirect check, consume_crl (revocation/mod.rs:384) performs, in order:

  1. Signature verificationverify_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.

  2. Freshness (revocation/mod.rs:438):

    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.

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

11.4 LDAP-CRL Retrieval (012-ldap-crl)

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:

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.

11.5 OCSP Processing (RFC 6960)

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.

11.5.1 Source order and request construction

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

11.5.2 The per-response evaluation pipeline

Each candidate response runs through evaluate_ocsp_candidate (revocation/mod.rs:752):

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

  2. Locate the matching SingleResponse by CertIDfind_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.)

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

  4. Freshnessevaluate_freshness (ocsp.rs:335), detailed in §11.5.4.

  5. Noncematch_nonce (ocsp.rs:407), detailed in §11.5.5.

  6. Signatureverify_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: goodGoodOnOcsp, revokedRevokedOnOcsp (with revocation time + reason), unknownIndeterminateOcspUnknown.

11.5.3 Responder authorisation (RFC 6960 §4.2.2.2)

validate_responder_authorisation (revocation/mod.rs:1129) implements the RFC 6960 case-split:

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.

11.5.4 Freshness windows (FR-067)

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

11.5.5 Nonce matching (FR-068)

match_nonce (ocsp.rs:407) applies the following discipline:

Mismatched and NotPresentOnline both route to IndeterminateOcspUnknown (revocation/mod.rs:925) — pverify refuses an online response it cannot correlate to its own request.

11.5.6 The OCSP attempt record

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.

11.6 Offline Honesty (012-offline-revocation-honesty)

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.

11.7 The RevocationOutcome Vocabulary

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

11.8 What Revocation Does Not Do

For completeness and to bound auditor expectations:

12. Timestamps & Per-Object Validation Reference Time

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


12.1 Scope and standards mapping

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.


12.2 The single-token verifier: verify_timestamp_token

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

12.2.1 Signature

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

12.2.2 Internal sequence

verify_timestamp_token performs, in order (timestamp.rs:132-319):

  1. CMS parse + content-type gate. SignedDataParser::parse decodes the outer CMS; eContentType is checked against id-ct-TSTInfo. A mismatch is a hard parse error.
  2. 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.
  3. GenTime decode. 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.
  4. TSA cert selection. The embedded certificates are reconstituted to full DER (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.
  5. TSA CMS signature + messageDigest. 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).
  6. Imprint recomputation (only when message_imprint_input == Some(_)). See §12.3.
  7. TSA chain validation. 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).
  8. Cert-signature wiring. 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.

12.2.3 The message_imprint_match semantics

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

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_token returns Result; a malformed token (Err) is dropped by the caller rather than fabricating a failed token. The verdict that flows from a dropped or false-flagged token is INDETERMINATE, never a forged-token TOTAL_FAILED. This is Constitution §I applied to timestamps.


12.3 Message-imprint recomputation and the archive imprint record

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 Mismatch is 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 the archive_timestamp_imprint_mismatch precedence in aggregate_etsi (Chapter 5 §5.7); an UnsupportedAlgorithm is INDETERMINATE (cannot affirm).

12.3.1 XAdES B-LTA ArchiveTimeStamp imprint (036-xades-blta)

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


12.4 The per-object Validation Reference Time problem

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:

diagram

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


12.5 The covering graph (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.

12.5.1 Node kinds and per-node flags

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:

12.5.2 Edge construction (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):

  1. Index 0 is always the SignerSignature pseudo-node.
  2. Signature-TS nodes are pushed in document order; their outer cover is patched to archive_timestamps[0] if any archive TS exists, else None.
  3. Archive-TS nodes are pushed in outermost-last order; archive[i]'s outer cover is archive[i+1] (each later token covers the earlier ones). The outermost archive TS has outer_cover == None.
  4. The signer node's outer cover is 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).


12.6 The promotion gate and recursive walk (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.

12.6.1 The FR-002a eligibility clauses

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

  1. it is not the signer pseudo-node (the signer is not a TST and cannot be a promotion source — returns Ok(false));
  2. clause 1gen_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);
  3. clause 2token_internally_valid is true (TSA signature + messageDigest OK) and imprint_status != Some(false) (the archive imprint, if recomputed, matched);
  4. clause 3tsa_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.

12.6.2 The recursive walk

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:

12.6.3 Demotion reasons

When a candidate covering TS is ineligible, diagnose_ineligible (promotion.rs:109) synthesises the reason from the outer's flags, in priority order: FutureGentimeImprintMismatchTokenParseFailedTsaChainUnanchored → (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.


12.7 The derive_vrt entry point and the wire shape

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

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


12.8 Wiring the VRT back into path validation and revocation

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:

diagram

12.8.1 First pass

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

12.8.2 Deriving the VRT

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

12.8.3 Second pass — re-judge each object at its VRT

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.


12.9 Consistent TSA-certificate revocation (branch 033)

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.

12.9.1 The shared helper

apply_tsa_chain_revocation_for_token (verify.rs:1552-1603):

  1. Locates the TSA leaf among the embedded certs by RFC 4514 subject-DN match against 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").
  2. Re-validates the TSA path at the object's VRT (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).
  3. Overwrites 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).

12.9.2 The verdict layer — revoked_no_poe

apply_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:

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.


12.10 Cross-format symmetry and invariants

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:


12.11 Worked example: CAdES-LTA past signer-cert expiry

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.

  1. First pass. Both timestamps verify; their gen_time and imprint_record are captured. The signer chain validated at --at would report INDETERMINATE / signing_certificate_expired.
  2. Graph. signer ← signature-TS ← archive-TS. The signature-TS GenTime (2024-03-01) ≤ --at, is internally valid, and its TSA chain anchors, so it is an eligible promotion source.
  3. 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.
  4. Second pass. The signer chain is re-validated at 2024-03-01inside the cert validity window — yielding TOTAL_PASSED. Each TSA chain is revocation-checked at its own VRT via apply_tsa_chain_revocation_for_token.
  5. Report. 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.

13. Algorithm Validity Policy

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

13.1 Design principles and the "cannot affirm" posture

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.

13.2 The policy model

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,
}

13.2.1 Closed enums

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.

13.2.2 Rule types and date semantics

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:56policy.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:

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.

13.2.3 Fail-closed deserialization

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.

13.3 The built-in default policy

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.

13.4 The pure-compute engine

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

13.4.1 ObjectUnderEvaluation — the engine input

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
}

13.4.2 Per-object evaluation: two sub-checks, worst-of composition

evaluate_one (mod.rs:264) runs two independent sub-checks and composes them worst-of:

Digest sub-check (check_digest, mod.rs:130):

Signature/key sub-check (check_signature, mod.rs:161):

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.

diagram

13.4.3 unrecognised_treatment

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.

13.5 Extraction: from parsed bytes to policy enums

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.

13.6 Object assembly per format

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.

13.6.1 The CMS assembler

assemble_and_evaluate_cms_objects (crates/pverify-core/src/verify.rs:1671) builds the object set in a deterministic order:

  1. 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.
  2. 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.
  3. 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).
  4. 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).

13.6.2 Cross-format coverage

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

13.7 Indication layering

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:

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.

13.8 The report block

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.

13.8.1 Relationship to 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.

13.9 Host wiring and parity

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.

13.10 Worked examples

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.

13.11 Supplying a stricter policy

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

13.12 Schema, versioning and constitutional governance

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.

13.13 Scope and deferred follow-ups

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.

14. Result Model: Indications, Diagnostic Data & Schema

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.

14.1 Design constraints on the report

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:

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

14.2 Report top-level shape

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

14.2.1 Stored-report deserialisation and the VRT fallback

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

14.3 ETSI EN 319 102-1 main indications

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

14.3.1 Indication aggregation and the never-mask discipline

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

  1. apply_signer_binding_indication (verify.rs:1324) — ESS signing-certificate binding.
  2. apply_content_type_indication (verify.rs:1441) — RFC 5652 §5.3 content-type equality.
  3. 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).
  4. 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.

14.4 Sub-indication vocabulary

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.

14.4.1 TOTAL_FAILED sub-indications (proven negative)

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.

14.4.2 INDETERMINATE sub-indications (cannot affirm)

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.

14.5 validation_objects[] — the material inventory and its provenance

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

14.5.1 Determinism of the inventory

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

14.5.2 ValidationObjectOrigin and the 034 PAdES additions

ValidationObjectOrigin (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_bundlebundle; a CRL with no source_urisignature_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.

14.5.3 ValidationObjectUsage cross-references

When present, usage[] records what role a material played (branch 016 US2). It is an internally-tagged enum on kind (ValidationObjectUsage, validation_objects.rs):

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.

14.6 Diagnostic data per signature

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

14.6.1 AdES level vocabulary surfaced in format

SignatureFormat (report/mod.rs) is the honest scope declaration. The emitted values, and what each implies about what pverify actually verified, are:

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.

14.6.2 Revocation, OCSP and timestamp diagnostic records

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

14.7 The report-schema.json contract

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

14.8 Additive-MINOR schema versioning (now 1.10.0)

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:

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.

14.9 CLI exit-code semantics and the relying-party verdict map

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 0 as signature validity. The verdict is signatures[*].etsi_indication.indication in the JSON output.

This is restated verbatim in the JNSA conformance statement (docs/jnsa-conformance-statement.md, the Exit-code caveat).

14.9.1 The relying-party verdict map (JNSA ⇄ ETSI)

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.

14.9.2 Why this decoupling is the right discipline

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.

15. Security Architecture

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:

  1. The threat model for processing untrusted signed documents (§15.1).
  2. Network egress control & the SSRF net-guard (021-cli-net-guard) — the address classifier, resolve-then-pin connection discipline, and per-hop redirect guarding (§15.2).
  3. Input size caps — bounded file reads, fetch-body ceilings, and ZIP/XML resource budgets (§15.3).
  4. The BER parse budget — depth, node-count and length-octet bounds on the hand-rolled TLV decoder (§15.4).
  5. The revocation-host allowlist in the web edge proxy, contrasted with the CLI's address-based model, and the residual-risk register (§15.5–§15.6).

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.


15.1 Threat Model for Untrusted Signed Documents

15.1.1 Trust 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

15.1.2 Security invariants

The threat model is enforced by a small set of cross-cutting invariants that each control instantiates. They are stated here in adversarial form:

15.1.3 Provenance of the controls

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:

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:


15.2 Network Egress Control & the SSRF Net-Guard

15.2.1 The exposure

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

15.2.2 The address classifier

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:

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

15.2.3 Resolve-then-pin

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:

  1. Resolves the netloc with to_socket_addrs(). An empty result is fail-closed: the fetch is blocked (BlockReason::Unresolvable) and an io::Error is returned.
  2. Applies the multi-address rule: if any resolved 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).
  3. Returns only the vetted addresses to ureq, which then connects to exactly those 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.

15.2.4 Per-hop redirect guarding

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

diagram

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:

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

15.2.5 The escape hatch and operator responsibilities

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

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.


15.3 Input Size Caps

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

15.3.1 Bounded file reads (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:

  1. A cheap metadata().len() pre-check rejects an oversized regular file before any allocation.
  2. The real guard is 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.

15.3.2 Fetch-body ceilings

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.

15.3.3 ASiC ZIP resource budget

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

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

15.3.4 XAdES signature-count cap

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.


15.4 The BER Parse Budget

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.

15.4.1 Depth, node-count and length bounds

parse_ber_tlv seeds a BerBudget { depth, nodes } and threads it through the recursion:

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

15.4.2 Length-octet bounds

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.


15.5 The Revocation-Host Allowlist (Web Edge)

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

15.5.1 Per-hop redirect guard (issue #120 Medium)

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.

15.5.2 Cross-site request guard (issue #120 Medium)

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.


15.6 Residual Risks and Hardening Roadmap

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"):

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.


15.7 Browser Trust-Boundary Controls (issue #120)

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.

15.7.1 Trust-anchor opt-in for user-supplied certificates

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:

  1. buildUploadRequest() in app.js only adds self-signed certificates to trust_anchors when the #custom-trust-toggle checkbox is explicitly checked by the user.
  2. When custom trust is active and self-signed certificates are present, the verdict block renders a prominently styled warning banner listing the names of the user-supplied anchors.
  3. Toggling the checkbox re-triggers 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.

15.7.2 Algorithm-validity policy in the browser

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

15.7.3 Content Security Policy

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.

16. Supply Chain & Release Engineering

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:

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.


16.1 The supply-chain threat model and what each artefact answers

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.


16.2 The release pipeline (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.

16.2.1 Least-privilege token posture

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 publishprovenance, 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.

16.2.2 Job DAG

diagram

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.

16.2.3 The binary build matrix (FR-014)

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.

16.2.4 The source archive and CBOM version injection (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).

16.2.5 The WASM artefact (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.

16.2.6 The dependency SBOM (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").

16.2.7 Subject digest computation and the release assembly

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


16.3 SLSA v1.0 Build-L3 provenance

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

16.3.1 What Build-L3 guarantees here

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

16.3.2 The private-repository transparency caveat (auditor-relevant)

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/pverify becomes 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.


16.4 The CBOM and its drift gate (013 US1, the MVP)

16.4.1 What the CBOM is

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.

16.4.2 The single source of truth (FR-006)

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:

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.

16.4.3 The RSA-PSS carve-out

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.

16.4.4 The drift gate — cargo xtask cbom-check

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

  1. Parse 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).
  2. Read the code-side sets from SUPPORTED_SIGNATURE_OIDS / SUPPORTED_HASH_OIDS.
  3. Emit a discrepancy for each of:
  4. Any non-empty discrepancy list is a 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.


16.5 The dependency SBOM

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:

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


16.6 Typed BOM attestations (024)

16.6.1 Motivation

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

16.6.2 Mechanism — GitHub Artifact Attestations

Each BOM is attested with actions/attest-sbom@v2:

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

16.6.3 Trust model and the second transparency caveat

The relying-party trust model is documented in docs/release-verification.md (Step 5, "Trust model"):


16.7 The continuous-integration gate battery (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.

16.7.1 release-workflow-lint — validating the pipeline before it fires

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

16.7.2 cargo audit and cargo vet — the two-axis dependency gate

pverify runs both dependency-audit tools, on different axes and at different points in the lifecycle:

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.


16.8 How a relying party verifies a release

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:

diagram

  1. Download the artefacts for the tag (gh release download).
  2. Verify SLSA provenance with 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.
  3. Confirm the SHA-256 sidecars (sha256sum -c) as a convenience cross-check; the authoritative binding remains the provenance in Step 2.
  4. Read the CBOM/SBOM, including the two auditor-facing 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.
  5. Verify the typed BOM attestations with 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.


16.9 Constitutional alignment and non-interference

The supply-chain layer is constructed so it cannot affect the verification kernel:

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.

17. Standards Conformance & JNSA Gap Mapping

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.

17.1 Standards corpus and what pverify claims against each

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.

17.2 AdES level vocabulary — observed structure vs. fully-validated level

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

17.2.1 What is explicitly refused (INDETERMINATE), not failed

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.

17.3 Result-vocabulary and exit-code mapping (JNSA ↔︎ ETSI)

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.

17.4 JNSA conformance matrix (M/E/O)

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.

17.4.1 Common — applies to every signature

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

17.4.2 CAdES

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

17.4.3 XAdES

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

17.4.4 PAdES

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

17.4.5 JAdES

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

17.4.6 ASiC, timestamp tokens, trust anchors

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.

17.4.7 Per-object Validation Reference Time (resolved)

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.

17.5 Algorithm validity (resolved) and the accept-and-flag deviation

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

17.6 PAdES /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:

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

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

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

17.7 TSA-chain revocation consistency (resolved)

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.

17.8 Supplier conformity declaration (resolved Medium)

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

17.9 Residual gaps and intentional deviations

The following are disclosed honestly. Items 1–3 are deliberate design choices, not defects; items 4–5 are genuine residual scope.

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

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

  3. Exit code is not a verdict (§17.3). Documented in the README and the conformance statement.

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

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

17.10 XAdES B-LTA ArchiveTimeStamp imprint (resolved, branch 036)

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.

17.10 Gap-review scorecard

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

18. Appendices: Glossary, Schema Reference & Lineage

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


18.1 Consolidated Glossary

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.

18.1.1 AdES levels and families

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

18.1.2 Path validation, trust and revocation

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.

18.1.3 CMS, XML, JWS and PDF signature mechanics

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.

18.1.4 Report model, indications and provenance

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

18.1.5 Engineering invariants

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.

18.2 Top-Level Report Schema Reference

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

18.2.1 Top-level fields

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

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

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

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

18.2.5 chain_resultChainResult (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.

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

18.2.7 etsi_indicationEtsiIndication (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.

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


18.3 Closed-Enum Catalogues

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.

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

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

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


18.4 Spec / Version Lineage

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.1v0.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

A few lineage subtleties worth an auditor's attention:


18.5 Source-File Map

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.

18.5.1 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.                                |
+-------------------------------------+------------------------------------------------------------+

18.5.2 WASM-clean host-extraction crates (compile to 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.

18.5.3 Host-only crates (native 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.                              |
+--------------------------+------------------------------------------------------------------+

18.5.4 Tooling (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.

18.5.5 Schema and gate artefacts

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

18.6 Cross-Reference Summary

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.