From 3f6c5c673082d0421944e1537c5a00768988fe4f Mon Sep 17 00:00:00 2001 From: baixiangcpp Date: Sat, 27 Jun 2026 14:47:00 +0800 Subject: [PATCH] Fix core tool audit follow-ups --- src/core/i18n/translations/de.json | 8 +- src/core/i18n/translations/en.json | 8 +- src/core/i18n/translations/fr.json | 8 +- src/core/i18n/translations/ja.json | 8 +- src/core/i18n/translations/ko.json | 8 +- src/core/i18n/translations/zh-CN.json | 8 +- src/core/i18n/translations/zh-TW.json | 8 +- src/core/jwt/base64url.ts | 40 ++++++++++ src/features/tools/crontab-generator/logic.ts | 80 +++++++++++++++++++ src/features/tools/crontab-generator/page.tsx | 68 +++++++++------- .../tools/csv-json-converter/logic.ts | 11 ++- src/features/tools/json-formatter/logic.ts | 8 +- src/features/tools/json-formatter/page.tsx | 28 +++---- src/features/tools/jwt-decoder/utils.ts | 17 +--- src/features/tools/jwt-verifier/logic.ts | 67 +++++++++++++--- src/features/tools/jwt-verifier/page.tsx | 67 +++------------- src/features/tools/jwt-workbench/logic.ts | 28 +------ .../tools/oauth-jwks-workbench/logic.ts | 17 +--- .../tools/public-key-jwk-helper/logic.ts | 5 +- tests/component/jwt-verifier-page.test.tsx | 29 +++++++ tests/unit/crontab-generator-logic.test.ts | 30 +++++++ tests/unit/csv-json-task-logic.test.ts | 19 +++++ tests/unit/json-formatter-tree-logic.test.ts | 7 +- tests/unit/jwt-verifier-claims.test.ts | 28 ++++++- 24 files changed, 419 insertions(+), 186 deletions(-) create mode 100644 src/core/jwt/base64url.ts create mode 100644 src/features/tools/crontab-generator/logic.ts create mode 100644 tests/unit/crontab-generator-logic.test.ts diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index ddf4b0f4..5dba428a 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -1217,7 +1217,9 @@ "preset_every_sunday": "Jeden Sonntag", "preset_every_monday_9am": "Jeden Montag um 9 Uhr", "preset_every_month_first": "Jeden Monat am 1.", - "invalid_expression": "Ungültiger Cron-Ausdruck." + "invalid_expression": "Ungültiger Cron-Ausdruck.", + "field_seconds": "Sekunden", + "hint_seconds": "0-59 Sek." }, "user_agent_parser": { "title": "User-Agent-Parser", @@ -1524,7 +1526,9 @@ "claim_iat": "iat (Ausgestellt am)", "claim_iss": "iss (Aussteller)", "claim_sub": "sub (Betreff)", - "claim_aud": "aud (Zielgruppe)" + "claim_aud": "aud (Zielgruppe)", + "result_unsupported": "Nicht unterstuetzter JWT-Algorithmus ({alg}). Nutze JWT Workbench oder den JWKS-Verifikationsablauf fuer RS*-, ES*-, PS*- oder EdDSA-Tokens.", + "result_none_warning": "Dieses Token deklariert alg: none. Behandle es als unsigniert; dieser Verifier markiert es nicht per Signatur als gueltig oder ungueltig." }, "jwt_workbench": { "title": "JWT-Workbench", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index 636f27b3..13b866b9 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -1245,7 +1245,9 @@ "preset_every_sunday": "Every Sunday", "preset_every_monday_9am": "Every Monday at 9AM", "preset_every_month_first": "Every Month on the 1st", - "invalid_expression": "Invalid cron expression." + "invalid_expression": "Invalid cron expression.", + "field_seconds": "Seconds", + "hint_seconds": "0-59 seconds" }, "user_agent_parser": { "title": "User-Agent Parser", @@ -1552,7 +1554,9 @@ "claim_iat": "iat (Issued At)", "claim_iss": "iss (Issuer)", "claim_sub": "sub (Subject)", - "claim_aud": "aud (Audience)" + "claim_aud": "aud (Audience)", + "result_unsupported": "Unsupported JWT algorithm ({alg}). Use JWT Workbench or the JWKS verification workflow for RS*, ES*, PS*, or EdDSA tokens.", + "result_none_warning": "This token declares alg: none. Treat it as unsigned; this verifier will not mark it valid or invalid by signature." }, "jwt_workbench": { "title": "JWT Workbench", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index d21788cc..f5ee46ec 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -1217,7 +1217,9 @@ "preset_every_sunday": "Chaque dimanche", "preset_every_monday_9am": "Chaque lundi à 9 h", "preset_every_month_first": "Le 1er de chaque mois", - "invalid_expression": "Expression cron invalide." + "invalid_expression": "Expression cron invalide.", + "field_seconds": "Secondes", + "hint_seconds": "0-59 s" }, "user_agent_parser": { "title": "Analyseur User-Agent", @@ -1524,7 +1526,9 @@ "claim_iat": "iat (Émis à)", "claim_iss": "iss (Émetteur)", "claim_sub": "sub (Sujet)", - "claim_aud": "aud (Destinataire)" + "claim_aud": "aud (Destinataire)", + "result_unsupported": "Algorithme JWT non pris en charge ({alg}). Utilisez JWT Workbench ou le flux de verification JWKS pour les tokens RS*, ES*, PS* ou EdDSA.", + "result_none_warning": "Ce token declare alg: none. Traitez-le comme non signe ; ce verificateur ne le marque pas valide ou invalide par signature." }, "jwt_workbench": { "title": "Atelier JWT", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index 1cbc0c35..1daf20c6 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -1217,7 +1217,9 @@ "preset_every_sunday": "毎週日曜", "preset_every_monday_9am": "毎週月曜 9:00", "preset_every_month_first": "毎月 1 日", - "invalid_expression": "無効な cron 式です。" + "invalid_expression": "無効な cron 式です。", + "field_seconds": "?", + "hint_seconds": "0-59 sec" }, "user_agent_parser": { "title": "User-Agent 解析", @@ -1524,7 +1526,9 @@ "claim_iat": "iat(発行時刻)", "claim_iss": "iss(発行者)", "claim_sub": "sub(件名)", - "claim_aud": "aud(対象者)" + "claim_aud": "aud(対象者)", + "result_unsupported": "Mitaio JWT algorithm ({alg}). Use JWT Workbench or the JWKS verification workflow for RS*, ES*, PS*, or EdDSA tokens.", + "result_none_warning": "Kono token declares alg: none. Treat it as unsigned; this verifier will not mark it valid or invalid by signature." }, "jwt_workbench": { "title": "JWTワークベンチ", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index ffcd8994..4de0058b 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -1217,7 +1217,9 @@ "preset_every_sunday": "매주 일요일", "preset_every_monday_9am": "매주 월요일 오전 9시", "preset_every_month_first": "매월 1일", - "invalid_expression": "유효하지 않은 cron 표현식입니다." + "invalid_expression": "유효하지 않은 cron 표현식입니다.", + "field_seconds": "?", + "hint_seconds": "0-59 sec" }, "user_agent_parser": { "title": "User-Agent 파서", @@ -1524,7 +1526,9 @@ "claim_iat": "iat(발급 시각)", "claim_iss": "iss(발급자)", "claim_sub": "sub(주체)", - "claim_aud": "aud(대상자)" + "claim_aud": "aud(대상자)", + "result_unsupported": "Jiwonhaji anneun JWT algorithm ({alg}). Use JWT Workbench or the JWKS verification workflow for RS*, ES*, PS*, or EdDSA tokens.", + "result_none_warning": "I token declares alg: none. Treat it as unsigned; this verifier will not mark it valid or invalid by signature." }, "jwt_workbench": { "title": "JWT 워크벤치", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index 01bf3f4c..956abf33 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -1217,7 +1217,9 @@ "preset_every_sunday": "每周日", "preset_every_monday_9am": "每周一 9 点", "preset_every_month_first": "每月 1 日", - "invalid_expression": "无效的 cron 表达式。" + "invalid_expression": "无效的 cron 表达式。", + "field_seconds": "?", + "hint_seconds": "0-59 sec" }, "user_agent_parser": { "title": "User-Agent 解析器", @@ -1524,7 +1526,9 @@ "claim_iat": "iat(签发时间)", "claim_iss": "iss(签发方)", "claim_sub": "sub(主题)", - "claim_aud": "aud(受众)" + "claim_aud": "aud(受众)", + "result_unsupported": "Bu zhichi JWT algorithm ({alg}). Use JWT Workbench or the JWKS verification workflow for RS*, ES*, PS*, or EdDSA tokens.", + "result_none_warning": "Ci token declares alg: none. Treat it as unsigned; this verifier will not mark it valid or invalid by signature." }, "jwt_workbench": { "title": "JWT 工作台", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index 1e377b68..e23ac271 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -1217,7 +1217,9 @@ "preset_every_sunday": "每週日", "preset_every_monday_9am": "每週一 9 點", "preset_every_month_first": "每月 1 日", - "invalid_expression": "無效的 cron 表達式。" + "invalid_expression": "無效的 cron 表達式。", + "field_seconds": "?", + "hint_seconds": "0-59 sec" }, "user_agent_parser": { "title": "User-Agent 解析器", @@ -1524,7 +1526,9 @@ "claim_iat": "iat(簽發時間)", "claim_iss": "iss(簽發者)", "claim_sub": "sub(主體)", - "claim_aud": "aud(受眾)" + "claim_aud": "aud(受眾)", + "result_unsupported": "Bu zhiyuan JWT algorithm ({alg}). Use JWT Workbench or the JWKS verification workflow for RS*, ES*, PS*, or EdDSA tokens.", + "result_none_warning": "Ci token declares alg: none. Treat it as unsigned; this verifier will not mark it valid or invalid by signature." }, "jwt_workbench": { "title": "JWT 工作台", diff --git a/src/core/jwt/base64url.ts b/src/core/jwt/base64url.ts new file mode 100644 index 00000000..c2f03cb6 --- /dev/null +++ b/src/core/jwt/base64url.ts @@ -0,0 +1,40 @@ +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/ + +export function bytesToBase64Url(bytes: Uint8Array): string { + let binary = "" + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "") +} + +export function base64UrlToBytes(input: string): Uint8Array { + if (!BASE64URL_PATTERN.test(input) || input.length % 4 === 1) { + throw new Error("invalid_base64url") + } + + const base64 = input + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(Math.ceil(input.length / 4) * 4, "=") + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + return bytes +} + +export function textToBase64Url(value: string): string { + return bytesToBase64Url(new TextEncoder().encode(value)) +} + +export function base64UrlToText(input: string): string { + return new TextDecoder().decode(base64UrlToBytes(input)) +} + +export function jsonToBase64Url(value: unknown): string { + return textToBase64Url(JSON.stringify(value)) +} + +export function base64UrlToJson(input: string): T { + return JSON.parse(base64UrlToText(input)) as T +} diff --git a/src/features/tools/crontab-generator/logic.ts b/src/features/tools/crontab-generator/logic.ts new file mode 100644 index 00000000..c82b3e7a --- /dev/null +++ b/src/features/tools/crontab-generator/logic.ts @@ -0,0 +1,80 @@ +export type CronFieldKey = "seconds" | "minute" | "hour" | "day_month" | "month" | "day_week" + +export type CronFieldDefinition = { + key: CronFieldKey + min: number + max: number +} + +export type CronValidationResult = + | { ok: true; parts: string[]; hasSeconds: boolean; normalized: string } + | { ok: false; error: string } + +export const FIVE_FIELD_CRON_DEFINITIONS: CronFieldDefinition[] = [ + { key: "minute", min: 0, max: 59 }, + { key: "hour", min: 0, max: 23 }, + { key: "day_month", min: 1, max: 31 }, + { key: "month", min: 1, max: 12 }, + { key: "day_week", min: 0, max: 6 }, +] + +export const SIX_FIELD_CRON_DEFINITIONS: CronFieldDefinition[] = [ + { key: "seconds", min: 0, max: 59 }, + ...FIVE_FIELD_CRON_DEFINITIONS, +] + +export function parseCronParts(expression: string): string[] { + const trimmed = expression.trim() + return trimmed ? trimmed.split(/\s+/) : [] +} + +export function normalizeCronExpression(expression: string): string { + return parseCronParts(expression).join(" ") +} + +export function getCronFieldDefinitions(partCount: number): CronFieldDefinition[] { + return partCount === 6 ? SIX_FIELD_CRON_DEFINITIONS : FIVE_FIELD_CRON_DEFINITIONS +} + +function getFieldErrorLabel(field: CronFieldDefinition): string { + return field.key.replace("_", " ") +} + +function validateNumericTokens(field: string, definition: CronFieldDefinition): string | null { + const numericTokens = field.match(/\d+/g) ?? [] + for (const token of numericTokens) { + const value = Number(token) + if (!Number.isInteger(value) || value < definition.min || value > definition.max) { + return `Invalid cron ${getFieldErrorLabel(definition)} field. Expected ${definition.min}-${definition.max}, got ${token}.` + } + } + return null +} + +export function validateCronExpression(expression: string, genericError: string): CronValidationResult { + const parts = parseCronParts(expression) + if (parts.length !== 5 && parts.length !== 6) { + return { ok: false, error: genericError } + } + + const definitions = getCronFieldDefinitions(parts.length) + for (let index = 0; index < definitions.length; index += 1) { + const fieldError = validateNumericTokens(parts[index] ?? "", definitions[index]) + if (fieldError) return { ok: false, error: fieldError } + } + + return { + ok: true, + parts, + hasSeconds: parts.length === 6, + normalized: parts.join(" "), + } +} + +export function updateCronPart(expression: string, index: number, value: string): string { + const parts = parseCronParts(expression) + const targetLength = parts.length === 6 ? 6 : 5 + while (parts.length < targetLength) parts.push("*") + parts[index] = value.trim() || "*" + return parts.slice(0, targetLength).join(" ") +} diff --git a/src/features/tools/crontab-generator/page.tsx b/src/features/tools/crontab-generator/page.tsx index 4fba13dc..cf77cbaf 100644 --- a/src/features/tools/crontab-generator/page.tsx +++ b/src/features/tools/crontab-generator/page.tsx @@ -9,6 +9,13 @@ import { Input } from "@/components/ui/input" import { safeClipboardWrite } from "@/core/clipboard/clipboard" import type { Locale } from "@/core/i18n/i18n" import cronstrue from "cronstrue/i18n.js" +import { + getCronFieldDefinitions, + parseCronParts, + updateCronPart, + validateCronExpression, + type CronFieldKey, +} from "./logic" const CRONSTRUE_LOCALE_BY_LANG: Record = { en: "en", @@ -36,13 +43,24 @@ export function CrontabGeneratorPage() { const [description, setDescription] = React.useState("") const [error, setError] = React.useState(null) - // Parse the cron string to parts - const parts = cronString.split(" ") - const pMinute = parts[0] || "" - const pHour = parts[1] || "" - const pDom = parts[2] || "" - const pMonth = parts[3] || "" - const pDow = parts[4] || "" + const parts = parseCronParts(cronString) + const fieldDefinitions = getCronFieldDefinitions(parts.length) + const fieldLabelByKey: Record = { + seconds: toolT.field_seconds, + minute: toolT.field_minute, + hour: toolT.field_hour, + day_month: toolT.field_day_month, + month: toolT.field_month, + day_week: toolT.field_day_week, + } + const fieldHintByKey: Record = { + seconds: toolT.hint_seconds, + minute: toolT.hint_minute, + hour: toolT.hint_hour, + day_month: toolT.hint_day_month, + month: toolT.hint_month, + day_week: toolT.hint_day_week, + } React.useEffect(() => { if (!cronString.trim()) { @@ -52,21 +70,18 @@ export function CrontabGeneratorPage() { } try { - // Validate length roughly - const parts = cronString.trim().split(/\s+/) - if (parts.length < 5 || parts.length > 6) { - throw new Error(toolT.invalid_expression) - } + const validation = validateCronExpression(cronString, toolT.invalid_expression) + if (!validation.ok) throw new Error(validation.error) - const humanReadable = cronstrue.toString(cronString, { + const humanReadable = cronstrue.toString(validation.normalized, { use24HourTimeFormat: true, locale: cronLocale, }) setDescription(humanReadable) setError(null) - } catch { + } catch (err) { setDescription("") - setError(toolT.invalid_expression) + setError(err instanceof Error ? err.message : toolT.invalid_expression) } }, [cronLocale, cronString, toolT.invalid_expression]) @@ -87,12 +102,7 @@ export function CrontabGeneratorPage() { } const updatePart = (index: number, val: string) => { - const p = cronString.split(" ") - // Fill array if too short - while (p.length < 5) p.push("*") - - p[index] = val || "*" - setCronString(p.slice(0, 5).join(" ")) + setCronString(updateCronPart(cronString, index, val)) } return ( @@ -139,12 +149,16 @@ export function CrontabGeneratorPage() { {/* Editor Grid */} -
- updatePart(0, v)} hint={toolT.hint_minute} /> - updatePart(1, v)} hint={toolT.hint_hour} /> - updatePart(2, v)} hint={toolT.hint_day_month} /> - updatePart(3, v)} hint={toolT.hint_month} /> - updatePart(4, v)} hint={toolT.hint_day_week} /> +
+ {fieldDefinitions.map((field, index) => ( + updatePart(index, v)} + hint={fieldHintByKey[field.key]} + /> + ))}
diff --git a/src/features/tools/csv-json-converter/logic.ts b/src/features/tools/csv-json-converter/logic.ts index 1f2504ab..e8411eda 100644 --- a/src/features/tools/csv-json-converter/logic.ts +++ b/src/features/tools/csv-json-converter/logic.ts @@ -113,12 +113,19 @@ function flattenObject(obj: Record, prefix = ""): Record, fullKey)) } else { - result[fullKey] = value === null || value === undefined ? "" : String(value) + result[fullKey] = value === null || value === undefined ? "" : serializeCsvCell(value) } } return result } +function serializeCsvCell(value: unknown): string { + if (Array.isArray(value) || (value !== null && typeof value === "object")) { + return JSON.stringify(value) + } + return String(value) +} + export function jsonToCsv(json: string, delimiter: string, includeHeader: boolean): string { const parsed = JSON.parse(json) const effectiveDelimiter = delimiter === "auto" ? "," : delimiter @@ -149,7 +156,7 @@ export function jsonToCsv(json: string, delimiter: string, includeHeader: boolea for (const row of parsed) { if (Array.isArray(row)) { lines.push( - row.map((v: unknown) => escapeCSVField(String(v ?? ""), effectiveDelimiter)).join(effectiveDelimiter) + row.map((v: unknown) => escapeCSVField(v === null || v === undefined ? "" : serializeCsvCell(v), effectiveDelimiter)).join(effectiveDelimiter) ) } else { lines.push(escapeCSVField(String(row), effectiveDelimiter)) diff --git a/src/features/tools/json-formatter/logic.ts b/src/features/tools/json-formatter/logic.ts index 906bb093..76f0259c 100644 --- a/src/features/tools/json-formatter/logic.ts +++ b/src/features/tools/json-formatter/logic.ts @@ -1,6 +1,6 @@ import type { JsonPath, JsonValue } from "./types" -export function isJsonObject(value: JsonValue): value is { [key: string]: JsonValue } { +export function isJsonObject(value: unknown): value is { [key: string]: JsonValue } { return typeof value === "object" && value !== null && !Array.isArray(value) } @@ -9,18 +9,20 @@ export function pathKey(path: JsonPath): string { return path.map((part) => String(part)).join("__") } -export function getValueAtPath(root: JsonValue, path: JsonPath): JsonValue { +export function getValueAtPath(root: JsonValue, path: JsonPath): JsonValue | undefined { let current: JsonValue = root for (const part of path) { if (Array.isArray(current) && typeof part === "number") { + if (part < 0 || part >= current.length) return undefined current = current[part] continue } if (isJsonObject(current) && typeof part === "string") { + if (!Object.prototype.hasOwnProperty.call(current, part)) return undefined current = current[part] continue } - return root + return undefined } return current } diff --git a/src/features/tools/json-formatter/page.tsx b/src/features/tools/json-formatter/page.tsx index 6ea62d6d..c18fb08b 100644 --- a/src/features/tools/json-formatter/page.tsx +++ b/src/features/tools/json-formatter/page.tsx @@ -472,14 +472,14 @@ export function JsonFormatterPage() { if (treeData === null) return const node = getValueAtPath(treeData, path) - if (Array.isArray(node)) { - const nextNode = [...node, null] - applyTreeValue(updateValueAtPath(treeData, path, nextNode)) - toggleExpand(path) - return - } - - if (isJsonObject(node)) { + if (Array.isArray(node)) { + const nextNode = [...node, null] + applyTreeValue(updateValueAtPath(treeData, path, nextNode)) + toggleExpand(path) + return + } + + if (isJsonObject(node)) { setTreeDialog({ type: "add_key", path, @@ -530,12 +530,12 @@ export function JsonFormatterPage() { } if (treeDialog.type === "add_key") { - const nextKey = treeDialog.draft.trim() - if (!nextKey) return - const node = getValueAtPath(treeData, treeDialog.path) - if (!isJsonObject(node)) { - closeTreeDialog() - return + const nextKey = treeDialog.draft.trim() + if (!nextKey) return + const node = getValueAtPath(treeData, treeDialog.path) + if (!isJsonObject(node)) { + closeTreeDialog() + return } if (Object.prototype.hasOwnProperty.call(node, nextKey)) { toast.error(text("key_exists")) diff --git a/src/features/tools/jwt-decoder/utils.ts b/src/features/tools/jwt-decoder/utils.ts index 8a03b24e..9f57f1e1 100644 --- a/src/features/tools/jwt-decoder/utils.ts +++ b/src/features/tools/jwt-decoder/utils.ts @@ -1,3 +1,5 @@ +import { base64UrlToText } from "@/core/jwt/base64url" + export type JwtJsonObject = Record export type JwtDecodeErrorCode = @@ -39,7 +41,6 @@ export type JwtDecodeResult = { semantics: JwtSemanticSummary } -const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/ const TIMESTAMP_MIN_SECONDS = 0 const TIMESTAMP_MAX_SECONDS = 253_402_300_799 @@ -58,22 +59,12 @@ function isJsonObject(value: unknown): value is JwtJsonObject { } function decodeBase64UrlText(segment: string): string { - if (!segment || !BASE64URL_PATTERN.test(segment) || segment.length % 4 === 1) { + if (!segment) { throw new JwtDecodeError("invalid_base64url") } - const base64 = segment - .replace(/-/g, "+") - .replace(/_/g, "/") - .padEnd(Math.ceil(segment.length / 4) * 4, "=") - try { - const binary = atob(base64) - const bytes = new Uint8Array(binary.length) - for (let index = 0; index < binary.length; index += 1) { - bytes[index] = binary.charCodeAt(index) - } - return new TextDecoder().decode(bytes) + return base64UrlToText(segment) } catch { throw new JwtDecodeError("invalid_base64url") } diff --git a/src/features/tools/jwt-verifier/logic.ts b/src/features/tools/jwt-verifier/logic.ts index e4d7f5a7..817de9cf 100644 --- a/src/features/tools/jwt-verifier/logic.ts +++ b/src/features/tools/jwt-verifier/logic.ts @@ -1,3 +1,5 @@ +import { base64UrlToBytes, base64UrlToJson, bytesToBase64Url } from "@/core/jwt/base64url" + export type JwtClaimLabels = { exp: string nbf: string @@ -7,6 +9,14 @@ export type JwtClaimLabels = { aud: string } +export type SupportedHmacJwtAlgorithm = "HS256" | "HS384" | "HS512" + +export type JwtSignatureVerificationResult = + | { status: "valid"; algorithm: SupportedHmacJwtAlgorithm } + | { status: "invalid"; algorithm: SupportedHmacJwtAlgorithm } + | { status: "unsupported"; algorithm: string } + | { status: "unsigned"; algorithm: "none" } + export type JwtClaimCheck = { label: string status: "valid" | "invalid" | "info" @@ -17,25 +27,18 @@ const MIN_UNIX_SECONDS = 0 const MAX_UNIX_SECONDS = 253_402_300_799 // 9999-12-31T23:59:59Z export function base64UrlDecode(str: string): Uint8Array { - const padded = str.replace(/-/g, "+").replace(/_/g, "/") - const binary = atob(padded) - const bytes = new Uint8Array(binary.length) - for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) - return bytes + return base64UrlToBytes(str) } export function base64UrlEncode(bytes: Uint8Array): string { - let binary = "" - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") + return bytesToBase64Url(bytes) } export function decodePayload(token: string): Record | null { try { const parts = token.split(".") if (parts.length !== 3) return null - const payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[1]))) - return payload + return base64UrlToJson>(parts[1]) } catch { return null } } @@ -43,10 +46,52 @@ export function decodeHeader(token: string): Record | null { try { const parts = token.split(".") if (parts.length !== 3) return null - return JSON.parse(new TextDecoder().decode(base64UrlDecode(parts[0]))) + return base64UrlToJson>(parts[0]) } catch { return null } } +function isSupportedHmacAlgorithm(algorithm: string): algorithm is SupportedHmacJwtAlgorithm { + return algorithm === "HS256" || algorithm === "HS384" || algorithm === "HS512" +} + +function hashForHmacAlgorithm(algorithm: SupportedHmacJwtAlgorithm): "SHA-256" | "SHA-384" | "SHA-512" { + if (algorithm === "HS384") return "SHA-384" + if (algorithm === "HS512") return "SHA-512" + return "SHA-256" +} + +export function classifyJwtVerificationAlgorithm(algorithm: string): "hmac" | "unsupported" | "unsigned" { + if (algorithm.toLowerCase() === "none") return "unsigned" + if (isSupportedHmacAlgorithm(algorithm)) return "hmac" + return "unsupported" +} + +async function verifyHmacJwt(token: string, secret: string, algorithm: SupportedHmacJwtAlgorithm): Promise { + const parts = token.split(".") + if (parts.length !== 3) return false + const signingInput = `${parts[0]}.${parts[1]}` + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: hashForHmacAlgorithm(algorithm) }, + false, + ["sign"], + ) + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signingInput)) + return bytesToBase64Url(new Uint8Array(signature)) === parts[2] +} + +export async function verifyJwtSignature(token: string, secret: string, algorithm: string): Promise { + const classification = classifyJwtVerificationAlgorithm(algorithm) + if (classification === "unsigned") return { status: "unsigned", algorithm: "none" } + if (classification === "unsupported") return { status: "unsupported", algorithm } + if (!secret.trim()) return null + if (!isSupportedHmacAlgorithm(algorithm)) return { status: "unsupported", algorithm } + + const valid = await verifyHmacJwt(token, secret, algorithm) + return { status: valid ? "valid" : "invalid", algorithm } +} + export function parseUnixTimestampClaim(value: unknown): { ok: true; seconds: number; iso: string } | { ok: false; error: string } { if (typeof value === "boolean" || value === null || Array.isArray(value) || typeof value === "object") { return { ok: false, error: `invalid timestamp: ${String(value)}` } diff --git a/src/features/tools/jwt-verifier/page.tsx b/src/features/tools/jwt-verifier/page.tsx index 0a2f97c7..2e78baa7 100644 --- a/src/features/tools/jwt-verifier/page.tsx +++ b/src/features/tools/jwt-verifier/page.tsx @@ -11,10 +11,11 @@ import { SensitiveInputWarning } from "@/features/tool-shell/sensitive-input-war import { JwtSecretField } from "@/features/tools/jwt-workbench/jwt-secret-field" import { safeClipboardWrite } from "@/core/clipboard/clipboard" import { - base64UrlEncode, checkClaims, decodeHeader, decodePayload, + verifyJwtSignature, + type JwtSignatureVerificationResult, } from "./logic" const ICON_BUTTON_CLASS = @@ -29,40 +30,6 @@ async function loadToast() { // ─── JWT Verification Engine (HMAC-SHA256 only, client-side) ──────────────── -async function verifyHS256(token: string, secret: string): Promise { - const parts = token.split(".") - if (parts.length !== 3) return false - const signingInput = `${parts[0]}.${parts[1]}` - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"] - ) - const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signingInput)) - const expected = base64UrlEncode(new Uint8Array(sig)) - return expected === parts[2] -} - -async function verifyHS384(token: string, secret: string): Promise { - const parts = token.split(".") - if (parts.length !== 3) return false - const signingInput = `${parts[0]}.${parts[1]}` - const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-384" }, false, ["sign"]) - const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signingInput)) - return base64UrlEncode(new Uint8Array(sig)) === parts[2] -} - -async function verifyHS512(token: string, secret: string): Promise { - const parts = token.split(".") - if (parts.length !== 3) return false - const signingInput = `${parts[0]}.${parts[1]}` - const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-512" }, false, ["sign"]) - const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signingInput)) - return base64UrlEncode(new Uint8Array(sig)) === parts[2] -} - export function JwtVerifierPage() { const { t, lang } = useLang() const toolT = t.tools["jwt_verifier"] as Record @@ -90,8 +57,7 @@ export function JwtVerifierPage() { const [token, setToken] = React.useState("") const [secret, setSecret] = React.useState("") const [secretVisible, setSecretVisible] = React.useState(false) - const [verifyResult, setVerifyResult] = React.useState<"valid" | "invalid" | null>(null) - const [algorithm, setAlgorithm] = React.useState("") + const [verifyResult, setVerifyResult] = React.useState(null) const [header, setHeader] = React.useState | null>(null) const [payload, setPayload] = React.useState | null>(null) const [claims, setClaims] = React.useState<{ label: string; status: string; value: string }[]>([]) @@ -104,21 +70,10 @@ export function JwtVerifierPage() { setHeader(h) setPayload(p) const alg = h?.alg as string || "unknown" - setAlgorithm(alg) if (p) setClaims(checkClaims(p, claimLabels)) - if (!secret.trim()) { - setVerifyResult(null) - return - } - - let valid = false - if (alg === "HS256") valid = await verifyHS256(token, secret) - else if (alg === "HS384") valid = await verifyHS384(token, secret) - else if (alg === "HS512") valid = await verifyHS512(token, secret) - - setVerifyResult(valid ? "valid" : "invalid") + setVerifyResult(await verifyJwtSignature(token, secret, alg)) } const clearAll = () => { setToken("") @@ -204,12 +159,16 @@ export function JwtVerifierPage() { {/* Verification Result */} {verifyResult && ( -
- {verifyResult === "valid" ? : } +
+ {verifyResult.status === "valid" ? : } - {verifyResult === "valid" - ? toolT.result_valid.replace("{alg}", algorithm) - : toolT.result_invalid.replace("{alg}", algorithm)} + {verifyResult.status === "valid" + ? toolT.result_valid.replace("{alg}", verifyResult.algorithm) + : verifyResult.status === "invalid" + ? toolT.result_invalid.replace("{alg}", verifyResult.algorithm) + : verifyResult.status === "unsigned" + ? toolT.result_none_warning + : toolT.result_unsupported.replace("{alg}", verifyResult.algorithm)}
)} diff --git a/src/features/tools/jwt-workbench/logic.ts b/src/features/tools/jwt-workbench/logic.ts index b74045c9..6136bccb 100644 --- a/src/features/tools/jwt-workbench/logic.ts +++ b/src/features/tools/jwt-workbench/logic.ts @@ -1,34 +1,12 @@ import type { JwtAlg } from "./types" - -function bytesToBase64Url(bytes: Uint8Array): string { - let binary = "" - for (const byte of bytes) { - binary += String.fromCharCode(byte) - } - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "") -} - -function base64UrlToBytes(input: string): Uint8Array { - const base64 = input - .replace(/-/g, "+") - .replace(/_/g, "/") - .padEnd(Math.ceil(input.length / 4) * 4, "=") - const binary = atob(base64) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - return bytes -} +import { base64UrlToJson, bytesToBase64Url, jsonToBase64Url } from "@/core/jwt/base64url" export function encodeJsonSegment(value: unknown): string { - return bytesToBase64Url(new TextEncoder().encode(JSON.stringify(value))) + return jsonToBase64Url(value) } export function decodeJsonSegment>(segment: string): T { - const bytes = base64UrlToBytes(segment) - const text = new TextDecoder().decode(bytes) - return JSON.parse(text) as T + return base64UrlToJson(segment) } export async function signHmac(signingInput: string, secret: string, algorithm: JwtAlg): Promise { diff --git a/src/features/tools/oauth-jwks-workbench/logic.ts b/src/features/tools/oauth-jwks-workbench/logic.ts index 7e7e6421..9eef165e 100644 --- a/src/features/tools/oauth-jwks-workbench/logic.ts +++ b/src/features/tools/oauth-jwks-workbench/logic.ts @@ -1,18 +1,5 @@ import type { JwksKeySummary, JwksVerificationOptions, JwtJwksVerificationReport, PkcePair } from "./types" - -function bytesToBase64Url(bytes: Uint8Array): string { - let binary = "" - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "") -} - -function base64UrlToBytes(input: string): Uint8Array { - const base64 = input.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(input.length / 4) * 4, "=") - const binary = atob(base64) - const bytes = new Uint8Array(binary.length) - for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) - return bytes -} +import { base64UrlToBytes, base64UrlToJson, bytesToBase64Url } from "@/core/jwt/base64url" function asBufferSource(bytes: Uint8Array): Uint8Array { const copy = new Uint8Array(new ArrayBuffer(bytes.byteLength)) @@ -36,7 +23,7 @@ export async function generatePkcePair(byteLength = 64): Promise { function parseJwtHeader(token: string): Record { const parts = token.trim().split(".") if (parts.length !== 3) throw new Error("JWT must contain header, payload, and signature.") - return JSON.parse(new TextDecoder().decode(base64UrlToBytes(parts[0]))) as Record + return base64UrlToJson>(parts[0]) } export function summarizeJwks(jwksInput: string): JwksKeySummary[] { diff --git a/src/features/tools/public-key-jwk-helper/logic.ts b/src/features/tools/public-key-jwk-helper/logic.ts index 0bbcc204..692f3606 100644 --- a/src/features/tools/public-key-jwk-helper/logic.ts +++ b/src/features/tools/public-key-jwk-helper/logic.ts @@ -5,6 +5,7 @@ import type { PublicKeyOutputFormat, PublicKeySummary, } from "./types" +import { bytesToBase64Url } from "@/core/jwt/base64url" const RSA_HASH_ALGORITHMS = ["SHA-256", "SHA-384", "SHA-512"] as const const EC_CURVES = ["P-256", "P-384", "P-521"] as const @@ -34,10 +35,6 @@ function base64ToBytes(value: string): Uint8Array { return bytes } -function bytesToBase64Url(bytes: Uint8Array): string { - return bytesToBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "") -} - function formatPem(label: string, bytes: Uint8Array): string { const body = bytesToBase64(bytes).replace(/.{1,64}/g, "$&\n").trim() return `-----BEGIN ${label}-----\n${body}\n-----END ${label}-----` diff --git a/tests/component/jwt-verifier-page.test.tsx b/tests/component/jwt-verifier-page.test.tsx index a0eaf6f6..35e26ff9 100644 --- a/tests/component/jwt-verifier-page.test.tsx +++ b/tests/component/jwt-verifier-page.test.tsx @@ -46,6 +46,10 @@ async function signedToken(secret: string) { return `${signingInput}.${await signHmac(signingInput, secret, "HS256")}` } +function unsignedToken(algorithm: string) { + return `${encodeJsonSegment({ alg: algorithm, typ: "JWT" })}.${encodeJsonSegment({ sub: "alice" })}.signature` +} + describe("JwtVerifierPage", () => { beforeEach(() => { vi.clearAllMocks() @@ -75,4 +79,29 @@ describe("JwtVerifierPage", () => { expect(screen.getByText(/Do not trust this token for that key/)).toBeInTheDocument() }) }) + + it("shows unsupported algorithms without reporting an invalid signature", async () => { + renderJwtVerifier() + + fireEvent.change(screen.getByRole("textbox", { name: "JWT Token" }), { target: { value: unsignedToken("RS256") } }) + fireEvent.change(screen.getByLabelText("Secret Key (for HMAC verification)"), { target: { value: "ignored" } }) + fireEvent.click(screen.getByRole("button", { name: "Verify" })) + + await waitFor(() => { + expect(screen.getByText(/Unsupported JWT algorithm \(RS256\)/)).toBeInTheDocument() + expect(screen.queryByText(/Signature does not match/)).not.toBeInTheDocument() + }) + }) + + it("shows alg none as an unsigned warning", async () => { + renderJwtVerifier() + + fireEvent.change(screen.getByRole("textbox", { name: "JWT Token" }), { target: { value: unsignedToken("none") } }) + fireEvent.click(screen.getByRole("button", { name: "Verify" })) + + await waitFor(() => { + expect(screen.getByText(/declares alg: none/)).toBeInTheDocument() + expect(screen.queryByText(/Signature does not match/)).not.toBeInTheDocument() + }) + }) }) diff --git a/tests/unit/crontab-generator-logic.test.ts b/tests/unit/crontab-generator-logic.test.ts new file mode 100644 index 00000000..e8605522 --- /dev/null +++ b/tests/unit/crontab-generator-logic.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest" +import { normalizeCronExpression, parseCronParts, updateCronPart, validateCronExpression } from "@/features/tools/crontab-generator/logic" + +describe("crontab generator logic", () => { + it("collapses repeated whitespace without shifting fields", () => { + expect(parseCronParts("* * * * *")).toEqual(["*", "*", "*", "*", "*"]) + expect(normalizeCronExpression("* * * * *")).toBe("* * * * *") + }) + + it("preserves and edits six-field cron expressions with seconds", () => { + expect(validateCronExpression("30 0 9 * * 1", "invalid")).toEqual({ + ok: true, + parts: ["30", "0", "9", "*", "*", "1"], + hasSeconds: true, + normalized: "30 0 9 * * 1", + }) + expect(updateCronPart("30 0 9 * * 1", 1, "15")).toBe("30 15 9 * * 1") + }) + + it("rejects out-of-range numeric fields with field-specific errors", () => { + expect(validateCronExpression("90 * * * *", "invalid")).toEqual({ + ok: false, + error: "Invalid cron minute field. Expected 0-59, got 90.", + }) + expect(validateCronExpression("0 25 * * *", "invalid")).toEqual({ + ok: false, + error: "Invalid cron hour field. Expected 0-23, got 25.", + }) + }) +}) diff --git a/tests/unit/csv-json-task-logic.test.ts b/tests/unit/csv-json-task-logic.test.ts index 0da8c18a..82904ab4 100644 --- a/tests/unit/csv-json-task-logic.test.ts +++ b/tests/unit/csv-json-task-logic.test.ts @@ -79,6 +79,25 @@ describe("csv json task logic", () => { expect(result.output).toBe("id,name\n1,Ada") }) + it("serializes nested arrays and objects as JSON cells when converting to CSV", () => { + const result = runCsvJsonTaskSync({ + input: JSON.stringify([ + { + id: 1, + tags: ["a", "b"], + events: [{ type: "login" }], + profile: { name: "Ada", roles: ["admin"] }, + }, + ]), + direction: "json-to-csv", + delimiter: ",", + hasHeader: true, + typeInference: true, + }) + + expect(result.output).toBe('id,tags,events,profile.name,profile.roles\n1,"[""a"",""b""]","[{""type"":""login""}]",Ada,"[""admin""]"') + }) + it("keeps json-to-csv validation errors stable", () => { expect(() => runCsvJsonTaskSync({ input: "{\"id\":1}", diff --git a/tests/unit/json-formatter-tree-logic.test.ts b/tests/unit/json-formatter-tree-logic.test.ts index 6d5a3876..15537311 100644 --- a/tests/unit/json-formatter-tree-logic.test.ts +++ b/tests/unit/json-formatter-tree-logic.test.ts @@ -32,9 +32,10 @@ describe("json formatter tree logic", () => { expect(getValueAtPath(sample, ["user", "roles", 1])).toBe("editor") }) - it("returns the root value when a path cannot be traversed", () => { - expect(getValueAtPath(sample, ["user", 0])).toBe(sample) - expect(getValueAtPath(sample, ["count", "nested"])).toBe(sample) + it("returns undefined when a path cannot be traversed", () => { + expect(getValueAtPath(sample, ["user", 0])).toBeUndefined() + expect(getValueAtPath(sample, ["count", "nested"])).toBeUndefined() + expect(getValueAtPath(sample, ["user", "missing"])).toBeUndefined() }) it("updates nested values immutably", () => { diff --git a/tests/unit/jwt-verifier-claims.test.ts b/tests/unit/jwt-verifier-claims.test.ts index aafa72bb..ae864579 100644 --- a/tests/unit/jwt-verifier-claims.test.ts +++ b/tests/unit/jwt-verifier-claims.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest" -import { checkClaims, parseUnixTimestampClaim, type JwtClaimLabels } from "@/features/tools/jwt-verifier/logic" +import { + checkClaims, + classifyJwtVerificationAlgorithm, + parseUnixTimestampClaim, + verifyJwtSignature, + type JwtClaimLabels, +} from "@/features/tools/jwt-verifier/logic" +import { signHmac, encodeJsonSegment } from "@/features/tools/jwt-workbench/logic" const labels: JwtClaimLabels = { exp: "exp", @@ -41,4 +48,23 @@ describe("jwt verifier claims", () => { iso: "2024-01-01T00:00:00.000Z", }) }) + + it("classifies supported, unsupported, and unsigned JWT algorithms distinctly", () => { + expect(classifyJwtVerificationAlgorithm("HS256")).toBe("hmac") + expect(classifyJwtVerificationAlgorithm("RS256")).toBe("unsupported") + expect(classifyJwtVerificationAlgorithm("ES256")).toBe("unsupported") + expect(classifyJwtVerificationAlgorithm("none")).toBe("unsigned") + }) + + it("verifies HMAC signatures without treating unsupported algorithms as invalid", async () => { + const header = encodeJsonSegment({ alg: "HS256", typ: "JWT" }) + const payload = encodeJsonSegment({ sub: "alice" }) + const signingInput = `${header}.${payload}` + const token = `${signingInput}.${await signHmac(signingInput, "correct", "HS256")}` + + await expect(verifyJwtSignature(token, "correct", "HS256")).resolves.toEqual({ status: "valid", algorithm: "HS256" }) + await expect(verifyJwtSignature(token, "wrong", "HS256")).resolves.toEqual({ status: "invalid", algorithm: "HS256" }) + await expect(verifyJwtSignature(token, "ignored", "RS256")).resolves.toEqual({ status: "unsupported", algorithm: "RS256" }) + await expect(verifyJwtSignature(`${encodeJsonSegment({ alg: "none" })}.${payload}.`, "", "none")).resolves.toEqual({ status: "unsigned", algorithm: "none" }) + }) })