diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index eabcc13768..704f5256fd 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -22,6 +22,7 @@ use OCA\Libresign\Service\ReminderService; use OCA\Libresign\Service\SignatureBackgroundService; use OCA\Libresign\Service\SignatureTextService; +use OCA\Libresign\Settings\Admin; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; @@ -662,4 +663,117 @@ public function reminderSave( } return new DataResponse($response); } + + /** + * Set TSA configuration values with proper sensitive data handling + * + * Only saves configuration if tsa_url is provided. Automatically manages + * username/password fields based on authentication type. + * + * @param string|null $tsa_url TSA server URL (required for saving) + * @param string|null $tsa_policy_oid TSA policy OID + * @param string|null $tsa_auth_type Authentication type (none|basic), defaults to 'none' + * @param string|null $tsa_username Username for basic authentication + * @param string|null $tsa_password Password for basic authentication (stored as sensitive data) + * @return DataResponse|DataResponse + * + * 200: OK + * 400: Validation error + */ + #[NoCSRFRequired] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/tsa', requirements: ['apiVersion' => '(v1)'])] + public function setTsaConfig( + ?string $tsa_url = null, + ?string $tsa_policy_oid = null, + ?string $tsa_auth_type = null, + ?string $tsa_username = null, + ?string $tsa_password = null, + ): DataResponse { + if (empty($tsa_url)) { + return $this->deleteTsaConfig(); + } + + $trimmedUrl = trim($tsa_url); + if (!filter_var($trimmedUrl, FILTER_VALIDATE_URL) + || !in_array(parse_url($trimmedUrl, PHP_URL_SCHEME), ['http', 'https'])) { + return new DataResponse([ + 'status' => 'error', + 'message' => 'Invalid URL format' + ], Http::STATUS_BAD_REQUEST); + } + + $this->appConfig->setValueString(Application::APP_ID, 'tsa_url', $trimmedUrl); + + if (empty($tsa_policy_oid)) { + $this->appConfig->deleteKey(Application::APP_ID, 'tsa_policy_oid'); + } else { + $trimmedOid = trim($tsa_policy_oid); + if (!preg_match('/^[0-9]+(\.[0-9]+)*$/', $trimmedOid)) { + return new DataResponse([ + 'status' => 'error', + 'message' => 'Invalid OID format' + ], Http::STATUS_BAD_REQUEST); + } + $this->appConfig->setValueString(Application::APP_ID, 'tsa_policy_oid', $trimmedOid); + } + + $authType = $tsa_auth_type ?? 'none'; + $this->appConfig->setValueString(Application::APP_ID, 'tsa_auth_type', $authType); + + if ($authType === 'basic') { + $hasUsername = !empty($tsa_username); + $hasPassword = !empty($tsa_password) && $tsa_password !== Admin::PASSWORD_PLACEHOLDER; + + if (!$hasUsername && !$hasPassword) { + return new DataResponse([ + 'status' => 'error', + 'message' => 'Username and password are required for basic authentication' + ], Http::STATUS_BAD_REQUEST); + } elseif (!$hasUsername) { + return new DataResponse([ + 'status' => 'error', + 'message' => 'Username is required' + ], Http::STATUS_BAD_REQUEST); + } elseif (!$hasPassword) { + return new DataResponse([ + 'status' => 'error', + 'message' => 'Password is required' + ], Http::STATUS_BAD_REQUEST); + } + + $this->appConfig->setValueString(Application::APP_ID, 'tsa_username', trim($tsa_username)); + $this->appConfig->setValueString( + Application::APP_ID, + key: 'tsa_password', + value: $tsa_password, + sensitive: true, + ); + } else { + $this->appConfig->deleteKey(Application::APP_ID, 'tsa_username'); + $this->appConfig->deleteKey(Application::APP_ID, 'tsa_password'); + } + + return new DataResponse(['status' => 'success']); + } + + /** + * Delete TSA configuration + * + * Delete all TSA configuration fields from the application settings. + * + * @return DataResponse + * + * 200: OK + */ + #[NoCSRFRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/admin/tsa', requirements: ['apiVersion' => '(v1)'])] + public function deleteTsaConfig(): DataResponse { + $fields = ['tsa_url', 'tsa_policy_oid', 'tsa_auth_type', 'tsa_username', 'tsa_password']; + + foreach ($fields as $field) { + $this->appConfig->deleteKey(Application::APP_ID, $field); + } + + return new DataResponse(['status' => 'success']); + } } diff --git a/lib/Handler/SignEngine/JSignPdfHandler.php b/lib/Handler/SignEngine/JSignPdfHandler.php index 8cfbe06897..7425828d47 100644 --- a/lib/Handler/SignEngine/JSignPdfHandler.php +++ b/lib/Handler/SignEngine/JSignPdfHandler.php @@ -406,18 +406,69 @@ private function listParamsToString(array $params): string { return $paramString; } + private function getTsaParameters(): array { + $tsaUrl = $this->appConfig->getValueString(Application::APP_ID, 'tsa_url', ''); + if (empty($tsaUrl)) { + return []; + } + + $params = [ + '--tsa-server-url' => $tsaUrl, + '--tsa-policy-oid' => $this->appConfig->getValueString(Application::APP_ID, 'tsa_policy_oid', ''), + ]; + + if (!$params['--tsa-policy-oid']) { + unset($params['--tsa-policy-oid']); + } + + $tsaAuthType = $this->appConfig->getValueString(Application::APP_ID, 'tsa_auth_type', 'none'); + if ($tsaAuthType === 'basic') { + $tsaUsername = $this->appConfig->getValueString(Application::APP_ID, 'tsa_username', ''); + $tsaPassword = $this->appConfig->getValueString(Application::APP_ID, 'tsa_password', ''); + + if (!empty($tsaUsername) && !empty($tsaPassword)) { + $params['--tsa-authentication'] = 'PASSWORD'; + $params['--tsa-user'] = $tsaUsername; + $params['--tsa-password'] = $tsaPassword; + } + } + + return $params; + } + private function signWrapper(JSignPDF $jSignPDF): string { try { + $params = [ + '--hash-algorithm' => $this->getHashAlgorithm(), + ]; + + $params = array_merge($params, $this->getTsaParameters()); $param = $this->getJSignParam(); $param ->setJSignParameters( $this->jSignParam->getJSignParameters() - . ' --hash-algorithm ' . $this->getHashAlgorithm() + . $this->listParamsToString($params) ); $jSignPDF->setParam($param); return $jSignPDF->sign(); } catch (\Throwable $th) { - $rows = str_getcsv($th->getMessage()); + $errorMessage = $th->getMessage(); + $rows = str_getcsv($errorMessage); + + $tsaError = array_filter($rows, fn ($r) => str_contains((string)$r, 'Invalid TSA')); + if (!empty($tsaError)) { + $tsaErrorMsg = current($tsaError); + if (preg_match("/Invalid TSA '([^']+)'/", $tsaErrorMsg, $matches)) { + $friendlyMessage = 'Timestamp Authority (TSA) service is unavailable or misconfigured.' . "\n" + . 'Please check the TSA configuration.'; + } else { + $friendlyMessage = 'Timestamp Authority (TSA) service error.' . "\n" + . 'Please check the TSA configuration.'; + } + throw new LibresignException($friendlyMessage); + } + + // Check for hash algorithm errors $hashAlgorithm = array_filter($rows, fn ($r) => str_contains((string)$r, 'The chosen hash algorithm')); if (!empty($hashAlgorithm)) { $hashAlgorithm = current($hashAlgorithm); @@ -426,8 +477,9 @@ private function signWrapper(JSignPDF $jSignPDF): string { $hashAlgorithm = preg_replace('/\.( )/', ".\n", $hashAlgorithm); throw new LibresignException($hashAlgorithm); } - $this->logger->error('Error at JSignPdf side. LibreSign can not do nothing. Follow the error message: ' . $th->getMessage()); - throw new \Exception($th->getMessage()); + + $this->logger->error('Error at JSignPdf side. LibreSign can not do nothing. Follow the error message: ' . $errorMessage); + throw new \Exception($errorMessage); } } } diff --git a/lib/Handler/SignEngine/Pkcs12Handler.php b/lib/Handler/SignEngine/Pkcs12Handler.php index 0e26d91b55..c7a90bba8f 100644 --- a/lib/Handler/SignEngine/Pkcs12Handler.php +++ b/lib/Handler/SignEngine/Pkcs12Handler.php @@ -99,18 +99,18 @@ public function getCertificateChain($resource): array { continue; } - if (!isset($fromFallback['signingTime'])) { - // Probably the best way to do this would be: - // ASN1::asn1map($decoded[0], Maps\TheMapName::MAP); - // But, what's the MAP to use? - // - // With maps also could be possible read all certificate data and - // maybe discart openssl at this pint - try { - $decoded = ASN1::decodeBER($signature); - $certificates[$signerCounter]['signingTime'] = $decoded[0]['content'][1]['content'][0]['content'][4]['content'][0]['content'][3]['content'][1]['content'][1]['content'][0]['content']; - } catch (\Throwable) { + $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($fromFallback['signingTime']) || !$fromFallback['signingTime'] instanceof \DateTime) { + $certificates[$signerCounter]['signingTime'] = $tsa->getSigninTime($decoded); } $pkcs7PemSignature = $this->der2pem($signature); diff --git a/lib/Handler/SignEngine/TSA.php b/lib/Handler/SignEngine/TSA.php new file mode 100644 index 0000000000..ab7464a473 --- /dev/null +++ b/lib/Handler/SignEngine/TSA.php @@ -0,0 +1,521 @@ + 'commonName', + '2.5.4.6' => 'countryName', + '2.5.4.10' => 'organizationName', + '1.2.840.113549.1.9.1' => 'emailAddress', + ]; + + private const TIMESTAMP_OIDS = [ + 'TIME_STAMP_TOKEN' => '1.2.840.113549.1.9.16.2.14', + 'SIGNING_TIME' => '1.2.840.113549.1.9.5', + 'TST_INFO' => '1.2.840.113549.1.9.16.1.4', + ]; + + private static ?array $timestampInfoStructure = null; + private const CACHE_SIZE_LIMIT = 50; + + public function __construct() { + $this->ensureOidsAreLoaded(); + } + + private function processContentCandidate($content, ?string &$cmsDer): array { + try { + if ($content instanceof Element) { + return $this->decodeWithCache($cmsDer = $content->element); + } elseif (is_string($content)) { + return $this->decodeWithCache($cmsDer = $content); + } elseif (is_array($content)) { + return $content; + } + } catch (\Throwable $e) { + error_log('TSA content processing failed: ' . $e->getMessage()); + } + return []; + } + + private function ensureOidsAreLoaded(): void { + if (self::$areOidsInitialized) { + return; + } + + ASN1::loadOIDs([ + 'md2' => '1.2.840.113549.2.2', + 'md5' => '1.2.840.113549.2.5', + 'id-sha1' => '1.3.14.3.2.26', + 'id-sha256' => '2.16.840.1.101.3.4.2.1', + 'id-sha384' => '2.16.840.1.101.3.4.2.2', + 'id-sha512' => '2.16.840.1.101.3.4.2.3', + 'timestampToken' => self::TIMESTAMP_OIDS['TIME_STAMP_TOKEN'], + 'signingTime' => self::TIMESTAMP_OIDS['SIGNING_TIME'], + 'tstInfo' => self::TIMESTAMP_OIDS['TST_INFO'], + ]); + + self::$areOidsInitialized = true; + } + + private function convertDerToPkcs7Pem(string $derData): string { + return "-----BEGIN PKCS7-----\n" . chunk_split(base64_encode($derData), 64, "\n") . "-----END PKCS7-----\n"; + } + + private function extractTimestampAuthorityName($timestampElement): array { + if (!$timestampElement instanceof Element || !is_string($timestampElement->element)) { + return []; + } + + try { + $decoded = $this->decodeWithCache($timestampElement->element); + return $decoded[0] ? $this->extractCertificateHints([$decoded[0]]) : []; + } catch (\Throwable) { + return []; + } + } + + private function buildTimestampInfoStructure(): array { + return [ + 'type' => ASN1::TYPE_SEQUENCE, + 'children' => [ + 'version' => ['type' => ASN1::TYPE_INTEGER], + 'policy' => ['type' => ASN1::TYPE_OBJECT_IDENTIFIER], + 'messageImprint' => [ + 'type' => ASN1::TYPE_SEQUENCE, + 'children' => [ + 'hashAlgorithm' => [ + 'type' => ASN1::TYPE_SEQUENCE, + 'children' => [ + 'algorithm' => ['type' => ASN1::TYPE_OBJECT_IDENTIFIER], + 'parameters' => ['optional' => true, 'type' => ASN1::TYPE_ANY], + ], + ], + 'hashedMessage' => ['type' => ASN1::TYPE_OCTET_STRING], + ], + ], + 'serialNumber' => ['type' => ASN1::TYPE_INTEGER], + 'genTime' => ['type' => ASN1::TYPE_GENERALIZED_TIME], + 'accuracy' => [ + 'constant' => 0, + 'implicit' => true, + 'optional' => true, + 'type' => ASN1::TYPE_SEQUENCE, + 'children' => [ + 'seconds' => ['constant' => 0, 'implicit' => true, 'optional' => true, 'type' => ASN1::TYPE_INTEGER], + 'millis' => ['constant' => 1, 'implicit' => true, 'optional' => true, 'type' => ASN1::TYPE_INTEGER], + 'micros' => ['constant' => 2, 'implicit' => true, 'optional' => true, 'type' => ASN1::TYPE_INTEGER], + ], + ], + 'ordering' => ['type' => ASN1::TYPE_BOOLEAN, 'optional' => true], + 'nonce' => ['type' => ASN1::TYPE_INTEGER, 'optional' => true], + 'tsa' => ['constant' => 0, 'optional' => true, 'implicit' => true, 'type' => ASN1::TYPE_ANY], + 'extensions' => ['constant' => 1, 'optional' => true, 'implicit' => true, 'type' => ASN1::TYPE_ANY], + ], + ]; + } + + public function extract(array $root): array { + $cmsDer = null; + $tstInfoOctets = null; + $cnHints = []; + + $values = $this->getAttributeValuesSetAfterOID($root, self::TIMESTAMP_OIDS['TIME_STAMP_TOKEN']); + if ($values) { + foreach ($values as $candidate) { + if (!isset($candidate['content'])) { + continue; + } + + $subtree = $this->processContentCandidate($candidate['content'], $cmsDer); + + if (!empty($subtree)) { + $tstInfoOctets = $this->findContentAfterOID($subtree, self::TIMESTAMP_OIDS['TST_INFO'], ASN1::TYPE_OCTET_STRING); + $cnHints = $this->extractCertificateHints($subtree); + if ($tstInfoOctets) { + break; // Found what we need, exit early + } + } + } + } + if (!isset($tstInfoOctets)) { + $tstInfoOctets = $this->findContentAfterOID($root, self::TIMESTAMP_OIDS['TST_INFO'], ASN1::TYPE_OCTET_STRING); + $cnHints = $this->extractCertificateHints($root); + } + + $tsa = ['genTime' => null, 'policy' => null, 'serialNumber' => null, 'cnHints' => []]; + + if ($tstInfoOctets) { + try { + $decoded = $this->decodeWithCache($tstInfoOctets); + $tstNode = $decoded[0] ?? null; + + $tst = null; + if ($tstNode && ($tstNode['type'] ?? null) === ASN1::TYPE_SEQUENCE) { + ASN1::setTimeFormat('Y-m-d\TH:i:s\Z'); + $tst = ASN1::asn1map($tstNode, self::$timestampInfoStructure ??= $this->buildTimestampInfoStructure()); + + if (!is_array($tst)) { + $tst = $this->parseTstInfoFallback($tstInfoOctets); + } + } + } catch (\Throwable) { + $tst = $this->parseTstInfoFallback($tstInfoOctets); + } + + if (is_array($tst)) { + $tsa['genTime'] = $tst['genTime'] ?? null; + $policyOid = $tst['policy'] ?? null; + $tsa['policy'] = $policyOid; + $tsa['policyName'] = $this->resolveTsaPolicyName($policyOid); + $tsa['serialNumber'] = $this->bigToString($tst['serialNumber'] ?? null); + + if (!empty($tst['messageImprint'])) { + $algOid = $tst['messageImprint']['hashAlgorithm']['algorithm'] ?? null; + + $friendlyName = $this->resolveHashAlgorithm($algOid); + + $numericOid = $this->getNumericOid($algOid); + + $tsa['hashAlgorithm'] = $friendlyName; + $tsa['hashAlgorithmOID'] = $numericOid; + + $hashed = $tst['messageImprint']['hashedMessage'] ?? null; + if (is_string($hashed)) { + $tsa['hashedMessageHex'] = strtoupper(bin2hex($hashed)); + } + } + if (!empty($tst['accuracy'])) { + $acc = $tst['accuracy']; + $tsa['accuracy'] = [ + 'seconds' => isset($acc['seconds']) ? (int)$this->bigToString($acc['seconds']) : null, + 'millis' => isset($acc['millis']) ? (int)$this->bigToString($acc['millis']) : null, + 'micros' => isset($acc['micros']) ? (int)$this->bigToString($acc['micros']) : null, + ]; + } + if (array_key_exists('ordering', $tst)) { + $tsa['ordering'] = (bool)$tst['ordering']; + } + if (isset($tst['nonce'])) { + $tsa['nonce'] = $this->bigToString($tst['nonce']); + } + if (isset($tst['tsa'])) { + $tsa['tsa'] = $this->extractTimestampAuthorityName($tst['tsa']); + } + } + } + + if ($cmsDer) { + $pem = $this->convertDerToPkcs7Pem($cmsDer); + $tsaPemCerts = []; + if (@openssl_pkcs7_read($pem, $tsaPemCerts)) { + $tsaChain = []; + foreach ($tsaPemCerts as $idx => $pemCert) { + $parsed = openssl_x509_parse($pemCert); + if ($parsed) { + $tsaChain[$idx] = $parsed; + } + } + $tsa['chain'] = array_values($tsaChain); + } + } + + $tsa['cnHints'] = $cnHints; + $tsa['displayName'] = $this->generateDistinguishedNames($cnHints); + $tsa['genTime'] = $tsa['genTime'] ? new \DateTime($tsa['genTime']) : null; + + return $tsa; + } + + public function getSigninTime($root): ?\DateTime { + $signingTime = null; + if ($values = $this->getAttributeValuesSetAfterOID($root, self::TIMESTAMP_OIDS['SIGNING_TIME'])) { + foreach ($values as $v) { + $t = $v['type'] ?? null; + if ($t === ASN1::TYPE_UTC_TIME || $t === ASN1::TYPE_GENERALIZED_TIME) { + $signingTime = $v['content'] ?? null; + if ($signingTime !== null) { + break; + } + } + } + } + return $signingTime; + } + + private function parseTstInfoFallback(string $tstInfoOctets): ?array { + try { + $nodes = $this->decodeWithCache($tstInfoOctets); + $root = $nodes[0] ?? null; + if (!$root || ($root['type'] ?? null) !== ASN1::TYPE_SEQUENCE || !is_array($root['content'] ?? null)) { + return null; + } + } catch (\Throwable) { + return null; + } + $out = ['policy' => null, 'serialNumber' => null, 'genTime' => null]; + + $seenPolicy = false; + $seenMsgImprint = false; + $seenSerial = false; + + foreach ($root['content'] as $child) { + $t = $child['type'] ?? null; + + if (!$seenPolicy && $t === ASN1::TYPE_OBJECT_IDENTIFIER && is_string($child['content'] ?? null)) { + $out['policy'] = $child['content']; + $seenPolicy = true; + continue; + } + if (!$seenMsgImprint && $t === ASN1::TYPE_SEQUENCE && is_array($child['content'] ?? null)) { + $hasOID = false; + $hasOctet = false; + foreach ($child['content'] as $miPart) { + if (($miPart['type'] ?? null) === ASN1::TYPE_SEQUENCE) { + foreach (($miPart['content'] ?? []) as $algPart) { + if (($algPart['type'] ?? null) === ASN1::TYPE_OBJECT_IDENTIFIER) { + $hasOID = true; + } + } + } + if (($miPart['type'] ?? null) === ASN1::TYPE_OCTET_STRING) { + $hasOctet = true; + } + } + if ($hasOID && $hasOctet) { + $seenMsgImprint = true; + continue; + } + } + if ($seenMsgImprint && !$seenSerial && $t === ASN1::TYPE_INTEGER) { + $out['serialNumber'] = $this->bigToString($child['content'] ?? null); + $seenSerial = true; + continue; + } + if ($t === ASN1::TYPE_GENERALIZED_TIME || $t === ASN1::TYPE_UTC_TIME) { + if (is_string($child['content'] ?? null)) { + $out['genTime'] = $child['content']; + } + } + } + + if (!$out['genTime']) { + foreach ($this->walkAsn1Tree([$root]) as $n) { + $tt = $n['type'] ?? null; + if (($tt === ASN1::TYPE_GENERALIZED_TIME || $tt === ASN1::TYPE_UTC_TIME) && is_string($n['content'] ?? null)) { + $out['genTime'] = $n['content']; + break; + } + } + } + return $out; + } + + private function getAttributeValuesSetAfterOID(array $tree, string $oid): ?array { + $seen = false; + foreach ($this->walkAsn1Tree($tree) as $n) { + if (($n['type'] ?? null) === ASN1::TYPE_OBJECT_IDENTIFIER && ($n['content'] ?? null) === $oid) { + $seen = true; + continue; + } + if ($seen && ($n['type'] ?? null) === ASN1::TYPE_SET && isset($n['content']) && is_array($n['content'])) { + return $n['content']; + } + } + return null; + } + + private function findContentAfterOID(array $tree, string $oid, int $expectedType): ?string { + $seen = false; + foreach ($this->walkAsn1Tree($tree) as $n) { + if (($n['type'] ?? null) === ASN1::TYPE_OBJECT_IDENTIFIER + && ($n['content'] ?? null) === $oid + ) { + $seen = true; + continue; + } + if ($seen + && ($n['type'] ?? null) === $expectedType + && is_string($n['content'] ?? null) + ) { + return $n['content']; + } + } + return null; + } + + private function extractCertificateHints(array $asn1Tree): array { + $certificateHints = []; + $currentAttributeOid = null; + + foreach ($this->walkAsn1Tree($asn1Tree) as $node) { + if (($node['type'] ?? null) === ASN1::TYPE_OBJECT_IDENTIFIER + && isset($node['content'], self::CERTIFICATE_ATTRIBUTE_OIDS[$node['content']])) { + $currentAttributeOid = $node['content']; + continue; + } + + if ($currentAttributeOid !== null + && isset($node['content']) + && is_string($node['content']) + && $this->isStringValidUtf8($node['content']) + ) { + $certificateHints[self::CERTIFICATE_ATTRIBUTE_OIDS[$currentAttributeOid]] = $node['content']; + $currentAttributeOid = null; + } + } + return $certificateHints; + } + + private function isStringValidUtf8(string $text): bool { + return mb_check_encoding($text, 'UTF-8') + && preg_match('/[\P{C}]/u', $text) + && !preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $text); + } + + private function generateDistinguishedNames(array $hints): string { + $mapping = [ + 'countryName' => 'C', + 'stateOrProvinceName' => 'ST', + 'localityName' => 'L', + 'organizationName' => 'O', + 'organizationalUnitName' => 'OU', + 'commonName' => 'CN', + 'description' => 'description', + 'emailAddress' => 'emailAddress', + ]; + + $parts = array_filter( + array_map( + fn ($field) => empty($hints[$field]) + ? null + : $mapping[$field] . '=' . addcslashes($hints[$field], '/+<>"#;'), + array_keys($mapping) + ) + ); + + return '/' . implode('/', $parts); + } + + private function bigToString($v): ?string { + return match (true) { + $v === null => null, + $v instanceof BigInteger => $v->toString(), + is_int($v) => (string)$v, + is_string($v) => ctype_digit($v) ? $v : null, + is_array($v) && isset($v['content']) => $this->bigToString($v['content']), + default => null, + }; + } + + private function resolveHashAlgorithm(?string $oid): ?string { + return match ($oid) { + null => null, + '1.3.14.3.2.26' => 'SHA-1', + '2.16.840.1.101.3.4.2.4' => 'SHA-224', + '2.16.840.1.101.3.4.2.1' => 'SHA-256', + '2.16.840.1.101.3.4.2.2' => 'SHA-384', + '2.16.840.1.101.3.4.2.3' => 'SHA-512', + '1.2.840.113549.2.5' => 'MD5', + '1.2.840.113549.2.2' => 'MD2', + 'id-sha1', 'sha1withrsaencryption', 'ecdsa-with-sha1', 'id-dsa-with-sha1' => 'SHA-1', + 'id-sha224', 'sha224withrsaencryption', 'ecdsa-with-sha224', 'id-dsa-with-sha224' => 'SHA-224', + 'id-sha256', 'sha256withrsaencryption', 'ecdsa-with-sha256', 'id-dsa-with-sha256' => 'SHA-256', + 'id-sha384', 'sha384withrsaencryption', 'ecdsa-with-sha384' => 'SHA-384', + 'id-sha512', 'sha512withrsaencryption', 'ecdsa-with-sha512' => 'SHA-512', + 'md2', 'md2withrsaencryption' => 'MD2', + 'md5', 'md5withrsaencryption' => 'MD5', + default => $oid, // Return original if not mapped + }; + } + + private function getNumericOid(?string $oid): ?string { + if (!$oid || preg_match('/^\d+(\.\d+)*$/', $oid)) { + return $oid; + } + + return match ($oid) { + 'id-sha1', 'sha1withrsaencryption', 'ecdsa-with-sha1', 'id-dsa-with-sha1' => '1.3.14.3.2.26', + 'id-sha224', 'sha224withrsaencryption', 'ecdsa-with-sha224', 'id-dsa-with-sha224' => '2.16.840.1.101.3.4.2.4', + 'id-sha256', 'sha256withrsaencryption', 'ecdsa-with-sha256', 'id-dsa-with-sha256' => '2.16.840.1.101.3.4.2.1', + 'id-sha384', 'sha384withrsaencryption', 'ecdsa-with-sha384' => '2.16.840.1.101.3.4.2.2', + 'id-sha512', 'sha512withrsaencryption', 'ecdsa-with-sha512' => '2.16.840.1.101.3.4.2.3', + 'md2', 'md2withrsaencryption' => '1.2.840.113549.2.2', + 'md5', 'md5withrsaencryption' => '1.2.840.113549.2.5', + default => $oid, // Return original if not mapped + }; + } + + private function resolveTsaPolicyName(?string $policyOid): ?string { + if (!$policyOid) { + return null; + } + + $resolved = ASN1::getOID($policyOid); + if ($resolved && $resolved !== $policyOid) { + return $resolved; + } + + return match ($policyOid) { + '1.2.3.4.1' => 'FreeTSA Policy', + '1.3.6.1.4.1.601.10.3.1' => 'VeriSign TSA Policy', + '1.3.6.1.4.1.311.3.2.1' => 'Microsoft TSA Policy', + '2.16.840.1.114412.7.1' => 'DigiCert TSA Policy', + '1.3.6.1.4.1.8302.3.1' => 'Comodo TSA Policy', + '2.16.840.1.113733.1.7.23.3' => 'Symantec TSA Policy', + default => null, + }; + } + + private function decodeWithCache(string $asn1Data): array { + $cacheKey = hash('xxh3', $asn1Data); + + if (isset(self::$asn1DecodingCache[$cacheKey])) { + return self::$asn1DecodingCache[$cacheKey]; + } + + $decodedResult = ASN1::decodeBER($asn1Data); + if ($decodedResult === null) { + $decodedResult = []; + } + + if (count(self::$asn1DecodingCache) >= self::CACHE_SIZE_LIMIT) { + array_shift(self::$asn1DecodingCache); + } + + self::$asn1DecodingCache[$cacheKey] = $decodedResult; + return $decodedResult; + } + + public static function clearCache(): void { + self::$asn1DecodingCache = []; + } + + private function walkAsn1Tree(array $nodes): \Generator { + $processingStack = $nodes; + + while (!empty($processingStack)) { + $currentNode = array_shift($processingStack); + yield $currentNode; + + foreach (['content', 'children'] as $childrenKey) { + if (isset($currentNode[$childrenKey]) && is_array($currentNode[$childrenKey])) { + array_unshift($processingStack, ...$currentNode[$childrenKey]); + } + } + } + } +} diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 572ed601a9..0a51d26632 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -488,6 +488,12 @@ private function loadSignersFromCertData(): void { } elseif (!empty($this->fileData->signers[$index]['uid'])) { $this->fileData->signers[$index]['displayName'] = $this->fileData->signers[$index]['uid']; } + if (!empty($signer['timestamp'])) { + $this->fileData->signers[$index]['timestamp'] = $signer['timestamp']; + if ($signer['timestamp']['genTime'] instanceof \DateTimeInterface) { + $this->fileData->signers[$index]['timestamp']['genTime'] = $signer['timestamp']['genTime']->format(DateTimeInterface::ATOM); + } + } for ($i = 1; $i < count($signer['chain']); $i++) { $this->fileData->signers[$index]['chain'][] = [ 'displayName' => $signer['chain'][$i]['name'], diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index b924765b96..f88ac19e2c 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -22,6 +22,8 @@ use OCP\Util; class Admin implements ISettings { + public const PASSWORD_PLACEHOLDER = '••••••••'; + public function __construct( private IInitialState $initialState, private IdentifyMethodService $identifyMethodService, @@ -61,6 +63,11 @@ public function getForm(): TemplateResponse { $this->initialState->provideInitialState('signature_text_template', $this->signatureTextService->getTemplate()); $this->initialState->provideInitialState('signature_width', $this->signatureTextService->getFullSignatureWidth()); $this->initialState->provideInitialState('template_font_size', $this->signatureTextService->getTemplateFontSize()); + $this->initialState->provideInitialState('tsa_url', $this->appConfig->getValueString(Application::APP_ID, 'tsa_url', '')); + $this->initialState->provideInitialState('tsa_policy_oid', $this->appConfig->getValueString(Application::APP_ID, 'tsa_policy_oid', '')); + $this->initialState->provideInitialState('tsa_auth_type', $this->appConfig->getValueString(Application::APP_ID, 'tsa_auth_type', 'none')); + $this->initialState->provideInitialState('tsa_username', $this->appConfig->getValueString(Application::APP_ID, 'tsa_username', '')); + $this->initialState->provideInitialState('tsa_password', $this->appConfig->getValueString(Application::APP_ID, 'tsa_password', self::PASSWORD_PLACEHOLDER)); return new TemplateResponse(Application::APP_ID, 'admin_settings'); } diff --git a/openapi-administration.json b/openapi-administration.json index 01fc0dbe64..88dbc57389 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -2329,6 +2329,256 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/tsa": { + "post": { + "operationId": "admin-set-tsa-config", + "summary": "Set TSA configuration values with proper sensitive data handling", + "description": "Only saves configuration if tsa_url is provided. Automatically manages username/password fields based on authentication type.\nThis endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tsa_url": { + "type": "string", + "nullable": true, + "description": "TSA server URL (required for saving)" + }, + "tsa_policy_oid": { + "type": "string", + "nullable": true, + "description": "TSA policy OID" + }, + "tsa_auth_type": { + "type": "string", + "nullable": true, + "description": "Authentication type (none|basic), defaults to 'none'" + }, + "tsa_username": { + "type": "string", + "nullable": true, + "description": "Username for basic authentication" + }, + "tsa_password": { + "type": "string", + "nullable": true, + "description": "Password for basic authentication (stored as sensitive data)" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "success" + ] + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "status", + "message" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "error" + ] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "admin-delete-tsa-config", + "summary": "Delete TSA configuration", + "description": "Delete all TSA configuration fields from the application settings.\nThis endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "success" + ] + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/setting/has-root-cert": { "get": { "operationId": "setting-has-root-cert", diff --git a/openapi-full.json b/openapi-full.json index daf8616c30..3571f1f6d2 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -10464,6 +10464,256 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/tsa": { + "post": { + "operationId": "admin-set-tsa-config", + "summary": "Set TSA configuration values with proper sensitive data handling", + "description": "Only saves configuration if tsa_url is provided. Automatically manages username/password fields based on authentication type.\nThis endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tsa_url": { + "type": "string", + "nullable": true, + "description": "TSA server URL (required for saving)" + }, + "tsa_policy_oid": { + "type": "string", + "nullable": true, + "description": "TSA policy OID" + }, + "tsa_auth_type": { + "type": "string", + "nullable": true, + "description": "Authentication type (none|basic), defaults to 'none'" + }, + "tsa_username": { + "type": "string", + "nullable": true, + "description": "Username for basic authentication" + }, + "tsa_password": { + "type": "string", + "nullable": true, + "description": "Password for basic authentication (stored as sensitive data)" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "success" + ] + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "status", + "message" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "error" + ] + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "admin-delete-tsa-config", + "summary": "Delete TSA configuration", + "description": "Delete all TSA configuration fields from the application settings.\nThis endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "success" + ] + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/setting/has-root-cert": { "get": { "operationId": "setting-has-root-cert", diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index d29168080e..6cc942152c 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -271,6 +271,32 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/tsa": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Set TSA configuration values with proper sensitive data handling + * @description Only saves configuration if tsa_url is provided. Automatically manages username/password fields based on authentication type. + * This endpoint requires admin access + */ + post: operations["admin-set-tsa-config"]; + /** + * Delete TSA configuration + * @description Delete all TSA configuration fields from the application settings. + * This endpoint requires admin access + */ + delete: operations["admin-delete-tsa-config"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/libresign/api/{apiVersion}/setting/has-root-cert": { parameters: { query?: never; @@ -1210,6 +1236,105 @@ export interface operations { }; }; }; + "admin-set-tsa-config": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description TSA server URL (required for saving) */ + tsa_url?: string | null; + /** @description TSA policy OID */ + tsa_policy_oid?: string | null; + /** @description Authentication type (none|basic), defaults to 'none' */ + tsa_auth_type?: string | null; + /** @description Username for basic authentication */ + tsa_username?: string | null; + /** @description Password for basic authentication (stored as sensitive data) */ + tsa_password?: string | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + status: "success"; + }; + }; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + status: "error"; + message: string; + }; + }; + }; + }; + }; + }; + }; + "admin-delete-tsa-config": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + status: "success"; + }; + }; + }; + }; + }; + }; + }; "setting-has-root-cert": { parameters: { query?: never; diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 8e5919cfb7..b0a437570f 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -1215,6 +1215,32 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/tsa": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Set TSA configuration values with proper sensitive data handling + * @description Only saves configuration if tsa_url is provided. Automatically manages username/password fields based on authentication type. + * This endpoint requires admin access + */ + post: operations["admin-set-tsa-config"]; + /** + * Delete TSA configuration + * @description Delete all TSA configuration fields from the application settings. + * This endpoint requires admin access + */ + delete: operations["admin-delete-tsa-config"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/libresign/api/{apiVersion}/setting/has-root-cert": { parameters: { query?: never; @@ -5387,6 +5413,105 @@ export interface operations { }; }; }; + "admin-set-tsa-config": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description TSA server URL (required for saving) */ + tsa_url?: string | null; + /** @description TSA policy OID */ + tsa_policy_oid?: string | null; + /** @description Authentication type (none|basic), defaults to 'none' */ + tsa_auth_type?: string | null; + /** @description Username for basic authentication */ + tsa_username?: string | null; + /** @description Password for basic authentication (stored as sensitive data) */ + tsa_password?: string | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + status: "success"; + }; + }; + }; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + status: "error"; + message: string; + }; + }; + }; + }; + }; + }; + }; + "admin-delete-tsa-config": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + /** @enum {string} */ + status: "success"; + }; + }; + }; + }; + }; + }; + }; "setting-has-root-cert": { parameters: { query?: never; diff --git a/src/views/Settings/Settings.vue b/src/views/Settings/Settings.vue index 5d47381951..31bfe74be4 100644 --- a/src/views/Settings/Settings.vue +++ b/src/views/Settings/Settings.vue @@ -21,6 +21,7 @@ + @@ -42,6 +43,7 @@ import RootCertificateCfssl from './RootCertificateCfssl.vue' import RootCertificateOpenSsl from './RootCertificateOpenSsl.vue' import SignatureHashAlgorithm from './SignatureHashAlgorithm.vue' import SignatureStamp from './SignatureStamp.vue' +import TSA from './TSA.vue' import Validation from './Validation.vue' export default { @@ -62,6 +64,7 @@ export default { RootCertificateOpenSsl, SignatureHashAlgorithm, SignatureStamp, + TSA, Validation, Reminders, }, diff --git a/src/views/Settings/TSA.vue b/src/views/Settings/TSA.vue new file mode 100644 index 0000000000..ccb523906d --- /dev/null +++ b/src/views/Settings/TSA.vue @@ -0,0 +1,367 @@ + + + + + + + diff --git a/src/views/Validation.vue b/src/views/Validation.vue index 0008254232..7dd3702b99 100644 --- a/src/views/Validation.vue +++ b/src/views/Validation.vue @@ -255,6 +255,115 @@ {{ signer.hash_algorithm }} + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+
/apps/libresign/develop/pdf"} | + | users | [{"displayName": "TSA Signer","identify": {"account": "signer1"}}] | + | name | TSA Document Test | + Then the response should have a status code 200 + And as user "signer1" + And sending "get" to ocs "/apps/libresign/api/v1/file/list" + Then the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.data[0].name | TSA Document Test | + And fetch field "(SIGN_UUID)ocs.data.data.0.signers.0.sign_uuid" from previous JSON response + And fetch field "(FILE_UUID)ocs.data.data.0.uuid" from previous JSON response + When sending "post" to ocs "/apps/libresign/api/v1/sign/uuid/" + | method | clickToSign | + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.meta.status | ok | + | (jq).ocs.meta.statuscode | 200 | + | (jq).ocs.data.action | 3500 | + | (jq).ocs.data.message | File signed | + And as user "signer1" + And sending "get" to ocs "/apps/libresign/api/v1/file/validate/uuid/" + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.signers[0].signature_validation | {"id":1,"label":"Signature is valid."} | + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.signers[0].timestamp.policy | 1.2.3.4.1 | + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.signers[0].timestamp.serialNumber \|test("^[0-9]+$") | true | + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.signers[0].timestamp.genTime \|test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}") | true | + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.signers[0].timestamp.cnHints.commonName | www.freetsa.org | + | (jq).ocs.data.signers[0].timestamp.cnHints.countryName | DE | + + Scenario: TSA error handling - Invalid server + Given run the command "config:app:set libresign tsa_url --value=https://invalid-tsa-server.example.com/tsr --type=string" with result code 0 + And run the command "config:app:set libresign tsa_auth_type --value=none --type=string" with result code 0 + And sending "post" to ocs "/apps/provisioning_api/api/v1/config/apps/libresign/identify_methods" + | value | (string)[{"name":"account","enabled":true,"mandatory":true,"signatureMethods":{"clickToSign":{"enabled":true}},"signatureMethodEnabled":"clickToSign"}] | + When sending "post" to ocs "/apps/libresign/api/v1/request-signature" + | file | {"url":"/apps/libresign/develop/pdf"} | + | users | [{"identify": {"account": "signer1"}}] | + | name | TSA Error Test | + And as user "signer1" + And sending "get" to ocs "/apps/libresign/api/v1/file/list" + And fetch field "(SIGN_UUID)ocs.data.data.0.signers.0.sign_uuid" from previous JSON response + And sending "post" to ocs "/apps/libresign/api/v1/sign/uuid/" + | method | clickToSign | + Then the response should have a status code 422 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.meta.status | failure | + | (jq).ocs.meta.statuscode | 422 | + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.action | 2000 | + + Scenario: Clean up TSA configuration after tests + Given run the command "config:app:delete libresign tsa_url" with result code 0 + And run the command "config:app:delete libresign tsa_policy_oid" with result code 0 + And run the command "config:app:delete libresign tsa_auth_type" with result code 0 diff --git a/tests/php/Api/Controller/AdminControllerTest.php b/tests/php/Api/Controller/AdminControllerTest.php index 3e9264e916..143b12d4bb 100644 --- a/tests/php/Api/Controller/AdminControllerTest.php +++ b/tests/php/Api/Controller/AdminControllerTest.php @@ -117,4 +117,64 @@ public function testGenerateCertificateWithFailure():void { // Make and test request mach with schema $this->assertRequest(); } + + /** + * @runInSeparateProcess + */ + public function testSetTsaConfigSensitivePassword(): void { + $this->createAccount('admintest', 'password', 'admin'); + + $this->request + ->withRequestHeader([ + 'Authorization' => 'Basic ' . base64_encode('admintest:password') + ]) + ->withPath('/api/v1/admin/tsa') + ->withMethod('POST') + ->withRequestBody([ + 'tsa_url' => 'https://tsa.example.com', + 'tsa_auth_type' => 'basic', + 'tsa_username' => 'testuser', + 'tsa_password' => 'secret_password' + ]) + ->assertResponseCode(200); + + $this->assertRequest(); + } + + /** + * @runInSeparateProcess + */ + public function testSetTsaConfigWithoutUrlDoesNothing(): void { + $this->createAccount('admintest', 'password', 'admin'); + + $this->request + ->withRequestHeader([ + 'Authorization' => 'Basic ' . base64_encode('admintest:password') + ]) + ->withPath('/api/v1/admin/tsa') + ->withMethod('POST') + ->withRequestBody([ + 'tsa_password' => 'secret_password' + ]) + ->assertResponseCode(200); + + $this->assertRequest(); + } + + /** + * @runInSeparateProcess + */ + public function testDeleteTsaConfig(): void { + $this->createAccount('admintest', 'password', 'admin'); + + $this->request + ->withRequestHeader([ + 'Authorization' => 'Basic ' . base64_encode('admintest:password') + ]) + ->withPath('/api/v1/admin/tsa') + ->withMethod('DELETE') + ->assertResponseCode(200); + + $this->assertRequest(); + } } diff --git a/tests/php/Unit/Handler/SignEngine/TSATest.php b/tests/php/Unit/Handler/SignEngine/TSATest.php new file mode 100644 index 0000000000..9b8265b4da --- /dev/null +++ b/tests/php/Unit/Handler/SignEngine/TSATest.php @@ -0,0 +1,191 @@ +tsa = new TSA(); + } + + public function testConstructorLoadsOIDs(): void { + $tsa = new TSA(); + + $this->assertEquals('2.16.840.1.101.3.4.2.1', ASN1::getOID('id-sha256')); + $this->assertEquals('1.3.14.3.2.26', ASN1::getOID('id-sha1')); + } + + public function testExtractWithEmptyArray(): void { + $result = $this->tsa->extract([]); + + $this->assertIsArray($result); + $this->assertArrayHasKey('genTime', $result); + $this->assertArrayHasKey('policy', $result); + $this->assertArrayHasKey('serialNumber', $result); + $this->assertArrayHasKey('cnHints', $result); + $this->assertArrayHasKey('displayName', $result); + + $this->assertNull($result['policy']); + $this->assertNull($result['serialNumber']); + $this->assertEquals([], $result['cnHints']); + } + + public function testGetSigninTimeWithNoSigningTime(): void { + $result = $this->tsa->getSigninTime([]); + $this->assertNull($result); + } + + public function testGetSigninTimeWithInvalidStructure(): void { + $invalidStructure = [ + [ + 'type' => ASN1::TYPE_SEQUENCE, + 'content' => 'invalid', + ] + ]; + + $result = $this->tsa->getSigninTime($invalidStructure); + $this->assertNull($result); + } + + public function testExtractWithMinimalValidStructure(): void { + $mockTstInfo = $this->createMockTstInfo(); + + $result = $this->tsa->extract($mockTstInfo); + + $this->assertIsArray($result); + $this->assertArrayHasKey('genTime', $result); + $this->assertArrayHasKey('policy', $result); + $this->assertArrayHasKey('serialNumber', $result); + $this->assertArrayHasKey('cnHints', $result); + $this->assertArrayHasKey('displayName', $result); + } + + public function testExtractWithMalformedData(): void { + $malformedData = [ + [ + 'type' => 'invalid_type', + 'content' => 'malformed_content', + ] + ]; + + $result = $this->tsa->extract($malformedData); + + $this->assertIsArray($result); + $this->assertArrayHasKey('genTime', $result); + } + + public function testDisplayNameGeneration(): void { + $mockStructureWithCN = $this->createMockStructureWithCommonName(); + + $result = $this->tsa->extract($mockStructureWithCN); + + $this->assertIsArray($result); + $this->assertArrayHasKey('displayName', $result); + $this->assertIsString($result['displayName']); + } + + private function createMockTstInfo(): array { + return [ + [ + 'type' => ASN1::TYPE_SEQUENCE, + 'content' => [ + [ + 'type' => ASN1::TYPE_OBJECT_IDENTIFIER, + 'content' => '1.2.840.113549.1.9.16.2.14' // id-aa-timeStampToken + ], + [ + 'type' => ASN1::TYPE_SET, + 'content' => [ + [ + 'type' => ASN1::TYPE_SEQUENCE, + 'content' => [] + ] + ] + ] + ] + ] + ]; + } + + private function createMockStructureWithCommonName(): array { + return [ + [ + 'type' => ASN1::TYPE_SEQUENCE, + 'content' => [ + [ + 'type' => ASN1::TYPE_OBJECT_IDENTIFIER, + 'content' => '2.5.4.3' // commonName OID + ], + [ + 'type' => ASN1::TYPE_UTF8_STRING, + 'content' => 'Test CA', + ] + ] + ] + ]; + } + + public function testSerialNumberHandling(): void { + $result = $this->tsa->extract([]); + + $this->assertNull($result['serialNumber']); + } + + public function testFallbackParsingWithEmptyData(): void { + $result = $this->tsa->extract([ + [ + 'type' => 'invalid', + 'content' => null + ] + ]); + + $this->assertIsArray($result); + $this->assertNull($result['genTime']); + $this->assertIsArray($result['cnHints']); + $this->assertIsString($result['displayName']); + } + + public function testOIDMapping(): void { + $structureWithMultipleOIDs = [ + [ + 'type' => ASN1::TYPE_SEQUENCE, + 'content' => [ + [ + 'type' => ASN1::TYPE_OBJECT_IDENTIFIER, + 'content' => '2.5.4.6', // countryName + ], + [ + 'type' => ASN1::TYPE_PRINTABLE_STRING, + 'content' => 'US', + ], + [ + 'type' => ASN1::TYPE_OBJECT_IDENTIFIER, + 'content' => '2.5.4.10', // organizationName + ], + [ + 'type' => ASN1::TYPE_UTF8_STRING, + 'content' => 'Test Organization', + ] + ] + ] + ]; + + $result = $this->tsa->extract($structureWithMultipleOIDs); + + $this->assertIsArray($result); + $this->assertArrayHasKey('cnHints', $result); + $this->assertIsArray($result['cnHints']); + } +}