Skip to content

Commit aa9d25a

Browse files
authored
Merge pull request #31 from nicobleiler/alpha
Promote alpha to beta
2 parents 89c8e2e + 541c87a commit aa9d25a

14 files changed

Lines changed: 516 additions & 31 deletions

.coderabbit.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
2+
language: "en-US"
3+
early_access: true
4+
5+
reviews:
6+
profile: "assertive"
7+
high_level_summary: true
8+
collapse_walkthrough: false
9+
review_status: false
10+
11+
auto_review:
12+
enabled: true
13+
drafts: false
14+
ignore_title_keywords:
15+
- "WIP"
16+
base_branches:
17+
- dev
18+
- alpha

.gitattributes

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
* text=auto
2+
3+
*.md diff=markdown
4+
*.php diff=php
5+
6+
/.github export-ignore
7+
/tests export-ignore
8+
/benchmarks export-ignore
9+
/scripts export-ignore
10+
.gitattributes export-ignore
11+
.gitignore export-ignore
12+
.coderabbit.yaml export-ignore
13+
AGENTS.md export-ignore
14+
CONTRIBUTING.md export-ignore
15+
phpbench.json export-ignore
16+
phpstan.neon export-ignore
17+
phpunit.xml export-ignore
18+
pint.json export-ignore
19+
rector.php export-ignore
20+
renovate.json export-ignore
21+
.releaserc export-ignore

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ Agents should run tests after meaningful changes, especially for behavior update
5858

5959
- Default behavior relies on the bundled EFF long list.
6060
- Custom word lists are provided as PHP arrays (config `word_list` or `WordList::fromArray()`).
61+
- Optional exclusion of words is provided via config `excluded_words` and `$wordlist->excludeWords()`.
6162
- Do not add logging/output that could leak generated passphrases.
6263

6364
## Laravel Integration Notes

README.md

Lines changed: 125 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
[![Latest Version](https://img.shields.io/packagist/v/nicobleiler/php-passphrase.svg)](https://packagist.org/packages/nicobleiler/php-passphrase)
44
[![Downloads](https://img.shields.io/packagist/dt/nicobleiler/php-passphrase.svg)](https://packagist.org/packages/nicobleiler/php-passphrase)
55
[![PHP Version](https://img.shields.io/packagist/php-v/nicobleiler/php-passphrase.svg)](https://packagist.org/packages/nicobleiler/php-passphrase)
6+
[![Code Size](https://img.shields.io/github/languages/code-size/nicobleiler/php-passphrase)](https://github.com/nicobleiler/php-passphrase)
7+
[![Wordlist Size](https://img.shields.io/github/size/nicobleiler/php-passphrase/resources/wordlists/eff_large_wordlist.php?label=wordlist)](https://github.com/nicobleiler/php-passphrase/blob/master/resources/wordlists/eff_large_wordlist.php)
68
[![CI](https://github.com/nicobleiler/php-passphrase/actions/workflows/test.yml/badge.svg)](https://github.com/nicobleiler/php-passphrase/actions/workflows/test.yml)
79
[![License](https://img.shields.io/packagist/l/nicobleiler/php-passphrase.svg)](LICENSE)
810

@@ -110,7 +112,7 @@ echo $generator->generate(); // deterministic output
110112

111113
| Parameter | Type | Default | Description |
112114
|---|---|---|---|
113-
| `numWords` | `?int` | `3` | Number of words (3–20). `null` uses instance/config default. |
115+
| `numWords` | `?int` | `3` | Number of words (minimum of 3). `null` uses instance/config default. |
114116
| `wordSeparator` | `?string` | `'-'` | Character(s) between words. `null` uses instance/config default. |
115117
| `capitalize` | `?bool` | `false` | Capitalize the first letter of each word. `null` uses instance/config default. |
116118
| `includeNumber` | `?bool` | `false` | Append a random digit (0–9) to one random word. `null` uses instance/config default. |
@@ -139,6 +141,10 @@ return [
139141
// null = bundled EFF long word list (7,776 words)
140142
// Or provide your own word list as a PHP array of strings
141143
'word_list' => null,
144+
145+
// Optional words to remove from the active word list
146+
// Works with both bundled EFF and custom word_list values
147+
'excluded_words' => [],
142148
];
143149
```
144150

@@ -153,6 +159,9 @@ Provide `word_list` as a PHP array of strings:
153159
```php
154160
// config/passphrase.php
155161
'word_list' => ['correct', 'horse', 'battery', 'staple'],
162+
163+
// Optionally remove specific words from the active list
164+
'excluded_words' => ['horse'],
156165
```
157166

158167
Or load it from a dedicated PHP file:
@@ -193,10 +202,49 @@ You can also publish the bundled EFF word list to your resources folder:
193202
php artisan vendor:publish --tag=passphrase-wordlists
194203
```
195204

205+
This is optional and mainly useful if you want a local copy to inspect or customize. By default, the package reads the bundled EFF list directly.
206+
196207
## How It Works
197208

198209
The generation algorithm mirrors [Bitwarden's Rust implementation](https://sdk-api-docs.bitwarden.com/src/bitwarden_generators/passphrase.rs.html):
199210

211+
### Sequence diagram
212+
213+
```mermaid
214+
sequenceDiagram
215+
participant Client
216+
participant ServiceProvider
217+
participant PassphraseGenerator
218+
participant Randomizer
219+
participant WordList
220+
221+
Client->>ServiceProvider: Boot (Laravel)
222+
ServiceProvider->>WordList: Create (singleton)
223+
ServiceProvider->>PassphraseGenerator: Create with Randomizer(Secure)
224+
ServiceProvider->>PassphraseGenerator: setDefaults(config values)
225+
activate PassphraseGenerator
226+
PassphraseGenerator-->>ServiceProvider: self (fluent)
227+
deactivate PassphraseGenerator
228+
229+
Client->>PassphraseGenerator: generate() [no params]
230+
activate PassphraseGenerator
231+
PassphraseGenerator->>PassphraseGenerator: Use instance defaults
232+
PassphraseGenerator->>Randomizer: getInt() [select words]
233+
activate Randomizer
234+
Randomizer-->>PassphraseGenerator: random indices
235+
deactivate Randomizer
236+
PassphraseGenerator->>WordList: wordAt(index)
237+
activate WordList
238+
WordList-->>PassphraseGenerator: word string
239+
deactivate WordList
240+
PassphraseGenerator->>Randomizer: getInt() [digit if needed]
241+
activate Randomizer
242+
Randomizer-->>PassphraseGenerator: random digit
243+
deactivate Randomizer
244+
PassphraseGenerator-->>Client: passphrase string
245+
deactivate PassphraseGenerator
246+
```
247+
200248
## Testing
201249

202250
```bash
@@ -219,40 +267,46 @@ The test suite includes tests modeled after Bitwarden's own test cases:
219267
- EFF word list integrity
220268
- Laravel integration (service provider, facade, config defaults)
221269

222-
## Performance (2026-02-14 02:41:47 MEZ)
270+
## Performance (2026-02-16)
223271

224272
These benchmarks were run on a local Ryzen 9 5950X machine running Windows 11 with PHP 8.5.0.
225273

226-
In warm-run benchmarks at similar entropy targets, php-passphrase is ~4.4× faster than genphrase/genphrase and ~1333× faster than martbock/laravel-diceware (based on mean time per generation).
227-
228-
In cold-run benchmarks (startup + first generation), php-passphrase is ~5.6× faster than genphrase/genphrase and ~12.8× faster than martbock/laravel-diceware (based on mean time per generation).
274+
In this run the available benchmark providers were: `php-passphrase`, `genphrase/genphrase`, `martbock/laravel-diceware`, `random_bytes`, `Illuminate\\Support\\Str::random`, and `Illuminate\\Support\\Str::password` (providers are included automatically when their packages are installed).
229275

230276
> **Note on cold runs:** `benchGenerateCold` includes setup and first-use initialization (autoloading, object construction, and initial word-list work). Cold-run `rstdev` is therefore expected to be higher and should be interpreted as startup-cost signal, not steady-state throughput.
231277
232-
```
278+
```text
233279
benchGenerateCold
234-
+----------------+-----------------------------------------------------+------+-----+-----------+-----------+---------+----------+
235-
| benchmark | set | revs | its | mem_peak | mode | mean | rstdev |
236-
+----------------+-----------------------------------------------------+------+-----+-----------+-----------+---------+----------+
237-
| ProvidersBench | php-passphrase (EFF 5 words, ~64.6 bits) | 1 | 20 | 1.614mb | 127.847μs | 320.5μs | ±241.90% |
238-
| ProvidersBench | genphrase/genphrase (65-bit target, diceware) | 1 | 20 | 1.364mb | 1.654ms | 1.806ms | ±27.58% |
239-
| ProvidersBench | martbock/laravel-diceware (EFF 5 words, ~64.6 bits) | 1 | 20 | 957.688kb | 3.608ms | 4.106ms | ±35.28% |
240-
| ProvidersBench | random_bytes(8) hex (~64 bits) | 1 | 20 | 493.784kb | 7.74μs | 8.8μs | ±21.14% |
241-
| ProvidersBench | Illuminate\Support\Str::random(11) (~65.5 bits) | 1 | 20 | 493.8kb | 175.25μs | 241μs | ±79.83% |
242-
+----------------+-----------------------------------------------------+------+-----+-----------+-----------+---------+----------+
280+
+----------------+--------------------------------------------------------------------+------+-----+-----------+-----------+----------+----------+
281+
| benchmark | set | revs | its | mem_peak | mode | mean | rstdev |
282+
+----------------+--------------------------------------------------------------------+------+-----+-----------+-----------+----------+----------+
283+
| ProvidersBench | php-passphrase (EFF 5 words, ~64.6 bits) | 1 | 20 | 1.612mb | 331.431μs | 504μs | ±141.06% |
284+
| ProvidersBench | genphrase/genphrase (65-bit target, diceware) | 1 | 20 | 1.366mb | 1.662ms | 3.788ms | ±240.86% |
285+
| ProvidersBench | martbock/laravel-diceware (EFF 5 words, ~64.6 bits) | 1 | 20 | 958.76kb | 3.68ms | 4.745ms | ±82.04% |
286+
| ProvidersBench | random_bytes(8) hex (~64 bits) | 1 | 20 | 494.856kb | 9.818μs | 11.6μs | ±35.59% |
287+
| ProvidersBench | Illuminate\Support\Str::random(11) (~65.5 bits) | 1 | 20 | 494.872kb | 182.63μs | 245.95μs | ±77.25% |
288+
| ProvidersBench | Illuminate\Support\Str::password(10) (default options, ~64.6 bits) | 1 | 20 | 1.143mb | 921.507μs | 1.371ms | ±132.20% |
289+
+----------------+--------------------------------------------------------------------+------+-----+-----------+-----------+----------+----------+
243290
244291
benchGenerateWarm
245-
+----------------+-----------------------------------------------------+------+-----+-----------+---------+---------+--------+
246-
| benchmark | set | revs | its | mem_peak | mode | mean | rstdev |
247-
+----------------+-----------------------------------------------------+------+-----+-----------+---------+---------+--------+
248-
| ProvidersBench | php-passphrase (EFF 5 words, ~64.6 bits) | 100 | 20 | 494.048kb | 1.596μs | 1.612μs | ±3.79% |
249-
| ProvidersBench | genphrase/genphrase (65-bit target, diceware) | 100 | 20 | 1.363mb | 6.956μs | 7.091μs | ±6.42% |
250-
| ProvidersBench | martbock/laravel-diceware (EFF 5 words, ~64.6 bits) | 100 | 20 | 508.944kb | 2.161ms | 2.149ms | ±2.87% |
251-
| ProvidersBench | random_bytes(8) hex (~64 bits) | 100 | 20 | 494.04kb | 0.125μs | 0.125μs | ±7.38% |
252-
| ProvidersBench | Illuminate\Support\Str::random(11) (~65.5 bits) | 100 | 20 | 494.056kb | 0.56μs | 0.565μs | ±3.86% |
253-
+----------------+-----------------------------------------------------+------+-----+-----------+---------+---------+--------+
292+
+----------------+--------------------------------------------------------------------+------+-----+-----------+---------+----------+---------+
293+
| benchmark | set | revs | its | mem_peak | mode | mean | rstdev |
294+
+----------------+--------------------------------------------------------------------+------+-----+-----------+---------+----------+---------+
295+
| ProvidersBench | php-passphrase (EFF 5 words, ~64.6 bits) | 100 | 20 | 495.12kb | 1.353μs | 1.406μs | ±14.18% |
296+
| ProvidersBench | genphrase/genphrase (65-bit target, diceware) | 100 | 20 | 1.364mb | 6.715μs | 6.829μs | ±3.74% |
297+
| ProvidersBench | martbock/laravel-diceware (EFF 5 words, ~64.6 bits) | 100 | 20 | 510.016kb | 2.099ms | 2.073ms | ±2.68% |
298+
| ProvidersBench | random_bytes(8) hex (~64 bits) | 100 | 20 | 495.112kb | 0.125μs | 0.132μs | ±24.62% |
299+
| ProvidersBench | Illuminate\Support\Str::random(11) (~65.5 bits) | 100 | 20 | 495.128kb | 0.532μs | 0.563μs | ±16.54% |
300+
| ProvidersBench | Illuminate\Support\Str::password(10) (default options, ~64.6 bits) | 100 | 20 | 587.672kb | 11.86μs | 11.927μs | ±2.81% |
301+
+----------------+--------------------------------------------------------------------+------+-----+-----------+---------+----------+---------+
254302
```
255303

304+
Relative comparisons (mean times):
305+
306+
- Cold run: `php-passphrase` (504 μs) is ~7.5× faster than `genphrase` (3.788 ms) and ~9.4× faster than `martbock/laravel-diceware` (4.745 ms).
307+
- Warm run: `php-passphrase` (1.406 μs) is ~4.9× faster than `genphrase` (6.829 μs) and ~1,475× faster than `martbock/laravel-diceware` (2.073 ms).
308+
309+
These values are environment-dependent; run `composer bench` locally if you need numbers for a different machine or PHP version.
256310
## Benchmarking
257311

258312
Run the benchmark suite with:
@@ -283,10 +337,55 @@ vendor/bin/phpbench run --report=providers --iterations=40 --revs=5 --retry-thre
283337
Compared providers:
284338

285339
- `php-passphrase` with EFF 5 words (~64.6 bits)
286-
- `genphrase/genphrase` with a 65-bit target on diceware mode
287-
- `martbock/laravel-diceware` with EFF 5 words (~64.6 bits)
288340
- `random_bytes(8)` (~64 bits)
289341
- `Illuminate\\Support\\Str::random(11)` (~65.5 bits)
342+
- `Illuminate\\Support\\Str::password(10)` with default options (~64.6 bits)
343+
344+
Optional providers can be added `composer require --dev genphrase/genphrase martbock/laravel-diceware` and will be included in the benchmark suite if present:
345+
346+
- `genphrase/genphrase` with a 65-bit target on diceware mode
347+
- `martbock/laravel-diceware` with EFF 5 words (~64.6 bits)
348+
349+
The `eurosat7/random` package cannot currently be required directly via Composer VCS because its upstream `composer.json` has no valid package `name`.
350+
351+
Use a local package-repository override in your `composer.json` instead:
352+
353+
```json
354+
{
355+
"repositories": [
356+
{
357+
"type": "package",
358+
"package": {
359+
"name": "eurosat7/random",
360+
"version": "dev-main",
361+
"source": {
362+
"type": "git",
363+
"url": "https://github.com/eurosat7/random.git",
364+
"reference": "main"
365+
},
366+
"autoload": {
367+
"psr-4": {
368+
"Eurosat7\\Random\\": "src/"
369+
}
370+
},
371+
"require": {
372+
"php": ">=8.2"
373+
}
374+
}
375+
}
376+
]
377+
}
378+
```
379+
380+
Then run:
381+
382+
```bash
383+
composer require --dev eurosat7/random:dev-main
384+
```
385+
386+
When installed, the benchmark also includes:
387+
388+
- `eurosat7/random` via `Eurosat7\Random\Generator::password(10)` (~64+ bits)
290389

291390
Baseline and comparison runs:
292391

benchmarks/ProvidersBench.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace NicoBleiler\Passphrase\Benchmarks;
66

7+
use Eurosat7\Random\Generator as EurosatRandomGenerator;
78
use GenPhrase\Password;
89
use Illuminate\Container\Container;
910
use Illuminate\Filesystem\Filesystem;
@@ -70,6 +71,14 @@ public function provideProviders(): iterable
7071
];
7172
}
7273

74+
if (class_exists(EurosatRandomGenerator::class)) {
75+
yield 'eurosat7/random Generator::password(10) (~64+ bits)' => [
76+
'provider' => 'eurosat7-random-password',
77+
'kind' => 'baseline',
78+
'entropy_bits' => 64.0,
79+
];
80+
}
81+
7382
yield 'random_bytes(8) hex (~64 bits)' => [
7483
'provider' => 'random-bytes',
7584
'kind' => 'baseline',
@@ -98,6 +107,7 @@ private function createGenerator(string $provider): callable
98107
'php-passphrase' => $this->phpPassphraseGenerator(),
99108
'genphrase' => $this->genphraseGenerator(),
100109
'laravel-diceware' => $this->laravelDicewareGenerator(),
110+
'eurosat7-random-password' => static fn (): string => EurosatRandomGenerator::password(10),
101111
'random-bytes' => static fn (): string => bin2hex(random_bytes(8)),
102112
'illuminate-str-random' => static fn (): string => Str::random(11),
103113
'illuminate-str-password' => static fn (): string => Str::password(10),

config/passphrase.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
|--------------------------------------------------------------------------
99
|
1010
| The default number of words to include in a generated passphrase.
11-
| Must be between 3 and 20.
11+
| Must be a minimum of 3.
1212
|
1313
*/
1414
'num_words' => 3,
@@ -57,4 +57,17 @@
5757
*/
5858
'word_list' => null,
5959

60+
/*
61+
|--------------------------------------------------------------------------
62+
| Excluded Words
63+
|--------------------------------------------------------------------------
64+
|
65+
| Words that should be removed from the configured word list.
66+
|
67+
| Set to an empty array to disable word exclusion.
68+
| Or provide your own list, for example: ['incorrect', 'wrong', 'fail', 'error']
69+
|
70+
*/
71+
'excluded_words' => [],
72+
6073
];
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace NicoBleiler\Passphrase\Exceptions;
6+
7+
use InvalidArgumentException;
8+
9+
class InvalidEntropyBitsTargetException extends InvalidArgumentException
10+
{
11+
public function __construct()
12+
{
13+
parent::__construct('Target entropy bits must be greater than 0');
14+
}
15+
}

src/Exceptions/WordListException.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,14 @@ public static function invalidType(): self
2727
{
2828
return new self('Word list must contain only strings');
2929
}
30+
31+
public static function invalidExcludedWordsType(): self
32+
{
33+
return new self('Excluded words must contain only strings');
34+
}
35+
36+
public static function invalidExcludedWordsConfigType(): self
37+
{
38+
return new self('Excluded words config must be an array of strings');
39+
}
3040
}

0 commit comments

Comments
 (0)