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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/tool_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 3 additions & 1 deletion packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 7 additions & 4 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()` |
Expand Down
6 changes: 4 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -129,7 +129,9 @@
"sha256",
"hmac",
"slugify",
"unicode"
"unicode",
"jwt verify",
"jwks"
],
"publishConfig": {
"access": "public"
Expand Down
158 changes: 158 additions & 0 deletions packages/core/src/jwt/der.ts
Original file line number Diff line number Diff line change
@@ -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)
}
2 changes: 2 additions & 0 deletions packages/core/src/jwt/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from './claims.ts'
export * from './example.ts'
export * from './keys.ts'
export * from './parse.ts'
export * from './verify.ts'
Loading
Loading