From ce1d0057465d3b917eaf83fdf0b31bbf251a5490 Mon Sep 17 00:00:00 2001 From: fadeltd Date: Fri, 30 Oct 2026 00:04:18 +0700 Subject: [PATCH 01/10] Tailwind: canonical class spellings, with a guard test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the 55 classes the Tailwind editor extension flagged as having a canonical spelling (w-[220px] → w-55, rounded-[2px] → rounded-xs, bg-[length:…] → bg-size-[…], [scrollbar-width:none] → scrollbar-none, [&>*]: → *:, and so on). Computed styles are identical before and after on 14 routes at 1280px and 400px. scripts/tailwind.ts runs Tailwind's own canonicalizer (the engine behind the editor hints) over every string and template literal in src/**/*.tsx, parsed with oxc so JSX text is never touched. `pnpm tw:fix` reports them and `--write` rewrites them; scripts/tailwind.test.ts fails CI when one appears, so new code, including agent-written tools, stays canonical. Adds @tailwindcss/node 4.3.3 as a dev dependency for its design-system loader. --- package.json | 2 + pnpm-lock.yaml | 3 + scripts/tailwind.test.ts | 40 ++++++++ scripts/tailwind.ts | 123 +++++++++++++++++++++++++ scripts/tw-fix.ts | 24 +++++ src/components/CommandPalette.tsx | 10 +- src/components/ShortcutCheatsheet.tsx | 2 +- src/components/layout/Sidebar.tsx | 2 +- src/components/layout/ToolFrame.tsx | 2 +- src/components/layout/TwoPane.tsx | 2 +- src/components/ui/Badge.tsx | 2 +- src/components/ui/CodeArea.tsx | 2 +- src/components/ui/Kbd.tsx | 2 +- src/components/ui/Select.tsx | 2 +- src/tools/diff/MergePane.tsx | 2 +- src/tools/hash/HashTool.tsx | 2 +- src/tools/hmac/HmacTool.tsx | 4 +- src/tools/id-gen/IdGenTool.tsx | 4 +- src/tools/json/JsonTool.tsx | 2 +- src/tools/jwt/JwtTool.tsx | 12 +-- src/tools/number/NumberTool.tsx | 2 +- src/tools/text-stats/TextStatsTool.tsx | 4 +- src/tools/timestamp/TimestampTool.tsx | 4 +- src/tools/unicode/UnicodeTool.tsx | 4 +- src/tools/url/UrlTool.tsx | 2 +- 25 files changed, 226 insertions(+), 34 deletions(-) create mode 100644 scripts/tailwind.test.ts create mode 100644 scripts/tailwind.ts create mode 100644 scripts/tw-fix.ts diff --git a/package.json b/package.json index e69b854..50cccd4 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "lint": "oxlint", "deploy": "pnpm build && wrangler deploy", "seo": "vite-node scripts/seo.ts", + "tw:fix": "vite-node scripts/tw-fix.ts", "core:build": "pnpm --filter @fadeltd/devtools-core build" }, "dependencies": { @@ -36,6 +37,7 @@ "react-router": "8.4.0" }, "devDependencies": { + "@tailwindcss/node": "4.3.3", "@tailwindcss/vite": "4.3.3", "@types/node": "24.9.2", "@types/react": "19.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80210dc..8e9143e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: specifier: 8.4.0 version: 8.4.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) devDependencies: + '@tailwindcss/node': + specifier: 4.3.3 + version: 4.3.3 '@tailwindcss/vite': specifier: 4.3.3 version: 4.3.3(vite@8.3.0(@types/node@24.9.2)(esbuild@0.28.1)(jiti@2.7.0)) diff --git a/scripts/tailwind.test.ts b/scripts/tailwind.test.ts new file mode 100644 index 0000000..3f3c821 --- /dev/null +++ b/scripts/tailwind.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { applyFindings, findInSource, findNonCanonical } from './tailwind' + +// A stand-in canonicalizer, so the extraction tests don't depend on Tailwind. +const fake = (token: string) => ({ 'w-[220px]': 'w-55', 'rounded-[2px]': 'rounded-xs' })[token] ?? token + +describe('findInSource', () => { + it('finds classes in className strings, cn() arguments and template literals', () => { + const src = [ + `const a =
`, + `const b = cn('p-2', on && 'rounded-[2px]')`, + 'const c = `gap-2 ${x} w-[220px]`', + ].join('\n') + const found = findInSource('x.tsx', src, fake) + expect(found.map((f) => [f.line, f.from, f.to])).toEqual([ + [1, 'w-[220px]', 'w-55'], + [2, 'rounded-[2px]', 'rounded-xs'], + [3, 'w-[220px]', 'w-55'], + ]) + }) + + it('ignores JSX text, so an apostrophe cannot open a fake string', () => { + const src = `const a =

Don't w-[220px] it's fine

` + expect(findInSource('x.tsx', src, fake)).toEqual([]) + }) + + it('reports offsets that applyFindings can rewrite exactly', () => { + const src = `` + const out = applyFindings(src, findInSource('x.tsx', src, fake)) + expect(out).toBe(``) + }) +}) + +describe('src/', () => { + // The guard: new code must use the canonical spelling. Run `pnpm tw:fix --write`. + it('has no non-canonical Tailwind classes', { timeout: 60_000 }, async () => { + const findings = await findNonCanonical() + expect(findings.map((f) => `${f.file}:${f.line} ${f.from} → ${f.to}`)).toEqual([]) + }) +}) diff --git a/scripts/tailwind.ts b/scripts/tailwind.ts new file mode 100644 index 0000000..01be5d3 --- /dev/null +++ b/scripts/tailwind.ts @@ -0,0 +1,123 @@ +/** + * Find Tailwind classes that have a canonical spelling, using Tailwind's own + * canonicalizer -- the engine behind the editor's "can be written as" hints. + * + * Only string and template literals are inspected (parsed with oxc via Vite), + * never JSX text, so an apostrophe in "Don't" cannot open a fake string. + * Each token is canonicalized on its own: collapsing `mt-2 mb-2` into `my-2` + * would change which classes a conditional `cn()` branch toggles. + */ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { __unstable__loadDesignSystem } from '@tailwindcss/node' +import { parseAst } from 'vite' + +export interface Finding { + file: string + line: number + column: number + /** Absolute offset of the token in the file. */ + offset: number + from: string + to: string +} + +type DesignSystem = Awaited> + +const ROOT = join(import.meta.dirname, '..') +const CSS_ENTRY = join(ROOT, 'src/styles/global.css') + +export async function loadDesignSystem(): Promise { + return __unstable__loadDesignSystem(readFileSync(CSS_ENTRY, 'utf8'), { + base: join(ROOT, 'src/styles'), + }) +} + +export function sourceFiles(dir = join(ROOT, 'src')): string[] { + const out: string[] = [] + for (const name of readdirSync(dir)) { + const path = join(dir, name) + if (statSync(path).isDirectory()) out.push(...sourceFiles(path)) + else if (name.endsWith('.tsx')) out.push(path) + } + return out +} + +interface Segment { + start: number + text: string +} + +/** Raw text of every string literal and template chunk, with its offset. */ +function stringSegments(source: string): Segment[] { + const segments: Segment[] = [] + const visit = (node: unknown): void => { + if (Array.isArray(node)) { + for (const child of node) visit(child) + return + } + if (node === null || typeof node !== 'object') return + const n = node as { type?: string; start: number; end: number; value?: unknown } + if (n.type === 'Literal' && typeof n.value === 'string') { + // start/end include the quotes. + segments.push({ start: n.start + 1, text: source.slice(n.start + 1, n.end - 1) }) + } else if (n.type === 'TemplateElement') { + // oxc's span includes the opening delimiter (` or }) and the closing one + // (` or ${), so the raw text starts one character in. + const { raw } = n.value as { raw: string } + segments.push({ start: n.start + 1, text: raw }) + } + for (const key in n) { + if (key !== 'type' && key !== 'start' && key !== 'end') visit((n as Record)[key]) + } + } + visit(parseAst(source, { lang: 'tsx' })) + return segments +} + +export function findInSource( + file: string, + source: string, + canonical: (token: string) => string, +): Finding[] { + const findings: Finding[] = [] + for (const segment of stringSegments(source)) { + for (const match of segment.text.matchAll(/\S+/g)) { + const from = match[0] + const to = canonical(from) + if (to === from) continue + const offset = segment.start + match.index + const before = source.slice(0, offset) + const line = before.split('\n').length + findings.push({ file, line, column: offset - before.lastIndexOf('\n'), offset, from, to }) + } + } + return findings +} + +export function canonicalizer(ds: DesignSystem): (token: string) => string { + const cache = new Map() + return (token) => { + let hit = cache.get(token) + if (hit === undefined) { + // rem: 16 is what turns `w-[220px]` into `w-55`, as the editor suggests. + hit = ds.canonicalizeCandidates([token], { rem: 16 })[0] ?? token + cache.set(token, hit) + } + return hit + } +} + +export async function findNonCanonical(files = sourceFiles()): Promise { + const canonical = canonicalizer(await loadDesignSystem()) + return files.flatMap((file) => findInSource(relative(ROOT, file), readFileSync(file, 'utf8'), canonical)) +} + +/** Apply findings for one file, last offset first so earlier offsets stay valid. */ +export function applyFindings(source: string, findings: Finding[]): string { + let out = source + for (const f of findings.toSorted((a, b) => b.offset - a.offset)) { + out = out.slice(0, f.offset) + f.to + out.slice(f.offset + f.from.length) + } + return out +} diff --git a/scripts/tw-fix.ts b/scripts/tw-fix.ts new file mode 100644 index 0000000..521e4ae --- /dev/null +++ b/scripts/tw-fix.ts @@ -0,0 +1,24 @@ +/** + * List Tailwind classes in src/ that have a canonical spelling, and rewrite + * them with --write. The test in tailwind.test.ts fails CI on any that remain. + * + * pnpm tw:fix # report + * pnpm tw:fix --write # rewrite in place + */ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { applyFindings, findNonCanonical } from './tailwind' + +const findings = await findNonCanonical() +for (const f of findings) console.log(`${f.file}:${f.line}:${f.column} ${f.from} → ${f.to}`) + +if (process.argv.includes('--write')) { + const byFile = Map.groupBy(findings, (f) => f.file) + for (const [file, list] of byFile) { + const path = join(import.meta.dirname, '..', file) + writeFileSync(path, applyFindings(readFileSync(path, 'utf8'), list)) + } + console.log(`tw:fix: rewrote ${findings.length} classes in ${byFile.size} files`) +} else { + console.log(`tw:fix: ${findings.length} non-canonical classes${findings.length > 0 ? ' (run with --write)' : ''}`) +} diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index b2b18f8..2b1fc65 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -49,7 +49,7 @@ export function CommandPalette({ open, onOpenChange }: Props) { shouldFilter={false} loop label="Search tools" - className="panel relative z-10 flex max-h-[400px] w-full max-w-[640px] flex-col overflow-hidden bg-surface-2" + className="panel relative z-10 flex max-h-100 w-full max-w-160 flex-col overflow-hidden bg-surface-2" onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault() @@ -73,7 +73,7 @@ export function CommandPalette({ open, onOpenChange }: Props) { {recent.length > 0 && ( {recent.map((tool) => ( {rest.map((tool) => ( {/* Pinning is an action you go looking for, so it only appears once you have typed something. */} @@ -181,7 +181,7 @@ function ToolRow({ {tool.title} {pinIndex >= 0 && ( - + {renderCombo(`mod+${pinIndex + 1}`)} )} diff --git a/src/components/ShortcutCheatsheet.tsx b/src/components/ShortcutCheatsheet.tsx index 0ec23d1..478669a 100644 --- a/src/components/ShortcutCheatsheet.tsx +++ b/src/components/ShortcutCheatsheet.tsx @@ -21,7 +21,7 @@ export function ShortcutCheatsheet({ open, onClose }: { open: boolean; onClose: return (
+ @@ -320,7 +354,13 @@ export default function TextBytesTool() { {outcome.error !== null ? (
{outcome.error}
) : ( - + + )} + {capped && ( +

+ Showing the first {formatCount(shown.length)} of {formatCount(outcome.output.length)} characters. Copy + and Download include all of it. +

)} {(outcome.stats !== null || outcome.notes.length > 0) && (
From d0ced0fb3f79c33106434170ec739954b0c449fa Mon Sep 17 00:00:00 2001 From: fadeltd Date: Fri, 30 Oct 2026 00:22:55 +0700 Subject: [PATCH 09/10] JWT: verify signatures against a pasted key Add verifyJwt(token, key, options) to @fadeltd/devtools-core/jwt. The key can be a JWK, a JWKS (picked by the header kid, or every compatible key tried when there is none), a PEM public key, a PKCS#1 RSA PUBLIC KEY, an X.509 certificate, or an HS secret as text, base64 or base64url. HS, RS, PS and ES at 256/384/512 and EdDSA (Ed25519) are verified with WebCrypto over the original header and payload segments, never re-encoded. It refuses alg none, algorithm confusion (an HMAC alg with a public key), a curve or key type that does not fit the alg, and a JWK whose alg, use or key_ops conflicts with the header. A DER-encoded ECDSA signature is reported as such. Every failure comes back as a result with a reason; nothing throws. The JWT Decoder gains a Verify signature panel: a key box with format detection, an encoding select for secrets, and a Signature valid / invalid badge kept separate from the expiry badge. The key is held in memory only, like the token. Fetching a JWKS URL is not offered, because the CSP's connect-src 'self' blocks it, and the panel says so. --- packages/core/src/jwt/der.ts | 158 ++++++++++ packages/core/src/jwt/index.ts | 2 + packages/core/src/jwt/keys.ts | 294 ++++++++++++++++++ packages/core/src/jwt/verify.test.ts | 427 +++++++++++++++++++++++++++ packages/core/src/jwt/verify.ts | 376 +++++++++++++++++++++++ src/tools/jwt/JwtTool.tsx | 187 +++++++++++- 6 files changed, 1433 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/jwt/der.ts create mode 100644 packages/core/src/jwt/keys.ts create mode 100644 packages/core/src/jwt/verify.test.ts create mode 100644 packages/core/src/jwt/verify.ts diff --git a/packages/core/src/jwt/der.ts b/packages/core/src/jwt/der.ts new file mode 100644 index 0000000..6c41fbf --- /dev/null +++ b/packages/core/src/jwt/der.ts @@ -0,0 +1,158 @@ +/** + * Just enough DER to turn the key wrappers people actually paste into the SPKI + * that WebCrypto imports, and to recognise an ASN.1 ECDSA signature. This is a + * walker, not a parser: it reads lengths and skips, and every read is bounds + * checked so a malformed blob is `null`, never a throw or an out-of-range read. + * WebCrypto's own import is what validates the key itself. + */ + +export interface Tlv { + tag: number + /** Offset of the tag byte. */ + start: number + /** Offset of the first content byte. */ + contentStart: number + /** Offset one past the last content byte. */ + end: number +} + +const SEQUENCE = 0x30 +const INTEGER = 0x02 +const BIT_STRING = 0x03 +const OID = 0x06 + +/** One TLV at `offset`, or null if it overruns `limit` or is not DER. */ +export function readTlv(bytes: Uint8Array, offset: number, limit = bytes.length): Tlv | null { + if (offset + 2 > limit) return null + const tag = bytes[offset]! + // A high-tag-number form never occurs in the structures walked here. + if ((tag & 0x1f) === 0x1f) return null + let length = bytes[offset + 1]! + let p = offset + 2 + if (length & 0x80) { + const n = length & 0x7f + // n === 0 is BER's indefinite length, which DER forbids. Four length bytes + // is 4 GB, far past any key, so more is malformed rather than large. + if (n === 0 || n > 4 || p + n > limit) return null + length = 0 + for (let i = 0; i < n; i++) length = length * 256 + bytes[p++]! + } + const end = p + length + return end > limit ? null : { tag, start: offset, contentStart: p, end } +} + +/** The children of a constructed TLV, or null if they do not exactly fill it. */ +export function readChildren(bytes: Uint8Array, parent: Tlv): Tlv[] | null { + const out: Tlv[] = [] + let p = parent.contentStart + while (p < parent.end) { + const child = readTlv(bytes, p, parent.end) + if (child === null) return null + out.push(child) + p = child.end + } + return out +} + +export function encodeTlv(tag: number, content: Uint8Array): Uint8Array { + const n = content.length + const length = + n < 0x80 ? [n] : n < 0x100 ? [0x81, n] : n < 0x10000 ? [0x82, n >> 8, n & 0xff] : [0x83, n >> 16, (n >> 8) & 0xff, n & 0xff] + const out = new Uint8Array(1 + length.length + n) + out[0] = tag + out.set(length, 1) + out.set(content, 1 + length.length) + return out +} + +/** Dotted form of an OBJECT IDENTIFIER's content bytes. */ +export function oidToString(content: Uint8Array): string { + if (content.length === 0) return '' + const first = content[0]! + const arcs = [first < 80 ? Math.floor(first / 40) : 2, first < 80 ? first % 40 : first - 80] + let value = 0 + for (let i = 1; i < content.length; i++) { + const b = content[i]! + value = value * 128 + (b & 0x7f) + if ((b & 0x80) === 0) { + arcs.push(value) + value = 0 + } + } + return arcs.join('.') +} + +export interface SpkiAlgorithm { + oid: string + /** The parameter OID, when there is one: the named curve for an EC key. */ + param?: string +} + +/** The AlgorithmIdentifier of a SubjectPublicKeyInfo. */ +export function spkiAlgorithm(der: Uint8Array): SpkiAlgorithm | null { + const spki = readTlv(der, 0) + if (spki === null || spki.tag !== SEQUENCE || spki.end !== der.length) return null + const [algId, key] = readChildren(der, spki) ?? [] + if (algId?.tag !== SEQUENCE || key?.tag !== BIT_STRING) return null + const [oid, param] = readChildren(der, algId) ?? [] + if (oid?.tag !== OID) return null + const out: SpkiAlgorithm = { oid: oidToString(der.subarray(oid.contentStart, oid.end)) } + if (param?.tag === OID) out.param = oidToString(der.subarray(param.contentStart, param.end)) + return out +} + +// AlgorithmIdentifier { rsaEncryption, NULL }: the fixed prefix of every RSA SPKI. +const RSA_ALGORITHM_ID = Uint8Array.of( + 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00, +) + +/** + * PKCS#1 `RSA PUBLIC KEY` is the bare { n, e } sequence; SPKI is that same + * sequence inside a BIT STRING with an algorithm identifier in front. So the + * conversion is a wrap, not a parse. + */ +export function pkcs1ToSpki(pkcs1: Uint8Array): Uint8Array | null { + const seq = readTlv(pkcs1, 0) + if (seq === null || seq.tag !== SEQUENCE || seq.end !== pkcs1.length) return null + const parts = readChildren(pkcs1, seq) + if (parts?.length !== 2 || parts.some((p) => p.tag !== INTEGER)) return null + const bits = new Uint8Array(pkcs1.length + 1) // leading 0: no unused bits + bits.set(pkcs1, 1) + const key = encodeTlv(BIT_STRING, bits) + const body = new Uint8Array(RSA_ALGORITHM_ID.length + key.length) + body.set(RSA_ALGORITHM_ID) + body.set(key, RSA_ALGORITHM_ID.length) + return encodeTlv(SEQUENCE, body) +} + +/** + * The SubjectPublicKeyInfo inside an X.509 certificate (RFC 5280 §4.1): + * Certificate → tbsCertificate → [0] version (optional), serialNumber, + * signature, issuer, validity, subject, then the SPKI. The certificate's own + * signature is not checked: verifying a JWT needs the key, not trust in the CA. + */ +export function certificateSpki(der: Uint8Array): Uint8Array | null { + const cert = readTlv(der, 0) + if (cert === null || cert.tag !== SEQUENCE) return null + const tbs = readTlv(der, cert.contentStart, cert.end) + if (tbs === null || tbs.tag !== SEQUENCE) return null + const fields = readChildren(der, tbs) + if (fields === null) return null + const rest = fields[0]?.tag === 0xa0 ? fields.slice(1) : fields + const expected = [INTEGER, SEQUENCE, SEQUENCE, SEQUENCE, SEQUENCE, SEQUENCE] + if (rest.length < expected.length || expected.some((tag, i) => rest[i]!.tag !== tag)) return null + const spki = rest[5]! + return der.slice(spki.start, spki.end) +} + +/** + * True for an ASN.1 `SEQUENCE { INTEGER r, INTEGER s }`: what OpenSSL, Java + * and most HSMs emit for ECDSA, and what JWS forbids (RFC 7518 §3.4 wants the + * fixed-width r‖s that WebCrypto also uses). + */ +export function isDerEcdsaSignature(sig: Uint8Array): boolean { + const seq = readTlv(sig, 0) + if (seq === null || seq.tag !== SEQUENCE || seq.end !== sig.length) return false + const parts = readChildren(sig, seq) + return parts?.length === 2 && parts.every((p) => p.tag === INTEGER) +} diff --git a/packages/core/src/jwt/index.ts b/packages/core/src/jwt/index.ts index a1278cc..bad45bb 100644 --- a/packages/core/src/jwt/index.ts +++ b/packages/core/src/jwt/index.ts @@ -1,3 +1,5 @@ export * from './claims.ts' export * from './example.ts' +export * from './keys.ts' export * from './parse.ts' +export * from './verify.ts' diff --git a/packages/core/src/jwt/keys.ts b/packages/core/src/jwt/keys.ts new file mode 100644 index 0000000..5e06ec3 --- /dev/null +++ b/packages/core/src/jwt/keys.ts @@ -0,0 +1,294 @@ +import { base64ToBytes } from '../base64/codec.ts' +import { parseBase64 } from '../hash/bytes.ts' +import { decodeKey } from '../hash/hmac.ts' +import { certificateSpki, pkcs1ToSpki, spkiAlgorithm } from './der.ts' +import { isPlainObject } from './parse.ts' + +export type KeyFormat = 'jwk' | 'jwks' | 'pem' | 'secret' +export type SecretEncoding = 'text' | 'base64' | 'base64url' + +/** Where the bytes WebCrypto imports come from. */ +export type KeySource = + | { type: 'jwk'; jwk: JsonWebKey } + | { type: 'spki'; der: Uint8Array } + | { type: 'secret'; bytes: Uint8Array } + /** A key in a set that cannot be used. Kept, so a kid match can say why. */ + | { type: 'unusable'; message: string } + +export interface KeyCandidate { + /** JWK key type: 'RSA', 'EC', 'OKP' or 'oct', or whatever an unknown key says. */ + kty: string + crv?: string + kid?: string + /** The JWK's own `alg`, `use` and `key_ops`, which restrict what it may verify. */ + alg?: string + use?: string + keyOps?: string[] + /** Position in a JWKS. */ + index?: number + source: KeySource +} + +export type ParsedKey = + | { ok: true; format: KeyFormat; candidates: KeyCandidate[]; notes: string[] } + | { ok: false; format: KeyFormat | null; message: string } + +/** + * What a pasted key looks like. Anything starting with `{` is JSON, even when + * it does not parse yet, so a half-typed JWK reports a JSON error instead of + * being quietly used as an HMAC secret. + */ +export function detectKeyFormat(input: string): KeyFormat | null { + const s = input.trim() + if (s === '') return null + if (s.startsWith('{')) { + try { + const v: unknown = JSON.parse(s) + if (isPlainObject(v) && Array.isArray(v['keys'])) return 'jwks' + } catch { + // Falls through to 'jwk'; parseKey reports the JSON error. + } + return 'jwk' + } + if (s.includes('-----BEGIN ')) return 'pem' + return 'secret' +} + +const OPENSSL_PUBKEY_HINT = 'Extract the public key with `openssl x509 -pubkey -noout -in cert.pem` and paste that.' + +const EC_CURVES: Record = { + '1.2.840.10045.3.1.7': 'P-256', + '1.3.132.0.34': 'P-384', + '1.3.132.0.35': 'P-521', + '1.3.132.0.10': 'secp256k1', +} + +const OKP_CURVES: Record = { + '1.3.101.110': 'X25519', + '1.3.101.111': 'X448', + '1.3.101.112': 'Ed25519', + '1.3.101.113': 'Ed448', +} + +/** Name the key inside an SPKI by its algorithm OID, so the alg check can run before import. */ +function spkiCandidate(der: Uint8Array): KeyCandidate { + const alg = spkiAlgorithm(der) + if (alg === null) { + return { kty: 'unknown', source: { type: 'unusable', message: 'The public key is not a well-formed SubjectPublicKeyInfo.' } } + } + const source: KeySource = { type: 'spki', der } + if (alg.oid === '1.2.840.113549.1.1.1') return { kty: 'RSA', source } + if (alg.oid === '1.2.840.113549.1.1.10') { + return { + kty: 'RSA', + source: { + type: 'unusable', + message: + 'This is an RSASSA-PSS-only public key (OID id-RSASSA-PSS), which browsers cannot import. Re-export it as a plain RSA key (rsaEncryption), or paste it as a JWK.', + }, + } + } + if (alg.oid === '1.2.840.10045.2.1') { + const crv = alg.param === undefined ? undefined : (EC_CURVES[alg.param] ?? alg.param) + return crv === undefined ? { kty: 'EC', source } : { kty: 'EC', crv, source } + } + const okp = OKP_CURVES[alg.oid] + if (okp !== undefined) return { kty: 'OKP', crv: okp, source } + return { kty: 'unknown', source: { type: 'unusable', message: `The public key's algorithm (OID ${alg.oid}) is not one JWS signs with.` } } +} + +const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/ + +const pemFail = (message: string): ParsedKey => ({ ok: false, format: 'pem', message }) + +function parsePem(input: string): ParsedKey { + const m = PEM_BLOCK.exec(input) + if (m === null) return pemFail('The PEM has a BEGIN line but no matching END line.') + const label = m[1]! + + if (label.includes('PRIVATE KEY')) { + return pemFail( + 'This is a private key. Verifying needs only the public half: run `openssl pkey -in key.pem -pubout` and paste the result. A private key lets its holder sign tokens, so it is best kept out of every web page, even one that sends nothing.', + ) + } + + // RFC 1421 header lines (Proc-Type, DEK-Info) carry a colon; base64 never does. + const body = m[2]! + .split('\n') + .filter((line) => !line.includes(':')) + .join('') + const decoded = parseBase64(body) + if (!decoded.ok) return pemFail(`The PEM body is not valid base64: ${decoded.message}`) + const der = decoded.bytes + + let spki: Uint8Array | null + const notes: string[] = [] + switch (label) { + case 'PUBLIC KEY': + spki = der + break + case 'RSA PUBLIC KEY': + spki = pkcs1ToSpki(der) + if (spki === null) return pemFail('This RSA PUBLIC KEY is not a well-formed PKCS#1 key.') + break + case 'CERTIFICATE': + case 'X509 CERTIFICATE': + case 'TRUSTED CERTIFICATE': + spki = certificateSpki(der) + if (spki === null) return pemFail(`Could not find the public key inside this certificate. ${OPENSSL_PUBKEY_HINT}`) + // A chain is leaf first, and the leaf is the one that signed. + if (input.indexOf('-----BEGIN', m.index + m[0].length) >= 0) { + notes.push('Only the first certificate in the chain was used.') + } + break + default: + return pemFail(`A "${label}" PEM block is not a public key. Paste a PUBLIC KEY, RSA PUBLIC KEY or CERTIFICATE.`) + } + return { ok: true, format: 'pem', candidates: [spkiCandidate(spki)], notes } +} + +const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) + +const B64URL = /^[A-Za-z0-9_-]*$/ + +function decodeB64url(s: string): Uint8Array | null { + if (!B64URL.test(s) || s.length % 4 === 1) return null + try { + return base64ToBytes(s, { alphabet: 'base64url', lenient: true }) + } catch { + return null + } +} + +/** + * Copy only the public members, so a pasted private JWK still verifies (WebCrypto + * will not give a private key the 'verify' usage), and so `alg`, `use` and + * `key_ops`, which are checked here with better messages, do not trip import. + */ +function publicJwk(v: Record, members: readonly string[]): JsonWebKey | string { + const out: Record = {} + for (const name of ['kty', ...members]) { + const value = str(v[name]) + if (value === undefined) return `"${name}" is missing or not a string.` + out[name] = value + } + return out as JsonWebKey +} + +function jwkCandidate(v: unknown, index: number | undefined): { candidate: KeyCandidate; private: boolean } { + const base = (kty: string): KeyCandidate => { + const c: KeyCandidate = { kty, source: { type: 'unusable', message: '' } } + if (!isPlainObject(v)) return c + const kid = str(v['kid']) + const alg = str(v['alg']) + const use = str(v['use']) + if (kid !== undefined) c.kid = kid + if (alg !== undefined) c.alg = alg + if (use !== undefined) c.use = use + // A private JWK's key_ops say "sign"; its public half, which is all that is + // used, is for "verify". So they only restrict a public key. + if (Array.isArray(v['key_ops']) && v['d'] === undefined) c.keyOps = v['key_ops'].filter((x): x is string => typeof x === 'string') + if (index !== undefined) c.index = index + return c + } + const unusable = (kty: string, message: string) => { + const c = base(kty) + c.source = { type: 'unusable', message } + return { candidate: c, private: false } + } + + if (!isPlainObject(v)) return unusable('unknown', 'A JWK must be a JSON object.') + const kty = str(v['kty']) + if (kty === undefined) return unusable('unknown', 'The JWK has no "kty", so its key type is unknown.') + const c = base(kty) + const isPrivate = v['d'] !== undefined + + const fromJwk = (members: readonly string[]) => { + const jwk = publicJwk(v, members) + c.source = typeof jwk === 'string' ? { type: 'unusable', message: `This ${kty} JWK is incomplete: ${jwk}` } : { type: 'jwk', jwk } + } + + switch (kty) { + case 'RSA': + case 'EC': { + if (kty === 'EC' && str(v['crv']) !== undefined) c.crv = str(v['crv'])! + const members = kty === 'RSA' ? ['n', 'e'] : ['crv', 'x', 'y'] + // Some JWKS (Azure AD, older Auth0) carry the certificate as well; it is + // enough on its own when the raw members are absent. + const x5c = Array.isArray(v['x5c']) ? str(v['x5c'][0]) : undefined + if (x5c !== undefined && v[members[0]!] === undefined) { + const der = parseBase64(x5c) + const spki = der.ok ? certificateSpki(der.bytes) : null + c.source = spki === null ? { type: 'unusable', message: `The JWK's x5c certificate could not be read. ${OPENSSL_PUBKEY_HINT}` } : { type: 'spki', der: spki } + } else { + fromJwk(members) + } + break + } + case 'OKP': + if (str(v['crv']) !== undefined) c.crv = str(v['crv'])! + fromJwk(['crv', 'x']) + break + case 'oct': { + const k = str(v['k']) + const bytes = k === undefined ? null : decodeB64url(k) + c.source = bytes === null ? { type: 'unusable', message: 'The oct JWK\'s "k" is missing or is not base64url.' } : { type: 'secret', bytes } + break + } + default: + c.source = { type: 'unusable', message: `"kty": "${kty}" is not a key type JWS signs with.` } + } + return { candidate: c, private: isPrivate && kty !== 'oct' } +} + +const PRIVATE_NOTE = + 'The JWK includes private members ("d"); only the public part was used. A private key lets its holder sign tokens, so keep it out of places it can leak.' + +function parseJson(input: string): ParsedKey { + let value: unknown + try { + value = JSON.parse(input) + } catch (e) { + return { ok: false, format: 'jwk', message: `The key is not valid JSON: ${e instanceof Error ? e.message : String(e)}` } + } + if (isPlainObject(value) && Array.isArray(value['keys'])) { + const found = value['keys'].map((k, i) => jwkCandidate(k, i)) + if (found.length === 0) return { ok: false, format: 'jwks', message: 'The key set is empty.' } + const notes = found.some((f) => f.private) ? [PRIVATE_NOTE] : [] + return { ok: true, format: 'jwks', candidates: found.map((f) => f.candidate), notes } + } + const one = jwkCandidate(value, undefined) + return { ok: true, format: 'jwk', candidates: [one.candidate], notes: one.private ? [PRIVATE_NOTE] : [] } +} + +const secretFail = (message: string): ParsedKey => ({ ok: false, format: 'secret', message }) + +function parseSecret(input: string, encoding: SecretEncoding): ParsedKey { + if (encoding === 'base64url') { + // decodeKey's base64 takes either alphabet. A secret declared base64url + // that holds + or / is really standard base64, and saying so beats + // guessing. + const bad = /[+/]/.exec(input) + if (bad !== null) return secretFail(`"${bad[0]}" (position ${bad.index + 1}) is not base64url. Choose Base64 if the secret uses + and /.`) + } + // Never trimmed: a secret is exact bytes, as in the HMAC tool. + const decoded = decodeKey(input, encoding === 'text' ? 'text' : 'base64') + if (!decoded.ok) return secretFail(`The secret is not valid ${encoding}: ${decoded.message}`) + return { ok: true, format: 'secret', candidates: [{ kty: 'oct', source: { type: 'secret', bytes: decoded.bytes } }], notes: [] } +} + +/** Turn a pasted key into importable candidates. Never throws. */ +export function parseKey(input: string, secretEncoding: SecretEncoding = 'text'): ParsedKey { + const format = detectKeyFormat(input) + switch (format) { + case null: + return { ok: false, format: null, message: 'No key.' } + case 'jwk': + case 'jwks': + return parseJson(input.trim()) + case 'pem': + return parsePem(input) + case 'secret': + return parseSecret(input, secretEncoding) + } +} diff --git a/packages/core/src/jwt/verify.test.ts b/packages/core/src/jwt/verify.test.ts new file mode 100644 index 0000000..5af7d45 --- /dev/null +++ b/packages/core/src/jwt/verify.test.ts @@ -0,0 +1,427 @@ +import { X509Certificate, createPublicKey } from 'node:crypto' +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' +import { bytesToBase64, wrap } from '../base64/codec.ts' +import { certificateSpki, isDerEcdsaSignature, pkcs1ToSpki, readTlv, spkiAlgorithm } from './der.ts' +import { encodeJsonSegment as seg } from './example.ts' +import { detectKeyFormat } from './keys.ts' +import { verifyJwt, type VerifyResult } from './verify.ts' + +const utf8 = (s: string) => new TextEncoder().encode(s) +const b64url = (bytes: Uint8Array) => bytesToBase64(bytes, { alphabet: 'base64url', padding: false }) +const b64 = (bytes: Uint8Array) => bytesToBase64(bytes, { alphabet: 'base64', padding: true }) +const pem = (der: Uint8Array, label: string) => `-----BEGIN ${label}-----\n${wrap(b64(der), 64)}\n-----END ${label}-----\n` + +function concat(...parts: (Uint8Array | number[])[]): Uint8Array { + return Uint8Array.from(parts.flatMap((p) => [...p])) +} + +/** An independent DER encoder, so the fixtures do not lean on the code under test. */ +function tlv(tag: number, ...parts: (Uint8Array | number[])[]): Uint8Array { + const body = concat(...parts) + const n = body.length + const len = n < 0x80 ? [n] : n < 0x100 ? [0x81, n] : [0x82, n >> 8, n & 0xff] + return concat([tag], len, body) +} + +/** Raw r‖s to the ASN.1 form OpenSSL emits. */ +function rawToDer(raw: Uint8Array): Uint8Array { + const int = (b: Uint8Array) => { + let i = 0 + while (i < b.length - 1 && b[i] === 0) i++ + const v = b.slice(i) + return tlv(0x02, v[0]! & 0x80 ? concat([0], v) : v) + } + const half = raw.length / 2 + return tlv(0x30, int(raw.slice(0, half)), int(raw.slice(half))) +} + +const flip = (bytes: Uint8Array, at = 0) => { + const out = bytes.slice() + out[at]! ^= 0x01 + return out +} + +const PAYLOAD = { sub: 'user_1234', iat: 1767225600, exp: 1767229200 } + +async function sign( + header: Record, + key: CryptoKey, + params: AlgorithmIdentifier | RsaPssParams | EcdsaParams, +): Promise<{ input: string; sig: Uint8Array }> { + const input = `${seg(header)}.${seg(PAYLOAD)}` + const sig = new Uint8Array(await crypto.subtle.sign(params, key, utf8(input))) + return { input, sig } +} + +const token = (input: string, sig: Uint8Array) => `${input}.${b64url(sig)}` + +async function publicForms(key: CryptoKey): Promise<{ jwk: string; spki: Uint8Array; pem: string }> { + const jwk = await crypto.subtle.exportKey('jwk', key) + const spki = new Uint8Array(await crypto.subtle.exportKey('spki', key)) + return { jwk: JSON.stringify(jwk), spki, pem: pem(spki, 'PUBLIC KEY') } +} + +const RSA_GEN = { modulusLength: 2048, publicExponent: Uint8Array.of(1, 0, 1) } + +const CASES = [ + { + alg: 'RS256', + gen: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256', ...RSA_GEN }, + params: { name: 'RSASSA-PKCS1-v1_5' }, + }, + { alg: 'PS256', gen: { name: 'RSA-PSS', hash: 'SHA-256', ...RSA_GEN }, params: { name: 'RSA-PSS', saltLength: 32 } }, + { alg: 'ES256', gen: { name: 'ECDSA', namedCurve: 'P-256' }, params: { name: 'ECDSA', hash: 'SHA-256' } }, + { alg: 'ES384', gen: { name: 'ECDSA', namedCurve: 'P-384' }, params: { name: 'ECDSA', hash: 'SHA-384' } }, + { alg: 'EdDSA', gen: { name: 'Ed25519' }, params: { name: 'Ed25519' } }, +] as const + +async function pair(gen: (typeof CASES)[number]['gen']): Promise { + return (await crypto.subtle.generateKey(gen, true, ['sign', 'verify'])) as CryptoKeyPair +} + +const expectStatus = (r: VerifyResult, status: VerifyResult['status'], reason?: RegExp) => { + expect(r.status, r.reason).toBe(status) + if (reason !== undefined) expect(r.reason).toMatch(reason) +} + +describe('RFC 7515 Appendix A.1 (HS256)', () => { + // The header contains a CR LF, so re-encoding the decoded JSON would change + // the signing input. Only the original segments verify. + const TOKEN = + 'eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9' + + '.eyJpc3MiOiJqb2UiLA0KICJleHAiOjEzMDA4MTkzODAsDQogImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ' + + '.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + const K = 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow' + + it('verifies with the RFC JWK', async () => { + const r = await verifyJwt(TOKEN, JSON.stringify({ kty: 'oct', k: K })) + expect(r).toEqual({ status: 'valid', alg: 'HS256', format: 'jwk' }) + }) + + it('verifies with the secret pasted as base64url, and as base64', async () => { + expectStatus(await verifyJwt(TOKEN, K, { secretEncoding: 'base64url' }), 'valid') + expectStatus(await verifyJwt(TOKEN, K, { secretEncoding: 'base64' }), 'valid') + }) + + it('accepts the token with a Bearer prefix', async () => { + expectStatus(await verifyJwt(`Bearer ${TOKEN}`, K, { secretEncoding: 'base64url' }), 'valid') + }) + + it('fails with the secret read as text, and suggests the encoding', async () => { + const r = await verifyJwt(TOKEN, K) + expectStatus(r, 'invalid', /Base64/) + expect(r.format).toBe('secret') + }) + + it('fails when the payload is changed', async () => { + const [h, , s] = TOKEN.split('.') + expectStatus(await verifyJwt(`${h}.${seg({ iss: 'joe' })}.${s}`, K, { secretEncoding: 'base64url' }), 'invalid') + }) + + it('says when the secret has surrounding whitespace', async () => { + const input = `${seg({ alg: 'HS256' })}.${seg(PAYLOAD)}` + const key = await crypto.subtle.importKey('raw', utf8('a-long-enough-secret-for-hs256!!'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, utf8(input))) + expectStatus(await verifyJwt(token(input, sig), 'a-long-enough-secret-for-hs256!!'), 'valid') + expectStatus(await verifyJwt(token(input, sig), 'a-long-enough-secret-for-hs256!!\n'), 'invalid', /whitespace/) + }) + + it('warns about a short HMAC secret on a valid result', async () => { + const input = `${seg({ alg: 'HS256' })}.${seg(PAYLOAD)}` + const key = await crypto.subtle.importKey('raw', utf8('secret'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, utf8(input))) + expectStatus(await verifyJwt(token(input, sig), 'secret'), 'valid', /6 bytes, shorter than the 32/) + }) + + it('rejects base64 characters in a base64url secret', async () => { + expectStatus(await verifyJwt(TOKEN, 'ab+c', { secretEncoding: 'base64url' }), 'error', /not base64url/) + }) +}) + +describe.each(CASES)('$alg', ({ alg, gen, params }) => { + it('verifies with JWK and PEM, and rejects a flipped byte and a wrong key', async () => { + const [mine, other] = await Promise.all([pair(gen), pair(gen)]) + const { input, sig } = await sign({ alg, typ: 'JWT' }, mine.privateKey, params) + const { jwk, pem: pemKey } = await publicForms(mine.publicKey) + const wrong = await publicForms(other.publicKey) + + expect(await verifyJwt(token(input, sig), jwk)).toEqual({ status: 'valid', alg, format: 'jwk' }) + expect(await verifyJwt(token(input, sig), pemKey)).toEqual({ status: 'valid', alg, format: 'pem' }) + + expectStatus(await verifyJwt(token(input, flip(sig, 10)), jwk), 'invalid', /does not match/) + expectStatus(await verifyJwt(token(input, flip(sig, 10)), pemKey), 'invalid') + expectStatus(await verifyJwt(token(input, sig), wrong.jwk), 'invalid', /does not match/) + expectStatus(await verifyJwt(token(input, sig), wrong.pem), 'invalid') + }) +}) + +describe('key wrappers', () => { + it('reads a PKCS#1 RSA PUBLIC KEY', async () => { + const kp = await pair(CASES[0].gen) + const { input, sig } = await sign({ alg: 'RS256' }, kp.privateKey, CASES[0].params) + const { spki } = await publicForms(kp.publicKey) + // node:crypto is an independent encoder for the PKCS#1 fixture. + const pkcs1 = createPublicKey({ key: Buffer.from(spki), format: 'der', type: 'spki' }).export({ type: 'pkcs1', format: 'pem' }) + expect(pkcs1).toMatch(/BEGIN RSA PUBLIC KEY/) + expectStatus(await verifyJwt(token(input, sig), String(pkcs1)), 'valid') + }) + + it('wraps PKCS#1 into the same SPKI WebCrypto exports', async () => { + const kp = await pair(CASES[0].gen) + const { spki } = await publicForms(kp.publicKey) + const pkcs1 = createPublicKey({ key: Buffer.from(spki), format: 'der', type: 'spki' }).export({ type: 'pkcs1', format: 'der' }) + expect(pkcs1ToSpki(new Uint8Array(pkcs1))).toEqual(spki) + }) + + describe('X.509 certificate', () => { + const oid = (...bytes: number[]) => tlv(0x06, bytes) + const name = tlv(0x30, tlv(0x31, tlv(0x30, oid(0x55, 0x04, 0x03), tlv(0x0c, utf8('devtools test'))))) + const sigAlg = tlv(0x30, oid(0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02)) + const validity = tlv(0x30, tlv(0x17, utf8('260101000000Z')), tlv(0x17, utf8('360101000000Z'))) + const certificate = (spki: Uint8Array, version = true) => + tlv( + 0x30, + tlv(0x30, version ? tlv(0xa0, tlv(0x02, [2])) : [], tlv(0x02, [0x01, 0x23]), sigAlg, name, validity, name, spki), + sigAlg, + tlv(0x03, [0], new Uint8Array(64)), + ) + + it('finds the SPKI, agreeing with node:crypto', async () => { + const kp = await pair(CASES[2].gen) + const { spki } = await publicForms(kp.publicKey) + const der = certificate(spki) + const x509 = new X509Certificate(Buffer.from(der)) + expect(new Uint8Array(x509.publicKey.export({ type: 'spki', format: 'der' }))).toEqual(spki) + expect(certificateSpki(der)).toEqual(spki) + expect(certificateSpki(certificate(spki, false))).toEqual(spki) + }) + + it('verifies through a certificate, using the first of a chain', async () => { + const [kp, other] = await Promise.all([pair(CASES[2].gen), pair(CASES[2].gen)]) + const { input, sig } = await sign({ alg: 'ES256' }, kp.privateKey, CASES[2].params) + const chain = pem(certificate((await publicForms(kp.publicKey)).spki), 'CERTIFICATE') + pem(certificate((await publicForms(other.publicKey)).spki), 'CERTIFICATE') + expectStatus(await verifyJwt(token(input, sig), chain), 'valid', /first certificate/) + }) + + it('reads a JWK that carries only x5c', async () => { + const kp = await pair(CASES[2].gen) + const { input, sig } = await sign({ alg: 'ES256' }, kp.privateKey, CASES[2].params) + const x5c = b64(certificate((await publicForms(kp.publicKey)).spki)) + expectStatus(await verifyJwt(token(input, sig), JSON.stringify({ kty: 'EC', x5c: [x5c] })), 'valid') + }) + + it('rejects a truncated or garbage certificate with the openssl hint', async () => { + const kp = await pair(CASES[2].gen) + const der = certificate((await publicForms(kp.publicKey)).spki) + expect(certificateSpki(der.slice(0, 40))).toBeNull() + expect(certificateSpki(new Uint8Array([0x30, 0x03, 0x02, 0x01, 0x00]))).toBeNull() + const { input, sig } = await sign({ alg: 'ES256' }, kp.privateKey, CASES[2].params) + expectStatus(await verifyJwt(token(input, sig), pem(der.slice(0, 40), 'CERTIFICATE')), 'error', /openssl x509 -pubkey -noout/) + }) + }) + + it('refuses a private key PEM with the openssl hint', async () => { + const r = await verifyJwt(`${seg({ alg: 'RS256' })}.${seg({})}.AAAA`, pem(new Uint8Array(32), 'PRIVATE KEY')) + expectStatus(r, 'error', /private key.*openssl pkey/) + }) + + it('uses only the public half of a private JWK, and says so', async () => { + const kp = await pair(CASES[2].gen) + const { input, sig } = await sign({ alg: 'ES256' }, kp.privateKey, CASES[2].params) + const priv = await crypto.subtle.exportKey('jwk', kp.privateKey) + expect(priv.d).toBeDefined() + expectStatus(await verifyJwt(token(input, sig), JSON.stringify(priv)), 'valid', /private members/) + }) +}) + +/** Two RSA keys; the token is signed by the second. */ +async function setupJwks(header: Record) { + const [a, b] = await Promise.all([pair(CASES[0].gen), pair(CASES[0].gen)]) + const { input, sig } = await sign({ alg: 'RS256', ...header }, b.privateKey, CASES[0].params) + const ja = await crypto.subtle.exportKey('jwk', a.publicKey) + const jb = await crypto.subtle.exportKey('jwk', b.publicKey) + return { tok: token(input, sig), ja, jb } +} + +describe('JWKS', () => { + it('picks the key by kid', async () => { + const { tok, ja, jb } = await setupJwks({ kid: 'b' }) + const set = JSON.stringify({ keys: [{ ...ja, kid: 'a' }, { ...jb, kid: 'b' }] }) + expect(await verifyJwt(tok, set)).toEqual({ status: 'valid', alg: 'RS256', format: 'jwks', keyId: 'b', keyIndex: 1 }) + }) + + it('does not fall back to other keys when the kid names the wrong one', async () => { + const { tok, ja, jb } = await setupJwks({ kid: 'a' }) + const set = JSON.stringify({ keys: [{ ...ja, kid: 'a' }, { ...jb, kid: 'b' }] }) + expect(await verifyJwt(tok, set)).toMatchObject({ status: 'invalid', keyId: 'a', keyIndex: 0 }) + }) + + it('reports an unknown kid and lists the ones there are', async () => { + const { tok, ja, jb } = await setupJwks({ kid: 'zzz' }) + const set = JSON.stringify({ keys: [{ ...ja, kid: 'a' }, { ...jb, kid: 'b' }] }) + expectStatus(await verifyJwt(tok, set), 'error', /kid "zzz".*"a", "b"/) + }) + + it('without a kid, tries every compatible key and reports which matched', async () => { + const { tok, ja, jb } = await setupJwks({}) + const ec = await crypto.subtle.exportKey('jwk', (await pair(CASES[2].gen)).publicKey) + const set = JSON.stringify({ keys: [ec, ja, { kty: 'RSA', n: 'broken' }, jb] }) + const r = await verifyJwt(tok, set) + expect(r).toMatchObject({ status: 'valid', keyIndex: 3 }) + expect(r.reason).toMatch(/no kid.*key 4 in the set matched/) + }) + + it('without a kid, is invalid when no key matches', async () => { + const { tok, ja } = await setupJwks({}) + expectStatus(await verifyJwt(tok, JSON.stringify({ keys: [ja, ja] })), 'invalid', /none of the 2 keys/) + }) + + it('says why when no key in the set fits the algorithm', async () => { + const { tok } = await setupJwks({}) + const ec = await crypto.subtle.exportKey('jwk', (await pair(CASES[2].gen)).publicKey) + expectStatus(await verifyJwt(tok, JSON.stringify({ keys: [ec, ec] })), 'invalid', /None of the 2 keys.*RS256 needs an RSA public key/) + }) + + it('reports an empty set', async () => { + const { tok } = await setupJwks({}) + expectStatus(await verifyJwt(tok, '{"keys":[]}'), 'error', /empty/) + }) +}) + +describe('refusals', () => { + it('refuses HS256 with an RSA public key: algorithm confusion', async () => { + const kp = await pair(CASES[0].gen) + const { pem: pemKey, jwk } = await publicForms(kp.publicKey) + // The real attack: HMAC the token with the public key's PEM text as the secret. + const input = `${seg({ alg: 'HS256', typ: 'JWT' })}.${seg(PAYLOAD)}` + const hmacKey = await crypto.subtle.importKey('raw', utf8(pemKey), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + const forged = token(input, new Uint8Array(await crypto.subtle.sign('HMAC', hmacKey, utf8(input)))) + + expectStatus(await verifyJwt(forged, pemKey), 'invalid', /Refused.*algorithm-confusion/) + expectStatus(await verifyJwt(forged, jwk), 'invalid', /Refused.*RSA public key/) + }) + + it.each(['none', 'None', 'NONE'])('refuses alg "%s"', async (alg) => { + const r = await verifyJwt(`${seg({ alg })}.${seg(PAYLOAD)}.`, 'secret') + expectStatus(r, 'invalid', /Refused.*unsigned/) + expect(r.alg).toBe(alg) + }) + + it('cannot verify RS256 with a shared secret', async () => { + expectStatus(await verifyJwt(`${seg({ alg: 'RS256' })}.${seg(PAYLOAD)}.${b64url(new Uint8Array(256))}`, 'secret'), 'error', /public key/) + }) + + it('refuses ES256 with a P-384 key', async () => { + const kp = await pair(CASES[2].gen) + const { input, sig } = await sign({ alg: 'ES256' }, kp.privateKey, CASES[2].params) + const p384 = (await publicForms((await pair(CASES[3].gen)).publicKey)).pem + expectStatus(await verifyJwt(token(input, sig), p384), 'invalid', /EC P-256.*EC P-384/) + }) + + it('refuses a JWK whose alg, use or key_ops conflicts with the token', async () => { + const kp = await pair(CASES[0].gen) + const { input, sig } = await sign({ alg: 'RS256' }, kp.privateKey, CASES[0].params) + const jwk = await crypto.subtle.exportKey('jwk', kp.publicKey) + const tok = token(input, sig) + expectStatus(await verifyJwt(tok, JSON.stringify({ ...jwk, alg: 'PS256' })), 'invalid', /pins it to PS256/) + expectStatus(await verifyJwt(tok, JSON.stringify({ ...jwk, alg: undefined, use: 'enc' })), 'invalid', /"use" is "enc"/) + expectStatus(await verifyJwt(tok, JSON.stringify({ ...jwk, alg: undefined, key_ops: ['encrypt'] })), 'invalid', /key_ops/) + expectStatus(await verifyJwt(tok, JSON.stringify({ ...jwk, alg: 'RS256', use: 'sig', key_ops: ['verify'] })), 'valid') + }) + + it('reports a DER-encoded ECDSA signature as such', async () => { + const kp = await pair(CASES[2].gen) + const { input, sig } = await sign({ alg: 'ES256' }, kp.privateKey, CASES[2].params) + const der = rawToDer(sig) + expect(isDerEcdsaSignature(der)).toBe(true) + expect(isDerEcdsaSignature(sig)).toBe(false) + const { jwk } = await publicForms(kp.publicKey) + expectStatus(await verifyJwt(token(input, der), jwk), 'invalid', /DER-encoded.*64-byte r‖s/) + }) + + it('reports a wrong-length ECDSA signature that is not DER', async () => { + const tok = `${seg({ alg: 'ES384' })}.${seg(PAYLOAD)}.${b64url(new Uint8Array(64))}` + expectStatus(await verifyJwt(tok, 'x'), 'invalid', /ES384 signatures are 96 bytes, but this one is 64/) + }) +}) + +describe('errors come back as data', () => { + const HS = `${seg({ alg: 'HS256' })}.${seg(PAYLOAD)}.${b64url(new Uint8Array(32))}` + + it.each([ + ['an empty token', '', 'k', /No token/], + ['a JWE', `${seg({ alg: 'dir', enc: 'A128GCM' })}..AAAA.AAAA.AAAA`, 'k', /encrypted/], + ['two segments', 'abc.def', 'k', /found 2/], + ['no alg', `${seg({ typ: 'JWT' })}.${seg({})}.AAAA`, 'k', /no "alg"/], + ['an unknown alg', `${seg({ alg: 'XS256' })}.${seg({})}.AAAA`, 'k', /not a JWS signing algorithm/], + ['ES256K', `${seg({ alg: 'ES256K' })}.${seg({})}.AAAA`, 'k', /secp256k1/], + ['crit', `${seg({ alg: 'HS256', crit: ['b64'], b64: false })}.${seg({})}.AAAA`, 'k', /crit/], + ['a detached payload', `${seg({ alg: 'HS256' })}..AAAA`, 'k', /detached/], + ['a bad signature segment', `${seg({ alg: 'HS256' })}.${seg({})}.AA+A`, 'k', /not a base64url/], + ['half-typed JSON', HS, '{"kty":"oct"', /not valid JSON/], + ['a JWK without kty', HS, '{"k":"AAAA"}', /no "kty"/], + ['an unfinished PEM', HS, '-----BEGIN PUBLIC KEY-----\nAAAA', /no matching END/], + ['an unknown PEM label', HS, pem(new Uint8Array(8), 'DH PARAMETERS'), /not a public key/], + ['a garbage SPKI', `${seg({ alg: 'RS256' })}.${seg({})}.AAAA`, pem(new Uint8Array(8), 'PUBLIC KEY'), /SubjectPublicKeyInfo/], + ])('%s', async (_, tok, key, reason) => { + expectStatus(await verifyJwt(tok, key), 'error', reason) + }) + + it('is invalid for an empty signature', async () => { + expectStatus(await verifyJwt(`${seg({ alg: 'HS256' })}.${seg({})}.`, 'k'), 'invalid', /empty/) + }) + + it('never throws, whatever the key', async () => { + await fc.assert( + fc.asyncProperty(fc.string({ maxLength: 200 }), fc.constantFrom('text', 'base64', 'base64url') , async (key, enc) => { + const r = await verifyJwt(HS, key, { secretEncoding: enc }) + expect(['valid', 'invalid', 'error']).toContain(r.status) + const j = await verifyJwt(HS, `{"keys":[${key}]}`) + expect(['valid', 'invalid', 'error']).toContain(j.status) + }), + { numRuns: 200 }, + ) + }) +}) + +describe('detectKeyFormat', () => { + it.each([ + ['', null], + [' ', null], + ['my-secret', 'secret'], + ['{"kty":"RSA"}', 'jwk'], + ['{"kty":', 'jwk'], + [' {"keys":[]} ', 'jwks'], + ['-----BEGIN PUBLIC KEY-----\n…', 'pem'], + ['-----BEGIN CERTIFICATE-----', 'pem'], + ])('%j is %s', (input, format) => { + expect(detectKeyFormat(input)).toBe(format) + }) +}) + +describe('der', () => { + it('refuses lengths that overrun, and the indefinite form', () => { + expect(readTlv(Uint8Array.of(0x30, 0x05, 0x00), 0)).toBeNull() + expect(readTlv(Uint8Array.of(0x30, 0x80, 0x00, 0x00), 0)).toBeNull() + expect(readTlv(Uint8Array.of(0x30, 0x84, 0xff, 0xff, 0xff, 0xff), 0)).toBeNull() + expect(readTlv(Uint8Array.of(0x30), 0)).toBeNull() + expect(readTlv(Uint8Array.of(0x04, 0x81, 0x01, 0xaa), 0)).toEqual({ tag: 4, start: 0, contentStart: 3, end: 4 }) + }) + + it('names the curve of an EC SPKI', async () => { + const { spki } = await publicForms((await pair(CASES[3].gen)).publicKey) + expect(spkiAlgorithm(spki)).toEqual({ oid: '1.2.840.10045.2.1', param: '1.3.132.0.34' }) + }) + + it('never throws on random bytes', () => { + fc.assert( + fc.property(fc.uint8Array({ maxLength: 64 }), (bytes) => { + certificateSpki(bytes) + pkcs1ToSpki(bytes) + spkiAlgorithm(bytes) + isDerEcdsaSignature(bytes) + }), + ) + }) +}) diff --git a/packages/core/src/jwt/verify.ts b/packages/core/src/jwt/verify.ts new file mode 100644 index 0000000..85d68d6 --- /dev/null +++ b/packages/core/src/jwt/verify.ts @@ -0,0 +1,376 @@ +import { base64ToBytes } from '../base64/codec.ts' +import { timingSafeEqual } from '../hash/compare.ts' +import { hmac } from '../hash/hmac.ts' +import { isDerEcdsaSignature } from './der.ts' +import { detectKeyFormat, parseKey, type KeyCandidate, type KeyFormat, type SecretEncoding } from './keys.ts' +import { isPlainObject, parseJwt } from './parse.ts' + +export type VerifyStatus = 'valid' | 'invalid' | 'error' + +/** + * 'valid' means the signature was made by the key, and nothing else: it says + * nothing about exp or nbf, which `checkClaims` judges separately. + * 'invalid' means a verifier must reject the token with this key: a wrong + * signature, or a refusal such as `alg: none` or algorithm confusion. + * 'error' means it could not be checked: a malformed token or key, or an + * algorithm this runtime lacks. + */ +export interface VerifyResult { + status: VerifyStatus + /** The header's `alg` as sent, or null when there is none. */ + alg: string | null + /** How the key input was read, or null when there was none. */ + format: KeyFormat | null + /** The `kid` of the key that verified, or of the only key tried. */ + keyId?: string + /** 0-based position in a JWKS of the key that verified. */ + keyIndex?: number + /** Why, in a sentence or two. Set for every non-valid result, and for a valid one that carries a caveat. */ + reason?: string +} + +export interface VerifyOptions { + /** How an HS secret is written. Default 'text': most secrets are used as their literal UTF-8. */ + secretEncoding?: SecretEncoding +} + +type Hash = 'SHA-256' | 'SHA-384' | 'SHA-512' + +type AlgSpec = + | { kind: 'hmac'; hash: Hash; bytes: number } + | { kind: 'rsa'; name: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS'; hash: Hash; saltLength: number } + | { kind: 'ec'; hash: Hash; crv: 'P-256' | 'P-384' | 'P-521'; bytes: number } + | { kind: 'ed25519' } + +const HASHES: Record = { + '256': { hash: 'SHA-256', bytes: 32 }, + '384': { hash: 'SHA-384', bytes: 48 }, + '512': { hash: 'SHA-512', bytes: 64 }, +} + +// ES512 is P-521, not "P-512": the curve is 521 bits, so r and s are 66 bytes each. +const EC: Record = { + '256': { crv: 'P-256', bytes: 64 }, + '384': { crv: 'P-384', bytes: 96 }, + '512': { crv: 'P-521', bytes: 132 }, +} + +/** Registered JWS algorithms WebCrypto has no primitive for. */ +const UNAVAILABLE: Record = { + ES256K: 'ES256K uses the secp256k1 curve, which WebCrypto does not implement.', + Ed448: 'Ed448 is not available in browser WebCrypto.', +} + +function algSpec(alg: string): AlgSpec | string { + const m = /^(HS|RS|PS|ES)(256|384|512)$/.exec(alg) + if (m !== null) { + const family = m[1]! + const size = m[2]! + const { hash, bytes } = HASHES[size]! + switch (family) { + case 'HS': + return { kind: 'hmac', hash, bytes } + case 'RS': + return { kind: 'rsa', name: 'RSASSA-PKCS1-v1_5', hash, saltLength: 0 } + case 'PS': + // RFC 7518 §3.5: the salt is as long as the hash. + return { kind: 'rsa', name: 'RSA-PSS', hash, saltLength: bytes } + default: + return { kind: 'ec', hash, ...EC[size]! } + } + } + // "Ed25519" is the fully-specified name from RFC 9864 for what "EdDSA" means with an Ed25519 key. + if (alg === 'EdDSA' || alg === 'Ed25519') return { kind: 'ed25519' } + if (alg.startsWith('ML-DSA-')) return `${alg} is a post-quantum algorithm that browser WebCrypto does not implement yet.` + return UNAVAILABLE[alg] ?? `"${alg}" is not a JWS signing algorithm this tool knows.` +} + +function describeKey(c: KeyCandidate): string { + switch (c.kty) { + case 'oct': + return 'a shared secret' + case 'RSA': + return 'an RSA public key' + case 'EC': + return c.crv === undefined ? 'an EC public key' : `an EC ${c.crv} public key` + case 'OKP': + return c.crv === undefined ? 'an OKP public key' : `an ${c.crv} public key` + default: + return `a key of type "${c.kty}"` + } +} + +function needs(spec: AlgSpec): string { + switch (spec.kind) { + case 'hmac': + return 'a shared secret' + case 'rsa': + return 'an RSA public key' + case 'ec': + return `an EC ${spec.crv} public key` + case 'ed25519': + return 'an Ed25519 public key' + } +} + +// RFC 9864 renamed EdDSA-with-Ed25519 to "Ed25519", and exporters now write +// that into a JWK's alg (Node does). Both names mean the same signature. +const ED25519_ALGS = new Set(['EdDSA', 'Ed25519']) + +interface Refusal { + status: 'invalid' | 'error' + reason: string +} + +/** + * The algorithm is pinned to the key, never taken on the token's word. The + * header is attacker-controlled; the key is the one thing the verifier chose. + */ +function refusal(alg: string, spec: AlgSpec, c: KeyCandidate): Refusal | null { + if (spec.kind === 'hmac' && c.kty !== 'oct') { + return { + status: 'invalid', + reason: `Refused: ${alg} is HMAC, which takes a shared secret, but this is ${describeKey(c)}. Checking it would use the public key as the HMAC secret, and since anyone can have the public key, anyone could forge a token that passes. This is the classic algorithm-confusion attack.`, + } + } + if (spec.kind !== 'hmac' && c.kty === 'oct') { + return { + status: 'error', + reason: `${alg} is signed with a private key and checked with its public key, so a shared secret cannot verify it. Paste the issuer's public key as PEM or JWK.`, + } + } + const wrongType = + (spec.kind === 'rsa' && c.kty !== 'RSA') || + (spec.kind === 'ec' && (c.kty !== 'EC' || (c.crv !== undefined && c.crv !== spec.crv))) || + (spec.kind === 'ed25519' && (c.kty !== 'OKP' || c.crv !== 'Ed25519')) + if (wrongType) { + if (spec.kind === 'ed25519' && c.kty === 'OKP' && c.crv === 'Ed448') { + return { status: 'error', reason: UNAVAILABLE['Ed448']! } + } + return { status: 'invalid', reason: `Refused: ${alg} needs ${needs(spec)}, but this is ${describeKey(c)}.` } + } + if (c.alg !== undefined && c.alg !== alg && !(spec.kind === 'ed25519' && ED25519_ALGS.has(c.alg))) { + return { status: 'invalid', reason: `Refused: the key's own "alg" pins it to ${c.alg}, but the token's header says ${alg}.` } + } + if (c.use !== undefined && c.use !== 'sig') { + return { status: 'invalid', reason: `Refused: the key's "use" is "${c.use}", so it is not a signing key.` } + } + if (c.keyOps !== undefined && !c.keyOps.includes('verify')) { + return { status: 'invalid', reason: `Refused: the key's "key_ops" (${c.keyOps.join(', ') || 'empty'}) does not include "verify".` } + } + return null +} + +type Outcome = { ok: boolean } | { error: string } + +function importParams(spec: AlgSpec): RsaHashedImportParams | EcKeyImportParams | Algorithm { + switch (spec.kind) { + case 'rsa': + return { name: spec.name, hash: spec.hash } + case 'ec': + return { name: 'ECDSA', namedCurve: spec.crv } + default: + return { name: 'Ed25519' } + } +} + +function verifyParams(spec: AlgSpec): RsaPssParams | EcdsaParams | Algorithm { + switch (spec.kind) { + case 'rsa': + return spec.name === 'RSA-PSS' ? { name: 'RSA-PSS', saltLength: spec.saltLength } : { name: spec.name } + case 'ec': + return { name: 'ECDSA', hash: spec.hash } + default: + return { name: 'Ed25519' } + } +} + +const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)) + +async function verifyWith(spec: AlgSpec, c: KeyCandidate, data: Uint8Array, sig: Uint8Array): Promise { + const { source } = c + if (source.type === 'unusable') return { error: source.message } + + if (spec.kind === 'hmac') { + if (source.type !== 'secret') return { error: 'Not a secret.' } + const mac = await hmac(spec.hash, source.bytes, data) + if (!mac.ok) return { error: mac.message } + return { ok: timingSafeEqual(mac.bytes, sig) } + } + if (source.type === 'secret') return { error: 'Not a public key.' } + + let key: CryptoKey + try { + key = + source.type === 'jwk' + ? await crypto.subtle.importKey('jwk', source.jwk, importParams(spec), false, ['verify']) + : await crypto.subtle.importKey('spki', source.der as Uint8Array, importParams(spec), false, ['verify']) + } catch (e) { + // Ed25519 reached Safari in 17 and Chrome in 137; older engines throw here. + if (e instanceof Error && e.name === 'NotSupportedError') { + return { error: `This browser's WebCrypto cannot use ${spec.kind === 'ed25519' ? 'Ed25519' : 'this key type'}, so it cannot check the signature.` } + } + return { error: `The key could not be imported: ${errorText(e)}` } + } + try { + const ok = await crypto.subtle.verify( + verifyParams(spec), + key, + sig as Uint8Array, + data as Uint8Array, + ) + return { ok } + } catch (e) { + return { error: `Verification failed to run: ${errorText(e)}` } + } +} + +/** A signature whose length alone settles it, before any key is involved. */ +function signatureShape(alg: string, spec: AlgSpec, sig: Uint8Array): string | null { + if (sig.length === 0) return 'The signature is empty.' + const expected = spec.kind === 'ec' ? spec.bytes : spec.kind === 'ed25519' ? 64 : null + if (expected === null || sig.length === expected) return null + if (spec.kind === 'ec' && isDerEcdsaSignature(sig)) { + return `The signature is DER-encoded (an ASN.1 sequence of r and s, ${sig.length} bytes), as OpenSSL and Java produce it. JWS requires the raw ${expected}-byte r‖s form (RFC 7518 §3.4), so a compliant verifier rejects it; the issuer must convert it.` + } + return `${alg} signatures are ${expected} bytes, but this one is ${sig.length}.` +} + +const utf8 = new TextEncoder() + +const MAX_KIDS_LISTED = 8 + +/** + * Verify a compact JWS against a pasted key: a JWK, a JWKS (picked by the + * header's kid), a PEM public key or certificate, or an HS secret. The signing + * input is the original header and payload segments exactly as pasted; they are + * never re-encoded, since any byte of difference is a different message. + * Never throws: every failure comes back as a result. + */ +export async function verifyJwt(token: string, key: string, options: VerifyOptions = {}): Promise { + const parsed = parseJwt(token) + const headerValue = parsed.header?.ok === true ? parsed.header.value : undefined + const header = isPlainObject(headerValue) ? headerValue : null + const alg = typeof header?.['alg'] === 'string' ? header['alg'] : null + const format = detectKeyFormat(key) + const result = (status: VerifyStatus, reason?: string, extra: Partial = {}): VerifyResult => { + const r: VerifyResult = { status, alg, format, ...extra } + if (reason !== undefined) r.reason = reason + return r + } + + if (parsed.token === '') return result('error', 'No token.') + if (parsed.kind === 'jwe') { + return result('error', 'This is an encrypted token (JWE). It has no signature to check, and decrypting it needs the recipient’s private key.') + } + if (parsed.kind === null) return result('error', parsed.errors[0]?.message ?? 'Not a JWT.') + if (header === null) return result('error', 'The header is not a readable JSON object, so the algorithm is unknown.') + if (alg === null) return result('error', 'The header has no "alg", so there is no algorithm to verify with.') + + if (alg.toLowerCase() === 'none') { + return result('invalid', `Refused: "alg" is "${alg}", which means unsigned. There is nothing to verify, and a verifier that accepts it lets anyone forge a token.`) + } + if (header['crit'] !== undefined) { + // RFC 7515 §4.1.11: a verifier MUST reject what it does not understand. b64 + // (RFC 7797) in particular changes the signing input itself. + return result('error', `The header lists critical extensions in "crit" (${JSON.stringify(header['crit'])}), which this tool does not implement, and RFC 7515 says to reject the token rather than guess.`) + } + + const [headerPart, payloadPart, sigPart] = parsed.parts + if (payloadPart!.text === '') { + return result('error', 'The payload segment is empty: this is a detached-payload JWS (RFC 7515 Appendix F), and the content it signs is not in the token.') + } + if (parsed.signature === null) { + return result('error', parsed.errors.find((e) => e.segment === 'signature')?.message ?? 'The signature is not base64url.') + } + + const spec = algSpec(alg) + if (typeof spec === 'string') return result('error', spec) + + const parsedKey = parseKey(key, options.secretEncoding ?? 'text') + if (!parsedKey.ok) return result('error', parsedKey.message) + + let sig: Uint8Array + try { + sig = base64ToBytes(sigPart!.text.replace(/=+$/, ''), { alphabet: 'base64url', lenient: true }) + } catch (e) { + return result('error', `The signature is not base64url: ${errorText(e)}`) + } + const shape = signatureShape(alg, spec, sig) + if (shape !== null) return result('invalid', shape) + + // Select by kid in a set; a single key is used whatever its kid, as every + // library does when it is handed one key. + const headerKid = typeof header['kid'] === 'string' ? header['kid'] : undefined + let pool = parsedKey.candidates + if (format === 'jwks' && headerKid !== undefined) { + pool = pool.filter((c) => c.kid === headerKid) + if (pool.length === 0) { + const kids = parsedKey.candidates.flatMap((c) => (c.kid === undefined ? [] : [JSON.stringify(c.kid)])) + const listed = kids.length === 0 ? 'None of its keys has a kid.' : `Its kids: ${kids.slice(0, MAX_KIDS_LISTED).join(', ')}${kids.length > MAX_KIDS_LISTED ? ', …' : ''}.` + return result('error', `No key in the set has the header's kid ${JSON.stringify(headerKid)}. ${listed}`) + } + } + + const refusals: Refusal[] = [] + const usable: KeyCandidate[] = [] + for (const c of pool) { + // An unreadable key is reported as such before any algorithm judgement. + const r = c.source.type === 'unusable' ? { status: 'error' as const, reason: c.source.message } : refusal(alg, spec, c) + if (r !== null) refusals.push(r) + else usable.push(c) + } + + const idOf = (c: KeyCandidate): Partial => { + const extra: Partial = {} + if (c.kid !== undefined) extra.keyId = c.kid + if (c.index !== undefined) extra.keyIndex = c.index + return extra + } + const notes = [...parsedKey.notes] + + if (usable.length === 0) { + const first = refusals[0]! + if (pool.length === 1) return result(first.status, first.reason, idOf(pool[0]!)) + return result(first.status, `None of the ${pool.length} keys in the set can verify ${alg}. The first: ${first.reason}`) + } + + const data = utf8.encode(`${headerPart!.text}.${payloadPart!.text}`) + const outcomes = await Promise.all(usable.map((c) => verifyWith(spec, c, data, sig))) + const hit = outcomes.findIndex((o) => 'ok' in o && o.ok) + + if (hit >= 0) { + const c = usable[hit]! + if (usable.length > 1) { + const why = headerKid === undefined ? 'The header has no kid, so' : `${usable.length} keys share the kid, so` + notes.unshift(`${why} all ${usable.length} keys that fit ${alg} were tried; key ${c.index! + 1} in the set matched.`) + } + if (spec.kind === 'hmac' && c.source.type === 'secret' && c.source.bytes.length < spec.bytes) { + notes.push(`The secret is ${c.source.bytes.length} bytes, shorter than the ${spec.bytes} RFC 7518 requires for ${alg}. Strict libraries refuse it, and a short secret can be brute-forced offline from any token.`) + } + return result('valid', notes.length > 0 ? notes.join(' ') : undefined, idOf(c)) + } + + const errors = outcomes.flatMap((o) => ('error' in o ? [o.error] : [])) + if (errors.length === outcomes.length) { + return result('error', errors[0], usable.length === 1 ? idOf(usable[0]!) : {}) + } + + const hints: string[] = [] + if (usable.length === 1) { + const c = usable[0]! + hints.push('The signature does not match this key.') + if (spec.kind === 'hmac' && sig.length !== spec.bytes) { + hints.push(`An ${alg} signature is ${spec.bytes} bytes, and this one is ${sig.length}, so it was not made with ${alg}.`) + } else if (format === 'secret' && key !== key.trim()) { + hints.push('The secret starts or ends with whitespace, which counts as part of it.') + } else if (format === 'secret' && (options.secretEncoding ?? 'text') === 'text' && /^[A-Za-z0-9+/_-]{16,}={0,2}$/.test(key)) { + hints.push('If the secret is base64-encoded, set its encoding to Base64 or Base64url.') + } + if (headerKid !== undefined && c.kid !== undefined && c.kid !== headerKid) { + hints.push(`The token's kid is ${JSON.stringify(headerKid)} but this key's is ${JSON.stringify(c.kid)}: probably the wrong key.`) + } + return result('invalid', [...hints, ...notes].join(' '), idOf(c)) + } + return result('invalid', [`The signature matches none of the ${usable.length} keys in the set that fit ${alg}.`, ...notes].join(' ')) +} diff --git a/src/tools/jwt/JwtTool.tsx b/src/tools/jwt/JwtTool.tsx index f01e30f..032ba5a 100644 --- a/src/tools/jwt/JwtTool.tsx +++ b/src/tools/jwt/JwtTool.tsx @@ -1,5 +1,5 @@ import { useDeferredValue, useEffect, useMemo, useState, type ReactNode } from 'react' -import { CircleX, TriangleAlert } from 'lucide-react' +import { CircleCheck, CircleX, TriangleAlert } from 'lucide-react' import { ToolFrame } from '@/components/layout/ToolFrame' import { TwoPane } from '@/components/layout/TwoPane' import { Badge } from '@/components/ui/Badge' @@ -13,25 +13,55 @@ import { useToolUsageTracker } from '@/lib/prefs' import { copyText } from '@/lib/util/clipboard' import { formatCount } from '@/lib/util/bytes' import { + detectKeyFormat, exampleToken, parseJwt, + verifyJwt, withClaims, type ClaimRow, + type KeyFormat, + type SecretEncoding, type JsonSegment, type JwtIssue, type JwtResult, type TokenPart, type ValidityStatus, + type VerifyResult, } from '@fadeltd/devtools-core/jwt' interface State { token: string skew: number + key: string + keyEncoding: SecretEncoding } // The registry declares store 'none', so useToolState keeps this in memory -// only. The token is a bearer credential and must never reach storage or a URL. -const INITIAL: State = { token: '', skew: 0 } +// only. The token is a bearer credential and the key may be an HS secret: +// neither may reach storage or a URL. +const INITIAL: State = { token: '', skew: 0, key: '', keyEncoding: 'text' } + +const KEY_FORMATS: Record = { + jwk: 'JWK', + jwks: 'JWKS', + pem: 'PEM', + secret: 'Secret', +} + +const SECRET_ENCODINGS: Record = { + text: 'Text (UTF-8)', + base64: 'Base64', + base64url: 'Base64url', +} + +/** 'checking' covers the gap between an edit and its async result, so a stale verdict is never shown. */ +type Verdict = VerifyResult | 'checking' | null + +const VERDICT: Record = { + valid: { label: 'Signature valid', tone: 'add', title: 'Signed by this key. Says nothing about expiry, which is the other badge.' }, + invalid: { label: 'Signature invalid', tone: 'del', title: 'A verifier must reject this token with this key.' }, + error: { label: 'Signature not checked', tone: 'warn', title: 'The token or the key could not be used to check it.' }, +} const SKEWS = [ { value: 0, label: 'No leeway' }, @@ -79,6 +109,28 @@ export default function JwtTool() { const payloadText = result.payload?.pretty ?? '' + // Verification is async (WebCrypto), so each result is stored with the + // inputs it answers, and anything else reads as 'checking'. + const key = useDeferredValue(state.key) + const encoding = state.keyEncoding + const wantsCheck = parsed.kind === 'jws' && key.trim() !== '' + const [checked, setChecked] = useState<{ token: string; key: string; encoding: SecretEncoding; result: VerifyResult } | null>(null) + useEffect(() => { + if (!wantsCheck) return + let cancelled = false + void verifyJwt(token, key, { secretEncoding: encoding }).then((r) => { + if (!cancelled) setChecked({ token, key, encoding, result: r }) + }) + return () => { + cancelled = true + } + }, [wantsCheck, token, key, encoding]) + const verdict: Verdict = !wantsCheck + ? null + : checked !== null && checked.token === token && checked.key === key && checked.encoding === encoding + ? checked.result + : 'checking' + useShortcuts([ { combo: 'mod+shift+c', @@ -142,7 +194,19 @@ export default function JwtTool() { never saved or sent anywhere.

) : ( - + setState((p) => ({ ...p, key: k }))} + onEncoding={(e) => setState((p) => ({ ...p, keyEncoding: e }))} + /> + } + /> )}
} @@ -151,7 +215,7 @@ export default function JwtTool() { ) } -function Decoded({ result }: { result: JwtResult }) { +function Decoded({ result, verdict, verify }: { result: JwtResult; verdict: Verdict; verify: ReactNode }) { const { status, kind, header, payload, signature, encrypted, claims, errors, warnings } = result const alg = header?.ok === true ? (header.value as Record | null)?.['alg'] : undefined @@ -165,11 +229,7 @@ function Decoded({ result }: { result: JwtResult }) { )} {kind !== null && {kind === 'jws' ? 'JWS · signed' : 'JWE · encrypted'}} {typeof alg === 'string' && {alg.length > 24 ? `${alg.slice(0, 24)}…` : alg}} - {kind === 'jws' && ( - - Signature not verified - - )} + {kind === 'jws' && }
{(errors.length > 0 || warnings.length > 0) && ( @@ -222,7 +282,14 @@ function Decoded({ result }: { result: JwtResult }) { action={} >

- {formatCount(signature.bytes)} bytes · not verified + {formatCount(signature.bytes)} bytes ·{' '} + {verdict !== null && verdict !== 'checking' && verdict.status === 'valid' ? ( + verified + ) : verdict !== null && verdict !== 'checking' && verdict.status === 'invalid' ? ( + invalid + ) : ( + not verified + )}

{signature.base64url !== '' && (

@@ -231,10 +298,108 @@ function Decoded({ result }: { result: JwtResult }) { )} )} + + {kind === 'jws' && verify} ) } +function VerdictBadge({ verdict }: { verdict: Verdict }) { + if (verdict === null) { + return Signature not verified + } + if (verdict === 'checking') return Checking signature… + const v = VERDICT[verdict.status] + return ( + + {v.label} + + ) +} + +function VerifyPanel({ + keyText, + encoding, + verdict, + onKey, + onEncoding, +}: { + keyText: string + encoding: SecretEncoding + verdict: Verdict + onKey: (key: string) => void + onEncoding: (encoding: SecretEncoding) => void +}) { + const format = detectKeyFormat(keyText) + // A URL is read as an HS secret, which can only fail; say why instead. + const isUrl = format === 'secret' && /^\s*https?:\/\//i.test(keyText) + + return ( +

{KEY_FORMATS[format]}} + > +
+ onKey(e.target.value)} + rows={4} + placeholder={'Public key as PEM or JWK, a JWKS {"keys": […]}, or the HS256 secret'} + aria-label="Verification key" + className="break-all" + /> +
+
+ {format === 'secret' && ( + + )} + {isUrl && ( +

+ That is a URL. Open it in a new tab and paste the JSON it returns here. +

+ )} + {verdict !== null && verdict !== 'checking' && } +

+ Paste the key; fetching a JWKS URL isn’t possible from this site, which makes no network + requests. The key stays in this tab and is never saved. +

+
+
+ ) +} + +function VerdictLine({ result }: { result: VerifyResult }) { + const tone = result.status === 'valid' ? 'text-add' : result.status === 'invalid' ? 'text-del' : 'text-warn' + const Icon = result.status === 'valid' ? CircleCheck : result.status === 'invalid' ? CircleX : TriangleAlert + const matched = + result.keyId !== undefined + ? `kid ${JSON.stringify(result.keyId)}` + : result.keyIndex !== undefined + ? `key ${result.keyIndex + 1} in the set` + : null + return ( +
+

+ + + {VERDICT[result.status].label} + {matched !== null && · {matched}} + +

+ {result.reason !== undefined &&

{result.reason}

} +
+ ) +} + function Section({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) { return (
From 18cf92f5f948fb72112dafed309fd5d63005e360 Mon Sep 17 00:00:00 2001 From: fadeltd Date: Fri, 30 Oct 2026 00:28:57 +0700 Subject: [PATCH 10/10] Batch-1 follow-ups: changelogs, READMEs and JWT registry entry Documents JWT signature verification, slug case collisions, the virtualised URL table and the Text to Binary / Hex output cap in both changelogs and READMEs, and updates the JWT blurb and search keywords to mention verification. --- CHANGELOG.md | 8 ++++++++ README.md | 4 ++-- packages/core/CHANGELOG.md | 4 +++- packages/core/README.md | 11 +++++++---- packages/core/package.json | 6 ++++-- src/lib/registry/index.ts | 4 ++-- 6 files changed, 26 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb4a500..95269ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,15 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] +### Added +- JWT Decoder: check the signature against a key you paste: a public key as PEM (PUBLIC KEY, RSA PUBLIC KEY or a certificate), a JWK, a JWKS `{"keys": […]}` picked by the token's `kid`, or the HS secret as text, base64 or base64url. It covers HS, RS, PS and ES at 256/384/512 and EdDSA, and shows "Signature valid" or "Signature invalid" next to the expiry badge with the reason. `alg: none`, an HMAC token checked against a public key (algorithm confusion) and DER-encoded ECDSA signatures are refused with an explanation. The key is never saved. A JWKS URL can't be fetched from this site, so paste the JSON instead. +- Slug Generator: in Filename mode, names that differ only by case are flagged, since Windows and macOS treat them as the same file ("2 names collide on Windows/macOS: lines 3, 7"), and so are exact duplicates. + +### Changed +- URL Encode / Decode: the parameter table now shows every parameter, however many there are, instead of stopping at the first 300. + ### Fixed +- Text to Binary / Hex: a large paste no longer freezes the page. Once the output passes 1 MB, the output pane shows the first 64K characters and says how much is hidden; Copy and the new Download button still give you all of it. - Number Converter: screen readers now announce each permission checkbox by name ("Owner read", "Group write") instead of a bare "r" or "w". ## [0.10.0] — 2026-10-29 diff --git a/README.md b/README.md index ca72283..03a43bd 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,11 @@ Three other things it does that the free sites generally do not: | **[Text Statistics](https://devtools.fadeltd.dev/text-stats)** | Characters, letters, words, sentences, paragraphs, reading time, and word/phrase frequency | | **[HTML to Text](https://devtools.fadeltd.dev/html-text)** | Readable plain text from HTML, keeping paragraphs, lists and tables; scripts, hidden elements and icon ligatures dropped | | **[Timestamp Converter](https://devtools.fadeltd.dev/timestamp)** | Epoch s/ms/µs/ns with auto-detected unit, exact to the nanosecond; ISO 8601 in UTC and any time zone with its real offset; HTTP-date, relative time, weekday and ISO week | -| **[JWT Decoder](https://devtools.fadeltd.dev/jwt)** | Header and payload as JSON, exp/iat/nbf as local and UTC dates, expiry status with clock-skew leeway, JWE header, `alg: none` and malformed-segment warnings, never saved | +| **[JWT Decoder](https://devtools.fadeltd.dev/jwt)** | Header and payload as JSON, exp/iat/nbf as local and UTC dates, expiry status with clock-skew leeway, signature check against a pasted PEM, JWK, JWKS or HS secret (HS/RS/PS/ES/EdDSA, refusing `alg: none` and algorithm confusion), JWE header, malformed-segment warnings, never saved | | **[URL Encode / Decode](https://devtools.fadeltd.dev/url)** | Component, full-URL and form encoding; decode with malformed-escape positions; double-encoding detection; URL parts and query parameter table | | **[Hash Text](https://devtools.fadeltd.dev/hash)** | SHA-1, SHA-256, SHA-384 and SHA-512 at once in hex, HEX or base64; compare against an expected hash; trailing-newline and CRLF warnings | | **[HMAC Signature](https://devtools.fadeltd.dev/hmac)** | GitHub, Stripe and Slack webhook signatures; verify a pasted header, with timestamp tolerance; text, hex or base64 keys; never saved | -| **[Slug Generator](https://devtools.fadeltd.dev/slug)** | URL slugs with accent folding, camelCase splitting and a word-safe max length; cross-platform safe filenames (reserved names, forbidden characters, 255-byte cap); one per line | +| **[Slug Generator](https://devtools.fadeltd.dev/slug)** | URL slugs with accent folding, camelCase splitting and a word-safe max length; cross-platform safe filenames (reserved names, forbidden characters, 255-byte cap, case collisions flagged); one per line | | **[Text to Binary / Hex](https://devtools.fadeltd.dev/text-bytes)** | UTF-8 text ↔ binary, hex, decimal or octal; `0x` / `\x` prefixes and C-string output; tolerant paste parsing with exact error positions; format auto-detect; invalid UTF-8 located, with a labelled fallback | | **[Number Converter](https://devtools.fadeltd.dev/number)** | Bases 2–36 with no precision loss, two's complement, byte sizes in SI vs IEC, chmod octal ↔ symbolic with checkboxes | | **[Unicode Inspector](https://devtools.fadeltd.dev/unicode)** | Every code point with line:column, UTF-8/UTF-16 and category; flags zero-width, bidi (Trojan Source), exotic spaces, tags, lone surrogates; flagged-only view; one-click clean with optional NFC/NFKC | diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index b8f4d26..efe1e8d 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -6,7 +6,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] -Nothing yet. +### Added +- `/jwt`: `verifyJwt(token, key, { secretEncoding })` verifies a compact JWS with WebCrypto and returns `{ status: 'valid' | 'invalid' | 'error', alg, format, keyId?, keyIndex?, reason? }`. The key can be a JWK, a JWKS (chosen by `kid`, or every compatible key tried when there is none), a PEM SPKI, a PKCS#1 key or an X.509 certificate, or an HS secret as text, base64 or base64url. It refuses `alg: none`, algorithm confusion, curve and key-type mismatches and JWK `alg`/`use`/`key_ops` conflicts, recognises DER ECDSA signatures, and never throws. Also `detectKeyFormat` and `parseKey`. +- `/slug`: `safeFilenameLines` also returns `collisions: { exact, caseOnly }`, groups of 1-based line numbers whose cleaned names are identical or equal ignoring case (after NFC), plus `findFilenameCollisions(names)`. ## [0.3.0] — 2026-10-29 diff --git a/packages/core/README.md b/packages/core/README.md index 36f8a1f..a1b9c7f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -56,10 +56,13 @@ if (r.ok) r.formatted.isoZoned // '2023-11-15T03:43:20.000+05:30' ``` ```js -import { decodeJwt } from '@fadeltd/devtools-core/jwt' +import { decodeJwt, verifyJwt } from '@fadeltd/devtools-core/jwt' const jwt = decodeJwt(token, { now: Date.now(), clockSkewSeconds: 60 }) -jwt.status // 'valid' | 'expired' | 'not-yet-valid' | 'no-expiry' | 'unknown' | null — the signature is never verified +jwt.status // 'valid' | 'expired' | 'not-yet-valid' | 'no-expiry' | 'unknown' | null — expiry only + +await verifyJwt(token, publicKeyPem) // { status: 'valid', alg: 'RS256', format: 'pem' } +await verifyJwt(token, jwksJson) // picks the key by the header's kid ``` ```js @@ -94,10 +97,10 @@ clean('a\u00a0b\u200bc').text // 'a bc' | `/text-stats` | Characters (four meanings), words, sentences (abbreviation-aware), paragraphs, reading time, word and phrase frequency | | `/html-text` | Readable text from HTML that keeps paragraphs, lists and tables | | `/timestamp` | Epoch s/ms/µs/ns ↔ ISO 8601 / RFC 3339 on BigInt nanoseconds, IANA zone offsets via `Intl`, HTTP-date, relative time, ISO week | -| `/jwt` | JWS/JWE decoding (never verification), claim dates and expiry status, `alg: none` and malformed-segment warnings | +| `/jwt` | JWS/JWE decoding, claim dates and expiry status, and signature verification against a JWK, JWKS, PEM or HS secret, refusing `alg: none` and algorithm confusion | | `/url` | Component, full-URI and form percent-encoding; decoding with malformed-escape positions; query-string parse and rebuild | | `/hash` | SHA digests, HMAC with text/hex/base64 keys, constant-time compare, GitHub/Stripe/Slack webhook signing and verification with timestamp tolerance | -| `/slug` | URL slugs with transliteration and camelCase splitting; cross-platform safe filenames | +| `/slug` | URL slugs with transliteration and camelCase splitting; cross-platform safe filenames with case-collision detection | | `/text-bytes` | UTF-8 text ↔ binary, hex, decimal or octal, with format auto-detection and invalid-UTF-8 offsets | | `/number` | BigInt base conversion 2–36 with two's complement, SI/IEC byte sizes, Unix permissions and chmod commands | | `/unicode` | Per-code-point inspection, invisible/bidi/tag/space flags with names, and an emoji-safe `clean()` | diff --git a/packages/core/package.json b/packages/core/package.json index 3e31cda..f3baf74 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@fadeltd/devtools-core", "version": "0.3.0", - "description": "The engines behind devtools.fadeltd.dev: list pipelines, JSON errors and fixes, diff, base64, IDs and UUIDs, timestamps, JWT decoding, URL encoding, hashes and HMAC, slugs, text bytes, number bases and Unicode inspection. Pure functions, no DOM, no network.", + "description": "The engines behind devtools.fadeltd.dev: list pipelines, JSON errors and fixes, diff, base64, IDs and UUIDs, timestamps, JWT decoding and verification, URL encoding, hashes and HMAC, slugs, text bytes, number bases and Unicode inspection. Pure functions, no DOM, no network.", "license": "MIT", "type": "module", "sideEffects": false, @@ -129,7 +129,9 @@ "sha256", "hmac", "slugify", - "unicode" + "unicode", + "jwt verify", + "jwks" ], "publishConfig": { "access": "public" diff --git a/src/lib/registry/index.ts b/src/lib/registry/index.ts index 62b2999..3d7603a 100644 --- a/src/lib/registry/index.ts +++ b/src/lib/registry/index.ts @@ -217,8 +217,8 @@ const RAW_TOOLS = [ { slug: 'jwt', title: 'JWT Decoder', - blurb: 'Decode a JSON Web Token’s header and payload, with exp, iat and nbf as real dates and an expiry check.', - keywords: ['jwt', 'json web token', 'decode', 'bearer token', 'jws', 'claims', 'exp', 'jwt.io', 'jwe', 'authorization header'], + blurb: 'Decode a JSON Web Token, see exp, iat and nbf as real dates, and verify its signature against a pasted key.', + keywords: ['jwt', 'json web token', 'decode', 'bearer token', 'jws', 'claims', 'exp', 'jwt.io', 'jwe', 'authorization header', 'verify', 'signature', 'jwks', 'jwk'], category: 'crypto', icon: FileKey2, load: () => import('@/tools/jwt/JwtTool'),