Password Strength Analyser — PHP source
Estimate password strength with zxcvbn - realistic dictionary and pattern cracking with crack-time estimates and improvement suggestions. Runs entirely in your browser.
This is the PHP implementation — the same logic the interactive tool runs, in a shareable, citable form.
<?php
/**
* password-strength-analyser — password strength scoring & feedback.
*
* Language: PHP (8.1+, standard library only — mbstring used for safe
* multibyte handling, which is effectively universal in modern PHP)
* Source: CosmoDev polyglot showcase port of the Password Strength Analyser
* tool, ported from cli/password-strength-analyser/password-strength-
* analyser.go (the live Go twin, which wraps zxcvbn) and
* src/lib/password-strength.ts (the canonical TS lib).
* License: display source — part of CosmoDev's polyglot tool pages.
*
* Design goals:
* - Pure + deterministic; never throws on odd input.
* - Self-contained: stdlib only (no Composer packages — no zxcvbn port).
* - Mirrors the Go twin's public API and result shape: pw_analyse() returns
* an associative array with `score` (0–4), `label`, `entropy` (bits), and a
* single crack-time display string.
*
* Parity note: the live Go and TS twins both delegate scoring to `zxcvbn`, a
* dictionary + pattern estimator whose ranking data is far too large to vendor
* in a stdlib display port. This snippet reproduces the SAME 0–4 scale and the
* SAME public contract with a self-contained heuristic estimator:
* (a) a curated common-password blocklist — forces the obvious weak passwords
* (password, 123456, qwerty, ...) to score 0, exactly as zxcvbn's
* dictionary does;
* (b) a character-pool entropy estimate (length × log2(pool));
* (c) penalties for low symbol variety, keyboard / numeric sequences, and
* user-supplied related tokens (the user_inputs seed zxcvbn accepts).
* It agrees with zxcvbn on the clear cases — common passwords score 0, long
* diverse passphrases score 3–4 — and is a reasonable approximation in between.
* This is a deliberate, documented trade-off to keep the port dependency-free;
* for exact scores use the live Go/TS twin.
*/
declare(strict_types=1);
/**
* Strength labels indexed by score. Identical to the Go/TS twins.
*/
const PW_LABELS = ['Very weak', 'Weak', 'Fair', 'Good', 'Strong'];
/**
* Curated common passwords (lowercased). An exact match forces score 0 — the
* observable effect of zxcvbn's dictionary for the most leaked passwords. A
* small, hand-picked set rather than zxcvbn's ~30k-entry list: enough to make
* the showcase behaviour meaningful without vendoring a data file. Stored as
* array keys for O(1) isset() lookup.
*/
const PW_COMMON = [
'1234' => true, '12345' => true, '123123' => true, '123456' => true,
'12345678' => true, '123456789' => true, '1234567890' => true,
'000000' => true, '111111' => true, '654321' => true, '666666' => true,
'abc123' => true, 'admin' => true, 'baseball' => true, 'batman' => true,
'dragon' => true, 'football' => true, 'iloveyou' => true, 'letmein' => true,
'login' => true, 'master' => true, 'monkey' => true, 'passw0rd' => true,
'password' => true, 'password1' => true, 'princess' => true, 'qwerty' => true,
'root' => true, 'shadow' => true, 'superman' => true, 'sunshine' => true,
'trustno1' => true, 'welcome' => true,
];
/**
* Keyboard rows and numeric / alphabetic runs. Any length-4 window of these
* appearing inside the password signals an easy-to-guess sequence and incurs
* an entropy penalty.
*/
const PW_SEQUENCES = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm', '1234567890', '0987654321', 'abcdefg', 'gfedcba'];
/** Min length for a sequence window to count as a match. */
const PW_SEQ_MIN_LEN = 4;
/**
* Entropy thresholds mapping a bit count to the 0–4 score. Shared across the
* whole polyglot showcase so every port lands on the same bucket.
*/
const PW_THRESHOLDS = [28.0, 36.0, 60.0, 128.0];
/**
* Tally the distinct character-class buckets present and return the combined
* guess-pool size (lower, upper, digit, ASCII symbols, other).
*/
function pw_pool_size(string $password): int
{
$lower = $upper = $digit = $sym = $other = false;
// preg_split(//u) splits into a UTF-8 code-point array; mb_ord gives the
// integer code point for the ASCII range checks below.
$chars = preg_split('//u', $password, -1, PREG_SPLIT_NO_EMPTY) ?: [];
foreach ($chars as $char) {
$cp = mb_ord($char, 'UTF-8');
if ($cp <= 0x7F) { // ASCII range
if ($cp >= 0x61 && $cp <= 0x7A) { // a-z
$lower = true;
} elseif ($cp >= 0x41 && $cp <= 0x5A) { // A-Z
$upper = true;
} elseif ($cp >= 0x30 && $cp <= 0x39) { // 0-9
$digit = true;
} else {
$sym = true; // ASCII punctuation/space
}
} else {
$other = true; // any non-ASCII code point
}
}
$pool = 0;
if ($lower) { $pool += 26; }
if ($upper) { $pool += 26; }
if ($digit) { $pool += 10; }
if ($sym) { $pool += 33; } // printable ASCII non-alnum: 32 punctuation + space
if ($other) { $pool += 128; } // broad bucket for the rest of Unicode
return $pool;
}
/**
* Report whether $lower contains any length-$min_len window drawn from $seq —
* i.e. a keyboard/numeric run of consecutive symbols.
*/
function pw_contains_sequence(string $lower, string $seq, int $min_len = PW_SEQ_MIN_LEN): bool
{
$len = mb_strlen($seq, 'UTF-8');
if ($len < $min_len) {
return false;
}
for ($i = 0; $i <= $len - $min_len; $i++) {
$window = mb_substr($seq, $i, $min_len, 'UTF-8');
if (mb_strpos($lower, $window, 0, 'UTF-8') !== false) {
return true;
}
}
return false;
}
/**
* Human-readable crack-time for an offline fast attack (1e10 guesses/sec),
* mirroring zxcvbn's `offline_fast_hashing_1e10_per_second` display. Works in
* log-space so large entropies (2^200 ≈ 1e60 guesses) stay finite.
*/
function pw_crack_time_display(float $entropy): string
{
if ($entropy <= 0.0) {
return 'instant';
}
$log10_seconds = $entropy * log10(2.0) - 10.0; // log10(guesses) − log10(1e10)
if ($log10_seconds < 0.0) {
return 'instant';
}
$seconds = pow(10.0, $log10_seconds);
if ($seconds < 1.0) {
return 'instant';
}
$minute = 60.0;
$hour = 3_600.0;
$day = 86_400.0;
$month = 2_592_000.0; // 30 days
$year = 31_536_000.0; // 365 days
if ($seconds < $minute) {
return round($seconds) . ' seconds';
}
if ($seconds < $hour) {
return round($seconds / $minute) . ' minutes';
}
if ($seconds < $day) {
return round($seconds / $hour) . ' hours';
}
if ($seconds < $month) {
return round($seconds / $day) . ' days';
}
if ($seconds < $year) {
return round($seconds / $month) . ' months';
}
$years = $seconds / $year;
if ($years < 1_000.0) {
return round($years) . ' years';
}
if ($years < 1_000_000.0) {
return round($years / 1_000.0) . ' thousand years';
}
if ($years < 1_000_000_000.0) {
return round($years / 1_000_000.0) . ' million years';
}
return 'centuries';
}
/**
* Score a password's strength. $user_inputs seeds the estimator with related
* tokens (username, site name, ...) — a substring match weakens the score,
* mirroring the userInputs parameter of the Go/TS twins.
*
* @param list<string> $user_inputs
* @return array{score:int, label:string, entropy:float, crack_time_display:string}
*/
function pw_analyse(string $password, array $user_inputs = []): array
{
// Empty → trivially weak.
if ($password === '') {
return ['score' => 0, 'label' => PW_LABELS[0], 'entropy' => 0.0, 'crack_time_display' => 'instant'];
}
$length = mb_strlen($password, 'UTF-8'); // code-point count
$pool = pw_pool_size($password);
$log_pool = $pool > 1 ? log($pool, 2.0) : 0.0;
// Base entropy: length × log2(pool), capped by symbol variety so heavy
// repetition ("aaaaaaaa") collapses instead of accruing length credit.
$raw = $length * $log_pool;
// mb_str_split yields one entry per code point; array_unique dedupes.
$chars = mb_str_split($password, 1, 'UTF-8') ?: [];
$unique = count(array_unique($chars));
if ($unique < $length && $pool > 1) {
// Repeats earn only a quarter credit: U full picks + 0.25× the repeats.
$variety = ($unique + ($length - $unique) * 0.25) * $log_pool;
} else {
$variety = $raw;
}
$entropy = $pool > 1 ? min($raw, $variety) : 0.0;
// Lowercased view for dictionary / sequence / user-input matching.
$lower = mb_strtolower($password, 'UTF-8');
// (a) common-password blocklist forces score 0 and zero entropy, the way
// zxcvbn's dictionary collapses a known leak.
$common = isset(PW_COMMON[$lower]);
if ($common) {
$entropy = 0.0;
} else {
// (b) keyboard / numeric sequence penalty.
foreach (PW_SEQUENCES as $seq) {
if (pw_contains_sequence($lower, $seq)) {
$entropy -= 12.0;
}
}
// (c) user-input relatedness penalty.
foreach ($user_inputs as $ui) {
$l = mb_strtolower((string) $ui, 'UTF-8');
if (mb_strlen($l, 'UTF-8') >= 3 && mb_strpos($lower, $l, 0, 'UTF-8') !== false) {
$entropy -= 10.0;
}
}
if ($entropy < 0.0) {
$entropy = 0.0;
}
}
// Map entropy (and a minimum-length floor) to the 0–4 bucket.
if ($length < 4 || $entropy < PW_THRESHOLDS[0]) {
$score = 0;
} elseif ($entropy < PW_THRESHOLDS[1]) {
$score = 1;
} elseif ($entropy < PW_THRESHOLDS[2]) {
$score = 2;
} elseif ($entropy < PW_THRESHOLDS[3]) {
$score = 3;
} else {
$score = 4;
}
return [
'score' => $score,
'label' => PW_LABELS[$score],
'entropy' => $entropy,
'crack_time_display' => pw_crack_time_display($entropy),
];
}
// ---------- showcase tests (run with: php php.php) ----------
if (PHP_SAPI === 'cli' && isset($argv) && realpath($argv[0]) === __FILE__) {
$r = pw_analyse('password');
assert($r['score'] === 0);
assert($r['label'] === 'Very weak');
$strong = pw_analyse('correct horse battery staple');
assert($strong['score'] >= 3);
assert(pw_analyse('123456')['score'] <= $strong['score']);
assert(pw_analyse('aB3$xK9p')['entropy'] > pw_analyse('aaaaaaaa')['entropy']);
foreach (['password', '12345678', 'monkey', 'Tr0ub4dour&3', 'correct horse battery staple', 'u2#9Xq!Lp$7wZ'] as $pw) {
$row = pw_analyse($pw);
assert($row['score'] >= 0 && $row['score'] <= 4);
assert($row['label'] !== '');
assert($row['crack_time_display'] !== '');
}
assert(pw_analyse('cosmolabs2024', ['cosmolabs'])['score'] <= pw_analyse('cosmolabs2024')['score']);
echo "all showcase tests passed\n";
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →