| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 32.65% covered (danger) | 32.65% | 16 / 49 | 40.00% covered (danger) | 40.00% | 2 / 5 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
EncryptedOption | 32.65% covered (danger) | 32.65% | 16 / 49 | 40.00% covered (danger) | 40.00% | 2 / 5 | 199.94 | 0.00% covered (danger) | 0.00% | 0 / 1
put | 41.67% covered (danger) | 41.67% | 5 / 12 | 0.00% covered (danger) | 0.00% | 0 / 1 | 4.79 |
get | 25.00% covered (danger) | 25.00% | 5 / 20 | 0.00% covered (danger) | 0.00% | 0 / 1 | 27.67 |
delete | 100.00% covered (success) | 100.00% | 2 / 2 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
sodiumReady | 100.00% covered (success) | 100.00% | 4 / 4 | 100.00% covered (success) | 100.00% | 1 / 1 | 4 |
deriveKey | 0.00% covered (danger) | 0.00% | 0 / 11 | 0.00% covered (danger) | 0.00% | 0 / 1 | 90 |

1 | <?php
2 | /**
3 | * TODO-CLIENT-API-001 (2026-08-01): encrypted at-rest storage for sensitive
4 | * credentials in wp_options.
5 | *
6 | * Wraps sodium_crypto_secretbox keyed off WordPress's per-install secret
7 | * (LOGGED_IN_KEY + LOGGED_IN_SALT, falling back to AUTH_KEY + AUTH_SALT).
8 | *
9 | * Storage format (single option value):
10 | * base64( nonce(24 bytes) || ciphertext )
11 | *
12 | * Why this matters:
13 | * - HMAC shared secret + license key were previously stored plaintext
14 | * in wp_options. A leaked DB (backup, SQL injection in another plugin,
15 | * shared hosting breach) = credential theft.
16 | * - sodium_crypto_secretbox is authenticated encryption (AE): tampered
17 | * ciphertexts fail decryption (no silent garbage output).
18 | * - Keying off LOGGED_IN_KEY makes the cipher per-install. If the operator
19 | * rotates their WP secret salts (rare but happens), EncryptedOption
20 | * will fail to decrypt → returns $default → caller re-prompts for the
21 | * secret via /public-key.
22 | *
23 | * Migration path:
24 | * - Existing plaintext values get auto-re-encrypted on first read.
25 | * We detect "this value is plaintext (not base64-ciphertext)" and
26 | * transparently migrate it.
27 | *
28 | * @package SwapAds\Client\Security
29 | * @since 1.5.9
30 | */
31 |
32 | declare(strict_types=1);
33 |
34 | namespace SwapAds\Client\Security;
35 |
36 | /**
37 | * Class EncryptedOption
38 | *
39 | * @since 1.5.9
40 | */
41 | final class EncryptedOption
42 | {
43 | /**
44 | * Version byte prepended to the ciphertext.
45 | *
46 | * Bumped if we ever change the cipher (e.g., switch from secretbox to
47 | * secretbox_xchacha20poly1305). Allows future migration logic to know
48 | * which cipher was used for a given stored value.
49 | *
50 | * Layout in storage:
51 | * v1 = base64( version(1) || nonce(24) || ciphertext )
52 | */
53 | private const CIPHER_VERSION = 0x01;
54 |
55 | /**
56 | * Expected ciphertext prefix length (version + nonce).
57 | */
58 | private const HEADER_BYTES = 1 + SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;
59 |
60 | /**
61 | * Store a plaintext value encrypted at rest.
62 | *
63 | * @param string $key WordPress option key.
64 | * @param string $plaintext The plaintext value to encrypt.
65 | *
66 | * @return bool True on success.
67 | *
68 | * @since 1.5.9
69 | */
70 | public static function put(string $key, string $plaintext): bool
71 | {
72 | if ($plaintext === '') {
73 | return (bool) update_option($key, '', '');
74 | }
75 |
76 | if (!self::sodiumReady()) {
77 | // Fallback to plaintext + an audit-flag option so we can
78 | // detect sites where encryption is unavailable (PHP < 7.2).
79 | update_option($key . '_unencrypted', '1', '');
80 | return (bool) update_option($key, $plaintext, '');
81 | }
82 |
83 | $keyBytes = self::deriveKey();
84 | $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
85 | $cipher = sodium_crypto_secretbox($plaintext, $nonce, $keyBytes);
86 | $blob = chr(self::CIPHER_VERSION) . $nonce . $cipher;
87 | $stored = base64_encode($blob);
88 |
89 | delete_option($key . '_unencrypted');
90 | return (bool) update_option($key, $stored, '');
91 | }
92 |
93 | /**
94 | * Retrieve and decrypt a previously-stored value.
95 | *
96 | * If the stored value is in legacy plaintext format (no version prefix),
97 | * transparently migrate it: return the plaintext AND re-encrypt on disk.
98 | *
99 | * @param string $key WordPress option key.
100 | * @param string $default Default to return when nothing is stored or
101 | * decryption fails.
102 | *
103 | * @return string Decrypted plaintext, or $default.
104 | *
105 | * @since 1.5.9
106 | */
107 | public static function get(string $key, string $default = ''): string
108 | {
109 | $stored = (string) get_option($key, '');
110 | if ($stored === '') {
111 | return $default;
112 | }
113 |
114 | if (!self::sodiumReady()) {
115 | return $stored;
116 | }
117 |
118 | $raw = base64_decode($stored, true);
119 | if ($raw === false || strlen($raw) < self::HEADER_BYTES) {
120 | // Legacy plaintext OR corrupt. Try as plaintext.
121 | // Migrate forward (re-encrypt on disk).
122 | update_option($key, $stored, '');
123 | self::put($key, $stored);
124 | return $stored;
125 | }
126 |
127 | $version = ord($raw[0]);
128 | if ($version !== self::CIPHER_VERSION) {
129 | // Unknown version — treat as legacy plaintext.
130 | return $stored;
131 | }
132 |
133 | $keyBytes = self::deriveKey();
134 | $nonce = substr($raw, 1, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
135 | $cipher = substr($raw, 1 + SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
136 | $plain = sodium_crypto_secretbox_open($cipher, $nonce, $keyBytes);
137 |
138 | if ($plain === false) {
139 | // Decryption failed — wrong key (LOGGED_IN_KEY rotated) or
140 | // tampered ciphertext. Return $default; the caller will
141 | // re-fetch from /public-key on next ensure.
142 | return $default;
143 | }
144 |
145 | return $plain;
146 | }
147 |
148 | /**
149 | * Delete an encrypted option (and its legacy migration flag).
150 | *
151 | * @param string $key WordPress option key.
152 | *
153 | * @return bool True on success.
154 | *
155 | * @since 1.5.9
156 | */
157 | public static function delete(string $key): bool
158 | {
159 | delete_option($key . '_unencrypted');
160 | return (bool) delete_option($key);
161 | }
162 |
163 | /**
164 | * Is the lib sodium extension available?
165 | *
166 | * PHP 7.2+ ships with sodium bundled; 8.0+ requires the extension.
167 | * If absent, we fall back to plaintext (with an audit flag).
168 | *
169 | * @return bool
170 | *
171 | * @since 1.5.9
172 | */
173 | public static function sodiumReady(): bool
174 | {
175 | return extension_loaded('sodium')
176 | && function_exists('sodium_crypto_secretbox')
177 | && function_exists('sodium_crypto_secretbox_open')
178 | && defined('SODIUM_CRYPTO_SECRETBOX_NONCEBYTES');
179 | }
180 |
181 | /**
182 | * Derive a 32-byte cipher key from WP's per-install secret salts.
183 | *
184 | * We use SHA-256 of the salt + a domain tag to produce a fixed-length
185 | * key suitable for secretbox. The domain tag prevents the same key
186 | * from being used for other purposes.
187 | *
188 | * @return string 32-byte raw key.
189 | *
190 | * @since 1.5.9
191 | */
192 | private static function deriveKey(): string
193 | {
194 | $base = '';
195 | if (defined('LOGGED_IN_KEY') && LOGGED_IN_KEY !== '') {
196 | $base = (string) LOGGED_IN_KEY;
197 | } elseif (defined('AUTH_KEY') && AUTH_KEY !== '') {
198 | $base = (string) AUTH_KEY;
199 | }
200 | $salt = '';
201 | if (defined('LOGGED_IN_SALT') && LOGGED_IN_SALT !== '') {
202 | $salt = (string) LOGGED_IN_SALT;
203 | } elseif (defined('AUTH_SALT') && AUTH_SALT !== '') {
204 | $salt = (string) AUTH_SALT;
205 | }
206 |
207 | return hash('sha256', 'swapads_encrypted_option_v1' . $base . $salt, true);
208 | }
209 | }