File Coverage

blib/lib/Crypt/TimestampedData.pm
Criterion Covered Total %
statement 122 167 73.0
branch 43 114 37.7
condition 8 37 21.6
subroutine 13 15 86.6
pod 12 12 100.0
total 198 345 57.3


line stmt bran cond sub pod time code
1             package Crypt::TimestampedData;
2             $Crypt::TimestampedData::VERSION = '0.02';
3 10     10   661770 use strict;
  10         18  
  10         337  
4 10     10   34 use warnings;
  10         17  
  10         435  
5              
6 10     10   4236 use Convert::ASN1;
  10         354013  
  10         17385  
7              
8             =pod
9              
10             =head1 NAME
11              
12             Crypt::TimestampedData - Read and write TimeStampedData files (.TSD, RFC 5544)
13              
14             =head1 VERSION
15              
16             version 0.02
17              
18             =head1 SYNOPSIS
19              
20             use Crypt::TimestampedData;
21              
22             # Decode from .TSD file
23             my $tsd = Crypt::TimestampedData->read_file('/path/file.tsd');
24             my $version = $tsd->{version};
25             my $data_uri = $tsd->{dataUri}; # optional
26             my $meta = $tsd->{metaData}; # optional
27             my $content_der = $tsd->{content}; # optional (CMS ContentInfo DER)
28             my $evidence_content = $tsd->{temporalEvidence};
29              
30             # Encode to .TSD file
31             Crypt::TimestampedData->write_file('/path/out.tsd', $tsd);
32              
33             =head1 DESCRIPTION
34              
35             Minimal implementation of the TimeStampedData format (RFC 5544) using Convert::ASN1.
36             This version treats CMS constructs and TimeStampTokens as opaque DER blobs.
37             The goal is to enable reading/writing of .TSD files, delegating CMS/TS handling
38             to external libraries when available.
39              
40             =head1 SECURITY
41              
42             Report security vulnerabilities **privately** to the maintainer at
43             Egdo@leader.itE. See the F file in this distribution's
44             root directory for the full policy (coordinated disclosure, optional CC to the
45             L at
46             Ecpan-security@security.metacpan.orgE). Do not file security issues on
47             public bug trackers before coordination.
48              
49             =head1 METHODS
50              
51             =head2 new(%args)
52              
53             Creates a new Crypt::TimestampedData object with the provided arguments.
54              
55             =head2 read_file($filepath)
56              
57             Reads and decodes a .TSD file from the specified path. Returns a hash reference
58             containing the decoded TimeStampedData structure.
59              
60             =head2 write_file($filepath, $tsd_hashref)
61              
62             Encodes and writes a TimeStampedData structure to the specified file path.
63              
64             =head2 decode_der($der)
65              
66             Decodes DER-encoded TimeStampedData. Handles both direct TSD format and
67             CMS ContentInfo wrappers (id-ct-TSTData and pkcs7-signedData).
68              
69             =head2 encode_der($tsd_hashref)
70              
71             Encodes a TimeStampedData hash reference to DER format.
72              
73             =head2 extract_content_der($tsd_hashref)
74              
75             Extracts the embedded original content from TimeStampedData.content.
76             Returns raw bytes of the original file if available, otherwise undef.
77              
78             =head2 extract_tst_tokens_der($tsd_hashref)
79              
80             Extracts RFC 3161 TimeStampToken(s) as DER ContentInfo blobs.
81             Returns array reference of DER-encoded ContentInfo tokens.
82              
83             =head2 write_content_file($tsd_hashref, $filepath)
84              
85             Convenience method to write extracted content to a file.
86              
87             =head2 extract_signed_content_bytes($tsd_hashref)
88              
89             Extracts encapsulated content from a SignedData (p7m) stored in TSD.content.
90             Returns raw bytes of the signed payload (eContent) when available.
91              
92             =head2 write_signed_content_file($tsd_hashref, $filepath)
93              
94             Convenience method to write extracted signed content to a file.
95              
96             =head2 write_tst_files($tsd_hashref, $dirpath)
97              
98             Writes extracted timestamp tokens to individual .tsr files in the specified directory.
99              
100             =head2 write_tds($marked_filepath, $tsr_input, $out_filepath_opt)
101              
102             Creates and writes a TSD file from a marked file and one or more RFC3161
103             TimeStampToken(s) provided as .TSR (DER CMS ContentInfo) blobs or paths.
104              
105             =head1 EXAMPLES
106              
107             # Read a TSD file
108             my $tsd = Crypt::TimestampedData->read_file('document.tsd');
109             print "Version: $tsd->{version}\n";
110            
111             # Extract the original content
112             Crypt::TimestampedData->write_content_file($tsd, 'original_document.pdf');
113            
114             # Extract timestamp tokens
115             my $tokens = Crypt::TimestampedData->extract_tst_tokens_der($tsd);
116             print "Found " . scalar(@$tokens) . " timestamp tokens\n";
117            
118             # Create a new TSD file
119             my $output = Crypt::TimestampedData->write_tds(
120             'document.pdf', # original file
121             'timestamp.tsr', # timestamp token
122             'document.tsd' # output file
123             );
124              
125             =head1 COMMAND-LINE SCRIPTS
126              
127             This distribution includes several command-line scripts for working with TimeStampedData files:
128              
129             =over 4
130              
131             =item * C - Create a .tsd file from a file and timestamp
132              
133             =item * C - Extract content and timestamps from a .tsd file
134              
135             =item * C - Display information about a .tsd file
136              
137             =back
138              
139             =head2 Windows Usage
140              
141             On Windows systems, scripts must be executed with Perl explicitly:
142              
143             perl tsd-create --help
144             perl tsd-extract document.tsd
145             perl tsd-info document.tsd
146              
147             =head2 Unix/Linux Usage
148              
149             On Unix/Linux systems, scripts can be executed directly:
150              
151             tsd-create --help
152             tsd-extract document.tsd
153             tsd-info document.tsd
154              
155             =head1 REQUIREMENTS
156              
157             =over 4
158              
159             =item * Convert::ASN1
160              
161             =back
162              
163             =head1 AUTHOR
164              
165             Guido Brugnara - created with AI support.
166              
167             =head1 LICENSE
168              
169             This module is released under the same terms as Perl itself.
170              
171             =head1 SEE ALSO
172              
173             =over 4
174              
175             =item * RFC 5544 - TimeStampedData Format
176              
177             =item * RFC 3161 - Internet X.509 Public Key Infrastructure Time-Stamp Protocol
178              
179             =item * RFC 5652 - Cryptographic Message Syntax (CMS)
180              
181             =back
182              
183             =cut
184              
185             my $ASN1_SPEC = <<'__ASN1_RFC5544__';
186             TimeStampedData ::= SEQUENCE {
187             version INTEGER,
188             dataUri IA5String OPTIONAL,
189             metaData MetaData OPTIONAL,
190             content OCTET STRING OPTIONAL, -- ContentInfo DER (RFC 5652)
191             temporalEvidence Evidence
192             }
193              
194             MetaData ::= SEQUENCE {
195             hashProtected BOOLEAN,
196             fileName UTF8String OPTIONAL,
197             mediaType IA5String OPTIONAL,
198             otherMetaData ANY OPTIONAL
199             }
200              
201             Evidence ::= CHOICE {
202             tstEvidence [0] TimeStampTokenEvidence, -- see RFC 3161
203             ersEvidence [1] EvidenceRecord, -- see RFC 4998
204             otherEvidence [2] OtherEvidence
205             }
206              
207             OtherEvidence ::= SEQUENCE {
208             oeType OBJECT IDENTIFIER,
209             oeValue ANY DEFINED BY oeType }
210              
211             TimeStampTokenEvidence ::=
212             SEQUENCE OF TimeStampAndCRL
213              
214             TimeStampAndCRL ::= SEQUENCE {
215             timeStamp TimeStampToken, -- according to RFC 3161
216             crl CertificateList OPTIONAL -- according to RFC 5280
217             }
218              
219             TimeStampToken ::= ContentInfo
220              
221             CertificateList ::= ANY
222              
223             Attribute ::= ANY
224              
225             EvidenceRecord ::= ANY
226             -- OtherEvidence payload defined above (oeType, oeValue)
227              
228             -- Minimal CMS ContentInfo wrapper (RFC 5652)
229             ContentInfo ::= SEQUENCE {
230             contentType OBJECT IDENTIFIER,
231             content [0] EXPLICIT ANY OPTIONAL
232             }
233              
234              
235             -- Helper to unwrap OCTET STRING containers when needed
236             OctetString ::= OCTET STRING
237              
238             -- Minimal CMS structures to navigate SignedData → EncapsulatedContentInfo
239             SignedData ::= SEQUENCE {
240             version INTEGER,
241             digestAlgorithms SET OF AlgorithmIdentifier,
242             encapContentInfo EncapsulatedContentInfo,
243             certificates [0] IMPLICIT ANY OPTIONAL,
244             crls [1] IMPLICIT ANY OPTIONAL,
245             signerInfos SET OF ANY
246             }
247              
248             EncapsulatedContentInfo ::= SEQUENCE {
249             eContentType OBJECT IDENTIFIER,
250             eContent [0] EXPLICIT OCTET STRING OPTIONAL
251             }
252              
253             AlgorithmIdentifier ::= SEQUENCE {
254             algorithm OBJECT IDENTIFIER,
255             parameters ANY OPTIONAL
256             }
257              
258             __ASN1_RFC5544__
259              
260             my $ASN1 = Convert::ASN1->new;
261             $ASN1->prepare($ASN1_SPEC) or die "TSD ASN.1 prepare error: " . $ASN1->error;
262              
263             my $TSD_CODEC = $ASN1->find('TimeStampedData')
264             or die 'ASN.1 type TimeStampedData not found';
265              
266             my $CONTENTINFO_CODEC = $ASN1->find('ContentInfo')
267             or die 'ASN.1 type ContentInfo not found';
268              
269             my $OCTETSTRING_CODEC = $ASN1->find('OctetString')
270             or die 'ASN.1 type OctetString not found';
271              
272             my $SIGNEDDATA_CODEC = $ASN1->find('SignedData')
273             or die 'ASN.1 type SignedData not found';
274              
275             my $CONTENTINFO_TSD_CODEC = $ASN1->find('ContentInfoTSD')
276             or undef;
277              
278             my $OID_CT_TSTDATA = '1.2.840.113549.1.9.16.1.31';
279             my $OID_CT_SIGNEDDATA = '1.2.840.113549.1.7.2';
280              
281             sub new {
282 2     2 1 325481 my ($class, %args) = @_;
283 2         6 my $self = {%args};
284 2         9 return bless $self, $class;
285             }
286              
287             sub decode_der {
288 9     9 1 1795 my ($class, $der) = @_;
289              
290             # Try direct TimeStampedData first
291 9         62 my $decoded = $TSD_CODEC->decode($der);
292 9 100       2260 return $decoded if defined $decoded;
293              
294             # If that fails, try unwrapping CMS ContentInfo (common packaging for TSD)
295 5         17 my $ci = $CONTENTINFO_CODEC->decode($der);
296 5 50       927 die 'ASN.1 decode failed: ' . $TSD_CODEC->error unless defined $ci;
297              
298             # Two acceptable wrappings for RFC 5544:
299             # 1) ContentInfo with contentType id-ct-TSTData and [0] content = TimeStampedData
300             # 2) ContentInfo with contentType pkcs7-signedData, whose encapContentInfo.eContentType is id-ct-TSTData
301              
302 5         13 my $content_der = $ci->{content};
303 5 50       13 die 'ASN.1 decode failed: ContentInfo without [0] content' unless defined $content_der;
304              
305             # Some encoders place TSD directly as [0] EXPLICIT SEQUENCE; others wrap as OCTET STRING
306 5 50       31 my $first_tag = length($content_der) ? ord(substr($content_der, 0, 1)) : -1;
307 5 50       16 if ($first_tag == 0x04) { # OCTET STRING
308 0         0 my $octets = $OCTETSTRING_CODEC->decode($content_der);
309 0 0       0 die 'ASN.1 decode failed: cannot unwrap OCTET STRING: ' . $OCTETSTRING_CODEC->error unless defined $octets;
310 0         0 $content_der = $octets;
311             }
312              
313             # Case 1: Direct id-ct-TSTData wrapper — decode TSD from [0] content (may be OCTET STRING)
314 5 50 33     32 if (defined $ci->{contentType} && $ci->{contentType} eq $OID_CT_TSTDATA) {
315 5         14 my $tsd = $TSD_CODEC->decode($content_der);
316 5 50       2828 die 'ASN.1 decode failed (inside ContentInfo/id-ct-TSTData): ' . $TSD_CODEC->error unless defined $tsd;
317 5         45 return $tsd;
318             }
319              
320             # Case 2: SignedData → EncapsulatedContentInfo with id-ct-TSTData
321 0 0 0     0 if (defined $ci->{contentType} && $ci->{contentType} eq $OID_CT_SIGNEDDATA) {
322 0         0 my $sd = $SIGNEDDATA_CODEC->decode($content_der);
323 0 0       0 die 'ASN.1 decode failed: cannot decode SignedData: ' . $SIGNEDDATA_CODEC->error unless defined $sd;
324 0   0     0 my $eci = $sd->{encapContentInfo} || {};
325 0         0 my $econtent_type = $eci->{eContentType};
326 0         0 my $econtent = $eci->{eContent};
327 0 0       0 die 'ASN.1 decode failed: SignedData without encapContentInfo.eContent' unless defined $econtent;
328 0 0 0     0 unless (defined $econtent_type && $econtent_type eq $OID_CT_TSTDATA) {
329 0         0 die "Input is CMS SignedData with eContentType '$econtent_type', expected id-ct-TSTData ($OID_CT_TSTDATA).";
330             }
331             # eContent is an OCTET STRING wrapping the TSD DER
332 0         0 my $tsd = $TSD_CODEC->decode($econtent);
333 0 0       0 die 'ASN.1 decode failed (inside SignedData/id-ct-TSTData): ' . $TSD_CODEC->error unless defined $tsd;
334 0         0 return $tsd;
335             }
336              
337 0         0 die "Input ContentInfo has unsupported contentType '$ci->{contentType}', expected id-ct-TSTData ($OID_CT_TSTDATA) or pkcs7-signedData ($OID_CT_SIGNEDDATA).";
338             }
339              
340             sub encode_der {
341 5     5 1 127872 my ($class, $tsd_hashref) = @_;
342 5         28 my $der = $TSD_CODEC->encode($tsd_hashref);
343 5 50       1343 die 'ASN.1 encode failed: ' . $TSD_CODEC->error unless defined $der;
344 5         23 return $der;
345             }
346              
347             sub read_file {
348 6     6 1 4173 my ($class, $filepath) = @_;
349 6 50       269 open my $fh, '<:raw', $filepath or die "Cannot open TSD file '$filepath' for reading: $!";
350 6         38 local $/; my $der = <$fh>; close $fh;
  6         296  
  6         65  
351 6         57 return $class->decode_der($der);
352             }
353              
354             sub write_file {
355 1     1 1 7748 my ($class, $filepath, $tsd_hashref) = @_;
356 1         4 my $der = $class->encode_der($tsd_hashref);
357 1 50       188 open my $fh, '>:raw', $filepath or die "Cannot open TSD file '$filepath' for writing: $!";
358 1         4 print {$fh} $der;
  1         16  
359 1         39 close $fh;
360 1         10 return 1;
361             }
362              
363             # Extract embedded original content from TimeStampedData.content
364             # Returns raw bytes of the original file if available, otherwise undef
365             sub extract_content_der {
366 7     7 1 4315 my ($class, $tsd_hashref) = @_;
367 7         17 my $content_der = $tsd_hashref->{content};
368 7 100       21 return undef unless defined $content_der;
369              
370             # content is a CMS ContentInfo (DER). Try to unwrap eContent when present
371 6         21 my $ci = $CONTENTINFO_CODEC->decode($content_der);
372 6 50       984 return $content_der unless defined $ci; # if decoding fails, return raw blob (treat as opaque)
373              
374 6         11 my $content_type = $ci->{contentType};
375 6         10 my $econtent = $ci->{content};
376 6 50       27 return undef unless defined $econtent; # no encapsulated content
377              
378             # If the ContentInfo is id-data, unwrap to the raw bytes; otherwise
379             # (e.g., SignedData/p7m), return the full ContentInfo DER as the original file
380 6 50 33     36 if (defined $content_type && $content_type eq '1.2.840.113549.1.7.1') { # id-data
381 6 50       39 my $first_tag = length($econtent) ? ord(substr($econtent, 0, 1)) : -1;
382 6 50       27 if ($first_tag == 0x04) {
383 6         17 my $octets = $OCTETSTRING_CODEC->decode($econtent);
384 6 50       360 return $octets if defined $octets;
385             }
386 0         0 return $econtent; # fallback: return inner content as-is
387             }
388              
389             # For SignedData and others: preserve outer ContentInfo (p7m)
390 0         0 return $content_der;
391             }
392              
393             # Extract RFC 3161 TimeStampToken(s) as DER ContentInfo blobs
394             # Returns arrayref of DER-encoded ContentInfo tokens
395             sub extract_tst_tokens_der {
396 4     4 1 2253 my ($class, $tsd_hashref) = @_;
397 4         6 my @tokens_der;
398              
399 4         22 my $evidence = $tsd_hashref->{temporalEvidence};
400 4 50       12 return \@tokens_der unless defined $evidence;
401              
402 4 50       13 if (exists $evidence->{tstEvidence}) {
403 4 50       21 foreach my $ts_and_crl (@{ $evidence->{tstEvidence} || [] }) {
  4         14  
404 4         9 my $ci_struct = $ts_and_crl->{timeStamp};
405 4 50       12 next unless defined $ci_struct;
406 4         17 my $der = $CONTENTINFO_CODEC->encode($ci_struct);
407 4 50       752 push @tokens_der, $der if defined $der;
408             }
409             }
410 4         12 return \@tokens_der;
411             }
412              
413             # Convenience helpers to write extracted payloads
414             sub write_content_file {
415 1     1 1 1599 my ($class, $tsd_hashref, $filepath) = @_;
416 1         3 my $bytes = $class->extract_content_der($tsd_hashref);
417 1 50       3 die 'No embedded content found in TSD' unless defined $bytes;
418 1 50       122 open my $fh, '>:raw', $filepath or die "Cannot open '$filepath' for writing: $!";
419 1         3 print {$fh} $bytes;
  1         14  
420 1         31 close $fh;
421 1         9 return 1;
422             }
423              
424             ## Extract encapsulated content from a SignedData (p7m) stored in TSD.content.
425             ## Returns raw bytes of the signed payload (eContent) when available.
426             sub extract_signed_content_bytes {
427 0     0 1 0 my ($class, $tsd_hashref) = @_;
428 0         0 my $content_der = $tsd_hashref->{content};
429 0 0       0 return undef unless defined $content_der;
430              
431 0 0       0 my $ci = $CONTENTINFO_CODEC->decode($content_der) or return undef;
432 0 0 0     0 return undef unless defined $ci->{contentType} && $ci->{contentType} eq $OID_CT_SIGNEDDATA;
433 0         0 my $sd_der = $ci->{content};
434 0 0       0 my $sd = $SIGNEDDATA_CODEC->decode($sd_der) or return undef;
435 0   0     0 my $eci = $sd->{encapContentInfo} || {};
436 0         0 my $econtent = $eci->{eContent};
437 0 0       0 return undef unless defined $econtent;
438             # eContent is EXPLICIT OCTET STRING; unwrap if needed
439 0 0       0 my $first_tag = length($econtent) ? ord(substr($econtent, 0, 1)) : -1;
440 0 0       0 if ($first_tag == 0x04) {
441 0         0 my $octets = $OCTETSTRING_CODEC->decode($econtent);
442 0 0       0 return $octets if defined $octets;
443             }
444 0         0 return $econtent;
445             }
446              
447             sub write_signed_content_file {
448 0     0 1 0 my ($class, $tsd_hashref, $filepath) = @_;
449 0         0 my $bytes = $class->extract_signed_content_bytes($tsd_hashref);
450 0 0       0 die 'No encapsulated signed content found in TSD.content' unless defined $bytes;
451 0 0       0 open my $fh, '>:raw', $filepath or die "Cannot open '$filepath' for writing: $!";
452 0         0 print {$fh} $bytes;
  0         0  
453 0         0 close $fh;
454 0         0 return 1;
455             }
456              
457             sub write_tst_files {
458 2     2 1 1123 my ($class, $tsd_hashref, $dirpath) = @_;
459 2         7 my $tokens = $class->extract_tst_tokens_der($tsd_hashref);
460 2         4 my $index = 1;
461 2         5 foreach my $der (@$tokens) {
462 2         6 my $out = "$dirpath/timestamp_$index.tsr"; # DER CMS ContentInfo
463 2 50       285 open my $fh, '>:raw', $out or die "Cannot open '$out' for writing: $!";
464 2         6 print {$fh} $der;
  2         33  
465 2         94 close $fh;
466 2         12 $index++;
467             }
468 2         11 return scalar(@$tokens);
469             }
470              
471             ###
472             # Create and write a TSD file from a marked file and one or more RFC3161
473             # TimeStampToken(s) provided as .TSR (DER CMS ContentInfo) blobs or paths.
474             #
475             # Usage examples:
476             # # single token, infer output path as .tsd
477             # my $out = Crypt::TimestampedData->write_tds('/path/file.bin', '/path/token.tsr');
478             #
479             # # multiple tokens
480             # my $out = Crypt::TimestampedData->write_tds('/path/file.bin', [ '/p/a.tsr', '/p/b.tsr' ], '/path/out.tsd');
481             #
482             # Args:
483             # $marked_filepath - path to the original file to embed
484             # $tsr_input - path to .tsr, raw DER bytes, or ARRAYREF of these
485             # $out_filepath_opt - optional output .tsd path; defaults to "$marked_filepath" with .tsd extension
486             #
487             # Returns output filepath on success.
488             sub write_tds {
489 4     4 1 446865 my ($class, $marked_filepath, $tsr_input, $out_filepath_opt) = @_;
490              
491 4 50       16 die 'Marked file path is required' unless defined $marked_filepath;
492 4 50       156 open my $in_fh, '<:raw', $marked_filepath or die "Cannot open marked file '$marked_filepath' for reading: $!";
493 4         23 local $/; my $marked_bytes = <$in_fh>; close $in_fh;
  4         117  
  4         35  
494              
495             # If the marked bytes are already a CMS ContentInfo (e.g., .p7m), embed as-is.
496             # Otherwise, build a ContentInfo (id-data) wrapping the raw bytes.
497 4         29 my $contentinfo_der;
498 4         32 my $ci_probe = $CONTENTINFO_CODEC->decode($marked_bytes);
499 4 50 33     448 if (defined $ci_probe && ref $ci_probe eq 'HASH' && exists $ci_probe->{contentType}) {
      33        
500 0         0 $contentinfo_der = $marked_bytes; # already a ContentInfo
501             } else {
502 4         9 my $DATA_OID = '1.2.840.113549.1.7.1'; # id-data
503 4 50       17 my $octets_der = $OCTETSTRING_CODEC->encode($marked_bytes)
504             or die 'ASN.1 encode failed for OctetString: ' . $OCTETSTRING_CODEC->error;
505 4 50       261 $contentinfo_der = $CONTENTINFO_CODEC->encode({ contentType => $DATA_OID, content => $octets_der })
506             or die 'ASN.1 encode failed for ContentInfo (data): ' . $CONTENTINFO_CODEC->error;
507             }
508              
509             # Normalize $tsr_input to an array of DER-encoded TimeStampToken ContentInfo blobs
510 4 50       592 my @tsr_items = ref($tsr_input) eq 'ARRAY' ? @$tsr_input : ($tsr_input);
511 4 50       10 die 'At least one .TSR token must be provided' unless @tsr_items;
512              
513 4         16 my @tst_seq;
514 4         8 for my $item (@tsr_items) {
515 4         7 my $der;
516 4 50 33     99 if (defined $item && !ref($item) && -e $item) {
      33        
517 4 50       114 open my $fh, '<:raw', $item or die "Cannot open TSR file '$item' for reading: $!";
518 4         15 local $/; $der = <$fh>; close $fh;
  4         116  
  4         49  
519             } else {
520 0         0 $der = $item;
521             }
522 4 50 33     23 die 'Undefined TSR DER provided' unless defined $der && length $der;
523 4 50       14 my $ci_struct = $CONTENTINFO_CODEC->decode($der)
524             or die 'ASN.1 decode failed for TSR (expecting ContentInfo/TimeStampToken): ' . $CONTENTINFO_CODEC->error;
525 4         924 push @tst_seq, { timeStamp => $ci_struct };
526             }
527              
528 4         63 my ($fname) = $marked_filepath =~ m{([^/]+)$};
529 4 50       38 my $tsd_hashref = {
530             version => 1,
531             metaData => {
532             hashProtected => 0,
533             (defined $fname ? (fileName => $fname) : ()),
534             },
535             content => $contentinfo_der,
536             temporalEvidence => {
537             tstEvidence => \@tst_seq,
538             },
539             };
540              
541             # Determine output path
542 4         8 my $out = $out_filepath_opt;
543 4 50 33     21 unless (defined $out && length $out) {
544 0         0 $out = $marked_filepath;
545 0         0 $out =~ s{(\.[^/\.]+)?$}{.tsd};
546             }
547              
548             # For better interoperability, wrap TSD as CMS ContentInfo with id-ct-TSTData
549 4 50       12 my $tsd_der = $TSD_CODEC->encode($tsd_hashref)
550             or die 'ASN.1 encode failed for TimeStampedData: ' . $TSD_CODEC->error;
551 4 50       1805 my $ci_tsd_der = $CONTENTINFO_CODEC->encode({ contentType => $OID_CT_TSTDATA, content => $tsd_der })
552             or die 'ASN.1 encode failed for ContentInfo(id-ct-TSTData): ' . $CONTENTINFO_CODEC->error;
553 4 50       1057 open my $out_fh, '>:raw', $out or die "Cannot open TSD file '$out' for writing: $!";
554 4         22 print {$out_fh} $ci_tsd_der;
  4         83  
555 4         167 close $out_fh;
556 4         66 return $out;
557             }
558              
559             1;