prefix . 'publisher_connector_journal'; } function db_ok($result) { demand($result !== false, 'storage_unavailable', 503); return $result; } function journal_table_ready() { global $wpdb; $engine = $wpdb->get_var($wpdb->prepare('SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', table())); demand($engine === 'InnoDB', 'storage_unavailable', 503); $indexes = $wpdb->get_results($wpdb->prepare('SHOW INDEX FROM %i WHERE Key_name="PRIMARY"', table())); demand(count($indexes) === 2 && $indexes[0]->Column_name === 'scope' && $indexes[1]->Column_name === 'slot', 'storage_unavailable', 503); } function journal_columns() { global $wpdb; $rows = $wpdb->get_results($wpdb->prepare('SHOW FULL COLUMNS FROM %i', table())); demand(!$wpdb->last_error, 'storage_unavailable', 503); $columns = []; foreach ($rows as $column) { if (isset($columns[$column->Field])) return []; $columns[$column->Field] = $column; } return $columns; } function journal_charset_ready() { $columns = journal_columns(); // Existing data rows remain byte-for-byte intact. BINARY in journal_cas already // supplies comparison semantics for data; only key columns need a binary collation. return strtolower((string) ($columns['scope']->Collation ?? '')) === 'utf8mb4_bin' && strtolower((string) ($columns['slot']->Collation ?? '')) === 'utf8mb4_bin' && str_starts_with(strtolower((string) ($columns['data']->Collation ?? '')), 'utf8mb4_'); } function journal_charset_upgradeable() { $columns = journal_columns(); foreach (['scope' => 'char(64)', 'slot' => 'varchar(100)', 'data' => 'longtext'] as $name => $type) { if (!isset($columns[$name]) || strtolower((string) $columns[$name]->Type) !== $type || strtoupper((string) $columns[$name]->Null) !== 'NO') return false; } foreach (['scope', 'slot'] as $name) { if (!in_array(strtolower((string) $columns[$name]->Collation), ['ascii_bin', 'utf8mb4_bin'], true)) return false; } return str_starts_with(strtolower((string) $columns['data']->Collation), 'utf8mb4_'); } /** This is intentionally separate from activation: table conversion is an explicit administrator action. */ function upgrade_journal_charset() { global $wpdb; wordpress_storage_ready(); journal_table_ready(); demand(journal_charset_upgradeable(), 'storage_unavailable', 503); if (journal_charset_ready()) return; db_ok($wpdb->query($wpdb->prepare('ALTER TABLE %i MODIFY scope char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, MODIFY slot varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL', table()))); demand(journal_charset_ready(), 'storage_unavailable', 503); } function admin_upgrade_journal_charset($nonce) { demand(current_user_can('manage_options') && is_string($nonce) && wp_verify_nonce($nonce, 'publisher_connector_admin'), 'admin_denied', 403); upgrade_journal_charset(); } function wordpress_storage_ready() { global $wpdb; foreach ([$wpdb->options => 'option_id', $wpdb->posts => 'ID', $wpdb->postmeta => 'meta_id', $wpdb->terms => 'term_id', $wpdb->term_taxonomy => 'term_taxonomy_id'] as $name => $id) { $column = $wpdb->get_row($wpdb->prepare('SELECT EXTRA, COLUMN_KEY FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s AND COLUMN_NAME=%s', $name, $id)); demand($column && str_contains(strtolower($column->EXTRA), 'auto_increment') && $column->COLUMN_KEY === 'PRI', 'wordpress_storage_unavailable', 503); } } function storage_ready() { global $wpdb; wordpress_storage_ready(); demand(!is_multisite() && PHP_VERSION_ID >= 80300 && function_exists('sodium_crypto_secretbox') && function_exists('mb_strlen') && function_exists('iconv'), 'unsupported_installation', 503); foreach ([$wpdb->options] as $name) { $engine = $wpdb->get_var($wpdb->prepare('SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $name)); demand($engine === 'InnoDB', 'storage_unavailable', 503); } journal_table_ready(); demand(journal_charset_ready(), 'storage_unavailable', 503); } function option_read($name) { global $wpdb; $raw = $wpdb->get_var($wpdb->prepare("SELECT option_value FROM %i WHERE option_name=%s", $wpdb->options, $name)); demand(!$wpdb->last_error, 'storage_unavailable', 503); return $raw === null ? null : maybe_unserialize($raw); } function option_cas($name, $before, $after) { global $wpdb; $changed = $wpdb->query($wpdb->prepare("UPDATE %i SET option_value=%s WHERE option_name=%s AND BINARY option_value=BINARY %s", $wpdb->options, maybe_serialize($after), $name, maybe_serialize($before))); demand($changed === 1, 'state_conflict', 409); wp_cache_delete($name, 'options'); wp_cache_delete('alloptions', 'options'); demand(option_read($name) == $after, 'storage_unavailable', 503); } function binding_read() { $b = option_read('publisher_connector_binding'); demand($b instanceof \stdClass, 'disabled', 503); return $b; } function journal_scope($b) { return hash('sha256', $b->tenant_id . '/' . $b->site_id); } function journal_read($scope, $slot) { global $wpdb; $raw = $wpdb->get_var($wpdb->prepare('SELECT data FROM %i WHERE scope=%s AND slot=%s', table(), $scope, $slot)); demand(!$wpdb->last_error, 'storage_unavailable', 503); return $raw === null ? null : parse_json($raw); } function journal_insert($scope, $slot, $data) { global $wpdb; // INSERT IGNORE only suppresses a duplicate; read-back must equal the first committed record. $n = db_ok($wpdb->query($wpdb->prepare('INSERT IGNORE INTO %i (scope,slot,data) VALUES (%s,%s,%s)', table(), $scope, $slot, canonical($data)))); demand(journal_read($scope, $slot) !== null, 'storage_unavailable', 503); return $n === 1; } function journal_cas($scope, $slot, $before, $after) { global $wpdb; $n = db_ok($wpdb->query($wpdb->prepare('UPDATE %i SET data=%s WHERE scope=%s AND slot=%s AND BINARY data=BINARY %s', table(), canonical($after), $scope, $slot, canonical($before)))); demand($n === 1, 'state_conflict', 409); demand(canonical(journal_read($scope, $slot)) === canonical($after), 'storage_unavailable', 503); } const MANAGED_KEY_MODE = 'managed-v1'; const MANAGED_KEY_OPTION = 'publisher_connector_key_mode'; const MANAGED_KEY_DIR = 'publisher-connector'; const MANAGED_KEY_FILE = 'managed-key.php'; const DATABASE_KEY_MODE = 'database-v1'; const DATABASE_KEY_OPTION = 'publisher_connector_database_key'; const RATE_KEY_OPTION = 'publisher_connector_rate_key'; const RECOVERY_KEY_OPTION = 'publisher_connector_recovery_key'; /** Native Sodium can wipe buffers; the WordPress polyfill cannot. */ function clear_secret(&$value) { if (extension_loaded('sodium') && function_exists('sodium_memzero')) sodium_memzero($value); $value = null; } function explicit_encryption_key() { if (defined('PUBLISHER_CONNECTOR_ENCRYPTION_KEY')) { $configured = constant('PUBLISHER_CONNECTOR_ENCRYPTION_KEY'); demand(is_string($configured), 'key_unavailable', 503); $key = base64_decode($configured, true); demand(is_string($key) && strlen($key) === 32, 'key_unavailable', 503); return $key; } return null; } function legacy_encryption_key() { $material = []; foreach (['AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT'] as $name) { demand(defined($name) && is_string(constant($name)) && strlen(constant($name)) >= 32 && constant($name) !== 'put your unique phrase here', 'key_unavailable', 503); $material[] = constant($name); } return hash_hkdf('sha256', implode("\0", $material), 32, 'publisher-connector-secret-v1'); } function legacy_cipher_key() { return explicit_encryption_key() ?? legacy_encryption_key(); } function managed_key_path() { demand(defined('WP_CONTENT_DIR') && is_string(WP_CONTENT_DIR) && WP_CONTENT_DIR !== '', 'key_unavailable', 503); return rtrim(WP_CONTENT_DIR, '/\\') . '/' . MANAGED_KEY_DIR . '/' . MANAGED_KEY_FILE; } function managed_key_from_source($source) { demand(is_string($source) && preg_match('/\\A<\\?php http_response_code\\(404\\); exit; \\/\\* Publisher Connector managed key v1: ([A-Za-z0-9+\\/]{43}=) \\*\\/\\n\\z/D', $source, $matches) === 1, 'key_unavailable', 503); $key = base64_decode($matches[1], true); demand(is_string($key) && strlen($key) === 32, 'key_unavailable', 503); return $key; } function managed_key_file_ready($path) { clearstatcache(true, $path); $stat = @lstat($path); demand(is_array($stat) && !is_link($path) && (($stat['mode'] & 0170000) === 0100000) && $stat['nlink'] === 1 && (($stat['mode'] & 0077) === 0), 'key_unavailable', 503); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Read-only legacy key needs locked descriptor identity; WP_Filesystem cannot preserve this check. No files are created or updated. $handle = @fopen($path, 'rb'); demand(is_resource($handle), 'key_unavailable', 503); try { demand(@flock($handle, LOCK_SH), 'key_unavailable', 503); // Bind the read to the regular private inode checked before open. The // pathname can be replaced between lstat and fopen, even under a lock. $opened = @fstat($handle); demand(is_array($opened) && $opened['dev'] === $stat['dev'] && $opened['ino'] === $stat['ino'] && (($opened['mode'] & 0170000) === 0100000) && $opened['nlink'] === 1 && (($opened['mode'] & 0077) === 0), 'key_unavailable', 503); $source = stream_get_contents($handle, 257); demand(is_string($source) && stream_get_contents($handle, 1) === '', 'key_unavailable', 503); return managed_key_from_source($source); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Read-only legacy key needs locked descriptor identity; WP_Filesystem cannot preserve this check. No files are created or updated. } finally { @flock($handle, LOCK_UN); fclose($handle); } } function managed_encryption_key() { demand(option_read(MANAGED_KEY_OPTION) === MANAGED_KEY_MODE, 'key_unavailable', 503); return managed_key_file_ready(managed_key_path()); } function managed_key_mode() { $mode = option_read(MANAGED_KEY_OPTION); demand($mode === null || in_array($mode, [MANAGED_KEY_MODE, DATABASE_KEY_MODE], true), 'key_unavailable', 503); return $mode; } /** Independent plugin secrets; never WordPress authentication keys or salts. */ function independent_secret_key($name, $initialize = false) { demand(in_array($name, [DATABASE_KEY_OPTION, RATE_KEY_OPTION, RECOVERY_KEY_OPTION], true), 'key_unavailable', 503); $stored = option_read($name); if ($stored === null && $initialize) { // add_option is an atomic unique-name insert; a concurrent winner is read back. add_option($name, base64_encode(random_bytes(32)), '', false); $stored = option_read($name); } demand(is_string($stored), 'key_unavailable', 503); $key = base64_decode($stored, true); demand(is_string($key) && strlen($key) === 32 && base64_encode($key) === $stored, 'key_unavailable', 503); return $key; } function database_encryption_key() { demand(option_read(MANAGED_KEY_OPTION) === DATABASE_KEY_MODE, 'key_unavailable', 503); return independent_secret_key(DATABASE_KEY_OPTION); } /** Fresh keys live in non-autoloaded options. Existing file-backed keys are read-only. */ function initialize_managed_key() { $mode = managed_key_mode(); if ($mode === MANAGED_KEY_MODE) { managed_encryption_key(); return; } if ($mode === DATABASE_KEY_MODE) { database_encryption_key(); return; } // Do not discard an orphaned key from an interrupted older installation. demand(!file_exists(managed_key_path()) && !is_link(managed_key_path()), 'key_unavailable', 503); independent_secret_key(DATABASE_KEY_OPTION, true); demand(add_option(MANAGED_KEY_OPTION, DATABASE_KEY_MODE, '', false) || option_read(MANAGED_KEY_OPTION) === DATABASE_KEY_MODE, 'key_unavailable', 503); database_encryption_key(); } function encryption_key() { $explicit = explicit_encryption_key(); if ($explicit !== null) return $explicit; if (managed_key_mode() === MANAGED_KEY_MODE) return managed_encryption_key(); initialize_managed_key(); return database_encryption_key(); } function secret_seal($secret, $b) { return secret_seal_bound($secret, canonical(pick($b, ['tenant_id', 'site_id', 'installation_id', 'connection_id', 'key_id', 'api_base']))); } function secret_seal_bound($secret, $bound) { demand(strlen($secret) === 32 && is_string($bound), 'invalid_secret'); $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $explicit = explicit_encryption_key(); $key = encryption_key(); $mode = $explicit !== null ? 'pcek1:' : (managed_key_mode() === MANAGED_KEY_MODE ? 'pcmk1:' : 'pcdb1:'); return $mode . base64_encode($nonce . sodium_crypto_secretbox($bound . "\0" . $secret, $nonce, $key)); } function secret_open($b) { demand(isset($b->encrypted_secret) && is_string($b->encrypted_secret), 'key_unavailable', 503); return secret_open_bound($b->encrypted_secret, canonical(pick($b, ['tenant_id', 'site_id', 'installation_id', 'connection_id', 'key_id', 'api_base']))); } function secret_open_bound($ciphertext, $bound) { demand(is_string($ciphertext) && is_string($bound), 'key_unavailable', 503); $key = null; if (str_starts_with($ciphertext, 'pcmk1:')) { $ciphertext = substr($ciphertext, 6); $key = managed_encryption_key(); } elseif (str_starts_with($ciphertext, 'pcdb1:')) { $ciphertext = substr($ciphertext, 6); $key = database_encryption_key(); } elseif (str_starts_with($ciphertext, 'pcek1:')) { $ciphertext = substr($ciphertext, 6); $key = explicit_encryption_key(); demand($key !== null, 'key_unavailable', 503); } else $key = legacy_cipher_key(); $bytes = base64_decode($ciphertext, true); demand($bytes !== false && strlen($bytes) > 40, 'key_unavailable', 503); $plain = sodium_crypto_secretbox_open(substr($bytes, 24), substr($bytes, 0, 24), $key); demand(is_string($plain) && strlen($plain) === strlen($bound) + 33 && hash_equals($bound . "\0", substr($plain, 0, -32)), 'key_unavailable', 503); return substr($plain, -32); } function activate() { global $wpdb; demand(!is_multisite() && PHP_VERSION_ID >= 80300 && function_exists('sodium_crypto_secretbox'), 'unsupported_installation', 503); wordpress_storage_ready(); db_ok($wpdb->query($wpdb->prepare('CREATE TABLE IF NOT EXISTS %i (scope char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, slot varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, data longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, PRIMARY KEY (scope,slot)) ENGINE=InnoDB', table()))); storage_ready(); $existingBinding = option_read('publisher_connector_binding'); demand($existingBinding === null || $existingBinding instanceof \stdClass, 'storage_unavailable', 503); if ($existingBinding !== null && isset($existingBinding->encrypted_secret) && is_string($existingBinding->encrypted_secret)) { if (str_starts_with($existingBinding->encrypted_secret, 'pcmk1:')) { demand(managed_key_mode() === MANAGED_KEY_MODE, 'key_unavailable', 503); secret_open($existingBinding); } elseif (str_starts_with($existingBinding->encrypted_secret, 'pcdb1:')) { demand(managed_key_mode() === DATABASE_KEY_MODE, 'key_unavailable', 503); secret_open($existingBinding); } elseif (str_starts_with($existingBinding->encrypted_secret, 'pcek1:')) secret_open($existingBinding); } if (explicit_encryption_key() === null && managed_key_mode() === null && ($existingBinding === null || (!isset($existingBinding->encrypted_secret) && in_array($existingBinding->state, ['disconnected', 'revoked'], true)))) initialize_managed_key(); encryption_key(); if (option_read('publisher_connector_installation') === null) demand(add_option('publisher_connector_installation', wp_generate_uuid4(), '', false), 'storage_unavailable', 503); if ($existingBinding === null) demand(add_option('publisher_connector_binding', obj(['state' => 'disconnected']), '', false), 'storage_unavailable', 503); demand(is_string(option_read('publisher_connector_installation')), 'installation_storage_unavailable', 503); $binding = binding_read(); if (in_array($binding->state, ['pending_consent', 'consented', 'active'], true)) revoke_local(); } function activate_plugin() { try { activate(); } catch (Failure $error) { $message = match ($error->getMessage()) { 'unsupported_installation' => 'Publisher Connector requires a single-site WordPress installation with PHP 8.3 or later, Sodium, mbstring and iconv.', 'storage_unavailable', 'wordpress_storage_unavailable', 'installation_storage_unavailable' => 'Publisher Connector could not verify its required WordPress database storage.', 'key_unavailable' => 'Publisher Connector could not verify its local secure key storage. Restore the original connector database options and any legacy key file before trying again.', default => 'Publisher Connector could not verify its activation requirements.' }; wp_die(esc_html($message), 'Publisher Connector activation failed', ['response' => 503]); } catch (\Throwable $error) { wp_die('Publisher Connector could not verify its activation requirements.', 'Publisher Connector activation failed', ['response' => 503]); } } function revoke_local() { return with_setup_lock(fn() => revoke_local_locked()); } function revoke_local_locked() { storage_ready(); $before = binding_read(); if (in_array($before->state, ['revoked', 'disconnected'], true)) return; $after = clone $before; $after->state = 'revoked'; unset($after->encrypted_secret, $after->challenge); option_cas('publisher_connector_binding', $before, $after); } function deactivate() { revoke_local(); } function uninstall() { revoke_local(); /* Preserve identity, objects and journal tombstones. */ } /** Fixed-window quotas use a durable row lock, never a transient or object cache. */ function consume_rate_limit($scope, $kind, $limit, $window, $now = null) { global $wpdb; demand(preg_match('/^[a-z_]+$/D', $kind) && is_int($limit) && $limit > 0 && is_int($window) && $window > 0, 'quota_configuration', 503); $now = $now ?? time(); $slot = 'rate:' . $kind . ':' . intdiv($now, $window); journal_insert($scope, $slot, obj(['count' => 0])); db_ok($wpdb->query('START TRANSACTION')); try { $raw = $wpdb->get_var($wpdb->prepare('SELECT data FROM %i WHERE scope=%s AND slot=%s FOR UPDATE', table(), $scope, $slot)); demand(is_string($raw) && !$wpdb->last_error, 'storage_unavailable', 503); $before = parse_json($raw); demand(isset($before->count) && is_int($before->count), 'storage_unavailable', 503); demand($before->count < $limit, 'rate_limited', 429); $after = obj(['count' => $before->count + 1]); journal_cas($scope, $slot, $before, $after); db_ok($wpdb->query('COMMIT')); } catch (\Throwable $e) { $wpdb->query('ROLLBACK'); throw $e; } }