Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 123 additions & 35 deletions lib/Handler/CertificateEngine/OrderCertificatesTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,66 +12,154 @@

trait OrderCertificatesTrait {
public function orderCertificates(array $certificates): array {
$this->validateCertificateStructure($certificates);
$remainingCerts = [];
if (empty($certificates)) {
throw new InvalidArgumentException('Certificate list cannot be empty');
}

$this->ensureValidStructure($certificates);

return match (true) {
count($certificates) === 1 => $certificates,
default => $this->buildChain($certificates)
};
}

private function buildChain(array $certificates): array {
$leaf = $this->findLeafCertificate($certificates);
if (!$leaf) {
return $certificates;
}

// Add the root cert at ordered list and collect the remaining certs
$ordered = [$leaf];
$remaining = $this->excludeCertificate($certificates, $leaf);

while ($remaining && !$this->isRootCertificate(end($ordered))) {
[$next, $remaining] = $this->findIssuer(end($ordered), $remaining);
if (!$next) {
break;
}
$ordered[] = $next;
}

return [...$ordered, ...$remaining];
}

private function ensureValidStructure(array $certificates): void {
foreach ($certificates as $cert) {
if (!$this->arrayDiffCanonicalized($cert['subject'], $cert['issuer'])) {
$ordered = [$cert];
continue;
if (!is_array($cert) || !isset($cert['subject'], $cert['issuer'], $cert['name'])) {
throw new InvalidArgumentException('Invalid certificate structure. Certificate must have "subject", "issuer", and "name".');
}
if (!is_array($cert['subject']) || !is_array($cert['issuer'])) {
throw new InvalidArgumentException('Invalid certificate structure. Certificate must have "subject", "issuer", and "name".');
}
$remainingCerts[$cert['name']] = $cert;
}

if (!isset($ordered)) {
return $certificates;
$names = array_column($certificates, 'name');
if (count($names) !== count(array_unique($names))) {
throw new InvalidArgumentException('Duplicate certificate names detected');
}
}

private function findLeafCertificate(array $certificates): ?array {
$issuers = [];
foreach ($certificates as $cert) {
if (isset($cert['issuer'])) {
$issuers[] = $this->normalizeDistinguishedName($cert['issuer']);
}
}

while (!empty($remainingCerts)) {
$found = false;
foreach ($remainingCerts as $name => $cert) {
$first = reset($ordered);
if (!$this->arrayDiffCanonicalized($first['subject'], $cert['issuer'])) {
array_unshift($ordered, $cert);
unset($remainingCerts[$name]);
$found = true;
break;
}
foreach ($certificates as $cert) {
if (!isset($cert['subject'])) {
continue;
}
$subject = $this->normalizeDistinguishedName($cert['subject']);
if (!in_array($subject, $issuers, true)) {
return $cert;
}
}

return $certificates[0] ?? null;
}

if (!$found) {
throw new InvalidArgumentException('Certificate chain is incomplete or invalid.');
private function findIssuer(array $cert, array $certificates): array {
foreach ($certificates as $index => $candidate) {
if ($this->isIssuedBy($cert, $candidate)) {
unset($certificates[$index]);
return [$candidate, array_values($certificates)];
}
}
return [null, $certificates];
}

private function excludeCertificate(array $certificates, array $toExclude): array {
return array_values(array_filter($certificates, fn ($cert) => $cert['name'] !== $toExclude['name']));
}

private function isRootCertificate(array $cert): bool {
if (!isset($cert['subject'], $cert['issuer']) || !is_array($cert['subject']) || !is_array($cert['issuer'])) {
return false;
}
return $this->normalizeDistinguishedName($cert['subject']) === $this->normalizeDistinguishedName($cert['issuer']);
}

return $ordered;
private function isIssuedBy(array $child, array $parent): bool {
if (!isset($child['issuer'], $parent['subject']) || !is_array($child['issuer']) || !is_array($parent['subject'])) {
return false;
}
return $this->normalizeDistinguishedName($child['issuer']) === $this->normalizeDistinguishedName($parent['subject']);
}

private function validateCertificateStructure(array $certificates): void {
private function normalizeDistinguishedName(array $dn): string {
ksort($dn);
return json_encode($dn, JSON_THROW_ON_ERROR);
}

public function validateCertificateChain(array $certificates): array {
return [
'valid' => $this->isValidChain($certificates),
'hasRoot' => $this->hasRootCertificate($certificates),
'isComplete' => $this->isCompleteChain($certificates),
'length' => count($certificates),
];
}

private function isValidChain(array $certificates): bool {
if (empty($certificates)) {
throw new InvalidArgumentException('Certificate list cannot be empty');
return false;
}

foreach ($certificates as $cert) {
if (!isset($cert['subject'], $cert['issuer'], $cert['name'])) {
throw new InvalidArgumentException(
'Invalid certificate structure. Certificate must have "subject", "issuer", and "name".'
);
if (!is_array($cert) || !isset($cert['subject'], $cert['issuer'], $cert['name'])
|| !is_array($cert['subject']) || !is_array($cert['issuer'])
|| empty($cert['subject']['CN']) || empty($cert['issuer']['CN'])) {
return false;
}
}

$names = array_column($certificates, 'name');
if (count($names) !== count(array_unique($names))) {
throw new InvalidArgumentException('Duplicate certificate names detected');
return true;
}

private function hasRootCertificate(array $certificates): bool {
foreach ($certificates as $cert) {
if ($this->isRootCertificate($cert)) {
return true;
}
}
return false;
}

private function arrayDiffCanonicalized(array $array1, array $array2): array {
sort($array1);
sort($array2);
private function isCompleteChain(array $certificates): bool {
if (!$this->hasRootCertificate($certificates)) {
return false;
}

$ordered = $this->orderCertificates($certificates);
for ($i = 0; $i < count($ordered) - 1; $i++) {
if (!$this->isIssuedBy($ordered[$i], $ordered[$i + 1])) {
return false;
}
}

return array_diff($array1, $array2);
return true;
}
}
131 changes: 80 additions & 51 deletions lib/Handler/SignEngine/Pkcs12Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,6 @@ class Pkcs12Handler extends SignEngineHandler {
use OrderCertificatesTrait;
protected string $certificate = '';
private array $signaturesFromPoppler = [];
/**
* Used by method self::getHandler()
*/
private ?JSignPdfHandler $jSignPdfHandler = null;

public function __construct(
Expand Down Expand Up @@ -58,11 +55,8 @@ private function getSignatures($resource): iterable {
}

for ($i = 0; $i < count($bytes['offset1']); $i++) {
// Starting position (in bytes) of the first part of the PDF that will be included in the validation.
$offset1 = (int)$bytes['offset1'][$i];
// Length (in bytes) of the first part.
$length1 = (int)$bytes['length1'][$i];
// Starting position (in bytes) of the second part, immediately after the signature.
$offset2 = (int)$bytes['offset2'][$i];

$signatureStart = $offset1 + $length1 + 1;
Expand All @@ -84,59 +78,95 @@ private function getSignatures($resource): iterable {
* @return array
*/
public function getCertificateChain($resource): array {
$signerCounter = 0;
$certificates = [];
$signerCounter = 0;

foreach ($this->getSignatures($resource) as $signature) {
// The signature could be invalid
$fromFallback = $this->popplerUtilsPdfSignFallback($resource, $signerCounter);
if ($fromFallback) {
$certificates[$signerCounter] = $fromFallback;
}
if (!$signature) {
$certificates[$signerCounter]['chain'][0]['signature_validation'] = $this->getReadableSigState('Digest Mismatch.');
$signerCounter++;
continue;
$certificates[$signerCounter] = $this->processSignature($resource, $signature, $signerCounter);
$signerCounter++;
}
return $certificates;
}

private function processSignature($resource, ?string $signature, int $signerCounter): array {
$fromFallback = $this->popplerUtilsPdfSignFallback($resource, $signerCounter);
$result = $fromFallback ?: [];

if (!$signature) {
$result['chain'][0]['signature_validation'] = $this->getReadableSigState('Digest Mismatch.');
return $result;
}

$decoded = ASN1::decodeBER($signature);
$result = $this->extractTimestampData($decoded, $result);

$chain = $this->extractCertificateChain($signature);
if (!empty($chain)) {
$result['chain'] = $this->orderCertificates($chain);
$this->enrichLeafWithPopplerData($result, $fromFallback);
}
return $result;
}

private function extractTimestampData(array $decoded, array $result): array {
$tsa = new TSA();

try {
$timestampData = $tsa->extract($decoded);
if (!empty($timestampData['genTime']) || !empty($timestampData['policy']) || !empty($timestampData['serialNumber'])) {
$result['timestamp'] = $timestampData;
}
} catch (\Throwable $e) {
}

$tsa = new TSA();
$decoded = ASN1::decodeBER($signature);
try {
$timestampData = $tsa->extract($decoded);
if (!empty($timestampData['genTime']) || !empty($timestampData['policy']) || !empty($timestampData['serialNumber'])) {
$certificates[$signerCounter]['timestamp'] = $timestampData;
}
} catch (\Throwable $e) {
if (!isset($result['signingTime']) || !$result['signingTime'] instanceof \DateTime) {
$result['signingTime'] = $tsa->getSigninTime($decoded);
}
return $result;
}

private function extractCertificateChain(string $signature): array {
$pkcs7PemSignature = $this->der2pem($signature);
$pemCertificates = [];

if (!openssl_pkcs7_read($pkcs7PemSignature, $pemCertificates)) {
return [];
}

$chain = [];
foreach ($pemCertificates as $index => $pemCertificate) {
$parsed = openssl_x509_parse($pemCertificate);
if ($parsed) {
$parsed['signature_validation'] = [
'id' => 1,
'label' => $this->l10n->t('Signature is valid.'),
];
$chain[$index] = $parsed;
}
}

if (!isset($fromFallback['signingTime']) || !$fromFallback['signingTime'] instanceof \DateTime) {
$certificates[$signerCounter]['signingTime'] = $tsa->getSigninTime($decoded);
return $chain;
}

private function enrichLeafWithPopplerData(array &$result, array $fromFallback): void {
if (empty($fromFallback['chain'][0]) || empty($result['chain'][0])) {
return;
}

$popplerData = $fromFallback['chain'][0];
$leafCert = &$result['chain'][0];

$popplerOnlyFields = ['field', 'range', 'certificate_validation'];
foreach ($popplerOnlyFields as $field) {
if (isset($popplerData[$field])) {
$leafCert[$field] = $popplerData[$field];
}
}

$pkcs7PemSignature = $this->der2pem($signature);
if (openssl_pkcs7_read($pkcs7PemSignature, $pemCertificates)) {
foreach ($pemCertificates as $certificateIndex => $pemCertificate) {
$parsed = openssl_x509_parse($pemCertificate);
foreach ($parsed as $key => $value) {
if (!isset($certificates[$signerCounter]['chain'][$certificateIndex][$key])) {
$certificates[$signerCounter]['chain'][$certificateIndex][$key] = $value;
} elseif ($key === 'name') {
$certificates[$signerCounter]['chain'][$certificateIndex][$key] = $value;
} elseif ($key === 'signatureTypeSN' && $certificates[$signerCounter]['chain'][$certificateIndex][$key] !== $value) {
$certificates[$signerCounter]['chain'][$certificateIndex][$key] = $value;
}
}
if (empty($certificates[$signerCounter]['chain'][$certificateIndex]['signature_validation'])) {
$certificates[$signerCounter]['chain'][$certificateIndex]['signature_validation'] = [
'id' => 1,
'label' => $this->l10n->t('Signature is valid.'),
];
}
}
};
$certificates[$signerCounter]['chain'] = $this->orderCertificates($certificates[$signerCounter]['chain']);
$signerCounter++;
if (isset($popplerData['signature_validation'])
&& (!isset($leafCert['signature_validation']) || $leafCert['signature_validation']['id'] !== 1)) {
$leafCert['signature_validation'] = $popplerData['signature_validation'];
}
return $certificates;
}

private function popplerUtilsPdfSignFallback($resource, int $signerCounter): array {
Expand Down Expand Up @@ -166,7 +196,6 @@ private function popplerUtilsPdfSignFallback($resource, int $signerCounter): arr
continue;
}


$match = [];
$isSecondLevel = preg_match('/^\s+-\s(?<key>.+):\s(?<value>.*)/', $item, $match);
if ($isSecondLevel) {
Expand Down
Loading
Loading