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

@@ -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, '/');
}
}