'SHA256', ]; /** * Map of supported algorithms to their key sizes in bits. */ private const ALG_KEY_SIZES = [ 'ES256' => 256, ]; /** * Decode and verify a JWT string. * * @param string $jwt The JWT string. * @param string $public_key The PEM-encoded public key. * @param string $algorithm The expected algorithm (e.g. 'ES256'). * @return array The decoded payload as an associative array. * * @throws \UnexpectedValueException If the JWT is malformed or the algorithm doesn't match. * @throws JWTSignatureInvalidException If signature verification fails. * @throws JWTExpiredException If the token has expired. * @throws JWTAlgorithmMismatchException If the JWT provided algorithm does not match the decode algorithm. */ public static function decode( string $jwt, string $public_key, string $algorithm ): array { if ( ! isset( self::SUPPORTED_ALGS[ $algorithm ] ) ) { throw new \UnexpectedValueException( 'Algorithm not supported: ' . $algorithm ); } $parts = explode( '.', $jwt ); if ( count( $parts ) !== 3 ) { throw new \UnexpectedValueException( 'Wrong number of segments' ); } list( $head_b64, $body_b64, $sig_b64 ) = $parts; $header = json_decode( self::urlsafe_b64_decode( $head_b64 ), true ); if ( null === $header ) { throw new \UnexpectedValueException( 'Invalid header encoding' ); } $payload = json_decode( self::urlsafe_b64_decode( $body_b64 ), true ); if ( null === $payload ) { throw new \UnexpectedValueException( 'Invalid claims encoding' ); } if ( empty( $header['alg'] ) ) { throw new \UnexpectedValueException( 'Empty algorithm' ); } if ( $header['alg'] !== $algorithm ) { throw new JWTAlgorithmMismatchException( 'Incorrect key for this algorithm' ); } // Verify signature. $signature = self::urlsafe_b64_decode( $sig_b64 ); $der_signature = self::signature_to_der( $signature ); $digest = self::SUPPORTED_ALGS[ $algorithm ]; $msg = $head_b64 . '.' . $body_b64; $result = openssl_verify( $msg, $der_signature, $public_key, $digest ); if ( 1 !== $result ) { if ( -1 === $result ) { throw new \UnexpectedValueException( 'OpenSSL error: ' . openssl_error_string() ); } throw new JWTSignatureInvalidException( 'Signature verification failed' ); } // Check expiration. if ( isset( $payload['exp'] ) && time() >= $payload['exp'] ) { throw new JWTExpiredException( 'Expired token' ); } return $payload; } /** * Encode a payload as a JWT string. * * @param array $payload The payload data. * @param string $private_key The PEM-encoded private key. * @param string $algorithm The signing algorithm (e.g. 'ES256'). * @return string The JWT string. * @throws \DomainException In case no product found. */ public static function encode( array $payload, string $private_key, string $algorithm ): string { if ( ! isset( self::SUPPORTED_ALGS[ $algorithm ] ) ) { throw new \DomainException( 'Algorithm not supported: ' . $algorithm ); } $header = [ 'typ' => 'JWT', 'alg' => $algorithm, ]; $segments = []; $segments[] = self::urlsafe_b64_encode( (string) wp_json_encode( $header ) ); $segments[] = self::urlsafe_b64_encode( (string) wp_json_encode( $payload ) ); $msg = implode( '.', $segments ); $signature = ''; $success = openssl_sign( $msg, $signature, $private_key, self::SUPPORTED_ALGS[ $algorithm ] ); if ( ! $success ) { throw new \DomainException( 'OpenSSL unable to sign data' ); } $key_size = self::ALG_KEY_SIZES[ $algorithm ]; $signature = self::signature_from_der( $signature, $key_size ); $segments[] = self::urlsafe_b64_encode( $signature ); return implode( '.', $segments ); } /** * Extract the payload from a JWT without verifying the signature. * * Validates the JWT structure (three dot-separated segments) and decodes * the payload, but does not check the signature or expiration. * * @param string $jwt The JWT string. * @return array The decoded payload as an associative array. * @throws \UnexpectedValueException If the JWT is malformed. */ public static function extract_unverified_payload( string $jwt ): array { $parts = explode( '.', $jwt ); if ( count( $parts ) !== 3 ) { throw new \UnexpectedValueException( 'Wrong number of segments' ); } $payload = json_decode( self::urlsafe_b64_decode( $parts[1] ), true ); if ( null === $payload ) { throw new \UnexpectedValueException( 'Invalid claims encoding' ); } return $payload; } /** * Decode a URL-safe Base64 string. * * @param string $input The base64url encoded string. * @return string The decoded bytes. */ public static function urlsafe_b64_decode( string $input ): string { $remainder = strlen( $input ) % 4; if ( $remainder ) { $input .= str_repeat( '=', 4 - $remainder ); } return base64_decode( strtr( $input, '-_', '+/' ) ); } /** * Encode a string as URL-safe Base64. * * @param string $input The raw bytes. * @return string The base64url encoded string. */ private static function urlsafe_b64_encode( string $input ): string { return str_replace( '=', '', strtr( base64_encode( $input ), '+/', '-_' ) ); } /** * Convert a fixed-length ECDSA signature (R||S) to ASN.1 DER format * as expected by openssl_verify(). * * @param string $sig The raw ECDSA signature. * @return string The DER-encoded signature. */ private static function signature_to_der( string $sig ): string { $length = max( 1, (int) ( strlen( $sig ) / 2 ) ); list( $r, $s ) = str_split( $sig, $length ); $r = ltrim( $r, "\x00" ); $s = ltrim( $s, "\x00" ); if ( ord( $r[0] ) > 0x7f ) { $r = "\x00" . $r; } if ( ord( $s[0] ) > 0x7f ) { $s = "\x00" . $s; } return self::encode_der( self::ASN1_SEQUENCE, self::encode_der( self::ASN1_INTEGER, $r ) . self::encode_der( self::ASN1_INTEGER, $s ) ); } /** * Convert a DER-encoded ECDSA signature to fixed-length (R||S) format * as used in JWT. * * @param string $der The DER-encoded signature. * @param int $key_size The key size in bits. * @return string The fixed-length signature. */ private static function signature_from_der( string $der, int $key_size ): string { list( $offset, $_ ) = self::read_der( $der ); list( $offset, $r ) = self::read_der( $der, $offset ); list( $offset, $s ) = self::read_der( $der, $offset ); $r = ltrim( $r, "\x00" ); $s = ltrim( $s, "\x00" ); $r = str_pad( $r, $key_size / 8, "\x00", STR_PAD_LEFT ); $s = str_pad( $s, $key_size / 8, "\x00", STR_PAD_LEFT ); return $r . $s; } /** * Encode a value into a DER object. * * @param int $type The ASN.1 tag type. * @param string $value The value to encode. * @return string The DER-encoded object. */ private static function encode_der( int $type, string $value ): string { $tag_header = 0; if ( self::ASN1_SEQUENCE === $type ) { $tag_header |= 0x20; } $der = chr( $tag_header | $type ); $der .= chr( strlen( $value ) ); return $der . $value; } /** * Read a single DER-encoded object. * * @param string $der The DER data. * @param int $offset The starting offset. * @return array{int, string|null} The new offset and the decoded value. */ private static function read_der( string $der, int $offset = 0 ): array { $pos = $offset; $size = strlen( $der ); $constructed = ( ord( $der[ $pos ] ) >> 5 ) & 0x01; $type = ord( $der[ $pos++ ] ) & 0x1f; $len = ord( $der[ $pos++ ] ); if ( $len & 0x80 ) { $n = $len & 0x1f; $len = 0; while ( $n-- && $pos < $size ) { $len = ( $len << 8 ) | ord( $der[ $pos++ ] ); } } if ( self::ASN1_BIT_STRING === $type ) { $pos++; $data = substr( $der, $pos, $len - 1 ); $pos += $len - 1; } elseif ( ! $constructed ) { $data = substr( $der, $pos, $len ); $pos += $len; } else { $data = null; } return [ $pos, $data ]; } }