assertProvider($provider); $secret = trim($secret); if ($secret === '' || strlen($secret) > 4096) { throw new \InvalidArgumentException('Credential must be between 1 and 4096 characters.'); } if (\Aiwend\Provider\Providers::looksLikeAnotherProvider($provider, $secret)) { throw new \InvalidArgumentException('key_wrong_provider'); // another provider's key would be sent to this provider's host } $now = $this->clock->now(); return $this->db->transaction(function () use ($siteId, $provider, $label, $secret, $now): int { // Insert first so the row ID can be bound into the ciphertext's associated data. $id = $this->db->insert('connections', [ 'site_id' => $siteId, 'provider' => $provider, 'label' => mb_substr(trim($label), 0, 191), 'ciphertext' => '', 'key_version' => 0, 'fingerprint' => '', 'status' => 'unverified', 'created_at' => $now, 'updated_at' => $now, ]); $this->storeSecret($siteId, $id, $secret); return $id; }); } public function rename(int $siteId, int $id, string $label): bool { $label = mb_substr(trim($label), 0, 191); if ($label === '') { throw new \InvalidArgumentException('Connection name is required.'); } return $this->db->execute('UPDATE {connections} SET label = ?, updated_at = ? WHERE id = ? AND site_id = ?', [$label, $this->clock->now(), $id, $siteId]) === 1; } public function replaceSecret(int $siteId, int $id, string $secret): void { $row = $this->requireRow($siteId, $id); if (\Aiwend\Provider\Providers::looksLikeAnotherProvider((string) $row['provider'], trim($secret))) { throw new \InvalidArgumentException('key_wrong_provider'); } $this->storeSecret($siteId, $id, trim($secret)); $this->db->execute('UPDATE {connections} SET status = ? WHERE id = ? AND site_id = ?', ['unverified', $id, $siteId]); } /** @return array|null Public fields only. */ public function find(int $siteId, int $id): ?array { $row = $this->db->selectOne( 'SELECT id, site_id, provider, label, fingerprint, status, key_version, created_at, updated_at FROM {connections} WHERE id = ? AND site_id = ?', [$id, $siteId] ); return $row === null ? null : $this->publicShape($row); } /** @return list> */ public function list(int $siteId): array { $rows = $this->db->select( 'SELECT id, site_id, provider, label, fingerprint, status, key_version, created_at, updated_at FROM {connections} WHERE site_id = ? ORDER BY id', [$siteId] ); return array_map(fn(array $r): array => $this->publicShape($r), $rows); } public function setStatus(int $siteId, int $id, string $status): void { if (!in_array($status, ['unverified', 'verified', 'failed', 'disabled'], true)) { throw new \InvalidArgumentException('Invalid connection status.'); } $this->db->execute('UPDATE {connections} SET status = ?, updated_at = ? WHERE id = ? AND site_id = ?', [$status, $this->clock->now(), $id, $siteId]); } /** * Locks the connection's row until the surrounding transaction ends (SELECT ... FOR UPDATE on MySQL/MariaDB; SQLite serializes write * transactions). Binding an assistant (BotRepository::setProvider) takes the same lock first, so a bind and a delete of one * connection are serialized: always connection row first, then assistant rows. Use inside a transaction. */ public function lock(int $siteId, int $id): bool { $lock = $this->db->driver() === 'mysql' ? ' FOR UPDATE' : ''; return $this->db->selectOne('SELECT id FROM {connections} WHERE id = ? AND site_id = ?' . $lock, [$id, $siteId]) !== null; } public function delete(int $siteId, int $id): bool { return (bool) $this->db->transaction(function () use ($siteId, $id): bool { if (!$this->lock($siteId, $id)) { return false; } // A locking read, so it sees every bind committed before the lock was granted (a plain read could use an older snapshot). $lock = $this->db->driver() === 'mysql' ? ' FOR UPDATE' : ''; if ($this->db->selectOne('SELECT id FROM {bots} WHERE site_id = ? AND connection_id = ?' . $lock, [$siteId, $id]) !== null) { return false; // Detach dependent assistants explicitly before removal. } return $this->db->execute('DELETE FROM {connections} WHERE id = ? AND site_id = ?', [$id, $siteId]) > 0; }); } /** Server-side only. The returned value is registered with the redactor. */ public function secretForDispatch(int $siteId, int $id): string { $row = $this->requireRow($siteId, $id); if ($row['status'] === 'disabled') { throw new \RuntimeException('Connection is disabled.'); } $secret = $this->vault->decrypt((string) $row['ciphertext'], $this->context($siteId, $id)); $this->redactor->remember($secret); return $secret; } /** * Re-encrypts every credential not on the current key version. * Run after adding a new key; remove the old key only when pendingRotation() returns 0. * * Safe by construction (Phase 89): each row is decrypted with the key its envelope names, re-encrypted under the current key, * and the new envelope is decrypted again and compared before anything is written; the write succeeds only if the row still * holds the exact ciphertext that was read (a credential replaced meanwhile is never overwritten with an older one). A row * that fails any step is left exactly as it was and counted as failed; it can simply be retried. The displayed fingerprint, * status and check results are not touched. Nothing here ever returns or logs a credential. * * @return array{rotated:int, failed:int} */ public function rotate(int $siteId): array { $rotated = 0; $failed = 0; foreach ($this->db->select('SELECT id, ciphertext FROM {connections} WHERE site_id = ?', [$siteId]) as $row) { $id = (int) $row['id']; $old = (string) $row['ciphertext']; try { if (!$this->vault->needsRotation($old)) { continue; } $context = $this->context($siteId, $id); $plain = $this->vault->decrypt($old, $context); $new = $this->vault->encrypt($plain, $context); if ($this->vault->needsRotation($new) || !hash_equals($plain, $this->vault->decrypt($new, $context))) { throw new \RuntimeException('Re-encrypted credential did not verify.'); } $changed = $this->db->execute( 'UPDATE {connections} SET ciphertext = ?, key_version = ?, updated_at = ? WHERE id = ? AND site_id = ? AND ciphertext = ?', [$new, $this->vault->keyVersionOf($new), $this->clock->now(), $id, $siteId, $old] ); $changed === 1 ? $rotated++ : $failed++; } catch (\RuntimeException | \SodiumException) { $failed++; } } return ['rotated' => $rotated, 'failed' => $failed]; } /** * The same count for EVERY site of the installation. The encryption key is shared by a whole multisite network, so an older key * may be retired only when this reaches 0, not when one site's own count does. */ public function pendingRotationEverywhere(): int { $pending = 0; foreach ($this->db->select('SELECT ciphertext FROM {connections}') as $row) { try { $pending += $this->vault->needsRotation((string) $row['ciphertext']) ? 1 : 0; } catch (\RuntimeException) { $pending++; } } return $pending; } /** Credentials still on an older key version, or whose envelope cannot be read at all. */ public function pendingRotation(int $siteId): int { $pending = 0; foreach ($this->db->select('SELECT ciphertext FROM {connections} WHERE site_id = ?', [$siteId]) as $row) { try { $pending += $this->vault->needsRotation((string) $row['ciphertext']) ? 1 : 0; } catch (\RuntimeException) { $pending++; } } return $pending; } private function storeSecret(int $siteId, int $id, string $secret): void { if ($secret === '' || strlen($secret) > 4096) { throw new \InvalidArgumentException('Credential must be between 1 and 4096 characters.'); } $envelope = $this->vault->encrypt($secret, $this->context($siteId, $id)); $this->db->execute( 'UPDATE {connections} SET ciphertext = ?, key_version = ?, fingerprint = ?, updated_at = ? WHERE id = ? AND site_id = ?', [$envelope, $this->vault->keyVersionOf($envelope), $this->vault->fingerprint($secret), $this->clock->now(), $id, $siteId] ); $this->redactor->remember($secret); } /** @return array */ private function requireRow(int $siteId, int $id): array { $row = $this->db->selectOne('SELECT * FROM {connections} WHERE id = ? AND site_id = ?', [$id, $siteId]); if ($row === null) { throw new \OutOfBoundsException('Connection not found.'); } return $row; } private function context(int $siteId, int $id): string { return "connection:$siteId:$id"; } private function assertProvider(string $provider): void { // The test-only "mock" type exists solely where the development constant is set outside production. $devMock = $provider === 'mock' && defined('AIWEND_DEV_MOCK_PROVIDER') && constant('AIWEND_DEV_MOCK_PROVIDER') === true && function_exists('wp_get_environment_type') && wp_get_environment_type() !== 'production'; if (!preg_match('/^[a-z][a-z0-9_-]{1,63}$/', $provider) || !($devMock || \Aiwend\Provider\Providers::isAvailable($provider))) { throw new \InvalidArgumentException('Invalid or unsupported provider type.'); } } /** @param array $row @return array */ private function publicShape(array $row): array { return [ 'id' => (int) $row['id'], 'provider' => (string) $row['provider'], 'label' => (string) $row['label'], 'fingerprint' => (string) $row['fingerprint'], 'status' => (string) $row['status'], 'key_version' => (int) $row['key_version'], 'has_secret' => true, 'updated_at' => (int) $row['updated_at'], ]; } }