PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.4.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.4.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / core / class-secret-at-rest.php

class-secret-at-rest.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.4.0, at includes/core/class-secret-at-rest.php

150 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 * The scheme Settings has used for API keys and OAuth tokens since 1.20 —
6 * libsodium `secretbox` under a key derived from the site's auth salt, marked
7 * by a `trenc:v1:` prefix — lifted out so anything else holding a credential
8 * can use it. Settings delegates here, so a value written by one reads back
9 * through the other.
10 *
11 * This protects a leaked database dump: a backup, a shared-host read, another
12 * plugin's SQL injection. It does not protect a leaked server, because the key
13 * comes from wp-config.php.
14 *
15 * @package ThinkRank
16 * @subpackage Core
17 * @since 2.0.1
18 */
19
20 declare(strict_types=1);
21
22 namespace ThinkRank\Core;
23
24 // Prevent direct access
25 if (!defined('ABSPATH')) {
26 exit;
27 }
28
29 /**
30 * Encrypt-at-rest helper.
31 *
32 * @since 2.0.1
33 */
34 class Secret_At_Rest {
35
36 /**
37 * Marks a value as encrypted by this class.
38 *
39 * @var string
40 */
41 public const PREFIX = 'trenc:v1:';
42
43 /**
44 * Encrypt a secret for storage.
45 *
46 * Falls back to the plaintext value where sodium is unavailable or no salt
47 * can be derived, so behaviour stays stable on a host that cannot encrypt
48 * rather than silently storing nothing.
49 *
50 * @since 2.0.1
51 *
52 * @param string $value Raw secret.
53 * @return string Ciphertext envelope, or the value unchanged.
54 */
55 public static function encrypt(string $value): string {
56 if ('' === $value || !function_exists('sodium_crypto_secretbox')) {
57 return $value;
58 }
59
60 $key = self::key();
61
62 if ('' === $key) {
63 return $value;
64 }
65
66 try {
67 $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
68 $cipher = sodium_crypto_secretbox($value, $nonce, $key);
69
70 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- transport encoding for a sodium ciphertext, not obfuscation.
71 return self::PREFIX . base64_encode($nonce . $cipher);
72 } catch (\Exception $e) {
73 return $value;
74 }
75 }
76
77 /**
78 * Decrypt a value written by encrypt().
79 *
80 * A value without the marker is legacy plaintext and returned untouched.
81 * A value that carries the marker and will not open is unrecoverable — the
82 * auth salt changed, or the row is corrupt — and returns '' rather than the
83 * ciphertext, which would otherwise be sent upstream as a credential and
84 * produce an opaque 401 far from the cause.
85 *
86 * @since 2.0.1
87 *
88 * @param string $value Stored value.
89 * @return string Plaintext, the original value, or ''.
90 */
91 public static function decrypt(string $value): string {
92 if (0 !== strncmp($value, self::PREFIX, strlen(self::PREFIX))) {
93 return $value;
94 }
95
96 if (!function_exists('sodium_crypto_secretbox_open')) {
97 return $value;
98 }
99
100 $key = self::key();
101
102 if ('' === $key) {
103 return $value;
104 }
105
106 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decodes our own ciphertext envelope; strict mode is on.
107 $decoded = base64_decode(substr($value, strlen(self::PREFIX)), true);
108
109 if (false === $decoded || strlen($decoded) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
110 return $value;
111 }
112
113 $nonce = substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
114 $cipher = substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
115 $plain = sodium_crypto_secretbox_open($cipher, $nonce, $key);
116
117 return false === $plain ? '' : $plain;
118 }
119
120 /**
121 * Whether a stored value is one of ours.
122 *
123 * @since 2.0.1
124 *
125 * @param string $value Stored value.
126 * @return bool
127 */
128 public static function is_encrypted(string $value): bool {
129 return 0 === strncmp($value, self::PREFIX, strlen(self::PREFIX));
130 }
131
132 /**
133 * Derive the 32-byte key from the site's auth salt.
134 *
135 * @since 2.0.1
136 *
137 * @return string Raw 32-byte key, or '' when salts are unavailable.
138 */
139 private static function key(): string {
140 if (!function_exists('wp_salt')) {
141 return '';
142 }
143
144 // SHA-256 output length matches SODIUM_CRYPTO_SECRETBOX_KEYBYTES.
145 // The literal is the one Settings has always used, so values written
146 // before this class existed still open.
147 return hash('sha256', 'thinkrank-settings|' . wp_salt('auth'), true);
148 }
149 }
150