Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.17% covered (warning)
79.17%
114 / 144
0.00% covered (danger)
0.00%
0 / 5
CRAP
n/a
0 / 0
ticketbai_authorize_env
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
7.23
ticketbai_authorize_validate_session
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
ticketbai_authorize_extract_material
64.44% covered (warning)
64.44%
29 / 45
0.00% covered (danger)
0.00%
0 / 1
40.82
ticketbai_authorize_pkcs8_der
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
6.60
ticketbai_authorize_wrap_private_key
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
4.01
1<?php
2
3declare(strict_types=1);
4
5/**
6 * @file
7 * TicketBAI action: return the leaf certificate and the PKCS#8 private key
8 * wrapped with AES-256-GCM, so the browser can unwrap it directly into a
9 * non-extractable CryptoKey (see UnwrapAuthorization in upp-data ticketbai.ts)
10 * without the raw key bytes ever existing as plaintext in JavaScript memory.
11 */
12
13require_once __DIR__.'/SessionPin.php';
14
15/**
16 * Resolve an environment value with fallback.
17 *
18 * @param string $key Environment key.
19 * @param string $default Fallback value.
20 * @return string
21 */
22function ticketbai_authorize_env(string $key, string $default = ''): string
23{
24    $value = getenv($key);
25    if ($value === false || $value === null || $value === '') {
26        $value = $_ENV[$key] ?? null;
27    }
28    if ($value === false || $value === null || $value === '') {
29        return $default;
30    }
31    return (string) $value;
32}
33
34/**
35 * Validate standard session params and check session.
36 *
37 * @param array<string, mixed> $query Query params.
38 * @param \ConxHelper $conx Connection helper.
39 * @param \UppServices\SessionService $sessionService Session service.
40 * @param \Psr\Log\LoggerInterface $logger Logger.
41 * @return int ApplicationError code.
42 */
43function ticketbai_authorize_validate_session(array $query, \ConxHelper $conx, \UppServices\SessionService $sessionService, \Psr\Log\LoggerInterface $logger): int
44{
45    $retval = \ApplicationError::Success;
46
47    $device = (string) ($query['device'] ?? '');
48    $session = (string) ($query['session'] ?? '');
49    $place = (string) ($query['place'] ?? '');
50
51    if ($device === '' || $session === '' || $place === '') {
52        $retval = \ApplicationError::Parameters;
53        $logger->error("Missing mandatory query params for ticketbai/authorize (device, session, place).");
54    }
55
56    if (success($retval)) {
57        $userid = null;
58        $retval = $sessionService->checkSession($conx, $session, $device, $userid);
59        if (!success($retval)) {
60            $logger->error("Session check failed for ticketbai/authorize.");
61        }
62    }
63
64    return $retval;
65}
66
67/**
68 * Load configured PKCS#12 file and extract PKCS#8 + PEM certificate.
69 *
70 * @param string $certificatePath PKCS#12 absolute path.
71 * @param string $certificatePassword PKCS#12 password.
72 * @param string|null $privateKeyPkcs8 Output key PEM.
73 * @param string|null $certificatePem Output certificate PEM.
74 * @param \Psr\Log\LoggerInterface $logger Logger.
75 * @return int ApplicationError code.
76 */
77function ticketbai_authorize_extract_material(string $certificatePath, string $certificatePassword, ?string &$privateKeyPkcs8, ?string &$certificatePem, \Psr\Log\LoggerInterface $logger): int
78{
79    $retval = \ApplicationError::Success;
80    $privateKeyPkcs8 = null;
81    $certificatePem = null;
82
83    if (!file_exists($certificatePath)) {
84        $retval = \ApplicationError::NotFound;
85        $logger->error("TicketBAI PKCS#12 certificate not found at '".$certificatePath."'.");
86    }
87
88    $pkcs12 = '';
89    if (success($retval)) {
90        $pkcs12Raw = @file_get_contents($certificatePath);
91        if ($pkcs12Raw === false || $pkcs12Raw === '') {
92            $retval = \ApplicationError::Certificate;
93            $err = error_get_last();
94            $reason = (is_array($err) && isset($err['message'])) ? ': '.$err['message'] : '.';
95            $logger->error("Could not read TicketBAI PKCS#12 certificate '".$certificatePath."'".$reason);
96        }
97        else {
98            $pkcs12 = (string) $pkcs12Raw;
99        }
100    }
101
102    $certs = [];
103    if (success($retval)) {
104        if (!@openssl_pkcs12_read($pkcs12, $certs, $certificatePassword)) {
105            $retval = \ApplicationError::Certificate;
106            $logger->error("Could not parse TicketBAI PKCS#12 with configured password.");
107        }
108    }
109
110    if (success($retval)) {
111        $certificatePem = isset($certs['cert']) ? (string) $certs['cert'] : null;
112        $privateKeyPem = isset($certs['pkey']) ? (string) $certs['pkey'] : null;
113        if ($certificatePem === null || $privateKeyPem === null || $certificatePem === '' || $privateKeyPem === '') {
114            $retval = \ApplicationError::Certificate;
115            $logger->error("Configured TicketBAI PKCS#12 has missing certificate or private key.");
116        }
117    }
118
119    if (success($retval)) {
120        $privateKey = @openssl_pkey_get_private((string) $certs['pkey']);
121        if ($privateKey === false) {
122            $retval = \ApplicationError::Certificate;
123            $logger->error("Could not load private key extracted from TicketBAI PKCS#12.");
124        }
125        else {
126            $exported = '';
127            $exportOptions = [
128                'encrypt_key' => false,
129            ];
130            if (!@openssl_pkey_export($privateKey, $exported, null, $exportOptions)) {
131                $retval = \ApplicationError::Certificate;
132                $logger->error("Could not export TicketBAI private key as PKCS#8 PEM.");
133            }
134            else {
135                $isEncryptedPkcs8 = (strpos($exported, 'BEGIN ENCRYPTED PRIVATE KEY') !== false);
136                $isPkcs8 = (strpos($exported, 'BEGIN PRIVATE KEY') !== false) && !$isEncryptedPkcs8;
137                if (!$isPkcs8) {
138                    $retval = \ApplicationError::Certificate;
139                    $logger->error("TicketBAI private key export is not unencrypted PKCS#8 PEM.");
140                }
141                else {
142                    $privateKeyPkcs8 = $exported;
143                }
144            }
145        }
146    }
147
148    return $retval;
149}
150
151/**
152 * Convert an unencrypted PKCS#8 PEM private key to its raw DER byte string, so it
153 * can be wrapped directly and later unwrapped by the browser without ever being
154 * re-encoded as a JSON string.
155 *
156 * @param string $privateKeyPkcs8Pem Unencrypted PKCS#8 PEM.
157 * @param \Psr\Log\LoggerInterface $logger Logger.
158 * @return string Raw DER bytes, or '' if the PEM body was empty or could not be decoded.
159 */
160function ticketbai_authorize_pkcs8_der(string $privateKeyPkcs8Pem, \Psr\Log\LoggerInterface $logger): string
161{
162    $body = preg_replace('/-----(BEGIN|END) [^-]+-----/', '', $privateKeyPkcs8Pem);
163    $body = preg_replace('/\s+/', '', (string) $body);
164    if ($body === null || $body === '') {
165        $logger->error("PKCS#8 PEM body is empty after stripping headers; cannot derive DER bytes for ticketbai/authorize.");
166        return '';
167    }
168
169    $decoded = base64_decode($body, true);
170    if (!is_string($decoded) || $decoded === '') {
171        $logger->error("PKCS#8 PEM body is not valid base64; cannot derive DER bytes for ticketbai/authorize.");
172        return '';
173    }
174
175    return $decoded;
176}
177
178/**
179 * Wrap raw PKCS#8 DER bytes with AES-256-GCM using a PBKDF2(pin) key. The browser
180 * unwraps this envelope straight into a non-extractable CryptoKey via crypto.subtle.unwrapKey.
181 * Precondition: the caller must guarantee $privateKeyDer is non-empty (validated right
182 * after ticketbai_authorize_pkcs8_der, before reaching this function).
183 *
184 * @param string $pin Session-derived PIN used as KDF input.
185 * @param string $privateKeyDer Non-empty raw PKCS#8 DER bytes to wrap.
186 * @param int $iterations PBKDF2 iterations.
187 * @param array<string, mixed> $wrappedKey Output wrapped key envelope.
188 * @param \Psr\Log\LoggerInterface $logger Logger.
189 * @return int ApplicationError code.
190 */
191function ticketbai_authorize_wrap_private_key(string $pin, string $privateKeyDer, int $iterations, array &$wrappedKey, \Psr\Log\LoggerInterface $logger): int
192{
193    $retval = \ApplicationError::Success;
194    $wrappedKey = [];
195
196    $salt = random_bytes(16);
197    $iv = random_bytes(12);
198    $key = hash_pbkdf2('sha256', $pin, $salt, $iterations, 32, true);
199
200    $tag = '';
201    $ciphertext = @openssl_encrypt($privateKeyDer, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
202    if ($ciphertext === false || $tag === '') {
203        $retval = \ApplicationError::Unknown;
204        $logger->error("Could not wrap TicketBAI private key with AES-256-GCM.");
205    }
206
207    if (success($retval)) {
208        $wrappedKey = [
209            'v' => 1,
210            'cipher' => 'AES-256-GCM',
211            'kdf' => [
212                'algorithm' => 'PBKDF2-SHA256',
213                'iterations' => $iterations,
214                'salt' => base64_encode($salt),
215                'keyLength' => 32,
216            ],
217            'iv' => base64_encode($iv),
218            'tag' => base64_encode($tag),
219            'payload' => base64_encode((string) $ciphertext),
220        ];
221    }
222
223    return $retval;
224}
225
226/**
227 * Action callable: return the leaf certificate and the wrapped PKCS#8 private key.
228 *
229 * @param array<string, mixed> $query Query params.
230 * @param \ConxHelper $conx Connection helper.
231 * @param \Psr\Log\LoggerInterface $logger Logger.
232 * @param \UppServices\SessionService $sessionService Session service.
233 * @return array{output: string, contentType: string}
234 */
235return function (array $query, \ConxHelper $conx, \Psr\Log\LoggerInterface $logger, \UppServices\SessionService $sessionService): array {
236    $retval = \ApplicationError::Success;
237
238    if (success($retval)) {
239        $retval = ticketbai_authorize_validate_session($query, $conx, $sessionService, $logger);
240    }
241
242    $secret = session_pin_secret($logger);
243    if (success($retval) && $secret === '') {
244        $retval = \ApplicationError::Parameters;
245        $logger->error("BUILD_DERIVED_SECRET is required for ticketbai/authorize.");
246    }
247
248    $certificatePath = \AppConstants::certPath();
249    $certificatePassword = ticketbai_authorize_env('BUILD_CERT_PASSWORD', '');
250    if (success($retval) && $certificatePassword === '') {
251        $retval = \ApplicationError::Parameters;
252        $logger->error("BUILD_CERT_PASSWORD is required for ticketbai/authorize.");
253    }
254
255    $privateKeyPkcs8 = null;
256    $certificatePem = null;
257    if (success($retval)) {
258        $retval = ticketbai_authorize_extract_material($certificatePath, $certificatePassword, $privateKeyPkcs8, $certificatePem, $logger);
259    }
260
261    $iterations = (int) ticketbai_authorize_env('BUILD_DERIVED_ITERATIONS', '210000');
262    if ($iterations < 10000 || $iterations > 1000000) {
263        $iterations = 210000;
264    }
265
266    $certificateFingerprintSha256 = null;
267    if (success($retval)) {
268        $fingerprint = @openssl_x509_fingerprint((string) $certificatePem, 'sha256');
269        if (is_string($fingerprint) && $fingerprint !== '') {
270            $certificateFingerprintSha256 = strtoupper($fingerprint);
271        }
272    }
273
274    $pin = '';
275    $privateKeyDer = '';
276    if (success($retval)) {
277        $session = (string) ($query['session'] ?? '');
278        $pin = session_pin($secret, $session);
279        $privateKeyDer = ticketbai_authorize_pkcs8_der((string) $privateKeyPkcs8, $logger);
280        if ($privateKeyDer === '') {
281            $retval = \ApplicationError::Certificate;
282        }
283    }
284
285    $wrappedKey = [];
286    if (success($retval)) {
287        $retval = ticketbai_authorize_wrap_private_key($pin, $privateKeyDer, $iterations, $wrappedKey, $logger);
288    }
289
290    $result = [
291        'errorcode' => $retval,
292        'certificatePem' => success($retval) ? (string) $certificatePem : null,
293        'certificateFingerprintSha256' => success($retval) ? $certificateFingerprintSha256 : null,
294        'wrappedKey' => success($retval) ? $wrappedKey : null,
295    ];
296
297    return ['output' => json_encode($result), 'contentType' => 'application/json; charset=utf-8'];
298};