Skip to content
86 changes: 86 additions & 0 deletions lib/Handler/SignEngine/JSignPdf/HashAlgorithmResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Handler\SignEngine\JSignPdf;

use OCA\Libresign\AppInfo\Application;
use OCP\IAppConfig;

/**
* Resolves which hash algorithm JSignPdf has to use.
*
* The algorithm that signs the document depends on the PDF version, so it is
* not a valid answer for the other hashes JSignPdf takes: each one needs its
* own method here.
*/
class HashAlgorithmResolver {
private const MIN_PDF_VERSION_SHA256 = 1.6;
private const MIN_PDF_VERSION_SHA1_REJECT = 1.7;
private const DEFAULT_ALGORITHM = 'SHA256';
/** @var string[] */
private const SUPPORTED_ALGORITHMS = ['SHA1', 'SHA256', 'SHA384', 'SHA512', 'RIPEMD160'];

public function __construct(
private IAppConfig $appConfig,
) {
}

/**
* Algorithm used to sign a document with the given PDF version.
*
* @param float|null $pdfVersion null when the content carries no readable PDF header
*/
public function forSignature(?float $pdfVersion): string {
$configuredAlgorithm = $this->getConfiguredAlgorithm();
/**
* Need to respect the follow code:
* https://github.com/intoolswetrust/jsignpdf/blob/JSignPdf_2_2_2/jsignpdf/src/main/java/net/sf/jsignpdf/types/HashAlgorithm.java#L46-L47
*/
if ($pdfVersion === null) {
return $this->validate($configuredAlgorithm);
}

return $this->forPdfVersion($pdfVersion, $configuredAlgorithm);
}

/**
* PDFs older than 1.6 have to be upgraded before JSignPdf accepts SHA-256.
*/
public function requiresPdfVersionUpgradeForSha256(float $pdfVersion): bool {
if ($pdfVersion >= self::MIN_PDF_VERSION_SHA256) {
return false;
}

return $this->getConfiguredAlgorithm() === self::DEFAULT_ALGORITHM;
}

private function forPdfVersion(float $pdfVersion, string $configuredAlgorithm): string {
// Legacy compatibility: JSignPdf still requires SHA1 for very old PDFs (< 1.6).
// The policy still exposes SHA1 for supported legacy workflows, and the runtime
// must continue enforcing this fallback for ancient PDFs that JSignPdf cannot sign otherwise.
if ($pdfVersion < self::MIN_PDF_VERSION_SHA256) {
return 'SHA1';
}
if ($pdfVersion < self::MIN_PDF_VERSION_SHA1_REJECT) {
return self::DEFAULT_ALGORITHM;
}
if ($configuredAlgorithm === 'SHA1') {
return self::DEFAULT_ALGORITHM;
}

return $this->validate($configuredAlgorithm);
}

private function validate(string $algorithm): string {
return in_array($algorithm, self::SUPPORTED_ALGORITHMS, true) ? $algorithm : self::DEFAULT_ALGORITHM;
}

private function getConfiguredAlgorithm(): string {
return $this->appConfig->getValueString(Application::APP_ID, 'signature_hash_algorithm', self::DEFAULT_ALGORITHM);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Handler\SignEngine;
namespace OCA\Libresign\Handler\SignEngine\JSignPdf;

use Imagick;
use ImagickPixel;
use OCA\Libresign\AppInfo\Application;
use OCA\Libresign\Exception\LibresignException;
use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
use OCA\Libresign\Handler\SignEngine\Pkcs12Handler;
use OCA\Libresign\Helper\JavaHelper;
use OCA\Libresign\Service\DocMdp\ConfigService as DocMdpConfigService;
use OCA\Libresign\Service\SignatureBackgroundService;
Expand All @@ -28,9 +29,7 @@
class JSignPdfHandler extends Pkcs12Handler {
private const MIN_PDF_VERSION = 1.2;
private const TARGET_OLD_PDF_VERSION = '1.3';
private const MIN_PDF_VERSION_SHA256 = 1.6;
private const TARGET_PDF_VERSION_SHA256 = '1.6';
private const MIN_PDF_VERSION_SHA1_REJECT = 1.7;
private const SIGNATURE_DEFAULT_FONT_SIZE = 10.0;
private const PAGE_FIRST = 1;
private const SCALE_FACTOR_MIN = 5;
Expand All @@ -50,6 +49,7 @@ public function __construct(
protected CertificateEngineFactory $certificateEngineFactory,
protected JavaHelper $javaHelper,
private DocMdpConfigService $docMdpConfigService,
private HashAlgorithmResolver $hashAlgorithmResolver,
) {
}

Expand Down Expand Up @@ -146,46 +146,13 @@ private function createEmptyFile(string $path): void {
fclose($file);
}

private function getHashAlgorithm(string $pdfContent): string {
$configuredAlgorithm = $this->appConfig->getValueString(Application::APP_ID, 'signature_hash_algorithm', 'SHA256');
/**
* Need to respect the follow code:
* https://github.com/intoolswetrust/jsignpdf/blob/JSignPdf_2_2_2/jsignpdf/src/main/java/net/sf/jsignpdf/types/HashAlgorithm.java#L46-L47
*/
$pdfVersion = $this->extractPdfVersion($pdfContent);

if ($pdfVersion === null) {
return $this->validateHashAlgorithm($configuredAlgorithm);
}

return $this->getHashAlgorithmForPdfVersion($pdfVersion, $configuredAlgorithm);
}

private function extractPdfVersion(string $content): ?float {
if (!preg_match('/^%PDF-(?<version>\d+(\.\d+)?)/', $content, $match)) {
return null;
}
return (float)$match['version'];
}

private function getHashAlgorithmForPdfVersion(float $pdfVersion, string $configuredAlgorithm): string {
if ($pdfVersion < 1.6) {
return 'SHA1';
}
if ($pdfVersion < self::MIN_PDF_VERSION_SHA1_REJECT) {
return 'SHA256';
}
if ($pdfVersion >= self::MIN_PDF_VERSION_SHA1_REJECT && $configuredAlgorithm === 'SHA1') {
return 'SHA256';
}
return $this->validateHashAlgorithm($configuredAlgorithm);
}

private function validateHashAlgorithm(string $algorithm): string {
$supportedAlgorithms = ['SHA1', 'SHA256', 'SHA384', 'SHA512', 'RIPEMD160'];
return in_array($algorithm, $supportedAlgorithms) ? $algorithm : 'SHA256';
}

/**
* Normalizes very old PDFs (1.0/1.1) to 1.3.
* Rationale: JSignPDF enum PdfVersion only defines 1.2+; for 1.0/1.1,
Expand All @@ -205,7 +172,7 @@ private function normalizePdfVersion(string $content): string {

// Convert PDFs < 1.6 to 1.6 if using SHA-256 (the default hash algorithm)
// This prevents "The chosen hash algorithm (SHA-256) requires a newer PDF version" error
if ($this->requiresPdfVersionUpgradeForSha256($version)) {
if ($this->hashAlgorithmResolver->requiresPdfVersionUpgradeForSha256($version)) {
return $this->replacePdfVersion($content, self::TARGET_PDF_VERSION_SHA256);
}

Expand All @@ -216,14 +183,6 @@ private function isVeryOldPdfVersion(float $version): bool {
return $version > 0 && $version < self::MIN_PDF_VERSION;
}

private function requiresPdfVersionUpgradeForSha256(float $version): bool {
if ($version >= self::MIN_PDF_VERSION_SHA256) {
return false;
}
$hashAlgorithm = $this->appConfig->getValueString(Application::APP_ID, 'signature_hash_algorithm', 'SHA256');
return $hashAlgorithm === 'SHA256';
}

private function replacePdfVersion(string $content, string $newVersion): string {
return (string)preg_replace('/^%PDF-\d+(\.\d+)?/', '%PDF-' . $newVersion, $content, 1);
}
Expand All @@ -248,7 +207,7 @@ public function sign(): File {
#[\Override]
public function getSignedContent(): string {
$normalizedPdf = $this->normalizePdfVersion($this->getInputFile()->getContent());
$hashAlgorithm = $this->getHashAlgorithm($normalizedPdf);
$hashAlgorithm = $this->hashAlgorithmResolver->forSignature($this->extractPdfVersion($normalizedPdf));
$param = $this->getJSignParam();
$param->setCertificate($this->getCertificate())
->setPdf($normalizedPdf)
Expand Down
12 changes: 10 additions & 2 deletions lib/Handler/SignEngine/Pkcs12Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OCA\Libresign\Handler\CertificateEngine\OrderCertificatesTrait;
use OCA\Libresign\Handler\DocMdpHandler;
use OCA\Libresign\Handler\FooterHandler;
use OCA\Libresign\Handler\SignEngine\JSignPdf\JSignPdfHandler;
use OCA\Libresign\Service\CaIdentifierService;
use OCA\Libresign\Service\Crl\CrlService;
use OCA\Libresign\Service\FolderService;
Expand All @@ -30,6 +31,11 @@
class Pkcs12Handler extends SignEngineHandler {
use OrderCertificatesTrait;
protected string $certificate = '';
/** @var array<string, class-string<SignEngineHandler>> */
private const ENGINE_HANDLERS = [
'jSignPdfHandler' => JSignPdfHandler::class,
'phpNativeHandler' => PhpNativeHandler::class,
];
private ?JSignPdfHandler $jSignPdfHandler = null;
private ?PhpNativeHandler $phpNativeHandler = null;
private string $rootCertificatePem = '';
Expand Down Expand Up @@ -358,11 +364,13 @@ private function enrichLeafWithNativeData(
private function getHandler(): SignEngineHandler {
$sign_engine = $this->appConfig->getValueString(Application::APP_ID, 'signature_engine', 'JSignPdf');
$property = lcfirst($sign_engine) . 'Handler';
if (!property_exists($this, $property)) {
// Resolved through a class map instead of a name built at runtime, so
// moving a handler to another namespace cannot break this silently.
if (!isset(self::ENGINE_HANDLERS[$property])) {
// TRANSLATORS API/config error when LibreSign's signature_engine setting names a backend that is not available (for example a mistyped JSignPdf/native engine).
throw new LibresignException($this->l10n->t('Invalid Sign engine.'), 400);
}
$classHandler = 'OCA\\Libresign\\Handler\\SignEngine\\' . ucfirst($property);
$classHandler = self::ENGINE_HANDLERS[$property];
if (!$this->$property instanceof $classHandler) {
$this->$property = \OCP\Server::get($classHandler);
}
Expand Down
2 changes: 1 addition & 1 deletion lib/SetupCheck/JSignPdfSetupCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
namespace OCA\Libresign\SetupCheck;

use OCA\Libresign\AppInfo\Application;
use OCA\Libresign\Handler\SignEngine\JSignPdfHandler;
use OCA\Libresign\Handler\SignEngine\JSignPdf\JSignPdfHandler;
use OCA\Libresign\Helper\JavaHelper;
use OCA\Libresign\Service\Install\InstallService;
use OCA\Libresign\Service\Install\SignSetupService;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Tests\Unit\Handler\SignEngine\JSignPdf;

use OCA\Libresign\AppInfo\Application;
use OCA\Libresign\Handler\SignEngine\JSignPdf\HashAlgorithmResolver;
use OCP\IAppConfig;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

class HashAlgorithmResolverTest extends TestCase {
private IAppConfig&MockObject $appConfig;

#[\Override]
protected function setUp(): void {
$this->appConfig = $this->createMock(IAppConfig::class);
}

private function getInstance(string $configuredAlgorithm): HashAlgorithmResolver {
$this->appConfig
->method('getValueString')
->with(Application::APP_ID, 'signature_hash_algorithm', 'SHA256')
->willReturn($configuredAlgorithm);

return new HashAlgorithmResolver($this->appConfig);
}

#[DataProvider('providerSignatureHashAlgorithm')]
public function testForSignature(string $configuredAlgorithm, ?float $pdfVersion, string $expected): void {
$resolver = $this->getInstance($configuredAlgorithm);

$this->assertSame($expected, $resolver->forSignature($pdfVersion));
}

public static function providerSignatureHashAlgorithm(): array {
return [
// Unknown PDF version: only the configured algorithm decides.
'unknown version keeps a supported algorithm' => ['SHA384', null, 'SHA384'],
'unknown version keeps RIPEMD160' => ['RIPEMD160', null, 'RIPEMD160'],
'unknown version falls back on an empty algorithm' => ['', null, 'SHA256'],
'unknown version falls back on an unsupported algorithm' => ['XYZ', null, 'SHA256'],
// JSignPdf only accepts SHA1 in PDFs older than 1.6.
'PDF 1.0 is signed with SHA1' => ['SHA256', 1.0, 'SHA1'],
'PDF 1.5 is signed with SHA1' => ['SHA512', 1.5, 'SHA1'],
// Between 1.6 and 1.7 JSignPdf only accepts SHA256.
'PDF 1.6 is signed with SHA256' => ['SHA384', 1.6, 'SHA256'],
'PDF 1.6 ignores an unsupported algorithm' => ['XYZ', 1.6, 'SHA256'],
// From 1.7 on the configured algorithm is used, except SHA1.
'PDF 1.7 keeps the configured SHA384' => ['SHA384', 1.7, 'SHA384'],
'PDF 1.7 keeps the configured SHA512' => ['SHA512', 1.7, 'SHA512'],
'PDF 1.7 keeps the configured RIPEMD160' => ['RIPEMD160', 1.7, 'RIPEMD160'],
'PDF 1.7 replaces SHA1 with SHA256' => ['SHA1', 1.7, 'SHA256'],
'PDF 2.0 replaces SHA1 with SHA256' => ['SHA1', 2.0, 'SHA256'],
'PDF 2.0 falls back on an unsupported algorithm' => ['XYZ', 2.0, 'SHA256'],
'PDF 2.0 keeps the configured SHA512' => ['SHA512', 2.0, 'SHA512'],
];
}

#[DataProvider('providerPdfVersionUpgrade')]
public function testRequiresPdfVersionUpgradeForSha256(string $configuredAlgorithm, float $pdfVersion, bool $expected): void {
$resolver = $this->getInstance($configuredAlgorithm);

$this->assertSame($expected, $resolver->requiresPdfVersionUpgradeForSha256($pdfVersion));
}

public static function providerPdfVersionUpgrade(): array {
return [
'SHA256 in a PDF 1.2 needs the upgrade' => ['SHA256', 1.2, true],
'SHA256 in a PDF 1.5 needs the upgrade' => ['SHA256', 1.5, true],
'SHA256 in a PDF 1.6 does not need the upgrade' => ['SHA256', 1.6, false],
'SHA256 in a PDF 1.7 does not need the upgrade' => ['SHA256', 1.7, false],
'SHA1 in a PDF 1.5 does not need the upgrade' => ['SHA1', 1.5, false],
'SHA512 in a PDF 1.5 does not need the upgrade' => ['SHA512', 1.5, false],
];
}
}
Loading
Loading