wp-m365-login/includes/class-m365-login-certificate.php
Friederich Loheide 9b893e42bc Fix the findings of the third security audit
- Privileged accounts: the UPN rule also applies when bind_oid is off or
  the account is not bound; privileges are checked on every site of a
  multisite user, include code/HTML capabilities (unfiltered_html,
  plugins, themes, users) and the remembered roles of deactivated
  accounts.
- send_auth_cookies protection also works on WordPress 6.0/6.1.
- Run lock via INSERT IGNORE (atomic), refreshed during long runs; a
  shutdown handler reports fatal errors and frees the lock.
- Deprovisioning only for accounts linked in the current tenant (tenant
  recorded per account; legacy links not found are left alone).
- Safety stop based on the accounts linked before the run; new safety
  stop for removals of administrative roles.
- Disable is idempotent; row-action nonces are bound to the state.
- Profile photos are re-encoded to 240 px (drops EXIF and appended
  data), size-limited while downloading, removed on deactivation;
  index.php guard in the photo folder.
- Privacy exporter and eraser for the copied data.
- One-time migration hardens accounts deactivated by 1.0 and cleans a
  stored certificate bundle; the .cer download is always re-exported.
- Password fields hidden in button-only mode even when the connection
  is broken; settings written non-autoloaded; robust user ID queries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 03:52:13 +00:00

253 lines
9.1 KiB
PHP

<?php
/**
* Certificate based client authentication (private_key_jwt / RFC 7523).
*
* @package M365_Login
*/
defined( 'ABSPATH' ) || exit;
/**
* Generates, validates and uses an RSA key pair + X.509 certificate for
* authenticating the app registration without a client secret.
*/
final class M365_Login_Certificate {
const KEY_BITS = 3072;
const VALID_DAYS = 730;
const ASSERTION_TTL = 300; // Seconds; Microsoft allows up to 10 minutes.
const EXPIRY_WARNING = 30 * DAY_IN_SECONDS;
/**
* Generates a new self-signed certificate for the given site.
*
* @param string $common_name Subject CN (host name of the site).
* @return array|WP_Error array( 'private_key' => 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 ),
);
}
/**
* Exactly one certificate re-exported from a PEM text, or '' if none can be read.
*
* @param string $pem PEM text (may contain other blocks).
* @return string
*/
public static function clean_pem( $pem ) {
if ( ! preg_match( '/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/s', (string) $pem, $m ) ) {
return '';
}
$cert = openssl_x509_read( $m[0] );
$clean = '';
if ( false === $cert || ! openssl_x509_export( $cert, $clean ) ) {
return '';
}
return 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;
}
}