'has a signature that does not verify', 'version' => 'is not contract version ' . self::VERSION, 'nonce' => 'does not answer this request (nonce)', ); /** * A fresh, unguessable nonce for one request. */ public static function nonce(): string { return bin2hex( random_bytes( 16 ) ); } /** * Whether this store takes signed answers only. * * @phpstan-impure */ public static function signed_only(): bool { return '1' === get_option( Options::SYNC_SIGNED, '' ); } /** * OK (signed and every check passed), LEGACY (unsigned, and this store does not yet require signed answers), * REFUSED (nothing in it can be trusted: no write and no report) or MISMATCH (signed by CortexCommerce, but for * another store or product: no write, and the failure is reported). Every outcome but OK and LEGACY is logged. */ public static function check( SignedResponse $response, string $product_id, string $nonce ): string { if ( '' === $response->signature ) { return self::unsigned( $product_id ); } $json = is_array( $response->json ) ? $response->json : array(); $refusal = self::refusal( $response, $json, $nonce ); if ( '' !== $refusal ) { Log::error( sprintf( 'Approved-product sync: the %s for product %s %s; nothing written.', 'signature' === $refusal ? 'answer' : 'signed answer', $product_id, self::REFUSALS[ $refusal ] ) ); return self::REFUSED; } self::log_clock_drift( $json ); // Verified: from now on this store takes signed answers only. if ( ! self::signed_only() ) { update_option( Options::SYNC_SIGNED, '1', false ); } $mismatch = self::mismatch( $json, $product_id ); if ( '' !== $mismatch ) { Log::error( sprintf( 'Approved-product sync: the answer for product %s is for another %s; nothing written.', $product_id, $mismatch ) ); return self::MISMATCH; } return self::OK; } private static function unsigned( string $product_id ): string { if ( ! self::signed_only() ) { return self::LEGACY; } Log::error( sprintf( 'Approved-product sync: the answer for product %s is not signed, and this store only takes signed answers; nothing written.', $product_id ) ); return self::REFUSED; } /** * The first REFUSALS key that applies, or '' when the signature, version and nonce all hold. The signature is * checked first, on the raw bytes, before anything decoded from them is looked at. * * @param array $json The decoded answer. */ private static function refusal( SignedResponse $response, array $json, string $nonce ): string { if ( ! SignedClient::verify_answer( $response->body, $response->signature ) ) { return 'signature'; } if ( ! isset( $json['v'] ) || self::VERSION !== $json['v'] ) { return 'version'; } if ( ! isset( $json['nonce'] ) || ! is_string( $json['nonce'] ) || ! hash_equals( $nonce, $json['nonce'] ) ) { return 'nonce'; } return ''; } /** * A warning when the verified answer's ts and the store's clock differ by more than MAX_SKEW: the answer is still * written (the nonce proved it fresh), and whoever reads the log learns the store's clock is off. * * @param array $json The decoded, verified answer. */ private static function log_clock_drift( array $json ): void { if ( ! isset( $json['ts'] ) || ! is_int( $json['ts'] ) ) { return; } $drift = time() - $json['ts']; if ( abs( $drift ) > self::MAX_SKEW ) { Log::warning( sprintf( 'Approved-product sync: this store\'s clock differs from CortexCommerce\'s by %d seconds; answers are still checked by their nonce, but the server clock should be corrected.', $drift ) ); } } /** * 'store' or 'product' when the answer is for another one, '' when it names this store and this product. * * @param array $json The decoded answer. */ private static function mismatch( array $json, string $product_id ): string { $tenant = isset( $json['tenant_id'] ) && is_string( $json['tenant_id'] ) ? $json['tenant_id'] : ''; if ( '' === $tenant || ! hash_equals( Options::tenant_id(), $tenant ) ) { return 'store'; } $answered = isset( $json['product_id'] ) && is_scalar( $json['product_id'] ) ? (string) $json['product_id'] : ''; return $answered === $product_id ? '' : 'product'; } }