From 0960a07a601eb903ce9ed4bb655244fb8c1ee980 Mon Sep 17 00:00:00 2001 From: Sean Sun <1194458432@qq.com> Date: Fri, 29 May 2026 14:52:54 +0800 Subject: [PATCH] feat(hash): pwned-password check via Have I Been Pwned (k-anonymity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Pwned password check" to the Hash tool — tells the user whether the text they typed has appeared in known breach corpora, so they can avoid easily cracked passwords. - src/lib/hibp.ts: pwnedPasswordCount() — SHA-1 the input locally, send only the first 5 hex chars to the HIBP range API (k-anonymity; a plain GET with no custom headers → a CORS "simple request", no preflight), and match the returned suffixes in-page. The password never leaves the browser — only a hash prefix shared by thousands of hashes does. - Explicit-trigger only (a "Check" button) per the no-silent-network rule; the result resets when the input is edited. Strips the textarea's trailing newline before checking (internal/leading spaces are preserved — passwords can contain them). - Meaning-correct wording: "found in N known breaches" vs "not found in any known breach (not a strength guarantee)" — never "safe"/"strong". 5 unit tests with an injected fetch, including the FOUND case asserted on the REAL SHA-1 of "password" so the silent-failure bugs (lowercase hex / wrong slice / prefix left in the suffix) can't pass as "not found". typecheck / lint / build clean, 208 tests green. Verified the real call end-to-end in the COEP-isolated app via headless Chrome (crossOriginIsolated=true, HTTP 200, "password" → 52,256,179, random → 0; the UI renders the breach count). EN + zh-CN strings. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/i18n/en.json | 11 ++++- src/i18n/zh-CN.json | 11 ++++- src/lib/__tests__/hibp.test.ts | 58 ++++++++++++++++++++++++ src/lib/hibp.ts | 60 +++++++++++++++++++++++++ src/pages/Hash.tsx | 82 +++++++++++++++++++++++++++++++++- 5 files changed, 218 insertions(+), 4 deletions(-) create mode 100644 src/lib/__tests__/hibp.test.ts create mode 100644 src/lib/hibp.ts diff --git a/src/i18n/en.json b/src/i18n/en.json index 2464739..786d11b 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -344,7 +344,16 @@ "verifyPlaceholder": "Paste expected hash for this algo…", "verifyMatch": "Matches", "verifyMismatch": "Does not match", - "computing": "Computing…" + "computing": "Computing…", + "pwned": { + "title": "Pwned password check", + "note": "Checks the text above against Have I Been Pwned. Only the first 5 characters of its SHA-1 hash are sent (k-anonymity) — the password itself never leaves your browser.", + "check": "Check", + "checking": "Checking…", + "found": "Found in {{times}} known breaches — pick a different password.", + "notFound": "Not found in any known breach (this is not a strength guarantee).", + "error": "Lookup failed: {{message}}" + } }, "hmac": { "description": "HMAC based on Web Crypto (SHA-1 / 256 / 384 / 512). Key stays in the browser.", diff --git a/src/i18n/zh-CN.json b/src/i18n/zh-CN.json index 19d0c56..c5e59ce 100644 --- a/src/i18n/zh-CN.json +++ b/src/i18n/zh-CN.json @@ -344,7 +344,16 @@ "verifyPlaceholder": "粘贴该算法的期望哈希…", "verifyMatch": "匹配", "verifyMismatch": "不匹配", - "computing": "计算中…" + "computing": "计算中…", + "pwned": { + "title": "密码泄露检测", + "note": "对照 Have I Been Pwned 检测上方文本。仅发送其 SHA-1 哈希的前 5 个字符(k-匿名),密码本身不会离开浏览器。", + "check": "检测", + "checking": "检测中…", + "found": "在 {{times}} 次已知泄露中出现 —— 请换一个密码。", + "notFound": "未在任何已知泄露中发现(这不代表密码就一定安全)。", + "error": "查询失败:{{message}}" + } }, "hmac": { "description": "基于 Web Crypto 的 HMAC(SHA-1 / 256 / 384 / 512)。本地计算,密钥不出浏览器。", diff --git a/src/lib/__tests__/hibp.test.ts b/src/lib/__tests__/hibp.test.ts new file mode 100644 index 0000000..3c4d2da --- /dev/null +++ b/src/lib/__tests__/hibp.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from 'vitest' +import { pwnedPasswordCount } from '../hibp' + +/** + * HIBP k-anonymity lookup. We inject a fake fetch so no network is touched. + * The FOUND case uses the REAL SHA-1 of "password" + * (5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8) so the test would catch the + * silent-failure bugs (lowercase hex, wrong slice, prefix left in the suffix) + * that all masquerade as "not found". + */ + +const PASSWORD_SUFFIX = '1E4C9B93F3F0682250B6CF8331B7EE68FD8' // SHA-1("password")[5:] +const PASSWORD_PREFIX = '5BAA6' + +function fakeFetch(body: string, ok = true, status = 200): typeof fetch { + return vi.fn(async () => ({ + ok, + status, + text: async () => body, + })) as unknown as typeof fetch +} + +describe('pwnedPasswordCount', () => { + it('requests the correct 5-char prefix and never sends the suffix', async () => { + const f = fakeFetch(`${PASSWORD_SUFFIX}:42\n`) + await pwnedPasswordCount('password', { fetchImpl: f }) + const url = (f as unknown as { mock: { calls: unknown[][] } }).mock.calls[0][0] as string + expect(url).toBe(`https://api.pwnedpasswords.com/range/${PASSWORD_PREFIX}`) + expect(url).not.toContain(PASSWORD_SUFFIX) + }) + + it('returns the breach count for a found password', async () => { + const body = + '0018A45C4D1DEF81644B54AB7F969B88D65:1\r\n' + + `${PASSWORD_SUFFIX}:3730471\r\n` + + '00D4F6E8FA6EECAD2A3AA415EEC418D38EC:2' + const count = await pwnedPasswordCount('password', { fetchImpl: fakeFetch(body) }) + expect(count).toBe(3730471) + }) + + it('matches case-insensitively on the returned suffix', async () => { + const body = `${PASSWORD_SUFFIX.toLowerCase()}:7\n` + const count = await pwnedPasswordCount('password', { fetchImpl: fakeFetch(body) }) + expect(count).toBe(7) + }) + + it('returns 0 when the suffix is absent from the range', async () => { + const body = '0018A45C4D1DEF81644B54AB7F969B88D65:1\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:9\n' + const count = await pwnedPasswordCount('password', { fetchImpl: fakeFetch(body) }) + expect(count).toBe(0) + }) + + it('throws on a non-OK response', async () => { + await expect( + pwnedPasswordCount('password', { fetchImpl: fakeFetch('', false, 503) }), + ).rejects.toThrow(/503/) + }) +}) diff --git a/src/lib/hibp.ts b/src/lib/hibp.ts new file mode 100644 index 0000000..e80e3a3 --- /dev/null +++ b/src/lib/hibp.ts @@ -0,0 +1,60 @@ +/** + * Have I Been Pwned — Pwned Passwords lookup via k-anonymity. + * + * PRIVACY: the password never leaves the browser. We SHA-1 it locally, send + * only the first 5 hex chars of the hash to the range API, and match the rest + * against the returned suffix list in-page. The server only ever sees a 5-char + * prefix shared by thousands of hashes (k-anonymity), so it can't learn which + * password was checked. See https://haveibeenpwned.com/API/v3#PwnedPasswords. + * + * This is an EXTERNAL network call and must only run on an explicit user action + * (a button), per the project's no-silent-network rule. + */ + +const RANGE_API = 'https://api.pwnedpasswords.com/range/' + +/** SHA-1 of `input` as UPPERCASE hex (HIBP returns uppercase suffixes). */ +async function sha1HexUpper(input: string): Promise { + const data = new TextEncoder().encode(input) + const buf = await crypto.subtle.digest('SHA-1', data) + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .toUpperCase() +} + +export type PwnedOpts = { + signal?: AbortSignal + /** Injectable for tests; defaults to the global fetch. */ + fetchImpl?: typeof fetch +} + +/** + * Return how many times `password` appears in known breach corpora — 0 means it + * wasn't found (which is NOT a guarantee of strength, only that it hasn't + * leaked). Throws on a non-OK HTTP response or a network/CORS failure. + */ +export async function pwnedPasswordCount( + password: string, + opts: PwnedOpts = {}, +): Promise { + const doFetch = opts.fetchImpl ?? fetch + const hash = await sha1HexUpper(password) + const prefix = hash.slice(0, 5) + const suffix = hash.slice(5) + + // Plain GET, no custom headers → a CORS "simple request" (no preflight). + const res = await doFetch(`${RANGE_API}${prefix}`, { signal: opts.signal }) + if (!res.ok) throw new Error(`HIBP request failed (${res.status})`) + const text = await res.text() + + for (const line of text.split('\n')) { + const idx = line.indexOf(':') + if (idx < 0) continue + if (line.slice(0, idx).trim().toUpperCase() === suffix) { + const count = parseInt(line.slice(idx + 1).trim(), 10) + return Number.isFinite(count) ? count : 0 + } + } + return 0 +} diff --git a/src/pages/Hash.tsx b/src/pages/Hash.tsx index 74fc842..cd2386a 100644 --- a/src/pages/Hash.tsx +++ b/src/pages/Hash.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Check, Copy, X } from 'lucide-react' +import { Check, Copy, X, Shield, ShieldAlert, ShieldCheck, Loader2 } from 'lucide-react' +import { pwnedPasswordCount } from '@/lib/hibp' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' @@ -21,6 +22,12 @@ const SAMPLE = 'The quick brown fox jumps over the lazy dog' type Tab = 'text' | 'file' +type PwnedState = + | { kind: 'idle' } + | { kind: 'checking' } + | { kind: 'done'; count: number } + | { kind: 'error'; message: string } + const ENCODINGS: DigestEncoding[] = ['hex', 'base64', 'base64url'] function emptyResults(): Record { @@ -41,6 +48,8 @@ export function HashPage() { const [pickedFile, setPickedFile] = useState<{ name: string; size: number } | null>(null) const [computing, setComputing] = useState(false) const [error, setError] = useState(null) + // Pwned-password (HIBP) check — explicit-trigger only; reset when input edits. + const [pwned, setPwned] = useState({ kind: 'idle' }) // ^ `computing` only flips during the file-mode async path (text mode is instant); // the text-mode useEffect intentionally doesn't touch it to satisfy the // react-hooks/set-state-in-effect rule. @@ -110,6 +119,22 @@ export function HashPage() { toast.success(t('common.copiedLabel', { label: algo })) } + // Strip the textarea's (almost always unintended) trailing newline before + // treating the input as a password. Internal/leading spaces are preserved — + // passwords can contain them. + const pwnedCandidate = input.replace(/[\r\n]+$/, '') + + const checkPwned = async () => { + if (!pwnedCandidate) return + setPwned({ kind: 'checking' }) + try { + const count = await pwnedPasswordCount(pwnedCandidate) + setPwned({ kind: 'done', count }) + } catch (e) { + setPwned({ kind: 'error', message: e instanceof Error ? e.message : String(e) }) + } + } + return (
@@ -161,7 +186,10 @@ export function HashPage() { {tab === 'text' ? (