From 2f4c1b2c72b615be5209a499f2faf2b47fca18ef Mon Sep 17 00:00:00 2001
From: peptiderepo <273788462+peptiderepo@users.noreply.github.com>
Date: Mon, 15 Jun 2026 10:49:08 +0700
Subject: [PATCH] =?UTF-8?q?v0.2.3=20=E2=80=94=20UI-editable=20encrypted=20?=
=?UTF-8?q?provider=20API=20key=20(#6)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Squash-merge PR #6: PR Vision v0.2.3 — UI-editable encrypted provider API key
QA APPROVED: 2026-06-14-85956da7c86bed2c9e2cf60625fda0cf2e9518bb.md
All 5 CI checks green on head SHA 85956da7c86bed2c9e2cf60625fda0cf2e9518bb
---
ARCHITECTURE.md | 27 ++-
CHANGELOG.md | 60 +++++
CONTEXT.md | 15 ++
CONVENTIONS.md | 10 +
includes/core/class-prv-crypto-helper.php | 185 +++++++++++++++
.../core/class-prv-key-manager-renderer.php | 212 ++++++++++++++++++
includes/core/class-prv-key-store.php | 186 +++++++++++++++
includes/core/class-prv-key-test-ajax.php | 94 ++++++++
includes/core/class-prv-model-test-ajax.php | 22 +-
.../core/class-prv-settings-controller.php | 96 +++++++-
includes/core/class-prv-settings-page.php | 38 +++-
includes/core/class-prv-settings-renderer.php | 55 ++---
.../class-prv-openrouter-provider.php | 62 ++---
.../class-prv-perplexity-provider.php | 41 ++--
pr-vision.php | 4 +-
tests/bootstrap.php | 2 +-
tests/unit/test-key-store.php | 182 +++++++++++++++
tests/unit/test-uninstall.php | 2 +
uninstall.php | 9 +-
19 files changed, 1183 insertions(+), 119 deletions(-)
create mode 100644 includes/core/class-prv-crypto-helper.php
create mode 100644 includes/core/class-prv-key-manager-renderer.php
create mode 100644 includes/core/class-prv-key-store.php
create mode 100644 includes/core/class-prv-key-test-ajax.php
create mode 100644 tests/unit/test-key-store.php
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 8ee87c5..89e9fd5 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -43,7 +43,11 @@ pr-vision/
│ │ ├── class-prv-settings-controller.php # [v0.2] POST handler impl: save config, model CRUD (split from settings-page)
│ │ ├── class-prv-settings-renderer.php # [v0.2] Settings HTML renderer (dark Assay theme)
│ │ ├── class-prv-model-manager-table.php # [v0.2] Model manager table + Add form renderer
-│ │ └── class-prv-model-test-ajax.php # [v0.2] Rate-limited AJAX handler for Test button
+│ │ ├── class-prv-model-test-ajax.php # [v0.2] Rate-limited AJAX handler for Test button
+│ │ ├── class-prv-key-store.php # [v0.2.3] API key resolver: constant → enc-option → none
+│ │ ├── class-prv-crypto-helper.php # [v0.2.3] libsodium/OpenSSL encrypt+decrypt
+│ │ ├── class-prv-key-manager-renderer.php # [v0.2.3] Write-only key card renderer
+│ │ └── class-prv-key-test-ajax.php # [v0.2.3] AJAX handler for Test key button
│ ├── providers/
│ │ ├── interface-prv-probe-provider.php # probe(query): PRV_Probe_Result contract
│ │ ├── class-prv-gateway-client.php # Cloudflare AI Gateway HTTP + retry
@@ -155,7 +159,8 @@ Schema version: `prv_schema_version` = 2.
| prv_config_versions | array | All config-version records |
| prv_active_config_version | int | Currently active version number |
| prv_cadence | string | 'weekly'\|'daily'\|'twicedaily' |
-| prv_api_key_status | string | 'ok'\|'failed'\|'unknown' |
+| prv_api_key_status | string | 'ok'|'failed'|'unknown' |
+| prv_provider_key_enc | string | [v0.2.3] Encrypted API key ciphertext (hex(nonce):hex(ciphertext)); PRV_Key_Store |
| prv_api_key_last_check | string | Datetime of last status update |
| prv_last_run_at | string | Datetime of last run |
| prv_last_run_counts | array | {probed, skipped_budget, skipped_error, truncated, run_id} |
@@ -193,12 +198,22 @@ Same as v0.1.0 -- see CONTEXT.md for the extension guide.
## 7. Security
-- Secrets read from `wp-config.php` constants at runtime -- never stored in DB.
-- API key status shown in 3 states; key never displayed.
+- **API key resolver (v0.2.3):** `PRV_Key_Store::get_key()` is the single access point for
+ the API key. Precedence: `PRV_OPENROUTER_API_KEY` constant → encrypted option `prv_provider_key_enc` → none.
+ All probe + test paths route through this resolver; direct constant reads are removed.
+- **Encrypted at rest (v0.2.3):** admin-entered key stored as ciphertext via `PRV_Crypto_Helper`
+ (libsodium preferred, OpenSSL AES-256-GCM fallback). Encryption key derived from WP salts via SHA-256.
+ Plaintext never stored in any option, transient, log, or error message.
+- **Write-only UI (v0.2.3):** Settings shows source status only; password input always renders empty.
+ Key value never in page source, JS, REST responses, or AJAX responses.
+- **wp-config precedence:** constant takes priority; admin option is only used when constant is absent.
+ Input is disabled with a note when constant is defined.
+- Secrets read from `wp-config.php` constants at runtime (pre-v0.2.3 primary path).
- All admin actions: `manage_options` check + nonce verification.
- All DB values escaped with `$wpdb->prepare()`.
- All HTML output escaped at render.
- AJAX `prv_test_model`: capability + nonce + 30s rate-limit transient.
+- AJAX `prv_test_key`: capability + nonce + 30s rate-limit transient; key never in response.
---
@@ -212,3 +227,7 @@ Same as v0.1.0 -- see CONTEXT.md for the extension guide.
| Projected cost from probe_count × fixed estimate | Conservative overestimate; avoids the need for per-model price config in v1; operator sees a safe upper bound. |
| Three-state API key (not two) | A defined-but-revoked key showed false-green in v1 (QA finding M4). The third state "defined, last run failed" closes that gap without ever displaying the key. |
| Sticky save-bar warning (not just card banner) | Card banner scrolls off; save-bar is always in viewport on a page with a tall model table (QA finding M2). |
+| wp-config constant beats admin option | Most-secure path (constant) stays the default and is untouched by the admin UI. Admin option is a convenience fallback, never a replacement. |
+| libsodium preferred, OpenSSL fallback | Sodium is bundled in PHP 7.2+ (available on all supported hosts); OpenSSL AES-256-GCM is the universal fallback. Algorithm inferred from nonce length at decrypt time, no stored metadata needed. |
+| Key resolver in PRV_Key_Store, not in providers | Single source of truth — any future provider added to the plugin automatically gets the right key without repeating the precedence logic. |
+| PRV_Crypto_Helper split from PRV_Key_Store | Keeps each file under the 300-line AI-readability limit while preserving a deep-module interface: Key_Store owns the WP-layer (options, salts, source detection); Crypto_Helper owns the crypto primitives. |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 594fbec..fd5ae00 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,66 @@ Versioning: [Semantic Versioning](https://semver.org/)
---
+## [0.2.3] — 2026-06-15
+
+UI-editable provider API key: encrypted at rest, write-only, wp-config precedence.
+Output-escaping hardening in key manager renderer (`esc_url`/`esc_attr`/literal-conditional on all echoed values).
+
+### Added
+
+- **`PRV_Key_Store`** (`includes/core/class-prv-key-store.php`): single key resolver with
+ precedence chain — `PRV_OPENROUTER_API_KEY` constant → encrypted admin option `prv_provider_key_enc` → none.
+ `get_key()` is the sole key-access point for all probe and test paths.
+- **`PRV_Crypto_Helper`** (`includes/core/class-prv-crypto-helper.php`): low-level
+ symmetric encryption/decryption. Prefers libsodium `sodium_crypto_secretbox`
+ (XSalsa20-Poly1305); falls back to OpenSSL AES-256-GCM. Encryption key is derived
+ from WP salts (`AUTH_KEY` + `SECURE_AUTH_KEY`) via SHA-256 — environment-specific,
+ never stored. Storage format: `hex(nonce):hex(ciphertext)`.
+- **`PRV_Key_Manager_Renderer`** (`includes/core/class-prv-key-manager-renderer.php`):
+ renders the write-only "Provider API Key" card in Settings. Shows source status only
+ ("Set via wp-config (takes precedence)" / "Set via admin" / "Not set") plus last-run
+ health. Password input always renders empty. Constant defined → input disabled with note.
+- **`PRV_Key_Test_Ajax`** (`includes/core/class-prv-key-test-ajax.php`): AJAX handler for
+ the "Test key" button. Resolves key via `PRV_Key_Store::get_key()`, runs one cheap probe,
+ reports valid/invalid with `aria-live` result; key never in response.
+- **Key set/remove POST actions** in `PRV_Settings_Controller`:
+ `handle_key_set()` (encrypts + stores; empty input = no-op) and `handle_key_remove()`.
+- **`prv_key_set`, `prv_key_remove`, `prv_key_error`** notices in `PRV_Settings_Renderer`.
+- **`wp_ajax_prv_test_key`** hook registered in `PRV_Settings_Page` → dispatches to `PRV_Key_Test_Ajax`.
+- **Tests** (`tests/unit/test-key-store.php`): encrypt↔decrypt round-trip, wrong-key fails
+ silently, resolver precedence (constant > option > none), write-only render emits no key,
+ Remove clears option, constant takes precedence.
+
+### Changed
+
+- **`PRV_OpenRouter_Provider`**: removed direct `PRV_OPENROUTER_API_KEY` constant reads;
+ `probe()` now calls `PRV_Key_Store::get_key()`. New `probe_with_key(string $q, string $key)`
+ method for the test path (PRV_Key_Test_Ajax injects the resolved key). `is_configured()`
+ now checks `PRV_Key_Store::get_key() !== ''`.
+- **`PRV_Perplexity_Provider`**: same key-resolver migration — `probe()` calls `PRV_Key_Store::get_key()`.
+ `is_configured()` checks the store. Direct constant reads removed.
+- **`PRV_Model_Test_Ajax`**: resolves key via `PRV_Key_Store::get_key()` instead of checking
+ the constant directly. Returns a specific "key not configured — set via Settings" message
+ when no key is available.
+- **`PRV_Settings_Renderer`**: replaced read-only `render_api_key_status()` with a call to
+ `PRV_Key_Manager_Renderer::render()`. Added key-action notices. Provider API Key card is
+ now separate from the Probe Configuration form.
+- **`PRV_Settings_Page`**: added `NONCE_KEY` + `NONCE_KEY_TEST` constants; registered
+ `admin_post_prv_key_set`, `admin_post_prv_key_remove`, `wp_ajax_prv_test_key`.
+- **`uninstall.php`**: updated comment to explicitly list `prv_provider_key_enc` as a v0.2.3
+ addition purged by the `prv_` wildcard DELETE.
+- **`tests/unit/test-uninstall.php`**: seeds + asserts `prv_provider_key_enc` purge.
+
+### Security
+
+- Plaintext API key never stored in any option, transient, log, or echoed to the browser.
+- The encrypted option (`prv_provider_key_enc`) is ciphertext only; decryption is server-side
+ at probe time only. The encryption key is derived from WP salts — not stored.
+- `manage_options` + nonce required for all key actions (set, remove, test).
+- `wp-config` constant always takes precedence; admin path is only active when absent.
+
+---
+
## [0.2.2] — 2026-06-14
Kill white WP admin gutters around the dark "Assay" UI — CSS only, no logic change.
diff --git a/CONTEXT.md b/CONTEXT.md
index 61fbf52..2514e8d 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -126,3 +126,18 @@ The plugin's scores are API-probe measurements, not real-world consumer LLM usag
Month-to-date spend in USD, summed from the `cost_usd` column for rows in the current calendar month.
**Code identifiers:** `PRV_Cost_Ledger::get_month_to_date_usd()`.
+
+### Key Store (v0.2.3)
+The subsystem that manages the OpenRouter API key with a precedence chain and encrypted-at-rest storage. Precedence: `PRV_OPENROUTER_API_KEY` wp-config constant (highest) → encrypted admin option `prv_provider_key_enc` → none. All key reads by providers and AJAX handlers route through `PRV_Key_Store::get_key()`. The plaintext key is never stored in any option, log, or client-side surface.
+
+**Code identifiers:** `PRV_Key_Store`, `PRV_Key_Store::get_key()`, `PRV_Key_Store::store_key()`, `PRV_Key_Store::clear_key()`, `PRV_Key_Store::get_source()`, `prv_provider_key_enc` option.
+
+### Write-only Key UI (v0.2.3)
+The Settings page section that allows the operator to set/replace/clear the API key without the key ever being rendered in the browser. The password input always renders empty. The UI shows only the key *source* (wp-config / admin / not set) and the last-run health state. The key value is never output to page HTML, JS, REST responses, or logs.
+
+**Code identifiers:** `PRV_Key_Manager_Renderer`, `PRV_Key_Test_Ajax`, `PRV_Settings_Controller::handle_key_set()`, `PRV_Settings_Controller::handle_key_remove()`.
+
+### Encryption Key (v0.2.3)
+The symmetric key used to encrypt/decrypt `prv_provider_key_enc`. Derived via SHA-256 from `AUTH_KEY + SECURE_AUTH_KEY` (or `wp_salt()` fallback) — environment-specific and never stored. Derived fresh at every encrypt/decrypt call.
+
+**Code identifiers:** `PRV_Key_Store::derive_encryption_key()`, `PRV_Crypto_Helper::encrypt()`, `PRV_Crypto_Helper::decrypt()`.
diff --git a/CONVENTIONS.md b/CONVENTIONS.md
index 25ea632..9d6e61d 100644
--- a/CONVENTIONS.md
+++ b/CONVENTIONS.md
@@ -82,3 +82,13 @@ Every public method must have:
2. Implement `PRV_Dashboard_Panel` in `includes/panel/`.
3. Register both in `PRV_Plugin::init()` via `PRV_Collector_Registry::instance()`.
4. Update `ARCHITECTURE.md` §6 (Collector/Panel seam).
+
+## 9. API key access (v0.2.3)
+
+**Never read `PRV_OPENROUTER_API_KEY` directly in provider or handler code.**
+All key reads must go through `PRV_Key_Store::get_key()`. This maintains the precedence chain (constant → encrypted option → none) and ensures a key set via the admin UI is used without code changes.
+
+- Providers: call `PRV_Key_Store::get_key()` in `probe()`.
+- AJAX handlers: call `PRV_Key_Store::get_key()` before any LLM call.
+- New provider that needs the key: implement `is_configured()` as `'' !== PRV_Key_Store::get_key()`.
+- Do NOT pass the resolved key to any client-side surface, log, exception message, or `error_log()`.
diff --git a/includes/core/class-prv-crypto-helper.php b/includes/core/class-prv-crypto-helper.php
new file mode 100644
index 0000000..fc78cdf
--- /dev/null
+++ b/includes/core/class-prv-crypto-helper.php
@@ -0,0 +1,185 @@
+';
+ echo '
' . esc_html__( 'Provider API Key', 'pr-vision' ) . '
';
+
+ $this->render_status_badge( $source, $status, $last_check );
+ $this->render_key_form( $is_const, $is_set_opt );
+ $this->render_test_button( $source );
+
+ echo '';
+ }
+
+ /**
+ * Render the read-only source + health status badge.
+ *
+ * @param string $source One of PRV_Key_Store::SOURCE_* constants.
+ * @param string $api_status Last stored API status (ok/failed/unknown).
+ * @param string $last_check ISO date of last status update.
+ *
+ * @return void
+ */
+ private function render_status_badge( string $source, string $api_status, string $last_check ): void {
+ echo '