wp-m365-login/includes/class-m365-login-crypto.php
Friederich Loheide 850f0dcd54
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
Fix the findings of a full second security audit
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>
2026-09-23 17:10:30 +00:00

87 lines
2.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}