wp-m365-login/includes/class-m365-login-crypto.php
friloo 3e3e87b399
Add M365 Login plugin: Microsoft Entra ID sign-in for existing users
Adds a WordPress plugin that places a customisable "Sign in with
Microsoft" button on wp-login.php and signs existing users in via the
OpenID Connect authorization code flow with PKCE. Users are matched by
e-mail address only; no accounts are created.

Security: single-use state/nonce bound to an HttpOnly cookie, ID token
signature verification against Microsoft's JWKS (RS256 only) with
issuer/audience/tenant/expiry/nonce checks, optional tenant pinning,
account binding to the Microsoft object ID, e-mail domain allow-list,
client secret encrypted at rest (AES-256-GCM).

Admin: settings screen with connection, button and security tabs, live
button preview, colour presets, media-library icon picker, redirect URI
copy button and tenant connectivity test.

Packaging for WordPress.org: readme.txt with External services section,
GPL-2.0 license, uninstall.php, POT + German translations, .distignore,
build script, PHPCS config and CI running Plugin Check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJxAHYdMfKPoN4koRc4Ci2
2026-09-22 14:21:10 +00:00

85 lines
2.2 KiB
PHP

<?php
/**
* Symmetric encryption for secrets at rest.
*
* @package M365_Login
*/
defined( 'ABSPATH' ) || exit;
/**
* AES-256-GCM helper keyed from the WordPress salts.
*
* The key is derived from AUTH_KEY / SECURE_AUTH_KEY (via wp_salt()), so the
* stored client secret is useless without access to wp-config.php.
*/
final class M365_Login_Crypto {
const PREFIX = 'm365v1:';
const CIPHER = 'aes-256-gcm';
/**
* Derives the encryption key.
*
* @return string 32 raw bytes.
*/
private static function key() {
$material = wp_salt( 'auth' ) . '|' . wp_salt( 'secure_auth' ) . '|m365-login';
if ( function_exists( 'hash_hkdf' ) ) {
return hash_hkdf( 'sha256', $material, 32, 'm365-login-client-secret' );
}
return hash( 'sha256', $material, true );
}
/**
* Whether encryption is available.
*
* @return bool
*/
public static function available() {
return function_exists( 'openssl_encrypt' ) && in_array( self::CIPHER, openssl_get_cipher_methods(), true );
}
/**
* Encrypts a string.
*
* @param string $plain Plain text.
* @return string|false
*/
public static function encrypt( $plain ) {
if ( ! self::available() ) {
return false;
}
$iv = random_bytes( 12 );
$tag = '';
$ct = openssl_encrypt( $plain, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag, '', 16 );
if ( false === $ct || '' === $tag ) {
return false;
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
return self::PREFIX . base64_encode( $iv . $tag . $ct );
}
/**
* Decrypts a string produced by encrypt().
*
* @param string $stored Stored value.
* @return string|false
*/
public static function decrypt( $stored ) {
if ( ! is_string( $stored ) || 0 !== strpos( $stored, self::PREFIX ) || ! self::available() ) {
return false;
}
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
$raw = base64_decode( substr( $stored, strlen( self::PREFIX ) ), true );
if ( false === $raw || strlen( $raw ) < 28 ) {
return false;
}
$iv = substr( $raw, 0, 12 );
$tag = substr( $raw, 12, 16 );
$ct = substr( $raw, 28 );
$plain = openssl_decrypt( $ct, self::CIPHER, self::key(), OPENSSL_RAW_DATA, $iv, $tag );
return false === $plain ? false : $plain;
}
}