Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
19.08% covered (danger)
19.08%
33 / 173
0.00% covered (danger)
0.00%
0 / 5
CRAP
n/a
0 / 0
validate_subject_string
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
56
validate_certificate_subject_payload
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
210
validate_normalize_taxid
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
validate_extract_certificate_taxids
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
156
validate_extract_certificate
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
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
11declare(strict_types=1);
12
13/**
14 * @param array<string, mixed> $subject
15 * @param string $key
16 * @return string
17 */
18function 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 */
46function 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 */
114function 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 */
124function 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 */
169function 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 */
231return 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?>