Skip to main content

structured_pades/
timestamp.rs

1//! Complete document timestamp checks over their original PDF `ByteRange`.
2
3use crate::{
4    arena::Kind,
5    bytes::Form,
6    container::Container,
7    document::Document,
8    error::{Error, ErrorKind, ReadFailure, Syntax},
9    model::{admit, get, unsigned},
10    range::RangeSource,
11    signature::{Coverage, CoveredError, Signature, SubFilter},
12    source::Location,
13};
14use structured_asn1::{
15    Reader, Span,
16    memory::{Execution, HashWork, MemoryError, Work},
17};
18use structured_cms::{
19    provider::SignatureProvider,
20    timestamp::{
21        AsyncRawInput, CheckContext, CheckedTimestamp, ProfileCheckError, ProfileFailure,
22        ProfileSource, Token, TokenExtension,
23    },
24};
25use structured_ltv::{
26    source::Location as Original,
27    timestamp::{CheckedActor, Purpose, Verified},
28};
29use structured_pkix::{Certificate, path::WorkingKey};
30
31pub mod coverage;
32mod requirements;
33pub mod sources;
34pub use requirements::{DocumentTimestamp, Requirements, VriTimestamp};
35
36/// Original PDF node identity, retaining decoded and inner CMS domains.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct Node {
39    carrier: Location,
40    member: Member,
41}
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43enum Member {
44    Pdf,
45    Cms(Span),
46    MissingContents,
47    Dss(usize),
48}
49impl Node {
50    /// Original physical enclosure, without logical-offset fabrication.
51    #[must_use]
52    pub fn span(self) -> Span {
53        self.carrier.enclosing()
54    }
55    /// Original PDF coordinate domain, including a decoded object's identity.
56    #[must_use]
57    pub fn pdf_location(self) -> Location {
58        self.carrier
59    }
60    /// Optional original CMS coordinates inside the physical PDF enclosure.
61    /// These are logical carried-source offsets, not contiguous file offsets.
62    #[must_use]
63    pub fn cms_span(self) -> Option<Span> {
64        match self.member {
65            Member::Cms(span) => Some(span),
66            Member::Pdf | Member::MissingContents | Member::Dss(_) => None,
67        }
68    }
69    /// A source occurrence required Contents but contained no such value.
70    #[must_use]
71    pub fn is_missing_contents(self) -> bool {
72        self.member == Member::MissingContents
73    }
74    /// Historical DSS interpretation window, including all indirect stream
75    /// controls and dictionary/xref placement. Body bytes alone are not proof.
76    #[must_use]
77    pub fn material_revision(self) -> Option<usize> {
78        match self.member {
79            Member::Dss(revision) => Some(revision),
80            _ => None,
81        }
82    }
83    pub(crate) fn of(location: Location) -> Self {
84        Self {
85            carrier: location,
86            member: Member::Pdf,
87        }
88    }
89    pub(crate) fn missing_contents(carrier: Location) -> Self {
90        Self {
91            carrier,
92            member: Member::MissingContents,
93        }
94    }
95    pub(crate) fn dss(carrier: Location, revision: usize) -> Self {
96        Self {
97            carrier,
98            member: Member::Dss(revision),
99        }
100    }
101    pub(crate) fn inside(carrier: Location, member: structured_cades::source::Node) -> Self {
102        Self {
103            carrier,
104            member: Member::Cms(member.span()),
105        }
106    }
107}
108impl HashWork for Node {
109    fn hash_work(&self) -> Result<u64, MemoryError> {
110        Ok(match (self.carrier.source(), self.member) {
111            (crate::source::Source::File, Member::Pdf | Member::MissingContents) => 4,
112            (crate::source::Source::File, Member::Cms(_)) => 6,
113            (crate::source::Source::Decoded { .. }, Member::Pdf | Member::MissingContents) => 8,
114            (crate::source::Source::Decoded { .. }, Member::Cms(_)) => 10,
115            (crate::source::Source::File, Member::Dss(_)) => 5,
116            (crate::source::Source::Decoded { .. }, Member::Dss(_)) => 9,
117        })
118    }
119}
120impl core::hash::Hash for Node {
121    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
122        match self.carrier.source() {
123            crate::source::Source::File => 0_u8.hash(state),
124            crate::source::Source::Decoded {
125                stream,
126                revision,
127                stage,
128            } => {
129                1_u8.hash(state);
130                stream.start.hash(state);
131                stream.length.hash(state);
132                revision.hash(state);
133                stage.hash(state);
134            }
135        }
136        self.carrier.span().start.hash(state);
137        self.carrier.span().length.hash(state);
138        match self.member {
139            Member::Pdf => 0_u8.hash(state),
140            Member::Cms(span) => {
141                1_u8.hash(state);
142                span.start.hash(state);
143                span.length.hash(state);
144            }
145            Member::MissingContents => 2_u8.hash(state),
146            Member::Dss(revision) => {
147                3_u8.hash(state);
148                revision.hash(state);
149            }
150        }
151    }
152}
153
154/// Issuer boundary for PDF-derived LTV evidence; callers cannot mint this value.
155pub struct Pades {
156    _private: (),
157}
158pub(crate) const ISSUER: Pades = Pades { _private: () };
159
160pub(crate) fn profile(
161    input: &structured_cms::timestamp::ProfileInput<'_, '_, '_, '_>,
162) -> Result<(), ProfileFailure<structured_cades::Error>> {
163    // EN319142-1 §6.2.1 excludes MD5 for both token-signature and imprint
164    // hashing. VRI TS and document TS share this exact pre-provider rule.
165    // https://www.etsi.org/deliver/etsi_en/319100_319199/31914201/01.02.01_60/en_31914201v010201p.pdf
166    let fail = |source, cause| ProfileFailure { source, cause };
167    let md5 = structured_asn1::Oid::from_bytes(&[42, 134, 72, 134, 247, 13, 2, 5]);
168    input
169        .parent
170        .charge(
171            1 + input
172                .token
173                .signer()
174                .digest_algorithm()
175                .algorithm()
176                .as_bytes()
177                .len() as u64,
178        )
179        .map_err(|cause| {
180            fail(
181                ProfileSource::Envelope,
182                structured_cades::Error::Encoding {
183                    field: "pades.timestamp",
184                    cause,
185                },
186            )
187        })?;
188    if input.token.signer().digest_algorithm().algorithm() == md5 {
189        return Err(fail(
190            ProfileSource::Envelope,
191            structured_cades::Error::Syntax {
192                code: structured_cms::ErrorCode::InvalidLocation,
193                field: "pades.timestamp.md5",
194                at: input.token.signer().span().start,
195            },
196        ));
197    }
198    if let structured_cms::timestamp::HashAlgorithmReading::Identifier(algorithm) =
199        input.info.imprint().algorithm()
200    {
201        input
202            .reader
203            .charge(1 + algorithm.algorithm().as_bytes().len() as u64)
204            .map_err(|cause| {
205                fail(
206                    ProfileSource::Information,
207                    structured_cades::Error::Encoding {
208                        field: "pades.timestamp",
209                        cause,
210                    },
211                )
212            })?;
213        if algorithm.algorithm() == md5 {
214            return Err(fail(
215                ProfileSource::Information,
216                structured_cades::Error::Syntax {
217                    code: structured_cms::ErrorCode::InvalidLocation,
218                    field: "pades.timestamp.md5",
219                    at: input.info.imprint().span().start,
220                },
221            ));
222        }
223    }
224    Ok(())
225}
226
227/// Original PDF/profile or common timestamp execution refusal.
228#[derive(Debug)]
229pub enum TimestampError<E, P, R> {
230    /// Original PDF source/profile/provenance failure.
231    Pdf(ReadFailure<E>),
232    /// Complete common timestamp/profile check failed with its original cause.
233    Check(ProfileCheckError<CoveredError<E>, P, R, structured_cades::Error>),
234}
235
236/// Complete original document timestamp answers and their exact selected TSA.
237/// Negative answers remain visible and cannot expose a successful actor.
238pub struct DocumentTime<'q, 'a, 's, 'd, D, G> {
239    signature: Signature<'a, 's, D, G>,
240    checks: CheckedTimestamp<'q>,
241    certificate: &'q Certificate<'d>,
242    key: WorkingKey<'d>,
243}
244
245impl<'q, 'a, 's, 'd, D: Copy + Eq, G: Copy + Eq> DocumentTime<'q, 'a, 's, 'd, D, G> {
246    /// Original complete token facts, independently of trust/path acceptance.
247    #[must_use]
248    pub fn checks(&self) -> &CheckedTimestamp<'q> {
249        &self.checks
250    }
251    /// Actual timestamp signature dictionary, not a replacement caller span.
252    #[must_use]
253    pub fn signature(&self) -> Signature<'a, 's, D, G> {
254        self.signature
255    }
256
257    /// Retains this timestamp's actual TSA and evidence only when its checked
258    /// original ranges cover the requested signature's original Contents.
259    /// The LTV receipt's purpose is archival; trusted-time selection remains
260    /// separate from the fact that the complete earlier signature is covered.
261    pub fn into_actor(
262        self,
263        covered: Signature<'_, 's, D, G>,
264        operation: &impl Work,
265    ) -> crate::Result<Option<CheckedActor<'q, 'd, Node, D, G, Pades>>> {
266        if !self.associated(covered, operation)? {
267            return Ok(None);
268        }
269        let location = self.signature.location();
270        let owner = self.signature.owner();
271        let work = owner.value.session.query(operation);
272        let Coverage::Revision(archive) = self.signature.coverage() else {
273            return Ok(None);
274        };
275        let Coverage::Revision(subject) = covered.coverage() else {
276            return Ok(None);
277        };
278        if subject > archive || covered.contents().source() != crate::source::Source::File {
279            return Ok(None);
280        }
281        let needed = covered.contents().span();
282        let mut complete = false;
283        for span in self.signature.ranges() {
284            admit(&work, location, 1)?;
285            if span.start <= needed.start
286                && span.start + span.length >= needed.start + needed.length
287            {
288                complete = true;
289                break;
290            }
291        }
292        if !complete {
293            return Ok(None);
294        }
295        Ok(self.issue(covered))
296    }
297
298    /// Retains an earlier DTS authority as a historical obligation of the same
299    /// original document. This does NOT claim coverage of the selected root;
300    /// B-T selection separately checks the actual original `ByteRange`.
301    pub fn into_historical_actor(
302        self,
303        root: Signature<'_, 's, D, G>,
304        operation: &impl Work,
305    ) -> crate::Result<Option<CheckedActor<'q, 'd, Node, D, G, Pades>>> {
306        if !self.associated(root, operation)? {
307            return Ok(None);
308        }
309        Ok(self.issue(root))
310    }
311
312    fn associated(
313        &self,
314        root: Signature<'_, 's, D, G>,
315        operation: &impl Work,
316    ) -> crate::Result<bool> {
317        let owner = self.signature.owner();
318        let target = root.owner();
319        let work = owner.value.session.query(operation);
320        let location = self.signature.location();
321        admit(&work, location, 1)?;
322        if !core::ptr::eq(owner.value.session, target.value.session)
323            || owner.value.stamp != target.value.stamp
324        {
325            return Err(Error {
326                kind: ErrorKind::Foreign,
327                location,
328            });
329        }
330        Ok(matches!(self.signature.coverage(), Coverage::Revision(_))
331            && matches!(root.coverage(), Coverage::Revision(_)))
332    }
333
334    fn issue(
335        self,
336        covered: Signature<'_, 's, D, G>,
337    ) -> Option<CheckedActor<'q, 'd, Node, D, G, Pades>> {
338        let location = self.signature.location();
339        let owner = self.signature.owner();
340        let evidence = self.checks.into_evidence()?;
341        let property = Node::of(location);
342        let verified = Verified::new(
343            ISSUER,
344            Original {
345                snapshot: owner.value.stamp.snapshot,
346                node: Node::of(self.signature.contents()),
347            },
348            Node::of(covered.location()),
349            property,
350            Purpose::Archive,
351            evidence,
352        );
353        Some(CheckedActor::new(
354            ISSUER,
355            verified,
356            self.certificate,
357            self.key,
358        ))
359    }
360}
361
362impl<'s, S: RangeSource> Document<'s, S> {
363    /// Applies the original PDF DTS declaration before provider callbacks,
364    /// then verifies the original carried token over asynchronous file ranges.
365    /// EN319142-1 §§5.4.3/6.3 requires exact whole-revision coverage and forbids
366    /// Cert/Reference/Changes/R/authentication metadata on this dictionary.
367    /// <https://www.etsi.org/deliver/etsi_EN/319100_319199/31914201/01.02.01_60/en_31914201v010201p.pdf>
368    ///
369    /// # Panics
370    /// Panics if the inspected timestamp loses its original `Contents` member.
371    #[expect(
372        clippy::result_large_err,
373        reason = "timestamp and profile failures retain original mapped context and opaque causes without an error-path allocation"
374    )]
375    pub async fn check_timestamp<'q, 'a, 'token, 'certificate, P, PE, RE>(
376        &'q self,
377        container: &'q Container<'a, 's, S::Document, S::Generation>,
378        token: &'q Token<'token>,
379        provider: &'q P,
380        parent: &Reader<'q>,
381        context: &'q CheckContext<'certificate, 'q>,
382        recognizes: impl FnMut(&TokenExtension<'_>, &Execution<'_>) -> Result<bool, RE>,
383    ) -> Result<
384        DocumentTime<'q, 'a, 's, 'certificate, S::Document, S::Generation>,
385        TimestampError<S::Error, PE, RE>,
386    >
387    where
388        'token: 'q,
389        'certificate: 'q,
390        's: 'q,
391        'a: 'q,
392        P: for<'scope> SignatureProvider<'scope, Error = PE>,
393    {
394        let pdf = TimestampError::Pdf;
395        let signature = container.signature();
396        let location = signature.location();
397        let work = parent.execution();
398        container
399            .require(token.message(), parent)
400            .map_err(|cause| pdf(cause.into()))?;
401        self.check(&work, location)
402            .map_err(|cause| pdf(cause.into()))?;
403        let malformed = malformed_timestamp::<S::Error, PE, RE>;
404        if !signature.is_document_timestamp()
405            || signature.sub_filter() != Some(SubFilter::Rfc3161)
406            || !matches!(signature.coverage(), Coverage::Revision(_))
407        {
408            return Err(malformed(location));
409        }
410        let reading = signature.owner();
411        let arena = &reading.value.arena;
412        let node = signature.node();
413        crate::direct::visit(arena, node, &work, |location| Err(malformed(location))).map_err(
414            |cause| match cause {
415                crate::direct::VisitError::Processing(cause) => pdf(cause.into()),
416                crate::direct::VisitError::Callback(cause) => cause,
417            },
418        )?;
419        let filter = get(arena, node, b"Filter", &work).map_err(|cause| pdf(cause.into()))?;
420        if filter.is_none_or(|node| !matches!(arena.nodes[node.0].kind, Kind::Name(_))) {
421            return Err(malformed(location));
422        }
423        let Coverage::Revision(revision) = signature.coverage() else {
424            return Err(malformed(location));
425        };
426        let graph = self.graph(revision, &work).await.map_err(pdf)?;
427        let fields = self.fields(&graph, &work).await.map_err(pdf)?;
428        let transforms = self
429            .transforms(signature, &graph, &work)
430            .await
431            .map_err(pdf)?;
432        let placement = self
433            .signature_placement(signature, &graph, &fields, &transforms, &work)
434            .map_err(pdf)?;
435        if !placement.all_requirements_met() {
436            return Err(malformed(location));
437        }
438        if !reading.value.faults().is_empty()
439            || !matches!(
440                arena.nodes[get(arena, node, b"Contents", &work)
441                    .map_err(|cause| pdf(cause.into()))?
442                    .expect("inspected Contents")
443                    .0]
444                    .kind,
445                Kind::String(Form::Hexadecimal)
446            )
447        {
448            return Err(malformed(signature.contents()));
449        }
450        if let Some(version) = get(arena, node, b"V", &work).map_err(|cause| pdf(cause.into()))?
451            && unsigned(arena, version, &work).map_err(|cause| pdf(cause.into()))? != 0
452        {
453            return Err(malformed(arena.nodes[version.0].location));
454        }
455        for key in [
456            b"Cert".as_slice(),
457            b"Reference",
458            b"Changes",
459            b"R",
460            b"Prop_AuthTime",
461            b"Prop_AuthType",
462        ] {
463            if let Some(value) = get(arena, node, key, &work).map_err(|cause| pdf(cause.into()))? {
464                return Err(malformed(arena.nodes[value.0].location));
465            }
466        }
467        let mut covered = self.covered(signature, &work).map_err(pdf)?;
468        let checks = token
469            .check_historical_async_with_profile(
470                provider,
471                parent,
472                context,
473                &mut AsyncRawInput::new(&mut covered),
474                recognizes,
475                |input| profile(&input),
476            )
477            .await
478            .map_err(TimestampError::Check)?;
479        self.check(&work, location)
480            .map_err(|cause| pdf(cause.into()))?;
481        Ok(DocumentTime {
482            signature,
483            checks,
484            certificate: context.certificate,
485            key: context.key,
486        })
487    }
488}
489
490fn malformed_timestamp<S, P, R>(location: crate::source::Location) -> TimestampError<S, P, R> {
491    TimestampError::Pdf(
492        Error {
493            kind: ErrorKind::Syntax(Syntax::Container),
494            location,
495        }
496        .into(),
497    )
498}