$access, 'refresh' => $refresh, 'expires_at' => (int) ( $tokens['expires_at'] ?? 0 ), 'pds_user_id' => isset( $tokens['pds_user_id'] ) && $tokens['pds_user_id'] !== null ? (string) $tokens['pds_user_id'] : '', 'wallet_id' => isset( $tokens['wallet_id'] ) && $tokens['wallet_id'] !== null ? (string) $tokens['wallet_id'] : '', // Per-install Validator webhook credential. It shares the token // store's AES-256-GCM envelope instead of a plaintext wp_option. 'event_secret' => $event_secret, ]; $json = json_encode( $bundle, JSON_UNESCAPED_SLASHES ); if ( $json === false ) { throw new \RuntimeException( 'Token_Store: failed to encode bundle' ); } update_option( self::OPT_BLOB_ENC, self::encrypt( $json ), false ); self::$cache = $bundle; } public static function get_access(): ?string { $b = self::bundle(); return $b === null || $b['access'] === '' ? null : $b['access']; } public static function get_refresh(): ?string { $b = self::bundle(); return $b === null || $b['refresh'] === '' ? null : $b['refresh']; } public static function get_expires_at(): ?int { $b = self::bundle(); return $b === null ? null : (int) $b['expires_at']; } public static function get_pds_user_id(): ?string { $b = self::bundle(); return $b === null || $b['pds_user_id'] === '' ? null : $b['pds_user_id']; } public static function get_wallet_id(): ?string { $b = self::bundle(); return $b === null || $b['wallet_id'] === '' ? null : $b['wallet_id']; } public static function get_event_secret(): ?string { $b = self::bundle(); return $b === null || $b['event_secret'] === '' ? null : $b['event_secret']; } public static function is_connected(): bool { $b = self::bundle(); return $b !== null && $b['access'] !== ''; } public static function clear(): void { delete_option( self::OPT_BLOB_ENC ); self::$cache = null; } // ------------------------------------------------------------------------- // Bundle loader (decrypt-once-per-process cache). // ------------------------------------------------------------------------- private static function bundle(): ?array { if ( self::$cache !== null ) { return self::$cache; } $enc = (string) get_option( self::OPT_BLOB_ENC, '' ); if ( $enc === '' ) { return null; } $json = self::decrypt( $enc ); if ( $json === null ) { // Tampered ciphertext, KEK rotation, or corruption — surface a signal // an operator can grep for. Never log plaintext or ciphertext. // The structured emit below is the signal; the bare error_log() that // used to duplicate it here was removed in ORL-111 / D-14. Prweb_Log::error( 'token_store decrypt_failed', [ 'error_type' => 'integrity_check', 'category' => 'token_store', ] ); return null; } $decoded = json_decode( $json, true ); if ( ! is_array( $decoded ) ) { Prweb_Log::error( 'token_store decrypt_failed', [ 'error_type' => 'decoded_payload_not_array', 'category' => 'token_store', ] ); return null; } // Normalise so accessors don't have to defensively coerce. self::$cache = [ 'access' => (string) ( $decoded['access'] ?? '' ), 'refresh' => (string) ( $decoded['refresh'] ?? '' ), 'expires_at' => (int) ( $decoded['expires_at'] ?? 0 ), 'pds_user_id' => (string) ( $decoded['pds_user_id'] ?? '' ), 'wallet_id' => (string) ( $decoded['wallet_id'] ?? '' ), 'event_secret' => (string) ( $decoded['event_secret'] ?? '' ), ]; return self::$cache; } // ------------------------------------------------------------------------- // Encryption helpers — AES-256-GCM, key from HKDF-SHA256 over WP salts. // Storage: base64( iv(12) || tag(16) || ciphertext ) // ------------------------------------------------------------------------- private static function key(): string { $salt = function_exists( 'wp_salt' ) ? (string) wp_salt( 'auth' ) : ''; $secret = defined( 'AUTH_KEY' ) ? (string) AUTH_KEY : ( defined( 'LOGGED_IN_KEY' ) ? (string) LOGGED_IN_KEY : '' ); $ikm = $salt . $secret; if ( $ikm === '' ) { throw new \RuntimeException( 'Token_Store: no WP key material available for HKDF' ); } return self::hkdf_sha256( $ikm, 32, self::HKDF_INFO ); } private static function hkdf_sha256( string $ikm, int $length, string $info, string $salt = '' ): string { if ( function_exists( 'hash_hkdf' ) ) { return hash_hkdf( 'sha256', $ikm, $length, $info, $salt ); } // Fallback (PHP without hash_hkdf — extract+expand by hand). $prk = hash_hmac( 'sha256', $ikm, $salt === '' ? str_repeat( "\0", 32 ) : $salt, true ); $out = ''; $t = ''; $i = 1; while ( strlen( $out ) < $length ) { $t = hash_hmac( 'sha256', $t . $info . chr( $i ), $prk, true ); $out .= $t; $i++; } return substr( $out, 0, $length ); } private static function encrypt( string $plaintext ): string { $iv = random_bytes( 12 ); $tag = ''; $ct = openssl_encrypt( $plaintext, 'aes-256-gcm', self::key(), OPENSSL_RAW_DATA, $iv, $tag ); if ( $ct === false ) { throw new \RuntimeException( 'Token_Store: openssl_encrypt failed' ); } return base64_encode( $iv . $tag . $ct ); } private static function decrypt( string $blob ): ?string { $raw = base64_decode( $blob, true ); if ( $raw === false || strlen( $raw ) < 12 + 16 + 1 ) { return null; } $iv = substr( $raw, 0, 12 ); $tag = substr( $raw, 12, 16 ); $ct = substr( $raw, 28 ); $pt = openssl_decrypt( $ct, 'aes-256-gcm', self::key(), OPENSSL_RAW_DATA, $iv, $tag ); return $pt === false ? null : $pt; } }