Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
19.08% |
33 / 173 |
|
0.00% |
0 / 5 |
CRAP | n/a |
0 / 0 |
|
| validate_subject_string | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
56 | |||
| validate_certificate_subject_payload | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
210 | |||
| validate_normalize_taxid | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| validate_extract_certificate_taxids | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
156 | |||
| validate_extract_certificate | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
56 | |||
| 1 | <?php |
| 2 | |
| 3 | /** |
| 4 | * @file |
| 5 | * Fiscal action: validate certificate before signing fiscal authorization. |
| 6 | * |
| 7 | * Certificate-specific failures use JSON field `certerr` (int), not global ApplicationError codes. |
| 8 | * Codes are defined as {@see CertificateErrors} in `config/defines.php`. |
| 9 | */ |
| 10 | |
| 11 | declare(strict_types=1); |
| 12 | |
| 13 | /** |
| 14 | * @param array<string, mixed> $subject |
| 15 | * @param string $key |
| 16 | * @return string |
| 17 | */ |
| 18 | function validate_subject_string(array $subject, string $key): string |
| 19 | { |
| 20 | if (!isset($subject[$key])) { |
| 21 | return ''; |
| 22 | } |
| 23 | |
| 24 | $v = $subject[$key]; |
| 25 | if (is_string($v)) { |
| 26 | return trim($v); |
| 27 | } |
| 28 | |
| 29 | if (is_array($v)) { |
| 30 | foreach ($v as $item) { |
| 31 | if (is_string($item) && $item !== '') { |
| 32 | return trim($item); |
| 33 | } |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | return ''; |
| 38 | } |
| 39 | |
| 40 | /** |
| 41 | * Certificate metadata for client display and legal-representative prefill (same idea as legacy readcert / V3 pem info). |
| 42 | * |
| 43 | * @param string $certificatePem PEM-encoded certificate |
| 44 | * @return array{displayName: string, taxIds: list<string>, role: string, documentRef: string, validTo: ?string} |
| 45 | */ |
| 46 | function validate_certificate_subject_payload(string $certificatePem): array |
| 47 | { |
| 48 | $parsed = @openssl_x509_parse($certificatePem); |
| 49 | if (!is_array($parsed)) { |
| 50 | return [ |
| 51 | 'displayName' => '', |
| 52 | 'taxIds' => [], |
| 53 | 'role' => '', |
| 54 | 'documentRef' => '', |
| 55 | 'validTo' => null, |
| 56 | ]; |
| 57 | } |
| 58 | |
| 59 | $subject = isset($parsed['subject']) && is_array($parsed['subject']) ? $parsed['subject'] : []; |
| 60 | $displayName = validate_subject_string($subject, 'CN'); |
| 61 | if ($displayName === '') { |
| 62 | $displayName = validate_subject_string($subject, 'commonName'); |
| 63 | } |
| 64 | |
| 65 | if ($displayName === '') { |
| 66 | $gn = validate_subject_string($subject, 'givenName'); |
| 67 | $sn = validate_subject_string($subject, 'surname'); |
| 68 | $displayName = trim($gn.' '.$sn); |
| 69 | } |
| 70 | |
| 71 | if ($displayName === '') { |
| 72 | $displayName = validate_subject_string($subject, 'O'); |
| 73 | } |
| 74 | |
| 75 | $taxIds = validate_extract_certificate_taxids($certificatePem); |
| 76 | $serialDn = validate_subject_string($subject, 'serialNumber'); |
| 77 | if ($serialDn !== '') { |
| 78 | $serialNorm = validate_normalize_taxid($serialDn); |
| 79 | if ($serialNorm !== '' && !in_array($serialNorm, $taxIds, true)) { |
| 80 | $taxIds[] = $serialNorm; |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | $taxIds = array_values(array_unique(array_filter($taxIds))); |
| 85 | |
| 86 | $role = validate_subject_string($subject, 'title'); |
| 87 | if ($role === '') { |
| 88 | $role = validate_subject_string($subject, 'OU'); |
| 89 | } |
| 90 | |
| 91 | $documentRef = ''; |
| 92 | if (isset($parsed['serialNumber'])) { |
| 93 | $documentRef = is_string($parsed['serialNumber']) |
| 94 | ? $parsed['serialNumber'] |
| 95 | : (string) $parsed['serialNumber']; |
| 96 | } |
| 97 | |
| 98 | $validToTs = (int) ($parsed['validTo_time_t'] ?? 0); |
| 99 | $validTo = $validToTs > 0 ? date('c', $validToTs) : null; |
| 100 | |
| 101 | return [ |
| 102 | 'displayName' => $displayName, |
| 103 | 'taxIds' => $taxIds, |
| 104 | 'role' => $role, |
| 105 | 'documentRef' => $documentRef, |
| 106 | 'validTo' => $validTo, |
| 107 | ]; |
| 108 | } |
| 109 | |
| 110 | /** |
| 111 | * @param string $taxid |
| 112 | * @return string |
| 113 | */ |
| 114 | function validate_normalize_taxid(string $taxid): string |
| 115 | { |
| 116 | $normalized = strtoupper(trim($taxid)); |
| 117 | return preg_replace('/[^A-Z0-9]/', '', $normalized) ?? ''; |
| 118 | } |
| 119 | |
| 120 | /** |
| 121 | * @param string $certificatePem |
| 122 | * @return array<int, string> |
| 123 | */ |
| 124 | function validate_extract_certificate_taxids(string $certificatePem): array |
| 125 | { |
| 126 | $taxids = []; |
| 127 | $parsed = @openssl_x509_parse($certificatePem); |
| 128 | if (!is_array($parsed)) { |
| 129 | return $taxids; |
| 130 | } |
| 131 | |
| 132 | $values = []; |
| 133 | if (isset($parsed['subject']) && is_array($parsed['subject'])) { |
| 134 | foreach ($parsed['subject'] as $value) { |
| 135 | if (is_string($value)) { |
| 136 | $values[] = $value; |
| 137 | } |
| 138 | } |
| 139 | } |
| 140 | foreach (['name', 'subject', 'issuer'] as $key) { |
| 141 | if (isset($parsed[$key]) && is_string($parsed[$key])) { |
| 142 | $values[] = $parsed[$key]; |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | $pattern = '/([A-Z][0-9]{7}[A-Z0-9]|[0-9]{8}[A-Z])/'; |
| 147 | foreach ($values as $value) { |
| 148 | if (preg_match_all($pattern, strtoupper($value), $matches)) { |
| 149 | foreach ($matches[1] as $candidate) { |
| 150 | $taxids[] = validate_normalize_taxid($candidate); |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | return array_values(array_unique(array_filter($taxids))); |
| 156 | } |
| 157 | |
| 158 | /** |
| 159 | * Extract PEM from PKCS#12 or single certificate; sets $certErr on failure. |
| 160 | * |
| 161 | * @param string $certificateData Raw certificate bytes |
| 162 | * @param string $certificatePassword Password (may be empty) |
| 163 | * @param string|null $certificatePem Output PEM |
| 164 | * @param bool $needPassword Set true when empty password may be required |
| 165 | * @param int|null $certErr Set to a {@see CertificateErrors} constant on failure; null on success or need_password |
| 166 | * @param \Psr\Log\LoggerInterface $logger Logger |
| 167 | * @return string 'ok' | 'need_password' | 'fail' |
| 168 | */ |
| 169 | function validate_extract_certificate( |
| 170 | string $certificateData, |
| 171 | string $certificatePassword, |
| 172 | ?string &$certificatePem, |
| 173 | bool &$needPassword, |
| 174 | ?int &$certErr, |
| 175 | \Psr\Log\LoggerInterface $logger |
| 176 | ): string { |
| 177 | $certificatePem = null; |
| 178 | $needPassword = false; |
| 179 | $certErr = null; |
| 180 | |
| 181 | $certs = []; |
| 182 | if (@openssl_pkcs12_read($certificateData, $certs, $certificatePassword)) { |
| 183 | $certificatePem = isset($certs['cert']) ? (string) $certs['cert'] : null; |
| 184 | if (!$certificatePem) { |
| 185 | $certErr = CertificateErrors::Pkcs12NoCert; |
| 186 | $logger->error("PKCS12 certificate does not contain a valid cert block."); |
| 187 | |
| 188 | return 'fail'; |
| 189 | } |
| 190 | |
| 191 | return 'ok'; |
| 192 | } |
| 193 | |
| 194 | $certResource = @openssl_x509_read($certificateData); |
| 195 | if ($certResource === false) { |
| 196 | if (trim($certificatePassword) === '') { |
| 197 | $needPassword = true; |
| 198 | |
| 199 | return 'need_password'; |
| 200 | } |
| 201 | |
| 202 | $certErr = CertificateErrors::InvalidOrWrongPassword; |
| 203 | $logger->error("Certificate could not be parsed with provided password."); |
| 204 | |
| 205 | return 'fail'; |
| 206 | } |
| 207 | |
| 208 | $exported = ''; |
| 209 | if (!@openssl_x509_export($certResource, $exported)) { |
| 210 | $certErr = CertificateErrors::ExportFailed; |
| 211 | $logger->error("Could not export certificate for validation."); |
| 212 | |
| 213 | return 'fail'; |
| 214 | } |
| 215 | |
| 216 | $certificatePem = $exported; |
| 217 | |
| 218 | return 'ok'; |
| 219 | } |
| 220 | |
| 221 | /** |
| 222 | * Action callable: validate certificate and signer authority. |
| 223 | * |
| 224 | * @param string $body Request body with JSON-encoded fields. |
| 225 | * @param array<string, mixed> $query Query params. |
| 226 | * @param \ConxHelper $conx Connection helper. |
| 227 | * @param \Psr\Log\LoggerInterface $logger Logger. |
| 228 | * @param \UppServices\SessionService $sessionService Session service. |
| 229 | * @return array{output: string, contentType: string} |
| 230 | */ |
| 231 | return function (string $body, array $query, \ConxHelper $conx, \Psr\Log\LoggerInterface $logger, \UppServices\SessionService $sessionService): array { |
| 232 | $retval = \ApplicationError::Success; |
| 233 | $payload = []; |
| 234 | $needPassword = false; |
| 235 | $isExpired = false; |
| 236 | $taxIdMatch = null; |
| 237 | $certErr = null; |
| 238 | $certificatePem = null; |
| 239 | $certificateOut = null; |
| 240 | |
| 241 | if (success($retval)) { |
| 242 | $payload = json_decode($body, true); |
| 243 | if (!is_array($payload)) { |
| 244 | $retval = \ApplicationError::Parameters; |
| 245 | $logger->error("Invalid JSON payload for validate endpoint."); |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | $device = $query['device'] ?? null; |
| 250 | if (success($retval) && !$device) { |
| 251 | $retval = \ApplicationError::Parameters; |
| 252 | $logger->error("No device provided on URL. Operation cancelled."); |
| 253 | } |
| 254 | |
| 255 | if (success($retval)) { |
| 256 | $session = $query['session'] ?? null; |
| 257 | if (!$session) { |
| 258 | $retval = \ApplicationError::Parameters; |
| 259 | $logger->error("No session provided on URL. Operation cancelled."); |
| 260 | } |
| 261 | else { |
| 262 | $userid = null; |
| 263 | $retval = $sessionService->checkSession($conx, (string) $session, (string) $device, $userid); |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | if (success($retval)) { |
| 268 | $certificateRawData = base64_decode((string) ($payload['certificateContents'] ?? ''), true); |
| 269 | if ($certificateRawData === false || $certificateRawData === '') { |
| 270 | $retval = \ApplicationError::Parameters; |
| 271 | $logger->error("Could not decode uploaded certificate content from payload."); |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | if (success($retval)) { |
| 276 | $extractStatus = validate_extract_certificate( |
| 277 | $certificateRawData, |
| 278 | (string) ($payload['certificatePassword'] ?? ''), |
| 279 | $certificatePem, |
| 280 | $needPassword, |
| 281 | $certErr, |
| 282 | $logger |
| 283 | ); |
| 284 | if ($extractStatus === 'fail') { |
| 285 | // $certErr set; keep errorcode Success for app-level JSON |
| 286 | } |
| 287 | elseif ($extractStatus === 'need_password') { |
| 288 | // $needPassword true |
| 289 | } |
| 290 | elseif ($extractStatus === 'ok' && $certificatePem !== null) { |
| 291 | $parsed = @openssl_x509_parse($certificatePem); |
| 292 | if (!is_array($parsed)) { |
| 293 | $certErr = CertificateErrors::X509ParseFailed; |
| 294 | $logger->error("Could not parse certificate for expiration validation."); |
| 295 | } |
| 296 | else { |
| 297 | $validTo = (int) ($parsed['validTo_time_t'] ?? 0); |
| 298 | $isExpired = $validTo > 0 && $validTo < time(); |
| 299 | if ($isExpired) { |
| 300 | $certErr = CertificateErrors::Expired; |
| 301 | $logger->error("Certificate is expired."); |
| 302 | } |
| 303 | } |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | if (success($retval) && $certificatePem !== null && !$needPassword && $certErr === null) { |
| 308 | $certificateOut = validate_certificate_subject_payload($certificatePem); |
| 309 | } |
| 310 | |
| 311 | if (success($retval) && $certErr === null && !$needPassword && $certificatePem !== null && is_array($certificateOut)) { |
| 312 | $expectedTaxId = validate_normalize_taxid((string) ($payload['authorizerTaxId'] ?? '')); |
| 313 | $isLegalRepresentative = ((string) ($payload['isLegalRepresentative'] ?? '0')) === '1'; |
| 314 | if ($isLegalRepresentative) { |
| 315 | $expectedTaxId = validate_normalize_taxid((string) ($payload['legalRepresentativeTaxId'] ?? '')); |
| 316 | } |
| 317 | |
| 318 | $taxids = $certificateOut['taxIds']; |
| 319 | $taxIdMatch = ($expectedTaxId !== '' && in_array($expectedTaxId, $taxids, true)); |
| 320 | if (!$taxIdMatch) { |
| 321 | $logger->info("Certificate tax ID does not match the signer tax ID."); |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | $isValid = success($retval) && $certErr === null && !$needPassword && ($taxIdMatch === true); |
| 326 | |
| 327 | $result = [ |
| 328 | "errorcode" => $retval, |
| 329 | "isValid" => $isValid, |
| 330 | "needPassword" => $needPassword, |
| 331 | "isExpired" => $isExpired, |
| 332 | "taxIdMatch" => $taxIdMatch, |
| 333 | "certerr" => $certErr, |
| 334 | "certificate" => $certificateOut, |
| 335 | ]; |
| 336 | return ['output' => json_encode($result), 'contentType' => 'application/json; charset=utf-8']; |
| 337 | }; |
| 338 | ?> |