Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
11 changes: 10 additions & 1 deletion src/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)。本地计算,密钥不出浏览器。",
Expand Down
58 changes: 58 additions & 0 deletions src/lib/__tests__/hibp.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
})
60 changes: 60 additions & 0 deletions src/lib/hibp.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<number> {
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
}
82 changes: 80 additions & 2 deletions src/pages/Hash.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<HashAlgo, Uint8Array | null> {
Expand All @@ -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<string | null>(null)
// Pwned-password (HIBP) check — explicit-trigger only; reset when input edits.
const [pwned, setPwned] = useState<PwnedState>({ 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.
Expand Down Expand Up @@ -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 (
<div className="mx-auto max-w-5xl px-8 py-12">
<header className="mb-6">
Expand Down Expand Up @@ -161,7 +186,10 @@ export function HashPage() {
{tab === 'text' ? (
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onChange={(e) => {
setInput(e.target.value)
setPwned({ kind: 'idle' })
}}
spellCheck={false}
className="mb-6 min-h-[180px] font-mono text-sm leading-relaxed"
placeholder={t('pages.hash.placeholder')}
Expand Down Expand Up @@ -244,6 +272,56 @@ export function HashPage() {
</div>

{error ? <div className="mt-3 text-xs text-destructive">⚠ {error}</div> : null}

{tab === 'text' ? (
<div className="mt-4 rounded-md border border-border bg-card/40 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{t('pages.hash.pwned.title')}
</span>
</div>
<Button
size="sm"
variant="outline"
onClick={checkPwned}
disabled={pwned.kind === 'checking' || !pwnedCandidate}
>
{pwned.kind === 'checking' ? (
<>
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
{t('pages.hash.pwned.checking')}
</>
) : (
t('pages.hash.pwned.check')
)}
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{t('pages.hash.pwned.note')}
</p>
{pwned.kind === 'done' && pwned.count > 0 ? (
<div className="mt-2 flex items-center gap-2 text-sm text-destructive">
<ShieldAlert className="h-4 w-4 shrink-0" />
{t('pages.hash.pwned.found', {
times: pwned.count.toLocaleString(),
})}
</div>
) : null}
{pwned.kind === 'done' && pwned.count === 0 ? (
<div className="mt-2 flex items-center gap-2 text-sm text-emerald-500">
<ShieldCheck className="h-4 w-4 shrink-0" />
{t('pages.hash.pwned.notFound')}
</div>
) : null}
{pwned.kind === 'error' ? (
<div className="mt-2 text-sm text-destructive">
⚠ {t('pages.hash.pwned.error', { message: pwned.message })}
</div>
) : null}
</div>
) : null}
</div>
)
}
Loading