diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index a7102c2..2732e77 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -28,6 +28,6 @@ 'visibility_required' => true, 'native_function_invocation' => true, 'native_constant_invocation' => true, - 'mb_str_functions' => true, + 'mb_str_functions' => false, // Disabled: we handle binary data (sodium keys) that requires strlen() not mb_strlen() 'modernize_strpos' => true, ]); diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7d82653 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,266 @@ +# AGENTS.md - TOR-PHP + +## ๐ŸŽฏ Core Concept + +**TOR-PHP** is a PHP library providing **Tor network integration** for anonymous HTTP requests and ControlPort management with a type-safe API. + +### Solution + +Two main components: +- **TorHttpClient**: Symfony HttpClient implementation for HTTP requests through Tor SOCKS5 proxy +- **TorControlClient**: Socket-based client for Tor Control Protocol (circuit management, onion services, configuration) + +--- + +## ๐Ÿ—๏ธ Architecture + +### Main Components + +#### 1. **TorHttpClient** (HTTP via Tor) +Implements `HttpClientInterface`, wraps Symfony HttpClient with SOCKS5 proxy configuration. + +Key methods: +- `request()`: HTTP requests through Tor +- `newIdentity()`: Request new Tor circuit +- `getExitNodes()`: Get list of exit nodes (Generator) + +#### 2. **TorControlClient** (Tor Control Protocol) +Manages Tor via ControlPort socket connection. + +Key methods: +- `getCircuits()`, `signalNewnym()`, `waitForCircuitBuild()`: Circuit management +- `getConfigValue()`, `setConfigValue()`: Configuration +- `addOnionService()`, `deleteOnionService()`, `listOnionServices()`: Onion services +- `setEvents()`: Subscribe to Tor events + +#### 3. **TorSocketClient** (Transport) +Low-level socket communication with Tor: +- `connect()`, `close()`: Connection management +- `writeLine()`, `readLine()`, `readUntil()`: Protocol I/O + +#### 4. **Model Layer** (readonly classes) +- `Circuit`: Circuit information +- `OnionService`: Hidden service data +- `PortMapping`: Port mapping for onion services +- `ExitNode`: Exit node information + +#### 5. **Exceptions** +- `SocketException`: Socket errors (connection, timeout, I/O) + +--- + +## ๐Ÿ’ก Design Patterns + +- **Facade**: Clients hide protocol complexity +- **Decorator**: TorHttpClient decorates HttpClientInterface +- **Immutable Value Objects**: All models are `readonly` +- **Generator**: `getExitNodes()` yields lazily for memory efficiency +- **Dependency Injection**: Constructor injection for testability +- **Sensitive Parameter Protection**: `#[\SensitiveParameter]` for credentials + +--- + +## ๐Ÿงช Testing + +### Requirements +Most tests require running Tor with ControlPort enabled: +```bash +# Install: brew install tor (macOS) or sudo apt install tor (Linux) +# Configure /etc/tor/torrc: +ControlPort 9051 +CookieAuthentication 0 # or HashedControlPassword +``` + +### Running Tests +```bash +make test # Run tests +make phpstan # Static analysis (level 8) +make phpcs # Linting/formatting +``` + +### Mocking +```php +$mockClient = $this->createMock(HttpClientInterface::class); +$torClient = new TorHttpClient(httpClient: $mockClient); +``` + +--- + +## ๐ŸŽจ Code Style & Standards + +### PHP Version & Standards +- **PHP 8.4+** required +- **PSR-12** code style (enforced by php-cs-fixer) +- **PHPStan level 8** (no baseline, all issues must be fixed) +- **Strict types**: Every file starts with `declare(strict_types=1);` + +### Key Rules +- `@PSR12` and `@Symfony` rulesets +- `declare_strict_types`: Enforced +- `trailing_comma_in_multiline`: Required +- `native_function_invocation`: Use native functions with backslash +- `yoda_style`: Disabled (natural comparisons) + +### Type Safety +```php +// โœ… Correct +declare(strict_types=1); + +public function request(string $method, string $url, array $options = []): ResponseInterface +readonly class Circuit { ... } +#[\SensitiveParameter] private readonly ?string $password = null +public function getExitNodes(): \Generator + +// โŒ Wrong +public function request($method, $url, $options = []) // No types +class Circuit { public string $id; } // Not readonly for value object +private ?string $password = null // Missing SensitiveParameter +``` + +### Naming Conventions +```php +private const string TOR_DEFAULT_HOST = '127.0.0.1'; // Constants: UPPER_SNAKE_CASE with type +public function signalNewnym(): void // Methods: camelCase +private readonly TorSocketClient $socketClient; // Properties: camelCase +``` + +### Documentation +```php +// โœ… Class-level PHPDoc always +/** + * A client for interacting with Tor via the ControlPort. + * + * @author Edouard Courty + */ +class TorControlClient + +// โœ… Method PHPDoc only for array structures +/** @param array $options */ +public function request(string $method, string $url, array $options = []): ResponseInterface + +// โœ… No PHPDoc when types are clear +public function close(): void +``` + +### Error Handling +```php +// โœ… Specific exceptions +try { + $response->getStatusCode(); +} catch (TransportExceptionInterface $e) { + // Handle +} + +// โœ… Meaningful messages +throw new SocketException("Failed to connect to Tor at {$host}:{$port}"); +``` + +--- + +## โšก Performance & Security + +### Performance +- Reuse client instances (avoid creating new instances per request) +- `TorControlClient`: Call `close()` in finally blocks +- `newIdentity()` is expensive (3-10s circuit build time) +- `getExitNodes()` returns Generator (don't use `iterator_to_array()`) + +### Security +- All credentials use `#[\SensitiveParameter]` (prevents stack trace leaks) +- Store onion private keys securely: `chmod 600 onion.key` +- Never log passwords or private keys +- Authentication priority: password > cookie > none + +--- + +## ๐Ÿ“‹ Developer Checklist + +### Adding Features +1. โœ… Implement in appropriate client class +2. โœ… Add readonly model classes for new entities +3. โœ… Write tests (may need running Tor) +4. โœ… Run `make phpcs` (linting) +5. โœ… Run `make phpstan` (static analysis) +6. โœ… Update README.md, CHANGELOG.md, add example to `examples/` + +### Fixing Bugs +1. โœ… Write failing test reproducing bug +2. โœ… Fix with minimal changes +3. โœ… Run `make test` +4. โœ… Run `make phpcs` and `make phpstan` +5. โœ… Update CHANGELOG.md + +### Before Committing +1. โœ… All tests pass: `make test` +2. โœ… No PHPStan errors: `make phpstan` +3. โœ… Code formatted: `make phpcs` +4. โœ… Commit message: `[type] description` (types: feat, fix, docs, refactor, test, chore) + +### Debugging +- Tor running? โ†’ `ps aux | grep tor` +- ControlPort enabled? โ†’ Check `/etc/tor/torrc` for `ControlPort 9051` +- Authentication? โ†’ Verify password or cookie +- Timeout? โ†’ Increase timeout in constructor +- Wrong response? โ†’ Check Tor logs: `tail -f /var/log/tor/notices.log` + +--- + +## Remarks for AI Agents + +### Critical Rules +- **NEVER commit or push** unless explicitly instructed +- **Always run** `make phpcs`, `make phpstan`, `make test` before completing work +- **Maintain backward compatibility** unless explicitly breaking change +- **PHPStan level 8** must pass with no baseline + +### When Adding Features +1. Check Tor control-spec for feature support +2. Add readonly model classes for new entities +3. Write tests (may require Tor instance) +4. Update README.md, CHANGELOG.md, add example + +### When Fixing Bugs +1. Reproduce with test first +2. Fix with minimal changes +3. Don't fix unrelated issues + +### When Refactoring +1. Tests must pass before refactoring +2. Tests must still pass after without modification +3. If tests need changes, refactoring may break API + +### Common Pitfalls +- โŒ Missing `declare(strict_types=1)` +- โŒ Missing type hints +- โŒ Using mutable classes for value objects (use `readonly`) +- โŒ Missing `#[\SensitiveParameter]` for credentials +- โŒ Ignoring PHPStan errors +- โŒ Breaking backward compatibility +- โŒ Adding unnecessary dependencies + +### Code Review Checklist +- [ ] `declare(strict_types=1)` at top of file +- [ ] All parameters and return types declared +- [ ] PHPStan level 8 passes +- [ ] PHP-CS-Fixer passes +- [ ] Tests written and passing +- [ ] No sensitive data logged +- [ ] `readonly` used for value objects +- [ ] `#[\SensitiveParameter]` for passwords/keys +- [ ] Generator used for large datasets +- [ ] Exceptions properly typed + +--- + +## ๐Ÿ“š Quick Reference + +### Useful Resources +- Tor Control Protocol: https://spec.torproject.org/control-spec/ +- Tor Manual: https://2019.www.torproject.org/docs/tor-manual.html.en +- Symfony HttpClient: https://symfony.com/doc/current/http_client.html + +### Project Files +- Source: `/src` (TorHttpClient, TorControlClient, Transport, Model, Helper, Exception) +- Tests: `/tests` (mirror structure of `/src`) +- Examples: `/examples` +- Config: `.php-cs-fixer.php`, `phpstan.neon`, `Makefile` diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ae303b..7449a46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,34 @@ This file contains information about every addition, update and deletion in the `ecourty/tor-php` library. It is recommended to read this file before updating the library to a new version. +## v1.2.0 + +#### Additions + +- Added [`KeyPairGenerator`](./src/Helper/KeyPairGenerator.php) for generating ED25519-V3 keypairs for Tor onion services + - Uses native PHP `sodium` extension for cryptographically secure key generation + - Derives `.onion` service IDs according to Tor v3 specification (base32 encoding with SHA3-256 checksum) + - Keys are properly expanded to match Tor's expected format +- Added [`KeyPair`](./src/Model/KeyPair.php) readonly model class + - Stores private key, public key, and derived service ID + - Provides multiple key format methods: raw, base64, and Tor-formatted (uses `PrivateKeyHelper`) + - Includes `__debugInfo()` to prevent sensitive data leaks in dumps +- Added comprehensive unit tests for key generation and model +- Added integration tests validating generated keys work with actual Tor instances +- Added `selective/base32` dependency for RFC 4648 compliant base32 encoding +- Added `ext-sodium` to `suggest` in composer.json (required for `KeyPairGenerator`, but not for other features) + +#### Updates + +- Updated `.php-cs-fixer.php`: disabled `mb_str_functions` rule (required for binary-safe string operations with cryptographic keys) +- Updated README.md with key generation examples +- `KeyPair::getPrivateKeyFormatted()` now uses `PrivateKeyHelper::parsePrivateKey()` for consistency + ## v1.1.0 #### Additions -- Added the [`TorHttpClient::getAllPeers`](./src/TorHttpClient.php) method which returns all current peers on the Tor network in a Generator. +- Added the [`TorHttpClient::getExitNodes`](./src/TorHttpClient.php) method which returns all current peers on the Tor network in a Generator. - Added [`ExitNodeHelper`](./src/Helper/ExitNodesHelper.php) helper to extract data from the Tor response. - Added tests for this method. - Added a [code example](./examples/get_exit_nodes.php) for getting exit nodes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 420e413..71a8804 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -49,21 +49,31 @@ I aim to maintain clean, consistent, and readable code throughout `tor-php`. Bef 2. **Running Tests Locally** - Ensure all existing and new tests pass before submitting a PR: ```bash - make test + composer test ``` - Make sure tests are passing and coverage is acceptable. ## Linting & Static Analysis -1. **Linting** - - Run `php-cs-fixer` to catch syntax or formatting issues: +1. **Code Fixing** + - Run `php-cs-fixer` to automatically fix syntax or formatting issues: ```bash - make phpcs + composer cs-fix ``` -2. **Static Analysis** +2. **Code Checking** + - Run `php-cs-fixer` in check mode to verify formatting without making changes: + ```bash + composer cs-check + ``` +3. **Static Analysis** - Run `phpstan` to catch potential bugs or incorrect assumptions: ```bash - make phpstan + composer phpstan + ``` +4. **Run All Quality Checks** + - Run all quality assurance checks (static analysis, code style check, and tests): + ```bash + composer qa ``` Any Pull Request that does not pass **all** of these checks (linting, static analysis, and tests) in the CI pipeline will not be reviewed. diff --git a/Makefile b/Makefile deleted file mode 100644 index b168412..0000000 --- a/Makefile +++ /dev/null @@ -1,19 +0,0 @@ -PHP = php - -PHPUNIT = vendor/bin/phpunit -PHPSTAN = vendor/bin/phpstan -PHPCSFIXER = vendor/bin/php-cs-fixer - -COMPOSER = composer - -install: - $(COMPOSER) install - -test: - $(PHP) $(PHPUNIT) tests - -phpstan: - $(PHP) $(PHPSTAN) analyse --memory-limit=-1 - -phpcs: - PHP_CS_FIXER_IGNORE_ENV=1 $(PHP) $(PHPCSFIXER) fix . --config .php-cs-fixer.php diff --git a/README.md b/README.md index 31fc3b4..cd60639 100644 --- a/README.md +++ b/README.md @@ -2,33 +2,76 @@ [![PHP CI](https://github.com/EdouardCourty/tor-php/actions/workflows/php_ci.yml/badge.svg)](https://github.com/EdouardCourty/tor-php/actions/workflows/php_ci.yml) -**Tor-PHP** is a PHP library that provides two main things: +**TOR-PHP** is a PHP library for Tor network integration. It provides two main components: -- `TorHttpClient` - A Tor-proxied HTTP client built on top of Symfony's `HttpClient` component. - _It allows you to send HTTP requests through the Tor network for anonymous browsing_ -- `TorControlClient` - A socket client implementing the TorControl protocol to manage your node, circuits and hidden services. +- **TorHttpClient** - A Symfony HttpClient implementation that routes HTTP requests through the Tor SOCKS5 proxy for anonymous communication. +- **TorControlClient** - A socket-based client implementing the Tor Control Protocol for managing circuits, configuration, and onion services. --- -## ๐Ÿš€ Features +## Table of Contents + +- [Quick Start](#quick-start) +- [Installation](#installation) +- [Requirements](#requirements) +- [Authentication](#authentication) + - [Password Authentication](#password-authentication) + - [Cookie Authentication](#cookie-authentication) + - [No Authentication](#no-authentication-not-recommended) +- [Features](#features) + - [HTTP Client Features](#http-client-features) + - [Control Client Features](#control-client-features) +- [Security Baseline](#security-baseline) +- [Usage Examples](#usage-examples) + - [Example 1: HTTP Requests via Tor](#example-1-get-current-ip-via-tor) + - [Example 2: Request a New Identity](#example-2-request-a-new-identity) + - [Example 3: Manage Your Tor Node](#example-3-manage-your-tor-node) + - [Example 4: Generate Onion Service Keys](#example-4-generate-onion-service-keys) +- [Changelog](#changelog) +- [Contributing](#contributing) +- [License](#license) -- HTTP requests through Tor (SOCKS5 proxy) -- Tor ControlPort integration for: - - Requesting a new identity - - Managing Tor circuits and configuration - - Creating and deleting Onion Services -- Retrieve the current Tor exit nodes +--- + +## Quick Start + +1. **Install and start Tor:** + + ```bash + # Debian/Ubuntu + sudo apt install tor + sudo systemctl start tor + + # macOS + brew install tor + brew services start tor + ``` + +2. **Install TOR-PHP:** + + ```bash + composer require ecourty/tor-php + ``` + +3. **Make your first request:** + + ```php + request('GET', 'https://api.ipify.org?format=json'); + echo $response->getContent(); // Your Tor exit IP + ``` --- -## ๐Ÿ“ฆ Installation +## Installation ### 1. Install Tor -You must have the Tor service installed and running. - #### On Debian/Ubuntu ```bash @@ -41,15 +84,17 @@ sudo apt install tor brew install tor ``` -> Make sure the `ControlPort` is enabled in your Tor configuration (`/etc/tor/torrc` or equivalent): -> -> ```ini -> ControlPort 9051 -> HashedControlPassword -> CookieAuthentication 0 -> ``` +### 2. Configure Tor ControlPort + +Edit your Tor configuration file (`/etc/tor/torrc` or `/usr/local/etc/tor/torrc`): + +```ini +ControlPort 9051 +``` + +See the [Authentication](#authentication) section for configuring authentication. -### 2. Install Tor-PHP via Composer +### 3. Install TOR-PHP via Composer ```bash composer require ecourty/tor-php @@ -57,14 +102,145 @@ composer require ecourty/tor-php --- -## ๐Ÿ›  Requirements +## Requirements - PHP 8.4 or higher -- Tor must be running locally with ControlPort enabled for full features integration +- Tor daemon running locally +- ControlPort enabled in Tor configuration (required for `TorControlClient` features) + +--- + +## Authentication + +The Tor ControlPort should always be protected with authentication. TOR-PHP supports three authentication methods. + +### Password Authentication + +**Recommended for production environments.** + +1. Generate a hashed password: + + ```bash + tor --hash-password "your_secure_password" + ``` + +2. Add to `/etc/tor/torrc`: + + ```ini + ControlPort 9051 + HashedControlPassword 16:872860B76453A77D60CA2BB8C1A7042072093276A3D701AD684053EC4C + ``` + +3. Use in PHP: + + ```php + use TorPHP\TorControlClient; + + $control = new TorControlClient( + password: 'your_secure_password' + ); + ``` + +### Cookie Authentication + +**Recommended when the PHP process runs as the same user as Tor.** + +1. Configure `/etc/tor/torrc`: + + ```ini + ControlPort 9051 + CookieAuthentication 1 + ``` + +2. Use in PHP: + + ```php + use TorPHP\TorControlClient; + + $control = new TorControlClient( + authenticationCookie: '/var/run/tor/control.authcookie' + ); + ``` + + The cookie file path varies by system: + - Linux: `/var/run/tor/control.authcookie` + - macOS (Homebrew): `/usr/local/var/run/tor/control.authcookie` + +### No Authentication (Not Recommended) + +**Only use for local development on isolated systems.** + +Configure `/etc/tor/torrc`: + +```ini +ControlPort 9051 +CookieAuthentication 0 +``` + +Use in PHP: + +```php +use TorPHP\TorControlClient; + +$control = new TorControlClient(); // No credentials +``` + +**Warning:** Running Tor ControlPort without authentication is a security risk. Any process on the system can control your Tor instance. + +--- + +## Features + +### HTTP Client Features + +- HTTP/HTTPS requests through Tor SOCKS5 proxy (port 9050) +- Request new identity (change Tor circuit) +- Retrieve current Tor exit nodes +- Compatible with Symfony HttpClient interface + +### Control Client Features + +- Circuit management and inspection +- Signal new identity (`NEWNYM`) +- Configuration management (`GETCONF`, `SETCONF`) +- Onion service creation and deletion +- ED25519-V3 keypair generation for onion services +- Tor event subscription +- Wait for circuit build completion + +--- + +## Security Baseline + +### Mandatory Authentication + +**Running Tor ControlPort without authentication is dangerous.** An attacker with local access could: +- Reconfigure your Tor instance +- Create onion services +- Monitor your traffic +- Manipulate circuits + +**Always use password or cookie authentication in production.** + +### Best Practices + +- **Protect credentials:** Never commit passwords or cookie files to version control +- **File permissions:** Set restrictive permissions on sensitive files: + ```bash + chmod 600 /path/to/onion/private.key + chmod 600 /path/to/.env + ``` +- **Environment variables:** Store credentials in environment variables: + ```php + $control = new TorControlClient( + password: $_ENV['TOR_CONTROL_PASSWORD'] + ); + ``` +- **Sensitive parameter protection:** TOR-PHP uses `#[\SensitiveParameter]` to prevent credential leakage in stack traces --- -## ๐Ÿ“˜ Usage Examples +## Usage Examples ### Example 1: Get current IP via Tor @@ -134,25 +310,58 @@ $services = $control->listOnionServices(); $control->deleteOnionService($onion->id); ``` +--- + +### Example 4: Generate Onion Service Keys + +```php +serviceId}.onion\n"; +echo "Private Key: {$keyPair->getPrivateKeyFormatted()}\n"; + +// Use the generated key to create an onion service +$control = new TorControlClient(); +$onion = $control->addOnionService( + portMappings: [new PortMapping('127.0.0.1', 8080, 80)], + privateKey: $keyPair->getPrivateKeyBase64() +); + +// The onion service ID will match the derived serviceId from the keypair +assert($onion->id === $keyPair->serviceId); +``` + Other code examples can be [found here](./examples). --- -## ๐Ÿ“š Changelog +## Changelog See [CHANGELOG.md](./CHANGELOG.md) for details. --- -## ๐Ÿ‘ค Author +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution guidelines. + +--- + +## License -[**Edouard Courty**](https://github.com/EdouardCourty) -MIT Licensed [(see)](./LICENSE) -© 2025 +**Author:** [Edouard Courty](https://github.com/EdouardCourty) +**License:** MIT - See [LICENSE](./LICENSE) +Copyright © 2025 --- -## ๐Ÿ™‹โ€โ™‚๏ธ Need Help? +## Need Help? -Feel free to open an issue or contribute via pull requests!
-[See contributing guidelines](./CONTRIBUTING.md) +Feel free to open an issue or contribute via pull requests. diff --git a/composer.json b/composer.json index 618fcb1..b575cb8 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,8 @@ ], "require": { "php": ">= 8.4", - "symfony/http-client": "^7.2" + "symfony/http-client": "^7.2", + "selective/base32": "^2.0" }, "require-dev": { "phpunit/phpunit": "^12.0", @@ -16,6 +17,9 @@ "friendsofphp/php-cs-fixer": "^3.71", "symfony/var-dumper": "^7.2" }, + "suggest": { + "ext-sodium": "Required for generating ED25519-V3 keypairs for Tor onion services" + }, "license": "MIT", "autoload": { "psr-4": { @@ -33,5 +37,25 @@ "email": "edouard.courty2@gmail.com", "homepage": "https://github.com/EdouardCourty" } - ] + ], + "scripts": { + "test": "phpunit tests", + "test-unit": "phpunit tests/Unit/", + "test-functional": "phpunit tests/Functional/", + "test-integration": "phpunit tests/Integration/", + "test-fast": "@test-unit", + "phpstan": "phpstan analyse --memory-limit=-1", + "cs-fix": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix . --config .php-cs-fixer.php", + "cs-check": "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix . --config .php-cs-fixer.php --dry-run --diff", + "qa": [ + "@phpstan", + "@cs-check", + "@test" + ] + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "stable", + "prefer-stable": true } diff --git a/examples/add_onion_service.php b/examples/add_onion_service.php index 160ae04..0acb044 100644 --- a/examples/add_onion_service.php +++ b/examples/add_onion_service.php @@ -4,10 +4,13 @@ require_once __DIR__ . '/../vendor/autoload.php'; +use TorPHP\Helper\KeyPairGenerator; use TorPHP\Model\PortMapping; use TorPHP\TorControlClient; -$torHttpClient = new TorControlClient(); +$torControlClient = new TorControlClient(); + +$keyPair = KeyPairGenerator::generate(); /** * This assumes you have some kind of web server running on localhost:8089 @@ -18,7 +21,7 @@ * @see https://community.torproject.org/onion-services/setup/ */ $portMapping = new PortMapping('127.0.0.1', 8089, 80); -$hiddenService = $torHttpClient->addOnionService(portMappings: [$portMapping]); // No private key passed, Tor will generate one for us +$hiddenService = $torControlClient->addOnionService(portMappings: [$portMapping], privateKey: $keyPair); // No private key passed, Tor will generate one for us echo '===== ONION SERVICE CREATED =====' . \PHP_EOL; diff --git a/examples/change_identity.php b/examples/change_identity.php index 00b678a..6b2fd44 100644 --- a/examples/change_identity.php +++ b/examples/change_identity.php @@ -4,8 +4,12 @@ require_once __DIR__ . '/../vendor/autoload.php'; +use Symfony\Component\HttpClient\HttpClient; use TorPHP\TorHttpClient; +$normalApiClient = HttpClient::create(); +$normalResponse = $normalApiClient->request('GET', 'http://check.torproject.org/api/ip')->toArray(); + $torHttpClient = new TorHttpClient(); $initialResponse = $torHttpClient->request('GET', 'http://check.torproject.org/api/ip')->toArray(); @@ -13,5 +17,6 @@ $secondaryResponse = $torHttpClient->request('GET', 'http://check.torproject.org/api/ip')->toArray(); -echo 'Non-Tor IP: ' . $initialResponse['IP'] . \PHP_EOL; -echo 'Tor IP: ' . $secondaryResponse['IP'] . \PHP_EOL; +echo 'Non-tor IP: ' . $normalResponse['IP'] . \PHP_EOL; +echo 'Initial Tor IP: ' . $initialResponse['IP'] . \PHP_EOL; +echo 'New Tor IP: ' . $secondaryResponse['IP'] . \PHP_EOL; diff --git a/examples/get_exit_nodes.php b/examples/get_exit_nodes.php index dd3e420..5f0c91a 100644 --- a/examples/get_exit_nodes.php +++ b/examples/get_exit_nodes.php @@ -13,9 +13,9 @@ /** @var ExitNode[] $exitNodesArray */ $exitNodesArray = iterator_to_array($exitNodes); -echo sprintf('Found %s exit nodes.', count($exitNodesArray)) . PHP_EOL; -echo 'First exit node: ' . PHP_EOL; -echo ' > Identifier : ' . $exitNodesArray[0]->id . PHP_EOL; -echo ' > Address : ' . $exitNodesArray[0]->exitAddress . PHP_EOL; -echo ' > Published : ' . $exitNodesArray[0]->published->format('Y-m-d H:i:s') . PHP_EOL; -echo ' > Last Status: ' . $exitNodesArray[0]->lastStatus->format('Y-m-d H:i:s') . PHP_EOL; +echo \sprintf('Found %s exit nodes.', \count($exitNodesArray)) . \PHP_EOL; +echo 'First exit node: ' . \PHP_EOL; +echo ' > Identifier : ' . $exitNodesArray[0]->id . \PHP_EOL; +echo ' > Address : ' . $exitNodesArray[0]->exitAddress . \PHP_EOL; +echo ' > Published : ' . $exitNodesArray[0]->published->format('Y-m-d H:i:s') . \PHP_EOL; +echo ' > Last Status: ' . $exitNodesArray[0]->lastStatus->format('Y-m-d H:i:s') . \PHP_EOL; diff --git a/src/Helper/ExitNodesHelper.php b/src/Helper/ExitNodesHelper.php index d1393d3..eac8cbe 100644 --- a/src/Helper/ExitNodesHelper.php +++ b/src/Helper/ExitNodesHelper.php @@ -5,7 +5,7 @@ namespace TorPHP\Helper; /** - * Helper class for private key management. + * Helper class for exit nodes data extraction. * * @author Edouard Courty */ diff --git a/src/Helper/KeyPairGenerator.php b/src/Helper/KeyPairGenerator.php new file mode 100644 index 0000000..0d361c8 --- /dev/null +++ b/src/Helper/KeyPairGenerator.php @@ -0,0 +1,130 @@ +serviceId; // e.g., "2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid" + * echo $keyPair->getPrivateKeyFormatted(); // For use with TorControlClient::addOnionService() + * ``` + * + * @author Edouard Courty + */ +class KeyPairGenerator +{ + private const string ONION_CHECKSUM_PREFIX = '.onion checksum'; + private const int ONION_VERSION = 0x03; + + /** + * Generates a new ED25519 keypair for Tor onion v3 services. + * + * @throws SocketException If sodium extension is not available + */ + public static function generate(): KeyPair + { + if (\extension_loaded('sodium') === false) { + throw new SocketException( + message: 'The sodium extension is required for key generation. Please install or enable ext-sodium.', + ); + } + + // Generate ED25519 keypair using libsodium + $keypair = sodium_crypto_sign_keypair(); + $publicKey = sodium_crypto_sign_publickey($keypair); + $secretKey = sodium_crypto_sign_secretkey($keypair); + + // Extract the 32-byte seed from the 64-byte secret key + $seed = substr($secretKey, 0, 32); + + // Expand the seed to the format Tor expects (a || RH) + $expandedKey = self::expandKey($seed); + + // Derive the .onion service ID according to Tor v3 spec + $serviceId = self::deriveServiceId($publicKey); + + // Clean up sensitive data + sodium_memzero($keypair); + sodium_memzero($secretKey); + sodium_memzero($seed); + + return KeyPair::create( + privateKey: $expandedKey, + publicKey: $publicKey, + serviceId: $serviceId, + ); + } + + /** + * Expands an ED25519 32-byte seed to 64-byte (a || RH) format for Tor. + * + * This matches the format Tor expects for ED25519-V3 private keys. + * Based on the reference: https://gist.github.com/wybiral/8f737644fc140c97b6b26c13b1409837 + * + * @param string $seed The 32-byte ED25519 seed + * + * @return string The 64-byte expanded key + */ + private static function expandKey(string $seed): string + { + // Hash the seed with SHA512 + $h = hash('sha512', $seed, binary: true); + + // Convert to byte array for bit manipulation + $hBytes = str_split($h); + + // Set bits so that h[:32] is private scalar "a" + $hBytes[0] = \chr(\ord($hBytes[0]) & 248); + $hBytes[31] = \chr((\ord($hBytes[31]) & 127) | 64); + + // Combine back to string - h is now (a || RH) + return implode('', $hBytes); + } + + /** + * Derives the .onion service ID from a public key. + * + * According to Tor v3 spec (rend-spec-v3.txt): + * - onion_address = base32(PUBKEY | CHECKSUM | VERSION) + * - CHECKSUM = H(".onion checksum" | PUBKEY | VERSION)[:2] + * - VERSION = 0x03 + * - H = SHA3-256 + * + * @param string $publicKey Raw 32-byte ED25519 public key + * + * @return string The derived service ID (56 chars, lowercase base32 without .onion suffix) + */ + private static function deriveServiceId(string $publicKey): string + { + // Build checksum input: ".onion checksum" || PUBKEY || VERSION + $checksumInput = self::ONION_CHECKSUM_PREFIX . $publicKey . \chr(self::ONION_VERSION); + + // Compute SHA3-256 hash and take first 2 bytes + $fullHash = hash('sha3-256', $checksumInput, binary: true); + $checksum = substr($fullHash, 0, 2); + + // Build the address: PUBKEY || CHECKSUM || VERSION + $addressBytes = $publicKey . $checksum . \chr(self::ONION_VERSION); + + // Encode to base32 (RFC 4648) and convert to lowercase + // For Tor v3: 35 bytes = 280 bits = exactly 56 base32 chars (no padding needed) + $base32 = new Base32(); + $serviceId = strtolower($base32->encode($addressBytes)); + + // Tor v3 addresses don't use padding + return rtrim($serviceId, '='); + } +} diff --git a/src/Model/Circuit.php b/src/Model/Circuit.php index 437e9e8..0e20339 100644 --- a/src/Model/Circuit.php +++ b/src/Model/Circuit.php @@ -9,7 +9,7 @@ * * @author Edouard Courty */ -class Circuit +readonly class Circuit { /** * @param array $nodes diff --git a/src/Model/KeyPair.php b/src/Model/KeyPair.php new file mode 100644 index 0000000..c60f692 --- /dev/null +++ b/src/Model/KeyPair.php @@ -0,0 +1,103 @@ +privateKey; + } + + /** + * Returns the base64-encoded private key. + */ + public function getPrivateKeyBase64(): string + { + return base64_encode($this->privateKey); + } + + /** + * Returns the private key formatted for Tor ADD_ONION command. + * Format: "ED25519-V3:" + */ + public function getPrivateKeyFormatted(): string + { + return PrivateKeyHelper::parsePrivateKey($this->getPrivateKeyBase64()); + } + + /** + * Returns the raw public key (32 bytes). + */ + public function getPublicKeyRaw(): string + { + return $this->publicKey; + } + + /** + * Returns the base64-encoded public key. + */ + public function getPublicKeyBase64(): string + { + return base64_encode($this->publicKey); + } + + /** + * Returns the full .onion URL. + */ + public function getOnionUrl(): string + { + return \sprintf('http://%s.onion', $this->serviceId); + } + + /** + * Prevents sensitive data from being exposed in var_dump/print_r. + * + * @return array + */ + public function __debugInfo(): array + { + return [ + 'serviceId' => $this->serviceId, + 'publicKey' => '[REDACTED]', + 'privateKey' => '[REDACTED]', + ]; + } +} diff --git a/src/Model/OnionService.php b/src/Model/OnionService.php index b23f245..893e556 100644 --- a/src/Model/OnionService.php +++ b/src/Model/OnionService.php @@ -9,7 +9,7 @@ * * @author Edouard Courty */ -class OnionService +readonly class OnionService { public function __construct( public string $id, diff --git a/src/Model/PortMapping.php b/src/Model/PortMapping.php index 01a5d54..7443632 100644 --- a/src/Model/PortMapping.php +++ b/src/Model/PortMapping.php @@ -9,7 +9,7 @@ * * @author Edouard Courty */ -class PortMapping +readonly class PortMapping { public function __construct( public string $host, diff --git a/src/TorControlClient.php b/src/TorControlClient.php index a3509b5..e18149f 100644 --- a/src/TorControlClient.php +++ b/src/TorControlClient.php @@ -7,6 +7,7 @@ use TorPHP\Exception\SocketException; use TorPHP\Helper\PrivateKeyHelper; use TorPHP\Model\Circuit; +use TorPHP\Model\KeyPair; use TorPHP\Model\OnionService; use TorPHP\Model\PortMapping; use TorPHP\Transport\TorSocketClient; @@ -256,13 +257,15 @@ public function listOnionServices(): array * Adds (or updates) an onion service. * * @param array $portMappings Array of PortMapping or this string format [',:', ...] - * @param string|null $privateKey Either null or a base64-encoded private-key (can be prefixed with "ED25519-V3:") + * @param string|null|KeyPair $privateKey A base64-encoded private-key or KeyPair class. If not provided, the Tor node will generate one. */ - public function addOnionService(array $portMappings, ?string $privateKey = null): OnionService + public function addOnionService(array $portMappings, string|KeyPair|null $privateKey = null): OnionService { - $privateKeyArgument = $privateKey === null - ? PrivateKeyHelper::NO_PRIVATE_KEY - : PrivateKeyHelper::parsePrivateKey($privateKey); + $privateKeyArgument = match (true) { + $privateKey === null => PrivateKeyHelper::NO_PRIVATE_KEY, + $privateKey instanceof KeyPair => PrivateKeyHelper::parsePrivateKey($privateKey->getPrivateKeyBase64()), + default => PrivateKeyHelper::parsePrivateKey($privateKey), + }; $cmd = 'ADD_ONION ' . $privateKeyArgument . ' Flags=Detach '; foreach ($portMappings as $mapping) { @@ -297,7 +300,7 @@ public function addOnionService(array $portMappings, ?string $privateKey = null) return new OnionService( id: $serviceId, url: \sprintf('http://%s.onion', $serviceId), - privateKey: $returnedPrivateKey, + privateKey: $returnedPrivateKey ?: $privateKeyArgument, ); } @@ -306,7 +309,7 @@ public function addOnionService(array $portMappings, ?string $privateKey = null) */ public function deleteOnionService(string $serviceId): void { - $this->socketClient->write(\sprintf('DEL_ONION %s\r\n', $serviceId)); + $this->socketClient->write("DEL_ONION {$serviceId}\r\n"); $this->assertSuccessResponse(); } } diff --git a/src/Transport/TorSocketClient.php b/src/Transport/TorSocketClient.php index 225da92..7e1bf1c 100644 --- a/src/Transport/TorSocketClient.php +++ b/src/Transport/TorSocketClient.php @@ -44,6 +44,14 @@ public function connect(): void ); } + // Set socket to blocking mode + stream_set_blocking($socket, true); + + // Set read/write timeout for the socket stream + $seconds = (int) $this->timeout; + $microseconds = (int) (($this->timeout - $seconds) * 1000000); + stream_set_timeout($socket, $seconds, $microseconds); + $this->socket = $socket; } @@ -58,6 +66,8 @@ public function write(string $data): void if ($bytes === false) { throw new SocketException(message: 'Failed to write to socket'); } + + fflush($this->socket); // @phpstan-ignore-line (False check already done in ::ensureConnected) } /** @@ -69,6 +79,12 @@ public function readLine(): ?string $line = fgets($this->socket); // @phpstan-ignore-line (False check already done in ::ensureConnected) if ($line === false) { + // Check if it was a timeout + $info = stream_get_meta_data($this->socket); // @phpstan-ignore-line + if ($info['timed_out'] === true) { + throw new SocketException(message: 'Socket read timed out'); + } + return null; } diff --git a/tests/TorControlClientTest.php b/tests/Functional/TorControlClientTest.php similarity index 96% rename from tests/TorControlClientTest.php rename to tests/Functional/TorControlClientTest.php index 42dfadf..cf823b2 100644 --- a/tests/TorControlClientTest.php +++ b/tests/Functional/TorControlClientTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TorPHP\Tests; +namespace TorPHP\Tests\Functional; use PHPUnit\Framework\TestCase; use TorPHP\TorControlClient; diff --git a/tests/TorHttpClientTest.php b/tests/Functional/TorHttpClientTest.php similarity index 99% rename from tests/TorHttpClientTest.php rename to tests/Functional/TorHttpClientTest.php index 5dc9405..609d617 100644 --- a/tests/TorHttpClientTest.php +++ b/tests/Functional/TorHttpClientTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TorPHP\Tests; +namespace TorPHP\Tests\Functional; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpClient\HttpClient; diff --git a/tests/Integration/KeyPairTorIntegrationTest.php b/tests/Integration/KeyPairTorIntegrationTest.php new file mode 100644 index 0000000..377788f --- /dev/null +++ b/tests/Integration/KeyPairTorIntegrationTest.php @@ -0,0 +1,111 @@ +serviceId; + + // Connect to Tor and create onion service with our private key + $torClient = new TorControlClient(password: 'Password'); + + try { + // Create onion service with our generated private key + $portMapping = new PortMapping('127.0.0.1', 8080, 80); + $onionService = $torClient->addOnionService( + portMappings: [$portMapping], + privateKey: $keyPair->getPrivateKeyBase64(), + ); + + // The moment of truth: does Tor's serviceId match ours? + $this->assertSame( + $ourServiceId, + $onionService->id, + \sprintf( + 'Service ID mismatch! Our derivation: %s, Tor returned: %s. ' . + 'This indicates our serviceId derivation algorithm does not match Tor\'s implementation.', + $ourServiceId, + $onionService->id, + ), + ); + + // Clean up: delete the test service + $torClient->deleteOnionService($onionService->id); + } finally { + $torClient->close(); + } + } + + /** + * Validates that multiple generated keys all work correctly with Tor. + * + * @covers ::generate + */ + public function testMultipleGeneratedKeysWorkWithTor(): void + { + $torClient = new TorControlClient(password: 'Password'); + $createdServices = []; + + try { + // Generate and test 3 different keypairs + for ($i = 0; $i < 3; ++$i) { + $keyPair = KeyPairGenerator::generate(); + + $portMapping = new PortMapping('127.0.0.1', 8080 + $i, 80); + $onionService = $torClient->addOnionService( + portMappings: [$portMapping], + privateKey: $keyPair->getPrivateKeyBase64(), + ); + + // Verify each one matches + $this->assertSame($keyPair->serviceId, $onionService->id); + + $createdServices[] = $onionService->id; + } + + // Verify all service IDs are unique + $this->assertSame(\count($createdServices), \count(array_unique($createdServices))); + } finally { + // Clean up all created services + foreach ($createdServices as $serviceId) { + try { + $torClient->deleteOnionService($serviceId); + } catch (\Throwable) { + // Ignore cleanup errors + } + } + + $torClient->close(); + } + } +} diff --git a/tests/Helper/ExitNodesHelperTest.php b/tests/Unit/Helper/ExitNodesHelperTest.php similarity index 97% rename from tests/Helper/ExitNodesHelperTest.php rename to tests/Unit/Helper/ExitNodesHelperTest.php index 3c70796..8bfe250 100644 --- a/tests/Helper/ExitNodesHelperTest.php +++ b/tests/Unit/Helper/ExitNodesHelperTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TorPHP\Tests\Helper; +namespace TorPHP\Tests\Unit\Helper; use PHPUnit\Framework\TestCase; use TorPHP\Helper\ExitNodesHelper; diff --git a/tests/Unit/Helper/KeyPairGeneratorTest.php b/tests/Unit/Helper/KeyPairGeneratorTest.php new file mode 100644 index 0000000..3e20cb8 --- /dev/null +++ b/tests/Unit/Helper/KeyPairGeneratorTest.php @@ -0,0 +1,112 @@ +assertNotEmpty($keyPair->serviceId); + $this->assertNotEmpty($keyPair->getPrivateKeyFormatted()); + $this->assertNotEmpty($keyPair->getOnionUrl()); + $this->assertNotEmpty($keyPair->getPrivateKeyRaw()); + $this->assertNotEmpty($keyPair->getPublicKeyRaw()); + $this->assertNotEmpty($keyPair->getPrivateKeyBase64()); + $this->assertNotEmpty($keyPair->getPublicKeyBase64()); + } + + /** + * @covers ::generate + */ + public function testGeneratedPrivateKeyHasCorrectLength(): void + { + $keyPair = KeyPairGenerator::generate(); + + // ED25519 private key should be 64 bytes (32-byte seed + 32-byte public key) + $this->assertSame(64, \strlen($keyPair->getPrivateKeyRaw())); + } + + /** + * @covers ::generate + */ + public function testGeneratedPublicKeyHasCorrectLength(): void + { + $keyPair = KeyPairGenerator::generate(); + + // ED25519 public key should be 32 bytes + $this->assertSame(32, \strlen($keyPair->getPublicKeyRaw())); + } + + /** + * @covers ::generate + */ + public function testServiceIdHasCorrectFormat(): void + { + $keyPair = KeyPairGenerator::generate(); + + // Tor v3 onion addresses are 56 characters (base32 encoded) + $this->assertSame(56, mb_strlen($keyPair->serviceId)); + + // Should be lowercase alphanumeric (base32: a-z, 2-7) + $this->assertMatchesRegularExpression('/^[a-z2-7]{56}$/', $keyPair->serviceId); + } + + /** + * @covers ::generate + */ + public function testServiceIdIsDeterministic(): void + { + // Generate two different keypairs + $keyPair1 = KeyPairGenerator::generate(); + $keyPair2 = KeyPairGenerator::generate(); + + // Different keys should produce different service IDs + $this->assertNotSame($keyPair1->serviceId, $keyPair2->serviceId); + } + + /** + * @covers ::generate + */ + public function testBase64EncodingIsValid(): void + { + $keyPair = KeyPairGenerator::generate(); + + $privateKeyBase64 = $keyPair->getPrivateKeyBase64(); + $publicKeyBase64 = $keyPair->getPublicKeyBase64(); + + // Verify base64 can be decoded back to original + $this->assertSame($keyPair->getPrivateKeyRaw(), base64_decode($privateKeyBase64, strict: true)); + $this->assertSame($keyPair->getPublicKeyRaw(), base64_decode($publicKeyBase64, strict: true)); + } + + /** + * @covers ::generate + */ + public function testFormattedPrivateKeyHasCorrectPrefix(): void + { + $keyPair = KeyPairGenerator::generate(); + + $formatted = $keyPair->getPrivateKeyFormatted(); + + $this->assertStringStartsWith('ED25519-V3:', $formatted); + + // Remove prefix and verify it's valid base64 + $base64Part = substr($formatted, 11); // Length of "ED25519-V3:" + $decoded = base64_decode($base64Part, strict: true); + $this->assertNotFalse($decoded); + $this->assertSame(64, \strlen($decoded)); + } +} diff --git a/tests/Helper/PrivateKeyHelperTest.php b/tests/Unit/Helper/PrivateKeyHelperTest.php similarity index 95% rename from tests/Helper/PrivateKeyHelperTest.php rename to tests/Unit/Helper/PrivateKeyHelperTest.php index 92f06e6..9c87080 100644 --- a/tests/Helper/PrivateKeyHelperTest.php +++ b/tests/Unit/Helper/PrivateKeyHelperTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TorPHP\Tests\Helper; +namespace TorPHP\Tests\Unit\Helper; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; diff --git a/tests/Unit/Model/KeyPairTest.php b/tests/Unit/Model/KeyPairTest.php new file mode 100644 index 0000000..b2cf943 --- /dev/null +++ b/tests/Unit/Model/KeyPairTest.php @@ -0,0 +1,121 @@ +keyPair = \TorPHP\Helper\KeyPairGenerator::generate(); + } + + /** + * @covers ::getPrivateKeyRaw + */ + public function testGetPrivateKeyRaw(): void + { + $raw = $this->keyPair->getPrivateKeyRaw(); + + $this->assertSame(64, \strlen($raw)); + } + + /** + * @covers ::getPrivateKeyBase64 + */ + public function testGetPrivateKeyBase64(): void + { + $base64 = $this->keyPair->getPrivateKeyBase64(); + + // Base64 of 64 bytes should be 88 chars (with potential padding) + $this->assertGreaterThanOrEqual(85, \strlen($base64)); + + // Verify it's valid base64 + $decoded = base64_decode($base64, strict: true); + $this->assertNotFalse($decoded); + $this->assertSame($this->keyPair->getPrivateKeyRaw(), $decoded); + } + + /** + * @covers ::getPrivateKeyFormatted + */ + public function testGetPrivateKeyFormatted(): void + { + $formatted = $this->keyPair->getPrivateKeyFormatted(); + + $this->assertStringStartsWith('ED25519-V3:', $formatted); + + // Verify the part after prefix is valid base64 + $base64Part = substr($formatted, 11); + $decoded = base64_decode($base64Part, strict: true); + $this->assertNotFalse($decoded); + $this->assertSame($this->keyPair->getPrivateKeyRaw(), $decoded); + } + + /** + * @covers ::getPublicKeyRaw + */ + public function testGetPublicKeyRaw(): void + { + $raw = $this->keyPair->getPublicKeyRaw(); + + $this->assertSame(32, \strlen($raw)); + } + + /** + * @covers ::getPublicKeyBase64 + */ + public function testGetPublicKeyBase64(): void + { + $base64 = $this->keyPair->getPublicKeyBase64(); + + // Base64 of 32 bytes should be 44 chars (with potential padding) + $this->assertGreaterThanOrEqual(43, \strlen($base64)); + + // Verify it's valid base64 + $decoded = base64_decode($base64, strict: true); + $this->assertNotFalse($decoded); + $this->assertSame($this->keyPair->getPublicKeyRaw(), $decoded); + } + + /** + * @covers ::getOnionUrl + */ + public function testGetOnionUrl(): void + { + $url = $this->keyPair->getOnionUrl(); + + $this->assertStringStartsWith('http://', $url); + $this->assertStringEndsWith('.onion', $url); + $this->assertStringContainsString($this->keyPair->serviceId, $url); + } + + /** + * @covers ::__debugInfo + */ + public function testDebugInfoHidesSensitiveData(): void + { + $debugInfo = $this->keyPair->__debugInfo(); + + $this->assertArrayHasKey('serviceId', $debugInfo); + $this->assertArrayHasKey('publicKey', $debugInfo); + $this->assertArrayHasKey('privateKey', $debugInfo); + + // Sensitive data should be redacted + $this->assertSame('[REDACTED]', $debugInfo['publicKey']); + $this->assertSame('[REDACTED]', $debugInfo['privateKey']); + + // Service ID is not sensitive + $this->assertSame($this->keyPair->serviceId, $debugInfo['serviceId']); + } +} diff --git a/tests/Model/PortMappingTest.php b/tests/Unit/Model/PortMappingTest.php similarity index 97% rename from tests/Model/PortMappingTest.php rename to tests/Unit/Model/PortMappingTest.php index d3f0535..0dc635e 100644 --- a/tests/Model/PortMappingTest.php +++ b/tests/Unit/Model/PortMappingTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TorPHP\Tests\Model; +namespace TorPHP\Tests\Unit\Model; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase;