Bearsampp 2026.7.11
Loading...
Searching...
No Matches
class.openssl.php
Go to the documentation of this file.
1<?php
2/*
3 *
4 * * Copyright (c) 2022-2025 Bearsampp
5 * * License: GNU General Public License version 3 or later; see LICENSE.txt
6 * * Website: https://bearsampp.com
7 * * Github: https://github.com/Bearsampp
8 *
9 */
10
12{
19
26 private $rootCaName = 'BearsamppRootCA';
27
28
34 private function ensureMkcertExeExists()
35 {
36 $mkcertExe = Path::getMkcertExe();
37 if (file_exists($mkcertExe)) {
38 return true;
39 }
40
41 Log::error('mkcert.exe missing at: ' . $mkcertExe . '. It must be fetched during build time (prepareBase/buildFull/buildLite).');
42 return false;
43 }
44
50 private function ensureSslDirExists()
51 {
52 $sslPath = Path::getSslPath();
53 if (!is_dir($sslPath)) {
54 Log::info('SSL directory missing, creating: ' . $sslPath);
55 if (mkdir($sslPath, 0700, true)) {
56 @chmod($sslPath, 0700);
57 // Create .gitignore if the directory was just created
58 $gitignorePath = $sslPath . '/.gitignore';
59 if (!file_exists($gitignorePath)) {
60 file_put_contents($gitignorePath, '# git holder' . PHP_EOL);
61 }
62 } else {
63 Log::error('Failed to create SSL directory: ' . $sslPath);
64 }
65 } else {
66 // Even if directory exists, ensure .gitignore is present
67 $gitignorePath = $sslPath . '/.gitignore';
68 if (!file_exists($gitignorePath)) {
69 file_put_contents($gitignorePath, '# git holder' . PHP_EOL);
70 }
71 }
72
73 if (!is_readable($sslPath) || !is_writable($sslPath)) {
74 Log::warning('SSL directory is not fully accessible. Attempting to relax permissions: ' . $sslPath);
75
76 // Set permissive permissions for local dev environment (0755 allows owner RWX, group/others RX)
77 @chmod($sslPath, 0755);
78 clearstatcache(true, $sslPath);
79
80 if (!is_readable($sslPath) || !is_writable($sslPath)) {
81 Log::error('SSL directory is still not readable/writable after permission adjustment: ' . $sslPath);
82 }
83 }
84
85 return $sslPath;
86 }
87
93 public function makeRootCa()
94 {
95 if (!$this->ensureMkcertExeExists()) {
96 return false;
97 }
98 $destPath = Path::getSslPath();
99 $mkcertExe = Path::getMkcertExe();
100
101 Log::info('Creating new Root CA and installing it...');
102
103 $rootCaPath = Path::getSslPath() . '/' . Path::getMkcertRootCaName();
104 $batch = 'SET "CAROOT=' . Path::formatWindowsPath(Path::getSslPath()) . '"' . PHP_EOL;
105 $batch .= '"' . $mkcertExe . '" -uninstall' . PHP_EOL;
106 $batch .= '"' . $mkcertExe . '" -install' . PHP_EOL;
107 $batch .= 'IF EXIST "' . Path::formatWindowsPath($rootCaPath) . '" (ECHO OK)' . PHP_EOL;
108
109 // Wait for the Root CA file to appear or timeout
110 $result = Batch::exec('mkcertMakeRootCa', $batch);
111
112 if (!file_exists($rootCaPath)) {
113 Log::error('Failed to create Root CA file at: ' . $rootCaPath);
114 return false;
115 }
116
117 // Display info about the new Root CA
118 $batch = 'SET "CAROOT=' . Path::formatWindowsPath(Path::getSslPath()) . '"' . PHP_EOL;
119 $batch .= '"' . $mkcertExe . '" -CAROOT' . PHP_EOL;
120 $caRootInfo = Batch::exec('mkcertCaRootInfo', $batch);
121 if ($caRootInfo && isset($caRootInfo[0])) {
122 Log::info('mkcert CAROOT is set to: ' . $caRootInfo[0]);
123 }
124
125 Log::info('Root CA created. Rebuilding all existing certificates and ensuring localhost exists...');
126
127 // Ensure localhost is created/rebuilt
128 $this->createCrt('localhost');
129
130 return $this->rebuildAllCerts();
131 }
132
138 public function rebuildAllCerts()
139 {
140 $certs = $this->getCrts();
141 $success = true;
142
143 foreach ($certs as $cert) {
144 Log::info('Rebuilding certificate: ' . $cert);
145 if (!$this->createCrt($cert)) {
146 Log::error('Failed to rebuild certificate: ' . $cert);
147 $success = false;
148 }
149 }
150
151 return $success;
152 }
153
163 private function validateCertificateName($name)
164 {
165 if (empty($name)) {
166 return false;
167 }
168
169 // Filesystem-safe whitelist with mandatory alphanumeric first character:
170 // - Prevents CLI flag injection (leading `-`)
171 // - Prevents relative path traversal (leading `.`)
172 // - Allows remaining chars to be alphanumeric, dots, dashes, underscores
173 if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/', $name)) {
174 return false;
175 }
176
177 return true;
178 }
179
187 public function createCrt($name, $destPath = null)
188 {
189 Log::trace('createCrt called for: ' . $name . ($destPath ? ' (dest: ' . $destPath . ')' : ''));
190
191 if (!$this->validateCertificateName($name)) {
192 Log::error('Invalid certificate name: ' . $name);
193 return false;
194 }
195
196 if (!$this->ensureMkcertExeExists()) {
197 return false;
198 }
199 if (empty($destPath)) {
200 $destPath = $this->ensureSslDirExists();
201 }
202 $mkcertExe = Path::getMkcertExe();
203
204 if (!$this->ensureRootCaExists($destPath)) {
205 Log::error('Failed to ensure Root CA exists for: ' . $name);
206 return false;
207 }
208
209 $crtPath = '"' . Path::formatWindowsPath($destPath . '/' . $name . '.crt') . '"';
210 $pubPath = '"' . Path::formatWindowsPath($destPath . '/' . $name . '.pub') . '"';
211 $keyPath = '"' . Path::formatWindowsPath($destPath . '/' . $name . '.ppk') . '"'; // Using .ppk as requested in previous tasks
212 $opensslExe = '"' . Path::formatWindowsPath(Path::getOpenSslExe()) . '"';
213
214 $batch = 'SET "CAROOT=' . Path::formatWindowsPath($destPath) . '"' . PHP_EOL;
215
216 $mkcertNames = '"' . $name . '"';
217 if ($name === 'localhost') {
218 $mkcertNames .= ' 127.0.0.1 ::1';
219 } else {
220 $mkcertNames .= ' "*.' . $name . '" localhost 127.0.0.1 ::1';
221 }
222
223 Log::trace('Executing mkcert for "' . $name . '"');
224 // Use -- to terminate flag parsing before hostname arguments as defense-in-depth
225 $batch .= '"' . $mkcertExe . '" -cert-file ' . $crtPath . ' -key-file ' . $keyPath . ' -- ' . $mkcertNames . PHP_EOL;
226 $batch .= $opensslExe . " rsa -in " . $keyPath . " -out " . $keyPath . " -passin pass:" . PHP_EOL;
227 $batch .= "COPY /Y " . $keyPath . " " . $pubPath . PHP_EOL;
228 $batch .= 'IF EXIST ' . $crtPath . ' IF EXIST ' . $keyPath . ' ECHO OK' . PHP_EOL;
229
230 Log::trace('Creating SSL Certificate for "' . $name . '" using mkcert. Batch content: ' . $batch);
231 $result = Batch::exec('createCertificateMkcert', $batch);
232
233 if ($result === false || !is_array($result)) {
234 Log::error('Batch execution failed for mkcert generation of "' . $name . '". Check logs for createCertificateMkcert.');
235 return false;
236 }
237
238 $success = false;
239 foreach ($result as $line) {
240 if (trim($line) === 'OK') {
241 $success = true;
242 break;
243 }
244 }
245
246 if (!$success) {
247 Log::error('mkcert generation for "' . $name . '" did not return OK. Output: ' . implode(' | ', $result));
248 }
249 Log::trace('mkcert generation for "' . $name . '": ' . ($success ? 'SUCCESS' : 'FAILURE'));
250
251 return $success;
252 }
253
260 private function ensureRootCaExists($destPath)
261 {
262 if (!$this->ensureMkcertExeExists()) {
263 return false;
264 }
265 $mkcertExe = Path::getMkcertExe();
266
267 $rootCaPath = $destPath . '/' . Path::getMkcertRootCaName(); // mkcert default root CA name
268 if (!file_exists($rootCaPath)) {
269 Log::info('Root CA missing at ' . $rootCaPath . '. Running mkcert -install');
270 $batch = 'SET "CAROOT=' . Path::formatWindowsPath($destPath) . '"' . PHP_EOL;
271 $batch .= '"' . $mkcertExe . '" -install' . PHP_EOL;
272 $batch .= 'IF EXIST "' . Path::formatWindowsPath($rootCaPath) . '" (ECHO OK)' . PHP_EOL;
273 $result = Batch::exec('mkcertInstall', $batch);
274
275 if ($result === false) {
276 Log::error('Batch execution failed for mkcert -install');
277 return false;
278 }
279
280 // Re-check after installation
281 if (!file_exists($rootCaPath)) {
282 Log::error('Root CA still missing after mkcert -install at: ' . $rootCaPath);
283 return false;
284 }
285 Log::info('Root CA successfully created and verified.');
286 }
287 return true;
288 }
289
293 public function delSslCertificate()
294 {
295 global $bearsamppLang, $bearsamppWinbinder;
296
297 $initServerName = 'test.local';
298 $initDocumentRoot = Path::formatWindowsPath(Path::getSslPath());
299
300 $bearsamppWinbinder->reset();
301 $wbWindow = $bearsamppWinbinder->createAppWindow($bearsamppLang->getValue(Lang::DELSSL_TITLE), 490, 160, WBC_NOTIFY, WBC_KEYDOWN | WBC_KEYUP);
302
303 $wbLabelName = $bearsamppWinbinder->createLabel($wbWindow, $bearsamppLang->getValue(Lang::NAME) . ' :', 15, 15, 85, null, WBC_RIGHT);
304 $this->wbDelSslListCerts = $bearsamppWinbinder->createInputText($wbWindow, $initServerName, 105, 13, 150, null);
305
306 $wbLabelDest = $bearsamppWinbinder->createLabel($wbWindow, $bearsamppLang->getValue(Lang::TARGET) . ' :', 15, 45, 85, null, WBC_RIGHT);
307 $this->wbDelSslInputDest = $bearsamppWinbinder->createInputText($wbWindow, $initDocumentRoot, 105, 43, 190, null, null, WBC_READONLY);
308 $this->wbDelSslBtnDest = $bearsamppWinbinder->createButton($wbWindow, $bearsamppLang->getValue(Lang::BUTTON_BROWSE), 300, 43, 110);
309
310 $this->wbDelSslProgressBar = $bearsamppWinbinder->createProgressBar($wbWindow, 3, 15, 97, 275);
311 $this->wbDelSslBtnDelete = $bearsamppWinbinder->createButton($wbWindow, $bearsamppLang->getValue(Lang::BUTTON_DELETE), 300, 92);
312 $this->wbDelSslBtnCancel = $bearsamppWinbinder->createButton($wbWindow, $bearsamppLang->getValue(Lang::BUTTON_CANCEL), 387, 92);
313
314 $bearsamppWinbinder->setHandler($wbWindow, $this, 'delSslCertificateHandler');
315
316 $bearsamppWinbinder->mainLoop();
317 $bearsamppWinbinder->reset();
318 }
319
323 public function delSslCertificateHandler($window, $id, $ctrl, $param1, $param2)
324 {
325 global $bearsamppLang, $bearsamppOpenSsl, $bearsamppWinbinder;
326
327 switch ($id) {
328 case $this->wbDelSslBtnDest[WinBinder::CTRL_ID]:
329 $target = $bearsamppWinbinder->getText($this->wbDelSslInputDest[WinBinder::CTRL_OBJ]);
330 $target = $bearsamppWinbinder->sysDlgPath($window, $bearsamppLang->getValue(Lang::GENSSL_PATH), $target);
331 if ($target && is_dir($target)) {
332 $bearsamppWinbinder->setText($this->wbDelSslInputDest[WinBinder::CTRL_OBJ], $target . '\\');
333 }
334 break;
335 case $this->wbDelSslBtnDelete[WinBinder::CTRL_ID]:
336 $cert = $bearsamppWinbinder->getText($this->wbDelSslListCerts[WinBinder::CTRL_OBJ]);
337 $target = $bearsamppWinbinder->getText($this->wbDelSslInputDest[WinBinder::CTRL_OBJ]);
338
339 if ($cert) {
340 $existingCerts = $this->getCrts();
341 if (!in_array($cert, $existingCerts)) {
342 $bearsamppWinbinder->messageBoxError(sprintf($bearsamppLang->getValue(Lang::ERROR_FILE_NOT_FOUND), $cert, $target), $bearsamppLang->getValue(Lang::DELSSL_TITLE));
343 return;
344 }
345
346 $bearsamppWinbinder->setProgressBarMax($this->wbDelSslProgressBar, 3);
347 $bearsamppWinbinder->incrProgressBar($this->wbDelSslProgressBar);
348
349 $target = Path::formatUnixPath($target);
350 if ($bearsamppOpenSsl->removeCrt($cert, $target)) {
351 $bearsamppWinbinder->incrProgressBar($this->wbDelSslProgressBar);
352 $bearsamppWinbinder->messageBoxInfo(
353 sprintf($bearsamppLang->getValue(Lang::DELSSL_DELETED), $cert),
355 );
356 $bearsamppWinbinder->destroyWindow($window);
357 } else {
358 $bearsamppWinbinder->messageBoxError($bearsamppLang->getValue(Lang::DELSSL_DELETED_ERROR), $bearsamppLang->getValue(Lang::DELSSL_TITLE));
359 $bearsamppWinbinder->resetProgressBar($this->wbDelSslProgressBar);
360 }
361 }
362 break;
363 case IDCLOSE:
364 case $this->wbDelSslBtnCancel[WinBinder::CTRL_ID]:
365 $bearsamppWinbinder->destroyWindow($window);
366 break;
367 }
368 }
369
376 public function existsCrt($name)
377 {
378 $ppkPath = Path::getSslPath() . '/' . $name . '.ppk';
379 $crtPath = Path::getSslPath() . '/' . $name . '.crt';
380
381 return is_file($ppkPath) && is_file($crtPath);
382 }
383
387 public function genSslCertificate()
388 {
389 global $bearsamppLang, $bearsamppWinbinder;
390
391 $initServerName = 'test.local';
392 $initDocumentRoot = Path::formatWindowsPath(Path::getSslPath());
393
394 $bearsamppWinbinder->reset();
395 $wbWindow = $bearsamppWinbinder->createAppWindow($bearsamppLang->getValue(Lang::GENSSL_TITLE), 490, 160, WBC_NOTIFY, WBC_KEYDOWN | WBC_KEYUP);
396
397 $wbLabelName = $bearsamppWinbinder->createLabel($wbWindow, $bearsamppLang->getValue(Lang::NAME) . ' :', 15, 15, 85, null, WBC_RIGHT);
398 $this->wbGenSslInputName = $bearsamppWinbinder->createInputText($wbWindow, $initServerName, 105, 13, 150, null);
399
400 $wbLabelDest = $bearsamppWinbinder->createLabel($wbWindow, $bearsamppLang->getValue(Lang::TARGET) . ' :', 15, 45, 85, null, WBC_RIGHT);
401 $this->wbGenSslInputDest = $bearsamppWinbinder->createInputText($wbWindow, $initDocumentRoot, 105, 43, 190, null, null, WBC_READONLY);
402 $this->wbGenSslBtnDest = $bearsamppWinbinder->createButton($wbWindow, $bearsamppLang->getValue(Lang::BUTTON_BROWSE), 300, 43, 110);
403
404 $this->wbGenSslProgressBar = $bearsamppWinbinder->createProgressBar($wbWindow, 3, 15, 97, 275);
405 $this->wbGenSslBtnSave = $bearsamppWinbinder->createButton($wbWindow, $bearsamppLang->getValue(Lang::BUTTON_SAVE), 300, 92);
406 $this->wbGenSslBtnCancel = $bearsamppWinbinder->createButton($wbWindow, $bearsamppLang->getValue(Lang::BUTTON_CANCEL), 387, 92);
407
408 $bearsamppWinbinder->setHandler($wbWindow, $this, 'genSslCertificateHandler');
409
410 $bearsamppWinbinder->mainLoop();
411 $bearsamppWinbinder->reset();
412 }
413
417 public function genSslCertificateHandler($window, $id, $ctrl, $param1, $param2)
418 {
419 global $bearsamppLang, $bearsamppOpenSsl, $bearsamppWinbinder;
420
421 switch ($id) {
422 case $this->wbGenSslBtnDest[WinBinder::CTRL_ID]:
423 $target = $bearsamppWinbinder->getText($this->wbGenSslInputDest[WinBinder::CTRL_OBJ]);
424 $target = $bearsamppWinbinder->sysDlgPath($window, $bearsamppLang->getValue(Lang::GENSSL_PATH), $target);
425 if ($target && is_dir($target)) {
426 $bearsamppWinbinder->setText($this->wbGenSslInputDest[WinBinder::CTRL_OBJ], $target . '\\');
427 }
428 break;
429 case $this->wbGenSslBtnSave[WinBinder::CTRL_ID]:
430 $name = $bearsamppWinbinder->getText($this->wbGenSslInputName[WinBinder::CTRL_OBJ]);
431 $target = $bearsamppWinbinder->getText($this->wbGenSslInputDest[WinBinder::CTRL_OBJ]);
432
433 $bearsamppWinbinder->setProgressBarMax($this->wbGenSslProgressBar, 3);
434 $bearsamppWinbinder->incrProgressBar($this->wbGenSslProgressBar);
435
436 $target = Path::formatUnixPath($target);
437 if ($bearsamppOpenSsl->createCrt($name, $target)) {
438 $bearsamppWinbinder->incrProgressBar($this->wbGenSslProgressBar);
439 $bearsamppWinbinder->messageBoxInfo(
440 sprintf($bearsamppLang->getValue(Lang::GENSSL_CREATED), $name),
442 $bearsamppWinbinder->destroyWindow($window);
443 } else {
444 $bearsamppWinbinder->messageBoxError($bearsamppLang->getValue(Lang::GENSSL_CREATED_ERROR), $bearsamppLang->getValue(Lang::GENSSL_TITLE));
445 $bearsamppWinbinder->resetProgressBar($this->wbGenSslProgressBar);
446 }
447 break;
448 case IDCLOSE:
449 case $this->wbGenSslBtnCancel[WinBinder::CTRL_ID]:
450 $bearsamppWinbinder->destroyWindow($window);
451 break;
452 }
453 }
454
460 public function getCrts()
461 {
462 $sslPath = $this->ensureSslDirExists();
463 $certs = [];
464 if (is_dir($sslPath)) {
465 $files = glob($sslPath . '/*.crt');
466 if ($files !== false) {
467 foreach ($files as $file) {
468 $certs[] = basename($file, '.crt');
469 }
470 }
471 }
472 sort($certs);
473 return $certs;
474 }
475
482 public function isExpired($name)
483 {
484 $crtPath = Path::getSslPath() . '/' . $name . '.crt';
485 $pubPath = Path::getSslPath() . '/' . $name . '.pub';
486
487 if (!is_file($crtPath)) {
488 Log::trace('SSL certificate file missing: ' . $crtPath);
489 return true;
490 }
491
492 if (!is_file($pubPath)) {
493 Log::trace('SSL public certificate file missing: ' . $pubPath);
494 return true;
495 }
496
497 if (!extension_loaded('openssl')) {
498 Log::warning('OpenSSL extension not loaded. Cannot parse certificate for expiry check. Assuming NOT expired if file exists.');
499 return false;
500 }
501
502 $crtContent = file_get_contents($crtPath);
503 if ($crtContent === false) {
504 Log::error('Could not read certificate file: ' . $crtPath);
505 return true;
506 }
507
508 $certInfo = openssl_x509_parse($crtContent);
509 if ($certInfo === false) {
510 Log::error('Could not parse certificate: ' . $crtPath . '. OpenSSL error: ' . openssl_error_string());
511 return true;
512 }
513
514 if (isset($certInfo['validTo_time_t'])) {
515 $isExpired = $certInfo['validTo_time_t'] < time();
516 if ($isExpired) {
517 Log::trace('SSL certificate expired: ' . $name . ' (Expired on ' . date('Y-m-d H:i:s', $certInfo['validTo_time_t']) . ')');
518 }
519 return $isExpired;
520 }
521
522 Log::error('Could not find expiry date in certificate: ' . $crtPath);
523 return true;
524 }
525
533 public function removeCrt($name, $destPath = null)
534 {
535 if ($name === 'localhost') {
536 Log::warning('Attempted to remove protected "localhost" certificate. Operation cancelled.');
537 return false;
538 }
539 $destPath = empty($destPath) ? $this->ensureSslDirExists() : $destPath;
540
541 // Basic validation for name to prevent arbitrary file deletion
542 if (!preg_match('/^[a-zA-Z0-9._-]+$/', $name)) {
543 Log::error('Invalid certificate name for removal: ' . $name);
544 return false;
545 }
546
547 $ppkPath = $destPath . '/' . $name . '.ppk';
548 $crtPath = $destPath . '/' . $name . '.crt';
549 $pubPath = $destPath . '/' . $name . '.pub';
550
551 Log::info('Removing SSL certificate: ' . $name . ' from ' . $destPath);
552 return @unlink($ppkPath) && @unlink($crtPath) && @unlink($pubPath);
553 }
554}
$result
global $bearsamppLang
static exec($basename, $content, $timeout=true, $catchOutput=true, $standalone=false, $silent=true, $rebuild=true)
const GENSSL_TITLE
const GENSSL_CREATED
const NAME
const BUTTON_BROWSE
const BUTTON_CANCEL
const GENSSL_PATH
const BUTTON_DELETE
const DELSSL_DELETED_ERROR
const BUTTON_SAVE
const GENSSL_CREATED_ERROR
const TARGET
const DELSSL_DELETED
const ERROR_FILE_NOT_FOUND
const DELSSL_TITLE
static info($data, $file=null)
static warning($data, $file=null)
static trace($data, $file=null)
static error($data, $file=null)
removeCrt($name, $destPath=null)
ensureRootCaExists($destPath)
createCrt($name, $destPath=null)
ensureMkcertExeExists()
isExpired($name)
delSslCertificateHandler($window, $id, $ctrl, $param1, $param2)
existsCrt($name)
validateCertificateName($name)
genSslCertificateHandler($window, $id, $ctrl, $param1, $param2)
ensureSslDirExists()
static getSslPath($aetrayPath=false)
static getOpenSslExe($aetrayPath=false)
static getMkcertExe($aetrayPath=false)
static formatWindowsPath($path)
static formatUnixPath($path)
static getMkcertRootCaName()