initialization

This commit is contained in:
Reindl David (IT-PTR-CEN2-SL10) committed 2026-10-07 01:16:57 +02:00
commit 9ff09693ed
92 files changed
+46209

No files matched your search

+360
View File
@@ -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,
],
};
}
}
+214
View File
@@ -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);
}
}
+317
View File
@@ -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)
];
}
}
+462
View File
@@ -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;
}
}
+194
View File
@@ -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;
}
+328
View File
@@ -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;
}
}
}
+621
View File
@@ -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),
];
}
}
+878
View File
@@ -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, '/');
}
}
+586
View File
@@ -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);
}
}
+31
View File
@@ -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');
}
}
+27
View File
@@ -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;
}
}
+86
View File
@@ -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';
}
}
+106
View File
@@ -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';
}
}
+150
View File
@@ -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;
}
}
+42
View File
@@ -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']);
}
}
+363
View File
@@ -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
);
}
}
*/
?>
+37
View File
@@ -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;
}
}
+59
View File
@@ -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);
}
}
+52
View File
@@ -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,
};
}
}