Skip to content

Breach checks

phil/passwords

k-anonymity

PwnedPasswordsSource hashes the password with SHA-1, sends the first five hexadecimal characters of the hash to the range API, and looks for the other 35 in the answer: the service never sees the password nor its hash, only a prefix shared by hundreds of others. Padding is asked by default (Add-Padding: true), so that the size of the answer tells an observer of the traffic nothing of the prefix. SHA-1 here is the address of the password in the corpus, not a protection of it: nothing in the library stores a password.

$checker = new BreachChecker(new PwnedPasswordsSource($httpClient));
$check = $checker->check($password);             // threshold 1
$check = $checker->check($password, threshold: 10);
Member of BreachCheckWhat it holds
statusBreachStatus::Compromised, NotFound or Unavailable.
occurrencesThe occurrences the source reported, 0 when it could not answer.
failureThe BreachCheckUnavailable of a source that could not answer, null otherwise.
isCompromised(), isUnavailable()Shortcuts.

The source

Argument of PwnedPasswordsSourceDefaultEffect
client—The HTTP client, from symfony/http-client-contracts.
baseUrlhttps://api.pwnedpasswords.com/range/A mirror that serves the same format.
paddingtrueAsks for padded answers.
timeout5 secondsThe idle timeout; the whole request is bounded by twice that.
userAgentphil-passwordsThe service refuses requests without one.
maxBytes1 MiBThe largest answer read; a padded range is a few tens of kilobytes.

Failures

A source that cannot answer — the network, a status other than 200, a timeout, an answer too large or malformed — throws BreachCheckUnavailable, and BreachChecker turns it into the status Unavailable with the failure. Whether to accept the password then is the application's decision: the NotCompromised constraint refuses it by default (failing closed) and accepts it with skipOnError. The password is never in the message of the failure.

BreachCheckUnavailable is the one exception a circuit breaker around the source counts. A decorator of the port keeps the source from being called while the circuit is open:

final readonly class GuardedSource implements BreachedPasswordSource
{
    public function __construct(private BreachedPasswordSource $inner, private CircuitBreaker $breaker)
    {
    }

    public function occurrences(#[SensitiveParameter] string $password): int
    {
        if (!$this->breaker->isAvailable()) {
            throw new BreachCheckUnavailable('The breach source is paused.');
        }

        try {
            $occurrences = $this->inner->occurrences($password);
        } catch (BreachCheckUnavailable $failure) {
            $this->breaker->recordFailure();

            throw $failure;
        }

        $this->breaker->recordSuccess();

        return $occurrences;
    }
}

Other sources

InMemoryBreachSource holds hashes in memory: fromPasswords() for a list of passwords and their occurrences, fromHashes() for SHA-1 hashes, as a list imported for a network without access to the Internet. Any class that implements BreachedPasswordSource is a source; RangeResponse::occurrences() reads a range for a source that fetches it otherwise.