PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.0
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.5.2 All 199 releases
betterdocs / includes / Core / SecretAtRest.php

SecretAtRest.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.0, at includes/Core/SecretAtRest.php

165 lines 4.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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