prefix . 'storesuit_mp2_order_private_keys'; } public static function identity( string $account, string $profile, string $order_ref ): string { foreach ( array( $account, $profile ) as $uuid ) { if ( ! preg_match( '/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/D', $uuid ) ) { throw new \InvalidArgumentException( 'Invalid private order scope.' ); } } if ( $order_ref === '' || strlen( $order_ref ) > 192 || trim( $order_ref ) !== $order_ref || ! preg_match( '//u', $order_ref ) || preg_match( '/[\x00-\x1f\x7f]/', $order_ref ) ) { throw new \InvalidArgumentException( 'Invalid private original-order reference.' ); } return hash( 'sha256', wp_json_encode( array( $account, $profile, $order_ref ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ); } private static function master(): string { if ( ! defined( 'AUTH_KEY' ) || ! defined( 'SECURE_AUTH_KEY' ) || strlen( AUTH_KEY ) < 32 || strlen( SECURE_AUTH_KEY ) < 32 || ! function_exists( 'openssl_encrypt' ) ) { throw new \RuntimeException( 'PRIVATE_STORAGE_UNAVAILABLE' ); } return hash_hkdf( 'sha256', AUTH_KEY . SECURE_AUTH_KEY, 32, 'StoreRuler original-order privacy v1', wp_salt( 'secure_auth' ) ); } private static function encrypt( string $plain, string $key, string $aad ): string { $nonce = random_bytes( 12 ); $tag = ''; $cipher = openssl_encrypt( $plain, self::CIPHER, $key, OPENSSL_RAW_DATA, $nonce, $tag, $aad, 16 ); if ( $cipher === false || strlen( $tag ) !== 16 ) { throw new \RuntimeException( 'PRIVATE_STORAGE_UNAVAILABLE' ); } return bin2hex( $nonce . $tag . $cipher ); } private static function decrypt( string $sealed, string $key, string $aad ): string { $bytes = strlen( $sealed ) % 2 === 0 && preg_match( '/^[0-9a-f]+$/D', $sealed ) ? hex2bin( $sealed ) : false; if ( $bytes === false || strlen( $bytes ) < 28 || strlen( $bytes ) > 2097180 ) { throw new \RuntimeException( 'PRIVATE_DATA_UNREADABLE' ); } $plain = openssl_decrypt( substr( $bytes, 28 ), self::CIPHER, $key, OPENSSL_RAW_DATA, substr( $bytes, 0, 12 ), substr( $bytes, 12, 16 ), $aad ); if ( $plain === false ) { throw new \RuntimeException( 'PRIVATE_DATA_UNREADABLE' ); } return $plain; } private static function key( string $id, string $account, string $profile, string $order_ref, bool $create ): ?string { global $wpdb; $table = self::table(); if ( $create ) { $sealed = self::encrypt( random_bytes( 32 ), self::master(), 'key/' . $id ); if ( $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO %i (id,account_id,profile_id,order_ref,key_blob,state,created_at,updated_at) VALUES (%s,%s,%s,%s,%s,'active',%s,%s)", $table, $id, $account, $profile, $order_ref, $sealed, gmdate( 'Y-m-d H:i:s' ), gmdate( 'Y-m-d H:i:s' ) ) ) === false ) { throw new \RuntimeException( 'PRIVATE_STORAGE_UNAVAILABLE' ); } } $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id=%s FOR UPDATE', $table, $id ), ARRAY_A ); if ( $wpdb->last_error !== '' ) { throw new \RuntimeException( 'PRIVATE_STORAGE_UNAVAILABLE' ); } if ( ! $row || $row['state'] === 'erased' ) { return null; } if ( $row['account_id'] !== $account || $row['profile_id'] !== $profile || $row['order_ref'] !== $order_ref || $row['state'] !== 'active' ) { throw new \RuntimeException( 'PRIVATE_SCOPE_CHANGED' ); } $key = self::decrypt( $row['key_blob'], self::master(), 'key/' . $id ); if ( strlen( $key ) !== 32 ) { throw new \RuntimeException( 'PRIVATE_DATA_UNREADABLE' ); } return $key; } public static function seal( string $account, string $profile, string $order_ref, string $package, array $data, int $expires, int $max_bytes = 32768 ): ?array { if ( $max_bytes < 1 || $max_bytes > 2097152 ) { throw new \InvalidArgumentException( 'Invalid private artifact limit.' ); } if ( $package === '' || strlen( $package ) > 192 || trim( $package ) !== $package || ! preg_match( '//u', $package ) || preg_match( '/[\x00-\x1f\x7f]/', $package ) || $expires <= time() || $expires > time() + 7 * 86400 ) { throw new \InvalidArgumentException( 'Invalid private source envelope.' ); } $id = self::identity( $account, $profile, $order_ref ); return Store::atomic( static function() use ( $id, $account, $profile, $order_ref, $package, $data, $expires, $max_bytes ): ?array { $key = self::key( $id, $account, $profile, $order_ref, true ); if ( $key === null ) { return null; } $plain = json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR ); if ( strlen( $plain ) > $max_bytes ) { throw new \InvalidArgumentException( 'Private delivery fields exceed their limit.' ); } $aad = 'delivery/' . $id . '/' . $package . '/' . $expires; return array( 'scope_id'=>$id, 'package_ref'=>$package, 'expires_at'=>$expires, 'hash'=>hash_hmac( 'sha256', $plain, $key ), 'ciphertext'=>self::encrypt( $plain, $key, $aad ) ); } ); } /** Server-internal use; browser responses use the projected WooCommerce order and its existing permissions. */ public static function open( string $account, string $profile, string $order_ref, string $package, array $envelope ): ?array { $id = self::identity( $account, $profile, $order_ref ); if ( ( $envelope['scope_id'] ?? '' ) !== $id || ( $envelope['package_ref'] ?? '' ) !== $package ) { throw new \RuntimeException( 'PRIVATE_SCOPE_CHANGED' ); } if ( ! is_int( $envelope['expires_at'] ?? null ) || $envelope['expires_at'] <= time() ) { throw new \RuntimeException( 'PRIVATE_SOURCE_EXPIRED' ); } return Store::atomic( static function() use ( $id, $account, $profile, $order_ref, $package, $envelope ): ?array { $key = self::key( $id, $account, $profile, $order_ref, false ); if ( $key === null ) { return null; } $plain = self::decrypt( $envelope['ciphertext'] ?? '', $key, 'delivery/' . $id . '/' . $package . '/' . $envelope['expires_at'] ); if ( ! hash_equals( $envelope['hash'] ?? '', hash_hmac( 'sha256', $plain, $key ) ) ) { throw new \RuntimeException( 'PRIVATE_DATA_UNREADABLE' ); } return json_decode( $plain, true, 16, JSON_THROW_ON_ERROR ); } ); } public static function erased( \WC_Order $order ): bool { return $order->get_meta( self::ERASED ) === '1' || $order->get_meta( '_anonymized' ) === 'yes'; } public static function digest( string $account, string $profile, string $order_ref, array $data ): ?string { $id = self::identity( $account, $profile, $order_ref ); return Store::atomic( static function() use ( $id, $account, $profile, $order_ref, $data ): ?string { $key = self::key( $id, $account, $profile, $order_ref, false ); return $key === null ? null : hash_hmac( 'sha256', json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR ), $key ); } ); } public static function hooks(): void { // Destroy the source key before WooCommerce clears fields: an in-flight reader cannot restore them afterwards. add_action( 'woocommerce_privacy_before_remove_order_personal_data', array( __CLASS__, 'erase' ) ); add_action( 'woocommerce_privacy_remove_order_personal_data', array( __CLASS__, 'erase' ) ); } public static function erase( $order ): void { if ( ! $order instanceof \WC_Order || ! $order->get_meta( '_storesuit_mp_v2_order' ) ) { return; } $account = (string) $order->get_meta( '_storesuit_mp_v2_account' ); $profile = (string) $order->get_meta( '_storesuit_mp_v2_profile' ); $ref = (string) $order->get_meta( '_storesuit_market_order_number' ); $id = self::identity( $account, $profile, $ref ); global $wpdb; $table = self::table(); // Old/free sites without this storage still keep the WooCommerce tombstone; no schema changes on erasure. $exists = $wpdb->get_var( $wpdb->prepare( 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ); if ( $exists ) { Store::atomic( static function() use ( $table, $id, $account, $profile, $ref, $wpdb ): void { if ( $wpdb->query( $wpdb->prepare( "INSERT INTO %i (id,account_id,profile_id,order_ref,key_blob,state,created_at,updated_at) VALUES (%s,%s,%s,%s,'','erased',%s,%s) ON DUPLICATE KEY UPDATE key_blob='',state='erased',updated_at=VALUES(updated_at)", $table, $id, $account, $profile, $ref, gmdate( 'Y-m-d H:i:s' ), gmdate( 'Y-m-d H:i:s' ) ) ) === false ) { throw new \RuntimeException( 'Private source key could not be erased.' ); } } ); $delivery = $wpdb->prefix . 'storesuit_mp2_order_delivery'; if ( $wpdb->get_var( $wpdb->prepare( 'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $delivery ) ) ) { if ( $wpdb->update( $delivery, array( 'state'=>'erased', 'payload_json'=>'{}', 'projected_hash'=>'', 'updated_at'=>gmdate( 'Y-m-d H:i:s' ) ), array( 'order_id'=>(string) $order->get_meta( '_storesuit_mp_v2_order' ), 'account_id'=>$account, 'profile_id'=>$profile ) ) === false ) { throw new \RuntimeException( 'Private delivery projection could not be erased.' ); } } } if ( $order->get_meta( self::ERASED ) !== '1' ) { $order->update_meta_data( self::ERASED, '1' ); $order->save(); } } }