passwordcheck

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 20, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

Package passwordcheck screens passwords against known-compromised values.

NIST SP 800-63B asks that a password chosen by a user be compared against a list of values known to be commonly used, expected, or compromised, and rejected if it appears there. Length alone does not do this: "iloveyou1234" is twelve characters and has been breached millions of times.

Two checkers ship here:

  • NewBlocklist compares against a corpus of common passwords embedded in the binary. No network, no third party, no failure mode. This is the default in sulis, and it is what a deployment gets without asking.
  • NewHIBP queries the Have I Been Pwned range API, which covers orders of magnitude more passwords than any list worth embedding. It is opt-in because it makes password *changes* depend on a third party being reachable — see the fail-open/fail-closed discussion on NewHIBP.

Use All to run both:

sulis.WithPasswordChecker(passwordcheck.All(
	passwordcheck.NewBlocklist(),
	passwordcheck.NewHIBP(),
))

Relationship to sulis

This package deliberately does not import sulis: sulis's default configuration constructs a Blocklist, so an import in this direction would be a cycle. Checker here and sulis.PasswordChecker there are the same method set, so any checker written against either interface satisfies both, and ErrCompromised is the very same error value sulis exports as sulis.ErrPasswordCompromised — errors.Is works against either name.

Normalization

sulis applies Unicode NFKC normalization to a password before it reaches a checker, so what arrives at Checker.Check is exactly the string that will be hashed and stored. A checker used standalone is handed whatever its caller passes; if that caller also hashes the raw form, normalize on both sides or the comparison and the hash disagree about what the password is.

Index

Constants

View Source
const DefaultHIBPBaseURL = "https://api.pwnedpasswords.com/range/"

DefaultHIBPBaseURL is the Have I Been Pwned range endpoint NewHIBP queries unless WithHIBPBaseURL says otherwise. The five-character prefix sent to it is not a secret, but the response must not be tamperable — a downgraded or forged reply is a silent "this password is fine" — so this is https and should stay that way.

Variables

View Source
var ErrCompromised = errors.New("sulis: password appears in a breach corpus")

ErrCompromised is returned by a Checker that recognises a password as commonly used, expected, or previously breached. sulis re-exports this exact value as sulis.ErrPasswordCompromised, so errors.Is matches it under either name.

It says nothing about the account: a password can be rejected here on the first registration attempt, before any account exists. Callers should surface it to the user as "choose a different password", never as a credential or account failure.

View Source
var ErrMalformedResponse = errors.New("passwordcheck: malformed HIBP response for the queried range")

ErrMalformedResponse indicates a lookup could not complete because the range response held data this client could not parse. Today that means exactly one row shape: a suffix matching the password being checked whose count is not a parseable integer. A real API has never been observed to send one — this is corrupted-mirror or tampering-middlebox territory. A malformed row for some other suffix is unrelated noise and never produces this error; see [HIBP.lookup]'s doc comment.

Under the fail-open default, an error wrapping this is treated the same as any other unreachable verdict: HIBP.Check returns nil and the password is accepted. Under WithHIBPFailClosed, Check returns the wrapped error instead, and it is never ErrCompromised either way.

This is exported so an application that wants custom handling for exactly this condition — without a new HIBPOption — can get it: wrap an *HIBP in your own Checker, call its Check, and branch on errors.Is(err, ErrMalformedResponse) to, say, fail closed only on suspected tampering while still failing open on an ordinary transport error, or to alert on it distinctly.

Functions

This section is empty.

Types

type Blocklist

type Blocklist struct {
	// contains filtered or unexported fields
}

Blocklist rejects passwords that appear in an embedded corpus of the ten thousand most common passwords, plus any extra entries supplied to NewBlocklist.

Comparison folds case: the corpus is lowercase, and so is the candidate before lookup. An attacker's dictionary is case-folded too, so matching only the exact lowercase spelling would be bypassed by pressing shift once. The cost is that Blocklist rejects slightly more than the literal corpus — "PASSWORD" is refused although only "password" is listed, which is the right trade.

Nothing else is stripped or folded. A decorated variant such as "password!" is not in the corpus and is not rejected here; catching those is what NewHIBP is for, since the breach corpus behind it already contains the decorated variants people actually chose.

What the default minimum length leaves it to do

Common passwords are short. At sulis's default MinPasswordLength of 12, only ten of the corpus's ten thousand entries are even reachable — the length gate rejects the rest first, and rejects them for a better reason. The blocklist earns its place in two situations: a deployment that lowers the minimum via sulis.WithPasswordLengthLimits, and site-specific words passed to NewBlocklist (a company name, a product, a stadium) which are exactly the twelve-plus-character passwords a targeted attacker tries first. For breadth beyond that, add NewHIBP.

A Blocklist is safe for concurrent use.

func NewBlocklist

func NewBlocklist(extra ...string) *Blocklist

NewBlocklist returns a Blocklist over the embedded common-password corpus, plus any extra values given. Extras are compared case-insensitively, like corpus entries, and are the place to put words specific to your deployment — the organisation's name, its products, its city — which no general corpus can know about but a targeted attacker will try early.

The returned value is cheap: the corpus is parsed lazily, at most once per process, and shared by every Blocklist.

func (*Blocklist) Check

func (b *Blocklist) Check(_ context.Context, password string) error

Check reports ErrCompromised if password appears in the embedded corpus or among the extras, and nil otherwise. It never fails for any other reason: there is nothing to reach and nothing to time out, which is why this is the default checker.

The empty string is always accepted. An empty password is the length policy's rejection to make, and answering "appears in a breach corpus" would be a lie about data this package does not have.

type Checker

type Checker interface {
	Check(ctx context.Context, password string) error
}

Checker screens a candidate password.

Check returns nil if the password is acceptable, ErrCompromised (or an error wrapping it) if the password is known-compromised, and any other error if it could not reach a verdict. Callers must treat those last two cases differently: an unreachable breach corpus is an operational failure, not evidence about the password, and reporting it as one teaches users to distrust the message.

Implementations must be safe for concurrent use and must respect ctx.

This is the same method set as sulis.PasswordChecker; either interface accepts a value implementing the other.

func All

func All(checkers ...Checker) Checker

All returns a Checker that runs each of checkers in order and returns the first non-nil error, or nil if every one of them accepts the password.

Order matters: put the cheap local checks first so an obviously bad password is rejected without a network round trip. All() with no arguments accepts every password, which is a usable "checking is configured, but this deployment has nothing to check" value.

type CheckerFunc

type CheckerFunc func(ctx context.Context, password string) error

CheckerFunc adapts an ordinary function to Checker.

func (CheckerFunc) Check

func (f CheckerFunc) Check(ctx context.Context, password string) error

Check calls f.

type HIBP

type HIBP struct {
	// contains filtered or unexported fields
}

HIBP checks passwords against the Have I Been Pwned range API using k-anonymity: only the first five hexadecimal digits of the password's SHA-1 are ever sent. The service answers with every hash suffix it holds under that prefix — several hundred to a few thousand of them — and the match is made locally. The password, its full hash, and even the hash's suffix never leave the process.

That property is the entire basis for querying a third party about a credential at all, so it is pinned by a test that inspects the whole outbound request — line, headers and body — and fails if any of those three values appears anywhere in it.

Availability

This makes registration, password change, and password reset depend on another organisation's uptime. By default an HIBP that cannot get an answer — connection refused, timeout, 5xx, 429 — returns nil: the password is allowed through unchecked. WithHIBPFailClosed inverts that. See NewHIBP for why open is the default.

Case and padding

Suffixes are compared case-insensitively, and rows with a count of zero are ignored: they are the fabricated padding entries the API adds when asked (which this client always does), and treating one as a hit would reject a perfectly good password.

An HIBP is safe for concurrent use.

func NewHIBP

func NewHIBP(opts ...HIBPOption) *HIBP

NewHIBP returns a Checker backed by the Have I Been Pwned range API.

It is opt-in, and it fails open by default. Both of those are deliberate.

Opt-in, because a library should not start making outbound requests on a user's behalf because they upgraded a dependency; the embedded NewBlocklist is the default instead, and it has no such property to declare.

Fail open, because the alternative makes a third party's availability a hard dependency of your own registration and password-reset flows. When api.pwnedpasswords.com is unreachable, a fail-closed deployment cannot register a user or complete a password reset — including the reset someone is doing *because* they were just breached. Weighed against that, the cost of failing open is that a small number of passwords set during an outage go unchecked against this corpus, while still facing the length policy and the embedded blocklist. Availability of the recovery path wins. Operators whose policy says otherwise have WithHIBPFailClosed, and should pair it with alerting on the error, because silently degrading to "nobody can change their password" is the failure mode to actually fear here.

Typical use, alongside the local blocklist rather than instead of it:

sulis.WithPasswordChecker(passwordcheck.All(
	passwordcheck.NewBlocklist(),
	passwordcheck.NewHIBP(),
))

func (*HIBP) Check

func (h *HIBP) Check(ctx context.Context, password string) error

Check looks password up in the breach corpus and returns ErrCompromised if it is present with a non-zero count.

A lookup that cannot reach a verdict returns nil by default and the underlying error when WithHIBPFailClosed is set; either way that error is never ErrCompromised. A cancelled or expired ctx is an unreachable verdict like any other, so it too is subject to the fail-open/fail-closed choice. One such error, ErrMalformedResponse, is exported specifically so callers can distinguish it from other unreachable-verdict causes with errors.Is.

type HIBPOption

type HIBPOption func(*HIBP)

HIBPOption configures an HIBP.

func WithHIBPBaseURL

func WithHIBPBaseURL(rawURL string) HIBPOption

WithHIBPBaseURL replaces DefaultHIBPBaseURL. The five-character prefix is appended directly to it, with exactly one "/" in between whether or not the URL already ends in one.

Intended for tests and for a self-hosted mirror of the range data. Pointing it at an untrusted host hands that host a prefix of every password set in your application, and lets it answer "not breached" to everything.

func WithHIBPFailClosed

func WithHIBPFailClosed() HIBPOption

WithHIBPFailClosed makes a lookup that could not reach a verdict reject the password instead of allowing it: HIBP.Check returns the underlying transport or protocol error, which is deliberately not ErrCompromised — an unreachable service is not evidence about the password, and telling a user their password is breached when nobody actually looked is a lie that costs you their trust in the message.

"Could not reach a verdict" also covers a response row that matches this password's suffix but whose count this client cannot parse: that row was supposed to be the answer, so under this option it is treated as an incomplete check and rejected, not silently read as "not found" (see lookup's doc comment and ErrMalformedResponse). A malformed row for some other suffix is unrelated noise and is still skipped either way.

Choose this when policy genuinely requires that no password is ever set without a breach check having succeeded, and make sure the surrounding application turns the resulting error into "try again in a moment" rather than "choose a different password".

func WithHIBPHTTPClient

func WithHIBPHTTPClient(c *http.Client) HIBPOption

WithHIBPHTTPClient supplies the *http.Client used for lookups — for a proxy, a custom transport, or connection pooling shared with the rest of an application. The client's own Timeout is respected in addition to WithHIBPTimeout, not instead of it.

func WithHIBPTimeout

func WithHIBPTimeout(d time.Duration) HIBPOption

WithHIBPTimeout bounds a single lookup (default 5s). It is applied to the request context, so it holds for a client supplied via WithHIBPHTTPClient as well as for the default one.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL