PEM, 'certificate' => PEM ). */ public static function generate( $common_name ) { if ( ! function_exists( 'openssl_pkey_new' ) ) { return new WP_Error( 'no_openssl', __( 'The PHP OpenSSL extension is not available.', 'm365-login' ) ); } $common_name = preg_replace( '/[^A-Za-z0-9.\-]/', '', (string) $common_name ); $common_name = '' === $common_name ? 'wordpress' : substr( $common_name, 0, 64 ); $key = openssl_pkey_new( array( 'private_key_bits' => self::KEY_BITS, 'private_key_type' => OPENSSL_KEYTYPE_RSA, ) ); if ( false === $key ) { return new WP_Error( 'keygen', self::openssl_error( __( 'Key generation failed.', 'm365-login' ) ) ); } $dn = array( 'CN' => $common_name, 'O' => 'M365 Login for WordPress', ); $csr = openssl_csr_new( $dn, $key, array( 'digest_alg' => 'sha256' ) ); if ( false === $csr ) { return new WP_Error( 'csr', self::openssl_error( __( 'Certificate request failed.', 'm365-login' ) ) ); } $cert = openssl_csr_sign( $csr, null, $key, self::VALID_DAYS, array( 'digest_alg' => 'sha256' ), (int) ( time() % PHP_INT_MAX ) ); if ( false === $cert ) { return new WP_Error( 'sign', self::openssl_error( __( 'Certificate signing failed.', 'm365-login' ) ) ); } $key_pem = ''; $cert_pem = ''; if ( ! openssl_pkey_export( $key, $key_pem ) || ! openssl_x509_export( $cert, $cert_pem ) ) { return new WP_Error( 'export', self::openssl_error( __( 'Exporting the certificate failed.', 'm365-login' ) ) ); } return array( 'private_key' => $key_pem, 'certificate' => $cert_pem, ); } /** * Validates a user supplied key/certificate pair. * * @param string $key_pem Private key PEM (unencrypted). * @param string $cert_pem Certificate PEM. * @return array|WP_Error Normalised pair. */ public static function from_pem( $key_pem, $cert_pem ) { $key_pem = self::normalise_pem( $key_pem ); $cert_pem = self::normalise_pem( $cert_pem ); if ( ! preg_match( '/-----BEGIN (RSA |EC |ENCRYPTED )?PRIVATE KEY-----/', $key_pem ) ) { return new WP_Error( 'key_format', __( 'The private key must be in PEM format (-----BEGIN PRIVATE KEY-----).', 'm365-login' ) ); } if ( false !== strpos( $key_pem, 'ENCRYPTED PRIVATE KEY' ) ) { return new WP_Error( 'key_encrypted', __( 'Password protected private keys are not supported. Export the key without a passphrase.', 'm365-login' ) ); } $key = openssl_pkey_get_private( $key_pem ); if ( false === $key ) { return new WP_Error( 'key_invalid', self::openssl_error( __( 'The private key could not be read.', 'm365-login' ) ) ); } $details = openssl_pkey_get_details( $key ); if ( ! is_array( $details ) || OPENSSL_KEYTYPE_RSA !== $details['type'] ) { return new WP_Error( 'key_type', __( 'Only RSA keys are supported.', 'm365-login' ) ); } if ( $details['bits'] < 2048 ) { return new WP_Error( 'key_bits', __( 'The RSA key must have at least 2048 bits.', 'm365-login' ) ); } if ( false !== strpos( $cert_pem, 'PRIVATE KEY' ) ) { return new WP_Error( 'cert_has_key', __( 'The certificate field contains a private key. Paste only the certificate (-----BEGIN CERTIFICATE-----) there.', 'm365-login' ) ); } $cert = openssl_x509_read( $cert_pem ); if ( false === $cert ) { return new WP_Error( 'cert_invalid', self::openssl_error( __( 'The certificate could not be read. Paste it in PEM format (-----BEGIN CERTIFICATE-----).', 'm365-login' ) ) ); } if ( ! openssl_x509_check_private_key( $cert, $key ) ) { return new WP_Error( 'cert_mismatch', __( 'The certificate does not belong to this private key.', 'm365-login' ) ); } $info = openssl_x509_parse( $cert ); if ( is_array( $info ) && ! empty( $info['validTo_time_t'] ) && (int) $info['validTo_time_t'] < time() ) { return new WP_Error( 'cert_expired', __( 'The certificate has already expired.', 'm365-login' ) ); } // Store exactly one clean certificate (drops chains, bundles and surrounding text). $clean = ''; if ( ! openssl_x509_export( $cert, $clean ) || '' === $clean ) { return new WP_Error( 'cert_invalid', self::openssl_error( __( 'The certificate could not be read. Paste it in PEM format (-----BEGIN CERTIFICATE-----).', 'm365-login' ) ) ); } return array( 'private_key' => $key_pem, 'certificate' => self::normalise_pem( $clean ), ); } /** * Normalises line endings and trims a PEM block. * * @param string $pem PEM. * @return string */ private static function normalise_pem( $pem ) { $pem = str_replace( array( "\r\n", "\r" ), "\n", trim( (string) $pem ) ); return $pem . "\n"; } /** * Certificate metadata for display. * * @param string $cert_pem Certificate PEM. * @return array|null array( 'thumbprint' => hex SHA-1, 'thumbprint_sha256' => hex, 'subject' => string, 'not_before' => ts, 'not_after' => ts, 'bits' => int ). */ public static function info( $cert_pem ) { if ( '' === (string) $cert_pem ) { return null; } $cert = openssl_x509_read( $cert_pem ); if ( false === $cert ) { return null; } $parsed = openssl_x509_parse( $cert ); $der = self::der( $cert_pem ); $public = openssl_pkey_get_public( $cert ); $detail = $public ? openssl_pkey_get_details( $public ) : null; return array( 'thumbprint' => strtoupper( sha1( $der ) ), 'thumbprint_sha256' => strtoupper( hash( 'sha256', $der ) ), 'subject' => isset( $parsed['subject']['CN'] ) ? (string) $parsed['subject']['CN'] : '', 'not_before' => isset( $parsed['validFrom_time_t'] ) ? (int) $parsed['validFrom_time_t'] : 0, 'not_after' => isset( $parsed['validTo_time_t'] ) ? (int) $parsed['validTo_time_t'] : 0, 'bits' => is_array( $detail ) && isset( $detail['bits'] ) ? (int) $detail['bits'] : 0, ); } /** * DER bytes of a PEM certificate. * * @param string $cert_pem PEM. * @return string */ private static function der( $cert_pem ) { $body = preg_replace( '/-----[^-]+-----|\s+/', '', (string) $cert_pem ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode $der = base64_decode( $body, true ); return false === $der ? '' : $der; } /** * Builds the signed client assertion for the token endpoint. * * @param string $key_pem Private key PEM. * @param string $cert_pem Certificate PEM (for the x5t header). * @param string $client_id Application (client) ID. * @param string $token_endpoint Audience. * @return string|WP_Error Compact JWS. */ public static function assertion( $key_pem, $cert_pem, $client_id, $token_endpoint ) { $key = openssl_pkey_get_private( $key_pem ); if ( false === $key ) { return new WP_Error( 'key_invalid', 'Private key could not be loaded.' ); } $der = self::der( $cert_pem ); if ( '' === $der ) { return new WP_Error( 'cert_invalid', 'Certificate could not be decoded.' ); } $now = time(); $header = array( 'alg' => 'RS256', 'typ' => 'JWT', 'x5t' => M365_Login_JWT::b64url_encode( sha1( $der, true ) ), 'x5t#S256' => M365_Login_JWT::b64url_encode( hash( 'sha256', $der, true ) ), ); $claims = array( 'aud' => $token_endpoint, 'iss' => $client_id, 'sub' => $client_id, 'jti' => M365_Login_JWT::b64url_encode( random_bytes( 24 ) ), 'nbf' => $now - 30, 'iat' => $now, 'exp' => $now + self::ASSERTION_TTL, ); $signing_input = M365_Login_JWT::b64url_encode( wp_json_encode( $header ) ) . '.' . M365_Login_JWT::b64url_encode( wp_json_encode( $claims ) ); $signature = ''; if ( ! openssl_sign( $signing_input, $signature, $key, OPENSSL_ALGO_SHA256 ) ) { return new WP_Error( 'sign', 'Signing the client assertion failed.' ); } return $signing_input . '.' . M365_Login_JWT::b64url_encode( $signature ); } /** * Prefixes the last OpenSSL error to a message (for admins). * * @param string $message Message. * @return string */ private static function openssl_error( $message ) { $detail = openssl_error_string(); return $detail ? $message . ' (' . $detail . ')' : $message; } }