Some checks are pending
CI / PHP lint (7.4) (pull_request) Waiting to run
CI / PHP lint (8.0) (pull_request) Waiting to run
CI / PHP lint (8.1) (pull_request) Waiting to run
CI / PHP lint (8.2) (pull_request) Waiting to run
CI / PHP lint (8.3) (pull_request) Waiting to run
CI / PHP lint (8.4) (pull_request) Waiting to run
CI / WordPress Coding Standards (pull_request) Waiting to run
CI / WordPress.org Plugin Check (pull_request) Waiting to run
Four-part audit (OIDC/JWT/crypto, user sync, admin UI, login bypasses) with dynamic PoCs against a real WordPress install; every fix is covered by a regression test. Report: docs/security-audit.md, section 6. Critical/High - Multisite: settings, AJAX actions and certificate download require manage_network_options (site admins could sign in as super admin). - Privileged accounts are only linked (sync and first sign-in) via a matching UPN of a member account, never via the settable mail attribute; the sync never changes their e-mail address; e-mail change notifications stay on. - Button-only mode exempts by credential (application passwords, WP-CLI) instead of request context, closing bypasses through xmlrpc.php and REST login handlers; API requests never receive login cookies. - Multi-tenant mode refuses guest/external identities. Medium/Low - Same message for right and wrong passwords; button-only no longer switches off when the connection breaks; server-side fallback cookie expiry; correct fallback key beats IP lockouts; right-most proxy hop; higher start limit; one object ID per account. - Deactivation sets a random password, revokes application passwords and removes the role (restored on reactivation); disabled people are deactivated even when their mail vanished; duplicate bindings handled. - Sync: abort on empty directory answer, no deprovisioning right after a tenant change, atomic run lock, strict photo path validation. - Certificates: key bundles refused, clean re-exported certificate. - Array-safe sanitising, encoded redirect_to, per-action nonces, escaped role lists, no Graph sleeps during sign-in, warnings for public groups, multi-tenant group rules and missing salts, uninstall clears the token. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
87 lines
2.4 KiB
PHP
87 lines
2.4 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 – provided the
|
||
* salts are defined there. Without them wp_salt() keeps generated salts in the
|
||
* database next to the ciphertext; the settings screen warns about that.
|
||
*/
|
||
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;
|
||
}
|
||
}
|