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
32declare(strict_types=1);
33
34namespace SwapAds\Client\Security;
35
36/**
37 * Class EncryptedOption
38 *
39 * @since 1.5.9
40 */
41final 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}