diff --git a/.github/ISSUE_TEMPLATE/tool_request.yml b/.github/ISSUE_TEMPLATE/tool_request.yml index b918e2b..69b338d 100644 --- a/.github/ISSUE_TEMPLATE/tool_request.yml +++ b/.github/ISSUE_TEMPLATE/tool_request.yml @@ -7,7 +7,7 @@ body: value: | This project makes one promise: **nothing you paste ever leaves the browser.** So some requests are a standing no, however useful they are: - - anything that needs a server, a fetch proxy, or a third-party request (e.g. "analyse this URL") + - anything that needs a server, a fetch proxy, or a third-party request (e.g. "analyse this URL", or "fetch the JWKS from my identity provider" in the JWT tool; paste the JWKS instead) - libraries that need `eval` or `new Function`, which the Content Security Policy forbids - accounts, sync, or storing your data anywhere but your own browser diff --git a/CHANGELOG.md b/CHANGELOG.md index c335b35..95269ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ## [Unreleased] -Nothing yet. +### 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/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/packages/core/src/slug/lines.test.ts b/packages/core/src/slug/lines.test.ts index b03e663..72438e8 100644 --- a/packages/core/src/slug/lines.test.ts +++ b/packages/core/src/slug/lines.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { safeFilenameLines, slugifyLines } from './lines.ts' +import { findFilenameCollisions, safeFilenameLines, slugifyLines } from './lines.ts' describe('slugifyLines', () => { it('slugs each line', () => { @@ -70,3 +70,55 @@ describe('safeFilenameLines', () => { expect(r.output.split('\n')[1]).toBe('untitled') }) }) + +describe('filename collisions', () => { + it('reports names that differ only by case as 1-based line groups', () => { + const r = safeFilenameLines('a.txt\nReport.pdf\nb.txt\n\nc.txt\nd.txt\nREPORT.pdf') + expect(r.collisions).toEqual({ exact: [], caseOnly: [[2, 7]] }) + }) + + it('reports exact duplicates separately', () => { + const r = safeFilenameLines('x\ny\nx\ny\ny') + expect(r.collisions).toEqual({ exact: [[1, 3], [2, 4, 5]], caseOnly: [] }) + }) + + it('lists an exact repeat inside a case group in both', () => { + const r = safeFilenameLines('a\nA\na') + expect(r.collisions.exact).toEqual([[1, 3]]) + expect(r.collisions.caseOnly).toEqual([[1, 2, 3]]) + }) + + it('compares the cleaned names, not the input', () => { + // Both become "a_b.txt" once ":" and "?" are replaced. + const r = safeFilenameLines('a:b.txt\na?b.txt\nA_B.TXT') + expect(r.output).toBe('a_b.txt\na_b.txt\nA_B.TXT') + expect(r.collisions).toEqual({ exact: [[1, 2]], caseOnly: [[1, 2, 3]] }) + }) + + it('counts fallback names: two lines with nothing usable are both untitled', () => { + expect(safeFilenameLines('...\n....').collisions.exact).toEqual([[1, 2]]) + }) + + it('ignores blank lines but keeps their line numbers', () => { + expect(safeFilenameLines('a\n\n\nA').collisions.caseOnly).toEqual([[1, 4]]) + }) + + it('folds non-ASCII case', () => { + expect(findFilenameCollisions(['Ünïcode', 'ünÏCODE']).caseOnly).toEqual([[1, 2]]) + }) + + it('treats composed and decomposed forms as one name, as APFS does', () => { + const composed = 'caf\u00e9' + const decomposed = 'cafe\u0301' + expect(findFilenameCollisions([composed, decomposed])).toEqual({ exact: [], caseOnly: [[1, 2]] }) + }) + + it('does not merge ß with ss: filesystems use simple case mapping', () => { + expect(findFilenameCollisions(['straße', 'STRASSE']).caseOnly).toEqual([]) + }) + + it('reports nothing for distinct names', () => { + expect(safeFilenameLines('a\nb\nc').collisions).toEqual({ exact: [], caseOnly: [] }) + expect(safeFilenameLines('').collisions).toEqual({ exact: [], caseOnly: [] }) + }) +}) diff --git a/packages/core/src/slug/lines.ts b/packages/core/src/slug/lines.ts index 7b5d32d..95cbe4d 100644 --- a/packages/core/src/slug/lines.ts +++ b/packages/core/src/slug/lines.ts @@ -22,9 +22,67 @@ export interface FilenameLinesSummary { fallback: number } +/** Groups of 1-based line numbers, each group in line order, groups ordered by their first line. */ +export type LineGroups = number[][] + +export interface FilenameCollisions { + /** Lines whose output names are identical: they collide on every filesystem. */ + exact: LineGroups + /** + * Lines whose output names differ, but only by case: separate files on Linux, + * the same file on Windows and on a default macOS volume. A group holds every + * line with that name, so an exact repeat inside it is listed in both. + */ + caseOnly: LineGroups +} + export interface FilenameLinesResult { output: string summary: FilenameLinesSummary + collisions: FilenameCollisions +} + +/** + * The key two names share when a case-insensitive filesystem treats them as + * one. NFC first because APFS also ignores normalisation, so a composed and a + * decomposed é are one file on a Mac. Then toLowerCase rather than full Unicode + * case folding: NTFS and APFS compare with a one-to-one case mapping, and full + * folding would wrongly merge "straße" with "strasse", which both keep apart. + * JavaScript has no simple-fold primitive, and the cases where toLowerCase and + * simple folding disagree (Greek final sigma, dotted capital İ) are rare enough + * that a missed warning there costs less than shipping a fold table. + */ +const caseKey = (name: string) => name.normalize('NFC').toLowerCase() + +/** + * Groups `names` (one per line, `null` for a blank line) by exact name and by + * case-insensitive key. Exported for tests and for callers that already have + * the names; `safeFilenameLines` uses it on its own output. + */ +export function findFilenameCollisions(names: readonly (string | null)[]): FilenameCollisions { + const exact = new Map() + const folded = new Map() + names.forEach((name, i) => { + if (name === null) return + const line = i + 1 + const same = exact.get(name) + if (same) same.push(line) + else exact.set(name, [line]) + + const key = caseKey(name) + const group = folded.get(key) + if (group) { + group.lines.push(line) + if (name !== group.first) group.mixed = true + } else { + folded.set(key, { lines: [line], first: name, mixed: false }) + } + }) + // Map iteration is insertion order, so groups come out ordered by first line. + return { + exact: [...exact.values()].filter((g) => g.length > 1), + caseOnly: [...folded.values()].filter((g) => g.mixed).map((g) => g.lines), + } } /** @@ -60,8 +118,12 @@ export function safeFilenameLines( truncated: 0, fallback: 0, } + const names: (string | null)[] = [] const out = doc.lines.map((line) => { - if (isBlank(line)) return '' + if (isBlank(line)) { + names.push(null) + return '' + } summary.lines++ const { name, changed, changes } = safeFilename(line, opts) if (changed) summary.changed++ @@ -70,7 +132,8 @@ export function safeFilenameLines( if (changes.reserved) summary.reserved++ if (changes.truncated) summary.truncated++ if (changes.fallback) summary.fallback++ + names.push(name) return name }) - return { output: serializeLines(withLines(doc, out)), summary } + return { output: serializeLines(withLines(doc, out)), summary, collisions: findFilenameCollisions(names) } } diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx index 6058b78..b251d6c 100644 --- a/src/components/ui/Select.tsx +++ b/src/components/ui/Select.tsx @@ -31,16 +31,20 @@ export function Toggle({ checked, onChange, children, + 'aria-label': ariaLabel, }: { checked: boolean onChange: (v: boolean) => void children: ReactNode + /** For a terse visible label (a grid of "r w x"), the name a screen reader should hear instead. */ + 'aria-label'?: string }) { return (