_ chỉ còn dùng để plugin ký lời chào (hello) gửi về DatPilot. */ class DATPCO_Auth { const OPTION = 'datpco_connection'; const MAX_SKEW = 300; /** Khoá công khai Ed25519 của DatPilot (base64). Nhiều khoá để xoay vòng. Ghi đè được bằng DATPCO_PORTAL_PUBKEYS trong wp-config. */ const PORTAL_KEYS = array( '1QqQn0kE5QP87EaCrBwVXeBPhyYVNgIiabIiJbxZv6E=' ); public static function portal_keys() { $k = ( defined( 'DATPCO_PORTAL_PUBKEYS' ) && DATPCO_PORTAL_PUBKEYS ) ? explode( ',', DATPCO_PORTAL_PUBKEYS ) : self::PORTAL_KEYS; return array_filter( array_map( function ( $x ) { $b = base64_decode( trim( $x ), true ); return ( $b && 32 === strlen( $b ) ) ? $b : null; }, $k ) ); } /** Chuỗi được ký: phải khớp từng byte với app/connector.py (signed_message). */ public static function message( $site, $route, $time, $nonce, $body ) { return "dpc2\n" . $site . "\n" . $route . "\n" . $time . "\n" . $nonce . "\n" . hash( 'sha256', $body ); } /** Ghi nonce một lần: true nếu chưa từng dùng. Dọn nonce cũ hơn 2×MAX_SKEW. */ private static function use_nonce( $nonce ) { global $wpdb; $now = time(); if ( 0 === wp_rand( 0, 19 ) ) { $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND option_value < %d", $wpdb->esc_like( 'datpco_nonce_' ) . '%', $now - 2 * self::MAX_SKEW ) ); // phpcs:ignore WordPress.DB } $n = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", 'datpco_nonce_' . md5( $nonce ), (string) $now ) ); // phpcs:ignore WordPress.DB return 1 === (int) $n; } /** @return array{site_id:string,secret:string,portal:string}|null */ public static function connection() { $c = get_option( self::OPTION ); if ( ! is_array( $c ) || empty( $c['site_id'] ) || empty( $c['secret'] ) ) { return null; } $c['portal'] = ! empty( $c['portal'] ) ? $c['portal'] : 'https://app.datpilot.com'; return $c; } /** * Mã kết nối dạng datpco__. Trả mảng hoặc WP_Error. */ public static function parse_key( $key ) { $key = trim( (string) $key ); if ( ! preg_match( '/^dpc_([a-z0-9]{6,32})_([A-Za-z0-9_-]{32,64})$/', $key, $m ) ) { return new WP_Error( 'datpco_bad_key', 'Mã kết nối không đúng định dạng.' ); } return array( 'site_id' => $m[1], 'secret' => $m[2] ); } public static function sign( $secret, $time, $nonce, $body ) { return hash_hmac( 'sha256', $time . "\n" . $nonce . "\n" . $body, $secret ); } /** * permission_callback cho REST: true hoặc WP_Error. */ public static function verify_request( WP_REST_Request $req ) { $c = self::connection(); if ( ! $c ) { return new WP_Error( 'datpco_not_connected', 'Chưa kết nối DatPilot.', array( 'status' => 403 ) ); } $time = (string) $req->get_header( 'x_dp_time' ); $nonce = (string) $req->get_header( 'x_dp_nonce' ); $sig = base64_decode( (string) $req->get_header( 'x_dp_ed' ), true ); $site = (string) $req->get_header( 'x_dp_site' ); if ( '' === $time || '' === $nonce || ! $sig || 64 !== strlen( $sig ) || ! hash_equals( $c['site_id'], $site ) ) { return new WP_Error( 'datpco_unsigned', 'Thiếu chữ ký DatPilot (Ed25519).', array( 'status' => 401 ) ); } if ( ! ctype_digit( $time ) || abs( time() - (int) $time ) > self::MAX_SKEW ) { return new WP_Error( 'datpco_expired', 'Lệnh hết hạn (lệch giờ).', array( 'status' => 401 ) ); } if ( ! preg_match( '/^[A-Za-z0-9_-]{16,64}$/', $nonce ) ) { return new WP_Error( 'datpco_bad_nonce', 'Nonce không hợp lệ.', array( 'status' => 401 ) ); } $msg = self::message( $c['site_id'], $req->get_route(), $time, $nonce, $req->get_body() ); $ok = false; foreach ( self::portal_keys() as $pk ) { try { $ok = $ok || sodium_crypto_sign_verify_detached( $sig, $msg, $pk ); } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement -- khoá hỏng: coi như không khớp } } if ( ! $ok ) { return new WP_Error( 'datpco_bad_sig', 'Chữ ký sai.', array( 'status' => 401 ) ); } if ( ! self::use_nonce( $nonce ) ) { return new WP_Error( 'datpco_replay', 'Lệnh đã dùng.', array( 'status' => 401 ) ); } return true; } /** * Gửi lời chào có chữ ký lên DatPilot để đăng ký địa chỉ REST của web. */ public static function hello() { $c = self::connection(); if ( ! $c ) { return new WP_Error( 'datpco_not_connected', 'Chưa có mã kết nối.' ); } $body = wp_json_encode( array( 'site_id' => $c['site_id'], 'home' => home_url( '/' ), 'rest' => rest_url( 'datpilot/v1/' ), 'wp_version' => get_bloginfo( 'version' ), 'php_version' => PHP_VERSION, 'plugin_version' => DATPCO_VERSION, ) ); $time = (string) time(); $nonce = wp_generate_password( 24, false ); $res = wp_remote_post( trailingslashit( $c['portal'] ) . 'api/v1/connector/hello', array( 'timeout' => 20, 'headers' => array( 'Content-Type' => 'application/json', 'X-DP-Site' => $c['site_id'], 'X-DP-Time' => $time, 'X-DP-Nonce' => $nonce, 'X-DP-Sig' => self::sign( $c['secret'], $time, $nonce, $body ), ), 'body' => $body, ) ); if ( is_wp_error( $res ) ) { return $res; } $code = wp_remote_retrieve_response_code( $res ); $data = json_decode( wp_remote_retrieve_body( $res ), true ); if ( 200 !== $code || empty( $data['ok'] ) ) { $msg = is_array( $data ) && ! empty( $data['error'] ) ? $data['error'] : 'HTTP ' . $code; return new WP_Error( 'datpco_hello_failed', 'DatPilot từ chối kết nối: ' . $msg ); } return true; } }