| 1 |
<?php |
| 2 |
/** |
| 3 |
* Encrypt-at-rest for plugin secrets. |
| 4 |
* |
| 5 |
* @package BetterDocs |
| 6 |
* @since 4.9.0 |
| 7 |
*/ |
| 8 |
|
| 9 |
namespace WPDeveloper\BetterDocs\Core; |
| 10 |
|
| 11 |
if ( ! defined( 'ABSPATH' ) ) { |
| 12 |
exit; // Exit if accessed directly. |
| 13 |
} |
| 14 |
|
| 15 |
/** |
| 16 |
* Libsodium `secretbox` under a key derived from the site's auth salt, marked |
| 17 |
* by a `bdenc:v1:` prefix, so anything holding a credential can store it |
| 18 |
* without keeping its own copy of the crypto. |
| 19 |
* |
| 20 |
* What this protects: a leaked **database**. A backup, a shared-host read, |
| 21 |
* another plugin's SQL injection — none of them yield a usable secret. What it |
| 22 |
* does not protect: a leaked **server**, because the key is derived from |
| 23 |
* `wp-config.php`. |
| 24 |
* |
| 25 |
* Two behaviours are deliberate and load-bearing: |
| 26 |
* |
| 27 |
* - **Plaintext passthrough.** Where `sodium` is missing or `wp_salt()` is |
| 28 |
* unavailable, `encrypt()` returns the value unchanged rather than storing |
| 29 |
* nothing. A host that cannot encrypt keeps working; the value simply lacks |
| 30 |
* the prefix, and `decrypt()` recognises it as legacy plaintext. |
| 31 |
* - **A ciphertext that will not open returns `''`, never itself.** The auth |
| 32 |
* salt was rotated, or the row is corrupt. Handing the envelope back would |
| 33 |
* send it upstream as if it were the credential and produce an opaque failure |
| 34 |
* a long way from the cause. |
| 35 |
* |
| 36 |
* Because of the second rule, a class storing an authenticator must keep a |
| 37 |
* separate SHA-256 of it ({@see \WPDeveloper\BetterDocs\Mcp\MCPPairing}): |
| 38 |
* verification then survives a salt rotation that makes the display copy |
| 39 |
* unrecoverable. |
| 40 |
* |
| 41 |
* `final`, static, no hooks. |
| 42 |
* |
| 43 |
* @since 4.9.0 |
| 44 |
*/ |
| 45 |
final class SecretAtRest { |
| 46 |
|
| 47 |
/** |
| 48 |
* Marks a value as encrypted by this class. |
| 49 |
* |
| 50 |
* @since 4.9.0 |
| 51 |
*/ |
| 52 |
const PREFIX = 'bdenc:v1:'; |
| 53 |
|
| 54 |
/** |
| 55 |
* Encrypt a secret for storage. |
| 56 |
* |
| 57 |
* @since 4.9.0 |
| 58 |
* |
| 59 |
* @param string $value Raw secret. |
| 60 |
* @return string Ciphertext envelope, or the value unchanged where this |
| 61 |
* host cannot encrypt. |
| 62 |
*/ |
| 63 |
public static function encrypt( $value ) { |
| 64 |
$value = (string) $value; |
| 65 |
|
| 66 |
if ( '' === $value || ! function_exists( 'sodium_crypto_secretbox' ) ) { |
| 67 |
return $value; |
| 68 |
} |
| 69 |
|
| 70 |
$key = self::key(); |
| 71 |
|
| 72 |
if ( '' === $key ) { |
| 73 |
return $value; |
| 74 |
} |
| 75 |
|
| 76 |
try { |
| 77 |
$nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); |
| 78 |
$cipher = sodium_crypto_secretbox( $value, $nonce, $key ); |
| 79 |
|
| 80 |
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- transport encoding for a sodium ciphertext, not obfuscation. |
| 81 |
return self::PREFIX . base64_encode( $nonce . $cipher ); |
| 82 |
} catch ( \Exception $e ) { |
| 83 |
return $value; |
| 84 |
} |
| 85 |
} |
| 86 |
|
| 87 |
/** |
| 88 |
* Decrypt a value written by {@see self::encrypt()}. |
| 89 |
* |
| 90 |
* A value without the marker is legacy plaintext and is returned untouched. |
| 91 |
* A marked value that will not open returns `''` — see the class docblock. |
| 92 |
* |
| 93 |
* @since 4.9.0 |
| 94 |
* |
| 95 |
* @param string $value Stored value. |
| 96 |
* @return string Plaintext, the original value, or ''. |
| 97 |
*/ |
| 98 |
public static function decrypt( $value ) { |
| 99 |
$value = (string) $value; |
| 100 |
|
| 101 |
if ( ! self::is_encrypted( $value ) ) { |
| 102 |
return $value; |
| 103 |
} |
| 104 |
|
| 105 |
if ( ! function_exists( 'sodium_crypto_secretbox_open' ) ) { |
| 106 |
return $value; |
| 107 |
} |
| 108 |
|
| 109 |
$key = self::key(); |
| 110 |
|
| 111 |
if ( '' === $key ) { |
| 112 |
return $value; |
| 113 |
} |
| 114 |
|
| 115 |
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decodes our own ciphertext envelope; strict mode is on. |
| 116 |
$decoded = base64_decode( substr( $value, strlen( self::PREFIX ) ), true ); |
| 117 |
|
| 118 |
if ( false === $decoded || strlen( $decoded ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) { |
| 119 |
return ''; |
| 120 |
} |
| 121 |
|
| 122 |
$nonce = substr( $decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); |
| 123 |
$cipher = substr( $decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); |
| 124 |
$plain = sodium_crypto_secretbox_open( $cipher, $nonce, $key ); |
| 125 |
|
| 126 |
return false === $plain ? '' : $plain; |
| 127 |
} |
| 128 |
|
| 129 |
/** |
| 130 |
* Whether a stored value carries this class's marker. |
| 131 |
* |
| 132 |
* @since 4.9.0 |
| 133 |
* |
| 134 |
* @param string $value Stored value. |
| 135 |
* @return bool |
| 136 |
*/ |
| 137 |
public static function is_encrypted( $value ) { |
| 138 |
return 0 === strncmp( (string) $value, self::PREFIX, strlen( self::PREFIX ) ); |
| 139 |
} |
| 140 |
|
| 141 |
/** |
| 142 |
* Derive the 32-byte key from the site's auth salt. |
| 143 |
* |
| 144 |
* SHA-256's output length is `SODIUM_CRYPTO_SECRETBOX_KEYBYTES`, so the |
| 145 |
* raw digest is the key. |
| 146 |
* |
| 147 |
* @since 4.9.0 |
| 148 |
* |
| 149 |
* @return string Raw 32-byte key, or '' when salts are unavailable. |
| 150 |
*/ |
| 151 |
private static function key() { |
| 152 |
if ( ! function_exists( 'wp_salt' ) ) { |
| 153 |
return ''; |
| 154 |
} |
| 155 |
|
| 156 |
$salt = (string) wp_salt( 'auth' ); |
| 157 |
|
| 158 |
if ( '' === $salt ) { |
| 159 |
return ''; |
| 160 |
} |
| 161 |
|
| 162 |
return hash( 'sha256', 'betterdocs-mcp|' . $salt, true ); |
| 163 |
} |
| 164 |
} |
| 165 |
|