🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@sentinel-password/breach

Package Overview
Dependencies
Maintainers
1
Versions
7
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@sentinel-password/breach - npm Package Compare versions

Comparing version
0.2.5
to
0.3.0
+19
-6
dist/index.cjs

@@ -35,2 +35,5 @@ "use strict";

function isTimeout(error) {
if (typeof error !== "object" || error === null) {
return false;
}
const name = String(error.name);

@@ -74,3 +77,11 @@ return name === "AbortError" || name === "TimeoutError";

const suffix = hash.slice(5);
let body = options.cache?.get(prefix);
let body;
try {
const cached = options.cache?.get(prefix);
if (typeof cached === "string") {
body = cached;
}
} catch {
body = void 0;
}
if (body === void 0) {

@@ -81,8 +92,7 @@ const headers = {};

}
const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const signal = options.signal !== void 0 ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
let response;
try {
response = await fetchImpl(RANGE_API + prefix, {
headers,
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
});
response = await fetchImpl(RANGE_API + prefix, { headers, signal });
} catch (error) {

@@ -102,3 +112,6 @@ return { status: "error", reason: isTimeout(error) ? "timeout" : "network" };

}
options.cache?.set(prefix, body);
try {
options.cache?.set(prefix, body);
} catch {
}
}

@@ -105,0 +118,0 @@ const breachCount = countFor(body, suffix);

@@ -1,1 +0,1 @@

{"version":3,"sources":["../src/index.ts","../src/check.ts","../src/cache.ts","../src/messages.ts"],"sourcesContent":["/**\n * @sentinel-password/breach\n *\n * Have I Been Pwned breach checking via k-anonymity. The password is SHA-1\n * hashed locally and only the first 5 hex characters of the digest are sent to\n * the Pwned Passwords range API — the password, full hash, and matched suffix\n * never leave the process and are never logged.\n *\n * Async and opt-in. Decoupled from `@sentinel-password/core` (no shared types\n * or runtime); compose the two explicitly. Recommended for server-side use.\n * Zero runtime dependencies. ≤ 10 KB gzipped (CI enforced).\n *\n * @example\n * ```typescript\n * import { validatePassword } from '@sentinel-password/core'\n * import { checkBreach } from '@sentinel-password/breach'\n *\n * const rule = validatePassword(password)\n * const pwned = await checkBreach(password)\n *\n * if (pwned.status === 'error') {\n * // your call: block submission, or allow and log the degraded check\n * } else {\n * const ok = rule.valid && !pwned.breached\n * }\n * ```\n */\n\nexport { checkBreach } from './check'\nexport { createBreachCache } from './cache'\nexport { DEFAULT_BREACH_MESSAGES, resolveBreachMessage } from './messages'\n\nexport type {\n BreachCache,\n BreachError,\n BreachErrorReason,\n BreachMessageCode,\n BreachMessageFormatter,\n BreachMessageOptions,\n BreachMessageParams,\n BreachOk,\n BreachOptions,\n BreachResult,\n} from './types'\n","import type { BreachOptions, BreachResult } from './types'\n\nconst RANGE_API: string = 'https://api.pwnedpasswords.com/range/'\nconst DEFAULT_TIMEOUT_MS: number = 5000\nconst DEFAULT_THRESHOLD: number = 1\n\n/** True for the abort/timeout signals raised by `AbortSignal.timeout`. */\nfunction isTimeout(error: unknown): boolean {\n const name: string = String((error as { name?: unknown }).name)\n return name === 'AbortError' || name === 'TimeoutError'\n}\n\n/** Uppercase hex encoding of an `ArrayBuffer`. */\nfunction toHex(buffer: ArrayBuffer): string {\n let hex: string = ''\n for (const byte of new Uint8Array(buffer)) {\n hex += byte.toString(16).padStart(2, '0')\n }\n return hex.toUpperCase()\n}\n\n/** Find the exposure count for `suffix` in a Pwned Passwords range body. */\nfunction countFor(body: string, suffix: string): number {\n for (const line of body.split('\\n')) {\n const idx: number = line.indexOf(':')\n if (idx === -1) {\n continue\n }\n if (line.slice(0, idx).trim().toUpperCase() === suffix) {\n return parseInt(line.slice(idx + 1), 10) || 0\n }\n }\n return 0\n}\n\n/**\n * Check a password against Have I Been Pwned's Pwned Passwords range API using\n * the k-anonymity model: the password is SHA-1 hashed locally and only the\n * first 5 hex characters of the digest are sent to the API. The full hash, the\n * password, and the matched suffix never leave this process and are never\n * logged.\n *\n * Never throws and never silently reports \"safe\" on failure — on any error it\n * resolves to `{ status: 'error', reason }` so the caller explicitly decides\n * fail-open vs fail-closed. Recommended for server-side use.\n *\n * @example\n * ```typescript\n * import { checkBreach } from '@sentinel-password/breach'\n *\n * const r = await checkBreach(password)\n * if (r.status === 'error') {\n * // your call: block submission, or allow and log\n * } else if (r.breached) {\n * // r.breachCount appearances in known breaches\n * }\n * ```\n */\nexport async function checkBreach(\n password: string,\n options: BreachOptions = {}\n): Promise<BreachResult> {\n if (password === '') {\n return { status: 'ok', breachCount: 0, breached: false }\n }\n\n const fetchImpl: typeof fetch | undefined = options.fetch ?? globalThis.fetch\n if (typeof fetchImpl !== 'function') {\n return { status: 'error', reason: 'unsupported', detail: 'fetch unavailable' }\n }\n const subtle: SubtleCrypto | undefined = globalThis.crypto?.subtle\n if (!subtle) {\n return { status: 'error', reason: 'unsupported', detail: 'crypto.subtle unavailable' }\n }\n\n const digest: ArrayBuffer = await subtle.digest('SHA-1', new TextEncoder().encode(password))\n const hash: string = toHex(digest)\n const prefix: string = hash.slice(0, 5)\n const suffix: string = hash.slice(5)\n\n let body: string | undefined = options.cache?.get(prefix)\n if (body === undefined) {\n const headers: Record<string, string> = {}\n if (options.addPadding ?? true) {\n headers['Add-Padding'] = 'true'\n }\n\n let response: Response\n try {\n response = await fetchImpl(RANGE_API + prefix, {\n headers,\n signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),\n })\n } catch (error: unknown) {\n return { status: 'error', reason: isTimeout(error) ? 'timeout' : 'network' }\n }\n\n if (response.status === 429) {\n return { status: 'error', reason: 'rate-limit' }\n }\n if (!response.ok) {\n return { status: 'error', reason: 'http', detail: `HTTP ${String(response.status)}` }\n }\n\n try {\n body = await response.text()\n } catch {\n return { status: 'error', reason: 'network' }\n }\n options.cache?.set(prefix, body)\n }\n\n const breachCount: number = countFor(body, suffix)\n // Guard against a misconfigured threshold (NaN/0/negative — e.g. a bad\n // `parseInt` of an env var). A NaN threshold makes `breachCount >= threshold`\n // always false, silently reporting a pwned password as safe — the exact\n // fail-open this package avoids elsewhere. Fall back to the default instead.\n const rawThreshold: number | undefined = options.threshold\n const threshold: number =\n rawThreshold !== undefined && Number.isFinite(rawThreshold) && rawThreshold >= 1\n ? rawThreshold\n : DEFAULT_THRESHOLD\n return { status: 'ok', breachCount, breached: breachCount >= threshold }\n}\n","import type { BreachCache } from './types'\n\n/**\n * Create a bounded, in-memory {@link BreachCache} with FIFO eviction.\n *\n * Keys are 5-hex-char SHA-1 prefixes; values are raw Pwned Passwords range\n * response bodies. Once `maxEntries` distinct prefixes are stored, inserting a\n * new prefix evicts the oldest one. Updating an existing prefix replaces its\n * value in place without changing insertion order or size.\n *\n * @example\n * ```typescript\n * import { checkBreach, createBreachCache } from '@sentinel-password/breach'\n *\n * const cache = createBreachCache()\n * await checkBreach('hunter2', { cache })\n * await checkBreach('hunter2', { cache }) // served from cache, no network\n * ```\n */\nexport function createBreachCache(maxEntries: number = 1024): BreachCache {\n const store: Map<string, string> = new Map<string, string>()\n\n return {\n get(prefix: string): string | undefined {\n return store.get(prefix)\n },\n set(prefix: string, body: string): void {\n if (!store.has(prefix) && store.size >= maxEntries) {\n // A new key at capacity: size >= maxEntries >= 0 and the key is\n // absent, so the store is non-empty and has an oldest entry.\n const oldest: string = store.keys().next().value as string\n store.delete(oldest)\n }\n store.set(prefix, body)\n },\n }\n}\n","import type { BreachMessageCode, BreachMessageOptions, BreachMessageParams } from './types'\n\n/**\n * Built-in English template for every {@link BreachMessageCode}.\n *\n * Strings are short, stable English so consumers can use them as translation\n * keys. Placeholders use `{name}` syntax. This map is owned by this package and\n * intentionally does NOT import core's `MessageCode` union — the two packages\n * stay decoupled and version independently.\n */\nexport const DEFAULT_BREACH_MESSAGES: Readonly<Record<BreachMessageCode, string>> = {\n // Intentionally count-free: a logic-less template cannot pluralize, and\n // \"appeared in 1 known data breaches\" would be ungrammatical. The exact\n // exposure count is available as `BreachOk.breachCount`; callers who want it\n // in the message can interpolate via a `messages` / `formatMessage` override.\n 'breach.found': 'This password has appeared in known data breaches. Choose a different one.',\n} as const\n\nconst PLACEHOLDER_PATTERN: RegExp = /\\{(\\w+)\\}/g\n\n/**\n * Substitute `{name}` placeholders in `template` with values from `params`.\n * Unknown placeholders are left intact so missing data surfaces as a visible\n * bug rather than a silent omission.\n */\nfunction formatTemplate(template: string, params: BreachMessageParams): string {\n return template.replace(PLACEHOLDER_PATTERN, (match, key: string): string => {\n const value: string | number | undefined = params[key]\n return value === undefined ? match : String(value)\n })\n}\n\n/**\n * Render a breach message via the fallback chain:\n * 1. `options.formatMessage(code, params, defaultMessage)` if provided\n * 2. `formatTemplate(options.messages[code], params)` if that override exists\n * 3. `formatTemplate(DEFAULT_BREACH_MESSAGES[code], params)` (built-in English)\n *\n * If `options.formatMessage` throws, the default English rendering is returned.\n *\n * @example\n * ```typescript\n * import { resolveBreachMessage } from '@sentinel-password/breach'\n *\n * resolveBreachMessage('breach.found')\n * // → \"This password has appeared in known data breaches. Choose a different one.\"\n * ```\n */\nexport function resolveBreachMessage(\n code: BreachMessageCode,\n params: BreachMessageParams,\n options: BreachMessageOptions = {}\n): string {\n const defaultMessage: string = formatTemplate(DEFAULT_BREACH_MESSAGES[code], params)\n\n if (options.formatMessage) {\n try {\n return options.formatMessage(code, params, defaultMessage)\n } catch {\n return defaultMessage\n }\n }\n\n const override: string | undefined = options.messages?.[code]\n if (override !== undefined) {\n return formatTemplate(override, params)\n }\n\n return defaultMessage\n}\n"],"mappings":"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","names":[]}
{"version":3,"sources":["../src/index.ts","../src/check.ts","../src/cache.ts","../src/messages.ts"],"sourcesContent":["/**\n * @sentinel-password/breach\n *\n * Have I Been Pwned breach checking via k-anonymity. The password is SHA-1\n * hashed locally and only the first 5 hex characters of the digest are sent to\n * the Pwned Passwords range API — the password, full hash, and matched suffix\n * never leave the process and are never logged.\n *\n * Async and opt-in. Decoupled from `@sentinel-password/core` (no shared types\n * or runtime); compose the two explicitly. Recommended for server-side use.\n * Zero runtime dependencies. ≤ 10 KB gzipped (CI enforced).\n *\n * @example\n * ```typescript\n * import { validatePassword } from '@sentinel-password/core'\n * import { checkBreach } from '@sentinel-password/breach'\n *\n * const rule = validatePassword(password)\n * const pwned = await checkBreach(password)\n *\n * if (pwned.status === 'error') {\n * // your call: block submission, or allow and log the degraded check\n * } else {\n * const ok = rule.valid && !pwned.breached\n * }\n * ```\n */\n\nexport { checkBreach } from './check'\nexport { createBreachCache } from './cache'\nexport { DEFAULT_BREACH_MESSAGES, resolveBreachMessage } from './messages'\n\nexport type {\n BreachCache,\n BreachError,\n BreachErrorReason,\n BreachMessageCode,\n BreachMessageFormatter,\n BreachMessageOptions,\n BreachMessageParams,\n BreachOk,\n BreachOptions,\n BreachResult,\n} from './types'\n","import type { BreachOptions, BreachResult } from './types'\n\nconst RANGE_API: string = 'https://api.pwnedpasswords.com/range/'\nconst DEFAULT_TIMEOUT_MS: number = 5000\nconst DEFAULT_THRESHOLD: number = 1\n\n/**\n * True for the abort/timeout signals raised by `AbortSignal.timeout`.\n * Tolerates arbitrary rejection reasons — a user-injected `options.fetch`\n * may reject with `null`/`undefined`/primitives, and reading `.name` off\n * those would throw, violating checkBreach's never-throws contract.\n */\nfunction isTimeout(error: unknown): boolean {\n if (typeof error !== 'object' || error === null) {\n return false\n }\n const name: string = String((error as { name?: unknown }).name)\n return name === 'AbortError' || name === 'TimeoutError'\n}\n\n/** Uppercase hex encoding of an `ArrayBuffer`. */\nfunction toHex(buffer: ArrayBuffer): string {\n let hex: string = ''\n for (const byte of new Uint8Array(buffer)) {\n hex += byte.toString(16).padStart(2, '0')\n }\n return hex.toUpperCase()\n}\n\n/** Find the exposure count for `suffix` in a Pwned Passwords range body. */\nfunction countFor(body: string, suffix: string): number {\n for (const line of body.split('\\n')) {\n const idx: number = line.indexOf(':')\n if (idx === -1) {\n continue\n }\n if (line.slice(0, idx).trim().toUpperCase() === suffix) {\n return parseInt(line.slice(idx + 1), 10) || 0\n }\n }\n return 0\n}\n\n/**\n * Check a password against Have I Been Pwned's Pwned Passwords range API using\n * the k-anonymity model: the password is SHA-1 hashed locally and only the\n * first 5 hex characters of the digest are sent to the API. The full hash, the\n * password, and the matched suffix never leave this process and are never\n * logged.\n *\n * Never throws and never silently reports \"safe\" on failure — on any error it\n * resolves to `{ status: 'error', reason }` so the caller explicitly decides\n * fail-open vs fail-closed. Recommended for server-side use.\n *\n * @example\n * ```typescript\n * import { checkBreach } from '@sentinel-password/breach'\n *\n * const r = await checkBreach(password)\n * if (r.status === 'error') {\n * // your call: block submission, or allow and log\n * } else if (r.breached) {\n * // r.breachCount appearances in known breaches\n * }\n * ```\n */\nexport async function checkBreach(\n password: string,\n options: BreachOptions = {}\n): Promise<BreachResult> {\n if (password === '') {\n return { status: 'ok', breachCount: 0, breached: false }\n }\n\n const fetchImpl: typeof fetch | undefined = options.fetch ?? globalThis.fetch\n if (typeof fetchImpl !== 'function') {\n return { status: 'error', reason: 'unsupported', detail: 'fetch unavailable' }\n }\n const subtle: SubtleCrypto | undefined = globalThis.crypto?.subtle\n if (!subtle) {\n return { status: 'error', reason: 'unsupported', detail: 'crypto.subtle unavailable' }\n }\n\n const digest: ArrayBuffer = await subtle.digest('SHA-1', new TextEncoder().encode(password))\n const hash: string = toHex(digest)\n const prefix: string = hash.slice(0, 5)\n const suffix: string = hash.slice(5)\n\n // A throwing or misbehaving user cache (e.g. localStorage-backed hitting a\n // quota/security error, or returning a non-string) must not break the\n // never-throws contract — treat any failure as a cache miss.\n let body: string | undefined\n try {\n const cached: string | undefined = options.cache?.get(prefix)\n if (typeof cached === 'string') {\n body = cached\n }\n } catch {\n body = undefined\n }\n if (body === undefined) {\n const headers: Record<string, string> = {}\n if (options.addPadding ?? true) {\n headers['Add-Padding'] = 'true'\n }\n\n // Compose the internal timeout with a caller-provided signal (if any) —\n // whichever fires first cancels the request. Both an elapsed timeout and\n // a caller abort surface as reason 'timeout' via isTimeout (AbortError /\n // TimeoutError).\n const timeoutSignal: AbortSignal = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)\n const signal: AbortSignal =\n options.signal !== undefined\n ? AbortSignal.any([options.signal, timeoutSignal])\n : timeoutSignal\n\n let response: Response\n try {\n response = await fetchImpl(RANGE_API + prefix, { headers, signal })\n } catch (error: unknown) {\n return { status: 'error', reason: isTimeout(error) ? 'timeout' : 'network' }\n }\n\n if (response.status === 429) {\n return { status: 'error', reason: 'rate-limit' }\n }\n if (!response.ok) {\n return { status: 'error', reason: 'http', detail: `HTTP ${String(response.status)}` }\n }\n\n try {\n body = await response.text()\n } catch {\n return { status: 'error', reason: 'network' }\n }\n try {\n options.cache?.set(prefix, body)\n } catch {\n // Failing to populate the cache is not a failed check — the fresh\n // response body is already in hand.\n }\n }\n\n const breachCount: number = countFor(body, suffix)\n // Guard against a misconfigured threshold (NaN/0/negative — e.g. a bad\n // `parseInt` of an env var). A NaN threshold makes `breachCount >= threshold`\n // always false, silently reporting a pwned password as safe — the exact\n // fail-open this package avoids elsewhere. Fall back to the default instead.\n const rawThreshold: number | undefined = options.threshold\n const threshold: number =\n rawThreshold !== undefined && Number.isFinite(rawThreshold) && rawThreshold >= 1\n ? rawThreshold\n : DEFAULT_THRESHOLD\n return { status: 'ok', breachCount, breached: breachCount >= threshold }\n}\n","import type { BreachCache } from './types'\n\n/**\n * Create a bounded, in-memory {@link BreachCache} with FIFO eviction.\n *\n * Keys are 5-hex-char SHA-1 prefixes; values are raw Pwned Passwords range\n * response bodies. Once `maxEntries` distinct prefixes are stored, inserting a\n * new prefix evicts the oldest one. Updating an existing prefix replaces its\n * value in place without changing insertion order or size.\n *\n * @example\n * ```typescript\n * import { checkBreach, createBreachCache } from '@sentinel-password/breach'\n *\n * const cache = createBreachCache()\n * await checkBreach('hunter2', { cache })\n * await checkBreach('hunter2', { cache }) // served from cache, no network\n * ```\n */\nexport function createBreachCache(maxEntries: number = 1024): BreachCache {\n const store: Map<string, string> = new Map<string, string>()\n\n return {\n get(prefix: string): string | undefined {\n return store.get(prefix)\n },\n set(prefix: string, body: string): void {\n if (!store.has(prefix) && store.size >= maxEntries) {\n // A new key at capacity: size >= maxEntries >= 0 and the key is\n // absent, so the store is non-empty and has an oldest entry.\n const oldest: string = store.keys().next().value as string\n store.delete(oldest)\n }\n store.set(prefix, body)\n },\n }\n}\n","import type { BreachMessageCode, BreachMessageOptions, BreachMessageParams } from './types'\n\n/**\n * Built-in English template for every {@link BreachMessageCode}.\n *\n * Strings are short, stable English so consumers can use them as translation\n * keys. Placeholders use `{name}` syntax. This map is owned by this package and\n * intentionally does NOT import core's `MessageCode` union — the two packages\n * stay decoupled and version independently.\n */\nexport const DEFAULT_BREACH_MESSAGES: Readonly<Record<BreachMessageCode, string>> = {\n // Intentionally count-free: a logic-less template cannot pluralize, and\n // \"appeared in 1 known data breaches\" would be ungrammatical. The exact\n // exposure count is available as `BreachOk.breachCount`; callers who want it\n // in the message can interpolate via a `messages` / `formatMessage` override.\n 'breach.found': 'This password has appeared in known data breaches. Choose a different one.',\n} as const\n\nconst PLACEHOLDER_PATTERN: RegExp = /\\{(\\w+)\\}/g\n\n/**\n * Substitute `{name}` placeholders in `template` with values from `params`.\n * Unknown placeholders are left intact so missing data surfaces as a visible\n * bug rather than a silent omission.\n */\nfunction formatTemplate(template: string, params: BreachMessageParams): string {\n return template.replace(PLACEHOLDER_PATTERN, (match, key: string): string => {\n const value: string | number | undefined = params[key]\n return value === undefined ? match : String(value)\n })\n}\n\n/**\n * Render a breach message via the fallback chain:\n * 1. `options.formatMessage(code, params, defaultMessage)` if provided\n * 2. `formatTemplate(options.messages[code], params)` if that override exists\n * 3. `formatTemplate(DEFAULT_BREACH_MESSAGES[code], params)` (built-in English)\n *\n * If `options.formatMessage` throws, the default English rendering is returned.\n *\n * @example\n * ```typescript\n * import { resolveBreachMessage } from '@sentinel-password/breach'\n *\n * resolveBreachMessage('breach.found')\n * // → \"This password has appeared in known data breaches. Choose a different one.\"\n * ```\n */\nexport function resolveBreachMessage(\n code: BreachMessageCode,\n params: BreachMessageParams,\n options: BreachMessageOptions = {}\n): string {\n const defaultMessage: string = formatTemplate(DEFAULT_BREACH_MESSAGES[code], params)\n\n if (options.formatMessage) {\n try {\n return options.formatMessage(code, params, defaultMessage)\n } catch {\n return defaultMessage\n }\n }\n\n const override: string | undefined = options.messages?.[code]\n if (override !== undefined) {\n return formatTemplate(override, params)\n }\n\n return defaultMessage\n}\n"],"mappings":"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","names":[]}

@@ -51,2 +51,10 @@ /**

/**
* Caller-provided abort signal, composed with the internal `timeoutMs`
* signal — whichever fires first cancels the request. Lets UI layers
* (e.g. a React hook superseding a stale keystroke) cancel in-flight
* lookups. An abort resolves to `{ status: 'error', reason: 'timeout' }`;
* `checkBreach` never throws, even when this signal is already aborted.
*/
readonly signal?: AbortSignal;
/**
* `fetch` implementation. Defaults to the global `fetch`. Inject for custom

@@ -53,0 +61,0 @@ * agents/proxies or for tests (no real network required).

@@ -51,2 +51,10 @@ /**

/**
* Caller-provided abort signal, composed with the internal `timeoutMs`
* signal — whichever fires first cancels the request. Lets UI layers
* (e.g. a React hook superseding a stale keystroke) cancel in-flight
* lookups. An abort resolves to `{ status: 'error', reason: 'timeout' }`;
* `checkBreach` never throws, even when this signal is already aborted.
*/
readonly signal?: AbortSignal;
/**
* `fetch` implementation. Defaults to the global `fetch`. Inject for custom

@@ -53,0 +61,0 @@ * agents/proxies or for tests (no real network required).

@@ -6,2 +6,5 @@ // src/check.ts

function isTimeout(error) {
if (typeof error !== "object" || error === null) {
return false;
}
const name = String(error.name);

@@ -45,3 +48,11 @@ return name === "AbortError" || name === "TimeoutError";

const suffix = hash.slice(5);
let body = options.cache?.get(prefix);
let body;
try {
const cached = options.cache?.get(prefix);
if (typeof cached === "string") {
body = cached;
}
} catch {
body = void 0;
}
if (body === void 0) {

@@ -52,8 +63,7 @@ const headers = {};

}
const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const signal = options.signal !== void 0 ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
let response;
try {
response = await fetchImpl(RANGE_API + prefix, {
headers,
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
});
response = await fetchImpl(RANGE_API + prefix, { headers, signal });
} catch (error) {

@@ -73,3 +83,6 @@ return { status: "error", reason: isTimeout(error) ? "timeout" : "network" };

}
options.cache?.set(prefix, body);
try {
options.cache?.set(prefix, body);
} catch {
}
}

@@ -76,0 +89,0 @@ const breachCount = countFor(body, suffix);

@@ -1,1 +0,1 @@

{"version":3,"sources":["../src/check.ts","../src/cache.ts","../src/messages.ts"],"sourcesContent":["import type { BreachOptions, BreachResult } from './types'\n\nconst RANGE_API: string = 'https://api.pwnedpasswords.com/range/'\nconst DEFAULT_TIMEOUT_MS: number = 5000\nconst DEFAULT_THRESHOLD: number = 1\n\n/** True for the abort/timeout signals raised by `AbortSignal.timeout`. */\nfunction isTimeout(error: unknown): boolean {\n const name: string = String((error as { name?: unknown }).name)\n return name === 'AbortError' || name === 'TimeoutError'\n}\n\n/** Uppercase hex encoding of an `ArrayBuffer`. */\nfunction toHex(buffer: ArrayBuffer): string {\n let hex: string = ''\n for (const byte of new Uint8Array(buffer)) {\n hex += byte.toString(16).padStart(2, '0')\n }\n return hex.toUpperCase()\n}\n\n/** Find the exposure count for `suffix` in a Pwned Passwords range body. */\nfunction countFor(body: string, suffix: string): number {\n for (const line of body.split('\\n')) {\n const idx: number = line.indexOf(':')\n if (idx === -1) {\n continue\n }\n if (line.slice(0, idx).trim().toUpperCase() === suffix) {\n return parseInt(line.slice(idx + 1), 10) || 0\n }\n }\n return 0\n}\n\n/**\n * Check a password against Have I Been Pwned's Pwned Passwords range API using\n * the k-anonymity model: the password is SHA-1 hashed locally and only the\n * first 5 hex characters of the digest are sent to the API. The full hash, the\n * password, and the matched suffix never leave this process and are never\n * logged.\n *\n * Never throws and never silently reports \"safe\" on failure — on any error it\n * resolves to `{ status: 'error', reason }` so the caller explicitly decides\n * fail-open vs fail-closed. Recommended for server-side use.\n *\n * @example\n * ```typescript\n * import { checkBreach } from '@sentinel-password/breach'\n *\n * const r = await checkBreach(password)\n * if (r.status === 'error') {\n * // your call: block submission, or allow and log\n * } else if (r.breached) {\n * // r.breachCount appearances in known breaches\n * }\n * ```\n */\nexport async function checkBreach(\n password: string,\n options: BreachOptions = {}\n): Promise<BreachResult> {\n if (password === '') {\n return { status: 'ok', breachCount: 0, breached: false }\n }\n\n const fetchImpl: typeof fetch | undefined = options.fetch ?? globalThis.fetch\n if (typeof fetchImpl !== 'function') {\n return { status: 'error', reason: 'unsupported', detail: 'fetch unavailable' }\n }\n const subtle: SubtleCrypto | undefined = globalThis.crypto?.subtle\n if (!subtle) {\n return { status: 'error', reason: 'unsupported', detail: 'crypto.subtle unavailable' }\n }\n\n const digest: ArrayBuffer = await subtle.digest('SHA-1', new TextEncoder().encode(password))\n const hash: string = toHex(digest)\n const prefix: string = hash.slice(0, 5)\n const suffix: string = hash.slice(5)\n\n let body: string | undefined = options.cache?.get(prefix)\n if (body === undefined) {\n const headers: Record<string, string> = {}\n if (options.addPadding ?? true) {\n headers['Add-Padding'] = 'true'\n }\n\n let response: Response\n try {\n response = await fetchImpl(RANGE_API + prefix, {\n headers,\n signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),\n })\n } catch (error: unknown) {\n return { status: 'error', reason: isTimeout(error) ? 'timeout' : 'network' }\n }\n\n if (response.status === 429) {\n return { status: 'error', reason: 'rate-limit' }\n }\n if (!response.ok) {\n return { status: 'error', reason: 'http', detail: `HTTP ${String(response.status)}` }\n }\n\n try {\n body = await response.text()\n } catch {\n return { status: 'error', reason: 'network' }\n }\n options.cache?.set(prefix, body)\n }\n\n const breachCount: number = countFor(body, suffix)\n // Guard against a misconfigured threshold (NaN/0/negative — e.g. a bad\n // `parseInt` of an env var). A NaN threshold makes `breachCount >= threshold`\n // always false, silently reporting a pwned password as safe — the exact\n // fail-open this package avoids elsewhere. Fall back to the default instead.\n const rawThreshold: number | undefined = options.threshold\n const threshold: number =\n rawThreshold !== undefined && Number.isFinite(rawThreshold) && rawThreshold >= 1\n ? rawThreshold\n : DEFAULT_THRESHOLD\n return { status: 'ok', breachCount, breached: breachCount >= threshold }\n}\n","import type { BreachCache } from './types'\n\n/**\n * Create a bounded, in-memory {@link BreachCache} with FIFO eviction.\n *\n * Keys are 5-hex-char SHA-1 prefixes; values are raw Pwned Passwords range\n * response bodies. Once `maxEntries` distinct prefixes are stored, inserting a\n * new prefix evicts the oldest one. Updating an existing prefix replaces its\n * value in place without changing insertion order or size.\n *\n * @example\n * ```typescript\n * import { checkBreach, createBreachCache } from '@sentinel-password/breach'\n *\n * const cache = createBreachCache()\n * await checkBreach('hunter2', { cache })\n * await checkBreach('hunter2', { cache }) // served from cache, no network\n * ```\n */\nexport function createBreachCache(maxEntries: number = 1024): BreachCache {\n const store: Map<string, string> = new Map<string, string>()\n\n return {\n get(prefix: string): string | undefined {\n return store.get(prefix)\n },\n set(prefix: string, body: string): void {\n if (!store.has(prefix) && store.size >= maxEntries) {\n // A new key at capacity: size >= maxEntries >= 0 and the key is\n // absent, so the store is non-empty and has an oldest entry.\n const oldest: string = store.keys().next().value as string\n store.delete(oldest)\n }\n store.set(prefix, body)\n },\n }\n}\n","import type { BreachMessageCode, BreachMessageOptions, BreachMessageParams } from './types'\n\n/**\n * Built-in English template for every {@link BreachMessageCode}.\n *\n * Strings are short, stable English so consumers can use them as translation\n * keys. Placeholders use `{name}` syntax. This map is owned by this package and\n * intentionally does NOT import core's `MessageCode` union — the two packages\n * stay decoupled and version independently.\n */\nexport const DEFAULT_BREACH_MESSAGES: Readonly<Record<BreachMessageCode, string>> = {\n // Intentionally count-free: a logic-less template cannot pluralize, and\n // \"appeared in 1 known data breaches\" would be ungrammatical. The exact\n // exposure count is available as `BreachOk.breachCount`; callers who want it\n // in the message can interpolate via a `messages` / `formatMessage` override.\n 'breach.found': 'This password has appeared in known data breaches. Choose a different one.',\n} as const\n\nconst PLACEHOLDER_PATTERN: RegExp = /\\{(\\w+)\\}/g\n\n/**\n * Substitute `{name}` placeholders in `template` with values from `params`.\n * Unknown placeholders are left intact so missing data surfaces as a visible\n * bug rather than a silent omission.\n */\nfunction formatTemplate(template: string, params: BreachMessageParams): string {\n return template.replace(PLACEHOLDER_PATTERN, (match, key: string): string => {\n const value: string | number | undefined = params[key]\n return value === undefined ? match : String(value)\n })\n}\n\n/**\n * Render a breach message via the fallback chain:\n * 1. `options.formatMessage(code, params, defaultMessage)` if provided\n * 2. `formatTemplate(options.messages[code], params)` if that override exists\n * 3. `formatTemplate(DEFAULT_BREACH_MESSAGES[code], params)` (built-in English)\n *\n * If `options.formatMessage` throws, the default English rendering is returned.\n *\n * @example\n * ```typescript\n * import { resolveBreachMessage } from '@sentinel-password/breach'\n *\n * resolveBreachMessage('breach.found')\n * // → \"This password has appeared in known data breaches. Choose a different one.\"\n * ```\n */\nexport function resolveBreachMessage(\n code: BreachMessageCode,\n params: BreachMessageParams,\n options: BreachMessageOptions = {}\n): string {\n const defaultMessage: string = formatTemplate(DEFAULT_BREACH_MESSAGES[code], params)\n\n if (options.formatMessage) {\n try {\n return options.formatMessage(code, params, defaultMessage)\n } catch {\n return defaultMessage\n }\n }\n\n const override: string | undefined = options.messages?.[code]\n if (override !== undefined) {\n return formatTemplate(override, params)\n }\n\n return defaultMessage\n}\n"],"mappings":"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","names":[]}
{"version":3,"sources":["../src/check.ts","../src/cache.ts","../src/messages.ts"],"sourcesContent":["import type { BreachOptions, BreachResult } from './types'\n\nconst RANGE_API: string = 'https://api.pwnedpasswords.com/range/'\nconst DEFAULT_TIMEOUT_MS: number = 5000\nconst DEFAULT_THRESHOLD: number = 1\n\n/**\n * True for the abort/timeout signals raised by `AbortSignal.timeout`.\n * Tolerates arbitrary rejection reasons — a user-injected `options.fetch`\n * may reject with `null`/`undefined`/primitives, and reading `.name` off\n * those would throw, violating checkBreach's never-throws contract.\n */\nfunction isTimeout(error: unknown): boolean {\n if (typeof error !== 'object' || error === null) {\n return false\n }\n const name: string = String((error as { name?: unknown }).name)\n return name === 'AbortError' || name === 'TimeoutError'\n}\n\n/** Uppercase hex encoding of an `ArrayBuffer`. */\nfunction toHex(buffer: ArrayBuffer): string {\n let hex: string = ''\n for (const byte of new Uint8Array(buffer)) {\n hex += byte.toString(16).padStart(2, '0')\n }\n return hex.toUpperCase()\n}\n\n/** Find the exposure count for `suffix` in a Pwned Passwords range body. */\nfunction countFor(body: string, suffix: string): number {\n for (const line of body.split('\\n')) {\n const idx: number = line.indexOf(':')\n if (idx === -1) {\n continue\n }\n if (line.slice(0, idx).trim().toUpperCase() === suffix) {\n return parseInt(line.slice(idx + 1), 10) || 0\n }\n }\n return 0\n}\n\n/**\n * Check a password against Have I Been Pwned's Pwned Passwords range API using\n * the k-anonymity model: the password is SHA-1 hashed locally and only the\n * first 5 hex characters of the digest are sent to the API. The full hash, the\n * password, and the matched suffix never leave this process and are never\n * logged.\n *\n * Never throws and never silently reports \"safe\" on failure — on any error it\n * resolves to `{ status: 'error', reason }` so the caller explicitly decides\n * fail-open vs fail-closed. Recommended for server-side use.\n *\n * @example\n * ```typescript\n * import { checkBreach } from '@sentinel-password/breach'\n *\n * const r = await checkBreach(password)\n * if (r.status === 'error') {\n * // your call: block submission, or allow and log\n * } else if (r.breached) {\n * // r.breachCount appearances in known breaches\n * }\n * ```\n */\nexport async function checkBreach(\n password: string,\n options: BreachOptions = {}\n): Promise<BreachResult> {\n if (password === '') {\n return { status: 'ok', breachCount: 0, breached: false }\n }\n\n const fetchImpl: typeof fetch | undefined = options.fetch ?? globalThis.fetch\n if (typeof fetchImpl !== 'function') {\n return { status: 'error', reason: 'unsupported', detail: 'fetch unavailable' }\n }\n const subtle: SubtleCrypto | undefined = globalThis.crypto?.subtle\n if (!subtle) {\n return { status: 'error', reason: 'unsupported', detail: 'crypto.subtle unavailable' }\n }\n\n const digest: ArrayBuffer = await subtle.digest('SHA-1', new TextEncoder().encode(password))\n const hash: string = toHex(digest)\n const prefix: string = hash.slice(0, 5)\n const suffix: string = hash.slice(5)\n\n // A throwing or misbehaving user cache (e.g. localStorage-backed hitting a\n // quota/security error, or returning a non-string) must not break the\n // never-throws contract — treat any failure as a cache miss.\n let body: string | undefined\n try {\n const cached: string | undefined = options.cache?.get(prefix)\n if (typeof cached === 'string') {\n body = cached\n }\n } catch {\n body = undefined\n }\n if (body === undefined) {\n const headers: Record<string, string> = {}\n if (options.addPadding ?? true) {\n headers['Add-Padding'] = 'true'\n }\n\n // Compose the internal timeout with a caller-provided signal (if any) —\n // whichever fires first cancels the request. Both an elapsed timeout and\n // a caller abort surface as reason 'timeout' via isTimeout (AbortError /\n // TimeoutError).\n const timeoutSignal: AbortSignal = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)\n const signal: AbortSignal =\n options.signal !== undefined\n ? AbortSignal.any([options.signal, timeoutSignal])\n : timeoutSignal\n\n let response: Response\n try {\n response = await fetchImpl(RANGE_API + prefix, { headers, signal })\n } catch (error: unknown) {\n return { status: 'error', reason: isTimeout(error) ? 'timeout' : 'network' }\n }\n\n if (response.status === 429) {\n return { status: 'error', reason: 'rate-limit' }\n }\n if (!response.ok) {\n return { status: 'error', reason: 'http', detail: `HTTP ${String(response.status)}` }\n }\n\n try {\n body = await response.text()\n } catch {\n return { status: 'error', reason: 'network' }\n }\n try {\n options.cache?.set(prefix, body)\n } catch {\n // Failing to populate the cache is not a failed check — the fresh\n // response body is already in hand.\n }\n }\n\n const breachCount: number = countFor(body, suffix)\n // Guard against a misconfigured threshold (NaN/0/negative — e.g. a bad\n // `parseInt` of an env var). A NaN threshold makes `breachCount >= threshold`\n // always false, silently reporting a pwned password as safe — the exact\n // fail-open this package avoids elsewhere. Fall back to the default instead.\n const rawThreshold: number | undefined = options.threshold\n const threshold: number =\n rawThreshold !== undefined && Number.isFinite(rawThreshold) && rawThreshold >= 1\n ? rawThreshold\n : DEFAULT_THRESHOLD\n return { status: 'ok', breachCount, breached: breachCount >= threshold }\n}\n","import type { BreachCache } from './types'\n\n/**\n * Create a bounded, in-memory {@link BreachCache} with FIFO eviction.\n *\n * Keys are 5-hex-char SHA-1 prefixes; values are raw Pwned Passwords range\n * response bodies. Once `maxEntries` distinct prefixes are stored, inserting a\n * new prefix evicts the oldest one. Updating an existing prefix replaces its\n * value in place without changing insertion order or size.\n *\n * @example\n * ```typescript\n * import { checkBreach, createBreachCache } from '@sentinel-password/breach'\n *\n * const cache = createBreachCache()\n * await checkBreach('hunter2', { cache })\n * await checkBreach('hunter2', { cache }) // served from cache, no network\n * ```\n */\nexport function createBreachCache(maxEntries: number = 1024): BreachCache {\n const store: Map<string, string> = new Map<string, string>()\n\n return {\n get(prefix: string): string | undefined {\n return store.get(prefix)\n },\n set(prefix: string, body: string): void {\n if (!store.has(prefix) && store.size >= maxEntries) {\n // A new key at capacity: size >= maxEntries >= 0 and the key is\n // absent, so the store is non-empty and has an oldest entry.\n const oldest: string = store.keys().next().value as string\n store.delete(oldest)\n }\n store.set(prefix, body)\n },\n }\n}\n","import type { BreachMessageCode, BreachMessageOptions, BreachMessageParams } from './types'\n\n/**\n * Built-in English template for every {@link BreachMessageCode}.\n *\n * Strings are short, stable English so consumers can use them as translation\n * keys. Placeholders use `{name}` syntax. This map is owned by this package and\n * intentionally does NOT import core's `MessageCode` union — the two packages\n * stay decoupled and version independently.\n */\nexport const DEFAULT_BREACH_MESSAGES: Readonly<Record<BreachMessageCode, string>> = {\n // Intentionally count-free: a logic-less template cannot pluralize, and\n // \"appeared in 1 known data breaches\" would be ungrammatical. The exact\n // exposure count is available as `BreachOk.breachCount`; callers who want it\n // in the message can interpolate via a `messages` / `formatMessage` override.\n 'breach.found': 'This password has appeared in known data breaches. Choose a different one.',\n} as const\n\nconst PLACEHOLDER_PATTERN: RegExp = /\\{(\\w+)\\}/g\n\n/**\n * Substitute `{name}` placeholders in `template` with values from `params`.\n * Unknown placeholders are left intact so missing data surfaces as a visible\n * bug rather than a silent omission.\n */\nfunction formatTemplate(template: string, params: BreachMessageParams): string {\n return template.replace(PLACEHOLDER_PATTERN, (match, key: string): string => {\n const value: string | number | undefined = params[key]\n return value === undefined ? match : String(value)\n })\n}\n\n/**\n * Render a breach message via the fallback chain:\n * 1. `options.formatMessage(code, params, defaultMessage)` if provided\n * 2. `formatTemplate(options.messages[code], params)` if that override exists\n * 3. `formatTemplate(DEFAULT_BREACH_MESSAGES[code], params)` (built-in English)\n *\n * If `options.formatMessage` throws, the default English rendering is returned.\n *\n * @example\n * ```typescript\n * import { resolveBreachMessage } from '@sentinel-password/breach'\n *\n * resolveBreachMessage('breach.found')\n * // → \"This password has appeared in known data breaches. Choose a different one.\"\n * ```\n */\nexport function resolveBreachMessage(\n code: BreachMessageCode,\n params: BreachMessageParams,\n options: BreachMessageOptions = {}\n): string {\n const defaultMessage: string = formatTemplate(DEFAULT_BREACH_MESSAGES[code], params)\n\n if (options.formatMessage) {\n try {\n return options.formatMessage(code, params, defaultMessage)\n } catch {\n return defaultMessage\n }\n }\n\n const override: string | undefined = options.messages?.[code]\n if (override !== undefined) {\n return formatTemplate(override, params)\n }\n\n return defaultMessage\n}\n"],"mappings":"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","names":[]}
{
"name": "@sentinel-password/breach",
"version": "0.2.5",
"version": "0.3.0",
"type": "module",

@@ -55,6 +55,6 @@ "description": "Have I Been Pwned breach checking via k-anonymity for sentinel-password. Zero runtime dependencies; ≤ 10 KB gzipped (CI enforced).",

"devDependencies": {
"@vitest/coverage-v8": "^4.1.8",
"@vitest/coverage-v8": "^4.1.9",
"tsup": "^8.5.1",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
"vitest": "^4.1.9"
},

@@ -61,0 +61,0 @@ "scripts": {