'base64...', '1' => 'base64...']); * define('AIWEND_SECRET_KEY_CURRENT', 2); * or, for a single key: * define('AIWEND_SECRET_KEY', 'base64...'); // version 1 * * Construction fails closed: no keys, malformed keys or a missing current * version throw MissingKeyException. There is no plaintext fallback. */ final class KeyRing { /** @var array version => raw 32-byte key */ private array $keys = []; /** @param array $base64Keys */ public function __construct(array $base64Keys, private int $current) { foreach ($base64Keys as $version => $encoded) { $raw = base64_decode((string) $encoded, true); if (!is_int($version) && !ctype_digit((string) $version)) { throw new MissingKeyException('Key versions must be positive integers.'); } if ($raw === false || strlen($raw) !== SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_KEYBYTES) { throw new MissingKeyException('Each Aiwend secret key must be 32 random bytes, base64-encoded.'); } $this->keys[(int) $version] = $raw; } if (!isset($this->keys[$current])) { throw new MissingKeyException('The current Aiwend secret key version is not configured.'); } } public static function fromConstants(): self { if (defined('AIWEND_SECRET_KEYS') && is_array(constant('AIWEND_SECRET_KEYS'))) { $keys = constant('AIWEND_SECRET_KEYS'); $current = defined('AIWEND_SECRET_KEY_CURRENT') ? (int) constant('AIWEND_SECRET_KEY_CURRENT') : max(array_map('intval', array_keys($keys))); return new self($keys, $current); } if (defined('AIWEND_SECRET_KEY') && is_string(constant('AIWEND_SECRET_KEY'))) { return new self([1 => constant('AIWEND_SECRET_KEY')], 1); } throw new MissingKeyException('No Aiwend secret key is configured. Add AIWEND_SECRET_KEY to wp-config.php.'); } /** * Keys from wp-config.php constants (when defined; they always win and decide the current version) merged with the key file's * keys (older versions stay readable, so moving from the key file to wp-config.php, or rotating, loses nothing). A constant that * defines the same version with a different key simply fails to decrypt what the file's key encrypted: it fails closed. * * @throws MissingKeyException when neither source provides a usable key */ public static function fromEnvironment(?KeyFile $file, ?callable $constants = null, ?callable $salts = null): self { $keys = []; $current = null; $noConstants = null; try { $fromConstants = $constants !== null ? $constants() : self::fromConstants(); foreach ($fromConstants->keys as $version => $raw) { $keys[$version] = base64_encode($raw); } $current = $fromConstants->current; } catch (MissingKeyException $e) { $noConstants = $e; } if ($file !== null && $file->exists()) { try { $stored = $file->read(); $keys += $stored['keys']; $current ??= $stored['current']; } catch (MissingKeyException $e) { if ($current === null) { throw $e; // the file is the only source and it is damaged: fail closed, never replace it } // a usable wp-config.php key keeps working; whatever the damaged file encrypted fails closed row by row } } // The WordPress-salts key (SaltKey) is the zero-configuration default: current only when nothing else is configured, and always readable. $derived = $salts !== null ? $salts() : null; if ($derived !== null) { $keys += [self::SALT_VERSION => base64_encode($derived)]; $current ??= self::SALT_VERSION; } if ($current === null) { throw $noConstants ?? new MissingKeyException('No Aiwend secret key is available.'); } return new self($keys, (int) $current); } /** Version number of the key derived from the WordPress salts (SaltKey); far above any version a person would configure. */ public const SALT_VERSION = 4096; /** Where the usable keys come from: 'constants', 'file', 'both', 'salts' (nothing configured, the salts-derived key is in use) or 'none'. */ public static function source(?KeyFile $file, bool $saltsUsable = false): string { $c = defined('AIWEND_SECRET_KEYS') || defined('AIWEND_SECRET_KEY'); $f = $file !== null && $file->exists(); return $c && $f ? 'both' : ($c ? 'constants' : ($f ? 'file' : ($saltsUsable ? 'salts' : 'none'))); } public static function generateKey(): string { return base64_encode(sodium_crypto_aead_xchacha20poly1305_ietf_keygen()); } public function currentVersion(): int { return $this->current; } public function key(int $version): string { if (!isset($this->keys[$version])) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception messages go to logs and callers, never echoed; public errors are fixed codes throw new MissingKeyException("Secret key version $version is not available."); } return $this->keys[$version]; } }