PR Vision v0.2.3: UI-editable provider API key, encrypted + write-only

Add PRV_Key_Store (resolver), PRV_Crypto_Helper (libsodium/OpenSSL),
PRV_Key_Manager_Renderer (write-only Settings card), PRV_Key_Test_Ajax.

Key resolver: PRV_OPENROUTER_API_KEY constant > prv_provider_key_enc
option > none. All probe + test paths read key through PRV_Key_Store::get_key().

Security:
- Ciphertext only in prv_provider_key_enc (hex nonce:hex ciphertext).
- Enc key from SHA-256(AUTH_KEY+SECURE_AUTH_KEY) — never stored.
- Plaintext never in options, logs, exceptions, page source, REST, or JS.
- manage_options + nonce on set/remove/test. wp-config precedence enforced.
- write-only UI: password input always renders empty; status text only.
- uninstall.php prv_ wildcard DELETE covers prv_provider_key_enc.

Files: 4 new (all ≤300 lines), 14 modified. Tests: test-key-store.php
(encrypt/decrypt round-trip, wrong-key fails silently, resolver precedence,
write-only render, Remove, constant wins). ARCHITECTURE/CONVENTIONS/CONTEXT/
CHANGELOG all updated in this commit.

Agent-Session: 2026-06-15-pr-vision-v0.2.3-api-key-ui
This commit is contained in:
peptiderepo
2026-06-15 03:08:54 +00:00
parent 28e3ce6576
commit 67499506ba
19 changed files with 1183 additions and 221 deletions

View File

@@ -27,7 +27,7 @@ if ( ! defined( 'ARRAY_N' ) ) {
define( 'ARRAY_N', 'ARRAY_N' );
}
define( 'PRV_VERSION', '0.2.1' );
define( 'PRV_VERSION', '0.2.3' );
define( 'PRV_PLUGIN_FILE', __DIR__ . '/../pr-vision.php' );
define( 'PRV_PLUGIN_DIR', realpath( __DIR__ . '/..' ) . '/' );
define( 'PRV_PLUGIN_URL', 'http://example.test/wp-content/plugins/pr-vision/' );

View File

@@ -0,0 +1,182 @@
<?php
/**
* Tests for PRV_Key_Store and PRV_Crypto_Helper:
* encrypt/decrypt round-trip, resolver precedence, write-only render, Remove.
*
* @package PrVision
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
// Provide deterministic WP-salt constants so derive_encryption_key() works.
if ( ! defined( 'AUTH_KEY' ) ) {
define( 'AUTH_KEY', 'test-auth-key-at-least-64-chars-long-for-realistic-entropy-aaaa' );
}
if ( ! defined( 'SECURE_AUTH_KEY' ) ) {
define( 'SECURE_AUTH_KEY', 'test-secure-auth-key-64-chars-long-realistic-entropy-bbbb-cccc' );
}
if ( ! function_exists( 'wp_salt' ) ) {
function wp_salt( string $scheme ): string {
return 'static-test-salt-' . $scheme;
}
}
/* ── Load classes under test ────────────────────────────────────────────── */
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-crypto-helper.php';
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-key-store.php';
/* ── Helpers ────────────────────────────────────────────────────────────── */
/**
* Capture rendered HTML from a callable.
*
* @param callable $fn Function that produces output.
*
* @return string Captured HTML.
*/
function prv_capture( callable $fn ): string {
ob_start();
$fn();
return (string) ob_get_clean();
}
echo "=== PRV_Key_Store + PRV_Crypto_Helper tests ===\n\n";
/* ─────────────────────────────────────────────────────────────────────────
* Group 1: PRV_Crypto_Helper — encrypt/decrypt round-trip
* ──────────────────────────────────────────────────────────────────────── */
echo "Group 1: PRV_Crypto_Helper encrypt/decrypt round-trip\n";
// Derive a test enc key the same way PRV_Key_Store does.
$enc_key = PRV_Key_Store::derive_encryption_key();
prv_assert( 32 === strlen( $enc_key ), 'derive_encryption_key() returns 32-byte key' );
$plaintext = 'sk-or-test-key-value-abc123';
$encrypted = PRV_Crypto_Helper::encrypt( $plaintext, $enc_key );
prv_assert( '' !== $encrypted, 'encrypt() returns non-empty string' );
prv_assert( $plaintext !== $encrypted, 'encrypt() does not return plaintext' );
prv_assert( false === strpos( $encrypted, $plaintext ), 'Plaintext not visible in ciphertext' );
$decrypted = PRV_Crypto_Helper::decrypt( $encrypted, $enc_key );
prv_assert_equals( $plaintext, $decrypted, 'decrypt(encrypt(key)) === original key' );
// Different calls produce different ciphertexts (random nonce).
$encrypted2 = PRV_Crypto_Helper::encrypt( $plaintext, $enc_key );
prv_assert( $encrypted !== $encrypted2, 'Two encryptions differ (random nonce)' );
prv_assert_equals( $plaintext, PRV_Crypto_Helper::decrypt( $encrypted2, $enc_key ), 'Second ciphertext also decrypts correctly' );
// Malformed inputs are rejected gracefully.
prv_assert_equals( '', PRV_Crypto_Helper::decrypt( '', $enc_key ), 'decrypt("") returns empty' );
prv_assert_equals( '', PRV_Crypto_Helper::decrypt( 'garbage', $enc_key ), 'decrypt(garbage) returns empty' );
prv_assert_equals( '', PRV_Crypto_Helper::decrypt( 'aabb:', $enc_key ), 'decrypt(empty cipher) returns empty' );
// Wrong key decrypts to empty (not wrong plaintext).
$wrong_key = hash( 'sha256', 'wrong-salt', true );
prv_assert_equals( '', PRV_Crypto_Helper::decrypt( $encrypted, $wrong_key ), 'decrypt with wrong key returns empty' );
/* ─────────────────────────────────────────────────────────────────────────
* Group 2: PRV_Key_Store — store_key / clear_key / get_source
* ──────────────────────────────────────────────────────────────────────── */
echo "\nGroup 2: store_key / clear_key / get_source\n";
prv_test_reset();
prv_assert_equals( PRV_Key_Store::SOURCE_NONE, PRV_Key_Store::get_source(), 'get_source() == NONE when no key stored' );
$stored = PRV_Key_Store::store_key( $plaintext );
prv_assert( $stored, 'store_key() returns true on success' );
$option_val = get_option( PRV_Key_Store::OPTION_ENC, '' );
prv_assert( '' !== $option_val, 'Option is set after store_key()' );
prv_assert( $plaintext !== $option_val, 'Option value is NOT the plaintext key (encrypted at rest)' );
prv_assert( false === strpos( (string) $option_val, $plaintext ), 'Plaintext not in stored option value' );
prv_assert( false === strpos( (string) $option_val, 'sk-or-' ), 'Key prefix not in stored option' );
prv_assert_equals( PRV_Key_Store::SOURCE_OPTION, PRV_Key_Store::get_source(), 'get_source() == OPTION after store_key()' );
PRV_Key_Store::clear_key();
prv_assert_equals( '', get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Option cleared after clear_key()' );
prv_assert_equals( PRV_Key_Store::SOURCE_NONE, PRV_Key_Store::get_source(), 'get_source() == NONE after clear_key()' );
// store_key('') is a no-op.
prv_assert( ! PRV_Key_Store::store_key( '' ), 'store_key("") returns false' );
prv_assert_equals( '', get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Option unchanged after store_key("")' );
/* ─────────────────────────────────────────────────────────────────────────
* Group 3: Resolver precedence — constant > option > none
* ──────────────────────────────────────────────────────────────────────── */
echo "\nGroup 3: Resolver precedence\n";
prv_test_reset();
// None scenario.
prv_assert_equals( '', PRV_Key_Store::get_key(), 'get_key() returns "" when no source' );
// Option scenario (no constant defined yet).
PRV_Key_Store::store_key( $plaintext );
prv_assert_equals( $plaintext, PRV_Key_Store::get_key(), 'get_key() returns plaintext from encrypted option' );
// Constant wins over option.
define( 'PRV_OPENROUTER_API_KEY', 'sk-or-constant-key-xyz' );
prv_assert_equals( 'sk-or-constant-key-xyz', PRV_Key_Store::get_key(), 'Constant takes precedence over option' );
prv_assert( PRV_Key_Store::constant_is_set(), 'constant_is_set() true when constant is defined' );
prv_assert_equals( PRV_Key_Store::SOURCE_CONSTANT, PRV_Key_Store::get_source(), 'get_source() == CONSTANT when constant defined' );
/* ─────────────────────────────────────────────────────────────────────────
* Group 4: Write-only — key value never in rendered HTML output
* ──────────────────────────────────────────────────────────────────────── */
echo "\nGroup 4: Write-only render — key not in output\n";
prv_test_reset();
PRV_Key_Store::store_key( $plaintext );
// Load rendering dependencies.
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-settings-page.php';
require_once PRV_PLUGIN_DIR . 'includes/core/class-prv-key-manager-renderer.php';
$html = prv_capture( function () {
( new PRV_Key_Manager_Renderer() )->render();
} );
prv_assert( '' !== $html, 'Key manager card renders non-empty HTML' );
// Plaintext key must be absent everywhere.
prv_assert( false === strpos( $html, $plaintext ), 'Plaintext key absent from rendered HTML' );
prv_assert( false === strpos( $html, 'sk-or-test' ), 'Key value prefix absent from rendered HTML' );
prv_assert( false === strpos( $html, 'sk-or-constant-key-xyz' ), 'Constant key value absent from rendered HTML' );
// Password input must render with empty value.
prv_assert( false !== strpos( $html, 'type="password"' ), 'Password input is present in output' );
prv_assert( false !== strpos( $html, 'value=""' ), 'Password input has empty value attribute' );
prv_assert( false === strpos( $html, 'value="sk-' ), 'No key value in any value= attribute' );
/* ─────────────────────────────────────────────────────────────────────────
* Group 5: Remove clears the encrypted option
* ──────────────────────────────────────────────────────────────────────── */
echo "\nGroup 5: Remove clears encrypted option\n";
prv_test_reset();
PRV_Key_Store::store_key( $plaintext );
prv_assert( '' !== get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Key present before remove' );
PRV_Key_Store::clear_key();
prv_assert_equals( '', get_option( PRV_Key_Store::OPTION_ENC, '' ), 'Option empty after clear_key()' );
// get_key() falls back to constant when present.
prv_assert_equals( 'sk-or-constant-key-xyz', PRV_Key_Store::get_key(), 'get_key() returns constant after option cleared' );
/* ─────────────────────────────────────────────────────────────────────────
* Summary
* ──────────────────────────────────────────────────────────────────────── */
exit( prv_test_summary() );

View File

@@ -36,6 +36,7 @@ update_option( 'prv_last_run_at', '2026-06-14 12:00:00' );
update_option( 'prv_last_run_counts', [] );
update_option( 'prv_last_run_truncated', 0 );
update_option( 'prv_last_run_truncated_at', '' );
update_option( 'prv_provider_key_enc', 'dummy-encrypted-ciphertext-value' );
update_option( 'prv_schema_version', PRV_SCHEMA_VERSION );
$GLOBALS['prv_test_state']['transients'][ PRV_Run_Lock::LOCK_KEY ] = time();
$GLOBALS['prv_test_state']['cron_events'][ PRV_CRON_HOOK ] = [ 'timestamp' => time() + 3600, 'schedule' => 'weekly' ];
@@ -72,6 +73,7 @@ prv_assert( false === get_option( 'prv_active_config_version' ), 'post-uninstall
prv_assert( false === get_option( 'prv_config_versions' ), 'post-uninstall: prv_config_versions deleted' );
prv_assert( false === get_option( 'prv_models_schema_version' ), 'post-uninstall: prv_models_schema_version deleted' );
prv_assert( false === get_option( 'prv_schema_version' ), 'post-uninstall: prv_schema_version deleted' );
prv_assert( false === get_option( 'prv_provider_key_enc' ), 'post-uninstall: prv_provider_key_enc (v0.2.3 encrypted key) deleted' );
prv_assert( false === get_option( 'prv_last_run_truncated' ), 'post-uninstall: prv_last_run_truncated deleted' );
prv_assert( false === get_transient( PRV_Run_Lock::LOCK_KEY ), 'post-uninstall: run lock transient cleared' );
prv_assert( false === PRV_Cron::is_scheduled(), 'post-uninstall: cron cleared' );