Harden JWT PEM normalization diagnostics

This commit is contained in:
2026-05-21 13:35:26 -05:00
parent 7836b72d6d
commit 99f35c64ad
3 changed files with 126 additions and 27 deletions

View File

@@ -109,10 +109,11 @@ class JwtAuthMiddleware
throw new \RuntimeException('JWT public key is empty'); throw new \RuntimeException('JWT public key is empty');
} }
$this->flushOpenSslErrors();
$keyResource = openssl_pkey_get_public($publicKey); $keyResource = openssl_pkey_get_public($publicKey);
if ($keyResource === false) { if ($keyResource === false) {
throw new \RuntimeException(openssl_error_string() ?: 'OpenSSL could not read JWT public key'); throw new \RuntimeException($this->openSslErrorMessage('OpenSSL could not read JWT public key'));
} }
$result = openssl_verify( $result = openssl_verify(
@@ -123,7 +124,7 @@ class JwtAuthMiddleware
); );
if ($result === false) { if ($result === false) {
throw new \RuntimeException(openssl_error_string() ?: 'OpenSSL could not verify JWT signature'); throw new \RuntimeException($this->openSslErrorMessage('OpenSSL could not verify JWT signature'));
} }
return $result === 1; return $result === 1;
@@ -131,6 +132,15 @@ class JwtAuthMiddleware
private function normalizePublicKey(string $publicKey): string private function normalizePublicKey(string $publicKey): string
{ {
$publicKey = trim($publicKey);
if (
(str_starts_with($publicKey, '"') && str_ends_with($publicKey, '"')) ||
(str_starts_with($publicKey, "'") && str_ends_with($publicKey, "'"))
) {
$publicKey = substr($publicKey, 1, -1);
}
$publicKey = trim(str_replace(['\\r\\n', '\\n', '\\r', "\r\n", "\r"], "\n", $publicKey)); $publicKey = trim(str_replace(['\\r\\n', '\\n', '\\r', "\r\n", "\r"], "\n", $publicKey));
if ($publicKey === '') { if ($publicKey === '') {
@@ -139,23 +149,52 @@ class JwtAuthMiddleware
if ( if (
preg_match( preg_match(
'/-----BEGIN PUBLIC KEY-----(.*?)-----END PUBLIC KEY-----/s', '/-----BEGIN ([A-Z ]*PUBLIC KEY)-----(.*?)-----END \1-----/s',
$publicKey, $publicKey,
$matches $matches
) )
) { ) {
$body = preg_replace('/\s+/', '', $matches[1]); $type = $matches[1];
$body = preg_replace('/[^A-Za-z0-9+\/=]/', '', $matches[2]);
if ($body === '') { if ($body === '') {
return ''; return '';
} }
return "-----BEGIN PUBLIC KEY-----\n" return "-----BEGIN {$type}-----\n"
. chunk_split($body, 64, "\n") . chunk_split($body, 64, "\n")
. "-----END PUBLIC KEY-----\n"; . "-----END {$type}-----\n";
} }
return $publicKey; if (!str_contains($publicKey, '-----BEGIN')) {
$body = preg_replace('/[^A-Za-z0-9+\/=]/', '', $publicKey);
if (strlen($body) > 100) {
return "-----BEGIN PUBLIC KEY-----\n"
. chunk_split($body, 64, "\n")
. "-----END PUBLIC KEY-----\n";
}
}
return trim($publicKey) . "\n";
}
private function flushOpenSslErrors(): void
{
while (openssl_error_string() !== false) {
// Clear stale OpenSSL errors before reading the next operation result.
}
}
private function openSslErrorMessage(string $fallback): string
{
$errors = [];
while (($error = openssl_error_string()) !== false) {
$errors[] = $error;
}
return $errors === [] ? $fallback : implode(' | ', $errors);
} }
private function tokenIsExpired(array $payload): bool private function tokenIsExpired(array $payload): bool

View File

@@ -30,31 +30,61 @@ Route::get('/health', function () {
Route::get('/health-check-key', function () { Route::get('/health-check-key', function () {
$rawPublicKey = (string) config('jwt.public_key'); $rawPublicKey = (string) config('jwt.public_key');
$formattedPublicKey = trim(str_replace(['\\r\\n', '\\n', '\\r', "\r\n", "\r"], "\n", $rawPublicKey)); $formattedPublicKey = trim($rawPublicKey);
if ( if (
preg_match( (str_starts_with($formattedPublicKey, '"') && str_ends_with($formattedPublicKey, '"')) ||
'/-----BEGIN PUBLIC KEY-----(.*?)-----END PUBLIC KEY-----/s', (str_starts_with($formattedPublicKey, "'") && str_ends_with($formattedPublicKey, "'"))
$formattedPublicKey,
$matches
)
) { ) {
$body = preg_replace('/\s+/', '', $matches[1]); $formattedPublicKey = substr($formattedPublicKey, 1, -1);
$formattedPublicKey = "-----BEGIN PUBLIC KEY-----\n" }
$formattedPublicKey = trim(str_replace(['\\r\\n', '\\n', '\\r', "\r\n", "\r"], "\n", $formattedPublicKey));
$pemType = null;
$bodyLength = null;
if (preg_match('/-----BEGIN ([A-Z ]*PUBLIC KEY)-----(.*?)-----END \1-----/s', $formattedPublicKey, $matches)) {
$pemType = $matches[1];
$body = preg_replace('/[^A-Za-z0-9+\/=]/', '', $matches[2]);
$bodyLength = strlen($body);
$formattedPublicKey = "-----BEGIN {$pemType}-----\n"
. chunk_split($body, 64, "\n") . chunk_split($body, 64, "\n")
. "-----END PUBLIC KEY-----\n"; . "-----END {$pemType}-----\n";
} elseif (!str_contains($formattedPublicKey, '-----BEGIN')) {
$body = preg_replace('/[^A-Za-z0-9+\/=]/', '', $formattedPublicKey);
$bodyLength = strlen($body);
if ($bodyLength > 100) {
$pemType = 'PUBLIC KEY';
$formattedPublicKey = "-----BEGIN PUBLIC KEY-----\n"
. chunk_split($body, 64, "\n")
. "-----END PUBLIC KEY-----\n";
}
}
while (openssl_error_string() !== false) {
// Clear stale OpenSSL errors before testing the current key.
} }
$publicKeyResource = openssl_pkey_get_public($formattedPublicKey); $publicKeyResource = openssl_pkey_get_public($formattedPublicKey);
$openSslErrors = [];
while (($error = openssl_error_string()) !== false) {
$openSslErrors[] = $error;
}
return response()->json([ return response()->json([
'raw_key_empty' => $rawPublicKey === '', 'raw_key_empty' => $rawPublicKey === '',
'raw_key_length' => strlen($rawPublicKey), 'raw_key_length' => strlen($rawPublicKey),
'formatted_key_length' => strlen($formattedPublicKey), 'formatted_key_length' => strlen($formattedPublicKey),
'pem_type' => $pemType,
'pem_body_length' => $bodyLength,
'has_begin_marker' => str_contains($rawPublicKey, '-----BEGIN PUBLIC KEY-----'), 'has_begin_marker' => str_contains($rawPublicKey, '-----BEGIN PUBLIC KEY-----'),
'has_rsa_begin_marker' => str_contains($rawPublicKey, '-----BEGIN RSA PUBLIC KEY-----'),
'has_end_marker' => str_contains($rawPublicKey, '-----END PUBLIC KEY-----'), 'has_end_marker' => str_contains($rawPublicKey, '-----END PUBLIC KEY-----'),
'has_rsa_end_marker' => str_contains($rawPublicKey, '-----END RSA PUBLIC KEY-----'),
'openssl_accepted' => $publicKeyResource !== false, 'openssl_accepted' => $publicKeyResource !== false,
'openssl_error' => openssl_error_string(), 'openssl_errors' => $openSslErrors,
]); ]);
}); });

View File

@@ -17,31 +17,61 @@ Route::get('/health', function () {
Route::get('/health-check-key', function () { Route::get('/health-check-key', function () {
$rawPublicKey = (string) config('jwt.public_key'); $rawPublicKey = (string) config('jwt.public_key');
$formattedPublicKey = trim(str_replace(['\\r\\n', '\\n', '\\r', "\r\n", "\r"], "\n", $rawPublicKey)); $formattedPublicKey = trim($rawPublicKey);
if ( if (
preg_match( (str_starts_with($formattedPublicKey, '"') && str_ends_with($formattedPublicKey, '"')) ||
'/-----BEGIN PUBLIC KEY-----(.*?)-----END PUBLIC KEY-----/s', (str_starts_with($formattedPublicKey, "'") && str_ends_with($formattedPublicKey, "'"))
$formattedPublicKey,
$matches
)
) { ) {
$body = preg_replace('/\s+/', '', $matches[1]); $formattedPublicKey = substr($formattedPublicKey, 1, -1);
$formattedPublicKey = "-----BEGIN PUBLIC KEY-----\n" }
$formattedPublicKey = trim(str_replace(['\\r\\n', '\\n', '\\r', "\r\n", "\r"], "\n", $formattedPublicKey));
$pemType = null;
$bodyLength = null;
if (preg_match('/-----BEGIN ([A-Z ]*PUBLIC KEY)-----(.*?)-----END \1-----/s', $formattedPublicKey, $matches)) {
$pemType = $matches[1];
$body = preg_replace('/[^A-Za-z0-9+\/=]/', '', $matches[2]);
$bodyLength = strlen($body);
$formattedPublicKey = "-----BEGIN {$pemType}-----\n"
. chunk_split($body, 64, "\n") . chunk_split($body, 64, "\n")
. "-----END PUBLIC KEY-----\n"; . "-----END {$pemType}-----\n";
} elseif (!str_contains($formattedPublicKey, '-----BEGIN')) {
$body = preg_replace('/[^A-Za-z0-9+\/=]/', '', $formattedPublicKey);
$bodyLength = strlen($body);
if ($bodyLength > 100) {
$pemType = 'PUBLIC KEY';
$formattedPublicKey = "-----BEGIN PUBLIC KEY-----\n"
. chunk_split($body, 64, "\n")
. "-----END PUBLIC KEY-----\n";
}
}
while (openssl_error_string() !== false) {
// Clear stale OpenSSL errors before testing the current key.
} }
$publicKeyResource = openssl_pkey_get_public($formattedPublicKey); $publicKeyResource = openssl_pkey_get_public($formattedPublicKey);
$openSslErrors = [];
while (($error = openssl_error_string()) !== false) {
$openSslErrors[] = $error;
}
return response()->json([ return response()->json([
'raw_key_empty' => $rawPublicKey === '', 'raw_key_empty' => $rawPublicKey === '',
'raw_key_length' => strlen($rawPublicKey), 'raw_key_length' => strlen($rawPublicKey),
'formatted_key_length' => strlen($formattedPublicKey), 'formatted_key_length' => strlen($formattedPublicKey),
'pem_type' => $pemType,
'pem_body_length' => $bodyLength,
'has_begin_marker' => str_contains($rawPublicKey, '-----BEGIN PUBLIC KEY-----'), 'has_begin_marker' => str_contains($rawPublicKey, '-----BEGIN PUBLIC KEY-----'),
'has_rsa_begin_marker' => str_contains($rawPublicKey, '-----BEGIN RSA PUBLIC KEY-----'),
'has_end_marker' => str_contains($rawPublicKey, '-----END PUBLIC KEY-----'), 'has_end_marker' => str_contains($rawPublicKey, '-----END PUBLIC KEY-----'),
'has_rsa_end_marker' => str_contains($rawPublicKey, '-----END RSA PUBLIC KEY-----'),
'openssl_accepted' => $publicKeyResource !== false, 'openssl_accepted' => $publicKeyResource !== false,
'openssl_error' => openssl_error_string(), 'openssl_errors' => $openSslErrors,
]); ]);
}); });