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
85 lines
2.2 KiB
PHP
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;
|
|
}
|
|
}
|