'hourly', 'six_hours' => self::SIX_HOURS, 'daily' => 'daily', ); public const COLLECTORS = array( 'woocommerce', 'changes', 'errors' ); /** Maximum age (and clock skew) of a signed configuration, in seconds. */ public const MAX_AGE = 900; /** * Add the six-hour WP-Cron recurrence. */ public static function register(): void { add_filter( 'cron_schedules', // phpcs:ignore WordPress.WP.CronInterval.ChangeDetected -- six hours, not more frequent than hourly. static function ( $schedules ) { $schedules = is_array( $schedules ) ? $schedules : array(); $schedules[ self::SIX_HOURS ] = array( 'interval' => 6 * HOUR_IN_SECONDS, 'display' => __( 'Every six hours (Keepwatch)', 'keepwatch' ), ); return $schedules; } ); } /** * Defaults (also used until the first signed configuration arrives). * * @return array{report_interval: string, collect: array} */ public static function defaults(): array { return array( 'report_interval' => 'hourly', 'collect' => array( 'woocommerce' => true, 'changes' => true, 'errors' => true, ), ); } /** * Configuration in force. * * @return array{report_interval: string, collect: array} */ public static function get(): array { $stored = get_option( self::OPTION ); if ( is_array( $stored ) && isset( $stored['config'] ) && is_array( $stored['config'] ) ) { $config = self::sanitize( $stored['config'] ); if ( null !== $config ) { return $config; } } return self::defaults(); } /** * Version of the stored configuration ('' when none). */ public static function version(): string { $stored = get_option( self::OPTION ); return is_array( $stored ) && isset( $stored['version'] ) ? (string) $stored['version'] : ''; } /** * Whether an optional data type is enabled. * * @param string $collector One of COLLECTORS. */ public static function collects( string $collector ): bool { $config = self::get(); return ! empty( $config['collect'][ $collector ] ); } /** * WP-Cron recurrence for reports. */ public static function recurrence(): string { $config = self::get(); return self::SCHEDULES[ $config['report_interval'] ] ?? 'hourly'; } /** * Strict whitelist: unknown keys are dropped, wrong types or values reject the whole configuration. * * @param array $config Raw configuration. * @return array{report_interval: string, collect: array}|null */ public static function sanitize( array $config ): ?array { $interval = $config['report_interval'] ?? null; $collect = $config['collect'] ?? null; if ( ! is_string( $interval ) || ! isset( self::SCHEDULES[ $interval ] ) || ! is_array( $collect ) ) { return null; } $clean = array(); foreach ( self::COLLECTORS as $name ) { if ( ! isset( $collect[ $name ] ) || ! is_bool( $collect[ $name ] ) ) { return null; } $clean[ $name ] = $collect[ $name ]; } return array( 'report_interval' => $interval, 'collect' => $clean, ); } /** * Verify a signed envelope from GET /keepwatch/config. * * @param array $body Decoded response body: {signed: string, signature: string}. * @param string $server_key Base64 Ed25519 public key of the WPXLab cloud. * @param string $site_id This site's id at WPXLab. * @param int $now Current time. * @return array{version: string, config: array{report_interval: string, collect: array}}|null */ public static function verify( array $body, string $server_key, string $site_id, int $now ): ?array { $signed = $body['signed'] ?? null; $signature = $body['signature'] ?? null; if ( ! is_string( $signed ) || ! is_string( $signature ) || '' === $server_key || '' === $site_id || ! function_exists( 'sodium_crypto_sign_verify_detached' ) ) { return null; } $raw_signature = base64_decode( $signature, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Ed25519 signature. $raw_key = base64_decode( $server_key, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Ed25519 public key. if ( false === $raw_signature || false === $raw_key || SODIUM_CRYPTO_SIGN_BYTES !== strlen( $raw_signature ) || SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES !== strlen( $raw_key ) ) { return null; } try { if ( ! sodium_crypto_sign_verify_detached( $raw_signature, $signed, $raw_key ) ) { return null; } } catch ( \Throwable $e ) { return null; } $data = json_decode( $signed, true ); if ( ! is_array( $data ) || 'keepwatch-config.v1' !== ( $data['schema'] ?? null ) || ( $data['site_id'] ?? null ) !== $site_id || ! is_int( $data['issued_at'] ?? null ) || abs( $now - $data['issued_at'] ) > self::MAX_AGE || ! is_string( $data['version'] ?? null ) || ! is_array( $data['config'] ?? null ) ) { return null; } $config = self::sanitize( $data['config'] ); if ( null === $config ) { return null; } return array( 'version' => substr( $data['version'], 0, 32 ), 'config' => $config, ); } /** * Fetch, verify and apply the configuration (WP-Cron only, after a report). Also learns the server key once for * sites connected before signed configuration existed. * * @param Client $client WPXLab client. * @return bool Whether a new configuration was applied. */ public static function refresh( Client $client ): bool { $state = Connection::state(); if ( ! Connection::is_connected() || '' === $state['site_id'] ) { return false; } $key = Connection::server_key(); if ( '' === $key ) { $site = $client->api()->request( 'GET', '/keepwatch/site' ); if ( 200 !== $site['status'] || ! isset( $site['body']['server_key'] ) || ! is_string( $site['body']['server_key'] ) ) { return false; } $key = $site['body']['server_key']; Connection::remember_server_key( $key ); } $response = $client->api()->request( 'GET', '/keepwatch/config' ); if ( 200 !== $response['status'] ) { return false; } $verified = self::verify( $response['body'], $key, $state['site_id'], time() ); if ( null === $verified ) { return false; } $previous = self::recurrence(); update_option( self::OPTION, $verified + array( 'applied_at' => time() ), false ); if ( self::recurrence() !== $previous ) { Reporter::reschedule(); } return true; } /** * Forget the configuration (disconnect, uninstall). */ public static function forget(): void { delete_option( self::OPTION ); } }