@sentinel-password/breach
Advanced tools
+2
-1
@@ -102,3 +102,4 @@ "use strict"; | ||
| const breachCount = countFor(body, suffix); | ||
| const threshold = options.threshold ?? DEFAULT_THRESHOLD; | ||
| const rawThreshold = options.threshold; | ||
| const threshold = rawThreshold !== void 0 && Number.isFinite(rawThreshold) && rawThreshold >= 1 ? rawThreshold : DEFAULT_THRESHOLD; | ||
| return { status: "ok", breachCount, breached: breachCount >= threshold }; | ||
@@ -105,0 +106,0 @@ } |
@@ -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 const threshold: number = options.threshold ?? 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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACEA,IAAM,YAAoB;AAC1B,IAAM,qBAA6B;AACnC,IAAM,oBAA4B;AAGlC,SAAS,UAAU,OAAyB;AAC1C,QAAM,OAAe,OAAQ,MAA6B,IAAI;AAC9D,SAAO,SAAS,gBAAgB,SAAS;AAC3C;AAGA,SAAS,MAAM,QAA6B;AAC1C,MAAI,MAAc;AAClB,aAAW,QAAQ,IAAI,WAAW,MAAM,GAAG;AACzC,WAAO,KAAK,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG;AAAA,EAC1C;AACA,SAAO,IAAI,YAAY;AACzB;AAGA,SAAS,SAAS,MAAc,QAAwB;AACtD,aAAW,QAAQ,KAAK,MAAM,IAAI,GAAG;AACnC,UAAM,MAAc,KAAK,QAAQ,GAAG;AACpC,QAAI,QAAQ,IAAI;AACd;AAAA,IACF;AACA,QAAI,KAAK,MAAM,GAAG,GAAG,EAAE,KAAK,EAAE,YAAY,MAAM,QAAQ;AACtD,aAAO,SAAS,KAAK,MAAM,MAAM,CAAC,GAAG,EAAE,KAAK;AAAA,IAC9C;AAAA,EACF;AACA,SAAO;AACT;AAyBA,eAAsB,YACpB,UACA,UAAyB,CAAC,GACH;AACvB,MAAI,aAAa,IAAI;AACnB,WAAO,EAAE,QAAQ,MAAM,aAAa,GAAG,UAAU,MAAM;AAAA,EACzD;AAEA,QAAM,YAAsC,QAAQ,SAAS,WAAW;AACxE,MAAI,OAAO,cAAc,YAAY;AACnC,WAAO,EAAE,QAAQ,SAAS,QAAQ,eAAe,QAAQ,oBAAoB;AAAA,EAC/E;AACA,QAAM,SAAmC,WAAW,QAAQ;AAC5D,MAAI,CAAC,QAAQ;AACX,WAAO,EAAE,QAAQ,SAAS,QAAQ,eAAe,QAAQ,4BAA4B;AAAA,EACvF;AAEA,QAAM,SAAsB,MAAM,OAAO,OAAO,SAAS,IAAI,YAAY,EAAE,OAAO,QAAQ,CAAC;AAC3F,QAAM,OAAe,MAAM,MAAM;AACjC,QAAM,SAAiB,KAAK,MAAM,GAAG,CAAC;AACtC,QAAM,SAAiB,KAAK,MAAM,CAAC;AAEnC,MAAI,OAA2B,QAAQ,OAAO,IAAI,MAAM;AACxD,MAAI,SAAS,QAAW;AACtB,UAAM,UAAkC,CAAC;AACzC,QAAI,QAAQ,cAAc,MAAM;AAC9B,cAAQ,aAAa,IAAI;AAAA,IAC3B;AAEA,QAAI;AACJ,QAAI;AACF,iBAAW,MAAM,UAAU,YAAY,QAAQ;AAAA,QAC7C;AAAA,QACA,QAAQ,YAAY,QAAQ,QAAQ,aAAa,kBAAkB;AAAA,MACrE,CAAC;AAAA,IACH,SAAS,OAAgB;AACvB,aAAO,EAAE,QAAQ,SAAS,QAAQ,UAAU,KAAK,IAAI,YAAY,UAAU;AAAA,IAC7E;AAEA,QAAI,SAAS,WAAW,KAAK;AAC3B,aAAO,EAAE,QAAQ,SAAS,QAAQ,aAAa;AAAA,IACjD;AACA,QAAI,CAAC,SAAS,IAAI;AAChB,aAAO,EAAE,QAAQ,SAAS,QAAQ,QAAQ,QAAQ,QAAQ,OAAO,SAAS,MAAM,CAAC,GAAG;AAAA,IACtF;AAEA,QAAI;AACF,aAAO,MAAM,SAAS,KAAK;AAAA,IAC7B,QAAQ;AACN,aAAO,EAAE,QAAQ,SAAS,QAAQ,UAAU;AAAA,IAC9C;AACA,YAAQ,OAAO,IAAI,QAAQ,IAAI;AAAA,EACjC;AAEA,QAAM,cAAsB,SAAS,MAAM,MAAM;AACjD,QAAM,YAAoB,QAAQ,aAAa;AAC/C,SAAO,EAAE,QAAQ,MAAM,aAAa,UAAU,eAAe,UAAU;AACzE;;;AChGO,SAAS,kBAAkB,aAAqB,MAAmB;AACxE,QAAM,QAA6B,oBAAI,IAAoB;AAE3D,SAAO;AAAA,IACL,IAAI,QAAoC;AACtC,aAAO,MAAM,IAAI,MAAM;AAAA,IACzB;AAAA,IACA,IAAI,QAAgB,MAAoB;AACtC,UAAI,CAAC,MAAM,IAAI,MAAM,KAAK,MAAM,QAAQ,YAAY;AAGlD,cAAM,SAAiB,MAAM,KAAK,EAAE,KAAK,EAAE;AAC3C,cAAM,OAAO,MAAM;AAAA,MACrB;AACA,YAAM,IAAI,QAAQ,IAAI;AAAA,IACxB;AAAA,EACF;AACF;;;AC1BO,IAAM,0BAAuE;AAAA;AAAA;AAAA;AAAA;AAAA,EAKlF,gBAAgB;AAClB;AAEA,IAAM,sBAA8B;AAOpC,SAAS,eAAe,UAAkB,QAAqC;AAC7E,SAAO,SAAS,QAAQ,qBAAqB,CAAC,OAAO,QAAwB;AAC3E,UAAM,QAAqC,OAAO,GAAG;AACrD,WAAO,UAAU,SAAY,QAAQ,OAAO,KAAK;AAAA,EACnD,CAAC;AACH;AAkBO,SAAS,qBACd,MACA,QACA,UAAgC,CAAC,GACzB;AACR,QAAM,iBAAyB,eAAe,wBAAwB,IAAI,GAAG,MAAM;AAEnF,MAAI,QAAQ,eAAe;AACzB,QAAI;AACF,aAAO,QAAQ,cAAc,MAAM,QAAQ,cAAc;AAAA,IAC3D,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AAEA,QAAM,WAA+B,QAAQ,WAAW,IAAI;AAC5D,MAAI,aAAa,QAAW;AAC1B,WAAO,eAAe,UAAU,MAAM;AAAA,EACxC;AAEA,SAAO;AACT;","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/** 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":[]} |
+3
-1
@@ -38,3 +38,5 @@ /** | ||
| * Exposure count at or above which `breached` is `true`. Default `1` (any | ||
| * appearance counts). Raise it to tolerate low-frequency hits. | ||
| * appearance counts). Raise it to tolerate low-frequency hits. Non-finite or | ||
| * `< 1` values (NaN, 0, negative) are ignored and the default `1` is used, so | ||
| * a misconfigured threshold can never silently report a pwned password safe. | ||
| */ | ||
@@ -41,0 +43,0 @@ readonly threshold?: number; |
+3
-1
@@ -38,3 +38,5 @@ /** | ||
| * Exposure count at or above which `breached` is `true`. Default `1` (any | ||
| * appearance counts). Raise it to tolerate low-frequency hits. | ||
| * appearance counts). Raise it to tolerate low-frequency hits. Non-finite or | ||
| * `< 1` values (NaN, 0, negative) are ignored and the default `1` is used, so | ||
| * a misconfigured threshold can never silently report a pwned password safe. | ||
| */ | ||
@@ -41,0 +43,0 @@ readonly threshold?: number; |
+2
-1
@@ -73,3 +73,4 @@ // src/check.ts | ||
| const breachCount = countFor(body, suffix); | ||
| const threshold = options.threshold ?? DEFAULT_THRESHOLD; | ||
| const rawThreshold = options.threshold; | ||
| const threshold = rawThreshold !== void 0 && Number.isFinite(rawThreshold) && rawThreshold >= 1 ? rawThreshold : DEFAULT_THRESHOLD; | ||
| return { status: "ok", breachCount, breached: breachCount >= threshold }; | ||
@@ -76,0 +77,0 @@ } |
@@ -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 const threshold: number = options.threshold ?? 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/** 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":[]} |
+7
-3
| { | ||
| "name": "@sentinel-password/breach", | ||
| "version": "0.2.3", | ||
| "version": "0.2.4", | ||
| "type": "module", | ||
@@ -25,3 +25,4 @@ "description": "Have I Been Pwned breach checking via k-anonymity for sentinel-password. Zero runtime dependencies; ≤ 10 KB gzipped (CI enforced).", | ||
| "publishConfig": { | ||
| "access": "public" | ||
| "access": "public", | ||
| "provenance": true | ||
| }, | ||
@@ -42,5 +43,8 @@ "sideEffects": false, | ||
| "license": "MIT", | ||
| "engines": { | ||
| "node": ">=20" | ||
| }, | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "https://github.com/akankov/sentinel-password", | ||
| "url": "git+https://github.com/akankov/sentinel-password.git", | ||
| "directory": "packages/breach" | ||
@@ -47,0 +51,0 @@ }, |
54564
3.28%430
0.94%