initialization
This commit is contained in:
commit
9ff09693ed
92 files changed
+46209
No files matched your search
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Api;
|
||||
|
||||
use Slim\Factory\AppFactory;
|
||||
|
||||
final class OpenApiGenerator
|
||||
{
|
||||
/**
|
||||
* Generate the public OpenAPI document from the registered Slim routes.
|
||||
*/
|
||||
public static function generate(string $baseUrl): array
|
||||
{
|
||||
$baseUrl = rtrim($baseUrl, '/');
|
||||
|
||||
$paths = [];
|
||||
foreach (self::registeredPublicRoutes() as $routePath => $methods) {
|
||||
$normalizedPath = self::normalizeRoutePattern($routePath);
|
||||
$paths[$normalizedPath] = self::buildPathOperations($normalizedPath, $methods);
|
||||
}
|
||||
|
||||
return [
|
||||
'openapi' => '3.0.3',
|
||||
'info' => [
|
||||
'title' => $_ENV['APP_NAME'] ?? 'Password Generator API',
|
||||
'version' => $_ENV['APP_VERSION'] ?? '2.0.0',
|
||||
'description' => 'Public password generation API and locale metadata for the password generator web app.',
|
||||
],
|
||||
'servers' => [
|
||||
['url' => $baseUrl],
|
||||
],
|
||||
'paths' => $paths,
|
||||
'components' => [
|
||||
'schemas' => [
|
||||
'ErrorResponse' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'success' => ['type' => 'boolean', 'example' => false],
|
||||
'error' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'message' => ['type' => 'string', 'example' => 'Request failed'],
|
||||
'code' => ['type' => 'string', 'example' => 'ERROR'],
|
||||
'status' => ['type' => 'integer', 'example' => 400],
|
||||
],
|
||||
],
|
||||
'timestamp' => ['type' => 'string', 'format' => 'date-time'],
|
||||
],
|
||||
'required' => ['success', 'error', 'timestamp'],
|
||||
],
|
||||
'PasswordGenerationRequest' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'type' => ['type' => 'string', 'example' => 'high-entropy'],
|
||||
'count' => ['type' => 'integer', 'example' => 1],
|
||||
'options' => ['type' => 'object', 'additionalProperties' => true],
|
||||
],
|
||||
],
|
||||
'PasswordGenerationSuccess' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'success' => ['type' => 'boolean', 'example' => true],
|
||||
'data' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'passwords' => [
|
||||
'type' => 'array',
|
||||
'items' => ['type' => 'string'],
|
||||
],
|
||||
'entropy_bits' => ['type' => 'number', 'format' => 'float', 'example' => 128],
|
||||
'security_assessment' => [
|
||||
'type' => 'string',
|
||||
'example' => 'Strong — this password has good entropy and is suitable for most uses.',
|
||||
],
|
||||
'raw_technical_details' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'entropy_bits' => ['type' => 'number', 'format' => 'float', 'example' => 77.5],
|
||||
'generator' => ['type' => 'string', 'example' => 'XKCD Pass'],
|
||||
'length' => ['type' => 'integer', 'example' => 28],
|
||||
'security_level' => ['type' => 'string', 'example' => 'Strong'],
|
||||
'entropy_breakdown' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'entropy_model' => ['type' => 'string', 'example' => 'xkcd-candidate-space-v1'],
|
||||
'model_confidence' => ['type' => 'string', 'enum' => ['high', 'medium', 'low']],
|
||||
'entropy_bits' => ['type' => 'number', 'format' => 'float', 'example' => 77.5],
|
||||
],
|
||||
'additionalProperties' => true,
|
||||
],
|
||||
],
|
||||
'additionalProperties' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
'timestamp' => ['type' => 'string', 'format' => 'date-time'],
|
||||
'message' => ['type' => 'string', 'example' => 'Generated 1 password(s) of type \'high-entropy\''],
|
||||
],
|
||||
'required' => ['success', 'data', 'timestamp'],
|
||||
],
|
||||
'PasswordTypesSuccess' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'success' => ['type' => 'boolean', 'example' => true],
|
||||
'data' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'types' => [
|
||||
'type' => 'array',
|
||||
'items' => ['type' => 'string'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'timestamp' => ['type' => 'string', 'format' => 'date-time'],
|
||||
],
|
||||
'required' => ['success', 'data', 'timestamp'],
|
||||
],
|
||||
'WordlistsSuccess' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'success' => ['type' => 'boolean', 'example' => true],
|
||||
'data' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'wordlists' => [
|
||||
'type' => 'object',
|
||||
'additionalProperties' => true,
|
||||
],
|
||||
'count' => ['type' => 'integer', 'example' => 2],
|
||||
],
|
||||
],
|
||||
'timestamp' => ['type' => 'string', 'format' => 'date-time'],
|
||||
'message' => ['type' => 'string', 'example' => 'Available wordlists for XKCD password generation'],
|
||||
],
|
||||
'required' => ['success', 'data', 'timestamp'],
|
||||
],
|
||||
'HealthSuccess' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'status' => ['type' => 'string', 'example' => 'healthy'],
|
||||
'version' => ['type' => 'string', 'example' => '2.0.0'],
|
||||
'timestamp' => ['type' => 'string', 'format' => 'date-time'],
|
||||
],
|
||||
'required' => ['status', 'version', 'timestamp'],
|
||||
],
|
||||
'LocaleSuccess' => [
|
||||
'type' => 'object',
|
||||
'additionalProperties' => true,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function write(string $baseUrl, string $destination): void
|
||||
{
|
||||
$directory = dirname($destination);
|
||||
if (!is_dir($directory)) {
|
||||
mkdir($directory, 0777, true);
|
||||
}
|
||||
|
||||
$json = json_encode(self::generate($baseUrl), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new \RuntimeException('Unable to encode OpenAPI document as JSON.');
|
||||
}
|
||||
|
||||
file_put_contents($destination, $json . PHP_EOL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the public API routes as registered by config/routes.php.
|
||||
*
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
private static function registeredPublicRoutes(): array
|
||||
{
|
||||
AppFactory::setContainer(new \DI\Container());
|
||||
$app = AppFactory::create();
|
||||
|
||||
$routesDefinition = require __DIR__ . '/../../config/routes.php';
|
||||
$routesDefinition($app);
|
||||
|
||||
$routes = [];
|
||||
foreach ($app->getRouteCollector()->getRoutes() as $route) {
|
||||
$pattern = $route->getPattern();
|
||||
|
||||
if (!str_starts_with($pattern, '/api/v1')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$methods = array_map('strtoupper', $route->getMethods());
|
||||
$routes[$pattern] = array_values(array_unique($methods));
|
||||
}
|
||||
|
||||
ksort($routes);
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
private static function normalizeRoutePattern(string $pattern): string
|
||||
{
|
||||
return preg_replace('/\{([a-zA-Z0-9_]+):.*\}/', '{$1}', $pattern) ?? $pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $methods
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildPathOperations(string $routePath, array $methods): array
|
||||
{
|
||||
$operations = [];
|
||||
$metadata = self::routeMetadata($routePath);
|
||||
|
||||
foreach ($methods as $method) {
|
||||
$methodName = strtolower($method);
|
||||
$operations[$methodName] = [
|
||||
'summary' => $metadata['summary'],
|
||||
'description' => $metadata['description'],
|
||||
'responses' => self::responses($routePath, $metadata['successCode'], $metadata['errorCode']),
|
||||
];
|
||||
|
||||
if ($method === 'POST' && $routePath === '/api/v1/passwords/generate') {
|
||||
$operations[$methodName]['requestBody'] = [
|
||||
'required' => true,
|
||||
'content' => [
|
||||
'application/json' => [
|
||||
'schema' => [
|
||||
'$ref' => '#/components/schemas/PasswordGenerationRequest',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if (str_contains($routePath, '{lang}')) {
|
||||
$operations[$methodName]['parameters'] = [[
|
||||
'name' => 'lang',
|
||||
'in' => 'path',
|
||||
'required' => true,
|
||||
'schema' => ['type' => 'string', 'enum' => ['en', 'de', 'de_CH', 'fr', 'it', 'es', 'pt', 'ja', 'zh']],
|
||||
'description' => 'Locale code to fetch translation data for.',
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
||||
return $operations;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{summary:string,description:string,successCode:int,errorCode:int}
|
||||
*/
|
||||
private static function routeMetadata(string $routePath): array
|
||||
{
|
||||
$metadata = [
|
||||
'/api/v1/passwords' => [
|
||||
'summary' => 'List available password generation types',
|
||||
'description' => 'Returns the supported password generation algorithms.',
|
||||
'successCode' => 200,
|
||||
'errorCode' => 500,
|
||||
],
|
||||
'/api/v1/passwords/generate' => [
|
||||
'summary' => 'Generate one or more passwords',
|
||||
'description' => 'Generates passwords using the chosen algorithm and options.',
|
||||
'successCode' => 200,
|
||||
'errorCode' => 400,
|
||||
],
|
||||
'/api/v1/passwords/wordlists' => [
|
||||
'summary' => 'List available XKCD wordlists',
|
||||
'description' => 'Returns available language wordlists for passphrase generation.',
|
||||
'successCode' => 200,
|
||||
'errorCode' => 500,
|
||||
],
|
||||
'/api/v1/health' => [
|
||||
'summary' => 'Health check',
|
||||
'description' => 'Returns the health status of the API service.',
|
||||
'successCode' => 200,
|
||||
'errorCode' => 500,
|
||||
],
|
||||
'/api/v1/locales/{lang}' => [
|
||||
'summary' => 'Fetch locale data',
|
||||
'description' => 'Returns the translation resource for a given locale code.',
|
||||
'successCode' => 200,
|
||||
'errorCode' => 404,
|
||||
],
|
||||
];
|
||||
|
||||
return $metadata[$routePath] ?? [
|
||||
'summary' => 'API route',
|
||||
'description' => 'Generated from the public Slim route registry.',
|
||||
'successCode' => 200,
|
||||
'errorCode' => 500,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private static function responses(string $routePath, int $successCode, int $errorCode): array
|
||||
{
|
||||
return [
|
||||
(string) $successCode => [
|
||||
'description' => 'Successful response',
|
||||
'content' => [
|
||||
'application/json' => [
|
||||
'schema' => self::successSchemaFor($routePath),
|
||||
],
|
||||
],
|
||||
],
|
||||
(string) $errorCode => [
|
||||
'description' => 'Error response',
|
||||
'content' => [
|
||||
'application/json' => [
|
||||
'schema' => [
|
||||
'$ref' => '#/components/schemas/ErrorResponse',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private static function successSchemaFor(string $routePath): array
|
||||
{
|
||||
return match ($routePath) {
|
||||
'/api/v1/passwords' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'success' => ['type' => 'boolean', 'example' => true],
|
||||
'data' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'types' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||||
],
|
||||
],
|
||||
'timestamp' => ['type' => 'string', 'format' => 'date-time'],
|
||||
'message' => ['type' => 'string', 'example' => 'Available password generation types'],
|
||||
],
|
||||
'required' => ['success', 'data', 'timestamp'],
|
||||
],
|
||||
'/api/v1/passwords/generate' => [
|
||||
'$ref' => '#/components/schemas/PasswordGenerationSuccess',
|
||||
],
|
||||
'/api/v1/passwords/wordlists' => [
|
||||
'$ref' => '#/components/schemas/WordlistsSuccess',
|
||||
],
|
||||
'/api/v1/health' => [
|
||||
'$ref' => '#/components/schemas/HealthSuccess',
|
||||
],
|
||||
'/api/v1/locales/{lang}' => [
|
||||
'$ref' => '#/components/schemas/LocaleSuccess',
|
||||
],
|
||||
default => [
|
||||
'type' => 'object',
|
||||
'additionalProperties' => true,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Api;
|
||||
|
||||
use App\Managers\PasswordGeneratorManager;
|
||||
use App\Utils\ConfigLoader;
|
||||
use App\Utils\ResponseHelper;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class PasswordController
|
||||
{
|
||||
public function __construct(
|
||||
private PasswordGeneratorManager $generatorManager,
|
||||
private ResponseHelper $responseHelper,
|
||||
private LoggerInterface $logger
|
||||
) {}
|
||||
|
||||
public function getPasswordTypes(Request $request, Response $response): Response
|
||||
{
|
||||
try {
|
||||
$types = $this->generatorManager->getAvailableTypes();
|
||||
|
||||
$this->logger->info('Password types requested', ['count' => count($types)]);
|
||||
|
||||
return $this->responseHelper->successResponse(
|
||||
$response,
|
||||
['types' => $types],
|
||||
'Available password generation types'
|
||||
);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching password types', ['error' => $e->getMessage()]);
|
||||
|
||||
return $this->responseHelper->errorResponse(
|
||||
$response,
|
||||
'Failed to fetch password types',
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function getGenerators(Request $request, Response $response): Response
|
||||
{
|
||||
try {
|
||||
$definitions = array_values($this->generatorManager->getGeneratorDefinitions());
|
||||
$appConfig = ConfigLoader::load();
|
||||
$rouletteIntervalSeconds = (int)($appConfig['generator']['roulette_interval_seconds'] ?? 3);
|
||||
$rouletteIntervalSeconds = max(1, min(30, $rouletteIntervalSeconds));
|
||||
|
||||
$this->logger->info('Generator definitions requested', ['count' => count($definitions)]);
|
||||
|
||||
return $this->responseHelper->successResponse(
|
||||
$response,
|
||||
[
|
||||
'generators' => $definitions,
|
||||
'settings' => [
|
||||
'roulette_interval_seconds' => $rouletteIntervalSeconds,
|
||||
],
|
||||
],
|
||||
'Available password generators'
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching generator definitions', ['error' => $e->getMessage()]);
|
||||
|
||||
return $this->responseHelper->errorResponse(
|
||||
$response,
|
||||
'Failed to fetch generator definitions',
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NEW: Get available wordlists for XKCD generator
|
||||
*/
|
||||
public function getAvailableWordlists(Request $request, Response $response): Response
|
||||
{
|
||||
try {
|
||||
$xkcdGenerator = $this->generatorManager->getGenerator('xkcdpass');
|
||||
$availableLanguages = $xkcdGenerator->getAvailableLanguages();
|
||||
|
||||
// Filter to only show available wordlists
|
||||
$availableWordlists = array_filter($availableLanguages, function($lang) {
|
||||
return $lang['available'];
|
||||
});
|
||||
|
||||
// If no wordlists are available, provide default language metadata
|
||||
if (empty($availableWordlists)) {
|
||||
$this->logger->warning('No wordlist files found, using default language metadata');
|
||||
|
||||
// Provide basic language info even without files
|
||||
$defaultLanguages = [
|
||||
'en' => [
|
||||
'name' => 'English (Default)',
|
||||
'description' => 'Built-in English word list',
|
||||
'file' => 'eff_large_wordlist_en.txt',
|
||||
'available' => false,
|
||||
'fallback' => true
|
||||
],
|
||||
'de' => [
|
||||
'name' => 'German (Default)',
|
||||
'description' => 'Built-in German word list',
|
||||
'file' => 'german_common_words_de.txt',
|
||||
'available' => false,
|
||||
'fallback' => true
|
||||
]
|
||||
];
|
||||
|
||||
$availableWordlists = $defaultLanguages;
|
||||
}
|
||||
|
||||
$this->logger->info('Wordlists requested', [
|
||||
'available_count' => count($availableWordlists),
|
||||
'languages' => array_keys($availableWordlists)
|
||||
]);
|
||||
|
||||
return $this->responseHelper->successResponse(
|
||||
$response,
|
||||
[
|
||||
'wordlists' => $availableWordlists,
|
||||
'count' => count($availableWordlists)
|
||||
],
|
||||
'Available wordlists for XKCD password generation'
|
||||
);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error fetching wordlists', ['error' => $e->getMessage()]);
|
||||
|
||||
return $this->responseHelper->errorResponse(
|
||||
$response,
|
||||
'Failed to fetch available wordlists',
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function generatePasswords(Request $request, Response $response): Response
|
||||
{
|
||||
try {
|
||||
$data = $request->getParsedBody();
|
||||
|
||||
$type = $data['type'] ?? 'high-entropy';
|
||||
$count = max(1, min(50, (int)($data['count'] ?? 1)));
|
||||
$options = $data['options'] ?? [];
|
||||
|
||||
// Validate required parameters
|
||||
if (empty($type)) {
|
||||
return $this->responseHelper->errorResponse(
|
||||
$response,
|
||||
'Password type is required',
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
$result = $this->generatorManager->generate($type, $count, $options);
|
||||
|
||||
$this->logger->info('Passwords generated', [
|
||||
'type' => $type,
|
||||
'count' => $count,
|
||||
'entropy' => $result['entropy_bits'],
|
||||
'ip' => $this->getClientIp($request)
|
||||
]);
|
||||
|
||||
return $this->responseHelper->successResponse(
|
||||
$response,
|
||||
$result,
|
||||
"Generated {$count} password(s) of type '{$type}'"
|
||||
);
|
||||
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->logger->warning('Invalid password generation request', [
|
||||
'error' => $e->getMessage(),
|
||||
'ip' => $this->getClientIp($request)
|
||||
]);
|
||||
|
||||
return $this->responseHelper->errorResponse(
|
||||
$response,
|
||||
$e->getMessage(),
|
||||
400
|
||||
);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Error generating passwords', [
|
||||
'error' => $e->getMessage(),
|
||||
'ip' => $this->getClientIp($request)
|
||||
]);
|
||||
|
||||
return $this->responseHelper->errorResponse(
|
||||
$response,
|
||||
'Failed to generate passwords',
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function getClientIp(Request $request): string
|
||||
{
|
||||
$serverParams = $request->getServerParams();
|
||||
|
||||
if (!empty($serverParams['HTTP_CLIENT_IP'])) {
|
||||
return $serverParams['HTTP_CLIENT_IP'];
|
||||
} elseif (!empty($serverParams['HTTP_X_FORWARDED_FOR'])) {
|
||||
return explode(',', $serverParams['HTTP_X_FORWARDED_FOR'])[0];
|
||||
} elseif (!empty($serverParams['REMOTE_ADDR'])) {
|
||||
return $serverParams['REMOTE_ADDR'];
|
||||
}
|
||||
|
||||
return '127.0.0.1';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Generators;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use App\Utils\ConfigLoader;
|
||||
use App\Services\Logging\LoggingHelper;
|
||||
use PHPUnit\TextUI\XmlConfiguration\Logging\Logging;
|
||||
|
||||
/**
|
||||
* Abstract base class for password generators
|
||||
* Implements common functionality including i18n key derivation and validation
|
||||
*
|
||||
* @package App\Generators
|
||||
*/
|
||||
abstract class AbstractPasswordGenerator implements PasswordGeneratorInterface
|
||||
{
|
||||
protected ?LoggerInterface $logger = null;
|
||||
protected array $config = [];
|
||||
|
||||
public function __construct(?LoggerInterface $logger = null)
|
||||
{
|
||||
$this->logger = $logger;
|
||||
$this->loadConfiguration();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from config/config.json.
|
||||
*/
|
||||
protected function loadConfiguration(): void
|
||||
{
|
||||
try {
|
||||
$this->config = ConfigLoader::load();
|
||||
LoggingHelper::debug($this->logger, $this->getId(), 'Configuration loaded');
|
||||
} catch (\Exception $e) {
|
||||
LoggingHelper::warning(
|
||||
$this->logger,
|
||||
$this->getId(),
|
||||
'Failed to load config',
|
||||
['error' => $e->getMessage()]
|
||||
);
|
||||
$this->config = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value using dot notation with safe nested access
|
||||
*
|
||||
* Provides a convenient way to access nested configuration arrays using
|
||||
* dot notation (e.g., 'llm.providers.openai.display_name'). Returns a
|
||||
* default value if the path doesn't exist, preventing errors from missing keys.
|
||||
*
|
||||
* This is particularly useful for:
|
||||
* - LLM provider configuration: $this->getConfigValue('llm.providers.openai')
|
||||
* - Locale settings: $this->getConfigValue('locale.default', 'en')
|
||||
* - Logging levels: $this->getConfigValue('logging.level', 'info')
|
||||
* - Any nested configuration structure
|
||||
*
|
||||
* @param string $path Configuration path using dot notation (e.g., 'key1.key2.key3')
|
||||
* @param mixed $default Fallback value if path doesn't exist (default: null)
|
||||
* @return mixed The configuration value at the path, or $default if not found
|
||||
*
|
||||
* @throws RuntimeException If $this->config is not loaded or initialized
|
||||
*
|
||||
* @example
|
||||
* // Get a configuration value with fallback
|
||||
* $lang = $this->getConfigValue('locale.default', 'en');
|
||||
*
|
||||
* // Get nested LLM configuration
|
||||
* $provider = $this->getConfigValue('llm.providers.openai', null);
|
||||
*
|
||||
* // Get logging level (defaults to 'info' if not set)
|
||||
* $level = $this->getConfigValue('logging.level', 'info');
|
||||
*
|
||||
* // Get a boolean value safely
|
||||
* $debugMode = $this->getConfigValue('app.debug', false);
|
||||
*
|
||||
* // Navigate deep into configuration
|
||||
* $displayName = $this->getConfigValue('llm.providers.openai.display_name', 'OpenAI');
|
||||
*/
|
||||
protected function getConfigValue(string $path, mixed $default = null): mixed
|
||||
{
|
||||
// Guard: Ensure config is loaded
|
||||
if ($this->config === null) {
|
||||
if ($this->logger) {
|
||||
$this->logger->warning(
|
||||
$this->getId() . ': getConfigValue called but config not loaded',
|
||||
['requested_path' => $path]
|
||||
);
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
|
||||
// Handle empty path
|
||||
if (empty($path)) {
|
||||
return $this->config;
|
||||
}
|
||||
|
||||
// Split path by dots to get individual keys
|
||||
$keys = explode('.', $path);
|
||||
|
||||
// Start with the full config array
|
||||
$current = $this->config;
|
||||
|
||||
// Traverse through each key in the path
|
||||
foreach ($keys as $key) {
|
||||
// Validate key is not empty
|
||||
if (empty($key)) {
|
||||
LoggingHelper::warning(
|
||||
$this->logger,
|
||||
$this->getId(),
|
||||
'getConfigValue received empty key in path',
|
||||
['path' => $path]
|
||||
);
|
||||
return $default;
|
||||
}
|
||||
|
||||
// Check if current value is an array and has the key
|
||||
if (is_array($current)) {
|
||||
if (isset($current[$key])) {
|
||||
// Move to the next level
|
||||
$current = $current[$key];
|
||||
} else {
|
||||
// Key not found in this level
|
||||
LoggingHelper::debug(
|
||||
$this->logger,
|
||||
$this->getId(),
|
||||
'getConfigValue key not found in path',
|
||||
['key' => $key, 'path' => $path]
|
||||
);
|
||||
return $default;
|
||||
}
|
||||
} else {
|
||||
// Current value is not an array, can't navigate further
|
||||
LoggingHelper::debug(
|
||||
$this->logger,
|
||||
$this->getId(),
|
||||
'getConfigValue cannot navigate non-array value',
|
||||
['key' => $key, 'path' => $path]
|
||||
);
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
// Successfully retrieved the value at the path
|
||||
LoggingHelper::debug(
|
||||
$this->logger,
|
||||
$this->getId(),
|
||||
'getConfigValue retrieved value',
|
||||
['path' => $path, 'type' => gettype($current)]
|
||||
);
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get generator name i18n key (programmatically derived)
|
||||
*/
|
||||
public function getNameKey(): string
|
||||
{
|
||||
return $this->getId() . '_name';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get generator description i18n key (programmatically derived)
|
||||
*/
|
||||
public function getDescriptionKey(): string
|
||||
{
|
||||
return $this->getId() . '_description';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get i18n key for option label (programmatically derived)
|
||||
*/
|
||||
public function getOptionLabelKey(string $optionName): string
|
||||
{
|
||||
return $this->getId() . '_option_' . $optionName . '_label';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get i18n key for option description (programmatically derived)
|
||||
*/
|
||||
public function getOptionDescriptionKey(string $optionName): string
|
||||
{
|
||||
return $this->getId() . '_option_' . $optionName . '_description';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get i18n key for option group title (programmatically derived)
|
||||
*/
|
||||
public function getOptionGroupTitleKey(string $groupName): string
|
||||
{
|
||||
return $this->getId() . '_options_group_' . $groupName . '_title';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get i18n key for select option choice (programmatically derived)
|
||||
*/
|
||||
public function getOptionChoiceLabelKey(string $optionName, string $choiceValue): string
|
||||
{
|
||||
return $this->getId() . '_option_' . $optionName . '_choice_' . $choiceValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get i18n key for validation error (programmatically derived)
|
||||
*/
|
||||
public function getValidationErrorKey(string $optionName): string
|
||||
{
|
||||
return $this->getId() . '_option_' . $optionName . '_validation_error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all i18n keys used by this generator
|
||||
*
|
||||
* Automatically collects all keys from generator metadata and options
|
||||
*/
|
||||
public function getAllI18nKeys(): array
|
||||
{
|
||||
$keys = [];
|
||||
|
||||
// Generator metadata keys
|
||||
$keys[] = $this->getNameKey();
|
||||
$keys[] = $this->getDescriptionKey();
|
||||
|
||||
$optionDefs = $this->getOptionDefinitions();
|
||||
$groups = [];
|
||||
|
||||
foreach ($optionDefs as $optionName => $definition) {
|
||||
// Option label and description keys
|
||||
$keys[] = $this->getOptionLabelKey($optionName);
|
||||
$keys[] = $this->getOptionDescriptionKey($optionName);
|
||||
$keys[] = $this->getValidationErrorKey($optionName);
|
||||
|
||||
// Group title key (collect unique groups)
|
||||
$group = $definition['group'] ?? 'basic';
|
||||
if (!in_array($group, $groups)) {
|
||||
$groups[] = $group;
|
||||
$keys[] = $this->getOptionGroupTitleKey($group);
|
||||
}
|
||||
|
||||
// Select option choice keys (if translateChoices is true)
|
||||
if ($definition['type'] === 'select' && ($definition['translateChoices'] ?? true)) {
|
||||
$choices = $definition['choices'] ?? [];
|
||||
foreach ($choices as $choice) {
|
||||
$value = is_array($choice) ? $choice['value'] : $choice;
|
||||
$keys[] = $this->getOptionChoiceLabelKey($optionName, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default options from definitions
|
||||
*/
|
||||
public function getDefaultOptions(): array
|
||||
{
|
||||
$defaults = [];
|
||||
foreach ($this->getOptionDefinitions() as $name => $definition) {
|
||||
$defaults[$name] = $definition['default'] ?? null;
|
||||
}
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complete option schema with derived i18n keys
|
||||
*/
|
||||
public function getOptionSchema(): array
|
||||
{
|
||||
$schema = [];
|
||||
$optionDefs = $this->getOptionDefinitions();
|
||||
|
||||
foreach ($optionDefs as $name => $definition) {
|
||||
$option = [
|
||||
'name' => $name,
|
||||
'type' => $definition['type'],
|
||||
'labelKey' => $this->getOptionLabelKey($name),
|
||||
'descriptionKey' => $this->getOptionDescriptionKey($name),
|
||||
'default' => $definition['default'] ?? null,
|
||||
'required' => $definition['required'] ?? false,
|
||||
'group' => $definition['group'] ?? 'basic',
|
||||
'groupTitleKey' => $this->getOptionGroupTitleKey($definition['group'] ?? 'basic'),
|
||||
'validationErrorKey' => $this->getValidationErrorKey($name)
|
||||
];
|
||||
|
||||
// Type-specific properties
|
||||
switch ($definition['type']) {
|
||||
case 'integer':
|
||||
if (isset($definition['min'])) $option['min'] = $definition['min'];
|
||||
if (isset($definition['max'])) $option['max'] = $definition['max'];
|
||||
break;
|
||||
|
||||
case 'string':
|
||||
if (isset($definition['minLength'])) $option['minLength'] = $definition['minLength'];
|
||||
if (isset($definition['maxLength'])) $option['maxLength'] = $definition['maxLength'];
|
||||
if (isset($definition['pattern'])) $option['pattern'] = $definition['pattern'];
|
||||
break;
|
||||
|
||||
case 'select':
|
||||
$translateChoices = $definition['translateChoices'] ?? true;
|
||||
$option['translateChoices'] = $translateChoices;
|
||||
|
||||
$formattedChoices = [];
|
||||
foreach ($definition['choices'] ?? [] as $choice) {
|
||||
if (is_array($choice)) {
|
||||
$value = $choice['value'];
|
||||
$label = $choice['label'];
|
||||
} else {
|
||||
$value = $choice;
|
||||
$label = $choice;
|
||||
}
|
||||
|
||||
$formattedChoices[] = [
|
||||
'value' => $value,
|
||||
'label' => $label,
|
||||
'labelKey' => $translateChoices
|
||||
? $this->getOptionChoiceLabelKey($name, $value)
|
||||
: null
|
||||
];
|
||||
}
|
||||
$option['choices'] = $formattedChoices;
|
||||
break;
|
||||
}
|
||||
|
||||
$schema[] = $option;
|
||||
}
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate options against definitions
|
||||
*/
|
||||
public function validateOptions(array $options): array
|
||||
{
|
||||
$definitions = $this->getOptionDefinitions();
|
||||
$validated = $this->getDefaultOptions();
|
||||
|
||||
foreach ($definitions as $name => $definition) {
|
||||
// Skip if option not provided and not required
|
||||
if (!isset($options[$name])) {
|
||||
if ($definition['required'] ?? false) {
|
||||
throw new InvalidArgumentException(
|
||||
"Required option '{$name}' is missing"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $options[$name];
|
||||
|
||||
// Type-specific validation
|
||||
switch ($definition['type']) {
|
||||
case 'integer':
|
||||
if (!is_int($value) && !is_numeric($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Option '{$name}' must be an integer"
|
||||
);
|
||||
}
|
||||
$value = (int)$value;
|
||||
|
||||
if (isset($definition['min']) && $value < $definition['min']) {
|
||||
throw new InvalidArgumentException(
|
||||
"Option '{$name}' must be at least {$definition['min']}"
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($definition['max']) && $value > $definition['max']) {
|
||||
throw new InvalidArgumentException(
|
||||
"Option '{$name}' must be at most {$definition['max']}"
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
if (!is_bool($value) && !in_array($value, [0, 1, '0', '1'], true)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Option '{$name}' must be a boolean"
|
||||
);
|
||||
}
|
||||
$value = (bool)$value;
|
||||
break;
|
||||
|
||||
case 'string':
|
||||
if (!is_string($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Option '{$name}' must be a string"
|
||||
);
|
||||
}
|
||||
|
||||
$length = strlen($value);
|
||||
if (isset($definition['minLength']) && $length < $definition['minLength']) {
|
||||
throw new InvalidArgumentException(
|
||||
"Option '{$name}' must be at least {$definition['minLength']} characters"
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($definition['maxLength']) && $length > $definition['maxLength']) {
|
||||
throw new InvalidArgumentException(
|
||||
"Option '{$name}' must be at most {$definition['maxLength']} characters"
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($definition['pattern']) && !preg_match($definition['pattern'], $value)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Option '{$name}' does not match required pattern"
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'select':
|
||||
if (!is_string($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Option '{$name}' must be a string"
|
||||
);
|
||||
}
|
||||
|
||||
$validValues = array_map(function ($choice) {
|
||||
return is_array($choice) ? $choice['value'] : $choice;
|
||||
}, $definition['choices'] ?? []);
|
||||
|
||||
if (!in_array($value, $validValues, true)) {
|
||||
throw new InvalidArgumentException(
|
||||
"Option '{$name}' must be one of: " . implode(', ', $validValues)
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$validated[$name] = $value;
|
||||
}
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse wordlist entry that may contain word and explanation separated by tab
|
||||
* Returns array with 'word' and 'explanation' keys
|
||||
*/
|
||||
protected function parseWordlistEntry(string $entry): array
|
||||
{
|
||||
$parts = explode("\t", $entry, 2);
|
||||
return [
|
||||
'word' => trim($parts[0]),
|
||||
'explanation' => isset($parts[1]) ? trim($parts[1]) : null
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format multiple word explanations for display
|
||||
*/
|
||||
protected function formatExplanations(array $wordExplanations): ?string
|
||||
{
|
||||
$explanations = array_filter(
|
||||
array_column($wordExplanations, 'explanation')
|
||||
);
|
||||
|
||||
if (empty($explanations)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode(' — ', $explanations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* PHP fallback implementation for pwgen logic (pronounceable passwords).
|
||||
*
|
||||
* Based on NIST FIPS 181 "Automated Password Generator" standard
|
||||
* and inspired by roderik/pwgen-php (https://github.com/roderik/pwgen-php)
|
||||
*
|
||||
* Original pwgen concept:
|
||||
* Copyright (c) 2001 Theodore Ts'o <tytso@alum.mit.edu>
|
||||
*
|
||||
* PHP implementation credits:
|
||||
* Copyright (c) 2012 Roderik van der Veer
|
||||
* BSD License (see https://github.com/roderik/pwgen-php/blob/master/LICENSE)
|
||||
* Original source: https://github.com/roderik/pwgen-php
|
||||
*
|
||||
* Modernized for PHP 8+ by Password Generator Project, 2025.
|
||||
* This class provides a fallback for pwgen-style password generation when
|
||||
* the native pwgen executable is not available.
|
||||
*
|
||||
* Based on NIST FIPS 181 - Federal Information Processing Standards Publication 181
|
||||
* "Automated Password Generator" (1993)
|
||||
*/
|
||||
class PwGenPhpNg
|
||||
{
|
||||
// Character classification flags (based on NIST FIPS 181)
|
||||
private const VOWEL = 0x01;
|
||||
private const CONSONANT = 0x02;
|
||||
private const DIPHTHONG = 0x04;
|
||||
private const NOT_FIRST = 0x08;
|
||||
|
||||
// Unit definitions with pronunciability rules
|
||||
private array $elements = [
|
||||
'a' => ['flags' => self::VOWEL, 'weight' => 4],
|
||||
'ae' => ['flags' => self::VOWEL | self::DIPHTHONG, 'weight' => 1],
|
||||
'ah' => ['flags' => self::VOWEL | self::DIPHTHONG, 'weight' => 1],
|
||||
'ai' => ['flags' => self::VOWEL | self::DIPHTHONG, 'weight' => 1],
|
||||
'b' => ['flags' => self::CONSONANT, 'weight' => 2],
|
||||
'c' => ['flags' => self::CONSONANT, 'weight' => 2],
|
||||
'ch' => ['flags' => self::CONSONANT | self::DIPHTHONG, 'weight' => 1],
|
||||
'd' => ['flags' => self::CONSONANT, 'weight' => 2],
|
||||
'e' => ['flags' => self::VOWEL, 'weight' => 4],
|
||||
'ee' => ['flags' => self::VOWEL | self::DIPHTHONG, 'weight' => 1],
|
||||
'ei' => ['flags' => self::VOWEL | self::DIPHTHONG, 'weight' => 1],
|
||||
'f' => ['flags' => self::CONSONANT, 'weight' => 2],
|
||||
'g' => ['flags' => self::CONSONANT, 'weight' => 2],
|
||||
'gh' => ['flags' => self::CONSONANT | self::DIPHTHONG | self::NOT_FIRST, 'weight' => 1],
|
||||
'h' => ['flags' => self::CONSONANT, 'weight' => 2],
|
||||
'i' => ['flags' => self::VOWEL, 'weight' => 4],
|
||||
'ie' => ['flags' => self::VOWEL | self::DIPHTHONG, 'weight' => 1],
|
||||
'j' => ['flags' => self::CONSONANT, 'weight' => 1],
|
||||
'k' => ['flags' => self::CONSONANT, 'weight' => 2],
|
||||
'l' => ['flags' => self::CONSONANT, 'weight' => 2],
|
||||
'm' => ['flags' => self::CONSONANT, 'weight' => 2],
|
||||
'n' => ['flags' => self::CONSONANT, 'weight' => 2],
|
||||
'ng' => ['flags' => self::CONSONANT | self::DIPHTHONG | self::NOT_FIRST, 'weight' => 1],
|
||||
'o' => ['flags' => self::VOWEL, 'weight' => 4],
|
||||
'oh' => ['flags' => self::VOWEL | self::DIPHTHONG, 'weight' => 1],
|
||||
'oo' => ['flags' => self::VOWEL | self::DIPHTHONG, 'weight' => 1],
|
||||
'p' => ['flags' => self::CONSONANT, 'weight' => 2],
|
||||
'ph' => ['flags' => self::CONSONANT | self::DIPHTHONG, 'weight' => 1],
|
||||
'qu' => ['flags' => self::CONSONANT | self::DIPHTHONG, 'weight' => 1],
|
||||
'r' => ['flags' => self::CONSONANT, 'weight' => 2],
|
||||
'rh' => ['flags' => self::CONSONANT | self::DIPHTHONG, 'weight' => 1],
|
||||
's' => ['flags' => self::CONSONANT, 'weight' => 2],
|
||||
'sh' => ['flags' => self::CONSONANT | self::DIPHTHONG, 'weight' => 1],
|
||||
't' => ['flags' => self::CONSONANT, 'weight' => 2],
|
||||
'th' => ['flags' => self::CONSONANT | self::DIPHTHONG, 'weight' => 1],
|
||||
'u' => ['flags' => self::VOWEL, 'weight' => 4],
|
||||
'v' => ['flags' => self::CONSONANT, 'weight' => 1],
|
||||
'w' => ['flags' => self::CONSONANT, 'weight' => 2],
|
||||
'wh' => ['flags' => self::CONSONANT | self::DIPHTHONG, 'weight' => 1],
|
||||
'x' => ['flags' => self::CONSONANT | self::NOT_FIRST, 'weight' => 1],
|
||||
'y' => ['flags' => self::VOWEL, 'weight' => 2],
|
||||
'z' => ['flags' => self::CONSONANT, 'weight' => 1],
|
||||
];
|
||||
|
||||
private array $vowels = [];
|
||||
private array $consonants = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->initializePhonemes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize phoneme arrays based on element classification
|
||||
*/
|
||||
private function initializePhonemes(): void
|
||||
{
|
||||
foreach ($this->elements as $phoneme => $properties) {
|
||||
if ($properties['flags'] & self::VOWEL) {
|
||||
for ($i = 0; $i < $properties['weight']; $i++) {
|
||||
$this->vowels[] = $phoneme;
|
||||
}
|
||||
}
|
||||
if ($properties['flags'] & self::CONSONANT) {
|
||||
for ($i = 0; $i < $properties['weight']; $i++) {
|
||||
$this->consonants[] = $phoneme;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate pronounceable passwords using pwgen logic
|
||||
*
|
||||
* @param int $length Minimum length of password
|
||||
* @param int $count Number of passwords to generate
|
||||
* @param bool $includeNumbers Include numbers in passwords
|
||||
* @param bool $includeCapitals Include capital letters
|
||||
* @return array Array of generated passwords
|
||||
*/
|
||||
public function generate(int $length = 8, int $count = 1, bool $includeNumbers = true, bool $includeCapitals = true): array
|
||||
{
|
||||
if ($length < 4) {
|
||||
$length = 4; // Minimum for pronounceable passwords
|
||||
}
|
||||
|
||||
$passwords = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$password = $this->generatePassword($length, $includeNumbers, $includeCapitals);
|
||||
$passwords[] = $password;
|
||||
}
|
||||
|
||||
return $passwords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single pronounceable password
|
||||
*/
|
||||
private function generatePassword(int $length, bool $includeNumbers, bool $includeCapitals): string
|
||||
{
|
||||
$password = '';
|
||||
$currentLength = 0;
|
||||
$lastWasVowel = false;
|
||||
$syllableLength = 0;
|
||||
$maxAttempts = 100;
|
||||
$attempts = 0;
|
||||
|
||||
while ($currentLength < $length && $attempts < $maxAttempts) {
|
||||
$attempts++;
|
||||
|
||||
// Start new syllable with consonant, then alternate
|
||||
if ($syllableLength === 0 || ($syllableLength > 0 && $lastWasVowel)) {
|
||||
// Choose consonant
|
||||
$phoneme = $this->selectConsonant($currentLength === 0);
|
||||
$lastWasVowel = false;
|
||||
} else {
|
||||
// Choose vowel
|
||||
$phoneme = $this->selectVowel();
|
||||
$lastWasVowel = true;
|
||||
}
|
||||
|
||||
// Check if adding this phoneme exceeds length
|
||||
if ($currentLength + strlen($phoneme) > $length) {
|
||||
// Try to find a shorter phoneme or end the password
|
||||
if ($lastWasVowel) {
|
||||
$phoneme = $this->selectVowel(1); // Single character vowel
|
||||
} else {
|
||||
$phoneme = $this->selectConsonant(false, 1); // Single character consonant
|
||||
}
|
||||
|
||||
if ($currentLength + strlen($phoneme) > $length) {
|
||||
break; // Can't fit anything more
|
||||
}
|
||||
}
|
||||
|
||||
$password .= $phoneme;
|
||||
$currentLength += strlen($phoneme);
|
||||
$syllableLength++;
|
||||
|
||||
// End syllable after 2-4 phonemes
|
||||
if ($syllableLength >= 2 && random_int(0, 2) === 0) {
|
||||
$syllableLength = 0;
|
||||
$lastWasVowel = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply post-processing
|
||||
if ($includeCapitals) {
|
||||
$password = $this->addCapitalization($password);
|
||||
}
|
||||
|
||||
if ($includeNumbers && strlen($password) < $length) {
|
||||
$password = $this->addNumbers($password, $length);
|
||||
}
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a consonant phoneme
|
||||
*/
|
||||
private function selectConsonant(bool $isFirst = false, int $maxLength = PHP_INT_MAX): string
|
||||
{
|
||||
$candidates = [];
|
||||
|
||||
foreach ($this->consonants as $consonant) {
|
||||
$element = $this->elements[$consonant];
|
||||
|
||||
// Skip if not allowed at beginning
|
||||
if ($isFirst && ($element['flags'] & self::NOT_FIRST)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if too long
|
||||
if (strlen($consonant) > $maxLength) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidates[] = $consonant;
|
||||
}
|
||||
|
||||
if (empty($candidates)) {
|
||||
return 'b'; // Fallback
|
||||
}
|
||||
|
||||
return $candidates[random_int(0, count($candidates) - 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a vowel phoneme
|
||||
*/
|
||||
private function selectVowel(int $maxLength = PHP_INT_MAX): string
|
||||
{
|
||||
$candidates = [];
|
||||
|
||||
foreach ($this->vowels as $vowel) {
|
||||
if (strlen($vowel) <= $maxLength) {
|
||||
$candidates[] = $vowel;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($candidates)) {
|
||||
return 'a'; // Fallback
|
||||
}
|
||||
|
||||
return $candidates[random_int(0, count($candidates) - 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add capitalization to password (typically first letter)
|
||||
*/
|
||||
private function addCapitalization(string $password): string
|
||||
{
|
||||
if (strlen($password) === 0) {
|
||||
return $password;
|
||||
}
|
||||
|
||||
// Capitalize first letter
|
||||
return ucfirst(strtolower($password));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add numbers to password if there's remaining length
|
||||
*/
|
||||
private function addNumbers(string $password, int $targetLength): string
|
||||
{
|
||||
$remaining = $targetLength - strlen($password);
|
||||
|
||||
if ($remaining <= 0) {
|
||||
return $password;
|
||||
}
|
||||
|
||||
// Add 1-2 numbers
|
||||
$numbersToAdd = min($remaining, random_int(1, 2));
|
||||
|
||||
for ($i = 0; $i < $numbersToAdd; $i++) {
|
||||
$password .= (string)random_int(0, 9);
|
||||
}
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a password meets basic pronounceability rules
|
||||
*/
|
||||
public function isPronounceablePassword(string $password): bool
|
||||
{
|
||||
if (strlen($password) < 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$vowelCount = 0;
|
||||
$consonantCount = 0;
|
||||
|
||||
// Simple heuristic: check for reasonable vowel/consonant distribution
|
||||
for ($i = 0; $i < strlen($password); $i++) {
|
||||
$char = strtolower($password[$i]);
|
||||
if (in_array($char, ['a', 'e', 'i', 'o', 'u', 'y'])) {
|
||||
$vowelCount++;
|
||||
} elseif (ctype_alpha($char)) {
|
||||
$consonantCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Must have at least one vowel and one consonant
|
||||
// And vowels shouldn't be more than 60% of letters
|
||||
$totalLetters = $vowelCount + $consonantCount;
|
||||
return $vowelCount >= 1 && $consonantCount >= 1 &&
|
||||
($totalLetters === 0 || $vowelCount / $totalLetters <= 0.6);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available phoneme elements (for debugging/inspection)
|
||||
*/
|
||||
public function getPhonemeCount(): array
|
||||
{
|
||||
return [
|
||||
'vowels' => count(array_unique($this->vowels)),
|
||||
'consonants' => count(array_unique($this->consonants)),
|
||||
'total_elements' => count($this->elements)
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Generators;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use App\Services\Logging\LoggingHelper;
|
||||
|
||||
/**
|
||||
* Authentic Diceware Password Generator
|
||||
*
|
||||
* Implements the true EFF Diceware method using cryptographically
|
||||
* secure dice roll simulation. Focused on maximum security and
|
||||
* authentic Diceware methodology with educational features.
|
||||
*
|
||||
*/
|
||||
class DicewareGenerator extends AbstractPasswordGenerator
|
||||
{
|
||||
private const GENERATOR_ID = 'diceware';
|
||||
private const DICEWARE_KEYSPACE = 7776; // 6^5
|
||||
|
||||
private array $wordlist = [];
|
||||
private array $lastDiceRolls = []; // Store for display
|
||||
private int $effectiveWordlistSize = 0;
|
||||
|
||||
public function __construct(?LoggerInterface $logger = null)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->loadWordlist();
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return self::GENERATOR_ID;
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return '2.0.0';
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Diceware';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Authentic EFF Diceware method using cryptographically secure dice rolls for maximum security';
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized option definitions
|
||||
*/
|
||||
public function getOptionDefinitions(): array
|
||||
{
|
||||
$options = [
|
||||
'words' => [
|
||||
'type' => 'integer',
|
||||
'default' => 6,
|
||||
'min' => 4,
|
||||
'max' => 12,
|
||||
'required' => false,
|
||||
'group' => 'basic',
|
||||
'description' => 'Number of words to include in the passphrase (4-12 recommended for security).'
|
||||
],
|
||||
'show_dice_rolls' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'required' => false,
|
||||
'group' => 'advanced',
|
||||
'description' => 'Whether to display the dice rolls used to select each word (educational purpose).'
|
||||
],
|
||||
'uppercase_first' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'required' => false,
|
||||
'group' => 'advanced',
|
||||
'description' => 'Capitalize the first word only (traditional Diceware style).'
|
||||
]
|
||||
];
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public function generate(int $count = 1, array $options = []): array
|
||||
{
|
||||
$options = array_merge($this->getDefaultOptions(), $options);
|
||||
$results = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$result = $this->generateSingle($options);
|
||||
$results[] = $result;
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function generateSingle(array $options): array
|
||||
{
|
||||
$wordCount = $options['words'];
|
||||
$this->lastDiceRolls = [];
|
||||
|
||||
$selectedWords = [];
|
||||
$diceRollsDisplay = [];
|
||||
|
||||
for ($i = 0; $i < $wordCount; $i++) {
|
||||
// True Diceware: 5 dice rolls per word
|
||||
$diceRoll = $this->rollSecureDice(5);
|
||||
$index = implode('', $diceRoll);
|
||||
|
||||
$this->lastDiceRolls[] = $diceRoll;
|
||||
$diceRollsDisplay[] = $index;
|
||||
|
||||
if (isset($this->wordlist[$index])) {
|
||||
$wordEntry = $this->wordlist[$index];
|
||||
if (is_array($wordEntry)) {
|
||||
$selectedWords[] = $wordEntry; // Word with explanation
|
||||
} else {
|
||||
$selectedWords[] = ['word' => $wordEntry, 'explanation' => null];
|
||||
}
|
||||
} else {
|
||||
// Fallback to random selection if index not found
|
||||
$randomIndex = array_rand($this->wordlist);
|
||||
$wordEntry = $this->wordlist[$randomIndex];
|
||||
$selectedWords[] = is_array($wordEntry)
|
||||
? $wordEntry
|
||||
: ['word' => $wordEntry, 'explanation' => null];
|
||||
}
|
||||
}
|
||||
|
||||
// Extract words for password
|
||||
$words = array_map(fn($entry) => $entry['word'], $selectedWords);
|
||||
|
||||
// Optional: Capitalize first word only (traditional Diceware style)
|
||||
if ($options['uppercase_first']) {
|
||||
$words[0] = ucfirst($words[0]);
|
||||
for ($i = 1; $i < count($words); $i++) {
|
||||
$words[$i] = strtolower($words[$i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Diceware standard: space separation (no randomization)
|
||||
$password = implode(' ', $words);
|
||||
|
||||
$result = [
|
||||
'password' => $password,
|
||||
'wordEntries' => $selectedWords
|
||||
];
|
||||
|
||||
// Add dice rolls and security info if requested
|
||||
if ($options['show_dice_rolls']) {
|
||||
$result['dice_rolls'] = $diceRollsDisplay;
|
||||
$result['security_info'] = $this->calculateDicewareEntropy($wordCount);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cryptographically secure dice rolling
|
||||
* Uses true randomness for authentic Diceware methodology
|
||||
*/
|
||||
private function rollSecureDice(int $diceCount): array
|
||||
{
|
||||
$rolls = [];
|
||||
for ($i = 0; $i < $diceCount; $i++) {
|
||||
$rolls[] = random_int(1, 6);
|
||||
}
|
||||
return $rolls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Diceware entropy and security metrics
|
||||
*/
|
||||
private function calculateDicewareEntropy(int $wordCount): array
|
||||
{
|
||||
// Use effective candidate count loaded at runtime.
|
||||
$wordsInList = max(1, $this->effectiveWordlistSize);
|
||||
$entropy = $wordCount * log($wordsInList, 2);
|
||||
|
||||
return [
|
||||
'entropy_bits' => round($entropy, 1),
|
||||
'words_in_list' => $wordsInList,
|
||||
'combinations' => number_format(pow($wordsInList, $wordCount)),
|
||||
'security_level' => $this->getSecurityLevel($entropy),
|
||||
'crack_time_estimate' => $this->getCrackTimeEstimate($entropy)
|
||||
];
|
||||
}
|
||||
|
||||
private function getSecurityLevel(float $entropy): string
|
||||
{
|
||||
if ($entropy >= 80) return 'Excellent (suitable for high-value accounts)';
|
||||
if ($entropy >= 65) return 'Strong (suitable for most uses)';
|
||||
if ($entropy >= 50) return 'Adequate (basic security)';
|
||||
return 'Weak (not recommended)';
|
||||
}
|
||||
|
||||
private function getCrackTimeEstimate(float $entropy): string
|
||||
{
|
||||
// Assuming 1 trillion guesses per second
|
||||
$combinations = pow(2, $entropy);
|
||||
$guessesPerSecond = 1e12;
|
||||
$secondsToHack = $combinations / (2 * $guessesPerSecond); // Average time
|
||||
|
||||
if ($secondsToHack < 60) return 'Less than 1 minute';
|
||||
if ($secondsToHack < 3600) return round($secondsToHack / 60) . ' minutes';
|
||||
if ($secondsToHack < 86400) return round($secondsToHack / 3600) . ' hours';
|
||||
if ($secondsToHack < 31536000) return round($secondsToHack / 86400) . ' days';
|
||||
if ($secondsToHack < 31536000000) return round($secondsToHack / 31536000) . ' years';
|
||||
return 'Millions of years';
|
||||
}
|
||||
|
||||
private function selectWords(int $count): array
|
||||
{
|
||||
// This helper remains available for the current diceware flow; generation is handled by the main path below.
|
||||
if (empty($this->wordlist)) {
|
||||
throw new \RuntimeException('Diceware wordlist is not loaded');
|
||||
}
|
||||
|
||||
$wordEntries = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$diceRoll = $this->rollSecureDice(5);
|
||||
$index = $this->diceRollToIndex($diceRoll);
|
||||
|
||||
if (isset($this->wordlist[$index])) {
|
||||
$wordEntry = $this->wordlist[$index];
|
||||
$wordEntries[] = is_array($wordEntry) ? $wordEntry : ['word' => $wordEntry, 'explanation' => null];
|
||||
} else {
|
||||
$randomIndex = array_rand($this->wordlist);
|
||||
$wordEntry = $this->wordlist[$randomIndex];
|
||||
$wordEntries[] = is_array($wordEntry) ? $wordEntry : ['word' => $wordEntry, 'explanation' => null];
|
||||
}
|
||||
}
|
||||
|
||||
return $wordEntries;
|
||||
}
|
||||
|
||||
private function diceRollToIndex(array $rolls): string
|
||||
{
|
||||
return implode('', $rolls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load EFF Diceware wordlist with explanation support
|
||||
* Supports both simple words and word\texplanation format
|
||||
*/
|
||||
private function loadWordlist(): void
|
||||
{
|
||||
$candidates = [];
|
||||
|
||||
// Preferred native Diceware-format file (11111 word).
|
||||
$primaryPath = __DIR__ . '/../../data/wordlists/eff_large_wordlist.txt';
|
||||
$this->collectWordsFromFile($primaryPath, $candidates);
|
||||
|
||||
// Augment with repository wordlists to ensure robust candidate space.
|
||||
$augmentPaths = [
|
||||
__DIR__ . '/../../data/wordlists/eff_large_wordlist_en.txt',
|
||||
__DIR__ . '/../../data/wordlists/german_common_words_de.txt',
|
||||
__DIR__ . '/../../data/wordlists/french_common_words_fr.txt',
|
||||
__DIR__ . '/../../data/wordlists/italian_common_words_it.txt',
|
||||
__DIR__ . '/../../data/wordlists/spanish_common_words_es.txt',
|
||||
__DIR__ . '/../../data/wordlists/swiss_german_slaengikon.txt',
|
||||
];
|
||||
|
||||
foreach ($augmentPaths as $path) {
|
||||
$this->collectWordsFromFile($path, $candidates);
|
||||
}
|
||||
|
||||
if (empty($candidates)) {
|
||||
$this->loadFallbackWordlist();
|
||||
$this->effectiveWordlistSize = count($this->wordlist);
|
||||
return;
|
||||
}
|
||||
|
||||
$candidateList = array_values($candidates);
|
||||
$candidateCount = count($candidateList);
|
||||
$keys = $this->generateDicewareKeys();
|
||||
|
||||
$this->wordlist = [];
|
||||
foreach ($keys as $index => $diceKey) {
|
||||
$this->wordlist[$diceKey] = $candidateList[$index % $candidateCount];
|
||||
}
|
||||
|
||||
$this->effectiveWordlistSize = min($candidateCount, self::DICEWARE_KEYSPACE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate accurate entropy for Diceware generation
|
||||
*/
|
||||
public function calculateEntropyForOptions(array $options): float
|
||||
{
|
||||
$breakdown = $this->getEntropyBreakdownForOptions($options);
|
||||
return (float) ($breakdown['entropy_bits'] ?? 0.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed entropy contributors for API transparency.
|
||||
*/
|
||||
public function getEntropyBreakdownForOptions(array $options): array
|
||||
{
|
||||
$validated = $this->validateOptions($options);
|
||||
$words = max(1, (int) ($validated['words'] ?? 6));
|
||||
|
||||
// Candidate space reflects the loaded wordlist quality.
|
||||
$wordlistSize = max(1, $this->effectiveWordlistSize);
|
||||
$wordEntropyBits = $words * log((float) $wordlistSize, 2);
|
||||
|
||||
return [
|
||||
'entropy_model' => 'diceware-combinatorial-v1',
|
||||
'model_confidence' => $wordlistSize >= self::DICEWARE_KEYSPACE ? 'high' : 'medium',
|
||||
'requested_words' => $words,
|
||||
'wordlist_size' => $wordlistSize,
|
||||
'word_entropy_bits' => round($wordEntropyBits, 1),
|
||||
'separator_entropy_bits' => 0.0,
|
||||
'number_entropy_bits' => 0.0,
|
||||
'entropy_bits' => round($wordEntropyBits, 1),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load fallback wordlist with explanations
|
||||
*/
|
||||
private function loadFallbackWordlist(): void
|
||||
{
|
||||
// Fallback wordlist with explanations for demonstration
|
||||
$fallbackWords = [
|
||||
'Aardvark' => 'nocturnal mammal that feeds on ants',
|
||||
'absurd' => 'wildly unreasonable or illogical',
|
||||
'accrue' => 'accumulate or receive over time',
|
||||
'Acme' => 'the highest point of achievement',
|
||||
'adrift' => 'floating without being steered',
|
||||
'Adult' => 'fully grown person',
|
||||
'afflict' => 'cause pain or suffering to',
|
||||
'ahead' => 'in front or in advance',
|
||||
'aimless' => 'without purpose or direction',
|
||||
'Algae' => 'simple plants found in water',
|
||||
'allow' => 'permit or give permission',
|
||||
'alone' => 'on one\'s own without others',
|
||||
'amuse' => 'provide entertainment or cause laughter',
|
||||
'Angel' => 'spiritual being or very kind person',
|
||||
'Ankle' => 'joint connecting foot and leg',
|
||||
'Apathy' => 'lack of interest or enthusiasm',
|
||||
'aqua' => 'light blue-green color like water',
|
||||
'Arcade' => 'covered passage with shops',
|
||||
'ardent' => 'passionate or enthusiastic',
|
||||
'Area' => 'region or extent of space',
|
||||
'argue' => 'give reasons for or against something',
|
||||
'Ark' => 'large boat or chest',
|
||||
'armed' => 'equipped with weapons',
|
||||
'Armor' => 'protective covering or clothing',
|
||||
'Army' => 'organized military force',
|
||||
'around' => 'on every side or in the vicinity',
|
||||
'Arrow' => 'projectile shot from a bow',
|
||||
'Arson' => 'crime of deliberately setting fire',
|
||||
'Art' => 'creative expression or skill',
|
||||
'ascend' => 'go up or climb higher',
|
||||
'ask' => 'request information or something',
|
||||
'Aspect' => 'particular part or feature',
|
||||
'Atom' => 'smallest unit of matter',
|
||||
'Attic' => 'space under the roof',
|
||||
'Avatar' => 'representation of a person online',
|
||||
'avoid' => 'keep away from or prevent',
|
||||
'awake' => 'not sleeping or alert',
|
||||
'Award' => 'prize given for achievement',
|
||||
'aware' => 'conscious or informed',
|
||||
'away' => 'at a distance from',
|
||||
'Axis' => 'imaginary line through center',
|
||||
'Backup' => 'copy kept for security',
|
||||
'Badge' => 'symbol of authority or achievement',
|
||||
'Bagel' => 'ring-shaped bread roll',
|
||||
'Balance' => 'even distribution of weight',
|
||||
'Ball' => 'round object used in games',
|
||||
'Bamboo' => 'fast-growing woody grass',
|
||||
'Banana' => 'yellow curved tropical fruit',
|
||||
'Band' => 'group or strip of material',
|
||||
'Banner' => 'flag or sign for display',
|
||||
'bring' => 'take or carry to a place'
|
||||
];
|
||||
|
||||
// Convert to Diceware index format for consistency
|
||||
$index = 11111;
|
||||
foreach ($fallbackWords as $word => $explanation) {
|
||||
$this->wordlist[(string)$index] = [
|
||||
'word' => $word,
|
||||
'explanation' => $explanation
|
||||
];
|
||||
$index++;
|
||||
}
|
||||
|
||||
$this->effectiveWordlistSize = count($this->wordlist);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect word entries from a wordlist file and deduplicate by lowercase word.
|
||||
*/
|
||||
private function collectWordsFromFile(string $path, array &$target): void
|
||||
{
|
||||
if (!file_exists($path) || !is_readable($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$content = file_get_contents($path);
|
||||
if ($content === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = explode("\n", $content);
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || str_starts_with($line, '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Supports classic Diceware lines: 12345 word[\texplanation]
|
||||
if (preg_match('/^(\d{5})\s+(.+)$/', $line, $matches)) {
|
||||
$entry = $this->parseWordlistEntry(trim($matches[2]));
|
||||
} else {
|
||||
$entry = $this->parseWordlistEntry($line);
|
||||
}
|
||||
|
||||
$word = trim((string) ($entry['word'] ?? ''));
|
||||
if ($word === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = strtolower($word);
|
||||
if (!isset($target[$key])) {
|
||||
$target[$key] = [
|
||||
'word' => $word,
|
||||
'explanation' => $entry['explanation'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate all valid 5-dice key strings from 11111 to 66666.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function generateDicewareKeys(): array
|
||||
{
|
||||
$keys = [];
|
||||
|
||||
for ($a = 1; $a <= 6; $a++) {
|
||||
for ($b = 1; $b <= 6; $b++) {
|
||||
for ($c = 1; $c <= 6; $c++) {
|
||||
for ($d = 1; $d <= 6; $d++) {
|
||||
for ($e = 1; $e <= 6; $e++) {
|
||||
$keys[] = (string) ($a . $b . $c . $d . $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Generators;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* High Entropy Password Generator
|
||||
*
|
||||
* Generates cryptographically secure random passwords with customizable
|
||||
* character sets based on user preferences.
|
||||
*/
|
||||
class HighEntropyGenerator extends AbstractPasswordGenerator
|
||||
{
|
||||
private const GENERATOR_ID = 'high_entropy';
|
||||
|
||||
private const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
|
||||
private const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
private const NUMBERS = '0123456789';
|
||||
private const SYMBOLS = '!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||||
|
||||
public function __construct(?LoggerInterface $logger = null)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return self::GENERATOR_ID;
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return '1.1.0';
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'High Entropy';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Cryptographically secure random passwords with customizable character sets';
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized option definitions
|
||||
*/
|
||||
public function getOptionDefinitions(): array
|
||||
{
|
||||
$options = [
|
||||
'length' => [
|
||||
'type' => 'integer',
|
||||
'default' => 16,
|
||||
'min' => 8,
|
||||
'max' => 128,
|
||||
'required' => false,
|
||||
'group' => 'basic',
|
||||
'description' => 'Total length of the generated password'
|
||||
],
|
||||
'include_uppercase' => [
|
||||
'type' => 'boolean',
|
||||
'default' => true,
|
||||
'required' => false,
|
||||
'group' => 'character_set',
|
||||
'description' => 'Include uppercase letters (A-Z)'
|
||||
],
|
||||
'include_lowercase' => [
|
||||
'type' => 'boolean',
|
||||
'default' => true,
|
||||
'required' => false,
|
||||
'group' => 'character_set',
|
||||
'description' => 'Include lowercase letters (a-z)'
|
||||
],
|
||||
'include_numbers' => [
|
||||
'type' => 'boolean',
|
||||
'default' => true,
|
||||
'required' => false,
|
||||
'group' => 'character_set',
|
||||
'description' => 'Include numbers (0-9)'
|
||||
],
|
||||
'include_symbols' => [
|
||||
'type' => 'boolean',
|
||||
'default' => true,
|
||||
'required' => false,
|
||||
'group' => 'character_set',
|
||||
'description' => 'Include symbols (e.g., !@#$%)'
|
||||
],
|
||||
];
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public function generate(int $count = 1, array $options = []): array
|
||||
{
|
||||
$options = array_merge($this->getDefaultOptions(), $options);
|
||||
$passwords = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$passwords[] = $this->generateSingle($options);
|
||||
}
|
||||
|
||||
return $passwords;
|
||||
}
|
||||
|
||||
private function generateSingle(array $options): string
|
||||
{
|
||||
$characterSet = $this->buildCharacterSet($options);
|
||||
$length = $options['length'];
|
||||
|
||||
if (empty($characterSet)) {
|
||||
throw new \RuntimeException('No character types selected');
|
||||
}
|
||||
|
||||
// Generate password ensuring at least one character from each selected type
|
||||
$password = $this->generateWithGuaranteedTypes($characterSet, $length, $options);
|
||||
|
||||
// Shuffle the password to randomize position of guaranteed characters
|
||||
return $this->shuffleString($password);
|
||||
}
|
||||
|
||||
private function buildCharacterSet(array $options): string
|
||||
{
|
||||
$characterSet = '';
|
||||
|
||||
if ($options['include_lowercase'] ?? false) {
|
||||
$characterSet .= self::LOWERCASE;
|
||||
}
|
||||
|
||||
if ($options['include_uppercase'] ?? false) {
|
||||
$characterSet .= self::UPPERCASE;
|
||||
}
|
||||
|
||||
if ($options['include_numbers'] ?? false) {
|
||||
$characterSet .= self::NUMBERS;
|
||||
}
|
||||
|
||||
if ($options['include_symbols'] ?? false) {
|
||||
$characterSet .= self::SYMBOLS;
|
||||
}
|
||||
|
||||
return $characterSet;
|
||||
}
|
||||
|
||||
private function generateWithGuaranteedTypes(string $characterSet, int $length, array $options): string
|
||||
{
|
||||
$password = '';
|
||||
$guaranteedChars = [];
|
||||
|
||||
// Add at least one character from each selected type
|
||||
if ($options['include_lowercase'] ?? false) {
|
||||
$guaranteedChars[] = self::LOWERCASE[random_int(0, strlen(self::LOWERCASE) - 1)];
|
||||
}
|
||||
|
||||
if ($options['include_uppercase'] ?? false) {
|
||||
$guaranteedChars[] = self::UPPERCASE[random_int(0, strlen(self::UPPERCASE) - 1)];
|
||||
}
|
||||
|
||||
if ($options['include_numbers'] ?? false) {
|
||||
$guaranteedChars[] = self::NUMBERS[random_int(0, strlen(self::NUMBERS) - 1)];
|
||||
}
|
||||
|
||||
if ($options['include_symbols'] ?? false) {
|
||||
$guaranteedChars[] = self::SYMBOLS[random_int(0, strlen(self::SYMBOLS) - 1)];
|
||||
}
|
||||
|
||||
// Add guaranteed characters
|
||||
$password = implode('', $guaranteedChars);
|
||||
|
||||
// Fill remaining length with random characters from full set
|
||||
$remainingLength = $length - count($guaranteedChars);
|
||||
for ($i = 0; $i < $remainingLength; $i++) {
|
||||
$password .= $characterSet[random_int(0, strlen($characterSet) - 1)];
|
||||
}
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
private function shuffleString(string $string): string
|
||||
{
|
||||
$characters = str_split($string);
|
||||
|
||||
// Fisher-Yates shuffle using cryptographically secure random
|
||||
for ($i = count($characters) - 1; $i > 0; $i--) {
|
||||
$j = random_int(0, $i);
|
||||
[$characters[$i], $characters[$j]] = [$characters[$j], $characters[$i]];
|
||||
}
|
||||
|
||||
return implode('', $characters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Generators\Interfaces;
|
||||
|
||||
/**
|
||||
* Interface for generator option schema definitions
|
||||
*
|
||||
* All option types must implement this interface to ensure
|
||||
* consistent metadata and i18n support across generators.
|
||||
*
|
||||
* @package App\Generators\Interfaces
|
||||
*/
|
||||
interface GeneratorOptionInterface
|
||||
{
|
||||
/**
|
||||
* Get the option name (internal identifier)
|
||||
* @return string
|
||||
*/
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* Get the option type (integer, boolean, string, select)
|
||||
* @return string
|
||||
*/
|
||||
public function getType(): string;
|
||||
|
||||
/**
|
||||
* Get i18n key for the option label
|
||||
* @return string
|
||||
*/
|
||||
public function getLabelKey(): string;
|
||||
|
||||
/**
|
||||
* Get i18n key for the option description/help text
|
||||
* @return string
|
||||
*/
|
||||
public function getDescriptionKey(): string;
|
||||
|
||||
/**
|
||||
* Get the default value for this option
|
||||
* @return mixed
|
||||
*/
|
||||
public function getDefault(): mixed;
|
||||
|
||||
/**
|
||||
* Check if this option is required
|
||||
* @return bool
|
||||
*/
|
||||
public function isRequired(): bool;
|
||||
|
||||
/**
|
||||
* Get the option group (basic, advanced, character_set, etc.)
|
||||
* @return string
|
||||
*/
|
||||
public function getGroup(): string;
|
||||
|
||||
/**
|
||||
* Get i18n key for the group title
|
||||
* @return string
|
||||
*/
|
||||
public function getGroupTitleKey(): string;
|
||||
|
||||
/**
|
||||
* Convert option to array format for API responses
|
||||
* @return array
|
||||
*/
|
||||
public function toArray(): array;
|
||||
|
||||
/**
|
||||
* Validate a value against this option's constraints
|
||||
* @param mixed $value
|
||||
* @return bool
|
||||
*/
|
||||
public function validate(mixed $value): bool;
|
||||
|
||||
/**
|
||||
* Get validation error message i18n key
|
||||
* @return string
|
||||
*/
|
||||
public function getValidationErrorKey(): string;
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Generators;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Apple-Style Passkey Generator
|
||||
*
|
||||
* Generates pronounceable passwords using syllable patterns with
|
||||
* integrated character substitutions, similar to Apple Keychain suggestions.
|
||||
*
|
||||
* REFACTORING CHANGES APPLIED:
|
||||
* - Step 5: Removed non-functional add_numbers and add_symbols options
|
||||
* - Focus on syllable-based generation with integrated substitutions
|
||||
* - Apple-style formatting with character replacements (o→0, i→1, etc.)
|
||||
*
|
||||
* FIXES APPLIED:
|
||||
* - Removed calculateEntropy method that conflicted with parent signature
|
||||
* - Fixed generate() method to return simple string array (not objects)
|
||||
*/
|
||||
class PasskeyGenerator extends AbstractPasswordGenerator
|
||||
{
|
||||
private const GENERATOR_ID = 'passkey';
|
||||
|
||||
private const CONSONANTS = 'bcdfghjklmnpqrstvwxyz';
|
||||
private const VOWELS = 'aeiou';
|
||||
|
||||
// Common consonant pairs that sound natural together
|
||||
private const CONSONANT_PAIRS = [
|
||||
'bl',
|
||||
'br',
|
||||
'ch',
|
||||
'cl',
|
||||
'cr',
|
||||
'dr',
|
||||
'fl',
|
||||
'fr',
|
||||
'gl',
|
||||
'gr',
|
||||
'pl',
|
||||
'pr',
|
||||
'sc',
|
||||
'sh',
|
||||
'sk',
|
||||
'sl',
|
||||
'sm',
|
||||
'sn',
|
||||
'sp',
|
||||
'st',
|
||||
'sw',
|
||||
'th',
|
||||
'tr',
|
||||
'tw',
|
||||
'wh',
|
||||
'wr'
|
||||
];
|
||||
|
||||
// Syllable patterns for natural pronunciation
|
||||
private const PATTERNS = [
|
||||
'cv', // consonant-vowel (most common)
|
||||
'cvc', // consonant-vowel-consonant
|
||||
'ccv', // consonant-consonant-vowel
|
||||
'cvcv', // consonant-vowel-consonant-vowel (longer syllables)
|
||||
'vc', // vowel-consonant (occasional)
|
||||
];
|
||||
|
||||
public function __construct(?LoggerInterface $logger = null)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return self::GENERATOR_ID;
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return '1.2.0';
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Passkey';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Apple-style pronounceable passwords with integrated character substitutions';
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized option definitions
|
||||
*/
|
||||
public function getOptionDefinitions(): array
|
||||
{
|
||||
$options = [
|
||||
'syllables' => [
|
||||
'type' => 'integer',
|
||||
'default' => 6,
|
||||
'min' => 4,
|
||||
'max' => 12,
|
||||
'required' => false,
|
||||
'group' => 'basic',
|
||||
'description' => 'Number of pronounceable syllables in the passkey'
|
||||
]
|
||||
];
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* FIXED: Return simple array of password strings (not objects)
|
||||
* This matches the expected format for both API and frontend
|
||||
*/
|
||||
public function generate(int $count = 1, array $options = []): array
|
||||
{
|
||||
$options = array_merge($this->getDefaultOptions(), $options);
|
||||
$passwords = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$passwords[] = $this->generateSingle($options);
|
||||
}
|
||||
|
||||
return $passwords;
|
||||
}
|
||||
|
||||
private function generateSingle(array $options): string
|
||||
{
|
||||
$syllableCount = $options['syllables'];
|
||||
|
||||
// Generate pronounceable syllables
|
||||
$syllables = $this->generateSyllables($syllableCount);
|
||||
|
||||
// Apply Apple-style formatting with integrated character substitutions
|
||||
$password = $this->formatAppleStyle($syllables);
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate pronounceable syllables using various patterns
|
||||
*/
|
||||
private function generateSyllables(int $count): array
|
||||
{
|
||||
$syllables = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
// Select a random syllable pattern
|
||||
$pattern = self::PATTERNS[random_int(0, count(self::PATTERNS) - 1)];
|
||||
$syllable = $this->generateSyllableByPattern($pattern);
|
||||
$syllables[] = $syllable;
|
||||
}
|
||||
|
||||
return $syllables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a syllable following a specific pattern
|
||||
*/
|
||||
private function generateSyllableByPattern(string $pattern): string
|
||||
{
|
||||
$syllable = '';
|
||||
$i = 0;
|
||||
|
||||
while ($i < strlen($pattern)) {
|
||||
$char = $pattern[$i];
|
||||
|
||||
if ($char === 'c') {
|
||||
// Check if we can use a consonant pair
|
||||
if ($i === 0 && random_int(1, 4) === 1) { // 25% chance
|
||||
$pair = self::CONSONANT_PAIRS[random_int(0, count(self::CONSONANT_PAIRS) - 1)];
|
||||
$syllable .= $pair;
|
||||
// Skip next consonant if it exists in pattern
|
||||
if ($i + 1 < strlen($pattern) && $pattern[$i + 1] === 'c') {
|
||||
$i++; // Skip the next 'c' since we used a pair
|
||||
}
|
||||
} else {
|
||||
$consonant = self::CONSONANTS[random_int(0, strlen(self::CONSONANTS) - 1)];
|
||||
$syllable .= $consonant;
|
||||
}
|
||||
} else if ($char === 'v') {
|
||||
$vowel = self::VOWELS[random_int(0, strlen(self::VOWELS) - 1)];
|
||||
$syllable .= $vowel;
|
||||
}
|
||||
|
||||
$i++;
|
||||
}
|
||||
|
||||
return $syllable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Apple-style formatting with integrated character substitutions
|
||||
*/
|
||||
private function formatAppleStyle(array $syllables): string
|
||||
{
|
||||
$password = implode('', $syllables);
|
||||
|
||||
// Apple-style character substitutions (looks similar, maintains pronounceability)
|
||||
$substitutions = [
|
||||
'o' => '0', // o looks like 0
|
||||
'i' => '1', // i looks like 1
|
||||
'a' => '@', // a looks like @
|
||||
's' => '$', // s looks like $
|
||||
'e' => '3', // e looks like 3
|
||||
];
|
||||
|
||||
// Apply 1-2 substitutions randomly for Apple-like feel (reduced from 1-3)
|
||||
$maxSubstitutions = min(2, max(1, strlen($password) / 4)); // More conservative
|
||||
$substitutionCount = random_int(1, $maxSubstitutions);
|
||||
$applied = 0;
|
||||
|
||||
// Shuffle substitutions for randomness
|
||||
$availableSubstitutions = $substitutions;
|
||||
$keys = array_keys($availableSubstitutions);
|
||||
shuffle($keys);
|
||||
|
||||
foreach ($keys as $letter) {
|
||||
if ($applied >= $substitutionCount) break;
|
||||
|
||||
$replacement = $substitutions[$letter];
|
||||
|
||||
// Find all positions of this letter
|
||||
$positions = [];
|
||||
for ($i = 0; $i < strlen($password); $i++) {
|
||||
if (strtolower($password[$i]) === $letter) { // Case insensitive search
|
||||
$positions[] = $i;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($positions)) {
|
||||
// Replace one random occurrence of this letter
|
||||
$randomPosition = $positions[random_int(0, count($positions) - 1)];
|
||||
$password[$randomPosition] = $replacement;
|
||||
$applied++;
|
||||
}
|
||||
}
|
||||
|
||||
// Apple-style: Capitalize first letter
|
||||
$password = ucfirst($password);
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate entropy for syllable-based generation
|
||||
*/
|
||||
public function calculateEntropyForOptions(array $options): float
|
||||
{
|
||||
$breakdown = $this->getEntropyBreakdownForOptions($options);
|
||||
return (float) ($breakdown['entropy_bits'] ?? 0.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed entropy contributors for API transparency.
|
||||
*/
|
||||
public function getEntropyBreakdownForOptions(array $options): array
|
||||
{
|
||||
$validated = $this->validateOptions($options);
|
||||
$syllables = max(1, (int) ($validated['syllables'] ?? 6));
|
||||
|
||||
// Heuristic estimate based on average pronounceable-syllable branching.
|
||||
$combinationsPerSyllable = 150.0;
|
||||
$baseEntropyBits = $syllables * log($combinationsPerSyllable, 2);
|
||||
|
||||
// Character substitutions add a small, heuristic bonus.
|
||||
$substitutionMultiplier = 1.2;
|
||||
$substitutionEntropyBits = log($substitutionMultiplier, 2);
|
||||
|
||||
$totalEntropyBits = $baseEntropyBits + $substitutionEntropyBits;
|
||||
|
||||
return [
|
||||
'entropy_model' => 'passkey-syllable-heuristic-v1',
|
||||
'model_confidence' => 'medium',
|
||||
'requested_syllables' => $syllables,
|
||||
'estimated_combinations_per_syllable' => $combinationsPerSyllable,
|
||||
'substitution_multiplier' => $substitutionMultiplier,
|
||||
'syllable_entropy_bits' => round($baseEntropyBits, 1),
|
||||
'substitution_entropy_bits' => round($substitutionEntropyBits, 1),
|
||||
'entropy_bits' => round($totalEntropyBits, 1),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get example passkeys for documentation
|
||||
*/
|
||||
public function getExamples(): array
|
||||
{
|
||||
return [
|
||||
'Short (4 syllables)' => $this->generate(3, ['syllables' => 4]),
|
||||
'Medium (6 syllables)' => $this->generate(3, ['syllables' => 6]),
|
||||
'Long (8 syllables)' => $this->generate(3, ['syllables' => 8]),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pronunciation guide for a generated password
|
||||
*/
|
||||
public function getPronunciationGuide(string $password): string
|
||||
{
|
||||
// Remove numbers and symbols for pronunciation
|
||||
$pronounceable = preg_replace('/[0-9@$3]/', '', $password);
|
||||
|
||||
// Add hyphens between likely syllable breaks
|
||||
$guide = '';
|
||||
$vowelPattern = '/[aeiou]/i';
|
||||
$lastWasVowel = false;
|
||||
|
||||
for ($i = 0; $i < strlen($pronounceable); $i++) {
|
||||
$char = $pronounceable[$i];
|
||||
$isVowel = preg_match($vowelPattern, $char);
|
||||
|
||||
// Add hyphen before consonants that follow vowels (syllable breaks)
|
||||
if (!$isVowel && $lastWasVowel && $i > 0) {
|
||||
$guide .= '-';
|
||||
}
|
||||
|
||||
$guide .= strtolower($char);
|
||||
$lastWasVowel = $isVowel;
|
||||
}
|
||||
|
||||
return $guide;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Generators;
|
||||
|
||||
use App\Generators\Interfaces\GeneratorOptionInterface;
|
||||
|
||||
/**
|
||||
* Interface for all password generators with centralized option definitions
|
||||
* and programmatic i18n key derivation
|
||||
*
|
||||
* All generators must define their options in getOptionDefinitions() and
|
||||
* i18n keys will be automatically derived from the generator ID and option names.
|
||||
*
|
||||
* @package App\Generators
|
||||
*/
|
||||
interface PasswordGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* Generate passwords
|
||||
*
|
||||
* @param int $count Number of passwords to generate
|
||||
* @param array $options Generation options
|
||||
* @return array Array of password data structures
|
||||
*/
|
||||
public function generate(int $count = 1, array $options = []): array;
|
||||
|
||||
/**
|
||||
* Get generator ID (internal identifier)
|
||||
* Used as base for all i18n keys
|
||||
*
|
||||
* @return string Generator ID (e.g., 'pwgen', 'xkcdpass')
|
||||
*/
|
||||
public function getId(): string;
|
||||
|
||||
/**
|
||||
* Get generator version
|
||||
*
|
||||
* @return string Version string (e.g., '3.1.0')
|
||||
*/
|
||||
public function getVersion(): string;
|
||||
|
||||
/**
|
||||
* Get option definitions (centralized configuration)
|
||||
*
|
||||
* All option metadata, constraints, and defaults in one place.
|
||||
* I18n keys will be automatically derived from generator ID and option names.
|
||||
*
|
||||
* Example:
|
||||
* [
|
||||
* 'length' => [
|
||||
* 'type' => 'integer',
|
||||
* 'default' => 8,
|
||||
* 'min' => 4,
|
||||
* 'max' => 32,
|
||||
* 'required' => false,
|
||||
* 'group' => 'basic'
|
||||
* ],
|
||||
* 'separator' => [
|
||||
* 'type' => 'string',
|
||||
* 'default' => '',
|
||||
* 'maxLength' => 10,
|
||||
* 'group' => 'advanced'
|
||||
* ],
|
||||
* 'wordlist' => [
|
||||
* 'type' => 'select',
|
||||
* 'default' => 'en',
|
||||
* 'choices' => $this->getAvailableWordlists(),
|
||||
* 'translateChoices' => false, // Don't translate wordlist names
|
||||
* 'group' => 'basic'
|
||||
* ]
|
||||
* ]
|
||||
*
|
||||
* @return array Associative array of option definitions
|
||||
*/
|
||||
public function getOptionDefinitions(): array;
|
||||
|
||||
/**
|
||||
* Get generator name i18n key (automatically derived)
|
||||
*
|
||||
* Pattern: {generator_id}_name
|
||||
*
|
||||
* @return string i18n key
|
||||
*/
|
||||
public function getNameKey(): string;
|
||||
|
||||
/**
|
||||
* Get generator description i18n key (automatically derived)
|
||||
*
|
||||
* Pattern: {generator_id}_description
|
||||
*
|
||||
* @return string i18n key
|
||||
*/
|
||||
public function getDescriptionKey(): string;
|
||||
|
||||
/**
|
||||
* Get i18n key for an option label (automatically derived)
|
||||
*
|
||||
* Pattern: {generator_id}_option_{option_name}_label
|
||||
*
|
||||
* @param string $optionName Option name
|
||||
* @return string i18n key
|
||||
*/
|
||||
public function getOptionLabelKey(string $optionName): string;
|
||||
|
||||
/**
|
||||
* Get i18n key for an option description (automatically derived)
|
||||
*
|
||||
* Pattern: {generator_id}_option_{option_name}_description
|
||||
*
|
||||
* @param string $optionName Option name
|
||||
* @return string i18n key
|
||||
*/
|
||||
public function getOptionDescriptionKey(string $optionName): string;
|
||||
|
||||
/**
|
||||
* Get i18n key for an option group title (automatically derived)
|
||||
*
|
||||
* Pattern: {generator_id}_options_group_{group_name}_title
|
||||
*
|
||||
* @param string $groupName Group name
|
||||
* @return string i18n key
|
||||
*/
|
||||
public function getOptionGroupTitleKey(string $groupName): string;
|
||||
|
||||
/**
|
||||
* Get i18n key for a select option choice (automatically derived)
|
||||
*
|
||||
* Pattern: {generator_id}_option_{option_name}_choice_{choice_value}
|
||||
*
|
||||
* @param string $optionName Option name
|
||||
* @param string $choiceValue Choice value
|
||||
* @return string i18n key
|
||||
*/
|
||||
public function getOptionChoiceLabelKey(string $optionName, string $choiceValue): string;
|
||||
|
||||
/**
|
||||
* Get i18n key for validation error (automatically derived)
|
||||
*
|
||||
* Pattern: {generator_id}_option_{option_name}_validation_error
|
||||
*
|
||||
* @param string $optionName Option name
|
||||
* @return string i18n key
|
||||
*/
|
||||
public function getValidationErrorKey(string $optionName): string;
|
||||
|
||||
/**
|
||||
* Get all i18n keys used by this generator (for translation extraction)
|
||||
*
|
||||
* Returns array of all keys that need translations
|
||||
*
|
||||
* @return array Array of i18n keys
|
||||
*/
|
||||
public function getAllI18nKeys(): array;
|
||||
|
||||
/**
|
||||
* Get default options (derived from option definitions)
|
||||
*
|
||||
* @return array Associative array of option_name => default_value
|
||||
*/
|
||||
public function getDefaultOptions(): array;
|
||||
|
||||
/**
|
||||
* Validate options against definitions
|
||||
*
|
||||
* @param array $options Options to validate
|
||||
* @return array Validated and sanitized options
|
||||
* @throws \InvalidArgumentException If validation fails
|
||||
*/
|
||||
public function validateOptions(array $options): array;
|
||||
|
||||
/**
|
||||
* Get complete option schema for API (with derived i18n keys)
|
||||
*
|
||||
* @return array Array of option metadata with all i18n keys
|
||||
*/
|
||||
public function getOptionSchema(): array;
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Generators;
|
||||
|
||||
use App\Services\LLM\LLMManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
|
||||
/**
|
||||
* Phrase Abbreviator Generator v1.3
|
||||
*
|
||||
* Generates passwords from sentences by extracting and combining first letters
|
||||
* and other word characters based on customizable rules.
|
||||
*
|
||||
* Supports:
|
||||
* - Single or dual character extraction per word
|
||||
* - Optional random number addition (1-4 digits)
|
||||
* - AI-generated sentences via LLM integration
|
||||
* - Localized number word conversion from translation files
|
||||
* - Multiple language support for number words
|
||||
*
|
||||
* @package App\Generators
|
||||
*/
|
||||
class PhraseAbbreviatorGenerator extends AbstractPasswordGenerator
|
||||
{
|
||||
private const GENERATOR_ID = 'phraseabbreviator';
|
||||
private const DEFAULT_SENTENCE = 'The quick brown fox jumps over the lazy dog.';
|
||||
private const VOWELS = 'aeiouyAEIOUY';
|
||||
private const CONSONANTS = 'bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ';
|
||||
private ?LLMManager $llmManager = null;
|
||||
|
||||
/**
|
||||
* Constructor with optional LLM Manager and logger
|
||||
*/
|
||||
public function __construct(?LLMManager $llmManager = null, ?LoggerInterface $logger = null)
|
||||
{
|
||||
parent::__construct($logger); // ✅ Calls parent::__construct()
|
||||
$this->llmManager = $llmManager;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return self::GENERATOR_ID;
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return '1.3.0';
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'Phrase Abbreviator';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Generates memorable passwords from sentence fragments and word abbreviations';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get option definitions
|
||||
*/
|
||||
public function getOptionDefinitions(): array
|
||||
{
|
||||
$options = [
|
||||
'sentence' => [
|
||||
'type' => 'string',
|
||||
'default' => self::DEFAULT_SENTENCE,
|
||||
'minLength' => 1,
|
||||
'maxLength' => 500,
|
||||
'required' => false, // Not required if using AI
|
||||
'group' => 'basic',
|
||||
'description' => 'User-supplied sentence (minimum 8 words, must start with capital letter)'
|
||||
],
|
||||
'character_extraction' => [
|
||||
'type' => 'select',
|
||||
'default' => 'single',
|
||||
'choices' => [
|
||||
['value' => 'single', 'label' => 'First letter only'],
|
||||
['value' => 'dual', 'label' => 'First letter + next vowel/consonant']
|
||||
],
|
||||
'translateChoices' => true,
|
||||
'required' => false,
|
||||
'group' => 'basic',
|
||||
'description' => 'Method for extracting characters from each word'
|
||||
],
|
||||
'language' => [
|
||||
'type' => 'select',
|
||||
'default' => 'en',
|
||||
'choices' => $this->getLanguageChoices(),
|
||||
'translateChoices' => false,
|
||||
'required' => false,
|
||||
'group' => 'basic',
|
||||
'description' => 'Language for number word conversion'
|
||||
],
|
||||
'add_number' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'required' => false,
|
||||
'group' => 'advanced',
|
||||
'description' => 'Add random digits to the password'
|
||||
],
|
||||
'number_digits' => [
|
||||
'type' => 'integer',
|
||||
'default' => 2,
|
||||
'min' => 1,
|
||||
'max' => 4,
|
||||
'required' => false,
|
||||
'group' => 'advanced',
|
||||
'description' => 'How many random digits to add (1-4)'
|
||||
],
|
||||
'number_position' => [
|
||||
'type' => 'select',
|
||||
'default' => 'append',
|
||||
'choices' => [
|
||||
['value' => 'prepend', 'label' => 'Before'],
|
||||
['value' => 'append', 'label' => 'After']
|
||||
],
|
||||
'translateChoices' => true,
|
||||
'required' => false,
|
||||
'group' => 'advanced',
|
||||
'description' => 'Place number before or after the password'
|
||||
]
|
||||
];
|
||||
|
||||
// Add AI options if LLM manager is available
|
||||
if ($this->llmManager && $this->llmManager->hasAvailableProvider()) {
|
||||
$options['use_ai_sentence'] = [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'required' => false,
|
||||
'group' => 'ai',
|
||||
'description' => 'Let AI generate a sentence from your prompt'
|
||||
];
|
||||
|
||||
$options['ai_prompt'] = [
|
||||
'type' => 'string',
|
||||
'default' => 'Generate a creative, memorable sentence about technology.',
|
||||
'maxLength' => 500,
|
||||
'required' => false,
|
||||
'group' => 'ai',
|
||||
'description' => 'Prompt for AI to generate a memorable sentence (minimum 12 words)'
|
||||
];
|
||||
|
||||
// Get available LLM providers
|
||||
$providers = $this->llmManager->getAvailableProviders();
|
||||
if (!empty($providers)) {
|
||||
$providerChoices = [];
|
||||
foreach ($providers as $name => $info) {
|
||||
$providerChoices[] = [
|
||||
'value' => $name,
|
||||
'label' => $info['display_name']
|
||||
];
|
||||
}
|
||||
|
||||
$options['ai_provider'] = [
|
||||
'type' => 'select',
|
||||
'default' => $this->llmManager->getDefaultProviderName(),
|
||||
'choices' => $providerChoices,
|
||||
'translateChoices' => false,
|
||||
'required' => false,
|
||||
'group' => 'ai',
|
||||
'description' => 'Which LLM provider to use for sentence generation'
|
||||
];
|
||||
|
||||
// Get models for default provider
|
||||
$defaultProvider = $this->llmManager->getDefaultProvider();
|
||||
$modelChoices = [];
|
||||
foreach ($defaultProvider->getAvailableModels() as $value => $label) {
|
||||
$modelChoices[] = ['value' => $value, 'label' => $label];
|
||||
}
|
||||
|
||||
$options['ai_model'] = [
|
||||
'type' => 'select',
|
||||
'default' => $defaultProvider->getDefaultModel(),
|
||||
'choices' => $modelChoices,
|
||||
'translateChoices' => false,
|
||||
'required' => false,
|
||||
'group' => 'ai',
|
||||
'description' => 'Which AI model to use'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language choices from configuration
|
||||
*/
|
||||
private function getLanguageChoices(): array
|
||||
{
|
||||
$languages = $this->config['locale']['languages'] ?? [
|
||||
'en' => 'English',
|
||||
'de' => 'Deutsch',
|
||||
'fr' => 'Français',
|
||||
'es' => 'Español',
|
||||
'it' => 'Italiano'
|
||||
];
|
||||
|
||||
$choices = [];
|
||||
foreach ($languages as $code => $name) {
|
||||
$choices[] = ['value' => $code, 'label' => $name];
|
||||
}
|
||||
|
||||
return $choices;
|
||||
}
|
||||
|
||||
public function validateOptions(array $options): array
|
||||
{
|
||||
$normalized = $options;
|
||||
|
||||
if (!($normalized['use_ai_sentence'] ?? false) && isset($normalized['sentence']) && empty(trim((string) $normalized['sentence']))) {
|
||||
$normalized['sentence'] = self::DEFAULT_SENTENCE;
|
||||
}
|
||||
|
||||
$validated = parent::validateOptions($normalized);
|
||||
|
||||
if (!($validated['use_ai_sentence'] ?? false) && empty(trim((string) ($validated['sentence'] ?? '')))) {
|
||||
$validated['sentence'] = self::DEFAULT_SENTENCE;
|
||||
}
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate passwords from phrases
|
||||
*/
|
||||
public function generate(int $count = 1, array $options = []): array
|
||||
{
|
||||
// Validate options
|
||||
$options = $this->validateOptions($options);
|
||||
|
||||
// Validate count
|
||||
if ($count < 1 || $count > 100) {
|
||||
throw new InvalidArgumentException('Count must be between 1 and 100');
|
||||
}
|
||||
|
||||
if ($this->logger && $this->isLogLevel('debug')) {
|
||||
$this->logger->debug('PhraseAbbreviator: starting generation', [
|
||||
'count' => $count,
|
||||
'extraction_type' => $options['character_extraction'],
|
||||
'use_ai' => $options['use_ai_sentence'] ?? false
|
||||
]);
|
||||
}
|
||||
|
||||
$passwords = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
try {
|
||||
// Get sentence
|
||||
if ($options['use_ai_sentence'] ?? false) {
|
||||
if (!$this->llmManager) {
|
||||
throw new RuntimeException('LLM manager not configured');
|
||||
}
|
||||
$sentence = $this->generateAiSentence($options);
|
||||
} else {
|
||||
$sentence = $options['sentence'];
|
||||
if (empty($sentence)) {
|
||||
throw new InvalidArgumentException(
|
||||
'Sentence is required when not using AI generation'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate sentence
|
||||
$this->validateSentence($sentence, $options['use_ai_sentence'] ?? false);
|
||||
|
||||
// Replace number words with digits
|
||||
$processedSentence = $this->replaceNumberWords($sentence, $options['language']);
|
||||
|
||||
// Generate password from sentence
|
||||
$password = $this->generatePasswordFromSentence(
|
||||
$processedSentence,
|
||||
$options['character_extraction'],
|
||||
$options['language']
|
||||
);
|
||||
|
||||
// Add optional number
|
||||
if ($options['add_number']) {
|
||||
$password = $this->addRandomNumber(
|
||||
$password,
|
||||
$options['number_digits'],
|
||||
$options['number_position']
|
||||
);
|
||||
}
|
||||
|
||||
$passwords[] = [
|
||||
'password' => $password,
|
||||
'method' => 'phrase_abbreviator',
|
||||
'source_sentence' => $sentence,
|
||||
'extraction_type' => $options['character_extraction']
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
if ($this->logger) {
|
||||
$this->logger->error('PhraseAbbreviator: generation iteration failed', [
|
||||
'iteration' => $i + 1,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->logger && $this->isLogLevel('info')) {
|
||||
$this->logger->info('PhraseAbbreviator: generation complete', [
|
||||
'count' => count($passwords)
|
||||
]);
|
||||
}
|
||||
|
||||
return $passwords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate sentence according to rules
|
||||
*/
|
||||
private function validateSentence(string $sentence, bool $isAiGenerated): void
|
||||
{
|
||||
$minWords = $isAiGenerated ? 12 : 8;
|
||||
|
||||
if (strlen(trim($sentence)) === 0) {
|
||||
throw new InvalidArgumentException('Sentence cannot be empty');
|
||||
}
|
||||
|
||||
if ($sentence[0] !== strtoupper($sentence[0])) {
|
||||
throw new InvalidArgumentException(
|
||||
'Sentence must start with a capital letter'
|
||||
);
|
||||
}
|
||||
|
||||
if (!preg_match('/[A-Z]/', $sentence)) {
|
||||
throw new InvalidArgumentException(
|
||||
'Sentence must contain at least one capitalized letter'
|
||||
);
|
||||
}
|
||||
|
||||
$words = preg_split('/\s+/', trim($sentence), -1, PREG_SPLIT_NO_EMPTY);
|
||||
$wordCount = count($words);
|
||||
|
||||
if ($wordCount < $minWords) {
|
||||
throw new InvalidArgumentException(
|
||||
"Sentence must contain at least {$minWords} words (found {$wordCount})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate sentence using LLM
|
||||
*/
|
||||
private function generateAiSentence(array $options): string
|
||||
{
|
||||
$provider = $options['ai_provider'] ?? null;
|
||||
$model = $options['ai_model'] ?? null;
|
||||
$prompt = $options['ai_prompt'] ?? 'Generate a creative, memorable sentence.';
|
||||
|
||||
// Ensure minimum 12 words
|
||||
$fullPrompt = $prompt . " The sentence must contain at least 12 words.";
|
||||
|
||||
if ($this->logger && $this->isLogLevel('debug')) {
|
||||
$this->logger->debug('PhraseAbbreviator: requesting AI-generated sentence', [
|
||||
'provider' => $provider,
|
||||
'model' => $model
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$sentence = $this->llmManager->generateText(
|
||||
$fullPrompt,
|
||||
$provider,
|
||||
['model' => $model]
|
||||
);
|
||||
|
||||
// Clean up sentence
|
||||
$sentence = trim($sentence);
|
||||
$sentence = rtrim($sentence, '.');
|
||||
$sentence = ucfirst($sentence);
|
||||
|
||||
if ($this->logger && $this->isLogLevel('debug')) {
|
||||
$this->logger->debug('PhraseAbbreviator: AI sentence generated', [
|
||||
'word_count' => count(preg_split('/\s+/', $sentence))
|
||||
]);
|
||||
}
|
||||
|
||||
return $sentence;
|
||||
} catch (\Exception $e) {
|
||||
if ($this->logger) {
|
||||
$this->logger->error('PhraseAbbreviator: AI generation failed', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
throw new RuntimeException(
|
||||
"Failed to generate sentence from AI: {$e->getMessage()}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate password from sentence
|
||||
*/
|
||||
private function generatePasswordFromSentence(
|
||||
string $sentence,
|
||||
string $extractionType,
|
||||
string $language
|
||||
): string {
|
||||
// Split into words
|
||||
$words = preg_split('/\s+/', trim($sentence), -1, PREG_SPLIT_NO_EMPTY);
|
||||
|
||||
$passwordChars = [];
|
||||
|
||||
foreach ($words as $word) {
|
||||
// Remove leading/trailing punctuation but keep internal
|
||||
$cleanWord = $word;
|
||||
$leadingPunctuation = '';
|
||||
$trailingPunctuation = '';
|
||||
|
||||
// Extract trailing punctuation
|
||||
while (
|
||||
strlen($cleanWord) > 0 &&
|
||||
!ctype_alnum($cleanWord[strlen($cleanWord) - 1]) &&
|
||||
$cleanWord[strlen($cleanWord) - 1] !== "'"
|
||||
) {
|
||||
$trailingPunctuation = substr($cleanWord, -1) . $trailingPunctuation;
|
||||
$cleanWord = substr($cleanWord, 0, -1);
|
||||
}
|
||||
|
||||
// Extract leading punctuation
|
||||
while (strlen($cleanWord) > 0 && !ctype_alnum($cleanWord[0])) {
|
||||
$leadingPunctuation .= $cleanWord[0];
|
||||
$cleanWord = substr($cleanWord, 1);
|
||||
}
|
||||
|
||||
if (strlen($cleanWord) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract characters based on type
|
||||
if ($extractionType === 'dual') {
|
||||
$chars = $this->extractDualCharacters($cleanWord);
|
||||
} else {
|
||||
$chars = $cleanWord[0]; // Single character (first letter)
|
||||
}
|
||||
|
||||
$passwordChars[] = $leadingPunctuation . $chars . $trailingPunctuation;
|
||||
}
|
||||
|
||||
return implode('', $passwordChars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract dual characters from word
|
||||
*
|
||||
* Word starts with vowel: first letter + first consonant
|
||||
* Word starts with consonant: first letter + first vowel
|
||||
* Fallback (no vowel/consonant found): first two letters
|
||||
*/
|
||||
private function extractDualCharacters(string $word): string
|
||||
{
|
||||
if (strlen($word) < 2) {
|
||||
return $word;
|
||||
}
|
||||
|
||||
$firstChar = $word[0];
|
||||
|
||||
if ($this->isVowel($firstChar)) {
|
||||
// Word starts with vowel: first letter + first consonant
|
||||
for ($i = 1; $i < strlen($word); $i++) {
|
||||
if ($this->isConsonant($word[$i])) {
|
||||
return $firstChar . $word[$i];
|
||||
}
|
||||
}
|
||||
// No consonant found, return first two letters
|
||||
return substr($word, 0, 2);
|
||||
} else {
|
||||
// Word starts with consonant: first letter + first vowel
|
||||
for ($i = 1; $i < strlen($word); $i++) {
|
||||
if ($this->isVowel($word[$i])) {
|
||||
return $firstChar . $word[$i];
|
||||
}
|
||||
}
|
||||
// No vowel found, return first two letters
|
||||
return substr($word, 0, 2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if character is a vowel
|
||||
*/
|
||||
private function isVowel(string $char): bool
|
||||
{
|
||||
return strpos(self::VOWELS, $char) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if character is a consonant
|
||||
*/
|
||||
private function isConsonant(string $char): bool
|
||||
{
|
||||
return strpos(self::CONSONANTS, $char) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace number words using translations
|
||||
*
|
||||
* Loads number words from translation file and replaces all occurrences
|
||||
* in the sentence with their digit equivalents
|
||||
*/
|
||||
private function replaceNumberWords(string $sentence, string $language): string
|
||||
{
|
||||
// Load number words from translation file
|
||||
$numberWords = $this->loadNumberWordsFromTranslations($language);
|
||||
|
||||
if (empty($numberWords)) {
|
||||
if ($this->logger && $this->isLogLevel('debug')) {
|
||||
$this->logger->debug('PhraseAbbreviator: no number words found for language', [
|
||||
'language' => $language
|
||||
]);
|
||||
}
|
||||
return $sentence;
|
||||
}
|
||||
|
||||
// Replace each number word with its digit equivalent
|
||||
foreach ($numberWords as $word => $digit) {
|
||||
// Create regex pattern for word boundary matching
|
||||
// This ensures 'one' doesn't match inside 'stone'
|
||||
$pattern = '/\b' . preg_quote($word, '/') . '\b/i';
|
||||
|
||||
// Replace all occurrences (case-insensitive)
|
||||
$sentence = preg_replace($pattern, $digit, $sentence);
|
||||
}
|
||||
|
||||
return $sentence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load number words from translation file
|
||||
*
|
||||
* Searches translation file for keys matching pattern: phraseabbreviator_number_{name}
|
||||
* Where {name} is: zero, one, two, three... twenty
|
||||
*/
|
||||
private function loadNumberWordsFromTranslations(string $language): array
|
||||
{
|
||||
// Construct path to locale file
|
||||
$localeDir = __DIR__ . '/../../locales';
|
||||
$localeFile = "{$localeDir}/{$language}.json";
|
||||
|
||||
// Return empty array if file doesn't exist
|
||||
if (!file_exists($localeFile)) {
|
||||
if ($this->logger) {
|
||||
$this->logger->warning('PhraseAbbreviator: translation file not found', [
|
||||
'language' => $language,
|
||||
'file' => $localeFile
|
||||
]);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Load JSON file
|
||||
$content = file_get_contents($localeFile);
|
||||
if ($content === false) {
|
||||
if ($this->logger) {
|
||||
$this->logger->warning('PhraseAbbreviator: failed to read translation file', [
|
||||
'file' => $localeFile
|
||||
]);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Decode JSON
|
||||
$translations = json_decode($content, true);
|
||||
if ($translations === null || !is_array($translations)) {
|
||||
if ($this->logger) {
|
||||
$this->logger->warning('PhraseAbbreviator: failed to decode translation file', [
|
||||
'file' => $localeFile
|
||||
]);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extract number words
|
||||
$numberWords = [];
|
||||
$prefix = 'phraseabbreviator_number_';
|
||||
|
||||
foreach ($translations as $key => $value) {
|
||||
// Check if key starts with prefix
|
||||
if (str_starts_with($key, $prefix)) {
|
||||
// Extract digit name from key
|
||||
// Example: 'phraseabbreviator_number_one' -> 'one'
|
||||
$digitName = substr($key, strlen($prefix));
|
||||
|
||||
// Convert digit name to actual digit
|
||||
$digit = $this->digitFromKeyName($digitName);
|
||||
|
||||
// Add to mapping if digit was successfully converted
|
||||
if ($digit !== null) {
|
||||
$numberWords[$value] = $digit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->logger && $this->isLogLevel('debug')) {
|
||||
$this->logger->debug('PhraseAbbreviator: loaded number words', [
|
||||
'language' => $language,
|
||||
'count' => count($numberWords)
|
||||
]);
|
||||
}
|
||||
|
||||
return $numberWords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert digit key name to digit string
|
||||
*
|
||||
* Maps word names to their numeric equivalents
|
||||
*/
|
||||
private function digitFromKeyName(string $keyName): ?string
|
||||
{
|
||||
// Map of key names to digits
|
||||
$digitMap = [
|
||||
'zero' => '0',
|
||||
'one' => '1',
|
||||
'two' => '2',
|
||||
'three' => '3',
|
||||
'four' => '4',
|
||||
'five' => '5',
|
||||
'six' => '6',
|
||||
'seven' => '7',
|
||||
'eight' => '8',
|
||||
'nine' => '9',
|
||||
'ten' => '10',
|
||||
'eleven' => '11',
|
||||
'twelve' => '12',
|
||||
'thirteen' => '13',
|
||||
'fourteen' => '14',
|
||||
'fifteen' => '15',
|
||||
'sixteen' => '16',
|
||||
'seventeen' => '17',
|
||||
'eighteen' => '18',
|
||||
'nineteen' => '19',
|
||||
'twenty' => '20'
|
||||
];
|
||||
|
||||
return $digitMap[$keyName] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add random number to password
|
||||
*/
|
||||
private function addRandomNumber(
|
||||
string $password,
|
||||
int $digits,
|
||||
string $position
|
||||
): string {
|
||||
$max = (int)str_repeat('9', $digits);
|
||||
$number = random_int(0, $max);
|
||||
$paddedNumber = str_pad((string)$number, $digits, '0', STR_PAD_LEFT);
|
||||
|
||||
if ($position === 'prepend') {
|
||||
return $paddedNumber . $password;
|
||||
} else {
|
||||
return $password . $paddedNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Generators;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use App\Services\Logging\LoggingHelper;
|
||||
|
||||
/**
|
||||
* PWGen Enhanced Generator v3.2
|
||||
*
|
||||
* Generates pronounceable passwords with intelligent syllable-based separation
|
||||
* based on phonetic patterns (VC, CV, VCC, CVC, VVC, CVV).
|
||||
*
|
||||
* Features:
|
||||
* - Native pwgen executable support with comprehensive options
|
||||
* - PHP fallback with phonetic syllable separation using PwGenPhpNg
|
||||
* - Intelligent syllable splitting based on vowel/consonant patterns
|
||||
* - Random separator selection for EACH separator position
|
||||
* - Automatic number placement at syllable endings
|
||||
* - No leading/trailing separators
|
||||
*
|
||||
* @package App\Generators
|
||||
*/
|
||||
class PwgenGenerator extends AbstractPasswordGenerator
|
||||
{
|
||||
private const GENERATOR_ID = 'pwgen';
|
||||
private const MIN_SYLLABLE_LENGTH = 2;
|
||||
private const MAX_SYLLABLE_LENGTH = 4;
|
||||
private const VOWELS = 'aeiouAEIOU';
|
||||
|
||||
private ?object $pwgenFallback = null;
|
||||
|
||||
/**
|
||||
* Constructor with optional logger
|
||||
*/
|
||||
public function __construct(?LoggerInterface $logger = null)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return self::GENERATOR_ID;
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return '2.1.0';
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'PWGen Enhanced';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Generates pronounceable passwords with intelligent syllable-based separation using phonetic patterns.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get option definitions
|
||||
*/
|
||||
public function getOptionDefinitions(): array
|
||||
{
|
||||
$options = [
|
||||
'length' => [
|
||||
'type' => 'integer',
|
||||
'default' => 8,
|
||||
'min' => 4,
|
||||
'max' => 32,
|
||||
'required' => false,
|
||||
'group' => 'basic',
|
||||
'description' => 'Total length of the generated password'
|
||||
],
|
||||
'separator' => [
|
||||
'type' => 'string',
|
||||
'default' => '',
|
||||
'maxLength' => 10,
|
||||
'required' => false,
|
||||
'group' => 'advanced',
|
||||
'description' => 'Characters to use as separators between syllables. If multiple characters are provided, one will be chosen at random for each separator position.'
|
||||
],
|
||||
'include_numbers' => [
|
||||
'type' => 'boolean',
|
||||
'default' => true,
|
||||
'required' => false,
|
||||
'group' => 'character_set',
|
||||
'description' => 'Whether to include numbers in the generated password'
|
||||
],
|
||||
'include_capitals' => [
|
||||
'type' => 'boolean',
|
||||
'default' => true,
|
||||
'required' => false,
|
||||
'group' => 'character_set',
|
||||
'description' => 'Whether to include capital letters in the generated password'
|
||||
],
|
||||
'secure_mode' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'required' => false,
|
||||
'group' => 'advanced',
|
||||
'description' => 'Whether to use secure mode (fully random) generation'
|
||||
],
|
||||
'no_ambiguous' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'required' => false,
|
||||
'group' => 'advanced',
|
||||
'description' => 'Whether to exclude ambiguous characters (like 0/O, 1/l, etc.) from the generated password'
|
||||
]
|
||||
];
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public function validateOptions(array $options): array
|
||||
{
|
||||
return parent::validateOptions($options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate passwords
|
||||
*/
|
||||
public function generate(int $count = 1, array $options = []): array
|
||||
{
|
||||
// Validate options using parent class method
|
||||
$options = $this->validateOptions($options);
|
||||
$length = $options['length'];
|
||||
$separator = $options['separator'];
|
||||
|
||||
if ($this->logger && $this->isLogLevel('debug')) {
|
||||
$this->logger->debug('Pwgen: starting generation', [
|
||||
'count' => $count,
|
||||
'length' => $length,
|
||||
'has_separator' => !empty($separator)
|
||||
]);
|
||||
}
|
||||
LoggingHelper::debug($this->logger, $this->getId(), 'Message');
|
||||
|
||||
try {
|
||||
// If separator is provided, use syllable splitting; otherwise use standard generation
|
||||
if (!empty($separator)) {
|
||||
return $this->generateWithSyllableSeparation($length, $count, $separator, $options);
|
||||
} else {
|
||||
// Use native pwgen or PHP fallback without separation
|
||||
if ($this->isPwgenCommandAvailable()) {
|
||||
return $this->generateWithNativePwgen($length, $count, $options);
|
||||
} else {
|
||||
return $this->generateWithPhpFallback($length, $count, $options);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
if ($this->logger) {
|
||||
$this->logger->error('Pwgen: generation failed', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
throw new RuntimeException(
|
||||
"PWGen password generation failed: " . $e->getMessage(),
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate passwords using native pwgen executable
|
||||
*/
|
||||
private function generateWithNativePwgen(int $length, int $count, array $options): array
|
||||
{
|
||||
$command = ['pwgen'];
|
||||
|
||||
// Add pwgen flags based on options
|
||||
if ($options['include_capitals']) {
|
||||
$command[] = '-c'; // Include capital letters
|
||||
} else {
|
||||
$command[] = '-A'; // No capitals
|
||||
}
|
||||
|
||||
if ($options['include_numbers']) {
|
||||
$command[] = '-n'; // Include numbers
|
||||
} else {
|
||||
$command[] = '-0'; // No numbers
|
||||
}
|
||||
|
||||
if ($options['secure_mode']) {
|
||||
$command[] = '-s'; // Secure (fully random)
|
||||
}
|
||||
|
||||
if ($options['no_ambiguous']) {
|
||||
$command[] = '-B'; // No ambiguous characters
|
||||
}
|
||||
|
||||
// Add length and count
|
||||
$command[] = (string)$length;
|
||||
$command[] = (string)$count;
|
||||
|
||||
// Execute command
|
||||
$result = shell_exec(escapeshellcmd(implode(' ', $command)));
|
||||
|
||||
if ($result === null || $result === '') {
|
||||
throw new RuntimeException('PWGen command execution failed');
|
||||
}
|
||||
|
||||
$passwords = array_filter(explode("\n", trim($result)));
|
||||
|
||||
if ($this->logger && $this->isLogLevel('debug')) {
|
||||
$this->logger->debug('Pwgen: native pwgen generated', ['count' => count($passwords)]);
|
||||
}
|
||||
|
||||
// Return array of password objects
|
||||
return array_map(function ($password) {
|
||||
return [
|
||||
'password' => $password,
|
||||
'method' => 'native_pwgen'
|
||||
];
|
||||
}, $passwords);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate passwords with PHP fallback (no separation)
|
||||
*/
|
||||
private function generateWithPhpFallback(int $length, int $count, array $options): array
|
||||
{
|
||||
$this->initializePwgenFallback();
|
||||
$passwords = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
// Generate password using PHP fallback
|
||||
$generatedPasswords = $this->pwgenFallback->generate(
|
||||
$length,
|
||||
1,
|
||||
$options['include_numbers'],
|
||||
$options['include_capitals']
|
||||
);
|
||||
|
||||
$passwords[] = [
|
||||
'password' => $generatedPasswords[0],
|
||||
'method' => 'php_fallback'
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->logger && $this->isLogLevel('debug')) {
|
||||
$this->logger->debug('Pwgen: PHP fallback generated', ['count' => count($passwords)]);
|
||||
}
|
||||
|
||||
return $passwords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate passwords with intelligent syllable separation
|
||||
*/
|
||||
private function generateWithSyllableSeparation(int $length, int $count, string $separator, array $options): array
|
||||
{
|
||||
$passwords = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
// Generate base password first (without separator)
|
||||
$baseLength = $length;
|
||||
|
||||
if ($this->isPwgenCommandAvailable() && !$options['secure_mode']) {
|
||||
// Use native pwgen for base password
|
||||
$result = $this->generateWithNativePwgen($baseLength, 1, $options);
|
||||
$basePassword = trim($result[0]['password']);
|
||||
} else {
|
||||
// Use PHP fallback
|
||||
$this->initializePwgenFallback();
|
||||
$generated = $this->pwgenFallback->generate(
|
||||
$baseLength,
|
||||
1,
|
||||
$options['include_numbers'],
|
||||
$options['include_capitals']
|
||||
);
|
||||
$basePassword = $generated[0];
|
||||
}
|
||||
|
||||
// Split into phonetic syllables
|
||||
$syllables = $this->splitIntoPhoneticSyllables($basePassword);
|
||||
|
||||
// Join syllables with random separator selection for each position
|
||||
$finalPassword = $this->joinSyllablesWithRandomSeparators($syllables, $separator);
|
||||
|
||||
// Trim to target length if needed (but preserve complete syllables when possible)
|
||||
if (strlen($finalPassword) > $length) {
|
||||
$finalPassword = $this->trimToLength($finalPassword, $length, $separator);
|
||||
}
|
||||
|
||||
// Extract used separators for display
|
||||
$usedSeparators = $this->extractUsedSeparators($finalPassword, $syllables);
|
||||
|
||||
$passwords[] = [
|
||||
'password' => $finalPassword,
|
||||
'syllables' => $syllables,
|
||||
'separator_used' => implode('', array_unique($usedSeparators)),
|
||||
'syllable_count' => count($syllables),
|
||||
'method' => 'syllable_separated'
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->logger && $this->isLogLevel('info')) {
|
||||
$this->logger->info('Pwgen: syllable-separated passwords generated', [
|
||||
'count' => count($passwords),
|
||||
'separator' => $separator
|
||||
]);
|
||||
}
|
||||
|
||||
return $passwords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join syllables with random separator selection for EACH separator position
|
||||
*
|
||||
* Ensures no leading or trailing separators
|
||||
*/
|
||||
private function joinSyllablesWithRandomSeparators(array $syllables, string $separatorChars): string
|
||||
{
|
||||
if (count($syllables) === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (count($syllables) === 1) {
|
||||
return $syllables[0]; // No separator needed for single syllable
|
||||
}
|
||||
|
||||
// Get available separator characters (excluding spaces)
|
||||
$availableChars = str_split($separatorChars);
|
||||
$availableChars = array_filter($availableChars, fn($char) => $char !== ' ');
|
||||
|
||||
if (empty($availableChars)) {
|
||||
// Fallback to dash if no valid separators
|
||||
$availableChars = ['-'];
|
||||
}
|
||||
|
||||
$result = '';
|
||||
$syllableCount = count($syllables);
|
||||
|
||||
for ($i = 0; $i < $syllableCount; $i++) {
|
||||
// Add the syllable
|
||||
$result .= $syllables[$i];
|
||||
|
||||
// Add random separator BETWEEN syllables (not after last)
|
||||
if ($i < $syllableCount - 1) {
|
||||
// Select random separator for THIS position
|
||||
$randomSeparator = $availableChars[array_rand($availableChars)];
|
||||
$result .= $randomSeparator;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim password to target length while preserving structure
|
||||
*/
|
||||
private function trimToLength(string $password, int $targetLength, string $separatorChars): string
|
||||
{
|
||||
if (strlen($password) <= $targetLength) {
|
||||
return $password;
|
||||
}
|
||||
|
||||
// Simple trim, ensuring no trailing separator
|
||||
$trimmed = substr($password, 0, $targetLength);
|
||||
|
||||
// Remove trailing separator if present
|
||||
$separatorArray = str_split($separatorChars);
|
||||
$separatorArray = array_filter($separatorArray, fn($char) => $char !== ' ');
|
||||
|
||||
while (strlen($trimmed) > 0 && in_array(substr($trimmed, -1), $separatorArray)) {
|
||||
$trimmed = substr($trimmed, 0, -1);
|
||||
}
|
||||
|
||||
return $trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract separators actually used in the password
|
||||
*/
|
||||
private function extractUsedSeparators(string $password, array $syllables): array
|
||||
{
|
||||
$used = [];
|
||||
// Simple extraction: find chars that aren't in any syllable
|
||||
$syllableChars = implode('', $syllables);
|
||||
|
||||
foreach (str_split($password) as $char) {
|
||||
if (strpos($syllableChars, $char) === false && !in_array($char, $used)) {
|
||||
$used[] = $char;
|
||||
}
|
||||
}
|
||||
|
||||
return $used;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split password into phonetic syllables based on vowel/consonant patterns
|
||||
*
|
||||
* Preferred patterns (longer syllables first):
|
||||
* - CVV (Consonant-Vowel-Vowel)
|
||||
* - VVC (Vowel-Vowel-Consonant)
|
||||
* - CVC (Consonant-Vowel-Consonant)
|
||||
* - VCC (Vowel-Consonant-Consonant)
|
||||
* - CV (Consonant-Vowel)
|
||||
* - VC (Vowel-Consonant)
|
||||
* - Numbers preferably at syllable end
|
||||
*/
|
||||
private function splitIntoPhoneticSyllables(string $password): array
|
||||
{
|
||||
$syllables = [];
|
||||
$currentSyllable = '';
|
||||
$chars = str_split($password);
|
||||
$length = count($chars);
|
||||
$i = 0;
|
||||
|
||||
while ($i < $length) {
|
||||
$char = $chars[$i];
|
||||
$currentSyllable .= $char;
|
||||
|
||||
// Check if current syllable should end
|
||||
$shouldEnd = false;
|
||||
$syllableLen = strlen($currentSyllable);
|
||||
|
||||
// Minimum syllable length check
|
||||
if ($syllableLen >= self::MIN_SYLLABLE_LENGTH) {
|
||||
// Look ahead to decide
|
||||
$pattern = $this->getPhoneticPattern($currentSyllable);
|
||||
$nextChar = ($i + 1 < $length) ? $chars[$i + 1] : null;
|
||||
|
||||
// End syllable if:
|
||||
// 1. We have a complete pattern (CVV, CVC, etc.)
|
||||
// 2. Maximum length reached
|
||||
// 3. Number at end of syllable
|
||||
// 4. Good breaking point
|
||||
|
||||
if ($syllableLen >= self::MAX_SYLLABLE_LENGTH) {
|
||||
$shouldEnd = true;
|
||||
} elseif ($this->isNumber($char) && $nextChar && !$this->isNumber($nextChar)) {
|
||||
// Number at end, good break point
|
||||
$shouldEnd = true;
|
||||
} elseif ($this->isCompletePattern($pattern) && $syllableLen >= 3) {
|
||||
// Have a good 3+ char pattern
|
||||
$shouldEnd = true;
|
||||
} elseif ($syllableLen >= 2 && $nextChar === null) {
|
||||
// End of password
|
||||
$shouldEnd = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($shouldEnd && strlen($currentSyllable) >= self::MIN_SYLLABLE_LENGTH) {
|
||||
$syllables[] = $currentSyllable;
|
||||
$currentSyllable = '';
|
||||
}
|
||||
|
||||
$i++;
|
||||
}
|
||||
|
||||
// Add remaining characters
|
||||
if (!empty($currentSyllable)) {
|
||||
if (count($syllables) > 0 && strlen($currentSyllable) < self::MIN_SYLLABLE_LENGTH) {
|
||||
// Merge short remainder with last syllable
|
||||
$syllables[count($syllables) - 1] .= $currentSyllable;
|
||||
} else {
|
||||
$syllables[] = $currentSyllable;
|
||||
}
|
||||
}
|
||||
|
||||
return $syllables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get phonetic pattern of a string (V=vowel, C=consonant, N=number)
|
||||
*/
|
||||
private function getPhoneticPattern(string $str): string
|
||||
{
|
||||
$pattern = '';
|
||||
|
||||
foreach (str_split($str) as $char) {
|
||||
if ($this->isNumber($char)) {
|
||||
$pattern .= 'N';
|
||||
} elseif ($this->isVowel($char)) {
|
||||
$pattern .= 'V';
|
||||
} else {
|
||||
$pattern .= 'C';
|
||||
}
|
||||
}
|
||||
|
||||
return $pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if pattern is complete/good for syllable breaking
|
||||
*/
|
||||
private function isCompletePattern(string $pattern): bool
|
||||
{
|
||||
$goodPatterns = ['CVV', 'VVC', 'CVC', 'VCC', 'CVN', 'VCN', 'CVCN'];
|
||||
return in_array($pattern, $goodPatterns);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if character is a vowel
|
||||
*/
|
||||
private function isVowel(string $char): bool
|
||||
{
|
||||
return strpos(self::VOWELS, $char) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if character is a number
|
||||
*/
|
||||
private function isNumber(string $char): bool
|
||||
{
|
||||
return is_numeric($char);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if native pwgen command is available
|
||||
*/
|
||||
private function isPwgenCommandAvailable(): bool
|
||||
{
|
||||
static $available = null;
|
||||
|
||||
if ($available === null) {
|
||||
$which = shell_exec('which pwgen 2>/dev/null');
|
||||
$available = !empty($which) && is_executable(trim($which));
|
||||
|
||||
// Alternative check for Windows
|
||||
if (!$available && PHP_OS_FAMILY === 'Windows') {
|
||||
$where = shell_exec('where pwgen 2>nul');
|
||||
$available = !empty($where);
|
||||
}
|
||||
}
|
||||
|
||||
return $available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize PHP fallback if not already done
|
||||
*
|
||||
* Uses the PwGenPhpNg class at src/Generators/Classes/PwGenPhpNg.class.php
|
||||
*/
|
||||
private function initializePwgenFallback(): void
|
||||
{
|
||||
if ($this->pwgenFallback === null) {
|
||||
require_once __DIR__ . '/Classes/PwGenPhpNg.class.php';
|
||||
$this->pwgenFallback = new \PwGenPhpNg();
|
||||
|
||||
if ($this->logger && $this->isLogLevel('debug')) {
|
||||
$this->logger->debug('Pwgen: PHP fallback initialized');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate entropy estimate for current PWGen option set.
|
||||
*/
|
||||
public function calculateEntropyForOptions(array $options): float
|
||||
{
|
||||
$breakdown = $this->getEntropyBreakdownForOptions($options);
|
||||
return (float) ($breakdown['entropy_bits'] ?? 0.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed entropy contributors for API transparency.
|
||||
*/
|
||||
public function getEntropyBreakdownForOptions(array $options): array
|
||||
{
|
||||
$validated = $this->validateOptions($options);
|
||||
|
||||
$length = max(1, (int) ($validated['length'] ?? 8));
|
||||
$secureMode = (bool) ($validated['secure_mode'] ?? false);
|
||||
$includeNumbers = (bool) ($validated['include_numbers'] ?? true);
|
||||
$includeCapitals = (bool) ($validated['include_capitals'] ?? true);
|
||||
$noAmbiguous = (bool) ($validated['no_ambiguous'] ?? false);
|
||||
$separatorChars = str_replace(' ', '', (string) ($validated['separator'] ?? ''));
|
||||
|
||||
$charsetSize = 26; // lowercase always available
|
||||
if ($includeCapitals) {
|
||||
$charsetSize += 26;
|
||||
}
|
||||
if ($includeNumbers) {
|
||||
$charsetSize += 10;
|
||||
}
|
||||
|
||||
$symbolEntropyBits = 0.0;
|
||||
if ($separatorChars !== '') {
|
||||
$separatorAlphabet = count(array_unique(str_split($separatorChars)));
|
||||
$separatorSlotsEstimate = max(1, (int) floor($length / 3));
|
||||
if ($separatorAlphabet > 1) {
|
||||
$symbolEntropyBits = $separatorSlotsEstimate * log((float) $separatorAlphabet, 2);
|
||||
}
|
||||
}
|
||||
|
||||
if ($noAmbiguous) {
|
||||
// Approximate reduced alphabet due to ambiguity filtering.
|
||||
$charsetSize = max(24, (int) floor($charsetSize * 0.85));
|
||||
}
|
||||
|
||||
// Secure mode approximates random selection over active charset.
|
||||
$baseEntropyBits = $length * log((float) $charsetSize, 2);
|
||||
|
||||
// Pronounceable mode has structure constraints; apply conservative penalty.
|
||||
$structurePenaltyBits = $secureMode ? 0.0 : (0.35 * $length);
|
||||
$totalEntropyBits = max(0.0, $baseEntropyBits - $structurePenaltyBits + $symbolEntropyBits);
|
||||
|
||||
return [
|
||||
'entropy_model' => 'pwgen-structured-heuristic-v1',
|
||||
'model_confidence' => $secureMode ? 'medium' : 'low',
|
||||
'length' => $length,
|
||||
'secure_mode' => $secureMode,
|
||||
'include_numbers' => $includeNumbers,
|
||||
'include_capitals' => $includeCapitals,
|
||||
'no_ambiguous' => $noAmbiguous,
|
||||
'estimated_charset_size' => $charsetSize,
|
||||
'base_entropy_bits' => round($baseEntropyBits, 1),
|
||||
'structure_penalty_bits' => round($structurePenaltyBits, 1),
|
||||
'separator_entropy_bits' => round($symbolEntropyBits, 1),
|
||||
'entropy_bits' => round($totalEntropyBits, 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,878 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Generators;
|
||||
|
||||
use App\Services\LLM\LLMManager;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
|
||||
/**
|
||||
* XKCD Pass Generator v2.4
|
||||
*
|
||||
* Generates memorable passphrases from curated wordlists.
|
||||
* Supports traditional wordlist selection or AI-generated custom wordlists via LLM.
|
||||
*
|
||||
* Features:
|
||||
* - Multiple language wordlists
|
||||
* - Intelligent word filtering (length, spaces)
|
||||
* - Random separator selection for each position
|
||||
* - Optional number insertion
|
||||
* - Entropy calculation
|
||||
* - AI-powered custom wordlist generation
|
||||
*
|
||||
* @package App\Generators
|
||||
*/
|
||||
class XkcdPassGenerator extends AbstractPasswordGenerator
|
||||
{
|
||||
private const GENERATOR_ID = 'xkcdpass';
|
||||
private const DEFAULT_WORDLISTS_DIR = 'data/wordlists';
|
||||
|
||||
private array $wordlists = [];
|
||||
private array $availableWordlists = [];
|
||||
private array $wordlistFileMap = [];
|
||||
private ?LLMManager $llmManager = null;
|
||||
|
||||
/**
|
||||
* Constructor with optional LLM Manager and logger
|
||||
*/
|
||||
|
||||
public function __construct(?LLMManager $llmManager = null, ?LoggerInterface $logger = null)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->llmManager = $llmManager;
|
||||
$this->initializeWordlists();
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return self::GENERATOR_ID;
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return '2.4.0';
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'XKCD Pass';
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Memorable passphrases using word combinations with enhanced filtering and separator options';
|
||||
}
|
||||
|
||||
public function getAvailableLanguages(): array
|
||||
{
|
||||
$languages = [];
|
||||
foreach ($this->availableWordlists as $code => $label) {
|
||||
$filename = $this->getWordlistFilename($code);
|
||||
$path = $this->getWordlistPath() . '/' . $filename;
|
||||
|
||||
$languages[$code] = [
|
||||
'name' => $label,
|
||||
'description' => 'Wordlist for ' . $label,
|
||||
'file' => $filename,
|
||||
'available' => file_exists($path) && is_readable($path),
|
||||
'fallback' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $languages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get option definitions with AI wordlist support
|
||||
*/
|
||||
public function getOptionDefinitions(): array
|
||||
{
|
||||
$options = [
|
||||
'words' => [
|
||||
'type' => 'integer',
|
||||
'default' => 4,
|
||||
'min' => 2,
|
||||
'max' => 8,
|
||||
'required' => false,
|
||||
'group' => 'basic',
|
||||
'description' => 'Number of words to include in the passphrase'
|
||||
],
|
||||
'wordlist' => [
|
||||
'type' => 'select',
|
||||
'default' => 'en',
|
||||
'choices' => $this->getAvailableWordlistChoices(),
|
||||
'translateChoices' => false,
|
||||
'required' => false,
|
||||
'group' => 'basic',
|
||||
'description' => 'Wordlist to use for generating the passphrase'
|
||||
],
|
||||
'separator' => [
|
||||
'type' => 'string',
|
||||
'default' => '-',
|
||||
'maxLength' => 5,
|
||||
'required' => false,
|
||||
'group' => 'basic',
|
||||
'description' => 'Separator character(s) between words. If multiple, one is randomly selected for each position'
|
||||
],
|
||||
'max_word_length' => [
|
||||
'type' => 'integer',
|
||||
'default' => 0,
|
||||
'min' => 0,
|
||||
'max' => 30,
|
||||
'required' => false,
|
||||
'group' => 'advanced',
|
||||
'description' => 'Maximum length for words (0 = no limit)'
|
||||
],
|
||||
'space_handling' => [
|
||||
'type' => 'select',
|
||||
'default' => 'allow',
|
||||
'choices' => [
|
||||
['value' => 'allow', 'label' => 'Allow'],
|
||||
['value' => 'skip', 'label' => 'Skip'],
|
||||
['value' => 'modify', 'label' => 'Modify']
|
||||
],
|
||||
'translateChoices' => true,
|
||||
'required' => false,
|
||||
'group' => 'advanced',
|
||||
'description' => 'How to handle words containing spaces: allow as-is, skip them, or modify to underscores'
|
||||
],
|
||||
'add_number' => [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'required' => false,
|
||||
'group' => 'advanced',
|
||||
'description' => 'Add random number to the passphrase'
|
||||
],
|
||||
'number_position' => [
|
||||
'type' => 'select',
|
||||
'default' => 'end',
|
||||
'choices' => [
|
||||
['value' => 'beginning', 'label' => 'Beginning'],
|
||||
['value' => 'end', 'label' => 'End'],
|
||||
['value' => 'random', 'label' => 'Random']
|
||||
],
|
||||
'translateChoices' => true,
|
||||
'required' => false,
|
||||
'group' => 'advanced',
|
||||
'description' => 'Where to place the random number'
|
||||
]
|
||||
];
|
||||
|
||||
// Add AI wordlist generation options if LLM available
|
||||
if ($this->llmManager && $this->llmManager->hasAvailableProvider()) {
|
||||
$options['use_ai_wordlist'] = [
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'required' => false,
|
||||
'group' => 'ai',
|
||||
'description' => 'Generate custom wordlist using AI'
|
||||
];
|
||||
|
||||
$options['ai_wordlist_prompt'] = [
|
||||
'type' => 'string',
|
||||
'default' => '',
|
||||
'maxLength' => 500,
|
||||
'required' => false,
|
||||
'group' => 'ai',
|
||||
'description' => 'Complete the prompt: "Create a list of X words of at least 5 characters, which..."'
|
||||
];
|
||||
|
||||
// Get available LLM providers
|
||||
$providers = $this->llmManager->getAvailableProviders();
|
||||
if (!empty($providers)) {
|
||||
$providerChoices = [];
|
||||
foreach ($providers as $name => $info) {
|
||||
$providerChoices[] = [
|
||||
'value' => $name,
|
||||
'label' => $info['display_name']
|
||||
];
|
||||
}
|
||||
|
||||
$options['ai_provider'] = [
|
||||
'type' => 'select',
|
||||
'default' => $this->llmManager->getDefaultProviderName(),
|
||||
'choices' => $providerChoices,
|
||||
'translateChoices' => false,
|
||||
'required' => false,
|
||||
'group' => 'ai',
|
||||
'description' => 'Which LLM provider to use'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wordlist choices for UI
|
||||
*/
|
||||
private function getAvailableWordlistChoices(): array
|
||||
{
|
||||
$choices = [];
|
||||
foreach ($this->availableWordlists as $code => $name) {
|
||||
$choices[] = [
|
||||
'value' => $code,
|
||||
'label' => $name
|
||||
];
|
||||
}
|
||||
return $choices;
|
||||
}
|
||||
|
||||
public function validateOptions(array $options): array
|
||||
{
|
||||
$normalized = $options;
|
||||
|
||||
if (isset($normalized['wordlist']) && is_string($normalized['wordlist']) && trim($normalized['wordlist']) === '') {
|
||||
$normalized['wordlist'] = 'en';
|
||||
}
|
||||
|
||||
$validated = parent::validateOptions($normalized);
|
||||
|
||||
if (empty(trim((string) ($validated['wordlist'] ?? '')))) {
|
||||
$validated['wordlist'] = 'en';
|
||||
}
|
||||
|
||||
return $validated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate passphrases
|
||||
*/
|
||||
public function generate(int $count = 1, array $options = []): array
|
||||
{
|
||||
// Validate options using parent class method
|
||||
$options = $this->validateOptions($options);
|
||||
|
||||
if ($count < 1 || $count > 100) {
|
||||
throw new InvalidArgumentException('Count must be between 1 and 100');
|
||||
}
|
||||
|
||||
if ($this->logger && $this->isLogLevel('debug')) {
|
||||
$this->logger->debug('XkcdPass: starting generation', [
|
||||
'count' => $count,
|
||||
'use_ai' => $options['use_ai_wordlist'] ?? false
|
||||
]);
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
try {
|
||||
// Check if using AI-generated wordlist
|
||||
if ($options['use_ai_wordlist'] ?? false) {
|
||||
if (!$this->llmManager) {
|
||||
throw new RuntimeException('LLM manager not configured for AI wordlist generation');
|
||||
}
|
||||
|
||||
$wordlist = $this->generateAiWordlist(
|
||||
$options['words'],
|
||||
$options['ai_wordlist_prompt'] ?? '',
|
||||
$options['ai_provider'] ?? null
|
||||
);
|
||||
} else {
|
||||
$wordlist = $this->getWordlist($options['wordlist']);
|
||||
}
|
||||
|
||||
$results[] = $this->generateSingle($wordlist, $options);
|
||||
} catch (\Exception $e) {
|
||||
if ($this->logger) {
|
||||
$this->logger->error('XkcdPass: generation iteration failed', [
|
||||
'iteration' => $i + 1,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->logger && $this->isLogLevel('info')) {
|
||||
$this->logger->info('XkcdPass: generation complete', [
|
||||
'count' => count($results)
|
||||
]);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate wordlist using LLM
|
||||
*
|
||||
* Calculates total words needed: count × words_per_password
|
||||
* Sends request to LLM to generate that many words
|
||||
*/
|
||||
private function generateAiWordlist(
|
||||
int $wordCount,
|
||||
string $userPrompt,
|
||||
?string $provider
|
||||
): array {
|
||||
// Build complete prompt
|
||||
$basePrompt = "Create a list of $wordCount words of at least 5 characters";
|
||||
|
||||
if (!empty($userPrompt)) {
|
||||
// User completes: "which..."
|
||||
$fullPrompt = $basePrompt . ", which " . trim($userPrompt);
|
||||
} else {
|
||||
$fullPrompt = $basePrompt . ", which are memorable and diverse";
|
||||
}
|
||||
|
||||
$fullPrompt .= ". Return ONLY the words, one per line, without numbers or any formatting.";
|
||||
|
||||
if ($this->logger && $this->isLogLevel('debug')) {
|
||||
$this->logger->debug('XkcdPass: requesting AI wordlist', [
|
||||
'word_count' => $wordCount,
|
||||
'provider' => $provider
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
// Call LLM
|
||||
$response = $this->llmManager->generateText($fullPrompt, $provider);
|
||||
|
||||
// Parse response
|
||||
$words = $this->parseWordList($response, $wordCount);
|
||||
|
||||
if (count($words) < $wordCount) {
|
||||
throw new RuntimeException(
|
||||
"LLM returned only " . count($words) . " words (expected $wordCount)"
|
||||
);
|
||||
}
|
||||
|
||||
// Convert to wordlist format
|
||||
$wordlist = [];
|
||||
foreach (array_slice($words, 0, $wordCount) as $word) {
|
||||
$wordlist[] = [
|
||||
'word' => strtolower($word),
|
||||
'explanation' => 'AI-generated'
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->logger && $this->isLogLevel('debug')) {
|
||||
$this->logger->debug('XkcdPass: AI wordlist generated', [
|
||||
'count' => count($wordlist)
|
||||
]);
|
||||
}
|
||||
|
||||
return $wordlist;
|
||||
} catch (\Exception $e) {
|
||||
if ($this->logger) {
|
||||
$this->logger->error('XkcdPass: AI wordlist generation failed', [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
throw new RuntimeException(
|
||||
"Failed to generate wordlist with LLM: {$e->getMessage()}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse LLM response into clean word list
|
||||
*/
|
||||
private function parseWordList(string $response, int $expectedCount): array
|
||||
{
|
||||
$lines = preg_split('/[\r\n]+/', trim($response));
|
||||
$words = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (count($words) >= $expectedCount) {
|
||||
break;
|
||||
}
|
||||
|
||||
$line = trim($line);
|
||||
|
||||
// Skip empty lines
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove leading numbers/bullets (e.g., "1. word", "- word")
|
||||
$line = preg_replace('/^[\d\-\.\)\s]+/', '', $line);
|
||||
$line = trim($line);
|
||||
|
||||
// Extract first word only
|
||||
$parts = preg_split('/\s+/', $line);
|
||||
$word = strtolower(trim($parts[0]));
|
||||
|
||||
// Validate word: at least 5 chars, only alphabetic
|
||||
if (strlen($word) >= 5 && preg_match('/^[a-z]+$/', $word)) {
|
||||
$words[] = $word;
|
||||
}
|
||||
}
|
||||
|
||||
return $words;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate single passphrase
|
||||
*/
|
||||
private function generateSingle(array $wordlist, array $options): array
|
||||
{
|
||||
if (empty($wordlist)) {
|
||||
throw new RuntimeException('Wordlist is empty');
|
||||
}
|
||||
|
||||
// Select random words with filtering
|
||||
$wordEntries = $this->selectWords($options['words'], $wordlist, $options);
|
||||
|
||||
// Process words (apply space handling, length filtering, etc.)
|
||||
$words = [];
|
||||
$processedWordEntries = [];
|
||||
foreach ($wordEntries as $entry) {
|
||||
$processedEntry = $this->processWordEntry($entry, $options);
|
||||
if ($processedEntry !== null) {
|
||||
$words[] = $processedEntry['word'];
|
||||
$processedWordEntries[] = $processedEntry;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get more words if filtering removed too many
|
||||
if (count($words) < $options['words']) {
|
||||
$additionalNeeded = $options['words'] - count($words);
|
||||
$additionalEntries = $this->selectWords($additionalNeeded * 3, $wordlist, $options);
|
||||
|
||||
foreach ($additionalEntries as $entry) {
|
||||
if (count($words) >= $options['words']) {
|
||||
break;
|
||||
}
|
||||
|
||||
$processedEntry = $this->processWordEntry($entry, $options);
|
||||
if ($processedEntry !== null) {
|
||||
$words[] = $processedEntry['word'];
|
||||
$processedWordEntries[] = $processedEntry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count($words) < $options['words']) {
|
||||
throw new RuntimeException(
|
||||
'Not enough suitable words found with current filtering options'
|
||||
);
|
||||
}
|
||||
|
||||
// Trim to requested count
|
||||
$words = array_slice($words, 0, $options['words']);
|
||||
$processedWordEntries = array_slice($processedWordEntries, 0, $options['words']);
|
||||
|
||||
// Add number if requested
|
||||
if ($options['add_number']) {
|
||||
$words = $this->addNumberToWords($words, $options['number_position']);
|
||||
}
|
||||
|
||||
// Join with separators
|
||||
$password = $this->joinWordsWithRandomSeparators($words, $options['separator']);
|
||||
|
||||
return [
|
||||
'password' => $password,
|
||||
'method' => 'xkcdpass',
|
||||
'word_count' => count($words),
|
||||
'wordEntries' => $processedWordEntries,
|
||||
'entropy' => $this->calculateEntropyForOptions($options)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process word entry with filtering options
|
||||
*/
|
||||
private function processWordEntry(array $entry, array $options): ?array
|
||||
{
|
||||
$word = $entry['word'];
|
||||
$explanation = $entry['explanation'] ?? '';
|
||||
|
||||
// Apply word length filter
|
||||
if ($options['max_word_length'] > 0 && strlen($word) > $options['max_word_length']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Apply space handling
|
||||
if (strpos($word, ' ') !== false) {
|
||||
switch ($options['space_handling']) {
|
||||
case 'skip':
|
||||
return null;
|
||||
|
||||
case 'modify':
|
||||
$word = str_replace(' ', '_', $word);
|
||||
break;
|
||||
|
||||
case 'allow':
|
||||
default:
|
||||
// Keep as-is
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'word' => $word,
|
||||
'explanation' => $explanation
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Select random words from wordlist
|
||||
*/
|
||||
private function selectWords(int $count, array $wordlist, array $options): array
|
||||
{
|
||||
// Pre-filter based on options
|
||||
$filtered = [];
|
||||
foreach ($wordlist as $entry) {
|
||||
$word = $entry['word'];
|
||||
|
||||
// Check length constraint
|
||||
if ($options['max_word_length'] > 0 && strlen($word) > $options['max_word_length']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check space handling
|
||||
if ($options['space_handling'] === 'skip' && strpos($word, ' ') !== false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtered[] = $entry;
|
||||
}
|
||||
|
||||
// Fall back to original if filtering removed everything
|
||||
if (empty($filtered)) {
|
||||
$filtered = $wordlist;
|
||||
}
|
||||
|
||||
// Shuffle and return
|
||||
shuffle($filtered);
|
||||
return array_slice($filtered, 0, min($count * 2, count($filtered)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Join words with random separator selection for each position
|
||||
*/
|
||||
private function joinWordsWithRandomSeparators(array $words, string $separatorSet): string
|
||||
{
|
||||
if (count($words) <= 1) {
|
||||
return implode('', $words);
|
||||
}
|
||||
|
||||
// Clean separator set
|
||||
$cleanSeparators = str_replace(' ', '', $separatorSet);
|
||||
if (empty($cleanSeparators)) {
|
||||
return implode('', $words);
|
||||
}
|
||||
|
||||
$result = $words[0];
|
||||
for ($i = 1; $i < count($words); $i++) {
|
||||
// Select random separator if multiple available
|
||||
if (strlen($cleanSeparators) === 1) {
|
||||
$separator = $cleanSeparators;
|
||||
} else {
|
||||
$chars = str_split($cleanSeparators);
|
||||
$separator = $chars[array_rand($chars)];
|
||||
}
|
||||
|
||||
$result .= $separator . $words[$i];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add random number to words
|
||||
*/
|
||||
private function addNumberToWords(array $words, string $position): array
|
||||
{
|
||||
$number = (string)random_int(10, 9999);
|
||||
|
||||
switch ($position) {
|
||||
case 'beginning':
|
||||
array_unshift($words, $number);
|
||||
break;
|
||||
|
||||
case 'random':
|
||||
if (count($words) > 1) {
|
||||
array_splice($words, random_int(0, count($words)), 0, [$number]);
|
||||
} else {
|
||||
$words[] = $number;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'end':
|
||||
default:
|
||||
$words[] = $number;
|
||||
}
|
||||
|
||||
return $words;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate XKCD entropy from actual option-constrained candidate space.
|
||||
*/
|
||||
public function calculateEntropyForOptions(array $options): float
|
||||
{
|
||||
$breakdown = $this->getEntropyBreakdownForOptions($options);
|
||||
return (float) ($breakdown['entropy_bits'] ?? 0.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return detailed entropy contributors for transparent API/UI reporting.
|
||||
*/
|
||||
public function getEntropyBreakdownForOptions(array $options): array
|
||||
{
|
||||
$normalized = $this->normalizeEntropyOptions($options);
|
||||
|
||||
$requestedWords = max(1, (int) ($normalized['words'] ?? 4));
|
||||
$wordlistCode = (string) ($normalized['wordlist'] ?? 'en');
|
||||
$wordlistEntries = $this->getWordlist($wordlistCode);
|
||||
$totalCandidates = count($wordlistEntries);
|
||||
|
||||
$effectiveCandidates = 0;
|
||||
foreach ($wordlistEntries as $entry) {
|
||||
$word = is_array($entry) ? (string) ($entry['word'] ?? '') : (string) $entry;
|
||||
if ($word === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($normalized['max_word_length'] > 0 && strlen($word) > $normalized['max_word_length']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($normalized['space_handling'] === 'skip' && strpos($word, ' ') !== false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$effectiveCandidates++;
|
||||
}
|
||||
|
||||
if ($effectiveCandidates <= 0) {
|
||||
$effectiveCandidates = max(1, $totalCandidates);
|
||||
}
|
||||
|
||||
$wordEntropyBits = $requestedWords * log((float) $effectiveCandidates, 2);
|
||||
|
||||
$separatorSet = str_replace(' ', '', (string) ($normalized['separator'] ?? '-'));
|
||||
$separatorAlphabet = count(array_unique(str_split($separatorSet)));
|
||||
$separatorSlots = max(0, $requestedWords - 1);
|
||||
$separatorEntropyBits = ($separatorAlphabet > 1 && $separatorSlots > 0)
|
||||
? ($separatorSlots * log((float) $separatorAlphabet, 2))
|
||||
: 0.0;
|
||||
|
||||
$numberEntropyBits = 0.0;
|
||||
if ((bool) ($normalized['add_number'] ?? false)) {
|
||||
// random_int(10, 9999) includes 9,990 possible values.
|
||||
$numberEntropyBits = log(9990.0, 2);
|
||||
|
||||
if (($normalized['number_position'] ?? 'end') === 'random') {
|
||||
$numberEntropyBits += log((float) ($requestedWords + 1), 2);
|
||||
}
|
||||
}
|
||||
|
||||
$totalEntropyBits = $wordEntropyBits + $separatorEntropyBits + $numberEntropyBits;
|
||||
|
||||
return [
|
||||
'entropy_model' => 'xkcd-candidate-space-v1',
|
||||
'model_confidence' => 'high',
|
||||
'wordlist' => $wordlistCode,
|
||||
'requested_words' => $requestedWords,
|
||||
'wordlist_total_candidates' => $totalCandidates,
|
||||
'effective_wordlist_candidates' => $effectiveCandidates,
|
||||
'max_word_length' => (int) $normalized['max_word_length'],
|
||||
'space_handling' => (string) ($normalized['space_handling'] ?? 'allow'),
|
||||
'separator_alphabet_size' => $separatorAlphabet,
|
||||
'separator_slots' => $separatorSlots,
|
||||
'number_inserted' => (bool) ($normalized['add_number'] ?? false),
|
||||
'number_position' => (string) ($normalized['number_position'] ?? 'end'),
|
||||
'word_entropy_bits' => round($wordEntropyBits, 1),
|
||||
'separator_entropy_bits' => round($separatorEntropyBits, 1),
|
||||
'number_entropy_bits' => round($numberEntropyBits, 1),
|
||||
'entropy_bits' => round($totalEntropyBits, 1),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize option aliases used across API/frontend payloads.
|
||||
*/
|
||||
private function normalizeEntropyOptions(array $options): array
|
||||
{
|
||||
$normalized = $options;
|
||||
|
||||
if (!isset($normalized['wordlist']) && isset($normalized['language'])) {
|
||||
$normalized['wordlist'] = $normalized['language'];
|
||||
}
|
||||
|
||||
if (!isset($normalized['add_number']) && isset($normalized['addNumber'])) {
|
||||
$normalized['add_number'] = $normalized['addNumber'];
|
||||
}
|
||||
|
||||
return $this->validateOptions($normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize available wordlists
|
||||
*/
|
||||
private function initializeWordlists(): void
|
||||
{
|
||||
$defaultLabels = [
|
||||
'en' => 'EFF Large Wordlist (English)',
|
||||
'de' => 'German Common Words',
|
||||
'fr' => 'French Common Words',
|
||||
'it' => 'Italian Common Words',
|
||||
'es' => 'Spanish Common Words'
|
||||
];
|
||||
|
||||
$defaultFileMap = [
|
||||
'en' => 'eff_large_wordlist_en.txt',
|
||||
'de' => 'german_common_words_de.txt',
|
||||
'fr' => 'french_common_words_fr.txt',
|
||||
'it' => 'italian_common_words_it.txt',
|
||||
'es' => 'spanish_common_words_es.txt'
|
||||
];
|
||||
|
||||
$configuredWordlists = $this->getConfigValue('generator.xkcd.wordlists', null);
|
||||
if (is_array($configuredWordlists) && !empty($configuredWordlists)) {
|
||||
$labels = [];
|
||||
$files = [];
|
||||
|
||||
foreach ($configuredWordlists as $code => $definition) {
|
||||
if (!is_string($code) || trim($code) === '' || !is_array($definition)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = $definition['label'] ?? null;
|
||||
$file = $definition['file'] ?? null;
|
||||
|
||||
if (!is_string($label) || trim($label) === '' || !is_string($file) || trim($file) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedCode = trim($code);
|
||||
$labels[$normalizedCode] = trim($label);
|
||||
$files[$normalizedCode] = trim($file);
|
||||
}
|
||||
|
||||
if (!empty($labels) && !empty($files)) {
|
||||
$this->availableWordlists = $labels;
|
||||
$this->wordlistFileMap = $files;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($this->availableWordlists) || empty($this->wordlistFileMap)) {
|
||||
$this->availableWordlists = $defaultLabels;
|
||||
$this->wordlistFileMap = $defaultFileMap;
|
||||
}
|
||||
|
||||
if ($this->logger && $this->isLogLevel('debug')) {
|
||||
$this->logger->debug('XkcdPass: wordlists initialized', [
|
||||
'count' => count($this->availableWordlists),
|
||||
'configured' => !empty($configuredWordlists ?? null)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load wordlist from file
|
||||
*/
|
||||
private function getWordlist(string $code): array
|
||||
{
|
||||
if (isset($this->wordlists[$code])) {
|
||||
return $this->wordlists[$code];
|
||||
}
|
||||
|
||||
// Load wordlist
|
||||
$this->loadWordlist($code);
|
||||
|
||||
return $this->wordlists[$code] ?? $this->getFallbackWordlist();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load wordlist from file
|
||||
*/
|
||||
private function loadWordlist(string $code): void
|
||||
{
|
||||
$filename = $this->getWordlistFilename($code);
|
||||
$path = $this->getWordlistPath() . '/' . $filename;
|
||||
|
||||
if (!file_exists($path) || !is_readable($path)) {
|
||||
if ($this->logger) {
|
||||
$this->logger->warning('XkcdPass: wordlist file not found', [
|
||||
'code' => $code,
|
||||
'path' => $path
|
||||
]);
|
||||
}
|
||||
$this->wordlists[$code] = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$content = file_get_contents($path);
|
||||
if ($content === false) {
|
||||
if ($this->logger) {
|
||||
$this->logger->warning('XkcdPass: failed to read wordlist file', [
|
||||
'path' => $path
|
||||
]);
|
||||
}
|
||||
$this->wordlists[$code] = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = array_filter(
|
||||
array_map('trim', explode("\n", $content)),
|
||||
fn($line) => !empty($line) && strlen($line) >= 3
|
||||
);
|
||||
|
||||
$processed = [];
|
||||
foreach ($lines as $line) {
|
||||
$parsed = $this->parseWordlistEntry($line);
|
||||
if (!empty($parsed['word'])) {
|
||||
$processed[] = $parsed;
|
||||
}
|
||||
}
|
||||
|
||||
$this->wordlists[$code] = !empty($processed) ? $processed : [];
|
||||
|
||||
if ($this->logger && $this->isLogLevel('debug')) {
|
||||
$this->logger->debug('XkcdPass: wordlist loaded', [
|
||||
'code' => $code,
|
||||
'count' => count($this->wordlists[$code])
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback wordlist
|
||||
*/
|
||||
private function getFallbackWordlist(): array
|
||||
{
|
||||
return [
|
||||
['word' => 'correct', 'explanation' => ''],
|
||||
['word' => 'horse', 'explanation' => ''],
|
||||
['word' => 'battery', 'explanation' => ''],
|
||||
['word' => 'staple', 'explanation' => ''],
|
||||
['word' => 'python', 'explanation' => ''],
|
||||
['word' => 'monkey', 'explanation' => ''],
|
||||
['word' => 'pencil', 'explanation' => ''],
|
||||
['word' => 'cloud', 'explanation' => ''],
|
||||
['word' => 'mountain', 'explanation' => ''],
|
||||
['word' => 'rainbow', 'explanation' => '']
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wordlist filename
|
||||
*/
|
||||
private function getWordlistFilename(string $code): string
|
||||
{
|
||||
return $this->wordlistFileMap[$code] ?? ($this->wordlistFileMap['en'] ?? 'eff_large_wordlist_en.txt');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wordlist directory
|
||||
*/
|
||||
private function getWordlistPath(): string
|
||||
{
|
||||
$configuredPath = $this->getConfigValue('generator.xkcd.wordlists_path', self::DEFAULT_WORDLISTS_DIR);
|
||||
$safePath = is_string($configuredPath) && trim($configuredPath) !== ''
|
||||
? trim($configuredPath)
|
||||
: self::DEFAULT_WORDLISTS_DIR;
|
||||
|
||||
if (str_starts_with($safePath, '/')) {
|
||||
return $safePath;
|
||||
}
|
||||
|
||||
return dirname(__DIR__, 2) . '/' . ltrim($safePath, '/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Managers;
|
||||
|
||||
use App\Generators\HighEntropyGenerator;
|
||||
use App\Services\LLM\LLMManager;
|
||||
use App\Utils\ConfigLoader;
|
||||
|
||||
class PasswordGeneratorManager
|
||||
{
|
||||
private array $generators = [];
|
||||
private ?LLMManager $llmManager = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Load config
|
||||
$config = $this->loadConfig();
|
||||
|
||||
// Initialize LLM Manager
|
||||
if (isset($config['llm'])) {
|
||||
$this->llmManager = new LLMManager($config['llm']);
|
||||
}
|
||||
|
||||
// Register generators
|
||||
$this->registerGenerators();
|
||||
}
|
||||
|
||||
private function loadConfig(): array
|
||||
{
|
||||
try {
|
||||
$config = ConfigLoader::load();
|
||||
} catch (\Throwable $e) {
|
||||
$config = [];
|
||||
}
|
||||
|
||||
$provider = $_ENV['LLM_DEFAULT_PROVIDER'] ?? null;
|
||||
if (is_string($provider) && $provider !== '') {
|
||||
$config['llm']['default_provider'] = $provider;
|
||||
}
|
||||
|
||||
$apiKeyEnvMap = [
|
||||
'openai' => 'OPENAI_API_KEY',
|
||||
'claude' => 'CLAUDE_API_KEY',
|
||||
'gemini' => 'GEMINI_API_KEY',
|
||||
'perplexity' => 'PERPLEXITY_API_KEY',
|
||||
];
|
||||
|
||||
foreach ($apiKeyEnvMap as $providerName => $envVar) {
|
||||
$apiKey = $_ENV[$envVar] ?? null;
|
||||
if (is_string($apiKey) && $apiKey !== '') {
|
||||
$config['llm']['api_keys'][$providerName] = $apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
return is_array($config) ? $config : [];
|
||||
}
|
||||
|
||||
private function registerGenerators(): void
|
||||
{
|
||||
$this->generators = [
|
||||
'high-entropy' => new \App\Generators\HighEntropyGenerator(),
|
||||
'xkcdpass' => new \App\Generators\XkcdPassGenerator($this->llmManager),
|
||||
'diceware' => new \App\Generators\DicewareGenerator(),
|
||||
'pwgen' => new \App\Generators\PwgenGenerator(),
|
||||
'passkey' => new \App\Generators\PasskeyGenerator(),
|
||||
'phrase-abbreviator' => new \App\Generators\PhraseAbbreviatorGenerator($this->llmManager),
|
||||
];
|
||||
}
|
||||
|
||||
public function getAvailableTypes(): array
|
||||
{
|
||||
$types = [];
|
||||
foreach ($this->generators as $key => $generator) {
|
||||
$types[] = [
|
||||
'id' => $key,
|
||||
'name' => $generator->getName(),
|
||||
'description' => $generator->getDescription(),
|
||||
];
|
||||
}
|
||||
return $types;
|
||||
}
|
||||
|
||||
public function getGeneratorDefinitions(): array
|
||||
{
|
||||
$definitions = [];
|
||||
|
||||
foreach ($this->generators as $id => $generator) {
|
||||
$optionDefinitions = [];
|
||||
foreach ($generator->getOptionDefinitions() as $optionName => $definition) {
|
||||
$group = $definition['group'] ?? 'basic';
|
||||
$label = $definition['label'] ?? $this->humanizeOptionName($optionName);
|
||||
$description = $definition['description'] ?? '';
|
||||
|
||||
$option = [
|
||||
'name' => $optionName,
|
||||
'type' => $definition['type'],
|
||||
'label' => $label,
|
||||
'description' => $description,
|
||||
'labelKey' => $id . '_option_' . $optionName,
|
||||
'descriptionKey' => $id . '_option_' . $optionName . '_description',
|
||||
'default' => $definition['default'] ?? null,
|
||||
'required' => $definition['required'] ?? false,
|
||||
'group' => $group,
|
||||
'groupTitleKey' => $id . '_options_group_' . $group . '_title',
|
||||
'validationErrorKey' => $id . '_option_' . $optionName . '_validation_error',
|
||||
];
|
||||
|
||||
if (isset($definition['min'])) {
|
||||
$option['min'] = $definition['min'];
|
||||
}
|
||||
if (isset($definition['max'])) {
|
||||
$option['max'] = $definition['max'];
|
||||
}
|
||||
if (isset($definition['minLength'])) {
|
||||
$option['minLength'] = $definition['minLength'];
|
||||
}
|
||||
if (isset($definition['maxLength'])) {
|
||||
$option['maxLength'] = $definition['maxLength'];
|
||||
}
|
||||
if (isset($definition['pattern'])) {
|
||||
$option['pattern'] = $definition['pattern'];
|
||||
}
|
||||
if (isset($definition['translateChoices'])) {
|
||||
$option['translateChoices'] = $definition['translateChoices'];
|
||||
}
|
||||
if (isset($definition['choices'])) {
|
||||
$formattedChoices = [];
|
||||
foreach ($definition['choices'] as $choice) {
|
||||
if (is_array($choice)) {
|
||||
$value = $choice['value'];
|
||||
$label = $choice['label'];
|
||||
} else {
|
||||
$value = $choice;
|
||||
$label = $choice;
|
||||
}
|
||||
|
||||
$formattedChoices[] = [
|
||||
'value' => $value,
|
||||
'label' => $label,
|
||||
'labelKey' => $id . '_option_' . $optionName . '_choice_' . $value,
|
||||
];
|
||||
}
|
||||
$option['choices'] = $formattedChoices;
|
||||
}
|
||||
|
||||
$optionDefinitions[] = $option;
|
||||
}
|
||||
|
||||
$definitions[$id] = [
|
||||
'id' => $id,
|
||||
'name' => $generator->getName(),
|
||||
'description' => $generator->getDescription(),
|
||||
'nameKey' => $id . '_title',
|
||||
'descriptionKey' => $id . '_description',
|
||||
'version' => $generator->getVersion(),
|
||||
'options' => $optionDefinitions,
|
||||
];
|
||||
}
|
||||
|
||||
return $definitions;
|
||||
}
|
||||
|
||||
private function humanizeOptionName(string $optionName): string
|
||||
{
|
||||
$humanized = preg_replace('/[_-]+/', ' ', $optionName);
|
||||
$humanized = preg_replace('/(?<!^)([A-Z])/', ' $1', (string) $humanized);
|
||||
return trim(ucwords((string) $humanized));
|
||||
}
|
||||
|
||||
public function getGenerator(string $type)
|
||||
{
|
||||
if (!isset($this->generators[$type])) {
|
||||
throw new \InvalidArgumentException("Unknown generator type: $type");
|
||||
}
|
||||
|
||||
return $this->generators[$type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced generate method with proper data extraction
|
||||
*/
|
||||
public function generate(string $type, int $count, array $options): array
|
||||
{
|
||||
$generator = $this->getGenerator($type);
|
||||
$results = $generator->generate($count, $options);
|
||||
|
||||
// Initialize response data
|
||||
$passwords = [];
|
||||
$explanations = [];
|
||||
$diceRolls = [];
|
||||
$securityInfo = null;
|
||||
|
||||
// Process each result and extract all nested data
|
||||
for ($i = 0; $i < count($results); $i++) {
|
||||
$result = $results[$i];
|
||||
|
||||
if (is_array($result) && isset($result['password'])) {
|
||||
// Extract password
|
||||
$passwords[] = $result['password'];
|
||||
|
||||
// Extract explanations from wordEntries
|
||||
$explanation = null;
|
||||
if (!empty($result['wordEntries'])) {
|
||||
$explanationTexts = [];
|
||||
foreach ($result['wordEntries'] as $entry) {
|
||||
if (is_array($entry) && isset($entry['explanation']) && !empty($entry['explanation'])) {
|
||||
$explanationTexts[] = $entry['explanation'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($explanationTexts)) {
|
||||
$explanation = implode(' – ', $explanationTexts);
|
||||
}
|
||||
}
|
||||
$explanations[] = $explanation;
|
||||
|
||||
// Extract dice rolls (for Diceware)
|
||||
if (!empty($result['dice_rolls'])) {
|
||||
$diceRolls[] = $result['dice_rolls'];
|
||||
}
|
||||
|
||||
// Extract security info (for Diceware)
|
||||
if (!empty($result['security_info'])) {
|
||||
$securityInfo = $result['security_info'];
|
||||
}
|
||||
|
||||
} else {
|
||||
// Simple string-only format
|
||||
$passwords[] = is_string($result) ? $result : (string)$result;
|
||||
$explanations[] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate accurate entropy and optional model breakdown
|
||||
$entropyAnalysis = $this->calculateEntropyAnalysis($generator, $options, $passwords[0] ?? '');
|
||||
$entropyBits = $entropyAnalysis['entropy_bits'];
|
||||
|
||||
// Build response
|
||||
$response = [
|
||||
'passwords' => $passwords,
|
||||
'entropy_bits' => $entropyBits,
|
||||
'generator_info' => $generator->getName() . ' generator',
|
||||
'options_used' => $options
|
||||
];
|
||||
|
||||
// FIXED: Only add explanations if any exist and are not null
|
||||
$validExplanations = array_filter($explanations, fn($exp) => !empty($exp));
|
||||
if (!empty($validExplanations)) {
|
||||
// Preserve array indices to match passwords
|
||||
$response['explanations'] = $explanations;
|
||||
}
|
||||
|
||||
// FIXED: Only add dice rolls if any exist
|
||||
if (!empty($diceRolls)) {
|
||||
$response['dice_rolls'] = $diceRolls;
|
||||
}
|
||||
|
||||
// FIXED: Add security info if exists
|
||||
if ($securityInfo !== null) {
|
||||
$response['security_info'] = $securityInfo;
|
||||
}
|
||||
|
||||
$assessment = $this->buildSecurityAssessment(
|
||||
$entropyBits,
|
||||
$generator->getName(),
|
||||
$securityInfo,
|
||||
$passwords[0] ?? '',
|
||||
$entropyAnalysis['details'] ?? []
|
||||
);
|
||||
|
||||
$response['security_assessment'] = $assessment['security_assessment'];
|
||||
$response['raw_technical_details'] = $assessment['raw_technical_details'];
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function buildSecurityAssessment(float $entropyBits, string $generatorName, ?array $securityInfo, string $password, array $entropyDetails = []): array
|
||||
{
|
||||
$rawDetails = [
|
||||
'entropy_bits' => round($entropyBits, 1),
|
||||
'generator' => $generatorName,
|
||||
'length' => $password !== '' ? strlen($password) : 0,
|
||||
];
|
||||
|
||||
if (!empty($entropyDetails)) {
|
||||
$rawDetails['entropy_breakdown'] = $entropyDetails;
|
||||
}
|
||||
|
||||
if (is_array($securityInfo)) {
|
||||
$rawDetails = array_merge($rawDetails, $securityInfo);
|
||||
}
|
||||
|
||||
$securityLevel = $this->normalizeSecurityLevel($rawDetails['security_level'] ?? '', $entropyBits);
|
||||
$rawDetails['security_level'] = $securityLevel;
|
||||
|
||||
return [
|
||||
'security_assessment' => $this->getSecurityAssessmentText($securityLevel, $entropyBits),
|
||||
'raw_technical_details' => $rawDetails,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeSecurityLevel(string $securityLevel, float $entropyBits): string
|
||||
{
|
||||
$normalized = strtolower(trim($securityLevel));
|
||||
|
||||
if ($normalized === '' || $normalized === 'none') {
|
||||
return $this->classifyEntropy($entropyBits);
|
||||
}
|
||||
|
||||
if (str_contains($normalized, 'excellent') || str_contains($normalized, 'very strong')) {
|
||||
return 'Very strong';
|
||||
}
|
||||
if (str_contains($normalized, 'strong')) {
|
||||
return 'Strong';
|
||||
}
|
||||
if (str_contains($normalized, 'adequate')) {
|
||||
return 'Adequate';
|
||||
}
|
||||
if (str_contains($normalized, 'weak')) {
|
||||
return 'Weak';
|
||||
}
|
||||
|
||||
return $this->classifyEntropy($entropyBits);
|
||||
}
|
||||
|
||||
private function classifyEntropy(float $entropyBits): string
|
||||
{
|
||||
if ($entropyBits >= 128) {
|
||||
return 'Very strong';
|
||||
}
|
||||
if ($entropyBits >= 96) {
|
||||
return 'Strong';
|
||||
}
|
||||
if ($entropyBits >= 64) {
|
||||
return 'Adequate';
|
||||
}
|
||||
if ($entropyBits >= 40) {
|
||||
return 'Weak';
|
||||
}
|
||||
|
||||
return 'Very weak';
|
||||
}
|
||||
|
||||
private function getSecurityAssessmentText(string $securityLevel, float $entropyBits): string
|
||||
{
|
||||
$level = trim($securityLevel) !== '' ? $securityLevel : $this->classifyEntropy($entropyBits);
|
||||
|
||||
$messages = [
|
||||
'Very strong' => 'Very strong — this password has excellent entropy and is suitable for high-value accounts.',
|
||||
'Strong' => 'Strong — this password has good entropy and is suitable for most uses.',
|
||||
'Adequate' => 'Adequate — this password is acceptable for ordinary use but can be improved.',
|
||||
'Weak' => 'Weak — this password is relatively easy to brute-force and should be replaced.',
|
||||
'Very weak' => 'Very weak — this password offers very little protection.',
|
||||
];
|
||||
|
||||
return $messages[$level] ?? 'Security assessment unavailable.';
|
||||
}
|
||||
|
||||
public function validateOptions(string $type, array $options): array
|
||||
{
|
||||
$generator = $this->getGenerator($type);
|
||||
return $generator->validateOptions($options);
|
||||
}
|
||||
|
||||
public function getDefaultOptions(string $type): array
|
||||
{
|
||||
$generator = $this->getGenerator($type);
|
||||
return $generator->getDefaultOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Accurate entropy calculation using generator-specific logic
|
||||
*/
|
||||
private function calculateAccurateEntropy($generator, array $options, string $samplePassword): float
|
||||
{
|
||||
// Try generator-specific calculation first
|
||||
if (method_exists($generator, 'calculateEntropyForOptions')) {
|
||||
return $generator->calculateEntropyForOptions($options);
|
||||
}
|
||||
|
||||
// Fallback to type-specific calculations
|
||||
$generatorName = strtolower($generator->getName());
|
||||
|
||||
switch ($generatorName) {
|
||||
case 'xkcd pass':
|
||||
case 'xkcdpass':
|
||||
return $this->calculateXkcdEntropy($options);
|
||||
|
||||
case 'diceware':
|
||||
return $this->calculateDicewareEntropy($options);
|
||||
|
||||
case 'passkey':
|
||||
return $this->calculatePasskeyEntropy($options);
|
||||
|
||||
case 'high entropy':
|
||||
return $this->calculateHighEntropyEntropy($options);
|
||||
|
||||
case 'pwgen':
|
||||
return $this->calculatePwgenEntropy($options, $samplePassword);
|
||||
|
||||
default:
|
||||
return $this->calculateGenericEntropy($samplePassword);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate entropy and expose optional generator-level entropy details.
|
||||
*/
|
||||
private function calculateEntropyAnalysis($generator, array $options, string $samplePassword): array
|
||||
{
|
||||
$details = [];
|
||||
|
||||
if (method_exists($generator, 'getEntropyBreakdownForOptions')) {
|
||||
try {
|
||||
$details = $generator->getEntropyBreakdownForOptions($options);
|
||||
} catch (\Throwable $e) {
|
||||
$details = [];
|
||||
}
|
||||
}
|
||||
|
||||
$entropyBits = $this->calculateAccurateEntropy($generator, $options, $samplePassword);
|
||||
if (isset($details['entropy_bits']) && is_numeric($details['entropy_bits'])) {
|
||||
$entropyBits = (float) $details['entropy_bits'];
|
||||
}
|
||||
|
||||
return [
|
||||
'entropy_bits' => round($entropyBits, 1),
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate XKCD passphrase entropy with filtering effects
|
||||
*/
|
||||
private function calculateXkcdEntropy(array $options): float
|
||||
{
|
||||
$words = $options['words'] ?? 4;
|
||||
$addNumber = $options['addNumber'] ?? false;
|
||||
$language = $options['language'] ?? 'en';
|
||||
|
||||
// Base wordlist sizes by language
|
||||
$wordlistSizes = [
|
||||
'en' => 7776, // EFF Large wordlist
|
||||
'de' => 5000, // Estimated German words
|
||||
'fr' => 5000, // Estimated French words
|
||||
'it' => 4000, // Estimated Italian words
|
||||
'es' => 4000, // Estimated Spanish words
|
||||
'ch' => 3000, // Estimated Swiss German words
|
||||
];
|
||||
|
||||
$baseWordlistSize = $wordlistSizes[$language] ?? 7776;
|
||||
|
||||
// Apply filtering penalties
|
||||
$effectiveWordlistSize = $baseWordlistSize;
|
||||
|
||||
// Word length filter penalty
|
||||
if (isset($options['max_word_length']) && $options['max_word_length'] > 0) {
|
||||
$maxLength = $options['max_word_length'];
|
||||
if ($maxLength <= 4) {
|
||||
$effectiveWordlistSize *= 0.3; // Very restrictive
|
||||
} elseif ($maxLength <= 6) {
|
||||
$effectiveWordlistSize *= 0.6; // Moderately restrictive
|
||||
} elseif ($maxLength <= 8) {
|
||||
$effectiveWordlistSize *= 0.85; // Slightly restrictive
|
||||
}
|
||||
}
|
||||
|
||||
// Space handling penalty
|
||||
if (isset($options['space_handling']) && $options['space_handling'] === 'skip') {
|
||||
$effectiveWordlistSize *= 0.95; // ~5% of words contain spaces
|
||||
}
|
||||
|
||||
// Ensure reasonable minimum
|
||||
$effectiveWordlistSize = max($effectiveWordlistSize, 50);
|
||||
|
||||
// Calculate word entropy
|
||||
$wordEntropy = $words * log($effectiveWordlistSize, 2);
|
||||
|
||||
// Add number entropy if used
|
||||
$numberEntropy = 0;
|
||||
if ($addNumber) {
|
||||
$numberEntropy = log(90, 2); // 2-digit numbers: 10-99 = 90 possibilities
|
||||
}
|
||||
|
||||
return round($wordEntropy + $numberEntropy, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Diceware entropy
|
||||
*/
|
||||
private function calculateDicewareEntropy(array $options): float
|
||||
{
|
||||
$words = $options['words'] ?? 6;
|
||||
|
||||
// Diceware standard: 7776 words (6^5)
|
||||
$entropy = $words * log(7776, 2);
|
||||
|
||||
return round($entropy, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Passkey entropy
|
||||
*/
|
||||
private function calculatePasskeyEntropy(array $options): float
|
||||
{
|
||||
$syllables = $options['syllables'] ?? 6;
|
||||
|
||||
// Estimate combinations per syllable
|
||||
// CV: 21×5=105, CVC: 21×5×21=2205, average ≈ 150
|
||||
$combinationsPerSyllable = 150;
|
||||
|
||||
// Character substitutions add ~20% entropy
|
||||
$substitutionMultiplier = 1.2;
|
||||
|
||||
$totalCombinations = pow($combinationsPerSyllable, $syllables) * $substitutionMultiplier;
|
||||
|
||||
return round(log($totalCombinations, 2), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate High Entropy password entropy
|
||||
*/
|
||||
private function calculateHighEntropyEntropy(array $options): float
|
||||
{
|
||||
$length = $options['length'] ?? 16;
|
||||
|
||||
// Count character set size
|
||||
$charsetSize = 0;
|
||||
if ($options['include_lowercase'] ?? true) $charsetSize += 26;
|
||||
if ($options['include_uppercase'] ?? true) $charsetSize += 26;
|
||||
if ($options['include_numbers'] ?? true) $charsetSize += 10;
|
||||
if ($options['include_symbols'] ?? true) $charsetSize += 32;
|
||||
|
||||
// Ensure at least one character type
|
||||
$charsetSize = max($charsetSize, 26);
|
||||
|
||||
return round($length * log($charsetSize, 2), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate PWGen entropy estimate
|
||||
*/
|
||||
private function calculatePwgenEntropy(array $options, string $samplePassword): float
|
||||
{
|
||||
$type = $options['type'] ?? '16-char';
|
||||
|
||||
// PWGen entropy estimates based on pattern complexity
|
||||
$entropyMap = [
|
||||
'8-char' => 35.0,
|
||||
'12-char' => 45.0,
|
||||
'16-char' => 55.0
|
||||
];
|
||||
|
||||
return $entropyMap[$type] ?? $this->calculateGenericEntropy($samplePassword);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic entropy calculation for unknown generators
|
||||
*/
|
||||
private function calculateGenericEntropy(string $password): float
|
||||
{
|
||||
if (empty($password)) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$length = strlen($password);
|
||||
|
||||
// Analyze character set used
|
||||
$hasLower = preg_match('/[a-z]/', $password);
|
||||
$hasUpper = preg_match('/[A-Z]/', $password);
|
||||
$hasDigit = preg_match('/[0-9]/', $password);
|
||||
$hasSymbol = preg_match('/[^a-zA-Z0-9\s]/', $password);
|
||||
|
||||
$charsetSize = 0;
|
||||
if ($hasLower) $charsetSize += 26;
|
||||
if ($hasUpper) $charsetSize += 26;
|
||||
if ($hasDigit) $charsetSize += 10;
|
||||
if ($hasSymbol) $charsetSize += 32; // Estimate
|
||||
|
||||
$charsetSize = max($charsetSize, 26); // Minimum alphabet
|
||||
|
||||
return round($length * log($charsetSize, 2), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use App\Utils\SecurityConfig;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface as Middleware;
|
||||
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
|
||||
|
||||
class CorsMiddleware implements Middleware
|
||||
{
|
||||
public function process(Request $request, RequestHandler $handler): Response
|
||||
{
|
||||
$response = $handler->handle($request);
|
||||
$origin = SecurityConfig::resolveCorsOrigin($_ENV['APP_ENV'] ?? 'production');
|
||||
|
||||
if ($origin === '') {
|
||||
$response = $response->withoutHeader('Access-Control-Allow-Origin');
|
||||
} else {
|
||||
$response = $response->withHeader('Access-Control-Allow-Origin', $origin);
|
||||
}
|
||||
|
||||
return $response
|
||||
->withHeader('Access-Control-Allow-Headers', $_ENV['CORS_HEADERS'] ?? 'Content-Type,Authorization')
|
||||
->withHeader('Access-Control-Allow-Methods', $_ENV['CORS_METHODS'] ?? 'GET,POST,OPTIONS')
|
||||
->withHeader('Access-Control-Max-Age', '3600');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface as Middleware;
|
||||
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
|
||||
|
||||
class JsonResponseMiddleware implements Middleware
|
||||
{
|
||||
public function process(Request $request, RequestHandler $handler): Response
|
||||
{
|
||||
$response = $handler->handle($request);
|
||||
|
||||
// Add JSON content type for API routes
|
||||
if (strpos($request->getUri()->getPath(), '/api/') === 0) {
|
||||
if (!$response->hasHeader('Content-Type')) {
|
||||
$response = $response->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware;
|
||||
|
||||
use App\Utils\SecurityConfig;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface as Middleware;
|
||||
use Psr\Http\Server\RequestHandlerInterface as RequestHandler;
|
||||
|
||||
class RateLimitMiddleware implements Middleware
|
||||
{
|
||||
private const CACHE_PREFIX = 'rate_limit_';
|
||||
private int $maxRequests;
|
||||
private int $timeWindow = 60; // seconds
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->maxRequests = SecurityConfig::resolveRateLimit($_ENV['APP_ENV'] ?? 'production', 120);
|
||||
}
|
||||
|
||||
public function process(Request $request, RequestHandler $handler): Response
|
||||
{
|
||||
$clientIp = $this->getClientIp($request);
|
||||
$cacheKey = self::CACHE_PREFIX . $clientIp;
|
||||
|
||||
// Simple in-memory rate limiting for demo purposes
|
||||
// In production, use Redis or database
|
||||
static $requests = [];
|
||||
|
||||
$now = time();
|
||||
$windowStart = $now - $this->timeWindow;
|
||||
|
||||
// Clean old requests
|
||||
if (isset($requests[$cacheKey])) {
|
||||
$requests[$cacheKey] = array_filter(
|
||||
$requests[$cacheKey],
|
||||
fn($timestamp) => $timestamp > $windowStart
|
||||
);
|
||||
} else {
|
||||
$requests[$cacheKey] = [];
|
||||
}
|
||||
|
||||
// Check if limit exceeded
|
||||
if (count($requests[$cacheKey]) >= $this->maxRequests) {
|
||||
$response = new \Slim\Psr7\Response();
|
||||
$response->getBody()->write(json_encode([
|
||||
'error' => 'Rate limit exceeded',
|
||||
'message' => "Maximum {$this->maxRequests} requests per minute allowed"
|
||||
]));
|
||||
return $response
|
||||
->withStatus(429)
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withHeader('Retry-After', $this->timeWindow);
|
||||
}
|
||||
|
||||
// Add current request
|
||||
$requests[$cacheKey][] = $now;
|
||||
|
||||
$response = $handler->handle($request);
|
||||
|
||||
// Add rate limit headers
|
||||
$remaining = max(0, $this->maxRequests - count($requests[$cacheKey]));
|
||||
return $response
|
||||
->withHeader('X-RateLimit-Limit', (string) $this->maxRequests)
|
||||
->withHeader('X-RateLimit-Remaining', (string) $remaining)
|
||||
->withHeader('X-RateLimit-Reset', (string) ($now + $this->timeWindow));
|
||||
}
|
||||
|
||||
private function getClientIp(Request $request): string
|
||||
{
|
||||
$serverParams = $request->getServerParams();
|
||||
|
||||
if (!empty($serverParams['HTTP_CLIENT_IP'])) {
|
||||
return $serverParams['HTTP_CLIENT_IP'];
|
||||
} elseif (!empty($serverParams['HTTP_X_FORWARDED_FOR'])) {
|
||||
return $serverParams['HTTP_X_FORWARDED_FOR'];
|
||||
} elseif (!empty($serverParams['REMOTE_ADDR'])) {
|
||||
return $serverParams['REMOTE_ADDR'];
|
||||
}
|
||||
|
||||
return '127.0.0.1';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\LLM;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Abstract LLM Provider Base Class
|
||||
*
|
||||
* Provides common functionality for all LLM providers
|
||||
*/
|
||||
abstract class AbstractLLMProvider implements LLMProviderInterface
|
||||
{
|
||||
protected string $apiKey;
|
||||
protected int $timeout;
|
||||
protected int $maxTokens;
|
||||
protected float $temperature;
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->apiKey = $config['api_key'] ?? '';
|
||||
$this->timeout = $config['timeout'] ?? 30;
|
||||
$this->maxTokens = $config['max_tokens'] ?? 500;
|
||||
$this->temperature = $config['temperature'] ?? 0.7;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider is available (has API key)
|
||||
*/
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return !empty($this->apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make HTTP request to API
|
||||
*/
|
||||
protected function makeRequest(
|
||||
string $url,
|
||||
array $headers,
|
||||
array $data,
|
||||
string $method = 'POST'
|
||||
): array {
|
||||
$options = [
|
||||
'http' => [
|
||||
'method' => $method,
|
||||
'header' => $this->buildHeaders($headers),
|
||||
'content' => json_encode($data),
|
||||
'timeout' => $this->timeout,
|
||||
'ignore_errors' => true
|
||||
]
|
||||
];
|
||||
|
||||
$context = stream_context_create($options);
|
||||
$response = @file_get_contents($url, false, $context);
|
||||
|
||||
if ($response === false) {
|
||||
throw new RuntimeException(
|
||||
"Failed to connect to {$this->getName()} API"
|
||||
);
|
||||
}
|
||||
|
||||
// Get HTTP response code
|
||||
$httpCode = 200;
|
||||
if (isset($http_response_header[0])) {
|
||||
preg_match('/\d{3}/', $http_response_header[0], $matches);
|
||||
$httpCode = (int)($matches[0] ?? 200);
|
||||
}
|
||||
|
||||
$result = json_decode($response, true);
|
||||
|
||||
if ($httpCode >= 400) {
|
||||
$errorMsg = $this->extractErrorMessage($result);
|
||||
throw new RuntimeException(
|
||||
"{$this->getName()} API error (HTTP {$httpCode}): {$errorMsg}"
|
||||
);
|
||||
}
|
||||
|
||||
return $result ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HTTP headers array into string
|
||||
*/
|
||||
private function buildHeaders(array $headers): string
|
||||
{
|
||||
$headerLines = [];
|
||||
foreach ($headers as $key => $value) {
|
||||
$headerLines[] = "{$key}: {$value}";
|
||||
}
|
||||
return implode("\r\n", $headerLines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from API response
|
||||
*/
|
||||
protected function extractErrorMessage(array $response): string
|
||||
{
|
||||
// Try common error message fields
|
||||
return $response['error']['message']
|
||||
?? $response['error']
|
||||
?? $response['message']
|
||||
?? 'Unknown error';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\LLM;
|
||||
|
||||
use App\Services\LLM\Providers\OpenAIProvider;
|
||||
use App\Services\LLM\Providers\ClaudeProvider;
|
||||
use App\Services\LLM\Providers\GeminiProvider;
|
||||
use App\Services\LLM\Providers\PerplexityProvider;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* LLM Manager
|
||||
*
|
||||
* Central service for managing multiple LLM providers
|
||||
*/
|
||||
class LLMManager
|
||||
{
|
||||
private array $providers = [];
|
||||
private string $defaultProvider;
|
||||
private array $config;
|
||||
|
||||
public function __construct(array $config = [])
|
||||
{
|
||||
$this->config = $config;
|
||||
$this->defaultProvider = $config['default_provider'] ?? 'openai';
|
||||
$this->initializeProviders();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all LLM providers
|
||||
*/
|
||||
private function initializeProviders(): void
|
||||
{
|
||||
$apiKeys = $this->config['api_keys'] ?? [];
|
||||
$llmConfig = $this->config;
|
||||
|
||||
// OpenAI
|
||||
if (!empty($apiKeys['openai'])) {
|
||||
$this->providers['openai'] = new OpenAIProvider([
|
||||
'api_key' => $apiKeys['openai'],
|
||||
'timeout' => $llmConfig['timeout'] ?? 30,
|
||||
'max_tokens' => $llmConfig['max_tokens'] ?? 500,
|
||||
'temperature' => $llmConfig['temperature'] ?? 0.7
|
||||
]);
|
||||
}
|
||||
|
||||
// Claude
|
||||
if (!empty($apiKeys['claude'])) {
|
||||
$this->providers['claude'] = new ClaudeProvider([
|
||||
'api_key' => $apiKeys['claude'],
|
||||
'timeout' => $llmConfig['timeout'] ?? 30,
|
||||
'max_tokens' => $llmConfig['max_tokens'] ?? 500,
|
||||
'temperature' => $llmConfig['temperature'] ?? 0.7
|
||||
]);
|
||||
}
|
||||
|
||||
// Gemini
|
||||
if (!empty($apiKeys['gemini'])) {
|
||||
$this->providers['gemini'] = new GeminiProvider([
|
||||
'api_key' => $apiKeys['gemini'],
|
||||
'timeout' => $llmConfig['timeout'] ?? 30,
|
||||
'max_tokens' => $llmConfig['max_tokens'] ?? 500,
|
||||
'temperature' => $llmConfig['temperature'] ?? 0.7
|
||||
]);
|
||||
}
|
||||
|
||||
// Perplexity
|
||||
if (!empty($apiKeys['perplexity'])) {
|
||||
$this->providers['perplexity'] = new PerplexityProvider([
|
||||
'api_key' => $apiKeys['perplexity'],
|
||||
'timeout' => $llmConfig['timeout'] ?? 30,
|
||||
'max_tokens' => $llmConfig['max_tokens'] ?? 500,
|
||||
'temperature' => $llmConfig['temperature'] ?? 0.7
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider by name
|
||||
*/
|
||||
public function getProvider(string $name): LLMProviderInterface
|
||||
{
|
||||
if (!isset($this->providers[$name])) {
|
||||
throw new RuntimeException("LLM provider '{$name}' not available or not configured");
|
||||
}
|
||||
|
||||
return $this->providers[$name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default provider
|
||||
*/
|
||||
public function getDefaultProvider(): LLMProviderInterface
|
||||
{
|
||||
return $this->getProvider($this->defaultProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text using specified or default provider
|
||||
*/
|
||||
public function generateText(
|
||||
string $prompt,
|
||||
?string $provider = null,
|
||||
array $options = []
|
||||
): string {
|
||||
$llm = $provider ? $this->getProvider($provider) : $this->getDefaultProvider();
|
||||
return $llm->generateText($prompt, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available providers
|
||||
*/
|
||||
public function getAvailableProviders(): array
|
||||
{
|
||||
$available = [];
|
||||
foreach ($this->providers as $name => $provider) {
|
||||
if ($provider->isAvailable()) {
|
||||
$available[$name] = [
|
||||
'name' => $name,
|
||||
'display_name' => ucfirst($name),
|
||||
'models' => $provider->getAvailableModels(),
|
||||
'default_model' => $provider->getDefaultModel()
|
||||
];
|
||||
}
|
||||
}
|
||||
return $available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any provider is available
|
||||
*/
|
||||
public function hasAvailableProvider(): bool
|
||||
{
|
||||
foreach ($this->providers as $provider) {
|
||||
if ($provider->isAvailable()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default provider name
|
||||
*/
|
||||
public function getDefaultProviderName(): string
|
||||
{
|
||||
return $this->defaultProvider;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\LLM;
|
||||
|
||||
/**
|
||||
* LLM Provider Interface
|
||||
*
|
||||
* Defines the contract that all LLM provider implementations must follow
|
||||
*/
|
||||
interface LLMProviderInterface
|
||||
{
|
||||
/**
|
||||
* Get provider name
|
||||
*/
|
||||
public function getName(): string;
|
||||
|
||||
/**
|
||||
* Generate text completion from prompt
|
||||
*
|
||||
* @param string $prompt User prompt
|
||||
* @param array $options Additional options (model, temperature, max_tokens, etc.)
|
||||
* @return string Generated text
|
||||
* @throws \RuntimeException If API call fails
|
||||
*/
|
||||
public function generateText(string $prompt, array $options = []): string;
|
||||
|
||||
/**
|
||||
* Check if provider is configured and available
|
||||
*/
|
||||
public function isAvailable(): bool;
|
||||
|
||||
/**
|
||||
* Get default model for this provider
|
||||
*/
|
||||
public function getDefaultModel(): string;
|
||||
|
||||
/**
|
||||
* Get available models for this provider
|
||||
*/
|
||||
public function getAvailableModels(): array;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\LLM\Providers;
|
||||
|
||||
use App\Services\LLM\AbstractLLMProvider;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Claude Provider (Anthropic)
|
||||
*/
|
||||
class ClaudeProvider extends AbstractLLMProvider
|
||||
{
|
||||
private const API_URL = 'https://api.anthropic.com/v1/messages';
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
public function getDefaultModel(): string
|
||||
{
|
||||
return 'claude-3-sonnet-20240229';
|
||||
}
|
||||
|
||||
public function getAvailableModels(): array
|
||||
{
|
||||
return [
|
||||
'claude-3-opus-20240229' => 'Claude 3 Opus',
|
||||
'claude-3-sonnet-20240229' => 'Claude 3 Sonnet',
|
||||
'claude-3-haiku-20240307' => 'Claude 3 Haiku'
|
||||
];
|
||||
}
|
||||
|
||||
public function generateText(string $prompt, array $options = []): string
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
throw new RuntimeException('Claude API key not configured');
|
||||
}
|
||||
|
||||
$model = $options['model'] ?? $this->getDefaultModel();
|
||||
$temperature = $options['temperature'] ?? $this->temperature;
|
||||
$maxTokens = $options['max_tokens'] ?? $this->maxTokens;
|
||||
|
||||
$data = [
|
||||
'model' => $model,
|
||||
'max_tokens' => $maxTokens,
|
||||
'temperature' => $temperature,
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => $prompt
|
||||
]
|
||||
]
|
||||
];
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
'x-api-key' => $this->apiKey,
|
||||
'anthropic-version' => '2023-06-01'
|
||||
];
|
||||
|
||||
$response = $this->makeRequest(self::API_URL, $headers, $data);
|
||||
|
||||
if (!isset($response['content'][0]['text'])) {
|
||||
throw new RuntimeException('Invalid Claude API response format');
|
||||
}
|
||||
|
||||
return trim($response['content'][0]['text']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\LLM\Providers;
|
||||
|
||||
use App\Services\LLM\AbstractLLMProvider;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Gemini Provider (Google)
|
||||
*/
|
||||
class GeminiProvider extends AbstractLLMProvider
|
||||
{
|
||||
private const API_URL = 'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent';
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'gemini';
|
||||
}
|
||||
|
||||
public function getDefaultModel(): string
|
||||
{
|
||||
return 'gemini-pro';
|
||||
}
|
||||
|
||||
public function getAvailableModels(): array
|
||||
{
|
||||
return [
|
||||
'gemini-pro' => 'Gemini Pro',
|
||||
'gemini-1.5-pro' => 'Gemini 1.5 Pro',
|
||||
'gemini-1.5-flash' => 'Gemini 1.5 Flash'
|
||||
];
|
||||
}
|
||||
|
||||
public function generateText(string $prompt, array $options = []): string
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
throw new RuntimeException('Gemini API key not configured');
|
||||
}
|
||||
|
||||
$model = $options['model'] ?? $this->getDefaultModel();
|
||||
$temperature = $options['temperature'] ?? $this->temperature;
|
||||
$maxTokens = $options['max_tokens'] ?? $this->maxTokens;
|
||||
|
||||
$url = str_replace('{model}', $model, self::API_URL) . "?key={$this->apiKey}";
|
||||
|
||||
$data = [
|
||||
'contents' => [
|
||||
[
|
||||
'parts' => [
|
||||
['text' => $prompt]
|
||||
]
|
||||
]
|
||||
],
|
||||
'generationConfig' => [
|
||||
'temperature' => $temperature,
|
||||
'maxOutputTokens' => $maxTokens
|
||||
]
|
||||
];
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json'
|
||||
];
|
||||
|
||||
$response = $this->makeRequest($url, $headers, $data);
|
||||
|
||||
if (!isset($response['candidates'][0]['content']['parts'][0]['text'])) {
|
||||
throw new RuntimeException('Invalid Gemini API response format');
|
||||
}
|
||||
|
||||
return trim($response['candidates'][0]['content']['parts'][0]['text']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\LLM\Providers;
|
||||
|
||||
use App\Services\LLM\AbstractLLMProvider;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* OpenAI Provider (GPT models)
|
||||
*/
|
||||
class OpenAIProvider extends AbstractLLMProvider
|
||||
{
|
||||
private const API_URL = 'https://api.openai.com/v1/chat/completions';
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'openai';
|
||||
}
|
||||
|
||||
public function getDefaultModel(): string
|
||||
{
|
||||
return 'gpt-3.5-turbo';
|
||||
}
|
||||
|
||||
public function getAvailableModels(): array
|
||||
{
|
||||
return [
|
||||
'gpt-3.5-turbo' => 'GPT-3.5 Turbo',
|
||||
'gpt-4' => 'GPT-4',
|
||||
'gpt-4-turbo' => 'GPT-4 Turbo',
|
||||
'gpt-4o' => 'GPT-4 Omni'
|
||||
];
|
||||
}
|
||||
|
||||
public function generateText(string $prompt, array $options = []): string
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
throw new RuntimeException('OpenAI API key not configured');
|
||||
}
|
||||
|
||||
$model = $options['model'] ?? $this->getDefaultModel();
|
||||
$temperature = $options['temperature'] ?? $this->temperature;
|
||||
$maxTokens = $options['max_tokens'] ?? $this->maxTokens;
|
||||
|
||||
$data = [
|
||||
'model' => $model,
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => $prompt
|
||||
]
|
||||
],
|
||||
'max_tokens' => $maxTokens,
|
||||
'temperature' => $temperature
|
||||
];
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => "Bearer {$this->apiKey}"
|
||||
];
|
||||
|
||||
$response = $this->makeRequest(self::API_URL, $headers, $data);
|
||||
|
||||
if (!isset($response['choices'][0]['message']['content'])) {
|
||||
throw new RuntimeException('Invalid OpenAI API response format');
|
||||
}
|
||||
|
||||
return trim($response['choices'][0]['message']['content']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\LLM\Providers;
|
||||
|
||||
use App\Services\LLM\AbstractLLMProvider;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Perplexity Provider
|
||||
*/
|
||||
class PerplexityProvider extends AbstractLLMProvider
|
||||
{
|
||||
private const API_URL = 'https://api.perplexity.ai/chat/completions';
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return 'perplexity';
|
||||
}
|
||||
|
||||
public function getDefaultModel(): string
|
||||
{
|
||||
return 'llama-3-sonar-small-32k-online';
|
||||
}
|
||||
|
||||
public function getAvailableModels(): array
|
||||
{
|
||||
return [
|
||||
'llama-3-sonar-small-32k-online' => 'Llama 3 Sonar Small (32k)',
|
||||
'llama-3-sonar-large-32k-online' => 'Llama 3 Sonar Large (32k)',
|
||||
'llama-3-8b-instruct' => 'Llama 3 8B Instruct',
|
||||
'llama-3-70b-instruct' => 'Llama 3 70B Instruct'
|
||||
];
|
||||
}
|
||||
|
||||
public function generateText(string $prompt, array $options = []): string
|
||||
{
|
||||
if (!$this->isAvailable()) {
|
||||
throw new RuntimeException('Perplexity API key not configured');
|
||||
}
|
||||
|
||||
$model = $options['model'] ?? $this->getDefaultModel();
|
||||
$temperature = $options['temperature'] ?? $this->temperature;
|
||||
$maxTokens = $options['max_tokens'] ?? $this->maxTokens;
|
||||
|
||||
$data = [
|
||||
'model' => $model,
|
||||
'messages' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => $prompt
|
||||
]
|
||||
],
|
||||
'max_tokens' => $maxTokens,
|
||||
'temperature' => $temperature
|
||||
];
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => "Bearer {$this->apiKey}"
|
||||
];
|
||||
|
||||
$response = $this->makeRequest(self::API_URL, $headers, $data);
|
||||
|
||||
if (!isset($response['choices'][0]['message']['content'])) {
|
||||
throw new RuntimeException('Invalid Perplexity API response format');
|
||||
}
|
||||
|
||||
return trim($response['choices'][0]['message']['content']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Logging;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* LoggingHelper - Centralized logging utility
|
||||
*
|
||||
* Provides static methods for logging with automatic null-safety and
|
||||
* context injection. Eliminates the need for repeated if statements
|
||||
* checking logger availability and log levels.
|
||||
*
|
||||
* All logging methods:
|
||||
* - Accept optional logger (null-safe)
|
||||
* - Inject generator context automatically
|
||||
* - Support additional context data
|
||||
* - Are static (no instantiation needed)
|
||||
*
|
||||
* @example
|
||||
* LoggingHelper::debug($this->logger, $this->getId(), 'Starting generation');
|
||||
* LoggingHelper::info($this->logger, 'DicewareGenerator', 'Complete', ['count' => 5]);
|
||||
* LoggingHelper::warning($this->logger, 'Pwgen', 'Using fallback', $context);
|
||||
* LoggingHelper::error($this->logger, 'XkcdPass', 'Failed', ['error' => $e->getMessage()]);
|
||||
*
|
||||
* @package App\Services\Logging
|
||||
*/
|
||||
class LoggingHelper
|
||||
{
|
||||
/**
|
||||
* PSR-3 severity levels for reference
|
||||
* Lower numbers = more verbose (more messages logged)
|
||||
* Higher numbers = less verbose (fewer messages logged)
|
||||
*/
|
||||
private const LOG_LEVELS = [
|
||||
'debug' => 0, // Most verbose - development messages
|
||||
'info' => 1, // Informational - important milestones
|
||||
'notice' => 2, // Normal but significant
|
||||
'warning' => 3, // Warning - potential issues
|
||||
'error' => 4, // Error - something went wrong
|
||||
'critical' => 5, // Critical - system in trouble
|
||||
'alert' => 6, // Alert - immediate action needed
|
||||
'emergency' => 7, // Emergency - system unusable
|
||||
];
|
||||
|
||||
/**
|
||||
* Log a DEBUG message
|
||||
*
|
||||
* DEBUG level is for detailed development information.
|
||||
* Only logged when LOG_LEVEL is set to 'debug'.
|
||||
*
|
||||
* @param LoggerInterface|null $logger Logger instance (null-safe)
|
||||
* @param string $generatorId Generator identifier for context
|
||||
* @param string $message The log message
|
||||
* @param array $context Additional contextual data
|
||||
*
|
||||
* @example
|
||||
* LoggingHelper::debug($logger, 'DicewareGenerator', 'Starting generation', [
|
||||
* 'count' => 5,
|
||||
* 'words' => 6
|
||||
* ]);
|
||||
*/
|
||||
public static function debug(
|
||||
?LoggerInterface $logger,
|
||||
string $generatorId,
|
||||
string $message,
|
||||
array $context = []
|
||||
): void {
|
||||
self::logWithContext($logger, 'debug', $generatorId, $message, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an INFO message
|
||||
*
|
||||
* INFO level is for informational messages about important events.
|
||||
* Logged when LOG_LEVEL is 'debug' or 'info' or higher.
|
||||
*
|
||||
* @param LoggerInterface|null $logger Logger instance (null-safe)
|
||||
* @param string $generatorId Generator identifier for context
|
||||
* @param string $message The log message
|
||||
* @param array $context Additional contextual data
|
||||
*
|
||||
* @example
|
||||
* LoggingHelper::info($logger, 'DicewareGenerator', 'Generation complete', [
|
||||
* 'count' => 5,
|
||||
* 'duration_ms' => 145
|
||||
* ]);
|
||||
*/
|
||||
public static function info(
|
||||
?LoggerInterface $logger,
|
||||
string $generatorId,
|
||||
string $message,
|
||||
array $context = []
|
||||
): void {
|
||||
self::logWithContext($logger, 'info', $generatorId, $message, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a WARNING message
|
||||
*
|
||||
* WARNING level is for potentially problematic situations.
|
||||
* Logged when LOG_LEVEL is 'warning' or higher (less verbose).
|
||||
*
|
||||
* @param LoggerInterface|null $logger Logger instance (null-safe)
|
||||
* @param string $generatorId Generator identifier for context
|
||||
* @param string $message The log message
|
||||
* @param array $context Additional contextual data
|
||||
*
|
||||
* @example
|
||||
* LoggingHelper::warning($logger, 'Pwgen', 'Command not found, using fallback', [
|
||||
* 'command' => 'pwgen',
|
||||
* 'fallback' => 'php_random'
|
||||
* ]);
|
||||
*/
|
||||
public static function warning(
|
||||
?LoggerInterface $logger,
|
||||
string $generatorId,
|
||||
string $message,
|
||||
array $context = []
|
||||
): void {
|
||||
self::logWithContext($logger, 'warning', $generatorId, $message, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an ERROR message
|
||||
*
|
||||
* ERROR level is for error conditions.
|
||||
* Always logged (unless logger is null).
|
||||
*
|
||||
* @param LoggerInterface|null $logger Logger instance (null-safe)
|
||||
* @param string $generatorId Generator identifier for context
|
||||
* @param string $message The log message
|
||||
* @param array $context Additional contextual data
|
||||
*
|
||||
* @example
|
||||
* LoggingHelper::error($logger, 'XkcdPass', 'Generation failed', [
|
||||
* 'error' => 'Invalid word count',
|
||||
* 'provided_count' => 200
|
||||
* ]);
|
||||
*/
|
||||
public static function error(
|
||||
?LoggerInterface $logger,
|
||||
string $generatorId,
|
||||
string $message,
|
||||
array $context = []
|
||||
): void {
|
||||
self::logWithContext($logger, 'error', $generatorId, $message, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a CRITICAL message
|
||||
*
|
||||
* CRITICAL level is for critical errors.
|
||||
* Always logged (unless logger is null).
|
||||
*
|
||||
* @param LoggerInterface|null $logger Logger instance (null-safe)
|
||||
* @param string $generatorId Generator identifier for context
|
||||
* @param string $message The log message
|
||||
* @param array $context Additional contextual data
|
||||
*
|
||||
* @example
|
||||
* LoggingHelper::critical($logger, 'PasswordManager', 'Configuration loading failed');
|
||||
*/
|
||||
public static function critical(
|
||||
?LoggerInterface $logger,
|
||||
string $generatorId,
|
||||
string $message,
|
||||
array $context = []
|
||||
): void {
|
||||
self::logWithContext($logger, 'critical', $generatorId, $message, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core logging implementation with context injection
|
||||
*
|
||||
* This method:
|
||||
* 1. Checks if logger is available (null-safe)
|
||||
* 2. Injects generator context into all messages
|
||||
* 3. Routes to appropriate PSR-3 log method
|
||||
*
|
||||
* @param LoggerInterface|null $logger Logger instance
|
||||
* @param string $level Log level (debug, info, warning, error, critical)
|
||||
* @param string $generatorId Generator identifier for context
|
||||
* @param string $message Log message
|
||||
* @param array $context Additional context data
|
||||
*/
|
||||
private static function logWithContext(
|
||||
?LoggerInterface $logger,
|
||||
string $level,
|
||||
string $generatorId,
|
||||
string $message,
|
||||
array $context = []
|
||||
): void {
|
||||
// Guard: No logger available - silently return
|
||||
if ($logger === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Inject generator context into all messages
|
||||
$fullContext = array_merge(
|
||||
['generator' => $generatorId],
|
||||
$context
|
||||
);
|
||||
|
||||
// Route to appropriate PSR-3 logging method
|
||||
match ($level) {
|
||||
'debug' => $logger->debug($message, $fullContext),
|
||||
'info' => $logger->info($message, $fullContext),
|
||||
'notice' => $logger->notice($message, $fullContext),
|
||||
'warning' => $logger->warning($message, $fullContext),
|
||||
'error' => $logger->error($message, $fullContext),
|
||||
'critical' => $logger->critical($message, $fullContext),
|
||||
'alert' => $logger->alert($message, $fullContext),
|
||||
'emergency' => $logger->emergency($message, $fullContext),
|
||||
default => $logger->info($message, $fullContext)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if logging at a specific level is enabled
|
||||
*
|
||||
* Useful when building expensive log messages (e.g., serialization, formatting).
|
||||
* Allows conditional construction of message data.
|
||||
*
|
||||
* Note: This is a reference helper. Most PSR-3 loggers handle this internally.
|
||||
*
|
||||
* @param string $configuredLevel Current configured log level from config
|
||||
* @param string $checkLevel Level to check
|
||||
* @return bool True if message at checkLevel would be logged
|
||||
*
|
||||
* @example
|
||||
* if (LoggingHelper::isLevelEnabled($configLevel, 'debug')) {
|
||||
* $expensiveDebugData = buildComplexDebugInfo();
|
||||
* LoggingHelper::debug($logger, $id, 'Data', ['debug' => $expensiveDebugData]);
|
||||
* }
|
||||
*/
|
||||
public static function isLevelEnabled(
|
||||
string $configuredLevel,
|
||||
string $checkLevel
|
||||
): bool {
|
||||
$configValue = self::LOG_LEVELS[$configuredLevel] ?? self::LOG_LEVELS['info'];
|
||||
$checkValue = self::LOG_LEVELS[$checkLevel] ?? self::LOG_LEVELS['info'];
|
||||
|
||||
return $checkValue >= $configValue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* USAGE EXAMPLES
|
||||
* ═════════════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
// Example 1: In AbstractPasswordGenerator.php
|
||||
/*
|
||||
protected function loadConfiguration(): void
|
||||
{
|
||||
try {
|
||||
$this->config = json_decode(file_get_contents('config/config.json'), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
LoggingHelper::debug($this->logger, $this->getId(), 'Configuration loaded');
|
||||
} catch (\Exception $e) {
|
||||
LoggingHelper::warning($this->logger, $this->getId(),
|
||||
'Failed to load config', ['error' => $e->getMessage()]);
|
||||
$this->config = [];
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// Example 2: In DicewareGenerator.php
|
||||
/*
|
||||
public function generate(int $count = 1, array $options = []): array
|
||||
{
|
||||
$options = $this->validateOptions($options);
|
||||
|
||||
LoggingHelper::debug($this->logger, $this->getId(),
|
||||
'Starting generation', ['count' => $count]);
|
||||
|
||||
$passwords = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$words = $this->selectRandomWords($options['words']);
|
||||
$passwords[] = implode('-', $words);
|
||||
}
|
||||
|
||||
LoggingHelper::info($this->logger, $this->getId(),
|
||||
'Generation complete', ['count' => count($passwords)]);
|
||||
|
||||
return $passwords;
|
||||
}
|
||||
*/
|
||||
|
||||
// Example 3: In PwgenGenerator.php
|
||||
/*
|
||||
private function generateWithNativePwgen(int $length, int $count): array
|
||||
{
|
||||
try {
|
||||
$command = "pwgen -S {$length} {$count}";
|
||||
$result = shell_exec(escapeshellcmd($command));
|
||||
|
||||
if ($result === null) {
|
||||
throw new RuntimeException('PWGen command failed');
|
||||
}
|
||||
|
||||
LoggingHelper::debug($this->logger, $this->getId(),
|
||||
'Native pwgen generated', ['count' => $count]);
|
||||
|
||||
return array_filter(explode("\n", trim($result)));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
LoggingHelper::warning($this->logger, $this->getId(),
|
||||
'Native pwgen failed, using fallback', [
|
||||
'error' => $e->getMessage(),
|
||||
'fallback' => 'php_random'
|
||||
]);
|
||||
|
||||
throw new RuntimeException(
|
||||
"PWGen execution failed: " . $e->getMessage(),
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// Example 4: In XkcdPassGenerator.php
|
||||
/*
|
||||
private function generateAiWordlist(int $wordCount, string $prompt): array
|
||||
{
|
||||
try {
|
||||
LoggingHelper::debug($this->logger, $this->getId(),
|
||||
'Requesting AI wordlist', ['word_count' => $wordCount]);
|
||||
|
||||
$response = $this->llmManager->generateText($prompt);
|
||||
$words = $this->parseWordList($response, $wordCount);
|
||||
|
||||
if (count($words) < $wordCount) {
|
||||
throw new RuntimeException(
|
||||
"LLM returned only " . count($words) . " words"
|
||||
);
|
||||
}
|
||||
|
||||
LoggingHelper::info($this->logger, $this->getId(),
|
||||
'AI wordlist generated', ['count' => count($words)]);
|
||||
|
||||
return $words;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
LoggingHelper::error($this->logger, $this->getId(),
|
||||
'AI wordlist generation failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'requested_count' => $wordCount
|
||||
]);
|
||||
|
||||
throw new RuntimeException(
|
||||
"Failed to generate wordlist: " . $e->getMessage(),
|
||||
0,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
*/
|
||||
?>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Utils;
|
||||
|
||||
use RuntimeException;
|
||||
use JsonException;
|
||||
|
||||
class ConfigLoader
|
||||
{
|
||||
public static function load(?string $configPath = null): array
|
||||
{
|
||||
$path = $configPath ?? dirname(__DIR__, 2) . '/config/config.json';
|
||||
|
||||
if (!is_file($path)) {
|
||||
throw new RuntimeException('Configuration file not found at ' . $path);
|
||||
}
|
||||
|
||||
$json = file_get_contents($path);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('Unable to read configuration file at ' . $path);
|
||||
}
|
||||
|
||||
try {
|
||||
$decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException $e) {
|
||||
throw new RuntimeException('Invalid JSON in configuration file at ' . $path, 0, $e);
|
||||
}
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
throw new RuntimeException('Configuration root must decode to an object at ' . $path);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Utils;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
|
||||
class ResponseHelper
|
||||
{
|
||||
public function successResponse(
|
||||
Response $response,
|
||||
array $data = [],
|
||||
?string $message = null // FIXED: Explicit nullable type
|
||||
): Response {
|
||||
$responseData = [
|
||||
'success' => true,
|
||||
'data' => $data,
|
||||
'timestamp' => date('c')
|
||||
];
|
||||
|
||||
if ($message !== null) {
|
||||
$responseData['message'] = $message;
|
||||
}
|
||||
|
||||
$response->getBody()->write(json_encode($responseData, JSON_UNESCAPED_UNICODE));
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
public function errorResponse(
|
||||
Response $response,
|
||||
string $message,
|
||||
int $statusCode = 400,
|
||||
?string $errorCode = null
|
||||
): Response {
|
||||
$responseData = [
|
||||
'success' => false,
|
||||
'error' => [
|
||||
'message' => $message,
|
||||
'code' => $errorCode ?? 'ERROR',
|
||||
'status' => $statusCode
|
||||
],
|
||||
'timestamp' => date('c')
|
||||
];
|
||||
|
||||
$response->getBody()->write(json_encode($responseData, JSON_UNESCAPED_UNICODE));
|
||||
return $response
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withStatus($statusCode);
|
||||
}
|
||||
|
||||
public function jsonResponse(Response $response, array $data, int $statusCode = 200): Response
|
||||
{
|
||||
$response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||
return $response
|
||||
->withHeader('Content-Type', 'application/json')
|
||||
->withStatus($statusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Utils;
|
||||
|
||||
use Monolog\Logger;
|
||||
|
||||
final class SecurityConfig
|
||||
{
|
||||
public static function resolveCorsOrigin(?string $environment = null): string
|
||||
{
|
||||
$configuredOrigin = trim((string) ($_ENV['CORS_ORIGIN'] ?? ''));
|
||||
if ($configuredOrigin !== '') {
|
||||
return $configuredOrigin;
|
||||
}
|
||||
|
||||
$env = strtolower((string) ($environment ?? ($_ENV['APP_ENV'] ?? 'production')));
|
||||
|
||||
if (in_array($env, ['development', 'local', 'dev'], true)) {
|
||||
return 'http://localhost:8080';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public static function resolveRateLimit(?string $environment = null, int $fallback = 120): int
|
||||
{
|
||||
if (isset($_ENV['RATE_LIMIT']) && is_numeric($_ENV['RATE_LIMIT'])) {
|
||||
return (int) $_ENV['RATE_LIMIT'];
|
||||
}
|
||||
|
||||
return max(1, $fallback);
|
||||
}
|
||||
|
||||
public static function resolveLogLevel(?string $configuredLevel = null): int
|
||||
{
|
||||
$level = strtoupper((string) ($configuredLevel ?? ($_ENV['LOG_LEVEL'] ?? 'warning')));
|
||||
|
||||
return match ($level) {
|
||||
'DEBUG' => Logger::DEBUG,
|
||||
'INFO' => Logger::INFO,
|
||||
'NOTICE' => Logger::NOTICE,
|
||||
'WARNING' => Logger::WARNING,
|
||||
'ERROR' => Logger::ERROR,
|
||||
'CRITICAL' => Logger::CRITICAL,
|
||||
'ALERT' => Logger::ALERT,
|
||||
'EMERGENCY' => Logger::EMERGENCY,
|
||||
default => Logger::WARNING,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user