Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
64.61% covered (warning)
64.61%
241 / 373
37.50% covered (danger)
37.50%
9 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
TSA
64.61% covered (warning)
64.61%
241 / 373
37.50% covered (danger)
37.50%
9 / 24
1279.44
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 processContentCandidate
45.45% covered (danger)
45.45%
5 / 11
0.00% covered (danger)
0.00%
0 / 1
14.95
 ensureOidsAreLoaded
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 convertDerToPkcs7Pem
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 extractTimestampAuthorityName
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 buildTimestampInfoStructure
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
1
 extract
63.64% covered (warning)
63.64%
49 / 77
0.00% covered (danger)
0.00%
0 / 1
81.24
 getSigninTime
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
12.79
 parseTstInfoFallback
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 1
1260
 getAttributeValuesSetAfterOID
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
8.38
 findContentAfterOID
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 matchesOid
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 extractCertificateHints
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 isStringValidUtf8
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 generateDistinguishedNames
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 bigToString
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 resolveHashAlgorithm
72.22% covered (warning)
72.22%
13 / 18
0.00% covered (danger)
0.00%
0 / 1
1.02
 getNumericOid
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
4.79
 resolveTsaPolicyName
28.57% covered (danger)
28.57%
4 / 14
0.00% covered (danger)
0.00%
0 / 1
9.83
 decodeWithCache
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
5.39
 getNodeContent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 clearCache
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 walkAsn1Tree
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
8.05
 decodeConstructedChildren
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
5.25
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Handler\SignEngine;
10
11use OCA\Libresign\Vendor\phpseclib4\File\ASN1;
12use OCA\Libresign\Vendor\phpseclib4\File\ASN1\Constructed;
13use OCA\Libresign\Vendor\phpseclib4\File\ASN1\Element;
14use OCA\Libresign\Vendor\phpseclib4\File\ASN1\Types\BaseType;
15use OCA\Libresign\Vendor\phpseclib4\Math\BigInteger;
16
17class TSA {
18    private static bool $areOidsInitialized = false;
19    private static array $asn1DecodingCache = [];
20
21    private const array CERTIFICATE_ATTRIBUTE_OIDS = [
22        '2.5.4.3' => 'commonName',
23        '2.5.4.6' => 'countryName',
24        '2.5.4.10' => 'organizationName',
25        '1.2.840.113549.1.9.1' => 'emailAddress',
26    ];
27
28    private const array TIMESTAMP_OIDS = [
29        'TIME_STAMP_TOKEN' => '1.2.840.113549.1.9.16.2.14',
30        'SIGNING_TIME' => '1.2.840.113549.1.9.5',
31        'TST_INFO' => '1.2.840.113549.1.9.16.1.4',
32    ];
33
34    private static ?array $timestampInfoStructure = null;
35    private const int CACHE_SIZE_LIMIT = 50;
36
37    public function __construct() {
38        $this->ensureOidsAreLoaded();
39    }
40
41    private function processContentCandidate($content, ?string &$cmsDer): array {
42        try {
43            if ($content instanceof Element && $content->value !== '') {
44                return $this->decodeWithCache($cmsDer = $content->value);
45            } elseif ($content instanceof Constructed) {
46                return $this->decodeWithCache($cmsDer = $content->getEncoded());
47            } elseif (is_string($content)) {
48                return $this->decodeWithCache($cmsDer = $content);
49            } elseif (is_array($content)) {
50                return $content;
51            }
52        } catch (\Throwable $e) {
53            error_log('TSA content processing failed: ' . $e->getMessage());
54        }
55        return [];
56    }
57
58    private function ensureOidsAreLoaded(): void {
59        if (self::$areOidsInitialized) {
60            return;
61        }
62
63        ASN1::loadOIDs([
64            'md2' => '1.2.840.113549.2.2',
65            'md5' => '1.2.840.113549.2.5',
66            'id-sha1' => '1.3.14.3.2.26',
67            'id-sha256' => '2.16.840.1.101.3.4.2.1',
68            'id-sha384' => '2.16.840.1.101.3.4.2.2',
69            'id-sha512' => '2.16.840.1.101.3.4.2.3',
70            'timestampToken' => self::TIMESTAMP_OIDS['TIME_STAMP_TOKEN'],
71            'signingTime' => self::TIMESTAMP_OIDS['SIGNING_TIME'],
72            'tstInfo' => self::TIMESTAMP_OIDS['TST_INFO'],
73        ]);
74
75        self::$areOidsInitialized = true;
76    }
77
78    private function convertDerToPkcs7Pem(string $derData): string {
79        return "-----BEGIN PKCS7-----\n" . chunk_split(base64_encode($derData), 64, "\n") . "-----END PKCS7-----\n";
80    }
81
82    private function extractTimestampAuthorityName($timestampElement): array {
83        if (!$timestampElement instanceof Element || $timestampElement->value === '') {
84            return [];
85        }
86
87        try {
88            $decoded = $this->decodeWithCache($timestampElement->value);
89            return ($decoded[0] ?? null) ? $this->extractCertificateHints([$decoded[0]]) : [];
90        } catch (\Throwable) {
91            return [];
92        }
93    }
94
95    private function buildTimestampInfoStructure(): array {
96        return [
97            'type' => ASN1::TYPE_SEQUENCE,
98            'children' => [
99                'version' => ['type' => ASN1::TYPE_INTEGER],
100                'policy' => ['type' => ASN1::TYPE_OBJECT_IDENTIFIER],
101                'messageImprint' => [
102                    'type' => ASN1::TYPE_SEQUENCE,
103                    'children' => [
104                        'hashAlgorithm' => [
105                            'type' => ASN1::TYPE_SEQUENCE,
106                            'children' => [
107                                'algorithm' => ['type' => ASN1::TYPE_OBJECT_IDENTIFIER],
108                                'parameters' => ['optional' => true, 'type' => ASN1::TYPE_ANY],
109                            ],
110                        ],
111                        'hashedMessage' => ['type' => ASN1::TYPE_OCTET_STRING],
112                    ],
113                ],
114                'serialNumber' => ['type' => ASN1::TYPE_INTEGER],
115                'genTime' => ['type' => ASN1::TYPE_GENERALIZED_TIME],
116                'accuracy' => [
117                    'constant' => 0,
118                    'implicit' => true,
119                    'optional' => true,
120                    'type' => ASN1::TYPE_SEQUENCE,
121                    'children' => [
122                        'seconds' => ['constant' => 0, 'implicit' => true, 'optional' => true, 'type' => ASN1::TYPE_INTEGER],
123                        'millis' => ['constant' => 1, 'implicit' => true, 'optional' => true, 'type' => ASN1::TYPE_INTEGER],
124                        'micros' => ['constant' => 2, 'implicit' => true, 'optional' => true, 'type' => ASN1::TYPE_INTEGER],
125                    ],
126                ],
127                'ordering' => ['type' => ASN1::TYPE_BOOLEAN, 'optional' => true],
128                'nonce' => ['type' => ASN1::TYPE_INTEGER, 'optional' => true],
129                'tsa' => ['constant' => 0, 'optional' => true, 'implicit' => true, 'type' => ASN1::TYPE_ANY],
130                'extensions' => ['constant' => 1, 'optional' => true, 'implicit' => true, 'type' => ASN1::TYPE_ANY],
131            ],
132        ];
133    }
134
135    public function extract(array $root): array {
136        if (isset($root['type'])) {
137            $root = [$root];
138        }
139
140        $cmsDer = null;
141        $tstInfoOctets = null;
142        $cnHints = [];
143
144        $values = $this->getAttributeValuesSetAfterOID($root, self::TIMESTAMP_OIDS['TIME_STAMP_TOKEN']);
145        if ($values) {
146            foreach ($values as $candidate) {
147                if (!isset($candidate['content'])) {
148                    continue;
149                }
150
151                $subtree = $this->processContentCandidate($candidate['content'], $cmsDer);
152
153                if (!empty($subtree)) {
154                    $tstInfoOctets = $this->findContentAfterOID($subtree, self::TIMESTAMP_OIDS['TST_INFO'], ASN1::TYPE_OCTET_STRING);
155                    $cnHints = $this->extractCertificateHints($subtree);
156                    if ($tstInfoOctets) {
157                        break; // Found what we need, exit early
158                    }
159                }
160            }
161        }
162        if (!isset($tstInfoOctets)) {
163            $tstInfoOctets = $this->findContentAfterOID($root, self::TIMESTAMP_OIDS['TST_INFO'], ASN1::TYPE_OCTET_STRING);
164            $cnHints = $this->extractCertificateHints($root);
165        }
166
167        $tsa = ['genTime' => null, 'policy' => null, 'serialNumber' => null, 'cnHints' => []];
168
169        if ($tstInfoOctets) {
170            try {
171                $decoded = $this->decodeWithCache($tstInfoOctets);
172                $tstNode = $decoded[0] ?? null;
173
174                $tst = null;
175                if ($tstNode && ($tstNode['type'] ?? null) === ASN1::TYPE_SEQUENCE) {
176                    $mappedTst = ASN1::map($tstNode, self::$timestampInfoStructure ??= $this->buildTimestampInfoStructure());
177                    $tst = $mappedTst instanceof Constructed ? $mappedTst->toArray(true) : null;
178                    if (!is_array($tst)) {
179                        $tst = $this->parseTstInfoFallback($tstInfoOctets);
180                    }
181                }
182            } catch (\Throwable) {
183                $tst = $this->parseTstInfoFallback($tstInfoOctets);
184            }
185
186            if (is_array($tst)) {
187                $tsa['genTime'] = $tst['genTime'] ?? null;
188                $policyOid = isset($tst['policy']) && is_string($tst['policy'])
189                    ? ASN1::getOIDFromName($tst['policy'])
190                    : null;
191                $tsa['policy'] = $policyOid;
192                $tsa['policyName'] = $this->resolveTsaPolicyName($policyOid);
193                $tsa['serialNumber'] = $this->bigToString($tst['serialNumber'] ?? null);
194
195                if (!empty($tst['messageImprint'])) {
196                    $algOid = $tst['messageImprint']['hashAlgorithm']['algorithm'] ?? null;
197
198                    $friendlyName = $this->resolveHashAlgorithm($algOid);
199
200                    $numericOid = $this->getNumericOid($algOid);
201
202                    $tsa['hashAlgorithm'] = $friendlyName;
203                    $tsa['hashAlgorithmOID'] = $numericOid;
204
205                    $hashed = $tst['messageImprint']['hashedMessage'] ?? null;
206                    if (is_string($hashed)) {
207                        $tsa['hashedMessageHex'] = strtoupper(bin2hex($hashed));
208                    }
209                }
210                if (!empty($tst['accuracy'])) {
211                    $acc = $tst['accuracy'];
212                    $tsa['accuracy'] = [
213                        'seconds' => isset($acc['seconds']) ? (int)$this->bigToString($acc['seconds']) : null,
214                        'millis' => isset($acc['millis'])  ? (int)$this->bigToString($acc['millis'])  : null,
215                        'micros' => isset($acc['micros'])  ? (int)$this->bigToString($acc['micros'])  : null,
216                    ];
217                }
218                if (array_key_exists('ordering', $tst)) {
219                    $tsa['ordering'] = (bool)$tst['ordering'];
220                }
221                if (isset($tst['nonce'])) {
222                    $tsa['nonce'] = $this->bigToString($tst['nonce']);
223                }
224                if (isset($tst['tsa'])) {
225                    $tsa['tsa'] = $this->extractTimestampAuthorityName($tst['tsa']);
226                }
227            }
228        }
229
230        if ($cmsDer) {
231            $pem = $this->convertDerToPkcs7Pem($cmsDer);
232            $tsaPemCerts = [];
233            if (@openssl_pkcs7_read($pem, $tsaPemCerts)) {
234                $tsaChain = [];
235                foreach ($tsaPemCerts as $idx => $pemCert) {
236                    $parsed = openssl_x509_parse($pemCert);
237                    if ($parsed) {
238                        $tsaChain[$idx] = $parsed;
239                    }
240                }
241                $tsa['chain'] = array_values($tsaChain);
242            }
243        }
244
245        $tsa['cnHints'] = $cnHints;
246        $tsa['displayName'] = $this->generateDistinguishedNames($cnHints);
247        try {
248            $tsa['genTime'] = $tsa['genTime'] ? new \DateTime($tsa['genTime']) : null;
249        } catch (\Exception) {
250            $tsa['genTime'] = null;
251        }
252
253        return $tsa;
254    }
255
256    public function getSigninTime($root): ?\DateTime {
257        $foundSigningTimeOid = false;
258        if (is_array($root) && isset($root['type'])) {
259            $root = [$root];
260        }
261
262        foreach ($this->walkAsn1Tree(is_array($root) ? $root : []) as $node) {
263            if (($node['type'] ?? null) === ASN1::TYPE_OBJECT_IDENTIFIER
264                && in_array($this->getNodeContent($node), [self::TIMESTAMP_OIDS['SIGNING_TIME'], 'signingTime', 'id-signingTime'], true)) {
265                $foundSigningTimeOid = true;
266                continue;
267            }
268
269            if (!$foundSigningTimeOid || !in_array($node['type'] ?? null, [ASN1::TYPE_UTC_TIME, ASN1::TYPE_GENERALIZED_TIME], true)) {
270                continue;
271            }
272
273            $signingTime = $this->getNodeContent($node);
274            if (!is_string($signingTime) || $signingTime === '') {
275                return null;
276            }
277
278            try {
279                return new \DateTime($signingTime);
280            } catch (\Exception) {
281                return null;
282            }
283        }
284
285        return null;
286    }
287
288    private function parseTstInfoFallback(string $tstInfoOctets): ?array {
289        try {
290            $nodes = $this->decodeWithCache($tstInfoOctets);
291            $root = $nodes[0] ?? null;
292            if (!$root || ($root['type'] ?? null) !== ASN1::TYPE_SEQUENCE) {
293                return null;
294            }
295            $children = is_array($root['content'] ?? null)
296                ? $root['content']
297                : (($root['content'] ?? null) instanceof Constructed ? $this->decodeConstructedChildren($root['content']) : []);
298        } catch (\Throwable) {
299            return null;
300        }
301        $out = ['policy' => null, 'serialNumber' => null, 'genTime' => null];
302
303        $seenPolicy = false;
304        $seenMsgImprint = false;
305        $seenSerial = false;
306
307        foreach ($children as $child) {
308            $t = $child['type'] ?? null;
309
310            $nodeContent = $this->getNodeContent($child);
311            if (!$seenPolicy && $t === ASN1::TYPE_OBJECT_IDENTIFIER && is_string($nodeContent)) {
312                $out['policy'] = $nodeContent;
313                $seenPolicy = true;
314                continue;
315            }
316            $messageImprintParts = is_array($child['content'] ?? null)
317                ? $child['content']
318                : (($child['content'] ?? null) instanceof Constructed ? $this->decodeConstructedChildren($child['content']) : []);
319            if (!$seenMsgImprint && $t === ASN1::TYPE_SEQUENCE && $messageImprintParts !== []) {
320                $hasOID = false;
321                $hasOctet = false;
322                foreach ($messageImprintParts as $miPart) {
323                    if (($miPart['type'] ?? null) === ASN1::TYPE_SEQUENCE) {
324                        $algorithmParts = is_array($miPart['content'] ?? null)
325                            ? $miPart['content']
326                            : (($miPart['content'] ?? null) instanceof Constructed ? $this->decodeConstructedChildren($miPart['content']) : []);
327                        foreach ($algorithmParts as $algPart) {
328                            if (($algPart['type'] ?? null) === ASN1::TYPE_OBJECT_IDENTIFIER) {
329                                $hasOID = true;
330                            }
331                        }
332                    }
333                    if (($miPart['type'] ?? null) === ASN1::TYPE_OCTET_STRING) {
334                        $hasOctet = true;
335                    }
336                }
337                if ($hasOID && $hasOctet) {
338                    $seenMsgImprint = true;
339                    continue;
340                }
341            }
342            if ($seenMsgImprint && !$seenSerial && $t === ASN1::TYPE_INTEGER) {
343                $out['serialNumber'] = $this->bigToString($child['content'] ?? null);
344                $seenSerial = true;
345                continue;
346            }
347            if ($t === ASN1::TYPE_GENERALIZED_TIME || $t === ASN1::TYPE_UTC_TIME) {
348                if (is_string($nodeContent)) {
349                    $out['genTime'] = $nodeContent;
350                }
351            }
352        }
353
354        if (!$out['genTime']) {
355            foreach ($this->walkAsn1Tree([$root]) as $n) {
356                $tt = $n['type'] ?? null;
357                $nodeContent = $this->getNodeContent($n);
358                if (($tt === ASN1::TYPE_GENERALIZED_TIME || $tt === ASN1::TYPE_UTC_TIME) && is_string($nodeContent)) {
359                    $out['genTime'] = $nodeContent;
360                    break;
361                }
362            }
363        }
364        return $out;
365    }
366
367    private function getAttributeValuesSetAfterOID(array $tree, string $oid): ?array {
368        $seen = false;
369        foreach ($this->walkAsn1Tree($tree) as $n) {
370            if (($n['type'] ?? null) === ASN1::TYPE_OBJECT_IDENTIFIER && $this->matchesOid($this->getNodeContent($n), $oid)) {
371                $seen = true;
372                continue;
373            }
374            if ($seen && ($n['type'] ?? null) === ASN1::TYPE_SET) {
375                if (is_array($n['content'] ?? null)) {
376                    return $n['content'];
377                }
378                if (($n['content'] ?? null) instanceof Constructed) {
379                    return $this->decodeConstructedChildren($n['content']);
380                }
381            }
382        }
383        return null;
384    }
385
386    private function findContentAfterOID(array $tree, string $oid, int $expectedType): ?string {
387        $seen = false;
388        foreach ($this->walkAsn1Tree($tree) as $n) {
389            if (($n['type'] ?? null) === ASN1::TYPE_OBJECT_IDENTIFIER
390                && $this->matchesOid($this->getNodeContent($n), $oid)
391            ) {
392                $seen = true;
393                continue;
394            }
395            if ($seen
396                && ($n['type'] ?? null) === $expectedType
397                && is_string($this->getNodeContent($n))
398            ) {
399                return $this->getNodeContent($n);
400            }
401        }
402        return null;
403    }
404
405    private function matchesOid(mixed $candidate, string $expected): bool {
406        return is_string($candidate)
407            && ASN1::getOIDFromName($candidate) === ASN1::getOIDFromName($expected);
408    }
409
410    private function extractCertificateHints(array $asn1Tree): array {
411        $certificateHints = [];
412        $currentAttributeOid = null;
413
414        foreach ($this->walkAsn1Tree($asn1Tree) as $node) {
415            $nodeContent = $this->getNodeContent($node);
416            $nodeOid = is_string($nodeContent) ? ASN1::getOIDFromName($nodeContent) : null;
417            if (($node['type'] ?? null) === ASN1::TYPE_OBJECT_IDENTIFIER
418                && $nodeOid !== null && isset(self::CERTIFICATE_ATTRIBUTE_OIDS[$nodeOid])) {
419                $currentAttributeOid = $nodeOid;
420                continue;
421            }
422
423            if ($currentAttributeOid !== null
424                && is_string($nodeContent)
425                && $this->isStringValidUtf8($nodeContent)
426            ) {
427                $certificateHints[self::CERTIFICATE_ATTRIBUTE_OIDS[$currentAttributeOid]] = $nodeContent;
428                $currentAttributeOid = null;
429            }
430        }
431        return $certificateHints;
432    }
433
434    private function isStringValidUtf8(string $text): bool {
435        return mb_check_encoding($text, 'UTF-8')
436            && preg_match('/[\P{C}]/u', $text)
437            && !preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $text);
438    }
439
440    private function generateDistinguishedNames(array $hints): string {
441        $mapping = [
442            'countryName' => 'C',
443            'stateOrProvinceName' => 'ST',
444            'localityName' => 'L',
445            'organizationName' => 'O',
446            'organizationalUnitName' => 'OU',
447            'commonName' => 'CN',
448            'description' => 'description',
449            'emailAddress' => 'emailAddress',
450        ];
451
452        $parts = array_filter(
453            array_map(
454                fn ($field) => empty($hints[$field])
455                    ? null
456                    : $mapping[$field] . '=' . addcslashes((string)$hints[$field], '/+<>"#;'),
457                array_keys($mapping)
458            )
459        );
460
461        return '/' . implode('/', $parts);
462    }
463
464    private function bigToString($v): ?string {
465        return match (true) {
466            $v === null => null,
467            $v instanceof BigInteger => $v->toString(),
468            is_int($v) => (string)$v,
469            is_string($v) => ctype_digit($v) ? $v : null,
470            is_array($v) && isset($v['content']) => $this->bigToString($v['content']),
471            default => null,
472        };
473    }
474
475    private function resolveHashAlgorithm(?string $oid): ?string {
476        return match ($oid) {
477            null => null,
478            '1.3.14.3.2.26' => 'SHA-1',
479            '2.16.840.1.101.3.4.2.4' => 'SHA-224',
480            '2.16.840.1.101.3.4.2.1' => 'SHA-256',
481            '2.16.840.1.101.3.4.2.2' => 'SHA-384',
482            '2.16.840.1.101.3.4.2.3' => 'SHA-512',
483            '1.2.840.113549.2.5' => 'MD5',
484            '1.2.840.113549.2.2' => 'MD2',
485            'id-sha1', 'sha1withrsaencryption', 'ecdsa-with-sha1', 'id-dsa-with-sha1' => 'SHA-1',
486            'id-sha224', 'sha224withrsaencryption', 'ecdsa-with-sha224', 'id-dsa-with-sha224' => 'SHA-224',
487            'id-sha256', 'sha256withrsaencryption', 'ecdsa-with-sha256', 'id-dsa-with-sha256' => 'SHA-256',
488            'id-sha384', 'sha384withrsaencryption', 'ecdsa-with-sha384' => 'SHA-384',
489            'id-sha512', 'sha512withrsaencryption', 'ecdsa-with-sha512' => 'SHA-512',
490            'md2', 'md2withrsaencryption' => 'MD2',
491            'md5', 'md5withrsaencryption' => 'MD5',
492            default => $oid, // Return original if not mapped
493        };
494    }
495
496    private function getNumericOid(?string $oid): ?string {
497        if (!$oid || preg_match('/^\d+(\.\d+)*$/', $oid)) {
498            return $oid;
499        }
500
501        return match ($oid) {
502            'id-sha1', 'sha1withrsaencryption', 'ecdsa-with-sha1', 'id-dsa-with-sha1' => '1.3.14.3.2.26',
503            'id-sha224', 'sha224withrsaencryption', 'ecdsa-with-sha224', 'id-dsa-with-sha224' => '2.16.840.1.101.3.4.2.4',
504            'id-sha256', 'sha256withrsaencryption', 'ecdsa-with-sha256', 'id-dsa-with-sha256' => '2.16.840.1.101.3.4.2.1',
505            'id-sha384', 'sha384withrsaencryption', 'ecdsa-with-sha384' => '2.16.840.1.101.3.4.2.2',
506            'id-sha512', 'sha512withrsaencryption', 'ecdsa-with-sha512' => '2.16.840.1.101.3.4.2.3',
507            'md2', 'md2withrsaencryption' => '1.2.840.113549.2.2',
508            'md5', 'md5withrsaencryption' => '1.2.840.113549.2.5',
509            default => $oid, // Return original if not mapped
510        };
511    }
512
513    private function resolveTsaPolicyName(?string $policyOid): ?string {
514        if (!$policyOid) {
515            return null;
516        }
517
518        $resolved = ASN1::getNameFromOID($policyOid);
519        if ($resolved && $resolved !== $policyOid) {
520            return $resolved;
521        }
522
523        return match ($policyOid) {
524            '1.2.3.4.1' => 'FreeTSA Policy',
525            '1.3.6.1.4.1.601.10.3.1' => 'VeriSign TSA Policy',
526            '1.3.6.1.4.1.311.3.2.1' => 'Microsoft TSA Policy',
527            '2.16.840.1.114412.7.1' => 'DigiCert TSA Policy',
528            '1.3.6.1.4.1.8302.3.1' => 'Comodo TSA Policy',
529            '2.16.840.1.113733.1.7.23.3' => 'Symantec TSA Policy',
530            default => null,
531        };
532    }
533
534    private function decodeWithCache(string $asn1Data): array {
535        $cacheKey = hash('xxh3', $asn1Data);
536
537        if (isset(self::$asn1DecodingCache[$cacheKey])) {
538            return self::$asn1DecodingCache[$cacheKey];
539        }
540
541        $decodedResult = ASN1::decodeBER($asn1Data);
542        if (isset($decodedResult['type'])) {
543            $decodedResult = [$decodedResult];
544        }
545        if ($decodedResult === null) {
546            $decodedResult = [];
547        }
548
549        if (count(self::$asn1DecodingCache) >= self::CACHE_SIZE_LIMIT) {
550            array_shift(self::$asn1DecodingCache);
551        }
552
553        self::$asn1DecodingCache[$cacheKey] = $decodedResult;
554        return $decodedResult;
555    }
556
557    private function getNodeContent(array $node): mixed {
558        $content = $node['content'] ?? null;
559        if (!$content instanceof BaseType) {
560            return $content;
561        }
562
563        return ASN1::convertToPrimitive($content);
564    }
565
566    public static function clearCache(): void {
567        self::$asn1DecodingCache = [];
568    }
569
570    private function walkAsn1Tree(array $nodes): \Generator {
571        $processingStack = $nodes;
572
573        while (!empty($processingStack)) {
574            $currentNode = array_shift($processingStack);
575            if (!is_array($currentNode)) {
576                continue;
577            }
578            yield $currentNode;
579
580            foreach (['content', 'children'] as $childrenKey) {
581                if (isset($currentNode[$childrenKey]) && is_array($currentNode[$childrenKey])) {
582                    array_unshift($processingStack, ...$currentNode[$childrenKey]);
583                } elseif ($childrenKey === 'content' && ($currentNode[$childrenKey] ?? null) instanceof Constructed) {
584                    array_unshift($processingStack, ...$this->decodeConstructedChildren($currentNode[$childrenKey]));
585                }
586            }
587        }
588    }
589
590    /** @return list<array> */
591    private function decodeConstructedChildren(Constructed $node): array {
592        $encoded = $node->getEncodedWithoutHeader();
593        $children = [];
594        $offset = 0;
595
596        while ($offset < strlen($encoded)) {
597            try {
598                $child = ASN1::decodeBER(substr($encoded, $offset));
599            } catch (\Throwable) {
600                break;
601            }
602
603            $headerLength = $child['headerlength'] ?? null;
604            $length = $child['length'] ?? null;
605            if (!is_int($headerLength) || !is_int($length)) {
606                break;
607            }
608
609            $children[] = $child;
610            $offset += $headerLength + $length;
611        }
612
613        return $children;
614    }
615}